@dineway-ai/plugin-payment 0.2.3 → 0.2.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dineway-ai/plugin-payment",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Provider-neutral payment infrastructure and Stripe integration for Dineway plugins",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -13,7 +13,8 @@
13
13
  "./configuration": "./src/configuration/index.ts",
14
14
  "./routes": "./src/routes/index.ts",
15
15
  "./stripe": "./src/stripe/index.ts",
16
- "./types": "./src/types.ts"
16
+ "./types": "./src/types.ts",
17
+ "./subscriptions": "./src/subscriptions/index.ts"
17
18
  },
18
19
  "files": [
19
20
  "src"
@@ -28,7 +29,7 @@
28
29
  "license": "MIT",
29
30
  "peerDependencies": {
30
31
  "react": "^18.0.0 || ^19.0.0",
31
- "dineway": "^0.2.19"
32
+ "dineway": "^0.2.29"
32
33
  },
33
34
  "dependencies": {
34
35
  "@stripe/stripe-js": "9.9.0",
@@ -0,0 +1,514 @@
1
+ import * as React from "react";
2
+
3
+ import { formatSubscriptionPrice } from "../subscriptions/format.js";
4
+ import type { getSubscriptionConfiguration } from "../subscriptions/runtime.js";
5
+ import type {
6
+ MemberBilling,
7
+ MemberSubscription,
8
+ SaveSubscriptionPlan,
9
+ SubscriptionPlan,
10
+ } from "../subscriptions/types.js";
11
+ import { postPaymentRoute } from "./api.js";
12
+
13
+ type Configuration = Awaited<ReturnType<typeof getSubscriptionConfiguration>>;
14
+ type Feature = { key: string; kind: "boolean" | "number"; value: string };
15
+ const emptyPlan: SaveSubscriptionPlan = {
16
+ code: "",
17
+ name: "",
18
+ enabled: true,
19
+ expectedVersion: 0,
20
+ entitlements: {},
21
+ monthlyPriceId: null,
22
+ yearlyPriceId: null,
23
+ };
24
+ const inputClass = "w-full rounded-lg border border-kumo-line bg-kumo-base px-3 py-2 text-sm";
25
+ const buttonClass = "rounded-lg border border-kumo-line px-3 py-2 text-sm disabled:opacity-50";
26
+
27
+ export function PaymentSubscriptionsPage() {
28
+ const [configuration, setConfiguration] = React.useState<Configuration | null>(null);
29
+ const [plans, setPlans] = React.useState<SubscriptionPlan[]>([]);
30
+ const [members, setMembers] = React.useState<MemberSubscription[]>([]);
31
+ const [cursor, setCursor] = React.useState<string>();
32
+ const [form, setForm] = React.useState<SaveSubscriptionPlan>({ ...emptyPlan });
33
+ const [features, setFeatures] = React.useState<Feature[]>([]);
34
+ const [webhookSecret, setWebhookSecret] = React.useState("");
35
+ const [detail, setDetail] = React.useState<{
36
+ subscription: MemberSubscription;
37
+ member: MemberBilling;
38
+ } | null>(null);
39
+ const [busy, setBusy] = React.useState(false);
40
+ const [error, setError] = React.useState<string | null>(null);
41
+ const [notice, setNotice] = React.useState<string | null>(null);
42
+ const refresh = React.useCallback(async () => {
43
+ const config = await postPaymentRoute<Configuration>("subscriptions/configuration/get");
44
+ setConfiguration(config);
45
+ if (config.ready) {
46
+ const [catalog, subscriptions] = await Promise.all([
47
+ postPaymentRoute<{ items: SubscriptionPlan[] }>("subscriptions/plans/list"),
48
+ postPaymentRoute<{ items: MemberSubscription[]; nextCursor?: string }>(
49
+ "subscriptions/list",
50
+ ),
51
+ ]);
52
+ setPlans(catalog.items);
53
+ setMembers(subscriptions.items);
54
+ setCursor(subscriptions.nextCursor);
55
+ }
56
+ }, []);
57
+ React.useEffect(() => {
58
+ void refresh().catch((cause) => setError(message(cause)));
59
+ }, [refresh]);
60
+ async function run(action: () => Promise<void>) {
61
+ setBusy(true);
62
+ setError(null);
63
+ setNotice(null);
64
+ try {
65
+ await action();
66
+ } catch (cause) {
67
+ setError(message(cause));
68
+ } finally {
69
+ setBusy(false);
70
+ }
71
+ }
72
+ function edit(plan?: SubscriptionPlan) {
73
+ setForm(
74
+ plan
75
+ ? {
76
+ code: plan.code,
77
+ name: plan.name,
78
+ enabled: plan.enabled,
79
+ expectedVersion: plan.version,
80
+ entitlements: plan.entitlements,
81
+ monthlyPriceId:
82
+ plan.prices.find((price) => price.enabled && price.interval === "month")?.id ?? null,
83
+ yearlyPriceId:
84
+ plan.prices.find((price) => price.enabled && price.interval === "year")?.id ?? null,
85
+ }
86
+ : { ...emptyPlan },
87
+ );
88
+ setFeatures(
89
+ Object.entries(plan?.entitlements ?? {}).map(([key, value]) => ({
90
+ key,
91
+ kind: typeof value === "boolean" ? "boolean" : "number",
92
+ value: String(value),
93
+ })),
94
+ );
95
+ }
96
+ const savePlan = async () => {
97
+ const entitlements: Record<string, boolean | number> = {};
98
+ for (const feature of features) {
99
+ if (Object.hasOwn(entitlements, feature.key))
100
+ throw new Error("Entitlement keys must be unique.");
101
+ if (
102
+ feature.kind === "number" &&
103
+ (!feature.value.trim() ||
104
+ !Number.isFinite(Number(feature.value)) ||
105
+ Number(feature.value) < 0)
106
+ ) {
107
+ throw new Error("Enter a non-negative number for each limit.");
108
+ }
109
+ entitlements[feature.key] =
110
+ feature.kind === "boolean" ? feature.value === "true" : Number(feature.value);
111
+ }
112
+ const result = await postPaymentRoute<{ plan: SubscriptionPlan }>("subscriptions/plans/save", {
113
+ ...form,
114
+ entitlements,
115
+ });
116
+ await refresh();
117
+ edit(result.plan);
118
+ setNotice("Plan saved. Existing subscriptions retain their price bindings.");
119
+ };
120
+ const updateFeature = (index: number, values: Partial<Feature>) =>
121
+ setFeatures((current) =>
122
+ current.map((feature, position) =>
123
+ position === index ? { ...feature, ...values } : feature,
124
+ ),
125
+ );
126
+ const openDetail = async (subscriptionId: string, sync = false) => {
127
+ setDetail(
128
+ await postPaymentRoute(sync ? "subscriptions/sync" : "subscriptions/get", { subscriptionId }),
129
+ );
130
+ if (sync) {
131
+ await refresh();
132
+ setNotice("Subscription synchronized with Stripe.");
133
+ }
134
+ };
135
+ return (
136
+ <div className="space-y-8">
137
+ <header>
138
+ <h1 className="text-2xl font-semibold">Subscriptions</h1>
139
+ <p className="mt-2 text-sm text-kumo-subtle">
140
+ Bind Stripe prices, configure plan entitlements, and inspect paid membership periods.
141
+ </p>
142
+ <a href="/_dineway/admin/payments" className="mt-2 inline-block text-sm underline">
143
+ Stripe account settings
144
+ </a>
145
+ </header>
146
+ {error && (
147
+ <p role="alert" className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">
148
+ {error}
149
+ </p>
150
+ )}
151
+ {notice && (
152
+ <p role="status" className="rounded-lg bg-kumo-success/10 p-3 text-sm">
153
+ {notice}
154
+ </p>
155
+ )}
156
+ {!configuration ? (
157
+ error ? (
158
+ <button className={buttonClass} disabled={busy} onClick={() => void run(refresh)}>
159
+ Retry configuration
160
+ </button>
161
+ ) : (
162
+ <p>Loading subscription configuration…</p>
163
+ )
164
+ ) : (
165
+ <>
166
+ <section className="space-y-4 rounded-xl border border-kumo-line p-5">
167
+ <h2 className="text-lg font-semibold">Subscription webhook</h2>
168
+ <p className="text-sm">
169
+ Application: {configuration.namespace} · {configuration.mode} ·{" "}
170
+ {configuration.ready ? "Ready" : "Configuration required"}
171
+ </p>
172
+ <label className="block text-sm">
173
+ Endpoint
174
+ <input className={inputClass} readOnly value={configuration.webhookUrl} />
175
+ </label>
176
+ <p className="text-sm text-kumo-subtle">
177
+ Create a Stripe webhook for this endpoint using the subscription events in the Payment
178
+ documentation. Its signing secret is separate from other payment webhooks.
179
+ </p>
180
+ <form
181
+ className="flex flex-wrap items-end gap-3"
182
+ onSubmit={(event) => {
183
+ event.preventDefault();
184
+ void run(async () => {
185
+ setConfiguration(
186
+ await postPaymentRoute<Configuration>("subscriptions/configuration/update", {
187
+ webhookSecret,
188
+ }),
189
+ );
190
+ setWebhookSecret("");
191
+ await refresh();
192
+ setNotice("Subscription webhook secret saved.");
193
+ });
194
+ }}
195
+ >
196
+ <label className="min-w-64 flex-1 text-sm">
197
+ Signing secret
198
+ <input
199
+ type="password"
200
+ autoComplete="new-password"
201
+ required
202
+ className={inputClass}
203
+ value={webhookSecret}
204
+ placeholder={
205
+ configuration.webhookSecret
206
+ ? "Leave unchanged unless rotating the secret"
207
+ : "whsec_…"
208
+ }
209
+ onChange={(event) => setWebhookSecret(event.target.value)}
210
+ />
211
+ </label>
212
+ <button className={buttonClass} disabled={busy || !webhookSecret}>
213
+ Save secret
214
+ </button>
215
+ </form>
216
+ {!configuration.ready && (
217
+ <p className="text-sm text-kumo-subtle">
218
+ Verify the Stripe account and save this webhook secret to manage subscription plans.
219
+ </p>
220
+ )}
221
+ </section>
222
+ {configuration.ready && (
223
+ <>
224
+ <section className="space-y-4">
225
+ <div className="flex items-center justify-between">
226
+ <h2 className="text-lg font-semibold">Plans</h2>
227
+ <button className={buttonClass} onClick={() => edit()} disabled={busy}>
228
+ New plan
229
+ </button>
230
+ </div>
231
+ {plans.length === 0 && (
232
+ <p className="text-sm text-kumo-subtle">
233
+ No plans yet. Bind an existing Stripe price below.
234
+ </p>
235
+ )}
236
+ <div className="grid gap-3 md:grid-cols-2">
237
+ {plans.map((plan) => (
238
+ <button
239
+ key={plan.code}
240
+ className="rounded-lg border border-kumo-line p-4 text-left"
241
+ onClick={() => edit(plan)}
242
+ disabled={busy}
243
+ >
244
+ <strong>{plan.name}</strong>
245
+ <span className="ml-2 text-xs">{plan.enabled ? "Enabled" : "Archived"}</span>
246
+ <p className="text-sm text-kumo-subtle">{plan.code}</p>
247
+ {plan.prices
248
+ .filter((price) => price.enabled)
249
+ .map((price) => (
250
+ <p key={price.id} className="text-sm">
251
+ {formatSubscriptionPrice(price.amount, price.currency)} /{" "}
252
+ {price.interval}
253
+ </p>
254
+ ))}
255
+ </button>
256
+ ))}
257
+ </div>
258
+ <form
259
+ className="space-y-4 rounded-xl border border-kumo-line p-5"
260
+ onSubmit={(event) => {
261
+ event.preventDefault();
262
+ void run(savePlan);
263
+ }}
264
+ >
265
+ <h3 className="font-semibold">
266
+ {form.expectedVersion ? "Edit plan" : "Create plan"}
267
+ </h3>
268
+ <fieldset disabled={busy} className="grid gap-4 md:grid-cols-2">
269
+ <label className="text-sm">
270
+ Plan code
271
+ <input
272
+ required
273
+ readOnly={form.expectedVersion > 0}
274
+ className={inputClass}
275
+ value={form.code}
276
+ onChange={(event) => setForm({ ...form, code: event.target.value })}
277
+ placeholder="pro"
278
+ />
279
+ </label>
280
+ <label className="text-sm">
281
+ Name
282
+ <input
283
+ required
284
+ className={inputClass}
285
+ value={form.name}
286
+ onChange={(event) => setForm({ ...form, name: event.target.value })}
287
+ />
288
+ </label>
289
+ <label className="text-sm">
290
+ Monthly Stripe price
291
+ <input
292
+ className={inputClass}
293
+ value={form.monthlyPriceId ?? ""}
294
+ placeholder="price_…"
295
+ onChange={(event) =>
296
+ setForm({ ...form, monthlyPriceId: event.target.value || null })
297
+ }
298
+ />
299
+ </label>
300
+ <label className="text-sm">
301
+ Yearly Stripe price
302
+ <input
303
+ className={inputClass}
304
+ value={form.yearlyPriceId ?? ""}
305
+ placeholder="price_…"
306
+ onChange={(event) =>
307
+ setForm({ ...form, yearlyPriceId: event.target.value || null })
308
+ }
309
+ />
310
+ </label>
311
+ <label className="flex items-center gap-2 text-sm">
312
+ <input
313
+ type="checkbox"
314
+ checked={form.enabled}
315
+ onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
316
+ />{" "}
317
+ Available for new subscriptions
318
+ </label>
319
+ </fieldset>
320
+ <div className="space-y-3">
321
+ <h4 className="text-sm font-semibold">Entitlements</h4>
322
+ {features.map((feature, index) => (
323
+ <div key={index} className="flex flex-wrap gap-2">
324
+ <input
325
+ aria-label="Entitlement key"
326
+ className={inputClass + " min-w-40 flex-1"}
327
+ value={feature.key}
328
+ disabled={busy}
329
+ placeholder="reports.full"
330
+ onChange={(event) => updateFeature(index, { key: event.target.value })}
331
+ />
332
+ <select
333
+ aria-label="Entitlement type"
334
+ className={buttonClass}
335
+ value={feature.kind}
336
+ disabled={busy}
337
+ onChange={(event) =>
338
+ updateFeature(index, {
339
+ kind: event.target.value === "boolean" ? "boolean" : "number",
340
+ value: event.target.value === "boolean" ? "true" : "0",
341
+ })
342
+ }
343
+ >
344
+ <option value="boolean">Feature</option>
345
+ <option value="number">Limit</option>
346
+ </select>
347
+ {feature.kind === "boolean" ? (
348
+ <select
349
+ aria-label="Feature access"
350
+ className={buttonClass}
351
+ value={feature.value}
352
+ disabled={busy}
353
+ onChange={(event) =>
354
+ updateFeature(index, { value: event.target.value })
355
+ }
356
+ >
357
+ <option value="true">Enabled</option>
358
+ <option value="false">Disabled</option>
359
+ </select>
360
+ ) : (
361
+ <input
362
+ aria-label="Entitlement limit"
363
+ type="number"
364
+ min="0"
365
+ step="any"
366
+ className={buttonClass + " w-28"}
367
+ value={feature.value}
368
+ disabled={busy}
369
+ onChange={(event) =>
370
+ updateFeature(index, { value: event.target.value })
371
+ }
372
+ />
373
+ )}
374
+ <button
375
+ type="button"
376
+ className={buttonClass}
377
+ disabled={busy}
378
+ onClick={() =>
379
+ setFeatures(features.filter((_, position) => position !== index))
380
+ }
381
+ >
382
+ Remove
383
+ </button>
384
+ </div>
385
+ ))}
386
+ <button
387
+ type="button"
388
+ className={buttonClass}
389
+ disabled={busy}
390
+ onClick={() =>
391
+ setFeatures([...features, { key: "", kind: "boolean", value: "true" }])
392
+ }
393
+ >
394
+ Add entitlement
395
+ </button>
396
+ </div>
397
+ <button className={buttonClass} disabled={busy}>
398
+ {busy ? "Saving…" : "Verify prices and save plan"}
399
+ </button>
400
+ </form>
401
+ </section>
402
+ <section className="space-y-4">
403
+ <h2 className="text-lg font-semibold">Member subscriptions</h2>
404
+ <p className="text-sm text-kumo-subtle">
405
+ Failed renewals keep access until the end of the paid period. Cancellation takes
406
+ effect at period end.
407
+ </p>
408
+ <div className="overflow-x-auto">
409
+ <table className="w-full text-left text-sm">
410
+ <thead>
411
+ <tr>
412
+ <th className="p-2">Member</th>
413
+ <th className="p-2">Plan</th>
414
+ <th className="p-2">Payment status</th>
415
+ <th className="p-2">Last synchronized</th>
416
+ <th />
417
+ </tr>
418
+ </thead>
419
+ <tbody>
420
+ {members.map((member) => (
421
+ <tr key={member.id} className="border-t border-kumo-line">
422
+ <td className="p-2">{member.userId}</td>
423
+ <td className="p-2">{member.planCode}</td>
424
+ <td className="p-2">{member.status}</td>
425
+ <td className="p-2">
426
+ {new Date(member.verifiedAt).toLocaleString()}
427
+ {member.lastError && " · Retry required"}
428
+ </td>
429
+ <td className="p-2">
430
+ <button
431
+ className={buttonClass}
432
+ disabled={busy}
433
+ onClick={() => void run(() => openDetail(member.id))}
434
+ >
435
+ View
436
+ </button>
437
+ </td>
438
+ </tr>
439
+ ))}
440
+ </tbody>
441
+ </table>
442
+ </div>
443
+ {members.length === 0 && (
444
+ <p className="text-sm text-kumo-subtle">No subscriptions have been recorded.</p>
445
+ )}
446
+ {cursor && (
447
+ <button
448
+ className={buttonClass}
449
+ disabled={busy}
450
+ onClick={() =>
451
+ void run(async () => {
452
+ const page = await postPaymentRoute<{
453
+ items: MemberSubscription[];
454
+ nextCursor?: string;
455
+ }>("subscriptions/list", { cursor });
456
+ setMembers([...members, ...page.items]);
457
+ setCursor(page.nextCursor);
458
+ })
459
+ }
460
+ >
461
+ Load more
462
+ </button>
463
+ )}
464
+ {detail && (
465
+ <div className="space-y-3 rounded-xl border border-kumo-line p-5">
466
+ <h3 className="font-semibold">{detail.subscription.id}</h3>
467
+ <dl className="grid gap-2 text-sm">
468
+ <dt>Member</dt>
469
+ <dd>{detail.subscription.userId}</dd>
470
+ <dt>Paid access until</dt>
471
+ <dd>
472
+ {detail.member.paidThrough
473
+ ? new Date(detail.member.paidThrough * 1000).toLocaleString()
474
+ : "No current paid period"}
475
+ </dd>
476
+ <dt>Cancellation</dt>
477
+ <dd>
478
+ {detail.subscription.cancelAtPeriodEnd
479
+ ? "At period end: " +
480
+ new Date(detail.subscription.periodEnd * 1000).toLocaleString()
481
+ : "Not scheduled"}
482
+ </dd>
483
+ <dt>Last synchronization</dt>
484
+ <dd>
485
+ {new Date(detail.subscription.verifiedAt).toLocaleString()}{" "}
486
+ {detail.subscription.lastError}
487
+ </dd>
488
+ <dt>Effective entitlements</dt>
489
+ <dd>
490
+ <pre className="overflow-auto">
491
+ {JSON.stringify(detail.member.entitlements, null, 2)}
492
+ </pre>
493
+ </dd>
494
+ </dl>
495
+ <button
496
+ className={buttonClass}
497
+ disabled={busy}
498
+ onClick={() => void run(() => openDetail(detail.subscription.id, true))}
499
+ >
500
+ Synchronize with Stripe
501
+ </button>
502
+ </div>
503
+ )}
504
+ </section>
505
+ </>
506
+ )}
507
+ </>
508
+ )}
509
+ </div>
510
+ );
511
+ }
512
+ function message(value: unknown): string {
513
+ return value instanceof Error ? value.message : "Subscription request failed";
514
+ }
package/src/admin.tsx CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  paymentSettingsForm,
7
7
  type PaymentSettingsForm,
8
8
  } from "./admin/form.js";
9
+ import { PaymentSubscriptionsPage } from "./admin/subscriptions.js";
9
10
  import type { PaymentStripeConfiguration } from "./configuration/service.js";
10
11
 
11
12
  type CredentialName = "secretKey" | "webhookSecret";
@@ -304,4 +305,7 @@ function message(value: unknown): string {
304
305
  return value instanceof Error ? value.message : "Payment request failed";
305
306
  }
306
307
 
307
- export const pages = { "/payments": PaymentSettingsPage };
308
+ export const pages = {
309
+ "/payments": PaymentSettingsPage,
310
+ "/payments/subscriptions": PaymentSubscriptionsPage,
311
+ };
@@ -10,10 +10,12 @@ import { PAYMENT_PLUGIN_ID } from "../constants.js";
10
10
 
11
11
  export const PAYMENT_STRIPE_SECRET_KEY = "stripe_secret_key";
12
12
  export const PAYMENT_STRIPE_WEBHOOK_SECRET = "stripe_webhook_secret";
13
+ export const PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET = "stripe_subscription_webhook_secret";
13
14
 
14
15
  export type PaymentStripeSecretName =
15
16
  | typeof PAYMENT_STRIPE_SECRET_KEY
16
- | typeof PAYMENT_STRIPE_WEBHOOK_SECRET;
17
+ | typeof PAYMENT_STRIPE_WEBHOOK_SECRET
18
+ | typeof PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET;
17
19
 
18
20
  export class PaymentStripeSecretStore {
19
21
  private readonly store: PluginSecretStore;
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ import { version as packageVersion } from "../package.json";
5
5
  import { PaymentStripeConfigurationRepository } from "./configuration/repository.js";
6
6
  import { PAYMENT_PLUGIN_ID } from "./constants.js";
7
7
  import { createPaymentRoutes } from "./routes/index.js";
8
+ import { createSubscriptionRoutes } from "./subscriptions/routes.js";
9
+ import type { SubscriptionOptions } from "./subscriptions/types.js";
8
10
 
9
11
  const PAYMENT_ADMIN_ENTRY = "@dineway-ai/plugin-payment/admin";
10
12
  const PAYMENT_ADMIN_PAGES = [
@@ -13,6 +15,7 @@ const PAYMENT_ADMIN_PAGES = [
13
15
 
14
16
  export interface PaymentPluginOptions {
15
17
  appName?: string;
18
+ subscriptions?: SubscriptionOptions;
16
19
  }
17
20
 
18
21
  export function paymentPlugin(
@@ -24,14 +27,19 @@ export function paymentPlugin(
24
27
  format: "native",
25
28
  entrypoint: "@dineway-ai/plugin-payment",
26
29
  adminEntry: PAYMENT_ADMIN_ENTRY,
27
- adminPages: [...PAYMENT_ADMIN_PAGES],
30
+ adminPages: [
31
+ ...PAYMENT_ADMIN_PAGES,
32
+ ...(options.subscriptions
33
+ ? [{ path: "/payments/subscriptions", label: "Subscriptions", icon: "credit-card" }]
34
+ : []),
35
+ ],
28
36
  options,
29
37
  capabilities: ["network:request:unrestricted"],
30
38
  allowedHosts: ["api.stripe.com"],
31
39
  };
32
40
  }
33
41
 
34
- export function createPlugin(_options: PaymentPluginOptions = {}): ResolvedPlugin {
42
+ export function createPlugin(options: PaymentPluginOptions = {}): ResolvedPlugin {
35
43
  const ensurePaymentFoundation = async () => {
36
44
  await new PaymentStripeConfigurationRepository(await getDb()).ensureDefaults();
37
45
  };
@@ -44,9 +52,20 @@ export function createPlugin(_options: PaymentPluginOptions = {}): ResolvedPlugi
44
52
  version: packageVersion,
45
53
  capabilities: ["network:request:unrestricted"],
46
54
  allowedHosts: ["api.stripe.com"],
47
- admin: { entry: PAYMENT_ADMIN_ENTRY, pages: [...PAYMENT_ADMIN_PAGES] },
55
+ admin: {
56
+ entry: PAYMENT_ADMIN_ENTRY,
57
+ pages: [
58
+ ...PAYMENT_ADMIN_PAGES,
59
+ ...(options.subscriptions
60
+ ? [{ path: "/payments/subscriptions", label: "Subscriptions", icon: "credit-card" }]
61
+ : []),
62
+ ],
63
+ },
48
64
  hooks,
49
- routes: createPaymentRoutes(),
65
+ routes: {
66
+ ...createPaymentRoutes(),
67
+ ...(options.subscriptions ? createSubscriptionRoutes(options.subscriptions) : {}),
68
+ },
50
69
  });
51
70
  }
52
71
 
@@ -85,6 +85,14 @@ function projectEvent(event: Stripe.Event, rawBody: Uint8Array): StripeWebhookEv
85
85
  const objectRecord = asRecord(event.data.object as unknown);
86
86
  const objectType = optionalSafeString(objectRecord?.object, 128);
87
87
  const objectId = optionalSafeString(objectRecord?.id, 255);
88
+ const subscriptionRelation =
89
+ objectType === "subscription"
90
+ ? objectId
91
+ : objectType === "checkout.session"
92
+ ? objectRecord?.subscription
93
+ : objectType === "invoice"
94
+ ? asRecord(asRecord(objectRecord?.parent)?.subscription_details)?.subscription
95
+ : null;
88
96
  if ((objectType === null) !== (objectId === null)) throw new Error("Incomplete Stripe object");
89
97
 
90
98
  return {
@@ -96,6 +104,9 @@ function projectEvent(event: Stripe.Event, rawBody: Uint8Array): StripeWebhookEv
96
104
  payloadSha256: `sha256:${createHash("sha256").update(rawBody).digest("hex")}`,
97
105
  objectType,
98
106
  objectId,
107
+ ...(subscriptionRelation
108
+ ? { subscriptionId: requiredRelationId(subscriptionRelation, "sub_") }
109
+ : {}),
99
110
  paymentIntent: objectType === "payment_intent" ? projectPaymentIntent(objectRecord) : null,
100
111
  refund: objectType === "refund" ? projectRefund(objectRecord) : null,
101
112
  };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Stripe charges ISK and UGX in hundredths despite their ISO zero-decimal
3
+ * presentation. Other supported currencies follow their ISO minor unit.
4
+ * https://docs.stripe.com/currencies#special-cases
5
+ */
6
+ export function formatSubscriptionPrice(amount: number, currency: string, locale = "en"): string {
7
+ const formatter = new Intl.NumberFormat(locale, { style: "currency", currency });
8
+ const digits = ["isk", "ugx"].includes(currency.toLowerCase())
9
+ ? 2
10
+ : (formatter.resolvedOptions().maximumFractionDigits ?? 2);
11
+ return formatter.format(amount / 10 ** digits);
12
+ }
@@ -0,0 +1,6 @@
1
+ export { PaymentSubscriptionService } from "./service.js";
2
+ export type { SubscriptionServiceOptions } from "./service.js";
3
+ export { getPaymentSubscriptionService, getSubscriptionConfiguration } from "./runtime.js";
4
+ export { createSubscriptionRoutes } from "./routes.js";
5
+ export * from "./types.js";
6
+ export { formatSubscriptionPrice } from "./format.js";