@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
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.7",
|
|
4
4
|
"description": "Provider-neutral payment infrastructure and Stripe integration for Dineway plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./admin": "./src/admin.tsx",
|
|
13
13
|
"./configuration": "./src/configuration/index.ts",
|
|
14
|
+
"./database": "./src/database/index.ts",
|
|
14
15
|
"./routes": "./src/routes/index.ts",
|
|
15
16
|
"./stripe": "./src/stripe/index.ts",
|
|
16
17
|
"./types": "./src/types.ts",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"license": "MIT",
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"react": "^18.0.0 || ^19.0.0",
|
|
32
|
-
"dineway": "^0.2.
|
|
33
|
+
"dineway": "^0.2.30"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@stripe/stripe-js": "9.9.0",
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { formatSubscriptionPrice } from "../subscriptions/format.js";
|
|
4
|
+
import type {
|
|
5
|
+
SubscriptionCatalogPage,
|
|
6
|
+
SubscriptionPrice,
|
|
7
|
+
SubscriptionProduct,
|
|
8
|
+
StripeSubscriptionPrice,
|
|
9
|
+
} from "../subscriptions/types.js";
|
|
10
|
+
import { postPaymentRoute } from "./api.js";
|
|
11
|
+
|
|
12
|
+
const inputClass = "w-full rounded-lg border border-kumo-line bg-kumo-base px-3 py-2 text-sm";
|
|
13
|
+
const buttonClass = "rounded-lg border border-kumo-line px-3 py-2 text-sm disabled:opacity-50";
|
|
14
|
+
type Selection = { monthlyPriceId: string | null; yearlyPriceId: string | null };
|
|
15
|
+
|
|
16
|
+
function useCatalog<T extends { id: string }>(
|
|
17
|
+
route: string,
|
|
18
|
+
productId: string | undefined,
|
|
19
|
+
revision: number,
|
|
20
|
+
) {
|
|
21
|
+
const [state, setState] = React.useState<
|
|
22
|
+
SubscriptionCatalogPage<T> & { loading: boolean; error: string | null }
|
|
23
|
+
>({ items: [], loading: true, error: null });
|
|
24
|
+
const generation = React.useRef(0);
|
|
25
|
+
const request = React.useCallback(
|
|
26
|
+
(cursor?: string) =>
|
|
27
|
+
postPaymentRoute<SubscriptionCatalogPage<T>>(route, {
|
|
28
|
+
...(productId ? { productId } : {}),
|
|
29
|
+
cursor,
|
|
30
|
+
}),
|
|
31
|
+
[route, productId],
|
|
32
|
+
);
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
const current = ++generation.current;
|
|
35
|
+
setState({ items: [], loading: true, error: null });
|
|
36
|
+
void request().then(
|
|
37
|
+
(page) => {
|
|
38
|
+
if (generation.current === current) setState({ ...page, loading: false, error: null });
|
|
39
|
+
return undefined;
|
|
40
|
+
},
|
|
41
|
+
(cause) => {
|
|
42
|
+
if (generation.current === current)
|
|
43
|
+
setState({ items: [], loading: false, error: errorMessage(cause) });
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
return () => {
|
|
47
|
+
generation.current++;
|
|
48
|
+
};
|
|
49
|
+
}, [request, revision]);
|
|
50
|
+
async function loadMore() {
|
|
51
|
+
if (state.loading || !state.nextCursor) return;
|
|
52
|
+
const current = generation.current;
|
|
53
|
+
setState((previous) => ({ ...previous, loading: true, error: null }));
|
|
54
|
+
try {
|
|
55
|
+
const page = await request(state.nextCursor);
|
|
56
|
+
if (generation.current === current)
|
|
57
|
+
setState((previous) => ({
|
|
58
|
+
items: [
|
|
59
|
+
...new Map([...previous.items, ...page.items].map((item) => [item.id, item])).values(),
|
|
60
|
+
],
|
|
61
|
+
nextCursor: page.nextCursor,
|
|
62
|
+
loading: false,
|
|
63
|
+
error: null,
|
|
64
|
+
}));
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
if (generation.current === current)
|
|
67
|
+
setState((previous) => ({ ...previous, loading: false, error: errorMessage(cause) }));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ...state, loadMore };
|
|
71
|
+
}
|
|
72
|
+
function errorMessage(cause: unknown) {
|
|
73
|
+
return cause instanceof Error
|
|
74
|
+
? cause.message
|
|
75
|
+
: "Could not load the Stripe catalog. Try refreshing.";
|
|
76
|
+
}
|
|
77
|
+
function priceLabel(price: SubscriptionPrice & { nickname?: string | null }) {
|
|
78
|
+
return `${formatSubscriptionPrice(price.amount, price.currency)} ${price.currency.toUpperCase()} / ${price.interval}${price.nickname ? ` · ${price.nickname}` : ""} · ${price.id}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function SubscriptionPricePicker({
|
|
82
|
+
value,
|
|
83
|
+
boundPrices,
|
|
84
|
+
onChange,
|
|
85
|
+
}: {
|
|
86
|
+
value: Selection;
|
|
87
|
+
boundPrices: SubscriptionPrice[];
|
|
88
|
+
onChange: (value: Selection) => void;
|
|
89
|
+
}) {
|
|
90
|
+
const boundProduct = boundPrices.find(
|
|
91
|
+
(price) => price.id === value.monthlyPriceId || price.id === value.yearlyPriceId,
|
|
92
|
+
)?.productId;
|
|
93
|
+
const [productId, setProductId] = React.useState(boundProduct ?? "");
|
|
94
|
+
const [revision, setRevision] = React.useState(0);
|
|
95
|
+
const products = useCatalog<SubscriptionProduct>(
|
|
96
|
+
"subscriptions/products/list",
|
|
97
|
+
undefined,
|
|
98
|
+
revision,
|
|
99
|
+
);
|
|
100
|
+
return (
|
|
101
|
+
<div className="space-y-3">
|
|
102
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
103
|
+
<label className="w-full text-sm">
|
|
104
|
+
Stripe product
|
|
105
|
+
<select
|
|
106
|
+
className={inputClass}
|
|
107
|
+
value={productId}
|
|
108
|
+
disabled={products.loading}
|
|
109
|
+
onChange={(event) => {
|
|
110
|
+
setProductId(event.target.value);
|
|
111
|
+
onChange({ monthlyPriceId: null, yearlyPriceId: null });
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<option value="">
|
|
115
|
+
{products.loading ? "Loading Stripe products…" : "Choose a product"}
|
|
116
|
+
</option>
|
|
117
|
+
{productId && !products.items.some((product) => product.id === productId) && (
|
|
118
|
+
<option value={productId}>{productId} · saved selection</option>
|
|
119
|
+
)}
|
|
120
|
+
{products.items.map((product) => (
|
|
121
|
+
<option key={product.id} value={product.id}>
|
|
122
|
+
{product.name} · {product.id}
|
|
123
|
+
</option>
|
|
124
|
+
))}
|
|
125
|
+
</select>
|
|
126
|
+
</label>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className={buttonClass}
|
|
130
|
+
disabled={products.loading}
|
|
131
|
+
onClick={() => setRevision((current) => current + 1)}
|
|
132
|
+
>
|
|
133
|
+
Refresh Stripe catalog
|
|
134
|
+
</button>
|
|
135
|
+
{products.nextCursor && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className={buttonClass}
|
|
139
|
+
disabled={products.loading}
|
|
140
|
+
onClick={() => void products.loadMore()}
|
|
141
|
+
>
|
|
142
|
+
Load more products
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
<p className="text-sm text-kumo-subtle">
|
|
147
|
+
Select prices from the connected Stripe account and environment. Amounts are read from
|
|
148
|
+
Stripe and verified again when you save.
|
|
149
|
+
</p>
|
|
150
|
+
{products.error && (
|
|
151
|
+
<p role="alert" className="text-sm text-kumo-danger">
|
|
152
|
+
{products.error} Refresh the Stripe catalog to retry.
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
{!products.loading && !products.error && products.items.length === 0 && (
|
|
156
|
+
<p className="text-sm text-kumo-subtle">
|
|
157
|
+
No active products found on this page. Create a product in this Stripe environment, then
|
|
158
|
+
refresh.
|
|
159
|
+
</p>
|
|
160
|
+
)}
|
|
161
|
+
{productId && (
|
|
162
|
+
<ProductPrices
|
|
163
|
+
key={productId}
|
|
164
|
+
productId={productId}
|
|
165
|
+
revision={revision}
|
|
166
|
+
value={value}
|
|
167
|
+
boundPrices={boundPrices}
|
|
168
|
+
onChange={onChange}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function ProductPrices({
|
|
176
|
+
productId,
|
|
177
|
+
revision,
|
|
178
|
+
value,
|
|
179
|
+
boundPrices,
|
|
180
|
+
onChange,
|
|
181
|
+
}: {
|
|
182
|
+
productId: string;
|
|
183
|
+
revision: number;
|
|
184
|
+
value: Selection;
|
|
185
|
+
boundPrices: SubscriptionPrice[];
|
|
186
|
+
onChange: (value: Selection) => void;
|
|
187
|
+
}) {
|
|
188
|
+
const prices = useCatalog<StripeSubscriptionPrice>(
|
|
189
|
+
"subscriptions/prices/list",
|
|
190
|
+
productId,
|
|
191
|
+
revision,
|
|
192
|
+
);
|
|
193
|
+
const knownPrices = [...prices.items, ...boundPrices];
|
|
194
|
+
return (
|
|
195
|
+
<div className="space-y-3">
|
|
196
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
197
|
+
{(["month", "year"] as const).map((interval) => {
|
|
198
|
+
const field = interval === "month" ? "monthlyPriceId" : "yearlyPriceId";
|
|
199
|
+
const otherField = interval === "month" ? "yearlyPriceId" : "monthlyPriceId";
|
|
200
|
+
const selected = value[field];
|
|
201
|
+
const other = knownPrices.find((price) => price.id === value[otherField]);
|
|
202
|
+
const saved = boundPrices.find((price) => price.id === selected);
|
|
203
|
+
return (
|
|
204
|
+
<label key={interval} className="text-sm">
|
|
205
|
+
{interval === "month" ? "Monthly Stripe price" : "Yearly Stripe price"}
|
|
206
|
+
<select
|
|
207
|
+
className={inputClass}
|
|
208
|
+
value={selected ?? ""}
|
|
209
|
+
disabled={prices.loading}
|
|
210
|
+
onChange={(event) => onChange({ ...value, [field]: event.target.value || null })}
|
|
211
|
+
>
|
|
212
|
+
<option value="">
|
|
213
|
+
{prices.loading ? "Loading Stripe prices…" : "Not offered"}
|
|
214
|
+
</option>
|
|
215
|
+
{selected && !prices.items.some((price) => price.id === selected) && (
|
|
216
|
+
<option value={selected} disabled>
|
|
217
|
+
{saved ? priceLabel(saved) : selected} · saved binding (not in loaded results)
|
|
218
|
+
</option>
|
|
219
|
+
)}
|
|
220
|
+
{prices.items
|
|
221
|
+
.filter((price) => price.interval === interval)
|
|
222
|
+
.map((price) => (
|
|
223
|
+
<option
|
|
224
|
+
key={price.id}
|
|
225
|
+
value={price.id}
|
|
226
|
+
disabled={Boolean(other && price.currency !== other.currency)}
|
|
227
|
+
>
|
|
228
|
+
{priceLabel(price)}
|
|
229
|
+
{other && price.currency !== other.currency ? " · different currency" : ""}
|
|
230
|
+
</option>
|
|
231
|
+
))}
|
|
232
|
+
</select>
|
|
233
|
+
</label>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
<p className="text-sm text-kumo-subtle">
|
|
238
|
+
Only active fixed monthly or yearly prices are available. Both prices must use the same
|
|
239
|
+
currency. Keep an interval as “Not offered” if you do not sell it.
|
|
240
|
+
</p>
|
|
241
|
+
{prices.error && (
|
|
242
|
+
<p role="alert" className="text-sm text-kumo-danger">
|
|
243
|
+
{prices.error} Refresh the Stripe catalog to retry.
|
|
244
|
+
</p>
|
|
245
|
+
)}
|
|
246
|
+
{!prices.loading && !prices.error && prices.items.length === 0 && (
|
|
247
|
+
<p className="text-sm text-kumo-subtle">
|
|
248
|
+
No supported recurring prices found on this page. Load more prices, or create a fixed
|
|
249
|
+
monthly or yearly price in Stripe and refresh.
|
|
250
|
+
</p>
|
|
251
|
+
)}
|
|
252
|
+
{prices.nextCursor && (
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
className={buttonClass}
|
|
256
|
+
disabled={prices.loading}
|
|
257
|
+
onClick={() => void prices.loadMore()}
|
|
258
|
+
>
|
|
259
|
+
Load more prices
|
|
260
|
+
</button>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -5,12 +5,18 @@ import type { getSubscriptionConfiguration } from "../subscriptions/runtime.js";
|
|
|
5
5
|
import type {
|
|
6
6
|
MemberBilling,
|
|
7
7
|
MemberSubscription,
|
|
8
|
+
BillingSubject,
|
|
9
|
+
SubscriptionPurchase,
|
|
10
|
+
SubscriptionSnapshot,
|
|
8
11
|
SaveSubscriptionPlan,
|
|
9
12
|
SubscriptionPlan,
|
|
10
13
|
} from "../subscriptions/types.js";
|
|
11
14
|
import { postPaymentRoute } from "./api.js";
|
|
15
|
+
import { SubscriptionPricePicker } from "./subscription-price-picker.js";
|
|
12
16
|
|
|
13
17
|
type Configuration = Awaited<ReturnType<typeof getSubscriptionConfiguration>>;
|
|
18
|
+
type AdminSubscription = MemberSubscription & { subject: BillingSubject | null };
|
|
19
|
+
type AdminPurchase = SubscriptionPurchase & { subject: BillingSubject | null };
|
|
14
20
|
type Feature = { key: string; kind: "boolean" | "number"; value: string };
|
|
15
21
|
const emptyPlan: SaveSubscriptionPlan = {
|
|
16
22
|
code: "",
|
|
@@ -27,15 +33,18 @@ const buttonClass = "rounded-lg border border-kumo-line px-3 py-2 text-sm disabl
|
|
|
27
33
|
export function PaymentSubscriptionsPage() {
|
|
28
34
|
const [configuration, setConfiguration] = React.useState<Configuration | null>(null);
|
|
29
35
|
const [plans, setPlans] = React.useState<SubscriptionPlan[]>([]);
|
|
30
|
-
const [members, setMembers] = React.useState<
|
|
36
|
+
const [members, setMembers] = React.useState<AdminSubscription[]>([]);
|
|
37
|
+
const [purchases, setPurchases] = React.useState<AdminPurchase[]>([]);
|
|
38
|
+
const [purchaseCursor, setPurchaseCursor] = React.useState<string>();
|
|
31
39
|
const [cursor, setCursor] = React.useState<string>();
|
|
32
40
|
const [form, setForm] = React.useState<SaveSubscriptionPlan>({ ...emptyPlan });
|
|
41
|
+
const [editorRevision, setEditorRevision] = React.useState(0);
|
|
42
|
+
const [boundPrices, setBoundPrices] = React.useState<SubscriptionPlan["prices"]>([]);
|
|
33
43
|
const [features, setFeatures] = React.useState<Feature[]>([]);
|
|
34
44
|
const [webhookSecret, setWebhookSecret] = React.useState("");
|
|
35
|
-
const [detail, setDetail] = React.useState<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} | null>(null);
|
|
45
|
+
const [detail, setDetail] = React.useState<
|
|
46
|
+
(SubscriptionSnapshot & { member: MemberBilling }) | null
|
|
47
|
+
>(null);
|
|
39
48
|
const [busy, setBusy] = React.useState(false);
|
|
40
49
|
const [error, setError] = React.useState<string | null>(null);
|
|
41
50
|
const [notice, setNotice] = React.useState<string | null>(null);
|
|
@@ -43,15 +52,18 @@ export function PaymentSubscriptionsPage() {
|
|
|
43
52
|
const config = await postPaymentRoute<Configuration>("subscriptions/configuration/get");
|
|
44
53
|
setConfiguration(config);
|
|
45
54
|
if (config.ready) {
|
|
46
|
-
const [catalog, subscriptions] = await Promise.all([
|
|
55
|
+
const [catalog, subscriptions, purchasePage] = await Promise.all([
|
|
47
56
|
postPaymentRoute<{ items: SubscriptionPlan[] }>("subscriptions/plans/list"),
|
|
48
|
-
postPaymentRoute<{ items:
|
|
49
|
-
|
|
57
|
+
postPaymentRoute<{ items: AdminSubscription[]; nextCursor?: string }>("subscriptions/list"),
|
|
58
|
+
postPaymentRoute<{ items: AdminPurchase[]; nextCursor?: string }>(
|
|
59
|
+
"subscriptions/purchases/list",
|
|
50
60
|
),
|
|
51
61
|
]);
|
|
52
62
|
setPlans(catalog.items);
|
|
53
63
|
setMembers(subscriptions.items);
|
|
54
64
|
setCursor(subscriptions.nextCursor);
|
|
65
|
+
setPurchases(purchasePage.items);
|
|
66
|
+
setPurchaseCursor(purchasePage.nextCursor);
|
|
55
67
|
}
|
|
56
68
|
}, []);
|
|
57
69
|
React.useEffect(() => {
|
|
@@ -70,6 +82,8 @@ export function PaymentSubscriptionsPage() {
|
|
|
70
82
|
}
|
|
71
83
|
}
|
|
72
84
|
function edit(plan?: SubscriptionPlan) {
|
|
85
|
+
setEditorRevision((current) => current + 1);
|
|
86
|
+
setBoundPrices(plan?.prices ?? []);
|
|
73
87
|
setForm(
|
|
74
88
|
plan
|
|
75
89
|
? {
|
|
@@ -265,49 +279,35 @@ export function PaymentSubscriptionsPage() {
|
|
|
265
279
|
<h3 className="font-semibold">
|
|
266
280
|
{form.expectedVersion ? "Edit plan" : "Create plan"}
|
|
267
281
|
</h3>
|
|
268
|
-
<fieldset disabled={busy} className="
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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>
|
|
282
|
+
<fieldset disabled={busy} className="space-y-4">
|
|
283
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
284
|
+
<label className="text-sm">
|
|
285
|
+
Plan code
|
|
286
|
+
<input
|
|
287
|
+
required
|
|
288
|
+
readOnly={form.expectedVersion > 0}
|
|
289
|
+
className={inputClass}
|
|
290
|
+
value={form.code}
|
|
291
|
+
onChange={(event) => setForm({ ...form, code: event.target.value })}
|
|
292
|
+
placeholder="pro"
|
|
293
|
+
/>
|
|
294
|
+
</label>
|
|
295
|
+
<label className="text-sm">
|
|
296
|
+
Name
|
|
297
|
+
<input
|
|
298
|
+
required
|
|
299
|
+
className={inputClass}
|
|
300
|
+
value={form.name}
|
|
301
|
+
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
|
302
|
+
/>
|
|
303
|
+
</label>
|
|
304
|
+
</div>
|
|
305
|
+
<SubscriptionPricePicker
|
|
306
|
+
key={editorRevision}
|
|
307
|
+
value={form}
|
|
308
|
+
boundPrices={boundPrices}
|
|
309
|
+
onChange={(prices) => setForm((current) => ({ ...current, ...prices }))}
|
|
310
|
+
/>
|
|
311
311
|
<label className="flex items-center gap-2 text-sm">
|
|
312
312
|
<input
|
|
313
313
|
type="checkbox"
|
|
@@ -400,7 +400,100 @@ export function PaymentSubscriptionsPage() {
|
|
|
400
400
|
</form>
|
|
401
401
|
</section>
|
|
402
402
|
<section className="space-y-4">
|
|
403
|
-
<h2 className="text-lg font-semibold">
|
|
403
|
+
<h2 className="text-lg font-semibold">Purchases</h2>
|
|
404
|
+
<button
|
|
405
|
+
className={buttonClass}
|
|
406
|
+
disabled={busy}
|
|
407
|
+
onClick={() =>
|
|
408
|
+
void run(async () => {
|
|
409
|
+
const result = await postPaymentRoute<{ processed: number; failed: number }>(
|
|
410
|
+
"subscriptions/reconcile",
|
|
411
|
+
);
|
|
412
|
+
await refresh();
|
|
413
|
+
setNotice(
|
|
414
|
+
`Processed ${result.processed}; ${result.failed} require retry or review.`,
|
|
415
|
+
);
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
>
|
|
419
|
+
Retry pending synchronization
|
|
420
|
+
</button>
|
|
421
|
+
<div className="overflow-x-auto">
|
|
422
|
+
<table className="w-full text-left text-sm">
|
|
423
|
+
<thead>
|
|
424
|
+
<tr>
|
|
425
|
+
<th className="p-2">Billing subject</th>
|
|
426
|
+
<th className="p-2">Purchase</th>
|
|
427
|
+
<th className="p-2">Terms</th>
|
|
428
|
+
<th className="p-2">Status</th>
|
|
429
|
+
</tr>
|
|
430
|
+
</thead>
|
|
431
|
+
<tbody>
|
|
432
|
+
{purchases.map((purchase) => (
|
|
433
|
+
<tr key={purchase.reference} className="border-t border-kumo-line">
|
|
434
|
+
<td className="p-2">
|
|
435
|
+
{purchase.subject
|
|
436
|
+
? `${purchase.subject.realm}: ${purchase.subject.id}`
|
|
437
|
+
: purchase.userId}
|
|
438
|
+
</td>
|
|
439
|
+
<td className="p-2">
|
|
440
|
+
{purchase.reference}
|
|
441
|
+
<br />
|
|
442
|
+
{purchase.contract.pricing.kind}
|
|
443
|
+
</td>
|
|
444
|
+
<td className="p-2">
|
|
445
|
+
{formatSubscriptionPrice(
|
|
446
|
+
purchase.contract.amount,
|
|
447
|
+
purchase.contract.currency,
|
|
448
|
+
)}{" "}
|
|
449
|
+
every {purchase.contract.intervalCount} {purchase.contract.interval}
|
|
450
|
+
<br />
|
|
451
|
+
First invoice credit:{" "}
|
|
452
|
+
{formatSubscriptionPrice(
|
|
453
|
+
purchase.contract.firstPeriodCredit ?? 0,
|
|
454
|
+
purchase.contract.currency,
|
|
455
|
+
)}
|
|
456
|
+
</td>
|
|
457
|
+
<td className="p-2">
|
|
458
|
+
{purchase.closed
|
|
459
|
+
? "Closed"
|
|
460
|
+
: purchase.subscriptionId
|
|
461
|
+
? "Subscription recorded"
|
|
462
|
+
: "Awaiting confirmation"}
|
|
463
|
+
<br />
|
|
464
|
+
{purchase.cancelMode && `Cancellation: ${purchase.cancelMode}`}
|
|
465
|
+
<br />
|
|
466
|
+
{purchase.lastError}
|
|
467
|
+
</td>
|
|
468
|
+
</tr>
|
|
469
|
+
))}
|
|
470
|
+
</tbody>
|
|
471
|
+
</table>
|
|
472
|
+
</div>
|
|
473
|
+
{!purchases.length && (
|
|
474
|
+
<p className="text-sm text-kumo-subtle">No purchases recorded.</p>
|
|
475
|
+
)}
|
|
476
|
+
{purchaseCursor && (
|
|
477
|
+
<button
|
|
478
|
+
className={buttonClass}
|
|
479
|
+
disabled={busy}
|
|
480
|
+
onClick={() =>
|
|
481
|
+
void run(async () => {
|
|
482
|
+
const page = await postPaymentRoute<{
|
|
483
|
+
items: AdminPurchase[];
|
|
484
|
+
nextCursor?: string;
|
|
485
|
+
}>("subscriptions/purchases/list", { cursor: purchaseCursor });
|
|
486
|
+
setPurchases([...purchases, ...page.items]);
|
|
487
|
+
setPurchaseCursor(page.nextCursor);
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
>
|
|
491
|
+
Load more purchases
|
|
492
|
+
</button>
|
|
493
|
+
)}
|
|
494
|
+
</section>
|
|
495
|
+
<section className="space-y-4">
|
|
496
|
+
<h2 className="text-lg font-semibold">Subscriptions and funding</h2>
|
|
404
497
|
<p className="text-sm text-kumo-subtle">
|
|
405
498
|
Failed renewals keep access until the end of the paid period. Cancellation takes
|
|
406
499
|
effect at period end.
|
|
@@ -419,8 +512,18 @@ export function PaymentSubscriptionsPage() {
|
|
|
419
512
|
<tbody>
|
|
420
513
|
{members.map((member) => (
|
|
421
514
|
<tr key={member.id} className="border-t border-kumo-line">
|
|
422
|
-
<td className="p-2">
|
|
423
|
-
|
|
515
|
+
<td className="p-2">
|
|
516
|
+
{member.subject
|
|
517
|
+
? `${member.subject.realm}: ${member.subject.id}`
|
|
518
|
+
: member.userId}
|
|
519
|
+
</td>
|
|
520
|
+
<td className="p-2">
|
|
521
|
+
{member.planCode || "Dynamic purchase"}
|
|
522
|
+
<br />
|
|
523
|
+
<span className="text-xs text-kumo-subtle">
|
|
524
|
+
{member.purchaseReference}
|
|
525
|
+
</span>
|
|
526
|
+
</td>
|
|
424
527
|
<td className="p-2">{member.status}</td>
|
|
425
528
|
<td className="p-2">
|
|
426
529
|
{new Date(member.verifiedAt).toLocaleString()}
|
|
@@ -450,7 +553,7 @@ export function PaymentSubscriptionsPage() {
|
|
|
450
553
|
onClick={() =>
|
|
451
554
|
void run(async () => {
|
|
452
555
|
const page = await postPaymentRoute<{
|
|
453
|
-
items:
|
|
556
|
+
items: AdminSubscription[];
|
|
454
557
|
nextCursor?: string;
|
|
455
558
|
}>("subscriptions/list", { cursor });
|
|
456
559
|
setMembers([...members, ...page.items]);
|
|
@@ -466,11 +569,31 @@ export function PaymentSubscriptionsPage() {
|
|
|
466
569
|
<h3 className="font-semibold">{detail.subscription.id}</h3>
|
|
467
570
|
<dl className="grid gap-2 text-sm">
|
|
468
571
|
<dt>Member</dt>
|
|
469
|
-
<dd>
|
|
572
|
+
<dd>
|
|
573
|
+
{detail.subject
|
|
574
|
+
? `${detail.subject.realm}: ${detail.subject.id} · ${detail.subject.email}`
|
|
575
|
+
: detail.subscription.userId}
|
|
576
|
+
</dd>
|
|
577
|
+
<dt>Purchase reference</dt>
|
|
578
|
+
<dd>{detail.purchase?.reference ?? "Catalogue subscription"}</dd>
|
|
470
579
|
<dt>Paid access until</dt>
|
|
471
580
|
<dd>
|
|
472
|
-
{detail.
|
|
473
|
-
|
|
581
|
+
{detail.periods.some(
|
|
582
|
+
(period) =>
|
|
583
|
+
period.eligible &&
|
|
584
|
+
period.startsAt * 1000 <= Date.now() &&
|
|
585
|
+
period.endsAt * 1000 > Date.now(),
|
|
586
|
+
)
|
|
587
|
+
? new Date(
|
|
588
|
+
Math.max(
|
|
589
|
+
...detail.periods
|
|
590
|
+
.filter(
|
|
591
|
+
(period) =>
|
|
592
|
+
period.eligible && period.startsAt * 1000 <= Date.now(),
|
|
593
|
+
)
|
|
594
|
+
.map((period) => period.endsAt),
|
|
595
|
+
) * 1000,
|
|
596
|
+
).toLocaleString()
|
|
474
597
|
: "No current paid period"}
|
|
475
598
|
</dd>
|
|
476
599
|
<dt>Cancellation</dt>
|
|
@@ -478,7 +601,9 @@ export function PaymentSubscriptionsPage() {
|
|
|
478
601
|
{detail.subscription.cancelAtPeriodEnd
|
|
479
602
|
? "At period end: " +
|
|
480
603
|
new Date(detail.subscription.periodEnd * 1000).toLocaleString()
|
|
481
|
-
:
|
|
604
|
+
: detail.subscription.status === "canceled"
|
|
605
|
+
? "Canceled"
|
|
606
|
+
: "Not scheduled"}
|
|
482
607
|
</dd>
|
|
483
608
|
<dt>Last synchronization</dt>
|
|
484
609
|
<dd>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { Database } from "dineway";
|
|
2
2
|
import type { Kysely, Selectable, Transaction, Updateable } from "kysely";
|
|
3
3
|
|
|
4
|
+
import type { PaymentDatabase, PaymentTables } from "../database/types.js";
|
|
4
5
|
import type { PaymentStripeSettings, UpdatePaymentStripeSettings } from "./types.js";
|
|
5
6
|
|
|
6
7
|
type PaymentDb = Kysely<Database> | Transaction<Database>;
|
|
7
|
-
type SettingsRow = Selectable<
|
|
8
|
+
type SettingsRow = Selectable<PaymentDatabase["payment_stripe_settings"]>;
|
|
8
9
|
|
|
9
10
|
export class PaymentStripeConfigurationRepository {
|
|
10
|
-
|
|
11
|
+
private readonly db: Kysely<PaymentDatabase> | Transaction<PaymentDatabase>;
|
|
12
|
+
constructor(db: PaymentDb) {
|
|
13
|
+
this.db = db.withTables<PaymentTables>();
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
async ensureDefaults(): Promise<PaymentStripeSettings> {
|
|
13
17
|
await this.db
|
|
@@ -32,7 +36,7 @@ export class PaymentStripeConfigurationRepository {
|
|
|
32
36
|
expectedVersion: number,
|
|
33
37
|
input: UpdatePaymentStripeSettings,
|
|
34
38
|
): Promise<PaymentStripeSettings> {
|
|
35
|
-
const update: Updateable<
|
|
39
|
+
const update: Updateable<PaymentDatabase["payment_stripe_settings"]> = {
|
|
36
40
|
...(input.mode === undefined ? {} : { mode: input.mode }),
|
|
37
41
|
...(input.publishableKey === undefined ? {} : { publishable_key: input.publishableKey }),
|
|
38
42
|
...(input.configurationStatus === undefined
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PluginMigration } from "dineway";
|
|
2
|
+
|
|
3
|
+
import * as configuration from "./migrations/001_stripe_configuration.js";
|
|
4
|
+
import * as subscriptions from "./migrations/002_subscriptions.js";
|
|
5
|
+
import * as purchases from "./migrations/003_purchases.js";
|
|
6
|
+
|
|
7
|
+
export const paymentMigrations = {
|
|
8
|
+
"001_stripe_configuration": configuration,
|
|
9
|
+
"002_subscriptions": subscriptions,
|
|
10
|
+
"003_purchases": purchases,
|
|
11
|
+
} satisfies Record<string, PluginMigration>;
|
|
12
|
+
export type { PaymentDatabase, PaymentTables } from "./types.js";
|