@opengeni/api-router 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,759 @@
1
+ import type {
2
+ BillingSubscriptionCatalog,
3
+ BillingSubscriptionChange,
4
+ SubscriptionPlan,
5
+ } from "@opengeni/contracts";
6
+ import { HTTPException } from "hono/http-exception";
7
+ import Stripe from "stripe";
8
+ import type { BillingSubscriptionOperationKind } from "@opengeni/db";
9
+ import type { SubscriptionAccountStore, SubscriptionStore } from "./billing-subscription-store";
10
+
11
+ const terminal = (subscription: Stripe.Subscription) =>
12
+ subscription.status === "canceled" || subscription.status === "incomplete_expired";
13
+ const liveSubscription = (subscription: Stripe.Subscription) =>
14
+ !terminal(subscription) && subscription.status !== "unpaid";
15
+ const id = (value: string | { id: string } | null | undefined) =>
16
+ typeof value === "string" ? value : value?.id;
17
+
18
+ type MutationIntent = {
19
+ subscriptionId: string;
20
+ customerId: string;
21
+ itemId: string;
22
+ priceId: string;
23
+ periodStart: number;
24
+ periodEnd: number;
25
+ scheduleId: string | null;
26
+ phaseStart: number;
27
+ planKey: string;
28
+ targetPriceId: string | null;
29
+ };
30
+ type MutationResult =
31
+ | { response: BillingSubscriptionChange }
32
+ | { error: string; status: 409 | 422 };
33
+
34
+ class SubscriptionMutationRejected extends Error {}
35
+
36
+ /** Enterprise is assigned only by operators, never inferred from an embedder. */
37
+ export function selfServicePlan(plan: SubscriptionPlan): boolean {
38
+ return (
39
+ plan.key !== "enterprise" &&
40
+ plan.key !== "free" &&
41
+ !plan.freeCredit &&
42
+ Boolean(plan.stripePriceId) &&
43
+ (plan.monthlyPriceMicros ?? 0) > 0
44
+ );
45
+ }
46
+
47
+ export function subscriptionItem(subscription: Stripe.Subscription): Stripe.SubscriptionItem {
48
+ const item = subscription.items.data[0];
49
+ if (
50
+ subscription.items.has_more ||
51
+ subscription.items.data.length !== 1 ||
52
+ !item ||
53
+ item.quantity !== 1 ||
54
+ item.price.currency !== "usd" ||
55
+ item.price.recurring?.interval !== "month" ||
56
+ item.price.recurring.interval_count !== 1 ||
57
+ !Number.isSafeInteger(item.current_period_start) ||
58
+ !Number.isSafeInteger(item.current_period_end) ||
59
+ item.current_period_end <= item.current_period_start
60
+ ) {
61
+ throw new Error(`Subscription ${subscription.id} must have one monthly USD plan item`);
62
+ }
63
+ return item;
64
+ }
65
+
66
+ export function subscriptionCheckoutParams(input: {
67
+ accountId: string;
68
+ customerId: string;
69
+ priceId: string;
70
+ successUrl: string;
71
+ cancelUrl: string;
72
+ }): Stripe.Checkout.SessionCreateParams {
73
+ return {
74
+ mode: "subscription",
75
+ customer: input.customerId,
76
+ customer_update: { address: "auto", name: "auto" },
77
+ automatic_tax: { enabled: true },
78
+ billing_address_collection: "auto",
79
+ line_items: [{ price: input.priceId, quantity: 1 }],
80
+ success_url: input.successUrl,
81
+ cancel_url: input.cancelUrl,
82
+ // No Checkout/PaymentIntent credit metadata: subscription fees are never
83
+ // top-ups in the existing refund/dispute handlers.
84
+ subscription_data: { metadata: { opengeni_account_id: input.accountId } },
85
+ };
86
+ }
87
+
88
+ export function subscriptionWebhookEvent(event: Stripe.Event): boolean {
89
+ return [
90
+ "customer.subscription.created",
91
+ "customer.subscription.updated",
92
+ "customer.subscription.deleted",
93
+ "invoice.paid",
94
+ "invoice.payment_failed",
95
+ ].includes(event.type);
96
+ }
97
+
98
+ export class StripeSubscriptions {
99
+ constructor(
100
+ private readonly stripe: Stripe,
101
+ private readonly store: SubscriptionStore,
102
+ private readonly enabled: boolean,
103
+ private readonly provider: "stripe:live" | "stripe:test",
104
+ ) {}
105
+
106
+ private requireEnabled() {
107
+ if (!this.enabled)
108
+ throw new HTTPException(404, { message: "subscription plans are not enabled" });
109
+ }
110
+
111
+ async catalog(accountId: string): Promise<BillingSubscriptionCatalog> {
112
+ this.requireEnabled();
113
+ const catalog = await this.store.catalog();
114
+ return await this.store.locked(accountId, async (tx) => ({
115
+ accountId,
116
+ revision: catalog.revision,
117
+ plans: catalog.plans.map((plan) => ({ ...plan, selfService: selfServicePlan(plan) })),
118
+ plan: await tx.balance(),
119
+ }));
120
+ }
121
+
122
+ private async paidPlan(key: string) {
123
+ const { plans } = await this.store.catalog();
124
+ const plan = plans.find((candidate) => candidate.key === key && selfServicePlan(candidate));
125
+ if (!plan) throw new HTTPException(422, { message: "plan is not available for self-service" });
126
+ const price = await this.stripe.prices.retrieve(plan.stripePriceId!);
127
+ if (
128
+ !price.active ||
129
+ price.currency !== "usd" ||
130
+ price.recurring?.interval !== "month" ||
131
+ price.recurring.interval_count !== 1 ||
132
+ price.billing_scheme !== "per_unit" ||
133
+ price.unit_amount === null ||
134
+ price.unit_amount * 10_000 !== plan.monthlyPriceMicros
135
+ ) {
136
+ throw new HTTPException(422, {
137
+ message: "plan Stripe price does not match the runtime catalog",
138
+ });
139
+ }
140
+ return plan;
141
+ }
142
+
143
+ private async subscriptions(customer: string, accountId: string) {
144
+ const subscriptions: Stripe.Subscription[] = [];
145
+ for await (const subscription of this.stripe.subscriptions.list({
146
+ customer,
147
+ status: "all",
148
+ limit: 100,
149
+ })) {
150
+ if (subscription.metadata.opengeni_account_id === accountId) subscriptions.push(subscription);
151
+ }
152
+ return subscriptions;
153
+ }
154
+
155
+ async checkout(input: {
156
+ accountId: string;
157
+ customerId: string;
158
+ planKey: string;
159
+ idempotencyKey: string;
160
+ successUrl: string;
161
+ cancelUrl: string;
162
+ }) {
163
+ this.requireEnabled();
164
+ const plan = await this.paidPlan(input.planKey);
165
+ return await this.store.locked(input.accountId, async (tx) => {
166
+ const state = await tx.state();
167
+ if (
168
+ state?.plan_key === "enterprise" ||
169
+ (await this.subscriptions(input.customerId, input.accountId)).some(liveSubscription)
170
+ ) {
171
+ throw new HTTPException(409, {
172
+ message: "organization already has a subscription; manage it instead",
173
+ });
174
+ }
175
+ const session = await this.stripe.checkout.sessions.create(
176
+ subscriptionCheckoutParams({
177
+ ...input,
178
+ priceId: plan.stripePriceId!,
179
+ }),
180
+ { idempotencyKey: `subscription-checkout:${input.accountId}:${input.idempotencyKey}` },
181
+ );
182
+ if (!session.url)
183
+ throw new HTTPException(502, { message: "Stripe did not return a checkout URL" });
184
+ return { checkoutSessionId: session.id, url: session.url };
185
+ });
186
+ }
187
+
188
+ private async current(tx: SubscriptionAccountStore, accountId: string) {
189
+ const state = await tx.state();
190
+ if (!state?.stripe_subscription_id || state.plan_key === "enterprise") {
191
+ throw new HTTPException(409, { message: "organization has no self-service subscription" });
192
+ }
193
+ const subscription = await this.stripe.subscriptions.retrieve(state.stripe_subscription_id);
194
+ if (
195
+ subscription.metadata.opengeni_account_id !== accountId ||
196
+ id(subscription.customer) !== (await tx.customer(this.provider)) ||
197
+ !liveSubscription(subscription)
198
+ ) {
199
+ throw new HTTPException(409, { message: "subscription is not available" });
200
+ }
201
+ return subscription;
202
+ }
203
+
204
+ /** Two independent commits are essential: provider success cannot roll back
205
+ * the immutable target when local reconciliation fails. Replays resolve the
206
+ * receipt before looking at the organization's current subscription/catalog. */
207
+ private async mutate(
208
+ kind: BillingSubscriptionOperationKind,
209
+ input: { accountId: string; idempotencyKey: string; planKey?: string },
210
+ prepare: (tx: SubscriptionAccountStore) => Promise<MutationIntent>,
211
+ execute: (
212
+ tx: SubscriptionAccountStore,
213
+ intent: MutationIntent,
214
+ key: string,
215
+ ) => Promise<BillingSubscriptionChange>,
216
+ ): Promise<BillingSubscriptionChange> {
217
+ this.requireEnabled();
218
+ const request = input.planKey === undefined ? {} : { planKey: input.planKey };
219
+ await this.store.locked(input.accountId, async (tx) => {
220
+ const existing = await tx.operation(input.idempotencyKey);
221
+ if (existing) {
222
+ if (existing.kind !== kind || JSON.stringify(existing.request) !== JSON.stringify(request))
223
+ throw new HTTPException(409, {
224
+ message: "idempotency key was already used for a different request",
225
+ });
226
+ return;
227
+ }
228
+ await tx.beginOperation({
229
+ operationKey: input.idempotencyKey,
230
+ kind,
231
+ request,
232
+ intent: await prepare(tx),
233
+ });
234
+ });
235
+ const outcome = await this.store.locked(
236
+ input.accountId,
237
+ async (tx): Promise<MutationResult> => {
238
+ const receipt = await tx.operation(input.idempotencyKey);
239
+ if (!receipt) throw new Error("Committed subscription operation intent is missing");
240
+ if (receipt.result) return receipt.result as MutationResult;
241
+ let result: MutationResult;
242
+ try {
243
+ result = {
244
+ response: await execute(
245
+ tx,
246
+ receipt.intent as MutationIntent,
247
+ `subscription-${kind}:${input.accountId}:${input.idempotencyKey}`,
248
+ ),
249
+ };
250
+ } catch (error) {
251
+ // Only definitive provider rejection or a pre-mutation conflict can
252
+ // finish an unsuccessful intent. Transport/DB errors remain pending.
253
+ if (error instanceof HTTPException && (error.status === 409 || error.status === 422))
254
+ result = { error: error.message, status: error.status };
255
+ else if (error instanceof SubscriptionMutationRejected)
256
+ result = { error: error.message, status: 422 };
257
+ else throw error;
258
+ }
259
+ await tx.completeOperation(input.idempotencyKey, result);
260
+ return result;
261
+ },
262
+ );
263
+ if ("error" in outcome) throw new HTTPException(outcome.status, { message: outcome.error });
264
+ return outcome.response;
265
+ }
266
+
267
+ private async providerMutation<T>(run: () => Promise<T>): Promise<T> {
268
+ try {
269
+ return await run();
270
+ } catch (error) {
271
+ if (
272
+ error instanceof Stripe.errors.StripeCardError ||
273
+ error instanceof Stripe.errors.StripeInvalidRequestError
274
+ )
275
+ throw new SubscriptionMutationRejected(error.message);
276
+ throw error;
277
+ }
278
+ }
279
+
280
+ private intent(
281
+ subscription: Stripe.Subscription,
282
+ planKey: string,
283
+ targetPriceId: string | null,
284
+ ): MutationIntent {
285
+ const item = subscriptionItem(subscription);
286
+ return {
287
+ subscriptionId: subscription.id,
288
+ customerId: id(subscription.customer)!,
289
+ itemId: item.id,
290
+ priceId: item.price.id,
291
+ periodStart: item.current_period_start,
292
+ periodEnd: item.current_period_end,
293
+ scheduleId: id(subscription.schedule) ?? null,
294
+ phaseStart: item.current_period_start,
295
+ planKey,
296
+ targetPriceId,
297
+ };
298
+ }
299
+
300
+ private async target(accountId: string, intent: MutationIntent) {
301
+ const subscription = await this.stripe.subscriptions.retrieve(intent.subscriptionId);
302
+ if (
303
+ subscription.metadata.opengeni_account_id !== accountId ||
304
+ id(subscription.customer) !== intent.customerId
305
+ )
306
+ throw new Error("Subscription operation target ownership changed");
307
+ return subscription;
308
+ }
309
+
310
+ private async unchanged(
311
+ tx: SubscriptionAccountStore,
312
+ subscription: Stripe.Subscription,
313
+ intent: MutationIntent,
314
+ ) {
315
+ const item = subscriptionItem(subscription);
316
+ const state = await tx.state();
317
+ if (
318
+ state?.plan_key === "enterprise" ||
319
+ state?.stripe_subscription_id !== intent.subscriptionId ||
320
+ !liveSubscription(subscription) ||
321
+ item.id !== intent.itemId ||
322
+ item.price.id !== intent.priceId ||
323
+ item.current_period_start !== intent.periodStart ||
324
+ item.current_period_end !== intent.periodEnd
325
+ )
326
+ throw new HTTPException(409, {
327
+ message: "original subscription changed; start a new operation",
328
+ });
329
+ }
330
+
331
+ /** Stripe fails the entire update for an incomplete immediate payment. Only
332
+ * its paid invoice may replace the old allowance, never the update itself. */
333
+ async upgrade(input: {
334
+ accountId: string;
335
+ planKey: string;
336
+ idempotencyKey: string;
337
+ }): Promise<BillingSubscriptionChange> {
338
+ return await this.mutate(
339
+ "upgrade",
340
+ input,
341
+ async (tx) => {
342
+ const target = await this.paidPlan(input.planKey);
343
+ const subscription = await this.current(tx, input.accountId);
344
+ const item = subscriptionItem(subscription);
345
+ const previous = (await this.store.catalog()).plans.find(
346
+ (plan) => plan.stripePriceId === item.price.id,
347
+ );
348
+ if (!previous || target.monthlyPriceMicros! <= previous.monthlyPriceMicros!) {
349
+ throw new HTTPException(422, { message: "upgrade requires a higher-priced plan" });
350
+ }
351
+ if (
352
+ subscription.schedule ||
353
+ subscription.cancel_at_period_end ||
354
+ subscription.status !== "active"
355
+ ) {
356
+ throw new HTTPException(409, {
357
+ message: "manage the pending change or payment in the billing portal first",
358
+ });
359
+ }
360
+ return this.intent(subscription, target.key, target.stripePriceId!);
361
+ },
362
+ async (tx, intent, key) => {
363
+ const subscription = await this.target(input.accountId, intent);
364
+ const replay = subscription.metadata.opengeni_last_upgrade_key === key;
365
+ if (!replay) {
366
+ await this.unchanged(tx, subscription, intent);
367
+ if (
368
+ subscription.schedule ||
369
+ subscription.cancel_at_period_end ||
370
+ subscription.status !== "active"
371
+ )
372
+ throw new HTTPException(409, {
373
+ message: "original subscription has a pending change or payment",
374
+ });
375
+ }
376
+ // Provider metadata also prevents a retry after Stripe's 24h request-key
377
+ // cache expires from resetting the billing anchor and buying twice.
378
+ const updated = replay
379
+ ? subscription
380
+ : await this.providerMutation(() =>
381
+ this.stripe.subscriptions.update(
382
+ subscription.id,
383
+ {
384
+ items: [{ id: intent.itemId, price: intent.targetPriceId!, quantity: 1 }],
385
+ billing_cycle_anchor: "now",
386
+ proration_behavior: "always_invoice",
387
+ payment_behavior: "error_if_incomplete",
388
+ metadata: { opengeni_last_upgrade_key: key },
389
+ },
390
+ { idempotencyKey: key },
391
+ ),
392
+ );
393
+ await this.synchronize(tx, input.accountId, subscription.id, id(updated.latest_invoice));
394
+ return {
395
+ subscriptionId: subscription.id,
396
+ planKey: intent.planKey,
397
+ effectiveAt: new Date(
398
+ subscriptionItem(updated).current_period_start * 1000,
399
+ ).toISOString(),
400
+ cancelAtPeriodEnd: false,
401
+ };
402
+ },
403
+ );
404
+ }
405
+
406
+ async change(input: {
407
+ accountId: string;
408
+ planKey: string;
409
+ idempotencyKey: string;
410
+ }): Promise<BillingSubscriptionChange> {
411
+ return await this.mutate(
412
+ "change",
413
+ input,
414
+ async (tx) => {
415
+ const target = await this.paidPlan(input.planKey);
416
+ const subscription = await this.current(tx, input.accountId);
417
+ const item = subscriptionItem(subscription);
418
+ const previous = (await this.store.catalog()).plans.find(
419
+ (plan) => plan.stripePriceId === item.price.id,
420
+ );
421
+ if (
422
+ !previous ||
423
+ target.monthlyPriceMicros! >= previous.monthlyPriceMicros! ||
424
+ subscription.cancel_at_period_end
425
+ ) {
426
+ throw new HTTPException(422, {
427
+ message: "only a lower-priced plan can be scheduled at period end",
428
+ });
429
+ }
430
+ const intent = this.intent(subscription, target.key, target.stripePriceId!);
431
+ if (intent.scheduleId) {
432
+ const schedule = await this.stripe.subscriptionSchedules.retrieve(intent.scheduleId);
433
+ if (!schedule.current_phase)
434
+ throw new HTTPException(409, { message: "subscription schedule has no current phase" });
435
+ intent.phaseStart = schedule.current_phase.start_date;
436
+ }
437
+ return intent;
438
+ },
439
+ async (tx, intent, key) => {
440
+ const subscription = await this.target(input.accountId, intent);
441
+ const response = {
442
+ subscriptionId: intent.subscriptionId,
443
+ planKey: intent.planKey,
444
+ effectiveAt: new Date(intent.periodEnd * 1000).toISOString(),
445
+ cancelAtPeriodEnd: false,
446
+ };
447
+ let schedule: Stripe.SubscriptionSchedule | undefined;
448
+ const scheduleId = intent.scheduleId ?? id(subscription.schedule);
449
+ if (scheduleId) schedule = await this.stripe.subscriptionSchedules.retrieve(scheduleId);
450
+ // A successful schedule update can complete/release before a DB retry.
451
+ // Its marker is durable even after Stripe's request-key cache expires.
452
+ if (!schedule) {
453
+ for await (const candidate of this.stripe.subscriptionSchedules.list({
454
+ customer: intent.customerId,
455
+ limit: 100,
456
+ })) {
457
+ if (
458
+ candidate.metadata?.opengeni_last_change_key === key &&
459
+ (id(candidate.subscription) === intent.subscriptionId ||
460
+ id(candidate.released_subscription) === intent.subscriptionId)
461
+ ) {
462
+ schedule = candidate;
463
+ break;
464
+ }
465
+ }
466
+ }
467
+ if (schedule?.metadata?.opengeni_last_change_key === key) return response;
468
+ await this.unchanged(tx, subscription, intent);
469
+ if (
470
+ subscription.cancel_at_period_end ||
471
+ (intent.scheduleId && id(subscription.schedule) !== intent.scheduleId)
472
+ )
473
+ throw new HTTPException(409, { message: "original subscription schedule changed" });
474
+ schedule ??= await this.providerMutation(() =>
475
+ this.stripe.subscriptionSchedules.create(
476
+ { from_subscription: intent.subscriptionId },
477
+ { idempotencyKey: `${key}:create` },
478
+ ),
479
+ );
480
+ if (!schedule.current_phase)
481
+ throw new HTTPException(409, { message: "subscription schedule has no current phase" });
482
+ await this.providerMutation(() =>
483
+ this.stripe.subscriptionSchedules.update(
484
+ schedule.id,
485
+ {
486
+ end_behavior: "release",
487
+ proration_behavior: "none",
488
+ metadata: { opengeni_last_change_key: key },
489
+ phases: [
490
+ {
491
+ start_date: intent.phaseStart,
492
+ end_date: intent.periodEnd,
493
+ items: [{ price: intent.priceId, quantity: 1 }],
494
+ proration_behavior: "none",
495
+ },
496
+ {
497
+ start_date: intent.periodEnd,
498
+ duration: { interval: "month", interval_count: 1 },
499
+ items: [{ price: intent.targetPriceId!, quantity: 1 }],
500
+ proration_behavior: "none",
501
+ automatic_tax: { enabled: true },
502
+ metadata: { opengeni_account_id: input.accountId },
503
+ },
504
+ ],
505
+ },
506
+ { idempotencyKey: key },
507
+ ),
508
+ );
509
+ return response;
510
+ },
511
+ );
512
+ }
513
+
514
+ async cancel(input: {
515
+ accountId: string;
516
+ idempotencyKey: string;
517
+ }): Promise<BillingSubscriptionChange> {
518
+ return await this.mutate(
519
+ "cancel",
520
+ input,
521
+ async (tx) => this.intent(await this.current(tx, input.accountId), "free", null),
522
+ async (tx, intent, key) => {
523
+ const subscription = await this.target(input.accountId, intent);
524
+ const replay = subscription.metadata.opengeni_last_cancel_key === key;
525
+ if (!replay && !terminal(subscription) && !subscription.cancel_at_period_end) {
526
+ await this.unchanged(tx, subscription, intent);
527
+ if (subscription.schedule && id(subscription.schedule) !== intent.scheduleId)
528
+ throw new HTTPException(409, { message: "original subscription schedule changed" });
529
+ // Release, not cancel, the schedule: preserve the current paid period.
530
+ if (subscription.schedule)
531
+ await this.providerMutation(() =>
532
+ this.stripe.subscriptionSchedules.release(
533
+ id(subscription.schedule)!,
534
+ {},
535
+ { idempotencyKey: `${key}:release` },
536
+ ),
537
+ );
538
+ await this.providerMutation(() =>
539
+ this.stripe.subscriptions.update(
540
+ subscription.id,
541
+ { cancel_at_period_end: true, metadata: { opengeni_last_cancel_key: key } },
542
+ { idempotencyKey: key },
543
+ ),
544
+ );
545
+ }
546
+ await this.synchronize(tx, input.accountId, subscription.id);
547
+ return {
548
+ subscriptionId: subscription.id,
549
+ planKey: "free",
550
+ effectiveAt: new Date(intent.periodEnd * 1000).toISOString(),
551
+ cancelAtPeriodEnd: true,
552
+ };
553
+ },
554
+ );
555
+ }
556
+
557
+ async handleEvent(event: Stripe.Event): Promise<boolean> {
558
+ if (!subscriptionWebhookEvent(event)) return false;
559
+ if (!this.enabled) return true;
560
+ const subscriptionEvent = event.type.startsWith("customer.subscription.");
561
+ const object = event.data.object as Stripe.Subscription | Stripe.Invoice;
562
+ const subscriptionId = subscriptionEvent
563
+ ? object.id
564
+ : id((object as Stripe.Invoice).parent?.subscription_details?.subscription);
565
+ if (!subscriptionId) return true;
566
+ const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
567
+ const accountId = subscription.metadata.opengeni_account_id;
568
+ if (!accountId) return true; // Another product on the same Stripe account.
569
+ await this.store.locked(accountId, async (tx) => {
570
+ if (!(await tx.exists())) return;
571
+ // synchronize re-reads AFTER the org fence; event.created is not ordering
572
+ // authority, and another delivery may have committed while we waited.
573
+ await this.synchronize(
574
+ tx,
575
+ accountId,
576
+ subscriptionId,
577
+ event.type === "invoice.paid" ? object.id : undefined,
578
+ );
579
+ });
580
+ return true;
581
+ }
582
+
583
+ private async synchronize(
584
+ tx: SubscriptionAccountStore,
585
+ accountId: string,
586
+ subscriptionId: string,
587
+ paidInvoiceId?: string,
588
+ ) {
589
+ const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
590
+ if (subscription.metadata.opengeni_account_id !== accountId)
591
+ throw new Error("subscription organization changed");
592
+ const customerId = id(subscription.customer);
593
+ if (!customerId || customerId !== (await tx.customer(this.provider)))
594
+ throw new Error("subscription customer does not match organization");
595
+ const state = await tx.state();
596
+ if (state?.plan_key === "enterprise") return; // Operator assignment wins; no historical refunds.
597
+ if (subscription.metadata.opengeni_duplicate_subscription === "1") {
598
+ await this.rejectDuplicate(subscription);
599
+ return;
600
+ }
601
+ const siblings = await this.subscriptions(customerId, accountId);
602
+ const live = siblings
603
+ .filter(
604
+ (candidate) =>
605
+ liveSubscription(candidate) && candidate.metadata.opengeni_duplicate_subscription !== "1",
606
+ )
607
+ .sort((a, b) => a.created - b.created || a.id.localeCompare(b.id));
608
+ const bound =
609
+ state?.stripe_subscription_id &&
610
+ live.find((candidate) => candidate.id === state.stripe_subscription_id);
611
+ const canonical = bound || live[0];
612
+ if (canonical && canonical.id !== subscriptionId) {
613
+ // A stale deletion/paid event of a prior legitimate subscription must not
614
+ // refund that past subscription or change the new subscription's state.
615
+ if (liveSubscription(subscription))
616
+ await this.rejectDuplicate(subscription, canonical.created);
617
+ return;
618
+ }
619
+ for (const sibling of live)
620
+ if (sibling.id !== subscriptionId) await this.rejectDuplicate(sibling, subscription.created);
621
+ if (terminal(subscription) || subscription.status === "unpaid") {
622
+ if (state?.stripe_subscription_id === subscriptionId) {
623
+ await tx.save({ planKey: "free", status: "active", stripeSubscriptionId: null });
624
+ // A canceled/failed checkout never owns the existing Free allowance.
625
+ await tx.expire(subscriptionId);
626
+ }
627
+ // Free eligibility/grants are owned by maintenance, never these events.
628
+ return;
629
+ }
630
+ // Stripe has not collected the first payment. Keep the existing Free
631
+ // entitlement and seat cap; no paid plan state is earned by an abandoned
632
+ // checkout. Later invoice.paid re-reads the active provider state normally.
633
+ if (subscription.status === "incomplete") return;
634
+ const item = subscriptionItem(subscription);
635
+ const plan = (await this.store.catalog()).plans.find(
636
+ (candidate) => candidate.stripePriceId === item.price.id && selfServicePlan(candidate),
637
+ );
638
+ if (!plan)
639
+ throw new Error(`Subscription ${subscription.id} price is not in the self-service catalog`);
640
+ if (
641
+ item.price.unit_amount === null ||
642
+ item.price.unit_amount * 10_000 !== plan.monthlyPriceMicros
643
+ )
644
+ throw new Error("subscription price does not match catalog");
645
+ const supported: Record<string, Parameters<SubscriptionAccountStore["save"]>[0]["status"]> = {
646
+ active: "active",
647
+ trialing: "trialing",
648
+ past_due: "past_due",
649
+ paused: "paused",
650
+ incomplete: "incomplete",
651
+ };
652
+ const status = supported[subscription.status];
653
+ if (!status) throw new Error(`Unsupported subscription status: ${subscription.status}`);
654
+ await tx.save({
655
+ planKey: plan.key,
656
+ status,
657
+ stripeSubscriptionId: subscription.id,
658
+ currentPeriodStart: new Date(item.current_period_start * 1000),
659
+ currentPeriodEnd: new Date(item.current_period_end * 1000),
660
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
661
+ });
662
+ if (!paidInvoiceId || subscription.status !== "active") return;
663
+ const invoice = await this.stripe.invoices.retrieve(paidInvoiceId);
664
+ if (
665
+ invoice.status !== "paid" ||
666
+ id(invoice.parent?.subscription_details?.subscription) !== subscription.id ||
667
+ id(invoice.customer) !== customerId ||
668
+ !["subscription_create", "subscription_cycle", "subscription_update"].includes(
669
+ invoice.billing_reason ?? "",
670
+ )
671
+ )
672
+ return;
673
+ // Old paid invoices must never fund a newer period/price. v22 period bounds
674
+ // live on subscription items; invoice coverage is described by its lines.
675
+ let matches = false;
676
+ for await (const line of this.stripe.invoices.listLineItems(invoice.id, { limit: 100 })) {
677
+ if (
678
+ line.parent?.subscription_item_details?.subscription_item === item.id &&
679
+ id(line.pricing?.price_details?.price) === item.price.id &&
680
+ line.period.start === item.current_period_start &&
681
+ line.period.end === item.current_period_end &&
682
+ line.amount >= 0
683
+ )
684
+ matches = true;
685
+ }
686
+ if (matches)
687
+ await tx.replace({
688
+ planKey: plan.key,
689
+ stripeSubscriptionId: subscription.id,
690
+ periodStart: new Date(item.current_period_start * 1000),
691
+ periodEnd: new Date(item.current_period_end * 1000),
692
+ });
693
+ }
694
+
695
+ private async rejectDuplicate(subscription: Stripe.Subscription, canonicalCreated?: number) {
696
+ const storedCutoff = Number(subscription.metadata.opengeni_duplicate_refund_from);
697
+ const refundFrom =
698
+ Number.isSafeInteger(storedCutoff) && storedCutoff > 0
699
+ ? storedCutoff
700
+ : (canonicalCreated ?? subscription.created);
701
+ // Persist the loser identity before canceling: replay after DB rollback or
702
+ // cancellation still refunds it and can never adopt it as the current plan.
703
+ if (subscription.metadata.opengeni_duplicate_subscription !== "1") {
704
+ await this.stripe.subscriptions.update(
705
+ subscription.id,
706
+ {
707
+ metadata: {
708
+ opengeni_duplicate_subscription: "1",
709
+ opengeni_duplicate_refund_from: String(refundFrom),
710
+ },
711
+ },
712
+ { idempotencyKey: `subscription-duplicate:${subscription.id}:mark` },
713
+ );
714
+ }
715
+ if (!terminal(subscription))
716
+ await this.stripe.subscriptions.cancel(
717
+ subscription.id,
718
+ { prorate: false, invoice_now: false },
719
+ { idempotencyKey: `subscription-duplicate:${subscription.id}:cancel` },
720
+ );
721
+ // Invoice payments moved off invoice.payment_intent in v22. Refund paid
722
+ // duplicate-period invoices, including payments arriving after cancellation.
723
+ for await (const invoice of this.stripe.invoices.list({
724
+ subscription: subscription.id,
725
+ status: "paid",
726
+ limit: 100,
727
+ })) {
728
+ // An old, previously legitimate subscription can become a live loser
729
+ // after a later subscription takes over. Never refund its prior history.
730
+ for await (const payment of this.stripe.invoicePayments.list({
731
+ invoice: invoice.id,
732
+ status: "paid",
733
+ limit: 100,
734
+ })) {
735
+ const fundedAt = payment.status_transitions?.paid_at ?? invoice.status_transitions?.paid_at;
736
+ if (!Number.isSafeInteger(fundedAt) || !fundedAt || fundedAt < 0)
737
+ throw new Error(`Paid invoice ${invoice.id} has no authoritative funding timestamp`);
738
+ if (fundedAt < refundFrom) continue;
739
+ let chargeId = id(payment.payment.charge);
740
+ const paymentIntentId = id(payment.payment.payment_intent);
741
+ if (paymentIntentId)
742
+ chargeId = id((await this.stripe.paymentIntents.retrieve(paymentIntentId)).latest_charge);
743
+ if (!chargeId)
744
+ throw new Error(
745
+ `Duplicate subscription invoice ${invoice.id} has no refundable Stripe charge`,
746
+ );
747
+ const charge = await this.stripe.charges.retrieve(chargeId);
748
+ const remaining = charge.amount - charge.amount_refunded;
749
+ if (remaining > 0)
750
+ await this.stripe.refunds.create(
751
+ { charge: charge.id, amount: remaining },
752
+ {
753
+ idempotencyKey: `subscription-duplicate-refund:${charge.id}:${charge.amount_refunded}`,
754
+ },
755
+ );
756
+ }
757
+ }
758
+ }
759
+ }