From af70bf8e06a97c25210afc0f49a415ced33488fd Mon Sep 17 00:00:00 2001 From: Petah1 <103060277+Petah1@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:13:35 +0000 Subject: [PATCH] feat(payouts): enforce configurable minimum payout via MIN_STELLAR_PAYOUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load the minimum payout threshold from the MIN_STELLAR_PAYOUT environment variable (default 5) through the centralized ConfigService. Validate at application startup that the configured value is a positive finite number, throwing immediately if it is not. Add a private assertMinimumPayout() guard in PayoutsService and call it at every payout entry point — requestPayout, requestPayoutWithDetails, initiateStellarPayout, processPayout, and batchProcessPayouts — before any balance query, Stellar account load, transaction build, signing, or on-chain submission. Replace the ad-hoc MIN_PAYOUT_AMOUNT env read in requestPayoutWithDetails with the centralized guard. Error message is dynamic and reflects the configured threshold: "Minimum payout amount is USD equivalent." --- src/config/config.service.spec.ts | 60 ++++++ src/config/config.service.ts | 13 ++ src/payouts/payouts.module.ts | 2 + src/payouts/payouts.service.spec.ts | 289 ++++++++++++++++++++++++++++ src/payouts/payouts.service.ts | 30 +-- 5 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 src/config/config.service.spec.ts diff --git a/src/config/config.service.spec.ts b/src/config/config.service.spec.ts new file mode 100644 index 0000000..6d5437a --- /dev/null +++ b/src/config/config.service.spec.ts @@ -0,0 +1,60 @@ +import { ConfigService } from './config.service'; + +describe('ConfigService', () => { + afterEach(() => { + delete process.env.MIN_STELLAR_PAYOUT; + }); + + describe('minStellarPayout', () => { + it('defaults to 5 when MIN_STELLAR_PAYOUT is not set', () => { + delete process.env.MIN_STELLAR_PAYOUT; + const service = new ConfigService(); + expect(service.minStellarPayout).toBe(5); + }); + + it('parses a valid positive integer', () => { + process.env.MIN_STELLAR_PAYOUT = '10'; + const service = new ConfigService(); + expect(service.minStellarPayout).toBe(10); + }); + + it('parses a valid positive decimal', () => { + process.env.MIN_STELLAR_PAYOUT = '2.5'; + const service = new ConfigService(); + expect(service.minStellarPayout).toBe(2.5); + }); + + it('throws at startup when MIN_STELLAR_PAYOUT is a non-numeric string', () => { + process.env.MIN_STELLAR_PAYOUT = 'five'; + expect(() => new ConfigService()).toThrow( + /Invalid MIN_STELLAR_PAYOUT/, + ); + }); + + it('throws at startup when MIN_STELLAR_PAYOUT is zero', () => { + process.env.MIN_STELLAR_PAYOUT = '0'; + expect(() => new ConfigService()).toThrow( + /Invalid MIN_STELLAR_PAYOUT/, + ); + }); + + it('throws at startup when MIN_STELLAR_PAYOUT is negative', () => { + process.env.MIN_STELLAR_PAYOUT = '-5'; + expect(() => new ConfigService()).toThrow( + /Invalid MIN_STELLAR_PAYOUT/, + ); + }); + + it('throws at startup when MIN_STELLAR_PAYOUT is empty string', () => { + process.env.MIN_STELLAR_PAYOUT = ''; + expect(() => new ConfigService()).toThrow( + /Invalid MIN_STELLAR_PAYOUT/, + ); + }); + + it('error message includes the invalid value', () => { + process.env.MIN_STELLAR_PAYOUT = 'abc'; + expect(() => new ConfigService()).toThrow('"abc"'); + }); + }); +}); diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 1738130..3c62525 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -4,6 +4,8 @@ import { Injectable } from '@nestjs/common'; export class ConfigService { readonly earningsCacheTtlSeconds = parseInt(process.env.EARNINGS_CACHE_TTL ?? '3600', 10); + readonly minStellarPayout: number; + readonly leaderboardEnabled = process.env.LEADERBOARD_ENABLED === 'true'; readonly creatorRoyaltyBps = parseInt(process.env.CREATOR_ROYALTY_BPS ?? '1000', 10); @@ -54,4 +56,15 @@ export class ConfigService { readonly redisPort = parseInt(process.env.REDIS_PORT ?? '6379', 10); readonly redisPassword = process.env.REDIS_PASSWORD; + + constructor() { + const raw = process.env.MIN_STELLAR_PAYOUT ?? '5'; + const parsed = parseFloat(raw); + if (!isFinite(parsed) || parsed <= 0) { + throw new Error( + `Invalid MIN_STELLAR_PAYOUT: "${raw}" — must be a positive number`, + ); + } + this.minStellarPayout = parsed; + } } diff --git a/src/payouts/payouts.module.ts b/src/payouts/payouts.module.ts index a70c5c7..ea412f1 100644 --- a/src/payouts/payouts.module.ts +++ b/src/payouts/payouts.module.ts @@ -16,6 +16,7 @@ import { MetricsModule } from '../metrics/metrics.module'; import { PayoutRetryProcessor } from './payout-retry.processor'; import { PAYOUT_RETRY_QUEUE, PAYOUT_RETRY_QUEUE_PRIORITY } from './payout-retry.queue'; import { PayoutApprovalService } from './payout-approval.service'; +import { ConfigService } from '../config/config.service'; @Module({ imports: [ @@ -42,6 +43,7 @@ import { PayoutApprovalService } from './payout-approval.service'; PayoutMethodService, PayoutRetryProcessor, PayoutApprovalService, + ConfigService, ], exports: [PayoutsService, FeeService, PayoutMethodService], }) diff --git a/src/payouts/payouts.service.spec.ts b/src/payouts/payouts.service.spec.ts index e565528..b70f827 100644 --- a/src/payouts/payouts.service.spec.ts +++ b/src/payouts/payouts.service.spec.ts @@ -15,6 +15,7 @@ import { FeeService } from './fee.service'; import { PAYOUT_RETRY_QUEUE } from './payout-retry.queue'; import { PayoutApprovalService } from './payout-approval.service'; import { EarningsService } from '../earnings/earnings.service'; +import { ConfigService } from '../config/config.service'; import { ConflictException, BadRequestException, @@ -80,6 +81,8 @@ describe('PayoutsService', () => { add: jest.fn(), }; + const mockConfigService = { minStellarPayout: 5 }; + const mockPlatformAddress = StellarSdk.Keypair.random().publicKey(); beforeEach(async () => { @@ -125,6 +128,10 @@ describe('PayoutsService', () => { processCreatorEarnings: jest.fn(), }, }, + { + provide: ConfigService, + useValue: mockConfigService, + }, ], }).compile(); @@ -137,6 +144,7 @@ describe('PayoutsService', () => { delete process.env.STELLAR_WALLET_ADDRESS; delete process.env.PLATFORM_WALLET_ADDRESS; jest.restoreAllMocks(); + mockConfigService.minStellarPayout = 5; }); it('should be defined', () => { @@ -340,6 +348,287 @@ describe('PayoutsService', () => { }); }); + // ─── Minimum Payout Enforcement ───────────────────────────────────────────── + + describe('minimum payout enforcement', () => { + const MIN = 5; + + describe('requestPayoutWithDetails', () => { + const setupConflictFree = () => { + mockPrismaService.payout.findFirst.mockResolvedValue(null); + }; + + it('throws BadRequestException when amount is below the configured minimum', async () => { + setupConflictFree(); + await expect( + service.requestPayoutWithDetails(1, MIN - 0.01, 'USD', 'stellar'), + ).rejects.toThrow(BadRequestException); + }); + + it('error message reflects the configured threshold, not a hardcoded value', async () => { + setupConflictFree(); + mockConfigService.minStellarPayout = 10; + await expect( + service.requestPayoutWithDetails(1, 4, 'USD', 'stellar'), + ).rejects.toThrow('Minimum payout amount is 10 USD equivalent.'); + }); + + it('does not query the database for balance when amount is below threshold', async () => { + setupConflictFree(); + await expect( + service.requestPayoutWithDetails(1, MIN - 1, 'USD', 'stellar'), + ).rejects.toThrow(BadRequestException); + expect(mockPrismaService.earning.aggregate).not.toHaveBeenCalled(); + expect(mockPrismaService.payout.create).not.toHaveBeenCalled(); + }); + + it('proceeds when amount exactly equals the configured minimum', async () => { + setupConflictFree(); + mockPrismaService.earning.aggregate.mockResolvedValue({ _sum: { amount: 100 } }); + mockPrismaService.payout.aggregate.mockResolvedValue({ _sum: { amount: 0 } }); + mockPrismaService.wallet.findFirst.mockResolvedValue({ id: 1, address: 'GTEST...' }); + mockPrismaService.payout.create.mockResolvedValue({ + id: 1, + amount: MIN, + currency: 'USD', + method: 'stellar', + status: 'approved', + createdAt: new Date(), + feeAmount: 0, + finalAmount: MIN, + }); + + const result = await service.requestPayoutWithDetails(1, MIN, 'USD', 'stellar'); + expect(result.amount).toBe(MIN); + }); + + it('proceeds when amount exceeds the configured minimum', async () => { + setupConflictFree(); + const amount = MIN + 100; + mockPrismaService.earning.aggregate.mockResolvedValue({ _sum: { amount: 1000 } }); + mockPrismaService.payout.aggregate.mockResolvedValue({ _sum: { amount: 0 } }); + mockPrismaService.wallet.findFirst.mockResolvedValue({ id: 1, address: 'GTEST...' }); + mockPrismaService.payout.create.mockResolvedValue({ + id: 2, + amount, + currency: 'USD', + method: 'stellar', + status: 'approved', + createdAt: new Date(), + feeAmount: 0, + finalAmount: amount, + }); + + const result = await service.requestPayoutWithDetails(1, amount, 'USD', 'stellar'); + expect(result.amount).toBe(amount); + }); + }); + + describe('requestPayout', () => { + it('throws BadRequestException when available balance is below the minimum', async () => { + mockPrismaService.payout.findFirst.mockResolvedValue(null); + mockPrismaService.wallet.findFirst.mockResolvedValue({ id: 1, address: 'GTEST...' }); + mockPrismaService.earning.aggregate.mockResolvedValue({ _sum: { amount: MIN - 1 } }); + mockPrismaService.payout.aggregate.mockResolvedValue({ _sum: { amount: 0 } }); + + await expect(service.requestPayout(1)).rejects.toThrow(BadRequestException); + await expect(service.requestPayout(1)).rejects.toThrow('Minimum payout amount is 5 USD equivalent.'); + }); + + it('does not create a payout when balance is below the minimum', async () => { + mockPrismaService.payout.findFirst.mockResolvedValue(null); + mockPrismaService.wallet.findFirst.mockResolvedValue({ id: 1, address: 'GTEST...' }); + mockPrismaService.earning.aggregate.mockResolvedValue({ _sum: { amount: MIN - 1 } }); + mockPrismaService.payout.aggregate.mockResolvedValue({ _sum: { amount: 0 } }); + + await expect(service.requestPayout(1)).rejects.toThrow(BadRequestException); + expect(mockPrismaService.payout.create).not.toHaveBeenCalled(); + }); + + it('proceeds when available balance exactly equals the minimum', async () => { + mockPrismaService.payout.findFirst.mockResolvedValue(null); + mockPrismaService.wallet.findFirst.mockResolvedValue({ id: 1, address: 'GTEST...' }); + mockPrismaService.earning.aggregate.mockResolvedValue({ _sum: { amount: MIN } }); + mockPrismaService.payout.aggregate.mockResolvedValue({ _sum: { amount: 0 } }); + mockPrismaService.payout.create.mockResolvedValue({ + id: 3, + amount: MIN, + status: 'approved', + createdAt: new Date(), + feeAmount: 0, + finalAmount: MIN, + }); + + const result = await service.requestPayout(1); + expect(result.amount).toBe(MIN); + }); + }); + + describe('initiateStellarPayout', () => { + const belowMinPayoutRecord = (amount: number) => ({ + id: 77, + userId: 1, + amount, + currency: 'USD', + method: 'stellar', + status: 'approved', + wallet: { address: StellarSdk.Keypair.random().publicKey() }, + transactionId: null, + }); + + beforeEach(() => { + process.env.STELLAR_PLATFORM_SECRET = 'SOME_SECRET'; + process.env.STELLAR_WALLET_ADDRESS = mockPlatformAddress; + }); + + afterEach(() => { + delete process.env.STELLAR_PLATFORM_SECRET; + delete process.env.STELLAR_WALLET_ADDRESS; + }); + + it('throws BadRequestException when amount is below the configured minimum', async () => { + const amount = MIN - 1; + mockPrismaService.payout.findFirst.mockResolvedValue( + belowMinPayoutRecord(amount), + ); + + await expect( + service.initiateStellarPayout(1, 77, amount), + ).rejects.toThrow(BadRequestException); + await expect( + service.initiateStellarPayout(1, 77, amount), + ).rejects.toThrow('Minimum payout amount is 5 USD equivalent.'); + }); + + it('does not query Stellar balance or build a transaction when amount is below minimum', async () => { + const amount = MIN - 1; + mockPrismaService.payout.findFirst.mockResolvedValue( + belowMinPayoutRecord(amount), + ); + + await expect( + service.initiateStellarPayout(1, 77, amount), + ).rejects.toThrow(BadRequestException); + expect(mockStellarService.getAccountBalance).not.toHaveBeenCalled(); + expect(mockPrismaService.payout.update).not.toHaveBeenCalled(); + }); + + it('proceeds when amount exactly equals the configured minimum', async () => { + const destination = StellarSdk.Keypair.random().publicKey(); + mockPrismaService.payout.findFirst + .mockResolvedValueOnce({ + id: 78, + userId: 1, + amount: MIN, + currency: 'USD', + method: 'stellar', + status: 'approved', + wallet: { address: destination }, + transactionId: null, + }) + .mockResolvedValueOnce(null); + + jest.spyOn(mockStellarService as any, 'getAccountBalance').mockResolvedValue(250); + jest.spyOn(StellarSdk.Horizon.Server.prototype, 'loadAccount').mockResolvedValue({ + sequenceNumber: () => '1', + accountId: () => mockPlatformAddress, + } as any); + jest.spyOn(StellarSdk.Keypair, 'fromSecret').mockReturnValue({ + publicKey: () => mockPlatformAddress, + sign: () => Buffer.from([]), + } as any); + jest.spyOn(StellarSdk.Operation, 'payment').mockImplementation(() => ({} as any)); + jest.spyOn(StellarSdk.TransactionBuilder.prototype, 'addOperation').mockImplementation(function () { return this; }); + jest.spyOn(StellarSdk.TransactionBuilder.prototype, 'setTimeout').mockImplementation(function () { return this; }); + jest.spyOn(StellarSdk.TransactionBuilder.prototype, 'build').mockImplementation(function () { + return { + sign: () => {}, + hash: () => Buffer.from('aabbccdd', 'hex'), + toXDR: () => 'mock-xdr-min', + }; + }); + mockPrismaService.payout.update.mockResolvedValue({ + id: 78, + amount: MIN, + status: 'pending', + }); + + const result = await service.initiateStellarPayout(1, 78, MIN); + expect(result.status).toBe('pending'); + }); + }); + + describe('processPayout', () => { + it('throws BadRequestException when payout amount is below the configured minimum', async () => { + mockPrismaService.payout.findUnique.mockResolvedValue({ + id: 99, + amount: MIN - 1, + currency: 'USD', + method: 'stellar', + status: 'approved', + retryCount: 0, + stellarXdr: null, + wallet: { address: StellarSdk.Keypair.random().publicKey() }, + user: { id: 1, email: 'user@example.com' }, + }); + + await expect(service.processPayout(99)).rejects.toThrow(BadRequestException); + await expect(service.processPayout(99)).rejects.toThrow('Minimum payout amount is 5 USD equivalent.'); + }); + + it('does not build or submit a Stellar transaction when payout is below the minimum', async () => { + mockPrismaService.payout.findUnique.mockResolvedValue({ + id: 99, + amount: MIN - 1, + currency: 'USD', + method: 'stellar', + status: 'approved', + retryCount: 0, + stellarXdr: null, + wallet: { address: StellarSdk.Keypair.random().publicKey() }, + user: { id: 1, email: 'user@example.com' }, + }); + + await expect(service.processPayout(99)).rejects.toThrow(BadRequestException); + expect(mockPrismaService.payout.update).not.toHaveBeenCalled(); + }); + }); + + describe('dynamic threshold', () => { + it('uses the configured threshold rather than a hardcoded value', async () => { + mockConfigService.minStellarPayout = 25; + mockPrismaService.payout.findFirst.mockResolvedValue(null); + + await expect( + service.requestPayoutWithDetails(1, 24, 'USD', 'stellar'), + ).rejects.toThrow('Minimum payout amount is 25 USD equivalent.'); + }); + + it('accepts amounts above a raised threshold', async () => { + mockConfigService.minStellarPayout = 1; + mockPrismaService.payout.findFirst.mockResolvedValue(null); + mockPrismaService.earning.aggregate.mockResolvedValue({ _sum: { amount: 100 } }); + mockPrismaService.payout.aggregate.mockResolvedValue({ _sum: { amount: 0 } }); + mockPrismaService.wallet.findFirst.mockResolvedValue({ id: 1, address: 'GTEST...' }); + mockPrismaService.payout.create.mockResolvedValue({ + id: 10, + amount: 2, + currency: 'USD', + method: 'stellar', + status: 'approved', + createdAt: new Date(), + feeAmount: 0, + finalAmount: 2, + }); + + const result = await service.requestPayoutWithDetails(1, 2, 'USD', 'stellar'); + expect(result.amount).toBe(2); + }); + }); + }); + + // ─── initiateStellarPayout (existing tests) ────────────────────────────────── + describe('initiateStellarPayout', () => { beforeEach(() => { process.env.STELLAR_PLATFORM_SECRET = 'SOME_SECRET'; diff --git a/src/payouts/payouts.service.ts b/src/payouts/payouts.service.ts index b40c99f..17168de 100644 --- a/src/payouts/payouts.service.ts +++ b/src/payouts/payouts.service.ts @@ -16,7 +16,7 @@ import { EarningsService } from '../earnings/earnings.service'; import { PAYOUT_RETRY_QUEUE, MAX_PAYOUT_RETRIES, PAYOUT_RETRY_BACKOFF_BASE } from './payout-retry.queue'; import { FeeService } from './fee.service'; import { PayoutApprovalService } from './payout-approval.service'; -import { FeeService } from './fee.service'; +import { ConfigService } from '../config/config.service'; const OPEN_PAYOUT_STATUSES = [ 'pending', @@ -39,12 +39,17 @@ export class PayoutsService { private payoutReceiptService: PayoutReceiptService, private feeService: FeeService, private payoutApprovalService: PayoutApprovalService, + private readonly config: ConfigService, @InjectQueue(PAYOUT_RETRY_QUEUE) private payoutRetryQueue: Queue, - ) { - this.minPayoutAmount = parseFloat(process.env.MIN_STELLAR_PAYOUT ?? '5'); - } + ) {} - private minPayoutAmount: number; + private assertMinimumPayout(amount: number): void { + if (amount < this.config.minStellarPayout) { + throw new BadRequestException( + `Minimum payout amount is ${this.config.minStellarPayout} USD equivalent.`, + ); + } + } private getPlatformWalletAddress(): string { return ( @@ -92,6 +97,8 @@ export class PayoutsService { throw new BadRequestException('Requested amount does not match payout amount'); } + this.assertMinimumPayout(amount); + const existingPending = await this.prisma.payout.findFirst({ where: { id: payoutId, @@ -227,6 +234,8 @@ export class PayoutsService { const availableBalance = (totalEarnings._sum.amount ?? 0) - (totalPaidOut._sum.amount ?? 0); + this.assertMinimumPayout(availableBalance); + const fee = await this.feeService.calculateFee(availableBalance, 'stellar'); const status = this.payoutApprovalService.resolveInitialStatus(availableBalance); @@ -280,12 +289,7 @@ export class PayoutsService { ); } - const minThreshold = parseFloat(process.env.MIN_PAYOUT_AMOUNT ?? '10'); - if (amount < minThreshold) { - throw new BadRequestException( - `Minimum payout amount is ${minThreshold} ${currency}`, - ); - } + this.assertMinimumPayout(amount); const totalEarnings = await this.prisma.earning.aggregate({ where: { clip: { video: { userId } }, deletedAt: null }, @@ -457,6 +461,8 @@ export class PayoutsService { throw new BadRequestException('No wallet associated with this payout'); } + this.assertMinimumPayout(payout.amount); + const platformSecret = process.env.STELLAR_PLATFORM_SECRET; if (!platformSecret) { throw new InternalServerErrorException( @@ -659,6 +665,8 @@ export class PayoutsService { ); } + this.assertMinimumPayout(payout.amount); + const platformSecret = process.env.STELLAR_PLATFORM_SECRET; if (!platformSecret) { throw new InternalServerErrorException(