@dineway-ai/plugin-payment 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License
2
+
3
+ Copyright 2026 Cloudflare Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@dineway-ai/plugin-payment",
3
+ "version": "0.2.1",
4
+ "description": "Provider-neutral payment infrastructure and Stripe integration for Dineway plugins",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./admin": "./src/admin.tsx",
13
+ "./configuration": "./src/configuration/index.ts",
14
+ "./routes": "./src/routes/index.ts",
15
+ "./stripe": "./src/stripe/index.ts",
16
+ "./types": "./src/types.ts"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "keywords": [
22
+ "dineway",
23
+ "plugin",
24
+ "payments",
25
+ "stripe"
26
+ ],
27
+ "author": "Dineway",
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "react": "^18.0.0 || ^19.0.0",
31
+ "dineway": "^0.2.1"
32
+ },
33
+ "dependencies": {
34
+ "@stripe/stripe-js": "9.9.0",
35
+ "astro": "^7.0.0",
36
+ "kysely": "^0.27.0",
37
+ "stripe": "22.3.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "19.2.14",
41
+ "better-sqlite3": "^12.8.0",
42
+ "vitest": "^4.0.18"
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "typecheck": "tsgo --noEmit"
47
+ }
48
+ }
@@ -0,0 +1,12 @@
1
+ import { apiFetch, parseApiResponse } from "dineway/plugin-utils";
2
+
3
+ const API_BASE = "/_dineway/api/plugins/dineway-payment";
4
+
5
+ export async function postPaymentRoute<T>(route: string, body: unknown = {}): Promise<T> {
6
+ const response = await apiFetch(`${API_BASE}/${route}`, {
7
+ method: "POST",
8
+ headers: { "Content-Type": "application/json" },
9
+ body: JSON.stringify(body),
10
+ });
11
+ return parseApiResponse<T>(response, `Payment request failed (${route})`);
12
+ }
@@ -0,0 +1,56 @@
1
+ import type {
2
+ PaymentStripeConfiguration,
3
+ UpdatePaymentStripeConfigurationInput,
4
+ } from "../configuration/service.js";
5
+ import type { StripeMode } from "../types.js";
6
+
7
+ export interface PaymentSettingsForm {
8
+ mode: StripeMode;
9
+ publishableKey: string;
10
+ secretKey: string;
11
+ webhookSecret: string;
12
+ }
13
+
14
+ export function paymentSettingsForm(
15
+ configuration: PaymentStripeConfiguration,
16
+ ): PaymentSettingsForm {
17
+ return {
18
+ mode: configuration.settings.mode,
19
+ publishableKey: configuration.settings.publishableKey ?? "",
20
+ secretKey: "",
21
+ webhookSecret: "",
22
+ };
23
+ }
24
+
25
+ export function buildPaymentSettingsUpdate(
26
+ form: PaymentSettingsForm,
27
+ configuration: PaymentStripeConfiguration,
28
+ ): UpdatePaymentStripeConfigurationInput {
29
+ const publishableKey = form.publishableKey.trim();
30
+ const secretKey = form.secretKey.trim();
31
+ const webhookSecret = form.webhookSecret.trim();
32
+ const keyMode = form.mode === "live" ? "live" : "test";
33
+ if (publishableKey && !publishableKey.startsWith(`pk_${keyMode}_`)) {
34
+ throw new Error(`Publishable key must match ${form.mode} mode`);
35
+ }
36
+ if (secretKey && !secretKey.startsWith(`sk_${keyMode}_`)) {
37
+ throw new Error(`Secret key must match ${form.mode} mode`);
38
+ }
39
+ if (webhookSecret && !webhookSecret.startsWith("whsec_")) {
40
+ throw new Error("Webhook signing secret must start with whsec_");
41
+ }
42
+ if (
43
+ configuration.secretKey &&
44
+ configuration.settings.mode !== form.mode &&
45
+ !secretKey
46
+ ) {
47
+ throw new Error("Enter the matching secret key when changing Stripe mode");
48
+ }
49
+ return {
50
+ expectedVersion: configuration.settings.version,
51
+ mode: form.mode,
52
+ publishableKey: publishableKey || null,
53
+ ...(secretKey ? { secretKey } : {}),
54
+ ...(webhookSecret ? { webhookSecret } : {}),
55
+ };
56
+ }
package/src/admin.tsx ADDED
@@ -0,0 +1,151 @@
1
+ import * as React from "react";
2
+
3
+ import type { PaymentStripeConfiguration } from "./configuration/service.js";
4
+ import { postPaymentRoute } from "./admin/api.js";
5
+ import {
6
+ buildPaymentSettingsUpdate,
7
+ paymentSettingsForm,
8
+ type PaymentSettingsForm,
9
+ } from "./admin/form.js";
10
+
11
+ type CredentialName = "secretKey" | "webhookSecret";
12
+
13
+ export function PaymentSettingsPage() {
14
+ const [configuration, setConfiguration] =
15
+ React.useState<PaymentStripeConfiguration | null>(null);
16
+ const [form, setForm] = React.useState<PaymentSettingsForm | null>(null);
17
+ const [loading, setLoading] = React.useState(true);
18
+ const [pending, setPending] = React.useState(false);
19
+ const [error, setError] = React.useState<string | null>(null);
20
+ const [notice, setNotice] = React.useState<string | null>(null);
21
+
22
+ const applyConfiguration = React.useCallback((next: PaymentStripeConfiguration) => {
23
+ setConfiguration(next);
24
+ setForm(paymentSettingsForm(next));
25
+ }, []);
26
+
27
+ React.useEffect(() => {
28
+ let cancelled = false;
29
+ void postPaymentRoute<{ configuration: PaymentStripeConfiguration }>(
30
+ "stripe/configuration/get",
31
+ )
32
+ .then((response) => {
33
+ if (!cancelled) applyConfiguration(response.configuration);
34
+ return undefined;
35
+ })
36
+ .catch((cause) => {
37
+ if (!cancelled) setError(message(cause));
38
+ })
39
+ .finally(() => {
40
+ if (!cancelled) setLoading(false);
41
+ });
42
+ return () => {
43
+ cancelled = true;
44
+ };
45
+ }, [applyConfiguration]);
46
+
47
+ async function save(event: React.FormEvent) {
48
+ event.preventDefault();
49
+ if (!configuration || !form) return;
50
+ await run(async () => {
51
+ const response = await postPaymentRoute<{ configuration: PaymentStripeConfiguration }>(
52
+ "stripe/configuration/update",
53
+ buildPaymentSettingsUpdate(form, configuration),
54
+ );
55
+ applyConfiguration(response.configuration);
56
+ setNotice("Stripe account saved. Test the connection before accepting payments.");
57
+ });
58
+ }
59
+
60
+ async function testConnection() {
61
+ await run(async () => {
62
+ const response = await postPaymentRoute<{ configuration: PaymentStripeConfiguration }>(
63
+ "stripe/configuration/test",
64
+ );
65
+ applyConfiguration(response.configuration);
66
+ setNotice("Stripe connection test completed.");
67
+ });
68
+ }
69
+
70
+ async function removeCredential(name: CredentialName) {
71
+ if (!configuration) return;
72
+ await run(async () => {
73
+ const response = await postPaymentRoute<{ configuration: PaymentStripeConfiguration }>(
74
+ "stripe/configuration/update",
75
+ { expectedVersion: configuration.settings.version, [name]: null },
76
+ );
77
+ applyConfiguration(response.configuration);
78
+ setNotice(name === "secretKey" ? "Secret key removed." : "Webhook secret removed.");
79
+ });
80
+ }
81
+
82
+ async function run(action: () => Promise<void>) {
83
+ setPending(true);
84
+ setError(null);
85
+ setNotice(null);
86
+ try {
87
+ await action();
88
+ } catch (cause) {
89
+ setError(message(cause));
90
+ } finally {
91
+ setPending(false);
92
+ }
93
+ }
94
+
95
+ if (loading) return <p className="text-sm text-kumo-subtle">Loading payment settings…</p>;
96
+ 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>;
98
+ }
99
+ const canTest = Boolean(
100
+ configuration.settings.publishableKey &&
101
+ configuration.secretKey &&
102
+ form.mode === configuration.settings.mode &&
103
+ form.publishableKey.trim() === configuration.settings.publishableKey &&
104
+ !form.secretKey.trim(),
105
+ );
106
+
107
+ return (
108
+ <div className="space-y-6">
109
+ <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
+ </header>
113
+ <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>
124
+ </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>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function Readiness({ configuration }: { configuration: PaymentStripeConfiguration }) {
131
+ 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>;
133
+ }
134
+
135
+ 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>;
137
+ }
138
+
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>;
141
+ }
142
+
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>;
145
+ }
146
+
147
+ function message(value: unknown): string {
148
+ return value instanceof Error ? value.message : "Payment request failed";
149
+ }
150
+
151
+ export const pages = { "/payments": PaymentSettingsPage };
@@ -0,0 +1,4 @@
1
+ export * from "./repository.js";
2
+ export * from "./service.js";
3
+ export * from "./secrets.js";
4
+ export * from "./types.js";
@@ -0,0 +1,103 @@
1
+ import type { Database } from "dineway";
2
+ import type { Kysely, Selectable, Transaction, Updateable } from "kysely";
3
+
4
+ import type { PaymentStripeSettings, UpdatePaymentStripeSettings } from "./types.js";
5
+
6
+ type PaymentDb = Kysely<Database> | Transaction<Database>;
7
+ type SettingsRow = Selectable<Database["payment_stripe_settings"]>;
8
+
9
+ export class PaymentStripeConfigurationRepository {
10
+ constructor(private readonly db: PaymentDb) {}
11
+
12
+ async ensureDefaults(): Promise<PaymentStripeSettings> {
13
+ await this.db
14
+ .insertInto("payment_stripe_settings")
15
+ .values({ id: "default", default_key: "default", publishable_key: null })
16
+ .onConflict((conflict) => conflict.column("default_key").doNothing())
17
+ .execute();
18
+ return this.requireSettings();
19
+ }
20
+
21
+ async requireSettings(): Promise<PaymentStripeSettings> {
22
+ const row = await this.db
23
+ .selectFrom("payment_stripe_settings")
24
+ .selectAll()
25
+ .where("default_key", "=", "default")
26
+ .executeTakeFirst();
27
+ if (!row) throw new Error("Payment Stripe configuration is not initialized");
28
+ return mapSettings(row);
29
+ }
30
+
31
+ async updateSettings(
32
+ expectedVersion: number,
33
+ input: UpdatePaymentStripeSettings,
34
+ ): Promise<PaymentStripeSettings> {
35
+ const update: Updateable<Database["payment_stripe_settings"]> = {
36
+ ...(input.mode === undefined ? {} : { mode: input.mode }),
37
+ ...(input.publishableKey === undefined
38
+ ? {}
39
+ : { publishable_key: input.publishableKey }),
40
+ ...(input.configurationStatus === undefined
41
+ ? {}
42
+ : { configuration_status: input.configurationStatus }),
43
+ ...(input.stripeAccountId === undefined
44
+ ? {}
45
+ : { stripe_account_id: input.stripeAccountId }),
46
+ ...(input.chargesEnabled === undefined
47
+ ? {}
48
+ : { charges_enabled: optionalBoolean(input.chargesEnabled) }),
49
+ ...(input.detailsSubmitted === undefined
50
+ ? {}
51
+ : { details_submitted: optionalBoolean(input.detailsSubmitted) }),
52
+ ...(input.taxStatus === undefined ? {} : { tax_status: input.taxStatus }),
53
+ ...(input.validationErrorCode === undefined
54
+ ? {}
55
+ : { 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 }),
62
+ version: expectedVersion + 1,
63
+ updated_at: new Date().toISOString(),
64
+ };
65
+ const result = await this.db
66
+ .updateTable("payment_stripe_settings")
67
+ .set(update)
68
+ .where("default_key", "=", "default")
69
+ .where("version", "=", expectedVersion)
70
+ .executeTakeFirst();
71
+ if (Number(result.numUpdatedRows) !== 1) {
72
+ throw new Error("Payment Stripe configuration changed; reload and try again");
73
+ }
74
+ return this.requireSettings();
75
+ }
76
+ }
77
+
78
+ function mapSettings(row: SettingsRow): PaymentStripeSettings {
79
+ return {
80
+ id: row.id,
81
+ mode: row.mode,
82
+ publishableKey: row.publishable_key,
83
+ configurationStatus: row.configuration_status,
84
+ stripeAccountId: row.stripe_account_id,
85
+ chargesEnabled: optionalDatabaseBoolean(row.charges_enabled),
86
+ detailsSubmitted: optionalDatabaseBoolean(row.details_submitted),
87
+ taxStatus: row.tax_status,
88
+ validationErrorCode: row.validation_error_code,
89
+ lastValidatedAt: row.last_validated_at,
90
+ connectStatus: row.connect_status,
91
+ version: row.version,
92
+ createdAt: row.created_at,
93
+ updatedAt: row.updated_at,
94
+ };
95
+ }
96
+
97
+ function optionalBoolean(value: boolean | null): number | null {
98
+ return value === null ? null : value ? 1 : 0;
99
+ }
100
+
101
+ function optionalDatabaseBoolean(value: number | null): boolean | null {
102
+ return value === null ? null : value === 1;
103
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ PluginSecretStore,
3
+ type Database,
4
+ type PluginSecretStatus,
5
+ type PluginSecretStoreOptions,
6
+ } from "dineway";
7
+ import type { Kysely, Transaction } from "kysely";
8
+
9
+ import { PAYMENT_PLUGIN_ID } from "../constants.js";
10
+
11
+ export const PAYMENT_STRIPE_SECRET_KEY = "stripe_secret_key";
12
+ export const PAYMENT_STRIPE_WEBHOOK_SECRET = "stripe_webhook_secret";
13
+
14
+ export type PaymentStripeSecretName =
15
+ | typeof PAYMENT_STRIPE_SECRET_KEY
16
+ | typeof PAYMENT_STRIPE_WEBHOOK_SECRET;
17
+
18
+ export class PaymentStripeSecretStore {
19
+ private readonly store: PluginSecretStore;
20
+
21
+ constructor(
22
+ db: Kysely<Database> | Transaction<Database>,
23
+ options: PluginSecretStoreOptions = {},
24
+ ) {
25
+ this.store = new PluginSecretStore(db, options);
26
+ }
27
+
28
+ set(name: PaymentStripeSecretName, value: string): Promise<PluginSecretStatus> {
29
+ return this.store.set(PAYMENT_PLUGIN_ID, name, value);
30
+ }
31
+
32
+ status(name: PaymentStripeSecretName): Promise<PluginSecretStatus | null> {
33
+ return this.store.status(PAYMENT_PLUGIN_ID, name);
34
+ }
35
+
36
+ delete(name: PaymentStripeSecretName): Promise<boolean> {
37
+ return this.store.delete(PAYMENT_PLUGIN_ID, name);
38
+ }
39
+
40
+ resolveForRuntime(name: PaymentStripeSecretName): Promise<string | null> {
41
+ return this.store.resolveForRuntime(PAYMENT_PLUGIN_ID, name);
42
+ }
43
+ }
@@ -0,0 +1,241 @@
1
+ import type {
2
+ Database,
3
+ PluginSecretStatus,
4
+ PluginSecretStoreOptions,
5
+ } from "dineway";
6
+ import type { Kysely, Transaction } from "kysely";
7
+
8
+ import type { StripeAccountProbe, StripeMode } from "../types.js";
9
+ import { PaymentStripeConfigurationRepository } from "./repository.js";
10
+ import {
11
+ PAYMENT_STRIPE_SECRET_KEY,
12
+ PAYMENT_STRIPE_WEBHOOK_SECRET,
13
+ PaymentStripeSecretStore,
14
+ } from "./secrets.js";
15
+ import type { PaymentStripeSettings } from "./types.js";
16
+
17
+ export interface PaymentStripeConfiguration {
18
+ settings: PaymentStripeSettings;
19
+ secretKey: PluginSecretStatus | null;
20
+ webhookSecret: PluginSecretStatus | null;
21
+ }
22
+
23
+ export interface UpdatePaymentStripeConfigurationInput {
24
+ expectedVersion: number;
25
+ mode?: StripeMode;
26
+ publishableKey?: string | null;
27
+ secretKey?: string | null;
28
+ webhookSecret?: string | null;
29
+ }
30
+
31
+ export class PaymentConfigurationError extends Error {
32
+ constructor(
33
+ readonly code: "VALIDATION_ERROR" | "CONFLICT" | "INVALID_STATE",
34
+ message: string,
35
+ ) {
36
+ super(message);
37
+ this.name = "PaymentConfigurationError";
38
+ }
39
+ }
40
+
41
+ export async function getPaymentStripeConfiguration(
42
+ db: Kysely<Database>,
43
+ secretOptions: PluginSecretStoreOptions = {},
44
+ ): Promise<PaymentStripeConfiguration> {
45
+ const repository = new PaymentStripeConfigurationRepository(db);
46
+ await repository.ensureDefaults();
47
+ return loadConfiguration(db, secretOptions);
48
+ }
49
+
50
+ export async function updatePaymentStripeConfiguration(
51
+ db: Kysely<Database>,
52
+ input: UpdatePaymentStripeConfigurationInput,
53
+ secretOptions: PluginSecretStoreOptions = {},
54
+ ): Promise<PaymentStripeConfiguration> {
55
+ validateUpdate(input);
56
+ return db.transaction().execute(async (trx) => {
57
+ const repository = new PaymentStripeConfigurationRepository(trx);
58
+ const before = await repository.ensureDefaults();
59
+ if (before.version !== input.expectedVersion) {
60
+ throw new PaymentConfigurationError(
61
+ "CONFLICT",
62
+ "Payment configuration changed; reload and try again",
63
+ );
64
+ }
65
+ const store = new PaymentStripeSecretStore(trx, secretOptions);
66
+ const mode = input.mode ?? before.mode;
67
+ const publishableKey = normalizeOptionalKey(input.publishableKey, before.publishableKey);
68
+ const existingSecret = await store.status(PAYMENT_STRIPE_SECRET_KEY);
69
+ const secretValue =
70
+ input.secretKey === undefined
71
+ ? input.mode !== undefined && input.mode !== before.mode && existingSecret
72
+ ? await store.resolveForRuntime(PAYMENT_STRIPE_SECRET_KEY)
73
+ : null
74
+ : normalizeOptionalSecret(input.secretKey);
75
+ validateModeKeys(mode, publishableKey, secretValue, input.secretKey !== undefined);
76
+ if (input.webhookSecret !== undefined && input.webhookSecret !== null) {
77
+ const webhookSecret = input.webhookSecret.trim();
78
+ if (!webhookSecret.startsWith("whsec_") || webhookSecret.length > 512) {
79
+ throw new PaymentConfigurationError(
80
+ "VALIDATION_ERROR",
81
+ "Webhook signing secret must start with whsec_",
82
+ );
83
+ }
84
+ }
85
+
86
+ await updateSecret(store, PAYMENT_STRIPE_SECRET_KEY, input.secretKey);
87
+ await updateSecret(store, PAYMENT_STRIPE_WEBHOOK_SECRET, input.webhookSecret);
88
+ const nextSecret = await store.status(PAYMENT_STRIPE_SECRET_KEY);
89
+ const credentialsChanged =
90
+ (input.mode !== undefined && input.mode !== before.mode) ||
91
+ (input.publishableKey !== undefined && publishableKey !== before.publishableKey) ||
92
+ input.secretKey !== undefined;
93
+ const credentialsComplete = Boolean(publishableKey && nextSecret);
94
+ await repository.updateSettings(before.version, {
95
+ mode,
96
+ publishableKey,
97
+ ...(credentialsChanged || !credentialsComplete
98
+ ? {
99
+ configurationStatus: credentialsComplete ? "unverified" : "unconfigured",
100
+ stripeAccountId: null,
101
+ chargesEnabled: null,
102
+ detailsSubmitted: null,
103
+ taxStatus: "unknown" as const,
104
+ validationErrorCode: null,
105
+ lastValidatedAt: null,
106
+ connectStatus: credentialsComplete ? ("restricted" as const) : ("not_configured" as const),
107
+ }
108
+ : {}),
109
+ });
110
+ return loadConfiguration(trx, secretOptions);
111
+ });
112
+ }
113
+
114
+ export async function testPaymentStripeConnection(
115
+ db: Kysely<Database>,
116
+ probe: StripeAccountProbe,
117
+ secretOptions: PluginSecretStoreOptions = {},
118
+ ): Promise<PaymentStripeConfiguration> {
119
+ const before = await new PaymentStripeConfigurationRepository(db).ensureDefaults();
120
+ if (!before.publishableKey) {
121
+ throw new PaymentConfigurationError(
122
+ "INVALID_STATE",
123
+ "Stripe publishable key is not configured",
124
+ );
125
+ }
126
+ const secretKey = await new PaymentStripeSecretStore(db, secretOptions).resolveForRuntime(
127
+ PAYMENT_STRIPE_SECRET_KEY,
128
+ );
129
+ if (!secretKey) {
130
+ throw new PaymentConfigurationError("INVALID_STATE", "Stripe secret key is not configured");
131
+ }
132
+ validateModeKeys(before.mode, before.publishableKey, secretKey, true);
133
+
134
+ let result;
135
+ try {
136
+ result = await probe.probe({ mode: before.mode, secretKey });
137
+ } catch {
138
+ result = { ok: false as const, errorCode: "connection_error" as const };
139
+ }
140
+ if (result.ok && result.livemode !== (before.mode === "live")) {
141
+ result = { ok: false as const, errorCode: "mode_mismatch" as const };
142
+ }
143
+
144
+ return db.transaction().execute(async (trx) => {
145
+ 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
+ });
168
+ return loadConfiguration(trx, secretOptions);
169
+ });
170
+ }
171
+
172
+ async function loadConfiguration(
173
+ db: Kysely<Database> | Transaction<Database>,
174
+ secretOptions: PluginSecretStoreOptions,
175
+ ): Promise<PaymentStripeConfiguration> {
176
+ const repository = new PaymentStripeConfigurationRepository(db);
177
+ const store = new PaymentStripeSecretStore(db, secretOptions);
178
+ const [settings, secretKey, webhookSecret] = await Promise.all([
179
+ repository.requireSettings(),
180
+ store.status(PAYMENT_STRIPE_SECRET_KEY),
181
+ store.status(PAYMENT_STRIPE_WEBHOOK_SECRET),
182
+ ]);
183
+ return { settings, secretKey, webhookSecret };
184
+ }
185
+
186
+ async function updateSecret(
187
+ store: PaymentStripeSecretStore,
188
+ name: typeof PAYMENT_STRIPE_SECRET_KEY | typeof PAYMENT_STRIPE_WEBHOOK_SECRET,
189
+ value: string | null | undefined,
190
+ ): Promise<void> {
191
+ if (value === undefined) return;
192
+ if (value === null) {
193
+ await store.delete(name);
194
+ return;
195
+ }
196
+ await store.set(name, value.trim());
197
+ }
198
+
199
+ function normalizeOptionalKey(value: string | null | undefined, current: string | null): string | null {
200
+ if (value === undefined) return current;
201
+ if (value === null) return null;
202
+ return value.trim() || null;
203
+ }
204
+
205
+ function normalizeOptionalSecret(value: string | null): string | null {
206
+ if (value === null) return null;
207
+ return value.trim() || null;
208
+ }
209
+
210
+ function validateUpdate(input: UpdatePaymentStripeConfigurationInput): void {
211
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1) {
212
+ throw new PaymentConfigurationError("VALIDATION_ERROR", "Expected version is invalid");
213
+ }
214
+ if (input.publishableKey !== undefined && input.publishableKey !== null && input.publishableKey.length > 512) {
215
+ throw new PaymentConfigurationError("VALIDATION_ERROR", "Publishable key is too long");
216
+ }
217
+ if (input.secretKey !== undefined && input.secretKey !== null && input.secretKey.length > 512) {
218
+ throw new PaymentConfigurationError("VALIDATION_ERROR", "Secret key is too long");
219
+ }
220
+ }
221
+
222
+ function validateModeKeys(
223
+ mode: StripeMode,
224
+ publishableKey: string | null,
225
+ secretKey: string | null,
226
+ validateSecret: boolean,
227
+ ): void {
228
+ const keyMode = mode === "live" ? "live" : "test";
229
+ if (publishableKey && !publishableKey.startsWith(`pk_${keyMode}_`)) {
230
+ throw new PaymentConfigurationError(
231
+ "VALIDATION_ERROR",
232
+ `Publishable key must match ${mode} mode`,
233
+ );
234
+ }
235
+ if (validateSecret && secretKey && !secretKey.startsWith(`sk_${keyMode}_`)) {
236
+ throw new PaymentConfigurationError(
237
+ "VALIDATION_ERROR",
238
+ `Secret key must match ${mode} mode`,
239
+ );
240
+ }
241
+ }