diff --git a/src/migrations/1731513600000-InitialSchema.ts b/src/migrations/1731513600000-InitialSchema.ts index c2758e7..7e9c840 100644 --- a/src/migrations/1731513600000-InitialSchema.ts +++ b/src/migrations/1731513600000-InitialSchema.ts @@ -7,6 +7,21 @@ export class InitialSchema1731513600000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { 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 @@ -86,6 +101,17 @@ export class InitialSchema1731513600000 implements MigrationInterface { 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"); @@ -118,6 +144,15 @@ export class InitialSchema1731513600000 implements MigrationInterface { 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"); diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 99a06e4..5eeda44 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -9,6 +9,8 @@ import { OneToMany, JoinColumn, Index, + BeforeInsert, + BeforeUpdate, } from "typeorm"; import Decimal from "decimal.js"; import { InvoiceStatus } from "../types/enums"; diff --git a/src/models/Transaction.model.ts b/src/models/Transaction.model.ts index 0c61bee..ec2e6b6 100644 --- a/src/models/Transaction.model.ts +++ b/src/models/Transaction.model.ts @@ -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; @@ -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, @@ -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 { + 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); + } + } } diff --git a/tests/migrations/initial-schema-325.test.ts b/tests/migrations/initial-schema-325.test.ts new file mode 100644 index 0000000..66b3301 --- /dev/null +++ b/tests/migrations/initial-schema-325.test.ts @@ -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); + }); +}); diff --git a/tests/models/invoice-model-324.test.ts b/tests/models/invoice-model-324.test.ts new file mode 100644 index 0000000..6c273b0 --- /dev/null +++ b/tests/models/invoice-model-324.test.ts @@ -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); + }); +}); diff --git a/tests/models/invoice-model-327.test.ts b/tests/models/invoice-model-327.test.ts new file mode 100644 index 0000000..70786b1 --- /dev/null +++ b/tests/models/invoice-model-327.test.ts @@ -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"); + }); +}); diff --git a/tests/models/transaction-model-326.test.ts b/tests/models/transaction-model-326.test.ts new file mode 100644 index 0000000..fb49827 --- /dev/null +++ b/tests/models/transaction-model-326.test.ts @@ -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"); + }); +});