@dszp/onebill-lib
v0.6.1
Published
Portable, Node-free OneBill toolkit: split read/write API clients and a generic multi-system link codec for the Subscriber externalId field. Runs unchanged in a Cloudflare Worker, Node, or the browser.
Maintainers
Readme
@dszp/onebill-lib
Portable, Node-free toolkit for the OneBill billing and
subscription API: a read-only client, and a generic codec that lets one Subscriber's externalId
field carry links to several other systems at once.
Web APIs only, zero runtime dependencies, injectable fetch — the same built output runs unchanged
in a Cloudflare Worker, in Node, and in a browser.
Install
npm install @dszp/onebill-lib # or pnpm add / yarn addRequires Node 20+ when run under Node. ESM only.
Reading
import { OneBillReadClient } from '@dszp/onebill-lib';
const client = new OneBillReadClient({
tenantId: 'tenant-0000', // Config > Settings > Business Profile
clientSecret: 'secret-0000',
username: '[email protected]',
password: '...', // hashed with SHA-256 before it leaves the process
// baseUrl defaults to OneBill's public endpoint
});
const sub = await client.getSubscriber('CLI00000');
const page = await client.searchSubscribers({ searchBy: 'accountName', searchString: 'Acme' });
const all = await client.listAllSubscribers();
const subs = await client.getSubscriptions('CLI00000');listAllSubscribers() returns active accounts only by default — a safety default, so a bulk job
that iterates and writes cannot reach a closed account by accident. Be aware that this means the
default is not every subscriber: the endpoint filters silently, and a search reporting a confident
total omits every closed account. For read-only work needing completeness, especially reconciliation, pass
{ statuses: SUBSCRIBER_STATUSES }; there is no "all" value in the API, so that walks each status
and merges, de-duplicating by account number.
It also throws rather than truncating if it hits
its page cap. It terminates on a short page rather than on totalCount, which matters more than
it sounds: whether OneBill returns totalCount varies by endpoint (subscribers and orders do; leads,
invoices, and products return only resultSize, the size of the page you just got), and the
published spec is wrong about which. The intuitive "stop when totalCount is missing or satisfied"
rule silently returns one page as the entire result set on half of them.
Holding an OneBillReadClient is proof you cannot write — it has only GET methods, and the transport
underneath it is private and unexported.
Catalogue
import { buildCatalogIndex, catalogLookup } from '@dszp/onebill-lib';
const products = await client.listProducts(); // summaries: id, code, name, status
const detailed = await Promise.all(products.map((p) => client.getProduct(p.code!)));
const catalog = buildCatalogIndex(detailed); // { byPlanName, products, plans }
catalogLookup(catalog, offer.name); // -> { planName, planCode, productCode, productName }A subscription line names only its price plan (subscriptionOffer[].name) — no plan code, no product
code. listProducts names every product but not its plans; getProduct(code) returns one product's
pricePlanInfos. buildCatalogIndex joins the two, keyed by the plan's normalised name — case,
whitespace and dash variants are ignored — so a rule can key on a plan or product code and still
match a line by the only thing it says about itself. A plan with an empty code — a retail plan
supplied by a reseller's upstream — is kept under its name with planCode: ''.
Orders and quotes
A quote in OneBill is an order in a quote state, not a separate object — so one set of methods reads both.
import {
ALL_ORDER_STATE_FILTERS,
ORDER_STATE_FILTERS,
OneBillReadClient,
quotePdfBytes,
} from '@dszp/onebill-lib';
const orders = await client.listAllOrders(); // NO quotes — see below
const quotes = await client.listAllOrders({ states: [ORDER_STATE_FILTERS.QUOTE] });
const every = await client.listAllOrders({ states: ALL_ORDER_STATE_FILTERS });
const quote = await client.getOrder('OR00000'); // line items, charges, tax, discountsThe order search hides quotes, and tells you nothing about it. With no state filter the endpoint
returns every order except quotes, and reports a totalCount for the narrowed set — so the
response looks complete when it is not. This is the same trap as listAllSubscribers defaulting to
active accounts. listAllOrders() keeps the server's behaviour by default and gives you
ALL_ORDER_STATE_FILTERS to opt out of it.
Filtering is also narrower than it looks: only searchBy: 'state' with a numeric searchString has
any effect. orderType, orderStatus, status, orderState, isQuote and friends are all
accepted and then silently ignored, returning the unfiltered set.
Downloading the rendered PDF
const doc = await client.getQuoteDocument('OR00000'); // { pdfBase64, docName }
const bytes = quotePdfBytes(doc); // Uint8Array
await writeFile(`${doc.docName}.pdf`, bytes); // Node
return new Response(bytes, { headers: { 'content-type': 'application/pdf' } }); // WorkerThis is the fully rendered quote — the same PDF OneBill's UI produces, taxes and all — which makes it
the practical input to an e-signature flow. It is genuinely the quote document, titled as one and
carrying an acceptance/signature section, not a generic order printout. The PDF arrives base64-encoded inside the JSON body
rather than as a binary response, so quotePdfBytes decodes via atob and never touches Buffer.
It verifies the %PDF- magic number and throws rather than handing back a corrupt file, because this
API answers HTTP 200 even when something has gone wrong.
A billing-active or pending order can return a PDF too — not because the endpoint renders a quote
template over any order, but because that order came from a quote and kept its document. So a 200
is not evidence that something is currently a quote; use isQuoteOrder(order) for that. It is
evidence that a quote once existed, which is its own useful signal.
Never test an order's status as a string
The two endpoints describe the same order differently, in both the text and the number:
| | search row | single-record read |
|---|---|---|
| Quote Created | state: 1034, "Quote Created" | orderState: "1034", "QuoteCreated" |
| Quote Expired | state: 1034, "Quote Expired" | orderState: **"1007"**, "QuoteExpired" |
The status string loses its space, the state field changes name, its type changes from number to
string, and for an expired quote the value changes too. Code matching orderStatus === 'Quote
Expired' works while iterating a list and silently stops working after a getOrder().
Use the helpers — isQuoteOrder(order) matches the union of states a quote holds in either shape,
and orderStateOf(order) reads whichever field is present and normalizes it to a number. The
de-spacing is not quote-specific: Pending Billing becomes PendingBilling too.
Superseded versions, and orders that never had a quote
A revised quote keeps its order number and gains a document version, so docName reads OR00000-3
for the third revision. Omitting version returns the current one; pass version to retrieve a
superseded revision — which is how you keep an audit trail of what a customer was actually shown.
const current = await client.getQuoteDocument('OR00000'); // newest
const asShown = await client.getQuoteDocument('OR00000', { version: 1 });version is the only parameter that works. quoteVersion, docVersion, revision and
quoteDocName are each accepted and silently ignored, returning the current version — so a caller
guessing the parameter name gets the wrong document with no error.
Most orders have no quote document at all, because an order can be raised without a quote ever
existing — verified across every non-quote state, and a few of the oldest quotes have none either.
Use tryGetQuoteDocument, which returns null instead of throwing, when sweeping:
const doc = await client.tryGetQuoteDocument(order.orderNumber);
if (doc) { /* ... */ }OneBill reports "no such document" as an authentication failure, and it is not one. The response is HTTP 200 carrying
USER_AUTHENTICATION_FAILED … Invalid access token response, with a perfectly good token whose very next request succeeds. Taking it at face value leads straight to re-minting a token on every order that lacks a document — most of them. This library decodes it fromerrorCodeinstead and raisesOneBillNoQuoteDocumentError.
This endpoint is undocumented. It appears nowhere in OneBill's published OpenAPI, which declares
application/jsonfor all 152 of its responses and never mentions PDFs. Treat that spec as a lower bound on what exists.
Linking
OneBill gives each Subscriber one short free-text externalId. That single field is often the only
place to record that a customer is also record X in one system and record Y in another, so this
library packs several links into it:
CRM:4471|PBX:acme.12345.service|PBX:acme.12345.service/Downtown|+2NS:value— a link. The namespace is 1–8 uppercase alphanumerics; the value is opaque.NS:value/qualifier— a link to a sub-unit of that value, for when one customer is billed as several accounts split by site, region, or cost centre.+N— a continuation marker: N further links exist in whatever overflow store you keep.- Order does not matter, and duplicates are collapsed.
No namespace is built in. CRM and PBX above are placeholders — pick your own, and describe
them in a NamespaceRegistry if you want them validated.
import { parseExternalId, upsertLink, formatExternalId } from '@dszp/onebill-lib';
const links = parseExternalId(sub.externalId);
const next = upsertLink(links, { ns: 'PBX', value: 'acme.12345.service', qualifier: 'Downtown' });
const value = formatExternalId(next); // throws OneBillLinkTooLongError if it won't fitParsing never discards anything
A token the codec cannot interpret is preserved verbatim in ParsedLinks.unknown and written back
out unchanged. The field is hand-edited in OneBill's UI, so a round trip that dropped an
unrecognized value would destroy real data on the next automated write.
The length limit
OneBill's UI caps externalId at 64 characters. formatExternalId refuses to exceed it and throws
OneBillLinkTooLongError, which names exactly which links did not fit so you can move them to an
overflow store and record a +N marker. Length is measured in code points: verified live, a
40-character 77-byte value is accepted and a 100-character one is rejected outright with
10PA1166, leaving the previous value intact — so there is no truncation to defend against.
Writing
import { OneBillWriteClient } from '@dszp/onebill-lib';
const writer = new OneBillWriteClient({ /* same config */ });
const result = await writer.setSubscriberExternalId('CLI00000', 'CRM:4471|PBX:acme.12345.service');
result.stored; // read back from the API, not assumed
result.collateral; // unrelated fields that moved — expected to be empty, worth checkingWrites are a separate class from reads, so holding an OneBillReadClient stays proof you cannot
write. Three behaviours here are not caution but necessity, all verified against a live tenant:
- It always reads first and PUTs the whole record back. A partial PUT of
{externalId}alone returns 200, sets the field, and wipes unrelated fields. A full read-modify-write changes only what you asked for. - It reads back and verifies. A write that cannot be proven did not happen. Pass
{strict: true}to also fail when unrelated fields moved. - Clearing needs a special request. OneBill reads an empty value,
null, or a space as "not provided" and keeps the old value. Passing''therefore sends the field in afieldsToRemovearray — the same channel OneBill's own web UI uses — and then verifies the field really is empty.
{dryRun: true} reads and reports what would be written without sending anything.
Links as first-class records
OneBill can hold links in custom-field groups — a repeating group per system, with a field for
its identifier and optionally one for a sub-unit. That is the same information externalId carries,
but structured, editable by a human, and free of the 64-character ceiling. So the groups hold the
truth and externalId becomes a derived index of them: cheap to read in bulk (it rides the search
rows, which attributes do not) and server-side prefix-searchable.
setSubscriberLinks writes both halves in one request, so they cannot drift:
const MAPPING = [
{ group: 'PBX', ns: 'NS', valueField: 'Domain', qualifierField: 'Site' },
{ group: 'PSA', ns: 'AT', valueField: 'PSA ID' },
];
const res = await writer.setSubscriberLinks('CLI00000', [
{ ns: 'NS', value: 'acme.12345.service', qualifier: 'Downtown' },
{ ns: 'AT', value: '4471' },
], MAPPING);
res.created; res.updated; res.unchanged;
res.notRemoved; // links already there that you did NOT request — left alone
res.externalId; // derived from the result and written in the same PUTWhich group means which namespace is your configuration, like the namespace registry — the library ships no mapping.
By default this adds and updates but never removes, so the record can hold more than you passed;
notRemoved tells you. Pass { removeUnlisted: true } to make the record match your input.
{ dryRun: true } reports the whole plan, including the externalId it would derive, without
sending anything.
Indexing
import { buildLinkIndex, findByValue, findByTarget } from '@dszp/onebill-lib';
const index = buildLinkIndex(await client.listAllSubscribers(), { ns: 'PBX' });
findByValue(index, 'acme.12345.service'); // every account billing for it, any sub-unit
findByTarget(index, 'acme.12345.service', 'Downtown'); // just that sub-unit
index.unlinked; // accounts with no link yet — the work list
index.conflicts; // targets claimed by more than one account, reported not resolved
index.problems; // accounts whose externalId did not fully parsebuildLinkIndex is a pure function over an array — it fetches nothing, so cache its input rather
than the index. Every lookup returns a list, because the relationship is genuinely many-to-many in
both directions.
Reconciling usage subscriptions
Some billing products override the subscription identifier to carry a routable value instead of the usual opaque composite label — a usage product whose identifier is the exact PBX domain is how metered usage finds the right invoice. Nothing enforces it. Misspell it, or let the subscription lapse, and usage silently stops flowing until a bill comes out wrong.
import {
gatherUsageRows, reconcileUsageSubscriptions, proposeMappings, bySeverity,
SUBSCRIBER_STATUSES,
} from '@dszp/onebill-lib';
const { rows, failures } = await gatherUsageRows(client, {
ns: 'PBX',
mapping: [{ group: 'PBX', ns: 'PBX', valueField: 'Domain', qualifierField: 'Site' }],
statuses: SUBSCRIBER_STATUSES, // closed accounts still hold links
});
const report = reconcileUsageSubscriptions(rows, {
spec: { offerNames: ['Domain Usage'] }, // YOUR product name — nothing is built in
}).sort(bySeverity);
const proposal = proposeMappings(report, {
ns: 'PBX',
knownTargets: await realDomainList(), // the typo guard — see below
});Each account gets one of eight verdicts: ok, mismatch, missing, ambiguous, inactive,
unlinked, extra, none. It reports and never resolves — where the two systems disagree it
says so and stops, because picking a winner silently makes the wrong choice permanent.
Four things worth knowing before you build on it:
extrais a verdict, not an error. An account legitimately billing several targets with one usage subscription is expected. Calling that a mismatch trains people to ignore the whole report.examineddetects a renamed product. If the offer is renamed, every account reports its subscription missing and the report reads as a catastrophe rather than a stale config. Seeingmatched: 0against a healthyexaminedon every account is the tell.knownTargetsis the typo guard, and you want it. A subscription identifier is free text somebody typed during billing setup. Seeding from it unchecked launders a typo into whatever you treat as truth, where it then agrees with itself forever and the report goes quiet. Candidates that match nothing come back asconfidence: 'unknown'— surface them, don't apply them. Ones that match only after normalizing come back'canonicalized', spelled your way, not the billing record's.proposeMappingsproposes; it never writes. Applying is a separate, explicit act — andskippedrecords why every other account was passed over, so a second run months later proposes only what is new.
gatherUsageRows is the only part that does I/O; the three layers above it are pure functions over
records. It defaults to reading links from the custom-field group rather than externalId,
because a report that reads the derived index cannot notice the index is wrong. That costs a GET per
subscriber; pass linkSource: 'externalId' for a cheaper pass that trades away drift detection.
Per-account failures are collected into failures rather than thrown, so one bad account cannot
destroy a long sweep — check it before trusting the report. A read that fails at the transport level
(a 5xx, a network error) is retried once after retryDelayMs (default 500 ms) before it counts as a
failure; retried says how often that happened. OneBill's own answers — 4xx, in-band failures — are
never retried.
Reconciling recurring subscriptions
compareRecurring answers a different question from the usage reconciler: not "is usage flowing", but
"does the quantity on the bill still match what the customer actually has".
You supply three things: the account's subscriptions, a count of the real world, and a rulebook saying which offer counts toward which dimension. The library ships no offer names — what a seat includes is a sales decision, not a property of billing software.
import { compareRecurring, type RecurringRule } from '@dszp/onebill-lib';
const rules: RecurringRule[] = [
{ offer: 'Seat Tier One', counts: 'extensions.total', group: 'seats' },
{ offer: 'Seat Tier Two', counts: 'extensions.total', group: 'seats' },
{ offer: 'Number Pack', counts: 'dids.total', group: 'numbers', perUnit: 10 },
{ offer: 'Emergency Location', counts: 'e911Addresses', alsoCounts: { 'dids.total': 1 } },
{ offer: 'Premium Seat', counts: 'extensions.total', group: 'seats',
entitles: { teamsConnected: 1, smsNumbers: 1 } },
];
const inventory = {
extensions: { total: 12 }, dids: { total: 14 }, e911Addresses: 1,
teamsConnected: 1, smsNumbers: 0,
};
const out = compareRecurring({ subscriptions, inventory, rules });
// out.rows - one per group: { group, dimensions, billed, entitled, observed, verdict,
// optional, items, offers, credits }
// out.unmapped - active recurring offers no rule accounts for
// out.ignored - offers an `ignore` rule deliberately excludes
// out.catalogMisses - plan names a code-keyed rulebook could not resolveinventory is any object whose counts paths end in a number, so this works over whatever you
count — extensions, mailboxes, licences, doors. counts also takes an array of paths: observed is
their sum, and the item list their union.
alsoCounts pays for something; entitles permits it
The two keys share a vocabulary — path or group name → per-unit number, landing on every group
whose dimensions include the path or whose name equals the key — and mean opposite things.
An E911 bundle includes a number. That number is a deliverable, so alsoCounts: { 'dids.total': 1 }
adds to the numbers row's billed and one fewer live than billed is a shortfall.
A premium seat permits a Teams connection and an SMS number. Those are a ceiling, so
entitles: { teamsConnected: 1, smsNumbers: 1 } adds to those rows' entitled instead. Using one
is covered; not using it is nothing. An entitlement never creates a shortfall.
billed stays the paid quantity and covered = billed + entitled is what the bill permits, so a row
with billed 0, entitled 2 is match at 0, 1 or 2 live and only reports at 3. Such a row also carries
optional: true — it exists only because something entitles it.
Both scale by the line's quantity, never by perUnit: perUnit says how many of its own dimension
a line is worth (ten numbers to a pack), which says nothing about how many of someone else's it pays
for or permits.
A credit of either kind naming a path or group no rule tracks creates a comparison-only row
named after the key, counting that key. Nothing is dropped: before v0.6.0 a seat's included SMS and
transcription reached no row, no unmapped list and no error at all. Treat a row named after a key as
a prompt to write the keyless group rule for it — baselines key on the group name, so a decision
recorded against teamsConnected follows the key, and renaming the row later orphans it. Every row
carries credits — { from, kind, quantity } per crediting offer — so a row billed entirely by
another line can say "via Premium Seat x1".
Accept individual items, not a count
A count says twelve extensions exist against ten billed. It cannot say which two are the extra ones,
so it cannot tell "the same two we already looked at" from "one of those was deleted and a different
one appeared". Pass itemsFor(path) — the list of things behind a dimension, or undefined where
that dimension has none — and every row carries one entry per item, each accepted, unreviewed or
stale. itemLabel(item) names one to a person; it defaults to the key.
const out = compareRecurring({
subscriptions, inventory, rules,
itemsFor: (path) => (path === 'extensions.total' ? extensions : undefined),
itemLabel: (e) => e.label,
baselines, // GroupBaseline[]: { group, items: ItemAcceptance[], groupRow?: GroupAcceptance }
});Where a dimension has an item list, observed is the length of that list. The item list arrives
injected: this library never learns what an extension or a phone number is, only that a dimension may
have keys behind it and that keys can be accepted.
Billed as. An acceptance may record which offer the item is billed as — ItemAcceptance.offer, a
name from the row's offers[].name, matched with case, whitespace and dash variants ignored. Each
offer entry then carries tagged (present accepted items billed as it; a stale acceptance counts
toward nothing) and the row carries untagged for accepted items naming none. Tags never enter the
verdict: nine seats tagged to a tier billing eight is something to show a reader — "Seat Tier One x8
— 9 tagged" — not a discrepancy this library can adjudicate.
Verdicts. With B = billed, C = B + entitled (so C == B unless something entitles the row),
n = the present items and G = the group row. A stale acceptance — accepted once, no longer
present — verdicts drift before any test below, in every branch: the membership changed after the
decision, and a swap whose count caught up would otherwise read clean. Then:
matchwhenB <= n <= C.- Over-covered (
n > C) — the case items exist for:acceptedwhen every present item is accepted,G.billed == BandG.entitledeither matches the row'sentitledor is absent (a decision recorded before 0.6.0);driftwhenGexists and any of those has moved, or an unreviewed item has appeared;unbaselinedotherwise, withunreviewedsaying how many remain. - Shortfall (
n < B): there is no item to point at for a seat that does not exist, so the group row carries the judgement.acceptedwhenG.accepted == n && G.billed == B,driftwhenGexists and either differs,unbaselinedwhen there is noG. - A dimension with no item list uses the shortfall rules in both directions — the count model,
kept for the one place items do not exist. Such a row has
items: undefinedandunreviewed: 0.
Rule keys and precedence
A subscription line carries the price plan's name and nothing else — no plan code, no product
code. So offer matches the name, while planCode and productCode resolve through a
CatalogIndex you pass as catalog (see Catalogue under Reading). When more
than one rule matches a line the precedence is planCode, then offer, then productCode — a
product rule therefore means "any plan under this product I have not named", and a named plan always
wins. A rulebook keyed only by offer never touches the index; one keyed by code reports every plan
name it could not resolve in catalogMisses rather than silently leaving the line unmatched.
Two more rule forms:
{ productCode: 'FAX', ignore: true } // known, deliberately not compared
{ group: 'callcenter', counts: [ // comparison-only: billed comes from credits alone
'extensions.byScope.Agent', 'extensions.byScope.Supervisor',
] }An ignore rule keeps the line out of unmapped, puts it in ignored with the rule key that matched
(ruleKeyOf renders those), and creates no row. A rule with no key at all defines a group whose
billed comes entirely from other rules' alsoCounts credits — whose keys may name a group as
well as a dotted path. Writing that rule is how you give such a row real counts paths and a name of
your choosing; a credit naming nothing else gets a row named after the key regardless.
Only offers carrying a REC charge and inside their activation window are counted; ONE_TIME and
USAGE are ignored, and USAGE has its own reconciler above. Activity is the window intersection of
the subscription and the offer, for the reason given in that section: the numeric status and state
vocabularies are undocumented, so this library reads dates rather than guessing at them.
Invoices
listAllInvoices walks the invoice list; getInvoiceDetail reads one invoice in full, down to the
individual rated calls behind a metered charge.
import {
OneBillReadClient, flattenInvoice, reconcileInvoice, findDuplicateCalls,
} from '@dszp/onebill-lib';
const invoices = await client.listAllInvoices({ accountNumber: 'CLI00000' });
const flat = flattenInvoice(await client.getInvoiceDetail(invoices[0].invoiceNumber));
flat.chargeLines; // recurring, one-time, and usage rollups
flat.calls; // one entry per rated call, with source, destination and rated durationaccountNumber is optional — omit it and you get the whole tenant.
Check the read before you trust it
const check = reconcileInvoice(flat);
if (!check.usageBalanced) throw new Error('lost calls while reading the invoice');reconcileInvoice compares a flattened invoice against the totals the invoice states about itself.
Run it. The failure mode on this endpoint is a walk that silently drops rows, and an analysis built
on a partial read reports a reassuring wrong answer.
It returns two checks, not one, because they fail for different reasons. balanced compares
charge lines + surcharges + discount against totalCurrentCharge. usageBalanced compares the
individual calls against their own rollups — an invoice can balance at the invoice level while the
per-call walk has lost rows, and only the second check sees that.
Do not add charge lines and calls together
A usage charge line's amount is the sum of its own calls. InvoiceChargeLine.isUsageRollup
marks those lines. Adding both double-counts every metered charge.
Comparing calls across invoices
const report = findDuplicateCalls(thisInvoice.calls, everyEarlierInvoice.calls);
report.naturalOnly.length; // calls re-ingested under a new eventIdeventId is assigned when OneBill ingests the CDR, not by the switch — so a call re-imported
after a broken usage feed carries a new id for the same call, and matching on eventId alone
reports "no duplicates" for exactly the case you are asking about. invoiceCallKey is the identity
that survives a re-import: timestamp, source, destination, rated quantity.
findDuplicateCalls applies both keys and reports them separately rather than merging them into a
verdict — naturalOnly is the count that matters, and a merged flag could not express it.
findRepeatedCalls answers the different question of whether one invoice repeats a call against
itself, which a replayed feed can cause with no earlier invoice involved.
PDFs and XML
import { invoicePdfBytes } from '@dszp/onebill-lib';
const pdf = await client.getInvoicePdf('INV00000');
writeFileSync(`${pdf.fileName}.pdf`, invoicePdfBytes(pdf)); // fileName carries NO extensiongetInvoiceXml returns the same content as getInvoiceDetail in OneBill's own template format.
Prefer getInvoiceDetail — the XML for a large invoice runs to tens of megabytes of text.
Large invoices are large. An invoice carrying a year of recovered usage took ~20 seconds to return and held over ten thousand calls. Budget for it, particularly in a Worker.
Tax: exemptions, and what an invoice was actually taxed
The published OpenAPI carries no tax or exemption paths at all. Everything here was established against a live tenant.
What an account is exempt from
import { taxExemptionCodesOf, hasTaxExemptionCode, taxJurisdictionsOf } from '@dszp/onebill-lib';
const account = await client.getSubscriber('CLI00000');
taxExemptionCodesOf(account); // [{ code: '32', description: 'State and Local Sales Tax Exempt' }]
taxExemptionCodesOf(account).map((c) => c.code); // ['32'] — the bare codes
hasTaxExemptionCode(account, '34'); // false — the membership test
taxJurisdictionsOf(account); // ['IN'] — the states this account has addresses intaxExemptionCodesOf returns objects, not strings, because the descriptions are worth keeping.
So codes.includes('34') is quietly false and codes.join(',') is quietly
"[object Object]" — neither errors. Use hasTaxExemptionCode to test membership, or
.map(c => c.code) for the bare codes.
taxExemptionCode is absent when an account has no exemption, not empty — test presence, not
truthiness. Use the accessor rather than walking it: a singular code key holds the array and
every element also has a code, so the value is at taxExemptionCode.code[].code and reaching one
level short yields an array where a string was expected, silently.
Codes are strings, are extended per tenant, and are not always numeric (TF alongside two-digit
codes). The library does not interpret them, deliberately — that is your configuration, not its API.
Which codes an account needs depends on its state, and the codes carry no jurisdiction of their
own. Live: Indiana accounts carried one sales-tax code, Michigan accounts carried use-tax codes, and
a Florida account carried six with no Midwest equivalent. That is what taxJurisdictionsOf is for.
isSkipTax looks like the exemption switch and is not — it was false on every account and every
address across a tenant where a minority of accounts were genuinely exempt.
What an invoice was taxed
import { flattenInvoice, taxTotalsByDescription, taxTotalsByJurisdiction } from '@dszp/onebill-lib';
// flattenInvoice first — reconcileInvoice and the tax totals take a FlatInvoice, not the raw record.
const flat = flattenInvoice(await client.getInvoiceDetail('INV00000'));
taxTotalsByDescription(flat).get('STATE USE TAX'); // 238.61
taxTotalsByJurisdiction(flat).get('MI'); // 759.86Tax components live at two different depths — on the charge line for recurring charges, and on
the individual calls for usage, because a usage rollup has no taxLineItem node and carries the sum
of its calls' tax as its own taxAmount. FlatInvoice.taxes collects both, aggregated per
(description, jurisdiction, code). Check it with reconcileInvoice(flat).taxBalanced.
Untaxed is not the same as taxed at zero
flat.calls.filter((c) => c.amount > 0 && !c.taxed); // billed, but carrying NO tax recordInvoiceCall.taxAmount is undefined — never 0 — when a call has no tax record. Do not write
taxAmount ?? 0: it erases the distinction between a call taxed at zero and a call the tax engine
never answered for, and the second is a real defect. On one live invoice, well over a thousand billed calls had no
tax element while identically-priced calls in the same months were taxed normally. Deciding whether
that is an exemption or a failure needs the account's exemption codes, which is why both surfaces
ship together.
Documents
import { subscriberDocumentBytes } from '@dszp/onebill-lib';
const docs = await client.getSubscriberDocuments('CLI00000');
const cert = docs.find((d) => /exempt/i.test(d.name ?? ''));
if (cert) writeFileSync(`${cert.name}.pdf`, subscriberDocumentBytes(cert));Contracts, exemption certificates, receipts. Hand-uploaded attachments only — no generated artefact is stored here, not even invoices.
Match on name, not type. type is required by the upload form but is missing from the API
response for every document uploaded to a live tenant since 2025-05-12, while every one uploaded
through 2024-11-22 carried it — a clean split, independent of the type chosen and of visibility. A
document uploaded as an externally-visible Contract came back untyped just as an internal
Supporting Document did, so "untyped" means neither "internal" nor "no type chosen". Filtering on
it drops every recent document.
documents is absent rather than empty when an account has none, which getSubscriberDocuments
normalises to []. Note the list response embeds every file's full base64 content and offers no
metadata-only mode, so listing downloads everything — fetch per account rather than sweeping.
Develop
pnpm install
pnpm build
pnpm test
pnpm typecheckpnpm test is green on a fresh clone with no credentials configured.
Docs
- ARCHITECTURE.md — why the library is shaped this way, and the traps it works around
- CONTRIBUTING.md — the rules, and the reason behind each one
- CHANGELOG.md
