@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dineway-ai/plugin-payment",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Provider-neutral payment infrastructure and Stripe integration for Dineway plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./admin": "./src/admin.tsx",
|
|
13
13
|
"./configuration": "./src/configuration/index.ts",
|
|
14
|
+
"./database": "./src/database/index.ts",
|
|
14
15
|
"./routes": "./src/routes/index.ts",
|
|
15
16
|
"./stripe": "./src/stripe/index.ts",
|
|
16
17
|
"./types": "./src/types.ts",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"license": "MIT",
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"react": "^18.0.0 || ^19.0.0",
|
|
32
|
-
"dineway": "^0.2.
|
|
33
|
+
"dineway": "^0.2.30"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@stripe/stripe-js": "9.9.0",
|
|
@@ -5,6 +5,9 @@ import type { getSubscriptionConfiguration } from "../subscriptions/runtime.js";
|
|
|
5
5
|
import type {
|
|
6
6
|
MemberBilling,
|
|
7
7
|
MemberSubscription,
|
|
8
|
+
BillingSubject,
|
|
9
|
+
SubscriptionPurchase,
|
|
10
|
+
SubscriptionSnapshot,
|
|
8
11
|
SaveSubscriptionPlan,
|
|
9
12
|
SubscriptionPlan,
|
|
10
13
|
} from "../subscriptions/types.js";
|
|
@@ -12,6 +15,8 @@ import { postPaymentRoute } from "./api.js";
|
|
|
12
15
|
import { SubscriptionPricePicker } from "./subscription-price-picker.js";
|
|
13
16
|
|
|
14
17
|
type Configuration = Awaited<ReturnType<typeof getSubscriptionConfiguration>>;
|
|
18
|
+
type AdminSubscription = MemberSubscription & { subject: BillingSubject | null };
|
|
19
|
+
type AdminPurchase = SubscriptionPurchase & { subject: BillingSubject | null };
|
|
15
20
|
type Feature = { key: string; kind: "boolean" | "number"; value: string };
|
|
16
21
|
const emptyPlan: SaveSubscriptionPlan = {
|
|
17
22
|
code: "",
|
|
@@ -28,17 +33,18 @@ const buttonClass = "rounded-lg border border-kumo-line px-3 py-2 text-sm disabl
|
|
|
28
33
|
export function PaymentSubscriptionsPage() {
|
|
29
34
|
const [configuration, setConfiguration] = React.useState<Configuration | null>(null);
|
|
30
35
|
const [plans, setPlans] = React.useState<SubscriptionPlan[]>([]);
|
|
31
|
-
const [members, setMembers] = React.useState<
|
|
36
|
+
const [members, setMembers] = React.useState<AdminSubscription[]>([]);
|
|
37
|
+
const [purchases, setPurchases] = React.useState<AdminPurchase[]>([]);
|
|
38
|
+
const [purchaseCursor, setPurchaseCursor] = React.useState<string>();
|
|
32
39
|
const [cursor, setCursor] = React.useState<string>();
|
|
33
40
|
const [form, setForm] = React.useState<SaveSubscriptionPlan>({ ...emptyPlan });
|
|
34
41
|
const [editorRevision, setEditorRevision] = React.useState(0);
|
|
35
42
|
const [boundPrices, setBoundPrices] = React.useState<SubscriptionPlan["prices"]>([]);
|
|
36
43
|
const [features, setFeatures] = React.useState<Feature[]>([]);
|
|
37
44
|
const [webhookSecret, setWebhookSecret] = React.useState("");
|
|
38
|
-
const [detail, setDetail] = React.useState<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} | null>(null);
|
|
45
|
+
const [detail, setDetail] = React.useState<
|
|
46
|
+
(SubscriptionSnapshot & { member: MemberBilling }) | null
|
|
47
|
+
>(null);
|
|
42
48
|
const [busy, setBusy] = React.useState(false);
|
|
43
49
|
const [error, setError] = React.useState<string | null>(null);
|
|
44
50
|
const [notice, setNotice] = React.useState<string | null>(null);
|
|
@@ -46,15 +52,18 @@ export function PaymentSubscriptionsPage() {
|
|
|
46
52
|
const config = await postPaymentRoute<Configuration>("subscriptions/configuration/get");
|
|
47
53
|
setConfiguration(config);
|
|
48
54
|
if (config.ready) {
|
|
49
|
-
const [catalog, subscriptions] = await Promise.all([
|
|
55
|
+
const [catalog, subscriptions, purchasePage] = await Promise.all([
|
|
50
56
|
postPaymentRoute<{ items: SubscriptionPlan[] }>("subscriptions/plans/list"),
|
|
51
|
-
postPaymentRoute<{ items:
|
|
52
|
-
|
|
57
|
+
postPaymentRoute<{ items: AdminSubscription[]; nextCursor?: string }>("subscriptions/list"),
|
|
58
|
+
postPaymentRoute<{ items: AdminPurchase[]; nextCursor?: string }>(
|
|
59
|
+
"subscriptions/purchases/list",
|
|
53
60
|
),
|
|
54
61
|
]);
|
|
55
62
|
setPlans(catalog.items);
|
|
56
63
|
setMembers(subscriptions.items);
|
|
57
64
|
setCursor(subscriptions.nextCursor);
|
|
65
|
+
setPurchases(purchasePage.items);
|
|
66
|
+
setPurchaseCursor(purchasePage.nextCursor);
|
|
58
67
|
}
|
|
59
68
|
}, []);
|
|
60
69
|
React.useEffect(() => {
|
|
@@ -391,7 +400,100 @@ export function PaymentSubscriptionsPage() {
|
|
|
391
400
|
</form>
|
|
392
401
|
</section>
|
|
393
402
|
<section className="space-y-4">
|
|
394
|
-
<h2 className="text-lg font-semibold">
|
|
403
|
+
<h2 className="text-lg font-semibold">Purchases</h2>
|
|
404
|
+
<button
|
|
405
|
+
className={buttonClass}
|
|
406
|
+
disabled={busy}
|
|
407
|
+
onClick={() =>
|
|
408
|
+
void run(async () => {
|
|
409
|
+
const result = await postPaymentRoute<{ processed: number; failed: number }>(
|
|
410
|
+
"subscriptions/reconcile",
|
|
411
|
+
);
|
|
412
|
+
await refresh();
|
|
413
|
+
setNotice(
|
|
414
|
+
`Processed ${result.processed}; ${result.failed} require retry or review.`,
|
|
415
|
+
);
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
>
|
|
419
|
+
Retry pending synchronization
|
|
420
|
+
</button>
|
|
421
|
+
<div className="overflow-x-auto">
|
|
422
|
+
<table className="w-full text-left text-sm">
|
|
423
|
+
<thead>
|
|
424
|
+
<tr>
|
|
425
|
+
<th className="p-2">Billing subject</th>
|
|
426
|
+
<th className="p-2">Purchase</th>
|
|
427
|
+
<th className="p-2">Terms</th>
|
|
428
|
+
<th className="p-2">Status</th>
|
|
429
|
+
</tr>
|
|
430
|
+
</thead>
|
|
431
|
+
<tbody>
|
|
432
|
+
{purchases.map((purchase) => (
|
|
433
|
+
<tr key={purchase.reference} className="border-t border-kumo-line">
|
|
434
|
+
<td className="p-2">
|
|
435
|
+
{purchase.subject
|
|
436
|
+
? `${purchase.subject.realm}: ${purchase.subject.id}`
|
|
437
|
+
: purchase.userId}
|
|
438
|
+
</td>
|
|
439
|
+
<td className="p-2">
|
|
440
|
+
{purchase.reference}
|
|
441
|
+
<br />
|
|
442
|
+
{purchase.contract.pricing.kind}
|
|
443
|
+
</td>
|
|
444
|
+
<td className="p-2">
|
|
445
|
+
{formatSubscriptionPrice(
|
|
446
|
+
purchase.contract.amount,
|
|
447
|
+
purchase.contract.currency,
|
|
448
|
+
)}{" "}
|
|
449
|
+
every {purchase.contract.intervalCount} {purchase.contract.interval}
|
|
450
|
+
<br />
|
|
451
|
+
First invoice credit:{" "}
|
|
452
|
+
{formatSubscriptionPrice(
|
|
453
|
+
purchase.contract.firstPeriodCredit ?? 0,
|
|
454
|
+
purchase.contract.currency,
|
|
455
|
+
)}
|
|
456
|
+
</td>
|
|
457
|
+
<td className="p-2">
|
|
458
|
+
{purchase.closed
|
|
459
|
+
? "Closed"
|
|
460
|
+
: purchase.subscriptionId
|
|
461
|
+
? "Subscription recorded"
|
|
462
|
+
: "Awaiting confirmation"}
|
|
463
|
+
<br />
|
|
464
|
+
{purchase.cancelMode && `Cancellation: ${purchase.cancelMode}`}
|
|
465
|
+
<br />
|
|
466
|
+
{purchase.lastError}
|
|
467
|
+
</td>
|
|
468
|
+
</tr>
|
|
469
|
+
))}
|
|
470
|
+
</tbody>
|
|
471
|
+
</table>
|
|
472
|
+
</div>
|
|
473
|
+
{!purchases.length && (
|
|
474
|
+
<p className="text-sm text-kumo-subtle">No purchases recorded.</p>
|
|
475
|
+
)}
|
|
476
|
+
{purchaseCursor && (
|
|
477
|
+
<button
|
|
478
|
+
className={buttonClass}
|
|
479
|
+
disabled={busy}
|
|
480
|
+
onClick={() =>
|
|
481
|
+
void run(async () => {
|
|
482
|
+
const page = await postPaymentRoute<{
|
|
483
|
+
items: AdminPurchase[];
|
|
484
|
+
nextCursor?: string;
|
|
485
|
+
}>("subscriptions/purchases/list", { cursor: purchaseCursor });
|
|
486
|
+
setPurchases([...purchases, ...page.items]);
|
|
487
|
+
setPurchaseCursor(page.nextCursor);
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
>
|
|
491
|
+
Load more purchases
|
|
492
|
+
</button>
|
|
493
|
+
)}
|
|
494
|
+
</section>
|
|
495
|
+
<section className="space-y-4">
|
|
496
|
+
<h2 className="text-lg font-semibold">Subscriptions and funding</h2>
|
|
395
497
|
<p className="text-sm text-kumo-subtle">
|
|
396
498
|
Failed renewals keep access until the end of the paid period. Cancellation takes
|
|
397
499
|
effect at period end.
|
|
@@ -410,8 +512,18 @@ export function PaymentSubscriptionsPage() {
|
|
|
410
512
|
<tbody>
|
|
411
513
|
{members.map((member) => (
|
|
412
514
|
<tr key={member.id} className="border-t border-kumo-line">
|
|
413
|
-
<td className="p-2">
|
|
414
|
-
|
|
515
|
+
<td className="p-2">
|
|
516
|
+
{member.subject
|
|
517
|
+
? `${member.subject.realm}: ${member.subject.id}`
|
|
518
|
+
: member.userId}
|
|
519
|
+
</td>
|
|
520
|
+
<td className="p-2">
|
|
521
|
+
{member.planCode || "Dynamic purchase"}
|
|
522
|
+
<br />
|
|
523
|
+
<span className="text-xs text-kumo-subtle">
|
|
524
|
+
{member.purchaseReference}
|
|
525
|
+
</span>
|
|
526
|
+
</td>
|
|
415
527
|
<td className="p-2">{member.status}</td>
|
|
416
528
|
<td className="p-2">
|
|
417
529
|
{new Date(member.verifiedAt).toLocaleString()}
|
|
@@ -441,7 +553,7 @@ export function PaymentSubscriptionsPage() {
|
|
|
441
553
|
onClick={() =>
|
|
442
554
|
void run(async () => {
|
|
443
555
|
const page = await postPaymentRoute<{
|
|
444
|
-
items:
|
|
556
|
+
items: AdminSubscription[];
|
|
445
557
|
nextCursor?: string;
|
|
446
558
|
}>("subscriptions/list", { cursor });
|
|
447
559
|
setMembers([...members, ...page.items]);
|
|
@@ -457,11 +569,31 @@ export function PaymentSubscriptionsPage() {
|
|
|
457
569
|
<h3 className="font-semibold">{detail.subscription.id}</h3>
|
|
458
570
|
<dl className="grid gap-2 text-sm">
|
|
459
571
|
<dt>Member</dt>
|
|
460
|
-
<dd>
|
|
572
|
+
<dd>
|
|
573
|
+
{detail.subject
|
|
574
|
+
? `${detail.subject.realm}: ${detail.subject.id} · ${detail.subject.email}`
|
|
575
|
+
: detail.subscription.userId}
|
|
576
|
+
</dd>
|
|
577
|
+
<dt>Purchase reference</dt>
|
|
578
|
+
<dd>{detail.purchase?.reference ?? "Catalogue subscription"}</dd>
|
|
461
579
|
<dt>Paid access until</dt>
|
|
462
580
|
<dd>
|
|
463
|
-
{detail.
|
|
464
|
-
|
|
581
|
+
{detail.periods.some(
|
|
582
|
+
(period) =>
|
|
583
|
+
period.eligible &&
|
|
584
|
+
period.startsAt * 1000 <= Date.now() &&
|
|
585
|
+
period.endsAt * 1000 > Date.now(),
|
|
586
|
+
)
|
|
587
|
+
? new Date(
|
|
588
|
+
Math.max(
|
|
589
|
+
...detail.periods
|
|
590
|
+
.filter(
|
|
591
|
+
(period) =>
|
|
592
|
+
period.eligible && period.startsAt * 1000 <= Date.now(),
|
|
593
|
+
)
|
|
594
|
+
.map((period) => period.endsAt),
|
|
595
|
+
) * 1000,
|
|
596
|
+
).toLocaleString()
|
|
465
597
|
: "No current paid period"}
|
|
466
598
|
</dd>
|
|
467
599
|
<dt>Cancellation</dt>
|
|
@@ -469,7 +601,9 @@ export function PaymentSubscriptionsPage() {
|
|
|
469
601
|
{detail.subscription.cancelAtPeriodEnd
|
|
470
602
|
? "At period end: " +
|
|
471
603
|
new Date(detail.subscription.periodEnd * 1000).toLocaleString()
|
|
472
|
-
:
|
|
604
|
+
: detail.subscription.status === "canceled"
|
|
605
|
+
? "Canceled"
|
|
606
|
+
: "Not scheduled"}
|
|
473
607
|
</dd>
|
|
474
608
|
<dt>Last synchronization</dt>
|
|
475
609
|
<dd>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { Database } from "dineway";
|
|
2
2
|
import type { Kysely, Selectable, Transaction, Updateable } from "kysely";
|
|
3
3
|
|
|
4
|
+
import type { PaymentDatabase, PaymentTables } from "../database/types.js";
|
|
4
5
|
import type { PaymentStripeSettings, UpdatePaymentStripeSettings } from "./types.js";
|
|
5
6
|
|
|
6
7
|
type PaymentDb = Kysely<Database> | Transaction<Database>;
|
|
7
|
-
type SettingsRow = Selectable<
|
|
8
|
+
type SettingsRow = Selectable<PaymentDatabase["payment_stripe_settings"]>;
|
|
8
9
|
|
|
9
10
|
export class PaymentStripeConfigurationRepository {
|
|
10
|
-
|
|
11
|
+
private readonly db: Kysely<PaymentDatabase> | Transaction<PaymentDatabase>;
|
|
12
|
+
constructor(db: PaymentDb) {
|
|
13
|
+
this.db = db.withTables<PaymentTables>();
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
async ensureDefaults(): Promise<PaymentStripeSettings> {
|
|
13
17
|
await this.db
|
|
@@ -32,7 +36,7 @@ export class PaymentStripeConfigurationRepository {
|
|
|
32
36
|
expectedVersion: number,
|
|
33
37
|
input: UpdatePaymentStripeSettings,
|
|
34
38
|
): Promise<PaymentStripeSettings> {
|
|
35
|
-
const update: Updateable<
|
|
39
|
+
const update: Updateable<PaymentDatabase["payment_stripe_settings"]> = {
|
|
36
40
|
...(input.mode === undefined ? {} : { mode: input.mode }),
|
|
37
41
|
...(input.publishableKey === undefined ? {} : { publishable_key: input.publishableKey }),
|
|
38
42
|
...(input.configurationStatus === undefined
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PluginMigration } from "dineway";
|
|
2
|
+
|
|
3
|
+
import * as configuration from "./migrations/001_stripe_configuration.js";
|
|
4
|
+
import * as subscriptions from "./migrations/002_subscriptions.js";
|
|
5
|
+
import * as purchases from "./migrations/003_purchases.js";
|
|
6
|
+
|
|
7
|
+
export const paymentMigrations = {
|
|
8
|
+
"001_stripe_configuration": configuration,
|
|
9
|
+
"002_subscriptions": subscriptions,
|
|
10
|
+
"003_purchases": purchases,
|
|
11
|
+
} satisfies Record<string, PluginMigration>;
|
|
12
|
+
export type { PaymentDatabase, PaymentTables } from "./types.js";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { currentTimestamp } from "dineway";
|
|
2
|
+
import type { Kysely } from "kysely";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
|
|
5
|
+
import { schemaExists } from "./adoption.js";
|
|
6
|
+
|
|
7
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
8
|
+
if (
|
|
9
|
+
await schemaExists(db, {
|
|
10
|
+
payment_stripe_settings: [
|
|
11
|
+
"id",
|
|
12
|
+
"default_key",
|
|
13
|
+
"mode",
|
|
14
|
+
"publishable_key",
|
|
15
|
+
"configuration_status",
|
|
16
|
+
"stripe_account_id",
|
|
17
|
+
"charges_enabled",
|
|
18
|
+
"details_submitted",
|
|
19
|
+
"tax_status",
|
|
20
|
+
"validation_error_code",
|
|
21
|
+
"last_validated_at",
|
|
22
|
+
"connect_status",
|
|
23
|
+
"version",
|
|
24
|
+
"created_at",
|
|
25
|
+
"updated_at",
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
)
|
|
29
|
+
return;
|
|
30
|
+
await db.schema
|
|
31
|
+
.createTable("payment_stripe_settings")
|
|
32
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
33
|
+
.addColumn("default_key", "text", (col) => col.notNull().unique().defaultTo("default"))
|
|
34
|
+
.addColumn("mode", "text", (col) =>
|
|
35
|
+
col
|
|
36
|
+
.notNull()
|
|
37
|
+
.check(sql`mode in ('test', 'live')`)
|
|
38
|
+
.defaultTo("test"),
|
|
39
|
+
)
|
|
40
|
+
.addColumn("publishable_key", "text")
|
|
41
|
+
.addColumn("configuration_status", "text", (col) =>
|
|
42
|
+
col
|
|
43
|
+
.notNull()
|
|
44
|
+
.check(sql`configuration_status in ('unconfigured', 'unverified', 'valid', 'invalid')`)
|
|
45
|
+
.defaultTo("unconfigured"),
|
|
46
|
+
)
|
|
47
|
+
.addColumn("stripe_account_id", "text")
|
|
48
|
+
.addColumn("charges_enabled", "integer", (col) =>
|
|
49
|
+
col.check(sql`charges_enabled is null or charges_enabled in (0, 1)`),
|
|
50
|
+
)
|
|
51
|
+
.addColumn("details_submitted", "integer", (col) =>
|
|
52
|
+
col.check(sql`details_submitted is null or details_submitted in (0, 1)`),
|
|
53
|
+
)
|
|
54
|
+
.addColumn("tax_status", "text", (col) =>
|
|
55
|
+
col
|
|
56
|
+
.notNull()
|
|
57
|
+
.check(sql`tax_status in ('unknown', 'active', 'pending', 'unavailable')`)
|
|
58
|
+
.defaultTo("unknown"),
|
|
59
|
+
)
|
|
60
|
+
.addColumn("validation_error_code", "text")
|
|
61
|
+
.addColumn("last_validated_at", "text")
|
|
62
|
+
.addColumn("connect_status", "text", (col) =>
|
|
63
|
+
col
|
|
64
|
+
.notNull()
|
|
65
|
+
.check(sql`connect_status in ('not_configured', 'restricted', 'ready')`)
|
|
66
|
+
.defaultTo("not_configured"),
|
|
67
|
+
)
|
|
68
|
+
.addColumn("version", "integer", (col) => col.notNull().defaultTo(1))
|
|
69
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo(currentTimestamp(db)))
|
|
70
|
+
.addColumn("updated_at", "text", (col) => col.notNull().defaultTo(currentTimestamp(db)))
|
|
71
|
+
.addCheckConstraint("ck_payment_stripe_settings_default_key", sql`default_key = 'default'`)
|
|
72
|
+
.execute();
|
|
73
|
+
|
|
74
|
+
const tables = await db.introspection.getTables();
|
|
75
|
+
if (tables.some((table) => table.name === "restaurant_stripe_settings"))
|
|
76
|
+
await sql`
|
|
77
|
+
INSERT INTO payment_stripe_settings (
|
|
78
|
+
id, default_key, mode, publishable_key, configuration_status,
|
|
79
|
+
stripe_account_id, charges_enabled, details_submitted, tax_status,
|
|
80
|
+
validation_error_code, last_validated_at, connect_status, version,
|
|
81
|
+
created_at, updated_at
|
|
82
|
+
)
|
|
83
|
+
SELECT
|
|
84
|
+
id, default_key, mode, publishable_key, configuration_status,
|
|
85
|
+
stripe_account_id, charges_enabled, details_submitted, tax_status,
|
|
86
|
+
validation_error_code, last_validated_at, connect_status, version,
|
|
87
|
+
created_at, updated_at
|
|
88
|
+
FROM restaurant_stripe_settings
|
|
89
|
+
`.execute(db);
|
|
90
|
+
|
|
91
|
+
await db.schema
|
|
92
|
+
.createIndex("idx_payment_stripe_settings_updated_at")
|
|
93
|
+
.on("payment_stripe_settings")
|
|
94
|
+
.column("updated_at")
|
|
95
|
+
.execute();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
99
|
+
await db.schema.dropTable("payment_stripe_settings").execute();
|
|
100
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
import { schemaExists } from "./adoption.js";
|
|
4
|
+
|
|
5
|
+
const tables = [
|
|
6
|
+
"payment_subscription_plans",
|
|
7
|
+
"payment_subscription_prices",
|
|
8
|
+
"payment_subscription_customers",
|
|
9
|
+
"payment_member_subscriptions",
|
|
10
|
+
"payment_subscription_periods",
|
|
11
|
+
"payment_subscription_events",
|
|
12
|
+
"payment_subscription_attempts",
|
|
13
|
+
"payment_subscription_locks",
|
|
14
|
+
"payment_subscription_portals",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
18
|
+
if (
|
|
19
|
+
await schemaExists(db, {
|
|
20
|
+
payment_subscription_plans: ["scope", "id", "name", "enabled", "entitlements", "version"],
|
|
21
|
+
payment_subscription_prices: [
|
|
22
|
+
"scope",
|
|
23
|
+
"id",
|
|
24
|
+
"plan_code",
|
|
25
|
+
"product_id",
|
|
26
|
+
"currency",
|
|
27
|
+
"amount",
|
|
28
|
+
"interval",
|
|
29
|
+
"enabled",
|
|
30
|
+
],
|
|
31
|
+
payment_subscription_customers: ["scope", "id", "customer_id"],
|
|
32
|
+
payment_member_subscriptions: [
|
|
33
|
+
"scope",
|
|
34
|
+
"id",
|
|
35
|
+
"user_id",
|
|
36
|
+
"customer_id",
|
|
37
|
+
"plan_code",
|
|
38
|
+
"price_id",
|
|
39
|
+
"status",
|
|
40
|
+
"period_end",
|
|
41
|
+
"cancel_at_period_end",
|
|
42
|
+
"verified_at",
|
|
43
|
+
"last_error",
|
|
44
|
+
],
|
|
45
|
+
payment_subscription_periods: [
|
|
46
|
+
"scope",
|
|
47
|
+
"id",
|
|
48
|
+
"subscription_id",
|
|
49
|
+
"invoice_id",
|
|
50
|
+
"plan_code",
|
|
51
|
+
"starts_at",
|
|
52
|
+
"ends_at",
|
|
53
|
+
],
|
|
54
|
+
payment_subscription_events: ["scope", "id", "received_at"],
|
|
55
|
+
payment_subscription_attempts: [
|
|
56
|
+
"scope",
|
|
57
|
+
"id",
|
|
58
|
+
"fingerprint",
|
|
59
|
+
"request_id",
|
|
60
|
+
"session_id",
|
|
61
|
+
"created_at",
|
|
62
|
+
],
|
|
63
|
+
payment_subscription_locks: ["scope", "id", "token", "expires_at"],
|
|
64
|
+
payment_subscription_portals: ["scope", "id", "configuration_id"],
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
return;
|
|
68
|
+
const base = (table: (typeof tables)[number]) =>
|
|
69
|
+
db.schema
|
|
70
|
+
.createTable(table)
|
|
71
|
+
.addColumn("scope", "text", (col) => col.notNull())
|
|
72
|
+
.addColumn("id", "text", (col) => col.notNull())
|
|
73
|
+
.addPrimaryKeyConstraint("pk_" + table, ["scope", "id"]);
|
|
74
|
+
await base("payment_subscription_plans")
|
|
75
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
76
|
+
.addColumn("enabled", "integer", (col) => col.notNull().defaultTo(0))
|
|
77
|
+
.addColumn("entitlements", "text", (col) => col.notNull())
|
|
78
|
+
.addColumn("version", "integer", (col) => col.notNull().defaultTo(1))
|
|
79
|
+
.execute();
|
|
80
|
+
await base("payment_subscription_prices")
|
|
81
|
+
.addColumn("plan_code", "text", (col) => col.notNull())
|
|
82
|
+
.addColumn("product_id", "text", (col) => col.notNull())
|
|
83
|
+
.addColumn("currency", "text", (col) => col.notNull())
|
|
84
|
+
.addColumn("amount", "integer", (col) => col.notNull())
|
|
85
|
+
.addColumn("interval", "text", (col) => col.notNull())
|
|
86
|
+
.addColumn("enabled", "integer", (col) => col.notNull().defaultTo(1))
|
|
87
|
+
.addForeignKeyConstraint(
|
|
88
|
+
"fk_payment_price_plan",
|
|
89
|
+
["scope", "plan_code"],
|
|
90
|
+
"payment_subscription_plans",
|
|
91
|
+
["scope", "id"],
|
|
92
|
+
)
|
|
93
|
+
.execute();
|
|
94
|
+
await base("payment_subscription_customers")
|
|
95
|
+
.addColumn("customer_id", "text", (col) => col.notNull())
|
|
96
|
+
.addUniqueConstraint("uq_payment_subscription_customer", ["scope", "customer_id"])
|
|
97
|
+
.execute();
|
|
98
|
+
await base("payment_member_subscriptions")
|
|
99
|
+
.addColumn("user_id", "text", (col) => col.notNull())
|
|
100
|
+
.addColumn("customer_id", "text", (col) => col.notNull())
|
|
101
|
+
.addColumn("plan_code", "text", (col) => col.notNull())
|
|
102
|
+
.addColumn("price_id", "text", (col) => col.notNull())
|
|
103
|
+
.addColumn("status", "text", (col) => col.notNull())
|
|
104
|
+
.addColumn("period_end", "integer", (col) => col.notNull())
|
|
105
|
+
.addColumn("cancel_at_period_end", "integer", (col) => col.notNull())
|
|
106
|
+
.addColumn("verified_at", "integer", (col) => col.notNull())
|
|
107
|
+
.addColumn("last_error", "text")
|
|
108
|
+
.addForeignKeyConstraint(
|
|
109
|
+
"fk_payment_subscription_user",
|
|
110
|
+
["scope", "user_id"],
|
|
111
|
+
"payment_subscription_customers",
|
|
112
|
+
["scope", "id"],
|
|
113
|
+
)
|
|
114
|
+
.addForeignKeyConstraint(
|
|
115
|
+
"fk_payment_subscription_price",
|
|
116
|
+
["scope", "price_id"],
|
|
117
|
+
"payment_subscription_prices",
|
|
118
|
+
["scope", "id"],
|
|
119
|
+
)
|
|
120
|
+
.execute();
|
|
121
|
+
await base("payment_subscription_periods")
|
|
122
|
+
.addColumn("subscription_id", "text", (col) => col.notNull())
|
|
123
|
+
.addColumn("invoice_id", "text", (col) => col.notNull())
|
|
124
|
+
.addColumn("plan_code", "text", (col) => col.notNull())
|
|
125
|
+
.addColumn("starts_at", "integer", (col) => col.notNull())
|
|
126
|
+
.addColumn("ends_at", "integer", (col) => col.notNull())
|
|
127
|
+
.addForeignKeyConstraint(
|
|
128
|
+
"fk_payment_period_subscription",
|
|
129
|
+
["scope", "subscription_id"],
|
|
130
|
+
"payment_member_subscriptions",
|
|
131
|
+
["scope", "id"],
|
|
132
|
+
)
|
|
133
|
+
.execute();
|
|
134
|
+
await base("payment_subscription_events")
|
|
135
|
+
.addColumn("received_at", "integer", (col) => col.notNull())
|
|
136
|
+
.execute();
|
|
137
|
+
await base("payment_subscription_attempts")
|
|
138
|
+
.addColumn("fingerprint", "text", (col) => col.notNull())
|
|
139
|
+
.addColumn("request_id", "text", (col) => col.notNull())
|
|
140
|
+
.addColumn("session_id", "text")
|
|
141
|
+
.addColumn("created_at", "integer", (col) => col.notNull())
|
|
142
|
+
.execute();
|
|
143
|
+
await base("payment_subscription_locks")
|
|
144
|
+
.addColumn("token", "text", (col) => col.notNull())
|
|
145
|
+
.addColumn("expires_at", "integer", (col) => col.notNull())
|
|
146
|
+
.execute();
|
|
147
|
+
for (const [table, column] of [
|
|
148
|
+
// Composite indexes keep reads within the configured account and application.
|
|
149
|
+
["payment_subscription_prices", "plan_code"],
|
|
150
|
+
["payment_member_subscriptions", "user_id"],
|
|
151
|
+
["payment_member_subscriptions", "price_id"],
|
|
152
|
+
["payment_subscription_periods", "subscription_id"],
|
|
153
|
+
["payment_subscription_periods", "invoice_id"],
|
|
154
|
+
] as const) {
|
|
155
|
+
await db.schema
|
|
156
|
+
.createIndex("idx_" + table + "_" + column)
|
|
157
|
+
.on(table)
|
|
158
|
+
.columns(["scope", column])
|
|
159
|
+
.execute();
|
|
160
|
+
}
|
|
161
|
+
await base("payment_subscription_portals")
|
|
162
|
+
.addColumn("configuration_id", "text", (col) => col.notNull())
|
|
163
|
+
.execute();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
167
|
+
for (const table of tables.toReversed()) await db.schema.dropTable(table).execute();
|
|
168
|
+
}
|