@dineway-ai/plugin-payment 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/admin/subscriptions.tsx +150 -16
- package/src/configuration/repository.ts +7 -3
- package/src/database/index.ts +12 -0
- package/src/database/migrations/001_stripe_configuration.ts +100 -0
- package/src/database/migrations/002_subscriptions.ts +168 -0
- package/src/database/migrations/003_purchases.ts +193 -0
- package/src/database/migrations/adoption.ts +27 -0
- package/src/database/types.ts +137 -0
- package/src/index.ts +2 -0
- package/src/subscriptions/funding.ts +256 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/projection.ts +39 -7
- package/src/subscriptions/purchases.ts +329 -0
- package/src/subscriptions/refunds.ts +134 -0
- package/src/subscriptions/repository.ts +352 -13
- package/src/subscriptions/routes.ts +8 -0
- package/src/subscriptions/schemas.ts +45 -0
- package/src/subscriptions/service.ts +479 -200
- package/src/subscriptions/types.ts +90 -0
- package/src/types.ts +1 -0
|
@@ -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<
|
|
18
|
-
type Trx = Transaction<
|
|
19
|
-
type SubscriptionRow =
|
|
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
|
-
|
|
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({
|
|
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
|
|
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({
|
|
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) =>
|
|
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({
|
|
312
|
-
|
|
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(),
|