invkit
v0.1.0
Published
Invoice calculator with multiple tax types, item and total discounts, and extras. Full-precision math with rounding only at output.
Maintainers
Readme
InvKit
Deterministic invoice math for TypeScript—multiple tax models, stacked discounts with explicit order, and extras—computed in full precision and rounded only when you read totals. Suitable for backends that need stable numbers and for interactive UIs that recompute as the cart changes.
InvKit is intentionally small, side-effect free, and dependency-free at runtime. The public API is a thin wrapper around two calculator modes:
Disclaimer: InvKit provides deterministic arithmetic only. It is not legal, accounting, or tax advice.
| API | When to use |
|-----|-------------|
| InvKit.once(inputs) | Immutable / one-shot: you pass the full snapshot (items, taxes, discounts, extras) and read totals. Ideal for server-side pricing, quotes, and PDFs—no mutable instance to keep in sync. |
| InvKit.dynamic(inputs?) | Mutable / interactive: same getters, plus methods to add, update, or remove lines and rules. Each mutating call invalidates cached figures; reads trigger a full recalculation. Built for carts, editors, and live previews. |
Both paths apply the same order of operations and rounding rules, so server and client stay aligned.
Why InvKit?
Invoice logic looks simple until production exposes the edge cases:
- Tax ordering – Applying taxes in the wrong sequence changes every line. InvKit applies line taxes in a fixed order: inclusive → normal → compound → withdraw → fixed, matching common regulatory patterns.
- Compound taxes – “Compound” here means taxes chain on the running line total in that order (normal before compound, etc.), so stacked percentages behave predictably.
- Withholding taxes – Modeled as
withdraw(rate or fixed off the line), so net amounts after VAT-style taxes and withholding stay consistent. - Stacked discounts – Item-level and invoice-level discounts stack with optional
priority(lower first); otherwise array order applies—no ambiguous “sometimes percentage first” behavior. - Rounding – Intermediate steps use full floating-point precision;
getTotal,getLineTotal, and discount display amounts round to two decimal places in a stable way, avoiding cumulative drift from rounding at every step.
Install
bun add invkit
# or
npm install invkitFor local development after cloning this repository, dependencies are resolved with:
bun installThe published artifact is built with Bun’s bundler (bun build): ESM, --target bun, --production (minified), external source maps, and bunx tsc --emitDeclarationOnly for .d.ts beside dist/index.js (declaration layout mirrors src/). CI uses Bun only (bun test, bun run build).
bun test
bun run build
bun run typecheckPeer (optional): TypeScript ^5.0.0 for project type-checking. You can import public types directly:
import type { Inputs, Item, Taxes } from 'invkit'Quick start
One-shot (immutable)
Use when the full invoice state is known up front (API handler, batch job, immutable domain object).
import InvKit from 'invkit'
const invoice = InvKit.once({
items: [
{ name: 'Widget', price: 100, quantity: 2, taxes: ['VAT'] },
{ name: 'Service', price: 50, quantity: 1, discount: [{ name: 'promo', type: 'fix', value: 5 }] },
],
taxes: [{ name: 'VAT', type: 'normal', value: 20 }],
discount: [{ name: 'loyalty', type: 'rate', value: 10 }],
extra: [{ name: 'shipping', type: 'additional', value: 8 }],
})
invoice.getTotal() // 278.4
invoice.getSubtotalBeforeGlobalDiscount() // 302.4
invoice.getLineTotal(0) // 240 (100×2 + 20% VAT)
invoice.getLineTotal(1) // 54 (50−5 + 20% VAT, then rounded)
invoice.getDiscount() // [{ name: 'loyalty', type: 'rate', value: 10, amount: 30.24 }]
invoice.listExtra() // [{ name: 'shipping', type: 'additional', value: 8, amount: 8 }]
invoice.findItem('Widget') // { name: 'Widget', price: 100, quantity: 2, taxes: ['VAT'] }Dynamic (mutable)
Use when lines and rules change over time (shopping cart, admin UI). Call InvKit.dynamic() with no args for an empty invoice, or pass partial Inputs to seed state; totals update when you call getters after changes.
import InvKit from 'invkit'
const invoice = InvKit.dynamic()
invoice.addItem({ name: 'A', price: 10, quantity: 2 })
invoice.addItem({ name: 'B', price: 25, quantity: 1, taxes: ['VAT'] })
invoice.setTaxes([{ name: 'VAT', type: 'normal', value: 10 }])
invoice.setDiscount([{ name: 'sale', type: 'rate', value: 5 }])
invoice.addExtra({ name: 'ship', type: 'additional', value: 5 })
invoice.getTotal() // recalculated after each mutation
invoice.modifyItem(0, { quantity: 3 })
invoice.removeItem(1)
invoice.getTotal() // updated againOrder of operations
- Per line:
amount ?? price × quantity→ item discounts (bypriority, then array order) → item taxes (invariant order: inclusive → normal → compound → withdraw → fixed). - Subtotal = sum of raw line totals.
- Global discounts (same priority / order rules as item discounts).
- Extras (additional / subtraction).
Rounding runs only on the values you read (getTotal, getLineTotal, getDiscount()[].amount, etc.), not after each intermediate operation. The final total is one rounding pass on the fully computed subtotal, which keeps results stable compared with rounding every step.
Validation & edge cases
InvKit focuses on predictable arithmetic, not business-rule validation. It does not throw on “invalid” business data; callers should enforce catalog and pricing policies upstream.
| Scenario | Behavior |
|----------|----------|
| Missing tax references | If an item lists taxes: ['VAT'] but no definition with name: 'VAT' exists in the invoice’s taxes array, that name is ignored for that line—no error. |
| Negative values | Negative price, quantity, or amount are not rejected. Math proceeds as written; results may not match real-world pricing rules. Validate sign and ranges in your domain layer. |
| Fixed discount overflow | For type: 'fix', the applied amount is capped at the current line or subtotal so a fixed discount cannot push a positive base below zero by itself. Rate discounts use a percentage of the current value. |
| Decimal quantities | quantity may be fractional (e.g. weight or hours). Line base is price × quantity using the same precision rules as other steps. |
| Non-finite inputs | roundMoney is used for getTotal, getLineTotal, getSubtotalBeforeGlobalDiscount, and getDiscount() amounts. If the value passed in is not finite, those reads return 0. Raw fields (e.g. listExtra() amount when sourced from value) are not coerced the same way—keep inputs numeric. |
Input types
Inputs
| Field | Type | Required | Description |
|------------|--------------|----------|-------------|
| items | Item[] | yes | Line items. |
| taxes | Taxes[] | no | Global tax definitions (referenced by name from items). |
| discount | Discount[] | no | Invoice-level discounts (after line totals). |
| extra | Extra[] | no | Charges or credits after discounts. |
Item
| Field | Type | Required | Description |
|------------|--------------|----------|-------------|
| name | string | yes | Label. |
| price | number | yes | Unit price (ignored when amount is set). |
| quantity | number | no | Defaults to 1. |
| amount | number | no | When set, replaces price × quantity for the line base. |
| taxes | string[] | no | Names of taxes to apply; each name must match a Taxes.name entry to take effect. |
| discount | Discount[] | no | Discounts applied only to this line. |
Taxes (tax definition)
| Field | Type | Description |
|---------|------------|-------------|
| name | string | Identifier referenced from items (taxes: ['VAT']). |
| type | TaxTypes | See below. |
| value | number | Percentage for rate-based types, or fixed amount for fixed / rate-off-fixed cases. |
Tax types
inclusive– Treats the line as already tax-inclusive; does not change the line amount.normal– Adds tax (rate:line × (1 + value/100); non-rate uses add fixed).compound– Same formula as normal; participates in the ordered tax chain.withdraw– Withholding-style reduction (rate:line × (1 − value/100), or subtract fixed).fixed– Adds a fixed amount to the line.
Taxes on a line always run in this order: inclusive → normal → compound → withdraw → fixed.
Discount
| Field | Type | Description |
|------------|------|-------------|
| name | string | Label. |
| type | 'rate' \| 'fix' | rate: percentage of the current amount. fix: fixed deduction, capped at the current amount. |
| value | number | Percent (rate) or amount (fix). |
| priority | number | Optional. Lower values apply first; if omitted, array order applies. |
Same semantics for line and invoice-level discounts.
Extra
| Field | Type | Description |
|---------|------|-------------|
| name | string | Label. |
| type | 'additional' \| 'subtraction' | Adds or subtracts from the total after global discounts. |
| value | number | Amount applied. |
API
Static calculator (InvKit.once(inputs))
| Method | Returns | Description |
|--------|---------|-------------|
| getTotal() | number | Final total (rounded). |
| getSubtotalBeforeGlobalDiscount() | number | Sum of line totals before global discounts (rounded). |
| listItem() | Item[] | All items. |
| findItem(name) | Item \| undefined | First item with that name. |
| getLineTotal(index) | number \| undefined | Line total (rounded); undefined if index out of range. |
| getDiscount() | (Discount & { amount?: number })[] | Global discounts with computed amount. |
| listExtra() | (Extra & { amount?: number })[] | Extras with amount. |
| findExtra(name) | (Extra & { amount?: number }) \| undefined | First extra with that name. |
Dynamic calculator (InvKit.dynamic(inputs?))
Same getters as above, plus mutators. After any of the methods below, the next read of totals or lines runs a full recalculation.
| Method | Description |
|--------|-------------|
| addItem(item) | Append an item. |
| modifyItem(index, updates) | Merge Partial<Item> at index. |
| removeItem(index) | Remove the item at index. |
| setDiscount(discount) | Replace the global discount list. |
| setTaxes(taxes) | Replace tax definitions. |
| addExtra(extra) | Append an extra. |
| modifyExtra(index, updates) | Merge Partial<Extra> at index. |
| removeExtra(index) | Remove the extra at index. |
Examples
E-commerce: mixed items, VAT, coupon, shipping
InvKit.once({
items: [
{ name: 'T-Shirt', price: 29.99, quantity: 2, taxes: ['VAT'] },
{ name: 'Gift card', price: 50, quantity: 1 },
],
taxes: [{ name: 'VAT', type: 'normal', value: 20 }],
discount: [{ name: 'SUMMER20', type: 'rate', value: 20 }],
extra: [{ name: 'Shipping', type: 'additional', value: 4.99 }],
})B2B: VAT + withholding tax
InvKit.once({
items: [{ name: 'Consulting', price: 1000, quantity: 1, taxes: ['VAT', 'WHT'] }],
taxes: [
{ name: 'VAT', type: 'normal', value: 20 },
{ name: 'WHT', type: 'withdraw', value: 5 },
],
})
// Line: 1000 → +20% VAT = 1200 → −5% WHT = 1140Discount priority: fix then rate
InvKit.once({
items: [{ name: 'Product', price: 100, quantity: 1 }],
discount: [
{ name: 'coupon', type: 'fix', value: 10, priority: 1 },
{ name: 'member', type: 'rate', value: 10, priority: 2 },
],
})
// Subtotal 100 → −10 = 90 → −10% = 81Testing
bun testCoverage includes basic totals, order of operations, all tax types, item- and invoice-level discounts (with priority), extras, precision (e.g. 1.005 → 1.01), edge cases (empty invoice, amount override, no taxes, out-of-range indices), and dynamic add / modify / remove / set flows.
