Keeps EnakPoint out of the money in on the payment method analytics
(docs/prd-point-coin.md F9, K7, PC-308), the one report that sums payments;
the daily transaction and profit-loss PDFs do not break payments down by
method.
summary.total_amount is now only money actually received. EnakPoint stays
listed as its own method, with points_used, and the summary adds
point_amount, points_used and total_with_points. Each method row says
whether it counts_as_cash_in, and the shares are of the money received, 0
for EnakPoint. The average order value still includes what EnakPoint paid,
since that is part of what the orders were worth. How EnakPoint is booked
waits on note N2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the system payment method for paying with EnakPoint
(docs/prd-point-coin.md F9, §8, §10.5, PC-303).
Migration 000094 allows the point type, keeps one per organization with a
partial unique index, creates it for every existing organization, and adds
a trigger that creates it for new ones, as the walk-in customer is. It adds
payments.points_used and point_value. Their CHECK is written so it can
never be NULL: the PRD form, (both NULL) OR (both > 0), is NULL for
points_used with a NULL point_value, which a CHECK lets through, so a
payment could have lost the value a refund depends on. A test caught it.
The API cannot create, delete or retype the EnakPoint method, nor turn
another method into one; that answers 400. Renaming it is allowed. The
method list takes the outlet from ?outlet_id= or the user's outlet and
leaves EnakPoint out when that outlet does not accept it, filtered in the
query so the count stays right. The organization-wide active list is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109).
Reading returns typed settings for an outlet (earning per currency, paying
with EnakPoint) and for an organization (point value, exchange rate,
transfers, and the expiry settings awaiting note N4). A key that was never
set takes the PRD default. A stored value that is unusable, such as an
earn_per_amount of 0 that would divide by zero, also falls back to the
default and is logged, so a bad row never reaches a calculation.
Writing takes the whole settings struct, validates every rule in the PRD
before touching the database, and stores and records in
loyalty_setting_changes only the keys whose effective value changes: old
value (NULL while it was on its default), new value, and who changed it.
Clearing a limit deletes the stored value. Each save runs in one
transaction under an advisory lock per outlet or organization, so two saves
at once cannot both compute their change from the same old value. The
outlet must belong to the caller's organization.
Every key is described once (key, default, valid range, bound field), and
reading, validating and diffing all use that description.
GET /customer/wallet now reads the point value through this processor; the
minimal organization settings repository from PC-106 is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds cmd/wallet-migrate (make wallet-migrate, args=-dry-run to only report),
which moves customer_points and customer_tokens into the wallet
(docs/prd-point-coin.md §10, PC-105). Each customer gets a MIGRATION ledger
row and a non-expiring lot per currency, written through WalletProcessor in
one transaction per customer. EnakCoin is the sum of every token type (Q6),
with the legacy rows listed in the row's metadata.
It credits the difference between the legacy balance and what earlier runs
migrated, so running it again never doubles a balance and picks up only
what the old code added since. A legacy balance that shrank after being
migrated is reported and left alone, since only an admin adjustment may
take balance away, and the command then exits non-zero. It ends with a
legacy / migrated / wallet total per currency.
Migration 000092 renames TOKENS to COINS in campaigns.type and
campaign_rules.reward_type. The campaign API now validates COINS; it still
accepts TOKENS, including as a list filter, and stores it as COINS so older
dashboards keep working while they are updated.
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>
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>