A wrong password or an unknown phone number was answered 900 (HTTP 500), like a
server error, so clients could only tell them apart by the cause text. Both are
now 304 (HTTP 400) from customer_auth_service with one cause, "invalid phone
number or password", so a login does not tell which numbers have an account. A
customer who never set a password gets 304 "customer not properly registered".
Anything else stays 900.
Login is limited per phone number: 5 attempts in 15 minutes, counted in Redis
before the password is checked, so attempts sent at once all count, and for
numbers without a customer too. The sixth is refused with 429 and
data.locked_until, even with the right password, until the window ends. A
successful login starts the count again. Since the number is normalized first,
0812… and 62812… count as one. When Redis fails, logins go on unlimited and the
error is logged.
There is no limit per IP: the client IP comes from X-Forwarded-For, which anyone
can set while no trusted proxies are configured.
Tested over HTTP with fakes; the Redis commands were checked against miniredis
outside the repo, not against a real Redis.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003).
Spin (EG-1001)
- PROBABILITY entries take an optional label (a wheel segment). The customer game
list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and
completing returns the drawn prize, so the client can draw the wheel and stop it
on the server's draw.
- docs/enakgame-spin.md: the admin steps to set up spin per organization (no
seeder) and the customer app flow. An HTTP test plays it end to end.
Old game flow removed (EG-1002)
- Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and
admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their
handlers, services, processors, repositories, validators, models, contracts,
mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also
closes RFC §15 findings 1 and 2 (double charge, spinning another org's game).
- Tables games, game_prizes, game_plays and rewards stay for ledger history.
entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps
game_id but no longer embeds the old game response.
games.is_active dropped (EG-1003)
- Migration 000115; nothing reads metadata.coin_cost any more.
The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests
were not run: no test database here. Migration 000115 has not been run anywhere.
The customer app must stop calling the removed endpoints before this is deployed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903).
Budget Controller (EG-901, EG-902)
- GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the
multiplier (budget − realized) / (forecast − realized), within one step of 1,
rounded down to two decimals, either way. Shows each game's new rules.
- POST .../recommendation/accept with the multiplier the admin saw: recomputed in the
transaction, then one new ACTIVE version per game, the old one RETIRED, audited
with source budget_controller and RECOMMENDATION_ACCEPTED on the budget.
- Migration 000112: base_config_id, multiplier and budget_id on
game_reward_configs. Rules are always scaled from the admin's last version, so
rounding does not compound and min/max are against what the admin set.
- Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier
50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4.
- RewardCalculator.Scale for the four reward types: amounts only, rounded down.
Analytics (EG-903)
- GET /marketing/enakgame/analytics/games and /analytics/economy over a range of
Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger.
- Migrations 000113 (game_sessions by organization and start) and 000114
(wallet_transactions by organization and time, CONCURRENTLY).
The Postgres tests for accepting and analytics were not run: no test database here.
Migrations 000112-000114 have not been run anywhere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).
Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
entry cost and result rules, old games archived (not deleted); budgets,
versioned reward configs, sessions and session rewards; the ledger types
GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.
Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
status, one ACTIVE per game), budgets with non-overlapping global periods and
a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
frozen on the session), complete (result validation, reward engine, max_reward
cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
rounded down.
Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.
Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
stack per PRD §16 defaults, with event and per-customer limits.
External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
tested with a fake provider. No provider adapter is registered yet, so
EXTERNAL vouchers stay out of the catalog.
Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.
Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnakPoint can only be redeemed for vouchers now: it can no longer pay for
orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001,
EG-002). No order was ever paid with EnakPoint, so there is no data to
move.
Removed:
- POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points
and GET /orders/:id/point-payment/preview, with their processors,
repositories, services, handlers and tests.
- The point payment method type: paying, splitting and refunding with it,
the outlet filter on the method list, and the system-method guard.
- points and payment_code on CreatePayment; points_used and point_value
on payments; accepts_point_payment on the customer outlets.
- The outlet point_payment settings. A PUT that still sends them is
rejected as an unknown field.
- The EnakPoint split in the payment method analytics.
- PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used
them as a generic EnakPoint debit use REWARD_REDEEM.
- The EnakPoint-paid part from the earning basis, which is
subtotal − discount again.
Migration 000102 drops the trigger, the point methods and their index,
the payments columns, and the outlet settings, and restores the method
type CHECK without point. payments.payment_method_id is ON DELETE
RESTRICT, so it fails rather than lose a payment made with EnakPoint.
The integration docs list the removed endpoints and fields, and the
EnakPoint & EnakCoin PRD and tasks note what is superseded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens are EnakCoin and no app uses the token names any more, so their
compatibility layer goes:
- GET /customer/tokens and its handler, service, processor and response
types.
- total_tokens and tokens_history on GET /customer/wallet; last_updated
now comes from the most recent row of either currency.
- token_used and tokens_remaining on game and spin responses, and
sort_by=token_used on the game play list.
- TOKENS as a campaign type and reward type, with the mapping to COINS:
migration 000092 already renamed the stored values.
The customer_tokens table and its entity stay, as cmd/wallet-migrate still
reads them, and LEGACY_TOKENS stays as the reference of the MIGRATION rows
it wrote. The docs list the removed names and their replacements.
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 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 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 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 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 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>
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>