@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 +21 -0
- package/README.md +18 -0
- package/dist/index.cjs +318 -0
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +298 -0
- package/package.json +44 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|