@dineway-ai/plugin-payment 0.2.6 → 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 +3 -2
- package/src/admin/subscriptions.tsx +160 -58
- package/src/admin.tsx +17 -3
- package/src/configuration/repository.ts +7 -3
- package/src/configuration/secrets.ts +1 -3
- package/src/database/index.ts +12 -0
- package/src/database/migrations/001_stripe_configuration.ts +100 -0
- package/src/database/migrations/002_subscriptions.ts +168 -0
- package/src/database/migrations/003_purchases.ts +193 -0
- package/src/database/migrations/adoption.ts +27 -0
- package/src/database/types.ts +137 -0
- package/src/index.ts +25 -0
- package/src/stripe/webhook-verifier.ts +1 -0
- package/src/subscriptions/funding.ts +256 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/projection.ts +39 -7
- package/src/subscriptions/purchases.ts +329 -0
- package/src/subscriptions/refunds.ts +134 -0
- package/src/subscriptions/repository.ts +352 -13
- package/src/subscriptions/routes.ts +11 -40
- package/src/subscriptions/runtime.ts +6 -8
- package/src/subscriptions/schemas.ts +45 -0
- package/src/subscriptions/service.ts +486 -203
- package/src/subscriptions/types.ts +90 -0
- 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
|
@@ -62,6 +62,8 @@ export interface MemberSubscription {
|
|
|
62
62
|
cancelAtPeriodEnd: boolean;
|
|
63
63
|
verifiedAt: number;
|
|
64
64
|
lastError: string | null;
|
|
65
|
+
purchaseReference?: string | null;
|
|
66
|
+
revision?: number;
|
|
65
67
|
}
|
|
66
68
|
export interface PaidSubscriptionPeriod {
|
|
67
69
|
id: string;
|
|
@@ -70,6 +72,94 @@ export interface PaidSubscriptionPeriod {
|
|
|
70
72
|
planCode: string;
|
|
71
73
|
startsAt: number;
|
|
72
74
|
endsAt: number;
|
|
75
|
+
eligible?: boolean;
|
|
76
|
+
evidence?: InvoiceFundingEvidence;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Supplied by a trusted session adapter, never by a browser request body. */
|
|
80
|
+
export interface BillingSubject {
|
|
81
|
+
realm: string;
|
|
82
|
+
id: string;
|
|
83
|
+
email: string;
|
|
84
|
+
emailVerified: boolean;
|
|
85
|
+
}
|
|
86
|
+
export type PurchasePricing =
|
|
87
|
+
| { kind: "catalogue"; planCode: string; interval: SubscriptionInterval }
|
|
88
|
+
| {
|
|
89
|
+
kind: "dynamic";
|
|
90
|
+
name: string;
|
|
91
|
+
amount: number;
|
|
92
|
+
currency: string;
|
|
93
|
+
interval: "day" | "week" | "month" | "year";
|
|
94
|
+
intervalCount: number;
|
|
95
|
+
};
|
|
96
|
+
export interface SubscriptionPurchaseInput {
|
|
97
|
+
reference: string;
|
|
98
|
+
groupKey: string;
|
|
99
|
+
pricing: PurchasePricing;
|
|
100
|
+
firstPeriodCredit?: number;
|
|
101
|
+
returnPath: string;
|
|
102
|
+
locale?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface PurchaseContract extends SubscriptionPurchaseInput {
|
|
105
|
+
amount: number;
|
|
106
|
+
currency: string;
|
|
107
|
+
interval: "day" | "week" | "month" | "year";
|
|
108
|
+
intervalCount: number;
|
|
109
|
+
priceId: string | null;
|
|
110
|
+
couponId: string | null;
|
|
111
|
+
}
|
|
112
|
+
export interface SubscriptionPurchase {
|
|
113
|
+
reference: string;
|
|
114
|
+
userId: string;
|
|
115
|
+
contract: PurchaseContract;
|
|
116
|
+
createdAt: number;
|
|
117
|
+
expiresAt: number;
|
|
118
|
+
sessionId: string | null;
|
|
119
|
+
subscriptionId: string | null;
|
|
120
|
+
closed: boolean;
|
|
121
|
+
cancelMode: "period_end" | "immediate" | null;
|
|
122
|
+
lastError: string | null;
|
|
123
|
+
}
|
|
124
|
+
export interface RefundEvidence {
|
|
125
|
+
id: string;
|
|
126
|
+
amount: number;
|
|
127
|
+
status: string;
|
|
128
|
+
metadata: Record<string, string>;
|
|
129
|
+
}
|
|
130
|
+
export interface ChargeEvidence {
|
|
131
|
+
id: string;
|
|
132
|
+
paymentIntentId: string | null;
|
|
133
|
+
customerId: string | null;
|
|
134
|
+
currency: string;
|
|
135
|
+
amount: number;
|
|
136
|
+
captured: number;
|
|
137
|
+
refunded: number;
|
|
138
|
+
settled: boolean;
|
|
139
|
+
refunds: RefundEvidence[];
|
|
140
|
+
disputes: { id: string; amount: number; status: string }[];
|
|
141
|
+
}
|
|
142
|
+
export interface InvoiceFundingEvidence {
|
|
143
|
+
verifiedAt: number;
|
|
144
|
+
currency: string;
|
|
145
|
+
amount: number;
|
|
146
|
+
credit: number;
|
|
147
|
+
cash: number;
|
|
148
|
+
charges: ChargeEvidence[];
|
|
149
|
+
paymentMethodId: string | null;
|
|
150
|
+
reason: string | null;
|
|
151
|
+
}
|
|
152
|
+
export interface SubscriptionSnapshot {
|
|
153
|
+
subscription: MemberSubscription;
|
|
154
|
+
subject: BillingSubject | null;
|
|
155
|
+
purchase: SubscriptionPurchase | null;
|
|
156
|
+
periods: PaidSubscriptionPeriod[];
|
|
157
|
+
}
|
|
158
|
+
export interface SubscriptionChange {
|
|
159
|
+
id: string;
|
|
160
|
+
revision: number;
|
|
161
|
+
createdAt: number;
|
|
162
|
+
snapshot: SubscriptionSnapshot;
|
|
73
163
|
}
|
|
74
164
|
export interface MemberBilling {
|
|
75
165
|
customer: boolean;
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type { PaymentDatabase, PaymentTables } from "./database/types.js";
|
|
1
2
|
export type StripeMode = "test" | "live";
|
|
2
3
|
export type StripeTaxStatus = "unknown" | "active" | "pending" | "unavailable";
|
|
3
4
|
|
|
@@ -261,6 +262,7 @@ export interface StripeWebhookRefund {
|
|
|
261
262
|
}
|
|
262
263
|
|
|
263
264
|
export interface StripeWebhookEvent {
|
|
265
|
+
accountId?: string;
|
|
264
266
|
subscriptionId?: string;
|
|
265
267
|
eventId: string;
|
|
266
268
|
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
|
+
}
|