@dineway-ai/plugin-payment 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ import type { Database } from "dineway";
4
+ import type { Kysely } from "kysely";
5
+ import Stripe from "stripe";
6
+
7
+ import { StripeNodeWebhookVerifier } from "../stripe/webhook-verifier.js";
8
+ import {
9
+ recurringPrice,
10
+ invoicePeriods,
11
+ memberProjection,
12
+ subscriptionProjection,
13
+ } from "./projection.js";
14
+ import { SubscriptionRepository, subscriptionScopeKey } from "./repository.js";
15
+ import { checkoutSchema, portalSchema, saveSubscriptionPlanSchema } from "./schemas.js";
16
+ import {
17
+ SubscriptionError,
18
+ type MemberBilling,
19
+ type SaveSubscriptionPlan,
20
+ type SubscriptionCheckoutInput,
21
+ type SubscriptionOptions,
22
+ type SubscriptionScope,
23
+ } from "./types.js";
24
+
25
+ export interface SubscriptionServiceOptions {
26
+ db: Kysely<Database>;
27
+ scope: SubscriptionScope;
28
+ siteUrl: string;
29
+ secretKey: string;
30
+ webhookSecret: string;
31
+ now?: () => number;
32
+ stripe?: Stripe;
33
+ }
34
+ const terminal = new Set(["canceled", "incomplete_expired"]);
35
+ const events = new Set([
36
+ "checkout.session.completed",
37
+ "checkout.session.async_payment_succeeded",
38
+ "checkout.session.async_payment_failed",
39
+ "customer.subscription.created",
40
+ "customer.subscription.updated",
41
+ "customer.subscription.deleted",
42
+ "customer.subscription.paused",
43
+ "customer.subscription.resumed",
44
+ "invoice.paid",
45
+ "invoice.payment_failed",
46
+ "invoice.payment_action_required",
47
+ "invoice.voided",
48
+ "invoice.marked_uncollectible",
49
+ ]);
50
+ const localeSchema = checkoutSchema.shape.locale;
51
+ const fingerprint = (values: unknown[]) =>
52
+ createHash("sha256").update(JSON.stringify(values)).digest("hex");
53
+ const providerIdempotency = (scope: SubscriptionScope, ...values: unknown[]) =>
54
+ "dineway-subscriptions:" + fingerprint([scope, ...values]);
55
+
56
+ export class PaymentSubscriptionService {
57
+ private readonly stripe: Stripe;
58
+ private readonly repository: SubscriptionRepository;
59
+ private readonly now: () => number;
60
+ private readonly origin: string;
61
+ constructor(private readonly options: SubscriptionServiceOptions) {
62
+ this.now = options.now ?? Date.now;
63
+ this.repository = new SubscriptionRepository(options.db, options.scope, this.now);
64
+ this.stripe =
65
+ options.stripe ??
66
+ new Stripe(options.secretKey, {
67
+ maxNetworkRetries: 0,
68
+ timeout: 15000,
69
+ appInfo: { name: "Dineway Subscriptions" },
70
+ });
71
+ const url = new URL(options.siteUrl);
72
+ if (
73
+ url.username ||
74
+ url.password ||
75
+ url.pathname !== "/" ||
76
+ url.search ||
77
+ url.hash ||
78
+ (url.protocol !== "https:" &&
79
+ !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
80
+ ) {
81
+ throw new SubscriptionError("INVALID_SITE_URL", "Configure the public site origin");
82
+ }
83
+ this.origin = url.origin;
84
+ }
85
+ async listPlans(includeArchived = false) {
86
+ const plans = await this.repository.plans();
87
+ return includeArchived
88
+ ? plans
89
+ : plans
90
+ .filter((plan) => plan.enabled)
91
+ .map((plan) => ({ ...plan, prices: plan.prices.filter((price) => price.enabled) }));
92
+ }
93
+ async savePlan(input: SaveSubscriptionPlan) {
94
+ const parsed = saveSubscriptionPlanSchema.parse(input);
95
+ const allPlans = await this.repository.plans();
96
+ for (const [key, value] of Object.entries(parsed.entitlements)) {
97
+ if (
98
+ allPlans.some(
99
+ (plan) =>
100
+ plan.entitlements[key] !== undefined && typeof plan.entitlements[key] !== typeof value,
101
+ )
102
+ ) {
103
+ throw new SubscriptionError(
104
+ "ENTITLEMENT_TYPE_CONFLICT",
105
+ "An entitlement must have the same type across plans",
106
+ );
107
+ }
108
+ }
109
+ const prices = [];
110
+ for (const [interval, id] of [
111
+ ["month", parsed.monthlyPriceId],
112
+ ["year", parsed.yearlyPriceId],
113
+ ] as const) {
114
+ if (!id) continue;
115
+ const price = recurringPrice(
116
+ await this.stripe.prices.retrieve(id),
117
+ this.options.scope,
118
+ parsed.enabled,
119
+ );
120
+ if (price.interval !== interval)
121
+ throw new SubscriptionError("INVALID_PRICE", "Price interval does not match its binding");
122
+ prices.push(price);
123
+ }
124
+ if (parsed.enabled && !prices.length)
125
+ throw new SubscriptionError("INVALID_PRICE", "An enabled plan needs a price");
126
+ if (
127
+ new Set(prices.map((price) => price.productId)).size > 1 ||
128
+ new Set(prices.map((price) => price.currency)).size > 1
129
+ ) {
130
+ throw new SubscriptionError("INVALID_PRICE", "Plan prices must share a product and currency");
131
+ }
132
+ await this.repository.savePlan(parsed, prices);
133
+ return (await this.repository.plans()).find((plan) => plan.code === parsed.code)!;
134
+ }
135
+ private returnUrl(path: string): string {
136
+ portalSchema.shape.returnPath.parse(path);
137
+ const url = new URL(path, this.origin);
138
+ if (url.origin !== this.origin)
139
+ throw new SubscriptionError("INVALID_RETURN_URL", "Return to this site");
140
+ return url.href;
141
+ }
142
+ private async user(userId: string) {
143
+ const user = await this.options.db
144
+ .selectFrom("users")
145
+ .select(["id", "email", "disabled"])
146
+ .where("id", "=", userId)
147
+ .executeTakeFirst();
148
+ if (!user || user.disabled)
149
+ throw new SubscriptionError("UNAUTHORIZED", "An active member account is required", 401);
150
+ return user;
151
+ }
152
+ private metadata(userId: string) {
153
+ return { dineway_app: this.options.scope.namespace, user_id: userId };
154
+ }
155
+ private sessionOwned(
156
+ session: Stripe.Checkout.Session,
157
+ userId: string,
158
+ customer: string,
159
+ ): boolean {
160
+ const owner = typeof session.customer === "string" ? session.customer : session.customer?.id;
161
+ return (
162
+ owner === customer &&
163
+ session.mode === "subscription" &&
164
+ session.livemode === (this.options.scope.mode === "live") &&
165
+ session.metadata?.dineway_app === this.options.scope.namespace &&
166
+ session.metadata.user_id === userId &&
167
+ session.client_reference_id === userId
168
+ );
169
+ }
170
+ async createCheckout(userId: string, input: SubscriptionCheckoutInput): Promise<{ url: string }> {
171
+ const parsed = checkoutSchema.parse(input);
172
+ const user = await this.user(userId);
173
+ const returnUrl = this.returnUrl(parsed.returnPath);
174
+ const successUrl = new URL(returnUrl);
175
+ successUrl.searchParams.set("checkout", "returned");
176
+ return this.repository.withLock("member:" + userId, async (repository) => {
177
+ const plans = await repository.plans();
178
+ const plan = plans.find((value) => value.code === parsed.planCode && value.enabled);
179
+ const price = plan?.prices.find(
180
+ (value) => value.interval === parsed.interval && value.enabled,
181
+ );
182
+ if (!price) throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
183
+ const livePrice = recurringPrice(
184
+ await this.stripe.prices.retrieve(price.id),
185
+ this.options.scope,
186
+ );
187
+ if (
188
+ livePrice.currency !== price.currency ||
189
+ livePrice.amount !== price.amount ||
190
+ livePrice.productId !== price.productId ||
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,
208
+ });
209
+ if (subscriptions.has_more)
210
+ throw new SubscriptionError("REVIEW_REQUIRED", "Subscription review is required", 409);
211
+ if (
212
+ subscriptions.data.some(
213
+ (subscription) =>
214
+ subscription.metadata.dineway_app === this.options.scope.namespace &&
215
+ !terminal.has(subscription.status),
216
+ )
217
+ ) {
218
+ throw new SubscriptionError(
219
+ "SUBSCRIPTION_EXISTS",
220
+ "Manage the existing subscription in the billing portal",
221
+ 409,
222
+ );
223
+ }
224
+ const open = await this.stripe.checkout.sessions.list({
225
+ customer,
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);
259
+ if (expired.status !== "expired")
260
+ throw new SubscriptionError(
261
+ "REVIEW_REQUIRED",
262
+ "Checkout changed; refresh and try again",
263
+ 409,
264
+ );
265
+ }
266
+ if (!previous && remembered?.session_id) {
267
+ const session = await this.stripe.checkout.sessions.retrieve(remembered.session_id);
268
+ if (!this.sessionOwned(session, userId, customer))
269
+ throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
270
+ const relation = session.subscription;
271
+ const subscriptionId = typeof relation === "string" ? relation : relation?.id;
272
+ const old = subscriptions.data.find((subscription) => subscription.id === subscriptionId);
273
+ if (
274
+ session.status !== "expired" &&
275
+ !(session.status === "complete" && old && terminal.has(old.status))
276
+ ) {
277
+ throw new SubscriptionError(
278
+ "SUBSCRIPTION_EXISTS",
279
+ "Confirm the previous subscription before starting another",
280
+ 409,
281
+ );
282
+ }
283
+ }
284
+ if (
285
+ !previous &&
286
+ remembered &&
287
+ !remembered.session_id &&
288
+ (remembered.fingerprint !== digest || this.now() - remembered.created_at > 23 * 3600000)
289
+ ) {
290
+ throw new SubscriptionError(
291
+ "REVIEW_REQUIRED",
292
+ "An unconfirmed checkout requires review",
293
+ 409,
294
+ );
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) };
334
+ });
335
+ }
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
+ async createPortal(
342
+ userId: string,
343
+ input: { returnPath: string; locale?: string },
344
+ ): Promise<{ url: string }> {
345
+ const parsed = portalSchema.parse(input);
346
+ await this.user(userId);
347
+ const customer = await this.repository.customer(userId);
348
+ if (!customer)
349
+ throw new SubscriptionError("CUSTOMER_NOT_FOUND", "No billing account exists", 404);
350
+ const configuration = await this.repository.withLock(
351
+ "portal-configuration",
352
+ async (repository) => {
353
+ let id = await repository.portalConfiguration();
354
+ if (!id) {
355
+ const created = await this.stripe.billingPortal.configurations.create(
356
+ {
357
+ features: {
358
+ invoice_history: { enabled: true },
359
+ payment_method_update: { enabled: true },
360
+ subscription_cancel: { enabled: true, mode: "at_period_end" },
361
+ subscription_update: { enabled: false },
362
+ customer_update: { enabled: false },
363
+ },
364
+ metadata: { dineway_app: this.options.scope.namespace },
365
+ },
366
+ { idempotencyKey: providerIdempotency(this.options.scope, "portal") },
367
+ );
368
+ id = created.id;
369
+ await repository.savePortalConfiguration(id);
370
+ }
371
+ const config = await this.stripe.billingPortal.configurations.retrieve(id);
372
+ if (
373
+ !config.active ||
374
+ config.livemode !== (this.options.scope.mode === "live") ||
375
+ config.features.subscription_update.enabled ||
376
+ !config.features.subscription_cancel.enabled ||
377
+ config.features.subscription_cancel.mode !== "at_period_end"
378
+ ) {
379
+ throw new SubscriptionError(
380
+ "PORTAL_CONFIGURATION_INVALID",
381
+ "The portal must use period-end cancellation without plan changes",
382
+ 503,
383
+ );
384
+ }
385
+ return id;
386
+ },
387
+ );
388
+ const session = await this.stripe.billingPortal.sessions.create({
389
+ customer,
390
+ configuration,
391
+ locale: localeSchema.parse(parsed.locale) ?? "auto",
392
+ return_url: this.returnUrl(parsed.returnPath),
393
+ });
394
+ if (new URL(session.url).origin !== "https://billing.stripe.com")
395
+ throw new SubscriptionError("INVALID_DESTINATION", "Invalid billing destination", 502);
396
+ return { url: session.url };
397
+ }
398
+ async refreshSubscription(id: string, eventId?: string): Promise<void> {
399
+ const previous = await this.repository.subscription(id);
400
+ try {
401
+ await this.repository.withLock("subscription:" + id, async (repository) => {
402
+ if (eventId && (await repository.eventSeen(eventId))) return;
403
+ const live = await this.stripe.subscriptions.retrieve(id);
404
+ if (live.metadata.dineway_app !== this.options.scope.namespace) return;
405
+ const customerId = typeof live.customer === "string" ? live.customer : live.customer.id;
406
+ const userId = await repository.customerOwner(customerId);
407
+ if (!userId) return;
408
+ const plans = await repository.plans();
409
+ const state = subscriptionProjection(
410
+ live,
411
+ plans,
412
+ this.options.scope,
413
+ userId,
414
+ customerId,
415
+ this.now(),
416
+ );
417
+ const invoices = await this.stripe.invoices
418
+ .list({ customer: customerId, subscription: id, status: "paid", limit: 100 })
419
+ .autoPagingToArray({ limit: 1000 });
420
+ const periods = [];
421
+ for (const invoice of invoices) {
422
+ const lines = invoice.lines.has_more
423
+ ? await this.stripe.invoices
424
+ .listLineItems(invoice.id, { limit: 100 })
425
+ .autoPagingToArray({ limit: 1000 })
426
+ : invoice.lines.data;
427
+ periods.push(...invoicePeriods(invoice, lines, state, plans, this.options.scope));
428
+ }
429
+ await repository.saveSubscription(state, periods, eventId);
430
+ });
431
+ } catch (error) {
432
+ if (previous) await this.repository.recordSyncFailure(id, previous.verifiedAt);
433
+ throw error;
434
+ }
435
+ }
436
+ async getMemberBilling(userId: string, refresh = true): Promise<MemberBilling> {
437
+ await this.user(userId);
438
+ const customer = await this.repository.customer(userId);
439
+ let states = await this.repository.subscriptions(userId);
440
+ if (refresh && customer) {
441
+ // A completed checkout can precede its webhook and local subscription row.
442
+ if (!states.length || states.every((state) => terminal.has(state.status))) {
443
+ try {
444
+ const live = await this.stripe.subscriptions.list({
445
+ customer,
446
+ status: "all",
447
+ limit: 100,
448
+ });
449
+ for (const subscription of live.data) {
450
+ if (subscription.metadata.dineway_app === this.options.scope.namespace)
451
+ await this.refreshSubscription(subscription.id);
452
+ }
453
+ } catch {
454
+ /* An unavailable provider cannot create access. */
455
+ }
456
+ } else {
457
+ for (const state of states) {
458
+ if (this.now() - state.verifiedAt < 900000) continue;
459
+ try {
460
+ await this.refreshSubscription(state.id);
461
+ } catch {
462
+ /* Reconciliation records the failure without extending access. */
463
+ }
464
+ }
465
+ }
466
+ states = await this.repository.subscriptions(userId);
467
+ }
468
+ return memberProjection(
469
+ Boolean(customer),
470
+ states,
471
+ await this.repository.periods(userId),
472
+ await this.repository.plans(),
473
+ this.now(),
474
+ );
475
+ }
476
+ async getEntitlements(userId: string) {
477
+ return (await this.getMemberBilling(userId)).entitlements;
478
+ }
479
+ async listSubscriptions(cursor?: string, limit = 50) {
480
+ return this.repository.listSubscriptions(cursor, Math.max(1, Math.min(100, limit)));
481
+ }
482
+ async subscriptionDetail(id: string) {
483
+ const state = await this.repository.subscription(id);
484
+ if (!state) throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
485
+ return { subscription: state, member: await this.getMemberBilling(state.userId, false) };
486
+ }
487
+ async receiveWebhook(rawBody: Uint8Array, signature: string | null): Promise<void> {
488
+ const verified = new StripeNodeWebhookVerifier().verify({
489
+ secretKey: this.options.secretKey,
490
+ webhookSecret: this.options.webhookSecret,
491
+ rawBody,
492
+ signature,
493
+ });
494
+ if (!verified.ok)
495
+ throw new SubscriptionError("INVALID_SIGNATURE", "Invalid Stripe webhook", 400);
496
+ const event = verified.event;
497
+ if (event.livemode !== (this.options.scope.mode === "live"))
498
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid webhook mode", 400);
499
+ if (
500
+ !events.has(event.eventType) ||
501
+ !event.subscriptionId ||
502
+ (await this.repository.eventSeen(event.eventId))
503
+ )
504
+ return;
505
+ await this.refreshSubscription(event.subscriptionId, event.eventId);
506
+ }
507
+ }
508
+
509
+ export type { SubscriptionOptions };
510
+ export { subscriptionScopeKey };
@@ -0,0 +1,80 @@
1
+ import type { StripeMode } from "../types.js";
2
+
3
+ export type SubscriptionEntitlements = Record<string, boolean | number>;
4
+ export type SubscriptionInterval = "month" | "year";
5
+ export interface SubscriptionScope {
6
+ accountId: string;
7
+ mode: StripeMode;
8
+ namespace: string;
9
+ }
10
+ export interface SubscriptionOptions {
11
+ namespace: string;
12
+ siteUrl: string;
13
+ }
14
+ export interface SubscriptionPrice {
15
+ id: string;
16
+ productId: string;
17
+ currency: string;
18
+ amount: number;
19
+ interval: SubscriptionInterval;
20
+ enabled: boolean;
21
+ }
22
+ export interface SubscriptionPlan {
23
+ code: string;
24
+ name: string;
25
+ enabled: boolean;
26
+ version: number;
27
+ entitlements: SubscriptionEntitlements;
28
+ prices: SubscriptionPrice[];
29
+ }
30
+ export interface SaveSubscriptionPlan {
31
+ code: string;
32
+ name: string;
33
+ enabled: boolean;
34
+ expectedVersion: number;
35
+ entitlements: SubscriptionEntitlements;
36
+ monthlyPriceId: string | null;
37
+ yearlyPriceId: string | null;
38
+ }
39
+ export interface MemberSubscription {
40
+ id: string;
41
+ userId: string;
42
+ customerId: string;
43
+ planCode: string;
44
+ priceId: string;
45
+ status: string;
46
+ periodEnd: number;
47
+ cancelAtPeriodEnd: boolean;
48
+ verifiedAt: number;
49
+ lastError: string | null;
50
+ }
51
+ export interface PaidSubscriptionPeriod {
52
+ id: string;
53
+ invoiceId: string;
54
+ subscriptionId: string;
55
+ planCode: string;
56
+ startsAt: number;
57
+ endsAt: number;
58
+ }
59
+ export interface MemberBilling {
60
+ customer: boolean;
61
+ subscriptions: MemberSubscription[];
62
+ paidThrough: number | null;
63
+ entitlements: SubscriptionEntitlements;
64
+ }
65
+ export interface SubscriptionCheckoutInput {
66
+ planCode: string;
67
+ interval: SubscriptionInterval;
68
+ locale?: string;
69
+ returnPath: string;
70
+ }
71
+ export class SubscriptionError extends Error {
72
+ constructor(
73
+ readonly code: string,
74
+ message: string,
75
+ readonly status = 400,
76
+ ) {
77
+ super(message);
78
+ this.name = "SubscriptionError";
79
+ }
80
+ }
package/src/types.ts CHANGED
@@ -261,6 +261,7 @@ export interface StripeWebhookRefund {
261
261
  }
262
262
 
263
263
  export interface StripeWebhookEvent {
264
+ subscriptionId?: string;
264
265
  eventId: string;
265
266
  eventType: string;
266
267
  eventCreated: number;