@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.
@@ -0,0 +1,256 @@
1
+ import type Stripe from "stripe";
2
+
3
+ import { relationId } from "./purchases.js";
4
+ import {
5
+ SubscriptionError,
6
+ type ChargeEvidence,
7
+ type InvoiceFundingEvidence,
8
+ type PurchaseContract,
9
+ type SubscriptionScope,
10
+ } from "./types.js";
11
+
12
+ /** Fail instead of silently truncating provider evidence. */
13
+ export async function collectBounded<T>(items: AsyncIterable<T>, limit = 1000): Promise<T[]> {
14
+ const result: T[] = [];
15
+ for await (const item of items) {
16
+ if (result.length === limit)
17
+ throw new SubscriptionError(
18
+ "RECONCILIATION_LIMIT",
19
+ "Payment evidence exceeds its safe limit",
20
+ 503,
21
+ );
22
+ result.push(item);
23
+ }
24
+ return result;
25
+ }
26
+
27
+ export async function inspectCharge(
28
+ stripe: Stripe,
29
+ scope: SubscriptionScope,
30
+ chargeId: string,
31
+ ): Promise<ChargeEvidence> {
32
+ const charge = await stripe.charges.retrieve(chargeId);
33
+ if (charge.livemode !== (scope.mode === "live"))
34
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid charge mode");
35
+ const refunds = await collectBounded(stripe.refunds.list({ charge: charge.id, limit: 100 }));
36
+ const disputes = await collectBounded(stripe.disputes.list({ charge: charge.id, limit: 100 }));
37
+ if (
38
+ refunds.some(
39
+ (refund) => relationId(refund.charge) !== charge.id || refund.currency !== charge.currency,
40
+ ) ||
41
+ disputes.some(
42
+ (dispute) =>
43
+ relationId(dispute.charge) !== charge.id ||
44
+ dispute.currency !== charge.currency ||
45
+ dispute.livemode !== charge.livemode,
46
+ )
47
+ )
48
+ throw new SubscriptionError(
49
+ "INVALID_EVIDENCE",
50
+ "Payment evidence does not match the charge",
51
+ 409,
52
+ );
53
+ return {
54
+ id: charge.id,
55
+ paymentIntentId: relationId(charge.payment_intent),
56
+ customerId: relationId(charge.customer),
57
+ currency: charge.currency,
58
+ amount: charge.amount,
59
+ captured: charge.amount_captured,
60
+ refunded: charge.amount_refunded,
61
+ settled:
62
+ charge.paid &&
63
+ charge.captured &&
64
+ charge.status === "succeeded" &&
65
+ charge.amount_captured === charge.amount &&
66
+ Number.isSafeInteger(charge.amount) &&
67
+ charge.amount > 0 &&
68
+ Number.isSafeInteger(charge.amount_refunded) &&
69
+ charge.amount_refunded >= 0 &&
70
+ charge.amount_refunded <= charge.amount &&
71
+ (!charge.disputed ||
72
+ (disputes.length > 0 &&
73
+ disputes.every((dispute) => ["won", "warning_closed"].includes(dispute.status)))),
74
+ refunds: refunds.map((refund) => ({
75
+ id: refund.id,
76
+ amount: refund.amount,
77
+ status: refund.status ?? "unknown",
78
+ metadata: refund.metadata ?? {},
79
+ })),
80
+ disputes: disputes.map((dispute) => ({
81
+ id: dispute.id,
82
+ amount: dispute.amount,
83
+ status: dispute.status,
84
+ })),
85
+ };
86
+ }
87
+
88
+ export async function invoiceFunding(
89
+ stripe: Stripe,
90
+ scope: SubscriptionScope,
91
+ subscription: Stripe.Subscription,
92
+ invoice: Stripe.Invoice,
93
+ contract: PurchaseContract | null,
94
+ now: number,
95
+ ): Promise<InvoiceFundingEvidence> {
96
+ const item = subscription.items.data[0]!;
97
+ const amount = contract?.amount ?? item.price.unit_amount ?? 0;
98
+ const currency = contract?.currency ?? item.price.currency;
99
+ const credit =
100
+ invoice.billing_reason === "subscription_create" ? (contract?.firstPeriodCredit ?? 0) : 0;
101
+ const cash = amount - credit;
102
+ const evidence: InvoiceFundingEvidence = {
103
+ verifiedAt: now,
104
+ amount,
105
+ currency,
106
+ credit,
107
+ cash,
108
+ charges: [],
109
+ paymentMethodId: null,
110
+ reason: null,
111
+ };
112
+ const invalid = (reason: string) => ({ ...evidence, reason });
113
+ const line = invoice.lines.data[0];
114
+ const details = line?.parent?.subscription_item_details;
115
+ const customer = relationId(subscription.customer);
116
+ if (
117
+ invoice.status !== "paid" ||
118
+ invoice.livemode !== (scope.mode === "live") ||
119
+ !customer ||
120
+ relationId(invoice.customer) !== customer ||
121
+ relationId(invoice.parent?.subscription_details?.subscription) !== subscription.id ||
122
+ !["subscription_create", "subscription_cycle"].includes(invoice.billing_reason ?? "") ||
123
+ invoice.currency !== currency ||
124
+ invoice.lines.has_more ||
125
+ invoice.lines.data.length !== 1 ||
126
+ !line ||
127
+ !details ||
128
+ details.proration ||
129
+ details.subscription_item !== item.id ||
130
+ details.subscription !== subscription.id ||
131
+ relationId(line.pricing?.price_details?.price) !== item.price.id ||
132
+ line.quantity !== 1 ||
133
+ line.currency !== currency ||
134
+ line.amount !== amount ||
135
+ line.subtotal !== amount ||
136
+ invoice.subtotal !== amount ||
137
+ invoice.total !== cash ||
138
+ invoice.amount_due !== cash ||
139
+ invoice.amount_paid !== cash ||
140
+ invoice.amount_remaining !== 0 ||
141
+ invoice.starting_balance !== 0 ||
142
+ invoice.ending_balance !== 0 ||
143
+ invoice.pre_payment_credit_notes_amount !== 0 ||
144
+ invoice.post_payment_credit_notes_amount !== 0 ||
145
+ invoice.total_taxes?.some((tax) => tax.amount !== 0) ||
146
+ !Number.isSafeInteger(line.period.start) ||
147
+ !Number.isSafeInteger(line.period.end) ||
148
+ line.period.start >= line.period.end
149
+ )
150
+ return invalid("INVOICE_CONTRACT_MISMATCH");
151
+ if (
152
+ contract?.interval === "day" &&
153
+ line.period.end - line.period.start !== contract.intervalCount * 86400
154
+ )
155
+ return invalid("SERVICE_PERIOD_MISMATCH");
156
+ const discounts = invoice.total_discount_amounts ?? [];
157
+ if (credit) {
158
+ if (discounts.length !== 1 || discounts[0]!.amount !== credit)
159
+ return invalid("CREDIT_MISMATCH");
160
+ const discount = invoice.discounts.find(
161
+ (value) => typeof value !== "string" && value.id === relationId(discounts[0]!.discount),
162
+ );
163
+ if (
164
+ !discount ||
165
+ typeof discount === "string" ||
166
+ discount.source.type !== "coupon" ||
167
+ relationId(discount.source.coupon) !== contract?.couponId
168
+ )
169
+ return invalid("CREDIT_MISMATCH");
170
+ const coupon =
171
+ typeof discount.source.coupon === "string"
172
+ ? await stripe.coupons.retrieve(discount.source.coupon)
173
+ : discount.source.coupon;
174
+ if (
175
+ !coupon ||
176
+ coupon.livemode !== invoice.livemode ||
177
+ coupon.amount_off !== credit ||
178
+ coupon.currency !== currency ||
179
+ coupon.percent_off !== null ||
180
+ coupon.duration !== "once" ||
181
+ coupon.max_redemptions !== 1 ||
182
+ coupon.metadata?.dineway_app !== scope.namespace ||
183
+ coupon.metadata.purchase_reference !== contract?.reference ||
184
+ coupon.metadata.user_id !== subscription.metadata.user_id
185
+ )
186
+ return invalid("CREDIT_MISMATCH");
187
+ } else if (discounts.some((discount) => discount.amount !== 0))
188
+ return invalid("UNEXPECTED_DISCOUNT");
189
+ const payments = await collectBounded(
190
+ stripe.invoicePayments.list({
191
+ invoice: invoice.id,
192
+ status: "paid",
193
+ limit: 100,
194
+ expand: ["data.payment.payment_intent"],
195
+ }),
196
+ );
197
+ if (payments.length !== (cash ? 1 : 0)) return invalid("UNVERIFIED_CASH");
198
+ if (cash) {
199
+ const payment = payments[0]!;
200
+ const intentRef = payment.payment.payment_intent;
201
+ const intent =
202
+ typeof intentRef === "string" ? await stripe.paymentIntents.retrieve(intentRef) : intentRef;
203
+ if (
204
+ payment.status !== "paid" ||
205
+ payment.livemode !== invoice.livemode ||
206
+ payment.currency !== currency ||
207
+ relationId(payment.invoice) !== invoice.id ||
208
+ payment.amount_paid !== cash ||
209
+ payment.payment.type !== "payment_intent" ||
210
+ !intent ||
211
+ intent.status !== "succeeded" ||
212
+ intent.livemode !== invoice.livemode ||
213
+ intent.currency !== currency ||
214
+ intent.amount_received !== cash ||
215
+ relationId(intent.customer) !== customer ||
216
+ !intent.latest_charge
217
+ )
218
+ return invalid("UNVERIFIED_CASH");
219
+ const charge = await inspectCharge(stripe, scope, relationId(intent.latest_charge)!);
220
+ evidence.charges.push(charge);
221
+ if (
222
+ !charge.settled ||
223
+ charge.amount !== cash ||
224
+ charge.captured !== cash ||
225
+ charge.currency !== currency ||
226
+ charge.customerId !== customer ||
227
+ charge.paymentIntentId !== intent.id
228
+ )
229
+ return invalid("UNSETTLED_CHARGE");
230
+ if (
231
+ charge.refunded > 0 ||
232
+ charge.refunds.some((refund) => !["failed", "canceled"].includes(refund.status)) ||
233
+ charge.disputes.some((dispute) => !["won", "warning_closed"].includes(dispute.status))
234
+ )
235
+ return invalid("FUNDING_REVERSED");
236
+ } else {
237
+ let method = subscription.default_payment_method;
238
+ if (!method) {
239
+ const account = await stripe.customers.retrieve(customer);
240
+ if (account.deleted || account.livemode !== invoice.livemode)
241
+ return invalid("PAYMENT_METHOD_REQUIRED");
242
+ method = account.invoice_settings.default_payment_method;
243
+ }
244
+ if (!method) return invalid("PAYMENT_METHOD_REQUIRED");
245
+ const resolved =
246
+ typeof method === "string" ? await stripe.paymentMethods.retrieve(method) : method;
247
+ if (
248
+ resolved.type !== "card" ||
249
+ resolved.livemode !== invoice.livemode ||
250
+ relationId(resolved.customer) !== customer
251
+ )
252
+ return invalid("PAYMENT_METHOD_REQUIRED");
253
+ evidence.paymentMethodId = resolved.id;
254
+ }
255
+ return evidence;
256
+ }
@@ -4,3 +4,4 @@ export { getPaymentSubscriptionService, getSubscriptionConfiguration } from "./r
4
4
  export { createSubscriptionRoutes } from "./routes.js";
5
5
  export * from "./types.js";
6
6
  export { formatSubscriptionPrice } from "./format.js";
7
+ export type { SubscriptionRefundInput } from "./refunds.js";
@@ -8,6 +8,7 @@ import {
8
8
  type SubscriptionPlan,
9
9
  type SubscriptionPrice,
10
10
  type SubscriptionScope,
11
+ type SubscriptionPurchase,
11
12
  } from "./types.js";
12
13
 
13
14
  export function recurringPrice(
@@ -76,30 +77,53 @@ export function subscriptionProjection(
76
77
  userId: string,
77
78
  customerId: string,
78
79
  now: number,
80
+ purchase?: SubscriptionPurchase | null,
79
81
  ): MemberSubscription {
80
82
  ownedSubscription(subscription, scope, userId, customerId);
81
83
  const item = subscription.items.data[0];
82
84
  const plan = plans.find((value) => value.prices.some((price) => price.id === item?.price.id));
83
85
  if (
84
- !plan ||
86
+ (!plan && purchase?.contract.pricing.kind !== "dynamic") ||
85
87
  subscription.items.has_more ||
86
88
  subscription.items.data.length !== 1 ||
87
89
  item?.quantity !== 1
88
90
  ) {
89
91
  throw new SubscriptionError("UNRECOGNIZED_SUBSCRIPTION", "Subscription requires review", 409);
90
92
  }
91
- recurringPrice(item.price, scope, false);
93
+ if (purchase) {
94
+ const { contract } = purchase;
95
+ if (
96
+ purchase.userId !== userId ||
97
+ subscription.metadata.purchase_reference !== purchase.reference ||
98
+ (purchase.subscriptionId && purchase.subscriptionId !== subscription.id) ||
99
+ item.price.livemode !== (scope.mode === "live") ||
100
+ item.price.currency !== contract.currency ||
101
+ item.price.unit_amount !== contract.amount ||
102
+ item.price.recurring?.interval !== contract.interval ||
103
+ item.price.recurring.interval_count !== contract.intervalCount ||
104
+ item.price.recurring.usage_type !== "licensed" ||
105
+ item.price.billing_scheme !== "per_unit" ||
106
+ item.price.transform_quantity ||
107
+ (contract.priceId && item.price.id !== contract.priceId)
108
+ )
109
+ throw new SubscriptionError(
110
+ "UNRECOGNIZED_SUBSCRIPTION",
111
+ "Subscription does not match its purchase",
112
+ 409,
113
+ );
114
+ } else recurringPrice(item.price, scope, false);
92
115
  return {
93
116
  id: subscription.id,
94
117
  userId,
95
118
  customerId,
96
- planCode: plan.code,
119
+ planCode: purchase?.contract.pricing.kind === "dynamic" ? "" : plan!.code,
97
120
  priceId: item.price.id,
98
- status: subscription.status,
121
+ status: subscription.pause_collection ? "paused" : subscription.status,
99
122
  periodEnd: item.current_period_end,
100
123
  cancelAtPeriodEnd: subscription.cancel_at_period_end,
101
124
  verifiedAt: now,
102
125
  lastError: null,
126
+ purchaseReference: purchase?.reference ?? null,
103
127
  };
104
128
  }
105
129
 
@@ -134,7 +158,7 @@ export function invoicePeriods(
134
158
  !details ||
135
159
  details.subscription !== subscription.id ||
136
160
  details.proration ||
137
- !plan ||
161
+ (!plan && !subscription.purchaseReference) ||
138
162
  line.quantity !== 1 ||
139
163
  line.amount <= 0
140
164
  )
@@ -150,7 +174,7 @@ export function invoicePeriods(
150
174
  id: invoice.id + ":" + line.id,
151
175
  invoiceId: invoice.id,
152
176
  subscriptionId: subscription.id,
153
- planCode: plan.code,
177
+ planCode: plan?.code ?? "",
154
178
  startsAt: line.period.start,
155
179
  endsAt: line.period.end,
156
180
  },
@@ -166,7 +190,15 @@ export function memberProjection(
166
190
  now: number,
167
191
  ): MemberBilling {
168
192
  const valid = periods.filter(
169
- (period) => period.startsAt * 1000 <= now && period.endsAt * 1000 > now,
193
+ (period) =>
194
+ period.eligible === true &&
195
+ period.startsAt * 1000 <= now &&
196
+ period.endsAt * 1000 > now &&
197
+ subscriptions.some(
198
+ (subscription) =>
199
+ subscription.id === period.subscriptionId &&
200
+ ["active", "past_due", "canceled"].includes(subscription.status),
201
+ ),
170
202
  );
171
203
  const entitlements: Record<string, boolean | number> = {};
172
204
  for (const period of valid) {
@@ -0,0 +1,329 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type Stripe from "stripe";
4
+
5
+ import { recurringPrice } from "./projection.js";
6
+ import type { SubscriptionRepository } from "./repository.js";
7
+ import { purchaseSchema } from "./schemas.js";
8
+ import {
9
+ SubscriptionError,
10
+ type BillingSubject,
11
+ type PurchaseContract,
12
+ type SubscriptionPurchaseInput,
13
+ type SubscriptionPurchase,
14
+ type SubscriptionScope,
15
+ } from "./types.js";
16
+
17
+ export const digest = (value: unknown) =>
18
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
19
+ export const relationId = (value: string | { id: string } | null | undefined) =>
20
+ typeof value === "string" ? value : (value?.id ?? null);
21
+ export interface PurchaseContext {
22
+ stripe: Stripe;
23
+ repository: SubscriptionRepository;
24
+ scope: SubscriptionScope;
25
+ now: () => number;
26
+ origin: string;
27
+ }
28
+ const idempotency = (context: PurchaseContext, ...values: unknown[]) =>
29
+ "dineway:" + digest([context.repository.scope, ...values]);
30
+ const purchaseInput = ({
31
+ reference,
32
+ groupKey,
33
+ pricing,
34
+ firstPeriodCredit,
35
+ returnPath,
36
+ locale,
37
+ }: SubscriptionPurchaseInput) =>
38
+ purchaseSchema.parse({ reference, groupKey, pricing, firstPeriodCredit, returnPath, locale });
39
+ export function purchaseMetadata(context: PurchaseContext, userId: string, reference: string) {
40
+ return { dineway_app: context.scope.namespace, user_id: userId, purchase_reference: reference };
41
+ }
42
+
43
+ async function customer(
44
+ context: PurchaseContext,
45
+ store: SubscriptionRepository,
46
+ userId: string,
47
+ subject: BillingSubject,
48
+ ) {
49
+ const existing = await store.customer(userId);
50
+ if (existing) return existing;
51
+ const attemptKey = "customer:" + userId;
52
+ const attempt = await store.attempt(attemptKey);
53
+ if (attempt) {
54
+ const listed = await context.stripe.customers.list({ email: attempt.fingerprint, limit: 100 });
55
+ if (listed.has_more)
56
+ throw new SubscriptionError(
57
+ "CUSTOMER_REVIEW_REQUIRED",
58
+ "Customer recovery exceeds its safe limit",
59
+ 409,
60
+ );
61
+ const matches = listed.data.filter(
62
+ (value) =>
63
+ value.metadata.dineway_app === context.scope.namespace &&
64
+ value.metadata.user_id === userId &&
65
+ value.livemode === (context.scope.mode === "live"),
66
+ );
67
+ if (matches.length > 1)
68
+ throw new SubscriptionError(
69
+ "CUSTOMER_REVIEW_REQUIRED",
70
+ "Multiple customers match this billing subject",
71
+ 409,
72
+ );
73
+ if (matches[0]) {
74
+ await store.saveCustomer(userId, matches[0].id, subject);
75
+ return matches[0].id;
76
+ }
77
+ }
78
+ if (attempt && context.now() - attempt.created_at > 23 * 3600000) {
79
+ throw new SubscriptionError(
80
+ "CUSTOMER_REVIEW_REQUIRED",
81
+ "An unconfirmed customer creation requires reconciliation",
82
+ 409,
83
+ );
84
+ }
85
+ // Pin the email as part of the request so a retry cannot change Stripe's idempotent payload.
86
+ const email = attempt ? attempt.fingerprint : subject.email;
87
+ if (!attempt)
88
+ await store.saveAttempt(attemptKey, {
89
+ fingerprint: email,
90
+ request_id: userId,
91
+ created_at: context.now(),
92
+ session_id: null,
93
+ });
94
+ const created = await context.stripe.customers.create(
95
+ { email, metadata: { dineway_app: context.scope.namespace, user_id: userId } },
96
+ { idempotencyKey: idempotency(context, "customer", userId) },
97
+ );
98
+ if (created.livemode !== (context.scope.mode === "live"))
99
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid customer mode");
100
+ await store.saveCustomer(userId, created.id, subject);
101
+ return created.id;
102
+ }
103
+
104
+ export async function createPurchase(
105
+ context: PurchaseContext,
106
+ userId: string,
107
+ subject: BillingSubject,
108
+ input: SubscriptionPurchaseInput,
109
+ ) {
110
+ const parsed = purchaseSchema.parse(input);
111
+ return context.repository.withLock("member:" + userId, async (store) => {
112
+ const previous = await store.purchase(parsed.reference);
113
+ if (previous) {
114
+ if (previous.userId !== userId)
115
+ throw new SubscriptionError("NOT_FOUND", "Purchase not found", 404);
116
+ if (digest(purchaseInput(previous.contract)) !== digest(parsed))
117
+ throw new SubscriptionError(
118
+ "PURCHASE_CONFLICT",
119
+ "A purchase reference cannot change its contract",
120
+ 409,
121
+ );
122
+ return resumePurchase(context, store, previous);
123
+ }
124
+ const open = await store.openPurchase(userId, parsed.groupKey);
125
+ if (open)
126
+ throw new SubscriptionError(
127
+ "SUBSCRIPTION_EXISTS",
128
+ "Resolve the existing purchase before starting another",
129
+ 409,
130
+ );
131
+ let contract: PurchaseContract;
132
+ if (parsed.pricing.kind === "catalogue") {
133
+ const pricing = parsed.pricing;
134
+ const plan = (await store.plans()).find(
135
+ (value) => value.enabled && value.code === pricing.planCode,
136
+ );
137
+ const price = plan?.prices.find(
138
+ (value) => value.enabled && value.interval === pricing.interval,
139
+ );
140
+ if (!price) throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
141
+ const verified = recurringPrice(
142
+ await context.stripe.prices.retrieve(price.id),
143
+ context.scope,
144
+ );
145
+ if (
146
+ verified.amount !== price.amount ||
147
+ verified.currency !== price.currency ||
148
+ verified.productId !== price.productId ||
149
+ verified.interval !== price.interval
150
+ )
151
+ throw new SubscriptionError("PRICE_CHANGED", "Refresh the plan price before checkout", 409);
152
+ contract = {
153
+ ...parsed,
154
+ amount: price.amount,
155
+ currency: price.currency,
156
+ interval: price.interval,
157
+ intervalCount: 1,
158
+ priceId: price.id,
159
+ couponId: null,
160
+ };
161
+ } else {
162
+ contract = {
163
+ ...parsed,
164
+ amount: parsed.pricing.amount,
165
+ currency: parsed.pricing.currency,
166
+ interval: parsed.pricing.interval,
167
+ intervalCount: parsed.pricing.intervalCount,
168
+ priceId: null,
169
+ couponId: null,
170
+ };
171
+ }
172
+ if (
173
+ parsed.firstPeriodCredit > contract.amount ||
174
+ (parsed.pricing.kind === "catalogue" && parsed.firstPeriodCredit)
175
+ )
176
+ throw new SubscriptionError(
177
+ "INVALID_CREDIT",
178
+ "Credit must fit a dynamic purchase's first invoice",
179
+ );
180
+ if (parsed.firstPeriodCredit)
181
+ contract.couponId = "dw_" + digest([store.scope, parsed.reference, "credit"]);
182
+ await customer(context, store, userId, subject);
183
+ await store.savePurchase(
184
+ userId,
185
+ contract,
186
+ digest(parsed),
187
+ Math.floor(context.now() / 1000) + 3600,
188
+ );
189
+ return resumePurchase(context, store, (await store.purchase(parsed.reference))!);
190
+ });
191
+ }
192
+
193
+ export async function resumePurchase(
194
+ context: PurchaseContext,
195
+ store: SubscriptionRepository,
196
+ purchase: SubscriptionPurchase,
197
+ ): Promise<{ reference: string; url: string | null; status: string }> {
198
+ const customerId = await store.customer(purchase.userId);
199
+ if (!customerId)
200
+ throw new SubscriptionError("CUSTOMER_NOT_FOUND", "No billing account exists", 404);
201
+ const { contract } = purchase;
202
+ let session: Stripe.Checkout.Session | undefined;
203
+ if (purchase.sessionId)
204
+ session = await context.stripe.checkout.sessions.retrieve(purchase.sessionId);
205
+ else {
206
+ const sessions = await context.stripe.checkout.sessions.list({
207
+ customer: customerId,
208
+ limit: 100,
209
+ });
210
+ if (sessions.has_more)
211
+ throw new SubscriptionError(
212
+ "REVIEW_REQUIRED",
213
+ "Checkout recovery exceeds its safe limit",
214
+ 409,
215
+ );
216
+ const matches = sessions.data.filter(
217
+ (value) =>
218
+ value.metadata?.dineway_app === context.scope.namespace &&
219
+ value.metadata.purchase_reference === purchase.reference,
220
+ );
221
+ if (matches.length > 1)
222
+ throw new SubscriptionError("REVIEW_REQUIRED", "Purchase has multiple checkouts", 409);
223
+ session = matches[0];
224
+ }
225
+ if (!session) {
226
+ if (
227
+ purchase.closed ||
228
+ purchase.cancelMode ||
229
+ purchase.expiresAt < Math.floor(context.now() / 1000) + 1800
230
+ )
231
+ throw new SubscriptionError(
232
+ "CHECKOUT_REVIEW_REQUIRED",
233
+ "The checkout outcome must be confirmed before retrying",
234
+ 409,
235
+ );
236
+ const metadata = purchaseMetadata(context, purchase.userId, purchase.reference);
237
+ if (contract.couponId) {
238
+ const coupon = await context.stripe.coupons.create(
239
+ {
240
+ id: contract.couponId,
241
+ amount_off: contract.firstPeriodCredit,
242
+ currency: contract.currency,
243
+ duration: "once",
244
+ max_redemptions: 1,
245
+ metadata,
246
+ },
247
+ { idempotencyKey: idempotency(context, "coupon", purchase.reference) },
248
+ );
249
+ if (
250
+ coupon.id !== contract.couponId ||
251
+ coupon.amount_off !== contract.firstPeriodCredit ||
252
+ coupon.currency !== contract.currency ||
253
+ coupon.livemode !== (context.scope.mode === "live") ||
254
+ coupon.duration !== "once" ||
255
+ coupon.max_redemptions !== 1 ||
256
+ coupon.percent_off !== null ||
257
+ Object.entries(metadata).some(([key, value]) => coupon.metadata?.[key] !== value)
258
+ )
259
+ throw new SubscriptionError(
260
+ "INVALID_CREDIT",
261
+ "Credit coupon does not match the purchase",
262
+ 409,
263
+ );
264
+ }
265
+ const returnUrl = new URL(contract.returnPath, context.origin);
266
+ if (returnUrl.origin !== context.origin)
267
+ throw new SubscriptionError("INVALID_RETURN_URL", "Return to this site");
268
+ const successUrl = new URL(returnUrl);
269
+ successUrl.searchParams.set("checkout", "returned");
270
+ const line: Stripe.Checkout.SessionCreateParams.LineItem = contract.priceId
271
+ ? { price: contract.priceId, quantity: 1 }
272
+ : {
273
+ quantity: 1,
274
+ price_data: {
275
+ currency: contract.currency,
276
+ unit_amount: contract.amount,
277
+ recurring: { interval: contract.interval, interval_count: contract.intervalCount },
278
+ product_data: {
279
+ name: contract.pricing.kind === "dynamic" ? contract.pricing.name : "Subscription",
280
+ metadata,
281
+ },
282
+ },
283
+ };
284
+ await store.updatePurchase(purchase.reference, { last_attempt_at: context.now() });
285
+ session = await context.stripe.checkout.sessions.create(
286
+ {
287
+ mode: "subscription",
288
+ customer: customerId,
289
+ line_items: [line],
290
+ payment_method_types: ["card"],
291
+ payment_method_collection: "always",
292
+ client_reference_id: purchase.reference,
293
+ metadata,
294
+ subscription_data: { metadata },
295
+ success_url: successUrl.href,
296
+ cancel_url: returnUrl.href,
297
+ locale: purchaseInput(contract).locale,
298
+ expires_at: purchase.expiresAt,
299
+ allow_promotion_codes: false,
300
+ ...(contract.couponId ? { discounts: [{ coupon: contract.couponId }] } : {}),
301
+ },
302
+ { idempotencyKey: idempotency(context, "checkout", purchase.reference) },
303
+ );
304
+ }
305
+ if (
306
+ relationId(session.customer) !== customerId ||
307
+ session.mode !== "subscription" ||
308
+ session.livemode !== (context.scope.mode === "live") ||
309
+ session.metadata?.dineway_app !== context.scope.namespace ||
310
+ session.metadata.user_id !== purchase.userId ||
311
+ session.metadata.purchase_reference !== purchase.reference ||
312
+ session.client_reference_id !== purchase.reference
313
+ )
314
+ throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
315
+ await store.updatePurchase(purchase.reference, {
316
+ session_id: session.id,
317
+ subscription_id: relationId(session.subscription),
318
+ closed: Number(session.status === "expired"),
319
+ last_error: null,
320
+ last_attempt_at: context.now(),
321
+ });
322
+ if (session.url && new URL(session.url).origin !== "https://checkout.stripe.com")
323
+ throw new SubscriptionError("INVALID_DESTINATION", "Invalid checkout destination", 502);
324
+ return {
325
+ reference: purchase.reference,
326
+ url: session.status === "open" ? session.url : null,
327
+ status: session.status ?? "unknown",
328
+ };
329
+ }