Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions ignition-api/src/campaigns/entities/campaign.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 22 additions & 22 deletions ignition-api/src/common/validators/decimal-amount.validator.ts
Original file line number Diff line number Diff line change
@@ -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.,
);
}
}
/**
* 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.`,
);
}
}
202 changes: 132 additions & 70 deletions ignition-api/src/disputes/disputes.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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);
});
});
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>(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();
});
});
Loading