@dineway-ai/plugin-payment 0.2.7 → 0.2.8
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 +2 -2
- package/src/admin/subscriptions.tsx +10 -42
- package/src/admin.tsx +17 -3
- package/src/configuration/secrets.ts +1 -3
- package/src/index.ts +23 -0
- package/src/stripe/webhook-verifier.ts +1 -0
- package/src/subscriptions/routes.ts +3 -40
- package/src/subscriptions/runtime.ts +6 -8
- package/src/subscriptions/service.ts +7 -3
- package/src/types.ts +1 -0
- package/src/webhooks/index.ts +2 -0
- package/src/webhooks/routes.ts +109 -0
- package/src/webhooks/types.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dineway-ai/plugin-payment",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Provider-neutral payment infrastructure and Stripe integration for Dineway plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"react": "^18.0.0 || ^19.0.0",
|
|
33
|
-
"dineway": "^0.2.
|
|
33
|
+
"dineway": "^0.2.31"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@stripe/stripe-js": "9.9.0",
|
|
@@ -41,7 +41,6 @@ export function PaymentSubscriptionsPage() {
|
|
|
41
41
|
const [editorRevision, setEditorRevision] = React.useState(0);
|
|
42
42
|
const [boundPrices, setBoundPrices] = React.useState<SubscriptionPlan["prices"]>([]);
|
|
43
43
|
const [features, setFeatures] = React.useState<Feature[]>([]);
|
|
44
|
-
const [webhookSecret, setWebhookSecret] = React.useState("");
|
|
45
44
|
const [detail, setDetail] = React.useState<
|
|
46
45
|
(SubscriptionSnapshot & { member: MemberBilling }) | null
|
|
47
46
|
>(null);
|
|
@@ -153,7 +152,10 @@ export function PaymentSubscriptionsPage() {
|
|
|
153
152
|
<p className="mt-2 text-sm text-kumo-subtle">
|
|
154
153
|
Bind Stripe prices, configure plan entitlements, and inspect paid membership periods.
|
|
155
154
|
</p>
|
|
156
|
-
<a
|
|
155
|
+
<a
|
|
156
|
+
href="/_dineway/admin/plugins/dineway-payment/payments"
|
|
157
|
+
className="mt-2 inline-block text-sm underline"
|
|
158
|
+
>
|
|
157
159
|
Stripe account settings
|
|
158
160
|
</a>
|
|
159
161
|
</header>
|
|
@@ -178,7 +180,7 @@ export function PaymentSubscriptionsPage() {
|
|
|
178
180
|
) : (
|
|
179
181
|
<>
|
|
180
182
|
<section className="space-y-4 rounded-xl border border-kumo-line p-5">
|
|
181
|
-
<h2 className="text-lg font-semibold">
|
|
183
|
+
<h2 className="text-lg font-semibold">Payment webhook</h2>
|
|
182
184
|
<p className="text-sm">
|
|
183
185
|
Application: {configuration.namespace} · {configuration.mode} ·{" "}
|
|
184
186
|
{configuration.ready ? "Ready" : "Configuration required"}
|
|
@@ -188,48 +190,14 @@ export function PaymentSubscriptionsPage() {
|
|
|
188
190
|
<input className={inputClass} readOnly value={configuration.webhookUrl} />
|
|
189
191
|
</label>
|
|
190
192
|
<p className="text-sm text-kumo-subtle">
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
Payment receives and verifies Stripe events for subscriptions, restaurant orders, and
|
|
194
|
+
venue payments at this shared endpoint. Configure its signing secret once in Payments
|
|
195
|
+
using the Stripe account settings link above.
|
|
193
196
|
</p>
|
|
194
|
-
<form
|
|
195
|
-
className="flex flex-wrap items-end gap-3"
|
|
196
|
-
onSubmit={(event) => {
|
|
197
|
-
event.preventDefault();
|
|
198
|
-
void run(async () => {
|
|
199
|
-
setConfiguration(
|
|
200
|
-
await postPaymentRoute<Configuration>("subscriptions/configuration/update", {
|
|
201
|
-
webhookSecret,
|
|
202
|
-
}),
|
|
203
|
-
);
|
|
204
|
-
setWebhookSecret("");
|
|
205
|
-
await refresh();
|
|
206
|
-
setNotice("Subscription webhook secret saved.");
|
|
207
|
-
});
|
|
208
|
-
}}
|
|
209
|
-
>
|
|
210
|
-
<label className="min-w-64 flex-1 text-sm">
|
|
211
|
-
Signing secret
|
|
212
|
-
<input
|
|
213
|
-
type="password"
|
|
214
|
-
autoComplete="new-password"
|
|
215
|
-
required
|
|
216
|
-
className={inputClass}
|
|
217
|
-
value={webhookSecret}
|
|
218
|
-
placeholder={
|
|
219
|
-
configuration.webhookSecret
|
|
220
|
-
? "Leave unchanged unless rotating the secret"
|
|
221
|
-
: "whsec_…"
|
|
222
|
-
}
|
|
223
|
-
onChange={(event) => setWebhookSecret(event.target.value)}
|
|
224
|
-
/>
|
|
225
|
-
</label>
|
|
226
|
-
<button className={buttonClass} disabled={busy || !webhookSecret}>
|
|
227
|
-
Save secret
|
|
228
|
-
</button>
|
|
229
|
-
</form>
|
|
230
197
|
{!configuration.ready && (
|
|
231
198
|
<p className="text-sm text-kumo-subtle">
|
|
232
|
-
Verify the Stripe account and
|
|
199
|
+
Verify the Stripe account and configure the shared webhook in Payments to manage
|
|
200
|
+
plans.
|
|
233
201
|
</p>
|
|
234
202
|
)}
|
|
235
203
|
</section>
|
package/src/admin.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./admin/form.js";
|
|
9
9
|
import { PaymentSubscriptionsPage } from "./admin/subscriptions.js";
|
|
10
10
|
import type { PaymentStripeConfiguration } from "./configuration/service.js";
|
|
11
|
+
import { PAYMENT_WEBHOOK_PATH } from "./webhooks/types.js";
|
|
11
12
|
|
|
12
13
|
type CredentialName = "secretKey" | "webhookSecret";
|
|
13
14
|
|
|
@@ -184,6 +185,10 @@ export function PaymentSettingsPage() {
|
|
|
184
185
|
}
|
|
185
186
|
type="password"
|
|
186
187
|
/>
|
|
188
|
+
<p className="text-sm text-kumo-subtle">
|
|
189
|
+
Use the complete whsec_… signing secret from Stripe for the endpoint below. For local
|
|
190
|
+
forwarding, use the value printed by stripe listen. Do not generate your own value.
|
|
191
|
+
</p>
|
|
187
192
|
<Credential
|
|
188
193
|
label="Webhook secret"
|
|
189
194
|
present={Boolean(configuration.webhookSecret)}
|
|
@@ -210,10 +215,19 @@ export function PaymentSettingsPage() {
|
|
|
210
215
|
</form>
|
|
211
216
|
<section className="max-w-3xl rounded-xl bg-kumo-tint/40 p-5 text-sm text-kumo-subtle">
|
|
212
217
|
<h2 className="font-semibold text-kumo-default">Webhook endpoint</h2>
|
|
218
|
+
<input
|
|
219
|
+
aria-label="Payment webhook endpoint"
|
|
220
|
+
className="mt-2 w-full rounded-lg border border-kumo-line bg-kumo-base px-3 py-2"
|
|
221
|
+
readOnly
|
|
222
|
+
value={
|
|
223
|
+
typeof window === "undefined"
|
|
224
|
+
? PAYMENT_WEBHOOK_PATH
|
|
225
|
+
: new URL(PAYMENT_WEBHOOK_PATH, window.location.origin).href
|
|
226
|
+
}
|
|
227
|
+
/>
|
|
213
228
|
<p className="mt-2">
|
|
214
|
-
Configure Stripe
|
|
215
|
-
|
|
216
|
-
signing secret.
|
|
229
|
+
Configure one Stripe endpoint for Venue, Restaurant, and Subscriptions. Payment verifies
|
|
230
|
+
each event and delivers it to the enabled plugins.
|
|
217
231
|
</p>
|
|
218
232
|
</section>
|
|
219
233
|
</div>
|
|
@@ -10,12 +10,10 @@ 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";
|
|
14
13
|
|
|
15
14
|
export type PaymentStripeSecretName =
|
|
16
15
|
| typeof PAYMENT_STRIPE_SECRET_KEY
|
|
17
|
-
| typeof PAYMENT_STRIPE_WEBHOOK_SECRET
|
|
18
|
-
| typeof PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET;
|
|
16
|
+
| typeof PAYMENT_STRIPE_WEBHOOK_SECRET;
|
|
19
17
|
|
|
20
18
|
export class PaymentStripeSecretStore {
|
|
21
19
|
private readonly store: PluginSecretStore;
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,14 @@ import { PAYMENT_PLUGIN_ID } from "./constants.js";
|
|
|
7
7
|
import { paymentMigrations } from "./database/index.js";
|
|
8
8
|
import { createPaymentRoutes } from "./routes/index.js";
|
|
9
9
|
import { createSubscriptionRoutes } from "./subscriptions/routes.js";
|
|
10
|
+
import { getPaymentSubscriptionService } from "./subscriptions/runtime.js";
|
|
11
|
+
import { SUBSCRIPTION_WEBHOOK_EVENTS } from "./subscriptions/service.js";
|
|
10
12
|
import type { SubscriptionOptions } from "./subscriptions/types.js";
|
|
13
|
+
import {
|
|
14
|
+
createPaymentWebhookRoute,
|
|
15
|
+
PAYMENT_WEBHOOK_EXTENSION,
|
|
16
|
+
type PaymentWebhookConsumer,
|
|
17
|
+
} from "./webhooks/index.js";
|
|
11
18
|
|
|
12
19
|
const PAYMENT_ADMIN_ENTRY = "@dineway-ai/plugin-payment/admin";
|
|
13
20
|
const PAYMENT_ADMIN_PAGES = [
|
|
@@ -63,9 +70,23 @@ export function createPlugin(options: PaymentPluginOptions = {}): ResolvedPlugin
|
|
|
63
70
|
],
|
|
64
71
|
},
|
|
65
72
|
migrations: paymentMigrations,
|
|
73
|
+
nativeExtensions: options.subscriptions
|
|
74
|
+
? {
|
|
75
|
+
[PAYMENT_WEBHOOK_EXTENSION]: {
|
|
76
|
+
id: "subscriptions",
|
|
77
|
+
eventTypes: SUBSCRIPTION_WEBHOOK_EVENTS,
|
|
78
|
+
consume: async ({ event, rawBody }) => {
|
|
79
|
+
await (
|
|
80
|
+
await getPaymentSubscriptionService(options.subscriptions!)
|
|
81
|
+
).receiveVerifiedWebhook(event, rawBody);
|
|
82
|
+
},
|
|
83
|
+
} satisfies PaymentWebhookConsumer,
|
|
84
|
+
}
|
|
85
|
+
: {},
|
|
66
86
|
hooks,
|
|
67
87
|
routes: {
|
|
68
88
|
...createPaymentRoutes(),
|
|
89
|
+
"stripe/webhook": createPaymentWebhookRoute(),
|
|
69
90
|
...(options.subscriptions ? createSubscriptionRoutes(options.subscriptions) : {}),
|
|
70
91
|
},
|
|
71
92
|
});
|
|
@@ -76,3 +97,5 @@ export { PAYMENT_PLUGIN_ID } from "./constants.js";
|
|
|
76
97
|
export * from "./stripe/index.js";
|
|
77
98
|
export * from "./routes/index.js";
|
|
78
99
|
export * from "./types.js";
|
|
100
|
+
|
|
101
|
+
export * from "./webhooks/index.js";
|
|
@@ -97,6 +97,7 @@ function projectEvent(event: Stripe.Event, rawBody: Uint8Array): StripeWebhookEv
|
|
|
97
97
|
|
|
98
98
|
return {
|
|
99
99
|
eventId: event.id,
|
|
100
|
+
...(event.account ? { accountId: requiredSafeString(event.account, 255) } : {}),
|
|
100
101
|
eventType: event.type,
|
|
101
102
|
eventCreated: event.created,
|
|
102
103
|
livemode: event.livemode,
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { z } from "astro/zod";
|
|
2
|
-
import {
|
|
2
|
+
import { PluginRouteError, type PluginRoute, type RouteContext } from "dineway";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
PaymentStripeSecretStore,
|
|
6
|
-
PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET,
|
|
7
|
-
} from "../configuration/secrets.js";
|
|
8
4
|
import { getPaymentSubscriptionService, getSubscriptionConfiguration } from "./runtime.js";
|
|
9
5
|
import {
|
|
10
6
|
checkoutSchema,
|
|
@@ -19,9 +15,6 @@ import type { PaymentSubscriptionService } from "./service.js";
|
|
|
19
15
|
import { SubscriptionError, type SubscriptionOptions } from "./types.js";
|
|
20
16
|
|
|
21
17
|
const empty = z.object({}).strict();
|
|
22
|
-
const secretSchema = z
|
|
23
|
-
.object({ webhookSecret: z.string().trim().startsWith("whsec_").max(512) })
|
|
24
|
-
.strict();
|
|
25
18
|
type PaymentPermission = "payments:read_own" | "payments:manage_own" | "payments:manage";
|
|
26
19
|
export interface SubscriptionRouteOptions {
|
|
27
20
|
service?: () => Promise<PaymentSubscriptionService>;
|
|
@@ -83,17 +76,6 @@ export function createSubscriptionRoutes(
|
|
|
83
76
|
"subscriptions/configuration/get": privateRoute(empty, "payments:manage", async () =>
|
|
84
77
|
getSubscriptionConfiguration(options),
|
|
85
78
|
),
|
|
86
|
-
"subscriptions/configuration/update": privateRoute(
|
|
87
|
-
secretSchema,
|
|
88
|
-
"payments:manage",
|
|
89
|
-
async (context) => {
|
|
90
|
-
await new PaymentStripeSecretStore(await getDb()).set(
|
|
91
|
-
PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET,
|
|
92
|
-
context.input.webhookSecret,
|
|
93
|
-
);
|
|
94
|
-
return getSubscriptionConfiguration(options);
|
|
95
|
-
},
|
|
96
|
-
),
|
|
97
79
|
"subscriptions/plans/list": privateRoute(empty, "payments:manage", async () => ({
|
|
98
80
|
items: await (await service()).listPlans(true),
|
|
99
81
|
})),
|
|
@@ -132,24 +114,9 @@ export function createSubscriptionRoutes(
|
|
|
132
114
|
await client.refreshSubscription(context.input.subscriptionId);
|
|
133
115
|
return client.subscriptionDetail(context.input.subscriptionId);
|
|
134
116
|
}),
|
|
135
|
-
"subscriptions/webhook": {
|
|
136
|
-
methods: ["POST"],
|
|
137
|
-
public: true,
|
|
138
|
-
body: "raw",
|
|
139
|
-
maxBodyBytes: 262144,
|
|
140
|
-
handler: async (context) =>
|
|
141
|
-
run(async () => {
|
|
142
|
-
if (!context.rawBody)
|
|
143
|
-
throw new SubscriptionError("INVALID_SIGNATURE", "A signed body is required");
|
|
144
|
-
await (
|
|
145
|
-
await service()
|
|
146
|
-
).receiveWebhook(context.rawBody, context.request.headers.get("Stripe-Signature"));
|
|
147
|
-
return { received: true };
|
|
148
|
-
}, true),
|
|
149
|
-
},
|
|
150
117
|
};
|
|
151
118
|
}
|
|
152
|
-
async function run<T>(action: () => Promise<T
|
|
119
|
+
async function run<T>(action: () => Promise<T>): Promise<T> {
|
|
153
120
|
try {
|
|
154
121
|
return await action();
|
|
155
122
|
} catch (error) {
|
|
@@ -157,10 +124,6 @@ async function run<T>(action: () => Promise<T>, webhook = false): Promise<T> {
|
|
|
157
124
|
throw new PluginRouteError(error.code, error.message, error.status);
|
|
158
125
|
if (error instanceof z.ZodError)
|
|
159
126
|
throw new PluginRouteError("VALIDATION_ERROR", "Invalid subscription input", 400);
|
|
160
|
-
throw new PluginRouteError(
|
|
161
|
-
webhook ? "WEBHOOK_RETRY_REQUIRED" : "BILLING_UNAVAILABLE",
|
|
162
|
-
webhook ? "Retry this webhook" : "Billing is temporarily unavailable",
|
|
163
|
-
503,
|
|
164
|
-
);
|
|
127
|
+
throw new PluginRouteError("BILLING_UNAVAILABLE", "Billing is temporarily unavailable", 503);
|
|
165
128
|
}
|
|
166
129
|
}
|
|
@@ -3,9 +3,10 @@ import { getDb, type PluginSecretStoreOptions } from "dineway";
|
|
|
3
3
|
import {
|
|
4
4
|
PaymentStripeSecretStore,
|
|
5
5
|
PAYMENT_STRIPE_SECRET_KEY,
|
|
6
|
-
|
|
6
|
+
PAYMENT_STRIPE_WEBHOOK_SECRET,
|
|
7
7
|
} from "../configuration/secrets.js";
|
|
8
8
|
import { getPaymentStripeConfiguration } from "../configuration/service.js";
|
|
9
|
+
import { PAYMENT_WEBHOOK_PATH } from "../webhooks/types.js";
|
|
9
10
|
import { PaymentSubscriptionService } from "./service.js";
|
|
10
11
|
import { SubscriptionError, type SubscriptionOptions } from "./types.js";
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@ export async function getPaymentSubscriptionService(
|
|
|
17
18
|
const config = await getPaymentStripeConfiguration(db, secretOptions);
|
|
18
19
|
const store = new PaymentStripeSecretStore(db, secretOptions);
|
|
19
20
|
const secretKey = await store.resolveForRuntime(PAYMENT_STRIPE_SECRET_KEY);
|
|
20
|
-
const webhookSecret = await store.resolveForRuntime(
|
|
21
|
+
const webhookSecret = await store.resolveForRuntime(PAYMENT_STRIPE_WEBHOOK_SECRET);
|
|
21
22
|
if (
|
|
22
23
|
config.settings.configurationStatus !== "valid" ||
|
|
23
24
|
!config.settings.stripeAccountId ||
|
|
@@ -26,7 +27,7 @@ export async function getPaymentSubscriptionService(
|
|
|
26
27
|
) {
|
|
27
28
|
throw new SubscriptionError(
|
|
28
29
|
"BILLING_UNAVAILABLE",
|
|
29
|
-
"Configure and verify Stripe and the
|
|
30
|
+
"Configure and verify Stripe and the Payment webhook first",
|
|
30
31
|
503,
|
|
31
32
|
);
|
|
32
33
|
}
|
|
@@ -47,7 +48,7 @@ export async function getSubscriptionConfiguration(options: SubscriptionOptions)
|
|
|
47
48
|
const db = await getDb();
|
|
48
49
|
const configuration = await getPaymentStripeConfiguration(db);
|
|
49
50
|
const webhookSecret = await new PaymentStripeSecretStore(db).status(
|
|
50
|
-
|
|
51
|
+
PAYMENT_STRIPE_WEBHOOK_SECRET,
|
|
51
52
|
);
|
|
52
53
|
return {
|
|
53
54
|
namespace: options.namespace,
|
|
@@ -57,9 +58,6 @@ export async function getSubscriptionConfiguration(options: SubscriptionOptions)
|
|
|
57
58
|
configuration.settings.configurationStatus === "valid" &&
|
|
58
59
|
Boolean(configuration.secretKey && webhookSecret),
|
|
59
60
|
webhookSecret,
|
|
60
|
-
webhookUrl: new URL(
|
|
61
|
-
"/_dineway/api/plugins/dineway-payment/subscriptions/webhook",
|
|
62
|
-
options.siteUrl,
|
|
63
|
-
).href,
|
|
61
|
+
webhookUrl: new URL(PAYMENT_WEBHOOK_PATH, options.siteUrl).href,
|
|
64
62
|
};
|
|
65
63
|
}
|
|
@@ -5,6 +5,7 @@ import type { Kysely } from "kysely";
|
|
|
5
5
|
import Stripe from "stripe";
|
|
6
6
|
|
|
7
7
|
import { StripeNodeWebhookVerifier } from "../stripe/webhook-verifier.js";
|
|
8
|
+
import type { StripeWebhookEvent } from "../types.js";
|
|
8
9
|
import { collectBounded, invoiceFunding, inspectCharge } from "./funding.js";
|
|
9
10
|
import {
|
|
10
11
|
recurringPrice,
|
|
@@ -53,7 +54,7 @@ export interface SubscriptionServiceOptions {
|
|
|
53
54
|
stripe?: Stripe;
|
|
54
55
|
}
|
|
55
56
|
const terminal = new Set(["canceled", "incomplete_expired"]);
|
|
56
|
-
const
|
|
57
|
+
export const SUBSCRIPTION_WEBHOOK_EVENTS = [
|
|
57
58
|
"checkout.session.completed",
|
|
58
59
|
"checkout.session.async_payment_succeeded",
|
|
59
60
|
"checkout.session.async_payment_failed",
|
|
@@ -76,7 +77,8 @@ const events = new Set([
|
|
|
76
77
|
"refund.updated",
|
|
77
78
|
"refund.failed",
|
|
78
79
|
"payment_intent.payment_failed",
|
|
79
|
-
]
|
|
80
|
+
] as const;
|
|
81
|
+
const events = new Set<string>(SUBSCRIPTION_WEBHOOK_EVENTS);
|
|
80
82
|
const localeSchema = checkoutSchema.shape.locale;
|
|
81
83
|
const fingerprint = (values: unknown[]) =>
|
|
82
84
|
createHash("sha256").update(JSON.stringify(values)).digest("hex");
|
|
@@ -723,7 +725,9 @@ export class PaymentSubscriptionService {
|
|
|
723
725
|
});
|
|
724
726
|
if (!verified.ok)
|
|
725
727
|
throw new SubscriptionError("INVALID_SIGNATURE", "Invalid Stripe webhook", 400);
|
|
726
|
-
|
|
728
|
+
await this.receiveVerifiedWebhook(verified.event, rawBody);
|
|
729
|
+
}
|
|
730
|
+
async receiveVerifiedWebhook(event: StripeWebhookEvent, rawBody: Uint8Array): Promise<void> {
|
|
727
731
|
if (event.livemode !== (this.options.scope.mode === "live"))
|
|
728
732
|
throw new SubscriptionError("MODE_MISMATCH", "Invalid webhook mode", 400);
|
|
729
733
|
if (!events.has(event.eventType)) return;
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { getDb, PluginRouteError, type PluginRoute, type PluginSecretStoreOptions } from "dineway";
|
|
2
|
+
|
|
3
|
+
import { PaymentStripeConfigurationRepository } from "../configuration/repository.js";
|
|
4
|
+
import {
|
|
5
|
+
PaymentStripeSecretStore,
|
|
6
|
+
PAYMENT_STRIPE_SECRET_KEY,
|
|
7
|
+
PAYMENT_STRIPE_WEBHOOK_SECRET,
|
|
8
|
+
} from "../configuration/secrets.js";
|
|
9
|
+
import { StripeNodeWebhookVerifier, STRIPE_WEBHOOK_MAX_BYTES } from "../stripe/webhook-verifier.js";
|
|
10
|
+
import type { StripeWebhookVerifier } from "../types.js";
|
|
11
|
+
import { PAYMENT_WEBHOOK_EXTENSION, type PaymentWebhookConsumer } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export interface PaymentWebhookRouteOptions {
|
|
14
|
+
secretOptions?: PluginSecretStoreOptions;
|
|
15
|
+
verifier?: StripeWebhookVerifier;
|
|
16
|
+
now?: () => Date;
|
|
17
|
+
}
|
|
18
|
+
export function createPaymentWebhookRoute(options: PaymentWebhookRouteOptions = {}): PluginRoute {
|
|
19
|
+
return {
|
|
20
|
+
methods: ["POST"],
|
|
21
|
+
public: true,
|
|
22
|
+
body: "raw",
|
|
23
|
+
maxBodyBytes: STRIPE_WEBHOOK_MAX_BYTES,
|
|
24
|
+
handler: async (context) => {
|
|
25
|
+
const rawBody = context.rawBody ?? new Uint8Array();
|
|
26
|
+
const signature = context.request.headers.get("stripe-signature");
|
|
27
|
+
if (
|
|
28
|
+
!rawBody.byteLength ||
|
|
29
|
+
rawBody.byteLength > STRIPE_WEBHOOK_MAX_BYTES ||
|
|
30
|
+
!signature?.trim()
|
|
31
|
+
)
|
|
32
|
+
throw new PluginRouteError("STRIPE_WEBHOOK_INVALID", "Stripe webhook is invalid", 400);
|
|
33
|
+
const db = await getDb();
|
|
34
|
+
let runtime;
|
|
35
|
+
try {
|
|
36
|
+
const store = new PaymentStripeSecretStore(db, options.secretOptions);
|
|
37
|
+
const [settings, secretKey, webhookSecret] = await Promise.all([
|
|
38
|
+
new PaymentStripeConfigurationRepository(db).requireSettings(),
|
|
39
|
+
store.resolveForRuntime(PAYMENT_STRIPE_SECRET_KEY),
|
|
40
|
+
store.resolveForRuntime(PAYMENT_STRIPE_WEBHOOK_SECRET),
|
|
41
|
+
]);
|
|
42
|
+
if (!secretKey || !webhookSecret) throw new Error("Missing credentials");
|
|
43
|
+
runtime = { settings, secretKey, webhookSecret };
|
|
44
|
+
} catch {
|
|
45
|
+
throw new PluginRouteError(
|
|
46
|
+
"STRIPE_WEBHOOK_NOT_CONFIGURED",
|
|
47
|
+
"Stripe webhook is not configured",
|
|
48
|
+
503,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const now = options.now?.() ?? new Date();
|
|
52
|
+
const result = (options.verifier ?? new StripeNodeWebhookVerifier()).verify({
|
|
53
|
+
secretKey: runtime.secretKey,
|
|
54
|
+
webhookSecret: runtime.webhookSecret,
|
|
55
|
+
rawBody,
|
|
56
|
+
signature,
|
|
57
|
+
receivedAt: Math.floor(now.getTime() / 1000),
|
|
58
|
+
});
|
|
59
|
+
if (
|
|
60
|
+
!result.ok ||
|
|
61
|
+
result.event.livemode !== (runtime.settings.mode === "live") ||
|
|
62
|
+
(result.event.accountId && result.event.accountId !== runtime.settings.stripeAccountId)
|
|
63
|
+
)
|
|
64
|
+
throw new PluginRouteError("STRIPE_WEBHOOK_INVALID", "Stripe webhook is invalid", 400);
|
|
65
|
+
if (!context.getNativeExtensions)
|
|
66
|
+
throw new PluginRouteError(
|
|
67
|
+
"STRIPE_WEBHOOK_NOT_CONFIGURED",
|
|
68
|
+
"Update Dineway to enable payment event dispatch",
|
|
69
|
+
503,
|
|
70
|
+
);
|
|
71
|
+
let failed = false;
|
|
72
|
+
for (const extension of context.getNativeExtensions(PAYMENT_WEBHOOK_EXTENSION)) {
|
|
73
|
+
if (!isConsumer(extension)) {
|
|
74
|
+
failed = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!extension.eventTypes.includes(result.event.eventType)) continue;
|
|
78
|
+
try {
|
|
79
|
+
await extension.consume({
|
|
80
|
+
db,
|
|
81
|
+
event: result.event,
|
|
82
|
+
rawBody,
|
|
83
|
+
expectedLivemode: result.event.livemode,
|
|
84
|
+
now,
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
failed = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (failed)
|
|
91
|
+
throw new PluginRouteError(
|
|
92
|
+
"STRIPE_WEBHOOK_PROCESSING_FAILED",
|
|
93
|
+
"Stripe webhook could not be processed",
|
|
94
|
+
503,
|
|
95
|
+
);
|
|
96
|
+
return { received: true };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function isConsumer(value: unknown): value is PaymentWebhookConsumer {
|
|
101
|
+
if (!value || typeof value !== "object") return false;
|
|
102
|
+
const candidate = value as Partial<PaymentWebhookConsumer>;
|
|
103
|
+
return (
|
|
104
|
+
typeof candidate.id === "string" &&
|
|
105
|
+
Array.isArray(candidate.eventTypes) &&
|
|
106
|
+
candidate.eventTypes.every((type) => typeof type === "string") &&
|
|
107
|
+
typeof candidate.consume === "function"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from "dineway";
|
|
2
|
+
import type { Kysely } from "kysely";
|
|
3
|
+
|
|
4
|
+
import type { StripeWebhookEvent } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export const PAYMENT_WEBHOOK_EXTENSION = "dineway-payment:stripe-webhook";
|
|
7
|
+
export const PAYMENT_WEBHOOK_PATH = "/_dineway/api/plugins/dineway-payment/stripe/webhook";
|
|
8
|
+
|
|
9
|
+
/** Delivered only after Payment has verified the exact request bytes and environment. */
|
|
10
|
+
export interface PaymentWebhookDelivery {
|
|
11
|
+
db: Kysely<Database>;
|
|
12
|
+
event: StripeWebhookEvent;
|
|
13
|
+
rawBody: Uint8Array;
|
|
14
|
+
expectedLivemode: boolean;
|
|
15
|
+
now: Date;
|
|
16
|
+
}
|
|
17
|
+
export interface PaymentWebhookConsumer {
|
|
18
|
+
id: string;
|
|
19
|
+
eventTypes: readonly string[];
|
|
20
|
+
consume(delivery: PaymentWebhookDelivery): Promise<void>;
|
|
21
|
+
}
|