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
35 changes: 35 additions & 0 deletions src/migrations/1731513600000-InitialSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@

public async up(queryRunner: QueryRunner): Promise<void> {
try {
logger.info("Executing up migration for InitialSchema1731513600000");

await queryRunner.query(`
CREATE TYPE "public"."users_usertype_enum" AS ENUM('seller', 'investor', 'both');
CREATE TYPE "public"."users_kycstatus_enum" AS ENUM('pending', 'in_review', 'approved', 'rejected');
CREATE TYPE "public"."invoices_invoicestatus_enum" AS ENUM('draft', 'pending', 'published', 'funded', 'settled', 'cancelled', 'rejected');
CREATE TYPE "public"."investments_investmentstatus_enum" AS ENUM('pending', 'confirmed', 'settled', 'cancelled');
CREATE TYPE "public"."transactions_transactiontype_enum" AS ENUM('investment', 'payment', 'withdrawal', 'refund');
CREATE TYPE "public"."transactions_transactionstatus_enum" AS ENUM('pending', 'completed', 'failed');
CREATE TYPE "public"."kyc_verifications_verificationtype_enum" AS ENUM('identity', 'address', 'business');
CREATE TYPE "public"."notifications_notificationtype_enum" AS ENUM('invoice', 'investment', 'payment', 'kyc', 'system');
`);

await queryRunner.query(`
CREATE TABLE "users" (
// 1. Create Enum Types safely
await queryRunner.query(`
DO $$ BEGIN

Check failure on line 27 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 27 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 27 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 27 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.

Check failure on line 27 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.

Check failure on line 27 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.
CREATE TYPE "public"."users_usertype_enum" AS ENUM('seller', 'investor', 'both');

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

')' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

Identifier expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Build

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

')' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

Identifier expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.

Check failure on line 28 in src/migrations/1731513600000-InitialSchema.ts

View workflow job for this annotation

GitHub Actions / Type Check

',' expected.
EXCEPTION WHEN duplicate_object THEN null; END $$;

DO $$ BEGIN
Expand Down Expand Up @@ -86,6 +101,17 @@
CONSTRAINT "PK_invoices" PRIMARY KEY ("id"),
CONSTRAINT "FK_invoices_seller" FOREIGN KEY ("seller_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE INDEX "idx_invoices_seller_id" ON "invoices" ("seller_id");
CREATE UNIQUE INDEX "idx_invoices_invoice_number" ON "invoices" ("invoice_number");
CREATE INDEX "idx_invoices_customer_name" ON "invoices" ("customer_name");
CREATE INDEX "idx_invoices_due_date" ON "invoices" ("due_date");
CREATE INDEX "idx_invoices_status" ON "invoices" ("status");
CREATE INDEX "idx_invoices_status_seller_id" ON "invoices" ("status", "seller_id");
CREATE INDEX "idx_invoices_status_due_date" ON "invoices" ("status", "due_date");
`);

await queryRunner.query(`
CREATE TABLE "investments" (
CREATE INDEX IF NOT EXISTS "idx_invoices_seller_id" ON "invoices" ("seller_id");
CREATE UNIQUE INDEX IF NOT EXISTS "idx_invoices_invoice_number" ON "invoices" ("invoice_number");
CREATE INDEX IF NOT EXISTS "idx_invoices_customer_name" ON "invoices" ("customer_name");
Expand Down Expand Up @@ -118,6 +144,15 @@
CONSTRAINT "FK_investments_invoice" FOREIGN KEY ("invoice_id") REFERENCES "invoices"("id") ON DELETE CASCADE,
CONSTRAINT "FK_investments_investor" FOREIGN KEY ("investor_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE INDEX "idx_investments_invoice_id" ON "investments" ("invoice_id");
CREATE INDEX "idx_investments_investor_id" ON "investments" ("investor_id");
CREATE INDEX "idx_investments_status" ON "investments" ("status");
CREATE INDEX "idx_investments_invoice_status" ON "investments" ("invoice_id", "status");
CREATE INDEX "idx_investments_investor_status" ON "investments" ("investor_id", "status");
`);

await queryRunner.query(`
CREATE TABLE "transactions" (
CREATE INDEX IF NOT EXISTS "idx_investments_invoice_id" ON "investments" ("invoice_id");
CREATE INDEX IF NOT EXISTS "idx_investments_investor_id" ON "investments" ("investor_id");
CREATE INDEX IF NOT EXISTS "idx_investments_status" ON "investments" ("status");
Expand Down
2 changes: 2 additions & 0 deletions src/models/Invoice.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
OneToMany,
JoinColumn,
Index,
BeforeInsert,

Check failure on line 12 in src/models/Invoice.model.ts

View workflow job for this annotation

GitHub Actions / Lint

'BeforeInsert' is defined but never used. Allowed unused vars must match /^_/u
BeforeUpdate,

Check failure on line 13 in src/models/Invoice.model.ts

View workflow job for this annotation

GitHub Actions / Lint

'BeforeUpdate' is defined but never used. Allowed unused vars must match /^_/u
} from "typeorm";
import Decimal from "decimal.js";
import { InvoiceStatus } from "../types/enums";
Expand Down
74 changes: 69 additions & 5 deletions src/models/Transaction.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ import {
ManyToOne,
JoinColumn,
Index,
BeforeInsert,
BeforeUpdate,
} from "typeorm";
import Decimal from "decimal.js";
import { TransactionType, TransactionStatus } from "../types/enums";
import type { Investment } from "./Investment.model";

import type { Invoice } from "./Invoice.model";

import { AppError } from "../utils/http-error";
import { logger } from "../observability/logger";

@Entity("transactions")
@Index("idx_transactions_status_type_timestamp", ["status", "type", "timestamp"])
@Index("idx_transactions_user_status_timestamp", ["userId", "status", "timestamp"])
@Index("idx_transactions_investment_status", ["investmentId", "status"])
@Index("idx_transactions_invoice_status", ["invoiceId", "status"])
export class Transaction {
@PrimaryGeneratedColumn("uuid")
id!: string;
Expand All @@ -26,12 +32,10 @@ export class Transaction {
@Index("idx_transactions_investment_id")
investmentId!: string | null;


@Column({ name: "invoice_id", type: "uuid", nullable: true })
@Index("idx_transactions_invoice_id")
invoiceId!: string | null;


@Column({
type: "enum",
enum: TransactionType,
Expand Down Expand Up @@ -68,9 +72,69 @@ export class Transaction {
@JoinColumn({ name: "investment_id" })
investment!: Investment | null;


@ManyToOne("Invoice", "transactions", { onDelete: "SET NULL", nullable: true })
@JoinColumn({ name: "invoice_id" })
invoice!: Invoice | null;

@BeforeInsert()
@BeforeUpdate()
sanitizeTransactionData(): void {
try {
if (this.amount) {
const parsed = new Decimal(this.amount);
if (parsed.isNegative()) {
throw new Error("Transaction amount cannot be negative");
}
this.amount = parsed.toFixed(4);
}
if (this.stellarTxHash) {
this.stellarTxHash = this.stellarTxHash.trim().toUpperCase();
}
} catch (error) {
logger.error("Failed to sanitize transaction data", {
transactionId: this.id,
userId: this.userId,
error: error instanceof Error ? error.message : String(error),
});
throw new AppError(400, "Invalid transaction amount formatting", "INVALID_TRANSACTION_AMOUNT");
}
}

markCompleted(stellarTxHash?: string, operationIndex?: number): void {
if (stellarTxHash) {
this.stellarTxHash = stellarTxHash;
}
if (operationIndex !== undefined) {
this.stellarOperationIndex = operationIndex;
}
this.status = TransactionStatus.COMPLETED;
this.sanitizeTransactionData();
}

markFailed(reason?: string): void {
this.status = TransactionStatus.FAILED;
logger.warn("Transaction marked as failed", {
transactionId: this.id,
userId: this.userId,
reason,
});
}

static async processBatchTransactions(transactions: Transaction[]): Promise<Transaction[]> {
try {
logger.info("Processing transaction batch", { count: transactions.length });
for (const tx of transactions) {
tx.sanitizeTransactionData();
}
return transactions;
} catch (error) {
logger.error("Failed to process transaction batch", {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof AppError) {
throw error;
}
throw new AppError(500, "Processing transaction batch failed", "TRANSACTION_BATCH_FAILED", error);
}
}
}
34 changes: 34 additions & 0 deletions tests/migrations/initial-schema-325.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { InitialSchema1731513600000 } from "../../src/migrations/1731513600000-InitialSchema";
import { AppError } from "../../src/utils/http-error";

describe("InitialSchema Migration - Issue #325", () => {
let migration: InitialSchema1731513600000;
let mockQueryRunner: any;

beforeEach(() => {
migration = new InitialSchema1731513600000();
mockQueryRunner = {
query: jest.fn().mockResolvedValue(undefined),
};
});

it("should execute up migration successfully", async () => {
await migration.up(mockQueryRunner);
expect(mockQueryRunner.query).toHaveBeenCalled();
});

it("should throw AppError on up migration failure", async () => {
mockQueryRunner.query.mockRejectedValue(new Error("Database connection lost"));
await expect(migration.up(mockQueryRunner)).rejects.toThrow(AppError);
});

it("should execute down migration successfully", async () => {
await migration.down(mockQueryRunner);
expect(mockQueryRunner.query).toHaveBeenCalled();
});

it("should throw AppError on down migration failure", async () => {
mockQueryRunner.query.mockRejectedValue(new Error("Table drop constraint failed"));
await expect(migration.down(mockQueryRunner)).rejects.toThrow(AppError);
});
});
50 changes: 50 additions & 0 deletions tests/models/invoice-model-324.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Invoice } from "../../src/models/Invoice.model";
import { InvoiceStatus } from "../../src/types/enums";
import { AppError } from "../../src/utils/http-error";

describe("Invoice Model - Issue #324 Enhancements", () => {
it("should evaluate status predicates correctly", () => {
const invoice = new Invoice();
invoice.status = InvoiceStatus.DRAFT;
invoice.ipfsHash = "QmHash324";
invoice.dueDate = new Date(Date.now() + 86400000);

expect(invoice.isExpired()).toBe(false);
expect(invoice.isPublishable()).toBe(true);
expect(invoice.canBeCancelled()).toBe(true);
expect(invoice.canBeRejected()).toBe(true);

invoice.status = InvoiceStatus.PUBLISHED;
expect(invoice.isFundable()).toBe(true);

invoice.status = InvoiceStatus.FUNDED;
expect(invoice.isSettlable()).toBe(true);
});

it("should execute batchUpdateStatus correctly", async () => {
const inv1 = new Invoice();
inv1.id = "inv-1";
inv1.status = InvoiceStatus.PENDING;

const inv2 = new Invoice();
inv2.id = "inv-2";
inv2.status = InvoiceStatus.DRAFT;

const updated = await Invoice.batchUpdateStatus([inv1, inv2], InvoiceStatus.REJECTED, "Invalid document");

expect(updated[0].status).toBe(InvoiceStatus.REJECTED);
expect(updated[0].rejectionReason).toBe("Invalid document");
expect(updated[1].status).toBe(InvoiceStatus.REJECTED);
expect(updated[1].rejectionReason).toBe("Invalid document");
});

it("should throw AppError on invalid batch status transition", async () => {
const inv = new Invoice();
inv.id = "inv-settled";
inv.status = InvoiceStatus.SETTLED;

await expect(
Invoice.batchUpdateStatus([inv], InvoiceStatus.REJECTED, "Cannot reject settled invoice")
).rejects.toThrow(AppError);
});
});
60 changes: 60 additions & 0 deletions tests/models/invoice-model-327.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Invoice } from "../../src/models/Invoice.model";
import { AppError } from "../../src/utils/http-error";

describe("Invoice Model - Issue #327 Enhancements", () => {
it("should calculate net amount precisely using Decimal math on lifecycle hooks", () => {
const invoice = new Invoice();
invoice.amount = "29.99";
invoice.discountRate = "0.5";
invoice.invoiceNumber = "INV-327-01";
invoice.customerName = " Acme Corp ";

invoice.calculateAndFormatAmounts();

expect(invoice.customerName).toBe("Acme Corp");
expect(invoice.amount).toBe("29.9900");
expect(invoice.discountRate).toBe("0.50");
expect(invoice.netAmount).toBe("29.8401");
});

it("should throw AppError on negative invoice amount during lifecycle hook", () => {
const invoice = new Invoice();
invoice.amount = "-100.00";
invoice.discountRate = "5.00";

expect(() => invoice.calculateAndFormatAmounts()).toThrow(AppError);
});

it("should throw AppError on discount rate > 100", () => {
const invoice = new Invoice();
invoice.amount = "100.00";
invoice.discountRate = "150.00";

expect(() => invoice.calculateAndFormatAmounts()).toThrow(AppError);
});

it("should validate publish readiness", () => {
const invoice = new Invoice();
invoice.ipfsHash = null;
invoice.dueDate = new Date(Date.now() + 86400000);

expect(() => invoice.validateForPublish()).toThrow(AppError);

invoice.ipfsHash = "QmTestHash327";
expect(() => invoice.validateForPublish()).not.toThrow();
});

it("should process batch of invoices safely", async () => {
const inv1 = new Invoice();
inv1.amount = "100.00";
inv1.discountRate = "10.00";

const inv2 = new Invoice();
inv2.amount = "500.00";
inv2.discountRate = "5.00";

const processed = await Invoice.processBatch([inv1, inv2]);
expect(processed[0].netAmount).toBe("90.0000");
expect(processed[1].netAmount).toBe("475.0000");
});
});
61 changes: 61 additions & 0 deletions tests/models/transaction-model-326.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Transaction } from "../../src/models/Transaction.model";
import { TransactionType, TransactionStatus } from "../../src/types/enums";
import { AppError } from "../../src/utils/http-error";

describe("Transaction Model - Issue #326 Enhancements", () => {
it("should sanitize amount and Stellar tx hash on lifecycle hooks", () => {
const tx = new Transaction();
tx.userId = "user-326";
tx.type = TransactionType.INVESTMENT;
tx.amount = "150.5";
tx.stellarTxHash = " abc123def456 ";

tx.sanitizeTransactionData();

expect(tx.amount).toBe("150.5000");
expect(tx.stellarTxHash).toBe("ABC123DEF456");
});

it("should throw AppError on negative transaction amount", () => {
const tx = new Transaction();
tx.amount = "-50.00";

expect(() => tx.sanitizeTransactionData()).toThrow(AppError);
});

it("should transition to completed status via markCompleted", () => {
const tx = new Transaction();
tx.status = TransactionStatus.PENDING;

tx.markCompleted("HASH12345", 2);

expect(tx.status).toBe(TransactionStatus.COMPLETED);
expect(tx.stellarTxHash).toBe("HASH12345");
expect(tx.stellarOperationIndex).toBe(2);
});

it("should transition to failed status via markFailed", () => {
const tx = new Transaction();
tx.status = TransactionStatus.PENDING;

tx.markFailed("Insufficient balance");

expect(tx.status).toBe(TransactionStatus.FAILED);
});

it("should process batch of transactions safely", async () => {
const tx1 = new Transaction();
tx1.amount = "100";
tx1.stellarTxHash = "hash1";

const tx2 = new Transaction();
tx2.amount = "250";
tx2.stellarTxHash = "hash2";

const batch = await Transaction.processBatchTransactions([tx1, tx2]);
expect(batch[0].amount).toBe("100.0000");
expect(batch[0].stellarTxHash).toBe("HASH1");
expect(batch[1].amount).toBe("250.0000");
expect(batch[1].stellarTxHash).toBe("HASH2");
});
});
Loading