Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/adr/0033-analytics-as-a-read-only-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
**Date**: 2026-07-25
**Status**: Accepted

> **Update (2026-11-04)**: The ledger GGR this ADR names is now the official GGR, which also takes
> off voided stakes and adds back wins taken back. The definition is in
> [ADR-0050](./0050-wallet-ggr-is-the-official-ggr.md); the wallet ledger stays its source.
> **Update (2026-10-13)**: `SERVICE_MANIFEST=analytics` alone does not boot: the manifest must also list the modules analytics depends on (wallet, identity, profile, gaming) and theirs. The boot names every missing one ([ADR-0017](./0017-extraction-readiness-manifest-outbox-command-ports.md)).

## Context
Expand Down
7 changes: 7 additions & 0 deletions docs/adr/0047-sportsbook-provider-promotions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
**Date**: 2026-10-16
**Status**: Accepted

> **Update (2026-11-04)**: the player summary reports a net result, not GGR. ADR-0050 makes the
> wallet ledger's GGR the one figure to report as GGR. The player summary's figure below keeps its
> rule, cash stake minus payout at each bet's latest settlement, and is now called the net result:
> a per-player activity figure, not revenue. The financial summary shows what sportsbook promotions
> paid inside GGR, read from the same transaction rows as the promotion report. The decision below
> is otherwise unchanged.

## Context

A turnkey sportsbook provider runs its own promotions (freebets, free money, no-risk bets, payout
Expand Down
52 changes: 52 additions & 0 deletions docs/adr/0050-wallet-ggr-is-the-official-ggr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR-0050: Wallet GGR Is the Official GGR

**Date**: 2026-11-04
**Status**: Accepted

## Context

Three reports total stakes and payouts, and each counts a bet differently:

- The financial summary and GGR trend read the wallet ledger. A stake counts when it is debited, a
payout when it is credited, and a reversal when it is booked.
- Revenue by product reads each product's own records. A sportsbook stake counts when the provider
accepts the bet, a payout when it settles, and a reopen takes the payout back.
- The sportsbook player summary reads bet rows as they stand now. A payout counts from each bet's
latest settlement (ADR-0047).

All three were called GGR. For the same period they can disagree, and an operator reporting to a
regulator needs one number that reconciles.

A sportsbook provider's freebet adds a second question. Its stake costs the player nothing, but
its win is withdrawable money the operator pays, booked as a plain win on the wallet (ADR-0047).
Splitting that win into a bonus credit would make GGR exclude it, but it would also change the
player's transaction list, net deposits and the debt paths for a cost the sportsbook rows already
record.

## Decision

- **The wallet ledger's GGR is the official GGR.** Stakes less voided stakes less wins, plus wins
taken back, over the ledger, per currency. It is the one figure to report as GGR. It is
append-only, ties to balances, and uses one rule for every product.
- **Cash basis.** A stake counts when the wallet debits it and a payout when the wallet credits it.
A reversal lands in the period it is booked, so a closed period never changes.
- **Sportsbook promotion wins are real wins inside GGR.** They stay plain wins on the wallet. The
financial summary shows what promotions paid, net of reopens, as its own figure, read from the
sportsbook's append-only transaction rows through the same optional seam the products report
uses. It is already inside GGR and is never deducted again. It takes a reopen's whole promotion
part back, as the promotion report does, so it can be lower than GGR's promotion share by
sportsbook debt the wallet never collected.
- **The other figures keep their basis.** Revenue by product is labelled settled basis; its
per-product field keeps the name GGR until it moves onto wallet rows. The player summary's figure
is renamed to net result and described as per-player activity, not revenue.

## Consequences

- Dashboards and regulator returns read GGR from one place, and it reconciles to balances.
- The player summary's contract changes: its GGR field is renamed to net result, a breaking change
for anyone reading it.
- Revenue by product can still differ from GGR in a period. Moving it onto wallet rows needs the
wallet to record which product each stake came from.
- NGR stays GGR less bonus cost. It does not deduct cashback credits.
- An analytics process without the sportsbook module reports no promotion figure, the same way the
products report omits sportsbook.
71 changes: 71 additions & 0 deletions docs/modules/analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Analytics

The read side over money and player data other modules own: financial reports and the
registration funnel. It owns no tables and moves no money. ADR-0033 records why it is its own
domain, and ADR-0050 why the wallet's GGR is the official one.

`docs/catalog.json` is the exhaustive list of this module's routes and agent tools. This file does
not repeat it.

## Rules every report keeps

- **One currency at a time.** The platform holds no exchange rates for reporting, so every money
figure is grouped by currency and never summed across currencies.
- **Short-lived cache.** Reports are dashboards on a refresh interval and may lag the ledger by up
to a minute.

## The official GGR

GGR is read from the wallet ledger: stakes, less stakes returned by a void, less wins, plus wins
taken back. This is the one figure to call GGR in a report, a dashboard or a regulator return. The
financial summary, the GGR trend and the agent's GGR summary all return it.

- **Cash basis.** A stake counts when the wallet debits it and a payout when the wallet credits it.
A reversal counts in the period it is booked, so a closed period never changes.
- **One rule for every product.** Casino, house games and sportsbook are counted alike, from the
money that moved, not from each product's own records.
- **It reconciles to balances.** The ledger is append-only and every balance change has a row, so
GGR ties to what players hold.
- **Sportsbook promotion wins are real wins.** A provider's freebet costs the player nothing, but
its win is withdrawable money the operator pays, so it is a win inside GGR like any other. A
promotion-paid stake moves no money and adds nothing to stakes (ADR-0047).

## The financial summary

Per currency, over the range:

- **Deposits and withdrawals,** completed ones only, split by rail.
- **Net deposits:** deposits less withdrawals less bonus credits.
- **GGR,** as above.
- **Bonus cost:** platform bonus credits.
- **Of which sportsbook promotions:** what sportsbook provider promotions paid, read from the
sportsbook's append-only bet transactions, the same total the sportsbook promotion report shows.
A settlement adds what its promotion paid, and a reopen takes it back in the reopen's period.
It is already inside GGR, not in bonus cost, and is never deducted again. A reopen takes back its
whole promotion part even when the player's balance could not cover it, while GGR takes back only
what the wallet collected, so the figure can be lower than GGR's promotion share by uncollected or
written-off sportsbook debt. It is empty when the sportsbook module is not loaded.
- **NGR:** GGR less bonus cost. It does not deduct cashback credits.

## Other figures that are not the official GGR

Two other reports total stakes and payouts. Each answers its own question, and neither is the
official GGR.

- **Revenue by product,** on a settled basis. Its per-product stakes less payouts keeps the field
name GGR, but it is not the official figure. A completed casino or house-game round counts by its
start, from the round's own stake and win. A sportsbook stake counts when the provider accepts
the bet, only its cash part, and a payout when the bet settles; a reopen takes the payout back in
its own period. A stake reserved but never accepted is left out. Its total can differ from GGR in
any period, because GGR counts a sportsbook stake at reserve and its reversals as the wallet
books them.
- **A player's sportsbook net result,** in the sportsbook module. It is the cash stake of the bets
staked in the range, less the payout of each bet's latest settlement: a per-player activity
figure for support and risk staff, not revenue. A reopened bet's uncollected reversal is not in
it (see `docs/modules/sportsbook.md`).

## The conversion funnel

A cohort of players who registered in the range, followed through email verification, first
deposit and first bet. Each step counts only players who passed the one before, so the funnel is
strictly nested.
18 changes: 11 additions & 7 deletions docs/modules/sportsbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ fields. This file does not repeat it.
range and none is open. With `dateFrom` and `dateTo`, a stake counts in the period its bet was
accepted, or reserved while it is not yet accepted, and a payout in the period it settled; a
cancelled bet counts in neither. Open exposure is the stake of every reserved or accepted,
unsettled bet now, whatever the range. GGR is cash stake minus payout, the wallet ledger's rule
applied per bet: a promotion-paid stake earns nothing and a promotion payout costs in full. It
unsettled bet now, whatever the range. The net result is cash stake minus payout: a
promotion-paid stake adds nothing and a promotion payout counts in full. It is a per-player
activity figure, not revenue; the official GGR is the wallet's (`docs/modules/analytics.md`). It
reads each bet's latest settlement, so a reopened bet shows as open until it settles again and
its payout leaves the period it first settled in, even when the reversal was collected. The
ledger books a stake at reserve, not acceptance, and is cash-basis for a reopened bet, so a
reversal left as debt or written off makes the ledger GGR lower than the summary's (ADR-0047).
reversal left as debt or written off makes GGR lower than the net result (ADR-0047).
- **What promotions cost.** A promotion win is a real win inside GGR. The financial summary shows
what promotions paid, net of reopens, and the promotion report splits the same total by
promotion type and funder.
- **Sportsbook debt.** What a reversed settlement could not take back, per player and currency,
with the withdrawal hold that lives exactly as long as the debt.
- **Reconciliation findings.** Everything the ledger noticed but could not decide on its own, for
Expand Down Expand Up @@ -309,10 +313,10 @@ nothing else, so the provider may retry it or treat it as final.
whether or not the player's balance covered it; what a reopen could not collect is in the
sportsbook debts, and a written-off debt is a cost the report does not show. A month heavy in
promotion wins can push GGR negative in the shared totals. `GET /analytics/financial/products`
splits stakes, payouts and GGR by product: a sportsbook stake counts when the bet is accepted,
a payout when it settles, and a reopen takes the payout back in the reopen's period. While the
book is loaded, its ledger is the whole sportsbook row: catalog game rounds typed `sportsbook`
are left out.
splits stakes, payouts and a settled-basis GGR, not the official one, by product: a sportsbook
stake counts when the bet is accepted, a payout when it settles, and a reopen takes the payout
back in the reopen's period. While the book is loaded, its ledger is the whole sportsbook row:
catalog game rounds of the sportsbook type are left out.
- **A promotion win is real money with no deposit behind it.** It is withdrawable at once. Read the
promotion report alongside the withdrawal review as the anti-money-laundering control.
- **Cooling-off and self-exclusion end play at once.** Either one queues a retried job that revokes
Expand Down
14 changes: 7 additions & 7 deletions docs/platform/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ Shared vocabulary for this repo: the **roles** (who's who), the **platform/archi

### Content, social & business

| Term | Meaning | Maps to |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| **CMS** | Operator-managed static pages and banners (T&C, FAQ, promos). | `cms` module |
| **Notifications** | In-app + email events (deposit confirmed, withdrawal status, bonus credited...). | `notifications` / `NotificationDeliveryAdapter` |
| **Chat** | Global and in-game messaging with soft moderation. | `chat` module |
| **Tip** / **donation** / **gift** / **rain** | Player-to-player money mechanics in chat (tip or donate to one player; drop a claimable gift; "rain" a split across many). Consumer-specific social features, not core. A tip is credited as cash; a donation, gift or rain as a wagered bonus grant. | consumer plugin |
| **GGR / NGR** | Gross Gaming Revenue (stakes less voided stakes less wins, plus wins taken back); Net Gaming Revenue (GGR less bonus cost). The core operator KPI. | analytics |
| Term | Meaning | Maps to |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| **CMS** | Operator-managed static pages and banners (T&C, FAQ, promos). | `cms` module |
| **Notifications** | In-app + email events (deposit confirmed, withdrawal status, bonus credited...). | `notifications` / `NotificationDeliveryAdapter` |
| **Chat** | Global and in-game messaging with soft moderation. | `chat` module |
| **Tip** / **gift** / **rain** | Player-to-player money mechanics in chat (tip one player; drop a claimable gift; "rain" a split across many). Consumer-specific social features, not core. | consumer plugin |
| **GGR / NGR** | Gross Gaming Revenue (wallet stakes less voided stakes less wins, plus wins taken back, cash basis); Net Gaming Revenue (GGR less bonus cost). Core KPI. | analytics |
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ import type { FunnelAnalyticsService } from '../service/funnel-analytics.service

const CTX = testContext();

const SUMMARY = { deposits: [], withdrawals: [], netDeposits: [], ggr: [], bonusCost: [], ngr: [] };
const SUMMARY = {
deposits: [],
withdrawals: [],
netDeposits: [],
ggr: [],
bonusCost: [],
sportsbookPromotionCost: [],
ngr: [],
};
const GGR: never[] = [];
const FUNNEL = [{ stage: 'registered' as const, count: 0, dropOffRate: 0 }];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('FinancialAnalyticsService.summary', () => {
{ currency: 'USD', total: '30.00' },
{ currency: 'EUR', total: '0' },
]);
expect(result.sportsbookPromotionCost).toEqual([]);
});

it('serves a repeat query from cache without touching the database', async () => {
Expand All @@ -62,6 +63,7 @@ describe('FinancialAnalyticsService.summary', () => {
netDeposits: [],
ggr: [],
bonusCost: [],
sportsbookPromotionCost: [],
ngr: [],
};
const cache = fakeCache(cached);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,10 @@ describe('revenue by product read from the database, with no sportsbook plugin l
expect(execute).not.toHaveBeenCalled();
execute.mockRestore();
});

it('reports no sportsbook promotion cost and never reads the sportsbook tables', async () => {
const service = new FinancialAnalyticsService(db.drizzle, makeCache());

await expect(service.summary({})).resolves.toMatchObject({ sportsbookPromotionCost: [] });
});
});
31 changes: 26 additions & 5 deletions packages/core/src/analytics/contract/financial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ export const FinancialSummarySchema = z.object({
deposits: z.array(MoneyByCurrencyRailSchema),
withdrawals: z.array(MoneyByCurrencyRailSchema),
netDeposits: z.array(MoneyByCurrencySchema),
/** The official GGR: wallet stakes less voided stakes less wins plus reversed wins, cash basis. */
ggr: z.array(MoneyByCurrencySchema),
bonusCost: z.array(MoneyByCurrencySchema),
/**
* What sportsbook promotions paid less what reopens took back in the range, already inside `ggr`.
* A reopen takes back its whole promotion part even when the wallet could not collect it, so
* this can be lower than `ggr`'s promotion share by that uncollected debt. Empty without the
* sportsbook module.
*/
sportsbookPromotionCost: z.array(MoneyByCurrencySchema),
ngr: z.array(MoneyByCurrencySchema),
});
export type FinancialSummary = z.infer<typeof FinancialSummarySchema>;
Expand Down Expand Up @@ -59,8 +67,9 @@ export const FinancialGgrQuerySchema = DateRangeSchema.extend({
export type FinancialGgrQuery = z.infer<typeof FinancialGgrQuerySchema>;

/**
* Stakes and payouts per product. A sportsbook stake counts when the bet is accepted and only its
* cash part; a payout counts when the bet settles, and a reopen takes it back in the reopen's period.
* Stakes and payouts per product, on a settled basis, not the official GGR. A sportsbook stake
* counts when the bet is accepted and only its cash part; a payout counts when the bet settles, and
* a reopen takes it back in the reopen's period.
*/
export const ProductRevenueSchema = z.object({
product: GameTypeSchema,
Expand All @@ -73,17 +82,29 @@ export type ProductRevenue = z.infer<typeof ProductRevenueSchema>;

export const financialContract = {
summary: oc
.route({ method: 'GET', path: '/analytics/financial/summary' })
.route({
method: 'GET',
path: '/analytics/financial/summary',
summary: 'Financial summary with the official GGR, cash basis',
})
.input(FinancialSummaryQuerySchema)
.output(FinancialSummarySchema),

ggr: oc
.route({ method: 'GET', path: '/analytics/financial/ggr' })
.route({
method: 'GET',
path: '/analytics/financial/ggr',
summary: 'Official GGR trend, cash basis',
})
.input(FinancialGgrQuerySchema)
.output(z.array(GgrSeriesSchema)),

products: oc
.route({ method: 'GET', path: '/analytics/financial/products' })
.route({
method: 'GET',
path: '/analytics/financial/products',
summary: 'Revenue by product, settled basis',
})
.input(FinancialSummaryQuerySchema)
.output(z.array(ProductRevenueSchema)),
};
Loading
Loading