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
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function bootstrap(): Promise<{ server: Server }> {

const authService = createAuthService(dataSource, config);
const notificationService = createNotificationService(dataSource);
const ipfsService = createIPFSService(config.ipfs);
const ipfsService = createIPFSService(config.ipfs, logger);
const invoiceService = createInvoiceService(dataSource, ipfsService);
const investmentService = createInvestmentService(dataSource);

Expand Down
81 changes: 38 additions & 43 deletions src/services/invoice.service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { DataSource } from "typeorm";
import { Invoice } from "../models/Invoice.model";
import { InvoiceStatus } from "../types/enums";
import { User } from "../models/User.model";
import { InvoiceStatus, KYCStatus } from "../types/enums";
import { ServiceError } from "../utils/service-error";
import { validateInvoiceForPublish } from "../lib/invoice-validation";
import type { IPFSService, IPFSUploadResult } from "./ipfs.service";

export interface InvoiceRepositoryContract {
findOne(options: { where: { id: string } }): Promise<Invoice | null>;
findOne(options: { where: { id: string }; relations?: string[] }): Promise<Invoice | null>;
findOneBy(options: { id?: string; invoiceNumber?: string }): Promise<Invoice | null>;
find(options: { where: { sellerId: string; status?: InvoiceStatus}, skip?: number, take?: number, order?: { [key: string]: "ASC" | "DESC" } }): Promise<Invoice[]>;
find(options: {
where: { sellerId: string; status?: InvoiceStatus };
skip?: number;
take?: number;
order?: { [key: string]: "ASC" | "DESC" };
}): Promise<Invoice[]>;
save(invoice: Invoice): Promise<Invoice>;
count(options: { where: { sellerId: string; status?: InvoiceStatus } }): Promise<number>;
create(data: Partial<Invoice>): Invoice;
Expand Down Expand Up @@ -88,23 +94,10 @@ export interface GetInvoicesOptions {
* Valid state transitions for InvoiceStatus
*/
const VALID_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
[InvoiceStatus.DRAFT]: [
InvoiceStatus.PENDING,
InvoiceStatus.PUBLISHED,
InvoiceStatus.CANCELLED,
],
[InvoiceStatus.PENDING]: [
InvoiceStatus.PUBLISHED,
InvoiceStatus.CANCELLED,
],
[InvoiceStatus.PUBLISHED]: [
InvoiceStatus.FUNDED,
InvoiceStatus.CANCELLED,
],
[InvoiceStatus.FUNDED]: [
InvoiceStatus.SETTLED,
InvoiceStatus.CANCELLED,
],
[InvoiceStatus.DRAFT]: [InvoiceStatus.PENDING, 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]: [],
};
Expand All @@ -125,7 +118,7 @@ export class InvoiceService {
private calculateNetAmount(amount: string, discountRate: string): string {
const amountNum = parseFloat(amount);
const discountNum = parseFloat(discountRate);
const netAmount = amountNum - (amountNum * (discountNum / 100));
const netAmount = amountNum - amountNum * (discountNum / 100);
return netAmount.toFixed(4);
}

Expand All @@ -146,11 +139,7 @@ export class InvoiceService {
});

if (existing) {
throw new ServiceError(
"invoice_number_exists",
"Invoice number must be unique",
409,
);
throw new ServiceError("invoice_number_exists", "Invoice number must be unique", 409);
}

// Calculate net amount
Expand All @@ -177,10 +166,7 @@ export class InvoiceService {
/**
* Get invoice by ID
*/
async getInvoiceById(
invoiceId: string,
sellerId?: string,
): Promise<InvoiceDTO | null> {
async getInvoiceById(invoiceId: string, sellerId?: string): Promise<InvoiceDTO | null> {
const invoice = await this.invoiceRepository.findOne({
where: { id: invoiceId },
});
Expand All @@ -194,7 +180,7 @@ export class InvoiceService {
throw new ServiceError(
"unauthorized_invoice_access",
"You do not have access to this invoice",
403,
403
);
}

Expand Down Expand Up @@ -250,7 +236,7 @@ export class InvoiceService {
throw new ServiceError(
"unauthorized_invoice_access",
"You can only update your own invoices",
403,
403
);
}

Expand All @@ -259,7 +245,7 @@ export class InvoiceService {
throw new ServiceError(
"invalid_invoice_status",
`Cannot update invoice in ${invoice.status} status. Only draft invoices can be updated.`,
400,
400
);
}

Expand Down Expand Up @@ -303,19 +289,16 @@ export class InvoiceService {
throw new ServiceError(
"unauthorized_invoice_access",
"You can only delete your own invoices",
403,
403
);
}

// Only draft and cancelled invoices can be deleted
if (
invoice.status !== InvoiceStatus.DRAFT &&
invoice.status !== InvoiceStatus.CANCELLED
) {
if (invoice.status !== InvoiceStatus.DRAFT && invoice.status !== InvoiceStatus.CANCELLED) {
throw new ServiceError(
"invalid_invoice_status",
`Cannot delete invoice in ${invoice.status} status`,
400,
400
);
}

Expand All @@ -329,6 +312,7 @@ export class InvoiceService {
async publishInvoice(input: PublishInvoiceInput): Promise<InvoiceDTO> {
const invoice = await this.invoiceRepository.findOne({
where: { id: input.invoiceId },
relations: ["seller"],
});

if (!invoice) {
Expand All @@ -340,7 +324,17 @@ export class InvoiceService {
throw new ServiceError(
"unauthorized_invoice_access",
"You can only publish your own invoices",
403,
403
);
}

// Check KYC status
const seller = invoice.seller as unknown as User;
if (!seller || seller.kycStatus !== KYCStatus.APPROVED) {
throw new ServiceError(
"kyc_approval_required",
"KYC approval is required to publish invoices",
403
);
}

Expand All @@ -349,7 +343,7 @@ export class InvoiceService {
throw new ServiceError(
"invalid_status_transition",
`Cannot transition from ${invoice.status} to ${InvoiceStatus.PUBLISHED}`,
400,
400
);
}

Expand Down Expand Up @@ -377,7 +371,7 @@ export class InvoiceService {
throw new ServiceError(
"unauthorized_invoice_access",
"You can only upload documents to your own invoices",
403,
403
);
}

Expand All @@ -386,6 +380,7 @@ export class InvoiceService {
input.fileBuffer,
input.filename,
input.mimeType,
input.invoiceId
);

// Update invoice with IPFS hash
Expand Down Expand Up @@ -425,7 +420,7 @@ export class InvoiceService {

export function createInvoiceService(
dataSource: DataSource,
ipfsService: IPFSService,
ipfsService: IPFSService
): InvoiceService {
const invoiceRepository = dataSource.getRepository(Invoice);

Expand Down
50 changes: 39 additions & 11 deletions src/services/ipfs.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AppConfig } from "../config/env";
import { ServiceError } from "../utils/service-error";
import type { AppLogger } from "../observability/logger";

export interface IPFSUploadResult {
hash: string;
Expand All @@ -9,6 +10,7 @@ export interface IPFSUploadResult {

export interface IPFSServiceDependencies {
config: AppConfig["ipfs"];
logger: AppLogger;
fetchImplementation?: typeof fetch;
}

Expand All @@ -20,25 +22,28 @@ export interface PinataResponse {

export class IPFSService {
private readonly config: AppConfig["ipfs"];
private readonly logger: AppLogger;
private readonly fetchImplementation: typeof fetch;

constructor(dependencies: IPFSServiceDependencies) {
this.config = dependencies.config;
this.logger = dependencies.logger;
this.fetchImplementation = dependencies.fetchImplementation ?? fetch;
}

async uploadFile(
fileBuffer: Buffer,
filename: string,
mimeType: string,
invoiceId?: string
): Promise<IPFSUploadResult> {
// Validate file size
const fileSizeMB = fileBuffer.length / (1024 * 1024);
if (fileSizeMB > this.config.maxFileSizeMB) {
throw new ServiceError(
"file_too_large",
`File size ${fileSizeMB.toFixed(2)}MB exceeds maximum allowed size of ${this.config.maxFileSizeMB}MB`,
400,
400
);
}

Expand All @@ -47,7 +52,7 @@ export class IPFSService {
throw new ServiceError(
"invalid_file_type",
`File type ${mimeType} is not allowed. Allowed types: ${this.config.allowedMimeTypes.join(", ")}`,
400,
400
);
}

Expand All @@ -64,19 +69,34 @@ export class IPFSService {
Authorization: `Bearer ${this.config.jwt}`,
},
body: formData,
},
}
);

if (!response.ok) {
const errorText = await response.text();
const errorReason = `${response.status} ${response.statusText}`;

this.logger.warn("IPFS document upload failed", {
invoice_id: invoiceId,
error_reason: errorReason,
failed_at: new Date().toISOString(),
});

throw new ServiceError(
"ipfs_upload_failed",
`IPFS upload failed: ${response.status} ${response.statusText} - ${errorText}`,
502,
`IPFS upload failed: ${errorReason} - ${errorText}`,
502
);
}

const result = await response.json() as PinataResponse;
const result = (await response.json()) as PinataResponse;

this.logger.info("IPFS document upload completed", {
cid: result.IpfsHash,
invoice_id: invoiceId,
file_size_bytes: result.PinSize,
uploaded_at: result.Timestamp,
});

return {
hash: result.IpfsHash,
Expand All @@ -88,15 +108,23 @@ export class IPFSService {
throw error;
}

const errorMessage = error instanceof Error ? error.message : "Unknown error";

this.logger.warn("IPFS document upload failed", {
invoice_id: invoiceId,
error_reason: errorMessage,
failed_at: new Date().toISOString(),
});

throw new ServiceError(
"ipfs_upload_error",
`Failed to upload file to IPFS: ${error instanceof Error ? error.message : "Unknown error"}`,
500,
`Failed to upload file to IPFS: ${errorMessage}`,
500
);
}
}
}

export function createIPFSService(config: AppConfig["ipfs"]): IPFSService {
return new IPFSService({ config });
}
export function createIPFSService(config: AppConfig["ipfs"], logger: AppLogger): IPFSService {
return new IPFSService({ config, logger });
}
24 changes: 24 additions & 0 deletions src/utils/compute-investor-return.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function computeInvestorReturn(
investorFundedStroops: bigint,
totalFundedStroops: bigint,
settledProceedsStroops: bigint
): bigint {
if (totalFundedStroops === 0n) {
throw new Error("Total funded amount cannot be zero");
}

if (investorFundedStroops < 0n || totalFundedStroops < 0n || settledProceedsStroops < 0n) {
throw new Error("All amounts must be non-negative");
}

if (investorFundedStroops > totalFundedStroops) {
throw new Error("Investor funded amount cannot exceed total funded amount");
}

// Calculate: (investorFunded * settledProceeds) / totalFunded
// Using floor division by default with bigint
const numerator = investorFundedStroops * settledProceedsStroops;
const investorReturn = numerator / totalFundedStroops;

return investorReturn;
}
Loading
Loading