@dineway-ai/plugin-payment 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/admin.tsx CHANGED
@@ -1,18 +1,18 @@
1
1
  import * as React from "react";
2
2
 
3
- import type { PaymentStripeConfiguration } from "./configuration/service.js";
4
3
  import { postPaymentRoute } from "./admin/api.js";
5
4
  import {
6
5
  buildPaymentSettingsUpdate,
7
6
  paymentSettingsForm,
8
7
  type PaymentSettingsForm,
9
8
  } from "./admin/form.js";
9
+ import { PaymentSubscriptionsPage } from "./admin/subscriptions.js";
10
+ import type { PaymentStripeConfiguration } from "./configuration/service.js";
10
11
 
11
12
  type CredentialName = "secretKey" | "webhookSecret";
12
13
 
13
14
  export function PaymentSettingsPage() {
14
- const [configuration, setConfiguration] =
15
- React.useState<PaymentStripeConfiguration | null>(null);
15
+ const [configuration, setConfiguration] = React.useState<PaymentStripeConfiguration | null>(null);
16
16
  const [form, setForm] = React.useState<PaymentSettingsForm | null>(null);
17
17
  const [loading, setLoading] = React.useState(true);
18
18
  const [pending, setPending] = React.useState(false);
@@ -26,9 +26,7 @@ export function PaymentSettingsPage() {
26
26
 
27
27
  React.useEffect(() => {
28
28
  let cancelled = false;
29
- void postPaymentRoute<{ configuration: PaymentStripeConfiguration }>(
30
- "stripe/configuration/get",
31
- )
29
+ void postPaymentRoute<{ configuration: PaymentStripeConfiguration }>("stripe/configuration/get")
32
30
  .then((response) => {
33
31
  if (!cancelled) applyConfiguration(response.configuration);
34
32
  return undefined;
@@ -94,58 +92,220 @@ export function PaymentSettingsPage() {
94
92
 
95
93
  if (loading) return <p className="text-sm text-kumo-subtle">Loading payment settings…</p>;
96
94
  if (!configuration || !form) {
97
- return <p className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error ?? "Payment configuration is unavailable."}</p>;
95
+ return (
96
+ <p className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">
97
+ {error ?? "Payment configuration is unavailable."}
98
+ </p>
99
+ );
98
100
  }
99
101
  const canTest = Boolean(
100
102
  configuration.settings.publishableKey &&
101
- configuration.secretKey &&
102
- form.mode === configuration.settings.mode &&
103
- form.publishableKey.trim() === configuration.settings.publishableKey &&
104
- !form.secretKey.trim(),
103
+ configuration.secretKey &&
104
+ form.mode === configuration.settings.mode &&
105
+ form.publishableKey.trim() === configuration.settings.publishableKey &&
106
+ !form.secretKey.trim(),
105
107
  );
106
108
 
107
109
  return (
108
110
  <div className="space-y-6">
109
111
  <header className="flex flex-wrap items-start justify-between gap-3">
110
- <div><h1 className="text-2xl font-semibold">Payments</h1><p className="mt-1 text-sm text-kumo-subtle">Shared Stripe account and encrypted credentials for every transactional plugin.</p></div>
111
- <span className="rounded-full bg-kumo-tint px-3 py-1 text-xs font-semibold uppercase">{configuration.settings.configurationStatus}</span>
112
+ <div>
113
+ <h1 className="text-2xl font-semibold">Payments</h1>
114
+ <p className="mt-1 text-sm text-kumo-subtle">
115
+ Shared Stripe account and encrypted credentials for every transactional plugin.
116
+ </p>
117
+ </div>
118
+ <span className="rounded-full bg-kumo-tint px-3 py-1 text-xs font-semibold uppercase">
119
+ {configuration.settings.configurationStatus}
120
+ </span>
112
121
  </header>
113
122
  <Readiness configuration={configuration} />
114
- {error ? <p className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger" role="alert">{error}</p> : null}
115
- {notice ? <p className="rounded-lg bg-kumo-success/10 p-3 text-sm text-kumo-success" role="status">{notice}</p> : null}
116
- <form className="grid max-w-3xl gap-5 rounded-xl border border-kumo-line p-5" onSubmit={(event) => void save(event)}>
117
- <fieldset className="grid gap-2" disabled={pending}><legend className="mb-2 text-sm font-medium">Stripe mode</legend><div className="flex gap-2">{(["test", "live"] as const).map((mode) => <button type="button" key={mode} className={`rounded-lg border px-4 py-2 text-sm ${form.mode === mode ? "border-kumo-contrast bg-kumo-contrast text-kumo-base" : "border-kumo-line"}`} onClick={() => setForm({ ...form, mode })}>{mode === "test" ? "Test" : "Live"}</button>)}</div></fieldset>
118
- <Field label="Publishable key" value={form.publishableKey} onChange={(value) => setForm({ ...form, publishableKey: value })} placeholder={form.mode === "test" ? "pk_test_…" : "pk_live_…"} />
119
- <Field label="Secret key" value={form.secretKey} onChange={(value) => setForm({ ...form, secretKey: value })} placeholder={configuration.secretKey ? "Leave blank to keep the current key" : form.mode === "test" ? "sk_test_…" : "sk_live_…"} type="password" />
120
- <Credential label="Secret key" present={Boolean(configuration.secretKey)} fingerprint={configuration.secretKey?.fingerprint ?? null} onRemove={() => void removeCredential("secretKey")} disabled={pending} />
121
- <Field label="Webhook signing secret" value={form.webhookSecret} onChange={(value) => setForm({ ...form, webhookSecret: value })} placeholder={configuration.webhookSecret ? "Leave blank to keep the current secret" : "whsec_…"} type="password" />
122
- <Credential label="Webhook secret" present={Boolean(configuration.webhookSecret)} fingerprint={configuration.webhookSecret?.fingerprint ?? null} onRemove={() => void removeCredential("webhookSecret")} disabled={pending} />
123
- <div className="flex flex-wrap gap-2"><button className="rounded-lg bg-kumo-contrast px-4 py-2 text-sm font-medium text-kumo-base" disabled={pending}>{pending ? "Saving…" : "Save account"}</button><button type="button" className="rounded-lg border border-kumo-line px-4 py-2 text-sm font-medium" disabled={!canTest || pending} onClick={() => void testConnection()}>{pending ? "Working…" : "Test connection"}</button></div>
123
+ {error ? (
124
+ <p className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger" role="alert">
125
+ {error}
126
+ </p>
127
+ ) : null}
128
+ {notice ? (
129
+ <p className="rounded-lg bg-kumo-success/10 p-3 text-sm text-kumo-success" role="status">
130
+ {notice}
131
+ </p>
132
+ ) : null}
133
+ <form
134
+ className="grid max-w-3xl gap-5 rounded-xl border border-kumo-line p-5"
135
+ onSubmit={(event) => void save(event)}
136
+ >
137
+ <fieldset className="grid gap-2" disabled={pending}>
138
+ <legend className="mb-2 text-sm font-medium">Stripe mode</legend>
139
+ <div className="flex gap-2">
140
+ {(["test", "live"] as const).map((mode) => (
141
+ <button
142
+ type="button"
143
+ key={mode}
144
+ className={`rounded-lg border px-4 py-2 text-sm ${form.mode === mode ? "border-kumo-contrast bg-kumo-contrast text-kumo-base" : "border-kumo-line"}`}
145
+ onClick={() => setForm({ ...form, mode })}
146
+ >
147
+ {mode === "test" ? "Test" : "Live"}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ </fieldset>
152
+ <Field
153
+ label="Publishable key"
154
+ value={form.publishableKey}
155
+ onChange={(value) => setForm({ ...form, publishableKey: value })}
156
+ placeholder={form.mode === "test" ? "pk_test_…" : "pk_live_…"}
157
+ />
158
+ <Field
159
+ label="Secret key"
160
+ value={form.secretKey}
161
+ onChange={(value) => setForm({ ...form, secretKey: value })}
162
+ placeholder={
163
+ configuration.secretKey
164
+ ? "Leave blank to keep the current key"
165
+ : form.mode === "test"
166
+ ? "sk_test_…"
167
+ : "sk_live_…"
168
+ }
169
+ type="password"
170
+ />
171
+ <Credential
172
+ label="Secret key"
173
+ present={Boolean(configuration.secretKey)}
174
+ fingerprint={configuration.secretKey?.fingerprint ?? null}
175
+ onRemove={() => void removeCredential("secretKey")}
176
+ disabled={pending}
177
+ />
178
+ <Field
179
+ label="Webhook signing secret"
180
+ value={form.webhookSecret}
181
+ onChange={(value) => setForm({ ...form, webhookSecret: value })}
182
+ placeholder={
183
+ configuration.webhookSecret ? "Leave blank to keep the current secret" : "whsec_…"
184
+ }
185
+ type="password"
186
+ />
187
+ <Credential
188
+ label="Webhook secret"
189
+ present={Boolean(configuration.webhookSecret)}
190
+ fingerprint={configuration.webhookSecret?.fingerprint ?? null}
191
+ onRemove={() => void removeCredential("webhookSecret")}
192
+ disabled={pending}
193
+ />
194
+ <div className="flex flex-wrap gap-2">
195
+ <button
196
+ className="rounded-lg bg-kumo-contrast px-4 py-2 text-sm font-medium text-kumo-base"
197
+ disabled={pending}
198
+ >
199
+ {pending ? "Saving…" : "Save account"}
200
+ </button>
201
+ <button
202
+ type="button"
203
+ className="rounded-lg border border-kumo-line px-4 py-2 text-sm font-medium"
204
+ disabled={!canTest || pending}
205
+ onClick={() => void testConnection()}
206
+ >
207
+ {pending ? "Working…" : "Test connection"}
208
+ </button>
209
+ </div>
124
210
  </form>
125
- <section className="max-w-3xl rounded-xl bg-kumo-tint/40 p-5 text-sm text-kumo-subtle"><h2 className="font-semibold text-kumo-default">Webhook endpoint</h2><p className="mt-2">Configure Stripe to send domain events to the webhook shown by each installed transactional plugin. Venue and Restaurant validate exact request bytes with this shared signing secret.</p></section>
211
+ <section className="max-w-3xl rounded-xl bg-kumo-tint/40 p-5 text-sm text-kumo-subtle">
212
+ <h2 className="font-semibold text-kumo-default">Webhook endpoint</h2>
213
+ <p className="mt-2">
214
+ Configure Stripe to send domain events to the webhook shown by each installed
215
+ transactional plugin. Venue and Restaurant validate exact request bytes with this shared
216
+ signing secret.
217
+ </p>
218
+ </section>
126
219
  </div>
127
220
  );
128
221
  }
129
222
 
130
223
  function Readiness({ configuration }: { configuration: PaymentStripeConfiguration }) {
131
224
  const settings = configuration.settings;
132
- return <div className="grid gap-3 md:grid-cols-4"><Metric label="Account" value={settings.stripeAccountId ?? "Not verified"} /><Metric label="Charges" value={settings.chargesEnabled === null ? "Unknown" : settings.chargesEnabled ? "Enabled" : "Restricted"} /><Metric label="Tax" value={settings.taxStatus} /><Metric label="Connection" value={settings.connectStatus} /></div>;
225
+ return (
226
+ <div className="grid gap-3 md:grid-cols-4">
227
+ <Metric label="Account" value={settings.stripeAccountId ?? "Not verified"} />
228
+ <Metric
229
+ label="Charges"
230
+ value={
231
+ settings.chargesEnabled === null
232
+ ? "Unknown"
233
+ : settings.chargesEnabled
234
+ ? "Enabled"
235
+ : "Restricted"
236
+ }
237
+ />
238
+ <Metric label="Tax" value={settings.taxStatus} />
239
+ <Metric label="Connection" value={settings.connectStatus} />
240
+ </div>
241
+ );
133
242
  }
134
243
 
135
244
  function Metric({ label, value }: { label: string; value: string }) {
136
- return <div className="rounded-xl border border-kumo-line p-4"><div className="text-xs font-medium uppercase text-kumo-subtle">{label}</div><div className="mt-1 truncate font-semibold">{value}</div></div>;
245
+ return (
246
+ <div className="rounded-xl border border-kumo-line p-4">
247
+ <div className="text-xs font-medium uppercase text-kumo-subtle">{label}</div>
248
+ <div className="mt-1 truncate font-semibold">{value}</div>
249
+ </div>
250
+ );
137
251
  }
138
252
 
139
- function Field(props: { label: string; value: string; onChange: (value: string) => void; placeholder: string; type?: string }) {
140
- return <label className="grid gap-1 text-sm font-medium">{props.label}<input className="rounded-lg border border-kumo-line bg-transparent px-3 py-2 font-normal" type={props.type ?? "text"} value={props.value} onChange={(event) => props.onChange(event.target.value)} placeholder={props.placeholder} autoComplete="off" disabled={false} /></label>;
253
+ function Field(props: {
254
+ label: string;
255
+ value: string;
256
+ onChange: (value: string) => void;
257
+ placeholder: string;
258
+ type?: string;
259
+ }) {
260
+ return (
261
+ <label className="grid gap-1 text-sm font-medium">
262
+ {props.label}
263
+ <input
264
+ className="rounded-lg border border-kumo-line bg-transparent px-3 py-2 font-normal"
265
+ type={props.type ?? "text"}
266
+ value={props.value}
267
+ onChange={(event) => props.onChange(event.target.value)}
268
+ placeholder={props.placeholder}
269
+ autoComplete="off"
270
+ disabled={false}
271
+ />
272
+ </label>
273
+ );
141
274
  }
142
275
 
143
- function Credential(props: { label: string; present: boolean; fingerprint: string | null; onRemove: () => void; disabled: boolean }) {
144
- return <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg bg-kumo-tint/50 px-3 py-2 text-sm"><span>{props.present ? `${props.label} stored · ${props.fingerprint ?? "encrypted"}` : `${props.label} not stored`}</span>{props.present ? <button type="button" className="text-kumo-danger underline" onClick={props.onRemove} disabled={props.disabled}>Remove</button> : null}</div>;
276
+ function Credential(props: {
277
+ label: string;
278
+ present: boolean;
279
+ fingerprint: string | null;
280
+ onRemove: () => void;
281
+ disabled: boolean;
282
+ }) {
283
+ return (
284
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg bg-kumo-tint/50 px-3 py-2 text-sm">
285
+ <span>
286
+ {props.present
287
+ ? `${props.label} stored · ${props.fingerprint ?? "encrypted"}`
288
+ : `${props.label} not stored`}
289
+ </span>
290
+ {props.present ? (
291
+ <button
292
+ type="button"
293
+ className="text-kumo-danger underline"
294
+ onClick={props.onRemove}
295
+ disabled={props.disabled}
296
+ >
297
+ Remove
298
+ </button>
299
+ ) : null}
300
+ </div>
301
+ );
145
302
  }
146
303
 
147
304
  function message(value: unknown): string {
148
305
  return value instanceof Error ? value.message : "Payment request failed";
149
306
  }
150
307
 
151
- export const pages = { "/payments": PaymentSettingsPage };
308
+ export const pages = {
309
+ "/payments": PaymentSettingsPage,
310
+ "/payments/subscriptions": PaymentSubscriptionsPage,
311
+ };
@@ -34,15 +34,11 @@ export class PaymentStripeConfigurationRepository {
34
34
  ): Promise<PaymentStripeSettings> {
35
35
  const update: Updateable<Database["payment_stripe_settings"]> = {
36
36
  ...(input.mode === undefined ? {} : { mode: input.mode }),
37
- ...(input.publishableKey === undefined
38
- ? {}
39
- : { publishable_key: input.publishableKey }),
37
+ ...(input.publishableKey === undefined ? {} : { publishable_key: input.publishableKey }),
40
38
  ...(input.configurationStatus === undefined
41
39
  ? {}
42
40
  : { configuration_status: input.configurationStatus }),
43
- ...(input.stripeAccountId === undefined
44
- ? {}
45
- : { stripe_account_id: input.stripeAccountId }),
41
+ ...(input.stripeAccountId === undefined ? {} : { stripe_account_id: input.stripeAccountId }),
46
42
  ...(input.chargesEnabled === undefined
47
43
  ? {}
48
44
  : { charges_enabled: optionalBoolean(input.chargesEnabled) }),
@@ -53,12 +49,8 @@ export class PaymentStripeConfigurationRepository {
53
49
  ...(input.validationErrorCode === undefined
54
50
  ? {}
55
51
  : { validation_error_code: input.validationErrorCode }),
56
- ...(input.lastValidatedAt === undefined
57
- ? {}
58
- : { last_validated_at: input.lastValidatedAt }),
59
- ...(input.connectStatus === undefined
60
- ? {}
61
- : { connect_status: input.connectStatus }),
52
+ ...(input.lastValidatedAt === undefined ? {} : { last_validated_at: input.lastValidatedAt }),
53
+ ...(input.connectStatus === undefined ? {} : { connect_status: input.connectStatus }),
62
54
  version: expectedVersion + 1,
63
55
  updated_at: new Date().toISOString(),
64
56
  };
@@ -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;
@@ -1,8 +1,4 @@
1
- import type {
2
- Database,
3
- PluginSecretStatus,
4
- PluginSecretStoreOptions,
5
- } from "dineway";
1
+ import type { Database, PluginSecretStatus, PluginSecretStoreOptions } from "dineway";
6
2
  import type { Kysely, Transaction } from "kysely";
7
3
 
8
4
  import type { StripeAccountProbe, StripeMode } from "../types.js";
@@ -103,7 +99,9 @@ export async function updatePaymentStripeConfiguration(
103
99
  taxStatus: "unknown" as const,
104
100
  validationErrorCode: null,
105
101
  lastValidatedAt: null,
106
- connectStatus: credentialsComplete ? ("restricted" as const) : ("not_configured" as const),
102
+ connectStatus: credentialsComplete
103
+ ? ("restricted" as const)
104
+ : ("not_configured" as const),
107
105
  }
108
106
  : {}),
109
107
  });
@@ -143,28 +141,31 @@ export async function testPaymentStripeConnection(
143
141
 
144
142
  return db.transaction().execute(async (trx) => {
145
143
  const repository = new PaymentStripeConfigurationRepository(trx);
146
- await repository.updateSettings(before.version, result.ok
147
- ? {
148
- configurationStatus: "valid",
149
- stripeAccountId: result.accountId,
150
- chargesEnabled: result.chargesEnabled,
151
- detailsSubmitted: result.detailsSubmitted,
152
- taxStatus: result.taxStatus,
153
- validationErrorCode: null,
154
- lastValidatedAt: new Date().toISOString(),
155
- connectStatus:
156
- result.chargesEnabled && result.detailsSubmitted ? "ready" : "restricted",
157
- }
158
- : {
159
- configurationStatus: "invalid",
160
- stripeAccountId: null,
161
- chargesEnabled: null,
162
- detailsSubmitted: null,
163
- taxStatus: "unknown",
164
- validationErrorCode: result.errorCode,
165
- lastValidatedAt: new Date().toISOString(),
166
- connectStatus: "restricted",
167
- });
144
+ await repository.updateSettings(
145
+ before.version,
146
+ result.ok
147
+ ? {
148
+ configurationStatus: "valid",
149
+ stripeAccountId: result.accountId,
150
+ chargesEnabled: result.chargesEnabled,
151
+ detailsSubmitted: result.detailsSubmitted,
152
+ taxStatus: result.taxStatus,
153
+ validationErrorCode: null,
154
+ lastValidatedAt: new Date().toISOString(),
155
+ connectStatus:
156
+ result.chargesEnabled && result.detailsSubmitted ? "ready" : "restricted",
157
+ }
158
+ : {
159
+ configurationStatus: "invalid",
160
+ stripeAccountId: null,
161
+ chargesEnabled: null,
162
+ detailsSubmitted: null,
163
+ taxStatus: "unknown",
164
+ validationErrorCode: result.errorCode,
165
+ lastValidatedAt: new Date().toISOString(),
166
+ connectStatus: "restricted",
167
+ },
168
+ );
168
169
  return loadConfiguration(trx, secretOptions);
169
170
  });
170
171
  }
@@ -196,7 +197,10 @@ async function updateSecret(
196
197
  await store.set(name, value.trim());
197
198
  }
198
199
 
199
- function normalizeOptionalKey(value: string | null | undefined, current: string | null): string | null {
200
+ function normalizeOptionalKey(
201
+ value: string | null | undefined,
202
+ current: string | null,
203
+ ): string | null {
200
204
  if (value === undefined) return current;
201
205
  if (value === null) return null;
202
206
  return value.trim() || null;
@@ -211,7 +215,11 @@ function validateUpdate(input: UpdatePaymentStripeConfigurationInput): void {
211
215
  if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1) {
212
216
  throw new PaymentConfigurationError("VALIDATION_ERROR", "Expected version is invalid");
213
217
  }
214
- if (input.publishableKey !== undefined && input.publishableKey !== null && input.publishableKey.length > 512) {
218
+ if (
219
+ input.publishableKey !== undefined &&
220
+ input.publishableKey !== null &&
221
+ input.publishableKey.length > 512
222
+ ) {
215
223
  throw new PaymentConfigurationError("VALIDATION_ERROR", "Publishable key is too long");
216
224
  }
217
225
  if (input.secretKey !== undefined && input.secretKey !== null && input.secretKey.length > 512) {
@@ -233,9 +241,6 @@ function validateModeKeys(
233
241
  );
234
242
  }
235
243
  if (validateSecret && secretKey && !secretKey.startsWith(`sk_${keyMode}_`)) {
236
- throw new PaymentConfigurationError(
237
- "VALIDATION_ERROR",
238
- `Secret key must match ${mode} mode`,
239
- );
244
+ throw new PaymentConfigurationError("VALIDATION_ERROR", `Secret key must match ${mode} mode`);
240
245
  }
241
246
  }
@@ -1,10 +1,6 @@
1
1
  import type { StripeMode, StripeTaxStatus } from "../types.js";
2
2
 
3
- export type PaymentStripeConfigurationStatus =
4
- | "unconfigured"
5
- | "unverified"
6
- | "valid"
7
- | "invalid";
3
+ export type PaymentStripeConfigurationStatus = "unconfigured" | "unverified" | "valid" | "invalid";
8
4
  export type PaymentStripeConnectStatus = "not_configured" | "restricted" | "ready";
9
5
 
10
6
  export interface PaymentStripeSettings {
package/src/index.ts CHANGED
@@ -5,12 +5,17 @@ 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
- const PAYMENT_ADMIN_PAGES = [{ path: "/payments", label: "Payments", icon: "credit-card" }] as const;
12
+ const PAYMENT_ADMIN_PAGES = [
13
+ { path: "/payments", label: "Payments", icon: "credit-card" },
14
+ ] as const;
11
15
 
12
16
  export interface PaymentPluginOptions {
13
17
  appName?: string;
18
+ subscriptions?: SubscriptionOptions;
14
19
  }
15
20
 
16
21
  export function paymentPlugin(
@@ -22,14 +27,19 @@ export function paymentPlugin(
22
27
  format: "native",
23
28
  entrypoint: "@dineway-ai/plugin-payment",
24
29
  adminEntry: PAYMENT_ADMIN_ENTRY,
25
- adminPages: [...PAYMENT_ADMIN_PAGES],
30
+ adminPages: [
31
+ ...PAYMENT_ADMIN_PAGES,
32
+ ...(options.subscriptions
33
+ ? [{ path: "/payments/subscriptions", label: "Subscriptions", icon: "credit-card" }]
34
+ : []),
35
+ ],
26
36
  options,
27
37
  capabilities: ["network:request:unrestricted"],
28
38
  allowedHosts: ["api.stripe.com"],
29
39
  };
30
40
  }
31
41
 
32
- export function createPlugin(_options: PaymentPluginOptions = {}): ResolvedPlugin {
42
+ export function createPlugin(options: PaymentPluginOptions = {}): ResolvedPlugin {
33
43
  const ensurePaymentFoundation = async () => {
34
44
  await new PaymentStripeConfigurationRepository(await getDb()).ensureDefaults();
35
45
  };
@@ -42,9 +52,20 @@ export function createPlugin(_options: PaymentPluginOptions = {}): ResolvedPlugi
42
52
  version: packageVersion,
43
53
  capabilities: ["network:request:unrestricted"],
44
54
  allowedHosts: ["api.stripe.com"],
45
- admin: { entry: PAYMENT_ADMIN_ENTRY, pages: [...PAYMENT_ADMIN_PAGES] },
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
+ },
46
64
  hooks,
47
- routes: createPaymentRoutes(),
65
+ routes: {
66
+ ...createPaymentRoutes(),
67
+ ...(options.subscriptions ? createSubscriptionRoutes(options.subscriptions) : {}),
68
+ },
48
69
  });
49
70
  }
50
71
 
@@ -31,7 +31,9 @@ export interface PaymentRouteOptions {
31
31
  secretOptions?: PluginSecretStoreOptions;
32
32
  }
33
33
 
34
- export function createPaymentRoutes(options: PaymentRouteOptions = {}): Record<string, PluginRoute> {
34
+ export function createPaymentRoutes(
35
+ options: PaymentRouteOptions = {},
36
+ ): Record<string, PluginRoute> {
35
37
  const probe = options.accountProbe ?? new StripeNodeAccountProbe();
36
38
  return {
37
39
  "stripe/configuration/get": route(getPaymentStripeConfigurationSchema, async () => ({
@@ -48,11 +50,7 @@ export function createPaymentRoutes(options: PaymentRouteOptions = {}): Record<s
48
50
  }),
49
51
  ),
50
52
  "stripe/configuration/test": route(testPaymentStripeConnectionSchema, async () => ({
51
- configuration: await testPaymentStripeConnection(
52
- await getDb(),
53
- probe,
54
- options.secretOptions,
55
- ),
53
+ configuration: await testPaymentStripeConnection(await getDb(), probe, options.secretOptions),
56
54
  })),
57
55
  };
58
56
  }
@@ -1,11 +1,11 @@
1
1
  import Stripe from "stripe";
2
2
 
3
- import { stripeProviderFailure } from "./errors.js";
4
3
  import type {
5
4
  StripeAccountProbe,
6
5
  StripeAccountProbeInput,
7
6
  StripeAccountProbeResult,
8
7
  } from "../types.js";
8
+ import { stripeProviderFailure } from "./errors.js";
9
9
 
10
10
  export interface StripeAccountProbeClient {
11
11
  retrieveCurrentAccount(): Promise<{
@@ -360,7 +360,11 @@ function stripeAddress(address: StripeAddress) {
360
360
  }
361
361
 
362
362
  function assertTaxCalculationInput(input: StripeTaxCalculationInput): void {
363
- if (!input.secretKey.trim() || !input.idempotencyKey.trim() || !CURRENCY_PATTERN.test(input.currency)) {
363
+ if (
364
+ !input.secretKey.trim() ||
365
+ !input.idempotencyKey.trim() ||
366
+ !CURRENCY_PATTERN.test(input.currency)
367
+ ) {
364
368
  throw new Error("Stripe Tax credentials or currency are invalid");
365
369
  }
366
370
  if (input.lines.length === 0 || input.lines.length > 100) {
@@ -399,7 +403,10 @@ function assertTaxCalculationResponse(
399
403
  }
400
404
  }
401
405
  const responseLines = new Map(response.lineItems.map((line) => [line.reference, line] as const));
402
- if (response.lineItems.length !== input.lines.length || responseLines.size !== input.lines.length) {
406
+ if (
407
+ response.lineItems.length !== input.lines.length ||
408
+ responseLines.size !== input.lines.length
409
+ ) {
403
410
  throw new Error("Stripe Tax response line set does not match the request");
404
411
  }
405
412
  let calculatedTaxMinor = 0;
@@ -459,7 +466,10 @@ function assertPaymentIntentResponse(
459
466
  }
460
467
 
461
468
  function assertOperationInput(
462
- input: StripeCancelPaymentIntentInput | StripeCapturePaymentIntentInput | StripeIncrementAuthorizationInput,
469
+ input:
470
+ | StripeCancelPaymentIntentInput
471
+ | StripeCapturePaymentIntentInput
472
+ | StripeIncrementAuthorizationInput,
463
473
  requireAmount: boolean,
464
474
  ): void {
465
475
  if (
@@ -517,7 +527,10 @@ function assertCancelResponse(
517
527
  }
518
528
 
519
529
  function assertMatchingOperationResponse(
520
- input: StripeCancelPaymentIntentInput | StripeCapturePaymentIntentInput | StripeIncrementAuthorizationInput,
530
+ input:
531
+ | StripeCancelPaymentIntentInput
532
+ | StripeCapturePaymentIntentInput
533
+ | StripeIncrementAuthorizationInput,
521
534
  response: StripeCheckoutPaymentIntentResponse,
522
535
  ): void {
523
536
  if (
@@ -553,7 +566,8 @@ function validMetadata(metadata: Record<string, string> | undefined): boolean {
553
566
  return (
554
567
  entries.length <= 50 &&
555
568
  entries.every(
556
- ([key, value]) => key.length > 0 && key.length <= 40 && value.length > 0 && value.length <= 500,
569
+ ([key, value]) =>
570
+ key.length > 0 && key.length <= 40 && value.length > 0 && value.length <= 500,
557
571
  )
558
572
  );
559
573
  }
@@ -125,7 +125,8 @@ function validMetadata(metadata: Record<string, string> | undefined): boolean {
125
125
  return (
126
126
  entries.length <= 50 &&
127
127
  entries.every(
128
- ([key, value]) => key.length > 0 && key.length <= 40 && value.length > 0 && value.length <= 500,
128
+ ([key, value]) =>
129
+ key.length > 0 && key.length <= 40 && value.length > 0 && value.length <= 500,
129
130
  )
130
131
  );
131
132
  }
@@ -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,8 +104,10 @@ function projectEvent(event: Stripe.Event, rawBody: Uint8Array): StripeWebhookEv
96
104
  payloadSha256: `sha256:${createHash("sha256").update(rawBody).digest("hex")}`,
97
105
  objectType,
98
106
  objectId,
99
- paymentIntent:
100
- objectType === "payment_intent" ? projectPaymentIntent(objectRecord) : null,
107
+ ...(subscriptionRelation
108
+ ? { subscriptionId: requiredRelationId(subscriptionRelation, "sub_") }
109
+ : {}),
110
+ paymentIntent: objectType === "payment_intent" ? projectPaymentIntent(objectRecord) : null,
101
111
  refund: objectType === "refund" ? projectRefund(objectRecord) : null,
102
112
  };
103
113
  }