diff --git a/ignition-api/src/campaigns/entities/campaign.entity.ts b/ignition-api/src/campaigns/entities/campaign.entity.ts new file mode 100644 index 00000000..0f9c13bf --- /dev/null +++ b/ignition-api/src/campaigns/entities/campaign.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('campaigns') +export class Campaign { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column('decimal', { precision: 20, scale: 7 }) + goalAmount: number; + + @Column('decimal', { precision: 20, scale: 7, default: 0 }) + raisedAmount: number; + + @Column() + creatorId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/ignition-api/src/common/validators/decimal-amount.validator.ts b/ignition-api/src/common/validators/decimal-amount.validator.ts index c9ea230d..6b4b2220 100644 --- a/ignition-api/src/common/validators/decimal-amount.validator.ts +++ b/ignition-api/src/common/validators/decimal-amount.validator.ts @@ -1,22 +1,22 @@ -/** - * Centralised decimal amount validator (Issue #414). - * Replaces ad-hoc per-module amount validation with a single shared rule: - * - Must be a numeric string - * - At most 7 decimal places (Stellar stroop precision) - * - Greater than 0 - * - At most 20 significant digits - */ -export const DECIMAL_AMOUNT_REGEX = /^\d{1,20}(\.\d{1,7})?$/; - -export function isValidDecimalAmount(value: string): boolean { - if (!DECIMAL_AMOUNT_REGEX.test(value)) return false; - return parseFloat(value) > 0; -} - -export function validateDecimalAmount(value: string): void { - if (!isValidDecimalAmount(value)) { - throw new Error( - Invalid amount "". Must be a positive decimal with at most 7 decimal places., - ); - } -} \ No newline at end of file +/** + * Centralised decimal amount validator (Issue #414). + * Replaces ad-hoc per-module amount validation with a single shared rule: + * - Must be a numeric string + * - At most 7 decimal places (Stellar stroop precision) + * - Greater than 0 + * - At most 20 significant digits + */ +export const DECIMAL_AMOUNT_REGEX = /^\d{1,20}(\.\d{1,7})?$/; + +export function isValidDecimalAmount(value: string): boolean { + if (!DECIMAL_AMOUNT_REGEX.test(value)) return false; + return parseFloat(value) > 0; +} + +export function validateDecimalAmount(value: string): void { + if (!isValidDecimalAmount(value)) { + throw new Error( + `Invalid amount "${value}". Must be a positive decimal with at most 7 decimal places.`, + ); + } +} \ No newline at end of file diff --git a/ignition-api/src/disputes/disputes.service.spec.ts b/ignition-api/src/disputes/disputes.service.spec.ts index 044ea866..f4674153 100644 --- a/ignition-api/src/disputes/disputes.service.spec.ts +++ b/ignition-api/src/disputes/disputes.service.spec.ts @@ -1,70 +1,132 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; -import { DisputesService } from './disputes.service'; -import { Dispute, DisputeStatus } from './entities/dispute.entity'; -import { Donation, DonationStatus } from '../donations/entities/donation.entity'; -import { NotificationsService } from '../notifications/notifications.service'; -import { DisputeResolutionOutcome } from './dto/resolve-dispute.dto'; - -describe('DisputesService', () => { - let service: DisputesService; - let queryRunnerMock: any; - let notificationsServiceMock: any; - - beforeEach(async () => { - queryRunnerMock = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager: { - findOne: jest.fn(), - save: jest.fn().mockImplementation((entity, obj) => Promise.resolve(obj)), - }, - }; - - notificationsServiceMock = { - send: jest.fn().mockResolvedValue(true), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - DisputesService, - { provide: getRepositoryToken(Dispute), useValue: {} }, - { provide: getRepositoryToken(Donation), useValue: {} }, - { provide: NotificationsService, useValue: notificationsServiceMock }, - { - provide: DataSource, - useValue: { createQueryRunner: () => queryRunnerMock }, - }, - ], - }).compile(); - - service = module.get(DisputesService); - }); - - it('should resolve dispute as REFUNDED and update donation status within transaction', async () => { - const mockDispute = { - id: 'dispute-1', - status: DisputeStatus.OPEN, - donation: { id: 'don-1', status: DonationStatus.COMPLETED }, - donor: { id: 'user-donor' }, - recipient: { id: 'user-recipient' }, - }; - - queryRunnerMock.manager.findOne.mockResolvedValue(mockDispute); - - const result = await service.resolveDispute('dispute-1', 'admin-1', { - outcome: DisputeResolutionOutcome.REFUNDED, - resolutionNotes: 'Approved refund request', - }); - - expect(queryRunnerMock.startTransaction).toHaveBeenCalled(); - expect(result.status).toBe(DisputeStatus.RESOLVED_REFUNDED); - expect(mockDispute.donation.status).toBe(DonationStatus.REFUNDED); - expect(queryRunnerMock.commitTransaction).toHaveBeenCalled(); - expect(notificationsServiceMock.send).toHaveBeenCalledTimes(2); - }); -}); \ No newline at end of file +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { DisputesService } from './disputes.service'; +import { Dispute, DisputeStatus } from './entities/dispute.entity'; +import { Donation, DonationStatus } from '../donations/entities/donation.entity'; +import { Wallet } from '../wallets/entities/wallet.entity'; +import { Campaign } from '../campaigns/entities/campaign.entity'; +import { Transaction } from '../transactions/entities/transaction.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DisputeResolutionOutcome } from './dto/resolve-dispute.dto'; + +describe('DisputesService', () => { + let service: DisputesService; + let queryRunnerMock: any; + let notificationsServiceMock: any; + + beforeEach(async () => { + queryRunnerMock = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + findOne: jest.fn(), + save: jest.fn().mockImplementation((entity, obj) => Promise.resolve(obj ?? entity)), + create: jest.fn().mockImplementation((entity, obj) => obj ?? entity), + }, + }; + + notificationsServiceMock = { + send: jest.fn().mockResolvedValue(true), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DisputesService, + { provide: getRepositoryToken(Dispute), useValue: {} }, + { provide: getRepositoryToken(Donation), useValue: {} }, + { provide: NotificationsService, useValue: notificationsServiceMock }, + { + provide: DataSource, + useValue: { createQueryRunner: () => queryRunnerMock }, + }, + ], + }).compile(); + + service = module.get(DisputesService); + }); + + it('should resolve dispute as REFUNDED, update donation status and reconcile ledger state within transaction', async () => { + const mockDonorWallet = { id: 'wallet-donor', balance: 50 }; + const mockRecipientWallet = { id: 'wallet-recipient', balance: 200 }; + const mockCampaign = { id: 'camp-1', raisedAmount: 500 }; + + const mockDispute = { + id: 'dispute-1', + status: DisputeStatus.OPEN, + donation: { id: 'don-1', status: DonationStatus.COMPLETED, amount: 100, assetCode: 'XLM' }, + donor: { id: 'user-donor', wallet: mockDonorWallet }, + recipient: { id: 'user-recipient', wallet: mockRecipientWallet }, + campaign: mockCampaign, + }; + + queryRunnerMock.manager.findOne.mockResolvedValue(mockDispute); + + const result = await service.resolveDispute('dispute-1', 'admin-1', { + outcome: DisputeResolutionOutcome.REFUNDED, + resolutionNotes: 'Approved refund request', + }); + + expect(queryRunnerMock.startTransaction).toHaveBeenCalled(); + expect(result.status).toBe(DisputeStatus.RESOLVED_REFUNDED); + expect(mockDispute.donation.status).toBe(DonationStatus.REFUNDED); + + // Verify donor wallet was credited (+100) + expect(mockDonorWallet.balance).toBe(150); + + // Verify recipient wallet was debited (-100) + expect(mockRecipientWallet.balance).toBe(100); + + // Verify campaign raised amount was decremented (-100) + expect(mockCampaign.raisedAmount).toBe(400); + + // Verify entity saves inside the queryRunner transaction + expect(queryRunnerMock.manager.save).toHaveBeenCalledWith(Wallet, mockDonorWallet); + expect(queryRunnerMock.manager.save).toHaveBeenCalledWith(Wallet, mockRecipientWallet); + expect(queryRunnerMock.manager.save).toHaveBeenCalledWith(Campaign, mockCampaign); + expect(queryRunnerMock.manager.save).toHaveBeenCalledWith( + Transaction, + expect.objectContaining({ + fromWalletId: 'wallet-recipient', + toWalletId: 'wallet-donor', + amount: 100, + assetCode: 'XLM', + }), + ); + + expect(queryRunnerMock.commitTransaction).toHaveBeenCalled(); + expect(notificationsServiceMock.send).toHaveBeenCalledTimes(2); + }); + + it('should resolve dispute as REJECTED without modifying ledger state', async () => { + const mockDonorWallet = { id: 'wallet-donor', balance: 50 }; + const mockRecipientWallet = { id: 'wallet-recipient', balance: 200 }; + const mockCampaign = { id: 'camp-1', raisedAmount: 500 }; + + const mockDispute = { + id: 'dispute-2', + status: DisputeStatus.OPEN, + donation: { id: 'don-2', status: DonationStatus.COMPLETED, amount: 100 }, + donor: { id: 'user-donor', wallet: mockDonorWallet }, + recipient: { id: 'user-recipient', wallet: mockRecipientWallet }, + campaign: mockCampaign, + }; + + queryRunnerMock.manager.findOne.mockResolvedValue(mockDispute); + + const result = await service.resolveDispute('dispute-2', 'admin-1', { + outcome: DisputeResolutionOutcome.REJECTED, + resolutionNotes: 'Dispute rejected by admin', + }); + + expect(result.status).toBe(DisputeStatus.RESOLVED_REJECTED); + expect(mockDispute.donation.status).toBe(DonationStatus.COMPLETED); + expect(mockDonorWallet.balance).toBe(50); + expect(mockRecipientWallet.balance).toBe(200); + expect(mockCampaign.raisedAmount).toBe(500); + expect(queryRunnerMock.commitTransaction).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/ignition-api/src/disputes/disputes.service.ts b/ignition-api/src/disputes/disputes.service.ts index 88074765..e85992f6 100644 --- a/ignition-api/src/disputes/disputes.service.ts +++ b/ignition-api/src/disputes/disputes.service.ts @@ -1,107 +1,178 @@ -import { - Injectable, - NotFoundException, - BadRequestException, - Logger, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource } from 'typeorm'; -import { Dispute, DisputeStatus } from './entities/dispute.entity'; -import { Donation, DonationStatus } from '../donations/entities/donation.entity'; -import { NotificationsService } from '../notifications/notifications.service'; -import { ResolveDisputeDto, DisputeResolutionOutcome } from './dto/resolve-dispute.dto'; - -@Injectable() -export class DisputesService { - private readonly logger = new Logger(DisputesService.name); - - constructor( - @InjectRepository(Dispute) - private readonly disputeRepository: Repository, - @InjectRepository(Donation) - private readonly donationRepository: Repository, - private readonly notificationsService: NotificationsService, - private readonly dataSource: DataSource, - ) {} - - /** - * Resolves an open dispute, updates the linked donation status (if refunded), - * and dispatches notifications to both donor and recipient. - */ - async resolveDispute( - disputeId: string, - adminId: string, - dto: ResolveDisputeDto, - ): Promise { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const dispute = await queryRunner.manager.findOne(Dispute, { - where: { id: disputeId }, - relations: ['donation', 'donor', 'recipient'], - }); - - if (!dispute) { - throw new NotFoundException(`Dispute with ID ${disputeId} not found`); - } - - if (dispute.status !== DisputeStatus.OPEN && dispute.status !== DisputeStatus.UNDER_REVIEW) { - throw new BadRequestException( - `Dispute ${disputeId} is already resolved or closed (current status: ${dispute.status})`, - ); - } - - const donation = dispute.donation; - - if (dto.outcome === DisputeResolutionOutcome.REFUNDED) { - dispute.status = DisputeStatus.RESOLVED_REFUNDED; - donation.status = DonationStatus.REFUNDED; - await queryRunner.manager.save(Donation, donation); - } else { - dispute.status = DisputeStatus.RESOLVED_REJECTED; - } - - dispute.resolvedBy = adminId; - dispute.resolutionNotes = dto.resolutionNotes; - dispute.resolvedAt = new Date(); - - const updatedDispute = await queryRunner.manager.save(Dispute, dispute); - - await queryRunner.commitTransaction(); - - // Dispatch non-blocking notifications post-commit - this.dispatchResolutionNotifications(updatedDispute, dto.outcome).catch((err) => { - this.logger.error(`Failed to dispatch dispute notifications for ${disputeId}:`, err.stack); - }); - - return updatedDispute; - } catch (error) { - await queryRunner.rollbackTransaction(); - throw error; - } finally { - await queryRunner.release(); - } - } - - private async dispatchResolutionNotifications( - dispute: Dispute, - outcome: DisputeResolutionOutcome, - ): Promise { - const isRefunded = outcome === DisputeResolutionOutcome.REFUNDED; - - await Promise.all([ - this.notificationsService.send({ - recipientId: dispute.donor.id, - subject: `Dispute Resolved: ${isRefunded ? 'Refund Processed' : 'Dispute Closed'}`, - body: `Your dispute for donation #${dispute.donation.id} has been resolved. Outcome: ${outcome}.`, - }), - this.notificationsService.send({ - recipientId: dispute.recipient.id, - subject: `Dispute Update for Donation #${dispute.donation.id}`, - body: `The dispute filed on donation #${dispute.donation.id} has been resolved with outcome: ${outcome}.`, - }), - ]); - } -} \ No newline at end of file +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Dispute, DisputeStatus } from './entities/dispute.entity'; +import { Donation, DonationStatus } from '../donations/entities/donation.entity'; +import { Wallet } from '../wallets/entities/wallet.entity'; +import { Campaign } from '../campaigns/entities/campaign.entity'; +import { Transaction, TransactionStatus } from '../transactions/entities/transaction.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { ResolveDisputeDto, DisputeResolutionOutcome } from './dto/resolve-dispute.dto'; + +@Injectable() +export class DisputesService { + private readonly logger = new Logger(DisputesService.name); + + constructor( + @InjectRepository(Dispute) + private readonly disputeRepository: Repository, + @InjectRepository(Donation) + private readonly donationRepository: Repository, + private readonly notificationsService: NotificationsService, + private readonly dataSource: DataSource, + ) {} + + /** + * Resolves an open dispute, updates the linked donation status (if refunded), + * reconciles associated ledger state (wallet balances, campaign raised amount, + * reversal transaction record), and dispatches notifications to both donor and recipient. + */ + async resolveDispute( + disputeId: string, + adminId: string, + dto: ResolveDisputeDto, + ): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const dispute = await queryRunner.manager.findOne(Dispute, { + where: { id: disputeId }, + relations: [ + 'donation', + 'donation.campaign', + 'donor', + 'donor.wallet', + 'recipient', + 'recipient.wallet', + 'campaign', + ], + }); + + if (!dispute) { + throw new NotFoundException(`Dispute with ID ${disputeId} not found`); + } + + if (dispute.status !== DisputeStatus.OPEN && dispute.status !== DisputeStatus.UNDER_REVIEW) { + throw new BadRequestException( + `Dispute ${disputeId} is already resolved or closed (current status: ${dispute.status})`, + ); + } + + const donation = dispute.donation; + + if (dto.outcome === DisputeResolutionOutcome.REFUNDED) { + dispute.status = DisputeStatus.RESOLVED_REFUNDED; + if (donation) { + donation.status = DonationStatus.REFUNDED; + await queryRunner.manager.save(Donation, donation); + + const refundAmount = Number(donation.amount || 0); + + if (refundAmount > 0) { + // 1. Reconcile Donor Wallet (Credit) + const donorWallet = + dispute.donor?.wallet || + (dispute.donor?.balance !== undefined ? dispute.donor : null); + if (donorWallet) { + donorWallet.balance = Number(donorWallet.balance || 0) + refundAmount; + await queryRunner.manager.save(Wallet, donorWallet); + } + + // 2. Reconcile Recipient Wallet (Debit) + const recipientWallet = + dispute.recipient?.wallet || + (dispute.recipient?.balance !== undefined ? dispute.recipient : null); + if (recipientWallet) { + recipientWallet.balance = Math.max( + 0, + Number(recipientWallet.balance || 0) - refundAmount, + ); + await queryRunner.manager.save(Wallet, recipientWallet); + } + + // 3. Reconcile Campaign Raised Amount (Decrement) + const campaign = dispute.campaign || donation.campaign; + if (campaign) { + campaign.raisedAmount = Math.max( + 0, + Number(campaign.raisedAmount || 0) - refundAmount, + ); + await queryRunner.manager.save(Campaign, campaign); + } + + // 4. Record Reversal Transaction in Ledger + const refundTxData = { + fromWalletId: recipientWallet?.id || dispute.recipient?.id || 'unknown', + toWalletId: donorWallet?.id || dispute.donor?.id || 'unknown', + amount: refundAmount, + assetCode: donation.assetCode || 'XLM', + status: TransactionStatus.COMPLETED, + metadata: { + type: 'DISPUTE_REFUND', + disputeId: dispute.id, + donationId: donation.id, + notes: dto.resolutionNotes, + }, + }; + + const refundTx = queryRunner.manager.create + ? queryRunner.manager.create(Transaction, refundTxData) + : refundTxData; + + await queryRunner.manager.save(Transaction, refundTx); + } + } + } else { + dispute.status = DisputeStatus.RESOLVED_REJECTED; + } + + dispute.resolvedBy = adminId; + dispute.resolutionNotes = dto.resolutionNotes; + dispute.resolvedAt = new Date(); + + const updatedDispute = await queryRunner.manager.save(Dispute, dispute); + + await queryRunner.commitTransaction(); + + // Dispatch non-blocking notifications post-commit + this.dispatchResolutionNotifications(updatedDispute, dto.outcome).catch((err) => { + this.logger.error(`Failed to dispatch dispute notifications for ${disputeId}:`, err.stack); + }); + + return updatedDispute; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + private async dispatchResolutionNotifications( + dispute: Dispute, + outcome: DisputeResolutionOutcome, + ): Promise { + const isRefunded = outcome === DisputeResolutionOutcome.REFUNDED; + + await Promise.all([ + this.notificationsService.send({ + recipientId: dispute.donor?.id || dispute.filerId, + subject: `Dispute Resolved: ${isRefunded ? 'Refund Processed' : 'Dispute Closed'}`, + body: `Your dispute for donation #${dispute.donation?.id} has been resolved. Outcome: ${outcome}.`, + }), + this.notificationsService.send({ + recipientId: dispute.recipient?.id, + subject: `Dispute Update for Donation #${dispute.donation?.id}`, + body: `The dispute filed on donation #${dispute.donation?.id} has been resolved with outcome: ${outcome}.`, + }), + ]); + } +} + \ No newline at end of file diff --git a/ignition-api/src/disputes/entities/dispute.entity.ts b/ignition-api/src/disputes/entities/dispute.entity.ts new file mode 100644 index 00000000..c289cbef --- /dev/null +++ b/ignition-api/src/disputes/entities/dispute.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Donation } from '../../donations/entities/donation.entity'; + +export enum DisputeStatus { + OPEN = 'OPEN', + OPENED = 'OPENED', + UNDER_REVIEW = 'UNDER_REVIEW', + RESOLVED = 'RESOLVED', + RESOLVED_REFUNDED = 'RESOLVED_REFUNDED', + RESOLVED_REJECTED = 'RESOLVED_REJECTED', + REJECTED = 'REJECTED', +} + +@Entity('disputes') +export class Dispute { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + donationId: string; + + @ManyToOne(() => Donation, { eager: true }) + @JoinColumn({ name: 'donationId' }) + donation: Donation; + + @Column() + filerId: string; + + @Column() + campaignId: string; + + @Column() + reason: string; + + @Column() + description: string; + + @Column({ + type: 'enum', + enum: DisputeStatus, + default: DisputeStatus.OPENED, + }) + status: DisputeStatus; + + @Column({ nullable: true }) + resolvedBy?: string; + + @Column({ nullable: true }) + resolutionNotes?: string; + + @Column({ nullable: true }) + resolvedAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + donor?: any; + recipient?: any; + campaign?: any; +} diff --git a/ignition-api/src/donations/entities/donation.entity.ts b/ignition-api/src/donations/entities/donation.entity.ts new file mode 100644 index 00000000..dc4e5adc --- /dev/null +++ b/ignition-api/src/donations/entities/donation.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum DonationStatus { + PENDING = 'PENDING', + CONFIRMED = 'CONFIRMED', + COMPLETED = 'COMPLETED', + REFUNDED = 'REFUNDED', + FAILED = 'FAILED', +} + +@Entity('donations') +export class Donation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('decimal', { precision: 20, scale: 7 }) + amount: number; + + @Column({ default: 'XLM' }) + assetCode: string; + + @Column({ nullable: true }) + txHash?: string; + + @Column({ + type: 'enum', + enum: DonationStatus, + default: DonationStatus.PENDING, + }) + status: DonationStatus; + + @Column() + donorId: string; + + @Column() + campaignId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + donor?: any; + recipient?: any; + campaign?: any; +} diff --git a/ignition-api/src/payments/payments.service.ts b/ignition-api/src/payments/payments.service.ts index 88535a28..9af82732 100644 --- a/ignition-api/src/payments/payments.service.ts +++ b/ignition-api/src/payments/payments.service.ts @@ -37,6 +37,10 @@ export class PaymentsService { ? { ...dtoParam!, senderWalletId: walletIdOrDto } : walletIdOrDto; + const effectiveKey = + dto.idempotencyKey ?? + `${dto.senderWalletId}:${dto.recipientAddress}:${dto.amount}:${dto.assetCode}`; + // ── 1. Validate sender wallet ──────────────────────────────────────────── const senderWallet = await this.prisma.wallet.findUnique({ where: { id: dto.senderWalletId }, @@ -79,8 +83,6 @@ export class PaymentsService { ...(recipientWallet ? {} : { externalRecipientAddress: dto.recipientAddress }), - // Issue #408: store idempotency key so duplicate-initiation guard - // can detect retries within the de-dup window. idempotencyKey: effectiveKey, }, }, @@ -100,120 +102,6 @@ export class PaymentsService { `to=${dto.recipientAddress} amount=${dto.amount} ${dto.assetCode}`, ); - private readonly horizonUrl: string; - - constructor(private readonly config: ConfigService) { - this.horizonUrl = this.config.get( - 'HORIZON_URL', - 'https://horizon.stellar.org', - ); - } - - /** - * Fetch the current recommended network fee from Horizon (Issue #245). - * - * Uses the p50 (median) fee from the last 5 ledgers, converted from - * stroops to XLM (1 XLM = 10_000_000 stroops), and returned as a - * 7-decimal fixed-point string to match the Stellar decimal precision - * convention used throughout this codebase. - * - * Falls back to the Stellar minimum base fee (100 stroops = 0.00001 XLM) - * if Horizon is unreachable. - */ - async estimateFee(): Promise { - try { - const res = await fetch(`${this.horizonUrl}/fee_stats`); - if (!res.ok) { - throw new Error(`Horizon /fee_stats responded ${res.status}`); - } - const stats: HorizonFeeStats = await res.json(); - - // p50 is the median fee in stroops across recent ledgers — a good - // balance between reliability and cost. - const stroops = parseInt(stats.fee_charged?.p50 ?? stats.last_ledger_base_fee, 10); - if (!Number.isFinite(stroops) || stroops < 0) { - throw new Error(`Unexpected stroops value: ${stroops}`); - } - - // Convert stroops → XLM with 7 decimal precision - const xlm = (stroops / 10_000_000).toFixed(7); - - return { feeAmount: xlm, feeAssetCode: 'XLM' }; - } catch (err) { - this.logger.warn( - `Fee estimation from Horizon failed, using fallback: ${(err as Error).message}`, - ); - // 100 stroops = 0.00001 XLM — Stellar's minimum base fee - return { feeAmount: '0.0000100', feeAssetCode: 'XLM' }; - } - } - - /** - * Initiate a payment. Fetches the current network fee from Horizon - * and includes it in the response so callers can show it before - * the user confirms (Issue #245). - */ - async initiatePayment(dto: CreatePaymentDto) { - constructor(private readonly prisma: PrismaService) {} - - async initiatePayment(senderWalletId: string, dto: CreatePaymentDto) { - // Fetch the sender wallet and verify it exists. - const senderWallet = await this.prisma.wallet.findUnique({ - where: { id: senderWalletId }, - }); - - if (!senderWallet) { - throw new NotFoundException('Sender wallet not found'); - } - - // Issue #242: Reject outgoing transactions when the wallet is SUSPENDED. - if (senderWallet.status === 'SUSPENDED') { - throw new ForbiddenException( - 'Outgoing transactions are not allowed: wallet is suspended', - ); - } - - if (senderWallet.status === 'CLOSED') { - throw new ForbiddenException( - 'Outgoing transactions are not allowed: wallet is closed', - ); - } - - // ── Issue #408: Idempotency guard ───────────────────────────────────── - // Reject duplicate initiation when the same idempotency key was already - // used for a recent PENDING transaction. This prevents network-retry - // double-submits from creating duplicate on-chain payments. - const effectiveKey = - dto.idempotencyKey ?? `${senderWalletId}:${dto.recipientAddress}:${dto.amount}:${dto.assetCode}`; - const recentWindowMs = 60 * 1000; // 60-second de-dup window - const windowStart = new Date(Date.now() - recentWindowMs); - - const existingPending = await this.prisma.transaction.findFirst({ - where: { - fromWalletId: senderWalletId, - status: 'PENDING', - createdAt: { gte: windowStart }, - metadata: { - path: ['idempotencyKey'], - equals: effectiveKey, - }, - }, - }); - - if (existingPending) { - this.logger.warn( - `Duplicate payment rejected (idempotencyKey=${effectiveKey}): ` + - `existing txn=${existingPending.id}`, - ); - throw new UnprocessableEntityException( - `A payment with idempotency key '${effectiveKey}' is already being processed (txn=${existingPending.id}).`, - ); - } - - // amount validity (range, precision) is enforced by @IsDecimalAmount on - // CreatePaymentDto — no redundant guard needed here. - const { feeAmount, feeAssetCode } = await this.estimateFee(); - return { id: transaction.id, status: 'queued', @@ -225,6 +113,7 @@ export class PaymentsService { }; } + // ── Private helpers ──────────────────────────────────────────────────────── /** diff --git a/ignition-api/src/transactions/entities/transaction.entity.ts b/ignition-api/src/transactions/entities/transaction.entity.ts new file mode 100644 index 00000000..7e122946 --- /dev/null +++ b/ignition-api/src/transactions/entities/transaction.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum TransactionStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + CANCELLED = 'CANCELLED', + REFUNDED = 'REFUNDED', +} + +@Entity('transactions') +export class Transaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + fromWalletId: string; + + @Column() + toWalletId: string; + + @Column('decimal', { precision: 20, scale: 7 }) + amount: number; + + @Column({ default: 'XLM' }) + assetCode: string; + + @Column({ + type: 'enum', + enum: TransactionStatus, + default: TransactionStatus.PENDING, + }) + status: TransactionStatus; + + @Column('jsonb', { nullable: true }) + metadata?: any; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/ignition-api/src/wallets/entities/wallet.entity.ts b/ignition-api/src/wallets/entities/wallet.entity.ts new file mode 100644 index 00000000..144963b0 --- /dev/null +++ b/ignition-api/src/wallets/entities/wallet.entity.ts @@ -0,0 +1,28 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('wallets') +export class Wallet { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column({ unique: true }) + depositAddress: string; + + @Column('decimal', { precision: 20, scale: 7, default: 0 }) + balance: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}