@billkit-eu/sdk 0.2.1 → 0.4.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,6 +8,72 @@ 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
+ ## [0.4.0]
12
+
13
+ ### Added
14
+ - **`creditNotes`** — `retrieve`, `list`, `iter` and `retrievePdf`. A credit
15
+ note is the document that reverses an issued invoice; one is created for you
16
+ when a refund settles, so there is no `create` here. `list` takes
17
+ `invoice_id` to answer "was this sale credited, and by how much".
18
+ - **`invoices.void(id)`** — records that an invoice was never owed. It keeps
19
+ its number and stays readable; it just stops being a receivable.
20
+
21
+ A **paid** invoice is refused with a `ConflictError` whose `code` is
22
+ `"invoice_not_voidable"`. Once the money has moved, "never owed" is not
23
+ true — refund the payment instead, and a credit note is issued when the
24
+ refund settles. Voiding twice is a no-op.
25
+
26
+ ## [0.3.0]
27
+
28
+ ### Added
29
+ - **Metered pricing below one minor unit.** `prices.create` takes
30
+ `unit_amount_decimal`: a per-unit rate in **minor units** with up to 12
31
+ decimal places, so `"0.02"` (0.02 cents, i.e. EUR 0.0002 per unit) is finally
32
+ expressible. `amount_cents` is an integer and could never say it. Metered
33
+ prices only.
34
+
35
+ It is typed as a `string`, so a `number` is a compile error. A number is also
36
+ refused at runtime with a `TypeError`, for the callers the type system cannot
37
+ reach — plain JavaScript, a value that came through `any`, a parsed JSON body.
38
+ A double cannot hold 0.0002 exactly, so accepting one would work for the rates
39
+ that happen to round-trip and silently mis-price the ones that do not.
40
+ - **Tiered pricing.** `prices.create({ billing_scheme: "tiered", tiers_mode,
41
+ tiers })`, with the new exported `PriceTier` type. `tiers_mode: "graduated"`
42
+ prices the units inside each band; `"volume"` lets the period total pick one
43
+ band which then prices every unit. The same table under the two modes is a
44
+ different bill, so the mode is required rather than defaulted. The last band
45
+ must be `up_to: "inf"`. Each band's `unit_amount_decimal` gets the same
46
+ string-only treatment.
47
+ - **`identifier` on `subscriptions.createUsageRecord`**, for the retry an
48
+ `Idempotency-Key` cannot catch. The key covers a retry of one HTTP request;
49
+ `identifier` covers a retry of *your own* call — a job runner replaying a
50
+ task, a queue delivering twice — which arrives as a genuinely new request with
51
+ a new key. It is unique within the subscription, and a second report of the
52
+ same identifier returns the first record unchanged rather than billing twice.
53
+ If your reporting pipeline is at-least-once, this is the one that matters.
54
+ - **`subscriptions.retrieveUsageSummary(id)`**, the money view of pending usage:
55
+ `pending_quantity`, `net_cents` / `tax_cents` / `gross_cents` computed through
56
+ the same rate or tier table the period close uses, and `will_charge`. Read
57
+ `will_charge` before promising a customer an amount: a period under
58
+ `minimum_charge_cents` (EUR 1.00) is **not** charged, because the provider
59
+ would refuse it, and the usage rolls into the next period instead. Previously
60
+ the only record of that decision was a server log line. `open_invoice_id`
61
+ names an earlier cycle still unsettled.
62
+ - **`refund_on_cancel` on `prices.create`.** Server-side since the `0066`
63
+ migration and unreachable from this SDK until now. `"full"` or `"prorated"`
64
+ issues the refund a cancellation promised without anyone having to remember
65
+ to. Metered prices must leave it at `"none"`.
66
+
67
+ ### Changed
68
+ - `CreatePriceParams.amount_cents` is now **optional**, because a price can be
69
+ priced by `unit_amount_decimal` or by `tiers` instead. Exactly one of the
70
+ three is required, and the server refuses a price with none of them. Existing
71
+ calls are unaffected.
72
+ - `prices.create` is now `async`. It was already `Promise`-returning, but the
73
+ new rate guard throws, and a synchronous throw out of a method typed
74
+ `Promise<T>` escapes `.catch()` — so the throw is delivered as a rejection
75
+ instead, and one error path handles both.
76
+
11
77
  ## [0.2.1]
12
78
 
13
79
  ### Changed
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.
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) {
@@ -556,6 +620,46 @@ var Invoices = class extends BaseResource {
556
620
  iter(options = {}) {
557
621
  return paginate((p) => this.get("/v1/invoices", p), { pageSize: options.pageSize });
558
622
  }
623
+ /**
624
+ * Void an invoice: state that the sale was never owed.
625
+ *
626
+ * The invoice keeps its number and stays readable — a gapless series
627
+ * cannot lose a row — and stops being a receivable. Use it for an
628
+ * invoice that should not have been issued.
629
+ *
630
+ * A **paid** invoice is refused with a `ConflictError` whose `code` is
631
+ * `"invoice_not_voidable"`. That is deliberate rather than a
632
+ * limitation: once the money has moved, "never owed" is false, and the
633
+ * document that reverses a real sale is a credit note — refund the
634
+ * payment and one is issued when the refund settles.
635
+ *
636
+ * Idempotent: re-voiding an already-void invoice returns it unchanged.
637
+ */
638
+ void(id, params = {}) {
639
+ return this.post(`/v1/invoices/${id}/void`, params);
640
+ }
641
+ };
642
+ var CreditNotes = class extends BaseResource {
643
+ retrieve(id) {
644
+ return this.get(`/v1/credit_notes/${id}`);
645
+ }
646
+ /**
647
+ * Download the rendered credit note PDF as raw bytes. Same storage
648
+ * split as {@link Invoices.retrievePdf}: bytes inline or a followed
649
+ * `302`, and `501 rendering_pending` on a deployment with no renderer.
650
+ */
651
+ retrievePdf(id) {
652
+ return this.t.requestBinary({ method: "GET", path: `/v1/credit_notes/${id}/pdf` });
653
+ }
654
+ list(params = {}) {
655
+ return this.get("/v1/credit_notes", params);
656
+ }
657
+ iter(options = {}) {
658
+ return paginate((p) => this.get("/v1/credit_notes", p), {
659
+ pageSize: options.pageSize,
660
+ filters: { invoice_id: options.invoice_id, customer_id: options.customer_id }
661
+ });
662
+ }
559
663
  };
560
664
  var AuditLogs = class extends BaseResource {
561
665
  retrieve(id) {
@@ -733,7 +837,7 @@ function sleep(ms) {
733
837
  }
734
838
 
735
839
  // src/version.ts
736
- var VERSION = "0.2.1";
840
+ var VERSION = "0.4.0";
737
841
 
738
842
  // src/transport.ts
739
843
  var DEFAULT_BASE_URL = "https://api.billkit.eu";
@@ -967,6 +1071,7 @@ var BillKit = class {
967
1071
  coupons;
968
1072
  taxRates;
969
1073
  invoices;
1074
+ creditNotes;
970
1075
  auditLogs;
971
1076
  payments;
972
1077
  billingPortalSessions;
@@ -989,6 +1094,7 @@ var BillKit = class {
989
1094
  this.coupons = new Coupons(transport);
990
1095
  this.taxRates = new TaxRates(transport);
991
1096
  this.invoices = new Invoices(transport);
1097
+ this.creditNotes = new CreditNotes(transport);
992
1098
  this.auditLogs = new AuditLogs(transport);
993
1099
  this.payments = new Payments(transport);
994
1100
  this.billingPortalSessions = new BillingPortalSessions(transport);