@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
|
@@ -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 {
|
|
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
|
|
153
|
-
return {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
"
|
|
220
|
-
"
|
|
308
|
+
"CHECKOUT_REVIEW_REQUIRED",
|
|
309
|
+
"Confirm the checkout before canceling",
|
|
221
310
|
409,
|
|
222
311
|
);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
"
|
|
262
|
-
"Checkout changed
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
"
|
|
292
|
-
"
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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)
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 (
|
|
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(
|
|
437
|
-
await this.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|