@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.
@@ -8,6 +8,7 @@ import {
8
8
  type SubscriptionPlan,
9
9
  type SubscriptionPrice,
10
10
  type SubscriptionScope,
11
+ type SubscriptionPurchase,
11
12
  } from "./types.js";
12
13
 
13
14
  export function recurringPrice(
@@ -76,30 +77,53 @@ export function subscriptionProjection(
76
77
  userId: string,
77
78
  customerId: string,
78
79
  now: number,
80
+ purchase?: SubscriptionPurchase | null,
79
81
  ): MemberSubscription {
80
82
  ownedSubscription(subscription, scope, userId, customerId);
81
83
  const item = subscription.items.data[0];
82
84
  const plan = plans.find((value) => value.prices.some((price) => price.id === item?.price.id));
83
85
  if (
84
- !plan ||
86
+ (!plan && purchase?.contract.pricing.kind !== "dynamic") ||
85
87
  subscription.items.has_more ||
86
88
  subscription.items.data.length !== 1 ||
87
89
  item?.quantity !== 1
88
90
  ) {
89
91
  throw new SubscriptionError("UNRECOGNIZED_SUBSCRIPTION", "Subscription requires review", 409);
90
92
  }
91
- recurringPrice(item.price, scope, false);
93
+ if (purchase) {
94
+ const { contract } = purchase;
95
+ if (
96
+ purchase.userId !== userId ||
97
+ subscription.metadata.purchase_reference !== purchase.reference ||
98
+ (purchase.subscriptionId && purchase.subscriptionId !== subscription.id) ||
99
+ item.price.livemode !== (scope.mode === "live") ||
100
+ item.price.currency !== contract.currency ||
101
+ item.price.unit_amount !== contract.amount ||
102
+ item.price.recurring?.interval !== contract.interval ||
103
+ item.price.recurring.interval_count !== contract.intervalCount ||
104
+ item.price.recurring.usage_type !== "licensed" ||
105
+ item.price.billing_scheme !== "per_unit" ||
106
+ item.price.transform_quantity ||
107
+ (contract.priceId && item.price.id !== contract.priceId)
108
+ )
109
+ throw new SubscriptionError(
110
+ "UNRECOGNIZED_SUBSCRIPTION",
111
+ "Subscription does not match its purchase",
112
+ 409,
113
+ );
114
+ } else recurringPrice(item.price, scope, false);
92
115
  return {
93
116
  id: subscription.id,
94
117
  userId,
95
118
  customerId,
96
- planCode: plan.code,
119
+ planCode: purchase?.contract.pricing.kind === "dynamic" ? "" : plan!.code,
97
120
  priceId: item.price.id,
98
- status: subscription.status,
121
+ status: subscription.pause_collection ? "paused" : subscription.status,
99
122
  periodEnd: item.current_period_end,
100
123
  cancelAtPeriodEnd: subscription.cancel_at_period_end,
101
124
  verifiedAt: now,
102
125
  lastError: null,
126
+ purchaseReference: purchase?.reference ?? null,
103
127
  };
104
128
  }
105
129
 
@@ -134,7 +158,7 @@ export function invoicePeriods(
134
158
  !details ||
135
159
  details.subscription !== subscription.id ||
136
160
  details.proration ||
137
- !plan ||
161
+ (!plan && !subscription.purchaseReference) ||
138
162
  line.quantity !== 1 ||
139
163
  line.amount <= 0
140
164
  )
@@ -150,7 +174,7 @@ export function invoicePeriods(
150
174
  id: invoice.id + ":" + line.id,
151
175
  invoiceId: invoice.id,
152
176
  subscriptionId: subscription.id,
153
- planCode: plan.code,
177
+ planCode: plan?.code ?? "",
154
178
  startsAt: line.period.start,
155
179
  endsAt: line.period.end,
156
180
  },
@@ -166,7 +190,15 @@ export function memberProjection(
166
190
  now: number,
167
191
  ): MemberBilling {
168
192
  const valid = periods.filter(
169
- (period) => period.startsAt * 1000 <= now && period.endsAt * 1000 > now,
193
+ (period) =>
194
+ period.eligible === true &&
195
+ period.startsAt * 1000 <= now &&
196
+ period.endsAt * 1000 > now &&
197
+ subscriptions.some(
198
+ (subscription) =>
199
+ subscription.id === period.subscriptionId &&
200
+ ["active", "past_due", "canceled"].includes(subscription.status),
201
+ ),
170
202
  );
171
203
  const entitlements: Record<string, boolean | number> = {};
172
204
  for (const period of valid) {
@@ -0,0 +1,329 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type Stripe from "stripe";
4
+
5
+ import { recurringPrice } from "./projection.js";
6
+ import type { SubscriptionRepository } from "./repository.js";
7
+ import { purchaseSchema } from "./schemas.js";
8
+ import {
9
+ SubscriptionError,
10
+ type BillingSubject,
11
+ type PurchaseContract,
12
+ type SubscriptionPurchaseInput,
13
+ type SubscriptionPurchase,
14
+ type SubscriptionScope,
15
+ } from "./types.js";
16
+
17
+ export const digest = (value: unknown) =>
18
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
19
+ export const relationId = (value: string | { id: string } | null | undefined) =>
20
+ typeof value === "string" ? value : (value?.id ?? null);
21
+ export interface PurchaseContext {
22
+ stripe: Stripe;
23
+ repository: SubscriptionRepository;
24
+ scope: SubscriptionScope;
25
+ now: () => number;
26
+ origin: string;
27
+ }
28
+ const idempotency = (context: PurchaseContext, ...values: unknown[]) =>
29
+ "dineway:" + digest([context.repository.scope, ...values]);
30
+ const purchaseInput = ({
31
+ reference,
32
+ groupKey,
33
+ pricing,
34
+ firstPeriodCredit,
35
+ returnPath,
36
+ locale,
37
+ }: SubscriptionPurchaseInput) =>
38
+ purchaseSchema.parse({ reference, groupKey, pricing, firstPeriodCredit, returnPath, locale });
39
+ export function purchaseMetadata(context: PurchaseContext, userId: string, reference: string) {
40
+ return { dineway_app: context.scope.namespace, user_id: userId, purchase_reference: reference };
41
+ }
42
+
43
+ async function customer(
44
+ context: PurchaseContext,
45
+ store: SubscriptionRepository,
46
+ userId: string,
47
+ subject: BillingSubject,
48
+ ) {
49
+ const existing = await store.customer(userId);
50
+ if (existing) return existing;
51
+ const attemptKey = "customer:" + userId;
52
+ const attempt = await store.attempt(attemptKey);
53
+ if (attempt) {
54
+ const listed = await context.stripe.customers.list({ email: attempt.fingerprint, limit: 100 });
55
+ if (listed.has_more)
56
+ throw new SubscriptionError(
57
+ "CUSTOMER_REVIEW_REQUIRED",
58
+ "Customer recovery exceeds its safe limit",
59
+ 409,
60
+ );
61
+ const matches = listed.data.filter(
62
+ (value) =>
63
+ value.metadata.dineway_app === context.scope.namespace &&
64
+ value.metadata.user_id === userId &&
65
+ value.livemode === (context.scope.mode === "live"),
66
+ );
67
+ if (matches.length > 1)
68
+ throw new SubscriptionError(
69
+ "CUSTOMER_REVIEW_REQUIRED",
70
+ "Multiple customers match this billing subject",
71
+ 409,
72
+ );
73
+ if (matches[0]) {
74
+ await store.saveCustomer(userId, matches[0].id, subject);
75
+ return matches[0].id;
76
+ }
77
+ }
78
+ if (attempt && context.now() - attempt.created_at > 23 * 3600000) {
79
+ throw new SubscriptionError(
80
+ "CUSTOMER_REVIEW_REQUIRED",
81
+ "An unconfirmed customer creation requires reconciliation",
82
+ 409,
83
+ );
84
+ }
85
+ // Pin the email as part of the request so a retry cannot change Stripe's idempotent payload.
86
+ const email = attempt ? attempt.fingerprint : subject.email;
87
+ if (!attempt)
88
+ await store.saveAttempt(attemptKey, {
89
+ fingerprint: email,
90
+ request_id: userId,
91
+ created_at: context.now(),
92
+ session_id: null,
93
+ });
94
+ const created = await context.stripe.customers.create(
95
+ { email, metadata: { dineway_app: context.scope.namespace, user_id: userId } },
96
+ { idempotencyKey: idempotency(context, "customer", userId) },
97
+ );
98
+ if (created.livemode !== (context.scope.mode === "live"))
99
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid customer mode");
100
+ await store.saveCustomer(userId, created.id, subject);
101
+ return created.id;
102
+ }
103
+
104
+ export async function createPurchase(
105
+ context: PurchaseContext,
106
+ userId: string,
107
+ subject: BillingSubject,
108
+ input: SubscriptionPurchaseInput,
109
+ ) {
110
+ const parsed = purchaseSchema.parse(input);
111
+ return context.repository.withLock("member:" + userId, async (store) => {
112
+ const previous = await store.purchase(parsed.reference);
113
+ if (previous) {
114
+ if (previous.userId !== userId)
115
+ throw new SubscriptionError("NOT_FOUND", "Purchase not found", 404);
116
+ if (digest(purchaseInput(previous.contract)) !== digest(parsed))
117
+ throw new SubscriptionError(
118
+ "PURCHASE_CONFLICT",
119
+ "A purchase reference cannot change its contract",
120
+ 409,
121
+ );
122
+ return resumePurchase(context, store, previous);
123
+ }
124
+ const open = await store.openPurchase(userId, parsed.groupKey);
125
+ if (open)
126
+ throw new SubscriptionError(
127
+ "SUBSCRIPTION_EXISTS",
128
+ "Resolve the existing purchase before starting another",
129
+ 409,
130
+ );
131
+ let contract: PurchaseContract;
132
+ if (parsed.pricing.kind === "catalogue") {
133
+ const pricing = parsed.pricing;
134
+ const plan = (await store.plans()).find(
135
+ (value) => value.enabled && value.code === pricing.planCode,
136
+ );
137
+ const price = plan?.prices.find(
138
+ (value) => value.enabled && value.interval === pricing.interval,
139
+ );
140
+ if (!price) throw new SubscriptionError("PLAN_UNAVAILABLE", "This plan is unavailable", 409);
141
+ const verified = recurringPrice(
142
+ await context.stripe.prices.retrieve(price.id),
143
+ context.scope,
144
+ );
145
+ if (
146
+ verified.amount !== price.amount ||
147
+ verified.currency !== price.currency ||
148
+ verified.productId !== price.productId ||
149
+ verified.interval !== price.interval
150
+ )
151
+ throw new SubscriptionError("PRICE_CHANGED", "Refresh the plan price before checkout", 409);
152
+ contract = {
153
+ ...parsed,
154
+ amount: price.amount,
155
+ currency: price.currency,
156
+ interval: price.interval,
157
+ intervalCount: 1,
158
+ priceId: price.id,
159
+ couponId: null,
160
+ };
161
+ } else {
162
+ contract = {
163
+ ...parsed,
164
+ amount: parsed.pricing.amount,
165
+ currency: parsed.pricing.currency,
166
+ interval: parsed.pricing.interval,
167
+ intervalCount: parsed.pricing.intervalCount,
168
+ priceId: null,
169
+ couponId: null,
170
+ };
171
+ }
172
+ if (
173
+ parsed.firstPeriodCredit > contract.amount ||
174
+ (parsed.pricing.kind === "catalogue" && parsed.firstPeriodCredit)
175
+ )
176
+ throw new SubscriptionError(
177
+ "INVALID_CREDIT",
178
+ "Credit must fit a dynamic purchase's first invoice",
179
+ );
180
+ if (parsed.firstPeriodCredit)
181
+ contract.couponId = "dw_" + digest([store.scope, parsed.reference, "credit"]);
182
+ await customer(context, store, userId, subject);
183
+ await store.savePurchase(
184
+ userId,
185
+ contract,
186
+ digest(parsed),
187
+ Math.floor(context.now() / 1000) + 3600,
188
+ );
189
+ return resumePurchase(context, store, (await store.purchase(parsed.reference))!);
190
+ });
191
+ }
192
+
193
+ export async function resumePurchase(
194
+ context: PurchaseContext,
195
+ store: SubscriptionRepository,
196
+ purchase: SubscriptionPurchase,
197
+ ): Promise<{ reference: string; url: string | null; status: string }> {
198
+ const customerId = await store.customer(purchase.userId);
199
+ if (!customerId)
200
+ throw new SubscriptionError("CUSTOMER_NOT_FOUND", "No billing account exists", 404);
201
+ const { contract } = purchase;
202
+ let session: Stripe.Checkout.Session | undefined;
203
+ if (purchase.sessionId)
204
+ session = await context.stripe.checkout.sessions.retrieve(purchase.sessionId);
205
+ else {
206
+ const sessions = await context.stripe.checkout.sessions.list({
207
+ customer: customerId,
208
+ limit: 100,
209
+ });
210
+ if (sessions.has_more)
211
+ throw new SubscriptionError(
212
+ "REVIEW_REQUIRED",
213
+ "Checkout recovery exceeds its safe limit",
214
+ 409,
215
+ );
216
+ const matches = sessions.data.filter(
217
+ (value) =>
218
+ value.metadata?.dineway_app === context.scope.namespace &&
219
+ value.metadata.purchase_reference === purchase.reference,
220
+ );
221
+ if (matches.length > 1)
222
+ throw new SubscriptionError("REVIEW_REQUIRED", "Purchase has multiple checkouts", 409);
223
+ session = matches[0];
224
+ }
225
+ if (!session) {
226
+ if (
227
+ purchase.closed ||
228
+ purchase.cancelMode ||
229
+ purchase.expiresAt < Math.floor(context.now() / 1000) + 1800
230
+ )
231
+ throw new SubscriptionError(
232
+ "CHECKOUT_REVIEW_REQUIRED",
233
+ "The checkout outcome must be confirmed before retrying",
234
+ 409,
235
+ );
236
+ const metadata = purchaseMetadata(context, purchase.userId, purchase.reference);
237
+ if (contract.couponId) {
238
+ const coupon = await context.stripe.coupons.create(
239
+ {
240
+ id: contract.couponId,
241
+ amount_off: contract.firstPeriodCredit,
242
+ currency: contract.currency,
243
+ duration: "once",
244
+ max_redemptions: 1,
245
+ metadata,
246
+ },
247
+ { idempotencyKey: idempotency(context, "coupon", purchase.reference) },
248
+ );
249
+ if (
250
+ coupon.id !== contract.couponId ||
251
+ coupon.amount_off !== contract.firstPeriodCredit ||
252
+ coupon.currency !== contract.currency ||
253
+ coupon.livemode !== (context.scope.mode === "live") ||
254
+ coupon.duration !== "once" ||
255
+ coupon.max_redemptions !== 1 ||
256
+ coupon.percent_off !== null ||
257
+ Object.entries(metadata).some(([key, value]) => coupon.metadata?.[key] !== value)
258
+ )
259
+ throw new SubscriptionError(
260
+ "INVALID_CREDIT",
261
+ "Credit coupon does not match the purchase",
262
+ 409,
263
+ );
264
+ }
265
+ const returnUrl = new URL(contract.returnPath, context.origin);
266
+ if (returnUrl.origin !== context.origin)
267
+ throw new SubscriptionError("INVALID_RETURN_URL", "Return to this site");
268
+ const successUrl = new URL(returnUrl);
269
+ successUrl.searchParams.set("checkout", "returned");
270
+ const line: Stripe.Checkout.SessionCreateParams.LineItem = contract.priceId
271
+ ? { price: contract.priceId, quantity: 1 }
272
+ : {
273
+ quantity: 1,
274
+ price_data: {
275
+ currency: contract.currency,
276
+ unit_amount: contract.amount,
277
+ recurring: { interval: contract.interval, interval_count: contract.intervalCount },
278
+ product_data: {
279
+ name: contract.pricing.kind === "dynamic" ? contract.pricing.name : "Subscription",
280
+ metadata,
281
+ },
282
+ },
283
+ };
284
+ await store.updatePurchase(purchase.reference, { last_attempt_at: context.now() });
285
+ session = await context.stripe.checkout.sessions.create(
286
+ {
287
+ mode: "subscription",
288
+ customer: customerId,
289
+ line_items: [line],
290
+ payment_method_types: ["card"],
291
+ payment_method_collection: "always",
292
+ client_reference_id: purchase.reference,
293
+ metadata,
294
+ subscription_data: { metadata },
295
+ success_url: successUrl.href,
296
+ cancel_url: returnUrl.href,
297
+ locale: purchaseInput(contract).locale,
298
+ expires_at: purchase.expiresAt,
299
+ allow_promotion_codes: false,
300
+ ...(contract.couponId ? { discounts: [{ coupon: contract.couponId }] } : {}),
301
+ },
302
+ { idempotencyKey: idempotency(context, "checkout", purchase.reference) },
303
+ );
304
+ }
305
+ if (
306
+ relationId(session.customer) !== customerId ||
307
+ session.mode !== "subscription" ||
308
+ session.livemode !== (context.scope.mode === "live") ||
309
+ session.metadata?.dineway_app !== context.scope.namespace ||
310
+ session.metadata.user_id !== purchase.userId ||
311
+ session.metadata.purchase_reference !== purchase.reference ||
312
+ session.client_reference_id !== purchase.reference
313
+ )
314
+ throw new SubscriptionError("OWNER_CONFLICT", "Checkout ownership conflict", 409);
315
+ await store.updatePurchase(purchase.reference, {
316
+ session_id: session.id,
317
+ subscription_id: relationId(session.subscription),
318
+ closed: Number(session.status === "expired"),
319
+ last_error: null,
320
+ last_attempt_at: context.now(),
321
+ });
322
+ if (session.url && new URL(session.url).origin !== "https://checkout.stripe.com")
323
+ throw new SubscriptionError("INVALID_DESTINATION", "Invalid checkout destination", 502);
324
+ return {
325
+ reference: purchase.reference,
326
+ url: session.status === "open" ? session.url : null,
327
+ status: session.status ?? "unknown",
328
+ };
329
+ }
@@ -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
+ }