From 7b81b9b7e377a9a7d10b3745e49d07193a51653c Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 22:49:55 +0200 Subject: [PATCH] Add partner statistic completion funnels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stage A (payment-info request → payment received) and stage B (settlement received → delivered / rejected / in progress) to the partner summary. Funnel members form additive groups and use the same block suppression as period totals: if any counter is under the threshold the whole direction funnel and its rate are null, so rejected = received − delivered − inProgress cannot be reconstructed from visible fields. --- .../partner-statistic.integration.spec.ts | 8 +- .../partner-statistic.service.spec.ts | 138 +++++++++++++ .../statistic/dto/partner-statistic.dto.ts | 133 +++++++++++++ .../statistic/partner-statistic.service.ts | 181 ++++++++++++++++++ .../core/statistic/statistic.module.ts | 2 + 5 files changed, 461 insertions(+), 1 deletion(-) diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts index 10510130e7..b8dfc10e39 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.integration.spec.ts @@ -113,7 +113,13 @@ describeDb('PartnerStatisticService SQL path (real Postgres)', () => { `); // Lightweight service shell for mergeNamedRows only (repos unused for SQL below). - service = new PartnerStatisticService({ manager: dataSource.manager } as any, {} as any, {} as any, {} as any); + service = new PartnerStatisticService( + { manager: dataSource.manager } as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); }); afterEach(async () => { diff --git a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts index ecce54fce7..18c0aed1ef 100644 --- a/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts +++ b/src/subdomains/core/statistic/__tests__/partner-statistic.service.spec.ts @@ -6,6 +6,11 @@ import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repo 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 { + TransactionRequestStatus, + TransactionRequestType, +} from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { TransactionRequestRepository } from 'src/subdomains/supporting/payment/repositories/transaction-request.repository'; import { PARTNER_STATISTIC_DEFAULT_PERIOD_DAYS, PARTNER_STATISTIC_MAX_PERIOD_DAYS, @@ -35,6 +40,12 @@ interface SettlementFixture { rejected: number; } +interface PaymentInfoRow { + type: TransactionRequestType; + status: TransactionRequestStatus; + count: number; +} + interface DirectionFixture { volume: number; transactions: number; @@ -49,6 +60,7 @@ interface WalletFixture { newUsers: number; allTime: { buy: number; sell: number; registeredUsers: number; tradingUsers: number }; referral: { volume: number; partnerRefCredit: number; refCredit: number; paidRefCredit: number }; + paymentInfo: PaymentInfoRow[]; settlement: Record; /** Named breakdown rows returned by getRawMany for asset/fiat/blockchain/payment queries. */ namedRows: { name: string; blockchain?: string; volume: number; transactions: number; users: number }[]; @@ -68,6 +80,7 @@ function emptyFixture(overrides: Partial = {}): WalletFixture { newUsers: 0, allTime: { buy: 0, sell: 0, registeredUsers: 0, tradingUsers: 0 }, referral: { volume: 0, partnerRefCredit: 0, refCredit: 0, paidRefCredit: 0 }, + paymentInfo: [], settlement: { [PartnerStatisticDirection.BUY]: emptySettlement(), [PartnerStatisticDirection.SELL]: emptySettlement(), @@ -148,6 +161,7 @@ describe('PartnerStatisticService', () => { merged.referral.partnerRefCredit += f.referral.partnerRefCredit; merged.referral.refCredit += f.referral.refCredit; merged.referral.paidRefCredit += f.referral.paidRefCredit; + merged.paymentInfo.push(...f.paymentInfo); for (const d of [ PartnerStatisticDirection.BUY, PartnerStatisticDirection.SELL, @@ -293,6 +307,10 @@ describe('PartnerStatisticService', () => { qb.getRawMany = jest.fn(async () => { getRawManyCalls += 1; + if (kind === 'txRequest' || state.isPaymentInfo) { + const f = fixtureFor(state.walletId); + return f.paymentInfo.map((r) => ({ type: r.type, status: r.status, count: r.count })); + } if (state.isTimeline) { const f = fixtureFor(state.walletId); return f.timelineRows.map((r) => ({ @@ -366,6 +384,10 @@ describe('PartnerStatisticService', () => { { provide: BuyFiatRepository, useValue: { createQueryBuilder: jest.fn(() => createQb('buyFiat')) } }, { provide: UserRepository, useValue: { createQueryBuilder: jest.fn(() => createQb('user')) } }, { provide: WalletRepository, useValue: { createQueryBuilder: jest.fn(() => createQb('wallet')) } }, + { + provide: TransactionRequestRepository, + useValue: { createQueryBuilder: jest.fn(() => createQb('txRequest')) }, + }, ], }).compile(); @@ -445,6 +467,7 @@ describe('PartnerStatisticService', () => { allTime: { buy: 5000, sell: 1000, registeredUsers: 100, tradingUsers: 40 }, newUsers: 8, activeUserIds: [1, 2, 3, 4, 5, 6], + paymentInfo: [{ type: TransactionRequestType.BUY, status: TransactionRequestStatus.CREATED, count: 10 }], }), ); @@ -467,6 +490,8 @@ describe('PartnerStatisticService', () => { 'inputAsset.blockchain', 'outputAsset.name', 'outputAsset.blockchain', + 'tr.type', + 'tr.status', ]), ); }); @@ -486,6 +511,7 @@ describe('PartnerStatisticService', () => { referral: { volume: 50, partnerRefCredit: 10, refCredit: 0, paidRefCredit: 4 }, newUsers: 8, activeUserIds: [11, 12, 13, 14, 15, 16], + paymentInfo: [{ type: TransactionRequestType.BUY, status: TransactionRequestStatus.COMPLETED, count: 20 }], settlement: { // inProgress = received − delivered − rejected must also be 0 or ≥ k [PartnerStatisticDirection.BUY]: { received: 20, delivered: 15, rejected: 0 }, @@ -504,6 +530,7 @@ describe('PartnerStatisticService', () => { referral: { volume: 12345, partnerRefCredit: 999, refCredit: 0, paidRefCredit: 111 }, newUsers: 500, activeUserIds: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30], + paymentInfo: [{ type: TransactionRequestType.BUY, status: TransactionRequestStatus.COMPLETED, count: 9000 }], settlement: { [PartnerStatisticDirection.BUY]: { received: 9000, delivered: 8000, rejected: 100 }, [PartnerStatisticDirection.SELL]: emptySettlement(), @@ -527,6 +554,8 @@ describe('PartnerStatisticService', () => { expect(amlFilterClauses.length).toBeGreaterThan(0); expect(amlFilterClauses.every((c) => /amlCheck\s*=\s*:check/.test(c))).toBe(true); + expect(settlementAmlPassOnly.length).toBeGreaterThan(0); + expect(settlementAmlPassOnly.every((v) => v === false)).toBe(true); expect(result.totals.volume.buy).toBe(1000); expect(result.totals.volume.sell).toBe(200); @@ -540,6 +569,11 @@ describe('PartnerStatisticService', () => { expect(result.allTime.registeredUsers).not.toBe(9999); expect(result.referral.volume).not.toBe(12345); expect(result.totals.newUsers).not.toBe(500); + + expect(result.completion.paymentInfoRequests.buy.paymentReceived).toBe(20); + expect(result.completion.paymentInfoRequests.buy.paymentReceived).not.toBe(9000); + expect(result.completion.settlement.buy.received).toBe(20); + expect(result.completion.settlement.buy.received).not.toBe(9000); }); it('returns only wallet B aggregates when called with wallet B (not wallet A)', async () => { @@ -554,6 +588,110 @@ describe('PartnerStatisticService', () => { expect(result.totals.volume.buy).toBe(99999); expect(result.totals.volume.buy).not.toBe(1000); + expect(result.completion.paymentInfoRequests.buy.paymentReceived).toBe(9000); + expect(result.completion.settlement.buy.received).toBe(9000); + }); + }); + + // --- COMPLETION --- // + + describe('completion funnels', () => { + it('computes stage A payment-info funnel counts and receivedRate as paymentReceived/requested', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + allTime: { buy: 1000, sell: 0, registeredUsers: 20, tradingUsers: 10 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + paymentInfo: [ + { type: TransactionRequestType.BUY, status: TransactionRequestStatus.CREATED, count: 80 }, + { type: TransactionRequestType.BUY, status: TransactionRequestStatus.WAITING_FOR_PAYMENT, count: 5 }, + { type: TransactionRequestType.BUY, status: TransactionRequestStatus.COMPLETED, count: 15 }, + { type: TransactionRequestType.SELL, status: TransactionRequestStatus.CREATED, count: 10 }, + { type: TransactionRequestType.SELL, status: TransactionRequestStatus.COMPLETED, count: 5 }, + { type: TransactionRequestType.SWAP, status: TransactionRequestStatus.COMPLETED, count: 5 }, + ], + settlement: { + [PartnerStatisticDirection.BUY]: { received: 20, delivered: 15, rejected: 0 }, + [PartnerStatisticDirection.SELL]: { received: 5, delivered: 5, rejected: 0 }, + [PartnerStatisticDirection.SWAP]: { received: 5, delivered: 5, rejected: 0 }, + }, + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + const buyA = result.completion.paymentInfoRequests.buy; + + expect(buyA.requested).toBe(100); + expect(buyA.noPaymentReceived).toBe(80); + expect(buyA.waitingForPayment).toBe(5); + expect(buyA.paymentReceived).toBe(15); + expect(buyA.receivedRate).toBe(0.15); + expect(buyA.receivedRate).not.toBe(Util.round(100 / 15, 4)); + + expect(result.completion.paymentInfoRequests.sell.requested).toBe(15); + expect(result.completion.paymentInfoRequests.sell.paymentReceived).toBe(5); + expect(result.completion.paymentInfoRequests.swap.paymentReceived).toBe(5); + }); + + it('computes stage B settlement counts and deliveredRate as delivered/received', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + allTime: { buy: 1000, sell: 0, registeredUsers: 20, tradingUsers: 10 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + settlement: { + [PartnerStatisticDirection.BUY]: { received: 40, delivered: 30, rejected: 5 }, + [PartnerStatisticDirection.SELL]: { received: 0, delivered: 0, rejected: 0 }, + [PartnerStatisticDirection.SWAP]: { received: 20, delivered: 10, rejected: 5 }, + }, + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + const buyB = result.completion.settlement.buy; + + expect(buyB.received).toBe(40); + expect(buyB.delivered).toBe(30); + expect(buyB.rejected).toBe(5); + expect(buyB.inProgress).toBe(5); + expect(buyB.deliveredRate).toBe(0.75); + + expect(result.completion.settlement.sell.received).toBe(0); + expect(result.completion.settlement.sell.deliveredRate).toBeNull(); + expect(result.completion.settlement.swap.inProgress).toBe(5); + }); + + it('block-suppresses the entire funnel when any counter is under k', async () => { + fixtures.set( + 1, + emptyFixture({ + buy: { volume: 1000, transactions: 20, users: 10 }, + allTime: { buy: 1000, sell: 0, registeredUsers: 20, tradingUsers: 10 }, + activeUserIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + paymentInfo: [{ type: TransactionRequestType.BUY, status: TransactionRequestStatus.CREATED, count: 4 }], + settlement: { + [PartnerStatisticDirection.BUY]: { received: 10, delivered: 3, rejected: 0 }, + [PartnerStatisticDirection.SELL]: emptySettlement(), + [PartnerStatisticDirection.SWAP]: emptySettlement(), + }, + }), + ); + + const result = await service.getStatistics(1, PERIOD_FROM, PERIOD_TO); + + // Stage A: requested=4 under k → entire group null (zeros stay 0) + expect(result.completion.paymentInfoRequests.buy.requested).toBeNull(); + expect(result.completion.paymentInfoRequests.buy.noPaymentReceived).toBeNull(); + expect(result.completion.paymentInfoRequests.buy.paymentReceived).toBe(0); + expect(result.completion.paymentInfoRequests.buy.receivedRate).toBeNull(); + + // Stage B: delivered=3 under k → block entire settlement group + expect(result.completion.settlement.buy.received).toBeNull(); + expect(result.completion.settlement.buy.delivered).toBeNull(); + expect(result.completion.settlement.buy.deliveredRate).toBeNull(); + expect(result.completion.settlement.buy.rejected).toBe(0); }); }); diff --git a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts index 86564c31cb..b9a9c842e3 100644 --- a/src/subdomains/core/statistic/dto/partner-statistic.dto.ts +++ b/src/subdomains/core/statistic/dto/partner-statistic.dto.ts @@ -202,6 +202,136 @@ export class PartnerReferralDto { currency: 'EUR'; } +// --- COMPLETION (funnels) --- // + +/** + * Stage A: payment-info quote requests → payment received. + * Each UI amount change creates a new transaction_request — this is NOT a user conversion rate. + */ +export class PartnerPaymentInfoDirectionDto { + @ApiProperty({ + type: Number, + nullable: true, + description: + 'Count of payment-info quote requests (transaction_request rows) in the period. ' + + 'One row is created on every payment-info fetch (e.g. amount change in the UI), not once per purchase intent. ' + + 'Null when any funnel member is under the suppression threshold (block suppression).', + }) + requested: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Requests that reached status Completed (a payment was linked).', + }) + paymentReceived: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Requests still in status WaitingForPayment.', + }) + waitingForPayment: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'Requests left in status Created (quote shown, no payment followed).', + }) + noPaymentReceived: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: + 'paymentReceived / requested (0..1, 4 dp). Share of payment-info requests that got a payment — ' + + 'not a per-user conversion rate. Null if denominator is 0 or the funnel group was suppressed.', + }) + receivedRate: number | null; +} + +export class PartnerPaymentInfoRequestsDto { + @ApiProperty({ type: PartnerPaymentInfoDirectionDto }) + buy: PartnerPaymentInfoDirectionDto; + + @ApiProperty({ type: PartnerPaymentInfoDirectionDto }) + sell: PartnerPaymentInfoDirectionDto; + + @ApiProperty({ type: PartnerPaymentInfoDirectionDto }) + swap: PartnerPaymentInfoDirectionDto; +} + +/** + * Stage B: payment received (buy_crypto / buy_fiat created) → delivered or rejected. + * Note: `delivered` requires amlCheck=Pass AND isComplete=true, which is a subset of + * `totals.transactions` (those only require amlCheck=Pass, including still-in-flight payouts). + */ +export class PartnerSettlementDirectionDto { + @ApiProperty({ + type: Number, + nullable: true, + description: + 'All buy_crypto/buy_fiat rows in the period for this direction (no amlCheck filter). ' + + 'Null when any funnel member is under the suppression threshold (block suppression).', + }) + received: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: + 'amlCheck=Pass AND isComplete=true. Stricter than totals.transactions (which counts all Pass, ' + + 'including rows not yet fully paid out).', + }) + delivered: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'amlCheck=Fail (regardless of isComplete).', + }) + rejected: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'received − delivered − rejected (pending AML, in-flight payout, etc.).', + }) + inProgress: number | null; + + @ApiProperty({ + type: Number, + nullable: true, + description: 'delivered / received (0..1, 4 dp). Null if denominator is 0 or the funnel group was suppressed.', + }) + deliveredRate: number | null; +} + +export class PartnerSettlementDto { + @ApiProperty({ type: PartnerSettlementDirectionDto }) + buy: PartnerSettlementDirectionDto; + + @ApiProperty({ type: PartnerSettlementDirectionDto }) + sell: PartnerSettlementDirectionDto; + + @ApiProperty({ type: PartnerSettlementDirectionDto }) + swap: PartnerSettlementDirectionDto; +} + +export class PartnerCompletionDto { + @ApiProperty({ + type: PartnerPaymentInfoRequestsDto, + description: 'Stage A: payment-info requests → payment received (transaction_request funnel).', + }) + paymentInfoRequests: PartnerPaymentInfoRequestsDto; + + @ApiProperty({ + type: PartnerSettlementDto, + description: 'Stage B: payment entered the system → delivered / rejected / in progress.', + }) + settlement: PartnerSettlementDto; +} + // --- SUMMARY RESPONSE --- // export class PartnerStatisticDto { @@ -223,6 +353,9 @@ export class PartnerStatisticDto { @ApiProperty({ type: PartnerReferralDto }) referral: PartnerReferralDto; + @ApiProperty({ type: PartnerCompletionDto }) + completion: PartnerCompletionDto; + @ApiProperty({ type: PartnerStatisticMetaDto }) meta: PartnerStatisticMetaDto; } diff --git a/src/subdomains/core/statistic/partner-statistic.service.ts b/src/subdomains/core/statistic/partner-statistic.service.ts index 6d2c57adfa..05d723ba00 100644 --- a/src/subdomains/core/statistic/partner-statistic.service.ts +++ b/src/subdomains/core/statistic/partner-statistic.service.ts @@ -6,14 +6,22 @@ import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repo 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 { + TransactionRequestStatus, + TransactionRequestType, +} from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionSourceType } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { TransactionRequestRepository } from 'src/subdomains/supporting/payment/repositories/transaction-request.repository'; 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, + PartnerCompletionDto, PartnerNamedBreakdownDto, + PartnerPaymentInfoDirectionDto, PartnerReferralDto, + PartnerSettlementDirectionDto, PartnerStatisticDto, PartnerTimelineBucketDto, PartnerTimelineDto, @@ -28,6 +36,7 @@ import { PartnerStatisticGranularity, } from './partner-statistic.enum'; import { + suppressAdditiveGroup, suppressAllTimeVolume, suppressBreakdownRows, suppressPeriodTotals, @@ -73,6 +82,7 @@ export class PartnerStatisticService { private readonly buyFiatRepo: BuyFiatRepository, private readonly userRepo: UserRepository, private readonly walletRepo: WalletRepository, + private readonly txRequestRepo: TransactionRequestRepository, ) {} // --- PUBLIC API --- // @@ -92,6 +102,7 @@ export class PartnerStatisticService { fiatRows, blockchainRows, paymentMethodRows, + completionResult, ] = await this.runLimited([ () => this.aggregateByDirection(walletId, period.from, period.to, PartnerStatisticDirection.BUY), () => this.aggregateByDirection(walletId, period.from, period.to, PartnerStatisticDirection.SELL), @@ -104,6 +115,7 @@ export class PartnerStatisticService { () => this.aggregateFiatCurrencies(walletId, period.from, period.to), () => this.aggregateBlockchains(walletId, period.from, period.to), () => this.aggregatePaymentMethods(walletId, period.from, period.to), + () => this.getCompletion(walletId, period.from, period.to), ]); const rawVolume = { @@ -150,6 +162,7 @@ export class PartnerStatisticService { paymentMethods.suppressedCount + totalsSuppressed.suppressedCount + allTimeSuppressed.suppressedCount + + completionResult.suppressedCount + (activeUsers === null ? 1 : 0) + (newUsers === null ? 1 : 0) + (tradingUsersSuppressed === null ? 1 : 0) + @@ -177,6 +190,7 @@ export class PartnerStatisticService { paymentMethods: paymentMethods.rows.map(({ users: _u, ...row }) => row), }, referral, + completion: completionResult.completion, meta: { suppressionThreshold: PARTNER_STATISTIC_SUPPRESSION_THRESHOLD, suppressedBuckets, @@ -640,6 +654,173 @@ export class PartnerStatisticService { return buckets; } + // --- COMPLETION (stage A + B) --- // + + private async getCompletion( + walletId: number, + from: Date, + to: Date, + ): Promise<{ completion: PartnerCompletionDto; suppressedCount: number }> { + const [paymentInfoRaw, buySettlement, sellSettlement, swapSettlement] = await this.runLimited([ + () => this.aggregatePaymentInfoRequests(walletId, from, to), + () => this.aggregateSettlement(walletId, from, to, PartnerStatisticDirection.BUY), + () => this.aggregateSettlement(walletId, from, to, PartnerStatisticDirection.SELL), + () => this.aggregateSettlement(walletId, from, to, PartnerStatisticDirection.SWAP), + ]); + + let suppressedCount = 0; + + const paymentInfoRequests = { + buy: this.suppressPaymentInfoFunnel(paymentInfoRaw.buy, (n) => { + suppressedCount += n; + }), + sell: this.suppressPaymentInfoFunnel(paymentInfoRaw.sell, (n) => { + suppressedCount += n; + }), + swap: this.suppressPaymentInfoFunnel(paymentInfoRaw.swap, (n) => { + suppressedCount += n; + }), + }; + + const settlement = { + buy: this.suppressSettlementFunnel(buySettlement, (n) => { + suppressedCount += n; + }), + sell: this.suppressSettlementFunnel(sellSettlement, (n) => { + suppressedCount += n; + }), + swap: this.suppressSettlementFunnel(swapSettlement, (n) => { + suppressedCount += n; + }), + }; + + return { completion: { paymentInfoRequests, settlement }, suppressedCount }; + } + + private async aggregatePaymentInfoRequests( + walletId: number, + from: Date, + to: Date, + ): Promise< + Record< + Direction, + { requested: number; paymentReceived: number; waitingForPayment: number; noPaymentReceived: number } + > + > { + const empty = () => ({ requested: 0, paymentReceived: 0, waitingForPayment: 0, noPaymentReceived: 0 }); + const byDir: Record> = { + [PartnerStatisticDirection.BUY]: empty(), + [PartnerStatisticDirection.SELL]: empty(), + [PartnerStatisticDirection.SWAP]: empty(), + }; + + const rows = await this.txRequestRepo + .createQueryBuilder('tr') + .innerJoin('tr.user', 'user') + .select('tr.type', 'type') + .addSelect('tr.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('user.walletId = :walletId', { walletId }) + .andWhere('tr.created >= :from AND tr.created < :to', { from, to }) + .groupBy('tr.type') + .addGroupBy('tr.status') + .getRawMany<{ type: string; status: string; count: string | number }>(); + + const typeToDir: Record = { + [TransactionRequestType.BUY]: PartnerStatisticDirection.BUY, + [TransactionRequestType.SELL]: PartnerStatisticDirection.SELL, + [TransactionRequestType.SWAP]: PartnerStatisticDirection.SWAP, + }; + + for (const row of rows) { + const dir = typeToDir[row.type]; + if (!dir) continue; + const count = this.toCount(row.count); + byDir[dir].requested += count; + if (row.status === TransactionRequestStatus.COMPLETED) byDir[dir].paymentReceived += count; + else if (row.status === TransactionRequestStatus.WAITING_FOR_PAYMENT) byDir[dir].waitingForPayment += count; + else if (row.status === TransactionRequestStatus.CREATED) byDir[dir].noPaymentReceived += count; + } + + return byDir; + } + + private async aggregateSettlement( + walletId: number, + from: Date, + to: Date, + direction: Direction, + ): Promise<{ received: number; delivered: number; rejected: number; inProgress: number }> { + const pass = CheckStatus.PASS; + const fail = CheckStatus.FAIL; + + const qb = this.baseTxQuery(direction, walletId, from, to, { amlPassOnly: false }); + qb.select('COUNT(*)', 'received') + .addSelect( + `COALESCE(SUM(CASE WHEN tx.amlCheck = :passCheck AND tx.isComplete = true THEN 1 ELSE 0 END), 0)`, + 'delivered', + ) + .addSelect(`COALESCE(SUM(CASE WHEN tx.amlCheck = :failCheck THEN 1 ELSE 0 END), 0)`, 'rejected') + .setParameter('passCheck', pass) + .setParameter('failCheck', fail); + + const raw = await qb.getRawOne<{ received: string; delivered: string; rejected: string }>(); + const received = this.toCount(raw?.received); + const delivered = this.toCount(raw?.delivered); + const rejected = this.toCount(raw?.rejected); + const inProgress = Math.max(received - delivered - rejected, 0); + + return { received, delivered, rejected, inProgress }; + } + + private suppressPaymentInfoFunnel( + raw: { requested: number; paymentReceived: number; waitingForPayment: number; noPaymentReceived: number }, + onSuppressed: (n: number) => void, + ): PartnerPaymentInfoDirectionDto { + const { values, rate, suppressedCount } = suppressAdditiveGroup( + { + requested: raw.requested, + paymentReceived: raw.paymentReceived, + waitingForPayment: raw.waitingForPayment, + noPaymentReceived: raw.noPaymentReceived, + }, + { numeratorKey: 'paymentReceived', denominatorKey: 'requested' }, + ); + onSuppressed(suppressedCount); + + return { + requested: values.requested, + paymentReceived: values.paymentReceived, + waitingForPayment: values.waitingForPayment, + noPaymentReceived: values.noPaymentReceived, + receivedRate: rate, + }; + } + + private suppressSettlementFunnel( + raw: { received: number; delivered: number; rejected: number; inProgress: number }, + onSuppressed: (n: number) => void, + ): PartnerSettlementDirectionDto { + const { values, rate, suppressedCount } = suppressAdditiveGroup( + { + received: raw.received, + delivered: raw.delivered, + rejected: raw.rejected, + inProgress: raw.inProgress, + }, + { numeratorKey: 'delivered', denominatorKey: 'received' }, + ); + onSuppressed(suppressedCount); + + return { + received: values.received, + delivered: values.delivered, + rejected: values.rejected, + inProgress: values.inProgress, + deliveredRate: rate, + }; + } + // --- QUERY BUILDERS --- // /** diff --git a/src/subdomains/core/statistic/statistic.module.ts b/src/subdomains/core/statistic/statistic.module.ts index 359ed5fcbb..f2022eaf1b 100644 --- a/src/subdomains/core/statistic/statistic.module.ts +++ b/src/subdomains/core/statistic/statistic.module.ts @@ -6,6 +6,7 @@ import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-f 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 { TransactionRequestRepository } from 'src/subdomains/supporting/payment/repositories/transaction-request.repository'; import { BuyCryptoModule } from '../buy-crypto/buy-crypto.module'; import { ReferralModule } from '../referral/referral.module'; import { SellCryptoModule } from '../sell-crypto/sell-crypto.module'; @@ -25,6 +26,7 @@ import { StatisticService } from './statistic.service'; BuyFiatRepository, UserRepository, WalletRepository, + TransactionRequestRepository, ], exports: [], })