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