@billkit-eu/sdk 0.2.0 → 0.3.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
@@ -8,7 +8,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  Versioning is independent of the Python SDK; the two ship on their own cadence,
9
9
  so the numbers will diverge after this first release.
10
10
 
11
- ## [Unreleased]
11
+ ## [0.3.0]
12
+
13
+ ### Added
14
+ - **Metered pricing below one minor unit.** `prices.create` takes
15
+ `unit_amount_decimal`: a per-unit rate in **minor units** with up to 12
16
+ decimal places, so `"0.02"` (0.02 cents, i.e. EUR 0.0002 per unit) is finally
17
+ expressible. `amount_cents` is an integer and could never say it. Metered
18
+ prices only.
19
+
20
+ It is typed as a `string`, so a `number` is a compile error. A number is also
21
+ refused at runtime with a `TypeError`, for the callers the type system cannot
22
+ reach — plain JavaScript, a value that came through `any`, a parsed JSON body.
23
+ A double cannot hold 0.0002 exactly, so accepting one would work for the rates
24
+ that happen to round-trip and silently mis-price the ones that do not.
25
+ - **Tiered pricing.** `prices.create({ billing_scheme: "tiered", tiers_mode,
26
+ tiers })`, with the new exported `PriceTier` type. `tiers_mode: "graduated"`
27
+ prices the units inside each band; `"volume"` lets the period total pick one
28
+ band which then prices every unit. The same table under the two modes is a
29
+ different bill, so the mode is required rather than defaulted. The last band
30
+ must be `up_to: "inf"`. Each band's `unit_amount_decimal` gets the same
31
+ string-only treatment.
32
+ - **`identifier` on `subscriptions.createUsageRecord`**, for the retry an
33
+ `Idempotency-Key` cannot catch. The key covers a retry of one HTTP request;
34
+ `identifier` covers a retry of *your own* call — a job runner replaying a
35
+ task, a queue delivering twice — which arrives as a genuinely new request with
36
+ a new key. It is unique within the subscription, and a second report of the
37
+ same identifier returns the first record unchanged rather than billing twice.
38
+ If your reporting pipeline is at-least-once, this is the one that matters.
39
+ - **`subscriptions.retrieveUsageSummary(id)`**, the money view of pending usage:
40
+ `pending_quantity`, `net_cents` / `tax_cents` / `gross_cents` computed through
41
+ the same rate or tier table the period close uses, and `will_charge`. Read
42
+ `will_charge` before promising a customer an amount: a period under
43
+ `minimum_charge_cents` (EUR 1.00) is **not** charged, because the provider
44
+ would refuse it, and the usage rolls into the next period instead. Previously
45
+ the only record of that decision was a server log line. `open_invoice_id`
46
+ names an earlier cycle still unsettled.
47
+ - **`refund_on_cancel` on `prices.create`.** Server-side since the `0066`
48
+ migration and unreachable from this SDK until now. `"full"` or `"prorated"`
49
+ issues the refund a cancellation promised without anyone having to remember
50
+ to. Metered prices must leave it at `"none"`.
51
+
52
+ ### Changed
53
+ - `CreatePriceParams.amount_cents` is now **optional**, because a price can be
54
+ priced by `unit_amount_decimal` or by `tiers` instead. Exactly one of the
55
+ three is required, and the server refuses a price with none of them. Existing
56
+ calls are unaffected.
57
+ - `prices.create` is now `async`. It was already `Promise`-returning, but the
58
+ new rate guard throws, and a synchronous throw out of a method typed
59
+ `Promise<T>` escapes `.catch()` — so the throw is delivered as a rejection
60
+ instead, and one error path handles both.
61
+
62
+ ## [0.2.1]
63
+
64
+ ### Changed
65
+ - Documentation only. API keys are now `bk_live_…` / `bk_test_…` and webhook
66
+ signing secrets `bkwhsec_…`; every example here used the previous
67
+ Stripe-shaped `sk_`/`whsec_` spelling. No code in this package changed: it
68
+ never parsed the prefix, it forwards the key as a bearer token.
69
+
70
+ ## [0.2.0]
12
71
 
13
72
  ### Added
14
73
  - `client.prices.update(id, { active: false })` archives a price
@@ -71,7 +130,7 @@ First public release.
71
130
  - **Opt-in logging.** Pass a `logger` to see the request/retry lifecycle:
72
131
 
73
132
  ```ts
74
- const client = new BillKit({ apiKey: "sk_test_...", logger: console });
133
+ const client = new BillKit({ apiKey: "bk_test_...", logger: console });
75
134
  ```
76
135
 
77
136
  Omitted (the default) the SDK is silent: it ships no logger, no transport
package/README.md CHANGED
@@ -97,7 +97,7 @@ The client exposes one accessor per resource family. Each mirrors the verbs from
97
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` (filter by `customer_id`, `status`, `renewal_state`), `cancel`, `pause`, `resume`, `previewUpdate`, `update`, `reauthorizePaymentMethod` |
100
+ | `client.subscriptions` | `retrieve`, `list`, `iter` (filter by `customer_id`, `status`, `renewal_state`), `cancel`, `pause`, `resume`, `reactivate`, `previewUpdate`, `update`, `reauthorizePaymentMethod`, `createUsageRecord`, `listUsageRecords`, `iterUsageRecords`, `retrieveUsageSummary` |
101
101
  | `client.refunds` | `create`, `retrieve`, `list`, `iter` |
102
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`) |
@@ -143,6 +143,73 @@ const paused = await client.subscriptions.list({ renewal_state: "paused" });
143
143
 
144
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
145
 
146
+ ### Metered billing
147
+
148
+ A metered price charges for what was consumed. You report usage; at each period close BillKit invoices the period's total and charges the stored mandate.
149
+
150
+ There are three ways to price a unit, and a price uses exactly one of them.
151
+
152
+ ```ts
153
+ // 1. Whole minor units: 5 cents per unit.
154
+ await client.prices.create({
155
+ product_id: product.id, amount_cents: 5,
156
+ currency: "EUR", interval: "month", usage_type: "metered",
157
+ });
158
+
159
+ // 2. Finer than a minor unit. "0.02" is 0.02 CENTS, i.e. EUR 0.0002 per unit:
160
+ // the canonical per-API-call price, which no integer can express.
161
+ await client.prices.create({
162
+ product_id: product.id, unit_amount_decimal: "0.02",
163
+ currency: "EUR", interval: "month", usage_type: "metered",
164
+ });
165
+
166
+ // 3. By bands. "graduated" prices the units inside each band; "volume" lets
167
+ // the period total pick one band which then prices every unit. The same
168
+ // table under the two modes is a different bill, so the mode is required.
169
+ await client.prices.create({
170
+ product_id: product.id, currency: "EUR", interval: "month",
171
+ usage_type: "metered", billing_scheme: "tiered", tiers_mode: "graduated",
172
+ tiers: [
173
+ { up_to: 1000, unit_amount: 1 }, // first 1,000 at EUR 0.01
174
+ { up_to: "inf", unit_amount_decimal: "0.5" }, // then EUR 0.005
175
+ ],
176
+ });
177
+ ```
178
+
179
+ `amount_cents` is optional for that reason. Send none of the three and the server refuses the price.
180
+
181
+ **`unit_amount_decimal` is a `string`, and a `number` will not compile.** A JS number is an IEEE-754 double and cannot hold 0.0002 exactly, so accepting one would work for the rates that happen to round-trip and silently mis-price the ones that do not. A number that reaches it anyway (plain JavaScript, a value through `any`, a parsed body) is rejected with a `TypeError` before the request goes out. The same applies to a band's `unit_amount_decimal`.
182
+
183
+ The rate is in **minor units**, so `"0.02"` is two hundredths of a cent, not two cents. The period's whole quantity is multiplied by the rate and rounded once, at the invoice, so a sub-cent rate loses nothing per record.
184
+
185
+ The last band must be `up_to: "inf"`, because a bounded top band cannot price the usage above it. Write a free band as `unit_amount: 0`. Metered prices must use `interval: "month"`, cannot have `trial_days`, and cannot set `refund_on_cancel`.
186
+
187
+ #### Reporting usage exactly once
188
+
189
+ ```ts
190
+ await client.subscriptions.createUsageRecord(sub.id, {
191
+ quantity: 1200,
192
+ identifier: "job-2026-09-19T10:00Z", // your id for what you are metering
193
+ });
194
+ ```
195
+
196
+ Two dedupe mechanisms, and they cover different failures. The `Idempotency-Key` the SDK sends covers a retry of *that HTTP request*, including its own internal retries. `identifier` covers a retry of *your* call — a job runner replaying a task, a queue delivering twice, your code re-invoking after its own timeout — which reaches the API as a genuinely new request with a new key. A second report of the same identifier returns the first record unchanged rather than billing twice. If your reporting pipeline is at-least-once, `identifier` is the one that matters.
197
+
198
+ Records are immutable once written: they are the audit trail behind an invoice line, so there is no update or delete.
199
+
200
+ #### Knowing what the next invoice will be
201
+
202
+ ```ts
203
+ const summary = await client.subscriptions.retrieveUsageSummary<{
204
+ pending_quantity: number;
205
+ gross_cents: number;
206
+ will_charge: boolean;
207
+ minimum_charge_cents: number;
208
+ }>(sub.id);
209
+ ```
210
+
211
+ Check `will_charge` before you promise a customer an amount. A period whose total is under `minimum_charge_cents` (EUR 1.00) is **not** charged, because the payment provider would refuse it. The usage is not lost: it stays pending and rolls into the next period, which is then billed for both. `net_cents` / `tax_cents` / `gross_cents` are computed through the same rate or tier table and the same VAT resolution the close itself uses, so this is a forecast of the real invoice rather than an estimate. `open_invoice_id` names an earlier cycle that is invoiced and still unsettled; while one is open, this period cannot be charged.
212
+
146
213
  ### Embedded checkout
147
214
 
148
215
  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.
@@ -193,7 +260,7 @@ for await (const row of client.auditLogs.iter({ actor_id: "act_1" })) {
193
260
  import { BillKit } from "@billkit-eu/sdk";
194
261
 
195
262
  const client = new BillKit({
196
- apiKey: "sk_test_...", // or set BILLKIT_API_KEY
263
+ apiKey: "bk_test_...", // or set BILLKIT_API_KEY
197
264
  baseUrl: "https://api.billkit.eu", // override for self-hosted
198
265
  timeoutMs: 30_000,
199
266
  retryPolicy: {
@@ -259,7 +326,7 @@ The status is the field the API cannot get wrong. Requests that never reach a ro
259
326
  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:
260
327
 
261
328
  ```ts
262
- const client = new BillKit({ apiKey: "sk_test_...", logger: console });
329
+ const client = new BillKit({ apiKey: "bk_test_...", logger: console });
263
330
  ```
264
331
 
265
332
  ```
@@ -285,7 +352,7 @@ If your logger takes context first, wrap it:
285
352
 
286
353
  ```ts
287
354
  const client = new BillKit({
288
- apiKey: "sk_test_...",
355
+ apiKey: "bk_test_...",
289
356
  logger: {
290
357
  debug: (m, c) => pino.debug(c, m),
291
358
  warn: (m, c) => pino.warn(c, m),
package/dist/index.cjs CHANGED
@@ -37,6 +37,22 @@ function splitIdempotency(params) {
37
37
  const { idempotencyKey, ...rest } = params;
38
38
  return { body: dropUndefined(rest), idempotencyKey };
39
39
  }
40
+ function assertDecimalRateIsString(value, field) {
41
+ if (value === void 0 || value === null || typeof value === "string") return;
42
+ throw new TypeError(
43
+ `${field} must be a string, not a ${typeof value}. A JavaScript number cannot hold a rate like 0.0002 exactly, so it would be corrupted before it was ever multiplied by a quantity. Pass it as a string: "${String(value)}".`
44
+ );
45
+ }
46
+ function assertPriceRatesAreStrings(params) {
47
+ assertDecimalRateIsString(params.unit_amount_decimal, "unit_amount_decimal");
48
+ (params.tiers ?? []).forEach((tier, index) => {
49
+ assertDecimalRateIsString(
50
+ tier?.unit_amount_decimal,
51
+ `tiers[${index}].unit_amount_decimal`
52
+ );
53
+ });
54
+ return params;
55
+ }
40
56
  var BaseResource = class {
41
57
  constructor(t) {
42
58
  this.t = t;
@@ -100,6 +116,16 @@ var Customers = class extends BaseResource {
100
116
  delete(id, params = {}) {
101
117
  return this.del(`/v1/customers/${id}`, params);
102
118
  }
119
+ /**
120
+ * List customers, newest first.
121
+ *
122
+ * `provisional` filters on whether the customer ever completed a
123
+ * payment. A checkout that captures an email commits its Customer
124
+ * before the charge, so a checkout nobody finished leaves a row behind:
125
+ * pass `false` for real customers only, `true` for the abandoned ones
126
+ * (the cart-recovery worklist), or omit for both. Abandoned rows are
127
+ * swept after the tenant's retention window.
128
+ */
103
129
  list(params = {}) {
104
130
  return this.get("/v1/customers", params);
105
131
  }
@@ -156,9 +182,23 @@ var Products = class extends BaseResource {
156
182
  }
157
183
  };
158
184
  var Prices = class extends BaseResource {
159
- /** Create immutable billing terms for an existing Product. */
160
- create(params) {
161
- return this.post("/v1/prices", params);
185
+ /**
186
+ * Create immutable billing terms for an existing Product.
187
+ *
188
+ * A licensed price sends `amount_cents`. A metered price sends one of
189
+ * `amount_cents`, `unit_amount_decimal` (a rate finer than one minor
190
+ * unit, as a string) or `billing_scheme: "tiered"` with `tiers` and
191
+ * `tiers_mode`. Throws `TypeError` before any HTTP call if a decimal
192
+ * rate arrives as a number — see
193
+ * {@link CreatePriceParams.unit_amount_decimal}.
194
+ */
195
+ // `async` on purpose. The rate guard throws, and a synchronous throw out
196
+ // of a method typed `Promise<T>` escapes `.catch()` entirely — the caller
197
+ // would have to wrap the call site in try/catch as well, which nobody
198
+ // does for a promise-returning API. Marking it async turns the throw into
199
+ // a rejection, so one error path handles both.
200
+ async create(params) {
201
+ return this.post("/v1/prices", assertPriceRatesAreStrings(params));
162
202
  }
163
203
  retrieve(id) {
164
204
  return this.get(`/v1/prices/${id}`);
@@ -279,13 +319,16 @@ var Subscriptions = class extends BaseResource {
279
319
  *
280
320
  * Only valid when the subscription's price is `usage_type:
281
321
  * "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.
322
+ * parameter_invalid`. Records accumulate until the next period close
323
+ * rolls them into one invoice line; the record's `invoice_id` stays
324
+ * `null` until then. Records are immutable once written — they are the
325
+ * audit trail behind that line — so there is no update or delete.
285
326
  *
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.
327
+ * Two dedupe mechanisms, covering different failures. The
328
+ * `Idempotency-Key` the SDK sends covers a retry of this HTTP request,
329
+ * including its own internal retries. `params.identifier` covers a
330
+ * retry of *your* call, which arrives as a new request with a new key.
331
+ * See {@link CreateUsageRecordParams.identifier}.
289
332
  */
290
333
  createUsageRecord(id, params) {
291
334
  return this.post(`/v1/subscriptions/${id}/usage_records`, params);
@@ -307,6 +350,27 @@ var Subscriptions = class extends BaseResource {
307
350
  filters: { invoice_id: options.invoice_id }
308
351
  });
309
352
  }
353
+ /**
354
+ * Price the pending usage, before the period close bills it.
355
+ *
356
+ * `listUsageRecords({ invoice_id: "pending" })` gives the quantity; this
357
+ * gives the money. `net_cents` / `tax_cents` / `gross_cents` are
358
+ * computed through the same rate or tier table and the same VAT
359
+ * resolution the close itself uses, so it is a forecast of the real
360
+ * invoice rather than an estimate.
361
+ *
362
+ * **Read `will_charge` before promising a customer an amount.** A period
363
+ * whose total is under `minimum_charge_cents` (EUR 1.00) is not charged,
364
+ * because the payment provider would refuse it. The usage is not lost:
365
+ * it stays pending and rolls into the next period, which is then billed
366
+ * for both.
367
+ *
368
+ * `open_invoice_id` names an earlier cycle that is invoiced and still
369
+ * unsettled; while one is open, this period cannot be charged.
370
+ */
371
+ retrieveUsageSummary(id) {
372
+ return this.get(`/v1/subscriptions/${id}/usage_summary`);
373
+ }
310
374
  };
311
375
  var Refunds = class extends BaseResource {
312
376
  create(params) {
@@ -365,7 +429,7 @@ var WebhookEndpoints = class extends BaseResource {
365
429
  delete(id, params = {}) {
366
430
  return this.del(`/v1/webhook_endpoints/${id}`, params);
367
431
  }
368
- /** Rotate the signing secret. The new `whsec_...` is returned once. */
432
+ /** Rotate the signing secret. The new `bkwhsec_...` is returned once. */
369
433
  rotateSecret(id, params = {}) {
370
434
  return this.postEmpty(`/v1/webhook_endpoints/${id}/rotate_secret`, params);
371
435
  }
@@ -733,7 +797,7 @@ function sleep(ms) {
733
797
  }
734
798
 
735
799
  // src/version.ts
736
- var VERSION = "0.2.0";
800
+ var VERSION = "0.3.0";
737
801
 
738
802
  // src/transport.ts
739
803
  var DEFAULT_BASE_URL = "https://api.billkit.eu";