@dineway-ai/plugin-payment 0.2.4 → 0.2.6
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 +1 -1
- package/src/admin/subscription-price-picker.tsx +264 -0
- package/src/admin/subscriptions.tsx +34 -43
- package/src/subscriptions/projection.ts +5 -1
- package/src/subscriptions/routes.ts +13 -1
- package/src/subscriptions/schemas.ts +25 -0
- package/src/subscriptions/service.ts +61 -2
- package/src/subscriptions/types.ts +15 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
SubscriptionPlan,
|
|
10
10
|
} from "../subscriptions/types.js";
|
|
11
11
|
import { postPaymentRoute } from "./api.js";
|
|
12
|
+
import { SubscriptionPricePicker } from "./subscription-price-picker.js";
|
|
12
13
|
|
|
13
14
|
type Configuration = Awaited<ReturnType<typeof getSubscriptionConfiguration>>;
|
|
14
15
|
type Feature = { key: string; kind: "boolean" | "number"; value: string };
|
|
@@ -30,6 +31,8 @@ export function PaymentSubscriptionsPage() {
|
|
|
30
31
|
const [members, setMembers] = React.useState<MemberSubscription[]>([]);
|
|
31
32
|
const [cursor, setCursor] = React.useState<string>();
|
|
32
33
|
const [form, setForm] = React.useState<SaveSubscriptionPlan>({ ...emptyPlan });
|
|
34
|
+
const [editorRevision, setEditorRevision] = React.useState(0);
|
|
35
|
+
const [boundPrices, setBoundPrices] = React.useState<SubscriptionPlan["prices"]>([]);
|
|
33
36
|
const [features, setFeatures] = React.useState<Feature[]>([]);
|
|
34
37
|
const [webhookSecret, setWebhookSecret] = React.useState("");
|
|
35
38
|
const [detail, setDetail] = React.useState<{
|
|
@@ -70,6 +73,8 @@ export function PaymentSubscriptionsPage() {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
function edit(plan?: SubscriptionPlan) {
|
|
76
|
+
setEditorRevision((current) => current + 1);
|
|
77
|
+
setBoundPrices(plan?.prices ?? []);
|
|
73
78
|
setForm(
|
|
74
79
|
plan
|
|
75
80
|
? {
|
|
@@ -265,49 +270,35 @@ export function PaymentSubscriptionsPage() {
|
|
|
265
270
|
<h3 className="font-semibold">
|
|
266
271
|
{form.expectedVersion ? "Edit plan" : "Create plan"}
|
|
267
272
|
</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>
|
|
273
|
+
<fieldset disabled={busy} className="space-y-4">
|
|
274
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
275
|
+
<label className="text-sm">
|
|
276
|
+
Plan code
|
|
277
|
+
<input
|
|
278
|
+
required
|
|
279
|
+
readOnly={form.expectedVersion > 0}
|
|
280
|
+
className={inputClass}
|
|
281
|
+
value={form.code}
|
|
282
|
+
onChange={(event) => setForm({ ...form, code: event.target.value })}
|
|
283
|
+
placeholder="pro"
|
|
284
|
+
/>
|
|
285
|
+
</label>
|
|
286
|
+
<label className="text-sm">
|
|
287
|
+
Name
|
|
288
|
+
<input
|
|
289
|
+
required
|
|
290
|
+
className={inputClass}
|
|
291
|
+
value={form.name}
|
|
292
|
+
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
|
293
|
+
/>
|
|
294
|
+
</label>
|
|
295
|
+
</div>
|
|
296
|
+
<SubscriptionPricePicker
|
|
297
|
+
key={editorRevision}
|
|
298
|
+
value={form}
|
|
299
|
+
boundPrices={boundPrices}
|
|
300
|
+
onChange={(prices) => setForm((current) => ({ ...current, ...prices }))}
|
|
301
|
+
/>
|
|
311
302
|
<label className="flex items-center gap-2 text-sm">
|
|
312
303
|
<input
|
|
313
304
|
type="checkbox"
|
|
@@ -16,10 +16,14 @@ export function recurringPrice(
|
|
|
16
16
|
requireActive = true,
|
|
17
17
|
): SubscriptionPrice {
|
|
18
18
|
const interval = price.recurring?.interval;
|
|
19
|
+
const product = typeof price.product === "string" ? null : price.product;
|
|
19
20
|
const productId = typeof price.product === "string" ? price.product : price.product.id;
|
|
20
21
|
if (
|
|
21
22
|
price.livemode !== (scope.mode === "live") ||
|
|
22
|
-
(requireActive &&
|
|
23
|
+
(requireActive &&
|
|
24
|
+
(!price.active ||
|
|
25
|
+
(product &&
|
|
26
|
+
(product.deleted || !product.active || product.livemode !== (scope.mode === "live"))))) ||
|
|
23
27
|
price.type !== "recurring" ||
|
|
24
28
|
!price.recurring ||
|
|
25
29
|
!["month", "year"].includes(interval ?? "") ||
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
memberListSchema,
|
|
13
13
|
saveSubscriptionPlanSchema,
|
|
14
14
|
subscriptionIdSchema,
|
|
15
|
+
stripeProductListSchema,
|
|
16
|
+
stripePriceListSchema,
|
|
15
17
|
} from "./schemas.js";
|
|
16
18
|
import type { PaymentSubscriptionService } from "./service.js";
|
|
17
19
|
import { SubscriptionError, type SubscriptionOptions } from "./types.js";
|
|
@@ -63,7 +65,7 @@ export function createSubscriptionRoutes(
|
|
|
63
65
|
"subscriptions/plans": {
|
|
64
66
|
methods: ["GET"],
|
|
65
67
|
public: true,
|
|
66
|
-
input: empty,
|
|
68
|
+
input: empty.default({}),
|
|
67
69
|
handler: async () => run(async () => ({ items: await (await service()).listPlans() })),
|
|
68
70
|
},
|
|
69
71
|
"subscriptions/checkout": privateRoute(checkoutSchema, "payments:manage_own", async (context) =>
|
|
@@ -95,6 +97,16 @@ export function createSubscriptionRoutes(
|
|
|
95
97
|
"subscriptions/plans/list": privateRoute(empty, "payments:manage", async () => ({
|
|
96
98
|
items: await (await service()).listPlans(true),
|
|
97
99
|
})),
|
|
100
|
+
"subscriptions/products/list": privateRoute(
|
|
101
|
+
stripeProductListSchema,
|
|
102
|
+
"payments:manage",
|
|
103
|
+
async (context) => (await service()).listStripeProducts(context.input),
|
|
104
|
+
),
|
|
105
|
+
"subscriptions/prices/list": privateRoute(
|
|
106
|
+
stripePriceListSchema,
|
|
107
|
+
"payments:manage",
|
|
108
|
+
async (context) => (await service()).listStripePrices(context.input),
|
|
109
|
+
),
|
|
98
110
|
"subscriptions/plans/save": privateRoute(
|
|
99
111
|
saveSubscriptionPlanSchema,
|
|
100
112
|
"payments:manage",
|
|
@@ -99,3 +99,28 @@ export const memberListSchema = z
|
|
|
99
99
|
export const subscriptionIdSchema = z
|
|
100
100
|
.object({ subscriptionId: z.string().regex(/^sub_[A-Za-z0-9]+$/) })
|
|
101
101
|
.strict();
|
|
102
|
+
|
|
103
|
+
export const stripeProductListSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
cursor: z
|
|
106
|
+
.string()
|
|
107
|
+
.regex(/^prod_[A-Za-z0-9]+$/)
|
|
108
|
+
.max(255)
|
|
109
|
+
.optional(),
|
|
110
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
111
|
+
})
|
|
112
|
+
.strict();
|
|
113
|
+
export const stripePriceListSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
productId: z
|
|
116
|
+
.string()
|
|
117
|
+
.regex(/^prod_[A-Za-z0-9]+$/)
|
|
118
|
+
.max(255),
|
|
119
|
+
cursor: z
|
|
120
|
+
.string()
|
|
121
|
+
.regex(/^price_[A-Za-z0-9]+$/)
|
|
122
|
+
.max(255)
|
|
123
|
+
.optional(),
|
|
124
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
125
|
+
})
|
|
126
|
+
.strict();
|
|
@@ -12,7 +12,13 @@ import {
|
|
|
12
12
|
subscriptionProjection,
|
|
13
13
|
} from "./projection.js";
|
|
14
14
|
import { SubscriptionRepository, subscriptionScopeKey } from "./repository.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
checkoutSchema,
|
|
17
|
+
portalSchema,
|
|
18
|
+
saveSubscriptionPlanSchema,
|
|
19
|
+
stripeProductListSchema,
|
|
20
|
+
stripePriceListSchema,
|
|
21
|
+
} from "./schemas.js";
|
|
16
22
|
import {
|
|
17
23
|
SubscriptionError,
|
|
18
24
|
type MemberBilling,
|
|
@@ -20,6 +26,10 @@ import {
|
|
|
20
26
|
type SubscriptionCheckoutInput,
|
|
21
27
|
type SubscriptionOptions,
|
|
22
28
|
type SubscriptionScope,
|
|
29
|
+
type SubscriptionCatalogInput,
|
|
30
|
+
type SubscriptionCatalogPage,
|
|
31
|
+
type SubscriptionProduct,
|
|
32
|
+
type StripeSubscriptionPrice,
|
|
23
33
|
} from "./types.js";
|
|
24
34
|
|
|
25
35
|
export interface SubscriptionServiceOptions {
|
|
@@ -90,6 +100,55 @@ export class PaymentSubscriptionService {
|
|
|
90
100
|
.filter((plan) => plan.enabled)
|
|
91
101
|
.map((plan) => ({ ...plan, prices: plan.prices.filter((price) => price.enabled) }));
|
|
92
102
|
}
|
|
103
|
+
async listStripeProducts(
|
|
104
|
+
input: SubscriptionCatalogInput = {},
|
|
105
|
+
): Promise<SubscriptionCatalogPage<SubscriptionProduct>> {
|
|
106
|
+
const { cursor, limit } = stripeProductListSchema.parse(input);
|
|
107
|
+
const page = await this.stripe.products.list({ active: true, limit, starting_after: cursor });
|
|
108
|
+
return {
|
|
109
|
+
items: page.data
|
|
110
|
+
.filter(
|
|
111
|
+
(product) => product.active && product.livemode === (this.options.scope.mode === "live"),
|
|
112
|
+
)
|
|
113
|
+
.map((product) => ({ id: product.id, name: product.name })),
|
|
114
|
+
nextCursor: page.has_more ? page.data.at(-1)?.id : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async listStripePrices(
|
|
118
|
+
input: SubscriptionCatalogInput & { productId: string },
|
|
119
|
+
): Promise<SubscriptionCatalogPage<StripeSubscriptionPrice>> {
|
|
120
|
+
const { productId, cursor, limit } = stripePriceListSchema.parse(input);
|
|
121
|
+
const product = await this.stripe.products.retrieve(productId);
|
|
122
|
+
if (
|
|
123
|
+
product.deleted ||
|
|
124
|
+
!product.active ||
|
|
125
|
+
product.livemode !== (this.options.scope.mode === "live")
|
|
126
|
+
) {
|
|
127
|
+
throw new SubscriptionError(
|
|
128
|
+
"INVALID_PRODUCT",
|
|
129
|
+
"Choose an active product from this Stripe environment",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const page = await this.stripe.prices.list({
|
|
133
|
+
product: productId,
|
|
134
|
+
active: true,
|
|
135
|
+
type: "recurring",
|
|
136
|
+
limit,
|
|
137
|
+
starting_after: cursor,
|
|
138
|
+
});
|
|
139
|
+
const items: StripeSubscriptionPrice[] = [];
|
|
140
|
+
for (const price of page.data) {
|
|
141
|
+
try {
|
|
142
|
+
const verified = recurringPrice(price, this.options.scope);
|
|
143
|
+
if (verified.productId === productId)
|
|
144
|
+
items.push({ ...verified, nickname: price.nickname ?? null });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!(error instanceof SubscriptionError) || error.code !== "INVALID_PRICE") throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { items, nextCursor: page.has_more ? page.data.at(-1)?.id : undefined };
|
|
150
|
+
}
|
|
151
|
+
|
|
93
152
|
async savePlan(input: SaveSubscriptionPlan) {
|
|
94
153
|
const parsed = saveSubscriptionPlanSchema.parse(input);
|
|
95
154
|
const allPlans = await this.repository.plans();
|
|
@@ -113,7 +172,7 @@ export class PaymentSubscriptionService {
|
|
|
113
172
|
] as const) {
|
|
114
173
|
if (!id) continue;
|
|
115
174
|
const price = recurringPrice(
|
|
116
|
-
await this.stripe.prices.retrieve(id),
|
|
175
|
+
await this.stripe.prices.retrieve(id, { expand: ["product"] }),
|
|
117
176
|
this.options.scope,
|
|
118
177
|
parsed.enabled,
|
|
119
178
|
);
|
|
@@ -19,6 +19,21 @@ export interface SubscriptionPrice {
|
|
|
19
19
|
interval: SubscriptionInterval;
|
|
20
20
|
enabled: boolean;
|
|
21
21
|
}
|
|
22
|
+
export interface SubscriptionProduct {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
export interface StripeSubscriptionPrice extends SubscriptionPrice {
|
|
27
|
+
nickname: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface SubscriptionCatalogPage<T> {
|
|
30
|
+
items: T[];
|
|
31
|
+
nextCursor?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SubscriptionCatalogInput {
|
|
34
|
+
cursor?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
}
|
|
22
37
|
export interface SubscriptionPlan {
|
|
23
38
|
code: string;
|
|
24
39
|
name: string;
|