@crvouga/mockingbird-service-kill-bill
Local emulators. Real API contracts. Part of Mockingbird.
Stateful Kill Bill REST emulator for billing integration tests. It models tenant-scoped accounts, payment methods, subscriptions, bundles, invoices, payments, credits, refunds, retries, catalogs, audit metadata, a test clock, account overdue state, and secret-protected lifecycle webhooks.
Install
npm install -D @crvouga/mockingbird-service-kill-bill
ESM only. Node 22+ or Bun 1.2+.
Usage
import { AccountApi, Configuration, apiKey } from "killbill"
import { createServer } from "@crvouga/mockingbird-service-kill-bill/server"
const emulator = await createServer()
// The Kill Bill client your application already uses. Only its basePath changes.
const config = new Configuration({
basePath: emulator.url,
username: "admin",
password: "password",
apiKey: apiKey("bob", "lazar"),
})
const accounts = new AccountApi(config)
await accounts.createAccount({ name: "Synthetic Customer", externalKey: "customer-1", currency: "USD" }, "example")
const { data: account } = await accounts.getAccountByKey("customer-1")
console.log(account.accountId, account.currency) // acc-… USD
await emulator.close()
The default credentials are Basic auth admin:password plus tenant headers X-Killbill-ApiKey: bob and X-Killbill-ApiSecret: lazar. Mutations also require X-Killbill-CreatedBy; optional reason and comment headers are recorded in the audit journal.
POST /1.0/kb/tenants creates another tenant with Basic auth and X-Killbill-CreatedBy only. Accounts created with that tenant's api key stay hidden from other tenants. POST /1.0/kb/tenants/registerNotificationCallback?cb= stores one push-notification URL for the tenant; invoice and payment events are POSTed there as Kill Bill notification JSON. As in Kill Bill 0.24.16 (measured with a callback against a real server), delivery happens after the request has answered, in order per account: an event raised while the bus is idle waits notificationDelayMs (2000 by default; Kill Bill's bus took 2–3 s), and one raised during or right after a delivery, such as a payment the callback makes, goes out at once (Kill Bill's bus polls again after delivering; ~70 ms measured). GET /1.0/kb/test/queues waits for pending deliveries. A delivery the callback refuses (non-2xx or no answer) is retried 15 minutes, 30 minutes, 2 hours, 12 hours and a day after each failed attempt, when the clock reaches it (a clock move or the next request), then dropped. The in-process onEvent hook still receives events during the request.
GET /1.0/kb/accounts/pagination and GET /1.0/kb/invoices/pagination enumerate the authenticated
tenant's existing billing records as bare arrays. offset defaults to 0 and limit to 100;
positive limits use creation order, and negative limits reverse it. Invoice updates retain their
original position. Responses carry X-Killbill-Pagination-CurrentOffset, TotalNbRecords and
MaxNbRecords. NextOffset and NextPageUri are present only when another page exists. Next-page
URLs retain the selected namespace and configured admin prefix; continue sending Basic auth and
tenant headers. Empty/exhausted pages return []. A zero limit also returns [], as the real SQL
pagination does; clients should stop on an empty page. Account balances default to null; use
accountWithBalance=true or accountWithBalanceAndCBA=true for the live invoice balance/CBA.
These reads use the same collections as invoice creation, payment and voiding, and participate in
the standard namespace, clock, reset, snapshot and fault controls.
The oracle is Kill Bill's account resource, invoice resource, pagination response, and ordering implementation. Counts are exact for the emulator's finite collections; the vendor's large-dataset count heuristic, audit projections and search endpoints are unmodelled. Invalid pagination uses the emulator's existing error envelope; exact live error text remains unverified. Invoice pagination returns invoices without their components (amounts zero, no items), as Kill Bill 0.24.16 does.
Live parity
bun run parity -- [scenario…] [--out <dir>] [--allow-remote] replays fixed request scenarios
(scripts/scenarios.ts) against a Kill Bill you run and a fresh emulator, each in a new tenant it
creates, and diffs the normalized transcripts (ids become placeholders and instants <ts>; dates,
amounts and states compare as they are). KILL_BILL_URL names that Kill Bill (for example
http://127.0.0.1:8080; KILL_BILL_USERNAME/KILL_BILL_PASSWORD default to admin/password);
without it the script exits 2. It writes, so it refuses a non-loopback URL without
--allow-remote.
The scenarios cover not-found answers, catalog errors, renewal invoicing
(POST /1.0/kb/invoices) and its target-date limits, account credit (committed and draft) and its
use, an adjustment of a paid item, seven cancellation shapes, an item-adjusted external refund
with its key lookup and audit logs, a future-dated subscription cancelled before it starts, an
entitlement pause and resume, a billing pause across two renewals, BCD changes under the test
clock (one, two stacked, and on an annual plan), plan changes on annual and semiannual plans
(immediate, on a requested day, at the period end, to the same plan, and with a cancellation
after it), and a renewal-day move (a billing hold, its release and a BCD change). Plan-change
scenarios use a second catalog whose rules leave changes and cancellations at END_OF_TERM and
every alignment at Kill Bill's defaults. All of them matched Kill Bill 0.24.16 (the version
Geviti runs in production) on 2026-11-06.
Controls
POST /__admin/payments/decline-nextand/pending-nextselect the next payment outcome.POST /__admin/payments/:id/retrysucceeds the latest failed or pending transaction.POST /__admin/catalog/plansseeds a plan.GET /__admin/billinginspects billing state and audit entries.GET /__admin/stateis the shared collection view.GET /1.0/kb/test/clockreturns Kill Bill's clock JSON (currentUtcTime,localDate,timeZone).PUT /1.0/kb/test/clock?requestedDate=...advances recurring billing;POST /1.0/kb/test/clockwith norequestedDateresets it to the system time.PUT /1.0/kb/subscriptions/:id/undoChangePlancancels a pending plan change. With no pending change it returns 400 and Kill Bill error code 1071.GET /1.0/kb/test/queues?timeoutSec=returns 200 when no due notification or bus event is outstanding, and 412 after that many seconds when work remains. The default timeout is 5 seconds. A notification counts only when itseffectiveDateis at or before the clock; rows inkb_bus_eventsalways count. Both collections are visible throughGET /__admin/state.- Fault presets cover plugin failure, rate limiting, network loss, and webhook duplicate/reorder/drop delivery.
API
KillBillAPI,KillBillAPIOptions,KillBillEvent: REST handler and event contract.- Account, subscription, bundle, invoice, payment, transaction, and catalog state types.
createRuntime,KillBillRuntime,KillBillRuntimeOptions: full runtime and webhook hub.KILL_BILL_NAMESPACE,KILL_BILL_PRESETS: namespace and fault controls.document,operationIds,supportedOperationIds: generated OpenAPI metadata.createServer,KillBillServerOptions,DEFAULT_PORT,serveTargetfrom./server.
Fidelity boundary
The emulator targets deterministic adapter and lifecycle tests. It does not run Kill Bill plugins, tax engines, databases, notification workers, or production entitlement and dunning algorithms. Invoice payments with externalPayment=true are the exception: they are stored on the built-in __EXTERNAL_PAYMENT__ method and are not sent to a gateway. GET /1.0/kb/invoices/{invoiceId}/payments returns those payments with targetInvoiceId. POST /1.0/kb/invoices/{invoiceId}/payments, external or through a payment method, stops at the invoice balance as Kill Bill 0.24.10 does: with nothing owed it records no payment and answers 204 (a retry with the same paymentExternalKey included), and an amount above the balance is refused with PaymentApiException 7199. An account tagged AUTO_INVOICING_OFF (00000000-0000-0000-0000-000000000002) is not invoiced: subscriptions start with chargedThroughDate null, POST /1.0/kb/invoices answers 201 and invoices nothing, and DELETE /1.0/kb/accounts/{id}/tags?tagDef= (204) of that tag bills what was held at once (parity scenario auto-invoicing-off). Automatic payment of a new invoice skips an account tagged AUTO_PAY_OFF (tag definition 00000000-0000-0000-0000-000000000001) and otherwise pays only the balance still owed when it runs, so a payment a notification consumer records before auto-pay runs is not charged again. Auto-pay runs during the request, so a consumer paying on the delayed INVOICE_CREATION finds nothing owed (204), as with Kill Bill 0.24.16. GET /1.0/kb/invoicePayments/{paymentId} returns that payment plus targetInvoiceId. POST /1.0/kb/invoicePayments/{paymentId}/refunds adds a REFUND on the same payment (Kill Bill 0.24.10). externalPayment=true on that refund records a separate credit payment instead, and leaves the original refundedAmount unchanged. GET /1.0/kb/test/queues only reports whether due notifications and bus events, push notifications included, are still outstanding. A future-dated notification, including an end-of-term plan change whose effective date is still ahead of the clock, does not count. GET /1.0/kb/accounts/{accountId}/overdue uses a fixed Kill Bill 0.24.10 config: the synthetic clear state __KILLBILL__CLEAR__OVERDUE_STATE__, and state OD1 once the earliest unpaid invoice (positive balance, not void) is at least one day old (timeSinceEarliestUnpaidInvoiceEqualsOrExceeds). Uploading a replacement overdue config is not modelled. A subscription priceOverrides entry whose phaseName is {planName}-evergreen (or whose phaseType is EVERGREEN when the name is omitted) replaces the catalog amount on the recurring invoice item, and GET /1.0/kb/subscriptions/{id} returns the overrides that were sent. Other catalog phases are not modelled. POST /1.0/kb/subscriptions answers as Kill Bill 0.24.10 does when the catalog cannot serve the plan: with no catalog uploaded (start the emulator with plans: []) it returns 500 java.lang.IllegalStateException "No existing versions in the VersionedCatalog catalog for input date …", so a client that uploads its catalog on that error can recover, and a plan missing from an uploaded catalog returns 400 SubscriptionBaseApiException code 2021 "Could not find any plans named '…'".
Account, subscription and invoice compatibility additions
The contracts below are best guess, not oracle-verified. They follow the official account API, SubscriptionResource, InvoiceResource, Shiro administrator configuration, and external bus listener. The live parity runner exits 2 because it requires an isolated tenant and is not enabled; exact vendor errors, account-specific permissions, proration and billing transitions were not replayed.
GET /1.0/kb/security/permissionsrequires only the configured Basic credentials and returns["*:*"], modelling the default administrator. Missing or incorrect credentials return 401UNAUTHORIZED. Custom roles and user-specific permission inventories are not modelled.PUT /1.0/kb/accounts/{accountId}updates only the suppliednameandemailfields and returns 204. Null leaves a value unchanged unlesstreatNullAsReset=true. Changed immutable fields return 400INVALID_ACCOUNT; other update fields return the emulator limitationUNSUPPORTED_ACCOUNT_UPDATE. Rejected updates leave the account unchanged.POST /1.0/kb/subscriptions/{subscriptionId}/blockrecords a blocking state and returns 201 (Kill Bill 0.24.16, parity scenarioblock-entitlement). The latest effective state perserviceapplies; any blocking service pauses billing, andisBlockEntitlementmakes the subscription readBLOCKED. Each state addsPAUSE_/RESUME_ENTITLEMENTand_BILLINGevents to the subscription and its bundletimeline.requestedDateoptionally schedules a state. Once a billing pause takes effect it credits the rest of the billed period (aREPAIR_ADJinvoice whoseCBA_ADJbecomes account credit) and the paused renewals bill nothing; a resume bills from its own day to the next billing day, prorated, using that credit first (Kill Bill 0.24.16, parity scenariobilling-pause-resume). A blocking state change re-runs invoicing at once, as Kill Bill does.PUT /1.0/kb/subscriptions/{subscriptionId}/bcdacceptsbillCycleDayLocalfrom 1 through 31 and returns 204 (Kill Bill 0.24.16, parity scenariosbcd-*). Monthly plans renew in calendar months on the billing day, clamped to month-end. FromeffectiveFromDate(today by default) the first period off the new day is a stub up to it, billed on the later of the effective date and the last new billing day on or before the stub: billed on a new billing day it is prorated over that new period (BCD 5 from 01-01 bills 02-01..02-05 on 01-05 at 4/31), otherwise over the month from its start (BCD 5 from 02-01 bills it on 02-01 at 4/28). The subscription reports the new day from the first new billing day on. Semiannual and annual plans follow the same rules over their longer periods. Changes stack, each taking over from its own effective date (a later change with the same date replaces it; parity scenariobcd-twice), and abillCycleDayLocalsent when the subscription is created is its BCD from the start. A billing hold from the period end, released on a later day with the BCD moved to that day, renews a full period from it (parity scenariomove-renewal-day-annual). Retroactive changes returnUNSUPPORTED_BCD.PUT /1.0/kb/subscriptions/{subscriptionId}changes the plan (Kill Bill 0.24.16, parity scenarioschange-*). WithbillingPolicy=IMMEDIATE(the default) the change takes effect today; with only arequestedDate, on that day; withbillingPolicy=END_OF_TERM, on the charged-through date, when the renewal bills the new plan. Taking effect, it bills one invoice: the new plan from that day to its next billing day, the period now falling in that day's month (an annual plan changed on 04-11 bills 04-11..next 04-01 at 355/365), and aREPAIR_ADJfor the unused share of the old plan's billed period, then pays the difference as automatic payment would.priceOverridessent with the change replace the subscription's. Each change is aCHANGEevent from the request on (dated the request instant when immediate),priceslists every plan the subscription has had or is changing to, and events report the plan in effect when they happen. Cancelling drops a change scheduled on or after the cancellation;undoChangePlanandDELETE …/changePlandrop any change not yet in effect. The catalog's own change-policy rules are not read: no policy and no date meansIMMEDIATE.POST /1.0/kb/invoices/dryRun?accountId=...&targetDate=YYYY-MM-DDpreviews the recurring periods due throughtargetDateper active, unblocked subscription without creating invoices, consuming IDs, emitting events, charging payments or advancing billing state. It supportsTARGET_DATEonly; other dry-run types/actions return an explicit error. A due charge returns 200 with invoiceitems; nothing due returns 204. Preview IDs are synthetic. Tax and entitlement-change simulations are not modelled.POST /1.0/kb/invoices?accountId=...&targetDate=YYYY-MM-DDinvoices every period of the account's active subscriptions that starts on or beforetargetDate, without moving the clock, pays it as automatic payment would, and returns 201 with the invoiceLocation. A pending subscription whose start is on or beforetargetDateis billed too. With nothing to invoice it returns Kill Bill 0.24.16's 404 with no entity (Tomcat's HTML page). A target 37 months or more after today is 400InvoiceApiException4005, and more than 15 items for one subscription in a day (previews included) is 400 code 4 "SAFETY BOUND TRIGGERED …" (parity scenariosinvoice-renewal,invoice-target-dates).GET /1.0/kb/paymentTransactions?transactionExternalKey=...returns the payment holding that transaction asPaymentJson, or 404PaymentApiException7020 "Payment … does not exist".- A refund lowers the invoice's
refundAdjby the refund, throughinvoicePaymentsorpayments. WithisAdjustedit addsITEM_ADJitems ("Invoice item adjustment", linked to the requestedadjustments) that lower the amount and leave the balance; without it the refund reopens the balance. An unkeyed payment or transaction is keyed by its own id, except the credit payment of an external refund, which gets a generated key (Kill Bill 0.24.16, parity scenariorefund-adjusted). POST /1.0/kb/creditsadds, per credit, aCREDIT_ADJ(−amount, the given description) and aCBA_ADJ(+amount, "Adjustment (account credit)") on a new invoice,COMMITTEDwithautoCommit=trueandDRAFTotherwise, and answers 200 with theCREDIT_ADJitems (amounts positive).PUT /1.0/kb/invoices/{invoiceId}/commitInvoicecommits aDRAFTand returns 204 (parity scenarioaccount-credit-draft). The account'saccountCBAis thecreditAdjof its committed invoices and itsaccountBalancethe open balance minus that credit. A new invoice uses the credit first ("Adjustment (use of account credit)"), and new credit pays the account's open invoices at once, oldest first, keeping only the rest (parity scenariocredit-pays-open-invoice).DELETE /1.0/kb/subscriptions/{id}: entitlement stops at the period end withentitlementPolicy=END_OF_TERM, now withIMMEDIATE, else onrequestedDate(a day or an instant) or now; billing stops at the period end withbillingPolicy=END_OF_TERMand by default, now withIMMEDIATE, or onrequestedDatewithuseRequestedDateForBilling=true. The subscription readsCANCELLEDfrom the entitlement stop, carriesSTOP_ENTITLEMENTandSTOP_BILLINGevents, and bills nothing from the billing stop. Billing that stops inside a billed period credits its unused share of what the item is still worth after its item adjustments ("Adjustment (subscription change)"; parity scenariocancel-after-item-adjust): aREPAIR_ADJinvoice whoseCBA_ADJbecomes account credit. A requested instant before the subscription started isEntitlementApiException1001.- An unknown account, subscription or invoice id is 404
ObjectDoesNotExist("Object id=… type=ACCOUNT doesn't exist!"); an unknown account external key is 404AccountApiException3003. Kill Bill answers a malformed (non-UUID) id with Tomcat's 404 page; emulator ids are not UUIDs, so the emulator answers every unknown id withObjectDoesNotExist. audit=FULL(orMINIMAL) on a read adds each invoice, invoice item, payment and transaction's audit rows:INSERT(andUPDATEfor payments and transactions) with the writing request'sX-Killbill-CreatedBy,-Comment,-Reason, and itsX-Request-IdasuserToken(generated from the instance's seeded ids when absent). A billing run writes asSubscriptionBaseTransition. Withoutauditevery object carriesauditLogs: [].- A catalog XML upload with an
effectiveDatethe tenant already uploaded is 400CatalogApiException2080 "Invalid catalog for tenant : "; earlier and later dates load. - Subscription, bundle, invoice, invoice item, payment and account reads carry the same fields as
Kill Bill 0.24.16 production responses: subscriptions add
prices,events,billingPeriod,productNameand the otherSubscriptionJsonfields, reportstartDateas the creation instant, key an unkeyed account, subscription and bundle by their ids, list an account's invoices by invoice number, reportchargedThroughDatenull until the first invoice, and readPENDINGuntil a later start (also when cancelled before it). The account invoice list carries amounts and items only withincludeInvoiceComponents=true. POST /1.0/kb/invoices/{invoiceId}adjusts the specifiedinvoiceItemIdby a positiveamount, or its remaining adjustable amount when omitted. It returns 201 and appends a negativeITEM_ADJlinked to the original item, reducing the invoice amount. What it takes beyond the open balance becomes account credit: aCBA_ADJ("Adjustment (account credit)") that keeps the balance at 0 and raisescreditAdj(Kill Bill 0.24.16, parity scenarioitem-adjust-paid). Invalid items, amounts, accounts or currencies fail without changing the invoice; their exact error codes are emulator approximations.- Successful and failed invoice purchases emit
INVOICE_PAYMENT_SUCCESSandINVOICE_PAYMENT_FAILEDalongsidePAYMENT_SUCCESSandPAYMENT_FAILED, withobjectType: "INVOICE",objectIdequal to the invoice ID, and the account ID. Pending payments emit onlyPAYMENT_PENDING. Events use both the in-process hook and registered tenant callbacks.POST /1.0/kb/accounts/{accountId}/invoicePaymentspays full unpaid invoice balances up totargetDate(today by default), returning 201, or 204 when none are due. CustompaymentAmountandpluginPropertyreturnUNSUPPORTED_INVOICE_PAYMENT. Successful admin retries also emit the invoice success event.
Unknown account, subscription and invoice IDs retain their existing 404 error envelopes; tenant isolation applies to these operations.