Skip to content

esbuker/invkit

Repository files navigation

InvKit

CI license: MIT

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 invkit

For local development after cloning this repository, dependencies are resolved with:

bun install

The 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 typecheck

Peer (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 again

Order of operations

  1. Per line: amount ?? price × quantityitem discounts (by priority, then array order) → item taxes (invariant order: inclusive → normal → compound → withdraw → fixed).
  2. Subtotal = sum of raw line totals.
  3. Global discounts (same priority / order rules as item discounts).
  4. 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 = 1140

Discount 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% = 81

Testing

bun test

Coverage 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.

License

MIT

About

Invoice calculator with multiple tax types, item and total discounts, and extras.

Topics

Resources

License

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Contributors