@openbilling/stripe 0.1.0-alpha.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 George Dimitrov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @openbilling/stripe
2
+
3
+ Stripe adapter for OpenBilling.
4
+
5
+ `@openbilling/stripe` provides a fetch-based Stripe adapter that implements the shared `@openbilling/core` billing contract. It supports the current OpenBilling MVP workflows for Stripe Checkout Sessions, Stripe Billing Portal sessions, webhook verification, and normalized webhook mapping.
6
+
7
+ This package keeps Stripe-specific behavior visible where it matters. Checkout creation is price-based and currently requires `priceId`; `productId` is not treated as a portable substitute for Stripe.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @openbilling/core @openbilling/stripe
13
+ ```
14
+
15
+ ## Links
16
+
17
+ - Documentation: [openbilling.geodim.dev](https://openbilling.geodim.dev)
18
+ - Repository: [github.com/gndimitro/openbilling](https://github.com/gndimitro/openbilling)
package/dist/index.cjs ADDED
@@ -0,0 +1,318 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ StripeProviderError: () => StripeProviderError,
24
+ createStripeProvider: () => createStripeProvider
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+ var import_core = require("@openbilling/core");
28
+ var STRIPE_API_BASE_URL = "https://api.stripe.com";
29
+ var STRIPE_API_VERSION = "2026-04-22.dahlia";
30
+ var WEBHOOK_TOLERANCE_SECONDS = 300;
31
+ var textDecoder = new TextDecoder();
32
+ var textEncoder = new TextEncoder();
33
+ var StripeProviderError = class extends Error {
34
+ /** Stable provider-specific error classification. */
35
+ code;
36
+ /** HTTP status returned by Stripe when the error came from an API response. */
37
+ status;
38
+ constructor(message, code, status, options) {
39
+ super(message, options);
40
+ this.name = "StripeProviderError";
41
+ this.code = code;
42
+ if (status !== void 0) {
43
+ this.status = status;
44
+ }
45
+ }
46
+ };
47
+ function createStripeProvider(config) {
48
+ return (0, import_core.createBilling)({
49
+ async createCheckout(input) {
50
+ const priceId = input.priceId;
51
+ if (!priceId) {
52
+ throw new StripeProviderError(
53
+ input.productId ? "Stripe checkout sessions currently require a priceId instead of a productId." : "Stripe checkout sessions require a priceId.",
54
+ "unsupported_input"
55
+ );
56
+ }
57
+ const response = await postForm(
58
+ "/v1/checkout/sessions",
59
+ config.apiKey,
60
+ buildCheckoutForm(input, priceId)
61
+ );
62
+ if (!response.id || !response.url) {
63
+ throw new StripeProviderError("Stripe checkout session did not include both an id and a URL.", "api_error");
64
+ }
65
+ return {
66
+ id: response.id,
67
+ url: response.url,
68
+ provider: import_core.Provider.Stripe,
69
+ raw: response
70
+ };
71
+ },
72
+ async createPortalLink(input) {
73
+ const response = await postForm(
74
+ "/v1/billing_portal/sessions",
75
+ config.apiKey,
76
+ buildPortalForm(input)
77
+ );
78
+ if (!response.url) {
79
+ throw new StripeProviderError("Stripe billing portal session did not include a URL.", "api_error");
80
+ }
81
+ return {
82
+ url: response.url,
83
+ provider: import_core.Provider.Stripe,
84
+ raw: response
85
+ };
86
+ },
87
+ async verifyWebhook(input) {
88
+ const payload = toWebhookPayload(input.payload);
89
+ const signature = getSignature(input);
90
+ const secret = input.secret ?? config.webhookSecret;
91
+ const parsedHeader = parseSignatureHeader(signature);
92
+ assertTimestampIsFresh(parsedHeader.timestamp);
93
+ const expectedSignature = await createWebhookSignature(secret, `${parsedHeader.timestamp}.${payload}`);
94
+ const hasMatchingSignature = parsedHeader.signatures.some(
95
+ (candidate) => timingSafeEqual(candidate, expectedSignature)
96
+ );
97
+ if (!hasMatchingSignature) {
98
+ throw new StripeProviderError("Stripe webhook signature verification failed.", "invalid_webhook_signature");
99
+ }
100
+ const event = parseWebhookEvent(payload);
101
+ return normalizeWebhookEvent(event);
102
+ }
103
+ });
104
+ }
105
+ function buildCheckoutForm(input, priceId) {
106
+ const params = new URLSearchParams();
107
+ params.set("mode", input.mode);
108
+ params.set("success_url", input.successUrl);
109
+ params.set("cancel_url", input.cancelUrl);
110
+ params.set("line_items[0][price]", priceId);
111
+ params.set("line_items[0][quantity]", "1");
112
+ if (input.customerId) {
113
+ params.set("customer", input.customerId);
114
+ } else if (input.customerEmail) {
115
+ params.set("customer_email", input.customerEmail);
116
+ }
117
+ if (input.metadata) {
118
+ for (const [key, value] of Object.entries(input.metadata)) {
119
+ params.set(`metadata[${key}]`, value);
120
+ }
121
+ }
122
+ return params;
123
+ }
124
+ function buildPortalForm(input) {
125
+ const params = new URLSearchParams();
126
+ params.set("customer", input.customerId);
127
+ params.set("return_url", input.returnUrl);
128
+ return params;
129
+ }
130
+ async function postForm(path, apiKey, body) {
131
+ const response = await fetch(`${STRIPE_API_BASE_URL}${path}`, {
132
+ method: "POST",
133
+ headers: {
134
+ authorization: `Bearer ${apiKey}`,
135
+ "content-type": "application/x-www-form-urlencoded",
136
+ "stripe-version": STRIPE_API_VERSION
137
+ },
138
+ body: body.toString()
139
+ });
140
+ const responseBody = await readResponseBody(response);
141
+ if (!response.ok) {
142
+ throw createApiError(response, responseBody);
143
+ }
144
+ return responseBody;
145
+ }
146
+ async function readResponseBody(response) {
147
+ const contentType = response.headers.get("content-type") ?? "";
148
+ if (contentType.includes("application/json")) {
149
+ return response.json();
150
+ }
151
+ return response.text();
152
+ }
153
+ function createApiError(response, responseBody) {
154
+ const message = getApiErrorMessage(responseBody) ?? `Stripe API request failed with status ${response.status}${response.statusText ? ` ${response.statusText}` : ""}.`;
155
+ return new StripeProviderError(message, "api_error", response.status);
156
+ }
157
+ function getApiErrorMessage(responseBody) {
158
+ if (!responseBody || typeof responseBody !== "object") {
159
+ return void 0;
160
+ }
161
+ return responseBody.error?.message;
162
+ }
163
+ function toWebhookPayload(payload) {
164
+ return typeof payload === "string" ? payload : textDecoder.decode(payload);
165
+ }
166
+ function getSignature(input) {
167
+ if (input.signature) {
168
+ return input.signature;
169
+ }
170
+ const headerEntry = Object.entries(input.headers ?? {}).find(([key]) => key.toLowerCase() === "stripe-signature");
171
+ if (headerEntry?.[1]) {
172
+ return headerEntry[1];
173
+ }
174
+ throw new StripeProviderError("Stripe webhook verification requires a Stripe-Signature header.", "invalid_webhook_signature");
175
+ }
176
+ function parseSignatureHeader(header) {
177
+ let timestamp;
178
+ const signatures = [];
179
+ for (const entry of header.split(",")) {
180
+ const [rawKey, rawValue] = entry.split("=", 2);
181
+ const key = rawKey?.trim();
182
+ const value = rawValue?.trim();
183
+ if (!key || !value) {
184
+ continue;
185
+ }
186
+ if (key === "t") {
187
+ const parsedTimestamp = Number(value);
188
+ if (Number.isSafeInteger(parsedTimestamp)) {
189
+ timestamp = parsedTimestamp;
190
+ }
191
+ }
192
+ if (key === "v1") {
193
+ signatures.push(value);
194
+ }
195
+ }
196
+ if (timestamp === void 0 || signatures.length === 0) {
197
+ throw new StripeProviderError("Stripe-Signature header is malformed.", "invalid_webhook_signature");
198
+ }
199
+ return {
200
+ timestamp,
201
+ signatures
202
+ };
203
+ }
204
+ function assertTimestampIsFresh(timestamp) {
205
+ const now = Math.floor(Date.now() / 1e3);
206
+ if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
207
+ throw new StripeProviderError("Stripe webhook signature timestamp is outside the accepted tolerance window.", "invalid_webhook_signature");
208
+ }
209
+ }
210
+ async function createWebhookSignature(secret, payload) {
211
+ const key = await crypto.subtle.importKey(
212
+ "raw",
213
+ textEncoder.encode(secret),
214
+ {
215
+ name: "HMAC",
216
+ hash: "SHA-256"
217
+ },
218
+ false,
219
+ ["sign"]
220
+ );
221
+ const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
222
+ return bytesToHex(new Uint8Array(signature));
223
+ }
224
+ function bytesToHex(bytes) {
225
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
226
+ }
227
+ function timingSafeEqual(left, right) {
228
+ const leftBytes = textEncoder.encode(left);
229
+ const rightBytes = textEncoder.encode(right);
230
+ const maxLength = Math.max(leftBytes.length, rightBytes.length);
231
+ let mismatch = leftBytes.length === rightBytes.length ? 0 : 1;
232
+ for (let index = 0; index < maxLength; index += 1) {
233
+ mismatch |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
234
+ }
235
+ return mismatch === 0;
236
+ }
237
+ function parseWebhookEvent(payload) {
238
+ try {
239
+ return JSON.parse(payload);
240
+ } catch (error) {
241
+ throw new StripeProviderError("Stripe webhook payload was not valid JSON.", "invalid_webhook_signature", void 0, {
242
+ cause: error
243
+ });
244
+ }
245
+ }
246
+ function normalizeWebhookEvent(event) {
247
+ const object = event.data?.object;
248
+ const customerId = getString(object?.customer);
249
+ switch (event.type) {
250
+ case "customer.subscription.created":
251
+ case "customer.subscription.updated":
252
+ if (getString(object?.status) === "active") {
253
+ const subscriptionId = getString(object?.id);
254
+ if (customerId && subscriptionId) {
255
+ return {
256
+ type: import_core.Subscription.Active,
257
+ provider: import_core.Provider.Stripe,
258
+ customerId,
259
+ subscriptionId,
260
+ raw: event
261
+ };
262
+ }
263
+ }
264
+ break;
265
+ case "customer.subscription.deleted": {
266
+ const subscriptionId = getString(object?.id);
267
+ if (customerId && subscriptionId) {
268
+ return {
269
+ type: import_core.Subscription.Cancelled,
270
+ provider: import_core.Provider.Stripe,
271
+ customerId,
272
+ subscriptionId,
273
+ raw: event
274
+ };
275
+ }
276
+ break;
277
+ }
278
+ case "checkout.session.completed": {
279
+ const paymentId = getString(object?.payment_intent);
280
+ if (paymentId) {
281
+ return {
282
+ type: import_core.Payment.Succeeded,
283
+ provider: import_core.Provider.Stripe,
284
+ ...customerId ? { customerId } : {},
285
+ paymentId,
286
+ raw: event
287
+ };
288
+ }
289
+ break;
290
+ }
291
+ case "payment_intent.succeeded": {
292
+ const paymentId = getString(object?.id);
293
+ if (paymentId) {
294
+ return {
295
+ type: import_core.Payment.Succeeded,
296
+ provider: import_core.Provider.Stripe,
297
+ ...customerId ? { customerId } : {},
298
+ paymentId,
299
+ raw: event
300
+ };
301
+ }
302
+ break;
303
+ }
304
+ }
305
+ return {
306
+ type: import_core.Webhook.Unknown,
307
+ provider: import_core.Provider.Stripe,
308
+ raw: event
309
+ };
310
+ }
311
+ function getString(value) {
312
+ return typeof value === "string" ? value : void 0;
313
+ }
314
+ // Annotate the CommonJS export names for ESM import in node:
315
+ 0 && (module.exports = {
316
+ StripeProviderError,
317
+ createStripeProvider
318
+ });
@@ -0,0 +1,77 @@
1
+ import { BillingProvider } from '@openbilling/core';
2
+
3
+ type StripeErrorCode = "unsupported_input" | "api_error" | "invalid_webhook_signature";
4
+ /**
5
+ * Configuration for the Stripe provider adapter.
6
+ *
7
+ * The current Stripe adapter is intentionally narrow and only covers the
8
+ * hosted checkout, billing portal, and webhook flows documented in the root
9
+ * README.
10
+ */
11
+ interface StripeProviderConfig {
12
+ /** Restricted or secret API key used for Stripe REST API requests. */
13
+ apiKey: string;
14
+ /** Webhook signing secret used to verify Stripe webhook deliveries. */
15
+ webhookSecret: string;
16
+ }
17
+ /**
18
+ * Error raised by the Stripe provider adapter.
19
+ *
20
+ * The `code` field is stable enough for app-level branching, while `status`
21
+ * is included when the failure originated from the Stripe HTTP API.
22
+ */
23
+ declare class StripeProviderError extends Error {
24
+ /** Stable provider-specific error classification. */
25
+ readonly code: StripeErrorCode;
26
+ /** HTTP status returned by Stripe when the error came from an API response. */
27
+ readonly status?: number;
28
+ constructor(message: string, code: StripeErrorCode, status?: number, options?: ErrorOptions);
29
+ }
30
+ /**
31
+ * Creates a fetch-based Stripe adapter that implements the shared
32
+ * `@openbilling/core` billing contract.
33
+ *
34
+ * The current MVP intentionally supports a narrow Stripe surface:
35
+ * - checkout creation through Stripe Checkout Sessions
36
+ * - customer billing management through Stripe Billing Portal
37
+ * - webhook verification plus normalization for a small set of events
38
+ *
39
+ * Important provider caveats:
40
+ * - Stripe currently requires `priceId` for checkout creation
41
+ * - `productId` alone is not treated as a portable substitute
42
+ * - both test and live mode target `https://api.stripe.com`; the key
43
+ * determines the environment
44
+ * - outbound REST requests pin `Stripe-Version: 2026-04-22.dahlia`
45
+ *
46
+ * Supported normalized Stripe webhook coverage:
47
+ * - `checkout.session.completed` where `payment_intent` is present
48
+ * - `payment_intent.succeeded`
49
+ * - `customer.subscription.created` with active status
50
+ * - `customer.subscription.updated` with active status
51
+ * - `customer.subscription.deleted`
52
+ *
53
+ * Unsupported Stripe events resolve to {@link Webhook.Unknown} instead of
54
+ * throwing purely because the event is outside the current MVP.
55
+ *
56
+ * @throws {StripeProviderError} When input is unsupported, webhook
57
+ * verification fails, or the Stripe API returns an error response.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const billing = createStripeProvider({
62
+ * apiKey: "sk_test_123",
63
+ * webhookSecret: "whsec_123"
64
+ * });
65
+ *
66
+ * const checkout = await billing.createCheckout({
67
+ * priceId: "price_123",
68
+ * customerEmail: "demo@example.com",
69
+ * successUrl: "https://example.com/success",
70
+ * cancelUrl: "https://example.com/cancel",
71
+ * mode: "subscription"
72
+ * });
73
+ * ```
74
+ */
75
+ declare function createStripeProvider(config: StripeProviderConfig): BillingProvider;
76
+
77
+ export { type StripeProviderConfig, StripeProviderError, createStripeProvider };
@@ -0,0 +1,77 @@
1
+ import { BillingProvider } from '@openbilling/core';
2
+
3
+ type StripeErrorCode = "unsupported_input" | "api_error" | "invalid_webhook_signature";
4
+ /**
5
+ * Configuration for the Stripe provider adapter.
6
+ *
7
+ * The current Stripe adapter is intentionally narrow and only covers the
8
+ * hosted checkout, billing portal, and webhook flows documented in the root
9
+ * README.
10
+ */
11
+ interface StripeProviderConfig {
12
+ /** Restricted or secret API key used for Stripe REST API requests. */
13
+ apiKey: string;
14
+ /** Webhook signing secret used to verify Stripe webhook deliveries. */
15
+ webhookSecret: string;
16
+ }
17
+ /**
18
+ * Error raised by the Stripe provider adapter.
19
+ *
20
+ * The `code` field is stable enough for app-level branching, while `status`
21
+ * is included when the failure originated from the Stripe HTTP API.
22
+ */
23
+ declare class StripeProviderError extends Error {
24
+ /** Stable provider-specific error classification. */
25
+ readonly code: StripeErrorCode;
26
+ /** HTTP status returned by Stripe when the error came from an API response. */
27
+ readonly status?: number;
28
+ constructor(message: string, code: StripeErrorCode, status?: number, options?: ErrorOptions);
29
+ }
30
+ /**
31
+ * Creates a fetch-based Stripe adapter that implements the shared
32
+ * `@openbilling/core` billing contract.
33
+ *
34
+ * The current MVP intentionally supports a narrow Stripe surface:
35
+ * - checkout creation through Stripe Checkout Sessions
36
+ * - customer billing management through Stripe Billing Portal
37
+ * - webhook verification plus normalization for a small set of events
38
+ *
39
+ * Important provider caveats:
40
+ * - Stripe currently requires `priceId` for checkout creation
41
+ * - `productId` alone is not treated as a portable substitute
42
+ * - both test and live mode target `https://api.stripe.com`; the key
43
+ * determines the environment
44
+ * - outbound REST requests pin `Stripe-Version: 2026-04-22.dahlia`
45
+ *
46
+ * Supported normalized Stripe webhook coverage:
47
+ * - `checkout.session.completed` where `payment_intent` is present
48
+ * - `payment_intent.succeeded`
49
+ * - `customer.subscription.created` with active status
50
+ * - `customer.subscription.updated` with active status
51
+ * - `customer.subscription.deleted`
52
+ *
53
+ * Unsupported Stripe events resolve to {@link Webhook.Unknown} instead of
54
+ * throwing purely because the event is outside the current MVP.
55
+ *
56
+ * @throws {StripeProviderError} When input is unsupported, webhook
57
+ * verification fails, or the Stripe API returns an error response.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const billing = createStripeProvider({
62
+ * apiKey: "sk_test_123",
63
+ * webhookSecret: "whsec_123"
64
+ * });
65
+ *
66
+ * const checkout = await billing.createCheckout({
67
+ * priceId: "price_123",
68
+ * customerEmail: "demo@example.com",
69
+ * successUrl: "https://example.com/success",
70
+ * cancelUrl: "https://example.com/cancel",
71
+ * mode: "subscription"
72
+ * });
73
+ * ```
74
+ */
75
+ declare function createStripeProvider(config: StripeProviderConfig): BillingProvider;
76
+
77
+ export { type StripeProviderConfig, StripeProviderError, createStripeProvider };
package/dist/index.js ADDED
@@ -0,0 +1,298 @@
1
+ // src/index.ts
2
+ import {
3
+ Payment,
4
+ Provider,
5
+ Subscription,
6
+ Webhook,
7
+ createBilling
8
+ } from "@openbilling/core";
9
+ var STRIPE_API_BASE_URL = "https://api.stripe.com";
10
+ var STRIPE_API_VERSION = "2026-04-22.dahlia";
11
+ var WEBHOOK_TOLERANCE_SECONDS = 300;
12
+ var textDecoder = new TextDecoder();
13
+ var textEncoder = new TextEncoder();
14
+ var StripeProviderError = class extends Error {
15
+ /** Stable provider-specific error classification. */
16
+ code;
17
+ /** HTTP status returned by Stripe when the error came from an API response. */
18
+ status;
19
+ constructor(message, code, status, options) {
20
+ super(message, options);
21
+ this.name = "StripeProviderError";
22
+ this.code = code;
23
+ if (status !== void 0) {
24
+ this.status = status;
25
+ }
26
+ }
27
+ };
28
+ function createStripeProvider(config) {
29
+ return createBilling({
30
+ async createCheckout(input) {
31
+ const priceId = input.priceId;
32
+ if (!priceId) {
33
+ throw new StripeProviderError(
34
+ input.productId ? "Stripe checkout sessions currently require a priceId instead of a productId." : "Stripe checkout sessions require a priceId.",
35
+ "unsupported_input"
36
+ );
37
+ }
38
+ const response = await postForm(
39
+ "/v1/checkout/sessions",
40
+ config.apiKey,
41
+ buildCheckoutForm(input, priceId)
42
+ );
43
+ if (!response.id || !response.url) {
44
+ throw new StripeProviderError("Stripe checkout session did not include both an id and a URL.", "api_error");
45
+ }
46
+ return {
47
+ id: response.id,
48
+ url: response.url,
49
+ provider: Provider.Stripe,
50
+ raw: response
51
+ };
52
+ },
53
+ async createPortalLink(input) {
54
+ const response = await postForm(
55
+ "/v1/billing_portal/sessions",
56
+ config.apiKey,
57
+ buildPortalForm(input)
58
+ );
59
+ if (!response.url) {
60
+ throw new StripeProviderError("Stripe billing portal session did not include a URL.", "api_error");
61
+ }
62
+ return {
63
+ url: response.url,
64
+ provider: Provider.Stripe,
65
+ raw: response
66
+ };
67
+ },
68
+ async verifyWebhook(input) {
69
+ const payload = toWebhookPayload(input.payload);
70
+ const signature = getSignature(input);
71
+ const secret = input.secret ?? config.webhookSecret;
72
+ const parsedHeader = parseSignatureHeader(signature);
73
+ assertTimestampIsFresh(parsedHeader.timestamp);
74
+ const expectedSignature = await createWebhookSignature(secret, `${parsedHeader.timestamp}.${payload}`);
75
+ const hasMatchingSignature = parsedHeader.signatures.some(
76
+ (candidate) => timingSafeEqual(candidate, expectedSignature)
77
+ );
78
+ if (!hasMatchingSignature) {
79
+ throw new StripeProviderError("Stripe webhook signature verification failed.", "invalid_webhook_signature");
80
+ }
81
+ const event = parseWebhookEvent(payload);
82
+ return normalizeWebhookEvent(event);
83
+ }
84
+ });
85
+ }
86
+ function buildCheckoutForm(input, priceId) {
87
+ const params = new URLSearchParams();
88
+ params.set("mode", input.mode);
89
+ params.set("success_url", input.successUrl);
90
+ params.set("cancel_url", input.cancelUrl);
91
+ params.set("line_items[0][price]", priceId);
92
+ params.set("line_items[0][quantity]", "1");
93
+ if (input.customerId) {
94
+ params.set("customer", input.customerId);
95
+ } else if (input.customerEmail) {
96
+ params.set("customer_email", input.customerEmail);
97
+ }
98
+ if (input.metadata) {
99
+ for (const [key, value] of Object.entries(input.metadata)) {
100
+ params.set(`metadata[${key}]`, value);
101
+ }
102
+ }
103
+ return params;
104
+ }
105
+ function buildPortalForm(input) {
106
+ const params = new URLSearchParams();
107
+ params.set("customer", input.customerId);
108
+ params.set("return_url", input.returnUrl);
109
+ return params;
110
+ }
111
+ async function postForm(path, apiKey, body) {
112
+ const response = await fetch(`${STRIPE_API_BASE_URL}${path}`, {
113
+ method: "POST",
114
+ headers: {
115
+ authorization: `Bearer ${apiKey}`,
116
+ "content-type": "application/x-www-form-urlencoded",
117
+ "stripe-version": STRIPE_API_VERSION
118
+ },
119
+ body: body.toString()
120
+ });
121
+ const responseBody = await readResponseBody(response);
122
+ if (!response.ok) {
123
+ throw createApiError(response, responseBody);
124
+ }
125
+ return responseBody;
126
+ }
127
+ async function readResponseBody(response) {
128
+ const contentType = response.headers.get("content-type") ?? "";
129
+ if (contentType.includes("application/json")) {
130
+ return response.json();
131
+ }
132
+ return response.text();
133
+ }
134
+ function createApiError(response, responseBody) {
135
+ const message = getApiErrorMessage(responseBody) ?? `Stripe API request failed with status ${response.status}${response.statusText ? ` ${response.statusText}` : ""}.`;
136
+ return new StripeProviderError(message, "api_error", response.status);
137
+ }
138
+ function getApiErrorMessage(responseBody) {
139
+ if (!responseBody || typeof responseBody !== "object") {
140
+ return void 0;
141
+ }
142
+ return responseBody.error?.message;
143
+ }
144
+ function toWebhookPayload(payload) {
145
+ return typeof payload === "string" ? payload : textDecoder.decode(payload);
146
+ }
147
+ function getSignature(input) {
148
+ if (input.signature) {
149
+ return input.signature;
150
+ }
151
+ const headerEntry = Object.entries(input.headers ?? {}).find(([key]) => key.toLowerCase() === "stripe-signature");
152
+ if (headerEntry?.[1]) {
153
+ return headerEntry[1];
154
+ }
155
+ throw new StripeProviderError("Stripe webhook verification requires a Stripe-Signature header.", "invalid_webhook_signature");
156
+ }
157
+ function parseSignatureHeader(header) {
158
+ let timestamp;
159
+ const signatures = [];
160
+ for (const entry of header.split(",")) {
161
+ const [rawKey, rawValue] = entry.split("=", 2);
162
+ const key = rawKey?.trim();
163
+ const value = rawValue?.trim();
164
+ if (!key || !value) {
165
+ continue;
166
+ }
167
+ if (key === "t") {
168
+ const parsedTimestamp = Number(value);
169
+ if (Number.isSafeInteger(parsedTimestamp)) {
170
+ timestamp = parsedTimestamp;
171
+ }
172
+ }
173
+ if (key === "v1") {
174
+ signatures.push(value);
175
+ }
176
+ }
177
+ if (timestamp === void 0 || signatures.length === 0) {
178
+ throw new StripeProviderError("Stripe-Signature header is malformed.", "invalid_webhook_signature");
179
+ }
180
+ return {
181
+ timestamp,
182
+ signatures
183
+ };
184
+ }
185
+ function assertTimestampIsFresh(timestamp) {
186
+ const now = Math.floor(Date.now() / 1e3);
187
+ if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
188
+ throw new StripeProviderError("Stripe webhook signature timestamp is outside the accepted tolerance window.", "invalid_webhook_signature");
189
+ }
190
+ }
191
+ async function createWebhookSignature(secret, payload) {
192
+ const key = await crypto.subtle.importKey(
193
+ "raw",
194
+ textEncoder.encode(secret),
195
+ {
196
+ name: "HMAC",
197
+ hash: "SHA-256"
198
+ },
199
+ false,
200
+ ["sign"]
201
+ );
202
+ const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
203
+ return bytesToHex(new Uint8Array(signature));
204
+ }
205
+ function bytesToHex(bytes) {
206
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
207
+ }
208
+ function timingSafeEqual(left, right) {
209
+ const leftBytes = textEncoder.encode(left);
210
+ const rightBytes = textEncoder.encode(right);
211
+ const maxLength = Math.max(leftBytes.length, rightBytes.length);
212
+ let mismatch = leftBytes.length === rightBytes.length ? 0 : 1;
213
+ for (let index = 0; index < maxLength; index += 1) {
214
+ mismatch |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
215
+ }
216
+ return mismatch === 0;
217
+ }
218
+ function parseWebhookEvent(payload) {
219
+ try {
220
+ return JSON.parse(payload);
221
+ } catch (error) {
222
+ throw new StripeProviderError("Stripe webhook payload was not valid JSON.", "invalid_webhook_signature", void 0, {
223
+ cause: error
224
+ });
225
+ }
226
+ }
227
+ function normalizeWebhookEvent(event) {
228
+ const object = event.data?.object;
229
+ const customerId = getString(object?.customer);
230
+ switch (event.type) {
231
+ case "customer.subscription.created":
232
+ case "customer.subscription.updated":
233
+ if (getString(object?.status) === "active") {
234
+ const subscriptionId = getString(object?.id);
235
+ if (customerId && subscriptionId) {
236
+ return {
237
+ type: Subscription.Active,
238
+ provider: Provider.Stripe,
239
+ customerId,
240
+ subscriptionId,
241
+ raw: event
242
+ };
243
+ }
244
+ }
245
+ break;
246
+ case "customer.subscription.deleted": {
247
+ const subscriptionId = getString(object?.id);
248
+ if (customerId && subscriptionId) {
249
+ return {
250
+ type: Subscription.Cancelled,
251
+ provider: Provider.Stripe,
252
+ customerId,
253
+ subscriptionId,
254
+ raw: event
255
+ };
256
+ }
257
+ break;
258
+ }
259
+ case "checkout.session.completed": {
260
+ const paymentId = getString(object?.payment_intent);
261
+ if (paymentId) {
262
+ return {
263
+ type: Payment.Succeeded,
264
+ provider: Provider.Stripe,
265
+ ...customerId ? { customerId } : {},
266
+ paymentId,
267
+ raw: event
268
+ };
269
+ }
270
+ break;
271
+ }
272
+ case "payment_intent.succeeded": {
273
+ const paymentId = getString(object?.id);
274
+ if (paymentId) {
275
+ return {
276
+ type: Payment.Succeeded,
277
+ provider: Provider.Stripe,
278
+ ...customerId ? { customerId } : {},
279
+ paymentId,
280
+ raw: event
281
+ };
282
+ }
283
+ break;
284
+ }
285
+ }
286
+ return {
287
+ type: Webhook.Unknown,
288
+ provider: Provider.Stripe,
289
+ raw: event
290
+ };
291
+ }
292
+ function getString(value) {
293
+ return typeof value === "string" ? value : void 0;
294
+ }
295
+ export {
296
+ StripeProviderError,
297
+ createStripeProvider
298
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@openbilling/stripe",
3
+ "version": "0.1.0-alpha.1",
4
+ "homepage": "https://openbilling.geodim.dev",
5
+ "description": "Switch between Stripe and Dodo Payments without rewriting your billing logic",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/gndimitro/openbilling",
9
+ "directory": "packages/stripe"
10
+ },
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "billing",
14
+ "payments",
15
+ "stripe",
16
+ "dodo"
17
+ ],
18
+ "type": "module",
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "require": "./dist/index.cjs"
30
+ }
31
+ },
32
+ "dependencies": {
33
+ "@openbilling/core": "0.1.0-alpha.1"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
40
+ "dev": "tsup src/index.ts --format esm,cjs --dts --clean --watch",
41
+ "test": "vitest run",
42
+ "typecheck": "tsc --project tsconfig.json --noEmit"
43
+ }
44
+ }