From dd9fedbd0c27470be5856af423e216ea67057af6 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Sun, 2 Aug 2026 00:07:16 +0200 Subject: [PATCH 1/9] Run the API in UTC and assert it at start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns such as `created` are `timestamp without time zone`: the Postgres driver serializes a JS Date in the process-local wall-clock and Postgres drops the offset, so a non-UTC process shifts stored values and every day bucket derived from them. Pins `TZ=UTC` in the image and checks the process timezone at boot. The check warns rather than aborts — it must not turn a timezone misconfiguration into a failed rollout — and covers the winter/summer trap by testing the offset year-round, not just at the current date. Boot failures now surface as an explicit "Bootstrap failed" log with exit 1 instead of an unhandled rejection. --- Dockerfile | 5 + jest.coverage-gate.config.js | 1 + src/__tests__/process-timezone.spec.ts | 151 +++++++++++++++++++++++++ src/main.ts | 15 ++- src/process-timezone.ts | 72 ++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/process-timezone.spec.ts create mode 100644 src/process-timezone.ts diff --git a/Dockerfile b/Dockerfile index bac4472dac..b5307e4914 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,11 @@ RUN echo "$GIT_COMMIT" > dist/version.txt FROM node:20-alpine +# Process must run in UTC. Columns such as `created` are `timestamp without time +# zone`; the Postgres driver serializes JS Date in the process-local wall-clock +# and Postgres drops the offset — non-UTC shifts stored values and day buckets. +ENV TZ=UTC + # tini as PID 1: forwards SIGTERM to node so stops behave exactly as they did # under npm (immediate exit), without npm's 5-line error block on every stop. # Bare node as PID 1 would IGNORE SIGTERM (no handler + PID-1 semantics) and diff --git a/jest.coverage-gate.config.js b/jest.coverage-gate.config.js index 51f3b5eae8..8eb2d5ba09 100644 --- a/jest.coverage-gate.config.js +++ b/jest.coverage-gate.config.js @@ -60,6 +60,7 @@ const PINNED_LOGIC = [ 'src/integration/scorechain/exceptions/scorechain-object-not-found.exception.ts', 'src/integration/sift/dto/sift.dto.ts', 'src/polyfills.ts', + 'src/process-timezone.ts', 'src/shared/auth/allow-tfa-pending.decorator.ts', 'src/shared/auth/exceptions/staff-kyc-required.exception.ts', 'src/shared/auth/get-jwt.decorator.ts', diff --git a/src/__tests__/process-timezone.spec.ts b/src/__tests__/process-timezone.spec.ts new file mode 100644 index 0000000000..5bd62cb827 --- /dev/null +++ b/src/__tests__/process-timezone.spec.ts @@ -0,0 +1,151 @@ +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { checkProcessTimezone, isProcessTimezoneUtcYearRound } from '../process-timezone'; + +/** London-like: UTC in winter, BST (UTC+1 → offset -60) in summer. */ +function londonLikeOffset(date: Date): number { + // Anchors use month 0 (Jan) and 6 (Jul); treat Jan–Mar as winter. + return date.getUTCMonth() < 6 ? 0 : -60; +} + +describe('isProcessTimezoneUtcYearRound', () => { + it('is true only when both January and July offsets are 0', () => { + expect(isProcessTimezoneUtcYearRound(() => 0)).toBe(true); + expect(isProcessTimezoneUtcYearRound(londonLikeOffset)).toBe(false); + expect(isProcessTimezoneUtcYearRound(() => -60)).toBe(false); + }); +}); + +describe('checkProcessTimezone', () => { + let warnSpy: jest.SpyInstance; + let infoSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); + infoSpy = jest.spyOn(DfxLogger.prototype, 'info').mockImplementation(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + infoSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + it('logs OK when January and July offsets are both UTC (0)', () => { + checkProcessTimezone({ + getTimezoneOffset: () => 0, + getTimeZoneName: () => 'UTC', + }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(infoSpy.mock.calls[0][0]).toContain('UTC'); + expect(infoSpy.mock.calls[0][0]).toMatch(/january=0.*july=0|july=0.*january=0/s); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('accepts a permanent zero-offset zone (Atlantic/Reykjavik) and logs OK', () => { + checkProcessTimezone({ + getTimezoneOffset: () => 0, + getTimeZoneName: () => 'Atlantic/Reykjavik', + }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(infoSpy.mock.calls[0][0]).toContain('Atlantic/Reykjavik'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns (does not throw) for a DST zone that is UTC-offset only in winter (e.g. Europe/London)', () => { + expect(() => + checkProcessTimezone({ + getTimezoneOffset: londonLikeOffset, + getTimeZoneName: () => 'Europe/London', + }), + ).not.toThrow(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('Europe/London'); + expect(warnSpy.mock.calls[0][0]).toMatch(/january=0.*july=-60|july=-60.*january=0/s); + expect(warnSpy.mock.calls[0][0]).toMatch(/must be UTC/i); + expect(infoSpy).not.toHaveBeenCalled(); + }); + + it('warns (does not throw) when either seasonal offset is non-zero and names the zone', () => { + checkProcessTimezone({ + getTimezoneOffset: () => -120, + getTimeZoneName: () => 'Europe/Zurich', + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('Europe/Zurich'); + expect(warnSpy.mock.calls[0][0]).toMatch(/must be UTC/i); + + warnSpy.mockClear(); + + checkProcessTimezone({ + getTimezoneOffset: () => -60, + getTimeZoneName: () => 'Europe/Berlin', + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/Europe\/Berlin.*must be UTC|must be UTC.*Europe\/Berlin/s); + expect(infoSpy).not.toHaveBeenCalled(); + }); + + it('samples January and July anchors via Date#getTimezoneOffset when not overridden', () => { + const seenMonths: number[] = []; + const spy = jest.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(function (this: Date) { + seenMonths.push(this.getUTCMonth()); + return 0; + }); + + checkProcessTimezone({ getTimeZoneName: () => 'UTC' }); + + expect(spy).toHaveBeenCalled(); + expect(seenMonths).toEqual([0, 6]); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns via the production default path when Date#getTimezoneOffset is seasonal (London-like)', () => { + jest.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(function (this: Date) { + return this.getUTCMonth() < 6 ? 0 : -60; + }); + + checkProcessTimezone({ + getTimeZoneName: () => 'Europe/London', + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch( + /Europe\/London.*january=0.*july=-60|january=0.*july=-60.*Europe\/London/s, + ); + }); + + it('uses the default Intl zone name and default logger when neither is injected', () => { + const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'unknown'; + + checkProcessTimezone({ + getTimezoneOffset: () => -60, + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(new RegExp(resolved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + }); + + it('falls back to "unknown" in the message when the zone name is missing', () => { + checkProcessTimezone({ + getTimezoneOffset: () => -60, + getTimeZoneName: () => undefined as unknown as string, + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/timezone "unknown"/); + }); + + it('accepts a no-arg call when the process offset is UTC year-round and logs OK', () => { + jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0); + + expect(() => checkProcessTimezone()).not.toThrow(); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main.ts b/src/main.ts index 96976213a5..07241f4aa2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { join } from 'path'; import { getVerifiedIp } from './shared/utils/ip.util'; import { AppModule } from './app.module'; import { Config, Environment } from './config/config'; +import { checkProcessTimezone } from './process-timezone'; import { ApiExceptionFilter } from './shared/filters/exception.filter'; import { apiTraceMiddleware, maskUrl } from './shared/middlewares/api-trace.middleware'; import { DetailedValidationPipe } from './shared/pipes/detailed-validation.pipe'; @@ -46,6 +47,11 @@ process.on('uncaughtException', (error) => { }); async function bootstrap() { + // Log process timezone at boot (warn if not UTC year-round; never throws — see + // process-timezone.ts and Dockerfile ENV TZ=UTC). Must run before NestFactory.create + // — TypeORM connects during app creation. + checkProcessTimezone(); + // Observability is initialized in src/tracing.ts (imported above): the // OpenTelemetry SDK auto-instruments HTTP/DB/NestJS and exports traces via // OTLP. 4xx-not-a-failure is handled there in the HTTP response hook. @@ -157,4 +163,11 @@ function runSeed(): void { } } -void bootstrap(); +// Catch boot failures so they surface as a clear "Bootstrap failed" log with +// exit 1, not as an unhandled rejection that the uncaughtException handler +// reports as a generic crash (and Spark-error heuristic). +void bootstrap().catch((error) => { + const logger = new DfxLogger('Bootstrap'); + logger.error('Bootstrap failed:', error instanceof Error ? error : new Error(String(error))); + process.exit(1); +}); diff --git a/src/process-timezone.ts b/src/process-timezone.ts new file mode 100644 index 0000000000..5e909697ff --- /dev/null +++ b/src/process-timezone.ts @@ -0,0 +1,72 @@ +import { DfxLogger } from './shared/services/dfx-logger'; + +/** + * Fixed mid-month noon-UTC anchors for seasonal offset sampling. + * Mid-month avoids DST transition days; a fixed year keeps the check deterministic. + * Both must be 0 for a process that is UTC (or permanently zero-offset, e.g. Atlantic/Reykjavik) + * year-round — a single `new Date().getTimezoneOffset()` would accept Europe/London in winter. + */ +const JANUARY_OFFSET_ANCHOR = new Date(Date.UTC(2024, 0, 15, 12, 0, 0)); +const JULY_OFFSET_ANCHOR = new Date(Date.UTC(2024, 6, 15, 12, 0, 0)); + +export type CheckProcessTimezoneDeps = { + logger?: DfxLogger; + /** + * Override for tests; production uses `Date#getTimezoneOffset` on fixed Jan/Jul anchors. + * Takes the date so tests can simulate seasonal offsets (e.g. London winter vs summer). + */ + getTimezoneOffset?: (date: Date) => number; + /** Override for tests; production uses Intl resolved zone name. */ + getTimeZoneName?: () => string; +}; + +/** + * True when the process wall-clock is UTC-offset year-round (Jan and Jul anchors both 0). + * A single `new Date().getTimezoneOffset()` would accept Europe/London in winter — use this + * (or `checkProcessTimezone`) whenever a gate depends on process TZ being UTC for + * `timestamp without time zone` serialization. + */ +export function isProcessTimezoneUtcYearRound( + getTimezoneOffset: (date: Date) => number = (date) => date.getTimezoneOffset(), +): boolean { + return getTimezoneOffset(JANUARY_OFFSET_ANCHOR) === 0 && getTimezoneOffset(JULY_OFFSET_ANCHOR) === 0; +} + +/** + * Logs the process timezone at boot. Warns when the Node process is not UTC year-round; + * never throws — deploy start commands may set `TZ` outside this repo, and a hard abort + * would block deploys without a confirmed host-side guarantee. + * + * Sibling of `assertValidStorageCombo` (config.ts) for runtime prerequisites, but advisory + * only: columns like `created` are `timestamp without time zone` and the Postgres driver + * serializes JS `Date` in the process-local wall-clock. + * + * Must run before NestFactory.create — TypeORM connects during app creation. + * + * Offset is checked at January and July anchors, not "today": zones with seasonal + * UTC-offset (Europe/London in winter) must not look fine just because boot landed in + * the zero-offset half of the year. For serialization only the offset matters — + * Atlantic/Reykjavik (permanently 0) is treated as OK. + */ +export function checkProcessTimezone(deps: CheckProcessTimezoneDeps = {}): void { + const getOffset = deps.getTimezoneOffset ?? ((date: Date) => date.getTimezoneOffset()); + const januaryOffset = getOffset(JANUARY_OFFSET_ANCHOR); + const julyOffset = getOffset(JULY_OFFSET_ANCHOR); + // The 'unknown' fallback feeds the diagnostic message only — never a decision. The offset + // already decides; Intl can return undefined on exotic ICU builds and must not mask that. + const timeZone = (deps.getTimeZoneName ?? (() => Intl.DateTimeFormat().resolvedOptions().timeZone))() ?? 'unknown'; + const logger = deps.logger ?? new DfxLogger('ProcessTimezone'); + + // Same predicate as isProcessTimezoneUtcYearRound — sample once so log offsets match the decision. + if (januaryOffset === 0 && julyOffset === 0) { + logger.info(`Process timezone OK: "${timeZone}" (getTimezoneOffset january=${januaryOffset}, july=${julyOffset}).`); + return; + } + + logger.warn( + `Process timezone must be UTC: columns like \`created\` are timestamp without time zone and the ` + + `Postgres driver serializes JS Date in the process-local wall-clock (Postgres then drops the offset). ` + + `Found timezone "${timeZone}" (getTimezoneOffset january=${januaryOffset}, july=${julyOffset}). ` + + `Set ENV TZ=UTC.`, + ); +} From 7ed12163e093478c552b1e93e10052d55ab81098 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Sun, 2 Aug 2026 00:07:33 +0200 Subject: [PATCH 2/9] Add aggregated partner statistics endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /v1/statistic/partner` and `GET /v1/statistic/partner/timeline`, scoped to the wallet in the company JWT. There is deliberately no `walletId` parameter, so a partner cannot express a request for someone else's data, and the lookup fails closed when the JWT carries no wallet. Both routes return aggregates only: volume and transaction counts by direction, active and new users, breakdowns by asset, fiat currency, blockchain and payment method, plus the referral position in EUR. Nothing in the implementation is partner-specific — any wallet gets its own numbers through the same route. Aggregates alone are not anonymous, so suppression blocks the whole group when any direction falls below the threshold (per-cell nulling would let `total - visible` recover the hidden member), applies the timeline threshold per direction rather than to the bucket sum, binds the threshold to distinct users as well as transactions, and snaps periods to UTC day boundaries with a minimum span of one day so two windows differing by hours cannot isolate a single transaction. A dedicated guard budgets queries by wallet id — the shared rate-limit guard keys by IP prefix and bypasses known addresses, which puts no bound on repeated scrapes from one partner JWT. Query parameters are rejected rather than coerced when they arrive as arrays, non-existent calendar days are refused, and query concurrency is bounded. --- .env.example | 4 + docs/coverage-gate.md | 16 +- jest.coverage-gate.config.js | 5 + ...partner-statistic-rate-limit.guard.spec.ts | 69 + .../partner-statistic.controller.spec.ts | 111 ++ .../partner-statistic.integration.spec.ts | 489 ++++++ .../partner-statistic.service.spec.ts | 1434 +++++++++++++++++ .../partner-statistic.suppression.spec.ts | 381 +++++ .../statistic/dto/partner-statistic.dto.ts | 291 ++++ .../partner-statistic-rate-limit.guard.ts | 30 + .../statistic/partner-statistic.controller.ts | 75 + .../core/statistic/partner-statistic.enum.ts | 70 + .../statistic/partner-statistic.service.ts | 958 +++++++++++ .../partner-statistic.suppression.ts | 250 +++ .../core/statistic/statistic.module.ts | 18 +- 15 files changed, 4191 insertions(+), 10 deletions(-) create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts create mode 100644 src/subdomains/core/statistic/dto/partner-statistic.dto.ts create mode 100644 src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts create mode 100644 src/subdomains/core/statistic/partner-statistic.controller.ts create mode 100644 src/subdomains/core/statistic/partner-statistic.enum.ts create mode 100644 src/subdomains/core/statistic/partner-statistic.service.ts create mode 100644 src/subdomains/core/statistic/partner-statistic.suppression.ts diff --git a/.env.example b/.env.example index 68300ecaac..80432b938b 100644 --- a/.env.example +++ b/.env.example @@ -343,3 +343,7 @@ REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05 REQUEST_KNOWN_IPS= CRON_JOB_DELAY= + +# Optional: connection string to a throwaway Postgres for the specs that exercise real SQL. +# Unset, the "(real Postgres)" describe blocks skip. Existing convention across the repo. +MIGRATION_TEST_PG= diff --git a/docs/coverage-gate.md b/docs/coverage-gate.md index 1926931c8c..cc3a1091aa 100644 --- a/docs/coverage-gate.md +++ b/docs/coverage-gate.md @@ -6,7 +6,7 @@ the other. | Gate | Config | Scope | Question it answers | | ---------------- | ------------------------------ | ------------------------------------------ | -------------------------------------------------------- | | Frick gate | `jest.frick.config.js` | 10 Frick files, run by 10 Frick specs only | Do _these specs alone_ fully cover _these files_? | -| Coverage ratchet | `jest.coverage-gate.config.js` | 439 files, whole suite | Has coverage regressed anywhere it was already complete? | +| Coverage ratchet | `jest.coverage-gate.config.js` | 445 files, whole suite | Has coverage regressed anywhere it was already complete? | ## What the ratchet is, and what it is not @@ -25,8 +25,8 @@ It is a **regression gate**, not a statement about test quality: ratchet only protects files already on the list, and that list grows by hand (see "How the list grows"). That is the price of the threshold approach. -Of the 439 pinned files, **246 carry real logic** (they have functions and/or branches) and -**193 are purely declarative today** (NestJS modules, constant files with neither). The two groups +Of the 445 pinned files, **251 carry real logic** (they have functions and/or branches) and +**194 are purely declarative today** (NestJS modules, constant files with neither). The two groups are kept visibly separate in the config so the count is not mistaken for test depth. Pinning the declarative ones is deliberate and not vacuous. Istanbul reports a metric with a total @@ -161,8 +161,8 @@ deleting them would be a separate cleanup. | Class | Files | Meaning | | -------- | ----- | ----------------------------------------------- | -| Complete | 422 | Pinned by the ratchet at that commit | -| Partial | 1,057 | Some coverage, below 100 on at least one metric | +| Complete | 445 | Pinned by the ratchet at that commit | +| Partial | 1,034 | Some coverage, below 100 on at least one metric | | None | 127 | No coverage at all | Totals: statements 59.61%, branches 42.64%, functions 34.21%, lines 59.97%. @@ -175,7 +175,7 @@ six under `subdomains/generic/admin` have no coverage at all. ## How the list grows Any PR may add files to `coverageThreshold` once they reach 100%. -`jest.coverage-gate.config.js` holds the 439 paths in two arrays, `PINNED_LOGIC` (logic-carrying +`jest.coverage-gate.config.js` holds the 445 paths in two arrays, `PINNED_LOGIC` (logic-carrying files) and `PINNED_DECLARATIVE` (purely declarative files), from which `coverageThreshold` is generated. Adding a file means appending its path to the matching array, not writing out a `coverageThreshold` object entry by hand. @@ -211,8 +211,8 @@ To regenerate the full picture, run the gate and read `coverage-gate/coverage-su below 100, the expected response is to extend the tests. Unpinning is an explicit decision that belongs in the PR description, not a silent edit. -That rule stays hard for the 246 logic-carrying files. A foreseeable friction case is different: -when one of the 193 purely declarative files (a NestJS module, a constants file) first gains +That rule stays hard for the 251 logic-carrying files. A foreseeable friction case is different: +when one of the 194 purely declarative files (a NestJS module, a constants file) first gains executable logic — for example a `useFactory` on a module — the function metric jumps from 0/0 to 0/N and the gate turns red. Tests remain the preferred fix, but unpinning that one file is an allowed outcome if the PR description names and justifies it (not as a silent edit). For diff --git a/jest.coverage-gate.config.js b/jest.coverage-gate.config.js index 8eb2d5ba09..02515f0dd2 100644 --- a/jest.coverage-gate.config.js +++ b/jest.coverage-gate.config.js @@ -119,6 +119,10 @@ const PINNED_LOGIC = [ 'src/subdomains/core/payment-link/entities/payment-link.config.ts', 'src/subdomains/core/payment-link/enums/index.ts', 'src/subdomains/core/payment-link/enums/merchant.enum.ts', + 'src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts', + 'src/subdomains/core/statistic/partner-statistic.controller.ts', + 'src/subdomains/core/statistic/partner-statistic.enum.ts', + 'src/subdomains/core/statistic/partner-statistic.suppression.ts', 'src/subdomains/core/trading/enums/index.ts', 'src/subdomains/generic/forwarding/controllers/lnurld-forward.controller.ts', 'src/subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts', @@ -403,6 +407,7 @@ const PINNED_DECLARATIVE = [ 'src/subdomains/core/sell-crypto/route/dto/sell.dto.ts', 'src/subdomains/core/sell-crypto/route/dto/unsigned-tx.dto.ts', 'src/subdomains/core/sell-crypto/route/dto/update-sell.dto.ts', + 'src/subdomains/core/statistic/dto/partner-statistic.dto.ts', 'src/subdomains/generic/gs/dto/support-data.dto.ts', 'src/subdomains/generic/kyc/dto/input/kyc-query.dto.ts', 'src/subdomains/generic/kyc/dto/input/update-kyc-step.dto.ts', diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts new file mode 100644 index 0000000000..8183a7ef3a --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts @@ -0,0 +1,69 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Config, ConfigService } from 'src/config/config'; +import { PartnerStatisticRateLimitGuard } from '../partner-statistic-rate-limit.guard'; + +describe('PartnerStatisticRateLimitGuard', () => { + beforeAll(() => new ConfigService()); + + const guard = new PartnerStatisticRateLimitGuard({} as any, {} as any, {} as any); + + const getTracker = (req: Record): string => guard['getTracker'](req); + + it('keys by jwt.user (wallet id) when present', () => { + expect(getTracker({ user: { user: 42 }, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:42'); + expect(getTracker({ user: { user: 99 }, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:99'); + expect(getTracker({ user: { user: 42 }, realIp: '1.2.3.4' })).not.toBe( + getTracker({ user: { user: 99 }, realIp: '1.2.3.4' }), + ); + }); + + it('does not share a bucket across wallets on the same IP', () => { + const a = getTracker({ user: { user: 1 }, realIp: '185.12.34.56' }); + const b = getTracker({ user: { user: 2 }, realIp: '185.12.34.56' }); + expect(a).not.toEqual(b); + }); + + it('throws plain Error when jwt.user is missing (fail-closed, no IP fallback)', () => { + expect(() => getTracker({ realIp: '9.9.9.9' })).toThrow(Error); + expect(() => getTracker({ realIp: '9.9.9.9' })).toThrow(/authenticated wallet/); + expect(() => getTracker({})).toThrow(Error); + try { + getTracker({}); + fail('expected throw'); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect((e as Error).constructor.name).toBe('Error'); + } + }); + + describe('handleRequest', () => { + it('skips throttling when Config.request.limitCheck is false', async () => { + const prev = Config.request.limitCheck; + Config.request.limitCheck = false; + try { + const result = await guard.handleRequest({} as any, 120, 3600); + expect(result).toBe(true); + } finally { + Config.request.limitCheck = prev; + } + }); + + it('delegates to ThrottlerGuard when limitCheck is true', async () => { + const prev = Config.request.limitCheck; + Config.request.limitCheck = true; + // handleRequest is protected on ThrottlerGuard — cast for the spy. + const superSpy = jest + .spyOn(ThrottlerGuard.prototype as unknown as { handleRequest: typeof guard.handleRequest }, 'handleRequest') + .mockResolvedValue(true); + try { + const ctx = { switchToHttp: () => ({ getRequest: () => ({}) }) } as any; + const result = await guard.handleRequest(ctx, 120, 3600); + expect(superSpy).toHaveBeenCalledWith(ctx, 120, 3600); + expect(result).toBe(true); + } finally { + Config.request.limitCheck = prev; + superSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts new file mode 100644 index 0000000000..eca6fc60f9 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts @@ -0,0 +1,111 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common/enums'; +import { AuthGuard } from '@nestjs/passport'; +import { THROTTLER_LIMIT, THROTTLER_TTL } from '@nestjs/throttler/dist/throttler.constants'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerStatisticRateLimitGuard } from '../partner-statistic-rate-limit.guard'; +import { PartnerStatisticController } from '../partner-statistic.controller'; +import { PartnerStatisticGranularity } from '../partner-statistic.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +/** + * Pins the partner-statistic controller wiring: guard order, throttle budget, + * walletId from jwt.user, and the Day granularity default on the timeline route. + * Pattern mirrors ledger.controller + bank.controller metadata specs. + */ +describe('PartnerStatisticController', () => { + let controller: PartnerStatisticController; + let service: DeepMocked; + + const jwt = { user: 42, role: UserRole.CLIENT_COMPANY, account: 7 } as JwtPayload; + + beforeEach(() => { + service = createMock(); + controller = new PartnerStatisticController(service); + }); + + it('forwards jwt.user as walletId to getStatistics (not account)', async () => { + service.getStatistics.mockResolvedValue({ currency: 'CHF' } as any); + + await controller.getPartnerStatistics(jwt, '2024-06-01', '2024-06-15'); + + expect(service.getStatistics).toHaveBeenCalledWith(42, '2024-06-01', '2024-06-15'); + expect(service.getStatistics).not.toHaveBeenCalledWith(7, expect.anything(), expect.anything()); + }); + + it('forwards jwt.user and optional granularity to getTimeline', async () => { + service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.WEEK } as any); + + await controller.getPartnerTimeline(jwt, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); + + expect(service.getTimeline).toHaveBeenCalledWith(42, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); + }); + + it('forwards undefined granularity to getTimeline when the query omits it', async () => { + service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.DAY } as any); + + await controller.getPartnerTimeline(jwt, undefined, undefined, undefined); + + expect(service.getTimeline).toHaveBeenCalledWith(42, undefined, undefined, undefined); + }); +}); + +// Call-path tests above pass JWT in directly and cannot see the decorators that decide +// whether a request is authenticated, role-checked, or rate-limited. Removing @UseGuards +// or swapping RoleGuard(CLIENT_COMPANY) for a weaker role would leave them green while +// every non-partner wallet reached the service. +describe('PartnerStatisticController routing & security metadata', () => { + const endpoints: Array<{ + handler: 'getPartnerStatistics' | 'getPartnerTimeline'; + path: string; + }> = [ + { handler: 'getPartnerStatistics', path: 'partner' }, + { handler: 'getPartnerTimeline', path: 'partner/timeline' }, + ]; + + it('is mounted under the statistic base path', () => { + expect(Reflect.getMetadata(PATH_METADATA, PartnerStatisticController)).toBe('statistic'); + }); + + it.each(endpoints)('maps $handler to GET $path', ({ handler, path }) => { + const fn = PartnerStatisticController.prototype[handler]; + expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path); + expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(RequestMethod.GET); + }); + + it.each(endpoints)( + 'guards $handler with AuthGuard → RoleGuard(CLIENT_COMPANY) → PartnerStatisticRateLimitGuard', + ({ handler }) => { + const fn = PartnerStatisticController.prototype[handler]; + const guards = Reflect.getMetadata(GUARDS_METADATA, fn) as unknown[]; + + expect(guards).toHaveLength(3); + + // Order matters: auth first, then role, then the partner-specific rate limit. + // AuthGuard() is memoized — same class reference as the decorator on the route + // (bank.controller pattern: exact class-token equality, not constructor.name). + expect(guards[0]).toBe(AuthGuard()); + + // RoleGuard is the only instance-based guard; it must carry CLIENT_COMPANY. + const roleGuard = guards.find((g) => (g as { entryRoles?: UserRole[] }).entryRoles !== undefined) as { + entryRoles: UserRole[]; + }; + expect(roleGuard).toBeDefined(); + expect(roleGuard.entryRoles).toEqual([UserRole.CLIENT_COMPANY]); + expect((roleGuard as { constructor: { name: string } }).constructor.name).toBe('RoleGuardClass'); + + expect(guards[1]).toBe(roleGuard); + expect(guards[2]).toBe(PartnerStatisticRateLimitGuard); + }, + ); + + it.each(endpoints)('throttles $handler at 120 req / 3600 s', ({ handler }) => { + const fn = PartnerStatisticController.prototype[handler]; + // Without this decorator the RateLimitGuard has no per-route budget (ThrottlerModule.forRoot + // is registered without options in this app for some routes). + expect(Reflect.getMetadata(THROTTLER_LIMIT, fn)).toBe(120); + expect(Reflect.getMetadata(THROTTLER_TTL, fn)).toBe(3600); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts new file mode 100644 index 0000000000..99750f3a1c --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -0,0 +1,489 @@ +import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; +import { ConfigService } from 'src/config/config'; +import { isProcessTimezoneUtcYearRound } from 'src/process-timezone'; +import { PartnerStatisticGranularity } from '../partner-statistic.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +/** + * Real-Postgres integration for partner-statistic **service methods**. + * + * Runs only when BOTH are true: + * 1. MIGRATION_TEST_PG is set (CI / local disposable DB) + * 2. Process timezone is UTC year-round (Jan + Jul anchors via `isProcessTimezoneUtcYearRound`) + * + * CI meets the UTC process-timezone condition. This suite deliberately does **not** + * force `process.env.TZ = 'UTC'` (that would hide the dependency and leak into other + * specs in the same Jest worker). The application expects `ENV TZ=UTC` (Dockerfile) + * and logs/warns via `checkProcessTimezone` at process start. Run this suite with + * `TZ=UTC` when developing outside UTC. + * + * Why UTC process TZ is required: `created` is `timestamp without time zone`. The + * Postgres driver serializes a JS `Date` in process-local wall time; Postgres then + * drops the offset. Session `TimeZone=UTC` does not fix driver-side serialization. + * A single `new Date().getTimezoneOffset() === 0` would wrongly accept Europe/London + * in winter — same trap as the process-timezone boot check. + * + * Calls `getStatistics` / `getTimeline` / `mergeNamedRows` against a minimal schema that + * mirrors the production join columns. Lightweight entity stubs provide TypeORM relation + * metadata so `createQueryBuilder('tx').innerJoin('tx.buy', …)` resolves correctly. + * + * SWAP path: `crypto_route` table is present but empty — swap aggregates return zero. + * SELL asset blockchain needs `crypto_input`; left-joined, so missing rows yield null blockchain. + */ + +const PG_URL = process.env.MIGRATION_TEST_PG; +// Year-round offset (Jan + Jul), not "today": IANA name alone can be empty/non-UTC under +// odd hosts even when the wall clock is already UTC-offset; a single getTimezoneOffset() +// would accept London in winter. +const isProcessTimezoneUtc = isProcessTimezoneUtcYearRound(); +const describeDb = PG_URL && isProcessTimezoneUtc ? describe : describe.skip; + +if (PG_URL && !isProcessTimezoneUtc) { + const resolved = typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : '(unknown)'; + const jan = new Date(Date.UTC(2024, 0, 15, 12, 0, 0)).getTimezoneOffset(); + const jul = new Date(Date.UTC(2024, 6, 15, 12, 0, 0)).getTimezoneOffset(); + console.warn( + `[partner-statistic.integration] suite skipped: process timezone must be UTC year-round ` + + `(got january=${jan} min, july=${jul} min, timeZone=${resolved}). ` + + `Column "created" is timestamp without time zone; the Postgres driver serializes ` + + `JS Date values in process-local wall time and Postgres drops the offset, so ` + + `half-open period bounds shift under non-UTC hosts. The application expects ` + + `ENV TZ=UTC and checkProcessTimezone at start. Run with TZ=UTC when developing ` + + `outside UTC.`, + ); +} + +const SCHEMA = 'partner_statistic_spec'; + +// --- Minimal entities (relation graph only; no production entity import tree) --- // + +@Entity({ name: 'user' }) +class TestUser { + @PrimaryColumn() id: number; + @Column() walletId: number; + @Column({ type: 'numeric', default: 0 }) buyVolume: number; + @Column({ type: 'numeric', default: 0 }) sellVolume: number; + @Column({ type: 'timestamp' }) created: Date; + @Column({ type: 'numeric', default: 0 }) partnerRefVolume: number; + @Column({ type: 'numeric', default: 0 }) partnerRefCredit: number; + @Column({ type: 'numeric', default: 0 }) refCredit: number; + @Column({ type: 'numeric', default: 0 }) paidRefCredit: number; +} + +@Entity({ name: 'wallet' }) +class TestWallet { + @PrimaryColumn() id: number; + @ManyToOne(() => TestUser, { nullable: true }) + @JoinColumn({ name: 'ownerId' }) + owner?: TestUser; +} + +@Entity({ name: 'buy' }) +class TestBuy { + @PrimaryColumn() id: number; + @ManyToOne(() => TestUser) + @JoinColumn({ name: 'userId' }) + user: TestUser; +} + +@Entity({ name: 'sell' }) +class TestSell { + @PrimaryColumn() id: number; + @ManyToOne(() => TestUser) + @JoinColumn({ name: 'userId' }) + user: TestUser; +} + +@Entity({ name: 'crypto_route' }) +class TestCryptoRoute { + @PrimaryColumn() id: number; + @ManyToOne(() => TestUser) + @JoinColumn({ name: 'userId' }) + user: TestUser; +} + +@Entity({ name: 'asset' }) +class TestAsset { + @PrimaryColumn() id: number; + @Column({ type: 'varchar', length: 256, nullable: true }) name?: string; + @Column({ type: 'varchar', length: 256, nullable: true }) blockchain?: string; +} + +@Entity({ name: 'transaction' }) +class TestTransaction { + @PrimaryColumn() id: number; + @Column({ type: 'varchar', length: 256, nullable: true }) sourceType?: string; +} + +@Entity({ name: 'crypto_input' }) +class TestCryptoInput { + @PrimaryColumn() id: number; + @ManyToOne(() => TestAsset, { nullable: true }) + @JoinColumn({ name: 'assetId' }) + asset?: TestAsset; +} + +@Entity({ name: 'buy_crypto' }) +class TestBuyCrypto { + @PrimaryColumn() id: number; + @ManyToOne(() => TestBuy, { nullable: true }) + @JoinColumn({ name: 'buyId' }) + buy?: TestBuy; + @ManyToOne(() => TestCryptoRoute, { nullable: true }) + @JoinColumn({ name: 'cryptoRouteId' }) + cryptoRoute?: TestCryptoRoute; + @ManyToOne(() => TestAsset, { nullable: true }) + @JoinColumn({ name: 'outputAssetId' }) + outputAsset?: TestAsset; + @ManyToOne(() => TestTransaction, { nullable: true }) + @JoinColumn({ name: 'transactionId' }) + transaction?: TestTransaction; + @Column({ type: 'numeric', default: 0 }) amountInChf: number; + @Column({ type: 'varchar', length: 256, nullable: true }) inputAsset?: string; + @Column({ type: 'varchar', length: 64, nullable: true }) amlCheck?: string; + @Column({ type: 'timestamp' }) created: Date; +} + +@Entity({ name: 'buy_fiat' }) +class TestBuyFiat { + @PrimaryColumn() id: number; + @ManyToOne(() => TestSell, { nullable: true }) + @JoinColumn({ name: 'sellId' }) + sell?: TestSell; + @ManyToOne(() => TestCryptoInput, { nullable: true }) + @JoinColumn({ name: 'cryptoInputId' }) + cryptoInput?: TestCryptoInput; + @ManyToOne(() => TestAsset, { nullable: true }) + @JoinColumn({ name: 'outputAssetId' }) + outputAsset?: TestAsset; + @ManyToOne(() => TestTransaction, { nullable: true }) + @JoinColumn({ name: 'transactionId' }) + transaction?: TestTransaction; + @Column({ type: 'numeric', default: 0 }) amountInChf: number; + @Column({ type: 'varchar', length: 256, nullable: true }) inputAsset?: string; + @Column({ type: 'varchar', length: 64, nullable: true }) amlCheck?: string; + @Column({ type: 'timestamp' }) created: Date; +} + +const ENTITIES = [ + TestUser, + TestWallet, + TestBuy, + TestSell, + TestCryptoRoute, + TestAsset, + TestTransaction, + TestCryptoInput, + TestBuyCrypto, + TestBuyFiat, +]; + +describeDb('PartnerStatisticService SQL path (real Postgres)', () => { + let dataSource: DataSource; + let service: PartnerStatisticService; + + /** Schema-qualified table name — never rely on session search_path. */ + const t = (name: string) => `"${SCHEMA}"."${name}"`; + + beforeAll(async () => { + new ConfigService(); + // `schema` qualifies every entity table name so QueryBuilder work is correct on any + // pool connection. DDL/DML below also uses fully qualified names — never SET search_path + // (session-bound; a pooled client that skipped the SET would create public."user" and + // race other suites). max:1 keeps setup + fan-out on one backend when the driver allows. + // Session TimeZone=UTC is still set so PG-side TIMESTAMP arithmetic stays UTC; it does + // not fix driver-side JS Date serialization (see file header — process TZ must be UTC). + dataSource = new DataSource({ + type: 'postgres', + url: PG_URL, + entities: ENTITIES, + synchronize: false, + schema: SCHEMA, + extra: { max: 1, options: '-c TimeZone=UTC' }, + }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + await dataSource.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await dataSource.query(`CREATE SCHEMA "${SCHEMA}"`); + + await dataSource.query(` + CREATE TABLE ${t('user')} ( + "id" int PRIMARY KEY, + "walletId" int NOT NULL, + "buyVolume" numeric DEFAULT 0, + "sellVolume" numeric DEFAULT 0, + "created" TIMESTAMP NOT NULL DEFAULT NOW(), + "partnerRefVolume" numeric DEFAULT 0, + "partnerRefCredit" numeric DEFAULT 0, + "refCredit" numeric DEFAULT 0, + "paidRefCredit" numeric DEFAULT 0 + ); + CREATE TABLE ${t('wallet')} ( + "id" int PRIMARY KEY, + "ownerId" int + ); + CREATE TABLE ${t('buy')} ( + "id" int PRIMARY KEY, + "userId" int NOT NULL + ); + CREATE TABLE ${t('sell')} ( + "id" int PRIMARY KEY, + "userId" int NOT NULL + ); + CREATE TABLE ${t('crypto_route')} ( + "id" int PRIMARY KEY, + "userId" int NOT NULL + ); + CREATE TABLE ${t('asset')} ( + "id" int PRIMARY KEY, + "name" varchar(256), + "blockchain" varchar(256) + ); + CREATE TABLE ${t('transaction')} ( + "id" int PRIMARY KEY, + "sourceType" varchar(256) + ); + CREATE TABLE ${t('crypto_input')} ( + "id" int PRIMARY KEY, + "assetId" int + ); + CREATE TABLE ${t('buy_crypto')} ( + "id" SERIAL PRIMARY KEY, + "buyId" int, + "cryptoRouteId" int, + "outputAssetId" int, + "transactionId" int, + "amountInChf" numeric DEFAULT 0, + "inputAsset" varchar(256), + "amlCheck" varchar(64), + "created" TIMESTAMP NOT NULL + ); + CREATE TABLE ${t('buy_fiat')} ( + "id" SERIAL PRIMARY KEY, + "sellId" int, + "cryptoInputId" int, + "outputAssetId" int, + "transactionId" int, + "amountInChf" numeric DEFAULT 0, + "inputAsset" varchar(256), + "amlCheck" varchar(64), + "created" TIMESTAMP NOT NULL + ); + `); + + await dataSource.query(` + INSERT INTO ${t('user')} ("id", "walletId", "buyVolume", "sellVolume", "created", + "partnerRefVolume", "partnerRefCredit", "refCredit", "paidRefCredit") VALUES + (1, 1, 100, 0, '2024-06-01 10:00:00', 0, 0, 0, 0), + (2, 1, 100, 0, '2024-06-01 11:00:00', 0, 0, 0, 0), + (3, 1, 100, 0, '2024-06-01 12:00:00', 0, 0, 0, 0), + (4, 1, 100, 0, '2024-06-01 13:00:00', 0, 0, 0, 0), + (5, 1, 100, 50, '2024-06-01 14:00:00', 0, 0, 0, 0), + (6, 2, 999, 0, '2024-06-01 10:00:00', 0, 0, 0, 0), + (100, 1, 0, 0, '2024-01-01 00:00:00', 42.5, 12.25, 5, 2.25); + INSERT INTO ${t('wallet')} ("id", "ownerId") VALUES (1, 100), (2, 6); + INSERT INTO ${t('buy')} ("id", "userId") VALUES (1,1),(2,2),(3,3),(4,4),(5,5),(6,6); + INSERT INTO ${t('sell')} ("id", "userId") VALUES (1,5); + INSERT INTO ${t('asset')} ("id", "name", "blockchain") VALUES + (1, 'BTC', 'Bitcoin'), (2, 'ETH', 'Ethereum'), (3, 'CHF', NULL); + INSERT INTO ${t('transaction')} ("id", "sourceType") VALUES + (1, 'BankTx'), (2, 'BankTx'), (3, 'BankTx'), (4, 'BankTx'), (5, 'BankTx'), (6, 'BankTx'), (7, 'BankTx'); + INSERT INTO ${t('crypto_input')} ("id", "assetId") VALUES (1, 1); + INSERT INTO ${t('buy_crypto')} + ("buyId", "outputAssetId", "transactionId", "amountInChf", "amlCheck", "created", "inputAsset") + VALUES + (1, 1, 1, 100, 'Pass', '2024-06-10 10:00:00', 'CHF'), + (2, 1, 2, 100, 'Pass', '2024-06-10 11:00:00', 'CHF'), + (3, 1, 3, 100, 'Pass', '2024-06-10 12:00:00', 'CHF'), + (4, 1, 4, 100, 'Pass', '2024-06-11 10:00:00', 'CHF'), + (5, 1, 5, 100, 'Pass', '2024-06-11 11:00:00', 'CHF'), + (6, 2, 6, 9999, 'Pass', '2024-06-10 10:00:00', 'CHF'); + INSERT INTO ${t('buy_fiat')} + ("sellId", "cryptoInputId", "outputAssetId", "transactionId", "amountInChf", "amlCheck", "created", "inputAsset") + VALUES + -- inputAsset left null so the SELL asset breakdown emits no under-k named row. + -- Period totals still see the 1 sell tx (block-suppressed). A named under-k SELL + -- asset (e.g. inputAsset='BTC') would trigger complementary suppression and drop + -- the COMMON BUY BTC row at exactly k — which is correct product behaviour but + -- would hide the SQL asset-join proof this fixture is meant to exercise. + (1, 1, 3, 7, 50, 'Pass', '2024-06-11 12:00:00', NULL); + `); + + const buyCryptoRepo = dataSource.getRepository(TestBuyCrypto) as unknown as Repository; + const buyFiatRepo = dataSource.getRepository(TestBuyFiat) as unknown as Repository; + const userRepo = dataSource.getRepository(TestUser) as unknown as Repository; + const walletRepo = dataSource.getRepository(TestWallet) as unknown as Repository; + + service = new PartnerStatisticService(buyCryptoRepo as any, buyFiatRepo as any, userRepo as any, walletRepo as any); + }); + + afterEach(async () => { + await dataSource.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + it('getStatistics aggregates real rows: volume, active users, breakdown, referral; scopes wallet', async () => { + const from = new Date('2024-06-01T00:00:00.000Z'); + const to = new Date('2024-06-30T00:00:00.000Z'); + + const result = await service.getStatistics(1, from, to); + + // buy 5×100 = 500; sell 50; swap 0 (no crypto_route rows — documented) + // sell has 1 tx → period totals block-suppressed (under k on sell direction) + expect(result.totals.volume.total).toBeNull(); + expect(result.totals.volume.buy).toBeNull(); + expect(result.totals.volume.sell).toBeNull(); + + // Foreign wallet (id=2) volume must not inflate anything when totals become visible at k + // — exercised via active-user / allTime scope instead: + expect(result.allTime.registeredUsers).toBe(6); // users 1–5 + owner 100 on wallet 1 + expect(result.allTime.registeredUsers).not.toBe(7); // must not include wallet-2 user 6 + + // Referral from wallet.owner (user 100) + expect(result.referral.volume).toBe(42.5); + expect(result.referral.creditEarned).toBe(12.25); + expect(result.referral.creditOpen).toBe(15); + expect(result.referral.currency).toBe('EUR'); + + // Asset breakdown: BUY BTC has 5 txs at exactly k and is visible (no under-k named + // SELL asset in this fixture — see seed comment — so complementary does not fire). + // Seed has exactly one BTC asset row → pin count so a missing BTC row fails. + const btc = result.breakdown.assets.filter((a) => a.name === 'BTC'); + expect(btc).toHaveLength(1); + expect(btc[0].volume).toBe(500); + + // No internal users field on the public payload + expect(JSON.stringify(result)).not.toMatch(/"users"/); + }); + + it('getTimeline builds UTC day buckets from real DATE_TRUNC rows and applies suppression', async () => { + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-11T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + + expect(result.buckets.length).toBe(2); + expect(result.buckets.map((b) => b.date.toISOString())).toEqual([ + '2024-06-10T00:00:00.000Z', + '2024-06-11T00:00:00.000Z', + ]); + + // Day-1 has 3 buy txs (under k) → suppressed; day-2 has 2 buy + 1 sell = 3 under k → suppressed + expect(result.buckets[0].suppressed).toBe(true); + expect(result.buckets[0].volume).toBeNull(); + expect(result.buckets[1].suppressed).toBe(true); + expect(result.buckets[1].volume).toBeNull(); + + expect(JSON.stringify(result)).not.toMatch(/"users"/); + }); + + it('countActiveUsers is a set across directions (UNION, not UNION ALL)', async () => { + // Three users each active on buy AND sell → DISTINCT 3; bag-count (UNION ALL) would be 6. + // That bag count is rawUsers.total in suppressPeriodTotals and activeUsers on the DTO — + // k=5 would pass for 6 and fail for 3, which is exactly the disclosure the gate blocks. + await dataSource.query(`DELETE FROM ${t('buy_crypto')}`); + await dataSource.query(`DELETE FROM ${t('buy_fiat')}`); + await dataSource.query(`DELETE FROM ${t('sell')}`); + await dataSource.query(`DELETE FROM ${t('buy')}`); + await dataSource.query(` + INSERT INTO ${t('buy')} ("id", "userId") VALUES (1,1),(2,2),(3,3); + INSERT INTO ${t('sell')} ("id", "userId") VALUES (1,1),(2,2),(3,3); + INSERT INTO ${t('buy_crypto')} + ("buyId", "outputAssetId", "transactionId", "amountInChf", "amlCheck", "created", "inputAsset") + VALUES + (1, 1, 1, 100, 'Pass', '2024-06-10 10:00:00', 'CHF'), + (2, 1, 2, 100, 'Pass', '2024-06-10 11:00:00', 'CHF'), + (3, 1, 3, 100, 'Pass', '2024-06-10 12:00:00', 'CHF'); + INSERT INTO ${t('buy_fiat')} + ("sellId", "cryptoInputId", "outputAssetId", "transactionId", "amountInChf", "amlCheck", "created", "inputAsset") + VALUES + (1, 1, 3, 4, 50, 'Pass', '2024-06-10 13:00:00', NULL), + (2, 1, 3, 5, 50, 'Pass', '2024-06-10 14:00:00', NULL), + (3, 1, 3, 6, 50, 'Pass', '2024-06-10 15:00:00', NULL); + `); + + const from = new Date('2024-06-01T00:00:00.000Z'); + const to = new Date('2024-06-30T00:00:00.000Z'); + const count = await service['countActiveUsers'](1, from, to); + expect(count).toBe(3); + expect(count).not.toBe(6); + + // Public path: 3 under k → activeUsers withheld; UNION ALL would surface 6. + const result = await service.getStatistics(1, from, to); + expect(result.totals.activeUsers).toBeNull(); + expect(result.totals.activeUsers).not.toBe(6); + }); + + it('half-open period excludes the exclusive end instant (semantic, not only SQL string)', async () => { + // Insert a buy_crypto exactly at the exclusive end (2024-06-12 00:00) — must not count. + await dataSource.query(` + INSERT INTO ${t('buy_crypto')} + ("buyId", "outputAssetId", "transactionId", "amountInChf", "amlCheck", "created", "inputAsset") + VALUES (1, 1, 1, 777, 'Pass', '2024-06-12 00:00:00', 'CHF'); + `); + + // Period [2024-06-10, 2024-06-12) — the 777 row is at `to` exclusive boundary. + // Use private aggregate via getTimeline bucket counts: only days 10 and 11. + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-11T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + expect(result.buckets).toHaveLength(2); + // No third bucket for June 12 + expect(result.buckets.every((b) => b.date.toISOString() < '2024-06-12T00:00:00.000Z')).toBe(true); + + // Direct direction aggregate through service private method for the same half-open window + const agg = await service['aggregateByDirection']( + 1, + new Date('2024-06-10T00:00:00.000Z'), + new Date('2024-06-12T00:00:00.000Z'), + // PartnerStatisticDirection.BUY + 'Buy' as any, + ); + // 5 buy rows in window (not the 777 at 06-12) + expect(agg.transactions).toBe(5); + expect(agg.volume).toBe(500); + expect(agg.volume).not.toBe(500 + 777); + }); + + it('mergeNamedRows still works on real GROUP BY output', async () => { + // Entity QB (schema from DataSource) — no search_path, no bare table names. + const rows = await dataSource + .getRepository(TestBuyCrypto) + .createQueryBuilder('tx') + .select('a.name', 'name') + .addSelect('a.blockchain', 'blockchain') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT u.id)', 'users') + .innerJoin(TestBuy, 'route', 'route.id = tx.buyId') + .innerJoin(TestUser, 'u', 'u.id = route.userId') + .leftJoin(TestAsset, 'a', 'a.id = tx.outputAssetId') + .where('u.walletId = :walletId', { walletId: 1 }) + .andWhere('tx.amlCheck = :check', { check: 'Pass' }) + .groupBy('a.name') + .addGroupBy('a.blockchain') + .getRawMany<{ name: string; blockchain: string; volume: string; transactions: string; users: string }>(); + + const merged = service.mergeNamedRows( + rows.map((r) => ({ + name: r.name, + blockchain: r.blockchain, + volume: r.volume, + transactions: r.transactions, + users: r.users, + })), + ); + expect(merged.find((r) => r.name === 'BTC')?.volume).toBe(500); + expect(typeof merged[0].volume).toBe('number'); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts new file mode 100644 index 0000000000..15e3f047bd --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts @@ -0,0 +1,1434 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from 'src/config/config'; +import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { UserRepository } from 'src/subdomains/generic/user/models/user/user.repository'; +import { WalletRepository } from 'src/subdomains/generic/user/models/wallet/wallet.repository'; +import { + PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS, + PARTNER_STATISTIC_MAX_PERIOD_DAYS, + PARTNER_STATISTIC_QUERY_CONCURRENCY, + PartnerStatisticDirection, + PartnerStatisticGranularity, +} from '../partner-statistic.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +// Timeline and period resolution are UTC-only (no process.TZ pin). Specs must stay green under +// arbitrary host timezones — see "timezone independence" below. + +type Direction = PartnerStatisticDirection; + +/** + * Single calendar anchor for all period fixtures. Every from/to is derived from this + * (or from `new Date()` via fake timers) so tests do not go stale as wall-clock years pass. + */ +const TEST_NOW = new Date('2024-06-15T12:00:00.000Z'); +const PERIOD_FROM = new Date('2024-05-16T00:00:00.000Z'); +const PERIOD_TO = TEST_NOW; +/** + * resolvePeriod snaps `to` to exclusive next-day UTC midnight. + * Bound :from/:to on getStatistics(…, PERIOD_FROM, PERIOD_TO) must equal these values. + */ +const RESOLVED_PERIOD_FROM = PERIOD_FROM; +const RESOLVED_PERIOD_TO = new Date('2024-06-16T00:00:00.000Z'); +/** Wednesday UTC before TEST_NOW (Saturday) — mid-week so week-edge buckets are partial. */ +const MID_WEEK_FROM = new Date('2024-06-12T00:00:00.000Z'); +const MID_WEEK_TO = new Date('2024-06-19T00:00:00.000Z'); + +/** + * getStatistics clause budgets captured by the unit-test harness. + * + * How the numbers arise from the production call graph (one getStatistics request): + * - SCOPED_BUILDERS (20): 3 direction aggs + 3 active-user UNION legs + newUsers + allTime + * + referral + 3 asset + 2 fiat + 3 blockchain + 3 payment-method = 20 wallet-scoped WHEREs + * - AML_FILTERS (17): baseTxQuery × (3 dir + 3 active + 3 asset + 2 fiat + 3 blockchain + 3 payment) + * - PERIOD_FILTERS (18): those 17 baseTxQuery half-open bounds + countNewUsers (user.created) + * - WHERE_CLAUSES (55): 17×(scope+period+aml) + newUsers(scope+period) + allTime(scope) + * + referral(scope) + * - ACTIVE_USER_UNION_LEGS (3): buy / sell / swap getQuery legs in countActiveUsers + * + * Keep these in one place: a legitimate expansion must update the budget once, not three copies. + */ +export const GET_STATISTICS_CLAUSE_BUDGET = { + SCOPED_BUILDERS: 20, + AML_FILTERS: 17, + PERIOD_FILTERS: 18, + WHERE_CLAUSES: 55, + ACTIVE_USER_UNION_LEGS: 3, +} as const; + +/** Multiset equality: same elements with same multiplicity, order ignored. */ +function expectMultisetEqual(actual: string[], expected: string[]): void { + expect([...actual].sort()).toEqual([...expected].sort()); +} + +interface DirectionFixture { + volume: number; + transactions: number; + users: number; +} + +interface WalletFixture { + buy: DirectionFixture; + sell: DirectionFixture; + swap: DirectionFixture; + activeUserIds: number[]; + newUsers: number; + allTime: { buy: number; sell: number; registeredUsers: number; tradingUsers: number }; + referral: { volume: number; partnerRefCredit: number; refCredit: number; paidRefCredit: number }; + /** When true, wallet getRawOne returns null (missing wallet row). */ + missingWallet?: boolean; + /** Named breakdown rows returned by getRawMany for asset/fiat/blockchain/payment queries. */ + namedRows: { name: string; blockchain?: string; volume: number; transactions: number; users: number }[]; + timelineRows: { bucket: Date; volume: number; transactions: number; users: number }[]; +} + +function emptyFixture(overrides: Partial = {}): WalletFixture { + return { + buy: { volume: 0, transactions: 0, users: 0 }, + sell: { volume: 0, transactions: 0, users: 0 }, + swap: { volume: 0, transactions: 0, users: 0 }, + activeUserIds: [], + newUsers: 0, + allTime: { buy: 0, sell: 0, registeredUsers: 0, tradingUsers: 0 }, + referral: { volume: 0, partnerRefCredit: 0, refCredit: 0, paidRefCredit: 0 }, + namedRows: [], + timelineRows: [], + ...overrides, + }; +} + +/** Recursively collect every own-property key name in a JSON-serializable value. */ +function collectKeys(value: unknown, out: Set = new Set()): Set { + if (value == null || typeof value !== 'object') return out; + if (Array.isArray(value)) { + for (const item of value) collectKeys(item, out); + return out; + } + for (const [k, v] of Object.entries(value as Record)) { + out.add(k); + collectKeys(v, out); + } + return out; +} + +interface QbState { + walletId?: number; + direction?: Direction; + selects: string[]; + selectAliases: string[]; + groupBys: string[]; + isTimeline: boolean; + isAllTime: boolean; + isReferral: boolean; +} + +interface GroupByCapture { + groupBys: string[]; + selectAliases: string[]; +} + +describe('PartnerStatisticService', () => { + let service: PartnerStatisticService; + let fixtures: Map; + let lastWalletIds: number[]; + let whereClauses: string[]; + let amlFilterClauses: string[]; + /** Bound `{ from, to }` from every where/andWhere that supplies period params. */ + let periodBoundParams: { from: unknown; to: unknown }[]; + let groupByCapture: GroupByCapture; + let managerCreateQueryBuilderCalls: number; + let getQueryCalls: number; + /** SQL passed to manager.createQueryBuilder().from(…) for the active-user UNION. */ + let activeUserUnionFromSql: string | undefined; + let activeUserCountFromManager: number | undefined; + let concurrentQueries: number; + let maxConcurrentQueries: number; + let queryDelayMs: number; + let nonNumericVolume: string | number | null | undefined | false; + + /** + * Records walletId from query params when present and returns it for fixture routing. + * `params &&` already excludes null/undefined — no separate null check (CodeQL inconvertible types). + */ + function trackWalletId(params?: unknown): number | undefined { + if (params && typeof params === 'object' && 'walletId' in params) { + const id = (params as { walletId: unknown }).walletId; + if (typeof id === 'number') { + lastWalletIds.push(id); + return id; + } + } + return undefined; + } + + function fixtureFor(walletId?: number): WalletFixture { + if (walletId == null) { + // Unscoped fallback when a QB never captured :walletId (harness gap). Must still + // carry boolean flags — dropping missingWallet made D6 return a synthetic row + // (merged volumes, row present) instead of null, so getStatistics did not throw. + const merged = emptyFixture(); + for (const f of fixtures.values()) { + merged.buy.volume += f.buy.volume; + merged.buy.transactions += f.buy.transactions; + merged.buy.users += f.buy.users; + merged.sell.volume += f.sell.volume; + merged.sell.transactions += f.sell.transactions; + merged.sell.users += f.sell.users; + merged.swap.volume += f.swap.volume; + merged.swap.transactions += f.swap.transactions; + merged.swap.users += f.swap.users; + merged.activeUserIds.push(...f.activeUserIds); + merged.newUsers += f.newUsers; + merged.allTime.buy += f.allTime.buy; + merged.allTime.sell += f.allTime.sell; + merged.allTime.registeredUsers += f.allTime.registeredUsers; + merged.allTime.tradingUsers += f.allTime.tradingUsers; + merged.referral.volume += f.referral.volume; + merged.referral.partnerRefCredit += f.referral.partnerRefCredit; + merged.referral.refCredit += f.referral.refCredit; + merged.referral.paidRefCredit += f.referral.paidRefCredit; + merged.namedRows.push(...f.namedRows); + merged.timelineRows.push(...f.timelineRows); + if (f.missingWallet) merged.missingWallet = true; + } + return merged; + } + return fixtures.get(walletId) ?? emptyFixture(); + } + + async function trackConcurrency(work: () => Promise): Promise { + concurrentQueries += 1; + maxConcurrentQueries = Math.max(maxConcurrentQueries, concurrentQueries); + try { + if (queryDelayMs > 0) await new Promise((r) => setTimeout(r, queryDelayMs)); + return await work(); + } finally { + concurrentQueries -= 1; + } + } + + function createQb(kind: 'buyCrypto' | 'buyFiat' | 'user' | 'wallet') { + const state: QbState = { + selects: [], + selectAliases: [], + groupBys: [], + isTimeline: false, + isAllTime: false, + isReferral: false, + }; + + const qb: Record unknown)> = {}; + const self = () => qb; + + const recordSelect = (clause: unknown, alias?: unknown) => { + const expr = String(clause); + const label = alias != null ? String(alias) : expr; + state.selects.push(expr); + if (alias != null) { + state.selectAliases.push(String(alias)); + groupByCapture.selectAliases.push(String(alias)); + } + if (expr.includes('DATE_TRUNC') || label === 'bucket') state.isTimeline = true; + if (label === 'registeredUsers' || expr.includes('registeredUsers')) state.isAllTime = true; + if (label === 'partnerRefCredit' || expr.includes('partnerRefCredit')) state.isReferral = true; + }; + + const recordGroupBy = (clause: unknown) => { + const g = String(clause); + state.groupBys.push(g); + groupByCapture.groupBys.push(g); + }; + + qb.select = jest.fn((clause: unknown, alias?: unknown) => { + recordSelect(clause, alias); + return self(); + }); + qb.addSelect = jest.fn((clause: unknown, alias?: unknown) => { + recordSelect(clause, alias); + return self(); + }); + qb.innerJoin = jest.fn((path: string) => { + if (path === 'tx.buy') state.direction = PartnerStatisticDirection.BUY; + if (path === 'tx.cryptoRoute') state.direction = PartnerStatisticDirection.SWAP; + if (path === 'tx.sell') state.direction = PartnerStatisticDirection.SELL; + return self(); + }); + qb.leftJoin = jest.fn(() => self()); + qb.groupBy = jest.fn((clause: unknown) => { + recordGroupBy(clause); + return self(); + }); + qb.addGroupBy = jest.fn((clause: unknown) => { + recordGroupBy(clause); + return self(); + }); + qb.orderBy = jest.fn(() => self()); + qb.setParameter = jest.fn(() => self()); + const capturePeriodParams = (params?: unknown) => { + if (params && typeof params === 'object' && 'from' in params) { + const p = params as { from: unknown; to?: unknown }; + periodBoundParams.push({ from: p.from, to: p.to }); + } + }; + qb.where = jest.fn((clause: unknown, params?: unknown) => { + whereClauses.push(String(clause)); + const walletId = trackWalletId(params); + if (walletId !== undefined) state.walletId = walletId; + capturePeriodParams(params); + return self(); + }); + qb.andWhere = jest.fn((clause: unknown, params?: unknown) => { + const clauseStr = String(clause); + whereClauses.push(clauseStr); + const walletId = trackWalletId(params); + if (walletId !== undefined) state.walletId = walletId; + capturePeriodParams(params); + if (clauseStr.includes('amlCheck')) { + amlFilterClauses.push(clauseStr); + } + return self(); + }); + qb.getQuery = jest.fn(() => { + // countActiveUsers is the only production caller of getQuery (UNION legs). + getQueryCalls += 1; + return `SELECT user.id AS id FROM mock_${kind}_${state.direction ?? 'x'}`; + }); + qb.getParameters = jest.fn(() => ({ + walletId: state.walletId, + check: 'Pass', + from: new Date(TEST_NOW), + to: new Date(TEST_NOW), + })); + + qb.getRawOne = jest.fn(async () => + trackConcurrency(async () => { + // Wallet-row presence must not depend on whether :walletId was captured into + // this QB's local state. Prefer the scoped fixture; if the id is missing, the + // unscoped merge still carries missingWallet (see fixtureFor). Using a bare + // emptyFixture() here would invent a row and silence D6. + const f = + kind === 'wallet' || state.isReferral + ? state.walletId != null + ? (fixtures.get(state.walletId) ?? emptyFixture()) + : fixtureFor(undefined) + : fixtureFor(state.walletId); + + if (kind === 'wallet' || state.isReferral) { + if (f.missingWallet) return null; + return { + volume: f.referral.volume, + partnerRefCredit: f.referral.partnerRefCredit, + refCredit: f.referral.refCredit, + paidRefCredit: f.referral.paidRefCredit, + }; + } + + if (kind === 'user' || state.isAllTime) { + return { + buy: f.allTime.buy, + sell: f.allTime.sell, + registeredUsers: f.allTime.registeredUsers, + tradingUsers: f.allTime.tradingUsers, + }; + } + + if (state.isTimeline) return null; + + const dir = + state.direction ?? (kind === 'buyFiat' ? PartnerStatisticDirection.SELL : PartnerStatisticDirection.BUY); + const agg = + f[dir === PartnerStatisticDirection.BUY ? 'buy' : dir === PartnerStatisticDirection.SELL ? 'sell' : 'swap']; + const volume = nonNumericVolume !== false ? nonNumericVolume : agg.volume; + return { volume, transactions: agg.transactions, users: agg.users }; + }), + ); + + qb.getRawMany = jest.fn(async () => + trackConcurrency(async () => { + if (state.isTimeline) { + const f = fixtureFor(state.walletId); + return f.timelineRows.map((r) => ({ + bucket: r.bucket, + volume: r.volume, + transactions: r.transactions, + users: r.users, + })); + } + // Breakdown path — exercise mergeNamedRows + const f = fixtureFor(state.walletId); + return f.namedRows.map((r) => ({ + name: r.name, + blockchain: r.blockchain ?? null, + volume: r.volume, + transactions: r.transactions, + users: r.users, + })); + }), + ); + + qb.getCount = jest.fn(async () => trackConcurrency(async () => fixtureFor(state.walletId).newUsers)); + + return qb; + } + + function createManagerQb() { + const state: { walletId?: number } = {}; + const qb: Record = {}; + const self = () => qb; + + qb.select = jest.fn(() => self()); + qb.from = jest.fn((table: unknown) => { + // countActiveUsers: .from(`(${unionSql})`, 'active_users') — capture the subquery so a + // hand-written 4th UNION leg (or UNION ALL) is visible even when getQueryCalls stays 3. + if (typeof table === 'string') activeUserUnionFromSql = table; + return self(); + }); + qb.setParameters = jest.fn((params: Record) => { + const walletId = trackWalletId(params); + if (walletId !== undefined) state.walletId = walletId; + return self(); + }); + qb.getRawOne = jest.fn(async () => + trackConcurrency(async () => { + managerCreateQueryBuilderCalls += 1; + const f = fixtureFor(state.walletId); + const count = activeUserCountFromManager ?? f.activeUserIds.length; + return { count }; + }), + ); + + return qb; + } + + /** + * Pins the active-user UNION shape: exactly three set-legs joined by UNION (not UNION ALL), + * and no extra hand-written SELECT leg that would skip getQueryCalls. + */ + function expectActiveUserUnionShape(): void { + expect(getQueryCalls).toBe(GET_STATISTICS_CLAUSE_BUDGET.ACTIVE_USER_UNION_LEGS); + expect(activeUserUnionFromSql).toBeDefined(); + const sql = activeUserUnionFromSql as string; + expect(sql).not.toMatch(/UNION\s+ALL/i); + // Outer wrapper is `(${unionSql})`; count bare UNION separators between legs. + const unionSeparators = (sql.match(/\bUNION\b/gi) ?? []).length; + expect(unionSeparators).toBe(GET_STATISTICS_CLAUSE_BUDGET.ACTIVE_USER_UNION_LEGS - 1); + } + + /** Pins half-open period clause text *and* the bound :from/:to values. */ + function expectPeriodBoundsMatchResolved(): void { + const PERIOD_EXACT_RE = /^(?:tx|user)\.created\s*>=\s*:from\s+AND\s+(?:tx|user)\.created\s*<\s*:to$/; + const halfOpen = whereClauses.filter((c) => PERIOD_EXACT_RE.test(c)); + expect(halfOpen).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.PERIOD_FILTERS); + // Every period-bearing andWhere must bind the resolved window — clause-only pins miss + // `from: new Date(0)` (or a dropped `to`) while the SQL text stays identical. + expect(periodBoundParams).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.PERIOD_FILTERS); + for (const p of periodBoundParams) { + expect(p.from).toEqual(RESOLVED_PERIOD_FROM); + expect(p.to).toEqual(RESOLVED_PERIOD_TO); + expect(p.from).not.toEqual(new Date(0)); + } + } + + beforeEach(async () => { + lastWalletIds = []; + whereClauses = []; + amlFilterClauses = []; + periodBoundParams = []; + fixtures = new Map(); + groupByCapture = { groupBys: [], selectAliases: [] }; + managerCreateQueryBuilderCalls = 0; + getQueryCalls = 0; + activeUserUnionFromSql = undefined; + activeUserCountFromManager = undefined; + concurrentQueries = 0; + maxConcurrentQueries = 0; + queryDelayMs = 0; + nonNumericVolume = false; + new ConfigService(); + + const buyCryptoRepo = { + createQueryBuilder: jest.fn(() => createQb('buyCrypto')), + manager: { + createQueryBuilder: jest.fn(() => createManagerQb()), + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PartnerStatisticService, + { provide: BuyCryptoRepository, useValue: buyCryptoRepo }, + { provide: BuyFiatRepository, useValue: { createQueryBuilder: jest.fn(() => createQb('buyFiat')) } }, + { provide: UserRepository, useValue: { createQueryBuilder: jest.fn(() => createQb('user')) } }, + { provide: WalletRepository, useValue: { createQueryBuilder: jest.fn(() => createQb('wallet')) } }, + ], + }).compile(); + + service = module.get(PartnerStatisticService); + }); + + // Fake timers must not leak into later tests (e.g. the concurrency cap that uses real setTimeout). + // useRealTimers only on the success path left a broken assertion with fake time still on — + // the next async test then hung until Jest's 5s timeout. + afterEach(() => { + jest.useRealTimers(); + }); + + // --- PERIOD VALIDATION --- // + + describe('resolvePeriod', () => { + it('defaults to the last 30 inclusive UTC calendar days when from/to are omitted', () => { + jest.useFakeTimers().setSystemTime(TEST_NOW); + + const period = service.resolvePeriod(); + // to = start of day after TEST_NOW's UTC day + expect(period.to.toISOString()).toBe('2024-06-16T00:00:00.000Z'); + // Inclusive N=30 → from = toDay − 29 = 2024-05-17 + expect(period.from.toISOString()).toBe( + new Date(Date.UTC(2024, 5, 15 - (PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS - 1))).toISOString(), + ); + const spanDays = (period.to.getTime() - period.from.getTime()) / (24 * 3600 * 1000); + expect(spanDays).toBe(PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS); + }); + + it('snaps from to UTC day start and to to exclusive end of day', () => { + const period = service.resolvePeriod('2024-06-01T15:30:00.000Z', '2024-06-03T08:00:00.000Z'); + expect(period.from.toISOString()).toBe('2024-06-01T00:00:00.000Z'); + expect(period.to.toISOString()).toBe('2024-06-04T00:00:00.000Z'); + }); + + it('rejects from ≥ to after snap', () => { + expect(() => service.resolvePeriod('2024-06-05', '2024-06-04')).toThrow(BadRequestException); + expect(() => service.resolvePeriod('2024-06-05', '2024-06-04')).toThrow(/From must be before to/); + }); + + it('rejects periods longer than the max span', () => { + const from = new Date(TEST_NOW.getTime() - (PARTNER_STATISTIC_MAX_PERIOD_DAYS + 50) * 24 * 3600 * 1000); + const to = TEST_NOW; + expect(() => service.resolvePeriod(from, to)).toThrow(BadRequestException); + expect(() => service.resolvePeriod(from, to)).toThrow(String(PARTNER_STATISTIC_MAX_PERIOD_DAYS)); + }); + + it('accepts a period of exactly the max span', () => { + const from = new Date(TEST_NOW.getTime() - (PARTNER_STATISTIC_MAX_PERIOD_DAYS - 1) * 24 * 3600 * 1000); + const to = TEST_NOW; + expect(() => service.resolvePeriod(from, to)).not.toThrow(); + }); + + it('accepts a single full day', () => { + const period = service.resolvePeriod('2024-06-01T00:00:00.000Z', '2024-06-01T23:59:59.000Z'); + expect(period.from.toISOString()).toBe('2024-06-01T00:00:00.000Z'); + expect(period.to.toISOString()).toBe('2024-06-02T00:00:00.000Z'); + }); + + it('default from is UTC-stable across process timezones and DST edges (D2)', () => { + // Instant that lands on different local calendar dates in Berlin vs New York. + const to = new Date('2024-11-02T00:30:00.000Z'); + const period = service.resolvePeriod(undefined, to); + // 30 inclusive UTC days ending 2024-11-02 → from 2024-10-04 + expect(period.from.toISOString()).toBe('2024-10-04T00:00:00.000Z'); + expect(period.to.toISOString()).toBe('2024-11-03T00:00:00.000Z'); + + // Near EU spring-forward (2024-03-31); local daysBefore would shift under Berlin. + const toSpring = new Date('2024-04-05T23:30:00.000Z'); + const p2 = service.resolvePeriod(undefined, toSpring); + expect(p2.from.toISOString()).toBe('2024-03-07T00:00:00.000Z'); + expect(p2.to.toISOString()).toBe('2024-04-06T00:00:00.000Z'); + + // Same results under two process TZ values (UTC arithmetic must not consult local setDate). + const prev = process.env.TZ; + try { + for (const tz of ['Europe/Berlin', 'America/New_York']) { + process.env.TZ = tz; + expect(service.resolvePeriod(undefined, to).from.toISOString()).toBe('2024-10-04T00:00:00.000Z'); + expect(service.resolvePeriod(undefined, toSpring).from.toISOString()).toBe('2024-03-07T00:00:00.000Z'); + } + } finally { + if (prev === undefined) delete process.env.TZ; + else process.env.TZ = prev; + } + + // Contrast: Util.daysBefore uses local setDate — under non-UTC hosts it can disagree. + // We only assert our path is the UTC one above; we do not call Util.daysBefore here. + }); + }); + + describe('parseGranularity', () => { + it('rejects invalid granularity with a clear message', () => { + expect(() => service.parseGranularity('year')).toThrow(BadRequestException); + expect(() => service.parseGranularity('year')).toThrow(/Day, Week, Month/); + }); + + it('accepts Day|Week|Month', () => { + expect(service.parseGranularity('Day')).toBe(PartnerStatisticGranularity.DAY); + expect(service.parseGranularity('Week')).toBe(PartnerStatisticGranularity.WEEK); + expect(service.parseGranularity('Month')).toBe(PartnerStatisticGranularity.MONTH); + }); + + // Express delivers string[] for repeated query params (?granularity=x&granularity=y). + it('rejects a single-element array (repeated query param, one value)', () => { + expect(() => service.parseGranularity(['Day'] as unknown as string)).toThrow(BadRequestException); + expect(() => service.parseGranularity(['Day'] as unknown as string)).toThrow(/must be a string/); + }); + + it('rejects a multi-element array (repeated query param, several values)', () => { + expect(() => service.parseGranularity(['Day', 'Week'] as unknown as string)).toThrow(BadRequestException); + expect(() => service.parseGranularity(['Day', 'Week'] as unknown as string)).toThrow(/must be a string/); + }); + }); + + describe('parseDate', () => { + it('accepts YYYY-MM-DD as UTC midnight', () => { + const d = service.parseDate('2026-01-15'); + expect(d?.toISOString()).toBe('2026-01-15T00:00:00.000Z'); + }); + + it('accepts ISO-8601 with Z', () => { + const d = service.parseDate('2026-01-15T12:30:00.000Z'); + expect(d?.toISOString()).toBe('2026-01-15T12:30:00.000Z'); + }); + + it('accepts ISO-8601 with numeric offset', () => { + const d = service.parseDate('2026-01-15T00:00:00+01:00'); + expect(d?.toISOString()).toBe('2026-01-14T23:00:00.000Z'); + }); + + it('passes through a valid Date instance', () => { + const input = new Date('2026-01-15T00:00:00.000Z'); + expect(service.parseDate(input)).toBe(input); + }); + + it('returns undefined for null/empty', () => { + expect(service.parseDate(undefined)).toBeUndefined(); + expect(service.parseDate('')).toBeUndefined(); + }); + + // Express delivers string[] for repeated query params (?from=x&from=y). Without an + // explicit typeof guard, RegExp#test coerces a one-element array and Array#slice + // returns an array — type confusion (CodeQL js/type-confusion-through-parameter-tampering). + it('rejects a single-element array (repeated query param, one value)', () => { + expect(() => service.parseDate(['2026-01-15'])).toThrow(BadRequestException); + expect(() => service.parseDate(['2026-01-15'])).toThrow(/must be a string or Date/); + }); + + it('rejects a multi-element array (repeated query param, several values)', () => { + expect(() => service.parseDate(['2026-01-15', '2026-01-16'])).toThrow(BadRequestException); + expect(() => service.parseDate(['2026-01-15', '2026-01-16'])).toThrow(/must be a string or Date/); + }); + + it('rejects timestamp without Z/offset (would be process-local under new Date)', () => { + expect(() => service.parseDate('2026-01-15T00:00:00')).toThrow(BadRequestException); + expect(() => service.parseDate('2026-01-15T00:00:00')).toThrow(/YYYY-MM-DD|ISO-8601|Z or offset/); + }); + + it('rejects free-text dates', () => { + expect(() => service.parseDate('Jan 15 2026')).toThrow(BadRequestException); + expect(() => service.parseDate('Jan 15 2026')).toThrow(/YYYY-MM-DD|ISO-8601|Z or offset/); + }); + + it("rejects numeric string '0' (new Date('0') is a real instant)", () => { + expect(() => service.parseDate('0')).toThrow(BadRequestException); + expect(() => service.parseDate('0')).toThrow(/YYYY-MM-DD|ISO-8601|Z or offset/); + }); + + it('rejects non-existent calendar days that Date would silently roll over', () => { + // June has 30 days; Date('2024-06-31') becomes 2024-07-01 without this guard. + expect(() => service.parseDate('2024-06-31')).toThrow(BadRequestException); + expect(() => service.parseDate('2024-06-31')).toThrow(/YYYY-MM-DD|ISO-8601|Z or offset/); + // 2023 is not a leap year. + expect(() => service.parseDate('2023-02-29')).toThrow(BadRequestException); + expect(() => service.parseDate('2024-02-30')).toThrow(BadRequestException); + }); + + it('rejects non-existent calendar days in ISO-with-Z form too', () => { + expect(() => service.parseDate('2024-06-31T00:00:00.000Z')).toThrow(BadRequestException); + expect(() => service.parseDate('2023-02-29T12:00:00+01:00')).toThrow(BadRequestException); + }); + + it('accepts a real leap-day (2024-02-29)', () => { + const d = service.parseDate('2024-02-29'); + expect(d?.toISOString()).toBe('2024-02-29T00:00:00.000Z'); + expect(service.parseDate('2024-02-29T00:00:00.000Z')?.toISOString()).toBe('2024-02-29T00:00:00.000Z'); + }); + + it('rejects out-of-range time components that pass the regex but yield Invalid Date', () => { + // Calendar day is fine; hour 25 is not — this is the remaining isNaN guard path. + expect(() => service.parseDate('2024-06-15T25:00:00.000Z')).toThrow(BadRequestException); + expect(() => service.parseDate('2024-06-15T25:00:00.000Z')).toThrow(/YYYY-MM-DD|ISO-8601|Z or offset/); + }); + }); + + // --- B1: GROUP BY must not use SELECT aliases --- // + + describe('groupBy uses qualified columns, never SELECT aliases (B1)', () => { + it('records only qualified columns or DATE_TRUNC expressions for every groupBy across all breakdowns', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + sell: { volume: 200, transactions: 10, users: 8 }, + swap: { volume: 50, transactions: 10, users: 6 }, + allTime: { buy: 5000, sell: 1000, registeredUsers: 100, tradingUsers: 40 }, + newUsers: 8, + activeUserIds: [1, 2, 3, 4, 5, 6], + }), + ); + + await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + await service.getTimeline(1, PERIOD_FROM, new Date('2024-05-30T00:00:00.000Z'), PartnerStatisticGranularity.DAY); + + // Multiset (order-independent): runAll registration order is not a product contract. + // toEqual on the full list went red on a pure reordering of aggregateBlockchains / + // aggregatePaymentMethods in the getStatistics runAll list — 17-element diff, no + // behaviour change. arrayContaining / length>0 still miss dropped or extra columns. + const EXPECTED_GROUP_BYS = [ + // aggregateAssets: BUY, SELL, SWAP + 'outputAsset.name', + 'outputAsset.blockchain', + 'tx.inputAsset', + 'inputAsset.blockchain', + 'outputAsset.name', + 'outputAsset.blockchain', + // aggregateFiatCurrencies: BUY input, SELL fiat + 'tx.inputAsset', + 'fiat.name', + // aggregateBlockchains: BUY, SWAP, SELL + 'outputAsset.blockchain', + 'outputAsset.blockchain', + 'inputAsset.blockchain', + // aggregatePaymentMethods: BUY, SELL, SWAP + 'transaction.sourceType', + 'transaction.sourceType', + 'transaction.sourceType', + // timelineByDirection: BUY, SELL, SWAP + "DATE_TRUNC('day', tx.created) AT TIME ZONE 'UTC'", + "DATE_TRUNC('day', tx.created) AT TIME ZONE 'UTC'", + "DATE_TRUNC('day', tx.created) AT TIME ZONE 'UTC'", + ]; + expectMultisetEqual(groupByCapture.groupBys, EXPECTED_GROUP_BYS); + + const aliases = new Set(groupByCapture.selectAliases); + for (const g of groupByCapture.groupBys) { + const isQualified = g.includes('.'); + const isDateTrunc = g.startsWith('DATE_TRUNC('); + expect(isQualified || isDateTrunc).toBe(true); + expect(aliases.has(g)).toBe(false); + } + }); + }); + + // --- SCOPE ISOLATION --- // + + describe('wallet scope isolation', () => { + beforeEach(() => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 10, users: 8 }, + sell: { volume: 200, transactions: 5, users: 5 }, + swap: { volume: 50, transactions: 5, users: 5 }, + allTime: { buy: 5000, sell: 1000, registeredUsers: 100, tradingUsers: 40 }, + referral: { volume: 50, partnerRefCredit: 10, refCredit: 0, paidRefCredit: 4 }, + newUsers: 8, + activeUserIds: [11, 12, 13, 14, 15, 16], + }), + ); + fixtures.set( + 2, + emptyFixture({ + buy: { volume: 99999, transactions: 999, users: 100 }, + sell: { volume: 88888, transactions: 888, users: 90 }, + swap: { volume: 77777, transactions: 777, users: 80 }, + allTime: { buy: 77777, sell: 66666, registeredUsers: 9999, tradingUsers: 8888 }, + referral: { volume: 12345, partnerRefCredit: 999, refCredit: 0, paidRefCredit: 111 }, + newUsers: 500, + activeUserIds: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30], + }), + ); + }); + + it('returns only wallet A aggregates and scopes SQL to user.walletId on every user-related query', async () => { + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + // Exact full-clause form (not substring): removing, rewriting, or OR-extending a scope + // drops the count — unanchored filter+every is vacuum-true on survivors and on + // `user.walletId = :walletId OR 1 = 1`. Budgets: GET_STATISTICS_CLAUSE_BUDGET. + const SCOPE_EXACT_RE = /^(?:user\.walletId|wallet\.id)\s*=\s*:walletId$/; + const scopeClauses = whereClauses.filter((c) => SCOPE_EXACT_RE.test(c)); + expect(scopeClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.SCOPED_BUILDERS); + expect(lastWalletIds.length).toBeGreaterThanOrEqual(GET_STATISTICS_CLAUSE_BUDGET.SCOPED_BUILDERS); + expect(lastWalletIds.every((id) => id === 1)).toBe(true); + expect(whereClauses.every((c) => !/user\.id\s*=\s*:walletId/.test(c))).toBe(true); + + // Pin count + exact clause so omitting SELL/SWAP (or OR-extending the check) fails; + // length>0 + unanchored every was vacuum-true when only BUY kept the filter. + const AML_EXACT_RE = /^tx\.amlCheck\s*=\s*:check$/; + expect(amlFilterClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.AML_FILTERS); + expect(amlFilterClauses.every((c) => AML_EXACT_RE.test(c))).toBe(true); + + // Filtered pins (20/17/18) miss an extra unscoped WHERE that matches none of the three + // exact regexes. Total pin + UNION shape (getQuery + from SQL) close that gap. + expect(whereClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.WHERE_CLAUSES); + expectActiveUserUnionShape(); + expectPeriodBoundsMatchResolved(); + + expect(result.totals.volume.buy).toBe(1000); + expect(result.totals.volume.sell).toBe(200); + expect(result.totals.volume.swap).toBe(50); + expect(result.allTime.registeredUsers).toBe(100); + expect(result.referral.volume).toBe(50); + expect(result.referral.creditEarned).toBe(10); + expect(result.totals.newUsers).toBe(8); + + expect(result.totals.volume.buy).not.toBe(99999); + expect(result.allTime.registeredUsers).not.toBe(9999); + expect(result.referral.volume).not.toBe(12345); + expect(result.totals.newUsers).not.toBe(500); + }); + + it('returns only wallet B aggregates when called with wallet B (not wallet A)', async () => { + // beforeEach already clears lastWalletIds / whereClauses / getQueryCalls / periodBoundParams + // / activeUserUnionFromSql — no manual partial reset (that left getQueryCalls asymmetric). + const result = await service.getStatistics(2, PERIOD_FROM, PERIOD_TO); + + const SCOPE_EXACT_RE = /^(?:user\.walletId|wallet\.id)\s*=\s*:walletId$/; + const scopeClauses = whereClauses.filter((c) => SCOPE_EXACT_RE.test(c)); + expect(scopeClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.SCOPED_BUILDERS); + expect(whereClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.WHERE_CLAUSES); + expectActiveUserUnionShape(); + expectPeriodBoundsMatchResolved(); + expect(lastWalletIds.length).toBeGreaterThanOrEqual(GET_STATISTICS_CLAUSE_BUDGET.SCOPED_BUILDERS); + expect(lastWalletIds.every((id) => id === 2)).toBe(true); + + expect(result.totals.volume.buy).toBe(99999); + expect(result.totals.volume.buy).not.toBe(1000); + }); + }); + + // --- B3: totals / allTime suppression via service --- // + + describe('totals and allTime suppression (B3)', () => { + it('nulls totals when overall transaction count is below k (boundary at k)', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 99, transactions: 4, users: 4 }, + allTime: { buy: 99, sell: 0, registeredUsers: 10, tradingUsers: 4 }, + newUsers: 0, + activeUserIds: [1, 2, 3, 4], + }), + ); + + const under = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + expect(under.totals.volume.total).toBeNull(); + expect(under.totals.volume.buy).toBeNull(); + expect(under.totals.transactions.total).toBeNull(); + expect(under.totals.averageTransactionVolume).toBeNull(); + expect(under.allTime.volume.total).toBeNull(); + expect(under.allTime.volume.buy).toBeNull(); + expect(under.allTime.registeredUsers).toBe(10); + expect(under.allTime.tradingUsers).toBeNull(); + expect(under.referral.volume).toBeNull(); + expect(under.meta.suppressedCount).toBeGreaterThanOrEqual(2); + + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 5, users: 5 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + newUsers: 0, + activeUserIds: [1, 2, 3, 4, 5], + }), + ); + + const atK = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + expect(atK.totals.volume.total).toBe(100); + expect(atK.totals.transactions.total).toBe(5); + expect(atK.allTime.volume.total).toBe(100); + expect(atK.allTime.tradingUsers).toBe(5); + }); + + it('nulls totals when person count is under k even with high transaction counts', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 2 }, + allTime: { buy: 1000, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + newUsers: 0, + activeUserIds: [1, 2], + }), + ); + activeUserCountFromManager = 2; + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + expect(result.totals.volume.total).toBeNull(); + expect(result.totals.activeUsers).toBeNull(); + }); + + it('sums buy+sell+swap into totals.volume.total (not a multiple of one direction)', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 8 }, + sell: { volume: 40, transactions: 10, users: 8 }, + swap: { volume: 25, transactions: 10, users: 8 }, + allTime: { buy: 100, sell: 40, registeredUsers: 20, tradingUsers: 15 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8], + newUsers: 0, + }), + ); + activeUserCountFromManager = 10; + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + expect(result.totals.volume.total).toBe(165); + expect(result.totals.volume.total).not.toBe(100 + 40 + 2 * 25); + expect(result.totals.transactions.total).toBe(30); + }); + }); + + // --- M2: active users via manager UNION count --- // + + describe('countActiveUsers uses DB COUNT over UNION (M2)', () => { + it('returns the manager COUNT (not per-direction buy users) and uses the manager path', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + allTime: { buy: 1000, sell: 0, registeredUsers: 50, tradingUsers: 20 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + newUsers: 0, + }), + ); + activeUserCountFromManager = 7; + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + expect(managerCreateQueryBuilderCalls).toBeGreaterThanOrEqual(1); + // activeUsers comes from the UNION COUNT (7), not buyAgg.users (10) + expect(result.totals.activeUsers).toBe(7); + expect(result.totals.activeUsers).not.toBe(10); + }); + + it('gates period totals on the UNION active-user count, not buyAgg.users alone', async () => { + // Each direction looks fine (≥ k users), but the true UNION is 3 → totals block-suppressed. + // Mutation rawUsers.total = buyAgg.users would keep totals visible (buy users = 10). + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + sell: { volume: 500, transactions: 15, users: 10 }, + swap: { volume: 200, transactions: 10, users: 10 }, + allTime: { buy: 1000, sell: 500, registeredUsers: 50, tradingUsers: 20 }, + activeUserIds: [1, 2, 3], + newUsers: 0, + }), + ); + activeUserCountFromManager = 3; + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + expect(result.totals.volume.total).toBeNull(); + expect(result.totals.transactions.total).toBeNull(); + expect(result.totals.activeUsers).toBeNull(); + }); + }); + + // --- CURRENCY / REFERRAL --- // + + describe('currency separation and referral creditOpen', () => { + it('keeps referral in EUR and computes creditOpen as ref + partner − paid', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 5 }, + // partner earned 12.25, personal ref 5, paid 2.25 → open = 15 + referral: { volume: 42.5, partnerRefCredit: 12.25, refCredit: 5, paidRefCredit: 2.25 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + newUsers: 5, + activeUserIds: [1, 2, 3, 4, 5], + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + expect(result.currency).toBe('CHF'); + expect(result.referral.currency).toBe('EUR'); + expect(result.referral.volume).toBe(42.5); + expect(result.referral.creditEarned).toBe(12.25); + expect(result.referral.creditPaid).toBe(2.25); + expect(result.referral.creditOpen).toBe(15); + // Old formula partner − paid would yield 10 — must not regress + expect(result.referral.creditOpen).not.toBe(10); + expect(result.totals.volume.buy).toBe(100); + }); + + it('throws when the wallet row is missing (D6 — not silent zero referral)', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 5 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + activeUserIds: [1, 2, 3, 4, 5], + missingWallet: true, + }), + ); + + await expect(service.getStatistics(1, PERIOD_FROM, PERIOD_TO)).rejects.toThrow(/wallet 1 not found/); + }); + + it('D6 harness: missingWallet survives unscoped fixture merge (walletId not captured)', async () => { + // Reproduces the flake: when state.walletId is unset, getRawOne used to call + // fixtureFor(undefined), which summed volumes but dropped missingWallet — so the + // wallet query invented a row and getStatistics returned instead of throwing. + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 5 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + activeUserIds: [1, 2, 3, 4, 5], + missingWallet: true, + }), + ); + // Second wallet so a naive merge has non-trivial aggregates (matches the + // "response looked like the merged fixture" observation). + fixtures.set( + 2, + emptyFixture({ + buy: { volume: 999, transactions: 50, users: 20 }, + referral: { volume: 12, partnerRefCredit: 3, refCredit: 1, paidRefCredit: 0 }, + }), + ); + + expect(fixtureFor(undefined).missingWallet).toBe(true); + expect(fixtureFor(undefined).buy.volume).toBe(1099); + + // Wallet QB with no where() — state.walletId stays undefined (the flake condition). + const qb = createQb('wallet'); + await expect(qb.getRawOne()).resolves.toBeNull(); + }); + }); + + // --- Response-path suppression (Block C) --- // + + describe('response-path k-anonymity (getStatistics / getTimeline)', () => { + it('drops under-k breakdown rows, nulls newUsers in 1..k-1, and never exposes users', async () => { + fixtures.set( + 1, + emptyFixture({ + // Totals above k so period totals stay visible — focus on breakdown + newUsers. + buy: { volume: 1000, transactions: 20, users: 10 }, + sell: { volume: 500, transactions: 15, users: 10 }, + swap: { volume: 200, transactions: 10, users: 8 }, + allTime: { buy: 5000, sell: 1000, registeredUsers: 50, tradingUsers: 20 }, + newUsers: 3, // 1..k-1 → null + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + namedRows: [ + { name: 'RARE', blockchain: 'Bitcoin', volume: 10, transactions: 2, users: 2 }, // under k + { name: 'COMMON', blockchain: 'Bitcoin', volume: 500, transactions: 20, users: 12 }, + { name: 'COMMON2', blockchain: 'Ethereum', volume: 400, transactions: 18, users: 11 }, + ], + }), + ); + activeUserCountFromManager = 10; + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + const assetNames = result.breakdown.assets.map((a) => a.name); + expect(assetNames).not.toContain('RARE'); + expect(assetNames).toContain('COMMON'); + expect(result.totals.newUsers).toBeNull(); + + const keys = collectKeys(JSON.parse(JSON.stringify(result))); + expect(keys.has('users')).toBe(false); + }); + + it('suppresses under-k timeline buckets (suppressed:true, volume null) and strips users', async () => { + fixtures.set( + 1, + emptyFixture({ + timelineRows: [ + // Day with 3 txs → under k → suppressed (+ complementary may hit another day) + { bucket: new Date('2024-06-10T00:00:00.000Z'), volume: 30, transactions: 3, users: 3 }, + { bucket: new Date('2024-06-11T00:00:00.000Z'), volume: 200, transactions: 20, users: 15 }, + { bucket: new Date('2024-06-12T00:00:00.000Z'), volume: 300, transactions: 30, users: 20 }, + ], + }), + ); + + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-12T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + + const under = result.buckets.find((b) => b.date.toISOString() === '2024-06-10T00:00:00.000Z'); + expect(under).toBeDefined(); + expect(under!.suppressed).toBe(true); + expect(under!.volume).toBeNull(); + expect(under!.transactions).toBeNull(); + + // At least one more day may be complementary-suppressed; a large day stays visible. + const large = result.buckets.find((b) => b.date.toISOString() === '2024-06-12T00:00:00.000Z'); + // complementary suppresses the smallest filled remaining (day 11 with 20), day 12 stays + expect(large!.suppressed).toBe(false); + expect(large!.volume).not.toBeNull(); + + const keys = collectKeys(JSON.parse(JSON.stringify(result))); + expect(keys.has('users')).toBe(false); + }); + }); + + // --- mergeNamedRows / breakdown pipeline --- // + + describe('mergeNamedRows and breakdown pipeline', () => { + it('merges same-name rows across directions and takes Math.max of users', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 500, transactions: 20, users: 10 }, + sell: { volume: 300, transactions: 10, users: 8 }, + allTime: { buy: 500, sell: 300, registeredUsers: 20, tradingUsers: 10 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + namedRows: [ + { name: 'BTC', blockchain: 'Bitcoin', volume: 200, transactions: 10, users: 6 }, + { name: 'BTC', blockchain: 'Bitcoin', volume: 100, transactions: 5, users: 4 }, + { name: 'ETH', blockchain: 'Ethereum', volume: 50, transactions: 5, users: 5 }, + { name: 'CHF', volume: 400, transactions: 15, users: 10 }, + ], + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + expect(result.breakdown.fiatCurrencies.length + result.breakdown.blockchains.length).toBeGreaterThan(0); + + const merged = service.mergeNamedRows([ + { name: 'BTC', volume: 200, transactions: 10, users: 6 }, + { name: 'BTC', volume: 100, transactions: 5, users: 4 }, + { name: 'ETH', volume: 50, transactions: 5, users: 5 }, + ]); + expect(merged.find((r) => r.name === 'BTC')?.volume).toBe(300); + expect(merged.find((r) => r.name === 'BTC')?.transactions).toBe(15); + // Math.max(6, 4) = 6 — Math.min would yield 4 and fail this + expect(merged.find((r) => r.name === 'BTC')?.users).toBe(6); + expect(merged.find((r) => r.name === 'BTC')?.users).not.toBe(4); + expect(merged).toHaveLength(2); + }); + + it('drops nameless rows from the breakdown payload', () => { + const merged = service.mergeNamedRows([ + { name: null, volume: 999, transactions: 50, users: 20 }, + { name: '', volume: 100, transactions: 5, users: 3 }, + { name: 'BTC', volume: 200, transactions: 10, users: 6 }, + ]); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe('BTC'); + expect(merged.find((r) => r.name === null || r.name === '')).toBeUndefined(); + // Nameless volume must not appear under any key + expect(merged.every((r) => r.volume !== 999)).toBe(true); + }); + }); + + // --- K1: partial edge buckets --- // + + describe('timeline partial edge buckets (K1)', () => { + it('marks week-edge buckets as partial when from/to cut mid-week', async () => { + const result = await service.getTimeline(1, MID_WEEK_FROM, MID_WEEK_TO, PartnerStatisticGranularity.WEEK); + + expect(result.buckets.length).toBeGreaterThan(0); + expect(result.buckets[0].partial).toBe(true); + expect(result.buckets[result.buckets.length - 1].partial).toBe(true); + }); + + it('marks full day range buckets as non-partial when aligned to midnight span', async () => { + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-12T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + + expect(result.buckets.length).toBe(3); + expect(result.buckets.every((b) => b.partial === false)).toBe(true); + }); + }); + + // --- Timeline collision accumulation (WEEK/MONTH re-bucket of day-truncated SQL rows) --- // + + describe('timeline bucket key collision accumulation', () => { + it('sums volume/transactions and takes max(users) when two day rows fall into the same week', async () => { + // Monday + Wednesday of ISO week 2024-06-10..16 → same WEEK key after startOfBucket. + fixtures.set( + 1, + emptyFixture({ + timelineRows: [ + { bucket: new Date('2024-06-10T00:00:00.000Z'), volume: 100, transactions: 10, users: 5 }, + { bucket: new Date('2024-06-12T00:00:00.000Z'), volume: 50, transactions: 7, users: 8 }, + ], + }), + ); + + const map = await service['timelineByDirection']( + 1, + new Date('2024-06-10T00:00:00.000Z'), + new Date('2024-06-17T00:00:00.000Z'), + PartnerStatisticDirection.BUY, + PartnerStatisticGranularity.WEEK, + ); + + expect(map.size).toBe(1); + const entry = [...map.values()][0]; + expect(entry.volume).toBe(150); + expect(entry.transactions).toBe(17); + expect(entry.users).toBe(8); + // Not last-write-wins (would be 7/50/8) and not first-write-wins (10/100/5). + expect(entry.transactions).not.toBe(7); + expect(entry.transactions).not.toBe(10); + expect(entry.volume).not.toBe(50); + expect(entry.volume).not.toBe(100); + }); + }); + + // --- WEEK / MONTH bucket alignment --- // + + describe('startOfBucket and addBucket (WEEK / MONTH)', () => { + it('snaps Sunday to the Monday before (ISO week) and any day to month start', () => { + // 2024-06-16 is a Sunday UTC. + const sunday = new Date('2024-06-16T15:30:00.000Z'); + const weekStart = service['startOfBucket'](sunday, PartnerStatisticGranularity.WEEK); + expect(weekStart.toISOString()).toBe('2024-06-10T00:00:00.000Z'); + // Wrong Sunday handling (day - 1 without the day===0 ? 6 branch) lands on Saturday 15th. + expect(weekStart.toISOString()).not.toBe('2024-06-15T00:00:00.000Z'); + + const midMonth = new Date('2024-06-15T12:00:00.000Z'); + const monthStart = service['startOfBucket'](midMonth, PartnerStatisticGranularity.MONTH); + expect(monthStart.toISOString()).toBe('2024-06-01T00:00:00.000Z'); + }); + + it('snaps Mon–Sat back to the Monday of that ISO week (not only Sunday)', () => { + // 2024-06-12 Wednesday → Monday 2024-06-10. Mutation day-1 → day would land on Tuesday. + const wednesday = new Date('2024-06-12T12:00:00.000Z'); + expect(service['startOfBucket'](wednesday, PartnerStatisticGranularity.WEEK).toISOString()).toBe( + '2024-06-10T00:00:00.000Z', + ); + + const monday = new Date('2024-06-10T08:00:00.000Z'); + expect(service['startOfBucket'](monday, PartnerStatisticGranularity.WEEK).toISOString()).toBe( + '2024-06-10T00:00:00.000Z', + ); + + const saturday = new Date('2024-06-15T08:00:00.000Z'); + expect(service['startOfBucket'](saturday, PartnerStatisticGranularity.WEEK).toISOString()).toBe( + '2024-06-10T00:00:00.000Z', + ); + // Mutation `day === 0 ? 6 : day` (drop the −1) would snap Saturday to Friday 14th. + expect(service['startOfBucket'](saturday, PartnerStatisticGranularity.WEEK).toISOString()).not.toBe( + '2024-06-14T00:00:00.000Z', + ); + }); + + it('addBucket advances WEEK by 7 days and MONTH across a month boundary', () => { + const monday = new Date('2024-06-10T00:00:00.000Z'); + expect(service['addBucket'](monday, PartnerStatisticGranularity.WEEK).toISOString()).toBe( + '2024-06-17T00:00:00.000Z', + ); + + const jan = new Date('2024-01-01T00:00:00.000Z'); + expect(service['addBucket'](jan, PartnerStatisticGranularity.MONTH).toISOString()).toBe( + '2024-02-01T00:00:00.000Z', + ); + + const dec = new Date('2024-12-01T00:00:00.000Z'); + expect(service['addBucket'](dec, PartnerStatisticGranularity.MONTH).toISOString()).toBe( + '2025-01-01T00:00:00.000Z', + ); + }); + }); + + // --- TIMEZONE INDEPENDENCE --- // + + describe('timezone independence of timeline buckets', () => { + it('emits exactly three UTC day buckets for a known three-day period (regression for local TZ drift)', async () => { + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-12T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + + expect(result.buckets).toHaveLength(3); + expect(result.buckets.map((b) => b.date.toISOString())).toEqual([ + '2024-06-10T00:00:00.000Z', + '2024-06-11T00:00:00.000Z', + '2024-06-12T00:00:00.000Z', + ]); + expect(result.period.from.toISOString()).toBe('2024-06-10T00:00:00.000Z'); + expect(result.period.to.toISOString()).toBe('2024-06-13T00:00:00.000Z'); + }); + + it('binds DATE_TRUNC to UTC in the timeline SQL expression', async () => { + await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-12T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + + // getTimeline fans out BUY/SELL/SWAP; each timelineByDirection groupBy's DATE_TRUNC once + // and nothing else. Filtering to DATE_TRUNC survivors then pinning length missed an + // extra .addGroupBy('tx.id') (COUNT(DISTINCT user.id) collapses to 1 per tx row). + // Multiset: order of the three direction tasks is not a product contract. + const TIMELINE_GROUP_BY = "DATE_TRUNC('day', tx.created) AT TIME ZONE 'UTC'"; + expectMultisetEqual(groupByCapture.groupBys, [TIMELINE_GROUP_BY, TIMELINE_GROUP_BY, TIMELINE_GROUP_BY]); + for (const g of groupByCapture.groupBys) { + expect(g).toContain("AT TIME ZONE 'UTC'"); + // SQL unit is lowercase via PartnerStatisticDateTruncUnit, not the PascalCase API value. + expect(g).toMatch(/DATE_TRUNC\('day',\s*tx\.created\)/); + } + }); + }); + + // --- TIMELINE VALIDATION PATH --- // + + describe('getTimeline validation', () => { + it('throws 400 for invalid granularity', async () => { + await expect( + service.getTimeline(1, PERIOD_FROM, PERIOD_TO, 'hour' as PartnerStatisticGranularity), + ).rejects.toThrow(BadRequestException); + }); + + it('throws 400 for period over max days', async () => { + const from = new Date(TEST_NOW.getTime() - (PARTNER_STATISTIC_MAX_PERIOD_DAYS + 100) * 24 * 3600 * 1000); + await expect(service.getTimeline(1, from, TEST_NOW, PartnerStatisticGranularity.DAY)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // --- HALF-OPEN INTERVAL --- // + + describe('half-open period filter', () => { + it('uses >= from AND < to on every created filter (not BETWEEN inclusive)', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 5 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + activeUserIds: [1, 2, 3, 4, 5], + }), + ); + + await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + // Clause text + bound values (expectPeriodBoundsMatchResolved). Clause-only pins missed + // `from: new Date(0)` while the SQL string stayed identical. Exact half-open form also + // excludes BETWEEN and open-left bounds — no filter→every on survivors (vacuum-true). + expectPeriodBoundsMatchResolved(); + // Pin count of created-related clauses (same budget as half-open above). A BETWEEN or + // missing period filter changes this multiset size; do not re-filter and every. + const createdRelated = whereClauses.filter((c) => /created/i.test(c)); + expect(createdRelated).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.PERIOD_FILTERS); + // Same total pin as wallet-scope isolation: an extra unscoped WHERE slips past 18 alone. + expect(whereClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.WHERE_CLAUSES); + expectActiveUserUnionShape(); + }); + }); + + // --- CONCURRENCY CAP (D1) --- // + + describe('query concurrency cap spans nested fan-outs (D1)', () => { + it(`keeps simultaneous SQL executions ≤ ${PARTNER_STATISTIC_QUERY_CONCURRENCY}`, async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + sell: { volume: 500, transactions: 15, users: 10 }, + swap: { volume: 200, transactions: 10, users: 8 }, + allTime: { buy: 5000, sell: 1000, registeredUsers: 50, tradingUsers: 20 }, + newUsers: 8, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + namedRows: [{ name: 'BTC', blockchain: 'Bitcoin', volume: 200, transactions: 10, users: 8 }], + }), + ); + activeUserCountFromManager = 10; + queryDelayMs = 15; + + await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + expect(maxConcurrentQueries).toBeGreaterThan(1); + expect(maxConcurrentQueries).toBeLessThanOrEqual(PARTNER_STATISTIC_QUERY_CONCURRENCY); + }); + }); + + // --- NaN guard (D5) --- // + + describe('non-numeric aggregates (D5)', () => { + it('throws instead of turning NaN into JSON null', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 5 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + activeUserIds: [1, 2, 3, 4, 5], + }), + ); + nonNumericVolume = 'not-a-number'; + + await expect(service.getStatistics(1, PERIOD_FROM, PERIOD_TO)).rejects.toThrow(/non-numeric volume/); + }); + + it('toCount rejects non-numeric values instead of producing NaN', () => { + expect(() => service['toCount']('not-a-number')).toThrow(/non-numeric count/); + // Driver could return an unexpected non-primitive; coerce path still NaNs. + expect(() => service['toCount']({} as unknown as string)).toThrow(/non-numeric count/); + }); + }); + + // --- Granularity default (Swagger documents Day) --- // + + describe('getTimeline granularity default', () => { + it('defaults to Day when granularity is omitted', async () => { + const result = await service.getTimeline(1, '2024-06-10T00:00:00.000Z', '2024-06-12T23:59:59.000Z'); + expect(result.granularity).toBe(PartnerStatisticGranularity.DAY); + expect(result.granularity).not.toBe(PartnerStatisticGranularity.MONTH); + expect(result.granularity).not.toBe(PartnerStatisticGranularity.WEEK); + // Day buckets: 10, 11, 12 — Month would collapse to one + expect(result.buckets.length).toBe(3); + }); + }); + + // --- Query gate release on error --- // + + describe('query concurrency gate release on error', () => { + it('releases the semaphore slot when a query rejects so later work is not starved', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 100, transactions: 10, users: 5 }, + allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, + activeUserIds: [1, 2, 3, 4, 5], + }), + ); + nonNumericVolume = 'not-a-number'; + + await expect(service.getStatistics(1, PERIOD_FROM, PERIOD_TO)).rejects.toThrow(/non-numeric volume/); + + // Gate must be free again: a subsequent request must complete (not hang on a full semaphore). + nonNumericVolume = false; + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 500, transactions: 20, users: 10 }, + allTime: { buy: 500, sell: 0, registeredUsers: 20, tradingUsers: 10 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }), + ); + activeUserCountFromManager = 10; + + await expect(service.getStatistics(1, PERIOD_FROM, PERIOD_TO)).resolves.toMatchObject({ + currency: 'CHF', + }); + }); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts new file mode 100644 index 0000000000..fdc9705878 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts @@ -0,0 +1,381 @@ +import { PARTNER_STATISTIC_SUPPRESSION_THRESHOLD } from '../partner-statistic.enum'; +import { + isUnderThreshold, + suppressAllTimeVolume, + suppressBreakdownRows, + suppressPeriodTotals, + suppressScalar, + suppressTimelineBuckets, +} from '../partner-statistic.suppression'; + +/** Single calendar anchor for payload dates — suppression logic is count-based, not calendar-based. */ +const TEST_BUCKET_DATE = new Date(); + +describe('Partner statistic suppression', () => { + describe('suppressScalar (M1)', () => { + it('keeps 0 as 0 and nulls only 1..k-1 (boundary is strict < k)', () => { + expect(suppressScalar(0)).toBe(0); + expect(suppressScalar(1)).toBeNull(); + expect(suppressScalar(4)).toBeNull(); + expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1)).toBeNull(); + expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).toBe(5); + expect(suppressScalar(5)).toBe(5); + expect(suppressScalar(10)).toBe(10); + expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1)).not.toBe( + PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1, + ); + expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).not.toBeNull(); + }); + + it('uses min(transactions, users) as the effective count (person gate)', () => { + // 10 txs but only 3 users → under k + expect(suppressScalar(10, undefined, 3)).toBeNull(); + // 10 txs and 5 users → visible + expect(suppressScalar(10, undefined, 5)).toBe(10); + // 3 txs and 10 users → under k on tx side + expect(suppressScalar(3, undefined, 10)).toBeNull(); + }); + + it('respects an explicit custom threshold', () => { + expect(suppressScalar(3, 3)).toBe(3); + expect(suppressScalar(2, 3)).toBeNull(); + expect(suppressScalar(3, 10, 3)).toBeNull(); + }); + + it('isUnderThreshold uses the module default k when threshold is omitted', () => { + expect(isUnderThreshold(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1)).toBe(true); + expect(isUnderThreshold(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).toBe(false); + expect(isUnderThreshold(0)).toBe(false); + }); + }); + + describe('suppressBreakdownRows', () => { + it('drops rows with fewer than k transactions (boundary at k)', () => { + const { rows, suppressedCount } = suppressBreakdownRows([ + { name: 'A', volume: 100, transactions: 4 }, + { name: 'B', volume: 50, transactions: 3 }, + { name: 'C', volume: 200, transactions: 5 }, + { name: 'D', volume: 300, transactions: 10 }, + ]); + + expect(rows.map((r) => r.name).sort()).toEqual(['C', 'D']); + expect(suppressedCount).toBe(2); + expect(rows.find((r) => r.name === 'C')?.transactions).toBe(5); + expect(rows.some((r) => r.transactions === PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).toBe(true); + }); + + it('drops rows when person count is under k even if transactions are above k', () => { + const { rows, suppressedCount } = suppressBreakdownRows([ + { name: 'A', volume: 100, transactions: 20, users: 2 }, + { name: 'B', volume: 200, transactions: 20, users: 10 }, + { name: 'C', volume: 300, transactions: 30, users: 15 }, + ]); + // A under k on person gate; complementary drops B (smallest remaining); C stays + expect(rows.map((r) => r.name)).toEqual(['C']); + expect(suppressedCount).toBe(2); + expect(rows.find((r) => r.name === 'A')).toBeUndefined(); + }); + + it('applies complementary suppression only when exactly one row is under threshold', () => { + const { rows, suppressedCount } = suppressBreakdownRows([ + { name: 'A', volume: 10, transactions: 3 }, + { name: 'B', volume: 20, transactions: 6 }, + { name: 'C', volume: 100, transactions: 50 }, + ]); + + expect(rows.map((r) => r.name)).toEqual(['C']); + expect(suppressedCount).toBe(2); + expect(rows.find((r) => r.name === 'B')).toBeUndefined(); + }); + + it('picks the later smaller filled row when complementary reduces across candidates', () => { + // First remaining filled is larger than the second — reduce must take the `b` arm. + const { rows, suppressedCount } = suppressBreakdownRows([ + { name: 'A', volume: 10, transactions: 3 }, + { name: 'B', volume: 100, transactions: 50 }, + { name: 'C', volume: 20, transactions: 6 }, + ]); + expect(rows.map((r) => r.name)).toEqual(['B']); + expect(suppressedCount).toBe(2); + expect(rows.find((r) => r.name === 'C')).toBeUndefined(); + }); + + it('does not apply complementary suppression when zero or two+ rows are under threshold', () => { + const none = suppressBreakdownRows([ + { name: 'A', volume: 10, transactions: 5 }, + { name: 'B', volume: 20, transactions: 6 }, + ]); + expect(none.rows).toHaveLength(2); + expect(none.suppressedCount).toBe(0); + + const two = suppressBreakdownRows([ + { name: 'A', volume: 10, transactions: 2 }, + { name: 'B', volume: 20, transactions: 3 }, + { name: 'C', volume: 100, transactions: 50 }, + ]); + expect(two.rows.map((r) => r.name)).toEqual(['C']); + expect(two.suppressedCount).toBe(2); + expect(two.rows).toHaveLength(1); + }); + + it('skips complementary when only zero-tx rows remain after dropping one under-k row', () => { + const { rows, suppressedCount } = suppressBreakdownRows([ + { name: 'A', volume: 10, transactions: 3 }, + { name: 'B', volume: 0, transactions: 0 }, + ]); + expect(rows.map((r) => r.name)).toEqual(['B']); + expect(suppressedCount).toBe(1); + }); + }); + + describe('suppressTimelineBuckets (B2)', () => { + const bucket = ( + buyTx: number, + sellTx = 0, + swapTx = 0, + buyUsers?: number, + sellUsers?: number, + swapUsers?: number, + ) => ({ + date: new Date(TEST_BUCKET_DATE), + volume: { buy: buyTx * 10, sell: sellTx * 10, swap: swapTx * 10 }, + transactions: { buy: buyTx, sell: sellTx, swap: swapTx }, + users: { + buy: buyUsers ?? buyTx, + sell: sellUsers ?? sellTx, + swap: swapUsers ?? swapTx, + }, + suppressed: false, + partial: false, + }); + + it('nullifies a bucket with 4 transactions and keeps one with 5 (plus complementary)', () => { + const { buckets, suppressedCount } = suppressTimelineBuckets([bucket(4), bucket(5), bucket(20)]); + + expect(buckets[0].suppressed).toBe(true); + expect(buckets[0].volume).toBeNull(); + expect(buckets[0].transactions).toBeNull(); + expect(buckets[1].suppressed).toBe(true); + expect(buckets[2].suppressed).toBe(false); + expect(buckets[2].volume).toEqual({ buy: 200, sell: 0, swap: 0 }); + expect(suppressedCount).toBe(2); + }); + + it('suppresses mixed bucket when any single direction is under k (block rule)', () => { + // buy=5 (ok), sell=1 (under) → whole bucket suppressed even though total=6 ≥ k + const mixed = bucket(5, 1, 0); + const large = bucket(20, 20, 20); + const { buckets, suppressedCount } = suppressTimelineBuckets([mixed, large]); + + expect(buckets[0].suppressed).toBe(true); + expect(buckets[0].volume).toBeNull(); + // Block rule nulls the whole under-k mixed bucket (sell=1 must not remain visible). + expect(buckets[0].transactions).toBeNull(); + // Exactly one under-k bucket → complementary also suppresses the only other filled bucket. + // (The old `if (!buckets[1].suppressed)` body was dead: complementary always fires here.) + expect(buckets[1].suppressed).toBe(true); + expect(buckets[1].volume).toBeNull(); + expect(buckets[1].transactions).toBeNull(); + expect(suppressedCount).toBe(2); + // No non-suppressed filled bucket remains — that is what blocks total−visible recovery of + // sell=1. (A filtered sum of visible sell would be 0 by construction once both are null.) + }); + + it('keeps a bucket at exactly k when no complementary case applies', () => { + const { buckets, suppressedCount } = suppressTimelineBuckets([bucket(4), bucket(3), bucket(5)]); + + expect(buckets[0].suppressed).toBe(true); + expect(buckets[1].suppressed).toBe(true); + expect(buckets[2].suppressed).toBe(false); + expect(buckets[2].transactions).toEqual({ buy: 5, sell: 0, swap: 0 }); + expect(suppressedCount).toBe(2); + }); + + it('leaves empty (0-tx) buckets as visible zeros, not suppressed', () => { + const empty = bucket(0); + const filled = bucket(10); + const { buckets, suppressedCount } = suppressTimelineBuckets([empty, filled]); + + expect(buckets[0].suppressed).toBe(false); + expect(buckets[0].volume).toEqual({ buy: 0, sell: 0, swap: 0 }); + expect(buckets[0].transactions).toEqual({ buy: 0, sell: 0, swap: 0 }); + expect(buckets[1].suppressed).toBe(false); + expect(suppressedCount).toBe(0); + }); + + it('still applies complementary when empty days would otherwise defeat it (B2 security)', () => { + const empties = [bucket(0), bucket(0), bucket(0), bucket(0), bucket(0)]; + const under = bucket(3); + const small = bucket(6); + const large = bucket(50); + const large2 = bucket(40); + + const { buckets, suppressedCount } = suppressTimelineBuckets([...empties, under, small, large, large2]); + + for (let i = 0; i < empties.length; i++) { + expect(buckets[i].suppressed).toBe(false); + expect(buckets[i].transactions).toEqual({ buy: 0, sell: 0, swap: 0 }); + expect(buckets[i].volume).toEqual({ buy: 0, sell: 0, swap: 0 }); + } + + expect(buckets[empties.length].suppressed).toBe(true); + expect(buckets[empties.length].volume).toBeNull(); + expect(buckets[empties.length + 1].suppressed).toBe(true); + expect(buckets[empties.length + 2].suppressed).toBe(false); + expect(buckets[empties.length + 3].suppressed).toBe(false); + expect(suppressedCount).toBe(2); + }); + + it('suppresses when person count is under k despite high transaction totals', () => { + // Per-direction txs look fine on count alone (20 ≥ k), but only 3 persons → withheld. + const fewPeople = { + date: new Date(TEST_BUCKET_DATE), + volume: { buy: 200, sell: 0, swap: 0 }, + transactions: { buy: 20, sell: 0, swap: 0 }, + users: { buy: 3, sell: 0, swap: 0 }, + suppressed: false, + partial: false, + }; + const large = bucket(50, 50, 50); + const { buckets, suppressedCount } = suppressTimelineBuckets([fewPeople, large]); + + expect(buckets[0].suppressed).toBe(true); + expect(buckets[0].volume).toBeNull(); + expect(buckets[0].transactions).toBeNull(); + expect(suppressedCount).toBeGreaterThanOrEqual(1); + }); + + it('uses Math.max (not min) of per-direction users as the bucket person-floor lower bound', () => { + // buy/sell well above k; swap has 0 txs but a leftover users=1 that must not collapse the + // person floor via Math.min — max(10,10,1)=10 keeps the bucket visible. + const mixed = { + date: new Date(TEST_BUCKET_DATE), + volume: { buy: 100, sell: 100, swap: 0 }, + transactions: { buy: 10, sell: 10, swap: 0 }, + users: { buy: 10, sell: 10, swap: 1 }, + suppressed: false, + partial: false, + }; + const { buckets, suppressedCount } = suppressTimelineBuckets([mixed]); + expect(buckets[0].suppressed).toBe(false); + expect(buckets[0].transactions).toEqual({ buy: 10, sell: 10, swap: 0 }); + expect(suppressedCount).toBe(0); + }); + + it('uses total transaction count alone when no per-bucket users are provided', () => { + const noUsers = { + date: new Date(TEST_BUCKET_DATE), + volume: { buy: 40, sell: 0, swap: 0 }, + transactions: { buy: 4, sell: 0, swap: 0 }, + suppressed: false, + partial: false, + }; + const large = { + date: new Date(TEST_BUCKET_DATE), + volume: { buy: 500, sell: 0, swap: 0 }, + transactions: { buy: 50, sell: 0, swap: 0 }, + suppressed: false, + partial: false, + }; + const { buckets } = suppressTimelineBuckets([noUsers, large]); + expect(buckets[0].suppressed).toBe(true); + expect(buckets[0].volume).toBeNull(); + }); + + it('leaves a bucket with null transactions untouched (no suppression flag)', () => { + const missing = { + date: new Date(TEST_BUCKET_DATE), + volume: null, + transactions: null, + suppressed: false, + partial: false, + }; + const large = bucket(50); + const { buckets, suppressedCount } = suppressTimelineBuckets([missing, large]); + expect(buckets[0].suppressed).toBe(false); + expect(buckets[0].transactions).toBeNull(); + expect(suppressedCount).toBe(0); + }); + }); + + describe('suppressPeriodTotals (B3 / block rule)', () => { + it('nulls all totals fields when overall transactions.total is in 1..k-1', () => { + const { volume, transactions, averageTransactionVolume, suppressedCount } = suppressPeriodTotals( + { buy: 100, sell: 0, swap: 0, total: 100 }, + { buy: 3, sell: 0, swap: 0, total: 3 }, + { buy: 3, sell: 0, swap: 0, total: 3 }, + ); + + expect(volume).toEqual({ buy: null, sell: null, swap: null, total: null }); + expect(transactions).toEqual({ buy: null, sell: null, swap: null, total: null }); + expect(averageTransactionVolume).toBeNull(); + expect(suppressedCount).toBe(1); + }); + + it('block-suppresses the entire group when any direction is under k (no partial nulling)', () => { + // Previously leaked: sell=null but total/buy/swap visible → sell = total − buy − swap. + const mixed = suppressPeriodTotals( + { buy: 1000, sell: 10, swap: 200, total: 1210 }, + { buy: 20, sell: 2, swap: 10, total: 32 }, + { buy: 10, sell: 2, swap: 8, total: 15 }, + ); + expect(mixed.volume).toEqual({ buy: null, sell: null, swap: null, total: null }); + expect(mixed.transactions).toEqual({ buy: null, sell: null, swap: null, total: null }); + expect(mixed.averageTransactionVolume).toBeNull(); + expect(mixed.suppressedCount).toBe(1); + // Reconstruction must fail + expect(mixed.volume.total).toBeNull(); + }); + + it('keeps totals at exactly k for every direction and overall', () => { + const atK = suppressPeriodTotals( + { buy: 500, sell: 0, swap: 0, total: 500 }, + { buy: 5, sell: 0, swap: 0, total: 5 }, + { buy: 5, sell: 0, swap: 0, total: 5 }, + ); + expect(atK.volume.total).toBe(500); + expect(atK.transactions.total).toBe(5); + expect(atK.volume.buy).toBe(500); + expect(atK.suppressedCount).toBe(0); + expect(atK.averageTransactionVolume).toBe(100); + }); + + it('suppresses when transaction count is high but person count is under k', () => { + const personGate = suppressPeriodTotals( + { buy: 1000, sell: 0, swap: 0, total: 1000 }, + { buy: 20, sell: 0, swap: 0, total: 20 }, + { buy: 2, sell: 0, swap: 0, total: 2 }, + ); + expect(personGate.volume.total).toBeNull(); + expect(personGate.suppressedCount).toBe(1); + }); + + it('leaves all-zero totals as zeros, not null', () => { + const { volume, transactions, averageTransactionVolume, suppressedCount } = suppressPeriodTotals( + { buy: 0, sell: 0, swap: 0, total: 0 }, + { buy: 0, sell: 0, swap: 0, total: 0 }, + { buy: 0, sell: 0, swap: 0, total: 0 }, + ); + expect(volume.total).toBe(0); + expect(transactions.total).toBe(0); + expect(averageTransactionVolume).toBeNull(); + expect(suppressedCount).toBe(0); + }); + }); + + describe('suppressAllTimeVolume (B3)', () => { + it('nulls all-time volume when tradingUsers is in 1..k-1 and keeps it at k', () => { + const under = suppressAllTimeVolume({ buy: 100, sell: 50, total: 150 }, 3); + expect(under.volume).toEqual({ buy: null, sell: null, total: null }); + expect(under.suppressedCount).toBe(1); + + const atK = suppressAllTimeVolume({ buy: 100, sell: 50, total: 150 }, 5); + expect(atK.volume).toEqual({ buy: 100, sell: 50, total: 150 }); + expect(atK.suppressedCount).toBe(0); + + const zero = suppressAllTimeVolume({ buy: 0, sell: 0, total: 0 }, 0); + expect(zero.volume).toEqual({ buy: 0, sell: 0, total: 0 }); + expect(zero.suppressedCount).toBe(0); + }); + }); +}); diff --git a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts new file mode 100644 index 0000000000..621df4f6d9 --- /dev/null +++ b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts @@ -0,0 +1,291 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PartnerStatisticDirection, PartnerStatisticGranularity } from '../partner-statistic.enum'; + +// --- PERIOD / META --- // + +export class PartnerStatisticPeriodDto { + @ApiProperty({ description: 'Inclusive period start, snapped to UTC midnight' }) + from: Date; + + @ApiProperty({ + description: 'Exclusive period end, snapped to UTC midnight of the day after the last included day', + }) + to: Date; +} + +export class PartnerStatisticMetaDto { + @ApiProperty({ description: 'Minimum effective count (min of transactions and distinct users) for disclosure' }) + suppressionThreshold: number; + + @ApiProperty({ + description: + 'Number of withheld disclosure units: suppressed fields and breakdown rows on the summary, ' + + 'suppressed timeline buckets on the timeline', + }) + suppressedCount: number; + + @ApiProperty({ nullable: true, required: false }) + generatedAt?: Date; +} + +// --- VOLUME / COUNTS --- // + +export class PartnerVolumeByTypeDto { + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) + buy: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) + sell: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) + swap: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) + total: number | null; +} + +export class PartnerVolumeBuySellDto { + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when tradingUsers < k' }) + buy: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when tradingUsers < k' }) + sell: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when tradingUsers < k' }) + total: number | null; +} + +export class PartnerTransactionsByTypeDto { + @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) + buy: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) + sell: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) + swap: number | null; + + @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) + total: number | null; +} + +export class PartnerTotalsDto { + @ApiProperty({ + type: PartnerVolumeByTypeDto, + description: 'Volume in CHF; entire group null when any direction is under the suppression threshold', + }) + volume: PartnerVolumeByTypeDto; + + @ApiProperty({ type: PartnerTransactionsByTypeDto }) + transactions: PartnerTransactionsByTypeDto; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Average volume per transaction in CHF; null if no transactions or totals suppressed', + }) + averageTransactionVolume: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Distinct users with ≥1 counted transaction in the period; null if 1..k-1', + }) + activeUsers: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Users of this wallet created in the period; null if 1..k-1', + }) + newUsers: number | null; +} + +export class PartnerAllTimeDto { + @ApiProperty({ + type: PartnerVolumeBuySellDto, + description: 'Lifetime volume in CHF; null fields when tradingUsers < k', + }) + volume: PartnerVolumeBuySellDto; + + @ApiProperty({ description: 'Always visible (installation count, no transaction linkage)' }) + registeredUsers: number; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Users of this wallet with buyVolume > 0 or sellVolume > 0; null if 1..k-1', + }) + tradingUsers: number | null; +} + +// --- BREAKDOWN --- // + +export class PartnerAssetBreakdownDto { + @ApiProperty() + name: string; + + @ApiProperty({ nullable: true }) + blockchain: string | null; + + @ApiProperty({ enum: PartnerStatisticDirection }) + direction: PartnerStatisticDirection; + + @ApiProperty({ description: 'Volume in CHF' }) + volume: number; + + @ApiProperty() + transactions: number; +} + +export class PartnerNamedBreakdownDto { + @ApiProperty() + name: string; + + @ApiProperty({ description: 'Volume in CHF' }) + volume: number; + + @ApiProperty() + transactions: number; +} + +export class PartnerBreakdownDto { + @ApiProperty({ type: PartnerAssetBreakdownDto, isArray: true }) + assets: PartnerAssetBreakdownDto[]; + + @ApiProperty({ type: PartnerNamedBreakdownDto, isArray: true }) + fiatCurrencies: PartnerNamedBreakdownDto[]; + + @ApiProperty({ type: PartnerNamedBreakdownDto, isArray: true }) + blockchains: PartnerNamedBreakdownDto[]; + + @ApiProperty({ type: PartnerNamedBreakdownDto, isArray: true }) + paymentMethods: PartnerNamedBreakdownDto[]; +} + +// --- REFERRAL --- // + +export class PartnerReferralDto { + @ApiProperty({ + type: Number, + nullable: true, + description: + 'Partner referral volume in EUR on the wallet owner’s account (all wallets of that owner). ' + + 'Null when tradingUsers < k (moves with individual customer trades).', + }) + volume: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: + 'Partner referral credit earned (owner.partnerRefCredit only) in EUR, account-wide. ' + + 'Null when tradingUsers < k.', + }) + creditEarned: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: + 'Referral credit already paid out (owner.paidRefCredit) in EUR, account-wide across both ' + + 'personal and partner pots. Null when tradingUsers < k.', + }) + creditPaid: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: + 'Open referral credit in EUR: owner.refCredit + owner.partnerRefCredit − owner.paidRefCredit ' + + '(account-wide). Null when tradingUsers < k.', + }) + creditOpen: number | null; + + @ApiProperty({ enum: ['EUR'], description: 'Native currency of the referral system' }) + currency: 'EUR'; +} + +// --- SUMMARY RESPONSE --- // + +export class PartnerStatisticDto { + @ApiProperty({ type: PartnerStatisticPeriodDto }) + period: PartnerStatisticPeriodDto; + + @ApiProperty({ enum: ['CHF'] }) + currency: 'CHF'; + + @ApiProperty({ type: PartnerTotalsDto }) + totals: PartnerTotalsDto; + + @ApiProperty({ type: PartnerAllTimeDto }) + allTime: PartnerAllTimeDto; + + @ApiProperty({ type: PartnerBreakdownDto }) + breakdown: PartnerBreakdownDto; + + @ApiProperty({ type: PartnerReferralDto }) + referral: PartnerReferralDto; + + @ApiProperty({ type: PartnerStatisticMetaDto }) + meta: PartnerStatisticMetaDto; +} + +// --- TIMELINE --- // + +/** Volume or transaction counts split by trade direction. */ +export class PartnerTimelineByDirectionDto { + @ApiProperty() + buy: number; + + @ApiProperty() + sell: number; + + @ApiProperty() + swap: number; +} + +export class PartnerTimelineBucketDto { + @ApiProperty() + date: Date; + + @ApiProperty({ + type: PartnerTimelineByDirectionDto, + nullable: true, + description: 'Volume in CHF; null when suppressed', + }) + volume: PartnerTimelineByDirectionDto | null; + + @ApiProperty({ + type: PartnerTimelineByDirectionDto, + nullable: true, + description: 'Transaction counts; null when suppressed', + }) + transactions: PartnerTimelineByDirectionDto | null; + + @ApiProperty({ description: 'True when values are withheld under the suppression threshold' }) + suppressed: boolean; + + @ApiProperty({ + description: + 'True when the bucket’s natural range extends outside the requested period (edge week/month truncated by from/to)', + }) + partial: boolean; +} + +export class PartnerTimelineDto { + @ApiProperty({ type: PartnerStatisticPeriodDto }) + period: PartnerStatisticPeriodDto; + + @ApiProperty({ enum: ['CHF'] }) + currency: 'CHF'; + + @ApiProperty({ enum: PartnerStatisticGranularity }) + granularity: PartnerStatisticGranularity; + + @ApiProperty({ type: PartnerTimelineBucketDto, isArray: true }) + buckets: PartnerTimelineBucketDto[]; + + @ApiProperty({ type: PartnerStatisticMetaDto }) + meta: PartnerStatisticMetaDto; +} diff --git a/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts new file mode 100644 index 0000000000..33c6f4aa98 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts @@ -0,0 +1,30 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Config } from 'src/config/config'; + +/** + * Wallet-scoped rate limit for partner statistic routes. + * + * The shared RateLimitGuard keys by IP prefix and bypasses known/Azure IPs, so it does not + * bound repeated scrapes by a single partner JWT. These routes authenticate first (AuthGuard), + * then count by `jwt.user` (wallet id). There is no other user/wallet tracker in the repo. + */ +@Injectable() +export class PartnerStatisticRateLimitGuard extends ThrottlerGuard { + protected getTracker(req: Record): string { + const walletId = req.user?.user; + if (walletId != null) return `partner-stat:wallet:${walletId}`; + // Unreachable when this guard is ordered after AuthGuard + RoleGuard(CLIENT_COMPANY): + // jwt.user is set. Falling back to IP would silently weaken the budget (NAT share-out or + // no useful key) — fail closed instead of pretending rate limiting still works. + // This throw is also unreachable while REQUEST_LIMIT_CHECK is not true: handleRequest + // returns true before super.handleRequest, so getTracker is never called — fail-closed + // stands or falls with the same switch as the budget itself. + throw new Error('Partner statistic rate limit requires an authenticated wallet'); + } + + async handleRequest(context: ExecutionContext, limit: number, ttl: number): Promise { + if (!Config.request.limitCheck) return true; + return super.handleRequest(context, limit, ttl); + } +} diff --git a/src/subdomains/core/statistic/partner-statistic.controller.ts b/src/subdomains/core/statistic/partner-statistic.controller.ts new file mode 100644 index 0000000000..2387560ec4 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler/dist/throttler.decorator'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerStatisticDto, PartnerTimelineDto } from './dto/partner-statistic.dto'; +import { PartnerStatisticRateLimitGuard } from './partner-statistic-rate-limit.guard'; +import { PartnerStatisticGranularity } from './partner-statistic.enum'; +import { PartnerStatisticService } from './partner-statistic.service'; + +@ApiTags('Statistic') +@Controller('statistic') +export class PartnerStatisticController { + constructor(private readonly partnerStatisticService: PartnerStatisticService) {} + + @Get('partner') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY), PartnerStatisticRateLimitGuard) + // 120 req/h per wallet: dashboard auto-refresh (~1/min) for summary + headroom + @Throttle(120, 3600) + @ApiOkResponse({ type: PartnerStatisticDto }) + @ApiQuery({ + name: 'from', + required: false, + description: + 'Period start (ISO date). Snapped to UTC day start. Default: start of the last 30 inclusive UTC calendar days ending on `to`.', + }) + @ApiQuery({ + name: 'to', + required: false, + description: 'Period end (ISO date). Snapped to exclusive UTC day end. Default: now.', + }) + async getPartnerStatistics( + @GetJwt() jwt: JwtPayload, + @Query('from') from?: string, + @Query('to') to?: string, + ): Promise { + return this.partnerStatisticService.getStatistics(jwt.user, from, to); + } + + @Get('partner/timeline') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY), PartnerStatisticRateLimitGuard) + // 120 req/h per wallet: same budget as summary so a dual-widget dashboard can refresh without 429s + @Throttle(120, 3600) + @ApiOkResponse({ type: PartnerTimelineDto }) + @ApiQuery({ + name: 'from', + required: false, + description: + 'Period start (ISO date). Snapped to UTC day start. Default: start of the last 30 inclusive UTC calendar days ending on `to`.', + }) + @ApiQuery({ + name: 'to', + required: false, + description: 'Period end (ISO date). Snapped to exclusive UTC day end. Default: now.', + }) + @ApiQuery({ + name: 'granularity', + required: false, + enum: PartnerStatisticGranularity, + description: 'Bucket size. Default: Day.', + }) + async getPartnerTimeline( + @GetJwt() jwt: JwtPayload, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('granularity') granularity?: PartnerStatisticGranularity, + ): Promise { + return this.partnerStatisticService.getTimeline(jwt.user, from, to, granularity); + } +} diff --git a/src/subdomains/core/statistic/partner-statistic.enum.ts b/src/subdomains/core/statistic/partner-statistic.enum.ts new file mode 100644 index 0000000000..3abe23857d --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.enum.ts @@ -0,0 +1,70 @@ +import { TransactionSourceType } from 'src/subdomains/supporting/payment/entities/transaction.entity'; + +/** k-anonymity threshold: disclosure units need min(transactions, distinct users) ≥ k. */ +export const PARTNER_STATISTIC_SUPPRESSION_THRESHOLD = 5; + +/** + * Default lookback when `from`/`to` are omitted: this many inclusive UTC calendar days + * ending on the resolved `to` day (half-open period after snap). + */ +export const PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS = 30; + +/** Maximum allowed period span in calendar days (half-open [from, to)). */ +export const PARTNER_STATISTIC_MAX_PERIOD_DAYS = 366; + +/** + * Max concurrent SQL queries per partner-statistic request. + * Kept well below the default TypeORM pool size (`SQL_POOL_MAX` defaults to 10) so one + * request cannot monopolise the pool; the effective pool size may differ per environment. + */ +export const PARTNER_STATISTIC_QUERY_CONCURRENCY = 4; + +export enum PartnerStatisticGranularity { + DAY = 'Day', + WEEK = 'Week', + MONTH = 'Month', +} + +export enum PartnerStatisticDirection { + BUY = 'Buy', + SELL = 'Sell', + SWAP = 'Swap', +} + +/** + * Maps partner-facing direction enums to the camelCase DTO field names used on + * volume / transactions / users objects (`buy` / `sell` / `swap`). Explicit so enum + * values (PascalCase API contract) stay decoupled from JSON property names. + */ +export const PartnerStatisticDirectionField: { + readonly [K in PartnerStatisticDirection]: 'buy' | 'sell' | 'swap'; +} = { + [PartnerStatisticDirection.BUY]: 'buy', + [PartnerStatisticDirection.SELL]: 'sell', + [PartnerStatisticDirection.SWAP]: 'swap', +}; + +/** Postgres `DATE_TRUNC` unit for each granularity (API values are PascalCase). */ +export const PartnerStatisticDateTruncUnit: { + readonly [K in PartnerStatisticGranularity]: 'day' | 'week' | 'month'; +} = { + [PartnerStatisticGranularity.DAY]: 'day', + [PartnerStatisticGranularity.WEEK]: 'week', + [PartnerStatisticGranularity.MONTH]: 'month', +}; + +export enum PartnerPaymentMethodName { + BANK = 'Bank', + CARD = 'Card', + ON_CHAIN = 'OnChain', + REFERRAL = 'Referral', +} + +/** Maps transaction.sourceType to a partner-facing payment method label. */ +export const PartnerPaymentMethodMap: { [key in TransactionSourceType]: PartnerPaymentMethodName } = { + [TransactionSourceType.BANK_TX]: PartnerPaymentMethodName.BANK, + [TransactionSourceType.CHECKOUT_TX]: PartnerPaymentMethodName.CARD, + [TransactionSourceType.CRYPTO_INPUT]: PartnerPaymentMethodName.ON_CHAIN, + [TransactionSourceType.REF]: PartnerPaymentMethodName.REFERRAL, + [TransactionSourceType.MANUAL_REF]: PartnerPaymentMethodName.REFERRAL, +}; diff --git a/src/subdomains/core/statistic/partner-statistic.service.ts b/src/subdomains/core/statistic/partner-statistic.service.ts new file mode 100644 index 0000000000..1166eb2d73 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -0,0 +1,958 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Util } from 'src/shared/utils/util'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { UserRepository } from 'src/subdomains/generic/user/models/user/user.repository'; +import { WalletRepository } from 'src/subdomains/generic/user/models/wallet/wallet.repository'; +import { TransactionSourceType } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { SelectQueryBuilder } from 'typeorm'; +import { BuyCrypto } from '../buy-crypto/process/entities/buy-crypto.entity'; +import { BuyFiat } from '../sell-crypto/process/buy-fiat.entity'; +import { + PartnerAssetBreakdownDto, + PartnerNamedBreakdownDto, + PartnerReferralDto, + PartnerStatisticDto, + PartnerTimelineBucketDto, + PartnerTimelineDto, +} from './dto/partner-statistic.dto'; +import { + PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS, + PARTNER_STATISTIC_MAX_PERIOD_DAYS, + PARTNER_STATISTIC_QUERY_CONCURRENCY, + PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, + PartnerPaymentMethodMap, + PartnerStatisticDateTruncUnit, + PartnerStatisticDirection, + PartnerStatisticGranularity, +} from './partner-statistic.enum'; +import { + suppressAllTimeVolume, + suppressBreakdownRows, + suppressPeriodTotals, + suppressScalar, + suppressTimelineBuckets, +} from './partner-statistic.suppression'; + +type Direction = PartnerStatisticDirection; + +interface AggregateRow { + volume: string | number | null; + transactions: string | number | null; + users: string | number | null; +} + +interface NamedAggregateRow extends AggregateRow { + name: string | null; + blockchain?: string | null; +} + +interface TimelineRawRow { + bucket: Date | string; + volume: string | number | null; + transactions: string | number | null; + users: string | number | null; +} + +interface DirectionAgg { + volume: number; + transactions: number; + users: number; +} + +/** + * Per-request semaphore for actual SQL executions. Nested fan-outs share one gate so the + * concurrency cap applies to queries, not to each runAll call site. + */ +class QueryConcurrencyGate { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly max: number) {} + + async run(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.active < this.max) { + this.active += 1; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(() => { + this.active += 1; + resolve(); + }); + }); + } + + private release(): void { + this.active -= 1; + const next = this.waiters.shift(); + if (next) next(); + } +} + +@Injectable() +export class PartnerStatisticService { + /** Request-scoped query gate (set for the duration of getStatistics / getTimeline). */ + private static readonly queryGateAls = new AsyncLocalStorage(); + + constructor( + private readonly buyCryptoRepo: BuyCryptoRepository, + private readonly buyFiatRepo: BuyFiatRepository, + private readonly userRepo: UserRepository, + private readonly walletRepo: WalletRepository, + ) {} + + // --- PUBLIC API --- // + + async getStatistics(walletId: number, from?: string | Date, to?: string | Date): Promise { + return this.withQueryGate(async () => { + const period = this.resolvePeriod(from, to); + + const [ + buyAgg, + sellAgg, + swapAgg, + activeUsersRaw, + newUsersRaw, + allTimeRaw, + referralRaw, + assetRows, + fiatRows, + blockchainRows, + paymentMethodRows, + ] = await this.runAll([ + () => this.aggregateByDirection(walletId, period.from, period.to, PartnerStatisticDirection.BUY), + () => this.aggregateByDirection(walletId, period.from, period.to, PartnerStatisticDirection.SELL), + () => this.aggregateByDirection(walletId, period.from, period.to, PartnerStatisticDirection.SWAP), + () => this.countActiveUsers(walletId, period.from, period.to), + () => this.countNewUsers(walletId, period.from, period.to), + () => this.getAllTime(walletId), + () => this.getReferralRaw(walletId), + () => this.aggregateAssets(walletId, period.from, period.to), + () => this.aggregateFiatCurrencies(walletId, period.from, period.to), + () => this.aggregateBlockchains(walletId, period.from, period.to), + () => this.aggregatePaymentMethods(walletId, period.from, period.to), + ]); + + const rawVolume = { + buy: buyAgg.volume, + sell: sellAgg.volume, + swap: swapAgg.volume, + total: Util.round(buyAgg.volume + sellAgg.volume + swapAgg.volume, Config.defaultVolumeDecimal), + }; + const rawTransactions = { + buy: buyAgg.transactions, + sell: sellAgg.transactions, + swap: swapAgg.transactions, + total: buyAgg.transactions + sellAgg.transactions + swapAgg.transactions, + }; + const rawUsers = { + buy: buyAgg.users, + sell: sellAgg.users, + swap: swapAgg.users, + total: activeUsersRaw, + }; + + const totalsSuppressed = suppressPeriodTotals(rawVolume, rawTransactions, rawUsers); + const averageTransactionVolume = + totalsSuppressed.averageTransactionVolume != null + ? Util.round(totalsSuppressed.averageTransactionVolume, Config.defaultVolumeDecimal) + : null; + + const tradingUsersSuppressed = suppressScalar(allTimeRaw.tradingUsers); + const allTimeSuppressed = suppressAllTimeVolume(allTimeRaw.volume, allTimeRaw.tradingUsers); + const referral = this.applyReferralSuppression(referralRaw, allTimeRaw.tradingUsers); + + const assets = suppressBreakdownRows(assetRows); + const fiatCurrencies = suppressBreakdownRows(fiatRows); + const blockchains = suppressBreakdownRows(blockchainRows); + const paymentMethods = suppressBreakdownRows(paymentMethodRows); + + const activeUsers = suppressScalar(activeUsersRaw); + const newUsers = suppressScalar(newUsersRaw); + + const suppressedCount = + assets.suppressedCount + + fiatCurrencies.suppressedCount + + blockchains.suppressedCount + + paymentMethods.suppressedCount + + totalsSuppressed.suppressedCount + + allTimeSuppressed.suppressedCount + + (activeUsers === null ? 1 : 0) + + (newUsers === null ? 1 : 0) + + (tradingUsersSuppressed === null ? 1 : 0) + + (referral.volume === null && referralRaw.volume !== 0 ? 1 : 0); + + return { + period, + currency: 'CHF', + totals: { + volume: totalsSuppressed.volume, + transactions: totalsSuppressed.transactions, + averageTransactionVolume, + activeUsers, + newUsers, + }, + allTime: { + volume: allTimeSuppressed.volume, + registeredUsers: allTimeRaw.registeredUsers, + tradingUsers: tradingUsersSuppressed, + }, + breakdown: { + assets: assets.rows.map(({ users: _u, ...row }) => row), + fiatCurrencies: fiatCurrencies.rows.map(({ users: _u, ...row }) => row), + blockchains: blockchains.rows.map(({ users: _u, ...row }) => row), + paymentMethods: paymentMethods.rows.map(({ users: _u, ...row }) => row), + }, + referral, + meta: { + suppressionThreshold: PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, + suppressedCount, + generatedAt: new Date(), + }, + }; + }); + } + + async getTimeline( + walletId: number, + from?: string | Date, + to?: string | Date, + granularity: PartnerStatisticGranularity = PartnerStatisticGranularity.DAY, + ): Promise { + return this.withQueryGate(async () => { + const resolvedGranularity = this.parseGranularity(granularity); + const period = this.resolvePeriod(from, to); + + const [buyRows, sellRows, swapRows] = await this.runAll([ + () => + this.timelineByDirection( + walletId, + period.from, + period.to, + PartnerStatisticDirection.BUY, + resolvedGranularity, + ), + () => + this.timelineByDirection( + walletId, + period.from, + period.to, + PartnerStatisticDirection.SELL, + resolvedGranularity, + ), + () => + this.timelineByDirection( + walletId, + period.from, + period.to, + PartnerStatisticDirection.SWAP, + resolvedGranularity, + ), + ]); + + const filled = this.fillTimelineGaps(period.from, period.to, resolvedGranularity, buyRows, sellRows, swapRows); + const { buckets, suppressedCount } = suppressTimelineBuckets(filled); + + // Drop internal users field from the public payload. + const publicBuckets: PartnerTimelineBucketDto[] = buckets.map(({ users: _u, ...rest }) => rest); + + return { + period, + currency: 'CHF', + granularity: resolvedGranularity, + buckets: publicBuckets, + meta: { + suppressionThreshold: PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, + suppressedCount, + }, + }; + }); + } + + // --- PERIOD / VALIDATION --- // + + /** + * Snaps `from`/`to` to UTC day boundaries (half-open [from, to)), enforces max span. + * Accepts ISO strings or Date; parsing lives here so the controller stays free of date logic. + * Min span of one day follows from the day snap plus the `fromDay >= toExclusive` rejection — + * after those two steps the remaining difference is always ≥ 1 day. + * + * Default period (when `from`/`to` omitted): the last + * {@link PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS} **inclusive** UTC calendar days ending on + * the resolved `to` day — computed with UTC arithmetic only (not process-local `setDate`). + */ + resolvePeriod(from?: string | Date, to?: string | Date): { from: Date; to: Date } { + const resolvedTo = this.parseDate(to) ?? new Date(); + const toDayStart = this.startOfUtcDay(resolvedTo); + const toExclusive = this.addUtcDays(toDayStart, 1); + + // Inclusive lookback: N calendar days ending on toDay ⇒ from = toDay − (N − 1). + // Pure UTC — never Util.daysBefore (local setDate drifts under non-UTC process TZ). + const defaultFromDay = this.addUtcDays(toDayStart, -(PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS - 1)); + const fromDay = this.startOfUtcDay(this.parseDate(from) ?? defaultFromDay); + + if (fromDay.getTime() >= toExclusive.getTime()) { + throw new BadRequestException('From must be before to'); + } + + const spanDays = (toExclusive.getTime() - fromDay.getTime()) / (24 * 3600 * 1000); + if (spanDays > PARTNER_STATISTIC_MAX_PERIOD_DAYS) { + throw new BadRequestException( + `Period must not exceed ${PARTNER_STATISTIC_MAX_PERIOD_DAYS} days (got ${Math.ceil(spanDays)})`, + ); + } + + return { from: fromDay, to: toExclusive }; + } + + parseGranularity(value: unknown): PartnerStatisticGranularity { + const allowed = Object.values(PartnerStatisticGranularity) as string[]; + // Express delivers string[] for repeated query params (?granularity=x&granularity=y). + // Reject non-strings before any string operations (same pattern as realunit.service / + // gs.service typeof guards that name the expected type in the 400 message). + if (typeof value !== 'string') { + throw new BadRequestException(`Granularity must be a string. Allowed: ${allowed.join(', ')}`); + } + if (!allowed.includes(value)) { + throw new BadRequestException(`Invalid granularity '${value}'. Allowed: ${allowed.join(', ')}`); + } + return value as PartnerStatisticGranularity; + } + + /** + * Accepts only unambiguous UTC-stable forms so process TZ cannot shift the day: + * - pure calendar date `YYYY-MM-DD` (ES Date parses this as UTC midnight), or + * - full ISO-8601 timestamp with explicit `Z` or numeric offset. + * Bare local-looking timestamps (`…T00:00:00` without Z/offset), free text, and + * numeric strings are rejected — `new Date(value)` would silently apply process TZ. + * Non-existent calendar days (`2024-06-31`, `2023-02-29`) are rejected even when + * `new Date` would silently roll them into the next month. + * + * Runtime input is `unknown` because Express can deliver `string[]` when a query + * parameter is set twice (`?from=x&from=y`). Coercion via `RegExp#test` / `Array#slice` + * would otherwise create a type-confusion path (CodeQL js/type-confusion-through-parameter-tampering). + */ + parseDate(value?: unknown): Date | undefined { + if (value == null || value === '') return undefined; + if (value instanceof Date) { + if (isNaN(value.getTime())) throw new BadRequestException('Invalid date'); + return value; + } + + // Must run before any regex or .slice — arrays pass .test() via ToString and + // Array#slice returns an array, not a substring (Number([...]) → NaN is not a guard). + if (typeof value !== 'string') { + throw new BadRequestException('Date must be a string or Date'); + } + + const dateOnly = /^\d{4}-\d{2}-\d{2}$/; + // ISO-8601 datetime with mandatory Z or ±HH:MM / ±HHMM offset (minutes required for HHMM form). + const isoWithTz = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}(?::?\d{2}))$/; + + if (!dateOnly.test(value) && !isoWithTz.test(value)) { + throw new BadRequestException( + `Invalid date '${value}'. Expected YYYY-MM-DD or an ISO-8601 timestamp with Z or offset (e.g. 2024-06-15T12:00:00.000Z)`, + ); + } + + // Reject non-existent calendar days before Date's rollover (June 31 → July 1). + // Applied to both accepted forms via the shared YYYY-MM-DD prefix. + const y = Number(value.slice(0, 4)); + const m = Number(value.slice(5, 7)); + const d = Number(value.slice(8, 10)); + const calendarCheck = new Date(Date.UTC(y, m - 1, d)); + if ( + calendarCheck.getUTCFullYear() !== y || + calendarCheck.getUTCMonth() + 1 !== m || + calendarCheck.getUTCDate() !== d + ) { + throw new BadRequestException( + `Invalid date '${value}'. Expected YYYY-MM-DD or an ISO-8601 timestamp with Z or offset (e.g. 2024-06-15T12:00:00.000Z)`, + ); + } + + const date = new Date(value); + // Still needed for out-of-range time components (e.g. T25:00:00Z) that pass the regex + // and the calendar-day check but produce an Invalid Date. + if (isNaN(date.getTime())) { + throw new BadRequestException( + `Invalid date '${value}'. Expected YYYY-MM-DD or an ISO-8601 timestamp with Z or offset (e.g. 2024-06-15T12:00:00.000Z)`, + ); + } + return date; + } + + // --- AGGREGATES (SQL GROUP BY — never load user rows) --- // + + private async aggregateByDirection( + walletId: number, + from: Date, + to: Date, + direction: Direction, + ): Promise { + const qb = this.baseTxQuery(direction, walletId, from, to); + qb.select('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users'); + + const raw = await this.runQuery(() => qb.getRawOne()); + return { + volume: this.toVolume(raw?.volume), + transactions: this.toCount(raw?.transactions), + users: this.toCount(raw?.users), + }; + } + + /** + * Distinct active users across buy/sell/swap — COUNT happens in the DB via UNION, not in Node. + */ + private async countActiveUsers(walletId: number, from: Date, to: Date): Promise { + const buyQb = this.baseTxQuery(PartnerStatisticDirection.BUY, walletId, from, to).select('user.id', 'id'); + const sellQb = this.baseTxQuery(PartnerStatisticDirection.SELL, walletId, from, to).select('user.id', 'id'); + const swapQb = this.baseTxQuery(PartnerStatisticDirection.SWAP, walletId, from, to).select('user.id', 'id'); + + const unionSql = `(${buyQb.getQuery()}) UNION (${sellQb.getQuery()}) UNION (${swapQb.getQuery()})`; + + const raw = await this.runQuery(() => + this.buyCryptoRepo.manager + .createQueryBuilder() + .from(`(${unionSql})`, 'active_users') + .select('COUNT(*)', 'count') + .setParameters({ + ...buyQb.getParameters(), + ...sellQb.getParameters(), + ...swapQb.getParameters(), + }) + .getRawOne<{ count: string | number }>(), + ); + + return this.toCount(raw?.count); + } + + private async countNewUsers(walletId: number, from: Date, to: Date): Promise { + return this.runQuery(() => + this.userRepo + .createQueryBuilder('user') + .where('user.walletId = :walletId', { walletId }) + .andWhere('user.created >= :from AND user.created < :to', { from, to }) + .getCount(), + ); + } + + private async getAllTime( + walletId: number, + ): Promise<{ volume: { buy: number; sell: number; total: number }; registeredUsers: number; tradingUsers: number }> { + const raw = await this.runQuery(() => + this.userRepo + .createQueryBuilder('user') + .select('COALESCE(SUM(user.buyVolume), 0)', 'buy') + .addSelect('COALESCE(SUM(user.sellVolume), 0)', 'sell') + .addSelect('COUNT(*)', 'registeredUsers') + .addSelect( + 'COALESCE(SUM(CASE WHEN user.buyVolume > 0 OR user.sellVolume > 0 THEN 1 ELSE 0 END), 0)', + 'tradingUsers', + ) + .where('user.walletId = :walletId', { walletId }) + .getRawOne<{ buy: string; sell: string; registeredUsers: string; tradingUsers: string }>(), + ); + + const buy = this.toVolume(raw?.buy); + const sell = this.toVolume(raw?.sell); + + return { + volume: { + buy, + sell, + total: Util.round(buy + sell, Config.defaultVolumeDecimal), + }, + registeredUsers: this.toCount(raw?.registeredUsers), + tradingUsers: this.toCount(raw?.tradingUsers), + }; + } + + /** + * Reads the wallet **owner** account (all wallets of that owner), not only the querying wallet. + * Open credit formula matches user.service / ref-reward.service: + * refCredit + partnerRefCredit − paidRefCredit. + * + * A missing wallet row is a system fault (walletId comes from a valid JWT), not a zero balance. + */ + private async getReferralRaw(walletId: number): Promise<{ + volume: number; + creditEarned: number; + creditPaid: number; + creditOpen: number; + }> { + const raw = await this.runQuery(() => + this.walletRepo + .createQueryBuilder('wallet') + .leftJoin('wallet.owner', 'owner') + .select('COALESCE(owner.partnerRefVolume, 0)', 'volume') + .addSelect('COALESCE(owner.partnerRefCredit, 0)', 'partnerRefCredit') + .addSelect('COALESCE(owner.refCredit, 0)', 'refCredit') + .addSelect('COALESCE(owner.paidRefCredit, 0)', 'paidRefCredit') + .where('wallet.id = :walletId', { walletId }) + .getRawOne<{ volume: string; partnerRefCredit: string; refCredit: string; paidRefCredit: string }>(), + ); + + if (raw == null) { + throw new Error(`Partner statistic: wallet ${walletId} not found`); + } + + const volume = this.toVolume(raw.volume); + const partnerRefCredit = this.toVolume(raw.partnerRefCredit); + const refCredit = this.toVolume(raw.refCredit); + const paidRefCredit = this.toVolume(raw.paidRefCredit); + + return { + volume, + creditEarned: partnerRefCredit, + creditPaid: paidRefCredit, + creditOpen: Util.round(refCredit + partnerRefCredit - paidRefCredit, Config.defaultVolumeDecimal), + }; + } + + private applyReferralSuppression( + raw: { volume: number; creditEarned: number; creditPaid: number; creditOpen: number }, + tradingUsers: number, + ): PartnerReferralDto { + // Referral balances move with individual customer trades. Gate them on tradingUsers so two + // successive polls cannot recover a single trade’s credit delta when the cohort is under k. + // These are the partner’s own account figures (owner-scoped), but the leakage path is the same. + // suppressScalar is null only for 1..k-1, so a separate `> 0` check is redundant. + if (suppressScalar(tradingUsers) === null) { + return { + volume: null, + creditEarned: null, + creditPaid: null, + creditOpen: null, + currency: 'EUR', + }; + } + return { ...raw, currency: 'EUR' }; + } + + private async aggregateAssets( + walletId: number, + from: Date, + to: Date, + ): Promise<(PartnerAssetBreakdownDto & { users: number })[]> { + const [buy, sell, swap] = await this.runAll([ + () => this.assetQuery(PartnerStatisticDirection.BUY, walletId, from, to), + () => this.assetQuery(PartnerStatisticDirection.SELL, walletId, from, to), + () => this.assetQuery(PartnerStatisticDirection.SWAP, walletId, from, to), + ]); + + return [...buy, ...sell, ...swap].sort((a, b) => b.volume - a.volume); + } + + private async assetQuery( + direction: Direction, + walletId: number, + from: Date, + to: Date, + ): Promise<(PartnerAssetBreakdownDto & { users: number })[]> { + const qb = this.baseTxQuery(direction, walletId, from, to); + + if (direction === PartnerStatisticDirection.SELL) { + // GROUP BY must use qualified columns — never SELECT aliases (Postgres resolves aliases as input columns). + qb.leftJoin('tx.cryptoInput', 'cryptoInput') + .leftJoin('cryptoInput.asset', 'inputAsset') + .select('tx.inputAsset', 'name') + .addSelect('inputAsset.blockchain', 'blockchain') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('tx.inputAsset') + .addGroupBy('inputAsset.blockchain'); + } else { + qb.leftJoin('tx.outputAsset', 'outputAsset') + .select('outputAsset.name', 'name') + .addSelect('outputAsset.blockchain', 'blockchain') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('outputAsset.name') + .addGroupBy('outputAsset.blockchain'); + } + + const rows = await this.runQuery(() => qb.getRawMany()); + return rows + .filter((r) => r.name) + .map((r) => ({ + name: r.name as string, + blockchain: r.blockchain ?? null, + direction, + volume: this.toVolume(r.volume), + transactions: this.toCount(r.transactions), + users: this.toCount(r.users), + })); + } + + private async aggregateFiatCurrencies( + walletId: number, + from: Date, + to: Date, + ): Promise<(PartnerNamedBreakdownDto & { users: number })[]> { + // Buy: inputAsset is the fiat ticker. Sell: outputAsset is Fiat. Swap has no fiat leg. + const buyQb = this.baseTxQuery(PartnerStatisticDirection.BUY, walletId, from, to) + .select('tx.inputAsset', 'name') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('tx.inputAsset'); + + const sellQb = this.baseTxQuery(PartnerStatisticDirection.SELL, walletId, from, to) + .leftJoin('tx.outputAsset', 'fiat') + .select('fiat.name', 'name') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('fiat.name'); + + const [buyRows, sellRows] = await this.runAll([ + () => this.runQuery(() => buyQb.getRawMany()), + () => this.runQuery(() => sellQb.getRawMany()), + ]); + + return this.mergeNamedRows([...buyRows, ...sellRows]); + } + + private async aggregateBlockchains( + walletId: number, + from: Date, + to: Date, + ): Promise<(PartnerNamedBreakdownDto & { users: number })[]> { + const queries = ([PartnerStatisticDirection.BUY, PartnerStatisticDirection.SWAP] as Direction[]).map( + (direction) => () => + this.runQuery(() => + this.baseTxQuery(direction, walletId, from, to) + .leftJoin('tx.outputAsset', 'outputAsset') + .select('outputAsset.blockchain', 'name') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('outputAsset.blockchain') + .getRawMany(), + ), + ); + + const sellQ = () => + this.runQuery(() => + this.baseTxQuery(PartnerStatisticDirection.SELL, walletId, from, to) + .leftJoin('tx.cryptoInput', 'cryptoInput') + .leftJoin('cryptoInput.asset', 'inputAsset') + .select('inputAsset.blockchain', 'name') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('inputAsset.blockchain') + .getRawMany(), + ); + + const rows = (await this.runAll([...queries, sellQ])).flat(); + return this.mergeNamedRows(rows); + } + + private async aggregatePaymentMethods( + walletId: number, + from: Date, + to: Date, + ): Promise<(PartnerNamedBreakdownDto & { users: number })[]> { + const rows = ( + await this.runAll( + ( + [PartnerStatisticDirection.BUY, PartnerStatisticDirection.SELL, PartnerStatisticDirection.SWAP] as Direction[] + ).map( + (direction) => () => + this.runQuery(() => + this.baseTxQuery(direction, walletId, from, to) + .innerJoin('tx.transaction', 'transaction') + .select('transaction.sourceType', 'name') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy('transaction.sourceType') + .getRawMany(), + ), + ), + ) + ).flat(); + + const mapped = rows.map((r) => ({ + ...r, + name: PartnerPaymentMethodMap[r.name as TransactionSourceType] ?? r.name, + })); + + return this.mergeNamedRows(mapped); + } + + private async timelineByDirection( + walletId: number, + from: Date, + to: Date, + direction: Direction, + granularity: PartnerStatisticGranularity, + ): Promise> { + // Truncate on the stored wall clock (UTC values in TIMESTAMP without TZ), then tag the + // result as UTC so the driver returns an unambiguous absolute instant. Session TimeZone + // must not shift buckets — DATE_TRUNC on timestamp without tz is field-only; AT TIME ZONE + // 'UTC' only re-labels the truncated value as timestamptz. + const unit = PartnerStatisticDateTruncUnit[granularity]; + const trunc = `DATE_TRUNC('${unit}', tx.created) AT TIME ZONE 'UTC'`; + const qb = this.baseTxQuery(direction, walletId, from, to) + .select(trunc, 'bucket') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .addSelect('COUNT(DISTINCT user.id)', 'users') + .groupBy(trunc) + .orderBy(trunc, 'ASC'); + + const rows = await this.runQuery(() => qb.getRawMany()); + const map = new Map(); + for (const row of rows) { + // UTC keys on both sides (SQL buckets + fill loop). Period bounds are UTC-normalized in + // resolvePeriod; this module deliberately does not follow support-issue.service local + // date-parts — that path has no UTC period snap. + const key = this.bucketKey(this.startOfBucket(new Date(row.bucket), granularity)); + const volume = this.toVolume(row.volume); + const transactions = this.toCount(row.transactions); + const users = this.toCount(row.users); + const existing = map.get(key); + if (existing) { + existing.volume = Util.round(existing.volume + volume, Config.defaultVolumeDecimal); + existing.transactions += transactions; + // users can double-count on key collision; take max as a lower-bound person estimate + existing.users = Math.max(existing.users, users); + } else { + map.set(key, { volume, transactions, users }); + } + } + return map; + } + + private fillTimelineGaps( + from: Date, + to: Date, + granularity: PartnerStatisticGranularity, + buy: Map, + sell: Map, + swap: Map, + ): (PartnerTimelineBucketDto & { users: { buy: number; sell: number; swap: number } })[] { + const buckets: (PartnerTimelineBucketDto & { users: { buy: number; sell: number; swap: number } })[] = []; + let cursor = this.startOfBucket(from, granularity); + // `to` is exclusive (half-open period). + const end = to.getTime(); + + while (cursor.getTime() < end) { + const key = this.bucketKey(cursor); + const b = buy.get(key) ?? { volume: 0, transactions: 0, users: 0 }; + const s = sell.get(key) ?? { volume: 0, transactions: 0, users: 0 }; + const w = swap.get(key) ?? { volume: 0, transactions: 0, users: 0 }; + const next = this.addBucket(cursor, granularity); + // Edge buckets whose natural range extends outside [from, to) are partial. + const partial = cursor.getTime() < from.getTime() || next.getTime() > to.getTime(); + + buckets.push({ + date: new Date(cursor), + volume: { buy: b.volume, sell: s.volume, swap: w.volume }, + transactions: { buy: b.transactions, sell: s.transactions, swap: w.transactions }, + users: { buy: b.users, sell: s.users, swap: w.users }, + suppressed: false, + partial, + }); + + cursor = next; + } + + return buckets; + } + + // --- QUERY BUILDERS --- // + + /** + * Base query for partner transactions of one direction. + * Scope is always `user.walletId = :walletId` — never accept walletId from the client. + * Always filters amlCheck=Pass (volume/totals/breakdown/timeline). + * Period is half-open: created >= from AND created < to. + * + * SQL-side wallet scope on purpose. Repo alternative loads users then IDs + * (kyc-client.service.ts:37–41 → transaction.service.ts:269 + doInBatchesWithLimit 100); + * for aggregates that means ~1.270 batches × 18 queries ≈ 22.8k roundtrips on Cake + * (~127k users) plus full user load — SQL scoping needs 18 queries, no user rows. + */ + private baseTxQuery( + direction: Direction, + walletId: number, + from: Date, + to: Date, + ): SelectQueryBuilder { + // Fail-closed: only amlCheck=Pass rows are counted. Omitting the filter would inflate + // figures with rejected traffic. + let qb: SelectQueryBuilder; + + if (direction === PartnerStatisticDirection.SELL) { + qb = this.buyFiatRepo + .createQueryBuilder('tx') + .innerJoin('tx.sell', 'route') + .innerJoin('route.user', 'user') + .where('user.walletId = :walletId', { walletId }) + .andWhere('tx.created >= :from AND tx.created < :to', { from, to }); + } else if (direction === PartnerStatisticDirection.BUY) { + qb = this.buyCryptoRepo + .createQueryBuilder('tx') + .innerJoin('tx.buy', 'route') + .innerJoin('route.user', 'user') + .where('user.walletId = :walletId', { walletId }) + .andWhere('tx.created >= :from AND tx.created < :to', { from, to }); + } else { + qb = this.buyCryptoRepo + .createQueryBuilder('tx') + .innerJoin('tx.cryptoRoute', 'route') + .innerJoin('route.user', 'user') + .where('user.walletId = :walletId', { walletId }) + .andWhere('tx.created >= :from AND tx.created < :to', { from, to }); + } + + qb.andWhere('tx.amlCheck = :check', { check: CheckStatus.PASS }); + + return qb; + } + + // --- HELPERS --- // + + /** + * Runs every actual SQL execution through the request-scoped gate so nested fan-outs + * (assets/fiat/blockchains/payment methods) share the same concurrency budget of + * {@link PARTNER_STATISTIC_QUERY_CONCURRENCY}. Without a shared gate, each nested + * `runAll` would open its own pool of workers and a single request could exceed the + * TypeORM connection pool. + */ + private async runQuery(fn: () => Promise): Promise { + const gate = PartnerStatisticService.queryGateAls.getStore(); + // No ALS store outside a request (unit/integration tests, direct private-method calls) — + // safe: those paths have no concurrent fan-out and do not share a TypeORM pool budget. + if (!gate) return fn(); + return gate.run(fn); + } + + private async withQueryGate(fn: () => Promise): Promise { + const existing = PartnerStatisticService.queryGateAls.getStore(); + if (existing) return fn(); + const gate = new QueryConcurrencyGate(PARTNER_STATISTIC_QUERY_CONCURRENCY); + return PartnerStatisticService.queryGateAls.run(gate, fn); + } + + /** Fan-out helper: schedule all tasks; concurrency is enforced inside {@link runQuery}. */ + private async runAll Promise)[]>( + tasks: [...T], + ): Promise<{ [K in keyof T]: T[K] extends () => Promise ? R : never }> { + const results = await Promise.all(tasks.map((t) => t())); + return results as { [K in keyof T]: T[K] extends () => Promise ? R : never }; + } + + /** Exposed for tests that exercise mergeNamedRows / breakdown mapping without full SQL. */ + mergeNamedRows(rows: NamedAggregateRow[]): (PartnerNamedBreakdownDto & { users: number })[] { + const map = new Map(); + + for (const row of rows) { + if (!row.name) continue; + const existing = map.get(row.name); + const volume = this.toVolume(row.volume); + const transactions = this.toCount(row.transactions); + const users = this.toCount(row.users); + if (existing) { + existing.volume = Util.round(existing.volume + volume, Config.defaultVolumeDecimal); + existing.transactions += transactions; + // Person floor across partial GROUP BY chunks: max, never min (min under-counts). + existing.users = Math.max(existing.users, users); + } else { + map.set(row.name, { name: row.name, volume, transactions, users }); + } + } + + return [...map.values()].sort((a, b) => b.volume - a.volume); + } + + /** + * Coerce a SQL aggregate cell to a finite number. + * COUNT(*)/COALESCE(SUM(...), 0) return 0 over an empty match set (a row is still returned). + * Non-numeric driver values must not become JSON `null` (which this API uses for suppression). + */ + private toVolume(value: string | number | null | undefined): number { + const n = +(value ?? 0); + if (Number.isNaN(n)) { + throw new Error(`Partner statistic: non-numeric volume aggregate (${String(value)})`); + } + return Util.round(n, Config.defaultVolumeDecimal); + } + + /** Same as toVolume for integer counts. */ + private toCount(value: string | number | null | undefined): number { + const n = +(value ?? 0); + if (Number.isNaN(n)) { + throw new Error(`Partner statistic: non-numeric count aggregate (${String(value)})`); + } + return Math.trunc(n); + } + + /** + * Stable date-part key for timeline bucket maps. Uses UTC getters to match the + * UTC-normalized bucket starts from {@link startOfBucket} and SQL `DATE_TRUNC` / + * `AT TIME ZONE 'UTC'`. The fill path builds the same keys, so SQL rows and empty + * fillers collide correctly; any consistent timezone would work as long as both + * sides match (bucket starts are ≥ 24 h apart, so local-vs-UTC day shifts do not + * collide distinct buckets under this spacing). + */ + private bucketKey(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; + } + + private startOfBucket(date: Date, granularity: PartnerStatisticGranularity): Date { + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); + + if (granularity === PartnerStatisticGranularity.MONTH) { + d.setUTCDate(1); + } else if (granularity === PartnerStatisticGranularity.WEEK) { + // Align to Monday UTC (Postgres DATE_TRUNC('week') is ISO week starting Monday). + const day = d.getUTCDay(); // 0=Sun … 6=Sat + const diff = day === 0 ? 6 : day - 1; + d.setUTCDate(d.getUTCDate() - diff); + } + + return d; + } + + private addBucket(date: Date, granularity: PartnerStatisticGranularity): Date { + const d = new Date(date); + if (granularity === PartnerStatisticGranularity.DAY) d.setUTCDate(d.getUTCDate() + 1); + else if (granularity === PartnerStatisticGranularity.WEEK) d.setUTCDate(d.getUTCDate() + 7); + else d.setUTCMonth(d.getUTCMonth() + 1); + return d; + } + + private startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + } + + private addUtcDays(date: Date, days: number): Date { + const d = new Date(date); + d.setUTCDate(d.getUTCDate() + days); + return d; + } +} diff --git a/src/subdomains/core/statistic/partner-statistic.suppression.ts b/src/subdomains/core/statistic/partner-statistic.suppression.ts new file mode 100644 index 0000000000..5938d0ef83 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.suppression.ts @@ -0,0 +1,250 @@ +import { + PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, + PartnerStatisticDirection, + PartnerStatisticDirectionField, +} from './partner-statistic.enum'; + +/** + * k-anonymity helpers for partner statistics. + * + * A disclosure unit is withheld when its effective count is in 1..k-1. + * The effective count is min(transactions, distinctUsers) when both are known; + * otherwise the transaction count alone. + * + * Additive groups (period totals by direction, timeline bucket directions) use + * block suppression: if any member is under the threshold, the entire group + * (members + derived sums/rates) is suppressed. Partial nulling would let + * totals − visible recover the hidden member exactly. + * + * Zero is never suppressed: 0 means “none”, null means “withheld”. + */ + +export function effectiveCount(transactions: number, users?: number): number { + if (users == null) return transactions; + return Math.min(transactions, users); +} + +export function isUnderThreshold( + transactions: number, + users?: number, + threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, +): boolean { + const n = effectiveCount(transactions, users); + return n > 0 && n < threshold; +} + +export function suppressScalar( + value: number, + threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, + users?: number, +): number | null { + if (value === 0) return 0; + return isUnderThreshold(value, users, threshold) ? null : value; +} + +export interface SuppressibleRow { + transactions: number; + /** Distinct users contributing to this row; when set, min(tx, users) is the gate. */ + users?: number; +} + +/** + * Removes rows with effective count in 1..k-1. If exactly one row is removed, also removes the + * smallest remaining filled row so never fewer than two are suppressed when complementary applies. + */ +export function suppressBreakdownRows( + rows: T[], + threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, +): { rows: T[]; suppressedCount: number } { + const visible = rows.filter((r) => !isUnderThreshold(r.transactions, r.users, threshold)); + let suppressedCount = rows.length - visible.length; + + if (suppressedCount === 1) { + // Complementary: suppress the smallest remaining filled row. SQL GROUP BY never emits + // 0-count groups, so a filled candidate exists whenever a non-empty visible set remains + // after dropping a single under-k row of real traffic. Zero-only remainders (synthetic + // fixtures) need no complementary partner — skip. + let smallest: T | undefined; + for (const r of visible) { + if (effectiveCount(r.transactions, r.users) <= 0) continue; + if ( + !smallest || + effectiveCount(r.transactions, r.users) <= effectiveCount(smallest.transactions, smallest.users) + ) { + smallest = r; + } + } + if (smallest) { + visible.splice(visible.indexOf(smallest), 1); + suppressedCount += 1; + } + } + + return { rows: visible, suppressedCount }; +} + +export interface TimelineSuppressible { + transactions: { buy: number; sell: number; swap: number } | null; + volume: { buy: number; sell: number; swap: number } | null; + /** Distinct users per direction (internal; not required on the public DTO). */ + users?: { buy: number; sell: number; swap: number } | null; + suppressed: boolean; +} + +const DIRECTIONS = [ + PartnerStatisticDirection.BUY, + PartnerStatisticDirection.SELL, + PartnerStatisticDirection.SWAP, +] as const; + +/** + * Nullifies volume/transactions when any direction is under the threshold (block rule), + * or when the combined effective total is under k. + * Empty (0-tx) buckets stay visible as zeros — they are not suppressed. + * Complementary: if exactly one real suppression, also suppress the smallest filled visible bucket. + */ +export function suppressTimelineBuckets( + buckets: T[], + threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, +): { buckets: T[]; suppressedCount: number } { + const result = buckets.map((b) => { + if (bucketNeedsSuppression(b, threshold)) { + return { ...b, volume: null, transactions: null, users: null, suppressed: true } as T; + } + return { ...b, suppressed: false }; + }); + + let suppressedCount = result.filter((b) => b.suppressed).length; + + if (suppressedCount === 1) { + const visible = result + .map((b, index) => ({ b, index, total: txTotal(b) })) + .filter((x) => !x.b.suppressed && x.total > 0); + + if (visible.length > 0) { + visible.sort((a, b) => a.total - b.total); + const target = visible[0].index; + result[target] = { + ...result[target], + volume: null, + transactions: null, + users: null, + suppressed: true, + } as T; + suppressedCount += 1; + } + } + + return { buckets: result, suppressedCount }; +} + +function bucketNeedsSuppression(b: TimelineSuppressible, threshold: number): boolean { + if (!b.transactions) return false; + const txs = b.transactions; + const users = b.users; + + // Per-direction gate (block rule for the bucket as an additive group). + for (const dir of DIRECTIONS) { + const field = PartnerStatisticDirectionField[dir]; + if (isUnderThreshold(txs[field], users?.[field], threshold)) return true; + } + + // Overall bucket total: if the whole day is under k when summed, hide it too. + const totalTx = txs.buy + txs.sell + txs.swap; + if (totalTx === 0) return false; + // Distinct users across directions can overlap; without a true UNION we use max of the + // per-direction user counts as a lower bound on the person count whenever a users object + // is present (callers always supply all three directions, including zeros). + if (users) { + const totalUsers = Math.max(users.buy, users.sell, users.swap); + return isUnderThreshold(totalTx, totalUsers, threshold); + } + return isUnderThreshold(totalTx, undefined, threshold); +} + +export interface DirectionTotals { + buy: number; + sell: number; + swap: number; + total: number; +} + +export interface DirectionUsers { + buy: number; + sell: number; + swap: number; + /** Distinct users across all directions (UNION), used for the total gate. */ + total: number; +} + +export interface SuppressedDirectionTotals { + buy: number | null; + sell: number | null; + swap: number | null; + total: number | null; +} + +/** + * Block-suppresses period totals: if any direction (or the overall total) is under k, + * every field is null — including derived average. No partial nulling of single directions. + */ +export function suppressPeriodTotals( + volume: DirectionTotals, + transactions: DirectionTotals, + users?: DirectionUsers, + threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, +): { + volume: SuppressedDirectionTotals; + transactions: SuppressedDirectionTotals; + averageTransactionVolume: number | null; + suppressedCount: number; +} { + const under = (dir: keyof DirectionTotals): boolean => isUnderThreshold(transactions[dir], users?.[dir], threshold); + + if (under('total') || under('buy') || under('sell') || under('swap')) { + return { + volume: { buy: null, sell: null, swap: null, total: null }, + transactions: { buy: null, sell: null, swap: null, total: null }, + averageTransactionVolume: null, + suppressedCount: 1, + }; + } + + const averageTransactionVolume = transactions.total > 0 ? volume.total / transactions.total : null; + + return { + volume: { ...volume }, + transactions: { ...transactions }, + averageTransactionVolume, + suppressedCount: 0, + }; +} + +export interface AllTimeVolume { + buy: number; + sell: number; + total: number; +} + +/** + * When tradingUsers is in 1..k-1, all-time volumes are withheld. registeredUsers stays visible + * (installation count, not a per-trade disclosure). + */ +export function suppressAllTimeVolume( + volume: AllTimeVolume, + tradingUsers: number, + threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, +): { volume: { buy: number | null; sell: number | null; total: number | null }; suppressedCount: number } { + if (isUnderThreshold(tradingUsers, undefined, threshold)) { + return { + volume: { buy: null, sell: null, total: null }, + suppressedCount: 1, + }; + } + return { volume, suppressedCount: 0 }; +} + +function txTotal(b: TimelineSuppressible): number { + if (b.suppressed || !b.transactions) return 0; + return b.transactions.buy + b.transactions.sell + b.transactions.swap; +} diff --git a/src/subdomains/core/statistic/statistic.module.ts b/src/subdomains/core/statistic/statistic.module.ts index ecbe419f2e..3ec612ec44 100644 --- a/src/subdomains/core/statistic/statistic.module.ts +++ b/src/subdomains/core/statistic/statistic.module.ts @@ -1,17 +1,31 @@ import { Module } from '@nestjs/common'; import { BitcoinModule } from 'src/integration/blockchain/bitcoin/bitcoin.module'; import { SharedModule } from 'src/shared/shared.module'; +import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { UserRepository } from 'src/subdomains/generic/user/models/user/user.repository'; +import { WalletRepository } from 'src/subdomains/generic/user/models/wallet/wallet.repository'; import { UserModule } from 'src/subdomains/generic/user/user.module'; import { BuyCryptoModule } from '../buy-crypto/buy-crypto.module'; import { ReferralModule } from '../referral/referral.module'; import { SellCryptoModule } from '../sell-crypto/sell-crypto.module'; +import { PartnerStatisticController } from './partner-statistic.controller'; +import { PartnerStatisticService } from './partner-statistic.service'; import { StatisticController } from './statistic.controller'; import { StatisticService } from './statistic.service'; @Module({ imports: [SharedModule, BuyCryptoModule, SellCryptoModule, ReferralModule, UserModule, BitcoinModule], - controllers: [StatisticController], - providers: [StatisticService], + controllers: [StatisticController, PartnerStatisticController], + providers: [ + StatisticService, + PartnerStatisticService, + // BuyCryptoRepository is exported by BuyCryptoModule (already imported) — do not re-provide. + // BuyFiatRepository is not in SellCryptoModule.exports — provide locally. + BuyFiatRepository, + // UserRepository / WalletRepository are not in UserModule.exports — provide locally. + UserRepository, + WalletRepository, + ], exports: [], }) export class StatisticModule {} From b210e768ba90080185692501e63e4c2f38d78e8a Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Sun, 2 Aug 2026 18:35:43 +0200 Subject: [PATCH 3/9] Report every day, and drop the k-anonymity layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoints exist so a partner can answer "how is my integration doing" without holding records about individual customers. That property comes from what the payload contains — sums, counts and named breakdown rows, no user id, address, name, email, IBAN or transaction id, and no user row ever loaded into the process. It does not come from withholding thin days. The threshold did withhold them, and it protected nothing: the same CLIENT_COMPANY credential already reaches GET /v2/kyc/client/payments, which returns the underlying transactions in full, including customer IBANs, up to 1000 per call and without a consent filter. A day with four transactions was hidden from the partner in the aggregate while the same partner could read those four transactions individually next door. So the layer goes, and with it the fields that only existed to describe it — meta.suppressionThreshold, meta.suppressedCount, bucket.suppressed — and the nullability that meant "withheld" rather than "no data". `averageTransactionVolume` stays nullable, where null still means there were no transactions. The guards that matter are untouched and still fail loudly: removing the wallet scope from one query fails 2 of 51, removing the AML filter 1 of 51, widening a period bound 3 of 51. --- docs/coverage-gate.md | 10 +- jest.coverage-gate.config.js | 1 - .../partner-statistic.integration.spec.ts | 32 +- .../partner-statistic.service.spec.ts | 178 +------- .../partner-statistic.suppression.spec.ts | 381 ------------------ .../statistic/dto/partner-statistic.dto.ts | 110 ++--- .../core/statistic/partner-statistic.enum.ts | 3 - .../statistic/partner-statistic.service.ts | 101 +---- .../partner-statistic.suppression.ts | 250 ------------ 9 files changed, 85 insertions(+), 981 deletions(-) delete mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts delete mode 100644 src/subdomains/core/statistic/partner-statistic.suppression.ts diff --git a/docs/coverage-gate.md b/docs/coverage-gate.md index cc3a1091aa..8508e6634d 100644 --- a/docs/coverage-gate.md +++ b/docs/coverage-gate.md @@ -6,7 +6,7 @@ the other. | Gate | Config | Scope | Question it answers | | ---------------- | ------------------------------ | ------------------------------------------ | -------------------------------------------------------- | | Frick gate | `jest.frick.config.js` | 10 Frick files, run by 10 Frick specs only | Do _these specs alone_ fully cover _these files_? | -| Coverage ratchet | `jest.coverage-gate.config.js` | 445 files, whole suite | Has coverage regressed anywhere it was already complete? | +| Coverage ratchet | `jest.coverage-gate.config.js` | 444 files, whole suite | Has coverage regressed anywhere it was already complete? | ## What the ratchet is, and what it is not @@ -25,7 +25,7 @@ It is a **regression gate**, not a statement about test quality: ratchet only protects files already on the list, and that list grows by hand (see "How the list grows"). That is the price of the threshold approach. -Of the 445 pinned files, **251 carry real logic** (they have functions and/or branches) and +Of the 444 pinned files, **250 carry real logic** (they have functions and/or branches) and **194 are purely declarative today** (NestJS modules, constant files with neither). The two groups are kept visibly separate in the config so the count is not mistaken for test depth. @@ -161,7 +161,7 @@ deleting them would be a separate cleanup. | Class | Files | Meaning | | -------- | ----- | ----------------------------------------------- | -| Complete | 445 | Pinned by the ratchet at that commit | +| Complete | 444 | Pinned by the ratchet at that commit | | Partial | 1,034 | Some coverage, below 100 on at least one metric | | None | 127 | No coverage at all | @@ -175,7 +175,7 @@ six under `subdomains/generic/admin` have no coverage at all. ## How the list grows Any PR may add files to `coverageThreshold` once they reach 100%. -`jest.coverage-gate.config.js` holds the 445 paths in two arrays, `PINNED_LOGIC` (logic-carrying +`jest.coverage-gate.config.js` holds the 444 paths in two arrays, `PINNED_LOGIC` (logic-carrying files) and `PINNED_DECLARATIVE` (purely declarative files), from which `coverageThreshold` is generated. Adding a file means appending its path to the matching array, not writing out a `coverageThreshold` object entry by hand. @@ -211,7 +211,7 @@ To regenerate the full picture, run the gate and read `coverage-gate/coverage-su below 100, the expected response is to extend the tests. Unpinning is an explicit decision that belongs in the PR description, not a silent edit. -That rule stays hard for the 251 logic-carrying files. A foreseeable friction case is different: +That rule stays hard for the 250 logic-carrying files. A foreseeable friction case is different: when one of the 194 purely declarative files (a NestJS module, a constants file) first gains executable logic — for example a `useFactory` on a module — the function metric jumps from 0/0 to 0/N and the gate turns red. Tests remain the preferred fix, but unpinning that one file is an diff --git a/jest.coverage-gate.config.js b/jest.coverage-gate.config.js index 02515f0dd2..87a1ef6b83 100644 --- a/jest.coverage-gate.config.js +++ b/jest.coverage-gate.config.js @@ -122,7 +122,6 @@ const PINNED_LOGIC = [ 'src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts', 'src/subdomains/core/statistic/partner-statistic.controller.ts', 'src/subdomains/core/statistic/partner-statistic.enum.ts', - 'src/subdomains/core/statistic/partner-statistic.suppression.ts', 'src/subdomains/core/trading/enums/index.ts', 'src/subdomains/generic/forwarding/controllers/lnurld-forward.controller.ts', 'src/subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts', diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts index 99750f3a1c..37085d2e2b 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -303,11 +303,8 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { INSERT INTO ${t('buy_fiat')} ("sellId", "cryptoInputId", "outputAssetId", "transactionId", "amountInChf", "amlCheck", "created", "inputAsset") VALUES - -- inputAsset left null so the SELL asset breakdown emits no under-k named row. - -- Period totals still see the 1 sell tx (block-suppressed). A named under-k SELL - -- asset (e.g. inputAsset='BTC') would trigger complementary suppression and drop - -- the COMMON BUY BTC row at exactly k — which is correct product behaviour but - -- would hide the SQL asset-join proof this fixture is meant to exercise. + -- inputAsset left null: the SELL asset row is not the subject of this fixture, + -- which exercises the buy-side asset join against real rows. (1, 1, 3, 7, 50, 'Pass', '2024-06-11 12:00:00', NULL); `); @@ -334,13 +331,12 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { const result = await service.getStatistics(1, from, to); // buy 5×100 = 500; sell 50; swap 0 (no crypto_route rows — documented) - // sell has 1 tx → period totals block-suppressed (under k on sell direction) - expect(result.totals.volume.total).toBeNull(); - expect(result.totals.volume.buy).toBeNull(); - expect(result.totals.volume.sell).toBeNull(); + expect(result.totals.volume.buy).toBe(500); + expect(result.totals.volume.sell).toBe(50); + expect(result.totals.volume.swap).toBe(0); + expect(result.totals.volume.total).toBe(550); - // Foreign wallet (id=2) volume must not inflate anything when totals become visible at k - // — exercised via active-user / allTime scope instead: + // Foreign wallet (id=2) rows must not inflate anything: expect(result.allTime.registeredUsers).toBe(6); // users 1–5 + owner 100 on wallet 1 expect(result.allTime.registeredUsers).not.toBe(7); // must not include wallet-2 user 6 @@ -361,7 +357,7 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { expect(JSON.stringify(result)).not.toMatch(/"users"/); }); - it('getTimeline builds UTC day buckets from real DATE_TRUNC rows and applies suppression', async () => { + it('getTimeline builds UTC day buckets from real DATE_TRUNC rows', async () => { const result = await service.getTimeline( 1, '2024-06-10T00:00:00.000Z', @@ -375,19 +371,17 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { '2024-06-11T00:00:00.000Z', ]); - // Day-1 has 3 buy txs (under k) → suppressed; day-2 has 2 buy + 1 sell = 3 under k → suppressed - expect(result.buckets[0].suppressed).toBe(true); - expect(result.buckets[0].volume).toBeNull(); - expect(result.buckets[1].suppressed).toBe(true); - expect(result.buckets[1].volume).toBeNull(); + // Day-1 has 3 buy txs, day-2 has 2 buy + 1 sell — every day reports what happened. + expect(result.buckets[0].volume?.buy).toBe(300); + expect(result.buckets[1].volume?.buy).toBe(200); + expect(result.buckets[1].volume?.sell).toBe(50); expect(JSON.stringify(result)).not.toMatch(/"users"/); }); it('countActiveUsers is a set across directions (UNION, not UNION ALL)', async () => { // Three users each active on buy AND sell → DISTINCT 3; bag-count (UNION ALL) would be 6. - // That bag count is rawUsers.total in suppressPeriodTotals and activeUsers on the DTO — - // k=5 would pass for 6 and fail for 3, which is exactly the disclosure the gate blocks. + // The DTO reports activeUsers from this set; a bag count would double anyone active in two directions. await dataSource.query(`DELETE FROM ${t('buy_crypto')}`); await dataSource.query(`DELETE FROM ${t('buy_fiat')}`); await dataSource.query(`DELETE FROM ${t('sell')}`); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts index 15e3f047bd..e43a2636d8 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts @@ -99,20 +99,6 @@ function emptyFixture(overrides: Partial = {}): WalletFixture { }; } -/** Recursively collect every own-property key name in a JSON-serializable value. */ -function collectKeys(value: unknown, out: Set = new Set()): Set { - if (value == null || typeof value !== 'object') return out; - if (Array.isArray(value)) { - for (const item of value) collectKeys(item, out); - return out; - } - for (const [k, v] of Object.entries(value as Record)) { - out.add(k); - collectKeys(v, out); - } - return out; -} - interface QbState { walletId?: number; direction?: Direction; @@ -806,87 +792,6 @@ describe('PartnerStatisticService', () => { }); }); - // --- B3: totals / allTime suppression via service --- // - - describe('totals and allTime suppression (B3)', () => { - it('nulls totals when overall transaction count is below k (boundary at k)', async () => { - fixtures.set( - 1, - emptyFixture({ - buy: { volume: 99, transactions: 4, users: 4 }, - allTime: { buy: 99, sell: 0, registeredUsers: 10, tradingUsers: 4 }, - newUsers: 0, - activeUserIds: [1, 2, 3, 4], - }), - ); - - const under = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); - expect(under.totals.volume.total).toBeNull(); - expect(under.totals.volume.buy).toBeNull(); - expect(under.totals.transactions.total).toBeNull(); - expect(under.totals.averageTransactionVolume).toBeNull(); - expect(under.allTime.volume.total).toBeNull(); - expect(under.allTime.volume.buy).toBeNull(); - expect(under.allTime.registeredUsers).toBe(10); - expect(under.allTime.tradingUsers).toBeNull(); - expect(under.referral.volume).toBeNull(); - expect(under.meta.suppressedCount).toBeGreaterThanOrEqual(2); - - fixtures.set( - 1, - emptyFixture({ - buy: { volume: 100, transactions: 5, users: 5 }, - allTime: { buy: 100, sell: 0, registeredUsers: 10, tradingUsers: 5 }, - newUsers: 0, - activeUserIds: [1, 2, 3, 4, 5], - }), - ); - - const atK = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); - expect(atK.totals.volume.total).toBe(100); - expect(atK.totals.transactions.total).toBe(5); - expect(atK.allTime.volume.total).toBe(100); - expect(atK.allTime.tradingUsers).toBe(5); - }); - - it('nulls totals when person count is under k even with high transaction counts', async () => { - fixtures.set( - 1, - emptyFixture({ - buy: { volume: 1000, transactions: 20, users: 2 }, - allTime: { buy: 1000, sell: 0, registeredUsers: 10, tradingUsers: 5 }, - newUsers: 0, - activeUserIds: [1, 2], - }), - ); - activeUserCountFromManager = 2; - - const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); - expect(result.totals.volume.total).toBeNull(); - expect(result.totals.activeUsers).toBeNull(); - }); - - it('sums buy+sell+swap into totals.volume.total (not a multiple of one direction)', async () => { - fixtures.set( - 1, - emptyFixture({ - buy: { volume: 100, transactions: 10, users: 8 }, - sell: { volume: 40, transactions: 10, users: 8 }, - swap: { volume: 25, transactions: 10, users: 8 }, - allTime: { buy: 100, sell: 40, registeredUsers: 20, tradingUsers: 15 }, - activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8], - newUsers: 0, - }), - ); - activeUserCountFromManager = 10; - - const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); - expect(result.totals.volume.total).toBe(165); - expect(result.totals.volume.total).not.toBe(100 + 40 + 2 * 25); - expect(result.totals.transactions.total).toBe(30); - }); - }); - // --- M2: active users via manager UNION count --- // describe('countActiveUsers uses DB COUNT over UNION (M2)', () => { @@ -911,8 +816,8 @@ describe('PartnerStatisticService', () => { }); it('gates period totals on the UNION active-user count, not buyAgg.users alone', async () => { - // Each direction looks fine (≥ k users), but the true UNION is 3 → totals block-suppressed. - // Mutation rawUsers.total = buyAgg.users would keep totals visible (buy users = 10). + // Each direction reports 10 users, but the true DISTINCT union across directions is 3. + // activeUsers must come from the UNION count, not from one direction's user count. fixtures.set( 1, emptyFixture({ @@ -927,9 +832,11 @@ describe('PartnerStatisticService', () => { activeUserCountFromManager = 3; const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); - expect(result.totals.volume.total).toBeNull(); - expect(result.totals.transactions.total).toBeNull(); - expect(result.totals.activeUsers).toBeNull(); + expect(result.totals.activeUsers).toBe(3); + expect(result.totals.activeUsers).not.toBe(10); + // Every day reports what happened; nothing is withheld for being thin. + expect(result.totals.volume.total).toBe(1700); + expect(result.totals.transactions.total).toBe(45); }); }); @@ -1008,77 +915,6 @@ describe('PartnerStatisticService', () => { }); }); - // --- Response-path suppression (Block C) --- // - - describe('response-path k-anonymity (getStatistics / getTimeline)', () => { - it('drops under-k breakdown rows, nulls newUsers in 1..k-1, and never exposes users', async () => { - fixtures.set( - 1, - emptyFixture({ - // Totals above k so period totals stay visible — focus on breakdown + newUsers. - buy: { volume: 1000, transactions: 20, users: 10 }, - sell: { volume: 500, transactions: 15, users: 10 }, - swap: { volume: 200, transactions: 10, users: 8 }, - allTime: { buy: 5000, sell: 1000, registeredUsers: 50, tradingUsers: 20 }, - newUsers: 3, // 1..k-1 → null - activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - namedRows: [ - { name: 'RARE', blockchain: 'Bitcoin', volume: 10, transactions: 2, users: 2 }, // under k - { name: 'COMMON', blockchain: 'Bitcoin', volume: 500, transactions: 20, users: 12 }, - { name: 'COMMON2', blockchain: 'Ethereum', volume: 400, transactions: 18, users: 11 }, - ], - }), - ); - activeUserCountFromManager = 10; - - const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); - - const assetNames = result.breakdown.assets.map((a) => a.name); - expect(assetNames).not.toContain('RARE'); - expect(assetNames).toContain('COMMON'); - expect(result.totals.newUsers).toBeNull(); - - const keys = collectKeys(JSON.parse(JSON.stringify(result))); - expect(keys.has('users')).toBe(false); - }); - - it('suppresses under-k timeline buckets (suppressed:true, volume null) and strips users', async () => { - fixtures.set( - 1, - emptyFixture({ - timelineRows: [ - // Day with 3 txs → under k → suppressed (+ complementary may hit another day) - { bucket: new Date('2024-06-10T00:00:00.000Z'), volume: 30, transactions: 3, users: 3 }, - { bucket: new Date('2024-06-11T00:00:00.000Z'), volume: 200, transactions: 20, users: 15 }, - { bucket: new Date('2024-06-12T00:00:00.000Z'), volume: 300, transactions: 30, users: 20 }, - ], - }), - ); - - const result = await service.getTimeline( - 1, - '2024-06-10T00:00:00.000Z', - '2024-06-12T23:59:59.000Z', - PartnerStatisticGranularity.DAY, - ); - - const under = result.buckets.find((b) => b.date.toISOString() === '2024-06-10T00:00:00.000Z'); - expect(under).toBeDefined(); - expect(under!.suppressed).toBe(true); - expect(under!.volume).toBeNull(); - expect(under!.transactions).toBeNull(); - - // At least one more day may be complementary-suppressed; a large day stays visible. - const large = result.buckets.find((b) => b.date.toISOString() === '2024-06-12T00:00:00.000Z'); - // complementary suppresses the smallest filled remaining (day 11 with 20), day 12 stays - expect(large!.suppressed).toBe(false); - expect(large!.volume).not.toBeNull(); - - const keys = collectKeys(JSON.parse(JSON.stringify(result))); - expect(keys.has('users')).toBe(false); - }); - }); - // --- mergeNamedRows / breakdown pipeline --- // describe('mergeNamedRows and breakdown pipeline', () => { diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts deleted file mode 100644 index fdc9705878..0000000000 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.suppression.spec.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { PARTNER_STATISTIC_SUPPRESSION_THRESHOLD } from '../partner-statistic.enum'; -import { - isUnderThreshold, - suppressAllTimeVolume, - suppressBreakdownRows, - suppressPeriodTotals, - suppressScalar, - suppressTimelineBuckets, -} from '../partner-statistic.suppression'; - -/** Single calendar anchor for payload dates — suppression logic is count-based, not calendar-based. */ -const TEST_BUCKET_DATE = new Date(); - -describe('Partner statistic suppression', () => { - describe('suppressScalar (M1)', () => { - it('keeps 0 as 0 and nulls only 1..k-1 (boundary is strict < k)', () => { - expect(suppressScalar(0)).toBe(0); - expect(suppressScalar(1)).toBeNull(); - expect(suppressScalar(4)).toBeNull(); - expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1)).toBeNull(); - expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).toBe(5); - expect(suppressScalar(5)).toBe(5); - expect(suppressScalar(10)).toBe(10); - expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1)).not.toBe( - PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1, - ); - expect(suppressScalar(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).not.toBeNull(); - }); - - it('uses min(transactions, users) as the effective count (person gate)', () => { - // 10 txs but only 3 users → under k - expect(suppressScalar(10, undefined, 3)).toBeNull(); - // 10 txs and 5 users → visible - expect(suppressScalar(10, undefined, 5)).toBe(10); - // 3 txs and 10 users → under k on tx side - expect(suppressScalar(3, undefined, 10)).toBeNull(); - }); - - it('respects an explicit custom threshold', () => { - expect(suppressScalar(3, 3)).toBe(3); - expect(suppressScalar(2, 3)).toBeNull(); - expect(suppressScalar(3, 10, 3)).toBeNull(); - }); - - it('isUnderThreshold uses the module default k when threshold is omitted', () => { - expect(isUnderThreshold(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD - 1)).toBe(true); - expect(isUnderThreshold(PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).toBe(false); - expect(isUnderThreshold(0)).toBe(false); - }); - }); - - describe('suppressBreakdownRows', () => { - it('drops rows with fewer than k transactions (boundary at k)', () => { - const { rows, suppressedCount } = suppressBreakdownRows([ - { name: 'A', volume: 100, transactions: 4 }, - { name: 'B', volume: 50, transactions: 3 }, - { name: 'C', volume: 200, transactions: 5 }, - { name: 'D', volume: 300, transactions: 10 }, - ]); - - expect(rows.map((r) => r.name).sort()).toEqual(['C', 'D']); - expect(suppressedCount).toBe(2); - expect(rows.find((r) => r.name === 'C')?.transactions).toBe(5); - expect(rows.some((r) => r.transactions === PARTNER_STATISTIC_SUPPRESSION_THRESHOLD)).toBe(true); - }); - - it('drops rows when person count is under k even if transactions are above k', () => { - const { rows, suppressedCount } = suppressBreakdownRows([ - { name: 'A', volume: 100, transactions: 20, users: 2 }, - { name: 'B', volume: 200, transactions: 20, users: 10 }, - { name: 'C', volume: 300, transactions: 30, users: 15 }, - ]); - // A under k on person gate; complementary drops B (smallest remaining); C stays - expect(rows.map((r) => r.name)).toEqual(['C']); - expect(suppressedCount).toBe(2); - expect(rows.find((r) => r.name === 'A')).toBeUndefined(); - }); - - it('applies complementary suppression only when exactly one row is under threshold', () => { - const { rows, suppressedCount } = suppressBreakdownRows([ - { name: 'A', volume: 10, transactions: 3 }, - { name: 'B', volume: 20, transactions: 6 }, - { name: 'C', volume: 100, transactions: 50 }, - ]); - - expect(rows.map((r) => r.name)).toEqual(['C']); - expect(suppressedCount).toBe(2); - expect(rows.find((r) => r.name === 'B')).toBeUndefined(); - }); - - it('picks the later smaller filled row when complementary reduces across candidates', () => { - // First remaining filled is larger than the second — reduce must take the `b` arm. - const { rows, suppressedCount } = suppressBreakdownRows([ - { name: 'A', volume: 10, transactions: 3 }, - { name: 'B', volume: 100, transactions: 50 }, - { name: 'C', volume: 20, transactions: 6 }, - ]); - expect(rows.map((r) => r.name)).toEqual(['B']); - expect(suppressedCount).toBe(2); - expect(rows.find((r) => r.name === 'C')).toBeUndefined(); - }); - - it('does not apply complementary suppression when zero or two+ rows are under threshold', () => { - const none = suppressBreakdownRows([ - { name: 'A', volume: 10, transactions: 5 }, - { name: 'B', volume: 20, transactions: 6 }, - ]); - expect(none.rows).toHaveLength(2); - expect(none.suppressedCount).toBe(0); - - const two = suppressBreakdownRows([ - { name: 'A', volume: 10, transactions: 2 }, - { name: 'B', volume: 20, transactions: 3 }, - { name: 'C', volume: 100, transactions: 50 }, - ]); - expect(two.rows.map((r) => r.name)).toEqual(['C']); - expect(two.suppressedCount).toBe(2); - expect(two.rows).toHaveLength(1); - }); - - it('skips complementary when only zero-tx rows remain after dropping one under-k row', () => { - const { rows, suppressedCount } = suppressBreakdownRows([ - { name: 'A', volume: 10, transactions: 3 }, - { name: 'B', volume: 0, transactions: 0 }, - ]); - expect(rows.map((r) => r.name)).toEqual(['B']); - expect(suppressedCount).toBe(1); - }); - }); - - describe('suppressTimelineBuckets (B2)', () => { - const bucket = ( - buyTx: number, - sellTx = 0, - swapTx = 0, - buyUsers?: number, - sellUsers?: number, - swapUsers?: number, - ) => ({ - date: new Date(TEST_BUCKET_DATE), - volume: { buy: buyTx * 10, sell: sellTx * 10, swap: swapTx * 10 }, - transactions: { buy: buyTx, sell: sellTx, swap: swapTx }, - users: { - buy: buyUsers ?? buyTx, - sell: sellUsers ?? sellTx, - swap: swapUsers ?? swapTx, - }, - suppressed: false, - partial: false, - }); - - it('nullifies a bucket with 4 transactions and keeps one with 5 (plus complementary)', () => { - const { buckets, suppressedCount } = suppressTimelineBuckets([bucket(4), bucket(5), bucket(20)]); - - expect(buckets[0].suppressed).toBe(true); - expect(buckets[0].volume).toBeNull(); - expect(buckets[0].transactions).toBeNull(); - expect(buckets[1].suppressed).toBe(true); - expect(buckets[2].suppressed).toBe(false); - expect(buckets[2].volume).toEqual({ buy: 200, sell: 0, swap: 0 }); - expect(suppressedCount).toBe(2); - }); - - it('suppresses mixed bucket when any single direction is under k (block rule)', () => { - // buy=5 (ok), sell=1 (under) → whole bucket suppressed even though total=6 ≥ k - const mixed = bucket(5, 1, 0); - const large = bucket(20, 20, 20); - const { buckets, suppressedCount } = suppressTimelineBuckets([mixed, large]); - - expect(buckets[0].suppressed).toBe(true); - expect(buckets[0].volume).toBeNull(); - // Block rule nulls the whole under-k mixed bucket (sell=1 must not remain visible). - expect(buckets[0].transactions).toBeNull(); - // Exactly one under-k bucket → complementary also suppresses the only other filled bucket. - // (The old `if (!buckets[1].suppressed)` body was dead: complementary always fires here.) - expect(buckets[1].suppressed).toBe(true); - expect(buckets[1].volume).toBeNull(); - expect(buckets[1].transactions).toBeNull(); - expect(suppressedCount).toBe(2); - // No non-suppressed filled bucket remains — that is what blocks total−visible recovery of - // sell=1. (A filtered sum of visible sell would be 0 by construction once both are null.) - }); - - it('keeps a bucket at exactly k when no complementary case applies', () => { - const { buckets, suppressedCount } = suppressTimelineBuckets([bucket(4), bucket(3), bucket(5)]); - - expect(buckets[0].suppressed).toBe(true); - expect(buckets[1].suppressed).toBe(true); - expect(buckets[2].suppressed).toBe(false); - expect(buckets[2].transactions).toEqual({ buy: 5, sell: 0, swap: 0 }); - expect(suppressedCount).toBe(2); - }); - - it('leaves empty (0-tx) buckets as visible zeros, not suppressed', () => { - const empty = bucket(0); - const filled = bucket(10); - const { buckets, suppressedCount } = suppressTimelineBuckets([empty, filled]); - - expect(buckets[0].suppressed).toBe(false); - expect(buckets[0].volume).toEqual({ buy: 0, sell: 0, swap: 0 }); - expect(buckets[0].transactions).toEqual({ buy: 0, sell: 0, swap: 0 }); - expect(buckets[1].suppressed).toBe(false); - expect(suppressedCount).toBe(0); - }); - - it('still applies complementary when empty days would otherwise defeat it (B2 security)', () => { - const empties = [bucket(0), bucket(0), bucket(0), bucket(0), bucket(0)]; - const under = bucket(3); - const small = bucket(6); - const large = bucket(50); - const large2 = bucket(40); - - const { buckets, suppressedCount } = suppressTimelineBuckets([...empties, under, small, large, large2]); - - for (let i = 0; i < empties.length; i++) { - expect(buckets[i].suppressed).toBe(false); - expect(buckets[i].transactions).toEqual({ buy: 0, sell: 0, swap: 0 }); - expect(buckets[i].volume).toEqual({ buy: 0, sell: 0, swap: 0 }); - } - - expect(buckets[empties.length].suppressed).toBe(true); - expect(buckets[empties.length].volume).toBeNull(); - expect(buckets[empties.length + 1].suppressed).toBe(true); - expect(buckets[empties.length + 2].suppressed).toBe(false); - expect(buckets[empties.length + 3].suppressed).toBe(false); - expect(suppressedCount).toBe(2); - }); - - it('suppresses when person count is under k despite high transaction totals', () => { - // Per-direction txs look fine on count alone (20 ≥ k), but only 3 persons → withheld. - const fewPeople = { - date: new Date(TEST_BUCKET_DATE), - volume: { buy: 200, sell: 0, swap: 0 }, - transactions: { buy: 20, sell: 0, swap: 0 }, - users: { buy: 3, sell: 0, swap: 0 }, - suppressed: false, - partial: false, - }; - const large = bucket(50, 50, 50); - const { buckets, suppressedCount } = suppressTimelineBuckets([fewPeople, large]); - - expect(buckets[0].suppressed).toBe(true); - expect(buckets[0].volume).toBeNull(); - expect(buckets[0].transactions).toBeNull(); - expect(suppressedCount).toBeGreaterThanOrEqual(1); - }); - - it('uses Math.max (not min) of per-direction users as the bucket person-floor lower bound', () => { - // buy/sell well above k; swap has 0 txs but a leftover users=1 that must not collapse the - // person floor via Math.min — max(10,10,1)=10 keeps the bucket visible. - const mixed = { - date: new Date(TEST_BUCKET_DATE), - volume: { buy: 100, sell: 100, swap: 0 }, - transactions: { buy: 10, sell: 10, swap: 0 }, - users: { buy: 10, sell: 10, swap: 1 }, - suppressed: false, - partial: false, - }; - const { buckets, suppressedCount } = suppressTimelineBuckets([mixed]); - expect(buckets[0].suppressed).toBe(false); - expect(buckets[0].transactions).toEqual({ buy: 10, sell: 10, swap: 0 }); - expect(suppressedCount).toBe(0); - }); - - it('uses total transaction count alone when no per-bucket users are provided', () => { - const noUsers = { - date: new Date(TEST_BUCKET_DATE), - volume: { buy: 40, sell: 0, swap: 0 }, - transactions: { buy: 4, sell: 0, swap: 0 }, - suppressed: false, - partial: false, - }; - const large = { - date: new Date(TEST_BUCKET_DATE), - volume: { buy: 500, sell: 0, swap: 0 }, - transactions: { buy: 50, sell: 0, swap: 0 }, - suppressed: false, - partial: false, - }; - const { buckets } = suppressTimelineBuckets([noUsers, large]); - expect(buckets[0].suppressed).toBe(true); - expect(buckets[0].volume).toBeNull(); - }); - - it('leaves a bucket with null transactions untouched (no suppression flag)', () => { - const missing = { - date: new Date(TEST_BUCKET_DATE), - volume: null, - transactions: null, - suppressed: false, - partial: false, - }; - const large = bucket(50); - const { buckets, suppressedCount } = suppressTimelineBuckets([missing, large]); - expect(buckets[0].suppressed).toBe(false); - expect(buckets[0].transactions).toBeNull(); - expect(suppressedCount).toBe(0); - }); - }); - - describe('suppressPeriodTotals (B3 / block rule)', () => { - it('nulls all totals fields when overall transactions.total is in 1..k-1', () => { - const { volume, transactions, averageTransactionVolume, suppressedCount } = suppressPeriodTotals( - { buy: 100, sell: 0, swap: 0, total: 100 }, - { buy: 3, sell: 0, swap: 0, total: 3 }, - { buy: 3, sell: 0, swap: 0, total: 3 }, - ); - - expect(volume).toEqual({ buy: null, sell: null, swap: null, total: null }); - expect(transactions).toEqual({ buy: null, sell: null, swap: null, total: null }); - expect(averageTransactionVolume).toBeNull(); - expect(suppressedCount).toBe(1); - }); - - it('block-suppresses the entire group when any direction is under k (no partial nulling)', () => { - // Previously leaked: sell=null but total/buy/swap visible → sell = total − buy − swap. - const mixed = suppressPeriodTotals( - { buy: 1000, sell: 10, swap: 200, total: 1210 }, - { buy: 20, sell: 2, swap: 10, total: 32 }, - { buy: 10, sell: 2, swap: 8, total: 15 }, - ); - expect(mixed.volume).toEqual({ buy: null, sell: null, swap: null, total: null }); - expect(mixed.transactions).toEqual({ buy: null, sell: null, swap: null, total: null }); - expect(mixed.averageTransactionVolume).toBeNull(); - expect(mixed.suppressedCount).toBe(1); - // Reconstruction must fail - expect(mixed.volume.total).toBeNull(); - }); - - it('keeps totals at exactly k for every direction and overall', () => { - const atK = suppressPeriodTotals( - { buy: 500, sell: 0, swap: 0, total: 500 }, - { buy: 5, sell: 0, swap: 0, total: 5 }, - { buy: 5, sell: 0, swap: 0, total: 5 }, - ); - expect(atK.volume.total).toBe(500); - expect(atK.transactions.total).toBe(5); - expect(atK.volume.buy).toBe(500); - expect(atK.suppressedCount).toBe(0); - expect(atK.averageTransactionVolume).toBe(100); - }); - - it('suppresses when transaction count is high but person count is under k', () => { - const personGate = suppressPeriodTotals( - { buy: 1000, sell: 0, swap: 0, total: 1000 }, - { buy: 20, sell: 0, swap: 0, total: 20 }, - { buy: 2, sell: 0, swap: 0, total: 2 }, - ); - expect(personGate.volume.total).toBeNull(); - expect(personGate.suppressedCount).toBe(1); - }); - - it('leaves all-zero totals as zeros, not null', () => { - const { volume, transactions, averageTransactionVolume, suppressedCount } = suppressPeriodTotals( - { buy: 0, sell: 0, swap: 0, total: 0 }, - { buy: 0, sell: 0, swap: 0, total: 0 }, - { buy: 0, sell: 0, swap: 0, total: 0 }, - ); - expect(volume.total).toBe(0); - expect(transactions.total).toBe(0); - expect(averageTransactionVolume).toBeNull(); - expect(suppressedCount).toBe(0); - }); - }); - - describe('suppressAllTimeVolume (B3)', () => { - it('nulls all-time volume when tradingUsers is in 1..k-1 and keeps it at k', () => { - const under = suppressAllTimeVolume({ buy: 100, sell: 50, total: 150 }, 3); - expect(under.volume).toEqual({ buy: null, sell: null, total: null }); - expect(under.suppressedCount).toBe(1); - - const atK = suppressAllTimeVolume({ buy: 100, sell: 50, total: 150 }, 5); - expect(atK.volume).toEqual({ buy: 100, sell: 50, total: 150 }); - expect(atK.suppressedCount).toBe(0); - - const zero = suppressAllTimeVolume({ buy: 0, sell: 0, total: 0 }, 0); - expect(zero.volume).toEqual({ buy: 0, sell: 0, total: 0 }); - expect(zero.suppressedCount).toBe(0); - }); - }); -}); diff --git a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts index 621df4f6d9..a7ef4c7104 100644 --- a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts +++ b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PartnerStatisticDirection, PartnerStatisticGranularity } from '../partner-statistic.enum'; // --- PERIOD / META --- // @@ -14,65 +14,55 @@ export class PartnerStatisticPeriodDto { } export class PartnerStatisticMetaDto { - @ApiProperty({ description: 'Minimum effective count (min of transactions and distinct users) for disclosure' }) - suppressionThreshold: number; - - @ApiProperty({ - description: - 'Number of withheld disclosure units: suppressed fields and breakdown rows on the summary, ' + - 'suppressed timeline buckets on the timeline', - }) - suppressedCount: number; - - @ApiProperty({ nullable: true, required: false }) + @ApiPropertyOptional({ description: 'Server time the response was assembled' }) generatedAt?: Date; } // --- VOLUME / COUNTS --- // export class PartnerVolumeByTypeDto { - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) - buy: number | null; + @ApiProperty({ description: 'CHF' }) + buy: number; - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) - sell: number | null; + @ApiProperty({ description: 'CHF' }) + sell: number; - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) - swap: number | null; + @ApiProperty({ description: 'CHF' }) + swap: number; - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when the totals group is suppressed' }) - total: number | null; + @ApiProperty({ description: 'CHF' }) + total: number; } export class PartnerVolumeBuySellDto { - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when tradingUsers < k' }) - buy: number | null; + @ApiProperty({ description: 'CHF' }) + buy: number; - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when tradingUsers < k' }) - sell: number | null; + @ApiProperty({ description: 'CHF' }) + sell: number; - @ApiProperty({ type: Number, nullable: true, description: 'CHF; null when tradingUsers < k' }) - total: number | null; + @ApiProperty({ description: 'CHF' }) + total: number; } export class PartnerTransactionsByTypeDto { - @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) - buy: number | null; + @ApiProperty() + buy: number; - @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) - sell: number | null; + @ApiProperty() + sell: number; - @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) - swap: number | null; + @ApiProperty() + swap: number; - @ApiProperty({ type: Number, nullable: true, description: 'Null when the totals group is suppressed' }) - total: number | null; + @ApiProperty() + total: number; } export class PartnerTotalsDto { @ApiProperty({ type: PartnerVolumeByTypeDto, - description: 'Volume in CHF; entire group null when any direction is under the suppression threshold', + description: 'Volume in CHF, by trade direction', }) volume: PartnerVolumeByTypeDto; @@ -82,23 +72,23 @@ export class PartnerTotalsDto { @ApiProperty({ type: Number, nullable: true, - description: 'Average volume per transaction in CHF; null if no transactions or totals suppressed', + description: 'Average volume per transaction in CHF; null when there were no transactions', }) averageTransactionVolume: number | null; @ApiProperty({ type: Number, nullable: true, - description: 'Distinct users with ≥1 counted transaction in the period; null if 1..k-1', + description: 'Distinct users with ≥1 counted transaction in the period', }) - activeUsers: number | null; + activeUsers: number; @ApiProperty({ type: Number, nullable: true, - description: 'Users of this wallet created in the period; null if 1..k-1', + description: 'Users of this wallet created in the period', }) - newUsers: number | null; + newUsers: number; } export class PartnerAllTimeDto { @@ -114,9 +104,9 @@ export class PartnerAllTimeDto { @ApiProperty({ type: Number, nullable: true, - description: 'Users of this wallet with buyVolume > 0 or sellVolume > 0; null if 1..k-1', + description: 'Users of this wallet with buyVolume > 0 or sellVolume > 0', }) - tradingUsers: number | null; + tradingUsers: number; } // --- BREAKDOWN --- // @@ -167,40 +157,27 @@ export class PartnerBreakdownDto { export class PartnerReferralDto { @ApiProperty({ - type: Number, - nullable: true, - description: - 'Partner referral volume in EUR on the wallet owner’s account (all wallets of that owner). ' + - 'Null when tradingUsers < k (moves with individual customer trades).', + description: 'Partner referral volume in EUR on the wallet owner’s account (all wallets of that owner)', }) - volume: number | null; + volume: number; @ApiProperty({ - type: Number, - nullable: true, - description: - 'Partner referral credit earned (owner.partnerRefCredit only) in EUR, account-wide. ' + - 'Null when tradingUsers < k.', + description: 'Partner referral credit earned (owner.partnerRefCredit only) in EUR, account-wide', }) - creditEarned: number | null; + creditEarned: number; @ApiProperty({ - type: Number, - nullable: true, description: 'Referral credit already paid out (owner.paidRefCredit) in EUR, account-wide across both ' + - 'personal and partner pots. Null when tradingUsers < k.', + 'personal and partner pots', }) - creditPaid: number | null; + creditPaid: number; @ApiProperty({ - type: Number, - nullable: true, description: - 'Open referral credit in EUR: owner.refCredit + owner.partnerRefCredit − owner.paidRefCredit ' + - '(account-wide). Null when tradingUsers < k.', + 'Open referral credit in EUR: owner.refCredit + owner.partnerRefCredit − owner.paidRefCredit ' + '(account-wide)', }) - creditOpen: number | null; + creditOpen: number; @ApiProperty({ enum: ['EUR'], description: 'Native currency of the referral system' }) currency: 'EUR'; @@ -252,20 +229,17 @@ export class PartnerTimelineBucketDto { @ApiProperty({ type: PartnerTimelineByDirectionDto, nullable: true, - description: 'Volume in CHF; null when suppressed', + description: 'Volume in CHF, by trade direction', }) volume: PartnerTimelineByDirectionDto | null; @ApiProperty({ type: PartnerTimelineByDirectionDto, nullable: true, - description: 'Transaction counts; null when suppressed', + description: 'Transaction counts, by trade direction', }) transactions: PartnerTimelineByDirectionDto | null; - @ApiProperty({ description: 'True when values are withheld under the suppression threshold' }) - suppressed: boolean; - @ApiProperty({ description: 'True when the bucket’s natural range extends outside the requested period (edge week/month truncated by from/to)', diff --git a/src/subdomains/core/statistic/partner-statistic.enum.ts b/src/subdomains/core/statistic/partner-statistic.enum.ts index 3abe23857d..b3b45682f3 100644 --- a/src/subdomains/core/statistic/partner-statistic.enum.ts +++ b/src/subdomains/core/statistic/partner-statistic.enum.ts @@ -1,8 +1,5 @@ import { TransactionSourceType } from 'src/subdomains/supporting/payment/entities/transaction.entity'; -/** k-anonymity threshold: disclosure units need min(transactions, distinct users) ≥ k. */ -export const PARTNER_STATISTIC_SUPPRESSION_THRESHOLD = 5; - /** * Default lookback when `from`/`to` are omitted: this many inclusive UTC calendar days * ending on the resolved `to` day (half-open period after snap). diff --git a/src/subdomains/core/statistic/partner-statistic.service.ts b/src/subdomains/core/statistic/partner-statistic.service.ts index 1166eb2d73..097cccc5d2 100644 --- a/src/subdomains/core/statistic/partner-statistic.service.ts +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -14,7 +14,6 @@ import { BuyFiat } from '../sell-crypto/process/buy-fiat.entity'; import { PartnerAssetBreakdownDto, PartnerNamedBreakdownDto, - PartnerReferralDto, PartnerStatisticDto, PartnerTimelineBucketDto, PartnerTimelineDto, @@ -23,19 +22,11 @@ import { PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS, PARTNER_STATISTIC_MAX_PERIOD_DAYS, PARTNER_STATISTIC_QUERY_CONCURRENCY, - PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, PartnerPaymentMethodMap, PartnerStatisticDateTruncUnit, PartnerStatisticDirection, PartnerStatisticGranularity, } from './partner-statistic.enum'; -import { - suppressAllTimeVolume, - suppressBreakdownRows, - suppressPeriodTotals, - suppressScalar, - suppressTimelineBuckets, -} from './partner-statistic.suppression'; type Direction = PartnerStatisticDirection; @@ -158,68 +149,37 @@ export class PartnerStatisticService { swap: swapAgg.transactions, total: buyAgg.transactions + sellAgg.transactions + swapAgg.transactions, }; - const rawUsers = { - buy: buyAgg.users, - sell: sellAgg.users, - swap: swapAgg.users, - total: activeUsersRaw, - }; - const totalsSuppressed = suppressPeriodTotals(rawVolume, rawTransactions, rawUsers); const averageTransactionVolume = - totalsSuppressed.averageTransactionVolume != null - ? Util.round(totalsSuppressed.averageTransactionVolume, Config.defaultVolumeDecimal) + rawTransactions.total > 0 + ? Util.round(rawVolume.total / rawTransactions.total, Config.defaultVolumeDecimal) : null; - const tradingUsersSuppressed = suppressScalar(allTimeRaw.tradingUsers); - const allTimeSuppressed = suppressAllTimeVolume(allTimeRaw.volume, allTimeRaw.tradingUsers); - const referral = this.applyReferralSuppression(referralRaw, allTimeRaw.tradingUsers); - - const assets = suppressBreakdownRows(assetRows); - const fiatCurrencies = suppressBreakdownRows(fiatRows); - const blockchains = suppressBreakdownRows(blockchainRows); - const paymentMethods = suppressBreakdownRows(paymentMethodRows); - - const activeUsers = suppressScalar(activeUsersRaw); - const newUsers = suppressScalar(newUsersRaw); - - const suppressedCount = - assets.suppressedCount + - fiatCurrencies.suppressedCount + - blockchains.suppressedCount + - paymentMethods.suppressedCount + - totalsSuppressed.suppressedCount + - allTimeSuppressed.suppressedCount + - (activeUsers === null ? 1 : 0) + - (newUsers === null ? 1 : 0) + - (tradingUsersSuppressed === null ? 1 : 0) + - (referral.volume === null && referralRaw.volume !== 0 ? 1 : 0); - return { period, currency: 'CHF', totals: { - volume: totalsSuppressed.volume, - transactions: totalsSuppressed.transactions, + volume: rawVolume, + transactions: rawTransactions, averageTransactionVolume, - activeUsers, - newUsers, + activeUsers: activeUsersRaw, + newUsers: newUsersRaw, }, allTime: { - volume: allTimeSuppressed.volume, + volume: allTimeRaw.volume, registeredUsers: allTimeRaw.registeredUsers, - tradingUsers: tradingUsersSuppressed, + tradingUsers: allTimeRaw.tradingUsers, }, + // The internal `users` count per row stays server-side: it is a distinct-person figure the + // partner has no use for, and the DTO does not carry it. breakdown: { - assets: assets.rows.map(({ users: _u, ...row }) => row), - fiatCurrencies: fiatCurrencies.rows.map(({ users: _u, ...row }) => row), - blockchains: blockchains.rows.map(({ users: _u, ...row }) => row), - paymentMethods: paymentMethods.rows.map(({ users: _u, ...row }) => row), + assets: assetRows.map(({ users: _u, ...row }) => row), + fiatCurrencies: fiatRows.map(({ users: _u, ...row }) => row), + blockchains: blockchainRows.map(({ users: _u, ...row }) => row), + paymentMethods: paymentMethodRows.map(({ users: _u, ...row }) => row), }, - referral, + referral: { ...referralRaw, currency: 'EUR' }, meta: { - suppressionThreshold: PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, - suppressedCount, generatedAt: new Date(), }, }; @@ -264,20 +224,16 @@ export class PartnerStatisticService { ]); const filled = this.fillTimelineGaps(period.from, period.to, resolvedGranularity, buyRows, sellRows, swapRows); - const { buckets, suppressedCount } = suppressTimelineBuckets(filled); // Drop internal users field from the public payload. - const publicBuckets: PartnerTimelineBucketDto[] = buckets.map(({ users: _u, ...rest }) => rest); + const publicBuckets: PartnerTimelineBucketDto[] = filled.map(({ users: _u, ...rest }) => rest); return { period, currency: 'CHF', granularity: resolvedGranularity, buckets: publicBuckets, - meta: { - suppressionThreshold: PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, - suppressedCount, - }, + meta: {}, }; }); } @@ -525,26 +481,6 @@ export class PartnerStatisticService { }; } - private applyReferralSuppression( - raw: { volume: number; creditEarned: number; creditPaid: number; creditOpen: number }, - tradingUsers: number, - ): PartnerReferralDto { - // Referral balances move with individual customer trades. Gate them on tradingUsers so two - // successive polls cannot recover a single trade’s credit delta when the cohort is under k. - // These are the partner’s own account figures (owner-scoped), but the leakage path is the same. - // suppressScalar is null only for 1..k-1, so a separate `> 0` check is redundant. - if (suppressScalar(tradingUsers) === null) { - return { - volume: null, - creditEarned: null, - creditPaid: null, - creditOpen: null, - currency: 'EUR', - }; - } - return { ...raw, currency: 'EUR' }; - } - private async aggregateAssets( walletId: number, from: Date, @@ -771,7 +707,6 @@ export class PartnerStatisticService { volume: { buy: b.volume, sell: s.volume, swap: w.volume }, transactions: { buy: b.transactions, sell: s.transactions, swap: w.transactions }, users: { buy: b.users, sell: s.users, swap: w.users }, - suppressed: false, partial, }); @@ -890,7 +825,7 @@ export class PartnerStatisticService { /** * Coerce a SQL aggregate cell to a finite number. * COUNT(*)/COALESCE(SUM(...), 0) return 0 over an empty match set (a row is still returned). - * Non-numeric driver values must not become JSON `null` (which this API uses for suppression). + * Non-numeric driver values must not become JSON `null` — an absent number is data loss, not a zero. */ private toVolume(value: string | number | null | undefined): number { const n = +(value ?? 0); diff --git a/src/subdomains/core/statistic/partner-statistic.suppression.ts b/src/subdomains/core/statistic/partner-statistic.suppression.ts deleted file mode 100644 index 5938d0ef83..0000000000 --- a/src/subdomains/core/statistic/partner-statistic.suppression.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { - PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, - PartnerStatisticDirection, - PartnerStatisticDirectionField, -} from './partner-statistic.enum'; - -/** - * k-anonymity helpers for partner statistics. - * - * A disclosure unit is withheld when its effective count is in 1..k-1. - * The effective count is min(transactions, distinctUsers) when both are known; - * otherwise the transaction count alone. - * - * Additive groups (period totals by direction, timeline bucket directions) use - * block suppression: if any member is under the threshold, the entire group - * (members + derived sums/rates) is suppressed. Partial nulling would let - * totals − visible recover the hidden member exactly. - * - * Zero is never suppressed: 0 means “none”, null means “withheld”. - */ - -export function effectiveCount(transactions: number, users?: number): number { - if (users == null) return transactions; - return Math.min(transactions, users); -} - -export function isUnderThreshold( - transactions: number, - users?: number, - threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, -): boolean { - const n = effectiveCount(transactions, users); - return n > 0 && n < threshold; -} - -export function suppressScalar( - value: number, - threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, - users?: number, -): number | null { - if (value === 0) return 0; - return isUnderThreshold(value, users, threshold) ? null : value; -} - -export interface SuppressibleRow { - transactions: number; - /** Distinct users contributing to this row; when set, min(tx, users) is the gate. */ - users?: number; -} - -/** - * Removes rows with effective count in 1..k-1. If exactly one row is removed, also removes the - * smallest remaining filled row so never fewer than two are suppressed when complementary applies. - */ -export function suppressBreakdownRows( - rows: T[], - threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, -): { rows: T[]; suppressedCount: number } { - const visible = rows.filter((r) => !isUnderThreshold(r.transactions, r.users, threshold)); - let suppressedCount = rows.length - visible.length; - - if (suppressedCount === 1) { - // Complementary: suppress the smallest remaining filled row. SQL GROUP BY never emits - // 0-count groups, so a filled candidate exists whenever a non-empty visible set remains - // after dropping a single under-k row of real traffic. Zero-only remainders (synthetic - // fixtures) need no complementary partner — skip. - let smallest: T | undefined; - for (const r of visible) { - if (effectiveCount(r.transactions, r.users) <= 0) continue; - if ( - !smallest || - effectiveCount(r.transactions, r.users) <= effectiveCount(smallest.transactions, smallest.users) - ) { - smallest = r; - } - } - if (smallest) { - visible.splice(visible.indexOf(smallest), 1); - suppressedCount += 1; - } - } - - return { rows: visible, suppressedCount }; -} - -export interface TimelineSuppressible { - transactions: { buy: number; sell: number; swap: number } | null; - volume: { buy: number; sell: number; swap: number } | null; - /** Distinct users per direction (internal; not required on the public DTO). */ - users?: { buy: number; sell: number; swap: number } | null; - suppressed: boolean; -} - -const DIRECTIONS = [ - PartnerStatisticDirection.BUY, - PartnerStatisticDirection.SELL, - PartnerStatisticDirection.SWAP, -] as const; - -/** - * Nullifies volume/transactions when any direction is under the threshold (block rule), - * or when the combined effective total is under k. - * Empty (0-tx) buckets stay visible as zeros — they are not suppressed. - * Complementary: if exactly one real suppression, also suppress the smallest filled visible bucket. - */ -export function suppressTimelineBuckets( - buckets: T[], - threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, -): { buckets: T[]; suppressedCount: number } { - const result = buckets.map((b) => { - if (bucketNeedsSuppression(b, threshold)) { - return { ...b, volume: null, transactions: null, users: null, suppressed: true } as T; - } - return { ...b, suppressed: false }; - }); - - let suppressedCount = result.filter((b) => b.suppressed).length; - - if (suppressedCount === 1) { - const visible = result - .map((b, index) => ({ b, index, total: txTotal(b) })) - .filter((x) => !x.b.suppressed && x.total > 0); - - if (visible.length > 0) { - visible.sort((a, b) => a.total - b.total); - const target = visible[0].index; - result[target] = { - ...result[target], - volume: null, - transactions: null, - users: null, - suppressed: true, - } as T; - suppressedCount += 1; - } - } - - return { buckets: result, suppressedCount }; -} - -function bucketNeedsSuppression(b: TimelineSuppressible, threshold: number): boolean { - if (!b.transactions) return false; - const txs = b.transactions; - const users = b.users; - - // Per-direction gate (block rule for the bucket as an additive group). - for (const dir of DIRECTIONS) { - const field = PartnerStatisticDirectionField[dir]; - if (isUnderThreshold(txs[field], users?.[field], threshold)) return true; - } - - // Overall bucket total: if the whole day is under k when summed, hide it too. - const totalTx = txs.buy + txs.sell + txs.swap; - if (totalTx === 0) return false; - // Distinct users across directions can overlap; without a true UNION we use max of the - // per-direction user counts as a lower bound on the person count whenever a users object - // is present (callers always supply all three directions, including zeros). - if (users) { - const totalUsers = Math.max(users.buy, users.sell, users.swap); - return isUnderThreshold(totalTx, totalUsers, threshold); - } - return isUnderThreshold(totalTx, undefined, threshold); -} - -export interface DirectionTotals { - buy: number; - sell: number; - swap: number; - total: number; -} - -export interface DirectionUsers { - buy: number; - sell: number; - swap: number; - /** Distinct users across all directions (UNION), used for the total gate. */ - total: number; -} - -export interface SuppressedDirectionTotals { - buy: number | null; - sell: number | null; - swap: number | null; - total: number | null; -} - -/** - * Block-suppresses period totals: if any direction (or the overall total) is under k, - * every field is null — including derived average. No partial nulling of single directions. - */ -export function suppressPeriodTotals( - volume: DirectionTotals, - transactions: DirectionTotals, - users?: DirectionUsers, - threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, -): { - volume: SuppressedDirectionTotals; - transactions: SuppressedDirectionTotals; - averageTransactionVolume: number | null; - suppressedCount: number; -} { - const under = (dir: keyof DirectionTotals): boolean => isUnderThreshold(transactions[dir], users?.[dir], threshold); - - if (under('total') || under('buy') || under('sell') || under('swap')) { - return { - volume: { buy: null, sell: null, swap: null, total: null }, - transactions: { buy: null, sell: null, swap: null, total: null }, - averageTransactionVolume: null, - suppressedCount: 1, - }; - } - - const averageTransactionVolume = transactions.total > 0 ? volume.total / transactions.total : null; - - return { - volume: { ...volume }, - transactions: { ...transactions }, - averageTransactionVolume, - suppressedCount: 0, - }; -} - -export interface AllTimeVolume { - buy: number; - sell: number; - total: number; -} - -/** - * When tradingUsers is in 1..k-1, all-time volumes are withheld. registeredUsers stays visible - * (installation count, not a per-trade disclosure). - */ -export function suppressAllTimeVolume( - volume: AllTimeVolume, - tradingUsers: number, - threshold = PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, -): { volume: { buy: number | null; sell: number | null; total: number | null }; suppressedCount: number } { - if (isUnderThreshold(tradingUsers, undefined, threshold)) { - return { - volume: { buy: null, sell: null, total: null }, - suppressedCount: 1, - }; - } - return { volume, suppressedCount: 0 }; -} - -function txTotal(b: TimelineSuppressible): number { - if (b.suppressed || !b.transactions) return 0; - return b.transactions.buy + b.transactions.sell + b.transactions.swap; -} From dda8236e4cd20cb8d0af96435e771edf10770d3e Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Sun, 2 Aug 2026 18:48:02 +0200 Subject: [PATCH 4/9] Report the active-user count instead of asserting it is withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real-Postgres integration spec still expected `activeUsers` to be null for a cohort of three, which was the old threshold behaviour. It now asserts the set count itself — three, and explicitly not six, which is what a UNION ALL would produce. That is the property the test is named after; the withholding was incidental to it. This only fails against a real database, so it ran in CI and not in the local gate. --- .../statistic/__tests__/partner-statistic.integration.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts index 37085d2e2b..8b95f82684 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -409,9 +409,9 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { expect(count).toBe(3); expect(count).not.toBe(6); - // Public path: 3 under k → activeUsers withheld; UNION ALL would surface 6. + // Public path reports the same set: UNION ALL would surface 6. const result = await service.getStatistics(1, from, to); - expect(result.totals.activeUsers).toBeNull(); + expect(result.totals.activeUsers).toBe(3); expect(result.totals.activeUsers).not.toBe(6); }); From 927d9a66b00a8753727a311de7100f9c382881cf Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Sun, 2 Aug 2026 19:00:11 +0200 Subject: [PATCH 5/9] Drop the k-anonymity wording from the Swagger descriptions Two published field descriptions still described the removed threshold: lifetime volume promised "null fields when tradingUsers < k", and registeredUsers was labelled "always visible", which only meant anything in contrast to fields that were not. Neither is true any more, and both are read by partners in the API docs rather than in the code. Descriptions only; no field, type or behaviour changes. --- src/subdomains/core/statistic/dto/partner-statistic.dto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts index a7ef4c7104..26dd4c0038 100644 --- a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts +++ b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts @@ -94,11 +94,11 @@ export class PartnerTotalsDto { export class PartnerAllTimeDto { @ApiProperty({ type: PartnerVolumeBuySellDto, - description: 'Lifetime volume in CHF; null fields when tradingUsers < k', + description: 'Lifetime volume in CHF', }) volume: PartnerVolumeBuySellDto; - @ApiProperty({ description: 'Always visible (installation count, no transaction linkage)' }) + @ApiProperty({ description: 'Installation count; no transaction linkage' }) registeredUsers: number; @ApiProperty({ From bac4c187f26d7bb372f764c4d554e2191f076bfa Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 3 Aug 2026 10:31:51 +0200 Subject: [PATCH 6/9] Make the statistic tests prove what they claim, and drop what the threshold left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four assertions passed while the code underneath them was broken. The AML filter was pinned by the text of its clause but never by the value it binds, so swapping Pass for Pending — which would report unchecked traffic as passed volume — kept every test green. The concurrency budget and the maximum period were asserted against the very constants they were meant to guard, which holds for any value; raising the budget to 20 or the span to 36,600 days went unnoticed. And the UTC binding on DATE_TRUNC was only ever compared as a string, in a spec that refuses to run outside UTC — where both SQL forms behave alike. It now runs in a child process under Asia/Tokyo, which is the case that tells them apart: a timezone behind UTC renormalizes back to the same day and proves nothing. Removing the threshold also left dead weight. The per-row distinct-user count existed only to feed it, yet nine query builders still computed it on every request; the direction-field map lost its last consumer; and three fields still advertised a null the code can no longer produce. The unit-level guard against that count reaching the response went with the deleted helper, so it is back, without needing a database. --- .../partner-statistic-tz-check.script.ts | 98 ++++++++++++++ .../partner-statistic.integration.spec.ts | 109 ++++++++++++++++ .../partner-statistic.service.spec.ts | 120 ++++++++++++++++-- .../statistic/dto/partner-statistic.dto.ts | 9 +- .../core/statistic/partner-statistic.enum.ts | 13 -- .../statistic/partner-statistic.service.ts | 95 ++++---------- 6 files changed, 343 insertions(+), 101 deletions(-) create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts new file mode 100644 index 0000000000..46cc9c6d0c --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts @@ -0,0 +1,98 @@ +/** + * Standalone child-process helper for the M4 non-UTC-process-timezone check in + * partner-statistic.integration.spec.ts. + * + * WHY a child process instead of `process.env.TZ = '...'` inside the test: Node/V8 resolves the + * process-default timezone once per process and does not re-read `process.env.TZ` for later + * `Date`/`Intl` calls in this repo's Jest setup (verified empirically — a `process.env.TZ` + * mutation inside a `beforeAll` has no effect on `Date.prototype.getTimezoneOffset()` here, even + * as the very first Date operation in the process). A genuinely different process timezone only + * exists if the process is started with that `TZ` from the outset — hence this file is executed + * via `ts-node` as a fresh child process with `TZ` set in its env, never `require`d directly. + * + * Not a `*.spec.ts` file on purpose: Jest's testRegex only matches `.spec.ts`, so this is never + * picked up as a test itself — it is a one-shot script invoked by the real test via + * `child_process.execFileSync`. + */ +import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; +// Same fixture default jest-env.setup.ts provides for Jest-run specs — this script runs outside +// Jest (a plain ts-node child process), so ConfigService's fail-loud boot check needs it too. +if (!process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD) { + process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD = '0.05'; +} +import { ConfigService } from 'src/config/config'; +import { PartnerStatisticGranularity } from '../partner-statistic.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +@Entity({ name: 'user' }) +class TzCheckUser { + @PrimaryColumn() id: number; + @Column() walletId: number; + @Column({ type: 'timestamp' }) created: Date; +} + +@Entity({ name: 'buy' }) +class TzCheckBuy { + @PrimaryColumn() id: number; + @ManyToOne(() => TzCheckUser) + @JoinColumn({ name: 'userId' }) + user: TzCheckUser; +} + +@Entity({ name: 'buy_crypto' }) +class TzCheckBuyCrypto { + @PrimaryColumn() id: number; + @ManyToOne(() => TzCheckBuy, { nullable: true }) + @JoinColumn({ name: 'buyId' }) + buy?: TzCheckBuy; + @Column({ type: 'numeric', default: 0 }) amountInChf: number; + @Column({ type: 'varchar', length: 64, nullable: true }) amlCheck?: string; + @Column({ type: 'timestamp' }) created: Date; +} + +async function main(): Promise { + const pgUrl = process.env.MIGRATION_TEST_PG; + const schema = process.env.TZ_CHECK_SCHEMA; + if (!pgUrl || !schema) throw new Error('MIGRATION_TEST_PG and TZ_CHECK_SCHEMA must both be set'); + + new ConfigService(); + + const dataSource = new DataSource({ + type: 'postgres', + url: pgUrl, + entities: [TzCheckUser, TzCheckBuy, TzCheckBuyCrypto], + synchronize: false, + schema, + extra: { max: 1, options: '-c TimeZone=UTC' }, + }); + await dataSource.initialize(); + + const buyCryptoRepo = dataSource.getRepository(TzCheckBuyCrypto) as unknown as Repository; + const userRepo = dataSource.getRepository(TzCheckUser) as unknown as Repository; + // BUY-only check: buyFiatRepo/walletRepo are never touched by timelineByDirection(BUY, ...). + const service = new PartnerStatisticService(buyCryptoRepo as any, {} as any, userRepo as any, {} as any); + + // Calls the REAL private method (same bracket-access pattern the existing specs use) so a + // future edit to the trunc expression in partner-statistic.service.ts is actually exercised — + // this is not a parallel/hand-copied SQL string. + const map: Map = await (service as any).timelineByDirection( + 1, + new Date('2024-06-10T00:00:00.000Z'), + new Date('2024-06-12T00:00:00.000Z'), + 'Buy', + PartnerStatisticGranularity.DAY, + ); + + await dataSource.destroy(); + + // Only the bucket KEYS are the signal here (derived from the DATE_TRUNC(...) result). Some + // transitively-imported modules (SDK clients pulled in by production code) log their own + // "Logging enabled" lines to stdout on import, so the result is marked with a distinctive + // prefix — the parent process extracts exactly that line rather than parsing all of stdout. + process.stdout.write(`TZ_CHECK_RESULT:${JSON.stringify([...map.keys()].sort())}\n`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts index 8b95f82684..53ca8f67c0 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -1,3 +1,5 @@ +import { execFileSync } from 'child_process'; +import * as path from 'path'; import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; import { ConfigService } from 'src/config/config'; import { isProcessTimezoneUtcYearRound } from 'src/process-timezone'; @@ -481,3 +483,110 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { expect(typeof merged[0].volume).toBe('number'); }); }); + +// --- M4: AT TIME ZONE 'UTC' binding must hold even when the process is NOT UTC --- // + +/** + * `describeDb` above (and its `getTimeline` test) only runs under a UTC process timezone, + * which is exactly the condition under which a missing `AT TIME ZONE 'UTC'` binding would be + * invisible (process offset 0 makes both SQL forms return the same instant). This block is + * gated only on `PG_URL` — not on `isProcessTimezoneUtcYearRound()` — so it must genuinely + * exercise a non-UTC offset regardless of the ambient host/CI timezone. + * + * Verified empirically: mutating `process.env.TZ` inside a `beforeAll`/`it` does NOT change + * `Date.prototype.getTimezoneOffset()` for the rest of this Jest worker process (checked with a + * minimal repro — even as the very first Date operation in a fresh describe block, the change + * has no effect here). Node/V8 resolves the process-default timezone once and does not re-read + * `process.env.TZ` afterwards in this repo's Jest setup. A `process.env.TZ` assignment can + * therefore not be used to prove this property, and doing so would silently test nothing new + * whenever the ambient TZ under which Jest itself was launched happens to already be non-UTC + * (it would then "pass" while only ever exercising that ambient zone, never provably switching). + * + * The only reliable way to exercise a genuinely different process timezone is a fresh child + * process started with that `TZ` in its env from the beginning — see + * `partner-statistic-tz-check.script.ts`, executed here via `ts-node` so it runs the REAL + * `timelineByDirection` production code, not a hand-copied SQL string. + */ +const describePgAnyTz = PG_URL ? describe : describe.skip; + +describePgAnyTz('PartnerStatisticService timeline UTC binding under non-UTC process TZ (M4)', () => { + const TZ_SCHEMA = 'partner_statistic_tz_spec'; + const tz = (name: string) => `"${TZ_SCHEMA}"."${name}"`; + // Asia/Tokyo: fixed +09:00, no DST (deterministic). MUST be a zone AHEAD of UTC — a zone + // behind UTC (e.g. America/New_York, -04:00 in June) was tried first and turned out to be a + // false negative: misparsing a wall-clock midnight as a few hours *later* in the same UTC + // calendar day still collapses back to the same day once startOfBucket() re-truncates to UTC + // midnight, so the missing binding stayed invisible. A zone ahead of UTC misparses the same + // midnight as being on the *previous* UTC calendar day, which does change the bucket key — + // verified by hand against the raw driver output before relying on it here. + const CHECK_TZ = 'Asia/Tokyo'; + + let dataSource: DataSource; + + beforeAll(async () => { + new ConfigService(); + dataSource = new DataSource({ + type: 'postgres', + url: PG_URL, + entities: ENTITIES, + synchronize: false, + schema: TZ_SCHEMA, + extra: { max: 1, options: '-c TimeZone=UTC' }, + }); + await dataSource.initialize(); + + await dataSource.query(`DROP SCHEMA IF EXISTS "${TZ_SCHEMA}" CASCADE`); + await dataSource.query(`CREATE SCHEMA "${TZ_SCHEMA}"`); + await dataSource.query(` + CREATE TABLE ${tz('user')} ( "id" int PRIMARY KEY, "walletId" int NOT NULL, "created" TIMESTAMP NOT NULL DEFAULT NOW() ); + CREATE TABLE ${tz('buy')} ( "id" int PRIMARY KEY, "userId" int NOT NULL ); + CREATE TABLE ${tz('buy_crypto')} ( + "id" SERIAL PRIMARY KEY, + "buyId" int, + "amountInChf" numeric DEFAULT 0, + "amlCheck" varchar(64), + "created" TIMESTAMP NOT NULL + ); + `); + + // Wall-clock values stored as-is (timestamp without time zone) — these are the UTC + // instants the production Dockerfile (ENV TZ=UTC) would have written. + await dataSource.query(` + INSERT INTO ${tz('user')} ("id", "walletId", "created") VALUES (1, 1, '2024-06-01 10:00:00'); + INSERT INTO ${tz('buy')} ("id", "userId") VALUES (1, 1); + INSERT INTO ${tz('buy_crypto')} ("buyId", "amountInChf", "amlCheck", "created") + VALUES + (1, 100, 'Pass', '2024-06-10 10:00:00'), + (1, 100, 'Pass', '2024-06-11 10:00:00'); + `); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.query(`DROP SCHEMA IF EXISTS "${TZ_SCHEMA}" CASCADE`); + await dataSource.destroy(); + } + }); + + it('bucket keys stay on true UTC day boundaries in a child process running under Asia/Tokyo', () => { + const scriptPath = path.join(__dirname, 'partner-statistic-tz-check.script.ts'); + const stdout = execFileSync( + path.join(process.cwd(), 'node_modules', '.bin', 'ts-node'), + ['-T', '-r', 'tsconfig-paths/register', scriptPath], + { + cwd: process.cwd(), + env: { ...process.env, TZ: CHECK_TZ, MIGRATION_TEST_PG: PG_URL, TZ_CHECK_SCHEMA: TZ_SCHEMA }, + encoding: 'utf-8', + }, + ); + + const resultLine = stdout.split('\n').find((line) => line.startsWith('TZ_CHECK_RESULT:')); + if (!resultLine) throw new Error(`TZ check script produced no result line. stdout was:\n${stdout}`); + const bucketKeys: string[] = JSON.parse(resultLine.slice('TZ_CHECK_RESULT:'.length)); + + // Without `AT TIME ZONE 'UTC'`, the driver would parse the DATE_TRUNC'd `timestamp without + // time zone` result in process-local wall time (Asia/Tokyo, UTC+9) — midnight Tokyo time is + // the previous day in UTC, so the bucket key would land one calendar day too early. + expect(bucketKeys).toEqual(['2024-06-10T00:00:00', '2024-06-11T00:00:00']); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts index e43a2636d8..312b06708d 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from 'src/config/config'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { UserRepository } from 'src/subdomains/generic/user/models/user/user.repository'; @@ -121,6 +122,8 @@ describe('PartnerStatisticService', () => { let lastWalletIds: number[]; let whereClauses: string[]; let amlFilterClauses: string[]; + /** Bound `:check` value from every amlCheck clause (M1 — text match alone misses a swapped value). */ + let amlCheckValues: unknown[]; /** Bound `{ from, to }` from every where/andWhere that supplies period params. */ let periodBoundParams: { from: unknown; to: unknown }[]; let groupByCapture: GroupByCapture; @@ -273,6 +276,7 @@ describe('PartnerStatisticService', () => { capturePeriodParams(params); if (clauseStr.includes('amlCheck')) { amlFilterClauses.push(clauseStr); + amlCheckValues.push(params && typeof params === 'object' ? (params as { check?: unknown }).check : undefined); } return self(); }); @@ -421,6 +425,7 @@ describe('PartnerStatisticService', () => { lastWalletIds = []; whereClauses = []; amlFilterClauses = []; + amlCheckValues = []; periodBoundParams = []; fixtures = new Map(); groupByCapture = { groupBys: [], selectAliases: [] }; @@ -502,6 +507,21 @@ describe('PartnerStatisticService', () => { expect(() => service.resolvePeriod(from, to)).not.toThrow(); }); + // M3: the two tests above derive their input from the constant they are supposed to pin + // (MAX ± N) — they would stay green even if PARTNER_STATISTIC_MAX_PERIOD_DAYS were bumped + // to 36600. Pin literal, constant-independent day counts at the exact boundary instead. + it('rejects a literal 367-day span regardless of the configured max (M3)', () => { + const from = new Date(TEST_NOW.getTime() - 366 * 24 * 3600 * 1000); + const to = TEST_NOW; + expect(() => service.resolvePeriod(from, to)).toThrow(BadRequestException); + }); + + it('accepts a literal 366-day span regardless of the configured max (M3)', () => { + const from = new Date(TEST_NOW.getTime() - 365 * 24 * 3600 * 1000); + const to = TEST_NOW; + expect(() => service.resolvePeriod(from, to)).not.toThrow(); + }); + it('accepts a single full day', () => { const period = service.resolvePeriod('2024-06-01T00:00:00.000Z', '2024-06-01T23:59:59.000Z'); expect(period.from.toISOString()).toBe('2024-06-01T00:00:00.000Z'); @@ -752,6 +772,9 @@ describe('PartnerStatisticService', () => { const AML_EXACT_RE = /^tx\.amlCheck\s*=\s*:check$/; expect(amlFilterClauses).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.AML_FILTERS); expect(amlFilterClauses.every((c) => AML_EXACT_RE.test(c))).toBe(true); + // M1: clause TEXT alone would still match `:check` bound to Pending — pin the bound value too. + expect(amlCheckValues).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.AML_FILTERS); + expect(amlCheckValues.every((v) => v === CheckStatus.PASS)).toBe(true); // Filtered pins (20/17/18) miss an extra unscoped WHERE that matches none of the three // exact regexes. Total pin + UNION shape (getQuery + from SQL) close that gap. @@ -918,7 +941,7 @@ describe('PartnerStatisticService', () => { // --- mergeNamedRows / breakdown pipeline --- // describe('mergeNamedRows and breakdown pipeline', () => { - it('merges same-name rows across directions and takes Math.max of users', async () => { + it('merges same-name rows across directions and sums volume/transactions', async () => { fixtures.set( 1, emptyFixture({ @@ -940,23 +963,20 @@ describe('PartnerStatisticService', () => { expect(result.breakdown.fiatCurrencies.length + result.breakdown.blockchains.length).toBeGreaterThan(0); const merged = service.mergeNamedRows([ - { name: 'BTC', volume: 200, transactions: 10, users: 6 }, - { name: 'BTC', volume: 100, transactions: 5, users: 4 }, - { name: 'ETH', volume: 50, transactions: 5, users: 5 }, + { name: 'BTC', volume: 200, transactions: 10 }, + { name: 'BTC', volume: 100, transactions: 5 }, + { name: 'ETH', volume: 50, transactions: 5 }, ]); expect(merged.find((r) => r.name === 'BTC')?.volume).toBe(300); expect(merged.find((r) => r.name === 'BTC')?.transactions).toBe(15); - // Math.max(6, 4) = 6 — Math.min would yield 4 and fail this - expect(merged.find((r) => r.name === 'BTC')?.users).toBe(6); - expect(merged.find((r) => r.name === 'BTC')?.users).not.toBe(4); expect(merged).toHaveLength(2); }); it('drops nameless rows from the breakdown payload', () => { const merged = service.mergeNamedRows([ - { name: null, volume: 999, transactions: 50, users: 20 }, - { name: '', volume: 100, transactions: 5, users: 3 }, - { name: 'BTC', volume: 200, transactions: 10, users: 6 }, + { name: null, volume: 999, transactions: 50 }, + { name: '', volume: 100, transactions: 5 }, + { name: 'BTC', volume: 200, transactions: 10 }, ]); expect(merged).toHaveLength(1); expect(merged[0].name).toBe('BTC'); @@ -993,7 +1013,7 @@ describe('PartnerStatisticService', () => { // --- Timeline collision accumulation (WEEK/MONTH re-bucket of day-truncated SQL rows) --- // describe('timeline bucket key collision accumulation', () => { - it('sums volume/transactions and takes max(users) when two day rows fall into the same week', async () => { + it('sums volume/transactions when two day rows fall into the same week', async () => { // Monday + Wednesday of ISO week 2024-06-10..16 → same WEEK key after startOfBucket. fixtures.set( 1, @@ -1017,7 +1037,6 @@ describe('PartnerStatisticService', () => { const entry = [...map.values()][0]; expect(entry.volume).toBe(150); expect(entry.transactions).toBe(17); - expect(entry.users).toBe(8); // Not last-write-wins (would be 7/50/8) and not first-write-wins (10/100/5). expect(entry.transactions).not.toBe(7); expect(entry.transactions).not.toBe(10); @@ -1194,6 +1213,10 @@ describe('PartnerStatisticService', () => { expect(maxConcurrentQueries).toBeGreaterThan(1); expect(maxConcurrentQueries).toBeLessThanOrEqual(PARTNER_STATISTIC_QUERY_CONCURRENCY); + // M2: the assertion above is tautological against the constant it is supposed to pin + // (bumping PARTNER_STATISTIC_QUERY_CONCURRENCY to 20 would still pass it). Pin the + // literal budget independently so a change to the constant is itself caught. + expect(maxConcurrentQueries).toBeLessThanOrEqual(4); }); }); @@ -1267,4 +1290,77 @@ describe('PartnerStatisticService', () => { }); }); }); + + // --- Part 3: unit-level leak guard for the internal `users` field --- // + + /** Recursively collects every own property key across arrays/objects (order/duplicates irrelevant). */ + function collectKeys(value: unknown, out: Set = new Set()): Set { + if (Array.isArray(value)) { + for (const item of value) collectKeys(item, out); + } else if (value !== null && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + out.add(k); + collectKeys(v, out); + } + } + return out; + } + + describe('response payload never leaks the internal users field', () => { + it('getStatistics response has no "users" key anywhere in its (serialized) shape', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 500, transactions: 20, users: 10 }, + sell: { volume: 300, transactions: 10, users: 8 }, + swap: { volume: 50, transactions: 5, users: 3 }, + allTime: { buy: 500, sell: 300, registeredUsers: 20, tradingUsers: 10 }, + newUsers: 8, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8], + namedRows: [ + { name: 'BTC', blockchain: 'Bitcoin', volume: 200, transactions: 10, users: 6 }, + { name: 'BTC', blockchain: 'Bitcoin', volume: 100, transactions: 5, users: 4 }, + { name: 'ETH', blockchain: 'Ethereum', volume: 50, transactions: 5, users: 5 }, + { name: 'CHF', volume: 400, transactions: 15, users: 10 }, + ], + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + // Fixture must actually exercise non-empty breakdown rows — otherwise this test would + // pass vacuously on an empty payload that never had a `users` key to begin with. + expect( + result.breakdown.assets.length + + result.breakdown.fiatCurrencies.length + + result.breakdown.blockchains.length + + result.breakdown.paymentMethods.length, + ).toBeGreaterThan(0); + + const keys = collectKeys(JSON.parse(JSON.stringify(result))); + expect(keys.has('users')).toBe(false); + }); + + it('getTimeline response has no "users" key anywhere in its (serialized) shape', async () => { + fixtures.set( + 1, + emptyFixture({ + timelineRows: [ + { bucket: new Date('2024-06-10T00:00:00.000Z'), volume: 100, transactions: 5, users: 3 }, + { bucket: new Date('2024-06-11T00:00:00.000Z'), volume: 50, transactions: 2, users: 2 }, + ], + }), + ); + + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-11T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + expect(result.buckets.length).toBeGreaterThan(0); + + const keys = collectKeys(JSON.parse(JSON.stringify(result))); + expect(keys.has('users')).toBe(false); + }); + }); }); diff --git a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts index 26dd4c0038..7291bed33f 100644 --- a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts +++ b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts @@ -78,14 +78,12 @@ export class PartnerTotalsDto { @ApiProperty({ type: Number, - nullable: true, description: 'Distinct users with ≥1 counted transaction in the period', }) activeUsers: number; @ApiProperty({ type: Number, - nullable: true, description: 'Users of this wallet created in the period', }) newUsers: number; @@ -103,7 +101,6 @@ export class PartnerAllTimeDto { @ApiProperty({ type: Number, - nullable: true, description: 'Users of this wallet with buyVolume > 0 or sellVolume > 0', }) tradingUsers: number; @@ -228,17 +225,15 @@ export class PartnerTimelineBucketDto { @ApiProperty({ type: PartnerTimelineByDirectionDto, - nullable: true, description: 'Volume in CHF, by trade direction', }) - volume: PartnerTimelineByDirectionDto | null; + volume: PartnerTimelineByDirectionDto; @ApiProperty({ type: PartnerTimelineByDirectionDto, - nullable: true, description: 'Transaction counts, by trade direction', }) - transactions: PartnerTimelineByDirectionDto | null; + transactions: PartnerTimelineByDirectionDto; @ApiProperty({ description: diff --git a/src/subdomains/core/statistic/partner-statistic.enum.ts b/src/subdomains/core/statistic/partner-statistic.enum.ts index b3b45682f3..458b5f48bf 100644 --- a/src/subdomains/core/statistic/partner-statistic.enum.ts +++ b/src/subdomains/core/statistic/partner-statistic.enum.ts @@ -28,19 +28,6 @@ export enum PartnerStatisticDirection { SWAP = 'Swap', } -/** - * Maps partner-facing direction enums to the camelCase DTO field names used on - * volume / transactions / users objects (`buy` / `sell` / `swap`). Explicit so enum - * values (PascalCase API contract) stay decoupled from JSON property names. - */ -export const PartnerStatisticDirectionField: { - readonly [K in PartnerStatisticDirection]: 'buy' | 'sell' | 'swap'; -} = { - [PartnerStatisticDirection.BUY]: 'buy', - [PartnerStatisticDirection.SELL]: 'sell', - [PartnerStatisticDirection.SWAP]: 'swap', -}; - /** Postgres `DATE_TRUNC` unit for each granularity (API values are PascalCase). */ export const PartnerStatisticDateTruncUnit: { readonly [K in PartnerStatisticGranularity]: 'day' | 'week' | 'month'; diff --git a/src/subdomains/core/statistic/partner-statistic.service.ts b/src/subdomains/core/statistic/partner-statistic.service.ts index 097cccc5d2..edc6bd46bc 100644 --- a/src/subdomains/core/statistic/partner-statistic.service.ts +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -33,7 +33,6 @@ type Direction = PartnerStatisticDirection; interface AggregateRow { volume: string | number | null; transactions: string | number | null; - users: string | number | null; } interface NamedAggregateRow extends AggregateRow { @@ -45,13 +44,11 @@ interface TimelineRawRow { bucket: Date | string; volume: string | number | null; transactions: string | number | null; - users: string | number | null; } interface DirectionAgg { volume: number; transactions: number; - users: number; } /** @@ -170,13 +167,11 @@ export class PartnerStatisticService { registeredUsers: allTimeRaw.registeredUsers, tradingUsers: allTimeRaw.tradingUsers, }, - // The internal `users` count per row stays server-side: it is a distinct-person figure the - // partner has no use for, and the DTO does not carry it. breakdown: { - assets: assetRows.map(({ users: _u, ...row }) => row), - fiatCurrencies: fiatRows.map(({ users: _u, ...row }) => row), - blockchains: blockchainRows.map(({ users: _u, ...row }) => row), - paymentMethods: paymentMethodRows.map(({ users: _u, ...row }) => row), + assets: assetRows, + fiatCurrencies: fiatRows, + blockchains: blockchainRows, + paymentMethods: paymentMethodRows, }, referral: { ...referralRaw, currency: 'EUR' }, meta: { @@ -225,15 +220,12 @@ export class PartnerStatisticService { const filled = this.fillTimelineGaps(period.from, period.to, resolvedGranularity, buyRows, sellRows, swapRows); - // Drop internal users field from the public payload. - const publicBuckets: PartnerTimelineBucketDto[] = filled.map(({ users: _u, ...rest }) => rest); - return { period, currency: 'CHF', granularity: resolvedGranularity, - buckets: publicBuckets, - meta: {}, + buckets: filled, + meta: { generatedAt: new Date() }, }; }); } @@ -360,15 +352,12 @@ export class PartnerStatisticService { direction: Direction, ): Promise { const qb = this.baseTxQuery(direction, walletId, from, to); - qb.select('COALESCE(SUM(tx.amountInChf), 0)', 'volume') - .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users'); + qb.select('COALESCE(SUM(tx.amountInChf), 0)', 'volume').addSelect('COUNT(*)', 'transactions'); const raw = await this.runQuery(() => qb.getRawOne()); return { volume: this.toVolume(raw?.volume), transactions: this.toCount(raw?.transactions), - users: this.toCount(raw?.users), }; } @@ -481,11 +470,7 @@ export class PartnerStatisticService { }; } - private async aggregateAssets( - walletId: number, - from: Date, - to: Date, - ): Promise<(PartnerAssetBreakdownDto & { users: number })[]> { + private async aggregateAssets(walletId: number, from: Date, to: Date): Promise { const [buy, sell, swap] = await this.runAll([ () => this.assetQuery(PartnerStatisticDirection.BUY, walletId, from, to), () => this.assetQuery(PartnerStatisticDirection.SELL, walletId, from, to), @@ -500,7 +485,7 @@ export class PartnerStatisticService { walletId: number, from: Date, to: Date, - ): Promise<(PartnerAssetBreakdownDto & { users: number })[]> { + ): Promise { const qb = this.baseTxQuery(direction, walletId, from, to); if (direction === PartnerStatisticDirection.SELL) { @@ -511,7 +496,6 @@ export class PartnerStatisticService { .addSelect('inputAsset.blockchain', 'blockchain') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('tx.inputAsset') .addGroupBy('inputAsset.blockchain'); } else { @@ -520,7 +504,6 @@ export class PartnerStatisticService { .addSelect('outputAsset.blockchain', 'blockchain') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('outputAsset.name') .addGroupBy('outputAsset.blockchain'); } @@ -534,21 +517,15 @@ export class PartnerStatisticService { direction, volume: this.toVolume(r.volume), transactions: this.toCount(r.transactions), - users: this.toCount(r.users), })); } - private async aggregateFiatCurrencies( - walletId: number, - from: Date, - to: Date, - ): Promise<(PartnerNamedBreakdownDto & { users: number })[]> { + private async aggregateFiatCurrencies(walletId: number, from: Date, to: Date): Promise { // Buy: inputAsset is the fiat ticker. Sell: outputAsset is Fiat. Swap has no fiat leg. const buyQb = this.baseTxQuery(PartnerStatisticDirection.BUY, walletId, from, to) .select('tx.inputAsset', 'name') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('tx.inputAsset'); const sellQb = this.baseTxQuery(PartnerStatisticDirection.SELL, walletId, from, to) @@ -556,7 +533,6 @@ export class PartnerStatisticService { .select('fiat.name', 'name') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('fiat.name'); const [buyRows, sellRows] = await this.runAll([ @@ -567,11 +543,7 @@ export class PartnerStatisticService { return this.mergeNamedRows([...buyRows, ...sellRows]); } - private async aggregateBlockchains( - walletId: number, - from: Date, - to: Date, - ): Promise<(PartnerNamedBreakdownDto & { users: number })[]> { + private async aggregateBlockchains(walletId: number, from: Date, to: Date): Promise { const queries = ([PartnerStatisticDirection.BUY, PartnerStatisticDirection.SWAP] as Direction[]).map( (direction) => () => this.runQuery(() => @@ -580,7 +552,6 @@ export class PartnerStatisticService { .select('outputAsset.blockchain', 'name') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('outputAsset.blockchain') .getRawMany(), ), @@ -594,7 +565,6 @@ export class PartnerStatisticService { .select('inputAsset.blockchain', 'name') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('inputAsset.blockchain') .getRawMany(), ); @@ -603,11 +573,7 @@ export class PartnerStatisticService { return this.mergeNamedRows(rows); } - private async aggregatePaymentMethods( - walletId: number, - from: Date, - to: Date, - ): Promise<(PartnerNamedBreakdownDto & { users: number })[]> { + private async aggregatePaymentMethods(walletId: number, from: Date, to: Date): Promise { const rows = ( await this.runAll( ( @@ -620,7 +586,6 @@ export class PartnerStatisticService { .select('transaction.sourceType', 'name') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy('transaction.sourceType') .getRawMany(), ), @@ -642,7 +607,7 @@ export class PartnerStatisticService { to: Date, direction: Direction, granularity: PartnerStatisticGranularity, - ): Promise> { + ): Promise> { // Truncate on the stored wall clock (UTC values in TIMESTAMP without TZ), then tag the // result as UTC so the driver returns an unambiguous absolute instant. Session TimeZone // must not shift buckets — DATE_TRUNC on timestamp without tz is field-only; AT TIME ZONE @@ -653,12 +618,11 @@ export class PartnerStatisticService { .select(trunc, 'bucket') .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') .addSelect('COUNT(*)', 'transactions') - .addSelect('COUNT(DISTINCT user.id)', 'users') .groupBy(trunc) .orderBy(trunc, 'ASC'); const rows = await this.runQuery(() => qb.getRawMany()); - const map = new Map(); + const map = new Map(); for (const row of rows) { // UTC keys on both sides (SQL buckets + fill loop). Period bounds are UTC-normalized in // resolvePeriod; this module deliberately does not follow support-issue.service local @@ -666,15 +630,12 @@ export class PartnerStatisticService { const key = this.bucketKey(this.startOfBucket(new Date(row.bucket), granularity)); const volume = this.toVolume(row.volume); const transactions = this.toCount(row.transactions); - const users = this.toCount(row.users); const existing = map.get(key); if (existing) { existing.volume = Util.round(existing.volume + volume, Config.defaultVolumeDecimal); existing.transactions += transactions; - // users can double-count on key collision; take max as a lower-bound person estimate - existing.users = Math.max(existing.users, users); } else { - map.set(key, { volume, transactions, users }); + map.set(key, { volume, transactions }); } } return map; @@ -684,20 +645,20 @@ export class PartnerStatisticService { from: Date, to: Date, granularity: PartnerStatisticGranularity, - buy: Map, - sell: Map, - swap: Map, - ): (PartnerTimelineBucketDto & { users: { buy: number; sell: number; swap: number } })[] { - const buckets: (PartnerTimelineBucketDto & { users: { buy: number; sell: number; swap: number } })[] = []; + buy: Map, + sell: Map, + swap: Map, + ): PartnerTimelineBucketDto[] { + const buckets: PartnerTimelineBucketDto[] = []; let cursor = this.startOfBucket(from, granularity); // `to` is exclusive (half-open period). const end = to.getTime(); while (cursor.getTime() < end) { const key = this.bucketKey(cursor); - const b = buy.get(key) ?? { volume: 0, transactions: 0, users: 0 }; - const s = sell.get(key) ?? { volume: 0, transactions: 0, users: 0 }; - const w = swap.get(key) ?? { volume: 0, transactions: 0, users: 0 }; + const b = buy.get(key) ?? { volume: 0, transactions: 0 }; + const s = sell.get(key) ?? { volume: 0, transactions: 0 }; + const w = swap.get(key) ?? { volume: 0, transactions: 0 }; const next = this.addBucket(cursor, granularity); // Edge buckets whose natural range extends outside [from, to) are partial. const partial = cursor.getTime() < from.getTime() || next.getTime() > to.getTime(); @@ -706,7 +667,6 @@ export class PartnerStatisticService { date: new Date(cursor), volume: { buy: b.volume, sell: s.volume, swap: w.volume }, transactions: { buy: b.transactions, sell: s.transactions, swap: w.transactions }, - users: { buy: b.users, sell: s.users, swap: w.users }, partial, }); @@ -800,22 +760,19 @@ export class PartnerStatisticService { } /** Exposed for tests that exercise mergeNamedRows / breakdown mapping without full SQL. */ - mergeNamedRows(rows: NamedAggregateRow[]): (PartnerNamedBreakdownDto & { users: number })[] { - const map = new Map(); + mergeNamedRows(rows: NamedAggregateRow[]): PartnerNamedBreakdownDto[] { + const map = new Map(); for (const row of rows) { if (!row.name) continue; const existing = map.get(row.name); const volume = this.toVolume(row.volume); const transactions = this.toCount(row.transactions); - const users = this.toCount(row.users); if (existing) { existing.volume = Util.round(existing.volume + volume, Config.defaultVolumeDecimal); existing.transactions += transactions; - // Person floor across partial GROUP BY chunks: max, never min (min under-counts). - existing.users = Math.max(existing.users, users); } else { - map.set(row.name, { name: row.name, volume, transactions, users }); + map.set(row.name, { name: row.name, volume, transactions }); } } From 08192ccdfa550a510626868e9432951587d78bfe Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 3 Aug 2026 10:42:49 +0200 Subject: [PATCH 7/9] Pass the test schema as an argument instead of through the environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timezone check handed its Postgres schema name to the child process in an environment variable, which made it look like configuration: the PR gate flagged it as an undocumented setting, and documenting it in .env.example would have been worse — it is never set by a person, only by the spec for its own child. It travels as a CLI argument now, and the missing-argument error says what was expected. MIGRATION_TEST_PG stays in the environment. That one really is a developer's switch, and it is already documented. --- .../__tests__/partner-statistic-tz-check.script.ts | 13 +++++++++++-- .../__tests__/partner-statistic.integration.spec.ts | 7 +++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts index 46cc9c6d0c..5c1f8dc2f3 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts @@ -14,6 +14,7 @@ * picked up as a test itself — it is a one-shot script invoked by the real test via * `child_process.execFileSync`. */ +import * as path from 'path'; import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; // Same fixture default jest-env.setup.ts provides for Jest-run specs — this script runs outside // Jest (a plain ts-node child process), so ConfigService's fail-loud boot check needs it too. @@ -52,8 +53,16 @@ class TzCheckBuyCrypto { async function main(): Promise { const pgUrl = process.env.MIGRATION_TEST_PG; - const schema = process.env.TZ_CHECK_SCHEMA; - if (!pgUrl || !schema) throw new Error('MIGRATION_TEST_PG and TZ_CHECK_SCHEMA must both be set'); + // Schema name is a CLI argument, not an env var: it is never a developer-facing switch — + // the parent spec invents and passes it purely to give this one-shot child process its own + // isolated schema. Documenting it in .env.example would misrepresent it as configuration. + const schema = process.argv[2]; + if (!pgUrl) throw new Error('MIGRATION_TEST_PG must be set'); + if (!schema) { + throw new Error( + `Expected the Postgres schema name as the first CLI argument (e.g. "ts-node ${path.basename(__filename)} my_schema"), got none.`, + ); + } new ConfigService(); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts index 53ca8f67c0..93cb630452 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -570,12 +570,15 @@ describePgAnyTz('PartnerStatisticService timeline UTC binding under non-UTC proc it('bucket keys stay on true UTC day boundaries in a child process running under Asia/Tokyo', () => { const scriptPath = path.join(__dirname, 'partner-statistic-tz-check.script.ts'); + // TZ_SCHEMA is passed as a CLI argument, not an env var: it is never a developer-facing + // switch (unlike MIGRATION_TEST_PG, which a person sets to opt into the Postgres suite) — + // it only exists so this one-shot child process gets its own isolated schema name. const stdout = execFileSync( path.join(process.cwd(), 'node_modules', '.bin', 'ts-node'), - ['-T', '-r', 'tsconfig-paths/register', scriptPath], + ['-T', '-r', 'tsconfig-paths/register', scriptPath, TZ_SCHEMA], { cwd: process.cwd(), - env: { ...process.env, TZ: CHECK_TZ, MIGRATION_TEST_PG: PG_URL, TZ_CHECK_SCHEMA: TZ_SCHEMA }, + env: { ...process.env, TZ: CHECK_TZ, MIGRATION_TEST_PG: PG_URL }, encoding: 'utf-8', }, ); From 191a289b507cf363f3e6e0cd9568e97696184508 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 3 Aug 2026 14:15:32 +0200 Subject: [PATCH 8/9] Let a partner's own staff read their statistics without the wallet key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading partner statistics required the ClientCompany role, which exists only on a token minted by signing with the partner's wallet key. That put the wallet's master secret in the hands of whoever wanted to look at a chart. A new Partner role, granted to individual users the way Support is, lets them sign in normally and see their own wallet's figures. The wallet is resolved from the role rather than taken from the token, because the JWT field `user` means two different things: the wallet id on a company token, the user id on a normal one. Passing it through for a normal login would have handed out whichever wallet happens to carry that number — a different partner's business figures. The role alone does not settle it either: a user record can carry a company role, and a normal login would then present a user id under a company role. Company tokens never set `account` and normal tokens always do, so that contradiction is refused rather than guessed at. A user without a wallet is refused too. Reporting zero would have looked like a partner with no business rather than a caller with no business here. The query budget still belongs to the wallet, not to the person, so two colleagues at the same partner share one allowance instead of each opening their own. --- src/shared/auth/role.guard.ts | 4 + src/shared/auth/user-role.enum.ts | 2 + ...partner-statistic-rate-limit.guard.spec.ts | 48 +++++++--- .../partner-statistic-resolve-wallet.spec.ts | 86 +++++++++++++++++ .../partner-statistic.controller.spec.ts | 95 ++++++++++++++++--- .../partner-statistic-rate-limit.guard.ts | 40 ++++++-- .../statistic/partner-statistic.controller.ts | 10 +- .../statistic/partner-statistic.service.ts | 42 +++++++- .../core/statistic/statistic.module.ts | 3 + 9 files changed, 293 insertions(+), 37 deletions(-) create mode 100644 src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts diff --git a/src/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index cc77a9e99c..56dc94192d 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -18,6 +18,7 @@ const additionalRoles: Partial> = { UserRole.SUPPORT, UserRole.COMPLIANCE, UserRole.MARKETING, + UserRole.PARTNER, UserRole.REALUNIT, ], [UserRole.USER]: [ @@ -29,12 +30,15 @@ const additionalRoles: Partial> = { UserRole.SUPPORT, UserRole.COMPLIANCE, UserRole.MARKETING, + UserRole.PARTNER, UserRole.REALUNIT, ], [UserRole.VIP]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BETA]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.SUPPORT]: [UserRole.COMPLIANCE, UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.MARKETING]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], + // Partner is a normal-login role (not staff): keep hierarchy like MARKETING, not StaffRoles/KycGatedRoles. + [UserRole.PARTNER]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.COMPLIANCE]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BANKING_BOT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.REALUNIT]: [UserRole.COMPLIANCE, UserRole.ADMIN, UserRole.SUPER_ADMIN], diff --git a/src/shared/auth/user-role.enum.ts b/src/shared/auth/user-role.enum.ts index 23e1569ead..354483c16d 100644 --- a/src/shared/auth/user-role.enum.ts +++ b/src/shared/auth/user-role.enum.ts @@ -11,6 +11,8 @@ export enum UserRole { CUSTODY = 'Custody', REALUNIT = 'RealUnit', MARKETING = 'Marketing', + // Partner-wallet employees: normal login token (jwt.user = user id), not company-token (jwt.user = wallet id). + PARTNER = 'Partner', DEBUG = 'Debug', // service roles diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts index 8183a7ef3a..db6798632f 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts @@ -1,29 +1,45 @@ import { ThrottlerGuard } from '@nestjs/throttler'; import { Config, ConfigService } from 'src/config/config'; +import { UserRole } from 'src/shared/auth/user-role.enum'; import { PartnerStatisticRateLimitGuard } from '../partner-statistic-rate-limit.guard'; +import { PartnerStatisticService } from '../partner-statistic.service'; describe('PartnerStatisticRateLimitGuard', () => { beforeAll(() => new ConfigService()); - const guard = new PartnerStatisticRateLimitGuard({} as any, {} as any, {} as any); + const resolveWalletId = jest.fn(); + const partnerStatisticService = { resolveWalletId } as unknown as PartnerStatisticService; + const guard = new PartnerStatisticRateLimitGuard({} as any, {} as any, {} as any, partnerStatisticService); const getTracker = (req: Record): string => guard['getTracker'](req); - it('keys by jwt.user (wallet id) when present', () => { - expect(getTracker({ user: { user: 42 }, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:42'); - expect(getTracker({ user: { user: 99 }, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:99'); - expect(getTracker({ user: { user: 42 }, realIp: '1.2.3.4' })).not.toBe( - getTracker({ user: { user: 99 }, realIp: '1.2.3.4' }), + beforeEach(() => { + resolveWalletId.mockReset(); + }); + + it('keys by resolved partnerStatWalletId when present', () => { + expect(getTracker({ partnerStatWalletId: 42, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:42'); + expect(getTracker({ partnerStatWalletId: 99, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:99'); + expect(getTracker({ partnerStatWalletId: 42, realIp: '1.2.3.4' })).not.toBe( + getTracker({ partnerStatWalletId: 99, realIp: '1.2.3.4' }), ); }); it('does not share a bucket across wallets on the same IP', () => { - const a = getTracker({ user: { user: 1 }, realIp: '185.12.34.56' }); - const b = getTracker({ user: { user: 2 }, realIp: '185.12.34.56' }); + const a = getTracker({ partnerStatWalletId: 1, realIp: '185.12.34.56' }); + const b = getTracker({ partnerStatWalletId: 2, realIp: '185.12.34.56' }); expect(a).not.toEqual(b); }); - it('throws plain Error when jwt.user is missing (fail-closed, no IP fallback)', () => { + it('does not key by jwt.user alone (PARTNER user id must not be the tracker)', () => { + // Without partnerStatWalletId, even if jwt.user is set, fail closed — otherwise two + // employees of the same wallet would get separate budgets keyed by user id. + expect(() => getTracker({ user: { user: 42, role: UserRole.PARTNER }, realIp: '1.2.3.4' })).toThrow( + /authenticated wallet/, + ); + }); + + it('throws plain Error when partnerStatWalletId is missing (fail-closed, no IP fallback)', () => { expect(() => getTracker({ realIp: '9.9.9.9' })).toThrow(Error); expect(() => getTracker({ realIp: '9.9.9.9' })).toThrow(/authenticated wallet/); expect(() => getTracker({})).toThrow(Error); @@ -43,21 +59,31 @@ describe('PartnerStatisticRateLimitGuard', () => { try { const result = await guard.handleRequest({} as any, 120, 3600); expect(result).toBe(true); + expect(resolveWalletId).not.toHaveBeenCalled(); } finally { Config.request.limitCheck = prev; } }); - it('delegates to ThrottlerGuard when limitCheck is true', async () => { + it('resolves wallet id and keys by it when limitCheck is true', async () => { const prev = Config.request.limitCheck; Config.request.limitCheck = true; + resolveWalletId.mockResolvedValue(7); + const req: Record = { + user: { user: 99, role: UserRole.PARTNER }, + }; // handleRequest is protected on ThrottlerGuard — cast for the spy. const superSpy = jest .spyOn(ThrottlerGuard.prototype as unknown as { handleRequest: typeof guard.handleRequest }, 'handleRequest') .mockResolvedValue(true); try { - const ctx = { switchToHttp: () => ({ getRequest: () => ({}) }) } as any; + const ctx = { switchToHttp: () => ({ getRequest: () => req }) } as any; const result = await guard.handleRequest(ctx, 120, 3600); + expect(resolveWalletId).toHaveBeenCalledWith(req.user); + expect(req.partnerStatWalletId).toBe(7); + // Two PARTNER employees of wallet 7 share the same tracker key. + expect(getTracker(req)).toBe('partner-stat:wallet:7'); + expect(getTracker(req)).not.toBe('partner-stat:wallet:99'); expect(superSpy).toHaveBeenCalledWith(ctx, 120, 3600); expect(result).toBe(true); } finally { diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts new file mode 100644 index 0000000000..a9d89ddaf0 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts @@ -0,0 +1,86 @@ +import { ForbiddenException } from '@nestjs/common'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +/** + * Unit tests for role-aware wallet resolution — the tenant boundary between + * company tokens (jwt.user = wallet id) and PARTNER tokens (jwt.user = user id). + */ +describe('PartnerStatisticService.resolveWalletId', () => { + const findOne = jest.fn(); + const userRepo = { findOne } as any; + const service = new PartnerStatisticService({} as any, {} as any, userRepo, {} as any); + + beforeEach(() => { + findOne.mockReset(); + }); + + it('CLIENT_COMPANY: returns jwt.user as wallet id without loading a user', async () => { + // Real company token: no account field (generateCompanyToken). + const jwt = { user: 42, role: UserRole.CLIENT_COMPANY } as JwtPayload; + await expect(service.resolveWalletId(jwt)).resolves.toBe(42); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('KYC_CLIENT_COMPANY: same as CLIENT_COMPANY (hierarchy super-role)', async () => { + const jwt = { user: 15, role: UserRole.KYC_CLIENT_COMPANY } as JwtPayload; + await expect(service.resolveWalletId(jwt)).resolves.toBe(15); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('CLIENT_COMPANY with account (user token): rejects — does not treat user id as wallet id', async () => { + // Malicious / contradictory: normal login token (account set) whose role was stored as + // CLIENT_COMPANY on the user row. jwt.user is a user id that equals a foreign wallet id. + const jwt = { user: 99, account: 5, role: UserRole.CLIENT_COMPANY } as JwtPayload; + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.resolveWalletId(jwt)).rejects.toThrow(/company token/i); + // Must not fall through to PARTNER lookup, and must not return 99 as wallet id. + expect(findOne).not.toHaveBeenCalled(); + }); + + it('KYC_CLIENT_COMPANY with account (user token): same reject', async () => { + const jwt = { user: 99, account: 5, role: UserRole.KYC_CLIENT_COMPANY } as JwtPayload; + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('PARTNER: loads user and returns their wallet id — not jwt.user', async () => { + // Critical tenant case: user id 99 equals foreign wallet 99; own wallet is 7. + const jwt = { user: 99, role: UserRole.PARTNER } as JwtPayload; + findOne.mockResolvedValue({ id: 99, wallet: { id: 7 } }); + + await expect(service.resolveWalletId(jwt)).resolves.toBe(7); + + expect(findOne).toHaveBeenCalledWith({ where: { id: 99 }, relations: { wallet: true } }); + await expect(service.resolveWalletId(jwt)).resolves.not.toBe(99); + }); + + it('PARTNER: rejects when user has no wallet (Forbidden, not silent 0)', async () => { + const jwt = { user: 5, role: UserRole.PARTNER } as JwtPayload; + findOne.mockResolvedValue({ id: 5, wallet: null }); + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.resolveWalletId(jwt)).rejects.toThrow(/no wallet/i); + }); + + it('PARTNER: rejects when user is missing', async () => { + const jwt = { user: 5, role: UserRole.PARTNER } as JwtPayload; + findOne.mockResolvedValue(null); + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('USER (no permission): rejects', async () => { + const jwt = { user: 1, role: UserRole.USER } as JwtPayload; + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('CLIENT_COMPANY without jwt.user: rejects', async () => { + const jwt = { role: UserRole.CLIENT_COMPANY } as JwtPayload; + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts index eca6fc60f9..08abf6f144 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts @@ -1,9 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ForbiddenException } from '@nestjs/common'; import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; import { RequestMethod } from '@nestjs/common/enums'; import { AuthGuard } from '@nestjs/passport'; import { THROTTLER_LIMIT, THROTTLER_TTL } from '@nestjs/throttler/dist/throttler.constants'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { PartnerStatisticRateLimitGuard } from '../partner-statistic-rate-limit.guard'; import { PartnerStatisticController } from '../partner-statistic.controller'; @@ -12,50 +14,104 @@ import { PartnerStatisticService } from '../partner-statistic.service'; /** * Pins the partner-statistic controller wiring: guard order, throttle budget, - * walletId from jwt.user, and the Day granularity default on the timeline route. + * role-aware wallet resolution, and the Day granularity default on the timeline route. * Pattern mirrors ledger.controller + bank.controller metadata specs. */ describe('PartnerStatisticController', () => { let controller: PartnerStatisticController; let service: DeepMocked; - const jwt = { user: 42, role: UserRole.CLIENT_COMPANY, account: 7 } as JwtPayload; + const companyJwt = { user: 42, role: UserRole.CLIENT_COMPANY, account: 7 } as JwtPayload; beforeEach(() => { service = createMock(); + // Default: resolve mirrors CLIENT_COMPANY (jwt.user is wallet id) unless a test overrides. + service.resolveWalletId.mockImplementation(async (jwt) => jwt.user as number); controller = new PartnerStatisticController(service); }); - it('forwards jwt.user as walletId to getStatistics (not account)', async () => { + it('CLIENT_COMPANY: resolves wallet from jwt and forwards to getStatistics (not account)', async () => { service.getStatistics.mockResolvedValue({ currency: 'CHF' } as any); + service.resolveWalletId.mockResolvedValue(42); - await controller.getPartnerStatistics(jwt, '2024-06-01', '2024-06-15'); + await controller.getPartnerStatistics(companyJwt, '2024-06-01', '2024-06-15'); + expect(service.resolveWalletId).toHaveBeenCalledWith(companyJwt); expect(service.getStatistics).toHaveBeenCalledWith(42, '2024-06-01', '2024-06-15'); expect(service.getStatistics).not.toHaveBeenCalledWith(7, expect.anything(), expect.anything()); }); - it('forwards jwt.user and optional granularity to getTimeline', async () => { + it('CLIENT_COMPANY: forwards resolved wallet and optional granularity to getTimeline', async () => { service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.WEEK } as any); + service.resolveWalletId.mockResolvedValue(42); - await controller.getPartnerTimeline(jwt, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); + await controller.getPartnerTimeline(companyJwt, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); + expect(service.resolveWalletId).toHaveBeenCalledWith(companyJwt); expect(service.getTimeline).toHaveBeenCalledWith(42, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); }); it('forwards undefined granularity to getTimeline when the query omits it', async () => { service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.DAY } as any); + service.resolveWalletId.mockResolvedValue(42); - await controller.getPartnerTimeline(jwt, undefined, undefined, undefined); + await controller.getPartnerTimeline(companyJwt, undefined, undefined, undefined); expect(service.getTimeline).toHaveBeenCalledWith(42, undefined, undefined, undefined); }); + + /** + * Tenant isolation (the real failure mode): a PARTNER user whose user id equals a *foreign* + * wallet id must still only see their own wallet — never treat jwt.user as walletId. + */ + it('PARTNER: never uses jwt.user as walletId even when user id equals a foreign wallet id', async () => { + const foreignWalletId = 99; + const ownWalletId = 7; + // jwt.user === foreignWalletId is exactly the cross-tenant trap if resolution is skipped. + const partnerJwt = { + user: foreignWalletId, + role: UserRole.PARTNER, + account: 1, + } as JwtPayload; + + service.resolveWalletId.mockResolvedValue(ownWalletId); + service.getStatistics.mockResolvedValue({ currency: 'CHF' } as any); + service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.DAY } as any); + + await controller.getPartnerStatistics(partnerJwt, '2024-06-01', '2024-06-15'); + await controller.getPartnerTimeline(partnerJwt, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.DAY); + + expect(service.resolveWalletId).toHaveBeenCalledWith(partnerJwt); + expect(service.getStatistics).toHaveBeenCalledWith(ownWalletId, '2024-06-01', '2024-06-15'); + expect(service.getStatistics).not.toHaveBeenCalledWith(foreignWalletId, expect.anything(), expect.anything()); + expect(service.getTimeline).toHaveBeenCalledWith( + ownWalletId, + '2024-06-01', + '2024-06-15', + PartnerStatisticGranularity.DAY, + ); + expect(service.getTimeline).not.toHaveBeenCalledWith( + foreignWalletId, + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('rejects when resolveWalletId rejects (e.g. user has no wallet)', async () => { + service.resolveWalletId.mockRejectedValue(new ForbiddenException('User has no wallet')); + + await expect(controller.getPartnerStatistics(companyJwt, undefined, undefined)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(service.getStatistics).not.toHaveBeenCalled(); + }); }); // Call-path tests above pass JWT in directly and cannot see the decorators that decide // whether a request is authenticated, role-checked, or rate-limited. Removing @UseGuards -// or swapping RoleGuard(CLIENT_COMPANY) for a weaker role would leave them green while -// every non-partner wallet reached the service. +// or swapping RoleGuard for a weaker role would leave them green while every non-partner +// wallet reached the service. describe('PartnerStatisticController routing & security metadata', () => { const endpoints: Array<{ handler: 'getPartnerStatistics' | 'getPartnerTimeline'; @@ -76,7 +132,7 @@ describe('PartnerStatisticController routing & security metadata', () => { }); it.each(endpoints)( - 'guards $handler with AuthGuard → RoleGuard(CLIENT_COMPANY) → PartnerStatisticRateLimitGuard', + 'guards $handler with AuthGuard → RoleGuard(CLIENT_COMPANY|PARTNER) → PartnerStatisticRateLimitGuard', ({ handler }) => { const fn = PartnerStatisticController.prototype[handler]; const guards = Reflect.getMetadata(GUARDS_METADATA, fn) as unknown[]; @@ -88,12 +144,12 @@ describe('PartnerStatisticController routing & security metadata', () => { // (bank.controller pattern: exact class-token equality, not constructor.name). expect(guards[0]).toBe(AuthGuard()); - // RoleGuard is the only instance-based guard; it must carry CLIENT_COMPANY. + // RoleGuard is the only instance-based guard; it must admit CLIENT_COMPANY or PARTNER. const roleGuard = guards.find((g) => (g as { entryRoles?: UserRole[] }).entryRoles !== undefined) as { entryRoles: UserRole[]; }; expect(roleGuard).toBeDefined(); - expect(roleGuard.entryRoles).toEqual([UserRole.CLIENT_COMPANY]); + expect(roleGuard.entryRoles).toEqual([UserRole.CLIENT_COMPANY, UserRole.PARTNER]); expect((roleGuard as { constructor: { name: string } }).constructor.name).toBe('RoleGuardClass'); expect(guards[1]).toBe(roleGuard); @@ -108,4 +164,19 @@ describe('PartnerStatisticController routing & security metadata', () => { expect(Reflect.getMetadata(THROTTLER_LIMIT, fn)).toBe(120); expect(Reflect.getMetadata(THROTTLER_TTL, fn)).toBe(3600); }); + + it('RoleGuard admits CLIENT_COMPANY and PARTNER, rejects plain USER', () => { + // Metadata alone does not execute RoleGuard; pin the OR semantics the decorator encodes. + const guard = RoleGuard(UserRole.CLIENT_COMPANY, UserRole.PARTNER); + const contextFor = (role: UserRole) => + ({ + switchToHttp: () => ({ getRequest: () => ({ user: { role, account: 1 } }) }), + }) as any; + + expect(guard.canActivate(contextFor(UserRole.CLIENT_COMPANY))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.PARTNER))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.KYC_CLIENT_COMPANY))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.USER))).toBe(false); + expect(guard.canActivate(contextFor(UserRole.SUPPORT))).toBe(false); + }); }); diff --git a/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts index 33c6f4aa98..3cc7219475 100644 --- a/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts +++ b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts @@ -1,30 +1,52 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; -import { ThrottlerGuard } from '@nestjs/throttler'; +import { Reflector } from '@nestjs/core'; +import { + InjectThrottlerOptions, + InjectThrottlerStorage, + ThrottlerGuard, + ThrottlerModuleOptions, + ThrottlerStorage, +} from '@nestjs/throttler'; import { Config } from 'src/config/config'; +import { PartnerStatisticService } from './partner-statistic.service'; /** * Wallet-scoped rate limit for partner statistic routes. * * The shared RateLimitGuard keys by IP prefix and bypasses known/Azure IPs, so it does not * bound repeated scrapes by a single partner JWT. These routes authenticate first (AuthGuard), - * then count by `jwt.user` (wallet id). There is no other user/wallet tracker in the repo. + * then count by the **resolved** wallet id (not raw `jwt.user`: for PARTNER that field is a + * user id). Staff of the same wallet therefore share one budget. */ @Injectable() export class PartnerStatisticRateLimitGuard extends ThrottlerGuard { + constructor( + @InjectThrottlerOptions() options: ThrottlerModuleOptions, + @InjectThrottlerStorage() storageService: ThrottlerStorage, + reflector: Reflector, + private readonly partnerStatisticService: PartnerStatisticService, + ) { + super(options, storageService, reflector); + } + protected getTracker(req: Record): string { - const walletId = req.user?.user; + const walletId = req.partnerStatWalletId; if (walletId != null) return `partner-stat:wallet:${walletId}`; - // Unreachable when this guard is ordered after AuthGuard + RoleGuard(CLIENT_COMPANY): - // jwt.user is set. Falling back to IP would silently weaken the budget (NAT share-out or - // no useful key) — fail closed instead of pretending rate limiting still works. - // This throw is also unreachable while REQUEST_LIMIT_CHECK is not true: handleRequest - // returns true before super.handleRequest, so getTracker is never called — fail-closed - // stands or falls with the same switch as the budget itself. + // partnerStatWalletId is set in handleRequest before super.handleRequest. Falling back to + // jwt.user would key PARTNER traffic by user id (separate budgets per employee) or worse + // treat a user id as a wallet id. Fail closed instead. + // Unreachable while REQUEST_LIMIT_CHECK is not true: handleRequest returns true before + // super.handleRequest, so getTracker is never called — fail-closed stands or falls with + // the same switch as the budget itself. throw new Error('Partner statistic rate limit requires an authenticated wallet'); } async handleRequest(context: ExecutionContext, limit: number, ttl: number): Promise { if (!Config.request.limitCheck) return true; + + const req = context.switchToHttp().getRequest(); + // Same resolveWalletId as the controller — budget hangs on the wallet, not the JWT subject. + req.partnerStatWalletId = await this.partnerStatisticService.resolveWalletId(req.user); return super.handleRequest(context, limit, ttl); } } diff --git a/src/subdomains/core/statistic/partner-statistic.controller.ts b/src/subdomains/core/statistic/partner-statistic.controller.ts index 2387560ec4..085f5c079c 100644 --- a/src/subdomains/core/statistic/partner-statistic.controller.ts +++ b/src/subdomains/core/statistic/partner-statistic.controller.ts @@ -18,7 +18,7 @@ export class PartnerStatisticController { @Get('partner') @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY), PartnerStatisticRateLimitGuard) + @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY, UserRole.PARTNER), PartnerStatisticRateLimitGuard) // 120 req/h per wallet: dashboard auto-refresh (~1/min) for summary + headroom @Throttle(120, 3600) @ApiOkResponse({ type: PartnerStatisticDto }) @@ -38,12 +38,13 @@ export class PartnerStatisticController { @Query('from') from?: string, @Query('to') to?: string, ): Promise { - return this.partnerStatisticService.getStatistics(jwt.user, from, to); + const walletId = await this.partnerStatisticService.resolveWalletId(jwt); + return this.partnerStatisticService.getStatistics(walletId, from, to); } @Get('partner/timeline') @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY), PartnerStatisticRateLimitGuard) + @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY, UserRole.PARTNER), PartnerStatisticRateLimitGuard) // 120 req/h per wallet: same budget as summary so a dual-widget dashboard can refresh without 429s @Throttle(120, 3600) @ApiOkResponse({ type: PartnerTimelineDto }) @@ -70,6 +71,7 @@ export class PartnerStatisticController { @Query('to') to?: string, @Query('granularity') granularity?: PartnerStatisticGranularity, ): Promise { - return this.partnerStatisticService.getTimeline(jwt.user, from, to, granularity); + const walletId = await this.partnerStatisticService.resolveWalletId(jwt); + return this.partnerStatisticService.getTimeline(walletId, from, to, granularity); } } diff --git a/src/subdomains/core/statistic/partner-statistic.service.ts b/src/subdomains/core/statistic/partner-statistic.service.ts index edc6bd46bc..3fb905a404 100644 --- a/src/subdomains/core/statistic/partner-statistic.service.ts +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -1,6 +1,9 @@ import { AsyncLocalStorage } from 'async_hooks'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { Config } from 'src/config/config'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; import { Util } from 'src/shared/utils/util'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; @@ -104,6 +107,43 @@ export class PartnerStatisticService { // --- PUBLIC API --- // + /** + * Resolve the wallet whose statistics the caller may read. + * + * The JWT field `user` means two different things: for a company token it is the wallet id + * (auth.service.ts generateCompanyToken), for a normal token it is the user id + * (generateUserToken). Returning it blindly for a normal login would hand out another + * wallet's id and cross tenants. + */ + async resolveWalletId(jwt: JwtPayload): Promise { + if (hasRoleAccess(UserRole.CLIENT_COMPANY, jwt.role)) { + // Role alone is not enough: generateUserToken passes user.role through unchanged and always + // sets account; generateCompanyToken never sets account and puts wallet.id in user. A user + // record carrying a company role + normal login would otherwise treat the user id as a wallet id. + if (jwt.account != null) { + throw new ForbiddenException('Company role requires a company token'); + } + if (jwt.user == null) throw new ForbiddenException('Partner wallet required'); + return jwt.user; + } + + if (hasRoleAccess(UserRole.PARTNER, jwt.role)) { + if (jwt.user == null) throw new ForbiddenException('Partner wallet required'); + + const user = await this.userRepo.findOne({ + where: { id: jwt.user }, + relations: { wallet: true }, + }); + const walletId = user?.wallet?.id; + // 403 not 404: caller is authenticated; without a wallet they are not allowed to see partner stats. + // Silent 0 / fallback would either empty-out or (worse) treat the user id as a wallet id. + if (walletId == null) throw new ForbiddenException('User has no wallet'); + return walletId; + } + + throw new ForbiddenException('Insufficient permissions'); + } + async getStatistics(walletId: number, from?: string | Date, to?: string | Date): Promise { return this.withQueryGate(async () => { const period = this.resolvePeriod(from, to); diff --git a/src/subdomains/core/statistic/statistic.module.ts b/src/subdomains/core/statistic/statistic.module.ts index 3ec612ec44..ce7ae91eb3 100644 --- a/src/subdomains/core/statistic/statistic.module.ts +++ b/src/subdomains/core/statistic/statistic.module.ts @@ -8,6 +8,7 @@ import { UserModule } from 'src/subdomains/generic/user/user.module'; import { BuyCryptoModule } from '../buy-crypto/buy-crypto.module'; import { ReferralModule } from '../referral/referral.module'; import { SellCryptoModule } from '../sell-crypto/sell-crypto.module'; +import { PartnerStatisticRateLimitGuard } from './partner-statistic-rate-limit.guard'; import { PartnerStatisticController } from './partner-statistic.controller'; import { PartnerStatisticService } from './partner-statistic.service'; import { StatisticController } from './statistic.controller'; @@ -19,6 +20,8 @@ import { StatisticService } from './statistic.service'; providers: [ StatisticService, PartnerStatisticService, + // DI for PartnerStatisticService in the rate-limit guard (wallet-scoped tracker). + PartnerStatisticRateLimitGuard, // BuyCryptoRepository is exported by BuyCryptoModule (already imported) — do not re-provide. // BuyFiatRepository is not in SellCryptoModule.exports — provide locally. BuyFiatRepository, From 96b06f8a049994872c36dc81a6e7f5cb4083e47b Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 3 Aug 2026 17:27:39 +0200 Subject: [PATCH 9/9] Name the role after what it describes `Partner` said too little for a value that lives in the database and in every token: the programme is about non-custodial wallet partners, and the role now says so. Nothing is deployed and nobody holds the role outside a local test database, so the rename costs nothing today and would cost every signed-in user a silent 403 later. Only the role moved. `FeeType.PARTNER`, the referral columns and everything named PartnerStatistic describe different things and keep their names. --- src/shared/auth/role.guard.ts | 8 ++++---- src/shared/auth/user-role.enum.ts | 4 ++-- .../partner-statistic-rate-limit.guard.spec.ts | 12 ++++++------ .../partner-statistic-resolve-wallet.spec.ts | 16 ++++++++-------- .../partner-statistic.controller.spec.ts | 18 +++++++++--------- .../partner-statistic-rate-limit.guard.ts | 4 ++-- .../statistic/partner-statistic.controller.ts | 12 ++++++++++-- .../statistic/partner-statistic.service.ts | 2 +- 8 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index 56dc94192d..115a682119 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -18,7 +18,7 @@ const additionalRoles: Partial> = { UserRole.SUPPORT, UserRole.COMPLIANCE, UserRole.MARKETING, - UserRole.PARTNER, + UserRole.NON_CUSTODIAL_WALLET_PARTNER, UserRole.REALUNIT, ], [UserRole.USER]: [ @@ -30,15 +30,15 @@ const additionalRoles: Partial> = { UserRole.SUPPORT, UserRole.COMPLIANCE, UserRole.MARKETING, - UserRole.PARTNER, + UserRole.NON_CUSTODIAL_WALLET_PARTNER, UserRole.REALUNIT, ], [UserRole.VIP]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BETA]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.SUPPORT]: [UserRole.COMPLIANCE, UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.MARKETING]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], - // Partner is a normal-login role (not staff): keep hierarchy like MARKETING, not StaffRoles/KycGatedRoles. - [UserRole.PARTNER]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], + // NonCustodialWalletPartner is a normal-login role (not staff): keep hierarchy like MARKETING, not StaffRoles/KycGatedRoles. + [UserRole.NON_CUSTODIAL_WALLET_PARTNER]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.COMPLIANCE]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BANKING_BOT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.REALUNIT]: [UserRole.COMPLIANCE, UserRole.ADMIN, UserRole.SUPER_ADMIN], diff --git a/src/shared/auth/user-role.enum.ts b/src/shared/auth/user-role.enum.ts index 354483c16d..98ecfe5249 100644 --- a/src/shared/auth/user-role.enum.ts +++ b/src/shared/auth/user-role.enum.ts @@ -11,8 +11,8 @@ export enum UserRole { CUSTODY = 'Custody', REALUNIT = 'RealUnit', MARKETING = 'Marketing', - // Partner-wallet employees: normal login token (jwt.user = user id), not company-token (jwt.user = wallet id). - PARTNER = 'Partner', + // NonCustodialWalletPartner employees: normal login token (jwt.user = user id), not company-token (jwt.user = wallet id). + NON_CUSTODIAL_WALLET_PARTNER = 'NonCustodialWalletPartner', DEBUG = 'Debug', // service roles diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts index db6798632f..37f97a753f 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts @@ -31,12 +31,12 @@ describe('PartnerStatisticRateLimitGuard', () => { expect(a).not.toEqual(b); }); - it('does not key by jwt.user alone (PARTNER user id must not be the tracker)', () => { + it('does not key by jwt.user alone (NON_CUSTODIAL_WALLET_PARTNER user id must not be the tracker)', () => { // Without partnerStatWalletId, even if jwt.user is set, fail closed — otherwise two // employees of the same wallet would get separate budgets keyed by user id. - expect(() => getTracker({ user: { user: 42, role: UserRole.PARTNER }, realIp: '1.2.3.4' })).toThrow( - /authenticated wallet/, - ); + expect(() => + getTracker({ user: { user: 42, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER }, realIp: '1.2.3.4' }), + ).toThrow(/authenticated wallet/); }); it('throws plain Error when partnerStatWalletId is missing (fail-closed, no IP fallback)', () => { @@ -70,7 +70,7 @@ describe('PartnerStatisticRateLimitGuard', () => { Config.request.limitCheck = true; resolveWalletId.mockResolvedValue(7); const req: Record = { - user: { user: 99, role: UserRole.PARTNER }, + user: { user: 99, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER }, }; // handleRequest is protected on ThrottlerGuard — cast for the spy. const superSpy = jest @@ -81,7 +81,7 @@ describe('PartnerStatisticRateLimitGuard', () => { const result = await guard.handleRequest(ctx, 120, 3600); expect(resolveWalletId).toHaveBeenCalledWith(req.user); expect(req.partnerStatWalletId).toBe(7); - // Two PARTNER employees of wallet 7 share the same tracker key. + // Two NON_CUSTODIAL_WALLET_PARTNER employees of wallet 7 share the same tracker key. expect(getTracker(req)).toBe('partner-stat:wallet:7'); expect(getTracker(req)).not.toBe('partner-stat:wallet:99'); expect(superSpy).toHaveBeenCalledWith(ctx, 120, 3600); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts index a9d89ddaf0..6d925f2d29 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts @@ -5,7 +5,7 @@ import { PartnerStatisticService } from '../partner-statistic.service'; /** * Unit tests for role-aware wallet resolution — the tenant boundary between - * company tokens (jwt.user = wallet id) and PARTNER tokens (jwt.user = user id). + * company tokens (jwt.user = wallet id) and NON_CUSTODIAL_WALLET_PARTNER tokens (jwt.user = user id). */ describe('PartnerStatisticService.resolveWalletId', () => { const findOne = jest.fn(); @@ -36,7 +36,7 @@ describe('PartnerStatisticService.resolveWalletId', () => { await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); await expect(service.resolveWalletId(jwt)).rejects.toThrow(/company token/i); - // Must not fall through to PARTNER lookup, and must not return 99 as wallet id. + // Must not fall through to NON_CUSTODIAL_WALLET_PARTNER lookup, and must not return 99 as wallet id. expect(findOne).not.toHaveBeenCalled(); }); @@ -47,9 +47,9 @@ describe('PartnerStatisticService.resolveWalletId', () => { expect(findOne).not.toHaveBeenCalled(); }); - it('PARTNER: loads user and returns their wallet id — not jwt.user', async () => { + it('NON_CUSTODIAL_WALLET_PARTNER: loads user and returns their wallet id — not jwt.user', async () => { // Critical tenant case: user id 99 equals foreign wallet 99; own wallet is 7. - const jwt = { user: 99, role: UserRole.PARTNER } as JwtPayload; + const jwt = { user: 99, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER } as JwtPayload; findOne.mockResolvedValue({ id: 99, wallet: { id: 7 } }); await expect(service.resolveWalletId(jwt)).resolves.toBe(7); @@ -58,16 +58,16 @@ describe('PartnerStatisticService.resolveWalletId', () => { await expect(service.resolveWalletId(jwt)).resolves.not.toBe(99); }); - it('PARTNER: rejects when user has no wallet (Forbidden, not silent 0)', async () => { - const jwt = { user: 5, role: UserRole.PARTNER } as JwtPayload; + it('NON_CUSTODIAL_WALLET_PARTNER: rejects when user has no wallet (Forbidden, not silent 0)', async () => { + const jwt = { user: 5, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER } as JwtPayload; findOne.mockResolvedValue({ id: 5, wallet: null }); await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); await expect(service.resolveWalletId(jwt)).rejects.toThrow(/no wallet/i); }); - it('PARTNER: rejects when user is missing', async () => { - const jwt = { user: 5, role: UserRole.PARTNER } as JwtPayload; + it('NON_CUSTODIAL_WALLET_PARTNER: rejects when user is missing', async () => { + const jwt = { user: 5, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER } as JwtPayload; findOne.mockResolvedValue(null); await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts index 08abf6f144..0935484679 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts @@ -61,16 +61,16 @@ describe('PartnerStatisticController', () => { }); /** - * Tenant isolation (the real failure mode): a PARTNER user whose user id equals a *foreign* + * Tenant isolation (the real failure mode): a NON_CUSTODIAL_WALLET_PARTNER user whose user id equals a *foreign* * wallet id must still only see their own wallet — never treat jwt.user as walletId. */ - it('PARTNER: never uses jwt.user as walletId even when user id equals a foreign wallet id', async () => { + it('NON_CUSTODIAL_WALLET_PARTNER: never uses jwt.user as walletId even when user id equals a foreign wallet id', async () => { const foreignWalletId = 99; const ownWalletId = 7; // jwt.user === foreignWalletId is exactly the cross-tenant trap if resolution is skipped. const partnerJwt = { user: foreignWalletId, - role: UserRole.PARTNER, + role: UserRole.NON_CUSTODIAL_WALLET_PARTNER, account: 1, } as JwtPayload; @@ -132,7 +132,7 @@ describe('PartnerStatisticController routing & security metadata', () => { }); it.each(endpoints)( - 'guards $handler with AuthGuard → RoleGuard(CLIENT_COMPANY|PARTNER) → PartnerStatisticRateLimitGuard', + 'guards $handler with AuthGuard → RoleGuard(CLIENT_COMPANY|NON_CUSTODIAL_WALLET_PARTNER) → PartnerStatisticRateLimitGuard', ({ handler }) => { const fn = PartnerStatisticController.prototype[handler]; const guards = Reflect.getMetadata(GUARDS_METADATA, fn) as unknown[]; @@ -144,12 +144,12 @@ describe('PartnerStatisticController routing & security metadata', () => { // (bank.controller pattern: exact class-token equality, not constructor.name). expect(guards[0]).toBe(AuthGuard()); - // RoleGuard is the only instance-based guard; it must admit CLIENT_COMPANY or PARTNER. + // RoleGuard is the only instance-based guard; it must admit CLIENT_COMPANY or NON_CUSTODIAL_WALLET_PARTNER. const roleGuard = guards.find((g) => (g as { entryRoles?: UserRole[] }).entryRoles !== undefined) as { entryRoles: UserRole[]; }; expect(roleGuard).toBeDefined(); - expect(roleGuard.entryRoles).toEqual([UserRole.CLIENT_COMPANY, UserRole.PARTNER]); + expect(roleGuard.entryRoles).toEqual([UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER]); expect((roleGuard as { constructor: { name: string } }).constructor.name).toBe('RoleGuardClass'); expect(guards[1]).toBe(roleGuard); @@ -165,16 +165,16 @@ describe('PartnerStatisticController routing & security metadata', () => { expect(Reflect.getMetadata(THROTTLER_TTL, fn)).toBe(3600); }); - it('RoleGuard admits CLIENT_COMPANY and PARTNER, rejects plain USER', () => { + it('RoleGuard admits CLIENT_COMPANY and NON_CUSTODIAL_WALLET_PARTNER, rejects plain USER', () => { // Metadata alone does not execute RoleGuard; pin the OR semantics the decorator encodes. - const guard = RoleGuard(UserRole.CLIENT_COMPANY, UserRole.PARTNER); + const guard = RoleGuard(UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER); const contextFor = (role: UserRole) => ({ switchToHttp: () => ({ getRequest: () => ({ user: { role, account: 1 } }) }), }) as any; expect(guard.canActivate(contextFor(UserRole.CLIENT_COMPANY))).toBe(true); - expect(guard.canActivate(contextFor(UserRole.PARTNER))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.NON_CUSTODIAL_WALLET_PARTNER))).toBe(true); expect(guard.canActivate(contextFor(UserRole.KYC_CLIENT_COMPANY))).toBe(true); expect(guard.canActivate(contextFor(UserRole.USER))).toBe(false); expect(guard.canActivate(contextFor(UserRole.SUPPORT))).toBe(false); diff --git a/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts index 3cc7219475..6f4ad61763 100644 --- a/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts +++ b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts @@ -15,7 +15,7 @@ import { PartnerStatisticService } from './partner-statistic.service'; * * The shared RateLimitGuard keys by IP prefix and bypasses known/Azure IPs, so it does not * bound repeated scrapes by a single partner JWT. These routes authenticate first (AuthGuard), - * then count by the **resolved** wallet id (not raw `jwt.user`: for PARTNER that field is a + * then count by the **resolved** wallet id (not raw `jwt.user`: for NON_CUSTODIAL_WALLET_PARTNER that field is a * user id). Staff of the same wallet therefore share one budget. */ @Injectable() @@ -33,7 +33,7 @@ export class PartnerStatisticRateLimitGuard extends ThrottlerGuard { const walletId = req.partnerStatWalletId; if (walletId != null) return `partner-stat:wallet:${walletId}`; // partnerStatWalletId is set in handleRequest before super.handleRequest. Falling back to - // jwt.user would key PARTNER traffic by user id (separate budgets per employee) or worse + // jwt.user would key NON_CUSTODIAL_WALLET_PARTNER traffic by user id (separate budgets per employee) or worse // treat a user id as a wallet id. Fail closed instead. // Unreachable while REQUEST_LIMIT_CHECK is not true: handleRequest returns true before // super.handleRequest, so getTracker is never called — fail-closed stands or falls with diff --git a/src/subdomains/core/statistic/partner-statistic.controller.ts b/src/subdomains/core/statistic/partner-statistic.controller.ts index 085f5c079c..b915ed6ec5 100644 --- a/src/subdomains/core/statistic/partner-statistic.controller.ts +++ b/src/subdomains/core/statistic/partner-statistic.controller.ts @@ -18,7 +18,11 @@ export class PartnerStatisticController { @Get('partner') @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY, UserRole.PARTNER), PartnerStatisticRateLimitGuard) + @UseGuards( + AuthGuard(), + RoleGuard(UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER), + PartnerStatisticRateLimitGuard, + ) // 120 req/h per wallet: dashboard auto-refresh (~1/min) for summary + headroom @Throttle(120, 3600) @ApiOkResponse({ type: PartnerStatisticDto }) @@ -44,7 +48,11 @@ export class PartnerStatisticController { @Get('partner/timeline') @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY, UserRole.PARTNER), PartnerStatisticRateLimitGuard) + @UseGuards( + AuthGuard(), + RoleGuard(UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER), + PartnerStatisticRateLimitGuard, + ) // 120 req/h per wallet: same budget as summary so a dual-widget dashboard can refresh without 429s @Throttle(120, 3600) @ApiOkResponse({ type: PartnerTimelineDto }) diff --git a/src/subdomains/core/statistic/partner-statistic.service.ts b/src/subdomains/core/statistic/partner-statistic.service.ts index 3fb905a404..9ebedb1694 100644 --- a/src/subdomains/core/statistic/partner-statistic.service.ts +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -127,7 +127,7 @@ export class PartnerStatisticService { return jwt.user; } - if (hasRoleAccess(UserRole.PARTNER, jwt.role)) { + if (hasRoleAccess(UserRole.NON_CUSTODIAL_WALLET_PARTNER, jwt.role)) { if (jwt.user == null) throw new ForbiddenException('Partner wallet required'); const user = await this.userRepo.findOne({