@dineway-ai/plugin-payment 0.2.3 → 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 +4 -3
- package/src/admin/subscriptions.tsx +514 -0
- package/src/admin.tsx +5 -1
- package/src/configuration/secrets.ts +3 -1
- package/src/index.ts +23 -4
- package/src/stripe/webhook-verifier.ts +11 -0
- package/src/subscriptions/format.ts +12 -0
- package/src/subscriptions/index.ts +6 -0
- package/src/subscriptions/projection.ts +185 -0
- package/src/subscriptions/repository.ts +443 -0
- package/src/subscriptions/routes.ts +146 -0
- package/src/subscriptions/runtime.ts +65 -0
- package/src/subscriptions/schemas.ts +101 -0
- package/src/subscriptions/service.ts +510 -0
- package/src/subscriptions/types.ts +80 -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.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.
|
|
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 = {
|
|
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: [
|
|
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(
|
|
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: {
|
|
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:
|
|
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";
|