WalletProcessor writes the balance, the ledger row and the lots or
allocations together, which keeps SUM(ledger) = balance = SUM(lot
remaining) (docs/prd-point-coin.md §7.5, PC-104).
- Credit writes the ledger row and creates lots, each with its own expiry
and origin lot.
- Debit draws from the preferred lots first (a reversal's own lots, or the
lot being expired), then from unexpired lots in K9 order, and returns the
allocations with their expiry so CarryOver can give the receiving side of
a transfer or exchange the same expiry.
- DebitUpTo takes what the wallet has and reports the shortfall (F10, Q3).
- An idempotency key returns the first result; reusing it for a different
operation is an error.
- §8.1 is checked in code from one rule table, ahead of the database
constraints, so callers get a readable error.
Each method locks the wallet itself, after validating the input and before
checking the idempotency key, so correctness does not depend on the caller.
Operations on two wallets still call LockWallets first to keep lock order.
Unit tests run on an in-memory repository and check the §7.5 invariants
after every scenario; one more test runs the engine against Postgres when
TEST_DATABASE_URL is set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entities for the four wallet tables and a WalletRepository that the wallet
processor will build on (PC-103).
Every method goes through the caller's transaction, and writes and locks
refuse to run without one: outside a transaction a lock is released as soon
as it is taken and a balance could move without its ledger row.
- LockWallet creates the wallet on first use, taking the organization from
the customer, then locks it with SELECT ... FOR UPDATE.
- LockWallets always locks in customer_id order so opposite transfers
cannot deadlock.
- AddBalance and ConsumeLot are conditional updates that return an error
when they would overdraw, instead of tripping the CHECK constraint.
- ListActiveLots returns unexpired lots with balance in K9 spending order.
The tests need a real Postgres and run only when TEST_DATABASE_URL points at
a migrated database. Both the lock and the lock ordering were checked by
removing them and watching the tests fail (lost update, deadlock detected).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A weight-based line is one weighing, so order_items.quantity is pinned to 1
while unit_price and unit_cost are per unit of weight. Analytics SQL was
multiplying and dividing per-unit rates by the raw quantity, costing a 4.2 ons
fish as a single ons: standard_hpp_total and moving_average_hpp_total came out
far too low across all four product reports, overstating gross profit, and
average_price and fifo_hpp_per_unit read per weighing while
standard_hpp_per_unit read per unit, so the three HPP figures in one row could
not be compared.
Adds billableQty and billableQtyNet as the single place that decides the
multiplier, mirroring entities.OrderItem.BillableQuantity.
quantity_sold and total_items stay as weighing counts; weight_sold already
carries the amount. revenue and fifo_hpp_total were already correct via
total_price/total_cost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CreateOrderContractToModel and AddToOrderContractToModel copied every order
item field except Weight, so a weight sent by the client never reached the
processor and every weight-based line failed with "product ... is sold by
weight and requires a weight".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A product sold by weight with no unit produces order lines with nothing to
print: the receipt would read "4,2" with no idea of what. Until now nothing
stopped that — the mistake only surfaced at the cashier.
Enforce it in two places, because neither alone sees the whole picture. On
create, the validator has everything it needs. On update, the request may
omit unit_id for a product that already has one, so the check runs in the
processor against the merged product: what is rejected is the end state, a
product sold by weight with no unit.
Also fixes two things this uncovered:
The struct tags on the product contracts are decorative — this validator is
hand-written and never calls validator.Struct — so `oneof=unit weight` was
never enforced, and an unknown sell_by was silently rewritten to "unit" by
the mapper. It is now rejected with a message that names the valid values.
The update validator's "at least one field" guard did not list unit_id,
sell_by or print_to_checker, so an update carrying only one of those was
turned away as an empty request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the
order line could not represent: quantity is INTEGER and prices are always
computed as quantity * unit_price.
Model one weighing as one order line. quantity stays INTEGER and keeps
meaning "how many items"; the measured amount goes into a new nullable
order_items.weight, and the line is priced weight * unit_price. Two
weighings of the same product are two lines, never merged into one.
Keeping quantity integral avoids float comparisons in void, refund and
split bill, where accumulated rounding error would silently misbehave —
"1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid
split-bill item marked unpaid.
BillableQuantity() is now the single place that decides between weight
and count; every price and cost calculation goes through it. Missing one
would bill a 4.2 ons fish as a single ons — wrong money, no error.
Two database constraints back the design: a weighed line always carries a
positive weight, and its quantity is pinned to 1. The latter also makes
void all-or-nothing for weighed lines, so the row-splitting branch can
never produce a zero-weight remainder row.
Also wires product.unit_id through the API, which was previously not
settable at all, and corrects the misleading comment on the request's
unit_price field — that value has never been used; price always comes
from the database.
Design notes and the audit of every price multiplication site are in
docs/rfc-weight-based-products.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>