A customer's phone number (customers.phone_number, the one they log in with) has
one stored form, 62 followed by the number without its 0: 6281234561234.
util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…,
with spaces, dashes, dots or parentheses, to it, and refuses anything that is not
an Indonesian mobile number (628 and 7 to 11 more digits).
It is applied wherever a customer types their number: check-phone, register,
login and resend-OTP (the validator rewrites the request), and the transfer
recipient. Before, the number was matched as typed and a leading 0 was refused,
so the same customer written another way was not found. The masked recipient
stays 08**-****-1234 as customers write numbers.
Migration 000116 rewrites the numbers already stored in customers and
otp_sessions. When several become the same number, the customer that already has
it keeps it, else a registered one, else the oldest; the others, and numbers that
are not Indonesian mobile numbers, are left as they are and cannot log in until
fixed by hand (the query to find them is in the migration). Down does nothing.
The migration was run, twice, against Postgres with a reduced customers and
otp_sessions schema and sample numbers; not against the real schema. The
Postgres tests were not run. customers.phone (the POS contact number) is not
touched.
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>
Production allows edc and delivery payment methods through a
payment_methods_type_check changed outside the migrations, and has rows of
both. 000094 rewrote the constraint with only cash, card, digital_wallet and
point, so it failed on production (in its transaction, leaving the database
dirty at 94 with nothing applied).
000094 now keeps edc and delivery, down included, and also allows qr, which
the code accepts but no constraint did. 000098 sets the same list where the
old 000094 already ran, as on staging.
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>
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>
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 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 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 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>