@dineway-ai/plugin-payment 0.2.6 → 0.2.8

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