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.
Where points come from
Section titled “Where points come from”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> |
Double-entry ledger
Section titled “Double-entry ledger”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:
- Claim the idempotency key (
insert … on conflict do nothing; a concurrent request with the same key waits). - 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. - Insert the two entries.
- Append a
points.postedevent to the feed.
If anything fails, everything rolls back, including the key, so only successful postings ever replay.
Keeping partners in sync
Section titled “Keeping partners in sync”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
seqper member. - Daily reconciliation totals are final once the Bali day has ended.
Integrity checks
Section titled “Integrity checks”- 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_failedatERROR, 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).

