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
87 changes: 87 additions & 0 deletions backend/src/invoices/invoices.cron.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InvoicesService } from './invoices.service';
import { ConfigService } from '@nestjs/config';
import { StellarService } from '../stellar/stellar.service';
import { SorobanService } from '../soroban/soroban.service';
import { PrismaService } from '../prisma/prisma.service';
import { WebhooksService } from '../webhooks/webhooks.service';

describe('InvoicesService Cron', () => {
let service: InvoicesService;
let prismaService: PrismaService;
let webhooksService: WebhooksService;

const mockPrismaService = {
invoice: {
findMany: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
};

const mockWebhooksService = {
enqueueWebhook: jest.fn(),
};

const mockConfigService = { get: jest.fn() };
const mockStellarService = { getMerchantPublicKey: jest.fn() };
const mockSorobanService = {};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
InvoicesService,
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: WebhooksService, useValue: mockWebhooksService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: StellarService, useValue: mockStellarService },
{ provide: SorobanService, useValue: mockSorobanService },
],
}).compile();

service = module.get<InvoicesService>(InvoicesService);
prismaService = module.get<PrismaService>(PrismaService);
webhooksService = module.get<WebhooksService>(WebhooksService);
});

afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
});

describe('handleOverdueInvoices', () => {
it('should mark overdue pending invoices as expired', async () => {
const now = new Date('2026-03-10T02:00:00Z');
jest.useFakeTimers().setSystemTime(now);

const overdueInvoices = [{ id: 'inv-1' }, { id: 'inv-2' }];

(mockPrismaService.invoice.findMany as jest.Mock).mockResolvedValue(overdueInvoices);
(mockPrismaService.invoice.update as jest.Mock).mockResolvedValue({
id: 'inv-1',
status: 'expired',
txHash: null,
amount: 100
});

await service.handleOverdueInvoices();

expect(mockPrismaService.invoice.findMany).toHaveBeenCalledWith({
where: {
status: 'pending',
dueDate: { lt: now },
},
select: { id: true },
});

expect(mockPrismaService.invoice.update).toHaveBeenCalledTimes(2);
expect(mockWebhooksService.enqueueWebhook).toHaveBeenCalledTimes(2);
});

it('should handle empty list gracefully', async () => {
(mockPrismaService.invoice.findMany as jest.Mock).mockResolvedValue([]);
await service.handleOverdueInvoices();
expect(mockPrismaService.invoice.update).not.toHaveBeenCalled();
});
});
});
44 changes: 44 additions & 0 deletions backend/src/invoices/invoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
NotFoundException,
OnModuleInit,
} from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { ConfigService } from "@nestjs/config";
import { Invoice } from "./entities/invoice.entity";
import { CreateInvoiceDto } from "./dto/create-invoice.dto";
Expand Down Expand Up @@ -256,6 +257,49 @@ export class InvoicesService implements OnModuleInit {
return { ...updated, txHash, ledger };
}

/**
* Cron job to expire overdue invoices.
* Runs every day at 02:00 UTC.
*/
@Cron('0 2 * * *')
async handleOverdueInvoices() {
this.logger.log('Running overdue invoices check...');
try {
const now = new Date();
const overdueInvoices = await this.prisma.invoice.findMany({
where: {
status: 'pending',
dueDate: { lt: now },
},
select: { id: true },
});

if (overdueInvoices.length === 0) {
this.logger.log('No overdue invoices found.');
return;
}

this.logger.log(`Found ${overdueInvoices.length} overdue invoices. Expiring...`);

let successCount = 0;
let failCount = 0;

for (const invoice of overdueInvoices) {
try {
await this.updateStatus(invoice.id, 'expired' as any);
successCount++;
} catch (err) {
this.logger.error(`Failed to expire invoice ${invoice.id}`, err);
failCount++;
}
}

this.logger.log(`Expired ${successCount} invoices. Failed: ${failCount}`);
} catch (error) {
this.logger.error('Error in handleOverdueInvoices cron job', error);
}
}

/** Normalize invoice before returning to callers (convert Decimal/string amounts to number and add destination address) */
private normalizeInvoice(inv: any): Invoice {
const amount = inv?.amount;
Expand Down
Loading