@dineway-ai/plugin-payment 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
3
  import type { Database } from "dineway";
4
- import type { Kysely, Transaction } from "kysely";
4
+ import type { Kysely, Transaction, Selectable } from "kysely";
5
5
 
6
+ import type { PaymentDatabase, PaymentTables } from "../database/types.js";
6
7
  import { entitlementSchema } from "./schemas.js";
7
8
  import {
8
9
  SubscriptionError,
@@ -12,11 +13,16 @@ import {
12
13
  type SubscriptionPlan,
13
14
  type SubscriptionPrice,
14
15
  type SubscriptionScope,
16
+ type BillingSubject,
17
+ type PurchaseContract,
18
+ type SubscriptionPurchase,
19
+ type SubscriptionSnapshot,
20
+ type SubscriptionChange,
15
21
  } from "./types.js";
16
22
 
17
- type Db = Kysely<Database>;
18
- type Trx = Transaction<Database>;
19
- type SubscriptionRow = Database["payment_member_subscriptions"];
23
+ type Db = Kysely<PaymentDatabase>;
24
+ type Trx = Transaction<PaymentDatabase>;
25
+ type SubscriptionRow = Selectable<PaymentDatabase["payment_member_subscriptions"]>;
20
26
  const scopedId = ["scope", "id"] as const;
21
27
  const accountPattern = /^acct_[A-Za-z0-9]+$/;
22
28
  const namespacePattern = /^[a-z][a-z0-9_-]{0,63}$/;
@@ -35,12 +41,14 @@ export function subscriptionScopeKey(scope: SubscriptionScope): string {
35
41
 
36
42
  export class SubscriptionRepository {
37
43
  readonly scope: string;
44
+ readonly db: Db;
38
45
  constructor(
39
- readonly db: Db,
46
+ db: Kysely<Database> | Kysely<PaymentDatabase>,
40
47
  scope: SubscriptionScope,
41
48
  readonly now: () => number = Date.now,
42
49
  private readonly lease?: { id: string; token: string },
43
50
  ) {
51
+ this.db = db.withTables<PaymentTables>();
44
52
  this.scope = subscriptionScopeKey(scope);
45
53
  this.scopeDetails = scope;
46
54
  }
@@ -141,7 +149,13 @@ export class SubscriptionRepository {
141
149
  enabled: Number(price.enabled),
142
150
  })
143
151
  .onConflict((conflict) =>
144
- conflict.columns(keyColumns()).doUpdateSet({ enabled: Number(price.enabled) }),
152
+ conflict.columns(keyColumns()).doUpdateSet({
153
+ product_id: price.productId,
154
+ currency: price.currency,
155
+ amount: price.amount,
156
+ interval: price.interval,
157
+ enabled: Number(price.enabled),
158
+ }),
145
159
  )
146
160
  .execute();
147
161
  }
@@ -168,12 +182,27 @@ export class SubscriptionRepository {
168
182
  return row?.id ?? null;
169
183
  }
170
184
 
171
- async saveCustomer(userId: string, customerId: string): Promise<void> {
185
+ async subject(userId: string): Promise<BillingSubject | null> {
186
+ const row = await this.db
187
+ .selectFrom("payment_subscription_customers")
188
+ .select("subject")
189
+ .where("scope", "=", this.scope)
190
+ .where("id", "=", userId)
191
+ .executeTakeFirst();
192
+ return row?.subject ? JSON.parse(row.subject) : null;
193
+ }
194
+
195
+ async saveCustomer(userId: string, customerId: string, subject?: BillingSubject): Promise<void> {
172
196
  await this.db.transaction().execute(async (trx) => {
173
197
  await this.assertLease(trx);
174
198
  await trx
175
199
  .insertInto("payment_subscription_customers")
176
- .values({ scope: this.scope, id: userId, customer_id: customerId })
200
+ .values({
201
+ scope: this.scope,
202
+ id: userId,
203
+ customer_id: customerId,
204
+ subject: subject ? JSON.stringify(subject) : null,
205
+ })
177
206
  .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
178
207
  .execute();
179
208
  const saved = await trx
@@ -245,6 +274,8 @@ export class SubscriptionRepository {
245
274
  planCode: row.plan_code,
246
275
  startsAt: row.starts_at,
247
276
  endsAt: row.ends_at,
277
+ eligible: row.eligible === 1,
278
+ evidence: JSON.parse(row.evidence),
248
279
  }));
249
280
  }
250
281
 
@@ -263,7 +294,7 @@ export class SubscriptionRepository {
263
294
  .executeTakeFirst();
264
295
  const existing = await trx
265
296
  .selectFrom("payment_member_subscriptions")
266
- .select(["user_id", "customer_id"])
297
+ .select(["user_id", "customer_id", "revision"])
267
298
  .where("scope", "=", this.scope)
268
299
  .where("id", "=", state.id)
269
300
  .executeTakeFirst();
@@ -283,7 +314,10 @@ export class SubscriptionRepository {
283
314
  period_end: state.periodEnd,
284
315
  cancel_at_period_end: Number(state.cancelAtPeriodEnd),
285
316
  verified_at: state.verifiedAt,
317
+ sync_attempted_at: this.now(),
286
318
  last_error: state.lastError,
319
+ purchase_reference: state.purchaseReference ?? null,
320
+ revision: (existing?.revision ?? 0) + 1,
287
321
  };
288
322
  await trx
289
323
  .insertInto("payment_member_subscriptions")
@@ -301,15 +335,69 @@ export class SubscriptionRepository {
301
335
  plan_code: period.planCode,
302
336
  starts_at: period.startsAt,
303
337
  ends_at: period.endsAt,
338
+ eligible: Number(period.eligible === true),
339
+ evidence: JSON.stringify(period.evidence ?? {}),
304
340
  })
305
- .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
341
+ .onConflict((conflict) =>
342
+ conflict.columns(keyColumns()).doUpdateSet({
343
+ eligible: Number(period.eligible === true),
344
+ evidence: JSON.stringify(period.evidence ?? {}),
345
+ }),
346
+ )
347
+ .execute();
348
+ }
349
+ if (state.purchaseReference) {
350
+ const purchase = await trx
351
+ .selectFrom("payment_subscription_purchases")
352
+ .select(["subscription_id", "user_id", "closed"])
353
+ .where("scope", "=", this.scope)
354
+ .where("id", "=", state.purchaseReference)
355
+ .executeTakeFirst();
356
+ if (
357
+ !purchase ||
358
+ purchase.user_id !== state.userId ||
359
+ (purchase.subscription_id && purchase.subscription_id !== state.id)
360
+ )
361
+ throw new SubscriptionError("PURCHASE_CONFLICT", "Purchase subscription conflict", 409);
362
+ await trx
363
+ .updateTable("payment_subscription_purchases")
364
+ .set({
365
+ subscription_id: state.id,
366
+ closed:
367
+ purchase.closed || Number(["canceled", "incomplete_expired"].includes(state.status)),
368
+ last_error: null,
369
+ })
370
+ .where("scope", "=", this.scope)
371
+ .where("id", "=", state.purchaseReference)
306
372
  .execute();
307
373
  }
374
+ const transactional = new SubscriptionRepository(trx, this.scopeDetails, this.now);
375
+ const snapshot = await transactional.snapshot(state.id);
376
+ await trx
377
+ .insertInto("payment_subscription_changes")
378
+ .values({
379
+ scope: this.scope,
380
+ id: randomUUID(),
381
+ subscription_id: state.id,
382
+ revision: values.revision,
383
+ snapshot: JSON.stringify(snapshot),
384
+ created_at: this.now(),
385
+ })
386
+ .execute();
308
387
  if (eventId)
309
388
  await trx
310
389
  .insertInto("payment_subscription_events")
311
- .values({ scope: this.scope, id: eventId, received_at: this.now() })
312
- .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
390
+ .values({
391
+ scope: this.scope,
392
+ id: eventId,
393
+ received_at: this.now(),
394
+ processed_at: this.now(),
395
+ })
396
+ .onConflict((conflict) =>
397
+ conflict
398
+ .columns(keyColumns())
399
+ .doUpdateSet({ processed_at: this.now(), last_error: null }),
400
+ )
313
401
  .execute();
314
402
  });
315
403
  }
@@ -317,7 +405,7 @@ export class SubscriptionRepository {
317
405
  async recordSyncFailure(id: string, verifiedAt: number): Promise<void> {
318
406
  await this.db
319
407
  .updateTable("payment_member_subscriptions")
320
- .set({ last_error: "SYNC_UNAVAILABLE" })
408
+ .set({ last_error: "SYNC_UNAVAILABLE", sync_attempted_at: this.now() })
321
409
  .where("scope", "=", this.scope)
322
410
  .where("id", "=", id)
323
411
  .where("verified_at", "=", verifiedAt)
@@ -330,6 +418,7 @@ export class SubscriptionRepository {
330
418
  .select("id")
331
419
  .where("scope", "=", this.scope)
332
420
  .where("id", "=", id)
421
+ .where("processed_at", "is not", null)
333
422
  .executeTakeFirst(),
334
423
  );
335
424
  }
@@ -379,6 +468,254 @@ export class SubscriptionRepository {
379
468
  });
380
469
  }
381
470
 
471
+ async purchase(reference: string): Promise<SubscriptionPurchase | null> {
472
+ const row = await this.db
473
+ .selectFrom("payment_subscription_purchases")
474
+ .selectAll()
475
+ .where("scope", "=", this.scope)
476
+ .where("id", "=", reference)
477
+ .executeTakeFirst();
478
+ if (!row) return null;
479
+ return {
480
+ reference: row.id,
481
+ userId: row.user_id,
482
+ contract: JSON.parse(row.contract),
483
+ createdAt: row.created_at,
484
+ expiresAt: row.expires_at,
485
+ sessionId: row.session_id,
486
+ subscriptionId: row.subscription_id,
487
+ closed: row.closed === 1,
488
+ cancelMode: row.cancel_mode as SubscriptionPurchase["cancelMode"],
489
+ lastError: row.last_error,
490
+ };
491
+ }
492
+ async openPurchase(userId: string, groupKey: string) {
493
+ const row = await this.db
494
+ .selectFrom("payment_subscription_purchases")
495
+ .select("id")
496
+ .where("scope", "=", this.scope)
497
+ .where("user_id", "=", userId)
498
+ .where("group_key", "=", groupKey)
499
+ .where("closed", "=", 0)
500
+ .executeTakeFirst();
501
+ return row ? this.purchase(row.id) : null;
502
+ }
503
+ async listPurchases(cursor?: string, limit = 50) {
504
+ let query = this.db
505
+ .selectFrom("payment_subscription_purchases")
506
+ .select("id")
507
+ .where("scope", "=", this.scope);
508
+ if (cursor) query = query.where("id", ">", cursor);
509
+ const rows = await query
510
+ .orderBy("id")
511
+ .limit(limit + 1)
512
+ .execute();
513
+ const items = await Promise.all(
514
+ rows.slice(0, limit).map(async (row) => {
515
+ const purchase = (await this.purchase(row.id))!;
516
+ return { ...purchase, subject: await this.subject(purchase.userId) };
517
+ }),
518
+ );
519
+ return { items, nextCursor: rows.length > limit ? rows[limit - 1]?.id : undefined };
520
+ }
521
+ async savePurchase(
522
+ userId: string,
523
+ contract: PurchaseContract,
524
+ digest: string,
525
+ expiresAt: number,
526
+ ) {
527
+ await this.db.transaction().execute(async (trx) => {
528
+ await this.assertLease(trx);
529
+ await trx
530
+ .insertInto("payment_subscription_purchases")
531
+ .values({
532
+ scope: this.scope,
533
+ id: contract.reference,
534
+ user_id: userId,
535
+ group_key: contract.groupKey,
536
+ contract: JSON.stringify(contract),
537
+ fingerprint: digest,
538
+ created_at: this.now(),
539
+ expires_at: expiresAt,
540
+ })
541
+ .execute();
542
+ });
543
+ }
544
+ async updatePurchase(
545
+ reference: string,
546
+ values: Partial<
547
+ Pick<
548
+ Selectable<PaymentDatabase["payment_subscription_purchases"]>,
549
+ | "session_id"
550
+ | "subscription_id"
551
+ | "closed"
552
+ | "cancel_mode"
553
+ | "last_attempt_at"
554
+ | "last_error"
555
+ >
556
+ >,
557
+ ) {
558
+ await this.db.transaction().execute(async (trx) => {
559
+ await this.assertLease(trx);
560
+ const current = await trx
561
+ .selectFrom("payment_subscription_purchases")
562
+ .select(["session_id", "subscription_id", "closed", "cancel_mode"])
563
+ .where("scope", "=", this.scope)
564
+ .where("id", "=", reference)
565
+ .executeTakeFirstOrThrow();
566
+ for (const field of ["session_id", "subscription_id"] as const) {
567
+ if (current[field] && values[field] && current[field] !== values[field])
568
+ throw new SubscriptionError(
569
+ "PURCHASE_CONFLICT",
570
+ "Purchase provider identity changed",
571
+ 409,
572
+ );
573
+ }
574
+ await trx
575
+ .updateTable("payment_subscription_purchases")
576
+ .set({
577
+ ...values,
578
+ session_id: current.session_id ?? values.session_id,
579
+ subscription_id: current.subscription_id ?? values.subscription_id,
580
+ closed: current.closed || values.closed,
581
+ cancel_mode:
582
+ current.cancel_mode === "immediate"
583
+ ? "immediate"
584
+ : (values.cancel_mode ?? current.cancel_mode),
585
+ })
586
+ .where("scope", "=", this.scope)
587
+ .where("id", "=", reference)
588
+ .execute();
589
+ });
590
+ }
591
+ async snapshot(id: string): Promise<SubscriptionSnapshot> {
592
+ const subscription = await this.subscription(id);
593
+ if (!subscription) throw new SubscriptionError("NOT_FOUND", "Subscription not found", 404);
594
+ return {
595
+ subscription,
596
+ subject: await this.subject(subscription.userId),
597
+ purchase: subscription.purchaseReference
598
+ ? await this.purchase(subscription.purchaseReference)
599
+ : null,
600
+ periods: (await this.periods(subscription.userId)).filter(
601
+ (period) => period.subscriptionId === id,
602
+ ),
603
+ };
604
+ }
605
+ async changes(limit = 50): Promise<SubscriptionChange[]> {
606
+ const rows = await this.db
607
+ .selectFrom("payment_subscription_changes")
608
+ .selectAll()
609
+ .where("scope", "=", this.scope)
610
+ .where("acknowledged_at", "is", null)
611
+ .orderBy("created_at")
612
+ .orderBy("subscription_id")
613
+ .orderBy("revision")
614
+ .limit(limit)
615
+ .execute();
616
+ return rows.map((row) => ({
617
+ id: row.id,
618
+ revision: row.revision,
619
+ createdAt: row.created_at,
620
+ snapshot: JSON.parse(row.snapshot),
621
+ }));
622
+ }
623
+ async acknowledgeChange(id: string) {
624
+ await this.db
625
+ .updateTable("payment_subscription_changes")
626
+ .set({ acknowledged_at: this.now() })
627
+ .where("scope", "=", this.scope)
628
+ .where("id", "=", id)
629
+ .where("acknowledged_at", "is", null)
630
+ .execute();
631
+ }
632
+ async receiveEvent(event: {
633
+ id: string;
634
+ digest: string;
635
+ type: string;
636
+ objectType: string | null;
637
+ objectId: string | null;
638
+ subscriptionId: string | null;
639
+ payload: string;
640
+ }) {
641
+ await this.db.transaction().execute(async (trx) => {
642
+ await trx
643
+ .insertInto("payment_subscription_events")
644
+ .values({
645
+ scope: this.scope,
646
+ id: event.id,
647
+ digest: event.digest,
648
+ event_type: event.type,
649
+ object_type: event.objectType,
650
+ object_id: event.objectId,
651
+ subscription_id: event.subscriptionId,
652
+ payload: event.payload,
653
+ received_at: this.now(),
654
+ })
655
+ .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
656
+ .execute();
657
+ const stored = await trx
658
+ .selectFrom("payment_subscription_events")
659
+ .select("digest")
660
+ .where("scope", "=", this.scope)
661
+ .where("id", "=", event.id)
662
+ .executeTakeFirstOrThrow();
663
+ if (stored.digest && stored.digest !== event.digest)
664
+ throw new SubscriptionError("EVENT_CONFLICT", "Webhook event content changed", 409);
665
+ });
666
+ }
667
+ async pendingEvents(limit: number) {
668
+ return this.db
669
+ .selectFrom("payment_subscription_events")
670
+ .selectAll()
671
+ .where("scope", "=", this.scope)
672
+ .where("processed_at", "is", null)
673
+ .orderBy("last_attempt_at")
674
+ .orderBy("received_at")
675
+ .orderBy("id")
676
+ .limit(limit)
677
+ .execute();
678
+ }
679
+ async finishEvent(id: string, error: string | null) {
680
+ await this.db.transaction().execute(async (trx) => {
681
+ await this.assertLease(trx);
682
+ await trx
683
+ .updateTable("payment_subscription_events")
684
+ .set({
685
+ last_attempt_at: this.now(),
686
+ last_error: error,
687
+ processed_at: error ? null : this.now(),
688
+ })
689
+ .where("scope", "=", this.scope)
690
+ .where("id", "=", id)
691
+ .where("processed_at", "is", null)
692
+ .execute();
693
+ });
694
+ }
695
+ async pendingPurchases(limit: number) {
696
+ return this.db
697
+ .selectFrom("payment_subscription_purchases")
698
+ .select("id")
699
+ .where("scope", "=", this.scope)
700
+ .where("closed", "=", 0)
701
+ .where((eb) => eb.or([eb("subscription_id", "is", null), eb("cancel_mode", "is not", null)]))
702
+ .orderBy("last_attempt_at")
703
+ .orderBy("id")
704
+ .limit(limit)
705
+ .execute();
706
+ }
707
+ async staleSubscriptions(limit: number) {
708
+ return this.db
709
+ .selectFrom("payment_member_subscriptions")
710
+ .select("id")
711
+ .where("scope", "=", this.scope)
712
+ .where("sync_attempted_at", "<", this.now() - 900000)
713
+ .orderBy("sync_attempted_at")
714
+ .orderBy("id")
715
+ .limit(limit)
716
+ .execute();
717
+ }
718
+
382
719
  async withLock<T>(
383
720
  id: string,
384
721
  action: (repository: SubscriptionRepository) => Promise<T>,
@@ -439,5 +776,7 @@ function projectSubscription(row: SubscriptionRow): MemberSubscription {
439
776
  cancelAtPeriodEnd: row.cancel_at_period_end === 1,
440
777
  verifiedAt: row.verified_at,
441
778
  lastError: row.last_error,
779
+ purchaseReference: row.purchase_reference,
780
+ revision: row.revision,
442
781
  };
443
782
  }
@@ -118,6 +118,14 @@ export function createSubscriptionRoutes(
118
118
  "subscriptions/get": privateRoute(subscriptionIdSchema, "payments:manage", async (context) =>
119
119
  (await service()).subscriptionDetail(context.input.subscriptionId),
120
120
  ),
121
+ "subscriptions/purchases/list": privateRoute(
122
+ memberListSchema,
123
+ "payments:manage",
124
+ async (context) => (await service()).listPurchases(context.input.cursor, context.input.limit),
125
+ ),
126
+ "subscriptions/reconcile": privateRoute(empty, "payments:manage", async () =>
127
+ (await service()).reconcile(10),
128
+ ),
121
129
  "subscriptions/sync": privateRoute(subscriptionIdSchema, "payments:manage", async (context) => {
122
130
  const client = await service();
123
131
  await client.subscriptionDetail(context.input.subscriptionId);
@@ -90,6 +90,51 @@ export const checkoutSchema = z
90
90
  })
91
91
  .strict();
92
92
  export const portalSchema = checkoutSchema.pick({ locale: true, returnPath: true });
93
+ export const billingSubjectSchema = z
94
+ .object({
95
+ realm: subscriptionCode.refine((value) => value !== "dineway", "The Dineway realm is reserved"),
96
+ id: z
97
+ .string()
98
+ .min(1)
99
+ .max(128)
100
+ .regex(/^[A-Za-z0-9_.:-]+$/),
101
+ email: z.string().trim().email().max(320),
102
+ emailVerified: z.literal(true),
103
+ })
104
+ .strict();
105
+ const purchaseKey = z
106
+ .string()
107
+ .min(1)
108
+ .max(160)
109
+ .regex(/^[A-Za-z0-9_.:-]+$/);
110
+ export const purchaseSchema = z
111
+ .object({
112
+ reference: purchaseKey,
113
+ groupKey: purchaseKey,
114
+ pricing: z.discriminatedUnion("kind", [
115
+ z
116
+ .object({
117
+ kind: z.literal("catalogue"),
118
+ planCode: subscriptionCode,
119
+ interval: z.enum(["month", "year"]),
120
+ })
121
+ .strict(),
122
+ z
123
+ .object({
124
+ kind: z.literal("dynamic"),
125
+ name: z.string().trim().min(1).max(160),
126
+ amount: z.number().int().min(1).max(99999999),
127
+ currency: z.string().regex(/^[a-z]{3}$/),
128
+ interval: z.enum(["day", "week", "month", "year"]),
129
+ intervalCount: z.number().int().min(1).max(365),
130
+ })
131
+ .strict(),
132
+ ]),
133
+ firstPeriodCredit: z.number().int().min(0).max(99999999).default(0),
134
+ returnPath: subscriptionReturnPath,
135
+ locale: subscriptionLocale.default("auto"),
136
+ })
137
+ .strict();
93
138
  export const memberListSchema = z
94
139
  .object({
95
140
  cursor: z.string().max(255).optional(),