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 e83794e..2267e9e 100644 --- a/src/payouts/payouts.module.ts +++ b/src/payouts/payouts.module.ts @@ -18,6 +18,7 @@ import { PAYOUT_RETRY_QUEUE, PAYOUT_RETRY_QUEUE_PRIORITY } from './payout-retry. import { StellarConfirmationProcessor } from './stellar-confirmation.processor'; import { STELLAR_CONFIRMATION_QUEUE } from './stellar-confirmation.queue'; import { PayoutApprovalService } from './payout-approval.service'; +import { ConfigService } from '../config/config.service'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { PayoutApprovalService } from './payout-approval.service'; PayoutRetryProcessor, StellarConfirmationProcessor, PayoutApprovalService, + ConfigService, ], exports: [PayoutsService, FeeService, PayoutMethodService], }) diff --git a/src/payouts/payouts.service.spec.ts b/src/payouts/payouts.service.spec.ts index 792b5ee..686cbdd 100644 --- a/src/payouts/payouts.service.spec.ts +++ b/src/payouts/payouts.service.spec.ts @@ -9,6 +9,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, @@ -74,6 +75,8 @@ describe('PayoutsService', () => { add: jest.fn(), }; + const mockConfigService = { minStellarPayout: 5 }; + const mockPlatformAddress = StellarSdk.Keypair.random().publicKey(); beforeEach(async () => { @@ -119,6 +122,10 @@ describe('PayoutsService', () => { processCreatorEarnings: jest.fn(), }, }, + { + provide: ConfigService, + useValue: mockConfigService, + }, ], }).compile(); @@ -131,6 +138,7 @@ describe('PayoutsService', () => { delete process.env.STELLAR_WALLET_ADDRESS; delete process.env.PLATFORM_WALLET_ADDRESS; jest.restoreAllMocks(); + mockConfigService.minStellarPayout = 5; }); it('should be defined', () => { @@ -334,6 +342,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 f6ad444..8513d60 100644 --- a/src/payouts/payouts.service.ts +++ b/src/payouts/payouts.service.ts @@ -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, @@ -228,6 +235,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); @@ -281,12 +290,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 }, @@ -458,6 +462,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( @@ -660,6 +666,8 @@ export class PayoutsService { ); } + this.assertMinimumPayout(payout.amount); + const platformSecret = process.env.STELLAR_PLATFORM_SECRET; if (!platformSecret) { throw new InternalServerErrorException(