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 the reconciliation of docs/prd-point-coin.md §7.5 (PC-108). One
aggregate query per check, across every wallet:
- wallet balance = SUM(ledger), per currency, including customers with
ledger rows but no wallet row
- wallet balance = SUM(lot remaining)
- lot original - SUM(allocations) = remaining
- SUM(allocations) = |amount| for every deduction
- lots created = amount for every addition, which the engine keeps and the
other checks rely on
The check on payments.points_used waits for that column (PC-305).
WalletReconciliationJob runs the checks at startup and every six hours,
alongside the omset scheduler. It is silent while the data is consistent.
Each discrepancy is logged with its check, customer, object and the
expected and actual values, and the organization's admins, owners and
managers get a high-priority notification. An organization is notified
again only when its set of discrepancies changes. Nothing is corrected
automatically. At most 50 discrepancies per check are reported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7,
PC-107), under /marketing for admins and managers:
- GET /marketing/customers/:id/wallet returns the customer, the ledger and
spendable balances, every lot that still holds something (flagged when
expired), and a page of history. Unlike the customer's own view, each row
carries the real names behind it: the transfer counterparty, the admin or
cashier, and the outlet, plus the reason and metadata.
- POST /marketing/customers/:id/wallet/adjust takes a signed amount and a
required reason. It writes an ADJUSTMENT pointing at the admin through the
wallet engine, refuses to take more than the customer can spend, and
accepts an optional idempotency key so a retried request adjusts once.
Reasons describing a cash-out are refused (K7).
The customer must belong to the caller's organization; otherwise both
endpoints answer 404. Positive adjustments create non-expiring lots until
the expiry model is decided (F12, note N4).
The mapping from ledger rows to what the apps show is now shared between the
customer and dashboard views.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /customer/wallet now reads the EnakPoint & EnakCoin wallet
(docs/prd-point-coin.md F6, PC-106): spendable point and coin balances, the
rupiah value of one EnakPoint and of the balance, the nearest day each
currency loses balance (grouped by Asia/Jakarta day), and recent ledger
rows. The fields of the pre-wallet response stay, filled from the wallet, so
app versions that read them keep working.
Adds GET /customer/wallet/transactions with pagination and filters for
currency, one or more types, and an inclusive date range. Each row shows
where the value came from (additions) or went to (deductions) as in §8.1,
and additions list their lots and earliest expiry. The counterparty id, the
admin and the metadata are left out; the description already carries the
masked name. A malformed query answers 400, a missing customer 404.
/customer/points and /customer/tokens keep their shape and now read the
wallet too, so customer_points_repository is no longer used for balances.
Balances are what the customer can spend: lots that have expired but that
the expiry job has not processed are not counted. The point value is read
from organization_settings (loyalty.point.value, default 1) through a small
repository that the typed settings reader in PC-109 will build on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>