Skip to content

Points & membership

Every signed-in guest is a member with a points balance. The program is split in two:

  • Core decides the “who”: the member list and each member’s balance. It is the system of record.
  • The engagement partner (Sota) decides the “how”: rewards, raffles, redemptions and campaigns. It adds and deducts points through core’s Points API and keeps its own records of draws, entries and prizes.

Points are not money. They can’t be bought or cashed out, which keeps them outside Indonesia’s e-money rules.

flowchart LR
  classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
  classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
  classDef person fill:#E9724C,stroke:#C55A37,color:#ffffff
  classDef ledger fill:#040809,stroke:#040809,color:#71CBCC

  gate["Gate check-in<br/>granted"]:::ext
  book["Booking engine<br/>booking paid"]:::ext
  sota["Sota Platforms<br/>rewards, raffles, games"]:::ext
  staff(["Staff<br/>admin console"]):::person

  gate -- "points per check-in<br/>client CORE · reason check_in" --> L
  book -- "points per paid booking<br/>client CORE · reason booking" --> L
  sota -- "credit / debit / refund<br/>client PARTNER" --> L
  staff -- "credit / debit<br/>client STAFF · staff_adjustment" --> L

  L[("<b>Points ledger</b><br/>in core")]:::ledger
  L -- "points.posted events<br/>(pull feed)" --> sota
  L -- "GET /app/points" --> app["Guest app<br/>membership card"]:::ours
Source Example rule
Check-in A fixed number of points per granted scan, once per QR
Booking Points per IDR 10,000 paid, rounded down
Partner postings Any amount within the API’s limits per credit or debit

All numbers here are examples, not commitments. Earn rates are configuration (finns.points.earn.*), not code. Setting the check-in rate to 0 turns it off.

Who posts client Can credit Can debit Can refund Idempotency
Engagement partner PARTNER ✓ ✓ its own postings, once, in full Idempotency-Key header, partner’s own scope
Staff (admin console) STAFF ✓ ✓ ✗ (post the opposite adjustment) Per staff member
Core itself CORE ✓ (check-in, booking) ✗ ✗ <reason>:<reference>

Each posting is a transaction with two entries that sum to zero: one on the member’s account and one on a system account. Credits come from ISSUED; debits go to REDEEMED. At any moment, points issued minus points redeemed equals the sum of all member balances.

flowchart LR
  classDef sys fill:#F3ECDF,stroke:#B9A88B,color:#12191B
  classDef acct fill:#E3F4F3,stroke:#13686C,color:#12191B

  ISSUED["ISSUED<br/>system account"]:::sys
  M["Member account<br/>balance ≥ 0"]:::acct
  REDEEMED["REDEEMED<br/>system account"]:::sys

  ISSUED -- "CREDIT +n<br/>entries: ISSUED −n · member +n" --> M
  M -- "DEBIT −n<br/>entries: member −n · REDEEMED +n" --> REDEEMED

A posting runs as one PostgreSQL transaction:

  1. Claim the idempotency key (insert … on conflict do nothing; a concurrent request with the same key waits).
  2. Move the member’s balance with one conditional update (balance + delta >= 0). This row is also the member’s lock: one member’s postings queue, different members’ run in parallel.
  3. Insert the two entries.
  4. Append a points.posted event to the feed.

If anything fails, everything rolls back, including the key, so only successful postings ever replay.

The engagement partner keeps its own copy of balances. Core offers two pull mechanisms; nothing is pushed.

sequenceDiagram
  autonumber
  box rgb(246,241,233) Sota Platforms
    participant P as Partner backend
  end
  box rgb(227,244,243) FINNS platform
    participant CORE as Core
  end

  loop Polling, at a pace the partner chooses
    P->>CORE: GET /api/v1/points/events?after=<cursor>&limit=100
    CORE-->>P: {events: [customer.created, points.posted…], next}
    Note over P: nowrap:Keep the highest seq per member.<br/>Store next as the cursor.
  end
  Note over P,CORE: nowrap:After midnight in Bali
  P->>CORE: GET /api/v1/points/reconciliation/2026-10-12
  CORE-->>P: {credits, debits, refunds: {count, points}, transactionsSha256}
  Note over P: nowrap:Compare totals and the hash<br/>of the day's transaction ids
  • The feed is served in commit order, and only from transactions older than any still running, so a reader never skips an event that commits late. Events are kept forever: reading from the start rebuilds a full copy.
  • Feed order isn’t always one member’s posting order, so consumers keep the highest seq per member.
  • Daily reconciliation totals are final once the Bali day has ended.
  • Database rules: entries sum to zero, balances never go negative, history is never updated or deleted. See Core data model.
  • Daily check at 02:30 Bali time: every cached member balance is recomputed from its entries, and all entries must sum to zero. A mismatch logs points.check_failed at ERROR, meant to page someone. One instance runs it, under an advisory lock.

Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).