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..8508e6634d 100644 --- a/docs/coverage-gate.md +++ b/docs/coverage-gate.md @@ -6,7 +6,7 @@ the other. | Gate | Config | Scope | Question it answers | | ---------------- | ------------------------------ | ------------------------------------------ | -------------------------------------------------------- | | Frick gate | `jest.frick.config.js` | 10 Frick files, run by 10 Frick specs only | Do _these specs alone_ fully cover _these files_? | -| Coverage ratchet | `jest.coverage-gate.config.js` | 439 files, whole suite | Has coverage regressed anywhere it was already complete? | +| Coverage ratchet | `jest.coverage-gate.config.js` | 444 files, whole suite | Has coverage regressed anywhere it was already complete? | ## What the ratchet is, and what it is not @@ -25,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 444 pinned files, **250 carry real logic** (they have functions and/or branches) and +**194 are purely declarative today** (NestJS modules, constant files with neither). The two groups are kept visibly separate in the config so the count is not mistaken for test depth. 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 | 444 | Pinned by the ratchet at that commit | +| Partial | 1,034 | Some coverage, below 100 on at least one metric | | None | 127 | No coverage at all | 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 444 paths in two arrays, `PINNED_LOGIC` (logic-carrying files) and `PINNED_DECLARATIVE` (purely declarative files), from which `coverageThreshold` is generated. Adding a file means appending its path to the matching array, not writing out a `coverageThreshold` object entry by hand. @@ -211,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 250 logic-carrying files. A foreseeable friction case is different: +when one of the 194 purely declarative files (a NestJS module, a constants file) first gains executable logic — for example a `useFactory` on a module — the function metric jumps from 0/0 to 0/N and the gate turns red. Tests remain the preferred fix, but unpinning that one file is an 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..87a1ef6b83 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,9 @@ 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/trading/enums/index.ts', 'src/subdomains/generic/forwarding/controllers/lnurld-forward.controller.ts', 'src/subdomains/generic/forwarding/controllers/lnurlw-forward.controller.ts', @@ -402,6 +406,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/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index cc77a9e99c..115a682119 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -18,6 +18,7 @@ const additionalRoles: Partial> = { UserRole.SUPPORT, UserRole.COMPLIANCE, UserRole.MARKETING, + UserRole.NON_CUSTODIAL_WALLET_PARTNER, UserRole.REALUNIT, ], [UserRole.USER]: [ @@ -29,12 +30,15 @@ const additionalRoles: Partial> = { UserRole.SUPPORT, UserRole.COMPLIANCE, UserRole.MARKETING, + UserRole.NON_CUSTODIAL_WALLET_PARTNER, UserRole.REALUNIT, ], [UserRole.VIP]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BETA]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.SUPPORT]: [UserRole.COMPLIANCE, UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.MARKETING]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], + // NonCustodialWalletPartner is a normal-login role (not staff): keep hierarchy like MARKETING, not StaffRoles/KycGatedRoles. + [UserRole.NON_CUSTODIAL_WALLET_PARTNER]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.COMPLIANCE]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BANKING_BOT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.REALUNIT]: [UserRole.COMPLIANCE, UserRole.ADMIN, UserRole.SUPER_ADMIN], diff --git a/src/shared/auth/user-role.enum.ts b/src/shared/auth/user-role.enum.ts index 23e1569ead..98ecfe5249 100644 --- a/src/shared/auth/user-role.enum.ts +++ b/src/shared/auth/user-role.enum.ts @@ -11,6 +11,8 @@ export enum UserRole { CUSTODY = 'Custody', REALUNIT = 'RealUnit', MARKETING = 'Marketing', + // NonCustodialWalletPartner employees: normal login token (jwt.user = user id), not company-token (jwt.user = wallet id). + NON_CUSTODIAL_WALLET_PARTNER = 'NonCustodialWalletPartner', DEBUG = 'Debug', // service roles diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts new file mode 100644 index 0000000000..37f97a753f --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-rate-limit.guard.spec.ts @@ -0,0 +1,95 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Config, ConfigService } from 'src/config/config'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerStatisticRateLimitGuard } from '../partner-statistic-rate-limit.guard'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +describe('PartnerStatisticRateLimitGuard', () => { + beforeAll(() => new ConfigService()); + + const resolveWalletId = jest.fn(); + const partnerStatisticService = { resolveWalletId } as unknown as PartnerStatisticService; + const guard = new PartnerStatisticRateLimitGuard({} as any, {} as any, {} as any, partnerStatisticService); + + const getTracker = (req: Record): string => guard['getTracker'](req); + + beforeEach(() => { + resolveWalletId.mockReset(); + }); + + it('keys by resolved partnerStatWalletId when present', () => { + expect(getTracker({ partnerStatWalletId: 42, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:42'); + expect(getTracker({ partnerStatWalletId: 99, realIp: '1.2.3.4' })).toBe('partner-stat:wallet:99'); + expect(getTracker({ partnerStatWalletId: 42, realIp: '1.2.3.4' })).not.toBe( + getTracker({ partnerStatWalletId: 99, realIp: '1.2.3.4' }), + ); + }); + + it('does not share a bucket across wallets on the same IP', () => { + const a = getTracker({ partnerStatWalletId: 1, realIp: '185.12.34.56' }); + const b = getTracker({ partnerStatWalletId: 2, realIp: '185.12.34.56' }); + expect(a).not.toEqual(b); + }); + + it('does not key by jwt.user alone (NON_CUSTODIAL_WALLET_PARTNER user id must not be the tracker)', () => { + // Without partnerStatWalletId, even if jwt.user is set, fail closed — otherwise two + // employees of the same wallet would get separate budgets keyed by user id. + expect(() => + getTracker({ user: { user: 42, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER }, realIp: '1.2.3.4' }), + ).toThrow(/authenticated wallet/); + }); + + it('throws plain Error when partnerStatWalletId is missing (fail-closed, no IP fallback)', () => { + 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); + expect(resolveWalletId).not.toHaveBeenCalled(); + } finally { + Config.request.limitCheck = prev; + } + }); + + it('resolves wallet id and keys by it when limitCheck is true', async () => { + const prev = Config.request.limitCheck; + Config.request.limitCheck = true; + resolveWalletId.mockResolvedValue(7); + const req: Record = { + user: { user: 99, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER }, + }; + // handleRequest is protected on ThrottlerGuard — cast for the spy. + const superSpy = jest + .spyOn(ThrottlerGuard.prototype as unknown as { handleRequest: typeof guard.handleRequest }, 'handleRequest') + .mockResolvedValue(true); + try { + const ctx = { switchToHttp: () => ({ getRequest: () => req }) } as any; + const result = await guard.handleRequest(ctx, 120, 3600); + expect(resolveWalletId).toHaveBeenCalledWith(req.user); + expect(req.partnerStatWalletId).toBe(7); + // Two NON_CUSTODIAL_WALLET_PARTNER employees of wallet 7 share the same tracker key. + expect(getTracker(req)).toBe('partner-stat:wallet:7'); + expect(getTracker(req)).not.toBe('partner-stat:wallet:99'); + expect(superSpy).toHaveBeenCalledWith(ctx, 120, 3600); + expect(result).toBe(true); + } finally { + Config.request.limitCheck = prev; + superSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts new file mode 100644 index 0000000000..6d925f2d29 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-resolve-wallet.spec.ts @@ -0,0 +1,86 @@ +import { ForbiddenException } from '@nestjs/common'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +/** + * Unit tests for role-aware wallet resolution — the tenant boundary between + * company tokens (jwt.user = wallet id) and NON_CUSTODIAL_WALLET_PARTNER tokens (jwt.user = user id). + */ +describe('PartnerStatisticService.resolveWalletId', () => { + const findOne = jest.fn(); + const userRepo = { findOne } as any; + const service = new PartnerStatisticService({} as any, {} as any, userRepo, {} as any); + + beforeEach(() => { + findOne.mockReset(); + }); + + it('CLIENT_COMPANY: returns jwt.user as wallet id without loading a user', async () => { + // Real company token: no account field (generateCompanyToken). + const jwt = { user: 42, role: UserRole.CLIENT_COMPANY } as JwtPayload; + await expect(service.resolveWalletId(jwt)).resolves.toBe(42); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('KYC_CLIENT_COMPANY: same as CLIENT_COMPANY (hierarchy super-role)', async () => { + const jwt = { user: 15, role: UserRole.KYC_CLIENT_COMPANY } as JwtPayload; + await expect(service.resolveWalletId(jwt)).resolves.toBe(15); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('CLIENT_COMPANY with account (user token): rejects — does not treat user id as wallet id', async () => { + // Malicious / contradictory: normal login token (account set) whose role was stored as + // CLIENT_COMPANY on the user row. jwt.user is a user id that equals a foreign wallet id. + const jwt = { user: 99, account: 5, role: UserRole.CLIENT_COMPANY } as JwtPayload; + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.resolveWalletId(jwt)).rejects.toThrow(/company token/i); + // Must not fall through to NON_CUSTODIAL_WALLET_PARTNER lookup, and must not return 99 as wallet id. + expect(findOne).not.toHaveBeenCalled(); + }); + + it('KYC_CLIENT_COMPANY with account (user token): same reject', async () => { + const jwt = { user: 99, account: 5, role: UserRole.KYC_CLIENT_COMPANY } as JwtPayload; + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('NON_CUSTODIAL_WALLET_PARTNER: loads user and returns their wallet id — not jwt.user', async () => { + // Critical tenant case: user id 99 equals foreign wallet 99; own wallet is 7. + const jwt = { user: 99, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER } as JwtPayload; + findOne.mockResolvedValue({ id: 99, wallet: { id: 7 } }); + + await expect(service.resolveWalletId(jwt)).resolves.toBe(7); + + expect(findOne).toHaveBeenCalledWith({ where: { id: 99 }, relations: { wallet: true } }); + await expect(service.resolveWalletId(jwt)).resolves.not.toBe(99); + }); + + it('NON_CUSTODIAL_WALLET_PARTNER: rejects when user has no wallet (Forbidden, not silent 0)', async () => { + const jwt = { user: 5, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER } as JwtPayload; + findOne.mockResolvedValue({ id: 5, wallet: null }); + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.resolveWalletId(jwt)).rejects.toThrow(/no wallet/i); + }); + + it('NON_CUSTODIAL_WALLET_PARTNER: rejects when user is missing', async () => { + const jwt = { user: 5, role: UserRole.NON_CUSTODIAL_WALLET_PARTNER } as JwtPayload; + findOne.mockResolvedValue(null); + + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('USER (no permission): rejects', async () => { + const jwt = { user: 1, role: UserRole.USER } as JwtPayload; + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('CLIENT_COMPANY without jwt.user: rejects', async () => { + const jwt = { role: UserRole.CLIENT_COMPANY } as JwtPayload; + await expect(service.resolveWalletId(jwt)).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts b/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts new file mode 100644 index 0000000000..5c1f8dc2f3 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic-tz-check.script.ts @@ -0,0 +1,107 @@ +/** + * Standalone child-process helper for the M4 non-UTC-process-timezone check in + * partner-statistic.integration.spec.ts. + * + * WHY a child process instead of `process.env.TZ = '...'` inside the test: Node/V8 resolves the + * process-default timezone once per process and does not re-read `process.env.TZ` for later + * `Date`/`Intl` calls in this repo's Jest setup (verified empirically — a `process.env.TZ` + * mutation inside a `beforeAll` has no effect on `Date.prototype.getTimezoneOffset()` here, even + * as the very first Date operation in the process). A genuinely different process timezone only + * exists if the process is started with that `TZ` from the outset — hence this file is executed + * via `ts-node` as a fresh child process with `TZ` set in its env, never `require`d directly. + * + * Not a `*.spec.ts` file on purpose: Jest's testRegex only matches `.spec.ts`, so this is never + * picked up as a test itself — it is a one-shot script invoked by the real test via + * `child_process.execFileSync`. + */ +import * as path from 'path'; +import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; +// Same fixture default jest-env.setup.ts provides for Jest-run specs — this script runs outside +// Jest (a plain ts-node child process), so ConfigService's fail-loud boot check needs it too. +if (!process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD) { + process.env.REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD = '0.05'; +} +import { ConfigService } from 'src/config/config'; +import { PartnerStatisticGranularity } from '../partner-statistic.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +@Entity({ name: 'user' }) +class TzCheckUser { + @PrimaryColumn() id: number; + @Column() walletId: number; + @Column({ type: 'timestamp' }) created: Date; +} + +@Entity({ name: 'buy' }) +class TzCheckBuy { + @PrimaryColumn() id: number; + @ManyToOne(() => TzCheckUser) + @JoinColumn({ name: 'userId' }) + user: TzCheckUser; +} + +@Entity({ name: 'buy_crypto' }) +class TzCheckBuyCrypto { + @PrimaryColumn() id: number; + @ManyToOne(() => TzCheckBuy, { nullable: true }) + @JoinColumn({ name: 'buyId' }) + buy?: TzCheckBuy; + @Column({ type: 'numeric', default: 0 }) amountInChf: number; + @Column({ type: 'varchar', length: 64, nullable: true }) amlCheck?: string; + @Column({ type: 'timestamp' }) created: Date; +} + +async function main(): Promise { + const pgUrl = process.env.MIGRATION_TEST_PG; + // Schema name is a CLI argument, not an env var: it is never a developer-facing switch — + // the parent spec invents and passes it purely to give this one-shot child process its own + // isolated schema. Documenting it in .env.example would misrepresent it as configuration. + const schema = process.argv[2]; + if (!pgUrl) throw new Error('MIGRATION_TEST_PG must be set'); + if (!schema) { + throw new Error( + `Expected the Postgres schema name as the first CLI argument (e.g. "ts-node ${path.basename(__filename)} my_schema"), got none.`, + ); + } + + new ConfigService(); + + const dataSource = new DataSource({ + type: 'postgres', + url: pgUrl, + entities: [TzCheckUser, TzCheckBuy, TzCheckBuyCrypto], + synchronize: false, + schema, + extra: { max: 1, options: '-c TimeZone=UTC' }, + }); + await dataSource.initialize(); + + const buyCryptoRepo = dataSource.getRepository(TzCheckBuyCrypto) as unknown as Repository; + const userRepo = dataSource.getRepository(TzCheckUser) as unknown as Repository; + // BUY-only check: buyFiatRepo/walletRepo are never touched by timelineByDirection(BUY, ...). + const service = new PartnerStatisticService(buyCryptoRepo as any, {} as any, userRepo as any, {} as any); + + // Calls the REAL private method (same bracket-access pattern the existing specs use) so a + // future edit to the trunc expression in partner-statistic.service.ts is actually exercised — + // this is not a parallel/hand-copied SQL string. + const map: Map = await (service as any).timelineByDirection( + 1, + new Date('2024-06-10T00:00:00.000Z'), + new Date('2024-06-12T00:00:00.000Z'), + 'Buy', + PartnerStatisticGranularity.DAY, + ); + + await dataSource.destroy(); + + // Only the bucket KEYS are the signal here (derived from the DATE_TRUNC(...) result). Some + // transitively-imported modules (SDK clients pulled in by production code) log their own + // "Logging enabled" lines to stdout on import, so the result is marked with a distinctive + // prefix — the parent process extracts exactly that line rather than parsing all of stdout. + process.stdout.write(`TZ_CHECK_RESULT:${JSON.stringify([...map.keys()].sort())}\n`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts new file mode 100644 index 0000000000..0935484679 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.controller.spec.ts @@ -0,0 +1,182 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ForbiddenException } from '@nestjs/common'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common/enums'; +import { AuthGuard } from '@nestjs/passport'; +import { THROTTLER_LIMIT, THROTTLER_TTL } from '@nestjs/throttler/dist/throttler.constants'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerStatisticRateLimitGuard } from '../partner-statistic-rate-limit.guard'; +import { PartnerStatisticController } from '../partner-statistic.controller'; +import { PartnerStatisticGranularity } from '../partner-statistic.enum'; +import { PartnerStatisticService } from '../partner-statistic.service'; + +/** + * Pins the partner-statistic controller wiring: guard order, throttle budget, + * role-aware wallet resolution, and the Day granularity default on the timeline route. + * Pattern mirrors ledger.controller + bank.controller metadata specs. + */ +describe('PartnerStatisticController', () => { + let controller: PartnerStatisticController; + let service: DeepMocked; + + const companyJwt = { user: 42, role: UserRole.CLIENT_COMPANY, account: 7 } as JwtPayload; + + beforeEach(() => { + service = createMock(); + // Default: resolve mirrors CLIENT_COMPANY (jwt.user is wallet id) unless a test overrides. + service.resolveWalletId.mockImplementation(async (jwt) => jwt.user as number); + controller = new PartnerStatisticController(service); + }); + + it('CLIENT_COMPANY: resolves wallet from jwt and forwards to getStatistics (not account)', async () => { + service.getStatistics.mockResolvedValue({ currency: 'CHF' } as any); + service.resolveWalletId.mockResolvedValue(42); + + await controller.getPartnerStatistics(companyJwt, '2024-06-01', '2024-06-15'); + + expect(service.resolveWalletId).toHaveBeenCalledWith(companyJwt); + expect(service.getStatistics).toHaveBeenCalledWith(42, '2024-06-01', '2024-06-15'); + expect(service.getStatistics).not.toHaveBeenCalledWith(7, expect.anything(), expect.anything()); + }); + + it('CLIENT_COMPANY: forwards resolved wallet and optional granularity to getTimeline', async () => { + service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.WEEK } as any); + service.resolveWalletId.mockResolvedValue(42); + + await controller.getPartnerTimeline(companyJwt, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); + + expect(service.resolveWalletId).toHaveBeenCalledWith(companyJwt); + expect(service.getTimeline).toHaveBeenCalledWith(42, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.WEEK); + }); + + it('forwards undefined granularity to getTimeline when the query omits it', async () => { + service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.DAY } as any); + service.resolveWalletId.mockResolvedValue(42); + + await controller.getPartnerTimeline(companyJwt, undefined, undefined, undefined); + + expect(service.getTimeline).toHaveBeenCalledWith(42, undefined, undefined, undefined); + }); + + /** + * Tenant isolation (the real failure mode): a NON_CUSTODIAL_WALLET_PARTNER user whose user id equals a *foreign* + * wallet id must still only see their own wallet — never treat jwt.user as walletId. + */ + it('NON_CUSTODIAL_WALLET_PARTNER: never uses jwt.user as walletId even when user id equals a foreign wallet id', async () => { + const foreignWalletId = 99; + const ownWalletId = 7; + // jwt.user === foreignWalletId is exactly the cross-tenant trap if resolution is skipped. + const partnerJwt = { + user: foreignWalletId, + role: UserRole.NON_CUSTODIAL_WALLET_PARTNER, + account: 1, + } as JwtPayload; + + service.resolveWalletId.mockResolvedValue(ownWalletId); + service.getStatistics.mockResolvedValue({ currency: 'CHF' } as any); + service.getTimeline.mockResolvedValue({ currency: 'CHF', granularity: PartnerStatisticGranularity.DAY } as any); + + await controller.getPartnerStatistics(partnerJwt, '2024-06-01', '2024-06-15'); + await controller.getPartnerTimeline(partnerJwt, '2024-06-01', '2024-06-15', PartnerStatisticGranularity.DAY); + + expect(service.resolveWalletId).toHaveBeenCalledWith(partnerJwt); + expect(service.getStatistics).toHaveBeenCalledWith(ownWalletId, '2024-06-01', '2024-06-15'); + expect(service.getStatistics).not.toHaveBeenCalledWith(foreignWalletId, expect.anything(), expect.anything()); + expect(service.getTimeline).toHaveBeenCalledWith( + ownWalletId, + '2024-06-01', + '2024-06-15', + PartnerStatisticGranularity.DAY, + ); + expect(service.getTimeline).not.toHaveBeenCalledWith( + foreignWalletId, + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('rejects when resolveWalletId rejects (e.g. user has no wallet)', async () => { + service.resolveWalletId.mockRejectedValue(new ForbiddenException('User has no wallet')); + + await expect(controller.getPartnerStatistics(companyJwt, undefined, undefined)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(service.getStatistics).not.toHaveBeenCalled(); + }); +}); + +// Call-path tests above pass JWT in directly and cannot see the decorators that decide +// whether a request is authenticated, role-checked, or rate-limited. Removing @UseGuards +// or swapping RoleGuard 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|NON_CUSTODIAL_WALLET_PARTNER) → 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 admit CLIENT_COMPANY or NON_CUSTODIAL_WALLET_PARTNER. + const roleGuard = guards.find((g) => (g as { entryRoles?: UserRole[] }).entryRoles !== undefined) as { + entryRoles: UserRole[]; + }; + expect(roleGuard).toBeDefined(); + expect(roleGuard.entryRoles).toEqual([UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER]); + 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); + }); + + it('RoleGuard admits CLIENT_COMPANY and NON_CUSTODIAL_WALLET_PARTNER, rejects plain USER', () => { + // Metadata alone does not execute RoleGuard; pin the OR semantics the decorator encodes. + const guard = RoleGuard(UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER); + const contextFor = (role: UserRole) => + ({ + switchToHttp: () => ({ getRequest: () => ({ user: { role, account: 1 } }) }), + }) as any; + + expect(guard.canActivate(contextFor(UserRole.CLIENT_COMPANY))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.NON_CUSTODIAL_WALLET_PARTNER))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.KYC_CLIENT_COMPANY))).toBe(true); + expect(guard.canActivate(contextFor(UserRole.USER))).toBe(false); + expect(guard.canActivate(contextFor(UserRole.SUPPORT))).toBe(false); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts new file mode 100644 index 0000000000..93cb630452 --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -0,0 +1,595 @@ +import { execFileSync } from 'child_process'; +import * as path from 'path'; +import { Column, DataSource, Entity, JoinColumn, ManyToOne, PrimaryColumn, Repository } from 'typeorm'; +import { ConfigService } from 'src/config/config'; +import { isProcessTimezoneUtcYearRound } from 'src/process-timezone'; +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: the SELL asset row is not the subject of this fixture, + -- which exercises the buy-side asset join against real rows. + (1, 1, 3, 7, 50, 'Pass', '2024-06-11 12:00:00', NULL); + `); + + 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) + expect(result.totals.volume.buy).toBe(500); + expect(result.totals.volume.sell).toBe(50); + expect(result.totals.volume.swap).toBe(0); + expect(result.totals.volume.total).toBe(550); + + // Foreign wallet (id=2) rows must not inflate anything: + expect(result.allTime.registeredUsers).toBe(6); // users 1–5 + owner 100 on wallet 1 + expect(result.allTime.registeredUsers).not.toBe(7); // must not include wallet-2 user 6 + + // 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', 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, day-2 has 2 buy + 1 sell — every day reports what happened. + expect(result.buckets[0].volume?.buy).toBe(300); + expect(result.buckets[1].volume?.buy).toBe(200); + expect(result.buckets[1].volume?.sell).toBe(50); + + expect(JSON.stringify(result)).not.toMatch(/"users"/); + }); + + it('countActiveUsers is a set across directions (UNION, not UNION ALL)', async () => { + // Three users each active on buy AND sell → DISTINCT 3; bag-count (UNION ALL) would be 6. + // The DTO reports activeUsers from this set; a bag count would double anyone active in two directions. + await dataSource.query(`DELETE FROM ${t('buy_crypto')}`); + await dataSource.query(`DELETE FROM ${t('buy_fiat')}`); + await dataSource.query(`DELETE FROM ${t('sell')}`); + 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 reports the same set: UNION ALL would surface 6. + const result = await service.getStatistics(1, from, to); + expect(result.totals.activeUsers).toBe(3); + 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'); + }); +}); + +// --- M4: AT TIME ZONE 'UTC' binding must hold even when the process is NOT UTC --- // + +/** + * `describeDb` above (and its `getTimeline` test) only runs under a UTC process timezone, + * which is exactly the condition under which a missing `AT TIME ZONE 'UTC'` binding would be + * invisible (process offset 0 makes both SQL forms return the same instant). This block is + * gated only on `PG_URL` — not on `isProcessTimezoneUtcYearRound()` — so it must genuinely + * exercise a non-UTC offset regardless of the ambient host/CI timezone. + * + * Verified empirically: mutating `process.env.TZ` inside a `beforeAll`/`it` does NOT change + * `Date.prototype.getTimezoneOffset()` for the rest of this Jest worker process (checked with a + * minimal repro — even as the very first Date operation in a fresh describe block, the change + * has no effect here). Node/V8 resolves the process-default timezone once and does not re-read + * `process.env.TZ` afterwards in this repo's Jest setup. A `process.env.TZ` assignment can + * therefore not be used to prove this property, and doing so would silently test nothing new + * whenever the ambient TZ under which Jest itself was launched happens to already be non-UTC + * (it would then "pass" while only ever exercising that ambient zone, never provably switching). + * + * The only reliable way to exercise a genuinely different process timezone is a fresh child + * process started with that `TZ` in its env from the beginning — see + * `partner-statistic-tz-check.script.ts`, executed here via `ts-node` so it runs the REAL + * `timelineByDirection` production code, not a hand-copied SQL string. + */ +const describePgAnyTz = PG_URL ? describe : describe.skip; + +describePgAnyTz('PartnerStatisticService timeline UTC binding under non-UTC process TZ (M4)', () => { + const TZ_SCHEMA = 'partner_statistic_tz_spec'; + const tz = (name: string) => `"${TZ_SCHEMA}"."${name}"`; + // Asia/Tokyo: fixed +09:00, no DST (deterministic). MUST be a zone AHEAD of UTC — a zone + // behind UTC (e.g. America/New_York, -04:00 in June) was tried first and turned out to be a + // false negative: misparsing a wall-clock midnight as a few hours *later* in the same UTC + // calendar day still collapses back to the same day once startOfBucket() re-truncates to UTC + // midnight, so the missing binding stayed invisible. A zone ahead of UTC misparses the same + // midnight as being on the *previous* UTC calendar day, which does change the bucket key — + // verified by hand against the raw driver output before relying on it here. + const CHECK_TZ = 'Asia/Tokyo'; + + let dataSource: DataSource; + + beforeAll(async () => { + new ConfigService(); + dataSource = new DataSource({ + type: 'postgres', + url: PG_URL, + entities: ENTITIES, + synchronize: false, + schema: TZ_SCHEMA, + extra: { max: 1, options: '-c TimeZone=UTC' }, + }); + await dataSource.initialize(); + + await dataSource.query(`DROP SCHEMA IF EXISTS "${TZ_SCHEMA}" CASCADE`); + await dataSource.query(`CREATE SCHEMA "${TZ_SCHEMA}"`); + await dataSource.query(` + CREATE TABLE ${tz('user')} ( "id" int PRIMARY KEY, "walletId" int NOT NULL, "created" TIMESTAMP NOT NULL DEFAULT NOW() ); + CREATE TABLE ${tz('buy')} ( "id" int PRIMARY KEY, "userId" int NOT NULL ); + CREATE TABLE ${tz('buy_crypto')} ( + "id" SERIAL PRIMARY KEY, + "buyId" int, + "amountInChf" numeric DEFAULT 0, + "amlCheck" varchar(64), + "created" TIMESTAMP NOT NULL + ); + `); + + // Wall-clock values stored as-is (timestamp without time zone) — these are the UTC + // instants the production Dockerfile (ENV TZ=UTC) would have written. + await dataSource.query(` + INSERT INTO ${tz('user')} ("id", "walletId", "created") VALUES (1, 1, '2024-06-01 10:00:00'); + INSERT INTO ${tz('buy')} ("id", "userId") VALUES (1, 1); + INSERT INTO ${tz('buy_crypto')} ("buyId", "amountInChf", "amlCheck", "created") + VALUES + (1, 100, 'Pass', '2024-06-10 10:00:00'), + (1, 100, 'Pass', '2024-06-11 10:00:00'); + `); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.query(`DROP SCHEMA IF EXISTS "${TZ_SCHEMA}" CASCADE`); + await dataSource.destroy(); + } + }); + + it('bucket keys stay on true UTC day boundaries in a child process running under Asia/Tokyo', () => { + const scriptPath = path.join(__dirname, 'partner-statistic-tz-check.script.ts'); + // TZ_SCHEMA is passed as a CLI argument, not an env var: it is never a developer-facing + // switch (unlike MIGRATION_TEST_PG, which a person sets to opt into the Postgres suite) — + // it only exists so this one-shot child process gets its own isolated schema name. + const stdout = execFileSync( + path.join(process.cwd(), 'node_modules', '.bin', 'ts-node'), + ['-T', '-r', 'tsconfig-paths/register', scriptPath, TZ_SCHEMA], + { + cwd: process.cwd(), + env: { ...process.env, TZ: CHECK_TZ, MIGRATION_TEST_PG: PG_URL }, + encoding: 'utf-8', + }, + ); + + const resultLine = stdout.split('\n').find((line) => line.startsWith('TZ_CHECK_RESULT:')); + if (!resultLine) throw new Error(`TZ check script produced no result line. stdout was:\n${stdout}`); + const bucketKeys: string[] = JSON.parse(resultLine.slice('TZ_CHECK_RESULT:'.length)); + + // Without `AT TIME ZONE 'UTC'`, the driver would parse the DATE_TRUNC'd `timestamp without + // time zone` result in process-local wall time (Asia/Tokyo, UTC+9) — midnight Tokyo time is + // the previous day in UTC, so the bucket key would land one calendar day too early. + expect(bucketKeys).toEqual(['2024-06-10T00:00:00', '2024-06-11T00:00:00']); + }); +}); diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts new file mode 100644 index 0000000000..312b06708d --- /dev/null +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts @@ -0,0 +1,1366 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from 'src/config/config'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { UserRepository } from 'src/subdomains/generic/user/models/user/user.repository'; +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, + }; +} + +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 `:check` value from every amlCheck clause (M1 — text match alone misses a swapped value). */ + let amlCheckValues: unknown[]; + /** Bound `{ from, to }` from every where/andWhere that supplies period params. */ + let periodBoundParams: { from: unknown; to: unknown }[]; + let groupByCapture: GroupByCapture; + 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); + amlCheckValues.push(params && typeof params === 'object' ? (params as { check?: unknown }).check : undefined); + } + 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 = []; + amlCheckValues = []; + 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(); + }); + + // M3: the two tests above derive their input from the constant they are supposed to pin + // (MAX ± N) — they would stay green even if PARTNER_STATISTIC_MAX_PERIOD_DAYS were bumped + // to 36600. Pin literal, constant-independent day counts at the exact boundary instead. + it('rejects a literal 367-day span regardless of the configured max (M3)', () => { + const from = new Date(TEST_NOW.getTime() - 366 * 24 * 3600 * 1000); + const to = TEST_NOW; + expect(() => service.resolvePeriod(from, to)).toThrow(BadRequestException); + }); + + it('accepts a literal 366-day span regardless of the configured max (M3)', () => { + const from = new Date(TEST_NOW.getTime() - 365 * 24 * 3600 * 1000); + const to = TEST_NOW; + expect(() => service.resolvePeriod(from, to)).not.toThrow(); + }); + + it('accepts a single full day', () => { + const period = service.resolvePeriod('2024-06-01T00:00:00.000Z', '2024-06-01T23:59:59.000Z'); + expect(period.from.toISOString()).toBe('2024-06-01T00:00:00.000Z'); + 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); + // M1: clause TEXT alone would still match `:check` bound to Pending — pin the bound value too. + expect(amlCheckValues).toHaveLength(GET_STATISTICS_CLAUSE_BUDGET.AML_FILTERS); + expect(amlCheckValues.every((v) => v === CheckStatus.PASS)).toBe(true); + + // Filtered pins (20/17/18) miss an extra unscoped WHERE that matches none of the three + // exact regexes. Total pin + UNION shape (getQuery + from SQL) close that gap. + 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); + }); + }); + + // --- 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 reports 10 users, but the true DISTINCT union across directions is 3. + // activeUsers must come from the UNION count, not from one direction's user count. + fixtures.set( + 1, + emptyFixture({ + 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.activeUsers).toBe(3); + expect(result.totals.activeUsers).not.toBe(10); + // Every day reports what happened; nothing is withheld for being thin. + expect(result.totals.volume.total).toBe(1700); + expect(result.totals.transactions.total).toBe(45); + }); + }); + + // --- 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(); + }); + }); + + // --- mergeNamedRows / breakdown pipeline --- // + + describe('mergeNamedRows and breakdown pipeline', () => { + it('merges same-name rows across directions and sums volume/transactions', 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 }, + { name: 'BTC', volume: 100, transactions: 5 }, + { name: 'ETH', volume: 50, transactions: 5 }, + ]); + expect(merged.find((r) => r.name === 'BTC')?.volume).toBe(300); + expect(merged.find((r) => r.name === 'BTC')?.transactions).toBe(15); + expect(merged).toHaveLength(2); + }); + + it('drops nameless rows from the breakdown payload', () => { + const merged = service.mergeNamedRows([ + { name: null, volume: 999, transactions: 50 }, + { name: '', volume: 100, transactions: 5 }, + { name: 'BTC', volume: 200, transactions: 10 }, + ]); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe('BTC'); + 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 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); + // 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); + // M2: the assertion above is tautological against the constant it is supposed to pin + // (bumping PARTNER_STATISTIC_QUERY_CONCURRENCY to 20 would still pass it). Pin the + // literal budget independently so a change to the constant is itself caught. + expect(maxConcurrentQueries).toBeLessThanOrEqual(4); + }); + }); + + // --- 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', + }); + }); + }); + + // --- Part 3: unit-level leak guard for the internal `users` field --- // + + /** Recursively collects every own property key across arrays/objects (order/duplicates irrelevant). */ + function collectKeys(value: unknown, out: Set = new Set()): Set { + if (Array.isArray(value)) { + for (const item of value) collectKeys(item, out); + } else if (value !== null && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + out.add(k); + collectKeys(v, out); + } + } + return out; + } + + describe('response payload never leaks the internal users field', () => { + it('getStatistics response has no "users" key anywhere in its (serialized) shape', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 500, transactions: 20, users: 10 }, + sell: { volume: 300, transactions: 10, users: 8 }, + swap: { volume: 50, transactions: 5, users: 3 }, + allTime: { buy: 500, sell: 300, registeredUsers: 20, tradingUsers: 10 }, + newUsers: 8, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8], + namedRows: [ + { name: 'BTC', blockchain: 'Bitcoin', volume: 200, transactions: 10, users: 6 }, + { name: 'BTC', blockchain: 'Bitcoin', volume: 100, transactions: 5, users: 4 }, + { name: 'ETH', blockchain: 'Ethereum', volume: 50, transactions: 5, users: 5 }, + { name: 'CHF', volume: 400, transactions: 15, users: 10 }, + ], + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + // Fixture must actually exercise non-empty breakdown rows — otherwise this test would + // pass vacuously on an empty payload that never had a `users` key to begin with. + expect( + result.breakdown.assets.length + + result.breakdown.fiatCurrencies.length + + result.breakdown.blockchains.length + + result.breakdown.paymentMethods.length, + ).toBeGreaterThan(0); + + const keys = collectKeys(JSON.parse(JSON.stringify(result))); + expect(keys.has('users')).toBe(false); + }); + + it('getTimeline response has no "users" key anywhere in its (serialized) shape', async () => { + fixtures.set( + 1, + emptyFixture({ + timelineRows: [ + { bucket: new Date('2024-06-10T00:00:00.000Z'), volume: 100, transactions: 5, users: 3 }, + { bucket: new Date('2024-06-11T00:00:00.000Z'), volume: 50, transactions: 2, users: 2 }, + ], + }), + ); + + const result = await service.getTimeline( + 1, + '2024-06-10T00:00:00.000Z', + '2024-06-11T23:59:59.000Z', + PartnerStatisticGranularity.DAY, + ); + expect(result.buckets.length).toBeGreaterThan(0); + + const keys = collectKeys(JSON.parse(JSON.stringify(result))); + expect(keys.has('users')).toBe(false); + }); + }); +}); diff --git a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts new file mode 100644 index 0000000000..7291bed33f --- /dev/null +++ b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts @@ -0,0 +1,260 @@ +import { ApiProperty, ApiPropertyOptional } 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 { + @ApiPropertyOptional({ description: 'Server time the response was assembled' }) + generatedAt?: Date; +} + +// --- VOLUME / COUNTS --- // + +export class PartnerVolumeByTypeDto { + @ApiProperty({ description: 'CHF' }) + buy: number; + + @ApiProperty({ description: 'CHF' }) + sell: number; + + @ApiProperty({ description: 'CHF' }) + swap: number; + + @ApiProperty({ description: 'CHF' }) + total: number; +} + +export class PartnerVolumeBuySellDto { + @ApiProperty({ description: 'CHF' }) + buy: number; + + @ApiProperty({ description: 'CHF' }) + sell: number; + + @ApiProperty({ description: 'CHF' }) + total: number; +} + +export class PartnerTransactionsByTypeDto { + @ApiProperty() + buy: number; + + @ApiProperty() + sell: number; + + @ApiProperty() + swap: number; + + @ApiProperty() + total: number; +} + +export class PartnerTotalsDto { + @ApiProperty({ + type: PartnerVolumeByTypeDto, + description: 'Volume in CHF, by trade direction', + }) + volume: PartnerVolumeByTypeDto; + + @ApiProperty({ type: PartnerTransactionsByTypeDto }) + transactions: PartnerTransactionsByTypeDto; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Average volume per transaction in CHF; null when there were no transactions', + }) + averageTransactionVolume: number | null; + + @ApiProperty({ + type: Number, + description: 'Distinct users with ≥1 counted transaction in the period', + }) + activeUsers: number; + + @ApiProperty({ + type: Number, + description: 'Users of this wallet created in the period', + }) + newUsers: number; +} + +export class PartnerAllTimeDto { + @ApiProperty({ + type: PartnerVolumeBuySellDto, + description: 'Lifetime volume in CHF', + }) + volume: PartnerVolumeBuySellDto; + + @ApiProperty({ description: 'Installation count; no transaction linkage' }) + registeredUsers: number; + + @ApiProperty({ + type: Number, + description: 'Users of this wallet with buyVolume > 0 or sellVolume > 0', + }) + tradingUsers: number; +} + +// --- 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({ + description: 'Partner referral volume in EUR on the wallet owner’s account (all wallets of that owner)', + }) + volume: number; + + @ApiProperty({ + description: 'Partner referral credit earned (owner.partnerRefCredit only) in EUR, account-wide', + }) + creditEarned: number; + + @ApiProperty({ + description: + 'Referral credit already paid out (owner.paidRefCredit) in EUR, account-wide across both ' + + 'personal and partner pots', + }) + creditPaid: number; + + @ApiProperty({ + description: + 'Open referral credit in EUR: owner.refCredit + owner.partnerRefCredit − owner.paidRefCredit ' + '(account-wide)', + }) + creditOpen: number; + + @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, + description: 'Volume in CHF, by trade direction', + }) + volume: PartnerTimelineByDirectionDto; + + @ApiProperty({ + type: PartnerTimelineByDirectionDto, + description: 'Transaction counts, by trade direction', + }) + transactions: PartnerTimelineByDirectionDto; + + @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..6f4ad61763 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic-rate-limit.guard.ts @@ -0,0 +1,52 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + InjectThrottlerOptions, + InjectThrottlerStorage, + ThrottlerGuard, + ThrottlerModuleOptions, + ThrottlerStorage, +} from '@nestjs/throttler'; +import { Config } from 'src/config/config'; +import { PartnerStatisticService } from './partner-statistic.service'; + +/** + * Wallet-scoped rate limit for partner statistic routes. + * + * The shared RateLimitGuard keys by IP prefix and bypasses known/Azure IPs, so it does not + * bound repeated scrapes by a single partner JWT. These routes authenticate first (AuthGuard), + * then count by the **resolved** wallet id (not raw `jwt.user`: for NON_CUSTODIAL_WALLET_PARTNER that field is a + * user id). Staff of the same wallet therefore share one budget. + */ +@Injectable() +export class PartnerStatisticRateLimitGuard extends ThrottlerGuard { + constructor( + @InjectThrottlerOptions() options: ThrottlerModuleOptions, + @InjectThrottlerStorage() storageService: ThrottlerStorage, + reflector: Reflector, + private readonly partnerStatisticService: PartnerStatisticService, + ) { + super(options, storageService, reflector); + } + + protected getTracker(req: Record): string { + const walletId = req.partnerStatWalletId; + if (walletId != null) return `partner-stat:wallet:${walletId}`; + // partnerStatWalletId is set in handleRequest before super.handleRequest. Falling back to + // jwt.user would key NON_CUSTODIAL_WALLET_PARTNER traffic by user id (separate budgets per employee) or worse + // treat a user id as a wallet id. Fail closed instead. + // Unreachable while REQUEST_LIMIT_CHECK is not true: handleRequest returns true before + // super.handleRequest, so getTracker is never called — fail-closed stands or falls with + // the same switch as the budget itself. + throw new Error('Partner statistic rate limit requires an authenticated wallet'); + } + + async handleRequest(context: ExecutionContext, limit: number, ttl: number): Promise { + if (!Config.request.limitCheck) return true; + + const req = context.switchToHttp().getRequest(); + // Same resolveWalletId as the controller — budget hangs on the wallet, not the JWT subject. + req.partnerStatWalletId = await this.partnerStatisticService.resolveWalletId(req.user); + return super.handleRequest(context, limit, ttl); + } +} diff --git a/src/subdomains/core/statistic/partner-statistic.controller.ts b/src/subdomains/core/statistic/partner-statistic.controller.ts new file mode 100644 index 0000000000..b915ed6ec5 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.controller.ts @@ -0,0 +1,85 @@ +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, UserRole.NON_CUSTODIAL_WALLET_PARTNER), + PartnerStatisticRateLimitGuard, + ) + // 120 req/h per wallet: dashboard auto-refresh (~1/min) for summary + headroom + @Throttle(120, 3600) + @ApiOkResponse({ type: PartnerStatisticDto }) + @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 { + const walletId = await this.partnerStatisticService.resolveWalletId(jwt); + return this.partnerStatisticService.getStatistics(walletId, from, to); + } + + @Get('partner/timeline') + @ApiBearerAuth() + @UseGuards( + AuthGuard(), + RoleGuard(UserRole.CLIENT_COMPANY, UserRole.NON_CUSTODIAL_WALLET_PARTNER), + PartnerStatisticRateLimitGuard, + ) + // 120 req/h per wallet: same budget as summary so a dual-widget dashboard can refresh without 429s + @Throttle(120, 3600) + @ApiOkResponse({ type: PartnerTimelineDto }) + @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 { + const walletId = await this.partnerStatisticService.resolveWalletId(jwt); + return this.partnerStatisticService.getTimeline(walletId, 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..458b5f48bf --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.enum.ts @@ -0,0 +1,54 @@ +import { TransactionSourceType } from 'src/subdomains/supporting/payment/entities/transaction.entity'; + +/** + * 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', +} + +/** 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..9ebedb1694 --- /dev/null +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -0,0 +1,890 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { Util } from 'src/shared/utils/util'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +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, + PartnerStatisticDto, + PartnerTimelineBucketDto, + PartnerTimelineDto, +} from './dto/partner-statistic.dto'; +import { + PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS, + PARTNER_STATISTIC_MAX_PERIOD_DAYS, + PARTNER_STATISTIC_QUERY_CONCURRENCY, + PartnerPaymentMethodMap, + PartnerStatisticDateTruncUnit, + PartnerStatisticDirection, + PartnerStatisticGranularity, +} from './partner-statistic.enum'; + +type Direction = PartnerStatisticDirection; + +interface AggregateRow { + volume: string | number | null; + transactions: 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; +} + +interface DirectionAgg { + volume: number; + transactions: 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 --- // + + /** + * Resolve the wallet whose statistics the caller may read. + * + * The JWT field `user` means two different things: for a company token it is the wallet id + * (auth.service.ts generateCompanyToken), for a normal token it is the user id + * (generateUserToken). Returning it blindly for a normal login would hand out another + * wallet's id and cross tenants. + */ + async resolveWalletId(jwt: JwtPayload): Promise { + if (hasRoleAccess(UserRole.CLIENT_COMPANY, jwt.role)) { + // Role alone is not enough: generateUserToken passes user.role through unchanged and always + // sets account; generateCompanyToken never sets account and puts wallet.id in user. A user + // record carrying a company role + normal login would otherwise treat the user id as a wallet id. + if (jwt.account != null) { + throw new ForbiddenException('Company role requires a company token'); + } + if (jwt.user == null) throw new ForbiddenException('Partner wallet required'); + return jwt.user; + } + + if (hasRoleAccess(UserRole.NON_CUSTODIAL_WALLET_PARTNER, jwt.role)) { + if (jwt.user == null) throw new ForbiddenException('Partner wallet required'); + + const user = await this.userRepo.findOne({ + where: { id: jwt.user }, + relations: { wallet: true }, + }); + const walletId = user?.wallet?.id; + // 403 not 404: caller is authenticated; without a wallet they are not allowed to see partner stats. + // Silent 0 / fallback would either empty-out or (worse) treat the user id as a wallet id. + if (walletId == null) throw new ForbiddenException('User has no wallet'); + return walletId; + } + + throw new ForbiddenException('Insufficient permissions'); + } + + async getStatistics(walletId: number, from?: string | Date, to?: string | Date): Promise { + return this.withQueryGate(async () => { + const period = this.resolvePeriod(from, to); + + 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 averageTransactionVolume = + rawTransactions.total > 0 + ? Util.round(rawVolume.total / rawTransactions.total, Config.defaultVolumeDecimal) + : null; + + return { + period, + currency: 'CHF', + totals: { + volume: rawVolume, + transactions: rawTransactions, + averageTransactionVolume, + activeUsers: activeUsersRaw, + newUsers: newUsersRaw, + }, + allTime: { + volume: allTimeRaw.volume, + registeredUsers: allTimeRaw.registeredUsers, + tradingUsers: allTimeRaw.tradingUsers, + }, + breakdown: { + assets: assetRows, + fiatCurrencies: fiatRows, + blockchains: blockchainRows, + paymentMethods: paymentMethodRows, + }, + referral: { ...referralRaw, currency: 'EUR' }, + meta: { + 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); + + return { + period, + currency: 'CHF', + granularity: resolvedGranularity, + buckets: filled, + meta: { generatedAt: new Date() }, + }; + }); + } + + // --- 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'); + + const raw = await this.runQuery(() => qb.getRawOne()); + return { + volume: this.toVolume(raw?.volume), + transactions: this.toCount(raw?.transactions), + }; + } + + /** + * 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 async aggregateAssets(walletId: number, from: Date, to: Date): Promise { + const [buy, sell, swap] = await this.runAll([ + () => this.assetQuery(PartnerStatisticDirection.BUY, walletId, from, to), + () => this.assetQuery(PartnerStatisticDirection.SELL, walletId, from, to), + () => 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 { + 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') + .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') + .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), + })); + } + + private async aggregateFiatCurrencies(walletId: number, from: Date, to: Date): Promise { + // Buy: inputAsset is the fiat ticker. Sell: outputAsset is Fiat. Swap has no fiat leg. + const buyQb = this.baseTxQuery(PartnerStatisticDirection.BUY, walletId, from, to) + .select('tx.inputAsset', 'name') + .addSelect('COALESCE(SUM(tx.amountInChf), 0)', 'volume') + .addSelect('COUNT(*)', 'transactions') + .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') + .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 { + 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') + .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') + .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 { + 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') + .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') + .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 existing = map.get(key); + if (existing) { + existing.volume = Util.round(existing.volume + volume, Config.defaultVolumeDecimal); + existing.transactions += transactions; + } else { + map.set(key, { volume, transactions }); + } + } + return map; + } + + private fillTimelineGaps( + from: Date, + to: Date, + granularity: PartnerStatisticGranularity, + buy: Map, + sell: Map, + swap: Map, + ): PartnerTimelineBucketDto[] { + const buckets: PartnerTimelineBucketDto[] = []; + let cursor = this.startOfBucket(from, granularity); + // `to` is exclusive (half-open period). + const end = to.getTime(); + + while (cursor.getTime() < end) { + const key = this.bucketKey(cursor); + const b = buy.get(key) ?? { volume: 0, transactions: 0 }; + const s = sell.get(key) ?? { volume: 0, transactions: 0 }; + const w = swap.get(key) ?? { volume: 0, transactions: 0 }; + const next = this.addBucket(cursor, granularity); + // Edge buckets whose natural range extends outside [from, to) are partial. + const partial = cursor.getTime() < from.getTime() || next.getTime() > to.getTime(); + + 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 }, + 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[] { + 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); + if (existing) { + existing.volume = Util.round(existing.volume + volume, Config.defaultVolumeDecimal); + existing.transactions += transactions; + } else { + map.set(row.name, { name: row.name, volume, transactions }); + } + } + + 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` — an absent number is data loss, not a zero. + */ + 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/statistic.module.ts b/src/subdomains/core/statistic/statistic.module.ts index ecbe419f2e..ce7ae91eb3 100644 --- a/src/subdomains/core/statistic/statistic.module.ts +++ b/src/subdomains/core/statistic/statistic.module.ts @@ -1,17 +1,34 @@ 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 { PartnerStatisticRateLimitGuard } from './partner-statistic-rate-limit.guard'; +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, + // DI for PartnerStatisticService in the rate-limit guard (wallet-scoped tracker). + PartnerStatisticRateLimitGuard, + // BuyCryptoRepository is exported by BuyCryptoModule (already imported) — do not re-provide. + // BuyFiatRepository is not in SellCryptoModule.exports — provide locally. + BuyFiatRepository, + // UserRepository / WalletRepository are not in UserModule.exports — provide locally. + UserRepository, + WalletRepository, + ], exports: [], }) export class StatisticModule {}