@billkit-eu/sdk 0.1.0 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -10,6 +10,37 @@ so the numbers will diverge after this first release.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ### Added
14
+ - `client.prices.update(id, { active: false })` archives a price
15
+ (`POST /v1/prices/{id}`). The price keeps its id and stays readable through
16
+ `retrieve()` and `list()`, because subscriptions renew against it by id.
17
+ Subscriptions already on it keep renewing; what stops is new business.
18
+ Re-archiving is a no-op that returns the price unchanged, so a retry is safe.
19
+ `active` is the only field a price accepts and `active: true` is refused,
20
+ because prices are immutable.
21
+ - `client.subscriptions.list()` and `.iter()` now take `customer_id`, `status`
22
+ and `renewal_state` through the new `SubscriptionsListParams`, and `iter()`
23
+ carries the filter onto every page request instead of narrowing client-side.
24
+ Both filters take a comma-separated list.
25
+
26
+ ### Removed
27
+ - `delete()` on `products`, `prices`, `coupons`, `taxRates` and
28
+ `webhookEndpoints`. None of them deleted anything: every one of those rows
29
+ stays readable afterwards, which is why they have to. Retire them through the
30
+ update route instead — `active: false` for products, prices, tax rates and
31
+ coupons, `status: "disabled"` for webhook endpoints. The server no longer
32
+ answers `DELETE` on those paths at all.
33
+
34
+ ### Changed
35
+ - `client.customers.delete(id)` resolves to `{ id, object: "customer", deleted:
36
+ true }` instead of the customer. The customer leaves the API, so returning a
37
+ body that reads like a live resource said the opposite of what happened.
38
+ - `renewal_state: "paused"` is the way to find paused subscriptions.
39
+ `status: "paused"` is no longer accepted by the API and now raises
40
+ `InvalidRequestError`: pausing sets `renewal_state` and leaves `status` at
41
+ `active`, because the customer has paid for the period they are in. The README
42
+ documents the split between the two filters.
43
+
13
44
  ## [0.1.0]
14
45
 
15
46
  First public release.
package/README.md CHANGED
@@ -93,25 +93,83 @@ The client exposes one accessor per resource family. Each mirrors the verbs from
93
93
  | Accessor | Verbs |
94
94
  | --- | --- |
95
95
  | `client.customers` | `create`, `retrieve`, `update`, `delete`, `list`, `iter` |
96
- | `client.products` | `create`, `retrieve`, `update`, `delete`, `list`, `iter` |
97
- | `client.prices` | `create`, `retrieve`, `list`, `iter` |
96
+ | `client.products` | `create`, `retrieve`, `update` (archive with `active: false`), `list`, `iter` |
97
+ | `client.prices` | `create`, `retrieve`, `update` (archive with `active: false`, restore with `active: true`), `list`, `iter` |
98
98
  | `client.checkoutSessions` | `create`, `retrieve` |
99
99
  | `client.oneShotPayments` | `create`, `retrieve` |
100
- | `client.subscriptions` | `retrieve`, `list`, `iter`, `cancel`, `pause`, `resume`, `previewUpdate`, `update`, `reauthorizePaymentMethod` |
100
+ | `client.subscriptions` | `retrieve`, `list`, `iter` (filter by `customer_id`, `status`, `renewal_state`), `cancel`, `pause`, `resume`, `previewUpdate`, `update`, `reauthorizePaymentMethod` |
101
101
  | `client.refunds` | `create`, `retrieve`, `list`, `iter` |
102
- | `client.webhookEndpoints` | `create`, `retrieve`, `update`, `delete`, `rotateSecret`, `list`, `iter`, `listDeliveries`, `iterDeliveries`, `getDelivery`, `redeliver` |
102
+ | `client.webhookEndpoints` | `create`, `retrieve`, `update` (stop delivery with `status: "disabled"`), `delete`, `rotateSecret`, `list`, `iter`, `listDeliveries`, `iterDeliveries`, `getDelivery`, `redeliver` |
103
103
  | `client.events` | `retrieve`, `list`, `iter` (filter by `type`) |
104
104
  | `client.tenant` | `capabilities`, `portalBranding`, `setPortalBranding`, `rotateProviderCredential` |
105
- | `client.coupons` | `create`, `retrieve`, `update`, `delete`, `validate`, `list`, `iter` |
106
- | `client.taxRates` | `create`, `retrieve`, `update`, `delete`, `list`, `iter` |
107
- | `client.invoices` | `retrieve`, `list`, `iter` |
105
+ | `client.coupons` | `create`, `retrieve`, `update` (withdraw with `active: false`), `validate`, `list`, `iter` |
106
+ | `client.taxRates` | `create`, `retrieve`, `update` (retire with `active: false`), `list`, `iter` |
107
+ | `client.invoices` | `retrieve`, `retrievePdf`, `list`, `iter` |
108
108
  | `client.auditLogs` | `retrieve`, `list`, `iter` (filter by `action`, `resource_type`, `actor_id`) |
109
109
  | `client.payments` | `retrieve`, `list`, `iter` |
110
110
  | `client.billingPortalSessions` | `create`, `revoke` |
111
111
 
112
+ ### Retiring something, and deleting something
113
+
114
+ `delete()` exists on `customers` and `webhookEndpoints`, and it resolves to `{ id, object, deleted: true }` rather than the object: it has left the API, so there is nothing to hand back. A deleted endpoint takes its delivery rows with it, because those are readable only through the endpoint that owns them; the events stay in `client.events`, which is the record of what you were sent.
115
+
116
+ The catalogue is retired through its update route instead, because it stays readable afterwards. A price, a product, a tax rate and a coupon each take `active: false`. Each of them has to survive: subscriptions renew against a price by id, an invoice records the VAT percentage a tax rate produced, and a redeemed coupon is part of what a customer was charged.
117
+
118
+ `status: "disabled"` on a webhook endpoint is the other half of the pair, not a substitute for deleting. It stops delivery and keeps the endpoint, its secret and its history, and it can be turned back on.
119
+
120
+ ```ts
121
+ // Stop selling a price. It stays readable; customers on it keep renewing.
122
+ await client.prices.update(price.id, { active: false });
123
+ // ...and put it back. The amount never moved.
124
+ await client.prices.update(price.id, { active: true });
125
+
126
+ // Stop sending to an endpoint, without losing its signing secret.
127
+ await client.webhookEndpoints.update(endpoint.id, { status: "disabled" });
128
+ // Remove one entirely, along with its delivery rows.
129
+ await client.webhookEndpoints.delete(endpoint.id); // → { deleted: true, ... }
130
+
131
+ // Remove a customer. Refused while they hold a subscription that can
132
+ // still charge them.
133
+ await client.customers.delete(customer.id); // → { deleted: true, ... }
134
+ ```
135
+
136
+ ### Finding paused subscriptions
137
+
138
+ `status` and `renewal_state` answer different questions, and only one of them knows about pausing. `status` is where the subscription stands with its payments (`incomplete`, `trialing`, `active`, `past_due`, `canceled`). `renewal_state` is what happens when the current period ends (`auto_renew`, `paused`, `canceling`, `stopped`). Pausing sets `renewal_state` and leaves `status` at `active`, because the customer has paid for the period they are in:
139
+
140
+ ```ts
141
+ const paused = await client.subscriptions.list({ renewal_state: "paused" });
142
+ ```
143
+
144
+ `status: "paused"` is not an accepted value and comes back as `InvalidRequestError`. Both filters take a comma-separated list (`status: "active,past_due"`), and an unrecognised value is rejected rather than silently ignored.
145
+
146
+ ### Embedded checkout
147
+
148
+ Pass `ui_mode: "embedded"` and the session comes back with a `client_secret` instead of a `url`. Hand that to [`@billkit-eu/js`](https://www.npmjs.com/package/@billkit-eu/js) or [`@billkit-eu/react`](https://www.npmjs.com/package/@billkit-eu/react) and the card fields render on your own page, inside a cross-origin iframe. `cancel_url` is still required.
149
+
150
+ ```ts
151
+ const session = await client.checkoutSessions.create<{ client_secret: string }>({
152
+ customer_id: customer.id,
153
+ price_id: price.id,
154
+ ui_mode: "embedded",
155
+ success_url: "https://app.example.com/success",
156
+ cancel_url: "https://app.example.com/cancel",
157
+ metadata: { order_id: "ord_42" },
158
+ });
159
+ ```
160
+
161
+ ### Invoice PDFs
162
+
163
+ ```ts
164
+ const pdf = await client.invoices.retrievePdf("inv_123");
165
+ await writeFile("invoice.pdf", Buffer.from(pdf));
166
+ ```
167
+
168
+ Returns the raw bytes. S3-backed deployments answer with a redirect to a presigned URL, which is followed transparently under the SDK's own timeout and retry policy, so both storage adapters look the same from here. A deployment with PDF rendering disabled throws a `ServerError` with `code: "rendering_pending"`; `retrieve()` still gives you the structured invoice to render yourself.
169
+
112
170
  ## Auto-pagination
113
171
 
114
- Every list-returning resource ships an `iter()` async iterator that walks the Stripe-shape `has_more` + `starting_after` cursor protocol for you. No more manual cursor loops:
172
+ Every list-returning resource ships an `iter()` async iterator that walks the Stripe-shape `has_more` + `starting_after` cursor protocol for you. Pagination is forward-only — BillKit has no `ending_before` — so page backwards by holding onto the cursors you have already walked.
115
173
 
116
174
  ```ts
117
175
  for await (const customer of client.customers.iter()) {
@@ -153,6 +211,8 @@ const client = new BillKit({
153
211
 
154
212
  The SDK auto-generates an `Idempotency-Key` for every mutating call, so 5xx and short `Retry-After` 429 retries are safe: the server replays the original response when an earlier attempt completed. Pass `idempotencyKey` to coalesce retries across process restarts.
155
213
 
214
+ `409 idempotency_in_progress` is retried too. It means an earlier request carrying the same key is still in flight, which is the one 4xx where giving up is the dangerous answer: that request may already have charged the customer, and the obvious workaround — retry with a *fresh* key — is exactly what turns one charge into two. The retry reuses the original key, so it either loses the race again or replays the first call's result. Every other 409 (`idempotency_key_in_use`, a conflicting subscription state) fails immediately, because retrying can only repeat it.
215
+
156
216
  ## Errors
157
217
 
158
218
  ```ts
@@ -180,6 +240,20 @@ try {
180
240
 
181
241
  All errors inherit from `BillKitError`. Subclasses: `APIConnectionError`, `APIError`, `ServerError`, `AuthenticationError`, `PermissionError`, `ResourceMissingError`, `InvalidRequestError`, `ConflictError`, `RateLimitError`.
182
242
 
243
+ The class is chosen by **HTTP status**, not by the envelope's `type`:
244
+
245
+ | Status | Class |
246
+ |---|---|
247
+ | 401 | `AuthenticationError` |
248
+ | 403 | `PermissionError` |
249
+ | 404 | `ResourceMissingError` |
250
+ | 409 | `ConflictError` |
251
+ | 429 | `RateLimitError` |
252
+ | other 4xx (400, 405, 422, …) | `InvalidRequestError` |
253
+ | 5xx | `ServerError` |
254
+
255
+ The status is the field the API cannot get wrong. Requests that never reach a route handler — an unmatched path, a method the route does not allow — are serialised by the framework as `{"type": "api_error", "code": "unhandled"}` *with a 4xx status*, so mapping on `type` would turn a plain 404 into a `ServerError` and tell you BillKit had broken when the request was at fault. The envelope's `type`, `code` and `param` are all still on the thrown object if you want them.
256
+
183
257
  ## Logging
184
258
 
185
259
  The SDK is **silent by default**. It ships no logger, no transport and no destination, so it can't take over your application's output because it never picks one. Hand it a logger to opt in:
package/dist/index.cjs CHANGED
@@ -87,6 +87,16 @@ var Customers = class extends BaseResource {
87
87
  update(id, params = {}) {
88
88
  return this.post(`/v1/customers/${id}`, params);
89
89
  }
90
+ /**
91
+ * Delete a customer. Resolves to `{ id, object: "customer", deleted:
92
+ * true }`, not the customer.
93
+ *
94
+ * The customer leaves the API: `retrieve()` 404s and they drop out of
95
+ * `list()`. Their payments, invoices and refunds are untouched, and so
96
+ * is their personal data — use {@link Customers.purge} for a GDPR
97
+ * erasure. Refused while they hold a subscription that can still
98
+ * charge them.
99
+ */
90
100
  delete(id, params = {}) {
91
101
  return this.del(`/v1/customers/${id}`, params);
92
102
  }
@@ -126,14 +136,18 @@ var Products = class extends BaseResource {
126
136
  retrieve(id) {
127
137
  return this.get(`/v1/products/${id}`);
128
138
  }
129
- /** Patch mutable Product fields. */
139
+ /**
140
+ * Patch mutable Product fields, or archive it with `active: false`.
141
+ *
142
+ * Archiving is how you stop offering something. The product keeps its
143
+ * id and still comes back from `retrieve()` and `list()`, because what
144
+ * was sold under it has to stay readable, so there is no `delete()`.
145
+ * A checkout against any of its prices is refused from then on, and
146
+ * `active: true` un-archives.
147
+ */
130
148
  update(id, params) {
131
149
  return this.post(`/v1/products/${id}`, params);
132
150
  }
133
- /** Archive a Product. */
134
- delete(id, params = {}) {
135
- return this.del(`/v1/products/${id}`, params);
136
- }
137
151
  list(params = {}) {
138
152
  return this.get("/v1/products", params);
139
153
  }
@@ -149,6 +163,29 @@ var Prices = class extends BaseResource {
149
163
  retrieve(id) {
150
164
  return this.get(`/v1/prices/${id}`);
151
165
  }
166
+ /**
167
+ * Archive a Price so it stops selling, or put it back on sale.
168
+ *
169
+ * `update(id, { active: false })` archives. The price keeps its id and
170
+ * is still returned by `retrieve()` and by `list()`, because
171
+ * subscriptions renew against it by id and what they are charged has to
172
+ * stay readable. Subscriptions already on it keep renewing at it. What
173
+ * stops is new business: a checkout session against the price is
174
+ * refused and it is no longer offered as a plan change.
175
+ *
176
+ * `{ active: true }` undoes that. `active` is the only field because
177
+ * `amount_cents`, `currency` and `interval` are fixed at creation, and
178
+ * since none of them move here neither direction can change what a past
179
+ * charge was made under. To charge something different, create a new
180
+ * price.
181
+ *
182
+ * Sending the value a price already has returns it unchanged and emits
183
+ * no second event, so a retry is safe. Archiving emits
184
+ * `price.archived`; putting one back emits `price.updated`.
185
+ */
186
+ update(id, params) {
187
+ return this.post(`/v1/prices/${id}`, params);
188
+ }
152
189
  list(params = {}) {
153
190
  return this.get("/v1/prices", params);
154
191
  }
@@ -180,11 +217,23 @@ var Subscriptions = class extends BaseResource {
180
217
  retrieve(id) {
181
218
  return this.get(`/v1/subscriptions/${id}`);
182
219
  }
220
+ /**
221
+ * List subscriptions, newest first, optionally filtered.
222
+ *
223
+ * Reach for `renewal_state: "paused"` rather than `status: "paused"`
224
+ * to find paused subscriptions; see `SubscriptionsListParams`.
225
+ */
183
226
  list(params = {}) {
184
227
  return this.get("/v1/subscriptions", params);
185
228
  }
229
+ /**
230
+ * Walk every page of `list()`. Filters are carried onto each page
231
+ * request, so a filtered walk narrows server-side instead of paging
232
+ * the whole history and discarding rows client-side.
233
+ */
186
234
  iter(options = {}) {
187
- return paginate((p) => this.get("/v1/subscriptions", p), { pageSize: options.pageSize });
235
+ const { pageSize, ...filter } = options;
236
+ return paginate((p) => this.get("/v1/subscriptions", { ...filter, ...p }), { pageSize });
188
237
  }
189
238
  cancel(id, params = {}) {
190
239
  return this.postEmpty(`/v1/subscriptions/${id}/cancel`, params);
@@ -225,6 +274,39 @@ var Subscriptions = class extends BaseResource {
225
274
  { idempotencyKey: params.idempotencyKey }
226
275
  );
227
276
  }
277
+ /**
278
+ * Report consumption against a metered subscription.
279
+ *
280
+ * Only valid when the subscription's price is `usage_type:
281
+ * "metered"`; a licensed subscription is rejected with `400
282
+ * parameter_invalid`. Records accumulate until the renewal invoice
283
+ * rolls them up (`amount_cents × sum(quantity)`); the record's
284
+ * `invoice_id` stays `null` until then.
285
+ *
286
+ * Supports `Idempotency-Key` replay: retrying with the same key
287
+ * returns the same record instead of double-counting the usage,
288
+ * which is what makes at-least-once reporting pipelines safe.
289
+ */
290
+ createUsageRecord(id, params) {
291
+ return this.post(`/v1/subscriptions/${id}/usage_records`, params);
292
+ }
293
+ /**
294
+ * List usage records for one subscription.
295
+ *
296
+ * Pass `invoice_id: "pending"` to reconcile what has been reported
297
+ * but not yet billed, or a concrete invoice id to see what that
298
+ * invoice charged for.
299
+ */
300
+ listUsageRecords(id, params = {}) {
301
+ return this.get(`/v1/subscriptions/${id}/usage_records`, params);
302
+ }
303
+ /** Walk every page of `listUsageRecords()` for one subscription. */
304
+ iterUsageRecords(id, options = {}) {
305
+ return paginate((p) => this.get(`/v1/subscriptions/${id}/usage_records`, p), {
306
+ pageSize: options.pageSize,
307
+ filters: { invoice_id: options.invoice_id }
308
+ });
309
+ }
228
310
  };
229
311
  var Refunds = class extends BaseResource {
230
312
  create(params) {
@@ -258,9 +340,28 @@ var WebhookEndpoints = class extends BaseResource {
258
340
  retrieve(id) {
259
341
  return this.get(`/v1/webhook_endpoints/${id}`);
260
342
  }
343
+ /**
344
+ * Update an endpoint, or stop delivery with `status: "disabled"`.
345
+ *
346
+ * Disabling keeps the endpoint, its signing secret and its delivery
347
+ * history, and `status: "enabled"` resumes. Use {@link
348
+ * WebhookEndpoints.delete} when the endpoint should not exist at all:
349
+ * disabling is reversible and deleting is not.
350
+ */
261
351
  update(id, params) {
262
352
  return this.post(`/v1/webhook_endpoints/${id}`, params);
263
353
  }
354
+ /**
355
+ * Delete an endpoint. Resolves to `{ id, object: "webhook_endpoint",
356
+ * deleted: true }`, not the endpoint.
357
+ *
358
+ * A URL registered by mistake should not be a permanent fixture of the
359
+ * account, so this removes it: `retrieve()` 404s afterwards and it is
360
+ * gone from `list()`. Its delivery attempts go with it, because they
361
+ * are readable only through the endpoint that owns them. The events
362
+ * themselves are untouched and still in `client.events`, so what you
363
+ * were sent stays on record.
364
+ */
264
365
  delete(id, params = {}) {
265
366
  return this.del(`/v1/webhook_endpoints/${id}`, params);
266
367
  }
@@ -369,12 +470,17 @@ var Coupons = class extends BaseResource {
369
470
  retrieve(id) {
370
471
  return this.get(`/v1/coupons/${id}`);
371
472
  }
473
+ /**
474
+ * Update a coupon's limits, or withdraw it with `active: false`.
475
+ *
476
+ * A withdrawn code is refused at checkout while the coupon stays
477
+ * readable and discounts already applied keep working out, so there is
478
+ * no `delete()`: a coupon that has been redeemed is part of what a
479
+ * customer was charged. `active: true` brings the campaign back.
480
+ */
372
481
  update(id, params) {
373
482
  return this.post(`/v1/coupons/${id}`, params);
374
483
  }
375
- delete(id, params = {}) {
376
- return this.del(`/v1/coupons/${id}`, params);
377
- }
378
484
  /**
379
485
  * Server-side dry-run of a coupon redemption.
380
486
  *
@@ -401,12 +507,17 @@ var TaxRates = class extends BaseResource {
401
507
  retrieve(id) {
402
508
  return this.get(`/v1/tax_rates/${id}`);
403
509
  }
510
+ /**
511
+ * Correct a rate, retire it with `active: false`, or bring one back.
512
+ *
513
+ * Retiring is how you stop charging VAT in a country. The rate stays
514
+ * readable, because an invoice records the percentage it charged and
515
+ * you have to be able to point at the rate that produced it, so there
516
+ * is no `delete()`.
517
+ */
404
518
  update(id, params) {
405
519
  return this.post(`/v1/tax_rates/${id}`, params);
406
520
  }
407
- delete(id, params = {}) {
408
- return this.del(`/v1/tax_rates/${id}`, params);
409
- }
410
521
  list(params = {}) {
411
522
  return this.get("/v1/tax_rates", params);
412
523
  }
@@ -418,6 +529,27 @@ var Invoices = class extends BaseResource {
418
529
  retrieve(id) {
419
530
  return this.get(`/v1/invoices/${id}`);
420
531
  }
532
+ /**
533
+ * Download the rendered invoice PDF as raw bytes.
534
+ *
535
+ * ```ts
536
+ * const pdf = await client.invoices.retrievePdf("inv_123");
537
+ * await writeFile("invoice.pdf", Buffer.from(pdf));
538
+ * ```
539
+ *
540
+ * Blob-backed deployments stream the bytes inline; S3-backed ones
541
+ * answer `302` to a presigned URL, which `fetch` follows for us under
542
+ * the SDK's own timeout and retry policy — so both storage adapters
543
+ * look identical from here.
544
+ *
545
+ * Deployments with `INVOICE_PDF_ENABLED=false` never render one and
546
+ * answer `501 rendering_pending`, which surfaces as a `ServerError`
547
+ * whose `code` is `"rendering_pending"`; `retrieve()` still returns the
548
+ * structured invoice for tenants who render their own.
549
+ */
550
+ retrievePdf(id) {
551
+ return this.t.requestBinary({ method: "GET", path: `/v1/invoices/${id}/pdf` });
552
+ }
421
553
  list(params = {}) {
422
554
  return this.get("/v1/invoices", params);
423
555
  }
@@ -523,19 +655,6 @@ var RateLimitError = class extends BillKitError {
523
655
  this.retryAfter = options.retryAfter;
524
656
  }
525
657
  };
526
- var TYPE_TO_CLASS = {
527
- api_connection_error: APIConnectionError,
528
- // ``api_error`` is the Stripe-convention type for 5xx, so surface it as
529
- // ServerError (a subclass of APIError) so `catch (e instanceof
530
- // ServerError)` works without false negatives.
531
- api_error: ServerError,
532
- authentication_error: AuthenticationError,
533
- permission_error: PermissionError,
534
- invalid_request_error: InvalidRequestError,
535
- idempotency_error: ConflictError,
536
- conflict: ConflictError,
537
- rate_limit_error: RateLimitError
538
- };
539
658
  function fallbackType(status) {
540
659
  if (status === 401) return "authentication_error";
541
660
  if (status === 403) return "permission_error";
@@ -545,13 +664,13 @@ function fallbackType(status) {
545
664
  if (status >= 500) return "api_error";
546
665
  return "invalid_request_error";
547
666
  }
548
- function fallbackClass(status) {
667
+ function classForStatus(status) {
668
+ if (status >= 500) return ServerError;
549
669
  if (status === 401) return AuthenticationError;
550
670
  if (status === 403) return PermissionError;
551
671
  if (status === 404) return ResourceMissingError;
552
672
  if (status === 409) return ConflictError;
553
673
  if (status === 429) return RateLimitError;
554
- if (status >= 500) return ServerError;
555
674
  return InvalidRequestError;
556
675
  }
557
676
  function errorFromResponse(args) {
@@ -559,9 +678,7 @@ function errorFromResponse(args) {
559
678
  const envelope = typeof body === "object" && body !== null && "error" in body ? body.error ?? {} : {};
560
679
  const type = envelope.type ?? fallbackType(status);
561
680
  const message = envelope.message ?? `BillKit API returned HTTP ${status} with no error body.`;
562
- let cls = TYPE_TO_CLASS[type] ?? fallbackClass(status);
563
- if (status === 404 && cls === InvalidRequestError) cls = ResourceMissingError;
564
- if (status === 409 && cls === InvalidRequestError) cls = ConflictError;
681
+ const cls = classForStatus(status);
565
682
  const options = {
566
683
  type,
567
684
  code: envelope.code,
@@ -600,9 +717,11 @@ function backoffForMs(attempt, policy) {
600
717
  const jittered = capped + (Math.random() * 2 - 1) * jitterRange;
601
718
  return Math.max(0, jittered);
602
719
  }
603
- function shouldRetry(status, attempt, policy, retryAfterMs) {
720
+ var IN_PROGRESS_CODE = "idempotency_in_progress";
721
+ function shouldRetry(status, attempt, policy, retryAfterMs, errorCode) {
604
722
  if (attempt >= policy.maxAttempts) return false;
605
723
  if (status === null) return true;
724
+ if (status === 409) return errorCode === IN_PROGRESS_CODE;
606
725
  if (status === 429) {
607
726
  if (retryAfterMs === void 0 || retryAfterMs < 0) return false;
608
727
  return policy.maxRetryAfterMs === void 0 || retryAfterMs <= policy.maxRetryAfterMs;
@@ -614,7 +733,7 @@ function sleep(ms) {
614
733
  }
615
734
 
616
735
  // src/version.ts
617
- var VERSION = "0.1.0";
736
+ var VERSION = "0.2.0";
618
737
 
619
738
  // src/transport.ts
620
739
  var DEFAULT_BASE_URL = "https://api.billkit.eu";
@@ -664,8 +783,7 @@ function buildHeaders(apiKey, hasBody, idempotencyKey, extra) {
664
783
  }
665
784
  return headers;
666
785
  }
667
- async function parseJson(response) {
668
- const text = await response.text();
786
+ function parseJsonText(text) {
669
787
  if (!text) return null;
670
788
  try {
671
789
  return JSON.parse(text);
@@ -673,6 +791,17 @@ async function parseJson(response) {
673
791
  return null;
674
792
  }
675
793
  }
794
+ async function parseJson(response) {
795
+ return parseJsonText(await response.text());
796
+ }
797
+ async function readBody(response, responseType) {
798
+ if (responseType !== "binary") {
799
+ return { parsed: await parseJson(response), binary: void 0 };
800
+ }
801
+ const buffer = await response.arrayBuffer();
802
+ if (response.ok) return { parsed: null, binary: buffer };
803
+ return { parsed: parseJsonText(new TextDecoder().decode(buffer)), binary: void 0 };
804
+ }
676
805
  function parseRetryAfterMs(header) {
677
806
  if (!header) return void 0;
678
807
  const n = Number.parseFloat(header);
@@ -716,7 +845,21 @@ var Transport = class {
716
845
  }
717
846
  this.fetchFn = fetchFn.bind(globalThis);
718
847
  }
848
+ /**
849
+ * Fetch a binary document (currently only the invoice PDF).
850
+ *
851
+ * Same retry policy, same timeout, same typed errors as
852
+ * {@link Transport.request}; only the success-path decoding differs.
853
+ * `fetch` follows the storage adapter's `302` to the signed URL by
854
+ * itself, and the WHATWG spec drops the `Authorization` header on that
855
+ * cross-origin hop — which is correct, since a presigned URL carries
856
+ * its own credential and must not be handed BillKit's API key.
857
+ */
858
+ requestBinary(options) {
859
+ return this.request({ ...options, responseType: "binary" });
860
+ }
719
861
  async request(options) {
862
+ const responseType = options.responseType ?? "json";
720
863
  const idempotencyKey = autoIdempotencyKey(options.method, options.idempotencyKey);
721
864
  const url = buildUrl(this.baseUrl, options.path, options.query);
722
865
  const headers = buildHeaders(
@@ -738,6 +881,7 @@ var Transport = class {
738
881
  const startedAt = Date.now();
739
882
  let response;
740
883
  let parsedBody;
884
+ let binaryBody;
741
885
  try {
742
886
  const init = {
743
887
  method: options.method,
@@ -746,7 +890,7 @@ var Transport = class {
746
890
  };
747
891
  if (body !== void 0) init.body = body;
748
892
  response = await this.fetchFn(url, init);
749
- parsedBody = await parseJson(response);
893
+ ({ parsed: parsedBody, binary: binaryBody } = await readBody(response, responseType));
750
894
  } catch (err) {
751
895
  lastError = connectionError(err, this.timeoutMs);
752
896
  if (!shouldRetry(null, attempt, this.retryPolicy)) throw lastError;
@@ -770,6 +914,7 @@ var Transport = class {
770
914
  requestId: requestId ?? null
771
915
  });
772
916
  if (response.ok) {
917
+ if (responseType === "binary") return binaryBody;
773
918
  return parsedBody ?? void 0;
774
919
  }
775
920
  const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
@@ -779,7 +924,7 @@ var Transport = class {
779
924
  requestId,
780
925
  retryAfter: retryAfterMs === void 0 ? void 0 : retryAfterMs / 1e3
781
926
  });
782
- if (!shouldRetry(response.status, attempt, this.retryPolicy, retryAfterMs)) {
927
+ if (!shouldRetry(response.status, attempt, this.retryPolicy, retryAfterMs, error.code)) {
783
928
  throw error;
784
929
  }
785
930
  lastError = error;