From e855252b09ccd6ea5058e22d79c4cc5d121efb34 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 12:19:32 +0200 Subject: [PATCH 1/2] Gate the partner payments API behind an explicit wallet flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/kyc/client/payments returns TransactionDetailDto / PaymentWebhookData with customer IBANs, user addresses, on-chain hashes, amounts and rates. The per-user path /kyc/client/users/:id/payments is additional depth defense behind KYC_CLIENT_COMPANY and is gated by the same service flag. Access on the list endpoint was only RoleGuard(CLIENT_COMPANY). That role is granted to every wallet with an address on company login, so setting an address on a partner wallet silently opened the full transaction dump. Add wallet.paymentsApiEnabled (boolean, NOT NULL DEFAULT false) and check it fail-closed on both payment pull paths and on PAYMENT webhooks (KYC webhook types unchanged). Ops enable/disable via admin PUT /wallet/:id; create/update invalidate the wallet cache so an emergency disable is not delayed by the 5-minute cache. The flag is on the GS debug allowlist for support. Backfill: SET paymentsApiEnabled = true WHERE address IS NOT NULL. Why not isKycClient: #2939 (c20190a869) introduced CLIENT_COMPANY and widened GET /kyc/client/payments to that role — effective for wallets with isKycClient = false. Backfilling isKycClient = true would exclude exactly those partners. Every wallet that can company-sign-in today (address set) keeps its access; new wallets start false so a newly set address does not open payments by itself. Behaviour change for existing partners (pull path): the list endpoints now apply the same payment webhook consent rule as the push path (webhookConfig.payment True / ConsentOnly / WalletOnly, with consented=false for the own-wallet channel and consented=true when the wallet is on the user's kycClientList). Users that neither channel admits drop out of the result. Partners that previously listed all wallet users regardless of webhookConfig will see fewer rows after deploy. --- .env.example | 4 + ...85550000000-AddWalletPaymentsApiEnabled.js | 83 +++++++ src/subdomains/generic/gs/dto/gs.dto.ts | 1 + .../__tests__/kyc-client.service.spec.ts | 215 ++++++++++++++++++ .../kyc/services/kyc-client.service.ts | 43 +++- ...let-payments-api-enabled.migration.spec.ts | 207 +++++++++++++++++ .../wallet/__tests__/wallet.entity.spec.ts | 95 ++++++++ .../wallet/__tests__/wallet.service.spec.ts | 52 +++++ .../user/models/wallet/dto/wallet.dto.ts | 4 + .../user/models/wallet/wallet.entity.ts | 20 +- .../user/models/wallet/wallet.service.ts | 9 +- 11 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 migration/1785550000000-AddWalletPaymentsApiEnabled.js create mode 100644 src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts create mode 100644 src/subdomains/generic/user/models/wallet/__tests__/wallet-payments-api-enabled.migration.spec.ts create mode 100644 src/subdomains/generic/user/models/wallet/__tests__/wallet.entity.spec.ts create mode 100644 src/subdomains/generic/user/models/wallet/__tests__/wallet.service.spec.ts diff --git a/.env.example b/.env.example index 5e7045785c..6d6faa8b93 100644 --- a/.env.example +++ b/.env.example @@ -344,3 +344,7 @@ REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05 REQUEST_KNOWN_IPS= CRON_JOB_DELAY= + +# Optional: connection string to a throwaway Postgres for the migration specs. +# Unset, the "(real Postgres)" describe blocks skip. Existing convention. +MIGRATION_TEST_PG= diff --git a/migration/1785550000000-AddWalletPaymentsApiEnabled.js b/migration/1785550000000-AddWalletPaymentsApiEnabled.js new file mode 100644 index 0000000000..72c28e8b45 --- /dev/null +++ b/migration/1785550000000-AddWalletPaymentsApiEnabled.js @@ -0,0 +1,83 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Partner Payments API gate (GET /v1/kyc/client/payments; the per-user path + * /kyc/client/users/:id/payments is an additional depth-defense surface behind + * KYC_CLIENT_COMPANY and is gated by the same flag in the service). + * + * GET /v1/kyc/client/payments returns PaymentWebhookData / TransactionDetailDto including + * customer IBANs, wallet addresses, on-chain hashes, amounts and rates. Access is currently + * gated by RoleGuard(UserRole.CLIENT_COMPANY). That role is granted to every wallet that has + * an address on company login (auth.service generateCompanyToken / companySignIn), so any + * new partner wallet would automatically see the full transaction dump without an explicit + * enablement decision. + * + * This migration adds wallet.paymentsApiEnabled (boolean NOT NULL DEFAULT false) — fail-closed: + * a new or unknown wallet has no payments API access until ops deliberately flip the flag + * (via the existing admin PUT /wallet/:id, which maps WalletDto including this field). + * + * Backfill: SET paymentsApiEnabled = true WHERE address IS NOT NULL. + * + * Why not isKycClient: that flag is the wrong predicate. The role CLIENT_COMPANY is granted + * precisely for wallets that can company-sign-in without being a KYC client + * (isKycClient = false). A backfill over isKycClient = true would by construction exclude + * exactly the partners the endpoint was opened for. Because 403 is excluded from WARN logging + * in exception.filter.ts, that loss of access would have gone unnoticed. + * + * Semantically, every wallet that can sign in as a company today (companySignIn requires a + * set address) keeps exactly the access it has. Newly created wallets start at false, so a + * newly set address no longer opens the payments door by itself — the purpose of the flag. + * + * Ownership: this migration creates "wallet"."paymentsApiEnabled". down() first persists the + * current per-wallet flag values into the immutable "log" table (so ops flips after deploy + * remain reconstructible), then drops only that column. No other row or column is affected. + * + * Verified on: throwaway Postgres 15 via docker (column add + backfill + drop; up → down → up + * with existing rows, with and without address, independent of isKycClient). Runs at boot via + * SQL_MIGRATE (fail-closed). + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddWalletPaymentsApiEnabled1785550000000 { + name = 'AddWalletPaymentsApiEnabled1785550000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "wallet" ADD "paymentsApiEnabled" boolean NOT NULL DEFAULT false`); + // Preserve access for every wallet that can company-sign-in today (address set). + // See file header for why isKycClient is the wrong predicate. + await queryRunner.query(`UPDATE "wallet" SET "paymentsApiEnabled" = true WHERE "address" IS NOT NULL`); + } + + /** + * Persist the current paymentsApiEnabled value per wallet into the immutable "log" table, + * then drop the column. + * + * After deploy, ops may flip individual wallets true/false. Dropping without a prior record + * would destroy those decisions; a later up() would only re-derive from address. + * + * Fail-closed: the audit INSERT runs before the DROP in the same migration transaction. If + * the audit write fails, the DROP is never reached and the transaction rolls back both — + * the column stays until the prior state is durable in "log". + * + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(` +INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message") +SELECT now(), now(), 'Wallet', 'PaymentsApiEnabledRollback', 'Info', + COALESCE(json_agg(json_build_object( + 'id', "id", + 'paymentsApiEnabled', "paymentsApiEnabled" + ) ORDER BY "id")::text, '[]') +FROM "wallet" +`); + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "paymentsApiEnabled"`); + } +}; diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index e1cb081930..6051d1d175 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -1590,6 +1590,7 @@ export const DebugAllowedColumns: Record = { 'isKycClient', 'name', 'ownerId', + 'paymentsApiEnabled', 'usesDummyAddresses', ], }, diff --git a/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts new file mode 100644 index 0000000000..e0340b5fb8 --- /dev/null +++ b/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts @@ -0,0 +1,215 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ForbiddenException } from '@nestjs/common'; +import { Util } from 'src/shared/utils/util'; +import { BuyCryptoWebhookService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service'; +import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { Wallet, WebhookConfigOption } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { WalletService } from 'src/subdomains/generic/user/models/wallet/wallet.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { KycDocumentService } from '../integration/kyc-document.service'; +import { KycClientService } from '../kyc-client.service'; + +describe('KycClientService payments API gate', () => { + let service: KycClientService; + let walletService: jest.Mocked; + let transactionService: jest.Mocked; + + // Single calendar anchor; ranges derived so fixtures do not go stale by year. + const TEST_NOW = new Date('2024-06-15'); + const dateFrom = Util.daysBefore(30, TEST_NOW); + const dateTo = Util.daysAfter(30, TEST_NOW); + + const userDataWith = (kycClients?: string): UserData => Object.assign(new UserData(), { id: 1, kycClients }); + + const userWith = (id: number, address: string, kycClients?: string) => + ({ + id, + address, + userData: userDataWith(kycClients), + }) as Wallet['users'][number]; + + const paymentConfig = (payment: WebhookConfigOption) => JSON.stringify({ payment, kyc: WebhookConfigOption.TRUE }); + + const walletWith = (overrides: { + paymentsApiEnabled?: boolean; + paymentOption?: WebhookConfigOption; + users?: Wallet['users']; + }): Wallet => + Object.assign(new Wallet(), { + id: 7, + users: overrides.users ?? [userWith(42, '0xUSER')], + ...(overrides.paymentsApiEnabled === undefined ? {} : { paymentsApiEnabled: overrides.paymentsApiEnabled }), + webhookConfig: paymentConfig(overrides.paymentOption ?? WebhookConfigOption.TRUE), + }); + + beforeEach(() => { + walletService = createMock(); + transactionService = createMock(); + transactionService.getTransactionsForUsers.mockResolvedValue([]); + + service = new KycClientService( + createMock(), + createMock(), + walletService, + createMock(), + createMock(), + transactionService, + ); + }); + + describe('getAllPayments', () => { + it('throws ForbiddenException, logs, and loads no transactions when paymentsApiEnabled is false', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: false })); + const warn = jest.spyOn((service as any).logger, 'warn').mockImplementation(); + + await expect(service.getAllPayments(7, dateFrom, dateTo)).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.getAllPayments(7, dateFrom, dateTo)).rejects.toThrow( + 'Payments API not enabled for this wallet', + ); + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('Payments API denied for wallet 7'); + }); + + it('returns payments when paymentsApiEnabled is true and payment option admits the own-wallet channel', async () => { + walletService.getByIdOrName.mockResolvedValue( + walletWith({ paymentsApiEnabled: true, paymentOption: WebhookConfigOption.TRUE }), + ); + + const result = await service.getAllPayments(7, dateFrom, dateTo, 100); + + expect(result).toEqual([]); + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([42], dateFrom, dateTo, 100); + }); + + it('rejects a freshly created wallet (paymentsApiEnabled defaults to false / unset)', async () => { + // Mirrors TypeORM entity construction before the column is set — fail-closed on !== true. + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: undefined })); + + await expect(service.getAllPayments(7, dateFrom, dateTo)).rejects.toBeInstanceOf(ForbiddenException); + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + }); + }); + + describe('getAllUserPayments', () => { + it('throws ForbiddenException and loads no transactions when paymentsApiEnabled is false', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: false })); + + await expect(service.getAllUserPayments(7, '0xUSER', dateFrom, dateTo)).rejects.toBeInstanceOf( + ForbiddenException, + ); + await expect(service.getAllUserPayments(7, '0xUSER', dateFrom, dateTo)).rejects.toThrow( + 'Payments API not enabled for this wallet', + ); + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + }); + + it('returns user payments when paymentsApiEnabled is true and option admits the user', async () => { + walletService.getByIdOrName.mockResolvedValue( + walletWith({ paymentsApiEnabled: true, paymentOption: WebhookConfigOption.TRUE }), + ); + + const result = await service.getAllUserPayments(7, '0xUSER', dateFrom, dateTo); + + expect(result).toEqual([]); + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([42], dateFrom, dateTo); + }); + }); + + /** + * Consent filter mirrors webhook.service sendWebhooks: + * - own-wallet channel → consented=false (isPaymentOptionValid(false)) + * - kycClientList channel → consented=true + * Matrix over TRUE / CONSENT_ONLY / WALLET_ONLY with a consented and a non-consented user. + */ + describe('payment pull consent filter (same rule as push)', () => { + const consentedUser = userWith(10, '0xCONSENTED', '7'); // wallet 7 in kycClientList + const plainUser = userWith(11, '0xPLAIN'); // no kycClientList entry + + const cases: { + option: WebhookConfigOption; + expectedIds: number[]; + label: string; + }[] = [ + { + option: WebhookConfigOption.TRUE, + expectedIds: [10, 11], + label: 'TRUE admits both channels → both users', + }, + { + option: WebhookConfigOption.WALLET_ONLY, + expectedIds: [10, 11], + label: 'WALLET_ONLY admits own-wallet (consented=false) → both users', + }, + { + option: WebhookConfigOption.CONSENT_ONLY, + expectedIds: [10], + label: 'CONSENT_ONLY admits only kycClientList channel → consented user only', + }, + ]; + + describe.each(cases)('$label', ({ option, expectedIds }) => { + it('getAllPayments filters users to the push-equivalent set', async () => { + walletService.getByIdOrName.mockResolvedValue( + walletWith({ + paymentsApiEnabled: true, + paymentOption: option, + users: [consentedUser, plainUser], + }), + ); + + await service.getAllPayments(7, dateFrom, dateTo); + + if (expectedIds.length === 0) { + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + } else { + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith( + expectedIds, + dateFrom, + dateTo, + undefined, + ); + } + }); + + it.each([ + { user: consentedUser, included: expectedIds.includes(10) }, + { user: plainUser, included: expectedIds.includes(11) }, + ])('getAllUserPayments for $user.address included=$included', async ({ user, included }) => { + walletService.getByIdOrName.mockResolvedValue( + walletWith({ + paymentsApiEnabled: true, + paymentOption: option, + users: [consentedUser, plainUser], + }), + ); + + const result = await service.getAllUserPayments(7, user.address, dateFrom, dateTo); + + expect(result).toEqual([]); + if (included) { + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([user.id], dateFrom, dateTo); + } else { + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + } + }); + }); + + it('FALSE admits nobody', async () => { + walletService.getByIdOrName.mockResolvedValue( + walletWith({ + paymentsApiEnabled: true, + paymentOption: WebhookConfigOption.FALSE, + users: [consentedUser, plainUser], + }), + ); + + await service.getAllPayments(7, dateFrom, dateTo); + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + + await service.getAllUserPayments(7, '0xCONSENTED', dateFrom, dateTo); + expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/generic/kyc/services/kyc-client.service.ts b/src/subdomains/generic/kyc/services/kyc-client.service.ts index f8ce48a56e..2f7ce8aad6 100644 --- a/src/subdomains/generic/kyc/services/kyc-client.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-client.service.ts @@ -1,4 +1,5 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyCryptoWebhookService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service'; @@ -7,6 +8,7 @@ import { Transaction } from 'src/subdomains/supporting/payment/entities/transact import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { User } from '../../user/models/user/user.entity'; import { UserService } from '../../user/models/user/user.service'; +import { Wallet } from '../../user/models/wallet/wallet.entity'; import { WalletService } from '../../user/models/wallet/wallet.service'; import { PaymentWebhookData } from '../../user/services/webhook/dto/payment-webhook.dto'; import { WebhookDataMapper } from '../../user/services/webhook/mapper/webhook-data.mapper'; @@ -17,6 +19,8 @@ import { KycDocumentService } from './integration/kyc-document.service'; @Injectable() export class KycClientService { + private readonly logger = new DfxLogger(KycClientService); + constructor( private readonly documentService: KycDocumentService, private readonly userService: UserService, @@ -36,8 +40,11 @@ export class KycClientService { async getAllPayments(walletId: number, dateFrom: Date, dateTo: Date, limit?: number): Promise { const wallet = await this.walletService.getByIdOrName(walletId, undefined, { users: { userData: true } }); if (!wallet) throw new NotFoundException('Wallet not found'); + this.assertPaymentsApiEnabled(wallet); + + const userIds = this.usersEligibleForPaymentPull(wallet).map((u) => u.id); + if (userIds.length === 0) return []; - const userIds = wallet.users.map((u) => u.id); const transactions = await this.transactionService.getTransactionsForUsers(userIds, dateFrom, dateTo, limit); return this.toPaymentDtos(transactions); @@ -51,9 +58,11 @@ export class KycClientService { ): Promise { const wallet = await this.walletService.getByIdOrName(walletId, undefined, { users: { userData: true } }); if (!wallet) throw new NotFoundException('Wallet not found'); + this.assertPaymentsApiEnabled(wallet); const user = wallet.users.find((u) => u.address === userAddress); if (!user) throw new NotFoundException('User not found'); + if (!this.isUserEligibleForPaymentPull(wallet, user)) return []; const transactions = await this.transactionService.getTransactionsForUsers([user.id], dateFrom, dateTo); @@ -87,6 +96,36 @@ export class KycClientService { } // --- HELPER METHODS --- // + + /** Fail-closed: only wallets with an explicit paymentsApiEnabled=true may list payments. */ + private assertPaymentsApiEnabled(wallet: Wallet): void { + if (!wallet.isPaymentsApiEnabled) { + this.logger.warn(`Payments API denied for wallet ${wallet.id}`); + throw new ForbiddenException('Payments API not enabled for this wallet'); + } + } + + /** + * Same consent channels as webhook.service sendWebhooks for PAYMENT: + * - own-wallet channel → consented=false + * - kycClientList channel → consented=true + * Users that neither channel admits fall out of the result. + */ + private usersEligibleForPaymentPull(wallet: Wallet): User[] { + return wallet.users.filter((user) => this.isUserEligibleForPaymentPull(wallet, user)); + } + + private isUserEligibleForPaymentPull(wallet: Wallet, user: User): boolean { + // Path 1: user's own wallet (webhook.service filters with isValidForWebhook(type, false)). + // Users in wallet.users belong to this wallet; option evaluation only (apiUrl is push-only). + if (wallet.isPaymentOptionValid(false)) return true; + + // Path 2: consented KYC-client channel (webhook.service: kycClientList + consented=true). + if (user.userData?.kycClientList.includes(wallet.id) && wallet.isPaymentOptionValid(true)) return true; + + return false; + } + private async toPaymentDtos(transactions: Transaction[]): Promise { const txList = transactions.filter((t) => t.buyCrypto || t.buyFiat).map((t) => t.buyCrypto || t.buyFiat); diff --git a/src/subdomains/generic/user/models/wallet/__tests__/wallet-payments-api-enabled.migration.spec.ts b/src/subdomains/generic/user/models/wallet/__tests__/wallet-payments-api-enabled.migration.spec.ts new file mode 100644 index 0000000000..1455097fa1 --- /dev/null +++ b/src/subdomains/generic/user/models/wallet/__tests__/wallet-payments-api-enabled.migration.spec.ts @@ -0,0 +1,207 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +/** + * Unit assertions on the migration SQL (always run) plus an optional real-Postgres + * up → down → up cycle when MIGRATION_TEST_PG is set. + */ +describe('AddWalletPaymentsApiEnabled migration', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Migration = require('../../../../../../../migration/1785550000000-AddWalletPaymentsApiEnabled'); + + it('adds the column fail-closed and backfills wallets with an address', async () => { + const queryRunner = { query: jest.fn().mockResolvedValue(undefined) }; + + await new Migration().up(queryRunner); + + const sql = queryRunner.query.mock.calls.map(([statement]: [string]) => statement).join('\n'); + expect(sql).toContain('ALTER TABLE "wallet" ADD "paymentsApiEnabled" boolean NOT NULL DEFAULT false'); + expect(sql).toContain('UPDATE "wallet" SET "paymentsApiEnabled" = true WHERE "address" IS NOT NULL'); + // No partner ID list — backfill is semantic. + expect(sql).not.toMatch(/WHERE\s+"id"\s+IN/i); + // isKycClient is the wrong predicate (see migration header). + expect(sql).not.toContain('WHERE "isKycClient" = true'); + }); + + it('audits current flag values into log before dropping the column on rollback', async () => { + const queryRunner = { query: jest.fn().mockResolvedValue(undefined) }; + + await new Migration().down(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledTimes(2); + const [auditSql] = queryRunner.query.mock.calls[0] as [string]; + const [dropSql] = queryRunner.query.mock.calls[1] as [string]; + + expect(auditSql).toContain('INSERT INTO "log"'); + expect(auditSql).toContain("'Wallet'"); + expect(auditSql).toContain("'PaymentsApiEnabledRollback'"); + expect(auditSql).toContain('"paymentsApiEnabled"'); + expect(auditSql).toContain('json_agg'); + // Audit write is ordered before the DROP (fail-closed in the migration transaction). + expect(dropSql).toBe('ALTER TABLE "wallet" DROP COLUMN "paymentsApiEnabled"'); + }); +}); + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'wallet_payments_api_enabled_spec'; + +let AddWalletPaymentsApiEnabled: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +describeDb('AddWalletPaymentsApiEnabled migration (real Postgres)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddWalletPaymentsApiEnabled = require('../../../../../../../migration/1785550000000-AddWalletPaymentsApiEnabled'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + await queryRunner.query(` + CREATE TABLE "wallet" ( + "id" SERIAL PRIMARY KEY, + "name" character varying(256), + "address" character varying(256), + "isKycClient" boolean NOT NULL DEFAULT false + ) + `); + // Minimal "log" table matching the columns the migration INSERT uses. + await queryRunner.query(` + CREATE TABLE "log" ( + "id" SERIAL PRIMARY KEY, + "created" TIMESTAMP NOT NULL DEFAULT now(), + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "system" character varying(256) NOT NULL, + "subsystem" character varying(256) NOT NULL, + "severity" character varying(256) NOT NULL, + "message" text NOT NULL + ) + `); + // Fixtures: with/without address, independent of isKycClient. + await queryRunner.query(` + INSERT INTO "wallet" ("name", "address", "isKycClient") VALUES + ('AddressKyc', '0xAAA', true), + ('AddressNonKyc', '0xBBB', false), + ('NoAddressKyc', NULL, true), + ('NoAddressNonKyc', NULL, false) + `); + }); + + afterEach(async () => { + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + await dataSource.destroy(); + }); + + it('up → down → up: backfills only wallets with address, default false otherwise, fully reversible', async () => { + const migration = new AddWalletPaymentsApiEnabled(); + + // Snapshot pre-up state: down() must restore this (other columns + row count). + const beforeUp: { id: number; name: string; address: string | null; isKycClient: boolean }[] = + await queryRunner.query(`SELECT "id", "name", "address", "isKycClient" FROM "wallet" ORDER BY "id"`); + const [{ count: beforeCount }] = await queryRunner.query(`SELECT COUNT(*)::int AS count FROM "wallet"`); + + await migration.up(queryRunner); + + let rows: { name: string; paymentsApiEnabled: boolean }[] = await queryRunner.query( + `SELECT "name", "paymentsApiEnabled" FROM "wallet" ORDER BY "id"`, + ); + expect(rows).toEqual([ + { name: 'AddressKyc', paymentsApiEnabled: true }, + { name: 'AddressNonKyc', paymentsApiEnabled: true }, + { name: 'NoAddressKyc', paymentsApiEnabled: false }, + { name: 'NoAddressNonKyc', paymentsApiEnabled: false }, + ]); + + // Ops decision that diverges from the backfill: flip AddressNonKyc (backfill true) to false. + // The audit on down() must retain this manual value — not re-derive from address. + await queryRunner.query(`UPDATE "wallet" SET "paymentsApiEnabled" = false WHERE "name" = 'AddressNonKyc'`); + const flagsBeforeDown: { id: number; name: string; paymentsApiEnabled: boolean }[] = await queryRunner.query( + `SELECT "id", "name", "paymentsApiEnabled" FROM "wallet" ORDER BY "id"`, + ); + expect(flagsBeforeDown.find((r) => r.name === 'AddressNonKyc')?.paymentsApiEnabled).toBe(false); + + await migration.down(queryRunner); + + const columnsAfterDown: { column_name: string }[] = await queryRunner.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = '${SCHEMA}' AND table_name = 'wallet' AND column_name = 'paymentsApiEnabled'`, + ); + expect(columnsAfterDown).toEqual([]); + + // Ownership of down(): only the column is gone; remaining columns and row count match pre-up. + const afterDown: { id: number; name: string; address: string | null; isKycClient: boolean }[] = + await queryRunner.query(`SELECT "id", "name", "address", "isKycClient" FROM "wallet" ORDER BY "id"`); + const [{ count: afterDownCount }] = await queryRunner.query(`SELECT COUNT(*)::int AS count FROM "wallet"`); + expect(afterDown).toEqual(beforeUp); + expect(afterDownCount).toBe(beforeCount); + + // Audit record: one immutable log row with the pre-drop flag values, including the + // manually flipped wallet (AddressNonKyc = false, not the backfill true). + const auditLogs: { system: string; subsystem: string; severity: string; message: string }[] = + await queryRunner.query( + `SELECT "system", "subsystem", "severity", "message" FROM "log" + WHERE "system" = 'Wallet' AND "subsystem" = 'PaymentsApiEnabledRollback'`, + ); + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0].severity).toBe('Info'); + const audited = JSON.parse(auditLogs[0].message) as { id: number; paymentsApiEnabled: boolean }[]; + expect(audited).toEqual(flagsBeforeDown.map((r) => ({ id: r.id, paymentsApiEnabled: r.paymentsApiEnabled }))); + const manualEntry = audited.find((e) => e.id === flagsBeforeDown.find((r) => r.name === 'AddressNonKyc')?.id); + expect(manualEntry?.paymentsApiEnabled).toBe(false); + // Sanity: a still-true backfill wallet is also recorded. + const stillEnabled = audited.find((e) => e.id === flagsBeforeDown.find((r) => r.name === 'AddressKyc')?.id); + expect(stillEnabled?.paymentsApiEnabled).toBe(true); + + // up again with existing rows still present + await migration.up(queryRunner); + rows = await queryRunner.query(`SELECT "name", "paymentsApiEnabled" FROM "wallet" ORDER BY "id"`); + expect(rows).toEqual([ + { name: 'AddressKyc', paymentsApiEnabled: true }, + { name: 'AddressNonKyc', paymentsApiEnabled: true }, + { name: 'NoAddressKyc', paymentsApiEnabled: false }, + { name: 'NoAddressNonKyc', paymentsApiEnabled: false }, + ]); + + // New row after migration gets fail-closed default (no address → stays false). + await queryRunner.query( + `INSERT INTO "wallet" ("name", "address", "isKycClient") VALUES ('NewWallet', NULL, false)`, + ); + const [fresh] = await queryRunner.query(`SELECT "paymentsApiEnabled" FROM "wallet" WHERE "name" = 'NewWallet'`); + expect(fresh.paymentsApiEnabled).toBe(false); + + // New row with address after migration: DEFAULT false still applies on INSERT + // (backfill only runs once in up()); a newly set address does not auto-enable. + await queryRunner.query( + `INSERT INTO "wallet" ("name", "address", "isKycClient") VALUES ('NewWithAddress', '0xCCC', false)`, + ); + const [freshWithAddress] = await queryRunner.query( + `SELECT "paymentsApiEnabled" FROM "wallet" WHERE "name" = 'NewWithAddress'`, + ); + expect(freshWithAddress.paymentsApiEnabled).toBe(false); + + rows = await queryRunner.query(`SELECT "name", "paymentsApiEnabled" FROM "wallet" ORDER BY "id"`); + expect(rows).toEqual([ + { name: 'AddressKyc', paymentsApiEnabled: true }, + { name: 'AddressNonKyc', paymentsApiEnabled: true }, + { name: 'NoAddressKyc', paymentsApiEnabled: false }, + { name: 'NoAddressNonKyc', paymentsApiEnabled: false }, + { name: 'NewWallet', paymentsApiEnabled: false }, + { name: 'NewWithAddress', paymentsApiEnabled: false }, + ]); + }); +}); diff --git a/src/subdomains/generic/user/models/wallet/__tests__/wallet.entity.spec.ts b/src/subdomains/generic/user/models/wallet/__tests__/wallet.entity.spec.ts new file mode 100644 index 0000000000..47c548fc75 --- /dev/null +++ b/src/subdomains/generic/user/models/wallet/__tests__/wallet.entity.spec.ts @@ -0,0 +1,95 @@ +import { getMetadataArgsStorage } from 'typeorm'; +import { WebhookType } from '../../../services/webhook/dto/webhook.dto'; +import { Wallet, WebhookConfigOption } from '../wallet.entity'; + +describe('Wallet', () => { + const paymentConfig = (payment: WebhookConfigOption) => JSON.stringify({ payment, kyc: WebhookConfigOption.TRUE }); + + const walletWith = (overrides: Partial): Wallet => Object.assign(new Wallet(), overrides); + + describe('paymentsApiEnabled column metadata', () => { + it('defaults paymentsApiEnabled to false (fail-closed; same as sibling wallet booleans)', () => { + const column = getMetadataArgsStorage().columns.find( + (c) => c.target === Wallet && c.propertyName === 'paymentsApiEnabled', + ); + const sibling = getMetadataArgsStorage().columns.find( + (c) => c.target === Wallet && c.propertyName === 'isKycClient', + ); + + // Fail-closed: missing / unset rows must not open the payments API. + expect(column?.options.default).toBe(false); + // Decorator style matches siblings — no explicit type/nullable options required. + expect(column?.options.default).toBe(sibling?.options.default); + }); + }); + + describe('#isPaymentsApiEnabled', () => { + it('is true only for an explicit true', () => { + expect(walletWith({ paymentsApiEnabled: true }).isPaymentsApiEnabled).toBe(true); + expect(walletWith({ paymentsApiEnabled: false }).isPaymentsApiEnabled).toBe(false); + expect(walletWith({}).isPaymentsApiEnabled).toBe(false); + }); + }); + + describe('#isPaymentOptionValid', () => { + it.each([ + [WebhookConfigOption.TRUE, false, true], + [WebhookConfigOption.TRUE, true, true], + [WebhookConfigOption.WALLET_ONLY, false, true], + [WebhookConfigOption.WALLET_ONLY, true, false], + [WebhookConfigOption.CONSENT_ONLY, false, false], + [WebhookConfigOption.CONSENT_ONLY, true, true], + [WebhookConfigOption.FALSE, false, false], + [WebhookConfigOption.FALSE, true, false], + ] as const)('option %s with consented=%s → %s', (option, consented, expected) => { + const wallet = walletWith({ webhookConfig: paymentConfig(option) }); + expect(wallet.isPaymentOptionValid(consented)).toBe(expected); + }); + }); + + describe('#isValidForWebhook', () => { + it('requires paymentsApiEnabled for PAYMENT but not for KYC types', () => { + const base = { + apiUrl: 'https://partner.example.com/hook', + webhookConfig: JSON.stringify({ + payment: WebhookConfigOption.TRUE, + kyc: WebhookConfigOption.TRUE, + }), + }; + + const disabled = walletWith({ ...base, paymentsApiEnabled: false }); + expect(disabled.isValidForWebhook(WebhookType.PAYMENT, false)).toBe(false); + expect(disabled.isValidForWebhook(WebhookType.KYC_CHANGED, false)).toBe(true); + expect(disabled.isValidForWebhook(WebhookType.KYC_FAILED, true)).toBe(true); + expect(disabled.isValidForWebhook(WebhookType.ACCOUNT_CHANGED, false)).toBe(true); + + const enabled = walletWith({ ...base, paymentsApiEnabled: true }); + expect(enabled.isValidForWebhook(WebhookType.PAYMENT, false)).toBe(true); + expect(enabled.isValidForWebhook(WebhookType.KYC_CHANGED, false)).toBe(true); + }); + + it('still requires apiUrl and payment option for PAYMENT when the flag is on', () => { + const enabled = walletWith({ + paymentsApiEnabled: true, + webhookConfig: paymentConfig(WebhookConfigOption.TRUE), + }); + expect(enabled.isValidForWebhook(WebhookType.PAYMENT, false)).toBe(false); + + const noOption = walletWith({ + apiUrl: 'https://partner.example.com/hook', + paymentsApiEnabled: true, + }); + expect(noOption.isValidForWebhook(WebhookType.PAYMENT, false)).toBe(false); + }); + + it('respects payment option for PAYMENT when flag and apiUrl are set', () => { + const wallet = walletWith({ + apiUrl: 'https://partner.example.com/hook', + paymentsApiEnabled: true, + webhookConfig: paymentConfig(WebhookConfigOption.CONSENT_ONLY), + }); + expect(wallet.isValidForWebhook(WebhookType.PAYMENT, false)).toBe(false); + expect(wallet.isValidForWebhook(WebhookType.PAYMENT, true)).toBe(true); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/wallet/__tests__/wallet.service.spec.ts b/src/subdomains/generic/user/models/wallet/__tests__/wallet.service.spec.ts new file mode 100644 index 0000000000..a5b7886885 --- /dev/null +++ b/src/subdomains/generic/user/models/wallet/__tests__/wallet.service.spec.ts @@ -0,0 +1,52 @@ +import { createMock } from '@golevelup/ts-jest'; +import { NotFoundException } from '@nestjs/common'; +import { WalletDto } from '../dto/wallet.dto'; +import { Wallet } from '../wallet.entity'; +import { WalletRepository } from '../wallet.repository'; +import { WalletService } from '../wallet.service'; + +describe('WalletService', () => { + let service: WalletService; + let repo: jest.Mocked; + + beforeEach(() => { + repo = createMock(); + repo.create.mockImplementation((dto) => Object.assign(new Wallet(), dto) as Wallet); + repo.save.mockImplementation(async (entity) => entity as Wallet); + service = new WalletService(repo); + }); + + describe('createWallet', () => { + it('saves the wallet and invalidates the cache', async () => { + const dto = { name: 'Partner' } as WalletDto; + + const saved = await service.createWallet(dto); + + expect(repo.create).toHaveBeenCalledWith(dto); + expect(repo.save).toHaveBeenCalled(); + expect(repo.invalidateCache).toHaveBeenCalledTimes(1); + expect(saved.name).toBe('Partner'); + }); + }); + + describe('updateWallet', () => { + it('updates the wallet and invalidates the cache', async () => { + const existing = Object.assign(new Wallet(), { id: 7, name: 'Old', paymentsApiEnabled: true }); + repo.findOneBy.mockResolvedValue(existing); + + const saved = await service.updateWallet(7, { paymentsApiEnabled: false } as WalletDto); + + expect(repo.save).toHaveBeenCalled(); + expect(repo.invalidateCache).toHaveBeenCalledTimes(1); + expect(saved.paymentsApiEnabled).toBe(false); + }); + + it('throws NotFoundException and does not invalidate cache when missing', async () => { + repo.findOneBy.mockResolvedValue(null); + + await expect(service.updateWallet(99, {} as WalletDto)).rejects.toBeInstanceOf(NotFoundException); + expect(repo.save).not.toHaveBeenCalled(); + expect(repo.invalidateCache).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/wallet/dto/wallet.dto.ts b/src/subdomains/generic/user/models/wallet/dto/wallet.dto.ts index 5758a3169a..ca90f92573 100644 --- a/src/subdomains/generic/user/models/wallet/dto/wallet.dto.ts +++ b/src/subdomains/generic/user/models/wallet/dto/wallet.dto.ts @@ -23,6 +23,10 @@ export class WalletDto { @IsBoolean() isKycClient: boolean; + @IsOptional() + @IsBoolean() + paymentsApiEnabled: boolean; + @IsOptional() @IsEnum(KycType) customKyc: KycType; diff --git a/src/subdomains/generic/user/models/wallet/wallet.entity.ts b/src/subdomains/generic/user/models/wallet/wallet.entity.ts index e939dfbcce..c9e00b3021 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.entity.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.entity.ts @@ -38,6 +38,9 @@ export class Wallet extends IEntity { @Column({ default: false }) isKycClient: boolean; + @Column({ default: false }) + paymentsApiEnabled: boolean; + @Column({ default: false }) displayFraudWarning: boolean; @@ -105,6 +108,20 @@ export class Wallet extends IEntity { ); } + /** Fail-closed: only an explicit true opens the partner payments API. */ + get isPaymentsApiEnabled(): boolean { + return this.paymentsApiEnabled === true; + } + + /** + * Whether payment webhook option admits the given consent channel. + * Same option evaluation as isValidForWebhook(PAYMENT, consented), without apiUrl / + * paymentsApiEnabled — those gates are applied separately (pull path / isValidForWebhook). + */ + isPaymentOptionValid(consented: boolean): boolean { + return this.isOptionValid(this.webhookConfigObject?.payment, consented); + } + isValidForWebhook(type: WebhookType, consented: boolean): boolean { if (!this.apiUrl) return false; @@ -115,7 +132,8 @@ export class Wallet extends IEntity { return this.isOptionValid(this.webhookConfigObject?.kyc, consented); case WebhookType.PAYMENT: - return this.isOptionValid(this.webhookConfigObject?.payment, consented); + // paymentsApiEnabled gates payment data on both pull and push; KYC types unchanged. + return this.isPaymentsApiEnabled && this.isOptionValid(this.webhookConfigObject?.payment, consented); } } diff --git a/src/subdomains/generic/user/models/wallet/wallet.service.ts b/src/subdomains/generic/user/models/wallet/wallet.service.ts index 068aa02508..ffda87b81f 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.service.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.service.ts @@ -11,8 +11,9 @@ export class WalletService { async createWallet(dto: WalletDto): Promise { const entity = this.repo.create(dto); - - return this.repo.save(entity); + const saved = await this.repo.save(entity); + this.repo.invalidateCache(); + return saved; } async updateWallet(id: number, dto: WalletDto): Promise { @@ -21,7 +22,9 @@ export class WalletService { Object.assign(entity, dto); - return this.repo.save(entity); + const saved = await this.repo.save(entity); + this.repo.invalidateCache(); + return saved; } async getByAddress(address: string): Promise { From 9285c2e2fae6a1d43f14302ca81c7a4cb5c9e6d3 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 16:09:33 +0200 Subject: [PATCH 2/2] Drop the pull consent filter and redact identifying payment fields The previous gate change filtered GET /kyc/client/payments (and the per-user path) to the same webhook consent set as the push path. That was not the intended behaviour: partners should still see the same row set as before the PR, but without bank accounts, wallet addresses or on-chain identifiers in the response. Pull path: - Remove the consent filter from getAllPayments / getAllUserPayments. paymentsApiEnabled remains the only new access gate on pull. - Introduce PartnerPaymentDto (OmitType of PaymentWebhookData) and strip identifying fields only on the pull responses. Webhook payloads stay on the full PaymentWebhookData / TransactionDetailDto. Breaking change for existing partners on the pull endpoints: - Same transactions as before the gate PR (subject to paymentsApiEnabled). - Response no longer includes: sourceAccount, targetAccount, inputTxId, inputTxUrl, outputTxId, outputTxUrl, depositAddress, chargebackTarget, chargebackTxId, chargebackTxUrl, networkStartTx. - Amounts, assets, fees, status, type, timestamps and dfxReference remain. Webhook PAYMENT push is unchanged (still full payload + its own consent and paymentsApiEnabled checks). --- .../kyc/controllers/kyc-client.controller.ts | 10 +- .../dto/__tests__/partner-payment.dto.spec.ts | 172 ++++++++++++ .../generic/kyc/dto/partner-payment.dto.ts | 45 ++++ .../__tests__/kyc-client.service.spec.ts | 249 +++++++++++------- .../kyc/services/kyc-client.service.ts | 37 +-- 5 files changed, 384 insertions(+), 129 deletions(-) create mode 100644 src/subdomains/generic/kyc/dto/__tests__/partner-payment.dto.spec.ts create mode 100644 src/subdomains/generic/kyc/dto/partner-payment.dto.ts diff --git a/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts b/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts index 42a39d0086..ef21c8d0d9 100644 --- a/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts @@ -6,8 +6,8 @@ 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 { PaymentWebhookData } from '../../user/services/webhook/dto/payment-webhook.dto'; import { KycClientDataDto, KycReportDto, KycReportType } from '../dto/kyc-file.dto'; +import { PartnerPaymentDto } from '../dto/partner-payment.dto'; import { KycClientService } from '../services/kyc-client.service'; @ApiTags('KYC Client') @@ -26,7 +26,7 @@ export class KycClientController { @Get('payments') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY)) - @ApiOkResponse({ type: PaymentWebhookData, isArray: true }) + @ApiOkResponse({ type: PartnerPaymentDto, isArray: true }) @ApiQuery({ name: 'from', required: false, description: 'Start date filter' }) @ApiQuery({ name: 'to', required: false, description: 'End date filter' }) @ApiQuery({ name: 'limit', required: false, description: 'Maximum number of results (default/max: 1000)' }) @@ -35,7 +35,7 @@ export class KycClientController { @Query('from') from: string, @Query('to') to: string, @Query('limit') limit?: string, - ): Promise { + ): Promise { const parsedLimit = Math.min(limit ? parseInt(limit) : 1000, 1000); return this.kycClientService.getAllPayments( @@ -69,13 +69,13 @@ export class KycClientController { @Get('users/:id/payments') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.KYC_CLIENT_COMPANY)) - @ApiOkResponse({ type: PaymentWebhookData, isArray: true }) + @ApiOkResponse({ type: PartnerPaymentDto, isArray: true }) async getUserPayments( @GetJwt() jwt: JwtPayload, @Param('id') userId: string, @Query('from') from: string, @Query('to') to: string, - ): Promise { + ): Promise { return this.kycClientService.getAllUserPayments( jwt.user, userId, diff --git a/src/subdomains/generic/kyc/dto/__tests__/partner-payment.dto.spec.ts b/src/subdomains/generic/kyc/dto/__tests__/partner-payment.dto.spec.ts new file mode 100644 index 0000000000..a4bafeecb7 --- /dev/null +++ b/src/subdomains/generic/kyc/dto/__tests__/partner-payment.dto.spec.ts @@ -0,0 +1,172 @@ +import { DECORATORS } from '@nestjs/swagger/dist/constants'; +import { TransactionState, TransactionType } from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { PaymentWebhookData } from '../../../user/services/webhook/dto/payment-webhook.dto'; +import { WebhookDataMapper } from '../../../user/services/webhook/mapper/webhook-data.mapper'; +import { PARTNER_PAYMENT_OMITTED_FIELDS, PartnerPaymentDto, toPartnerPaymentDto } from '../partner-payment.dto'; + +/** + * Locked denylist — if a sensitive field is re-added to the pull DTO (removed from this list + * or re-introduced on PartnerPaymentDto), these tests fail. Do not silently shrink this set. + */ +const EXPECTED_OMITTED_FIELDS = [ + 'sourceAccount', + 'targetAccount', + 'inputTxId', + 'inputTxUrl', + 'outputTxId', + 'outputTxUrl', + 'depositAddress', + 'chargebackTarget', + 'chargebackTxId', + 'chargebackTxUrl', + 'networkStartTx', +] as const; + +describe('PartnerPaymentDto redaction', () => { + const fullPayload = (): PaymentWebhookData => + ({ + id: 1, + uid: 'T1', + orderUid: 'O1', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + inputAmount: 10, + inputAsset: 'EUR', + inputAssetId: 1, + inputTxId: 'in-tx', + inputTxUrl: 'https://ex/in-tx', + depositAddress: 'dep', + chargebackTarget: 'cb-target', + chargebackAmount: 1, + chargebackAsset: 'EUR', + chargebackAssetId: 1, + chargebackTxId: 'cb-tx', + chargebackTxUrl: 'https://ex/cb-tx', + chargebackDate: new Date('2024-01-01'), + date: new Date('2024-01-02'), + exchangeRate: 1, + rate: 1, + outputAmount: 9, + outputAsset: 'BTC', + outputAssetId: 2, + outputTxId: 'out-tx', + outputTxUrl: 'https://ex/out-tx', + outputDate: new Date('2024-01-03'), + externalTransactionId: 'ext', + networkStartTx: { + txId: 'net-tx', + txUrl: 'https://ex/net-tx', + amount: 0.01, + exchangeRate: 1, + asset: 'ETH', + }, + sourceAccount: 'CH93…', + targetAccount: '0xabc', + dfxReference: 42, + }) as PaymentWebhookData; + + it('locks the omitted-field denylist (re-adding a sensitive pull field must fail here)', () => { + expect([...PARTNER_PAYMENT_OMITTED_FIELDS].sort()).toEqual([...EXPECTED_OMITTED_FIELDS].sort()); + }); + + it('toPartnerPaymentDto drops every omitted field and keeps the rest', () => { + const full = fullPayload(); + const reduced = toPartnerPaymentDto(full); + const omitted = new Set(PARTNER_PAYMENT_OMITTED_FIELDS); + + for (const key of Object.keys(full)) { + if (omitted.has(key)) { + expect(Object.prototype.hasOwnProperty.call(reduced, key)).toBe(false); + } else { + expect(Object.prototype.hasOwnProperty.call(reduced, key)).toBe(true); + expect((reduced as any)[key]).toEqual((full as any)[key]); + } + } + + // Response must not expose any omitted field — iterate the denylist, not ad-hoc toBeUndefined. + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(Object.keys(reduced)).not.toContain(field); + } + }); + + it('does not mutate the full webhook payload', () => { + const full = fullPayload(); + const before = { ...full }; + toPartnerPaymentDto(full); + expect(full).toEqual(before); + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(full).toHaveProperty(field); + } + }); + + it('PartnerPaymentDto Swagger metadata excludes every omitted field', () => { + const meta: Record = + Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES_ARRAY, PartnerPaymentDto.prototype) ?? []; + // Nest stores ':propName' entries on the prototype array and per-property metadata on the class. + const propNames = (Array.isArray(meta) ? meta : []) + .map((entry) => (typeof entry === 'string' ? entry.replace(/^:/, '') : '')) + .filter(Boolean); + + // Also collect keys from per-property decorator metadata when present. + const ownProps = Object.getOwnPropertyNames(PartnerPaymentDto.prototype).filter((p) => p !== 'constructor'); + const declared = new Set([...propNames, ...ownProps]); + + // Prefer swagger plugin / omit-type cloned metadata when available. + const swaggerProps = Object.keys( + (Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, PartnerPaymentDto.prototype) as object) ?? {}, + ); + for (const name of swaggerProps) declared.add(name); + + // Walk the denylist against declared PartnerPaymentDto surface. + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(declared.has(field)).toBe(false); + // OmitType must not re-expose the property on the reduced class prototype chain for swagger clone. + const propMeta = Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, PartnerPaymentDto.prototype, field); + expect(propMeta).toBeUndefined(); + } + }); +}); + +describe('Webhook payment payload stays full (push path unchanged)', () => { + it('PaymentWebhookData / mapper path still carries identifying fields', () => { + // Construct the same shape WebhookDataMapper returns — full TransactionDetailDto + dfxReference. + // We do not go through BuyCrypto entities here; the contract is that the *type* and strip boundary + // keep webhook consumers on PaymentWebhookData while pull uses PartnerPaymentDto. + const webhookPayload = { + sourceAccount: 'CH9300762011623852957', + targetAccount: '0xDEAD', + inputTxId: 'abc', + inputTxUrl: 'https://ex/abc', + outputTxId: 'def', + outputTxUrl: 'https://ex/def', + depositAddress: 'bc1q…', + chargebackTarget: 'CH…', + chargebackTxId: 'cb', + chargebackTxUrl: 'https://ex/cb', + networkStartTx: { txId: 'n', txUrl: 'https://ex/n', amount: 1, exchangeRate: 1, asset: 'ETH' }, + dfxReference: 9, + inputAmount: 100, + } as PaymentWebhookData; + + // Full payload retains every omitted field (webhook / mapper contract). + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(webhookPayload).toHaveProperty(field); + expect((webhookPayload as any)[field]).toBeDefined(); + } + + // Pull redaction is a separate function — webhook code never calls it. + const reduced = toPartnerPaymentDto(webhookPayload); + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(Object.keys(reduced)).not.toContain(field); + } + // Original webhook object untouched. + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(webhookPayload).toHaveProperty(field); + } + + // Mapper module still exports the full-data mappers used by the push path. + expect(typeof WebhookDataMapper.mapFiatCryptoData).toBe('function'); + expect(typeof WebhookDataMapper.mapCryptoFiatData).toBe('function'); + expect(typeof WebhookDataMapper.mapCryptoCryptoData).toBe('function'); + }); +}); diff --git a/src/subdomains/generic/kyc/dto/partner-payment.dto.ts b/src/subdomains/generic/kyc/dto/partner-payment.dto.ts new file mode 100644 index 0000000000..c6d5f67d76 --- /dev/null +++ b/src/subdomains/generic/kyc/dto/partner-payment.dto.ts @@ -0,0 +1,45 @@ +import { OmitType } from '@nestjs/swagger'; +import { PaymentWebhookData } from '../../user/services/webhook/dto/payment-webhook.dto'; + +/** + * Identifying fields stripped from partner payment *pull* responses + * (GET /kyc/client/payments, GET /kyc/client/users/:id/payments). + * + * Webhook payloads still use the full PaymentWebhookData / TransactionDetailDto. + * Keep this list the single source of truth for the pull redaction — strip + DTO + tests. + */ +export const PARTNER_PAYMENT_OMITTED_FIELDS = [ + // Customer IBAN / wallet address (TransactionDetailDto) + 'sourceAccount', + 'targetAccount', + // On-chain / bank transfer identifiers and explorer links + 'inputTxId', + 'inputTxUrl', + 'outputTxId', + 'outputTxUrl', + // Crypto deposit address (user- or route-bound) + 'depositAddress', + // Chargeback destination and on-chain / transfer identifiers + 'chargebackTarget', + 'chargebackTxId', + 'chargebackTxUrl', + // Nested object whose purpose is the network-start tx hash + explorer URL + 'networkStartTx', +] as const; + +export type PartnerPaymentOmittedField = (typeof PARTNER_PAYMENT_OMITTED_FIELDS)[number]; + +/** + * Reduced payment DTO for the partner pull endpoints. + * Same row set as before the payments-API gate PR; identifying account/tx fields removed. + */ +export class PartnerPaymentDto extends OmitType(PaymentWebhookData, [...PARTNER_PAYMENT_OMITTED_FIELDS]) {} + +/** Drop identifying fields from a full payment payload. Does not mutate the input. */ +export function toPartnerPaymentDto(full: PaymentWebhookData): PartnerPaymentDto { + const reduced = { ...full } as PaymentWebhookData & Record; + for (const key of PARTNER_PAYMENT_OMITTED_FIELDS) { + delete reduced[key]; + } + return reduced as PartnerPaymentDto; +} diff --git a/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts index e0340b5fb8..2423910839 100644 --- a/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts +++ b/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts @@ -7,7 +7,10 @@ import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { Wallet, WebhookConfigOption } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; import { WalletService } from 'src/subdomains/generic/user/models/wallet/wallet.service'; +import { PaymentWebhookData } from 'src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto'; +import { TransactionState, TransactionType } from 'src/subdomains/supporting/payment/dto/transaction.dto'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { PARTNER_PAYMENT_OMITTED_FIELDS } from '../../dto/partner-payment.dto'; import { KycDocumentService } from '../integration/kyc-document.service'; import { KycClientService } from '../kyc-client.service'; @@ -15,6 +18,8 @@ describe('KycClientService payments API gate', () => { let service: KycClientService; let walletService: jest.Mocked; let transactionService: jest.Mocked; + let buyCryptoWebhookService: jest.Mocked; + let buyFiatService: jest.Mocked; // Single calendar anchor; ranges derived so fixtures do not go stale by year. const TEST_NOW = new Date('2024-06-15'); @@ -44,17 +49,75 @@ describe('KycClientService payments API gate', () => { webhookConfig: paymentConfig(overrides.paymentOption ?? WebhookConfigOption.TRUE), }); + /** Full PaymentWebhookData with every omitted field set to a non-empty sentinel. */ + const fullPaymentPayload = (): PaymentWebhookData => + ({ + id: 99, + uid: 'T99', + orderUid: 'O1', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + inputAmount: 100, + inputAsset: 'EUR', + inputAssetId: 1, + inputChainId: null, + inputBlockchain: null, + inputEvmChainId: null, + inputPaymentMethod: undefined, + inputTxId: 'SENSITIVE_INPUT_TX', + inputTxUrl: 'https://explorer/SENSITIVE_INPUT_TX', + depositAddress: 'SENSITIVE_DEPOSIT', + chargebackTarget: 'SENSITIVE_CHARGEBACK_TARGET', + chargebackAmount: 5, + chargebackAsset: 'EUR', + chargebackAssetId: 1, + chargebackTxId: 'SENSITIVE_CHARGEBACK_TX', + chargebackTxUrl: 'https://explorer/SENSITIVE_CHARGEBACK_TX', + chargebackDate: new Date('2024-01-01'), + date: new Date('2024-01-02'), + reason: undefined, + exchangeRate: 1.1, + rate: 1.05, + outputAmount: 90, + outputAsset: 'BTC', + outputAssetId: 2, + outputChainId: null, + outputBlockchain: undefined, + outputEvmChainId: null, + outputPaymentMethod: undefined, + outputTxId: 'SENSITIVE_OUTPUT_TX', + outputTxUrl: 'https://explorer/SENSITIVE_OUTPUT_TX', + outputDate: new Date('2024-01-03'), + priceSteps: [], + feeAmount: 1, + feeAsset: 'EUR', + fees: undefined, + externalTransactionId: 'partner-ext-1', + networkStartTx: { + txId: 'SENSITIVE_NETWORK_TX', + txUrl: 'https://explorer/SENSITIVE_NETWORK_TX', + amount: 0.001, + exchangeRate: 1, + asset: 'ETH', + }, + sourceAccount: 'CH9300762011623852957', + targetAccount: '0xDEADBEEF', + dfxReference: 12345, + }) as PaymentWebhookData; + beforeEach(() => { walletService = createMock(); transactionService = createMock(); + buyCryptoWebhookService = createMock(); + buyFiatService = createMock(); transactionService.getTransactionsForUsers.mockResolvedValue([]); service = new KycClientService( createMock(), createMock(), walletService, - createMock(), - createMock(), + buyCryptoWebhookService, + buyFiatService, transactionService, ); }); @@ -72,10 +135,8 @@ describe('KycClientService payments API gate', () => { expect(warn).toHaveBeenCalledWith('Payments API denied for wallet 7'); }); - it('returns payments when paymentsApiEnabled is true and payment option admits the own-wallet channel', async () => { - walletService.getByIdOrName.mockResolvedValue( - walletWith({ paymentsApiEnabled: true, paymentOption: WebhookConfigOption.TRUE }), - ); + it('returns payments when paymentsApiEnabled is true', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: true })); const result = await service.getAllPayments(7, dateFrom, dateTo, 100); @@ -90,6 +151,23 @@ describe('KycClientService payments API gate', () => { await expect(service.getAllPayments(7, dateFrom, dateTo)).rejects.toBeInstanceOf(ForbiddenException); expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); }); + + it('includes every wallet user regardless of payment webhook consent (pre-gate row set)', async () => { + // CONSENT_ONLY would admit only kycClientList users on the push path; pull must still list both. + const consentedUser = userWith(10, '0xCONSENTED', '7'); + const plainUser = userWith(11, '0xPLAIN'); + walletService.getByIdOrName.mockResolvedValue( + walletWith({ + paymentsApiEnabled: true, + paymentOption: WebhookConfigOption.CONSENT_ONLY, + users: [consentedUser, plainUser], + }), + ); + + await service.getAllPayments(7, dateFrom, dateTo); + + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([10, 11], dateFrom, dateTo, undefined); + }); }); describe('getAllUserPayments', () => { @@ -105,111 +183,88 @@ describe('KycClientService payments API gate', () => { expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); }); - it('returns user payments when paymentsApiEnabled is true and option admits the user', async () => { - walletService.getByIdOrName.mockResolvedValue( - walletWith({ paymentsApiEnabled: true, paymentOption: WebhookConfigOption.TRUE }), - ); + it('returns user payments when paymentsApiEnabled is true', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: true })); const result = await service.getAllUserPayments(7, '0xUSER', dateFrom, dateTo); expect(result).toEqual([]); expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([42], dateFrom, dateTo); }); - }); - /** - * Consent filter mirrors webhook.service sendWebhooks: - * - own-wallet channel → consented=false (isPaymentOptionValid(false)) - * - kycClientList channel → consented=true - * Matrix over TRUE / CONSENT_ONLY / WALLET_ONLY with a consented and a non-consented user. - */ - describe('payment pull consent filter (same rule as push)', () => { - const consentedUser = userWith(10, '0xCONSENTED', '7'); // wallet 7 in kycClientList - const plainUser = userWith(11, '0xPLAIN'); // no kycClientList entry - - const cases: { - option: WebhookConfigOption; - expectedIds: number[]; - label: string; - }[] = [ - { - option: WebhookConfigOption.TRUE, - expectedIds: [10, 11], - label: 'TRUE admits both channels → both users', - }, - { - option: WebhookConfigOption.WALLET_ONLY, - expectedIds: [10, 11], - label: 'WALLET_ONLY admits own-wallet (consented=false) → both users', - }, - { - option: WebhookConfigOption.CONSENT_ONLY, - expectedIds: [10], - label: 'CONSENT_ONLY admits only kycClientList channel → consented user only', - }, - ]; - - describe.each(cases)('$label', ({ option, expectedIds }) => { - it('getAllPayments filters users to the push-equivalent set', async () => { - walletService.getByIdOrName.mockResolvedValue( - walletWith({ - paymentsApiEnabled: true, - paymentOption: option, - users: [consentedUser, plainUser], - }), - ); - - await service.getAllPayments(7, dateFrom, dateTo); - - if (expectedIds.length === 0) { - expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); - } else { - expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith( - expectedIds, - dateFrom, - dateTo, - undefined, - ); - } - }); - - it.each([ - { user: consentedUser, included: expectedIds.includes(10) }, - { user: plainUser, included: expectedIds.includes(11) }, - ])('getAllUserPayments for $user.address included=$included', async ({ user, included }) => { - walletService.getByIdOrName.mockResolvedValue( - walletWith({ - paymentsApiEnabled: true, - paymentOption: option, - users: [consentedUser, plainUser], - }), - ); - - const result = await service.getAllUserPayments(7, user.address, dateFrom, dateTo); - - expect(result).toEqual([]); - if (included) { - expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([user.id], dateFrom, dateTo); - } else { - expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); - } - }); - }); - - it('FALSE admits nobody', async () => { + it('includes a user without payment consent (pre-gate row set)', async () => { + const plainUser = userWith(11, '0xPLAIN'); walletService.getByIdOrName.mockResolvedValue( walletWith({ paymentsApiEnabled: true, - paymentOption: WebhookConfigOption.FALSE, - users: [consentedUser, plainUser], + paymentOption: WebhookConfigOption.CONSENT_ONLY, + users: [plainUser], }), ); - await service.getAllPayments(7, dateFrom, dateTo); - expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + await service.getAllUserPayments(7, '0xPLAIN', dateFrom, dateTo); - await service.getAllUserPayments(7, '0xCONSENTED', dateFrom, dateTo); - expect(transactionService.getTransactionsForUsers).not.toHaveBeenCalled(); + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith([11], dateFrom, dateTo); + }); + }); + + describe('partner pull response redaction', () => { + /** Inject a full webhook-shaped payload; redaction under test is the pull-path strip only. */ + const seedFullPaymentDto = () => { + jest.spyOn(service as any, 'toPaymentDtos').mockResolvedValue([fullPaymentPayload()]); + }; + + it('getAllPayments omits every field in PARTNER_PAYMENT_OMITTED_FIELDS', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: true })); + seedFullPaymentDto(); + + const [row] = await service.getAllPayments(7, dateFrom, dateTo); + const keys = Object.keys(row); + + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(keys).not.toContain(field); + expect(row).not.toHaveProperty(field); + } + + // Kept operational fields still present. + expect(row.dfxReference).toBe(12345); + expect(row.inputAmount).toBe(100); + expect(row.outputAmount).toBe(90); + expect(row.externalTransactionId).toBe('partner-ext-1'); + expect(row.chargebackAmount).toBe(5); + }); + + it('getAllUserPayments omits every field in PARTNER_PAYMENT_OMITTED_FIELDS', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: true })); + seedFullPaymentDto(); + + const [row] = await service.getAllUserPayments(7, '0xUSER', dateFrom, dateTo); + const keys = Object.keys(row); + + for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) { + expect(keys).not.toContain(field); + expect(row).not.toHaveProperty(field); + } + }); + + it('walks the full payload keys: kept fields survive, omitted fields are gone', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: true })); + seedFullPaymentDto(); + const full = fullPaymentPayload(); + const fullKeys = Object.keys(full); + const omitted = new Set(PARTNER_PAYMENT_OMITTED_FIELDS); + + const [row] = await service.getAllPayments(7, dateFrom, dateTo); + const rowKeys = new Set(Object.keys(row)); + + for (const key of fullKeys) { + if (omitted.has(key)) { + expect(rowKeys.has(key)).toBe(false); + } else { + expect(rowKeys.has(key)).toBe(true); + expect((row as any)[key]).toEqual((full as any)[key]); + } + } }); }); }); diff --git a/src/subdomains/generic/kyc/services/kyc-client.service.ts b/src/subdomains/generic/kyc/services/kyc-client.service.ts index 2f7ce8aad6..10e790734e 100644 --- a/src/subdomains/generic/kyc/services/kyc-client.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-client.service.ts @@ -13,6 +13,7 @@ import { WalletService } from '../../user/models/wallet/wallet.service'; import { PaymentWebhookData } from '../../user/services/webhook/dto/payment-webhook.dto'; import { WebhookDataMapper } from '../../user/services/webhook/mapper/webhook-data.mapper'; import { FileType, KycClientDataDto, KycFileBlob, KycReportDto, KycReportType } from '../dto/kyc-file.dto'; +import { PartnerPaymentDto, toPartnerPaymentDto } from '../dto/partner-payment.dto'; import { ContentType } from '../enums/content-type.enum'; import { FileCategory } from '../enums/file-category.enum'; import { KycDocumentService } from './integration/kyc-document.service'; @@ -37,17 +38,16 @@ export class KycClientService { return wallet.users.map((b) => this.toKycDataDto(b)); } - async getAllPayments(walletId: number, dateFrom: Date, dateTo: Date, limit?: number): Promise { + async getAllPayments(walletId: number, dateFrom: Date, dateTo: Date, limit?: number): Promise { const wallet = await this.walletService.getByIdOrName(walletId, undefined, { users: { userData: true } }); if (!wallet) throw new NotFoundException('Wallet not found'); this.assertPaymentsApiEnabled(wallet); - const userIds = this.usersEligibleForPaymentPull(wallet).map((u) => u.id); - if (userIds.length === 0) return []; - + // Row set matches pre-gate behaviour: every wallet user, no consent filter. + const userIds = wallet.users.map((u) => u.id); const transactions = await this.transactionService.getTransactionsForUsers(userIds, dateFrom, dateTo, limit); - return this.toPaymentDtos(transactions); + return this.toPartnerPaymentDtos(transactions); } async getAllUserPayments( @@ -55,18 +55,17 @@ export class KycClientService { userAddress: string, dateFrom: Date, dateTo: Date, - ): Promise { + ): Promise { const wallet = await this.walletService.getByIdOrName(walletId, undefined, { users: { userData: true } }); if (!wallet) throw new NotFoundException('Wallet not found'); this.assertPaymentsApiEnabled(wallet); const user = wallet.users.find((u) => u.address === userAddress); if (!user) throw new NotFoundException('User not found'); - if (!this.isUserEligibleForPaymentPull(wallet, user)) return []; const transactions = await this.transactionService.getTransactionsForUsers([user.id], dateFrom, dateTo); - return this.toPaymentDtos(transactions); + return this.toPartnerPaymentDtos(transactions); } async getKycFiles(userAddress: string, walletId: number): Promise { @@ -105,25 +104,9 @@ export class KycClientService { } } - /** - * Same consent channels as webhook.service sendWebhooks for PAYMENT: - * - own-wallet channel → consented=false - * - kycClientList channel → consented=true - * Users that neither channel admits fall out of the result. - */ - private usersEligibleForPaymentPull(wallet: Wallet): User[] { - return wallet.users.filter((user) => this.isUserEligibleForPaymentPull(wallet, user)); - } - - private isUserEligibleForPaymentPull(wallet: Wallet, user: User): boolean { - // Path 1: user's own wallet (webhook.service filters with isValidForWebhook(type, false)). - // Users in wallet.users belong to this wallet; option evaluation only (apiUrl is push-only). - if (wallet.isPaymentOptionValid(false)) return true; - - // Path 2: consented KYC-client channel (webhook.service: kycClientList + consented=true). - if (user.userData?.kycClientList.includes(wallet.id) && wallet.isPaymentOptionValid(true)) return true; - - return false; + private async toPartnerPaymentDtos(transactions: Transaction[]): Promise { + const full = await this.toPaymentDtos(transactions); + return full.map(toPartnerPaymentDto); } private async toPaymentDtos(transactions: Transaction[]): Promise {