@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dineway-ai/plugin-payment",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Provider-neutral payment infrastructure and Stripe integration for Dineway plugins",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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="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>
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 && !price.active) ||
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 { checkoutSchema, portalSchema, saveSubscriptionPlanSchema } from "./schemas.js";
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;