integration-enakgame.md was only the flow of a play. It now also has:
- §2 the games: Spin is the only one; what each reward_type needs from the
client at complete; what to agree on to register a new game.
- §3 the endpoints the client calls, in one table.
- §5 tokens: with no token, or one the backend refuses, the game shows a
customer login (POST /customer-auth/login) and repeats the request once.
Standalone mode for a browser without the app finds its game_id by slug. The
login errors (304, 429 with locked_until), the attempt limit and the phone
formats accepted. A JS helper for all of it.
The bridge loses token_expired and token: the game logs the customer in
itself. Sections are renumbered.
integration-mobile-customer.md: customer phone numbers are 62… (§2), a login
section with its errors and the change from 900 to 304 (§2.4), 62… examples,
and init carrying the access token. integration-backoffice.md: example
responses are the data field, so a list is data.data in a raw response.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
The EnakGame client runs at its game_url, another origin than the API, so the
browser asks before POST /customer/enakgame/sessions. Idempotency-Key was not in
Access-Control-Allow-Headers, so the preflight refused it and a play could not
start. X-Idempotency-Key, the older name the backend also reads, is allowed too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /customer/enakgame/sessions takes optional game_id and status, so a game
reloaded mid-play finds the session it was running (status=STARTED) instead
of starting a new one and charging EnakCoin again. An invalid game_id or
status is refused.
integration-enakgame.md §4.4 now describes recovery after a reload: keep the
session_id in sessionStorage, continue a STARTED session before expires_at,
and call complete again for a COMPLETED one to get the full answer, prize
included. The mobile guide and RFC §11 mention the filters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One guide per team, covering EnakPoint, EnakCoin, EnakGame and vouchers:
- integration-mobile-customer.md: wallet, history (with the game and voucher
ledger types), push, PIN, exchange, transfer, game list and webview, play
history, voucher catalog, redeem and my vouchers.
- integration-pos.md: linking customers to orders, earning, receipts,
void/refund, and vouchers as a known gap (no POS endpoint to mark one used).
- integration-enakgame.md: the Phaser client's side of a play: start with
Idempotency-Key, complete, rewards, spin, expiry and refunds, retries.
- integration-backoffice.md: loyalty settings and customer wallets, plus
games, reward configs, spin setup, budgets, metrics and recommendations,
events, vouchers and code import, analytics.
The JS bridge between the app and the game is a proposal both teams still
have to agree on. Replaces api-enakpoint.md, integration-enakpoint.md,
mobile-customer-enakpoint.md, backoffice-enakpoint.md and enakgame-spin.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vouchers are what EnakPoint is redeemed for, wherever it came from, so they are
not part of EnakGame. Their only link to it is the budget attribution, which
does not change.
- Admin: /marketing/enakgame/vouchers... -> /marketing/vouchers...
- Customer: /customer/enakgame/vouchers -> /customer/vouchers,
/customer/enakgame/vouchers/:id/redeem -> /customer/vouchers/:id/redeem,
/customer/enakgame/redemptions -> /customer/vouchers/redemptions
Roles, handlers and logic stay the same. No client calls these endpoints yet.
RFC §7.4 and §11 updated.
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>
EnakGame: pay EnakCoin to play, earn EnakCoin from the result, exchange
into EnakPoint, and redeem EnakPoint for vouchers only.
- enakgame-prd.md: economy and business rules, including entry cost and
automatic refund, monthly global budget with a separate budget per
event (event = campaign), and EnakPoint being voucher-only.
- rfc-enakgame.md: built on the existing wallet, ledger and lots. Game
sessions with a state machine, versioned reward configs, Economy Guard
counters, vouchers with internal codes and external providers, and
realized cost attributed to budgets by tracing the lots spent.
- tasks-enakgame.md: EG-001 to EG-1003 in eleven phases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CreateOrderContractToModel never copied customer_id, so every order from
POST /orders was saved without a customer. Paying it then earned no
EnakPoint or EnakCoin (skipped as NO_CUSTOMER, which is not logged).
The customer must now belong to the order's organization, as
SetOrderCustomer already requires: it is who earns once the order is paid,
and orders.customer_id has no foreign key. A nil UUID means no customer.
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>
The HEALTHCHECK curled localhost:3300/health, but the app listens on 4000
(server.port in infra/*.yaml, EXPOSE 4000). The check always failed, so
deployment.sh waited on 'Waiting for healthcheck...', found the container
unhealthy and rolled back to an image with the same broken check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds docs/mobile-customer-enakpoint.md, written as a brief for building the
customer app: the UI rules, the API conventions, each screen with its
requests and responses, push handling, PIN flows, paying at the cashier,
exchange, transfer, games, what was removed, and a checklist. It covers the
new GET /customer/outlets, GET /customer/orders and /customer/orders/:id,
and the optional organization_id at registration, which the API reference
now lists too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET /customer/orders and GET /customer/orders/:id for the customer
app: the orders linked to the logged-in customer across their
organization's outlets, newest first, paginated (limit 1-100, default 20).
The list shows the order number, outlet, type, status, total, item count,
void/refund flags and the EnakPoint and EnakCoin it earned. The detail adds
the amounts, the items with product and variant names, weight and unit
for weighed lines, modifiers and notes, and the payments with the method
and, for EnakPoint, the points used. Costs, cashier and other internal
fields are left out. Another customer's order answers 404 like one that
does not exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET /customer/outlets for the customer app: the active outlets of the
customer's organization, where their EnakPoint and EnakCoin can be used,
sorted by name. Each carries its name and address, and from the outlet's
loyalty settings whether the cashier accepts EnakPoint and whether orders
there earn EnakPoint or EnakCoin. Nothing internal (printer settings, tax
rate) is exposed.
The outlets list under /outlets needs a staff token, so the app had no way
to show where the wallet works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requiring organization_id broke the current app, which does not send it.
When it is left out and the database has exactly one organization, the
customer now joins that one, so the app works unchanged. A sent
organization_id must still exist, and with several organizations and none
sent registration is refused with a clear message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Registration put every new customer into a hardcoded organization id,
which does not exist in staging, so set-password failed on the
fk_customers_organization foreign key with a 500.
POST /customer-auth/register/start now takes organization_id, the
organization the app is built for. It must be a UUID of an existing
organization, checked before the OTP is sent; it is kept in the OTP
session and set-password creates the customer there. A registration
started before this change has no organization in its session and is asked
to start again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging staging brought back, through its own re-apply of #32, the TOKENS
campaign mapping, its test and the token_used sort fallback that c988a79
had removed, because those hunks did not conflict. This restores the five
files to c988a79.
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>
Deploying staging from a folder checked out on main replaced the
production container, because the environment was inferred from the
current branch and both environments shared the container name
"apskel-pos".
- Environment is now a required argument (staging|production)
- Refuse to deploy when the branch doesn't match, HEAD is detached,
or the tree is dirty
- Container name per environment; the legacy "apskel-pos" container
is only removed by production deploys
- Abort if the port is held by another environment's container
- Confirmation prompt for production (--yes to skip)
- Fast-forward-only pull from origin/<branch>
- Keep the previous image and roll back automatically when the new
container is not healthy; --rollback for manual rollback
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>
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>
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 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>