@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 +31 -0
- package/README.md +82 -8
- package/dist/index.cjs +181 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +243 -15
- package/dist/index.d.ts +243 -15
- package/dist/index.js +181 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +28 -22
- package/src/index.ts +3 -0
- package/src/resources.ts +254 -21
- package/src/retry.ts +31 -3
- package/src/transport.ts +58 -4
- package/src/version.ts +1 -1
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
|
|
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
|
|
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
|
|
106
|
-
| `client.taxRates` | `create`, `retrieve`, `update
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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;
|