@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/package.json +4 -3
- package/src/admin/form.ts +1 -5
- package/src/admin/subscriptions.tsx +514 -0
- package/src/admin.tsx +191 -31
- package/src/configuration/repository.ts +4 -12
- package/src/configuration/secrets.ts +3 -1
- package/src/configuration/service.ts +39 -34
- package/src/configuration/types.ts +1 -5
- package/src/index.ts +26 -5
- package/src/routes/index.ts +4 -6
- package/src/stripe/account-probe.ts +1 -1
- package/src/stripe/checkout-provider.ts +19 -5
- package/src/stripe/refund-provider.ts +2 -1
- package/src/stripe/webhook-verifier.ts +12 -2
- package/src/subscriptions/format.ts +12 -0
- package/src/subscriptions/index.ts +6 -0
- package/src/subscriptions/projection.ts +185 -0
- package/src/subscriptions/repository.ts +443 -0
- package/src/subscriptions/routes.ts +146 -0
- package/src/subscriptions/runtime.ts +65 -0
- package/src/subscriptions/schemas.ts +101 -0
- package/src/subscriptions/service.ts +510 -0
- package/src/subscriptions/types.ts +80 -0
- package/src/types.ts +1 -0
package/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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
111
|
-
|
|
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 ?
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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"
|
|
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
|
|
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
|
|
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: {
|
|
140
|
-
|
|
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: {
|
|
144
|
-
|
|
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 = {
|
|
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
|
|
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(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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 (
|
|
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 = [
|
|
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: [
|
|
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(
|
|
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: {
|
|
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:
|
|
65
|
+
routes: {
|
|
66
|
+
...createPaymentRoutes(),
|
|
67
|
+
...(options.subscriptions ? createSubscriptionRoutes(options.subscriptions) : {}),
|
|
68
|
+
},
|
|
48
69
|
});
|
|
49
70
|
}
|
|
50
71
|
|
package/src/routes/index.ts
CHANGED
|
@@ -31,7 +31,9 @@ export interface PaymentRouteOptions {
|
|
|
31
31
|
secretOptions?: PluginSecretStoreOptions;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function createPaymentRoutes(
|
|
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 (
|
|
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 (
|
|
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:
|
|
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:
|
|
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]) =>
|
|
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]) =>
|
|
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
|
-
|
|
100
|
-
|
|
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
|
}
|