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/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 new file mode 100644 index 0000000000..2423910839 --- /dev/null +++ b/src/subdomains/generic/kyc/services/__tests__/kyc-client.service.spec.ts @@ -0,0 +1,270 @@ +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 { 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'; + +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'); + 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), + }); + + /** 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, + buyCryptoWebhookService, + buyFiatService, + 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', async () => { + walletService.getByIdOrName.mockResolvedValue(walletWith({ paymentsApiEnabled: 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(); + }); + + 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', () => { + 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', 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); + }); + + 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.CONSENT_ONLY, + users: [plainUser], + }), + ); + + await service.getAllUserPayments(7, '0xPLAIN', dateFrom, dateTo); + + 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 f8ce48a56e..10e790734e 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,16 +8,20 @@ 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'; 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'; @Injectable() export class KycClientService { + private readonly logger = new DfxLogger(KycClientService); + constructor( private readonly documentService: KycDocumentService, private readonly userService: UserService, @@ -33,14 +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); + // 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( @@ -48,16 +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'); 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 { @@ -87,6 +95,20 @@ 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'); + } + } + + private async toPartnerPaymentDtos(transactions: Transaction[]): Promise { + const full = await this.toPaymentDtos(transactions); + return full.map(toPartnerPaymentDto); + } + 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 {