@dineway-ai/plugin-payment 0.2.5 → 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/subscription-price-picker.tsx +264 -0
- package/src/admin/subscriptions.tsx +184 -59
- 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 +44 -8
- 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 +20 -0
- package/src/subscriptions/schemas.ts +70 -0
- package/src/subscriptions/service.ts +540 -202
- package/src/subscriptions/types.ts +105 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
|
|
4
|
+
import { collectBounded, inspectCharge } from "./funding.js";
|
|
5
|
+
import { digest, relationId, type PurchaseContext } from "./purchases.js";
|
|
6
|
+
import { SubscriptionError } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface SubscriptionRefundInput {
|
|
9
|
+
reference: string;
|
|
10
|
+
chargeId: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
currency: string;
|
|
13
|
+
metadata?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
const schema = z
|
|
16
|
+
.object({
|
|
17
|
+
reference: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(160)
|
|
21
|
+
.regex(/^[A-Za-z0-9_.:-]+$/),
|
|
22
|
+
chargeId: z.string().regex(/^ch_[A-Za-z0-9]+$/),
|
|
23
|
+
amount: z.number().int().min(1).max(99999999),
|
|
24
|
+
currency: z.string().regex(/^[a-z]{3}$/),
|
|
25
|
+
metadata: z
|
|
26
|
+
.record(
|
|
27
|
+
z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(40)
|
|
31
|
+
.regex(/^[a-zA-Z0-9_.-]+$/),
|
|
32
|
+
z.string().max(500),
|
|
33
|
+
)
|
|
34
|
+
.refine((value) => Object.keys(value).length <= 40)
|
|
35
|
+
.default({}),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
|
|
39
|
+
export async function refundCharge(
|
|
40
|
+
context: PurchaseContext,
|
|
41
|
+
userId: string,
|
|
42
|
+
input: SubscriptionRefundInput,
|
|
43
|
+
) {
|
|
44
|
+
const parsed = schema.parse(input);
|
|
45
|
+
const fingerprint = JSON.stringify({ ...parsed, userId });
|
|
46
|
+
const key = "refund:" + parsed.reference;
|
|
47
|
+
return context.repository.withLock("refunds", async (store) => {
|
|
48
|
+
const previous = await store.attempt(key);
|
|
49
|
+
if (previous && previous.fingerprint !== fingerprint)
|
|
50
|
+
throw new SubscriptionError(
|
|
51
|
+
"REFUND_CONFLICT",
|
|
52
|
+
"A refund reference cannot change its amount or owner",
|
|
53
|
+
409,
|
|
54
|
+
);
|
|
55
|
+
const charge = await inspectCharge(context.stripe, context.scope, parsed.chargeId);
|
|
56
|
+
if (!charge.customerId || (await store.customerOwner(charge.customerId)) !== userId)
|
|
57
|
+
throw new SubscriptionError("NOT_FOUND", "Charge not found", 404);
|
|
58
|
+
const metadata = {
|
|
59
|
+
...parsed.metadata,
|
|
60
|
+
dineway_app: context.scope.namespace,
|
|
61
|
+
user_id: userId,
|
|
62
|
+
refund_reference: parsed.reference,
|
|
63
|
+
};
|
|
64
|
+
const known = await collectBounded(
|
|
65
|
+
context.stripe.refunds.list({ charge: parsed.chargeId, limit: 100 }),
|
|
66
|
+
);
|
|
67
|
+
const matches = known.filter(
|
|
68
|
+
(refund) =>
|
|
69
|
+
refund.metadata?.dineway_app === context.scope.namespace &&
|
|
70
|
+
refund.metadata.refund_reference === parsed.reference,
|
|
71
|
+
);
|
|
72
|
+
if (matches.length > 1)
|
|
73
|
+
throw new SubscriptionError(
|
|
74
|
+
"REFUND_REVIEW_REQUIRED",
|
|
75
|
+
"Multiple refunds match the reference",
|
|
76
|
+
409,
|
|
77
|
+
);
|
|
78
|
+
let refund: Stripe.Refund | undefined = previous?.session_id
|
|
79
|
+
? await context.stripe.refunds.retrieve(previous.session_id)
|
|
80
|
+
: matches[0];
|
|
81
|
+
const attempt = previous ?? {
|
|
82
|
+
fingerprint,
|
|
83
|
+
request_id: parsed.reference,
|
|
84
|
+
session_id: null,
|
|
85
|
+
created_at: context.now(),
|
|
86
|
+
};
|
|
87
|
+
if (!refund) {
|
|
88
|
+
if (context.now() - attempt.created_at > 23 * 3600000)
|
|
89
|
+
throw new SubscriptionError(
|
|
90
|
+
"REFUND_REVIEW_REQUIRED",
|
|
91
|
+
"The refund outcome must be confirmed before retrying",
|
|
92
|
+
409,
|
|
93
|
+
);
|
|
94
|
+
const reserved = known
|
|
95
|
+
.filter((value) => !["failed", "canceled"].includes(value.status ?? "unknown"))
|
|
96
|
+
.reduce((total, value) => total + value.amount, 0);
|
|
97
|
+
if (
|
|
98
|
+
!charge.settled ||
|
|
99
|
+
charge.disputes.some((value) => !["won", "warning_closed"].includes(value.status)) ||
|
|
100
|
+
charge.currency !== parsed.currency ||
|
|
101
|
+
parsed.amount > charge.amount - Math.max(reserved, charge.refunded)
|
|
102
|
+
)
|
|
103
|
+
throw new SubscriptionError(
|
|
104
|
+
"REFUND_UNAVAILABLE",
|
|
105
|
+
"The charge has insufficient refundable funds",
|
|
106
|
+
409,
|
|
107
|
+
);
|
|
108
|
+
await store.saveAttempt(key, attempt);
|
|
109
|
+
refund = await context.stripe.refunds.create(
|
|
110
|
+
{ charge: parsed.chargeId, amount: parsed.amount, metadata },
|
|
111
|
+
{ idempotencyKey: "dineway-refund:" + digest([store.scope, parsed.reference]) },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
relationId(refund.charge) !== parsed.chargeId ||
|
|
116
|
+
refund.amount !== parsed.amount ||
|
|
117
|
+
refund.currency !== parsed.currency ||
|
|
118
|
+
Object.entries(metadata).some(([name, value]) => refund.metadata?.[name] !== value)
|
|
119
|
+
)
|
|
120
|
+
throw new SubscriptionError(
|
|
121
|
+
"REFUND_CONFLICT",
|
|
122
|
+
"Refund evidence does not match the request",
|
|
123
|
+
409,
|
|
124
|
+
);
|
|
125
|
+
await store.saveAttempt(key, { ...attempt, session_id: refund.id });
|
|
126
|
+
return {
|
|
127
|
+
id: refund.id,
|
|
128
|
+
amount: refund.amount,
|
|
129
|
+
currency: refund.currency,
|
|
130
|
+
status: refund.status ?? "unknown",
|
|
131
|
+
metadata: refund.metadata ?? {},
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -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
|
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
memberListSchema,
|
|
13
13
|
saveSubscriptionPlanSchema,
|
|
14
14
|
subscriptionIdSchema,
|
|
15
|
+
stripeProductListSchema,
|
|
16
|
+
stripePriceListSchema,
|
|
15
17
|
} from "./schemas.js";
|
|
16
18
|
import type { PaymentSubscriptionService } from "./service.js";
|
|
17
19
|
import { SubscriptionError, type SubscriptionOptions } from "./types.js";
|
|
@@ -95,6 +97,16 @@ export function createSubscriptionRoutes(
|
|
|
95
97
|
"subscriptions/plans/list": privateRoute(empty, "payments:manage", async () => ({
|
|
96
98
|
items: await (await service()).listPlans(true),
|
|
97
99
|
})),
|
|
100
|
+
"subscriptions/products/list": privateRoute(
|
|
101
|
+
stripeProductListSchema,
|
|
102
|
+
"payments:manage",
|
|
103
|
+
async (context) => (await service()).listStripeProducts(context.input),
|
|
104
|
+
),
|
|
105
|
+
"subscriptions/prices/list": privateRoute(
|
|
106
|
+
stripePriceListSchema,
|
|
107
|
+
"payments:manage",
|
|
108
|
+
async (context) => (await service()).listStripePrices(context.input),
|
|
109
|
+
),
|
|
98
110
|
"subscriptions/plans/save": privateRoute(
|
|
99
111
|
saveSubscriptionPlanSchema,
|
|
100
112
|
"payments:manage",
|
|
@@ -106,6 +118,14 @@ export function createSubscriptionRoutes(
|
|
|
106
118
|
"subscriptions/get": privateRoute(subscriptionIdSchema, "payments:manage", async (context) =>
|
|
107
119
|
(await service()).subscriptionDetail(context.input.subscriptionId),
|
|
108
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
|
+
),
|
|
109
129
|
"subscriptions/sync": privateRoute(subscriptionIdSchema, "payments:manage", async (context) => {
|
|
110
130
|
const client = await service();
|
|
111
131
|
await client.subscriptionDetail(context.input.subscriptionId);
|