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>
Migration 000091 creates organization_settings, a key-value store per
organization shaped like outlet_settings, for the loyalty settings that must
be the same in every outlet (point value, exchange rate, transfer limits,
expiry). Until now there was nowhere to keep organization-level settings.
Also creates loyalty_setting_changes, the append-only log of who changed
which loyalty setting from what to what (PRD F2), for both organization and
outlet settings (PC-102).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 000090 creates customer_wallets, wallet_transactions, wallet_lots
and wallet_lot_allocations as specified in docs/prd-point-coin.md §8 (PC-101).
The CHECK constraints enforce K5 at the database: every ledger row names its
source or destination, PAYMENT and the other point-only types cannot carry
COIN, transfers need a counterparty, reversals need the row they reverse,
adjustments need an admin and a reason, and EXPIRE must point at a lot.
Balances and lot remainders cannot go negative, and a lot cannot hold more
than it was created with.
Beyond §8, adds idx_wallet_lot_allocations_lot_id: the primary key cannot
serve lookups by lot, which the reconciliation job needs.
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>