@dineway-ai/plugin-payment 0.2.7 → 0.2.9
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/form.ts +4 -1
- package/src/admin/subscriptions.tsx +18 -43
- package/src/admin.tsx +43 -3
- package/src/configuration/repository.ts +2 -0
- package/src/configuration/secrets.ts +1 -3
- package/src/configuration/service.ts +9 -1
- package/src/configuration/types.ts +3 -1
- package/src/database/index.ts +2 -0
- package/src/database/migrations/004_collection_mode.ts +26 -0
- package/src/database/types.ts +4 -0
- package/src/index.ts +23 -0
- package/src/routes/index.ts +1 -1
- package/src/routes/schemas.ts +1 -0
- package/src/stripe/webhook-verifier.ts +1 -0
- package/src/subscriptions/funding.ts +27 -4
- package/src/subscriptions/projection.ts +1 -0
- package/src/subscriptions/purchases.ts +39 -1
- package/src/subscriptions/repository.ts +3 -0
- package/src/subscriptions/routes.ts +3 -40
- package/src/subscriptions/runtime.ts +8 -8
- package/src/subscriptions/schemas.ts +5 -0
- package/src/subscriptions/service.ts +10 -3
- package/src/subscriptions/types.ts +4 -1
- package/src/types.ts +2 -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.9",
|
|
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",
|
package/src/admin/form.ts
CHANGED
|
@@ -2,10 +2,11 @@ import type {
|
|
|
2
2
|
PaymentStripeConfiguration,
|
|
3
3
|
UpdatePaymentStripeConfigurationInput,
|
|
4
4
|
} from "../configuration/service.js";
|
|
5
|
-
import type { StripeMode } from "../types.js";
|
|
5
|
+
import type { StripeCollectionMode, StripeMode } from "../types.js";
|
|
6
6
|
|
|
7
7
|
export interface PaymentSettingsForm {
|
|
8
8
|
mode: StripeMode;
|
|
9
|
+
collectionMode: StripeCollectionMode;
|
|
9
10
|
publishableKey: string;
|
|
10
11
|
secretKey: string;
|
|
11
12
|
webhookSecret: string;
|
|
@@ -16,6 +17,7 @@ export function paymentSettingsForm(
|
|
|
16
17
|
): PaymentSettingsForm {
|
|
17
18
|
return {
|
|
18
19
|
mode: configuration.settings.mode,
|
|
20
|
+
collectionMode: configuration.settings.collectionMode,
|
|
19
21
|
publishableKey: configuration.settings.publishableKey ?? "",
|
|
20
22
|
secretKey: "",
|
|
21
23
|
webhookSecret: "",
|
|
@@ -45,6 +47,7 @@ export function buildPaymentSettingsUpdate(
|
|
|
45
47
|
return {
|
|
46
48
|
expectedVersion: configuration.settings.version,
|
|
47
49
|
mode: form.mode,
|
|
50
|
+
collectionMode: form.collectionMode,
|
|
48
51
|
publishableKey: publishableKey || null,
|
|
49
52
|
...(secretKey ? { secretKey } : {}),
|
|
50
53
|
...(webhookSecret ? { webhookSecret } : {}),
|
|
@@ -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,58 +180,27 @@ 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
|
-
{configuration.
|
|
186
|
+
{configuration.collectionMode === "managed"
|
|
187
|
+
? "Managed Payments"
|
|
188
|
+
: "Standard Stripe Billing"}{" "}
|
|
189
|
+
· {configuration.ready ? "Ready" : "Configuration required"}
|
|
185
190
|
</p>
|
|
186
191
|
<label className="block text-sm">
|
|
187
192
|
Endpoint
|
|
188
193
|
<input className={inputClass} readOnly value={configuration.webhookUrl} />
|
|
189
194
|
</label>
|
|
190
195
|
<p className="text-sm text-kumo-subtle">
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
Payment receives and verifies Stripe events for subscriptions, restaurant orders, and
|
|
197
|
+
venue payments at this shared endpoint. Configure its signing secret once in Payments
|
|
198
|
+
using the Stripe account settings link above.
|
|
193
199
|
</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
200
|
{!configuration.ready && (
|
|
231
201
|
<p className="text-sm text-kumo-subtle">
|
|
232
|
-
Verify the Stripe account and
|
|
202
|
+
Verify the Stripe account and configure the shared webhook in Payments to manage
|
|
203
|
+
plans.
|
|
233
204
|
</p>
|
|
234
205
|
)}
|
|
235
206
|
</section>
|
|
@@ -440,6 +411,10 @@ export function PaymentSubscriptionsPage() {
|
|
|
440
411
|
{purchase.reference}
|
|
441
412
|
<br />
|
|
442
413
|
{purchase.contract.pricing.kind}
|
|
414
|
+
<br />
|
|
415
|
+
{purchase.collectionMode === "managed"
|
|
416
|
+
? "Managed Payments"
|
|
417
|
+
: "Standard"}
|
|
443
418
|
</td>
|
|
444
419
|
<td className="p-2">
|
|
445
420
|
{formatSubscriptionPrice(
|
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
|
|
|
@@ -102,6 +103,7 @@ export function PaymentSettingsPage() {
|
|
|
102
103
|
configuration.settings.publishableKey &&
|
|
103
104
|
configuration.secretKey &&
|
|
104
105
|
form.mode === configuration.settings.mode &&
|
|
106
|
+
form.collectionMode === configuration.settings.collectionMode &&
|
|
105
107
|
form.publishableKey.trim() === configuration.settings.publishableKey &&
|
|
106
108
|
!form.secretKey.trim(),
|
|
107
109
|
);
|
|
@@ -149,6 +151,31 @@ export function PaymentSettingsPage() {
|
|
|
149
151
|
))}
|
|
150
152
|
</div>
|
|
151
153
|
</fieldset>
|
|
154
|
+
<label className="grid gap-1 text-sm font-medium">
|
|
155
|
+
Collection mode
|
|
156
|
+
<select
|
|
157
|
+
className="rounded-lg border border-kumo-line bg-kumo-base px-3 py-2 font-normal"
|
|
158
|
+
value={form.collectionMode}
|
|
159
|
+
disabled={pending}
|
|
160
|
+
onChange={(event) =>
|
|
161
|
+
setForm({
|
|
162
|
+
...form,
|
|
163
|
+
collectionMode: event.target.value === "managed" ? "managed" : "standard",
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
<option value="standard">Standard Stripe Billing (default)</option>
|
|
168
|
+
<option value="managed">Stripe Managed Payments</option>
|
|
169
|
+
</select>
|
|
170
|
+
</label>
|
|
171
|
+
<p className="text-sm text-kumo-subtle">
|
|
172
|
+
{form.collectionMode === "managed"
|
|
173
|
+
? "Stripe acts as merchant of record for eligible digital products. Enable Managed Payments in your Stripe account and assign an eligible tax code to each product. Stripe calculates tax and chooses supported payment methods."
|
|
174
|
+
: "Your business is the merchant of record and manages its tax obligations. Subscriptions use standard Stripe Billing."}{" "}
|
|
175
|
+
This setting applies to new subscription Checkouts. Existing subscriptions and pending
|
|
176
|
+
purchases keep their original mode. Restaurant orders and venue payments continue to use
|
|
177
|
+
standard payments.
|
|
178
|
+
</p>
|
|
152
179
|
<Field
|
|
153
180
|
label="Publishable key"
|
|
154
181
|
value={form.publishableKey}
|
|
@@ -184,6 +211,10 @@ export function PaymentSettingsPage() {
|
|
|
184
211
|
}
|
|
185
212
|
type="password"
|
|
186
213
|
/>
|
|
214
|
+
<p className="text-sm text-kumo-subtle">
|
|
215
|
+
Use the complete whsec_… signing secret from Stripe for the endpoint below. For local
|
|
216
|
+
forwarding, use the value printed by stripe listen. Do not generate your own value.
|
|
217
|
+
</p>
|
|
187
218
|
<Credential
|
|
188
219
|
label="Webhook secret"
|
|
189
220
|
present={Boolean(configuration.webhookSecret)}
|
|
@@ -210,10 +241,19 @@ export function PaymentSettingsPage() {
|
|
|
210
241
|
</form>
|
|
211
242
|
<section className="max-w-3xl rounded-xl bg-kumo-tint/40 p-5 text-sm text-kumo-subtle">
|
|
212
243
|
<h2 className="font-semibold text-kumo-default">Webhook endpoint</h2>
|
|
244
|
+
<input
|
|
245
|
+
aria-label="Payment webhook endpoint"
|
|
246
|
+
className="mt-2 w-full rounded-lg border border-kumo-line bg-kumo-base px-3 py-2"
|
|
247
|
+
readOnly
|
|
248
|
+
value={
|
|
249
|
+
typeof window === "undefined"
|
|
250
|
+
? PAYMENT_WEBHOOK_PATH
|
|
251
|
+
: new URL(PAYMENT_WEBHOOK_PATH, window.location.origin).href
|
|
252
|
+
}
|
|
253
|
+
/>
|
|
213
254
|
<p className="mt-2">
|
|
214
|
-
Configure Stripe
|
|
215
|
-
|
|
216
|
-
signing secret.
|
|
255
|
+
Configure one Stripe endpoint for Venue, Restaurant, and Subscriptions. Payment verifies
|
|
256
|
+
each event and delivers it to the enabled plugins.
|
|
217
257
|
</p>
|
|
218
258
|
</section>
|
|
219
259
|
</div>
|
|
@@ -38,6 +38,7 @@ export class PaymentStripeConfigurationRepository {
|
|
|
38
38
|
): Promise<PaymentStripeSettings> {
|
|
39
39
|
const update: Updateable<PaymentDatabase["payment_stripe_settings"]> = {
|
|
40
40
|
...(input.mode === undefined ? {} : { mode: input.mode }),
|
|
41
|
+
...(input.collectionMode === undefined ? {} : { collection_mode: input.collectionMode }),
|
|
41
42
|
...(input.publishableKey === undefined ? {} : { publishable_key: input.publishableKey }),
|
|
42
43
|
...(input.configurationStatus === undefined
|
|
43
44
|
? {}
|
|
@@ -75,6 +76,7 @@ function mapSettings(row: SettingsRow): PaymentStripeSettings {
|
|
|
75
76
|
return {
|
|
76
77
|
id: row.id,
|
|
77
78
|
mode: row.mode,
|
|
79
|
+
collectionMode: row.collection_mode,
|
|
78
80
|
publishableKey: row.publishable_key,
|
|
79
81
|
configurationStatus: row.configuration_status,
|
|
80
82
|
stripeAccountId: row.stripe_account_id,
|
|
@@ -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;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Database, PluginSecretStatus, PluginSecretStoreOptions } from "dineway";
|
|
2
2
|
import type { Kysely, Transaction } from "kysely";
|
|
3
3
|
|
|
4
|
-
import type { StripeAccountProbe, StripeMode } from "../types.js";
|
|
4
|
+
import type { StripeAccountProbe, StripeCollectionMode, StripeMode } from "../types.js";
|
|
5
5
|
import { PaymentStripeConfigurationRepository } from "./repository.js";
|
|
6
6
|
import {
|
|
7
7
|
PAYMENT_STRIPE_SECRET_KEY,
|
|
@@ -19,6 +19,7 @@ export interface PaymentStripeConfiguration {
|
|
|
19
19
|
export interface UpdatePaymentStripeConfigurationInput {
|
|
20
20
|
expectedVersion: number;
|
|
21
21
|
mode?: StripeMode;
|
|
22
|
+
collectionMode?: StripeCollectionMode;
|
|
22
23
|
publishableKey?: string | null;
|
|
23
24
|
secretKey?: string | null;
|
|
24
25
|
webhookSecret?: string | null;
|
|
@@ -89,6 +90,7 @@ export async function updatePaymentStripeConfiguration(
|
|
|
89
90
|
const credentialsComplete = Boolean(publishableKey && nextSecret);
|
|
90
91
|
await repository.updateSettings(before.version, {
|
|
91
92
|
mode,
|
|
93
|
+
collectionMode: input.collectionMode,
|
|
92
94
|
publishableKey,
|
|
93
95
|
...(credentialsChanged || !credentialsComplete
|
|
94
96
|
? {
|
|
@@ -212,6 +214,12 @@ function normalizeOptionalSecret(value: string | null): string | null {
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
function validateUpdate(input: UpdatePaymentStripeConfigurationInput): void {
|
|
217
|
+
if (
|
|
218
|
+
input.collectionMode !== undefined &&
|
|
219
|
+
!["standard", "managed"].includes(input.collectionMode)
|
|
220
|
+
) {
|
|
221
|
+
throw new PaymentConfigurationError("VALIDATION_ERROR", "Choose Standard or Managed Payments");
|
|
222
|
+
}
|
|
215
223
|
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1) {
|
|
216
224
|
throw new PaymentConfigurationError("VALIDATION_ERROR", "Expected version is invalid");
|
|
217
225
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StripeMode, StripeTaxStatus } from "../types.js";
|
|
1
|
+
import type { StripeCollectionMode, StripeMode, StripeTaxStatus } from "../types.js";
|
|
2
2
|
|
|
3
3
|
export type PaymentStripeConfigurationStatus = "unconfigured" | "unverified" | "valid" | "invalid";
|
|
4
4
|
export type PaymentStripeConnectStatus = "not_configured" | "restricted" | "ready";
|
|
@@ -6,6 +6,7 @@ export type PaymentStripeConnectStatus = "not_configured" | "restricted" | "read
|
|
|
6
6
|
export interface PaymentStripeSettings {
|
|
7
7
|
id: string;
|
|
8
8
|
mode: StripeMode;
|
|
9
|
+
collectionMode: StripeCollectionMode;
|
|
9
10
|
publishableKey: string | null;
|
|
10
11
|
configurationStatus: PaymentStripeConfigurationStatus;
|
|
11
12
|
stripeAccountId: string | null;
|
|
@@ -22,6 +23,7 @@ export interface PaymentStripeSettings {
|
|
|
22
23
|
|
|
23
24
|
export interface UpdatePaymentStripeSettings {
|
|
24
25
|
mode?: StripeMode;
|
|
26
|
+
collectionMode?: StripeCollectionMode;
|
|
25
27
|
publishableKey?: string | null;
|
|
26
28
|
configurationStatus?: PaymentStripeConfigurationStatus;
|
|
27
29
|
stripeAccountId?: string | null;
|
package/src/database/index.ts
CHANGED
|
@@ -3,10 +3,12 @@ import type { PluginMigration } from "dineway";
|
|
|
3
3
|
import * as configuration from "./migrations/001_stripe_configuration.js";
|
|
4
4
|
import * as subscriptions from "./migrations/002_subscriptions.js";
|
|
5
5
|
import * as purchases from "./migrations/003_purchases.js";
|
|
6
|
+
import * as collectionMode from "./migrations/004_collection_mode.js";
|
|
6
7
|
|
|
7
8
|
export const paymentMigrations = {
|
|
8
9
|
"001_stripe_configuration": configuration,
|
|
9
10
|
"002_subscriptions": subscriptions,
|
|
10
11
|
"003_purchases": purchases,
|
|
12
|
+
"004_collection_mode": collectionMode,
|
|
11
13
|
} satisfies Record<string, PluginMigration>;
|
|
12
14
|
export type { PaymentDatabase, PaymentTables } from "./types.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { sql, type Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
4
|
+
for (const table of ["payment_stripe_settings", "payment_subscription_purchases"]) {
|
|
5
|
+
await db.schema
|
|
6
|
+
.alterTable(table)
|
|
7
|
+
.addColumn("collection_mode", "text", (column) =>
|
|
8
|
+
column
|
|
9
|
+
.notNull()
|
|
10
|
+
.defaultTo("standard")
|
|
11
|
+
.check(sql`collection_mode IN ('standard', 'managed')`),
|
|
12
|
+
)
|
|
13
|
+
.execute();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
18
|
+
const purchases = await sql`SELECT 1 FROM payment_subscription_purchases LIMIT 1`.execute(db);
|
|
19
|
+
if (purchases.rows.length)
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Restore a database snapshot to roll back payment purchases without losing their collection mode.",
|
|
22
|
+
);
|
|
23
|
+
for (const table of ["payment_subscription_purchases", "payment_stripe_settings"]) {
|
|
24
|
+
await db.schema.alterTable(table).dropColumn("collection_mode").execute();
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/database/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Database } from "dineway";
|
|
2
2
|
import type { Generated } from "kysely";
|
|
3
3
|
|
|
4
|
+
import type { StripeCollectionMode } from "../types.js";
|
|
5
|
+
|
|
4
6
|
export interface PaymentSubscriptionKey {
|
|
5
7
|
scope: string;
|
|
6
8
|
id: string;
|
|
@@ -86,6 +88,7 @@ export interface PaymentSubscriptionPortalTable extends PaymentSubscriptionKey {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export interface PaymentSubscriptionPurchaseTable extends PaymentSubscriptionKey {
|
|
91
|
+
collection_mode: Generated<StripeCollectionMode>;
|
|
89
92
|
user_id: string;
|
|
90
93
|
group_key: string;
|
|
91
94
|
contract: string;
|
|
@@ -117,6 +120,7 @@ export interface PaymentStripeSettingsTable {
|
|
|
117
120
|
id: string;
|
|
118
121
|
default_key: Generated<"default">;
|
|
119
122
|
mode: Generated<PaymentStripeMode>;
|
|
123
|
+
collection_mode: Generated<StripeCollectionMode>;
|
|
120
124
|
publishable_key: string | null;
|
|
121
125
|
configuration_status: Generated<PaymentStripeConfigurationStatus>;
|
|
122
126
|
stripe_account_id: string | null;
|
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";
|
package/src/routes/index.ts
CHANGED
package/src/routes/schemas.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const updatePaymentStripeConfigurationSchema = z
|
|
|
6
6
|
.object({
|
|
7
7
|
expectedVersion: z.number().int().positive(),
|
|
8
8
|
mode: z.enum(["test", "live"]).optional(),
|
|
9
|
+
collectionMode: z.enum(["standard", "managed"]).optional(),
|
|
9
10
|
publishableKey: z.string().max(512).nullable().optional(),
|
|
10
11
|
secretKey: z.string().max(512).nullable().optional(),
|
|
11
12
|
webhookSecret: z.string().max(512).nullable().optional(),
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import type Stripe from "stripe";
|
|
2
2
|
|
|
3
|
+
import type { StripeCollectionMode } from "../types.js";
|
|
3
4
|
import { relationId } from "./purchases.js";
|
|
4
5
|
import {
|
|
5
6
|
SubscriptionError,
|
|
@@ -92,13 +93,31 @@ export async function invoiceFunding(
|
|
|
92
93
|
invoice: Stripe.Invoice,
|
|
93
94
|
contract: PurchaseContract | null,
|
|
94
95
|
now: number,
|
|
96
|
+
collectionMode: StripeCollectionMode = "standard",
|
|
95
97
|
): Promise<InvoiceFundingEvidence> {
|
|
96
98
|
const item = subscription.items.data[0]!;
|
|
97
99
|
const amount = contract?.amount ?? item.price.unit_amount ?? 0;
|
|
98
100
|
const currency = contract?.currency ?? item.price.currency;
|
|
99
101
|
const credit =
|
|
100
102
|
invoice.billing_reason === "subscription_create" ? (contract?.firstPeriodCredit ?? 0) : 0;
|
|
101
|
-
const
|
|
103
|
+
const taxes = invoice.total_taxes ?? [];
|
|
104
|
+
const invalidTax = taxes.some(
|
|
105
|
+
(tax) =>
|
|
106
|
+
!Number.isSafeInteger(tax.amount) ||
|
|
107
|
+
tax.amount < 0 ||
|
|
108
|
+
(tax.amount !== 0 &&
|
|
109
|
+
(collectionMode !== "managed" || !["exclusive", "inclusive"].includes(tax.tax_behavior))),
|
|
110
|
+
);
|
|
111
|
+
const exclusiveTax = taxes.reduce(
|
|
112
|
+
(sum, tax) => sum + (tax.tax_behavior === "exclusive" ? tax.amount : 0),
|
|
113
|
+
0,
|
|
114
|
+
);
|
|
115
|
+
// Stripe line subtotals exclude inclusive tax; invoice subtotals retain it.
|
|
116
|
+
const inclusiveTax = taxes.reduce(
|
|
117
|
+
(sum, tax) => sum + (tax.tax_behavior === "inclusive" ? tax.amount : 0),
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
const cash = amount - credit + exclusiveTax;
|
|
102
121
|
const evidence: InvoiceFundingEvidence = {
|
|
103
122
|
verifiedAt: now,
|
|
104
123
|
amount,
|
|
@@ -132,7 +151,7 @@ export async function invoiceFunding(
|
|
|
132
151
|
line.quantity !== 1 ||
|
|
133
152
|
line.currency !== currency ||
|
|
134
153
|
line.amount !== amount ||
|
|
135
|
-
line.subtotal !== amount ||
|
|
154
|
+
line.subtotal !== amount - inclusiveTax ||
|
|
136
155
|
invoice.subtotal !== amount ||
|
|
137
156
|
invoice.total !== cash ||
|
|
138
157
|
invoice.amount_due !== cash ||
|
|
@@ -142,7 +161,11 @@ export async function invoiceFunding(
|
|
|
142
161
|
invoice.ending_balance !== 0 ||
|
|
143
162
|
invoice.pre_payment_credit_notes_amount !== 0 ||
|
|
144
163
|
invoice.post_payment_credit_notes_amount !== 0 ||
|
|
145
|
-
|
|
164
|
+
invalidTax ||
|
|
165
|
+
!Number.isSafeInteger(inclusiveTax) ||
|
|
166
|
+
inclusiveTax > amount - credit ||
|
|
167
|
+
!Number.isSafeInteger(cash) ||
|
|
168
|
+
cash < 0 ||
|
|
146
169
|
!Number.isSafeInteger(line.period.start) ||
|
|
147
170
|
!Number.isSafeInteger(line.period.end) ||
|
|
148
171
|
line.period.start >= line.period.end
|
|
@@ -245,7 +268,7 @@ export async function invoiceFunding(
|
|
|
245
268
|
const resolved =
|
|
246
269
|
typeof method === "string" ? await stripe.paymentMethods.retrieve(method) : method;
|
|
247
270
|
if (
|
|
248
|
-
resolved.type !== "card" ||
|
|
271
|
+
(collectionMode === "standard" && resolved.type !== "card") ||
|
|
249
272
|
resolved.livemode !== invoice.livemode ||
|
|
250
273
|
relationId(resolved.customer) !== customer
|
|
251
274
|
)
|
|
@@ -94,6 +94,7 @@ export function subscriptionProjection(
|
|
|
94
94
|
const { contract } = purchase;
|
|
95
95
|
if (
|
|
96
96
|
purchase.userId !== userId ||
|
|
97
|
+
Boolean(subscription.managed_payments?.enabled) !== (purchase.collectionMode === "managed") ||
|
|
97
98
|
subscription.metadata.purchase_reference !== purchase.reference ||
|
|
98
99
|
(purchase.subscriptionId && purchase.subscriptionId !== subscription.id) ||
|
|
99
100
|
item.price.livemode !== (scope.mode === "live") ||
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import type Stripe from "stripe";
|
|
4
4
|
|
|
5
|
+
import type { StripeCollectionMode } from "../types.js";
|
|
5
6
|
import { recurringPrice } from "./projection.js";
|
|
6
7
|
import type { SubscriptionRepository } from "./repository.js";
|
|
7
8
|
import { purchaseSchema } from "./schemas.js";
|
|
@@ -24,6 +25,7 @@ export interface PurchaseContext {
|
|
|
24
25
|
scope: SubscriptionScope;
|
|
25
26
|
now: () => number;
|
|
26
27
|
origin: string;
|
|
28
|
+
collectionMode: StripeCollectionMode;
|
|
27
29
|
}
|
|
28
30
|
const idempotency = (context: PurchaseContext, ...values: unknown[]) =>
|
|
29
31
|
"dineway:" + digest([context.repository.scope, ...values]);
|
|
@@ -108,6 +110,8 @@ export async function createPurchase(
|
|
|
108
110
|
input: SubscriptionPurchaseInput,
|
|
109
111
|
) {
|
|
110
112
|
const parsed = purchaseSchema.parse(input);
|
|
113
|
+
if (!["standard", "managed"].includes(context.collectionMode))
|
|
114
|
+
throw new SubscriptionError("INVALID_COLLECTION_MODE", "Choose Standard or Managed Payments");
|
|
111
115
|
return context.repository.withLock("member:" + userId, async (store) => {
|
|
112
116
|
const previous = await store.purchase(parsed.reference);
|
|
113
117
|
if (previous) {
|
|
@@ -128,6 +132,15 @@ export async function createPurchase(
|
|
|
128
132
|
"Resolve the existing purchase before starting another",
|
|
129
133
|
409,
|
|
130
134
|
);
|
|
135
|
+
if (
|
|
136
|
+
context.collectionMode === "managed" &&
|
|
137
|
+
parsed.pricing.kind === "dynamic" &&
|
|
138
|
+
!parsed.pricing.taxCode
|
|
139
|
+
)
|
|
140
|
+
throw new SubscriptionError(
|
|
141
|
+
"MANAGED_PAYMENTS_TAX_CODE_REQUIRED",
|
|
142
|
+
"Managed Payments requires an eligible Stripe product tax code",
|
|
143
|
+
);
|
|
131
144
|
let contract: PurchaseContract;
|
|
132
145
|
if (parsed.pricing.kind === "catalogue") {
|
|
133
146
|
const pricing = parsed.pricing;
|
|
@@ -149,6 +162,23 @@ export async function createPurchase(
|
|
|
149
162
|
verified.interval !== price.interval
|
|
150
163
|
)
|
|
151
164
|
throw new SubscriptionError("PRICE_CHANGED", "Refresh the plan price before checkout", 409);
|
|
165
|
+
if (context.collectionMode === "managed") {
|
|
166
|
+
const product = await context.stripe.products.retrieve(price.productId);
|
|
167
|
+
if (
|
|
168
|
+
product.deleted ||
|
|
169
|
+
!product.active ||
|
|
170
|
+
product.livemode !== (context.scope.mode === "live")
|
|
171
|
+
)
|
|
172
|
+
throw new SubscriptionError(
|
|
173
|
+
"INVALID_PRODUCT",
|
|
174
|
+
"Choose an active product from this Stripe environment",
|
|
175
|
+
);
|
|
176
|
+
if (!product.tax_code)
|
|
177
|
+
throw new SubscriptionError(
|
|
178
|
+
"MANAGED_PAYMENTS_TAX_CODE_REQUIRED",
|
|
179
|
+
"Set an eligible tax code on the Stripe product before using Managed Payments",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
152
182
|
contract = {
|
|
153
183
|
...parsed,
|
|
154
184
|
amount: price.amount,
|
|
@@ -185,6 +215,7 @@ export async function createPurchase(
|
|
|
185
215
|
contract,
|
|
186
216
|
digest(parsed),
|
|
187
217
|
Math.floor(context.now() / 1000) + 3600,
|
|
218
|
+
context.collectionMode,
|
|
188
219
|
);
|
|
189
220
|
return resumePurchase(context, store, (await store.purchase(parsed.reference))!);
|
|
190
221
|
});
|
|
@@ -274,9 +305,12 @@ export async function resumePurchase(
|
|
|
274
305
|
price_data: {
|
|
275
306
|
currency: contract.currency,
|
|
276
307
|
unit_amount: contract.amount,
|
|
308
|
+
tax_behavior:
|
|
309
|
+
contract.pricing.kind === "dynamic" ? contract.pricing.taxBehavior : undefined,
|
|
277
310
|
recurring: { interval: contract.interval, interval_count: contract.intervalCount },
|
|
278
311
|
product_data: {
|
|
279
312
|
name: contract.pricing.kind === "dynamic" ? contract.pricing.name : "Subscription",
|
|
313
|
+
tax_code: contract.pricing.kind === "dynamic" ? contract.pricing.taxCode : undefined,
|
|
280
314
|
metadata,
|
|
281
315
|
},
|
|
282
316
|
},
|
|
@@ -287,7 +321,10 @@ export async function resumePurchase(
|
|
|
287
321
|
mode: "subscription",
|
|
288
322
|
customer: customerId,
|
|
289
323
|
line_items: [line],
|
|
290
|
-
|
|
324
|
+
managed_payments: { enabled: purchase.collectionMode === "managed" },
|
|
325
|
+
...(purchase.collectionMode === "standard"
|
|
326
|
+
? { payment_method_types: ["card" as const] }
|
|
327
|
+
: {}),
|
|
291
328
|
payment_method_collection: "always",
|
|
292
329
|
client_reference_id: purchase.reference,
|
|
293
330
|
metadata,
|
|
@@ -305,6 +342,7 @@ export async function resumePurchase(
|
|
|
305
342
|
if (
|
|
306
343
|
relationId(session.customer) !== customerId ||
|
|
307
344
|
session.mode !== "subscription" ||
|
|
345
|
+
Boolean(session.managed_payments?.enabled) !== (purchase.collectionMode === "managed") ||
|
|
308
346
|
session.livemode !== (context.scope.mode === "live") ||
|
|
309
347
|
session.metadata?.dineway_app !== context.scope.namespace ||
|
|
310
348
|
session.metadata.user_id !== purchase.userId ||
|
|
@@ -478,6 +478,7 @@ export class SubscriptionRepository {
|
|
|
478
478
|
if (!row) return null;
|
|
479
479
|
return {
|
|
480
480
|
reference: row.id,
|
|
481
|
+
collectionMode: row.collection_mode,
|
|
481
482
|
userId: row.user_id,
|
|
482
483
|
contract: JSON.parse(row.contract),
|
|
483
484
|
createdAt: row.created_at,
|
|
@@ -523,6 +524,7 @@ export class SubscriptionRepository {
|
|
|
523
524
|
contract: PurchaseContract,
|
|
524
525
|
digest: string,
|
|
525
526
|
expiresAt: number,
|
|
527
|
+
collectionMode: SubscriptionPurchase["collectionMode"],
|
|
526
528
|
) {
|
|
527
529
|
await this.db.transaction().execute(async (trx) => {
|
|
528
530
|
await this.assertLease(trx);
|
|
@@ -534,6 +536,7 @@ export class SubscriptionRepository {
|
|
|
534
536
|
user_id: userId,
|
|
535
537
|
group_key: contract.groupKey,
|
|
536
538
|
contract: JSON.stringify(contract),
|
|
539
|
+
collection_mode: collectionMode,
|
|
537
540
|
fingerprint: digest,
|
|
538
541
|
created_at: this.now(),
|
|
539
542
|
expires_at: expiresAt,
|
|
@@ -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
|
}
|
|
@@ -40,6 +41,7 @@ export async function getPaymentSubscriptionService(
|
|
|
40
41
|
siteUrl: options.siteUrl,
|
|
41
42
|
secretKey,
|
|
42
43
|
webhookSecret,
|
|
44
|
+
collectionMode: config.settings.collectionMode,
|
|
43
45
|
});
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -47,19 +49,17 @@ export async function getSubscriptionConfiguration(options: SubscriptionOptions)
|
|
|
47
49
|
const db = await getDb();
|
|
48
50
|
const configuration = await getPaymentStripeConfiguration(db);
|
|
49
51
|
const webhookSecret = await new PaymentStripeSecretStore(db).status(
|
|
50
|
-
|
|
52
|
+
PAYMENT_STRIPE_WEBHOOK_SECRET,
|
|
51
53
|
);
|
|
52
54
|
return {
|
|
53
55
|
namespace: options.namespace,
|
|
54
56
|
mode: configuration.settings.mode,
|
|
57
|
+
collectionMode: configuration.settings.collectionMode,
|
|
55
58
|
accountId: configuration.settings.stripeAccountId,
|
|
56
59
|
ready:
|
|
57
60
|
configuration.settings.configurationStatus === "valid" &&
|
|
58
61
|
Boolean(configuration.secretKey && webhookSecret),
|
|
59
62
|
webhookSecret,
|
|
60
|
-
webhookUrl: new URL(
|
|
61
|
-
"/_dineway/api/plugins/dineway-payment/subscriptions/webhook",
|
|
62
|
-
options.siteUrl,
|
|
63
|
-
).href,
|
|
63
|
+
webhookUrl: new URL(PAYMENT_WEBHOOK_PATH, options.siteUrl).href,
|
|
64
64
|
};
|
|
65
65
|
}
|
|
@@ -127,6 +127,11 @@ export const purchaseSchema = z
|
|
|
127
127
|
currency: z.string().regex(/^[a-z]{3}$/),
|
|
128
128
|
interval: z.enum(["day", "week", "month", "year"]),
|
|
129
129
|
intervalCount: z.number().int().min(1).max(365),
|
|
130
|
+
taxCode: z
|
|
131
|
+
.string()
|
|
132
|
+
.regex(/^txcd_[0-9]+$/)
|
|
133
|
+
.optional(),
|
|
134
|
+
taxBehavior: z.enum(["exclusive", "inclusive"]).optional(),
|
|
130
135
|
})
|
|
131
136
|
.strict(),
|
|
132
137
|
]),
|
|
@@ -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 { StripeCollectionMode, StripeWebhookEvent } from "../types.js";
|
|
8
9
|
import { collectBounded, invoiceFunding, inspectCharge } from "./funding.js";
|
|
9
10
|
import {
|
|
10
11
|
recurringPrice,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
} from "./types.js";
|
|
45
46
|
|
|
46
47
|
export interface SubscriptionServiceOptions {
|
|
48
|
+
collectionMode?: StripeCollectionMode;
|
|
47
49
|
db: Kysely<Database>;
|
|
48
50
|
scope: SubscriptionScope;
|
|
49
51
|
siteUrl: string;
|
|
@@ -53,7 +55,7 @@ export interface SubscriptionServiceOptions {
|
|
|
53
55
|
stripe?: Stripe;
|
|
54
56
|
}
|
|
55
57
|
const terminal = new Set(["canceled", "incomplete_expired"]);
|
|
56
|
-
const
|
|
58
|
+
export const SUBSCRIPTION_WEBHOOK_EVENTS = [
|
|
57
59
|
"checkout.session.completed",
|
|
58
60
|
"checkout.session.async_payment_succeeded",
|
|
59
61
|
"checkout.session.async_payment_failed",
|
|
@@ -76,7 +78,8 @@ const events = new Set([
|
|
|
76
78
|
"refund.updated",
|
|
77
79
|
"refund.failed",
|
|
78
80
|
"payment_intent.payment_failed",
|
|
79
|
-
]
|
|
81
|
+
] as const;
|
|
82
|
+
const events = new Set<string>(SUBSCRIPTION_WEBHOOK_EVENTS);
|
|
80
83
|
const localeSchema = checkoutSchema.shape.locale;
|
|
81
84
|
const fingerprint = (values: unknown[]) =>
|
|
82
85
|
createHash("sha256").update(JSON.stringify(values)).digest("hex");
|
|
@@ -235,6 +238,7 @@ export class PaymentSubscriptionService {
|
|
|
235
238
|
scope: this.options.scope,
|
|
236
239
|
now: this.now,
|
|
237
240
|
origin: this.origin,
|
|
241
|
+
collectionMode: this.options.collectionMode ?? "standard",
|
|
238
242
|
};
|
|
239
243
|
}
|
|
240
244
|
private async resolveSubject(subject: BillingSubject | string) {
|
|
@@ -565,6 +569,7 @@ export class PaymentSubscriptionService {
|
|
|
565
569
|
invoice,
|
|
566
570
|
purchase?.contract ?? null,
|
|
567
571
|
this.now(),
|
|
572
|
+
purchase?.collectionMode ?? "standard",
|
|
568
573
|
);
|
|
569
574
|
const old = previousPeriods.filter((period) => period.invoiceId === invoiceId);
|
|
570
575
|
const projected = old.length
|
|
@@ -723,7 +728,9 @@ export class PaymentSubscriptionService {
|
|
|
723
728
|
});
|
|
724
729
|
if (!verified.ok)
|
|
725
730
|
throw new SubscriptionError("INVALID_SIGNATURE", "Invalid Stripe webhook", 400);
|
|
726
|
-
|
|
731
|
+
await this.receiveVerifiedWebhook(verified.event, rawBody);
|
|
732
|
+
}
|
|
733
|
+
async receiveVerifiedWebhook(event: StripeWebhookEvent, rawBody: Uint8Array): Promise<void> {
|
|
727
734
|
if (event.livemode !== (this.options.scope.mode === "live"))
|
|
728
735
|
throw new SubscriptionError("MODE_MISMATCH", "Invalid webhook mode", 400);
|
|
729
736
|
if (!events.has(event.eventType)) return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StripeMode } from "../types.js";
|
|
1
|
+
import type { StripeCollectionMode, StripeMode } from "../types.js";
|
|
2
2
|
|
|
3
3
|
export type SubscriptionEntitlements = Record<string, boolean | number>;
|
|
4
4
|
export type SubscriptionInterval = "month" | "year";
|
|
@@ -92,6 +92,8 @@ export type PurchasePricing =
|
|
|
92
92
|
currency: string;
|
|
93
93
|
interval: "day" | "week" | "month" | "year";
|
|
94
94
|
intervalCount: number;
|
|
95
|
+
taxCode?: string;
|
|
96
|
+
taxBehavior?: "exclusive" | "inclusive";
|
|
95
97
|
};
|
|
96
98
|
export interface SubscriptionPurchaseInput {
|
|
97
99
|
reference: string;
|
|
@@ -110,6 +112,7 @@ export interface PurchaseContract extends SubscriptionPurchaseInput {
|
|
|
110
112
|
couponId: string | null;
|
|
111
113
|
}
|
|
112
114
|
export interface SubscriptionPurchase {
|
|
115
|
+
collectionMode: StripeCollectionMode;
|
|
113
116
|
reference: string;
|
|
114
117
|
userId: string;
|
|
115
118
|
contract: PurchaseContract;
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { PaymentDatabase, PaymentTables } from "./database/types.js";
|
|
2
2
|
export type StripeMode = "test" | "live";
|
|
3
|
+
export type StripeCollectionMode = "standard" | "managed";
|
|
3
4
|
export type StripeTaxStatus = "unknown" | "active" | "pending" | "unavailable";
|
|
4
5
|
|
|
5
6
|
export type StripeProviderErrorCode =
|
|
@@ -262,6 +263,7 @@ export interface StripeWebhookRefund {
|
|
|
262
263
|
}
|
|
263
264
|
|
|
264
265
|
export interface StripeWebhookEvent {
|
|
266
|
+
accountId?: string;
|
|
265
267
|
subscriptionId?: string;
|
|
266
268
|
eventId: string;
|
|
267
269
|
eventType: string;
|
|
@@ -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
|
+
}
|