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/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/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 51f3b5eae8..02515f0dd2 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', @@ -118,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', @@ -402,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/__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.`, + ); +} 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 {}