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
48 changes: 48 additions & 0 deletions src/controllers/invoice.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,5 +336,53 @@ export function createInvoiceController(invoiceService: InvoiceService) {
next(error);
}
},

async getInvoiceTokenHolders(
req: Request & { params: { id: string } },
res: Response,
next: NextFunction,
): Promise<void> {
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<void> {
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);
}
},
};
}
14 changes: 14 additions & 0 deletions src/routes/invoice.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
112 changes: 111 additions & 1 deletion src/services/invoice.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +27,7 @@ export interface InvoiceRepositoryContract {
export interface InvoiceServiceDependencies {
invoiceRepository: InvoiceRepositoryContract;
ipfsService: IPFSService;
dataSource?: DataSource;
}

export interface UploadDocumentInput {
Expand Down Expand Up @@ -114,10 +117,12 @@ const VALID_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
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;
}

/**
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -453,5 +562,6 @@ export function createInvoiceService(
return new InvoiceService({
invoiceRepository,
ipfsService,
dataSource,
});
}
21 changes: 21 additions & 0 deletions src/utils/invoice-state.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { InvoiceStatus } from "@/types/enums";

export function isValidInvoiceStateTransition(
currentStatus: InvoiceStatus,
targetStatus: InvoiceStatus
): boolean {
const validTransitions: Record<InvoiceStatus, InvoiceStatus[]> = {
[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);
}
4 changes: 4 additions & 0 deletions src/utils/stellar-address.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading