@dineway-ai/plugin-payment 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
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.2"
32
+ "dineway": "^0.2.29"
32
33
  },
33
34
  "dependencies": {
34
35
  "@stripe/stripe-js": "9.9.0",
package/src/admin/form.ts CHANGED
@@ -39,11 +39,7 @@ export function buildPaymentSettingsUpdate(
39
39
  if (webhookSecret && !webhookSecret.startsWith("whsec_")) {
40
40
  throw new Error("Webhook signing secret must start with whsec_");
41
41
  }
42
- if (
43
- configuration.secretKey &&
44
- configuration.settings.mode !== form.mode &&
45
- !secretKey
46
- ) {
42
+ if (configuration.secretKey && configuration.settings.mode !== form.mode && !secretKey) {
47
43
  throw new Error("Enter the matching secret key when changing Stripe mode");
48
44
  }
49
45
  return {
@@ -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
+ }