1import stripe
2from sqlalchemy.orm import Session
3from sqlalchemy import select, func as sa_func
4from datetime import datetime, timedelta
5from typing import Optional
6from uuid import uuid4
7from dataclasses import dataclass
8
9@dataclass
10class ProrationResult:
11 credit_cents: int
12 charge_cents: int
13 net_cents: int
14 effective_at: datetime
15 description: str
16
17class BillingService:
18 def __init__(self, db: Session, stripe_api_key: str):
19 self.db = db
20 stripe.api_key = stripe_api_key
21
22 def create_subscription(
23 self,
24 customer_id: str,
25 plan_slug: str,
26 quantity: int,
27 stripe_customer_id: str,
28 ) -> Subscription:
29 plan = self.db.execute(
30 select(Plan).where(Plan.slug == plan_slug, Plan.active == True)
31 ).scalar_one()
32
33 stripe_sub = stripe.Subscription.create(
34 customer=stripe_customer_id,
35 items=[{"price": plan.stripe_price_id, "quantity": quantity}],
36 )
37
38 sub = Subscription(
39 id=str(uuid4()),
40 customer_id=customer_id,
41 plan_id=plan.id,
42 stripe_subscription_id=stripe_sub.id,
43 stripe_customer_id=stripe_customer_id,
44 status=SubscriptionStatus.ACTIVE,
45 quantity=quantity,
46 current_period_start=datetime.fromtimestamp(stripe_sub.current_period_start),
47 current_period_end=datetime.fromtimestamp(stripe_sub.current_period_end),
48 )
49
50 self.db.add(sub)
51 self.db.commit()
52 return sub
53
54 def change_plan(
55 self, subscription_id: str, new_plan_slug: str
56 ) -> tuple[Subscription, ProrationResult]:
57 sub = self.db.get(Subscription, subscription_id)
58 current_plan = self.db.get(Plan, sub.plan_id)
59 new_plan = self.db.execute(
60 select(Plan).where(Plan.slug == new_plan_slug)
61 ).scalar_one()
62
63 proration = self._calculate_proration(
64 current_plan, new_plan, sub.quantity, sub.quantity,
65 sub.current_period_start, sub.current_period_end,
66 )
67
68 stripe.Subscription.modify(
69 sub.stripe_subscription_id,
70 items=[{
71 "id": sub.stripe_subscription_id,
72 "price": new_plan.stripe_price_id,
73 }],
74 proration_behavior="create_prorations",
75 )
76
77 sub.plan_id = new_plan.id
78 self.db.commit()
79
80 return sub, proration
81
82 def update_seats(self, subscription_id: str, new_quantity: int) -> Subscription:
83 sub = self.db.get(Subscription, subscription_id)
84
85 stripe.Subscription.modify(
86 sub.stripe_subscription_id,
87 items=[{
88 "id": sub.stripe_subscription_id,
89 "quantity": new_quantity,
90 }],
91 proration_behavior="create_prorations",
92 )
93
94 sub.quantity = new_quantity
95 self.db.commit()
96 return sub
97
98 def cancel_subscription(
99 self, subscription_id: str, immediately: bool = False
100 ) -> Subscription:
101 sub = self.db.get(Subscription, subscription_id)
102
103 if immediately:
104 stripe.Subscription.cancel(sub.stripe_subscription_id)
105 sub.status = SubscriptionStatus.CANCELLED
106 sub.cancelled_at = datetime.utcnow()
107 else:
108 stripe.Subscription.modify(
109 sub.stripe_subscription_id,
110 cancel_at_period_end=True,
111 )
112 sub.cancel_at_period_end = True
113
114 self.db.commit()
115 return sub
116
117 def record_usage(
118 self,
119 subscription_id: str,
120 metric_id: str,
121 quantity: int,
122 idempotency_key: str,
123 timestamp: Optional[datetime] = None,
124 ) -> None:
125 existing = self.db.execute(
126 select(UsageEvent).where(UsageEvent.idempotency_key == idempotency_key)
127 ).scalar_one_or_none()
128
129 if existing:
130 return
131
132 event = UsageEvent(
133 id=str(uuid4()),
134 idempotency_key=idempotency_key,
135 subscription_id=subscription_id,
136 metric_id=metric_id,
137 quantity=quantity,
138 timestamp=timestamp or datetime.utcnow(),
139 )
140 self.db.add(event)
141 self.db.commit()
142
143 def get_usage_summary(
144 self, subscription_id: str, metric_id: str,
145 period_start: datetime, period_end: datetime,
146 ) -> int:
147 result = self.db.execute(
148 select(sa_func.coalesce(sa_func.sum(UsageEvent.quantity), 0))
149 .where(
150 UsageEvent.subscription_id == subscription_id,
151 UsageEvent.metric_id == metric_id,
152 UsageEvent.timestamp >= period_start,
153 UsageEvent.timestamp < period_end,
154 )
155 ).scalar()
156 return int(result)
157
158 def check_feature_access(self, customer_id: str, feature: str) -> bool:
159 sub = self.db.execute(
160 select(Subscription)
161 .join(Plan)
162 .where(
163 Subscription.customer_id == customer_id,
164 Subscription.status == SubscriptionStatus.ACTIVE,
165 )
166 ).scalar_one_or_none()
167
168 if not sub:
169 return False
170 return feature in sub.plan.features
171
172 def _calculate_proration(
173 self, current_plan: Plan, new_plan: Plan,
174 current_qty: int, new_qty: int,
175 period_start: datetime, period_end: datetime,
176 ) -> ProrationResult:
177 now = datetime.utcnow()
178 total_days = (period_end - period_start).days
179 remaining_days = (period_end - now).days
180
181 current_total = current_plan.base_price_cents + (
182 current_plan.price_per_seat_cents * max(0, current_qty - current_plan.included_seats)
183 )
184 credit = round(current_total / total_days * remaining_days)
185
186 new_total = new_plan.base_price_cents + (
187 new_plan.price_per_seat_cents * max(0, new_qty - new_plan.included_seats)
188 )
189 charge = round(new_total / total_days * remaining_days)
190
191 return ProrationResult(
192 credit_cents=credit,
193 charge_cents=charge,
194 net_cents=charge - credit,
195 effective_at=now,
196 description=(
197 f"{current_plan.name} ({current_qty} seats) → "
198 f"{new_plan.name} ({new_qty} seats), "
199 f"{remaining_days} days remaining"
200 ),
201 )
202