diff --git a/docs/adr/0033-analytics-as-a-read-only-domain.md b/docs/adr/0033-analytics-as-a-read-only-domain.md index 2ae5da00d..ef6cb4c5e 100644 --- a/docs/adr/0033-analytics-as-a-read-only-domain.md +++ b/docs/adr/0033-analytics-as-a-read-only-domain.md @@ -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 diff --git a/docs/adr/0047-sportsbook-provider-promotions.md b/docs/adr/0047-sportsbook-provider-promotions.md index 053cc68a9..727a52513 100644 --- a/docs/adr/0047-sportsbook-provider-promotions.md +++ b/docs/adr/0047-sportsbook-provider-promotions.md @@ -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 diff --git a/docs/adr/0050-wallet-ggr-is-the-official-ggr.md b/docs/adr/0050-wallet-ggr-is-the-official-ggr.md new file mode 100644 index 000000000..7932ce93e --- /dev/null +++ b/docs/adr/0050-wallet-ggr-is-the-official-ggr.md @@ -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. diff --git a/docs/modules/analytics.md b/docs/modules/analytics.md new file mode 100644 index 000000000..a149e7d73 --- /dev/null +++ b/docs/modules/analytics.md @@ -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. diff --git a/docs/modules/sportsbook.md b/docs/modules/sportsbook.md index 468f1677f..0bf52f079 100644 --- a/docs/modules/sportsbook.md +++ b/docs/modules/sportsbook.md @@ -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 @@ -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 diff --git a/docs/platform/glossary.md b/docs/platform/glossary.md index 4ebe0342b..6ee73a744 100644 --- a/docs/platform/glossary.md +++ b/docs/platform/glossary.md @@ -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 | diff --git a/packages/core/src/analytics/__tests__/analytics.router.test.ts b/packages/core/src/analytics/__tests__/analytics.router.test.ts index c01495a5d..cca3aca8a 100644 --- a/packages/core/src/analytics/__tests__/analytics.router.test.ts +++ b/packages/core/src/analytics/__tests__/analytics.router.test.ts @@ -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 }]; diff --git a/packages/core/src/analytics/__tests__/financial-analytics.service.test.ts b/packages/core/src/analytics/__tests__/financial-analytics.service.test.ts index 9677526c5..887e20d59 100644 --- a/packages/core/src/analytics/__tests__/financial-analytics.service.test.ts +++ b/packages/core/src/analytics/__tests__/financial-analytics.service.test.ts @@ -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 () => { @@ -62,6 +63,7 @@ describe('FinancialAnalyticsService.summary', () => { netDeposits: [], ggr: [], bonusCost: [], + sportsbookPromotionCost: [], ngr: [], }; const cache = fakeCache(cached); diff --git a/packages/core/src/analytics/__tests__/products-without-sportsbook.int.test.ts b/packages/core/src/analytics/__tests__/products-without-sportsbook.int.test.ts index 364278c8f..339d7594a 100644 --- a/packages/core/src/analytics/__tests__/products-without-sportsbook.int.test.ts +++ b/packages/core/src/analytics/__tests__/products-without-sportsbook.int.test.ts @@ -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: [] }); + }); }); diff --git a/packages/core/src/analytics/contract/financial.ts b/packages/core/src/analytics/contract/financial.ts index 646c019cb..e409e1b57 100644 --- a/packages/core/src/analytics/contract/financial.ts +++ b/packages/core/src/analytics/contract/financial.ts @@ -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; @@ -59,8 +67,9 @@ export const FinancialGgrQuerySchema = DateRangeSchema.extend({ export type FinancialGgrQuery = z.infer; /** - * 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, @@ -73,17 +82,29 @@ export type ProductRevenue = z.infer; 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)), }; diff --git a/packages/core/src/analytics/service/financial-analytics.service.ts b/packages/core/src/analytics/service/financial-analytics.service.ts index 66eb06b1c..965c8390f 100644 --- a/packages/core/src/analytics/service/financial-analytics.service.ts +++ b/packages/core/src/analytics/service/financial-analytics.service.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, inArray, isNotNull, lte, ne, sql, type SQL } from 'drizzle-orm'; +import { and, eq, gt, gte, inArray, isNotNull, lte, ne, sql, type SQL } from 'drizzle-orm'; import { alias, type PgColumn } from 'drizzle-orm/pg-core'; import type { CacheAdapter, GameType, Granularity } from '@openora/core/contracts'; import { DrizzleService, moneyAdd, moneySubtract } from '@openora/core/server'; @@ -77,7 +77,7 @@ export class FinancialAnalyticsService { ) {} async summary(query: FinancialSummaryQuery): Promise { - return this.cached('financial.summary', query, () => this.computeSummary(query)); + return this.cached('financial.summary.v2', query, () => this.computeSummary(query)); } async ggr(query: FinancialGgrQuery): Promise { @@ -157,9 +157,10 @@ export class FinancialAnalyticsService { ) .groupBy(walletTransaction.currency, walletTransaction.rail); - const [deposits, withdrawals, byCurrency] = await Promise.all([ + const [deposits, withdrawals, sportsbookPromotionCost, byCurrency] = await Promise.all([ totalsByRail('deposit'), totalsByRail('withdrawal'), + this.sportsbook ? this.sportsbookPromotionCost({ dateFrom, dateTo, currency }) : [], db .select({ currency: walletTransaction.currency, @@ -187,10 +188,34 @@ export class FinancialAnalyticsService { netDeposits: byCurrency.map((r) => ({ currency: r.currency, total: r.netDeposits })), ggr: byCurrency.map((r) => ({ currency: r.currency, total: r.ggr })), bonusCost: byCurrency.map((r) => ({ currency: r.currency, total: r.bonusCost })), + sportsbookPromotionCost, ngr: byCurrency.map((r) => ({ currency: r.currency, total: r.ngr })), }; } + private sportsbookPromotionCost(query: FinancialSummaryQuery) { + const tx = sportsbookBetTransaction; + const bet = sportsbookBet; + const partOf = (kind: 'settle' | 'reopen') => + sql`coalesce(sum(${tx.promotionAmount}) filter (where ${tx.kind} = ${kind}), 0)`; + return this.drizzle.db + .select({ + currency: bet.currency, + total: sql`${partOf('settle')} - ${partOf('reopen')}`, + }) + .from(tx) + .innerJoin(bet, eq(bet.id, tx.betId)) + .where( + and( + gt(tx.promotionAmount, '0'), + isNotNull(bet.promotionType), + ...inRange(tx.createdAt, bet.currency, query), + ), + ) + .groupBy(bet.currency) + .orderBy(bet.currency); + } + private async computeProducts(query: FinancialSummaryQuery): Promise { const db = this.drizzle.db; const bet = sportsbookBet; diff --git a/packages/core/src/sportsbook/betting/contract/index.ts b/packages/core/src/sportsbook/betting/contract/index.ts index 8b209dc3c..9230951ba 100644 --- a/packages/core/src/sportsbook/betting/contract/index.ts +++ b/packages/core/src/sportsbook/betting/contract/index.ts @@ -407,10 +407,10 @@ export const SportsbookPlayerSummaryRowSchema = z.object({ cashPayout: MoneyAmountSchema, promotionPayout: MoneyAmountSchema, /** - * `cashStake - payout`: a promotion-paid stake earns nothing and a promotion payout costs in - * full, as on the wallet ledger. Bet-level, so a reopened bet's uncollected reversal is not in it. + * `cashStake - payout`, the player's activity, not revenue: the official GGR is the wallet's. + * Bet-level, so a reopened bet's uncollected reversal is not in it. */ - ggr: SignedMoneyAmountSchema, + netResult: SignedMoneyAmountSchema, openExposure: MoneyAmountSchema, }); export type SportsbookPlayerSummaryRow = z.infer; @@ -474,7 +474,11 @@ export const sportsbookAdminContract = { .input(UserIdInputSchema) .output(SportsbookRestrictionSchema), playerSummary: oc - .route({ method: 'GET', path: '/backoffice/sportsbook/players/{userId}/summary' }) + .route({ + method: 'GET', + path: '/backoffice/sportsbook/players/{userId}/summary', + summary: "A player's sportsbook activity by currency, not revenue", + }) .input(SportsbookPlayerSummaryInputSchema) .output(z.array(SportsbookPlayerSummaryRowSchema)), restrictPlayer: oc diff --git a/packages/core/src/sportsbook/betting/service/bet-query.service.ts b/packages/core/src/sportsbook/betting/service/bet-query.service.ts index afbcb7cf8..8ace8b225 100644 --- a/packages/core/src/sportsbook/betting/service/bet-query.service.ts +++ b/packages/core/src/sportsbook/betting/service/bet-query.service.ts @@ -235,7 +235,7 @@ export class SportsbookBetQueryService { payout: sumOf(bet.payoutTotal, settled), cashPayout: sumOf(sql`${bet.payoutTotal} - ${bet.promotionPaid}`, settled), promotionPayout: sumOf(bet.promotionPaid, settled), - ggr: sql`(${sumOf(bet.cashStake, staked)} - ${sumOf(bet.payoutTotal, settled)})::numeric(38, 18)`, + netResult: sql`(${sumOf(bet.cashStake, staked)} - ${sumOf(bet.payoutTotal, settled)})::numeric(38, 18)`, openExposure: sumOf(bet.stake, open), }) .from(bet) diff --git a/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts b/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts index 0f4424f7c..1d67e9887 100644 --- a/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts +++ b/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts @@ -1523,16 +1523,16 @@ describe('player summary', () => { payout: zero, cashPayout: zero, promotionPayout: zero, - ggr: zero, + netResult: zero, openExposure: money('5'), ...fields, }); expect(await summary(player.userId, september)).toEqual([ - row({ bets: 2, stake: money('15'), cashStake: money('15'), ggr: money('15') }), + row({ bets: 2, stake: money('15'), cashStake: money('15'), netResult: money('15') }), ]); expect(await summary(player.userId, october)).toEqual([ - row({ payout: money('25'), cashPayout: money('25'), ggr: money('-25') }), + row({ payout: money('25'), cashPayout: money('25'), netResult: money('-25') }), ]); expect(await summary(player.userId)).toEqual([ row({ @@ -1541,7 +1541,7 @@ describe('player summary', () => { cashStake: money('15'), payout: money('25'), cashPayout: money('25'), - ggr: money('-10'), + netResult: money('-10'), }), ]); expect(await summary(player.userId, '?dateFrom=2026-11-01T00:00:00Z')).toEqual([row({})]); @@ -1582,7 +1582,7 @@ describe('player summary', () => { payout: money('27.5'), cashPayout: money('10'), promotionPayout: money('17.5'), - ggr: money('4.5'), + netResult: money('4.5'), openExposure: money('7'), }, ]); @@ -1597,7 +1597,7 @@ describe('player summary', () => { payout: zero, cashPayout: zero, promotionPayout: zero, - ggr: zero, + netResult: zero, openExposure: zero, ...fields, }); @@ -1614,7 +1614,7 @@ describe('player summary', () => { bets: 2, stake: money('14'), cashStake: money('14'), - ggr: money('14'), + netResult: money('14'), openExposure: money('14'), }), ]); @@ -1631,7 +1631,7 @@ describe('player summary', () => { bets: 1, stake: money('8'), cashStake: money('8'), - ggr: money('8'), + netResult: money('8'), openExposure: money('8'), }); expect(await summary(player.userId)).toEqual([afterCancel]); @@ -1658,7 +1658,7 @@ describe('player summary', () => { cashStake: money('10'), payout: money('30'), cashPayout: money('30'), - ggr: money('-20'), + netResult: money('-20'), }), ]); @@ -1669,7 +1669,7 @@ describe('player summary', () => { bets: 1, stake: money('10'), cashStake: money('10'), - ggr: money('10'), + netResult: money('10'), openExposure: money('10'), }), ]); @@ -1677,7 +1677,7 @@ describe('player summary', () => { expectApplied(await settle(args.providerBetId, 'lost', '0')); expect(await balanceOf(app, player.userId)).toBe(money('90')); expect(await summary(player.userId)).toEqual([ - cashRow({ bets: 1, stake: money('10'), cashStake: money('10'), ggr: money('10') }), + cashRow({ bets: 1, stake: money('10'), cashStake: money('10'), netResult: money('10') }), ]); }); }); diff --git a/packages/testing/src/__tests__/sportsbook-race-reporting.e2e.test.ts b/packages/testing/src/__tests__/sportsbook-race-reporting.e2e.test.ts index 19dd25c9c..835a69a05 100644 --- a/packages/testing/src/__tests__/sportsbook-race-reporting.e2e.test.ts +++ b/packages/testing/src/__tests__/sportsbook-race-reporting.e2e.test.ts @@ -199,6 +199,97 @@ describe('race eligible products', () => { }); }); +describe('financial summary', () => { + type ByCurrency = { currency: string; total: string }[]; + type Summary = { ggr: ByCurrency; sportsbookPromotionCost: ByCurrency }; + + const usd = (rows: ByCurrency) => Number(rows.find((r) => r.currency === 'USD')?.total ?? 0); + + async function inWindow(step: () => Promise) { + const from = new Date(); + await step(); + const range = new URLSearchParams({ + dateFrom: from.toISOString(), + dateTo: new Date(Date.now() + 1).toISOString(), + }); + const res = await admin.get(`/analytics/financial/summary?${range}¤cy=USD`); + expect(res.status).toBe(200); + const summary = (await res.json()) as Summary; + const report = await admin.get(`/backoffice/sportsbook/promotions/report?${range}`); + expect(report.status).toBe(200); + const promotions = (await report.json()) as { + currency: string; + promotionPaid: string; + promotionReversed: string; + }[]; + return { + ggr: usd(summary.ggr), + promotionCost: usd(summary.sportsbookPromotionCost), + reported: promotions + .filter((r) => r.currency === 'USD') + .reduce((sum, r) => sum + Number(r.promotionPaid) - Number(r.promotionReversed), 0), + }; + } + + it('shows the freebet win inside the official GGR and as promotion cost, and a reopen takes both back', async () => { + const player = await fundedPlayer(app); + const freebet: NonNullable = { + type: 'freebet', + id: `grant-${randomUUID()}`, + stake: 'promotion', + voidReturnsStake: false, + }; + let freebetId = ''; + + const won = await inWindow(async () => { + const promoted = await acceptedBet(app, player, { + stake: '5', + potentialPayout: '12', + promotion: freebet, + }); + freebetId = promoted.args.providerBetId; + await settle(freebetId, 'won', '12'); + const { args } = await acceptedBet(app, player, { stake: '10' }); + await settle(args.providerBetId, 'lost', '0'); + }); + const reopened = await inWindow(() => reopen(freebetId)); + + expect(won).toEqual({ ggr: -2, promotionCost: 12, reported: 12 }); + expect(reopened).toEqual({ ggr: 12, promotionCost: -12, reported: -12 }); + }); + + it('takes a reopened promotion win out of the promotion cost even when GGR collects none of it', async () => { + const player = await fundedPlayer(app, '5'); + const { args } = await acceptedBet(app, player, { + stake: '5', + potentialPayout: '12', + promotion: { + type: 'freebet', + id: `grant-${randomUUID()}`, + stake: 'promotion', + voidReturnsStake: false, + }, + }); + await settle(args.providerBetId, 'won', '12'); + await acceptedBet(app, player, { stake: '17' }); + + const reopened = await inWindow(() => reopen(args.providerBetId)); + + expect(reopened).toEqual({ ggr: 0, promotionCost: -12, reported: -12 }); + }); + + it('counts no promotion cost for a cash bet', async () => { + const player = await fundedPlayer(app); + + const played = await inWindow(async () => { + const { args } = await acceptedBet(app, player, { stake: '10', potentialPayout: '25' }); + await settle(args.providerBetId, 'won', '25'); + }); + + expect(played).toEqual({ ggr: -15, promotionCost: 0, reported: 0 }); + }); +}); + describe('revenue by product', () => { const products = async (from: Date, to: Date) => { const query = new URLSearchParams({