@dineway-ai/plugin-payment 0.2.6 → 0.2.7

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.
@@ -5,12 +5,20 @@ import type { Kysely } from "kysely";
5
5
  import Stripe from "stripe";
6
6
 
7
7
  import { StripeNodeWebhookVerifier } from "../stripe/webhook-verifier.js";
8
+ import { collectBounded, invoiceFunding, inspectCharge } from "./funding.js";
8
9
  import {
9
10
  recurringPrice,
10
11
  invoicePeriods,
11
12
  memberProjection,
12
13
  subscriptionProjection,
13
14
  } from "./projection.js";
15
+ import {
16
+ createPurchase,
17
+ resumePurchase,
18
+ digest as purchaseDigest,
19
+ relationId,
20
+ } from "./purchases.js";
21
+ import { refundCharge, type SubscriptionRefundInput } from "./refunds.js";
14
22
  import { SubscriptionRepository, subscriptionScopeKey } from "./repository.js";
15
23
  import {
16
24
  checkoutSchema,
@@ -18,6 +26,7 @@ import {
18
26
  saveSubscriptionPlanSchema,
19
27
  stripeProductListSchema,
20
28
  stripePriceListSchema,
29
+ billingSubjectSchema,
21
30
  } from "./schemas.js";
22
31
  import {
23
32
  SubscriptionError,
@@ -30,6 +39,8 @@ import {
30
39
  type SubscriptionCatalogPage,
31
40
  type SubscriptionProduct,
32
41
  type StripeSubscriptionPrice,
42
+ type BillingSubject,
43
+ type SubscriptionPurchaseInput,
33
44
  } from "./types.js";
34
45
 
35
46
  export interface SubscriptionServiceOptions {
@@ -56,6 +67,15 @@ const events = new Set([
56
67
  "invoice.payment_action_required",
57
68
  "invoice.voided",
58
69
  "invoice.marked_uncollectible",
70
+ "charge.refunded",
71
+ "charge.failed",
72
+ "charge.dispute.created",
73
+ "charge.dispute.updated",
74
+ "charge.dispute.closed",
75
+ "refund.created",
76
+ "refund.updated",
77
+ "refund.failed",
78
+ "payment_intent.payment_failed",
59
79
  ]);
60
80
  const localeSchema = checkoutSchema.shape.locale;
61
81
  const fingerprint = (values: unknown[]) =>
@@ -201,208 +221,221 @@ export class PaymentSubscriptionService {
201
221
  private async user(userId: string) {
202
222
  const user = await this.options.db
203
223
  .selectFrom("users")
204
- .select(["id", "email", "disabled"])
224
+ .select(["id", "email", "disabled", "email_verified"])
205
225
  .where("id", "=", userId)
206
226
  .executeTakeFirst();
207
- if (!user || user.disabled)
227
+ if (!user || user.disabled || !user.email_verified)
208
228
  throw new SubscriptionError("UNAUTHORIZED", "An active member account is required", 401);
209
229
  return user;
210
230
  }
211
- private metadata(userId: string) {
212
- return { dineway_app: this.options.scope.namespace, user_id: userId };
213
- }
214
- private sessionOwned(
215
- session: Stripe.Checkout.Session,
216
- userId: string,
217
- customer: string,
218
- ): boolean {
219
- const owner = typeof session.customer === "string" ? session.customer : session.customer?.id;
220
- return (
221
- owner === customer &&
222
- session.mode === "subscription" &&
223
- session.livemode === (this.options.scope.mode === "live") &&
224
- session.metadata?.dineway_app === this.options.scope.namespace &&
225
- session.metadata.user_id === userId &&
226
- session.client_reference_id === userId
231
+ private purchaseContext() {
232
+ return {
233
+ stripe: this.stripe,
234
+ repository: this.repository,
235
+ scope: this.options.scope,
236
+ now: this.now,
237
+ origin: this.origin,
238
+ };
239
+ }
240
+ private async resolveSubject(subject: BillingSubject | string) {
241
+ if (typeof subject === "string") {
242
+ const user = await this.user(subject);
243
+ return {
244
+ key: user.id,
245
+ subject: { realm: "dineway", id: user.id, email: user.email, emailVerified: true },
246
+ };
247
+ }
248
+ const parsed = billingSubjectSchema.parse(subject);
249
+ return { key: "external:" + purchaseDigest([parsed.realm, parsed.id]), subject: parsed };
250
+ }
251
+ async createPurchase(subject: BillingSubject | string, input: SubscriptionPurchaseInput) {
252
+ const resolved = await this.resolveSubject(subject);
253
+ return createPurchase(this.purchaseContext(), resolved.key, resolved.subject, input);
254
+ }
255
+ async getPurchase(subject: BillingSubject | string, reference: string) {
256
+ const resolved = await this.resolveSubject(subject);
257
+ const purchase = await this.repository.purchase(reference);
258
+ if (!purchase || purchase.userId !== resolved.key)
259
+ throw new SubscriptionError("NOT_FOUND", "Purchase not found", 404);
260
+ return purchase;
261
+ }
262
+ async recoverCheckout(subject: BillingSubject | string, reference: string) {
263
+ const purchase = await this.getPurchase(subject, reference);
264
+ return this.repository.withLock("member:" + purchase.userId, (store) =>
265
+ resumePurchase(this.purchaseContext(), store, purchase),
227
266
  );
228
267
  }
229
- async createCheckout(userId: string, input: SubscriptionCheckoutInput): Promise<{ url: string }> {
230
- const parsed = checkoutSchema.parse(input);
231
- const user = await this.user(userId);
232
- const returnUrl = this.returnUrl(parsed.returnPath);
233
- const successUrl = new URL(returnUrl);
234
- successUrl.searchParams.set("checkout", "returned");
235
- return this.repository.withLock("member:" + userId, async (repository) => {
236
- const plans = await repository.plans();
237
- const plan = plans.find((value) => value.code === parsed.planCode && value.enabled);
238
- const price = plan?.prices.find(
239
- (value) => value.interval === parsed.interval && value.enabled,
240
- );
241
- if (!price) throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
242
- const livePrice = recurringPrice(
243
- await this.stripe.prices.retrieve(price.id),
244
- this.options.scope,
245
- );
246
- if (
247
- livePrice.currency !== price.currency ||
248
- livePrice.amount !== price.amount ||
249
- livePrice.productId !== price.productId ||
250
- livePrice.interval !== price.interval
251
- ) {
252
- throw new SubscriptionError("PRICE_CHANGED", "Refresh the plan price before checkout", 409);
253
- }
254
- let customer = await repository.customer(userId);
255
- if (!customer) {
256
- const created = await this.stripe.customers.create(
257
- { email: user.email, metadata: this.metadata(userId) },
258
- { idempotencyKey: providerIdempotency(this.options.scope, "customer", userId) },
259
- );
260
- customer = created.id;
261
- await repository.saveCustomer(userId, customer);
262
- }
263
- const subscriptions = await this.stripe.subscriptions.list({
264
- customer,
265
- status: "all",
266
- limit: 100,
268
+ async getSubscription(subject: BillingSubject | string, id: string) {
269
+ const resolved = await this.resolveSubject(subject);
270
+ const snapshot = await this.repository.snapshot(id);
271
+ if (snapshot.subscription.userId !== resolved.key)
272
+ throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
273
+ return snapshot;
274
+ }
275
+ /** Only trusted server operations may request immediate cancellation. */
276
+ async cancelPurchase(
277
+ subject: BillingSubject | string,
278
+ reference: string,
279
+ mode: "period_end" | "immediate" = "period_end",
280
+ ) {
281
+ const purchase = await this.getPurchase(subject, reference);
282
+ if (mode !== "period_end" && mode !== "immediate")
283
+ throw new SubscriptionError("INVALID_CANCELLATION", "Invalid cancellation mode");
284
+ await this.repository.withLock("member:" + purchase.userId, async (store) => {
285
+ const current = (await store.purchase(reference))!;
286
+ await store.updatePurchase(reference, {
287
+ cancel_mode: current.cancelMode === "immediate" ? "immediate" : mode,
288
+ last_attempt_at: this.now(),
267
289
  });
268
- if (subscriptions.has_more)
269
- throw new SubscriptionError("REVIEW_REQUIRED", "Subscription review is required", 409);
270
- if (
271
- subscriptions.data.some(
272
- (subscription) =>
273
- subscription.metadata.dineway_app === this.options.scope.namespace &&
274
- !terminal.has(subscription.status),
275
- )
276
- ) {
290
+ await this.applyCancellation(store, (await store.purchase(reference))!);
291
+ });
292
+ const updated = (await this.repository.purchase(reference))!;
293
+ if (updated.subscriptionId) await this.refreshSubscription(updated.subscriptionId);
294
+ return this.repository.purchase(reference);
295
+ }
296
+ private async applyCancellation(
297
+ store: SubscriptionRepository,
298
+ purchase: NonNullable<Awaited<ReturnType<SubscriptionRepository["purchase"]>>>,
299
+ ) {
300
+ if (!purchase.cancelMode) return;
301
+ if (!purchase.subscriptionId) {
302
+ await resumePurchase(this.purchaseContext(), store, purchase);
303
+ purchase = (await store.purchase(purchase.reference))!;
304
+ }
305
+ if (!purchase.subscriptionId) {
306
+ if (!purchase.sessionId)
277
307
  throw new SubscriptionError(
278
- "SUBSCRIPTION_EXISTS",
279
- "Manage the existing subscription in the billing portal",
308
+ "CHECKOUT_REVIEW_REQUIRED",
309
+ "Confirm the checkout before canceling",
280
310
  409,
281
311
  );
282
- }
283
- const open = await this.stripe.checkout.sessions.list({
284
- customer,
285
- status: "open",
286
- limit: 100,
287
- });
288
- const owned = open.data.filter(
289
- (session) => session.metadata?.dineway_app === this.options.scope.namespace,
290
- );
291
- if (open.has_more || owned.length > 1)
292
- throw new SubscriptionError("REVIEW_REQUIRED", "Checkout review is required", 409);
293
- const previous = owned[0];
294
- const locale = parsed.locale ?? "auto";
295
- const remembered = await repository.attempt(userId);
296
- const digest = fingerprint([customer, price.id, returnUrl, locale]);
297
- if (previous) {
298
- if (!this.sessionOwned(previous, userId, customer))
299
- throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
300
- const lines = await this.stripe.checkout.sessions.listLineItems(previous.id, { limit: 2 });
301
- if (
302
- previous.locale === locale &&
303
- previous.success_url === successUrl.href &&
304
- previous.cancel_url === returnUrl &&
305
- previous.expires_at * 1000 > this.now() &&
306
- previous.allow_promotion_codes === false &&
307
- !lines.has_more &&
308
- lines.data.length === 1 &&
309
- lines.data[0]?.quantity === 1 &&
310
- lines.data[0].price?.id === price.id
311
- ) {
312
- if (remembered && !remembered.session_id && remembered.fingerprint === digest) {
313
- await repository.saveAttempt(userId, { ...remembered, session_id: previous.id });
314
- }
315
- return { url: this.checkoutUrl(previous.url) };
316
- }
317
- const expired = await this.stripe.checkout.sessions.expire(previous.id);
312
+ const session = await this.stripe.checkout.sessions.retrieve(purchase.sessionId);
313
+ if (session.status === "open") {
314
+ const expired = await this.stripe.checkout.sessions.expire(session.id);
318
315
  if (expired.status !== "expired")
319
316
  throw new SubscriptionError(
320
- "REVIEW_REQUIRED",
321
- "Checkout changed; refresh and try again",
317
+ "CHECKOUT_REVIEW_REQUIRED",
318
+ "Checkout changed during cancellation",
322
319
  409,
323
320
  );
321
+ } else if (session.status !== "expired")
322
+ throw new SubscriptionError(
323
+ "CHECKOUT_REVIEW_REQUIRED",
324
+ "Confirm the checkout before canceling",
325
+ 409,
326
+ );
327
+ await store.updatePurchase(purchase.reference, { closed: 1, last_error: null });
328
+ return;
329
+ }
330
+ const subscription = await this.stripe.subscriptions.retrieve(purchase.subscriptionId);
331
+ subscriptionProjection(
332
+ subscription,
333
+ await store.plans(),
334
+ this.options.scope,
335
+ purchase.userId,
336
+ (await store.customer(purchase.userId))!,
337
+ this.now(),
338
+ purchase,
339
+ );
340
+ if (!terminal.has(subscription.status)) {
341
+ const request = {
342
+ idempotencyKey: providerIdempotency(
343
+ this.options.scope,
344
+ purchase.reference,
345
+ "cancel",
346
+ purchase.cancelMode,
347
+ ),
348
+ };
349
+ if (purchase.cancelMode === "immediate")
350
+ await this.stripe.subscriptions.cancel(
351
+ subscription.id,
352
+ { invoice_now: false, prorate: false },
353
+ request,
354
+ );
355
+ else if (!subscription.cancel_at_period_end)
356
+ await this.stripe.subscriptions.update(
357
+ subscription.id,
358
+ { cancel_at_period_end: true },
359
+ request,
360
+ );
361
+ }
362
+ await store.updatePurchase(purchase.reference, {
363
+ last_error: null,
364
+ last_attempt_at: this.now(),
365
+ });
366
+ }
367
+ async createCheckout(userId: string, input: SubscriptionCheckoutInput): Promise<{ url: string }> {
368
+ const parsed = checkoutSchema.parse(input);
369
+ await this.user(userId);
370
+ this.returnUrl(parsed.returnPath);
371
+ return this.repository.withLock("catalogue:" + userId, async () => {
372
+ const plan = (await this.repository.plans()).find(
373
+ (value) => value.code === parsed.planCode && value.enabled,
374
+ );
375
+ if (!plan?.prices.some((price) => price.enabled && price.interval === parsed.interval))
376
+ throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
377
+ const customer = await this.repository.customer(userId);
378
+ if (customer) {
379
+ const subscriptions = await this.stripe.subscriptions.list({
380
+ customer,
381
+ status: "all",
382
+ limit: 100,
383
+ });
384
+ if (subscriptions.has_more)
385
+ throw new SubscriptionError("REVIEW_REQUIRED", "Subscription review is required", 409);
386
+ for (const subscription of subscriptions.data) {
387
+ if (
388
+ subscription.metadata.dineway_app !== this.options.scope.namespace ||
389
+ terminal.has(subscription.status)
390
+ )
391
+ continue;
392
+ const purchase = subscription.metadata.purchase_reference
393
+ ? await this.repository.purchase(subscription.metadata.purchase_reference)
394
+ : null;
395
+ if (!purchase || purchase.contract.groupKey === "membership")
396
+ throw new SubscriptionError(
397
+ "SUBSCRIPTION_EXISTS",
398
+ "Manage the existing subscription in the billing portal",
399
+ 409,
400
+ );
401
+ }
324
402
  }
325
- if (!previous && remembered?.session_id) {
326
- const session = await this.stripe.checkout.sessions.retrieve(remembered.session_id);
327
- if (!this.sessionOwned(session, userId, customer))
328
- throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
329
- const relation = session.subscription;
330
- const subscriptionId = typeof relation === "string" ? relation : relation?.id;
331
- const old = subscriptions.data.find((subscription) => subscription.id === subscriptionId);
332
- if (
333
- session.status !== "expired" &&
334
- !(session.status === "complete" && old && terminal.has(old.status))
335
- ) {
336
- throw new SubscriptionError(
337
- "SUBSCRIPTION_EXISTS",
338
- "Confirm the previous subscription before starting another",
339
- 409,
340
- );
403
+ let existing = await this.repository.openPurchase(userId, "membership");
404
+ if (existing) {
405
+ const pricing = existing.contract.pricing;
406
+ const compatible =
407
+ pricing.kind === "catalogue" &&
408
+ pricing.planCode === parsed.planCode &&
409
+ pricing.interval === parsed.interval &&
410
+ existing.contract.returnPath === parsed.returnPath &&
411
+ (existing.contract.locale ?? "auto") === (parsed.locale ?? "auto");
412
+ if (!compatible) {
413
+ await this.cancelPurchase(userId, existing.reference);
414
+ existing = null;
341
415
  }
342
416
  }
343
- if (
344
- !previous &&
345
- remembered &&
346
- !remembered.session_id &&
347
- (remembered.fingerprint !== digest || this.now() - remembered.created_at > 23 * 3600000)
348
- ) {
417
+ const result = await this.createPurchase(userId, {
418
+ reference: existing?.reference ?? randomUUID(),
419
+ groupKey: "membership",
420
+ pricing: { kind: "catalogue", planCode: parsed.planCode, interval: parsed.interval },
421
+ returnPath: parsed.returnPath,
422
+ locale: parsed.locale,
423
+ });
424
+ if (!result.url)
349
425
  throw new SubscriptionError(
350
- "REVIEW_REQUIRED",
351
- "An unconfirmed checkout requires review",
426
+ "SUBSCRIPTION_EXISTS",
427
+ "Confirm your subscription status before starting another",
352
428
  409,
353
429
  );
354
- }
355
- const attempt =
356
- !previous && remembered && !remembered.session_id
357
- ? remembered
358
- : {
359
- fingerprint: digest,
360
- request_id: randomUUID(),
361
- session_id: null,
362
- created_at: this.now(),
363
- };
364
- await repository.saveAttempt(userId, attempt);
365
- const session = await this.stripe.checkout.sessions.create(
366
- {
367
- mode: "subscription",
368
- customer,
369
- locale,
370
- line_items: [{ price: price.id, quantity: 1 }],
371
- client_reference_id: userId,
372
- metadata: this.metadata(userId),
373
- subscription_data: { metadata: this.metadata(userId) },
374
- success_url: successUrl.href,
375
- cancel_url: returnUrl,
376
- allow_promotion_codes: false,
377
- },
378
- {
379
- idempotencyKey: providerIdempotency(
380
- this.options.scope,
381
- "checkout",
382
- userId,
383
- attempt.request_id,
384
- ),
385
- },
386
- );
387
- if (!this.sessionOwned(session, userId, customer))
388
- throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
389
- await repository.saveAttempt(userId, { ...attempt, session_id: session.id });
390
- if (session.status !== "open")
391
- throw new SubscriptionError("CHECKOUT_COMPLETED", "Refresh your subscription status", 409);
392
- return { url: this.checkoutUrl(session.url) };
430
+ return { url: result.url };
393
431
  });
394
432
  }
395
- private checkoutUrl(value: string | null): string {
396
- if (!value || new URL(value).origin !== "https://checkout.stripe.com")
397
- throw new SubscriptionError("INVALID_DESTINATION", "Invalid checkout destination", 502);
398
- return value;
399
- }
400
433
  async createPortal(
401
- userId: string,
434
+ subject: BillingSubject | string,
402
435
  input: { returnPath: string; locale?: string },
403
436
  ): Promise<{ url: string }> {
404
437
  const parsed = portalSchema.parse(input);
405
- await this.user(userId);
438
+ const { key: userId } = await this.resolveSubject(subject);
406
439
  const customer = await this.repository.customer(userId);
407
440
  if (!customer)
408
441
  throw new SubscriptionError("CUSTOMER_NOT_FOUND", "No billing account exists", 404);
@@ -432,6 +465,9 @@ export class PaymentSubscriptionService {
432
465
  !config.active ||
433
466
  config.livemode !== (this.options.scope.mode === "live") ||
434
467
  config.features.subscription_update.enabled ||
468
+ !config.features.invoice_history.enabled ||
469
+ !config.features.payment_method_update.enabled ||
470
+ config.features.customer_update.enabled ||
435
471
  !config.features.subscription_cancel.enabled ||
436
472
  config.features.subscription_cancel.mode !== "at_period_end"
437
473
  ) {
@@ -460,11 +496,39 @@ export class PaymentSubscriptionService {
460
496
  await this.repository.withLock("subscription:" + id, async (repository) => {
461
497
  if (eventId && (await repository.eventSeen(eventId))) return;
462
498
  const live = await this.stripe.subscriptions.retrieve(id);
463
- if (live.metadata.dineway_app !== this.options.scope.namespace) return;
499
+ if (live.metadata.dineway_app !== this.options.scope.namespace) {
500
+ if (previous)
501
+ throw new SubscriptionError("OWNER_CONFLICT", "Subscription application changed", 409);
502
+ return;
503
+ }
464
504
  const customerId = typeof live.customer === "string" ? live.customer : live.customer.id;
505
+ if (
506
+ previous &&
507
+ (previous.customerId !== customerId ||
508
+ (previous.purchaseReference ?? null) !== (live.metadata.purchase_reference ?? null))
509
+ )
510
+ throw new SubscriptionError(
511
+ "OWNER_CONFLICT",
512
+ "Subscription purchase identity changed",
513
+ 409,
514
+ );
465
515
  const userId = await repository.customerOwner(customerId);
466
- if (!userId) return;
516
+ if (!userId)
517
+ throw new SubscriptionError(
518
+ "CUSTOMER_NOT_FOUND",
519
+ "The billing account has not been synchronized",
520
+ 503,
521
+ );
467
522
  const plans = await repository.plans();
523
+ const purchase = live.metadata.purchase_reference
524
+ ? await repository.purchase(live.metadata.purchase_reference)
525
+ : null;
526
+ if (live.metadata.purchase_reference && !purchase)
527
+ throw new SubscriptionError(
528
+ "PURCHASE_NOT_FOUND",
529
+ "The purchase has not been synchronized",
530
+ 503,
531
+ );
468
532
  const state = subscriptionProjection(
469
533
  live,
470
534
  plans,
@@ -472,28 +536,103 @@ export class PaymentSubscriptionService {
472
536
  userId,
473
537
  customerId,
474
538
  this.now(),
539
+ purchase,
540
+ );
541
+ const invoices = await collectBounded(
542
+ this.stripe.invoices.list({
543
+ customer: customerId,
544
+ subscription: id,
545
+ status: "paid",
546
+ limit: 100,
547
+ }),
475
548
  );
476
- const invoices = await this.stripe.invoices
477
- .list({ customer: customerId, subscription: id, status: "paid", limit: 100 })
478
- .autoPagingToArray({ limit: 1000 });
479
549
  const periods = [];
480
- for (const invoice of invoices) {
481
- const lines = invoice.lines.has_more
482
- ? await this.stripe.invoices
483
- .listLineItems(invoice.id, { limit: 100 })
484
- .autoPagingToArray({ limit: 1000 })
485
- : invoice.lines.data;
486
- periods.push(...invoicePeriods(invoice, lines, state, plans, this.options.scope));
550
+ const previousPeriods = (await repository.periods(userId)).filter(
551
+ (period) => period.subscriptionId === id,
552
+ );
553
+ const invoiceIds = new Set([
554
+ ...invoices.map((invoice) => invoice.id),
555
+ ...previousPeriods.map((period) => period.invoiceId),
556
+ ]);
557
+ for (const invoiceId of invoiceIds) {
558
+ const invoice = await this.stripe.invoices.retrieve(invoiceId, {
559
+ expand: ["discounts.source.coupon"],
560
+ });
561
+ const evidence = await invoiceFunding(
562
+ this.stripe,
563
+ this.options.scope,
564
+ live,
565
+ invoice,
566
+ purchase?.contract ?? null,
567
+ this.now(),
568
+ );
569
+ const old = previousPeriods.filter((period) => period.invoiceId === invoiceId);
570
+ const projected = old.length
571
+ ? old
572
+ : invoice.status === "paid"
573
+ ? invoicePeriods(invoice, invoice.lines.data, state, plans, this.options.scope)
574
+ : [];
575
+ const historyChanged = old.some(
576
+ (period) =>
577
+ !invoice.lines.data.some(
578
+ (line) =>
579
+ period.id === invoice.id + ":" + line.id &&
580
+ line.period.start === period.startsAt &&
581
+ line.period.end === period.endsAt,
582
+ ),
583
+ );
584
+ if (historyChanged) evidence.reason = "PAID_PERIOD_CHANGED";
585
+ periods.push(
586
+ ...projected.map((period) => ({
587
+ ...period,
588
+ eligible: evidence.reason === null,
589
+ evidence,
590
+ })),
591
+ );
592
+ if (evidence.reason) state.lastError = evidence.reason;
487
593
  }
488
594
  await repository.saveSubscription(state, periods, eventId);
489
595
  });
490
596
  } catch (error) {
491
- if (previous) await this.repository.recordSyncFailure(id, previous.verifiedAt);
597
+ if (
598
+ previous &&
599
+ error instanceof SubscriptionError &&
600
+ [
601
+ "OWNER_CONFLICT",
602
+ "UNRECOGNIZED_SUBSCRIPTION",
603
+ "INVALID_PRICE",
604
+ "INVALID_INVOICE",
605
+ "INVALID_EVIDENCE",
606
+ "MODE_MISMATCH",
607
+ "PURCHASE_CONFLICT",
608
+ ].includes(error.code)
609
+ ) {
610
+ await this.repository.withLock("subscription:" + id, async (store) => {
611
+ const current = await store.subscription(id);
612
+ if (current?.revision !== previous.revision) return;
613
+ const periods = (await store.periods(previous.userId))
614
+ .filter((period) => period.subscriptionId === id)
615
+ .map((period) => ({
616
+ ...period,
617
+ eligible: false,
618
+ evidence: period.evidence
619
+ ? { ...period.evidence, verifiedAt: this.now(), reason: error.code }
620
+ : undefined,
621
+ }));
622
+ await store.saveSubscription(
623
+ { ...previous, verifiedAt: this.now(), lastError: error.code },
624
+ periods,
625
+ );
626
+ });
627
+ } else if (previous) await this.repository.recordSyncFailure(id, previous.verifiedAt);
492
628
  throw error;
493
629
  }
494
630
  }
495
- async getMemberBilling(userId: string, refresh = true): Promise<MemberBilling> {
496
- await this.user(userId);
631
+ async getMemberBilling(subject: BillingSubject | string, refresh = true): Promise<MemberBilling> {
632
+ const { key } = await this.resolveSubject(subject);
633
+ return this.memberBilling(key, refresh);
634
+ }
635
+ private async memberBilling(userId: string, refresh = true): Promise<MemberBilling> {
497
636
  const customer = await this.repository.customer(userId);
498
637
  let states = await this.repository.subscriptions(userId);
499
638
  if (refresh && customer) {
@@ -532,16 +671,48 @@ export class PaymentSubscriptionService {
532
671
  this.now(),
533
672
  );
534
673
  }
535
- async getEntitlements(userId: string) {
674
+ async getEntitlements(userId: BillingSubject | string) {
536
675
  return (await this.getMemberBilling(userId)).entitlements;
537
676
  }
538
677
  async listSubscriptions(cursor?: string, limit = 50) {
539
- return this.repository.listSubscriptions(cursor, Math.max(1, Math.min(100, limit)));
678
+ const page = await this.repository.listSubscriptions(cursor, Math.max(1, Math.min(100, limit)));
679
+ return {
680
+ ...page,
681
+ items: await Promise.all(
682
+ page.items.map(async (state) => ({
683
+ ...state,
684
+ subject: await this.repository.subject(state.userId),
685
+ })),
686
+ ),
687
+ };
688
+ }
689
+ async listPurchases(cursor?: string, limit = 50) {
690
+ return this.repository.listPurchases(cursor, Math.max(1, Math.min(100, limit)));
691
+ }
692
+ async pendingChanges(limit = 50) {
693
+ return this.repository.changes(Math.max(1, Math.min(100, limit)));
694
+ }
695
+ async acknowledgeChange(id: string) {
696
+ await this.repository.acknowledgeChange(id);
697
+ }
698
+ async inspectCharge(subject: BillingSubject | string, chargeId: string) {
699
+ const { key } = await this.resolveSubject(subject);
700
+ const evidence = await inspectCharge(this.stripe, this.options.scope, chargeId);
701
+ if (!evidence.customerId || (await this.repository.customerOwner(evidence.customerId)) !== key)
702
+ throw new SubscriptionError("NOT_FOUND", "Charge not found", 404);
703
+ return evidence;
704
+ }
705
+ async refundCharge(subject: BillingSubject | string, input: SubscriptionRefundInput) {
706
+ const { key } = await this.resolveSubject(subject);
707
+ return refundCharge(this.purchaseContext(), key, input);
540
708
  }
541
709
  async subscriptionDetail(id: string) {
542
710
  const state = await this.repository.subscription(id);
543
711
  if (!state) throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
544
- return { subscription: state, member: await this.getMemberBilling(state.userId, false) };
712
+ return {
713
+ ...(await this.repository.snapshot(id)),
714
+ member: await this.memberBilling(state.userId, false),
715
+ };
545
716
  }
546
717
  async receiveWebhook(rawBody: Uint8Array, signature: string | null): Promise<void> {
547
718
  const verified = new StripeNodeWebhookVerifier().verify({
@@ -555,13 +726,121 @@ export class PaymentSubscriptionService {
555
726
  const event = verified.event;
556
727
  if (event.livemode !== (this.options.scope.mode === "live"))
557
728
  throw new SubscriptionError("MODE_MISMATCH", "Invalid webhook mode", 400);
558
- if (
559
- !events.has(event.eventType) ||
560
- !event.subscriptionId ||
561
- (await this.repository.eventSeen(event.eventId))
562
- )
563
- return;
564
- await this.refreshSubscription(event.subscriptionId, event.eventId);
729
+ if (!events.has(event.eventType)) return;
730
+ await this.repository.receiveEvent({
731
+ id: event.eventId,
732
+ digest: event.payloadSha256,
733
+ type: event.eventType,
734
+ objectType: event.objectType,
735
+ objectId: event.objectId,
736
+ subscriptionId: event.subscriptionId ?? null,
737
+ payload: new TextDecoder().decode(rawBody),
738
+ });
739
+ }
740
+ private async eventSubscriptions(event: {
741
+ subscription_id: string | null;
742
+ object_type: string | null;
743
+ object_id: string | null;
744
+ }): Promise<string[]> {
745
+ if (event.subscription_id) return [event.subscription_id];
746
+ const id = event.object_id;
747
+ if (!id) return [];
748
+ let intent: string | null = null;
749
+ if (event.object_type === "payment_intent") intent = id;
750
+ else {
751
+ let charge: string | null = null;
752
+ if (event.object_type === "charge") charge = id;
753
+ if (event.object_type === "refund")
754
+ charge = relationId((await this.stripe.refunds.retrieve(id)).charge);
755
+ if (event.object_type === "dispute")
756
+ charge = relationId((await this.stripe.disputes.retrieve(id)).charge);
757
+ if (charge) {
758
+ const payment = await this.stripe.charges.retrieve(charge);
759
+ if (payment.livemode !== (this.options.scope.mode === "live"))
760
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid charge mode");
761
+ intent = relationId(payment.payment_intent);
762
+ }
763
+ }
764
+ if (!intent) return [];
765
+ const payments = await collectBounded(
766
+ this.stripe.invoicePayments.list({
767
+ payment: { type: "payment_intent", payment_intent: intent },
768
+ limit: 100,
769
+ }),
770
+ );
771
+ const ids = new Set<string>();
772
+ for (const payment of payments) {
773
+ const invoiceId = relationId(payment.invoice);
774
+ if (!invoiceId) continue;
775
+ const invoice = await this.stripe.invoices.retrieve(invoiceId);
776
+ if (invoice.livemode !== (this.options.scope.mode === "live"))
777
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid invoice mode");
778
+ const subscription = relationId(invoice.parent?.subscription_details?.subscription);
779
+ if (subscription) ids.add(subscription);
780
+ }
781
+ return [...ids];
782
+ }
783
+ async reconcilePending(limit = 25) {
784
+ const bounded = Math.max(1, Math.min(100, Math.floor(limit)));
785
+ const result = { processed: 0, failed: 0 };
786
+ for (const event of await this.repository.pendingEvents(bounded)) {
787
+ try {
788
+ await this.repository.withLock("event:" + event.id, async (store) => {
789
+ if (await store.eventSeen(event.id)) return;
790
+ try {
791
+ for (const id of await this.eventSubscriptions(event))
792
+ await this.refreshSubscription(id);
793
+ await store.finishEvent(event.id, null);
794
+ result.processed++;
795
+ } catch (error) {
796
+ await store.finishEvent(
797
+ event.id,
798
+ error instanceof SubscriptionError ? error.code : "SYNC_UNAVAILABLE",
799
+ );
800
+ result.failed++;
801
+ }
802
+ });
803
+ } catch {
804
+ result.failed++;
805
+ }
806
+ }
807
+ return result;
808
+ }
809
+ /** Run from a protected scheduler; domain consumers acknowledge changes after their own commit. */
810
+ async reconcile(limit = 25) {
811
+ const bounded = Math.max(1, Math.min(100, Math.floor(limit)));
812
+ const result = await this.reconcilePending(bounded);
813
+ for (const row of await this.repository.pendingPurchases(bounded)) {
814
+ const purchase = (await this.repository.purchase(row.id))!;
815
+ try {
816
+ await this.repository.withLock("member:" + purchase.userId, async (store) => {
817
+ try {
818
+ if (purchase.cancelMode) await this.applyCancellation(store, purchase);
819
+ else await resumePurchase(this.purchaseContext(), store, purchase);
820
+ } catch (error) {
821
+ await store.updatePurchase(row.id, {
822
+ last_attempt_at: this.now(),
823
+ last_error: error instanceof SubscriptionError ? error.code : "SYNC_UNAVAILABLE",
824
+ });
825
+ throw error;
826
+ }
827
+ });
828
+ const current = (await this.repository.purchase(row.id))!;
829
+ if (current.subscriptionId) await this.refreshSubscription(current.subscriptionId);
830
+ result.processed++;
831
+ } catch {
832
+ result.failed++;
833
+ }
834
+ }
835
+ for (const row of await this.repository.staleSubscriptions(bounded)) {
836
+ try {
837
+ await this.refreshSubscription(row.id);
838
+ result.processed++;
839
+ } catch {
840
+ result.failed++;
841
+ }
842
+ }
843
+ return result;
565
844
  }
566
845
  }
567
846