@hello-bill/node 1.0.0 → 1.0.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +216 -0
  4. package/dist/express/index.d.cts +34 -0
  5. package/dist/express/index.d.ts +34 -0
  6. package/dist/express/index.js +1019 -0
  7. package/dist/express/index.js.map +1 -0
  8. package/dist/express/index.mjs +1016 -0
  9. package/dist/express/index.mjs.map +1 -0
  10. package/dist/hono/index.d.cts +37 -0
  11. package/dist/hono/index.d.ts +37 -0
  12. package/dist/hono/index.js +1036 -0
  13. package/dist/hono/index.js.map +1 -0
  14. package/dist/hono/index.mjs +1033 -0
  15. package/dist/hono/index.mjs.map +1 -0
  16. package/dist/index-BrfG82zs.d.cts +341 -0
  17. package/dist/index-EXetQn1f.d.ts +341 -0
  18. package/dist/index.d.cts +164 -0
  19. package/dist/index.d.ts +164 -0
  20. package/dist/index.js +1178 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/index.mjs +1164 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/koa/index.d.cts +42 -0
  25. package/dist/koa/index.d.ts +42 -0
  26. package/dist/koa/index.js +1042 -0
  27. package/dist/koa/index.js.map +1 -0
  28. package/dist/koa/index.mjs +1039 -0
  29. package/dist/koa/index.mjs.map +1 -0
  30. package/dist/next/index.d.cts +60 -0
  31. package/dist/next/index.d.ts +60 -0
  32. package/dist/next/index.js +1092 -0
  33. package/dist/next/index.js.map +1 -0
  34. package/dist/next/index.mjs +1090 -0
  35. package/dist/next/index.mjs.map +1 -0
  36. package/dist/types/index.d.cts +51 -0
  37. package/dist/types/index.d.ts +51 -0
  38. package/dist/types/index.js +12 -0
  39. package/dist/types/index.js.map +1 -0
  40. package/dist/types/index.mjs +9 -0
  41. package/dist/types/index.mjs.map +1 -0
  42. package/dist/webhook/index.d.cts +2 -0
  43. package/dist/webhook/index.d.ts +2 -0
  44. package/dist/webhook/index.js +342 -0
  45. package/dist/webhook/index.js.map +1 -0
  46. package/dist/webhook/index.mjs +336 -0
  47. package/dist/webhook/index.mjs.map +1 -0
  48. package/dist/webhooks-DPpqf7d2.d.cts +751 -0
  49. package/dist/webhooks-DPpqf7d2.d.ts +751 -0
  50. package/package.json +164 -7
@@ -0,0 +1,1019 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+ var module$1 = require('module');
5
+
6
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
7
+ // src/types/session.ts
8
+
9
+ // src/types/errors.ts
10
+ var RETRYABLE_HTTP_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
11
+
12
+ // src/core/retry.ts
13
+ var NetworkError = class extends Error {
14
+ constructor(message, cause, kind = "network") {
15
+ super(message);
16
+ this.cause = cause;
17
+ this.kind = kind;
18
+ this.name = "NetworkError";
19
+ }
20
+ };
21
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ function parseRetryAfter(value) {
23
+ if (!value) return null;
24
+ const seconds = Number(value);
25
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
26
+ return null;
27
+ }
28
+ function backoff(attempt, base, cap, random) {
29
+ const exp = Math.min(cap, base * 2 ** attempt);
30
+ return Math.floor(random() * exp);
31
+ }
32
+ async function readErrorCode(res) {
33
+ try {
34
+ const clone = res.clone();
35
+ const body = await clone.json();
36
+ return body?.error?.code;
37
+ } catch {
38
+ return void 0;
39
+ }
40
+ }
41
+ async function retryFetch(input, init, opts = {}) {
42
+ const {
43
+ maxAttempts = 5,
44
+ baseDelayMs = 1e3,
45
+ maxDelayMs = 16e3,
46
+ fetch: fetchImpl = globalThis.fetch,
47
+ sleep = defaultSleep,
48
+ random = Math.random,
49
+ onTokenExpired,
50
+ timeoutMs = 3e4
51
+ } = opts;
52
+ let tokenRefreshed = false;
53
+ let lastResponse;
54
+ let lastError;
55
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
56
+ let res;
57
+ try {
58
+ res = await fetchWithTimeout(fetchImpl, input, init, timeoutMs);
59
+ } catch (err) {
60
+ lastError = err;
61
+ if (attempt === maxAttempts - 1) {
62
+ if (err instanceof NetworkError) throw err;
63
+ throw new NetworkError(
64
+ "upstream request failed",
65
+ err,
66
+ isAbortLike(err) ? "timeout" : "network"
67
+ );
68
+ }
69
+ const delay2 = backoff(attempt, baseDelayMs, maxDelayMs, random);
70
+ await sleep(delay2);
71
+ continue;
72
+ }
73
+ lastResponse = res;
74
+ if (res.ok) return res;
75
+ if (!tokenRefreshed && onTokenExpired && res.status === 401) {
76
+ const code = await readErrorCode(res);
77
+ if (code === "auth.token_expired") {
78
+ tokenRefreshed = true;
79
+ await onTokenExpired();
80
+ continue;
81
+ }
82
+ }
83
+ if (!RETRYABLE_HTTP_STATUSES.has(res.status)) return res;
84
+ if (attempt === maxAttempts - 1) return res;
85
+ const retryAfterMs = parseRetryAfter(res.headers.get("retry-after"));
86
+ const delay = retryAfterMs ?? backoff(attempt, baseDelayMs, maxDelayMs, random);
87
+ await sleep(delay);
88
+ }
89
+ if (lastResponse) return lastResponse;
90
+ throw new NetworkError("upstream request failed", lastError);
91
+ }
92
+ function isAbortLike(err) {
93
+ if (!err || typeof err !== "object") return false;
94
+ const name = err.name;
95
+ return name === "AbortError" || name === "TimeoutError";
96
+ }
97
+ async function fetchWithTimeout(fetchImpl, input, init, timeoutMs) {
98
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
99
+ return fetchImpl(input, init);
100
+ }
101
+ const controller = new AbortController();
102
+ const userSignal = init.signal;
103
+ if (userSignal) {
104
+ if (userSignal.aborted) controller.abort(userSignal.reason);
105
+ else userSignal.addEventListener("abort", () => controller.abort(userSignal.reason), { once: true });
106
+ }
107
+ const timer = setTimeout(() => controller.abort(new Error("upstream timeout")), timeoutMs);
108
+ try {
109
+ return await fetchImpl(input, { ...init, signal: controller.signal });
110
+ } catch (err) {
111
+ if (controller.signal.aborted && !userSignal?.aborted) {
112
+ throw new NetworkError(`upstream request timed out after ${timeoutMs}ms`, err, "timeout");
113
+ }
114
+ throw err;
115
+ } finally {
116
+ clearTimeout(timer);
117
+ }
118
+ }
119
+
120
+ // src/core/client.ts
121
+ function buildQueryString(query) {
122
+ if (!query) return "";
123
+ const sp = new URLSearchParams();
124
+ for (const [k, v] of Object.entries(query)) {
125
+ if (v === void 0) continue;
126
+ if (Array.isArray(v)) {
127
+ for (const item of v) sp.append(k, item);
128
+ } else {
129
+ sp.append(k, v);
130
+ }
131
+ }
132
+ const qs = sp.toString();
133
+ return qs ? `?${qs}` : "";
134
+ }
135
+ async function callUpstream(apiBaseUrl, tokenManager, opts, fetchImpl) {
136
+ const base = apiBaseUrl.replace(/\/$/, "");
137
+ const url = `${base}${opts.path}${buildQueryString(opts.query)}`;
138
+ const token = await tokenManager.getAccessToken();
139
+ const headers = {
140
+ Authorization: `Bearer ${token}`,
141
+ Accept: "application/json"
142
+ };
143
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
144
+ if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
145
+ const init = {
146
+ method: opts.method,
147
+ headers
148
+ };
149
+ if (opts.body !== void 0) init.body = JSON.stringify(opts.body);
150
+ const res = await retryFetch(url, init, {
151
+ fetch: fetchImpl,
152
+ timeoutMs: opts.timeoutMs,
153
+ onTokenExpired: async () => {
154
+ await tokenManager.invalidate();
155
+ const fresh = await tokenManager.getAccessToken();
156
+ headers.Authorization = `Bearer ${fresh}`;
157
+ }
158
+ });
159
+ let body = null;
160
+ const text = await res.text();
161
+ if (text.length > 0) {
162
+ try {
163
+ body = JSON.parse(text);
164
+ } catch {
165
+ body = text;
166
+ }
167
+ }
168
+ return {
169
+ status: res.status,
170
+ headers: res.headers,
171
+ body,
172
+ requestId: res.headers.get("x-hellobill-request-id") ?? void 0
173
+ };
174
+ }
175
+ function defaultIdempotencyKey() {
176
+ return crypto.randomUUID();
177
+ }
178
+
179
+ // src/core/errors.ts
180
+ var HellobillApiError = class extends Error {
181
+ status;
182
+ envelope;
183
+ requestId;
184
+ headers;
185
+ constructor(status, envelope, requestId, headers) {
186
+ super(envelope?.error.message ?? `HelloBill API error (${status})`);
187
+ this.name = "HellobillApiError";
188
+ this.status = status;
189
+ this.envelope = envelope;
190
+ this.requestId = requestId;
191
+ this.headers = headers;
192
+ }
193
+ get code() {
194
+ return this.envelope?.error.code;
195
+ }
196
+ };
197
+
198
+ // src/core/token-manager.ts
199
+ var InMemoryTokenStorage = class {
200
+ map = /* @__PURE__ */ new Map();
201
+ async get(clientId) {
202
+ return this.map.get(clientId) ?? null;
203
+ }
204
+ async set(clientId, value) {
205
+ this.map.set(clientId, value);
206
+ }
207
+ };
208
+ var TokenManager = class {
209
+ clientId;
210
+ clientSecret;
211
+ apiBaseUrl;
212
+ fetchImpl;
213
+ storage;
214
+ logger;
215
+ refreshLeewayMs;
216
+ now;
217
+ inflight = null;
218
+ constructor(opts) {
219
+ this.clientId = opts.clientId;
220
+ this.clientSecret = opts.clientSecret;
221
+ this.apiBaseUrl = opts.apiBaseUrl.replace(/\/$/, "");
222
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
223
+ this.storage = opts.storage ?? new InMemoryTokenStorage();
224
+ this.logger = opts.logger;
225
+ this.refreshLeewayMs = opts.refreshLeewayMs ?? 3e4;
226
+ this.now = opts.now ?? Date.now;
227
+ }
228
+ /** Returns a valid bearer token, refreshing if necessary. Single-flight on refresh. */
229
+ async getAccessToken() {
230
+ if (this.inflight) return this.inflight;
231
+ const cached = await this.storage.get(this.clientId);
232
+ if (cached && cached.accessToken && cached.expiresAt - this.refreshLeewayMs > this.now()) {
233
+ return cached.accessToken;
234
+ }
235
+ if (this.inflight) return this.inflight;
236
+ this.inflight = this.fetchToken().finally(() => {
237
+ this.inflight = null;
238
+ });
239
+ return this.inflight;
240
+ }
241
+ /** Forces invalidation of the cached token. Next `getAccessToken` will refresh. */
242
+ async invalidate() {
243
+ await this.storage.set(this.clientId, { accessToken: "", expiresAt: 0 });
244
+ }
245
+ async fetchToken() {
246
+ const body = new URLSearchParams({
247
+ grant_type: "client_credentials",
248
+ client_id: this.clientId,
249
+ client_secret: this.clientSecret
250
+ });
251
+ const res = await this.fetchImpl(`${this.apiBaseUrl}/auth/partner/token`, {
252
+ method: "POST",
253
+ headers: {
254
+ "Content-Type": "application/x-www-form-urlencoded",
255
+ Accept: "application/json"
256
+ },
257
+ body: body.toString()
258
+ });
259
+ if (!res.ok) {
260
+ let envelope = null;
261
+ try {
262
+ envelope = await res.json();
263
+ } catch {
264
+ }
265
+ const requestId = res.headers.get("x-hellobill-request-id") ?? void 0;
266
+ this.logger?.error("hellobill.token_refresh_failed", { status: res.status });
267
+ throw new HellobillApiError(res.status, envelope, requestId, res.headers);
268
+ }
269
+ const json = await res.json();
270
+ const expiresAt = this.now() + json.expires_in * 1e3;
271
+ await this.storage.set(this.clientId, {
272
+ accessToken: json.access_token,
273
+ expiresAt
274
+ });
275
+ this.logger?.debug("hellobill.token_refreshed", { expires_in: json.expires_in });
276
+ return json.access_token;
277
+ }
278
+ };
279
+ var WebhookVerificationError = class extends Error {
280
+ constructor(message, code) {
281
+ super(message);
282
+ this.code = code;
283
+ this.name = "WebhookVerificationError";
284
+ }
285
+ };
286
+ function parseHeader(signatureHeader) {
287
+ if (!signatureHeader || typeof signatureHeader !== "string") {
288
+ throw new WebhookVerificationError(
289
+ "Missing signature header",
290
+ "signature.missing_header"
291
+ );
292
+ }
293
+ const parts = signatureHeader.split(",").map((p) => p.trim()).filter(Boolean);
294
+ let t;
295
+ const signatures = {};
296
+ for (const part of parts) {
297
+ const eq = part.indexOf("=");
298
+ if (eq === -1) {
299
+ throw new WebhookVerificationError(
300
+ "Malformed signature header element",
301
+ "signature.malformed"
302
+ );
303
+ }
304
+ const key = part.slice(0, eq);
305
+ const value = part.slice(eq + 1);
306
+ if (key === "t") {
307
+ t = Number(value);
308
+ if (!Number.isFinite(t)) {
309
+ throw new WebhookVerificationError(
310
+ "Malformed timestamp in signature header",
311
+ "signature.malformed"
312
+ );
313
+ }
314
+ } else if (key.length > 0 && value.length > 0) {
315
+ signatures[key] = value;
316
+ }
317
+ }
318
+ if (t === void 0) {
319
+ throw new WebhookVerificationError(
320
+ "Missing timestamp in signature header",
321
+ "signature.malformed"
322
+ );
323
+ }
324
+ if (Object.keys(signatures).length === 0) {
325
+ throw new WebhookVerificationError(
326
+ "No signature values in header",
327
+ "signature.malformed"
328
+ );
329
+ }
330
+ return { t, signatures };
331
+ }
332
+ function computeHmacHex(secret, payload) {
333
+ return crypto.createHmac("sha256", secret).update(payload).digest("hex");
334
+ }
335
+ function constantTimeHexEqual(a, b) {
336
+ if (a.length !== b.length) return false;
337
+ try {
338
+ return crypto.timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+ function verifyWebhookSignature(rawBody, signatureHeader, secret, opts = {}) {
344
+ const tolerance = opts.tolerance ?? 300;
345
+ const supportedVersions = opts.supportedVersions ?? ["v1"];
346
+ const now = opts.now ?? Date.now;
347
+ let parsed;
348
+ try {
349
+ parsed = parseHeader(signatureHeader);
350
+ } catch (err) {
351
+ if (err instanceof WebhookVerificationError) return false;
352
+ throw err;
353
+ }
354
+ const { t, signatures } = parsed;
355
+ const nowSeconds = Math.floor(now() / 1e3);
356
+ const skew = Math.abs(nowSeconds - t);
357
+ if (skew > tolerance) return false;
358
+ const hasAnyKnownVersion = supportedVersions.some((v) => v in signatures);
359
+ if (!hasAnyKnownVersion) return false;
360
+ const bodyStr = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
361
+ const signingInput = `${t}.${bodyStr}`;
362
+ for (const version of supportedVersions) {
363
+ const provided = signatures[version];
364
+ if (!provided) continue;
365
+ const expected = computeHmacHex(secret, signingInput);
366
+ if (constantTimeHexEqual(expected, provided)) {
367
+ return true;
368
+ }
369
+ }
370
+ return false;
371
+ }
372
+
373
+ // src/webhook/dedupe-store.ts
374
+ var InMemoryWebhookDedupeStore = class {
375
+ // Optional test injection. Defaults to Date.now.
376
+ constructor(now = () => Date.now()) {
377
+ this.now = now;
378
+ }
379
+ store = /* @__PURE__ */ new Map();
380
+ async has(id) {
381
+ const expiresAt = this.store.get(id);
382
+ if (expiresAt === void 0) return false;
383
+ if (expiresAt <= this.now()) {
384
+ this.store.delete(id);
385
+ return false;
386
+ }
387
+ return true;
388
+ }
389
+ async record(id, ttlSeconds) {
390
+ this.store.set(id, this.now() + ttlSeconds * 1e3);
391
+ }
392
+ };
393
+
394
+ // src/webhook/handler.ts
395
+ function assertNever(x) {
396
+ throw new Error(`Unhandled webhook variant: ${JSON.stringify(x)}`);
397
+ }
398
+ var noopLogger = {
399
+ debug: () => {
400
+ },
401
+ info: () => {
402
+ },
403
+ warn: () => {
404
+ },
405
+ error: () => {
406
+ }
407
+ };
408
+ function withInfoFallback(logger) {
409
+ return {
410
+ debug: logger?.debug?.bind(logger) ?? (() => {
411
+ }),
412
+ info: logger?.info?.bind(logger) ?? (() => {
413
+ }),
414
+ warn: logger?.warn?.bind(logger) ?? (() => {
415
+ }),
416
+ error: logger?.error?.bind(logger) ?? (() => {
417
+ })
418
+ };
419
+ }
420
+ function headerValue(headers, name) {
421
+ const lower = name.toLowerCase();
422
+ for (const [k, v] of Object.entries(headers)) {
423
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;
424
+ }
425
+ return void 0;
426
+ }
427
+ async function dispatch(event, ctx, handlers) {
428
+ const logUnhandled = (name) => {
429
+ ctx.logger.info("hellobill.webhook.unhandled_variant", {
430
+ event_id: event.id,
431
+ event_type: event.type,
432
+ handler: name
433
+ });
434
+ };
435
+ switch (event.type) {
436
+ case "session.embed_opened":
437
+ if (handlers.onSessionEmbedOpened) await handlers.onSessionEmbedOpened(event, ctx);
438
+ else logUnhandled("onSessionEmbedOpened");
439
+ return;
440
+ case "session.details_confirmed":
441
+ if (handlers.onSessionDetailsConfirmed) await handlers.onSessionDetailsConfirmed(event, ctx);
442
+ else logUnhandled("onSessionDetailsConfirmed");
443
+ return;
444
+ case "session.products_selected":
445
+ if (handlers.onSessionProductsSelected) await handlers.onSessionProductsSelected(event, ctx);
446
+ else logUnhandled("onSessionProductsSelected");
447
+ return;
448
+ case "session.loa_signed":
449
+ if (handlers.onSessionLoaSigned) await handlers.onSessionLoaSigned(event, ctx);
450
+ else logUnhandled("onSessionLoaSigned");
451
+ return;
452
+ case "session.account_created":
453
+ if (handlers.onSessionAccountCreated) await handlers.onSessionAccountCreated(event, ctx);
454
+ else logUnhandled("onSessionAccountCreated");
455
+ return;
456
+ case "session.app_installed":
457
+ if (handlers.onSessionAppInstalled) await handlers.onSessionAppInstalled(event, ctx);
458
+ else logUnhandled("onSessionAppInstalled");
459
+ return;
460
+ case "session.app_deferred":
461
+ if (handlers.onSessionAppDeferred) await handlers.onSessionAppDeferred(event, ctx);
462
+ else logUnhandled("onSessionAppDeferred");
463
+ return;
464
+ case "subscription.changed":
465
+ if (handlers.onSubscriptionChanged) await handlers.onSubscriptionChanged(event, ctx);
466
+ else logUnhandled("onSubscriptionChanged");
467
+ return;
468
+ case "setup_status.changed":
469
+ if (handlers.onSetupStatusChanged) await handlers.onSetupStatusChanged(event, ctx);
470
+ else logUnhandled("onSetupStatusChanged");
471
+ return;
472
+ case "move_out_notification.sent":
473
+ if (handlers.onMoveOutNotificationSent) await handlers.onMoveOutNotificationSent(event, ctx);
474
+ else logUnhandled("onMoveOutNotificationSent");
475
+ return;
476
+ case "move_out_notification.failed":
477
+ if (handlers.onMoveOutNotificationFailed) await handlers.onMoveOutNotificationFailed(event, ctx);
478
+ else logUnhandled("onMoveOutNotificationFailed");
479
+ return;
480
+ case "bank_details.collected":
481
+ if (handlers.onBankDetailsCollected) await handlers.onBankDetailsCollected(event, ctx);
482
+ else logUnhandled("onBankDetailsCollected");
483
+ return;
484
+ default:
485
+ return assertNever(event);
486
+ }
487
+ }
488
+ function createWebhookHandler(opts) {
489
+ if (opts.webhookSecret == null || opts.webhookSecret === "") {
490
+ throw new Error(
491
+ 'HELLOBILL_WEBHOOK_SECRET must be set; see spec \xA713.2 and the @hello-bill/node README "Webhook receiver" section.'
492
+ );
493
+ }
494
+ const now = opts.now ?? (() => Date.now());
495
+ const dedupeStore = opts.dedupeStore ?? new InMemoryWebhookDedupeStore(now);
496
+ const dedupeTtlSeconds = opts.dedupeTtlSeconds ?? 86400;
497
+ const tolerance = opts.tolerance ?? 300;
498
+ const supportedVersions = opts.supportedVersions ?? ["v1"];
499
+ const handlers = opts.handlers ?? {};
500
+ const safeLogger = withInfoFallback(opts.logger ?? noopLogger);
501
+ return async (req, res) => {
502
+ const rawBody = req.rawBody;
503
+ if (!rawBody) {
504
+ safeLogger.error("hellobill.webhook.missing_raw_body", {});
505
+ res.status(500).json({
506
+ error: {
507
+ type: "api_error",
508
+ code: "server.internal",
509
+ message: "Webhook receiver requires raw body \u2014 adapter not wired correctly"
510
+ }
511
+ });
512
+ return;
513
+ }
514
+ const sigHeader = headerValue(req.headers, "x-hellobill-signature");
515
+ if (!sigHeader) {
516
+ res.status(401).json({ error: "invalid_signature" });
517
+ return;
518
+ }
519
+ const valid = verifyWebhookSignature(rawBody, sigHeader, opts.webhookSecret, {
520
+ tolerance,
521
+ supportedVersions,
522
+ now
523
+ });
524
+ if (!valid) {
525
+ res.status(401).json({ error: "invalid_signature" });
526
+ return;
527
+ }
528
+ let event;
529
+ try {
530
+ event = JSON.parse(rawBody.toString("utf8"));
531
+ } catch {
532
+ res.status(400).json({ error: "invalid_payload" });
533
+ return;
534
+ }
535
+ const seen = await dedupeStore.has(event.id);
536
+ if (seen) {
537
+ safeLogger.info("hellobill.webhook.dedup_hit", { event_id: event.id, event_type: event.type });
538
+ res.status(200).json({ received: true, deduped: true });
539
+ return;
540
+ }
541
+ await dedupeStore.record(event.id, dedupeTtlSeconds);
542
+ const deprecation = headerValue(req.headers, "x-hellobill-deprecation") ?? null;
543
+ if (deprecation) {
544
+ safeLogger.warn("hellobill.webhook.deprecation_notice", {
545
+ deprecation,
546
+ event_id: event.id,
547
+ event_type: event.type
548
+ });
549
+ }
550
+ res.status(200).json({ received: true });
551
+ const ctx = {
552
+ // safeLogger guarantees `info` is defined regardless of partner Logger shape.
553
+ logger: safeLogger,
554
+ headers: req.headers,
555
+ deprecation
556
+ };
557
+ void dispatch(event, ctx, handlers).catch((err) => {
558
+ safeLogger.error("hellobill.webhook.handler_error", {
559
+ event_id: event.id,
560
+ event_type: event.type,
561
+ message: err.message
562
+ });
563
+ });
564
+ };
565
+ }
566
+
567
+ // src/core/handler.ts
568
+ function decodeSessionToken(token) {
569
+ const parts = token.split(".");
570
+ if (parts.length < 2) {
571
+ throw new SessionTokenError("auth.token_malformed", "Session token is malformed");
572
+ }
573
+ let payload;
574
+ try {
575
+ const raw = Buffer.from(parts[1], "base64url").toString("utf8");
576
+ payload = JSON.parse(raw);
577
+ } catch {
578
+ throw new SessionTokenError("auth.token_malformed", "Session token payload unparseable");
579
+ }
580
+ const sessionId = typeof payload.session_id === "string" && payload.session_id.length > 0 ? payload.session_id : typeof payload.sub === "string" && payload.sub.length > 0 ? payload.sub : "";
581
+ if (sessionId === "") {
582
+ throw new SessionTokenError("auth.token_malformed", "Session token missing session_id (or sub)");
583
+ }
584
+ payload.session_id = sessionId;
585
+ return payload;
586
+ }
587
+ var SessionTokenError = class extends Error {
588
+ constructor(code, message) {
589
+ super(message);
590
+ this.code = code;
591
+ }
592
+ };
593
+ function getHeader(req, name) {
594
+ const lower = name.toLowerCase();
595
+ for (const [k, v] of Object.entries(req.headers)) {
596
+ if (k.toLowerCase() === lower) {
597
+ return Array.isArray(v) ? v[0] : v;
598
+ }
599
+ }
600
+ return void 0;
601
+ }
602
+ function extractBearer(req) {
603
+ const auth = getHeader(req, "authorization");
604
+ if (!auth) return void 0;
605
+ const match = /^Bearer\s+(.+)$/i.exec(auth);
606
+ return match?.[1]?.trim();
607
+ }
608
+ function sendError(res, status, code, message, requestId) {
609
+ if (requestId) res.header("X-HelloBill-Request-Id", requestId);
610
+ const type = status === 401 || status === 403 ? "authentication_error" : status === 429 ? "rate_limit_error" : status >= 500 ? "api_error" : "validation_error";
611
+ res.status(status).json({
612
+ error: {
613
+ type,
614
+ code,
615
+ message,
616
+ request_id: requestId ?? ""
617
+ }
618
+ });
619
+ }
620
+ var PASSTHROUGH_HEADER_MAP = {
621
+ "retry-after": "Retry-After",
622
+ "x-ratelimit-limit": "X-RateLimit-Limit",
623
+ "x-ratelimit-remaining": "X-RateLimit-Remaining",
624
+ "x-ratelimit-reset": "X-RateLimit-Reset"
625
+ };
626
+ function passthroughUpstream(res, status, body, requestId, upstreamHeaders) {
627
+ if (requestId) res.header("X-HelloBill-Request-Id", requestId);
628
+ if (upstreamHeaders) {
629
+ for (const [lower, emit] of Object.entries(PASSTHROUGH_HEADER_MAP)) {
630
+ const v = upstreamHeaders.get(lower);
631
+ if (v !== null && v !== void 0 && v !== "") {
632
+ res.header(emit, v);
633
+ }
634
+ }
635
+ }
636
+ res.status(status).json(body);
637
+ }
638
+ function ensureSessionContext(req, res) {
639
+ const token = extractBearer(req);
640
+ if (!token) {
641
+ sendError(res, 401, "auth.token_malformed", "Missing Authorization bearer token");
642
+ return null;
643
+ }
644
+ try {
645
+ const claims = decodeSessionToken(token);
646
+ return { sessionId: claims.session_id };
647
+ } catch (err) {
648
+ const e = err;
649
+ sendError(res, 401, e.code ?? "auth.token_malformed", e.message ?? "Invalid session token");
650
+ return null;
651
+ }
652
+ }
653
+ function inboundIdempotencyKey(req) {
654
+ const v = getHeader(req, "idempotency-key");
655
+ return v && v.trim().length > 0 ? v.trim() : void 0;
656
+ }
657
+ function createHellobillHandler(opts) {
658
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
659
+ const tokenStorage = opts.tokenStorage ?? new InMemoryTokenStorage();
660
+ const idempotencyKeyFactory = opts.idempotencyKeyFactory ?? defaultIdempotencyKey;
661
+ const logger = opts.logger;
662
+ const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? 3e4;
663
+ const tokenManager = new TokenManager({
664
+ clientId: opts.clientId,
665
+ clientSecret: opts.clientSecret,
666
+ apiBaseUrl: opts.apiBaseUrl,
667
+ fetch: fetchImpl,
668
+ storage: tokenStorage,
669
+ logger
670
+ });
671
+ async function safeCallUpstream(res, upstreamOpts) {
672
+ try {
673
+ const result2 = await callUpstream(
674
+ opts.apiBaseUrl,
675
+ tokenManager,
676
+ { ...upstreamOpts, timeoutMs: upstreamOpts.timeoutMs ?? upstreamTimeoutMs },
677
+ fetchImpl
678
+ );
679
+ if (result2.status >= 200 && result2.status < 300 && result2.body !== null && typeof result2.body !== "object") {
680
+ logger?.warn("hellobill.upstream_invalid_response", {
681
+ status: result2.status,
682
+ path: upstreamOpts.path
683
+ });
684
+ sendError(
685
+ res,
686
+ 502,
687
+ "internal.error",
688
+ "Upstream returned a malformed response",
689
+ result2.requestId
690
+ );
691
+ return null;
692
+ }
693
+ return result2;
694
+ } catch (err) {
695
+ if (err instanceof NetworkError) {
696
+ logger?.error("hellobill.upstream_unreachable", {
697
+ kind: err.kind,
698
+ path: upstreamOpts.path
699
+ });
700
+ if (err.kind === "timeout") {
701
+ sendError(res, 504, "internal.error", "Upstream request timed out");
702
+ } else {
703
+ sendError(res, 502, "internal.error", "Upstream request failed");
704
+ }
705
+ return null;
706
+ }
707
+ throw err;
708
+ }
709
+ }
710
+ const session = async (req, res) => {
711
+ let partnerSessionResult = null;
712
+ if (opts.partnerSession) {
713
+ try {
714
+ partnerSessionResult = await opts.partnerSession(req);
715
+ } catch (err) {
716
+ logger?.warn("hellobill.partner_session_resolver_failed", {
717
+ message: err.message
718
+ });
719
+ sendError(res, 500, "internal.error", "Partner session resolver failed");
720
+ return;
721
+ }
722
+ if (partnerSessionResult === null) {
723
+ sendError(res, 401, "auth.invalid_credentials", "Partner session is not established");
724
+ return;
725
+ }
726
+ }
727
+ let payload;
728
+ try {
729
+ payload = await opts.buildSessionPayload(req, partnerSessionResult);
730
+ } catch (err) {
731
+ logger?.warn("hellobill.build_session_payload_failed", {
732
+ message: err.message
733
+ });
734
+ sendError(res, 400, "validation.invalid_field_value", err.message);
735
+ return;
736
+ }
737
+ const result2 = await safeCallUpstream(res, {
738
+ method: "POST",
739
+ path: "/partner/sessions",
740
+ body: payload,
741
+ idempotencyKey: inboundIdempotencyKey(req) ?? idempotencyKeyFactory(),
742
+ logger
743
+ });
744
+ if (!result2) return;
745
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
746
+ };
747
+ const loa = async (req, res) => {
748
+ const ctx = ensureSessionContext(req, res);
749
+ if (!ctx) return;
750
+ const rawProductIds = req.query.product_ids;
751
+ const productIds = Array.isArray(rawProductIds) ? rawProductIds : typeof rawProductIds === "string" ? rawProductIds.split(",").map((s) => s.trim()).filter(Boolean) : [];
752
+ const result2 = await safeCallUpstream(res, {
753
+ method: "GET",
754
+ path: `/partner/sessions/${encodeURIComponent(ctx.sessionId)}/loa`,
755
+ query: { product_ids: productIds.join(",") || void 0 },
756
+ logger
757
+ });
758
+ if (!result2) return;
759
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
760
+ };
761
+ const customers = async (req, res) => {
762
+ const ctx = ensureSessionContext(req, res);
763
+ if (!ctx) return;
764
+ const result2 = await safeCallUpstream(res, {
765
+ method: "POST",
766
+ path: `/partner/sessions/${encodeURIComponent(ctx.sessionId)}/customers`,
767
+ body: req.body,
768
+ idempotencyKey: inboundIdempotencyKey(req) ?? idempotencyKeyFactory(),
769
+ logger
770
+ });
771
+ if (!result2) return;
772
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
773
+ };
774
+ const bankDetailsSubmit = async (req, res) => {
775
+ const ctx = ensureSessionContext(req, res);
776
+ if (!ctx) return;
777
+ const result2 = await safeCallUpstream(res, {
778
+ method: "POST",
779
+ path: `/partner/sessions/${encodeURIComponent(ctx.sessionId)}/bank-details`,
780
+ body: req.body,
781
+ idempotencyKey: inboundIdempotencyKey(req) ?? idempotencyKeyFactory(),
782
+ logger
783
+ });
784
+ if (!result2) return;
785
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
786
+ };
787
+ const bankDetailsGet = async (req, res) => {
788
+ const ctx = ensureSessionContext(req, res);
789
+ if (!ctx) return;
790
+ const result2 = await safeCallUpstream(res, {
791
+ method: "GET",
792
+ path: `/partner/sessions/${encodeURIComponent(ctx.sessionId)}/bank-details`,
793
+ logger
794
+ });
795
+ if (!result2) return;
796
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
797
+ };
798
+ const status = async (req, res) => {
799
+ const ctx = ensureSessionContext(req, res);
800
+ if (!ctx) return;
801
+ const rawCustomer = req.query.customer_id;
802
+ const customerId = Array.isArray(rawCustomer) ? rawCustomer[0] : rawCustomer;
803
+ if (!customerId) {
804
+ sendError(res, 400, "validation.missing_required_field", "customer_id query parameter required");
805
+ return;
806
+ }
807
+ const result2 = await safeCallUpstream(res, {
808
+ method: "GET",
809
+ path: `/partner/customers/${encodeURIComponent(customerId)}/status`,
810
+ logger
811
+ });
812
+ if (!result2) return;
813
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
814
+ };
815
+ const products = async (req, res) => {
816
+ const ctx = ensureSessionContext(req, res);
817
+ if (!ctx) return;
818
+ const query = {};
819
+ const keys = ["cursor", "limit", "categories", "include_previous", "force_refresh"];
820
+ for (const k of keys) {
821
+ const v = req.query[k];
822
+ if (Array.isArray(v)) query[k] = v[0];
823
+ else if (typeof v === "string") query[k] = v;
824
+ }
825
+ const result2 = await safeCallUpstream(res, {
826
+ method: "GET",
827
+ path: `/partner/sessions/${encodeURIComponent(ctx.sessionId)}/products`,
828
+ query,
829
+ logger
830
+ });
831
+ if (!result2) return;
832
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
833
+ };
834
+ const property = async (req, res) => {
835
+ const ctx = ensureSessionContext(req, res);
836
+ if (!ctx) return;
837
+ const query = {};
838
+ const rawForce = req.query.force_refresh;
839
+ if (typeof rawForce === "string" && rawForce.length > 0) {
840
+ query.force_refresh = rawForce;
841
+ } else if (Array.isArray(rawForce) && typeof rawForce[0] === "string" && rawForce[0].length > 0) {
842
+ query.force_refresh = rawForce[0];
843
+ }
844
+ const result2 = await safeCallUpstream(res, {
845
+ method: "GET",
846
+ path: `/partner/sessions/${encodeURIComponent(ctx.sessionId)}/property`,
847
+ query,
848
+ logger
849
+ });
850
+ if (!result2) return;
851
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
852
+ };
853
+ const sessionsList = async (req, res) => {
854
+ const query = {};
855
+ for (const [k, v] of Object.entries(req.query)) {
856
+ if (typeof v === "string" && v.length > 0) {
857
+ query[k] = v;
858
+ } else if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) {
859
+ query[k] = v[0];
860
+ }
861
+ }
862
+ const result2 = await safeCallUpstream(res, {
863
+ method: "GET",
864
+ path: "/partner/sessions",
865
+ query,
866
+ logger
867
+ });
868
+ if (!result2) return;
869
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
870
+ };
871
+ const sessionsGet = async (req, res) => {
872
+ const id = typeof req.params?.id === "string" ? req.params.id : void 0;
873
+ if (!id || id.length === 0) {
874
+ sendError(res, 400, "validation.missing_required_field", "sessions/:id requires a non-empty id path param");
875
+ return;
876
+ }
877
+ const result2 = await safeCallUpstream(res, {
878
+ method: "GET",
879
+ path: `/partner/sessions/${encodeURIComponent(id)}`,
880
+ logger
881
+ });
882
+ if (!result2) return;
883
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
884
+ };
885
+ const savingsSummary = async (req, res) => {
886
+ const rawCustomer = req.query.customer_id ?? req.params?.id;
887
+ const customerId = Array.isArray(rawCustomer) ? rawCustomer[0] : rawCustomer;
888
+ if (!customerId) {
889
+ sendError(res, 400, "validation.missing_required_field", "customer_id query parameter required");
890
+ return;
891
+ }
892
+ const result2 = await safeCallUpstream(res, {
893
+ method: "GET",
894
+ path: `/partner/customers/${encodeURIComponent(customerId)}/savings-summary`,
895
+ logger
896
+ });
897
+ if (!result2) return;
898
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
899
+ };
900
+ const anyvanSlots = async (req, res) => {
901
+ const rawSession = req.query.session_id ?? req.params?.id;
902
+ const sessionId = Array.isArray(rawSession) ? rawSession[0] : rawSession;
903
+ if (!sessionId) {
904
+ sendError(res, 400, "validation.missing_required_field", "session_id query parameter required");
905
+ return;
906
+ }
907
+ const result2 = await safeCallUpstream(res, {
908
+ method: "GET",
909
+ path: `/partner/sessions/${encodeURIComponent(sessionId)}/anyvan-slots`,
910
+ logger
911
+ });
912
+ if (!result2) return;
913
+ passthroughUpstream(res, result2.status, result2.body, result2.requestId, result2.headers);
914
+ };
915
+ const webhookHandler = opts.webhook ? createWebhookHandler({ ...opts.webhook, logger: opts.webhook.logger ?? opts.logger }) : void 0;
916
+ const result = {
917
+ session,
918
+ loa,
919
+ bankDetails: { submit: bankDetailsSubmit, get: bankDetailsGet },
920
+ customers,
921
+ status,
922
+ products,
923
+ property,
924
+ sessions: { list: sessionsList, get: sessionsGet },
925
+ savingsSummary,
926
+ anyvanSlots
927
+ };
928
+ if (webhookHandler !== void 0) {
929
+ result.webhook = webhookHandler;
930
+ }
931
+ return result;
932
+ }
933
+ function loadExpress() {
934
+ try {
935
+ const req = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)));
936
+ return req("express");
937
+ } catch {
938
+ throw new Error(
939
+ "@hello-bill/node/express requires the 'express' peer dependency. Install it with `bun add express` or `npm install express`."
940
+ );
941
+ }
942
+ }
943
+ function toHellobillRequest(req) {
944
+ return {
945
+ method: req.method,
946
+ url: req.url,
947
+ query: req.query,
948
+ body: req.body,
949
+ headers: req.headers,
950
+ params: req.params,
951
+ rawBody: req.rawBody
952
+ };
953
+ }
954
+ function toHellobillResponse(res) {
955
+ const wrapped = {
956
+ status(code) {
957
+ res.status(code);
958
+ return wrapped;
959
+ },
960
+ header(name, value) {
961
+ res.setHeader(name, value);
962
+ return wrapped;
963
+ },
964
+ json(body) {
965
+ res.json(body);
966
+ }
967
+ };
968
+ return wrapped;
969
+ }
970
+ function asyncWrap(fn) {
971
+ return (req, res, next) => {
972
+ Promise.resolve().then(() => fn(toHellobillRequest(req), toHellobillResponse(res))).catch(next);
973
+ };
974
+ }
975
+ function captureRaw(req, _res, buf, _encoding) {
976
+ req.rawBody = buf;
977
+ }
978
+ function createHellobillRouter(opts) {
979
+ const express = loadExpress();
980
+ const handlers = createHellobillHandler(opts);
981
+ const router = express.Router();
982
+ if (handlers.webhook) {
983
+ router.post(
984
+ "/webhooks",
985
+ express.raw({ type: "*/*", limit: "1mb", verify: captureRaw }),
986
+ asyncWrap(handlers.webhook)
987
+ );
988
+ }
989
+ router.use(express.json({ limit: "1mb" }));
990
+ router.post("/session", asyncWrap(handlers.session));
991
+ router.get("/loa", asyncWrap(handlers.loa));
992
+ router.post("/bank-details", asyncWrap(handlers.bankDetails.submit));
993
+ router.get("/bank-details", asyncWrap(handlers.bankDetails.get));
994
+ router.post("/customers", asyncWrap(handlers.customers));
995
+ router.get("/status", asyncWrap(handlers.status));
996
+ router.get("/products", asyncWrap(handlers.products));
997
+ router.get("/property", asyncWrap(handlers.property));
998
+ router.get("/sessions", asyncWrap(handlers.sessions.list));
999
+ router.get("/sessions/:id", asyncWrap(handlers.sessions.get));
1000
+ router.use(
1001
+ (err, _req, res, _next) => {
1002
+ opts.logger?.error?.("hellobill.router_error", { message: err?.message });
1003
+ if (res.headersSent) return;
1004
+ res.status(500).json({
1005
+ error: {
1006
+ type: "api_error",
1007
+ code: "internal.error",
1008
+ message: "Internal server error",
1009
+ request_id: "unknown"
1010
+ }
1011
+ });
1012
+ }
1013
+ );
1014
+ return router;
1015
+ }
1016
+
1017
+ exports.createHellobillRouter = createHellobillRouter;
1018
+ //# sourceMappingURL=index.js.map
1019
+ //# sourceMappingURL=index.js.map