@baraka-team/pos-domain
v0.2.0
Published
Shared POS pricing and payment calculations for the monolith, the branch store agent and the POS client
Readme
@baraka-team/pos-domain
The POS pricing and payment calculations, in one place, so the monolith, the branch store agent and the POS client all compute the same total from the same version.
This package exists because @iztech-baraka/baraka_common pulls in express,
winston and axios middleware. Importing it into a browser bundle or into the
Bun sidecar that runs on a counter PC drags server code along with it. The
calculations therefore live here instead.
What is in it
| Area | Entry points |
|---|---|
| Money arithmetic | add, subtract, multiply, divide, applyDiscount, applyCharge, fixedToPercentage |
| Rounding | roundMoneyCustom, applyShopRounding, wasPriceRounded |
| Line breakdown | calculateLineBreakdown |
| Order breakdown | calculateOrderBreakdown |
| Discount and promotion engine | resolveDiscounts, resolveDiscountLifecycle, buildDiscountLifecycleFilter |
| Tender allocation | allocateTenders, allocateTenderTotals, sumCardTenders, sumNonCashTenders |
| Payment status | determinePaidStatus, calculatePaymentTotalFromTenders, appliedPaymentAmount, paymentCoverageRatio, calculateRemainingFromPayments |
| Partial-payment slicing | calculatePaymentBreakdown, calculatePaymentRoundedPrice, calculateUnroundedPaymentDue |
The rules it encodes
These are the behaviours the package is pinned to. They are deliberate, they are what the monolith does today, and a change to any of them is a breaking release.
subtract never goes below zero. Every money subtraction clamps at zero, so
a discount larger than the price yields a free line rather than a negative one.
Every operation rounds to the currency scale, half up. JOD carries three decimals, everything else two. An unknown currency falls back to two.
Shop rounding is a separate rule from currency scale. roundMoneyCustom
rounds to a whole unit with the break at .45, not .50 — 10.44 becomes
10, 10.45 becomes 11. It applies only when the shop has roundPrices on,
which is why it is a separate call and not folded into the arithmetic.
A percentage service charge is plain arithmetic. (price * amount) / 100,
without a Decimal pass and without rounding to the currency scale. This matches
the monolith and is preserved deliberately.
Card and debt tenders allocate first and always at face value. A card tender is captured money and a debt tender is a recorded receivable; neither can ever be change. Only cash absorbs an overpayment. Counting part of a card or debt tender would drop the rest out of the drawer report, the ledger and the debtor's balance alike.
The last line absorbs the allocation remainder. When order-level amounts are spread across paid lines, each line takes its weighted share except the last, which takes whatever is left in the pool. That is what makes the line amounts sum exactly to the payment total instead of drifting by a rounding unit.
What it does not do
It does not mutate what you pass it. calculateOrderBreakdown returns the
breakdown, the computed price and the resolved service and delivery costs; the
caller writes them onto its own entities. A serviceCost of null means the
order's saved service cost stands — a dine-in order the branch charge did not
price — and the caller leaves its own value untouched. The monolith's calculateAndSetTotalOrderPrice
mutates the order in place, which is why the adapter, not this package, is where
that assignment belongs.
It does not read ambient context. The monolith reads roundPrices from the
request context and the dine-in service charge from branch settings. Both arrive
here as explicit arguments. This is the seam that makes the same code runnable
on a branch agent that has no request context at all.
It does not resolve the catalog. A line arrives with its unit price and its addon prices already resolved. Looking up an item variant, falling back to a branch price, or naming an addon is the caller's job — the agent does it from its local mirror, the monolith from the database.
It does not evaluate promotions as part of the order breakdown. Promotions
are computed separately (resolveDiscounts) and handed to
calculateOrderBreakdown as an input, so the order assembly stays synchronous
and pure.
Types
Currency is a type parameter, not a fixed enum. Money<C> carries whatever
string type the caller uses, so the monolith's Currency enum flows through
add, subtract and the rest unchanged and comes back out the same type. The
package's own Currency, MoneyType, DiscountType and the rest are const
objects with a matching union type, so an enum member from a consumer assigns to
them without a cast.
Structural *Like types (PaymentLike, PaymentOrderLike, TenderLike) are
satisfied by the monolith's entities as they are, so the adapter passes an
entity straight in.
Dependencies
One: decimal.js, pinned exactly. It has no dependencies of its own, ships ESM
and CJS, and runs in the browser.
This is a deliberate deviation from decision D21's "zero runtime dependencies".
The reason D21 gave for a new package was that baraka_common drags server code
into the browser and the agent, and decimal.js does not. Reimplementing decimal
arithmetic to satisfy the letter of the rule would put a hand-written rounding
routine underneath every price in the system, for no gain — the monolith already
depends on this exact version, and bit-exact parity with it is worth more than a
dependency count.
types is empty in tsconfig.json, so neither Node nor DOM globals can be used
in src/. That constraint is compiler-enforced, not a convention.
Versioning
POS_DOMAIN_VERSION is exported so a pushed SALE_OPENED event can record which
rules priced an offline sale. A pricing change ships as a release here first,
then the monolith bumps, then the agent bumps. The server compares the agent's
totals against its own recomputation and stores a mismatch flag rather than
rejecting the sale.
POS_DOMAIN_VERSION in src/version.ts must be bumped with the package.json
version; tests/version.spec.ts fails if they drift.
Parity with the monolith
The package is a port of the monolith's pricing code, not a reimplementation. Parity was verified before release:
- 415,200 generated comparisons of the money and tender functions against the monolith's own function bodies, across four currencies, tie cases and fuzzed amounts — zero mismatches.
- The monolith's 53 discount-engine tests pass against this package unmodified
(
tests/discountEngine.parity.spec.ts). - The monolith's partial-payment allocation tests pass against this package
unmodified (
tests/paymentBreakdown.parity.spec.ts).
Development
npm install
npm run typecheck
npm test
npm run buildPublishing
Releases are published by hand from a maintainer's machine. There is no CI
release job, so prepublishOnly is the gate: it runs typecheck, lint, the full
test suite and the build, and npm publish aborts if any of them fails.
Publishing is done by the baraka-team npm account, which owns the
@baraka-team scope. Check you are on the right one before publishing — the
older @iztech-baraka scope still owns baraka_common and is a different
account:
npm whoami # expect: baraka-teamTo let a teammate publish, add them to the scope (npm org set baraka-team
<user> developer) or to this package alone (npm owner add <user>
@baraka-team/pos-domain).
First publish
npm login
npm publish --access public--access public is required on a scoped package's first publish. Without it npm
treats the package as restricted, which needs a paid plan and fails with a 402.
If the account has 2FA enabled on writes, npm prompts for a one-time password.
Pass it inline instead with --otp=<code> if you prefer.
Later releases
npm version patch -m "chore(release): %s"
npm publish
git push --follow-tagsUse minor for a new capability and major for a change to any of the rules in
The rules it encodes — those are behavioural contracts
that the monolith, the branch agent and the client all depend on matching.
Bump POS_DOMAIN_VERSION in src/version.ts to match. npm version does not
touch it, and tests/version.spec.ts fails when the two drift. That is on
purpose: the constant is what a replayed SALE_OPENED records as the rules that
priced an offline sale, so a stale one silently misattributes a price.
What ships
Eight files, about 72 kB packed: dist/ (ESM, CJS, both declaration files and
source maps), README.md and package.json. No source, no tests. Check before
publishing with:
npm pack --dry-run