Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,6 +40,12 @@ interface SettlementFixture {
rejected: number;
}

interface PaymentInfoRow {
type: TransactionRequestType;
status: TransactionRequestStatus;
count: number;
}

interface DirectionFixture {
volume: number;
transactions: number;
Expand All @@ -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<Direction, SettlementFixture>;
/** Named breakdown rows returned by getRawMany for asset/fiat/blockchain/payment queries. */
namedRows: { name: string; blockchain?: string; volume: number; transactions: number; users: number }[];
Expand All @@ -68,6 +80,7 @@ function emptyFixture(overrides: Partial<WalletFixture> = {}): 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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 }],
}),
);

Expand All @@ -467,6 +490,8 @@ describe('PartnerStatisticService', () => {
'inputAsset.blockchain',
'outputAsset.name',
'outputAsset.blockchain',
'tr.type',
'tr.status',
]),
);
});
Expand All @@ -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 },
Expand All @@ -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(),
Expand All @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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);
});
});

Expand Down
133 changes: 133 additions & 0 deletions src/subdomains/core/statistic/dto/partner-statistic.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -223,6 +353,9 @@ export class PartnerStatisticDto {
@ApiProperty({ type: PartnerReferralDto })
referral: PartnerReferralDto;

@ApiProperty({ type: PartnerCompletionDto })
completion: PartnerCompletionDto;

@ApiProperty({ type: PartnerStatisticMetaDto })
meta: PartnerStatisticMetaDto;
}
Expand Down
Loading