@dineway-ai/plugin-payment 0.2.5 → 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,14 +5,29 @@ 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
- import { checkoutSchema, portalSchema, saveSubscriptionPlanSchema } from "./schemas.js";
23
+ import {
24
+ checkoutSchema,
25
+ portalSchema,
26
+ saveSubscriptionPlanSchema,
27
+ stripeProductListSchema,
28
+ stripePriceListSchema,
29
+ billingSubjectSchema,
30
+ } from "./schemas.js";
16
31
  import {
17
32
  SubscriptionError,
18
33
  type MemberBilling,
@@ -20,6 +35,12 @@ import {
20
35
  type SubscriptionCheckoutInput,
21
36
  type SubscriptionOptions,
22
37
  type SubscriptionScope,
38
+ type SubscriptionCatalogInput,
39
+ type SubscriptionCatalogPage,
40
+ type SubscriptionProduct,
41
+ type StripeSubscriptionPrice,
42
+ type BillingSubject,
43
+ type SubscriptionPurchaseInput,
23
44
  } from "./types.js";
24
45
 
25
46
  export interface SubscriptionServiceOptions {
@@ -46,6 +67,15 @@ const events = new Set([
46
67
  "invoice.payment_action_required",
47
68
  "invoice.voided",
48
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",
49
79
  ]);
50
80
  const localeSchema = checkoutSchema.shape.locale;
51
81
  const fingerprint = (values: unknown[]) =>
@@ -90,6 +120,55 @@ export class PaymentSubscriptionService {
90
120
  .filter((plan) => plan.enabled)
91
121
  .map((plan) => ({ ...plan, prices: plan.prices.filter((price) => price.enabled) }));
92
122
  }
123
+ async listStripeProducts(
124
+ input: SubscriptionCatalogInput = {},
125
+ ): Promise<SubscriptionCatalogPage<SubscriptionProduct>> {
126
+ const { cursor, limit } = stripeProductListSchema.parse(input);
127
+ const page = await this.stripe.products.list({ active: true, limit, starting_after: cursor });
128
+ return {
129
+ items: page.data
130
+ .filter(
131
+ (product) => product.active && product.livemode === (this.options.scope.mode === "live"),
132
+ )
133
+ .map((product) => ({ id: product.id, name: product.name })),
134
+ nextCursor: page.has_more ? page.data.at(-1)?.id : undefined,
135
+ };
136
+ }
137
+ async listStripePrices(
138
+ input: SubscriptionCatalogInput & { productId: string },
139
+ ): Promise<SubscriptionCatalogPage<StripeSubscriptionPrice>> {
140
+ const { productId, cursor, limit } = stripePriceListSchema.parse(input);
141
+ const product = await this.stripe.products.retrieve(productId);
142
+ if (
143
+ product.deleted ||
144
+ !product.active ||
145
+ product.livemode !== (this.options.scope.mode === "live")
146
+ ) {
147
+ throw new SubscriptionError(
148
+ "INVALID_PRODUCT",
149
+ "Choose an active product from this Stripe environment",
150
+ );
151
+ }
152
+ const page = await this.stripe.prices.list({
153
+ product: productId,
154
+ active: true,
155
+ type: "recurring",
156
+ limit,
157
+ starting_after: cursor,
158
+ });
159
+ const items: StripeSubscriptionPrice[] = [];
160
+ for (const price of page.data) {
161
+ try {
162
+ const verified = recurringPrice(price, this.options.scope);
163
+ if (verified.productId === productId)
164
+ items.push({ ...verified, nickname: price.nickname ?? null });
165
+ } catch (error) {
166
+ if (!(error instanceof SubscriptionError) || error.code !== "INVALID_PRICE") throw error;
167
+ }
168
+ }
169
+ return { items, nextCursor: page.has_more ? page.data.at(-1)?.id : undefined };
170
+ }
171
+
93
172
  async savePlan(input: SaveSubscriptionPlan) {
94
173
  const parsed = saveSubscriptionPlanSchema.parse(input);
95
174
  const allPlans = await this.repository.plans();
@@ -113,7 +192,7 @@ export class PaymentSubscriptionService {
113
192
  ] as const) {
114
193
  if (!id) continue;
115
194
  const price = recurringPrice(
116
- await this.stripe.prices.retrieve(id),
195
+ await this.stripe.prices.retrieve(id, { expand: ["product"] }),
117
196
  this.options.scope,
118
197
  parsed.enabled,
119
198
  );
@@ -142,208 +221,221 @@ export class PaymentSubscriptionService {
142
221
  private async user(userId: string) {
143
222
  const user = await this.options.db
144
223
  .selectFrom("users")
145
- .select(["id", "email", "disabled"])
224
+ .select(["id", "email", "disabled", "email_verified"])
146
225
  .where("id", "=", userId)
147
226
  .executeTakeFirst();
148
- if (!user || user.disabled)
227
+ if (!user || user.disabled || !user.email_verified)
149
228
  throw new SubscriptionError("UNAUTHORIZED", "An active member account is required", 401);
150
229
  return user;
151
230
  }
152
- private metadata(userId: string) {
153
- return { dineway_app: this.options.scope.namespace, user_id: userId };
154
- }
155
- private sessionOwned(
156
- session: Stripe.Checkout.Session,
157
- userId: string,
158
- customer: string,
159
- ): boolean {
160
- const owner = typeof session.customer === "string" ? session.customer : session.customer?.id;
161
- return (
162
- owner === customer &&
163
- session.mode === "subscription" &&
164
- session.livemode === (this.options.scope.mode === "live") &&
165
- session.metadata?.dineway_app === this.options.scope.namespace &&
166
- session.metadata.user_id === userId &&
167
- 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),
168
266
  );
169
267
  }
170
- async createCheckout(userId: string, input: SubscriptionCheckoutInput): Promise<{ url: string }> {
171
- const parsed = checkoutSchema.parse(input);
172
- const user = await this.user(userId);
173
- const returnUrl = this.returnUrl(parsed.returnPath);
174
- const successUrl = new URL(returnUrl);
175
- successUrl.searchParams.set("checkout", "returned");
176
- return this.repository.withLock("member:" + userId, async (repository) => {
177
- const plans = await repository.plans();
178
- const plan = plans.find((value) => value.code === parsed.planCode && value.enabled);
179
- const price = plan?.prices.find(
180
- (value) => value.interval === parsed.interval && value.enabled,
181
- );
182
- if (!price) throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
183
- const livePrice = recurringPrice(
184
- await this.stripe.prices.retrieve(price.id),
185
- this.options.scope,
186
- );
187
- if (
188
- livePrice.currency !== price.currency ||
189
- livePrice.amount !== price.amount ||
190
- livePrice.productId !== price.productId ||
191
- livePrice.interval !== price.interval
192
- ) {
193
- throw new SubscriptionError("PRICE_CHANGED", "Refresh the plan price before checkout", 409);
194
- }
195
- let customer = await repository.customer(userId);
196
- if (!customer) {
197
- const created = await this.stripe.customers.create(
198
- { email: user.email, metadata: this.metadata(userId) },
199
- { idempotencyKey: providerIdempotency(this.options.scope, "customer", userId) },
200
- );
201
- customer = created.id;
202
- await repository.saveCustomer(userId, customer);
203
- }
204
- const subscriptions = await this.stripe.subscriptions.list({
205
- customer,
206
- status: "all",
207
- 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(),
208
289
  });
209
- if (subscriptions.has_more)
210
- throw new SubscriptionError("REVIEW_REQUIRED", "Subscription review is required", 409);
211
- if (
212
- subscriptions.data.some(
213
- (subscription) =>
214
- subscription.metadata.dineway_app === this.options.scope.namespace &&
215
- !terminal.has(subscription.status),
216
- )
217
- ) {
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)
218
307
  throw new SubscriptionError(
219
- "SUBSCRIPTION_EXISTS",
220
- "Manage the existing subscription in the billing portal",
308
+ "CHECKOUT_REVIEW_REQUIRED",
309
+ "Confirm the checkout before canceling",
221
310
  409,
222
311
  );
223
- }
224
- const open = await this.stripe.checkout.sessions.list({
225
- customer,
226
- status: "open",
227
- limit: 100,
228
- });
229
- const owned = open.data.filter(
230
- (session) => session.metadata?.dineway_app === this.options.scope.namespace,
231
- );
232
- if (open.has_more || owned.length > 1)
233
- throw new SubscriptionError("REVIEW_REQUIRED", "Checkout review is required", 409);
234
- const previous = owned[0];
235
- const locale = parsed.locale ?? "auto";
236
- const remembered = await repository.attempt(userId);
237
- const digest = fingerprint([customer, price.id, returnUrl, locale]);
238
- if (previous) {
239
- if (!this.sessionOwned(previous, userId, customer))
240
- throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
241
- const lines = await this.stripe.checkout.sessions.listLineItems(previous.id, { limit: 2 });
242
- if (
243
- previous.locale === locale &&
244
- previous.success_url === successUrl.href &&
245
- previous.cancel_url === returnUrl &&
246
- previous.expires_at * 1000 > this.now() &&
247
- previous.allow_promotion_codes === false &&
248
- !lines.has_more &&
249
- lines.data.length === 1 &&
250
- lines.data[0]?.quantity === 1 &&
251
- lines.data[0].price?.id === price.id
252
- ) {
253
- if (remembered && !remembered.session_id && remembered.fingerprint === digest) {
254
- await repository.saveAttempt(userId, { ...remembered, session_id: previous.id });
255
- }
256
- return { url: this.checkoutUrl(previous.url) };
257
- }
258
- 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);
259
315
  if (expired.status !== "expired")
260
316
  throw new SubscriptionError(
261
- "REVIEW_REQUIRED",
262
- "Checkout changed; refresh and try again",
317
+ "CHECKOUT_REVIEW_REQUIRED",
318
+ "Checkout changed during cancellation",
263
319
  409,
264
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
+ }
265
402
  }
266
- if (!previous && remembered?.session_id) {
267
- const session = await this.stripe.checkout.sessions.retrieve(remembered.session_id);
268
- if (!this.sessionOwned(session, userId, customer))
269
- throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
270
- const relation = session.subscription;
271
- const subscriptionId = typeof relation === "string" ? relation : relation?.id;
272
- const old = subscriptions.data.find((subscription) => subscription.id === subscriptionId);
273
- if (
274
- session.status !== "expired" &&
275
- !(session.status === "complete" && old && terminal.has(old.status))
276
- ) {
277
- throw new SubscriptionError(
278
- "SUBSCRIPTION_EXISTS",
279
- "Confirm the previous subscription before starting another",
280
- 409,
281
- );
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;
282
415
  }
283
416
  }
284
- if (
285
- !previous &&
286
- remembered &&
287
- !remembered.session_id &&
288
- (remembered.fingerprint !== digest || this.now() - remembered.created_at > 23 * 3600000)
289
- ) {
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)
290
425
  throw new SubscriptionError(
291
- "REVIEW_REQUIRED",
292
- "An unconfirmed checkout requires review",
426
+ "SUBSCRIPTION_EXISTS",
427
+ "Confirm your subscription status before starting another",
293
428
  409,
294
429
  );
295
- }
296
- const attempt =
297
- !previous && remembered && !remembered.session_id
298
- ? remembered
299
- : {
300
- fingerprint: digest,
301
- request_id: randomUUID(),
302
- session_id: null,
303
- created_at: this.now(),
304
- };
305
- await repository.saveAttempt(userId, attempt);
306
- const session = await this.stripe.checkout.sessions.create(
307
- {
308
- mode: "subscription",
309
- customer,
310
- locale,
311
- line_items: [{ price: price.id, quantity: 1 }],
312
- client_reference_id: userId,
313
- metadata: this.metadata(userId),
314
- subscription_data: { metadata: this.metadata(userId) },
315
- success_url: successUrl.href,
316
- cancel_url: returnUrl,
317
- allow_promotion_codes: false,
318
- },
319
- {
320
- idempotencyKey: providerIdempotency(
321
- this.options.scope,
322
- "checkout",
323
- userId,
324
- attempt.request_id,
325
- ),
326
- },
327
- );
328
- if (!this.sessionOwned(session, userId, customer))
329
- throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
330
- await repository.saveAttempt(userId, { ...attempt, session_id: session.id });
331
- if (session.status !== "open")
332
- throw new SubscriptionError("CHECKOUT_COMPLETED", "Refresh your subscription status", 409);
333
- return { url: this.checkoutUrl(session.url) };
430
+ return { url: result.url };
334
431
  });
335
432
  }
336
- private checkoutUrl(value: string | null): string {
337
- if (!value || new URL(value).origin !== "https://checkout.stripe.com")
338
- throw new SubscriptionError("INVALID_DESTINATION", "Invalid checkout destination", 502);
339
- return value;
340
- }
341
433
  async createPortal(
342
- userId: string,
434
+ subject: BillingSubject | string,
343
435
  input: { returnPath: string; locale?: string },
344
436
  ): Promise<{ url: string }> {
345
437
  const parsed = portalSchema.parse(input);
346
- await this.user(userId);
438
+ const { key: userId } = await this.resolveSubject(subject);
347
439
  const customer = await this.repository.customer(userId);
348
440
  if (!customer)
349
441
  throw new SubscriptionError("CUSTOMER_NOT_FOUND", "No billing account exists", 404);
@@ -373,6 +465,9 @@ export class PaymentSubscriptionService {
373
465
  !config.active ||
374
466
  config.livemode !== (this.options.scope.mode === "live") ||
375
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 ||
376
471
  !config.features.subscription_cancel.enabled ||
377
472
  config.features.subscription_cancel.mode !== "at_period_end"
378
473
  ) {
@@ -401,11 +496,39 @@ export class PaymentSubscriptionService {
401
496
  await this.repository.withLock("subscription:" + id, async (repository) => {
402
497
  if (eventId && (await repository.eventSeen(eventId))) return;
403
498
  const live = await this.stripe.subscriptions.retrieve(id);
404
- 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
+ }
405
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
+ );
406
515
  const userId = await repository.customerOwner(customerId);
407
- 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
+ );
408
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
+ );
409
532
  const state = subscriptionProjection(
410
533
  live,
411
534
  plans,
@@ -413,28 +536,103 @@ export class PaymentSubscriptionService {
413
536
  userId,
414
537
  customerId,
415
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
+ }),
416
548
  );
417
- const invoices = await this.stripe.invoices
418
- .list({ customer: customerId, subscription: id, status: "paid", limit: 100 })
419
- .autoPagingToArray({ limit: 1000 });
420
549
  const periods = [];
421
- for (const invoice of invoices) {
422
- const lines = invoice.lines.has_more
423
- ? await this.stripe.invoices
424
- .listLineItems(invoice.id, { limit: 100 })
425
- .autoPagingToArray({ limit: 1000 })
426
- : invoice.lines.data;
427
- 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;
428
593
  }
429
594
  await repository.saveSubscription(state, periods, eventId);
430
595
  });
431
596
  } catch (error) {
432
- 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);
433
628
  throw error;
434
629
  }
435
630
  }
436
- async getMemberBilling(userId: string, refresh = true): Promise<MemberBilling> {
437
- 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> {
438
636
  const customer = await this.repository.customer(userId);
439
637
  let states = await this.repository.subscriptions(userId);
440
638
  if (refresh && customer) {
@@ -473,16 +671,48 @@ export class PaymentSubscriptionService {
473
671
  this.now(),
474
672
  );
475
673
  }
476
- async getEntitlements(userId: string) {
674
+ async getEntitlements(userId: BillingSubject | string) {
477
675
  return (await this.getMemberBilling(userId)).entitlements;
478
676
  }
479
677
  async listSubscriptions(cursor?: string, limit = 50) {
480
- 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);
481
708
  }
482
709
  async subscriptionDetail(id: string) {
483
710
  const state = await this.repository.subscription(id);
484
711
  if (!state) throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
485
- 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
+ };
486
716
  }
487
717
  async receiveWebhook(rawBody: Uint8Array, signature: string | null): Promise<void> {
488
718
  const verified = new StripeNodeWebhookVerifier().verify({
@@ -496,13 +726,121 @@ export class PaymentSubscriptionService {
496
726
  const event = verified.event;
497
727
  if (event.livemode !== (this.options.scope.mode === "live"))
498
728
  throw new SubscriptionError("MODE_MISMATCH", "Invalid webhook mode", 400);
499
- if (
500
- !events.has(event.eventType) ||
501
- !event.subscriptionId ||
502
- (await this.repository.eventSeen(event.eventId))
503
- )
504
- return;
505
- 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;
506
844
  }
507
845
  }
508
846