Commit Graph
7 Commits
Author SHA1 Message Date
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
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>
2026-10-07 20:53:14 +07:00
efrilm 582dc75543 Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
2026-09-30 15:31:44 +07:00
efrilmandClaude Opus 5.5 4e24f9bbb0 Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit 645da30, returning main to f0ff59f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:16:15 +07:00
efrilmandClaude Opus 5.5 4d63673a25 feat(loyalty): give new balances their expiry
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>
2026-09-30 13:29:25 +07:00
efrilmandClaude Opus 5.5 bf9651e221 feat(loyalty): notify transfer recipients through FCM
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>
2026-09-30 12:30:14 +07:00
efrilmandClaude Opus 5.5 694d65b6d8 feat(loyalty): send EnakPoint and EnakCoin to another customer
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>
2026-09-30 12:14:32 +07:00
efrilmandClaude Opus 5.5 ab3425070b feat(loyalty): exchange EnakCoin into EnakPoint
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>
2026-09-30 12:09:53 +07:00