Skip to content
Open
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
29 changes: 29 additions & 0 deletions src/controllers/invoice.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,35 @@ export function createInvoiceController(invoiceService: InvoiceService) {
}
},

async getInvoiceAnalytics(
req: Request & { params: { id: string } },
res: Response,
next: NextFunction,
): Promise<void> {
try {
const authReq = req as AuthenticatedRequest;
if (!authReq.user) {
throw new HttpError(401, "Authentication required");
}

const { id } = req.params;

const result = await invoiceService.getInvoiceAnalytics(id, authReq.user.id);

res.status(200).json({
success: true,
data: result,
});
} catch (error) {
if (error instanceof ServiceError) {
next(new HttpError(error.statusCode, error.message));
return;
}

next(error);
}
},

async calculateTerms(
req: Request,
res: Response,
Expand Down
7 changes: 7 additions & 0 deletions src/routes/invoice.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ export function createInvoiceRouter({
controller.getInvoiceEscrowStatus,
);

// GET /api/v1/invoices/:id/analytics - Get invoice analytics (owner only)
router.get(
"/:id/analytics",
authenticateJWT,
controller.getInvoiceAnalytics,
);

// POST /api/v1/invoices/calculate-terms - Calculate invoice discounting terms, fees, and APR
router.post(
"/calculate-terms",
Expand Down
42 changes: 42 additions & 0 deletions src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,48 @@ export class InvoiceService {
};
}

/**
* Get analytics data for an invoice (views, click-throughs, investor interest).
* Only the owning seller can access this endpoint.
*/
async getInvoiceAnalytics(invoiceId: string, sellerId: string): Promise<{
invoiceId: string;
views: number;
clickThroughs: number;
investorInterest: number;
}> {
const invoice = await this.invoiceRepository.findOne({
where: { id: invoiceId },
});

if (!invoice) {
throw new ServiceError("invoice_not_found", "Invoice not found", 404);
}

if (invoice.sellerId !== sellerId) {
throw new ServiceError(
"forbidden",
"You do not have access to this invoice's analytics",
403,
);
}

let investorInterest = 0;
if (this.dataSource) {
const investmentRepository = this.dataSource.getRepository(Investment);
investorInterest = await investmentRepository.count({
where: { invoiceId },
});
}

return {
invoiceId: invoice.id,
views: 0,
clickThroughs: 0,
investorInterest,
};
}

/**
* Convert Invoice model to DTO
*/
Expand Down
158 changes: 158 additions & 0 deletions tests/integration/invoice-analytics.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { InvoiceService } from "../../src/services/invoice.service";
import { Invoice } from "../../src/models/Invoice.model";
import { Investment } from "../../src/models/Investment.model";
import { User } from "../../src/models/User.model";
import { InvoiceStatus, UserType, KYCStatus } from "../../src/types/enums";
import type { IPFSService } from "../../src/services/ipfs.service";

const mockIPFSService = {} as IPFSService;

function createInvoiceRepository(invoices: Map<string, Invoice>) {
return {
findOne: async (options: { where: { id: string } }) => {
return invoices.get(options.where.id) || null;
},
findOneBy: async (options: { id?: string; invoiceNumber?: string }) => {
if (options.id) {
return invoices.get(options.id) || null;
}
return null;
},
} as any;
}

function createInvestmentRepository(investments: Map<string, Investment>) {
return {
count: async (options: { where: { invoiceId: string } }) => {
return [...investments.values()].filter(
(inv) => inv.invoiceId === options.where.invoiceId
).length;
},
} as any;
}

function createFakeDataSource(investments: Map<string, Investment>) {
return {
getRepository: jest.fn().mockImplementation((entity: any) => {
if (entity === Investment) {
return createInvestmentRepository(investments);
}
return {};
}),
} as any;
}

describe("Invoice analytics integration: seller authorization scoping", () => {
let invoices: Map<string, Invoice>;
let investments: Map<string, Investment>;
let sellerA: User;
let sellerB: User;
let invoiceA: Invoice;
let invoiceService: InvoiceService;

beforeEach(() => {
invoices = new Map();
investments = new Map();

sellerA = {
id: "seller-a-id",
stellarAddress: "GSELLERA123",
email: "sellerA@test.com",
userType: UserType.SELLER,
kycStatus: KYCStatus.APPROVED,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as User;

sellerB = {
id: "seller-b-id",
stellarAddress: "GSELLERB123",
email: "sellerB@test.com",
userType: UserType.SELLER,
kycStatus: KYCStatus.APPROVED,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as User;

invoiceA = {
id: "invoice-a-id",
sellerId: sellerA.id,
invoiceNumber: "INV-A-ANALYTICS-001",
customerName: "Customer A",
amount: "1000.0000",
discountRate: "5.00",
netAmount: "950.0000",
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
status: InvoiceStatus.PUBLISHED,
ipfsHash: null,
riskScore: null,
smartContractId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as Invoice;

invoices.set(invoiceA.id, invoiceA);

const investmentA = {
id: "investment-a-id",
invoiceId: invoiceA.id,
investorId: sellerB.id,
investmentAmount: "100.0000",
expectedReturn: "105.0000",
status: "pending" as any,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as Investment;

investments.set(investmentA.id, investmentA);

const invoiceRepository = createInvoiceRepository(invoices);
const fakeDataSource = createFakeDataSource(investments);

invoiceService = new InvoiceService({
invoiceRepository,
ipfsService: mockIPFSService,
dataSource: fakeDataSource,
});
});

it("returns 403 with error code forbidden when seller B requests analytics for seller A's invoice", async () => {
await expect(
invoiceService.getInvoiceAnalytics(invoiceA.id, sellerB.id),
).rejects.toMatchObject({
code: "forbidden",
statusCode: 403,
});
});

it("returns 200 with analytics data when seller A requests analytics for their own invoice", async () => {
const result = await invoiceService.getInvoiceAnalytics(invoiceA.id, sellerA.id);

expect(result).toBeDefined();
expect(result.invoiceId).toBe(invoiceA.id);
expect(result.views).toBe(0);
expect(result.clickThroughs).toBe(0);
expect(result.investorInterest).toBe(1);
});

it("returns 404 for non-existent invoice", async () => {
await expect(
invoiceService.getInvoiceAnalytics("non-existent-id", sellerA.id),
).rejects.toMatchObject({
code: "invoice_not_found",
statusCode: 404,
});
});

it("returns investorInterest of 0 when invoice has no investments", async () => {
investments.clear();

const result = await invoiceService.getInvoiceAnalytics(invoiceA.id, sellerA.id);

expect(result.investorInterest).toBe(0);
});
});