@dineway-ai/plugin-payment 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/admin/subscriptions.tsx +150 -16
- 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 +39 -7
- 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 +8 -0
- package/src/subscriptions/schemas.ts +45 -0
- package/src/subscriptions/service.ts +479 -200
- package/src/subscriptions/types.ts +90 -0
- package/src/types.ts +1 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
|
|
4
|
+
import { collectBounded, inspectCharge } from "./funding.js";
|
|
5
|
+
import { digest, relationId, type PurchaseContext } from "./purchases.js";
|
|
6
|
+
import { SubscriptionError } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface SubscriptionRefundInput {
|
|
9
|
+
reference: string;
|
|
10
|
+
chargeId: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
currency: string;
|
|
13
|
+
metadata?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
const schema = z
|
|
16
|
+
.object({
|
|
17
|
+
reference: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(160)
|
|
21
|
+
.regex(/^[A-Za-z0-9_.:-]+$/),
|
|
22
|
+
chargeId: z.string().regex(/^ch_[A-Za-z0-9]+$/),
|
|
23
|
+
amount: z.number().int().min(1).max(99999999),
|
|
24
|
+
currency: z.string().regex(/^[a-z]{3}$/),
|
|
25
|
+
metadata: z
|
|
26
|
+
.record(
|
|
27
|
+
z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(40)
|
|
31
|
+
.regex(/^[a-zA-Z0-9_.-]+$/),
|
|
32
|
+
z.string().max(500),
|
|
33
|
+
)
|
|
34
|
+
.refine((value) => Object.keys(value).length <= 40)
|
|
35
|
+
.default({}),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
|
|
39
|
+
export async function refundCharge(
|
|
40
|
+
context: PurchaseContext,
|
|
41
|
+
userId: string,
|
|
42
|
+
input: SubscriptionRefundInput,
|
|
43
|
+
) {
|
|
44
|
+
const parsed = schema.parse(input);
|
|
45
|
+
const fingerprint = JSON.stringify({ ...parsed, userId });
|
|
46
|
+
const key = "refund:" + parsed.reference;
|
|
47
|
+
return context.repository.withLock("refunds", async (store) => {
|
|
48
|
+
const previous = await store.attempt(key);
|
|
49
|
+
if (previous && previous.fingerprint !== fingerprint)
|
|
50
|
+
throw new SubscriptionError(
|
|
51
|
+
"REFUND_CONFLICT",
|
|
52
|
+
"A refund reference cannot change its amount or owner",
|
|
53
|
+
409,
|
|
54
|
+
);
|
|
55
|
+
const charge = await inspectCharge(context.stripe, context.scope, parsed.chargeId);
|
|
56
|
+
if (!charge.customerId || (await store.customerOwner(charge.customerId)) !== userId)
|
|
57
|
+
throw new SubscriptionError("NOT_FOUND", "Charge not found", 404);
|
|
58
|
+
const metadata = {
|
|
59
|
+
...parsed.metadata,
|
|
60
|
+
dineway_app: context.scope.namespace,
|
|
61
|
+
user_id: userId,
|
|
62
|
+
refund_reference: parsed.reference,
|
|
63
|
+
};
|
|
64
|
+
const known = await collectBounded(
|
|
65
|
+
context.stripe.refunds.list({ charge: parsed.chargeId, limit: 100 }),
|
|
66
|
+
);
|
|
67
|
+
const matches = known.filter(
|
|
68
|
+
(refund) =>
|
|
69
|
+
refund.metadata?.dineway_app === context.scope.namespace &&
|
|
70
|
+
refund.metadata.refund_reference === parsed.reference,
|
|
71
|
+
);
|
|
72
|
+
if (matches.length > 1)
|
|
73
|
+
throw new SubscriptionError(
|
|
74
|
+
"REFUND_REVIEW_REQUIRED",
|
|
75
|
+
"Multiple refunds match the reference",
|
|
76
|
+
409,
|
|
77
|
+
);
|
|
78
|
+
let refund: Stripe.Refund | undefined = previous?.session_id
|
|
79
|
+
? await context.stripe.refunds.retrieve(previous.session_id)
|
|
80
|
+
: matches[0];
|
|
81
|
+
const attempt = previous ?? {
|
|
82
|
+
fingerprint,
|
|
83
|
+
request_id: parsed.reference,
|
|
84
|
+
session_id: null,
|
|
85
|
+
created_at: context.now(),
|
|
86
|
+
};
|
|
87
|
+
if (!refund) {
|
|
88
|
+
if (context.now() - attempt.created_at > 23 * 3600000)
|
|
89
|
+
throw new SubscriptionError(
|
|
90
|
+
"REFUND_REVIEW_REQUIRED",
|
|
91
|
+
"The refund outcome must be confirmed before retrying",
|
|
92
|
+
409,
|
|
93
|
+
);
|
|
94
|
+
const reserved = known
|
|
95
|
+
.filter((value) => !["failed", "canceled"].includes(value.status ?? "unknown"))
|
|
96
|
+
.reduce((total, value) => total + value.amount, 0);
|
|
97
|
+
if (
|
|
98
|
+
!charge.settled ||
|
|
99
|
+
charge.disputes.some((value) => !["won", "warning_closed"].includes(value.status)) ||
|
|
100
|
+
charge.currency !== parsed.currency ||
|
|
101
|
+
parsed.amount > charge.amount - Math.max(reserved, charge.refunded)
|
|
102
|
+
)
|
|
103
|
+
throw new SubscriptionError(
|
|
104
|
+
"REFUND_UNAVAILABLE",
|
|
105
|
+
"The charge has insufficient refundable funds",
|
|
106
|
+
409,
|
|
107
|
+
);
|
|
108
|
+
await store.saveAttempt(key, attempt);
|
|
109
|
+
refund = await context.stripe.refunds.create(
|
|
110
|
+
{ charge: parsed.chargeId, amount: parsed.amount, metadata },
|
|
111
|
+
{ idempotencyKey: "dineway-refund:" + digest([store.scope, parsed.reference]) },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
relationId(refund.charge) !== parsed.chargeId ||
|
|
116
|
+
refund.amount !== parsed.amount ||
|
|
117
|
+
refund.currency !== parsed.currency ||
|
|
118
|
+
Object.entries(metadata).some(([name, value]) => refund.metadata?.[name] !== value)
|
|
119
|
+
)
|
|
120
|
+
throw new SubscriptionError(
|
|
121
|
+
"REFUND_CONFLICT",
|
|
122
|
+
"Refund evidence does not match the request",
|
|
123
|
+
409,
|
|
124
|
+
);
|
|
125
|
+
await store.saveAttempt(key, { ...attempt, session_id: refund.id });
|
|
126
|
+
return {
|
|
127
|
+
id: refund.id,
|
|
128
|
+
amount: refund.amount,
|
|
129
|
+
currency: refund.currency,
|
|
130
|
+
status: refund.status ?? "unknown",
|
|
131
|
+
metadata: refund.metadata ?? {},
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|