diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index cbf9476..ec3860d 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -336,5 +336,53 @@ export function createInvoiceController(invoiceService: InvoiceService) { next(error); } }, + + async getInvoiceTokenHolders( + req: Request & { params: { id: string } }, + res: Response, + next: NextFunction, + ): Promise { + try { + const { id } = req.params; + + const result = await invoiceService.getInvoiceTokenHolders(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 getInvoiceEscrowStatus( + req: Request & { params: { id: string } }, + res: Response, + next: NextFunction, + ): Promise { + try { + const { id } = req.params; + + const result = await invoiceService.getInvoiceEscrowStatus(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); + } + }, }; } diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 4c2e407..d44327f 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -214,5 +214,19 @@ export function createInvoiceRouter({ controller.uploadDocument, ); + // GET /api/v1/invoices/:id/tokens - Get invoice token holders + router.get( + "/:id/tokens", + authenticateJWT, + controller.getInvoiceTokenHolders, + ); + + // GET /api/v1/invoices/:id/escrow - Get invoice escrow status + router.get( + "/:id/escrow", + authenticateJWT, + controller.getInvoiceEscrowStatus, + ); + return router; } diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index c6be270..f77eec2 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -1,7 +1,9 @@ import { DataSource } from "typeorm"; +import Decimal from "decimal.js"; import { Invoice } from "../models/Invoice.model"; +import { Investment } from "../models/Investment.model"; import { User } from "../models/User.model"; -import { InvoiceStatus, KYCStatus } from "../types/enums"; +import { InvoiceStatus, KYCStatus, InvestmentStatus } from "../types/enums"; import { ServiceError } from "../utils/service-error"; import { validateInvoiceForPublish } from "../lib/validate-invoice-for-publish"; import { logInvoiceTransition } from "../lib/invoice-lifecycle-log"; @@ -25,6 +27,7 @@ export interface InvoiceRepositoryContract { export interface InvoiceServiceDependencies { invoiceRepository: InvoiceRepositoryContract; ipfsService: IPFSService; + dataSource?: DataSource; } export interface UploadDocumentInput { @@ -114,10 +117,12 @@ const VALID_TRANSITIONS: Record = { export class InvoiceService { private readonly invoiceRepository: InvoiceRepositoryContract; private readonly ipfsService: IPFSService; + private readonly dataSource?: DataSource; constructor(dependencies: InvoiceServiceDependencies) { this.invoiceRepository = dependencies.invoiceRepository; this.ipfsService = dependencies.ipfsService; + this.dataSource = dependencies.dataSource; } /** @@ -421,6 +426,110 @@ export class InvoiceService { }; } + /** + * Get all token holders for a published invoice with their token balances and percentage shares + */ + async getInvoiceTokenHolders(invoiceId: string): Promise< + Array<{ + walletAddress: string; + investmentAmount: string; + percentageShare: string; + status: InvestmentStatus; + }> + > { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + if (invoice.status === InvoiceStatus.DRAFT) { + throw new ServiceError( + "invalid_invoice_status", + "Token holders can only be queried for published invoices", + 400 + ); + } + + if (!this.dataSource) { + throw new ServiceError( + "internal_error", + "Database connection unavailable", + 500 + ); + } + + const investmentRepository = this.dataSource.getRepository(Investment); + + const investments = await investmentRepository + .createQueryBuilder("investment") + .leftJoinAndSelect("investment.investor", "investor") + .where("investment.invoiceId = :invoiceId", { invoiceId }) + .andWhere("investment.deletedAt IS NULL") + .getMany(); + + if (investments.length === 0) { + return []; + } + + const totalInvested = investments.reduce( + (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), + new Decimal(0) + ); + + return investments.map((investment) => { + const investor = investment.investor as unknown as User; + const percentage = totalInvested.isZero() + ? new Decimal(0) + : new Decimal(investment.investmentAmount) + .dividedBy(totalInvested) + .times(100) + .toDecimalPlaces(2); + + return { + walletAddress: investor.stellarAddress, + investmentAmount: investment.investmentAmount, + percentageShare: percentage.toString(), + status: investment.status, + }; + }); + } + + /** + * Get on-chain escrow status for an invoice + */ + async getInvoiceEscrowStatus(invoiceId: string): Promise<{ + invoiceId: string; + hasEscrow: boolean; + contractId: string | null; + status: string | null; + }> { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + if (!invoice.smartContractId) { + throw new ServiceError( + "no_escrow_contract", + "This invoice does not have a deployed escrow contract", + 404 + ); + } + + return { + invoiceId: invoice.id, + hasEscrow: true, + contractId: invoice.smartContractId, + status: invoice.status, + }; + } + /** * Convert Invoice model to DTO */ @@ -453,5 +562,6 @@ export function createInvoiceService( return new InvoiceService({ invoiceRepository, ipfsService, + dataSource, }); } diff --git a/src/utils/invoice-state.utils.ts b/src/utils/invoice-state.utils.ts new file mode 100644 index 0000000..3e27990 --- /dev/null +++ b/src/utils/invoice-state.utils.ts @@ -0,0 +1,21 @@ +import { InvoiceStatus } from "@/types/enums"; + +export function isValidInvoiceStateTransition( + currentStatus: InvoiceStatus, + targetStatus: InvoiceStatus +): boolean { + const validTransitions: Record = { + [InvoiceStatus.DRAFT]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED], + [InvoiceStatus.PENDING]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED], + [InvoiceStatus.PUBLISHED]: [InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED], + [InvoiceStatus.FUNDED]: [InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED], + [InvoiceStatus.SETTLED]: [InvoiceStatus.CANCELLED], + [InvoiceStatus.CANCELLED]: [], + }; + + if (!validTransitions[currentStatus]) { + return false; + } + + return validTransitions[currentStatus].includes(targetStatus); +} diff --git a/src/utils/stellar-address.utils.ts b/src/utils/stellar-address.utils.ts index 12dd071..c5c2ae9 100644 --- a/src/utils/stellar-address.utils.ts +++ b/src/utils/stellar-address.utils.ts @@ -3,3 +3,7 @@ import { StrKey } from "stellar-sdk"; export function isValidStellarPublicKey(address: unknown): address is string { return typeof address === "string" && StrKey.isValidEd25519PublicKey(address); } + +export function isValidSorobanContractId(contractId: unknown): contractId is string { + return typeof contractId === "string" && StrKey.isValidContract(contractId); +} diff --git a/tests/unit/utils/invoice-state-machine.test.ts b/tests/unit/utils/invoice-state-machine.test.ts new file mode 100644 index 0000000..f85e4aa --- /dev/null +++ b/tests/unit/utils/invoice-state-machine.test.ts @@ -0,0 +1,223 @@ +import { InvoiceStatus } from "../../../src/types/enums"; +import { isValidInvoiceStateTransition } from "../../../src/utils/invoice-state.utils"; + +describe("isValidInvoiceStateTransition", () => { + const allStatuses = Object.values(InvoiceStatus); + + describe("valid transitions from DRAFT", () => { + it("allows transition from DRAFT to PUBLISHED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.PUBLISHED)).toBe( + true + ); + }); + + it("allows transition from DRAFT to CANCELLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED)).toBe( + true + ); + }); + + it("rejects transition from DRAFT to FUNDED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.FUNDED)).toBe(false); + }); + + it("rejects transition from DRAFT to SETTLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.SETTLED)).toBe( + false + ); + }); + + it("rejects transition from DRAFT to PENDING", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.PENDING)).toBe( + false + ); + }); + + it("rejects transition from DRAFT to DRAFT", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.DRAFT)).toBe(false); + }); + }); + + describe("valid transitions from PENDING", () => { + it("allows transition from PENDING to PUBLISHED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.PUBLISHED)).toBe( + true + ); + }); + + it("allows transition from PENDING to CANCELLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.CANCELLED)).toBe( + true + ); + }); + + it("rejects transition from PENDING to DRAFT", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.DRAFT)).toBe( + false + ); + }); + + it("rejects transition from PENDING to FUNDED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.FUNDED)).toBe( + false + ); + }); + }); + + describe("valid transitions from PUBLISHED", () => { + it("allows transition from PUBLISHED to FUNDED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED)).toBe( + true + ); + }); + + it("allows transition from PUBLISHED to CANCELLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED)).toBe( + true + ); + }); + + it("rejects transition from PUBLISHED to DRAFT", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.DRAFT)).toBe( + false + ); + }); + + it("rejects transition from PUBLISHED to SETTLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.SETTLED)).toBe( + false + ); + }); + + it("rejects transition from PUBLISHED to PENDING", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.PENDING)).toBe( + false + ); + }); + }); + + describe("valid transitions from FUNDED", () => { + it("allows transition from FUNDED to SETTLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.SETTLED)).toBe( + true + ); + }); + + it("allows transition from FUNDED to CANCELLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED)).toBe( + true + ); + }); + + it("rejects transition from FUNDED to DRAFT", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.DRAFT)).toBe(false); + }); + + it("rejects transition from FUNDED to PUBLISHED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.PUBLISHED)).toBe( + false + ); + }); + + it("rejects transition from FUNDED to PENDING", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.PENDING)).toBe( + false + ); + }); + }); + + describe("valid transitions from SETTLED", () => { + it("allows transition from SETTLED to CANCELLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED)).toBe( + true + ); + }); + + it("rejects transition from SETTLED to DRAFT", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.DRAFT)).toBe( + false + ); + }); + + it("rejects transition from SETTLED to PENDING", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.PENDING)).toBe( + false + ); + }); + + it("rejects transition from SETTLED to PUBLISHED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.PUBLISHED)).toBe( + false + ); + }); + + it("rejects transition from SETTLED to FUNDED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.FUNDED)).toBe( + false + ); + }); + + it("rejects transition from SETTLED to SETTLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.SETTLED)).toBe( + false + ); + }); + }); + + describe("valid transitions from CANCELLED", () => { + it("rejects all transitions from CANCELLED", () => { + allStatuses.forEach((status) => { + expect( + isValidInvoiceStateTransition(InvoiceStatus.CANCELLED, status), + `CANCELLED to ${status} should be invalid` + ).toBe(false); + }); + }); + }); + + describe("invalid self-transitions", () => { + it("rejects transition from PENDING to PENDING", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.PENDING)).toBe( + false + ); + }); + + it("rejects transition from PUBLISHED to PUBLISHED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.PUBLISHED)).toBe( + false + ); + }); + + it("rejects transition from FUNDED to FUNDED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.FUNDED)).toBe( + false + ); + }); + + it("rejects transition from CANCELLED to CANCELLED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.CANCELLED, InvoiceStatus.CANCELLED)).toBe( + false + ); + }); + }); + + describe("backward transitions (all invalid)", () => { + it("rejects transition from PUBLISHED to DRAFT", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.DRAFT)).toBe( + false + ); + }); + + it("rejects transition from FUNDED to PUBLISHED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.PUBLISHED)).toBe( + false + ); + }); + + it("rejects transition from SETTLED to FUNDED", () => { + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.FUNDED)).toBe( + false + ); + }); + }); +}); diff --git a/tests/unit/utils/stellar-address.test.ts b/tests/unit/utils/stellar-address.test.ts new file mode 100644 index 0000000..acf33cf --- /dev/null +++ b/tests/unit/utils/stellar-address.test.ts @@ -0,0 +1,56 @@ +import { isValidStellarPublicKey, isValidSorobanContractId } from "../../../src/utils/stellar-address.utils"; + +describe("stellar-address utils", () => { + describe("isValidStellarPublicKey", () => { + it("accepts valid Stellar public key starting with G", () => { + const validKey = "GBBD47UZQ5PQQ4DFFH7D6XWVV37G5FFQVLNGIS2HFJ7FALL3UL5TWUC6"; + expect(isValidStellarPublicKey(validKey)).toBe(true); + }); + + it("rejects non-string input", () => { + expect(isValidStellarPublicKey(123)).toBe(false); + expect(isValidStellarPublicKey(null)).toBe(false); + expect(isValidStellarPublicKey(undefined)).toBe(false); + }); + + it("rejects invalid key format", () => { + expect(isValidStellarPublicKey("invalid-key")).toBe(false); + expect(isValidStellarPublicKey("")).toBe(false); + }); + + it("rejects contract addresses (starting with C)", () => { + const contractAddress = "CBBD47UZQ5PQQ4DFFH7D6XWVV37G5FFQVLNGIS2HFJ7FALL3UL5TWUC6"; + expect(isValidStellarPublicKey(contractAddress)).toBe(false); + }); + }); + + describe("isValidSorobanContractId", () => { + it("accepts valid Soroban contract ID starting with C", () => { + const validContractId = "CBBD47UZQ5PQQ4DFFH7D6XWVV37G5FFQVLNGIS2HFJ7FALL3UL5TWUC6"; + expect(isValidSorobanContractId(validContractId)).toBe(true); + }); + + it("rejects non-string input", () => { + expect(isValidSorobanContractId(123)).toBe(false); + expect(isValidSorobanContractId(null)).toBe(false); + expect(isValidSorobanContractId(undefined)).toBe(false); + }); + + it("rejects invalid contract ID format", () => { + expect(isValidSorobanContractId("invalid-contract")).toBe(false); + expect(isValidSorobanContractId("")).toBe(false); + }); + + it("rejects Stellar public keys (starting with G)", () => { + const publicKey = "GBBD47UZQ5PQQ4DFFH7D6XWVV37G5FFQVLNGIS2HFJ7FALL3UL5TWUC6"; + expect(isValidSorobanContractId(publicKey)).toBe(false); + }); + + it("rejects malformed contract addresses", () => { + expect(isValidSorobanContractId("C" + "0".repeat(55))).toBe(false); + expect(isValidSorobanContractId("CBBD47UZQ5PQQ4DFFH7D6XWVV37G5FFQVLNGIS2HFJ7FALL3UL5TWUC")).toBe( + false + ); + }); + }); +});