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
1,360 changes: 12 additions & 1,348 deletions backend/package-lock.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
"redis": "^5.11.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
"throttle": "^1.0.3",
"uuid": "^11.1.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Enable pg_trgm for trigram similarity indexes
CREATE EXTENSION IF NOT EXISTS "pg_trgm";

-- Composite GIN index for full-text search over key client fields
CREATE INDEX "invoices_search_document_idx"
ON "invoices"
USING GIN (
to_tsvector(
'simple',
coalesce("client_name", '') || ' ' ||
coalesce("client_email", '') || ' ' ||
coalesce("memo", '')
)
);

-- Trigram indexes to accelerate ILIKE/similarity matches
CREATE INDEX "invoices_client_name_trgm_idx"
ON "invoices"
USING GIN ("client_name" gin_trgm_ops);

CREATE INDEX "invoices_client_email_trgm_idx"
ON "invoices"
USING GIN ("client_email" gin_trgm_ops);

CREATE INDEX "invoices_memo_trgm_idx"
ON "invoices"
USING GIN ("memo" gin_trgm_ops);
2 changes: 1 addition & 1 deletion backend/src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import type { Request } from "express";

/**
* JWT authentication guard
Expand Down
34 changes: 34 additions & 0 deletions backend/src/invoices/dto/search-invoices.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from "class-validator";
import { Transform } from "class-transformer";

/**
* Query DTO for invoice search endpoint
*/
export class SearchInvoicesDto {
@IsString()
@MinLength(2)
@MaxLength(120)
q: string;

@IsOptional()
@Transform(({ value }) => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
})
@IsInt()
@Min(1)
@Max(50)
limit?: number;
}
22 changes: 22 additions & 0 deletions backend/src/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import {
Patch,
UseGuards,
Query,
Req,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { InvoicesService } from "./invoices.service";
import { CreateInvoiceDto } from "./dto/create-invoice.dto";
import { SearchInvoicesDto } from "./dto/search-invoices.dto";
import { Invoice } from "./entities/invoice.entity";
import { AuthGuard } from "../auth/auth.guard";
import { InvoiceStatus } from "@prisma/client";
import type { Request } from "express";

/**
* Invoices controller
Expand Down Expand Up @@ -43,6 +46,25 @@ export class InvoicesController {
return result.items;
}

/**
* Search invoices by client name, email, or memo for the authenticated merchant
* @returns Array of matching invoices ordered by relevance
*/
@Get("search")
@UseGuards(AuthGuard)
async search(
@Query() query: SearchInvoicesDto,
@Req() req: Request,
): Promise<Invoice[]> {
const user = req["user"] as { sub?: string } | undefined;
const userId = user?.sub;
return await this.invoicesService.searchInvoices(
userId,
query.q,
query.limit ?? 20,
);
}

/**
* Get a single invoice by ID
* @param id - Invoice UUID
Expand Down
48 changes: 26 additions & 22 deletions backend/src/invoices/invoices.cron.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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';
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', () => {
describe("InvoicesService Cron", () => {
let service: InvoicesService;
let prismaService: PrismaService;
let webhooksService: WebhooksService;
Expand Down Expand Up @@ -49,39 +49,43 @@ describe('InvoicesService Cron', () => {
jest.useRealTimers();
});

describe('handleOverdueInvoices', () => {
it('should mark overdue pending invoices as expired', async () => {
const now = new Date('2026-03-10T02:00:00Z');
describe("handleOverdueInvoices", () => {
it("should mark overdue pending invoices as overdue", 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',
const overdueInvoices = [{ id: "inv-1" }, { id: "inv-2" }];

mockPrismaService.invoice.findMany.mockResolvedValue(overdueInvoices);
mockPrismaService.invoice.update.mockResolvedValue({
id: "inv-1",
status: "overdue",
txHash: null,
amount: 100
amount: 100,
});

await service.handleOverdueInvoices();

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

expect(mockPrismaService.invoice.update).toHaveBeenCalledTimes(2);
expect(mockPrismaService.invoice.update).toHaveBeenCalledWith({
where: { id: "inv-2" },
data: { status: "overdue" },
});
expect(mockWebhooksService.enqueueWebhook).toHaveBeenCalledTimes(2);
});

it('should handle empty list gracefully', async () => {
(mockPrismaService.invoice.findMany as jest.Mock).mockResolvedValue([]);
it("should handle empty list gracefully", async () => {
mockPrismaService.invoice.findMany.mockResolvedValue([]);
await service.handleOverdueInvoices();
expect(mockPrismaService.invoice.update).not.toHaveBeenCalled();
});
});
});
});
31 changes: 31 additions & 0 deletions backend/src/invoices/invoices.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Test, TestingModule } from "@nestjs/testing";
import { UnauthorizedException } from "@nestjs/common";
import { InvoicesService } from "./invoices.service";
import { ConfigService } from "@nestjs/config";
import { StellarService } from "../stellar/stellar.service";
Expand Down Expand Up @@ -106,6 +107,7 @@ describe("InvoicesService", () => {
const sampleInvoices = [
{
id: "1",
userId: "user-1",
invoiceNumber: "INV-001",
clientName: "Acme",
clientEmail: "[email protected]",
Expand All @@ -122,6 +124,7 @@ describe("InvoicesService", () => {
},
{
id: "2",
userId: "user-1",
invoiceNumber: "INV-002",
clientName: "Acme",
clientEmail: "[email protected]",
Expand All @@ -138,6 +141,7 @@ describe("InvoicesService", () => {
},
{
id: "3",
userId: "user-2",
invoiceNumber: "INV-003",
clientName: "Acme",
clientEmail: "[email protected]",
Expand All @@ -157,6 +161,14 @@ describe("InvoicesService", () => {
const mockPrisma = () => {
// create a mutable copy so test operations affect returned values
const invoices = sampleInvoices.map((i) => ({ ...i }));
const queryRows = invoices
.filter((inv) => inv.userId === "user-1")
.map((inv, index) => ({
...inv,
ft_match: index === 0,
ft_rank: Number((0.9 - index * 0.1).toFixed(2)),
trigram_rank: Number((0.8 - index * 0.1).toFixed(2)),
}));

return {
invoice: {
Expand Down Expand Up @@ -193,6 +205,7 @@ describe("InvoicesService", () => {
return Promise.resolve({ count: 0 });
}),
},
$queryRaw: jest.fn().mockResolvedValue(queryRows),
};
};

Expand Down Expand Up @@ -392,4 +405,22 @@ describe("InvoicesService", () => {
expect(result).toBeNull();
});
});

describe("searchInvoices", () => {
it("should return invoices scoped to the merchant", async () => {
const results = await service.searchInvoices("user-1", "Acme", 25);

expect(results.length).toBeGreaterThan(0);
for (const invoice of results) {
expect(invoice).toHaveProperty("clientName");
expect(invoice).toHaveProperty("asset_code");
}
});

it("should throw when merchant context is missing", async () => {
await expect(
service.searchInvoices(undefined as any, "Acme"),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});
Loading
Loading