@dineway-ai/plugin-payment 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/admin/subscriptions.tsx +160 -58
- package/src/admin.tsx +17 -3
- package/src/configuration/repository.ts +7 -3
- package/src/configuration/secrets.ts +1 -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 +25 -0
- package/src/stripe/webhook-verifier.ts +1 -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 +11 -40
- package/src/subscriptions/runtime.ts +6 -8
- package/src/subscriptions/schemas.ts +45 -0
- package/src/subscriptions/service.ts +486 -203
- package/src/subscriptions/types.ts +90 -0
- package/src/types.ts +2 -0
- package/src/webhooks/index.ts +2 -0
- package/src/webhooks/routes.ts +109 -0
- package/src/webhooks/types.ts +21 -0
|
@@ -5,12 +5,21 @@ import type { Kysely } from "kysely";
|
|
|
5
5
|
import Stripe from "stripe";
|
|
6
6
|
|
|
7
7
|
import { StripeNodeWebhookVerifier } from "../stripe/webhook-verifier.js";
|
|
8
|
+
import type { StripeWebhookEvent } from "../types.js";
|
|
9
|
+
import { collectBounded, invoiceFunding, inspectCharge } from "./funding.js";
|
|
8
10
|
import {
|
|
9
11
|
recurringPrice,
|
|
10
12
|
invoicePeriods,
|
|
11
13
|
memberProjection,
|
|
12
14
|
subscriptionProjection,
|
|
13
15
|
} from "./projection.js";
|
|
16
|
+
import {
|
|
17
|
+
createPurchase,
|
|
18
|
+
resumePurchase,
|
|
19
|
+
digest as purchaseDigest,
|
|
20
|
+
relationId,
|
|
21
|
+
} from "./purchases.js";
|
|
22
|
+
import { refundCharge, type SubscriptionRefundInput } from "./refunds.js";
|
|
14
23
|
import { SubscriptionRepository, subscriptionScopeKey } from "./repository.js";
|
|
15
24
|
import {
|
|
16
25
|
checkoutSchema,
|
|
@@ -18,6 +27,7 @@ import {
|
|
|
18
27
|
saveSubscriptionPlanSchema,
|
|
19
28
|
stripeProductListSchema,
|
|
20
29
|
stripePriceListSchema,
|
|
30
|
+
billingSubjectSchema,
|
|
21
31
|
} from "./schemas.js";
|
|
22
32
|
import {
|
|
23
33
|
SubscriptionError,
|
|
@@ -30,6 +40,8 @@ import {
|
|
|
30
40
|
type SubscriptionCatalogPage,
|
|
31
41
|
type SubscriptionProduct,
|
|
32
42
|
type StripeSubscriptionPrice,
|
|
43
|
+
type BillingSubject,
|
|
44
|
+
type SubscriptionPurchaseInput,
|
|
33
45
|
} from "./types.js";
|
|
34
46
|
|
|
35
47
|
export interface SubscriptionServiceOptions {
|
|
@@ -42,7 +54,7 @@ export interface SubscriptionServiceOptions {
|
|
|
42
54
|
stripe?: Stripe;
|
|
43
55
|
}
|
|
44
56
|
const terminal = new Set(["canceled", "incomplete_expired"]);
|
|
45
|
-
const
|
|
57
|
+
export const SUBSCRIPTION_WEBHOOK_EVENTS = [
|
|
46
58
|
"checkout.session.completed",
|
|
47
59
|
"checkout.session.async_payment_succeeded",
|
|
48
60
|
"checkout.session.async_payment_failed",
|
|
@@ -56,7 +68,17 @@ const events = new Set([
|
|
|
56
68
|
"invoice.payment_action_required",
|
|
57
69
|
"invoice.voided",
|
|
58
70
|
"invoice.marked_uncollectible",
|
|
59
|
-
|
|
71
|
+
"charge.refunded",
|
|
72
|
+
"charge.failed",
|
|
73
|
+
"charge.dispute.created",
|
|
74
|
+
"charge.dispute.updated",
|
|
75
|
+
"charge.dispute.closed",
|
|
76
|
+
"refund.created",
|
|
77
|
+
"refund.updated",
|
|
78
|
+
"refund.failed",
|
|
79
|
+
"payment_intent.payment_failed",
|
|
80
|
+
] as const;
|
|
81
|
+
const events = new Set<string>(SUBSCRIPTION_WEBHOOK_EVENTS);
|
|
60
82
|
const localeSchema = checkoutSchema.shape.locale;
|
|
61
83
|
const fingerprint = (values: unknown[]) =>
|
|
62
84
|
createHash("sha256").update(JSON.stringify(values)).digest("hex");
|
|
@@ -201,208 +223,221 @@ export class PaymentSubscriptionService {
|
|
|
201
223
|
private async user(userId: string) {
|
|
202
224
|
const user = await this.options.db
|
|
203
225
|
.selectFrom("users")
|
|
204
|
-
.select(["id", "email", "disabled"])
|
|
226
|
+
.select(["id", "email", "disabled", "email_verified"])
|
|
205
227
|
.where("id", "=", userId)
|
|
206
228
|
.executeTakeFirst();
|
|
207
|
-
if (!user || user.disabled)
|
|
229
|
+
if (!user || user.disabled || !user.email_verified)
|
|
208
230
|
throw new SubscriptionError("UNAUTHORIZED", "An active member account is required", 401);
|
|
209
231
|
return user;
|
|
210
232
|
}
|
|
211
|
-
private
|
|
212
|
-
return {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
private purchaseContext() {
|
|
234
|
+
return {
|
|
235
|
+
stripe: this.stripe,
|
|
236
|
+
repository: this.repository,
|
|
237
|
+
scope: this.options.scope,
|
|
238
|
+
now: this.now,
|
|
239
|
+
origin: this.origin,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
private async resolveSubject(subject: BillingSubject | string) {
|
|
243
|
+
if (typeof subject === "string") {
|
|
244
|
+
const user = await this.user(subject);
|
|
245
|
+
return {
|
|
246
|
+
key: user.id,
|
|
247
|
+
subject: { realm: "dineway", id: user.id, email: user.email, emailVerified: true },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const parsed = billingSubjectSchema.parse(subject);
|
|
251
|
+
return { key: "external:" + purchaseDigest([parsed.realm, parsed.id]), subject: parsed };
|
|
252
|
+
}
|
|
253
|
+
async createPurchase(subject: BillingSubject | string, input: SubscriptionPurchaseInput) {
|
|
254
|
+
const resolved = await this.resolveSubject(subject);
|
|
255
|
+
return createPurchase(this.purchaseContext(), resolved.key, resolved.subject, input);
|
|
256
|
+
}
|
|
257
|
+
async getPurchase(subject: BillingSubject | string, reference: string) {
|
|
258
|
+
const resolved = await this.resolveSubject(subject);
|
|
259
|
+
const purchase = await this.repository.purchase(reference);
|
|
260
|
+
if (!purchase || purchase.userId !== resolved.key)
|
|
261
|
+
throw new SubscriptionError("NOT_FOUND", "Purchase not found", 404);
|
|
262
|
+
return purchase;
|
|
263
|
+
}
|
|
264
|
+
async recoverCheckout(subject: BillingSubject | string, reference: string) {
|
|
265
|
+
const purchase = await this.getPurchase(subject, reference);
|
|
266
|
+
return this.repository.withLock("member:" + purchase.userId, (store) =>
|
|
267
|
+
resumePurchase(this.purchaseContext(), store, purchase),
|
|
227
268
|
);
|
|
228
269
|
}
|
|
229
|
-
async
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
livePrice.interval !== price.interval
|
|
251
|
-
) {
|
|
252
|
-
throw new SubscriptionError("PRICE_CHANGED", "Refresh the plan price before checkout", 409);
|
|
253
|
-
}
|
|
254
|
-
let customer = await repository.customer(userId);
|
|
255
|
-
if (!customer) {
|
|
256
|
-
const created = await this.stripe.customers.create(
|
|
257
|
-
{ email: user.email, metadata: this.metadata(userId) },
|
|
258
|
-
{ idempotencyKey: providerIdempotency(this.options.scope, "customer", userId) },
|
|
259
|
-
);
|
|
260
|
-
customer = created.id;
|
|
261
|
-
await repository.saveCustomer(userId, customer);
|
|
262
|
-
}
|
|
263
|
-
const subscriptions = await this.stripe.subscriptions.list({
|
|
264
|
-
customer,
|
|
265
|
-
status: "all",
|
|
266
|
-
limit: 100,
|
|
270
|
+
async getSubscription(subject: BillingSubject | string, id: string) {
|
|
271
|
+
const resolved = await this.resolveSubject(subject);
|
|
272
|
+
const snapshot = await this.repository.snapshot(id);
|
|
273
|
+
if (snapshot.subscription.userId !== resolved.key)
|
|
274
|
+
throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
|
|
275
|
+
return snapshot;
|
|
276
|
+
}
|
|
277
|
+
/** Only trusted server operations may request immediate cancellation. */
|
|
278
|
+
async cancelPurchase(
|
|
279
|
+
subject: BillingSubject | string,
|
|
280
|
+
reference: string,
|
|
281
|
+
mode: "period_end" | "immediate" = "period_end",
|
|
282
|
+
) {
|
|
283
|
+
const purchase = await this.getPurchase(subject, reference);
|
|
284
|
+
if (mode !== "period_end" && mode !== "immediate")
|
|
285
|
+
throw new SubscriptionError("INVALID_CANCELLATION", "Invalid cancellation mode");
|
|
286
|
+
await this.repository.withLock("member:" + purchase.userId, async (store) => {
|
|
287
|
+
const current = (await store.purchase(reference))!;
|
|
288
|
+
await store.updatePurchase(reference, {
|
|
289
|
+
cancel_mode: current.cancelMode === "immediate" ? "immediate" : mode,
|
|
290
|
+
last_attempt_at: this.now(),
|
|
267
291
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
292
|
+
await this.applyCancellation(store, (await store.purchase(reference))!);
|
|
293
|
+
});
|
|
294
|
+
const updated = (await this.repository.purchase(reference))!;
|
|
295
|
+
if (updated.subscriptionId) await this.refreshSubscription(updated.subscriptionId);
|
|
296
|
+
return this.repository.purchase(reference);
|
|
297
|
+
}
|
|
298
|
+
private async applyCancellation(
|
|
299
|
+
store: SubscriptionRepository,
|
|
300
|
+
purchase: NonNullable<Awaited<ReturnType<SubscriptionRepository["purchase"]>>>,
|
|
301
|
+
) {
|
|
302
|
+
if (!purchase.cancelMode) return;
|
|
303
|
+
if (!purchase.subscriptionId) {
|
|
304
|
+
await resumePurchase(this.purchaseContext(), store, purchase);
|
|
305
|
+
purchase = (await store.purchase(purchase.reference))!;
|
|
306
|
+
}
|
|
307
|
+
if (!purchase.subscriptionId) {
|
|
308
|
+
if (!purchase.sessionId)
|
|
277
309
|
throw new SubscriptionError(
|
|
278
|
-
"
|
|
279
|
-
"
|
|
310
|
+
"CHECKOUT_REVIEW_REQUIRED",
|
|
311
|
+
"Confirm the checkout before canceling",
|
|
280
312
|
409,
|
|
281
313
|
);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
status: "open",
|
|
286
|
-
limit: 100,
|
|
287
|
-
});
|
|
288
|
-
const owned = open.data.filter(
|
|
289
|
-
(session) => session.metadata?.dineway_app === this.options.scope.namespace,
|
|
290
|
-
);
|
|
291
|
-
if (open.has_more || owned.length > 1)
|
|
292
|
-
throw new SubscriptionError("REVIEW_REQUIRED", "Checkout review is required", 409);
|
|
293
|
-
const previous = owned[0];
|
|
294
|
-
const locale = parsed.locale ?? "auto";
|
|
295
|
-
const remembered = await repository.attempt(userId);
|
|
296
|
-
const digest = fingerprint([customer, price.id, returnUrl, locale]);
|
|
297
|
-
if (previous) {
|
|
298
|
-
if (!this.sessionOwned(previous, userId, customer))
|
|
299
|
-
throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
|
|
300
|
-
const lines = await this.stripe.checkout.sessions.listLineItems(previous.id, { limit: 2 });
|
|
301
|
-
if (
|
|
302
|
-
previous.locale === locale &&
|
|
303
|
-
previous.success_url === successUrl.href &&
|
|
304
|
-
previous.cancel_url === returnUrl &&
|
|
305
|
-
previous.expires_at * 1000 > this.now() &&
|
|
306
|
-
previous.allow_promotion_codes === false &&
|
|
307
|
-
!lines.has_more &&
|
|
308
|
-
lines.data.length === 1 &&
|
|
309
|
-
lines.data[0]?.quantity === 1 &&
|
|
310
|
-
lines.data[0].price?.id === price.id
|
|
311
|
-
) {
|
|
312
|
-
if (remembered && !remembered.session_id && remembered.fingerprint === digest) {
|
|
313
|
-
await repository.saveAttempt(userId, { ...remembered, session_id: previous.id });
|
|
314
|
-
}
|
|
315
|
-
return { url: this.checkoutUrl(previous.url) };
|
|
316
|
-
}
|
|
317
|
-
const expired = await this.stripe.checkout.sessions.expire(previous.id);
|
|
314
|
+
const session = await this.stripe.checkout.sessions.retrieve(purchase.sessionId);
|
|
315
|
+
if (session.status === "open") {
|
|
316
|
+
const expired = await this.stripe.checkout.sessions.expire(session.id);
|
|
318
317
|
if (expired.status !== "expired")
|
|
319
318
|
throw new SubscriptionError(
|
|
320
|
-
"
|
|
321
|
-
"Checkout changed
|
|
319
|
+
"CHECKOUT_REVIEW_REQUIRED",
|
|
320
|
+
"Checkout changed during cancellation",
|
|
322
321
|
409,
|
|
323
322
|
);
|
|
323
|
+
} else if (session.status !== "expired")
|
|
324
|
+
throw new SubscriptionError(
|
|
325
|
+
"CHECKOUT_REVIEW_REQUIRED",
|
|
326
|
+
"Confirm the checkout before canceling",
|
|
327
|
+
409,
|
|
328
|
+
);
|
|
329
|
+
await store.updatePurchase(purchase.reference, { closed: 1, last_error: null });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const subscription = await this.stripe.subscriptions.retrieve(purchase.subscriptionId);
|
|
333
|
+
subscriptionProjection(
|
|
334
|
+
subscription,
|
|
335
|
+
await store.plans(),
|
|
336
|
+
this.options.scope,
|
|
337
|
+
purchase.userId,
|
|
338
|
+
(await store.customer(purchase.userId))!,
|
|
339
|
+
this.now(),
|
|
340
|
+
purchase,
|
|
341
|
+
);
|
|
342
|
+
if (!terminal.has(subscription.status)) {
|
|
343
|
+
const request = {
|
|
344
|
+
idempotencyKey: providerIdempotency(
|
|
345
|
+
this.options.scope,
|
|
346
|
+
purchase.reference,
|
|
347
|
+
"cancel",
|
|
348
|
+
purchase.cancelMode,
|
|
349
|
+
),
|
|
350
|
+
};
|
|
351
|
+
if (purchase.cancelMode === "immediate")
|
|
352
|
+
await this.stripe.subscriptions.cancel(
|
|
353
|
+
subscription.id,
|
|
354
|
+
{ invoice_now: false, prorate: false },
|
|
355
|
+
request,
|
|
356
|
+
);
|
|
357
|
+
else if (!subscription.cancel_at_period_end)
|
|
358
|
+
await this.stripe.subscriptions.update(
|
|
359
|
+
subscription.id,
|
|
360
|
+
{ cancel_at_period_end: true },
|
|
361
|
+
request,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
await store.updatePurchase(purchase.reference, {
|
|
365
|
+
last_error: null,
|
|
366
|
+
last_attempt_at: this.now(),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
async createCheckout(userId: string, input: SubscriptionCheckoutInput): Promise<{ url: string }> {
|
|
370
|
+
const parsed = checkoutSchema.parse(input);
|
|
371
|
+
await this.user(userId);
|
|
372
|
+
this.returnUrl(parsed.returnPath);
|
|
373
|
+
return this.repository.withLock("catalogue:" + userId, async () => {
|
|
374
|
+
const plan = (await this.repository.plans()).find(
|
|
375
|
+
(value) => value.code === parsed.planCode && value.enabled,
|
|
376
|
+
);
|
|
377
|
+
if (!plan?.prices.some((price) => price.enabled && price.interval === parsed.interval))
|
|
378
|
+
throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
|
|
379
|
+
const customer = await this.repository.customer(userId);
|
|
380
|
+
if (customer) {
|
|
381
|
+
const subscriptions = await this.stripe.subscriptions.list({
|
|
382
|
+
customer,
|
|
383
|
+
status: "all",
|
|
384
|
+
limit: 100,
|
|
385
|
+
});
|
|
386
|
+
if (subscriptions.has_more)
|
|
387
|
+
throw new SubscriptionError("REVIEW_REQUIRED", "Subscription review is required", 409);
|
|
388
|
+
for (const subscription of subscriptions.data) {
|
|
389
|
+
if (
|
|
390
|
+
subscription.metadata.dineway_app !== this.options.scope.namespace ||
|
|
391
|
+
terminal.has(subscription.status)
|
|
392
|
+
)
|
|
393
|
+
continue;
|
|
394
|
+
const purchase = subscription.metadata.purchase_reference
|
|
395
|
+
? await this.repository.purchase(subscription.metadata.purchase_reference)
|
|
396
|
+
: null;
|
|
397
|
+
if (!purchase || purchase.contract.groupKey === "membership")
|
|
398
|
+
throw new SubscriptionError(
|
|
399
|
+
"SUBSCRIPTION_EXISTS",
|
|
400
|
+
"Manage the existing subscription in the billing portal",
|
|
401
|
+
409,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
324
404
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
"SUBSCRIPTION_EXISTS",
|
|
338
|
-
"Confirm the previous subscription before starting another",
|
|
339
|
-
409,
|
|
340
|
-
);
|
|
405
|
+
let existing = await this.repository.openPurchase(userId, "membership");
|
|
406
|
+
if (existing) {
|
|
407
|
+
const pricing = existing.contract.pricing;
|
|
408
|
+
const compatible =
|
|
409
|
+
pricing.kind === "catalogue" &&
|
|
410
|
+
pricing.planCode === parsed.planCode &&
|
|
411
|
+
pricing.interval === parsed.interval &&
|
|
412
|
+
existing.contract.returnPath === parsed.returnPath &&
|
|
413
|
+
(existing.contract.locale ?? "auto") === (parsed.locale ?? "auto");
|
|
414
|
+
if (!compatible) {
|
|
415
|
+
await this.cancelPurchase(userId, existing.reference);
|
|
416
|
+
existing = null;
|
|
341
417
|
}
|
|
342
418
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
419
|
+
const result = await this.createPurchase(userId, {
|
|
420
|
+
reference: existing?.reference ?? randomUUID(),
|
|
421
|
+
groupKey: "membership",
|
|
422
|
+
pricing: { kind: "catalogue", planCode: parsed.planCode, interval: parsed.interval },
|
|
423
|
+
returnPath: parsed.returnPath,
|
|
424
|
+
locale: parsed.locale,
|
|
425
|
+
});
|
|
426
|
+
if (!result.url)
|
|
349
427
|
throw new SubscriptionError(
|
|
350
|
-
"
|
|
351
|
-
"
|
|
428
|
+
"SUBSCRIPTION_EXISTS",
|
|
429
|
+
"Confirm your subscription status before starting another",
|
|
352
430
|
409,
|
|
353
431
|
);
|
|
354
|
-
}
|
|
355
|
-
const attempt =
|
|
356
|
-
!previous && remembered && !remembered.session_id
|
|
357
|
-
? remembered
|
|
358
|
-
: {
|
|
359
|
-
fingerprint: digest,
|
|
360
|
-
request_id: randomUUID(),
|
|
361
|
-
session_id: null,
|
|
362
|
-
created_at: this.now(),
|
|
363
|
-
};
|
|
364
|
-
await repository.saveAttempt(userId, attempt);
|
|
365
|
-
const session = await this.stripe.checkout.sessions.create(
|
|
366
|
-
{
|
|
367
|
-
mode: "subscription",
|
|
368
|
-
customer,
|
|
369
|
-
locale,
|
|
370
|
-
line_items: [{ price: price.id, quantity: 1 }],
|
|
371
|
-
client_reference_id: userId,
|
|
372
|
-
metadata: this.metadata(userId),
|
|
373
|
-
subscription_data: { metadata: this.metadata(userId) },
|
|
374
|
-
success_url: successUrl.href,
|
|
375
|
-
cancel_url: returnUrl,
|
|
376
|
-
allow_promotion_codes: false,
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
idempotencyKey: providerIdempotency(
|
|
380
|
-
this.options.scope,
|
|
381
|
-
"checkout",
|
|
382
|
-
userId,
|
|
383
|
-
attempt.request_id,
|
|
384
|
-
),
|
|
385
|
-
},
|
|
386
|
-
);
|
|
387
|
-
if (!this.sessionOwned(session, userId, customer))
|
|
388
|
-
throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
|
|
389
|
-
await repository.saveAttempt(userId, { ...attempt, session_id: session.id });
|
|
390
|
-
if (session.status !== "open")
|
|
391
|
-
throw new SubscriptionError("CHECKOUT_COMPLETED", "Refresh your subscription status", 409);
|
|
392
|
-
return { url: this.checkoutUrl(session.url) };
|
|
432
|
+
return { url: result.url };
|
|
393
433
|
});
|
|
394
434
|
}
|
|
395
|
-
private checkoutUrl(value: string | null): string {
|
|
396
|
-
if (!value || new URL(value).origin !== "https://checkout.stripe.com")
|
|
397
|
-
throw new SubscriptionError("INVALID_DESTINATION", "Invalid checkout destination", 502);
|
|
398
|
-
return value;
|
|
399
|
-
}
|
|
400
435
|
async createPortal(
|
|
401
|
-
|
|
436
|
+
subject: BillingSubject | string,
|
|
402
437
|
input: { returnPath: string; locale?: string },
|
|
403
438
|
): Promise<{ url: string }> {
|
|
404
439
|
const parsed = portalSchema.parse(input);
|
|
405
|
-
await this.
|
|
440
|
+
const { key: userId } = await this.resolveSubject(subject);
|
|
406
441
|
const customer = await this.repository.customer(userId);
|
|
407
442
|
if (!customer)
|
|
408
443
|
throw new SubscriptionError("CUSTOMER_NOT_FOUND", "No billing account exists", 404);
|
|
@@ -432,6 +467,9 @@ export class PaymentSubscriptionService {
|
|
|
432
467
|
!config.active ||
|
|
433
468
|
config.livemode !== (this.options.scope.mode === "live") ||
|
|
434
469
|
config.features.subscription_update.enabled ||
|
|
470
|
+
!config.features.invoice_history.enabled ||
|
|
471
|
+
!config.features.payment_method_update.enabled ||
|
|
472
|
+
config.features.customer_update.enabled ||
|
|
435
473
|
!config.features.subscription_cancel.enabled ||
|
|
436
474
|
config.features.subscription_cancel.mode !== "at_period_end"
|
|
437
475
|
) {
|
|
@@ -460,11 +498,39 @@ export class PaymentSubscriptionService {
|
|
|
460
498
|
await this.repository.withLock("subscription:" + id, async (repository) => {
|
|
461
499
|
if (eventId && (await repository.eventSeen(eventId))) return;
|
|
462
500
|
const live = await this.stripe.subscriptions.retrieve(id);
|
|
463
|
-
if (live.metadata.dineway_app !== this.options.scope.namespace)
|
|
501
|
+
if (live.metadata.dineway_app !== this.options.scope.namespace) {
|
|
502
|
+
if (previous)
|
|
503
|
+
throw new SubscriptionError("OWNER_CONFLICT", "Subscription application changed", 409);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
464
506
|
const customerId = typeof live.customer === "string" ? live.customer : live.customer.id;
|
|
507
|
+
if (
|
|
508
|
+
previous &&
|
|
509
|
+
(previous.customerId !== customerId ||
|
|
510
|
+
(previous.purchaseReference ?? null) !== (live.metadata.purchase_reference ?? null))
|
|
511
|
+
)
|
|
512
|
+
throw new SubscriptionError(
|
|
513
|
+
"OWNER_CONFLICT",
|
|
514
|
+
"Subscription purchase identity changed",
|
|
515
|
+
409,
|
|
516
|
+
);
|
|
465
517
|
const userId = await repository.customerOwner(customerId);
|
|
466
|
-
if (!userId)
|
|
518
|
+
if (!userId)
|
|
519
|
+
throw new SubscriptionError(
|
|
520
|
+
"CUSTOMER_NOT_FOUND",
|
|
521
|
+
"The billing account has not been synchronized",
|
|
522
|
+
503,
|
|
523
|
+
);
|
|
467
524
|
const plans = await repository.plans();
|
|
525
|
+
const purchase = live.metadata.purchase_reference
|
|
526
|
+
? await repository.purchase(live.metadata.purchase_reference)
|
|
527
|
+
: null;
|
|
528
|
+
if (live.metadata.purchase_reference && !purchase)
|
|
529
|
+
throw new SubscriptionError(
|
|
530
|
+
"PURCHASE_NOT_FOUND",
|
|
531
|
+
"The purchase has not been synchronized",
|
|
532
|
+
503,
|
|
533
|
+
);
|
|
468
534
|
const state = subscriptionProjection(
|
|
469
535
|
live,
|
|
470
536
|
plans,
|
|
@@ -472,28 +538,103 @@ export class PaymentSubscriptionService {
|
|
|
472
538
|
userId,
|
|
473
539
|
customerId,
|
|
474
540
|
this.now(),
|
|
541
|
+
purchase,
|
|
542
|
+
);
|
|
543
|
+
const invoices = await collectBounded(
|
|
544
|
+
this.stripe.invoices.list({
|
|
545
|
+
customer: customerId,
|
|
546
|
+
subscription: id,
|
|
547
|
+
status: "paid",
|
|
548
|
+
limit: 100,
|
|
549
|
+
}),
|
|
475
550
|
);
|
|
476
|
-
const invoices = await this.stripe.invoices
|
|
477
|
-
.list({ customer: customerId, subscription: id, status: "paid", limit: 100 })
|
|
478
|
-
.autoPagingToArray({ limit: 1000 });
|
|
479
551
|
const periods = [];
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
552
|
+
const previousPeriods = (await repository.periods(userId)).filter(
|
|
553
|
+
(period) => period.subscriptionId === id,
|
|
554
|
+
);
|
|
555
|
+
const invoiceIds = new Set([
|
|
556
|
+
...invoices.map((invoice) => invoice.id),
|
|
557
|
+
...previousPeriods.map((period) => period.invoiceId),
|
|
558
|
+
]);
|
|
559
|
+
for (const invoiceId of invoiceIds) {
|
|
560
|
+
const invoice = await this.stripe.invoices.retrieve(invoiceId, {
|
|
561
|
+
expand: ["discounts.source.coupon"],
|
|
562
|
+
});
|
|
563
|
+
const evidence = await invoiceFunding(
|
|
564
|
+
this.stripe,
|
|
565
|
+
this.options.scope,
|
|
566
|
+
live,
|
|
567
|
+
invoice,
|
|
568
|
+
purchase?.contract ?? null,
|
|
569
|
+
this.now(),
|
|
570
|
+
);
|
|
571
|
+
const old = previousPeriods.filter((period) => period.invoiceId === invoiceId);
|
|
572
|
+
const projected = old.length
|
|
573
|
+
? old
|
|
574
|
+
: invoice.status === "paid"
|
|
575
|
+
? invoicePeriods(invoice, invoice.lines.data, state, plans, this.options.scope)
|
|
576
|
+
: [];
|
|
577
|
+
const historyChanged = old.some(
|
|
578
|
+
(period) =>
|
|
579
|
+
!invoice.lines.data.some(
|
|
580
|
+
(line) =>
|
|
581
|
+
period.id === invoice.id + ":" + line.id &&
|
|
582
|
+
line.period.start === period.startsAt &&
|
|
583
|
+
line.period.end === period.endsAt,
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
if (historyChanged) evidence.reason = "PAID_PERIOD_CHANGED";
|
|
587
|
+
periods.push(
|
|
588
|
+
...projected.map((period) => ({
|
|
589
|
+
...period,
|
|
590
|
+
eligible: evidence.reason === null,
|
|
591
|
+
evidence,
|
|
592
|
+
})),
|
|
593
|
+
);
|
|
594
|
+
if (evidence.reason) state.lastError = evidence.reason;
|
|
487
595
|
}
|
|
488
596
|
await repository.saveSubscription(state, periods, eventId);
|
|
489
597
|
});
|
|
490
598
|
} catch (error) {
|
|
491
|
-
if (
|
|
599
|
+
if (
|
|
600
|
+
previous &&
|
|
601
|
+
error instanceof SubscriptionError &&
|
|
602
|
+
[
|
|
603
|
+
"OWNER_CONFLICT",
|
|
604
|
+
"UNRECOGNIZED_SUBSCRIPTION",
|
|
605
|
+
"INVALID_PRICE",
|
|
606
|
+
"INVALID_INVOICE",
|
|
607
|
+
"INVALID_EVIDENCE",
|
|
608
|
+
"MODE_MISMATCH",
|
|
609
|
+
"PURCHASE_CONFLICT",
|
|
610
|
+
].includes(error.code)
|
|
611
|
+
) {
|
|
612
|
+
await this.repository.withLock("subscription:" + id, async (store) => {
|
|
613
|
+
const current = await store.subscription(id);
|
|
614
|
+
if (current?.revision !== previous.revision) return;
|
|
615
|
+
const periods = (await store.periods(previous.userId))
|
|
616
|
+
.filter((period) => period.subscriptionId === id)
|
|
617
|
+
.map((period) => ({
|
|
618
|
+
...period,
|
|
619
|
+
eligible: false,
|
|
620
|
+
evidence: period.evidence
|
|
621
|
+
? { ...period.evidence, verifiedAt: this.now(), reason: error.code }
|
|
622
|
+
: undefined,
|
|
623
|
+
}));
|
|
624
|
+
await store.saveSubscription(
|
|
625
|
+
{ ...previous, verifiedAt: this.now(), lastError: error.code },
|
|
626
|
+
periods,
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
} else if (previous) await this.repository.recordSyncFailure(id, previous.verifiedAt);
|
|
492
630
|
throw error;
|
|
493
631
|
}
|
|
494
632
|
}
|
|
495
|
-
async getMemberBilling(
|
|
496
|
-
await this.
|
|
633
|
+
async getMemberBilling(subject: BillingSubject | string, refresh = true): Promise<MemberBilling> {
|
|
634
|
+
const { key } = await this.resolveSubject(subject);
|
|
635
|
+
return this.memberBilling(key, refresh);
|
|
636
|
+
}
|
|
637
|
+
private async memberBilling(userId: string, refresh = true): Promise<MemberBilling> {
|
|
497
638
|
const customer = await this.repository.customer(userId);
|
|
498
639
|
let states = await this.repository.subscriptions(userId);
|
|
499
640
|
if (refresh && customer) {
|
|
@@ -532,16 +673,48 @@ export class PaymentSubscriptionService {
|
|
|
532
673
|
this.now(),
|
|
533
674
|
);
|
|
534
675
|
}
|
|
535
|
-
async getEntitlements(userId: string) {
|
|
676
|
+
async getEntitlements(userId: BillingSubject | string) {
|
|
536
677
|
return (await this.getMemberBilling(userId)).entitlements;
|
|
537
678
|
}
|
|
538
679
|
async listSubscriptions(cursor?: string, limit = 50) {
|
|
539
|
-
|
|
680
|
+
const page = await this.repository.listSubscriptions(cursor, Math.max(1, Math.min(100, limit)));
|
|
681
|
+
return {
|
|
682
|
+
...page,
|
|
683
|
+
items: await Promise.all(
|
|
684
|
+
page.items.map(async (state) => ({
|
|
685
|
+
...state,
|
|
686
|
+
subject: await this.repository.subject(state.userId),
|
|
687
|
+
})),
|
|
688
|
+
),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
async listPurchases(cursor?: string, limit = 50) {
|
|
692
|
+
return this.repository.listPurchases(cursor, Math.max(1, Math.min(100, limit)));
|
|
693
|
+
}
|
|
694
|
+
async pendingChanges(limit = 50) {
|
|
695
|
+
return this.repository.changes(Math.max(1, Math.min(100, limit)));
|
|
696
|
+
}
|
|
697
|
+
async acknowledgeChange(id: string) {
|
|
698
|
+
await this.repository.acknowledgeChange(id);
|
|
699
|
+
}
|
|
700
|
+
async inspectCharge(subject: BillingSubject | string, chargeId: string) {
|
|
701
|
+
const { key } = await this.resolveSubject(subject);
|
|
702
|
+
const evidence = await inspectCharge(this.stripe, this.options.scope, chargeId);
|
|
703
|
+
if (!evidence.customerId || (await this.repository.customerOwner(evidence.customerId)) !== key)
|
|
704
|
+
throw new SubscriptionError("NOT_FOUND", "Charge not found", 404);
|
|
705
|
+
return evidence;
|
|
706
|
+
}
|
|
707
|
+
async refundCharge(subject: BillingSubject | string, input: SubscriptionRefundInput) {
|
|
708
|
+
const { key } = await this.resolveSubject(subject);
|
|
709
|
+
return refundCharge(this.purchaseContext(), key, input);
|
|
540
710
|
}
|
|
541
711
|
async subscriptionDetail(id: string) {
|
|
542
712
|
const state = await this.repository.subscription(id);
|
|
543
713
|
if (!state) throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
|
|
544
|
-
return {
|
|
714
|
+
return {
|
|
715
|
+
...(await this.repository.snapshot(id)),
|
|
716
|
+
member: await this.memberBilling(state.userId, false),
|
|
717
|
+
};
|
|
545
718
|
}
|
|
546
719
|
async receiveWebhook(rawBody: Uint8Array, signature: string | null): Promise<void> {
|
|
547
720
|
const verified = new StripeNodeWebhookVerifier().verify({
|
|
@@ -552,16 +725,126 @@ export class PaymentSubscriptionService {
|
|
|
552
725
|
});
|
|
553
726
|
if (!verified.ok)
|
|
554
727
|
throw new SubscriptionError("INVALID_SIGNATURE", "Invalid Stripe webhook", 400);
|
|
555
|
-
|
|
728
|
+
await this.receiveVerifiedWebhook(verified.event, rawBody);
|
|
729
|
+
}
|
|
730
|
+
async receiveVerifiedWebhook(event: StripeWebhookEvent, rawBody: Uint8Array): Promise<void> {
|
|
556
731
|
if (event.livemode !== (this.options.scope.mode === "live"))
|
|
557
732
|
throw new SubscriptionError("MODE_MISMATCH", "Invalid webhook mode", 400);
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
733
|
+
if (!events.has(event.eventType)) return;
|
|
734
|
+
await this.repository.receiveEvent({
|
|
735
|
+
id: event.eventId,
|
|
736
|
+
digest: event.payloadSha256,
|
|
737
|
+
type: event.eventType,
|
|
738
|
+
objectType: event.objectType,
|
|
739
|
+
objectId: event.objectId,
|
|
740
|
+
subscriptionId: event.subscriptionId ?? null,
|
|
741
|
+
payload: new TextDecoder().decode(rawBody),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
private async eventSubscriptions(event: {
|
|
745
|
+
subscription_id: string | null;
|
|
746
|
+
object_type: string | null;
|
|
747
|
+
object_id: string | null;
|
|
748
|
+
}): Promise<string[]> {
|
|
749
|
+
if (event.subscription_id) return [event.subscription_id];
|
|
750
|
+
const id = event.object_id;
|
|
751
|
+
if (!id) return [];
|
|
752
|
+
let intent: string | null = null;
|
|
753
|
+
if (event.object_type === "payment_intent") intent = id;
|
|
754
|
+
else {
|
|
755
|
+
let charge: string | null = null;
|
|
756
|
+
if (event.object_type === "charge") charge = id;
|
|
757
|
+
if (event.object_type === "refund")
|
|
758
|
+
charge = relationId((await this.stripe.refunds.retrieve(id)).charge);
|
|
759
|
+
if (event.object_type === "dispute")
|
|
760
|
+
charge = relationId((await this.stripe.disputes.retrieve(id)).charge);
|
|
761
|
+
if (charge) {
|
|
762
|
+
const payment = await this.stripe.charges.retrieve(charge);
|
|
763
|
+
if (payment.livemode !== (this.options.scope.mode === "live"))
|
|
764
|
+
throw new SubscriptionError("MODE_MISMATCH", "Invalid charge mode");
|
|
765
|
+
intent = relationId(payment.payment_intent);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (!intent) return [];
|
|
769
|
+
const payments = await collectBounded(
|
|
770
|
+
this.stripe.invoicePayments.list({
|
|
771
|
+
payment: { type: "payment_intent", payment_intent: intent },
|
|
772
|
+
limit: 100,
|
|
773
|
+
}),
|
|
774
|
+
);
|
|
775
|
+
const ids = new Set<string>();
|
|
776
|
+
for (const payment of payments) {
|
|
777
|
+
const invoiceId = relationId(payment.invoice);
|
|
778
|
+
if (!invoiceId) continue;
|
|
779
|
+
const invoice = await this.stripe.invoices.retrieve(invoiceId);
|
|
780
|
+
if (invoice.livemode !== (this.options.scope.mode === "live"))
|
|
781
|
+
throw new SubscriptionError("MODE_MISMATCH", "Invalid invoice mode");
|
|
782
|
+
const subscription = relationId(invoice.parent?.subscription_details?.subscription);
|
|
783
|
+
if (subscription) ids.add(subscription);
|
|
784
|
+
}
|
|
785
|
+
return [...ids];
|
|
786
|
+
}
|
|
787
|
+
async reconcilePending(limit = 25) {
|
|
788
|
+
const bounded = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
789
|
+
const result = { processed: 0, failed: 0 };
|
|
790
|
+
for (const event of await this.repository.pendingEvents(bounded)) {
|
|
791
|
+
try {
|
|
792
|
+
await this.repository.withLock("event:" + event.id, async (store) => {
|
|
793
|
+
if (await store.eventSeen(event.id)) return;
|
|
794
|
+
try {
|
|
795
|
+
for (const id of await this.eventSubscriptions(event))
|
|
796
|
+
await this.refreshSubscription(id);
|
|
797
|
+
await store.finishEvent(event.id, null);
|
|
798
|
+
result.processed++;
|
|
799
|
+
} catch (error) {
|
|
800
|
+
await store.finishEvent(
|
|
801
|
+
event.id,
|
|
802
|
+
error instanceof SubscriptionError ? error.code : "SYNC_UNAVAILABLE",
|
|
803
|
+
);
|
|
804
|
+
result.failed++;
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
} catch {
|
|
808
|
+
result.failed++;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return result;
|
|
812
|
+
}
|
|
813
|
+
/** Run from a protected scheduler; domain consumers acknowledge changes after their own commit. */
|
|
814
|
+
async reconcile(limit = 25) {
|
|
815
|
+
const bounded = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
816
|
+
const result = await this.reconcilePending(bounded);
|
|
817
|
+
for (const row of await this.repository.pendingPurchases(bounded)) {
|
|
818
|
+
const purchase = (await this.repository.purchase(row.id))!;
|
|
819
|
+
try {
|
|
820
|
+
await this.repository.withLock("member:" + purchase.userId, async (store) => {
|
|
821
|
+
try {
|
|
822
|
+
if (purchase.cancelMode) await this.applyCancellation(store, purchase);
|
|
823
|
+
else await resumePurchase(this.purchaseContext(), store, purchase);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
await store.updatePurchase(row.id, {
|
|
826
|
+
last_attempt_at: this.now(),
|
|
827
|
+
last_error: error instanceof SubscriptionError ? error.code : "SYNC_UNAVAILABLE",
|
|
828
|
+
});
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
const current = (await this.repository.purchase(row.id))!;
|
|
833
|
+
if (current.subscriptionId) await this.refreshSubscription(current.subscriptionId);
|
|
834
|
+
result.processed++;
|
|
835
|
+
} catch {
|
|
836
|
+
result.failed++;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (const row of await this.repository.staleSubscriptions(bounded)) {
|
|
840
|
+
try {
|
|
841
|
+
await this.refreshSubscription(row.id);
|
|
842
|
+
result.processed++;
|
|
843
|
+
} catch {
|
|
844
|
+
result.failed++;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return result;
|
|
565
848
|
}
|
|
566
849
|
}
|
|
567
850
|
|