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>
Client-facing companion to the RFC, aimed at the POS Mobile and Backoffice
teams: endpoints and payloads for setting up a weight product, placing an
order, rendering the line, and voiding, refunding or splitting it.
Documents two gaps the teams have to work around rather than discover:
unit_id is not yet enforced when sell_by is "weight", so Backoffice must
require it in the form; and money rounds to 2 decimals rather than whole
rupiah, which is still an open decision.
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>
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 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>
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>
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>
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>
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>
GetTransactionByIdempotencyKey used First, so every wallet operation with a
key not seen before, which is the normal case, logged a "record not found"
error. It now uses Find with a limit and returns nil when nothing matches.
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>
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>
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>
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 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 GET and PUT /outlets/:id/loyalty-settings (docs/prd-point-coin.md F1,
PC-201) on top of the typed settings processor.
The response shows every setting with its default when unset, the
organization's point value, and the effective EnakPoint cashback
(earn_value × point_value / earn_per_amount), so an owner cannot misread
the scale. PUT applies the body on top of the current settings: fields left
out keep their value, null clears an optional limit, and unknown fields are
refused so a typo cannot be ignored silently. The read-only fields of the
GET response are accepted and ignored, so a client can send back what it
received. It returns the keys that changed. Values outside the F1 bounds
answer 400, and an outlet of another organization 404.
RequireAdminOrManager also lets the purchasing role through, so loyalty
settings and the manual wallet adjustment from PC-107 now use a stricter
RequireLoyaltyManager (superadmin, admin, manager, owner).
Adds a test that registers every route, since gin panics at startup when
two routes name the same path parameter differently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds CalculateEarning (docs/prd-point-coin.md F1, Q1, Q10, PC-202), a pure
function returning, per currency, the amount an order earns and the
settings that produced it, plus the basis:
basis = subtotal − discount − paid with EnakPoint (never negative)
amount = 0 below min_order_amount, else
floor(basis / earn_per_amount) × earn_value, capped by max_per_order
Tax and anything added on top of the subtotal are not part of the basis,
and the part paid with EnakPoint earns nothing. Money is handled in whole
cents: in float64 some baskets divide to 4956.999… and a naive floor would
lose a point, which a test reproduces. Metadata() gives the snapshot the
EARN row will freeze.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds earning at payment time (docs/prd-point-coin.md F3, PC-203).
An order becomes fully paid through UpdateOrder, CreatePayment and both
kinds of split bill. All of them now go through one OrderProcessorImpl
hook, onOrderPaid, called after the payment has committed; for
CreatePayment that is after its transaction, not from updateOrderStatus
inside it. The hook runs detached from the caller's transaction and from
the request being cancelled, and it runs synchronously so the order
response can show what was earned.
EarningProcessor.EarnForOrder skips orders that are not paid, are void,
have no customer, or whose customer is the walk-in customer or inactive.
It computes the earning with CalculateEarning, subtracting any part paid
with EnakPoint (none until phase 3), and credits each currency through the
wallet engine as EARN with key earn:{order_id}:{currency} and the settings
snapshot in metadata. A repeat, even concurrent, credits nothing more.
OnOrderPaid never fails the payment: errors and panics are logged.
EarningBackfillJob is the safety net: every 30 minutes it earns for orders
paid in the last three days that have no EARN row. It only looks at
outlets with earning switched on and pages by (updated_at, id), so orders
that correctly earned nothing cannot starve the ones that were missed.
Lots from earning never expire until the expiry model is decided (F12,
note N4). Adds the point payment method type constant, not yet accepted as
a payment method.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds earning reversal (docs/prd-point-coin.md F10, Q3, PC-204).
VoidOrder, RefundOrder and RefundPayment now end with an onOrderRefunded
hook, called once their writes have committed and, like onOrderPaid,
detached from the request so it can never block or fail the void or
refund. For RefundPayment that is after its transaction.
EarningProcessor.ReverseForOrder computes how much of each EARN row should
have come back in total: everything for a void, otherwise
floor(earned × refunded / basis) with the order's cumulative refund and
the basis frozen on the EARN row, never more than was earned (a refund
including tax can pass the basis). It takes only what has not been asked
back yet, what was taken plus any shortfall, so repeats and successive
partial refunds never add up to more than the earning. It writes an
EARN_REVERSAL pointing at the EARN with DebitUpTo, drawing from the lots
the EARN created first, and records the shortfall when the balance was
already spent.
When the balance is empty there is no ledger row to carry the shortfall;
that case is logged. VoidOrder still refuses fully paid orders, so a void
has nothing to take back today; the hook keeps it correct if that changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds points_earned and coins_earned to the order response
(docs/prd-point-coin.md F3, PC-205). The POS prints the receipt from this
response, so the receipt gets them too.
The values are the sums of the order's EARN rows, read in one query for a
list of orders. They are filled for create, add items, update, detail and
list, and are 0 for an order that earned nothing. UpdateOrder earns before
building its response, so a payment completed there already shows the
earning. A failure to read them is logged and leaves them at 0 rather than
failing the order read. The self-order session listing reads orders
directly from the repository and still shows 0.
The two order hooks are merged into one OrderLoyalty interface (paid,
refunded, earned by orders) with a single SetLoyalty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the 6-digit customer PIN that approves every action moving EnakPoint
or EnakCoin on the customer's request (docs/prd-point-coin.md K8, F11, Q16,
Q17, PC-301).
Migration 000093 adds the PIN columns to customers and the
customer_security_events table. PIN data is read and written only through
CustomerPinRepository, never the Customer entity, so the hash cannot reach
a customer response. Only a bcrypt hash is stored.
- /customer/pin: status, OTP (pin_setup, pin_reset), create, change,
reset. The OTP must be for that purpose and sent to the customer's own
number; the existing OTP validation checks neither. A new PIN is checked
(6 digits, confirmed, not one digit, not a run up or down, not the birth
date as DDMMYY or YYMMDD) before the OTP is spent.
- Five wrong attempts in a row lock the PIN for 30 minutes; the counter is
incremented in one statement so attempts at the same time all count,
and a lock that ran out starts a new series. A locked PIN is refused even
when right. The customer is told by WhatsApp, as there is no push channel
to customers yet; only the attempt that reached the limit alerts.
- A reset through OTP lifts the lock and holds outgoing transfers for 24
hours; paying and exchanging still work, and a held transfer costs no
attempt.
- VerifyPin(ctx, customer, pin, action) for the flows that follow, with
PIN_NOT_SET, PIN_INVALID (attempts left), PIN_LOCKED and
TRANSFER_BLOCKED (until when), which PinErrorResponse turns into
distinct codes and statuses.
- DELETE /marketing/customers/:id/pin (loyalty managers, reason required)
and GET /marketing/customers/:id/security-events, scoped to the
organization.
Every PIN event is in the security log with IP and user agent. No message
or binding error contains a PIN.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET and PUT /marketing/loyalty-settings and GET
/marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302).
The settings are the point value, the exchange rate, transfer limits and
the stored expiry settings. PUT merges the body like the outlet settings
and is limited to loyalty managers. Every response carries the impact of
the change on the balances in circulation: outstanding EnakPoint and
EnakCoin, their rupiah value, and the coins exchanged into points, before
and after. With ?dry_run=true nothing is saved and the response lists the
keys that would change, for the warning shown before saving.
Saving records each change in loyalty_setting_changes with who made it;
history can be filtered to one outlet. Changing the value leaves what was
already written alone. The diff behind saving and previewing is shared.
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 POST /customer/wallet/payment-code (docs/prd-point-coin.md F9, K8,
PC-304). The customer approves with their PIN on their own phone and gets a
6-digit code, as digits and as a QR payload (enakpoint:<code>) for the app
to render, valid for two minutes. The PIN is never typed at the cashier.
Codes are drawn from crypto/rand and stored in Redis with SET NX and a TTL,
bound to the customer; a new code retires the previous one. Redeeming is a
single Lua step that uses the code up only if it belongs to the order's
customer, so it stays one-time under a race, and a cashier scanning it
against the wrong order does not burn it for its owner, which a plain
GETDEL would. Expired, used, unknown and other customers' codes are all
refused alike.
Tests run against miniredis, added as a test dependency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7,
PC-305).
POST /payments with the EnakPoint method now takes points and the
customer's payment code and goes through PointPaymentProcessor instead of
the generic path, which would record a payment without taking any balance.
After checking the order, its customer (not walk-in, active), the outlet
(accepts EnakPoint, minimum) and the method, it redeems the code, then in
one transaction locks the order row and the wallet, recomputes the F9
limits from fresh data, inserts the payment with points_used and the frozen
point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet,
the cashier) and updates the order. The limits are
min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid
with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than
what is left and gives no change.
Unlike the generic CreatePayment, which always marks the order paid, an
EnakPoint payment leaves it partial with the right remaining amount until
it is settled, so the rest can be paid in cash. Settling it triggers
earning, whose basis leaves out the EnakPoint part. Splitting with the
EnakPoint method is refused. Refusals answer 400. The payment response
carries points_used and point_value for the receipt.
GET /orders/:id/point-payment/preview returns eligibility, balance, point
value and the maximum for the use-maximum button.
The payment and order repositories write outside transactions, so this
path uses its own repository that joins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds POST /customer/orders/:id/pay-with-points (docs/prd-point-coin.md F9,
PC-306) for the customer app and self-order. It uses the same payment path
as the cashier, approved by the customer's PIN instead of a code: the
session alone is not enough (K8), and a wrong PIN takes nothing and counts
toward the lock.
A customer can pay only their own order; any other order, and one that
does not exist, answer 404 alike, so the endpoint does not reveal other
customers' orders. The method is the organization's EnakPoint method, no
cashier is recorded, and settling the order triggers earning through the
same onOrderPaid hook as every other payment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds refunds of EnakPoint payments (docs/prd-point-coin.md F9, K7, Q13,
PC-307).
After a void or refund, onOrderRefunded now returns EnakPoint before taking
earning back. For each EnakPoint payment of the order it returns everything
on a void, and floor(refunded rupiah / the frozen point_value) when the
payment itself was refunded, so a later change of the point value does not
change how many come back and a remainder below one EnakPoint is lost. It
never returns more than the payment used, and only what has not come back
yet, so repeating is safe. PAYMENT_REFUND rows point at the PAYMENT they
reverse, and the EnakPoint go back into lots with the expiry of the lots
they were taken from, longest-lasting first (the 7-day extension waits on
note N4).
RefundOrder, which hands money back in cash or another method, is now
limited to what was paid with other methods; the EnakPoint part has to be
refunded through its own payment. That answers 400.
Fixes earning reversal from PC-204: a refund of the EnakPoint part raised
orders.refund_amount and so took earning back, although that part never
earned. It is now left out of the refund the reversal uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 GET /customer/wallet/exchange/preview?coins= and
POST /customer/wallet/exchange (docs/prd-point-coin.md F4, K3, PC-401).
The customer exchanges a multiple of the organization's coin_amount and
gets (coins / coin_amount) x point_amount EnakPoint, approved by their PIN
(K8). A malformed amount is refused before the PIN is checked, so it costs
no attempt. In one transaction the wallet is locked, EXCHANGE_OUT takes the
EnakCoin in K9 order and EXCHANGE_IN adds the EnakPoint; the two rows share
a group, point at each other and both freeze the rate in their metadata.
The EnakPoint are split over the EnakCoin lots they came from, each part
keeping its lot's expiry and pointing back at it, so exchanging cannot
extend a balance's life. The split takes floor(coins so far x rate) per
lot, which adds up exactly because the total is a multiple of coin_amount.
EnakPoint have no validity of their own until the expiry model is decided
(N4), so the EnakCoin lot is for now the only bound.
The Idempotency-Key header (or X-Idempotency-Key) is required. A retry
with the same key is recognised under the wallet lock and replayed with
the ids and rate the first attempt froze, even if the rate has changed
since; the same key for another amount is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET /customer/wallet/transfer/recipient?phone= and
POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16,
PC-402).
The recipient is found by phone number and must be an active customer of
the same organization, not the walk-in customer and not the sender. A
number of another organization answers 404 like an unknown one, so the
check does not reveal who uses the app elsewhere. The recipient check
returns the name and number masked ("Bu*** Sa***", "08**-****-1234").
The organization's transfer settings apply: transfers turned off, the
minimum, the maximum per transaction and the daily limit per currency,
which starts over at midnight WIB. Everything the request alone can get
wrong is refused before the PIN, so it costs no attempt; the PIN then
refuses a transfer held for 24 hours after a PIN reset.
Both wallets are locked in customer_id order, so transfers in opposite
directions cannot deadlock, and the daily limit is summed under the lock.
TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN
gives the recipient lots with exactly the same expiries, pointing back at
the sender's lots. The rows share a group, reference each other and name
the other customer; descriptions carry only the masked name.
The Idempotency-Key header is required. A retry is recognised under the
lock before the daily limit, so it replays instead of counting twice; the
same key towards another recipient is refused.
The recipient is told by WhatsApp after the commit, as PIN locks are:
NotificationService only reaches staff devices, there is no push channel
to customers yet. A failure to send is logged, never undoes the transfer.
Transfers must not be released before note N3 (legal) is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Games now spend the wallet's EnakCoin instead of the per-type tokens
(docs/prd-point-coin.md F8, K1, PC-403).
GamePlayProcessor.PlayGame charges the game's metadata.coin_cost, 1 when
it is not set; a cost that is not a whole number of at least 1 refuses the
game. In one transaction it picks the prize, takes the EnakCoin with a
GAME_SPEND row pointing at the new game_plays.id (which locks the wallet,
so a customer's plays at the same time queue up), records the play and
takes the prize from stock. The play owns its transaction, so the spin
service no longer wraps it, and the admin play endpoint is now atomic too.
The game, game prize and game play repositories go through DBFromContext
so they join that transaction. DecreaseStock now reports a prize that ran
out (ErrGamePrizeOutOfStock) instead of silently updating nothing; that,
or any other stock failure, cancels the whole play, where it used to be
only printed. The manual AddTokens rollback is gone. Not enough EnakCoin,
an inactive game or a prize that ran out answer 400 on /customer/spin
instead of 500.
game_plays.token_used is renamed coins_used (migration 000095). What a
play costs is no longer the caller's choice, so PlayGameRequest loses
token_used. Responses carry coins_used and coins_remaining; token_used and
tokens_remaining stay as deprecated copies until the apps move over, and
sort_by=token_used still sorts by coins_used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md
F7, §8.1, PC-404).
From any ledger row of the organization, the trace lists the lots a debit
took from, with how much it took from each, or the lots a credit created.
Each lot is followed back through origin_lot_id, across transfers,
exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first
created. Every step shows the lot and the row that created it, with the
real name of the customer it belongs to, so the example of §8 (A sends 120
to B, B pays 30) leads from B's payment to A's order #ORD-1.
Lots are loaded a generation at a time, and a chain stops at 100 steps or
at a lot it has already seen, which only bad data could cause. A row of
another organization answers 404.
The dashboard's wallet view now builds its lots with the same helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recipient of a transfer now gets a push through FCM instead of a
WhatsApp message (docs/prd-point-coin.md F5).
Customers had nowhere to keep FCM tokens: user_devices only holds staff
devices. Migration 000096 adds customer_devices, and the customer app
registers with PUT /customer/devices { device_id, fcm_token, platform,
app_version } after login and whenever FCM refreshes the token, and
unregisters with DELETE /customer/devices/:device_id on logout. A token
belongs to one customer only: registering it takes it away from whoever
had it on that phone before, so they stop getting this customer's
notifications.
The push goes to every device of the recipient after the commit, titled
"EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the
TRANSFER_IN transaction id, the group id, the currency and the amount in
its data so the app can open it. A retried transfer sends nothing again. It
stays best effort: no device, FCM not configured or FCM failing is logged
and never undoes the transfer.
The app builds one FCM client and shares it between staff notifications
and customer pushes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A customer whose PIN locks after five wrong attempts is now told by a push
through FCM instead of WhatsApp (docs/prd-point-coin.md F11), to every
device registered at /customer/devices. The push is titled "PIN terkunci",
says until when it is locked, and carries type PIN_LOCKED and locked_until
in its data so the app can offer the PIN reset. As before, only the attempt
that reached the limit sends it, and a failure to send is logged without
affecting the lock.
OtpProcessor.SendWhatsAppMessage was only there for this alert and is
removed; OTPs still go out by WhatsApp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settles note N4 of docs/prd-point-coin.md: both expiry models are
supported, chosen per currency by the owner, defaulting to one fixed date a
year (PC-501, F12).
New organization keys, per currency (loyalty.point.* / loyalty.coin.*):
- expiry_mode: FIXED_DATE (default) or ROLLING.
- expiry_fixed_dates: the days of the year balances expire on, as sorted
MM-DD values ("12-31" by default, "06-30,12-31" for twice a year). 29 Feb
is refused.
- expiry_grace_months: 0 to 24, default 3. A balance lasts at least this
long before a fixed date takes it.
The existing period, unit and end_of_month keys now belong to ROLLING, and
reminder_days to both.
ComputeExpiry gives the expiry of a balance received at a time: the first
fixed date on or after the day received plus the grace months, or the day
received plus the period (to the end of that month when asked). Days are
the customer's (WIB), a shorter month keeps to its last day, and a lot
lasts to 23:59:59 of its day so the apps group it under that day. Nil when
expiry is off. ActivationExpiry, RefundExpiry and EarlierExpiry hold the
other decided rules and are used by PC-502.
GET and PUT /marketing/loyalty-settings return expiry_preview: when a
balance received now would expire, for the dashboard's "received today
expires on ..." hint, also on a dry run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:
- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
least seven days from the refund (N4, decided). A lot that never expired
stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.
Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.
The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the expiry job (docs/prd-point-coin.md F12, PC-503).
Every 15 minutes it lists the lots whose expiry has passed and that still
hold something, the longest overdue first, 500 at a time, and expires each
in its own transaction through WalletProcessor.ExpireLot: lock the wallet,
read the lot again, and take what is left with an EXPIRE row pointing at
the lot, keyed expire:{lot_id}. The description is frozen as
"Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read
under the lock. Lots expire at the end of their day, so none stays past it
for more than about a quarter of an hour.
It is safe on several instances and across restarts, keeping no state in
memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP
LOCKED, as PC-503 suggested, would lock a lot before its wallet and
deadlock against payments, which lock the wallet first; instead the
listing takes no lock, and the wallet lock plus the idempotency key make a
second instance find the lot empty or the key used and take nothing.
A lot that fails is logged and retried on the next run without stopping
the others. Each customer gets one FCM push per currency with the total
that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type
WALLET_EXPIRED).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12,
PC-504).
GET /customer/wallet/expiring lists everything that will expire, per
currency and day, soonest first. GET /customer/wallet already had the
nearest expiry per currency.
The expiry job now also sends reminders, with the settings of note N4 as
decided: once, reminder_days before (7 by default, 0 for none), per
currency. A customer gets one FCM push per currency and expiry day,
however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt
2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency,
amount and expiry_date in its data. Reminders cover whatever falls within
the window, so a run that was missed catches up rather than skipping a day.
Migration 000097 adds wallet_expiry_reminders, one row per customer,
currency and expiry day. The row is written before the push is sent, so
several instances of the job or a restart never remind twice; a push that
then fails is logged and not retried. Lots that expire later on the same
day as an earlier reminder are not reminded of again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First part of PC-601 (docs/prd-point-coin.md §10.7): the code that has
had no way in since balances moved to the wallet.
- The /marketing/customer-points and /marketing/customer-tokens routes
were commented out; their 16 handler methods, the GamificationService
methods behind them, and the validators, transformers, mappers and
contract/model types only they used are gone.
- CustomerPointsProcessor loses its "not implemented" stubs; it keeps the
customer app's balance, wallet and games endpoints.
- CustomerTokensProcessor and the customer points and tokens repositories,
wired but no longer called by anything, are gone.
What stays until its preconditions are met: the customer_points and
customer_tokens tables and their entities, which cmd/wallet-migrate still
reads, and the /customer/points, /customer/tokens aliases and the
token_used / tokens_remaining fields, until the apps no longer use them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds docs/integration-enakpoint.md for the customer app, POS and dashboard
teams (PC-602), in the style of the weight-based products guide.
It covers the response envelope and error codes, balances and history with
every ledger type, the expiring list and FCM push types with their data,
the PIN flows and the four PIN error codes, paying with EnakPoint at the
cashier (payment code, preview, POST /payments) and in the app, void and
refund rules, exchange and transfer with Idempotency-Key, games on
EnakCoin, the deprecated endpoints and fields with their replacements, the
dashboard's outlet and organization settings including both expiry models,
the customer wallet, adjustments, trace and PIN removal, and a checklist
per team.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds docs/api-enakpoint.md, the endpoint reference for the customer app,
POS and dashboard, and docs/backoffice-enakpoint.md, the screens the
backoffice needs: outlet and organization settings with the save flow and
impact dialog, both expiry models, the customer wallet with adjustment and
trace, PIN removal and security log, settings history, game coin_cost and
the EnakPoint payment method.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Adds CalculateEarning (docs/prd-point-coin.md F1, Q1, Q10, PC-202), a pure function returning, per currency, the amount an order earns and the settings that produced it, plus the basis: basis = subtotal − discount − paid with EnakPoint (never negative) amount = 0 below min_order_amount, else floor(basis / earn_per_amount) × earn_value, capped by max_per_order Tax and anything added on top of the subtotal are not part of the basis, and the part paid with EnakPoint earns nothing. Money is handled in whole cents: in float64 some baskets divide to 4956.999… and a naive floor would lose a point, which a test reproduces. Metadata() gives the snapshot the EARN row will freeze. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Adds earning at payment time (docs/prd-point-coin.md F3, PC-203). An order becomes fully paid through UpdateOrder, CreatePayment and both kinds of split bill. All of them now go through one OrderProcessorImpl hook, onOrderPaid, called after the payment has committed; for CreatePayment that is after its transaction, not from updateOrderStatus inside it. The hook runs detached from the caller's transaction and from the request being cancelled, and it runs synchronously so the order response can show what was earned. EarningProcessor.EarnForOrder skips orders that are not paid, are void, have no customer, or whose customer is the walk-in customer or inactive. It computes the earning with CalculateEarning, subtracting any part paid with EnakPoint (none until phase 3), and credits each currency through the wallet engine as EARN with key earn:{order_id}:{currency} and the settings snapshot in metadata. A repeat, even concurrent, credits nothing more. OnOrderPaid never fails the payment: errors and panics are logged. EarningBackfillJob is the safety net: every 30 minutes it earns for orders paid in the last three days that have no EARN row. It only looks at outlets with earning switched on and pages by (updated_at, id), so orders that correctly earned nothing cannot starve the ones that were missed. Lots from earning never expire until the expiry model is decided (F12, note N4). Adds the point payment method type constant, not yet accepted as a payment method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7, PC-305). POST /payments with the EnakPoint method now takes points and the customer's payment code and goes through PointPaymentProcessor instead of the generic path, which would record a payment without taking any balance. After checking the order, its customer (not walk-in, active), the outlet (accepts EnakPoint, minimum) and the method, it redeems the code, then in one transaction locks the order row and the wallet, recomputes the F9 limits from fresh data, inserts the payment with points_used and the frozen point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet, the cashier) and updates the order. The limits are min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than what is left and gives no change. Unlike the generic CreatePayment, which always marks the order paid, an EnakPoint payment leaves it partial with the right remaining amount until it is settled, so the rest can be paid in cash. Settling it triggers earning, whose basis leaves out the EnakPoint part. Splitting with the EnakPoint method is refused. Refusals answer 400. The payment response carries points_used and point_value for the receipt. GET /orders/:id/point-payment/preview returns eligibility, balance, point value and the maximum for the use-maximum button. The payment and order repositories write outside transactions, so this path uses its own repository that joins it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Adds GET /customer/wallet/transfer/recipient?phone= and POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16, PC-402). The recipient is found by phone number and must be an active customer of the same organization, not the walk-in customer and not the sender. A number of another organization answers 404 like an unknown one, so the check does not reveal who uses the app elsewhere. The recipient check returns the name and number masked ("Bu*** Sa***", "08**-****-1234"). The organization's transfer settings apply: transfers turned off, the minimum, the maximum per transaction and the daily limit per currency, which starts over at midnight WIB. Everything the request alone can get wrong is refused before the PIN, so it costs no attempt; the PIN then refuses a transfer held for 24 hours after a PIN reset. Both wallets are locked in customer_id order, so transfers in opposite directions cannot deadlock, and the daily limit is summed under the lock. TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing back at the sender's lots. The rows share a group, reference each other and name the other customer; descriptions carry only the masked name. The Idempotency-Key header is required. A retry is recognised under the lock before the daily limit, so it replays instead of counting twice; the same key towards another recipient is refused. The recipient is told by WhatsApp after the commit, as PIN locks are: NotificationService only reaches staff devices, there is no push channel to customers yet. A failure to send is logged, never undoes the transfer. Transfers must not be released before note N3 (legal) is closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>The recipient of a transfer now gets a push through FCM instead of a WhatsApp message (docs/prd-point-coin.md F5). Customers had nowhere to keep FCM tokens: user_devices only holds staff devices. Migration 000096 adds customer_devices, and the customer app registers with PUT /customer/devices { device_id, fcm_token, platform, app_version } after login and whenever FCM refreshes the token, and unregisters with DELETE /customer/devices/:device_id on logout. A token belongs to one customer only: registering it takes it away from whoever had it on that phone before, so they stop getting this customer's notifications. The push goes to every device of the recipient after the commit, titled "EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the TRANSFER_IN transaction id, the group id, the currency and the amount in its data so the app can open it. A retried transfer sends nothing again. It stays best effort: no device, FCM not configured or FCM failing is logged and never undoes the transfer. The app builds one FCM client and shares it between staff notifications and customer pushes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Settles note N4 of docs/prd-point-coin.md: both expiry models are supported, chosen per currency by the owner, defaulting to one fixed date a year (PC-501, F12). New organization keys, per currency (loyalty.point.* / loyalty.coin.*): - expiry_mode: FIXED_DATE (default) or ROLLING. - expiry_fixed_dates: the days of the year balances expire on, as sorted MM-DD values ("12-31" by default, "06-30,12-31" for twice a year). 29 Feb is refused. - expiry_grace_months: 0 to 24, default 3. A balance lasts at least this long before a fixed date takes it. The existing period, unit and end_of_month keys now belong to ROLLING, and reminder_days to both. ComputeExpiry gives the expiry of a balance received at a time: the first fixed date on or after the day received plus the grace months, or the day received plus the period (to the end of that month when asked). Days are the customer's (WIB), a shorter month keeps to its last day, and a lot lasts to 23:59:59 of its day so the apps group it under that day. Nil when expiry is off. ActivationExpiry, RefundExpiry and EarlierExpiry hold the other decided rules and are used by PC-502. GET and PUT /marketing/loyalty-settings return expiry_preview: when a balance received now would expire, for the dashboard's "received today expires on ..." hint, also on a dry run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Adds the expiry job (docs/prd-point-coin.md F12, PC-503). Every 15 minutes it lists the lots whose expiry has passed and that still hold something, the longest overdue first, 500 at a time, and expires each in its own transaction through WalletProcessor.ExpireLot: lock the wallet, read the lot again, and take what is left with an EXPIRE row pointing at the lot, keyed expire:{lot_id}. The description is frozen as "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read under the lock. Lots expire at the end of their day, so none stays past it for more than about a quarter of an hour. It is safe on several instances and across restarts, keeping no state in memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP LOCKED, as PC-503 suggested, would lock a lot before its wallet and deadlock against payments, which lock the wallet first; instead the listing takes no lock, and the wallet lock plus the idempotency key make a second instance find the lot empty or the key used and take nothing. A lot that fails is logged and retried on the next run without stopping the others. Each customer gets one FCM push per currency with the total that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type WALLET_EXPIRED). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>