@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.
- package/package.json +3 -2
- package/src/admin/subscription-price-picker.tsx +264 -0
- package/src/admin/subscriptions.tsx +184 -59
- package/src/configuration/repository.ts +7 -3
- package/src/database/index.ts +12 -0
- package/src/database/migrations/001_stripe_configuration.ts +100 -0
- package/src/database/migrations/002_subscriptions.ts +168 -0
- package/src/database/migrations/003_purchases.ts +193 -0
- package/src/database/migrations/adoption.ts +27 -0
- package/src/database/types.ts +137 -0
- package/src/index.ts +2 -0
- package/src/subscriptions/funding.ts +256 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/projection.ts +44 -8
- package/src/subscriptions/purchases.ts +329 -0
- package/src/subscriptions/refunds.ts +134 -0
- package/src/subscriptions/repository.ts +352 -13
- package/src/subscriptions/routes.ts +20 -0
- package/src/subscriptions/schemas.ts +70 -0
- package/src/subscriptions/service.ts +540 -202
- package/src/subscriptions/types.ts +105 -0
- package/src/types.ts +1 -0
|
@@ -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(
|
|
@@ -16,10 +17,14 @@ export function recurringPrice(
|
|
|
16
17
|
requireActive = true,
|
|
17
18
|
): SubscriptionPrice {
|
|
18
19
|
const interval = price.recurring?.interval;
|
|
20
|
+
const product = typeof price.product === "string" ? null : price.product;
|
|
19
21
|
const productId = typeof price.product === "string" ? price.product : price.product.id;
|
|
20
22
|
if (
|
|
21
23
|
price.livemode !== (scope.mode === "live") ||
|
|
22
|
-
(requireActive &&
|
|
24
|
+
(requireActive &&
|
|
25
|
+
(!price.active ||
|
|
26
|
+
(product &&
|
|
27
|
+
(product.deleted || !product.active || product.livemode !== (scope.mode === "live"))))) ||
|
|
23
28
|
price.type !== "recurring" ||
|
|
24
29
|
!price.recurring ||
|
|
25
30
|
!["month", "year"].includes(interval ?? "") ||
|
|
@@ -72,30 +77,53 @@ export function subscriptionProjection(
|
|
|
72
77
|
userId: string,
|
|
73
78
|
customerId: string,
|
|
74
79
|
now: number,
|
|
80
|
+
purchase?: SubscriptionPurchase | null,
|
|
75
81
|
): MemberSubscription {
|
|
76
82
|
ownedSubscription(subscription, scope, userId, customerId);
|
|
77
83
|
const item = subscription.items.data[0];
|
|
78
84
|
const plan = plans.find((value) => value.prices.some((price) => price.id === item?.price.id));
|
|
79
85
|
if (
|
|
80
|
-
!plan ||
|
|
86
|
+
(!plan && purchase?.contract.pricing.kind !== "dynamic") ||
|
|
81
87
|
subscription.items.has_more ||
|
|
82
88
|
subscription.items.data.length !== 1 ||
|
|
83
89
|
item?.quantity !== 1
|
|
84
90
|
) {
|
|
85
91
|
throw new SubscriptionError("UNRECOGNIZED_SUBSCRIPTION", "Subscription requires review", 409);
|
|
86
92
|
}
|
|
87
|
-
|
|
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);
|
|
88
115
|
return {
|
|
89
116
|
id: subscription.id,
|
|
90
117
|
userId,
|
|
91
118
|
customerId,
|
|
92
|
-
planCode: plan
|
|
119
|
+
planCode: purchase?.contract.pricing.kind === "dynamic" ? "" : plan!.code,
|
|
93
120
|
priceId: item.price.id,
|
|
94
|
-
status: subscription.status,
|
|
121
|
+
status: subscription.pause_collection ? "paused" : subscription.status,
|
|
95
122
|
periodEnd: item.current_period_end,
|
|
96
123
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
97
124
|
verifiedAt: now,
|
|
98
125
|
lastError: null,
|
|
126
|
+
purchaseReference: purchase?.reference ?? null,
|
|
99
127
|
};
|
|
100
128
|
}
|
|
101
129
|
|
|
@@ -130,7 +158,7 @@ export function invoicePeriods(
|
|
|
130
158
|
!details ||
|
|
131
159
|
details.subscription !== subscription.id ||
|
|
132
160
|
details.proration ||
|
|
133
|
-
!plan ||
|
|
161
|
+
(!plan && !subscription.purchaseReference) ||
|
|
134
162
|
line.quantity !== 1 ||
|
|
135
163
|
line.amount <= 0
|
|
136
164
|
)
|
|
@@ -146,7 +174,7 @@ export function invoicePeriods(
|
|
|
146
174
|
id: invoice.id + ":" + line.id,
|
|
147
175
|
invoiceId: invoice.id,
|
|
148
176
|
subscriptionId: subscription.id,
|
|
149
|
-
planCode: plan
|
|
177
|
+
planCode: plan?.code ?? "",
|
|
150
178
|
startsAt: line.period.start,
|
|
151
179
|
endsAt: line.period.end,
|
|
152
180
|
},
|
|
@@ -162,7 +190,15 @@ export function memberProjection(
|
|
|
162
190
|
now: number,
|
|
163
191
|
): MemberBilling {
|
|
164
192
|
const valid = periods.filter(
|
|
165
|
-
(period) =>
|
|
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
|
+
),
|
|
166
202
|
);
|
|
167
203
|
const entitlements: Record<string, boolean | number> = {};
|
|
168
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
|
+
}
|