From 8a49044f9be6aa64cfe097c7d6b6a4c95bba4c90 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 14:20:55 +0100 Subject: [PATCH 1/4] feat(models): Enhance and optimize Invoice.model.ts (#327) --- src/models/Invoice.model.ts | 79 ++++++++++++++++++++++++++ tests/models/invoice-model-327.test.ts | 60 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/models/invoice-model-327.test.ts diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 3bcd7b6..70f9725 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -9,10 +9,18 @@ import { OneToMany, JoinColumn, Index, + BeforeInsert, + BeforeUpdate, } from "typeorm"; +import Decimal from "decimal.js"; import { InvoiceStatus } from "../types/enums"; +import { AppError } from "../utils/http-error"; +import { logger } from "../observability/logger"; @Entity("invoices") +@Index("idx_invoices_status_seller_id", ["status", "sellerId"]) +@Index("idx_invoices_status_due_date", ["status", "dueDate"]) +@Index("idx_invoices_created_at", ["createdAt"]) export class Invoice { @PrimaryGeneratedColumn("uuid") id!: string; @@ -80,4 +88,75 @@ export class Invoice { @OneToMany("Transaction", "invoice") transactions!: import("./Transaction.model").Transaction[]; + + @BeforeInsert() + @BeforeUpdate() + calculateAndFormatAmounts(): void { + try { + this.sanitizeInputs(); + this.netAmount = this.calculateNetAmount(); + } catch (error) { + logger.error("Failed to calculate invoice amounts", { + invoiceId: this.id, + invoiceNumber: this.invoiceNumber, + error: error instanceof Error ? error.message : String(error), + }); + throw new AppError(400, "Invalid invoice amount or discount rate formatting", "INVALID_INVOICE_AMOUNT"); + } + } + + sanitizeInputs(): void { + if (this.customerName) { + this.customerName = this.customerName.trim(); + } + if (this.invoiceNumber) { + this.invoiceNumber = this.invoiceNumber.trim(); + } + if (this.amount) { + const parsedAmount = new Decimal(this.amount); + if (parsedAmount.isNegative()) { + throw new Error("Invoice amount cannot be negative"); + } + this.amount = parsedAmount.toFixed(4); + } + if (this.discountRate) { + const parsedDiscount = new Decimal(this.discountRate); + if (parsedDiscount.isNegative() || parsedDiscount.greaterThan(100)) { + throw new Error("Discount rate must be between 0 and 100"); + } + this.discountRate = parsedDiscount.toFixed(2); + } + } + + calculateNetAmount(): string { + const grossAmount = new Decimal(this.amount || "0"); + const rate = new Decimal(this.discountRate || "0"); + const discountMultiplier = new Decimal(1).minus(rate.dividedBy(100)); + return grossAmount.times(discountMultiplier).toFixed(4); + } + + validateForPublish(): void { + if (!this.ipfsHash) { + throw new AppError(400, "Invoice document (IPFS hash) is required before publishing", "MISSING_IPFS_HASH"); + } + if (new Date(this.dueDate).getTime() <= Date.now()) { + throw new AppError(400, "Invoice due date must be in the future", "INVALID_DUE_DATE"); + } + } + + static async processBatch(invoices: Invoice[]): Promise { + try { + logger.info("Processing batch of invoices", { count: invoices.length }); + for (const invoice of invoices) { + invoice.calculateAndFormatAmounts(); + } + return invoices; + } catch (error) { + logger.error("Failed to process invoice batch", { error: error instanceof Error ? error.message : String(error) }); + if (error instanceof AppError) { + throw error; + } + throw new AppError(500, "Processing invoice batch failed", "INVOICE_BATCH_PROCESSING_FAILED", error); + } + } } 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"); + }); +}); From 25beaf0bc5d67665e5f3192e45b562bcebc1bd1e Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 14:41:19 +0100 Subject: [PATCH 2/4] feat(models): Enhance and optimize Transaction.model.ts (#326) --- src/models/Transaction.model.ts | 74 ++++++++++++++++++++-- tests/models/transaction-model-326.test.ts | 61 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/models/transaction-model-326.test.ts 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/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"); + }); +}); From 1c361dbe2551ad00b01b0b96a5c49a4a48f84e57 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 14:46:40 +0100 Subject: [PATCH 3/4] feat(migrations): Enhance and optimize 1731513600000-InitialSchema.ts (#325) --- src/migrations/1731513600000-InitialSchema.ts | 341 ++++++++++-------- tests/migrations/initial-schema-325.test.ts | 34 ++ 2 files changed, 223 insertions(+), 152 deletions(-) create mode 100644 tests/migrations/initial-schema-325.test.ts diff --git a/src/migrations/1731513600000-InitialSchema.ts b/src/migrations/1731513600000-InitialSchema.ts index 0e707af..00f45a8 100644 --- a/src/migrations/1731513600000-InitialSchema.ts +++ b/src/migrations/1731513600000-InitialSchema.ts @@ -1,169 +1,206 @@ import { MigrationInterface, QueryRunner } from "typeorm"; +import { logger } from "../observability/logger"; +import { AppError } from "../utils/http-error"; export class InitialSchema1731513600000 implements MigrationInterface { name = "InitialSchema1731513600000"; public async up(queryRunner: QueryRunner): Promise { - 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'); - `); + try { + logger.info("Executing up migration for InitialSchema1731513600000"); - await queryRunner.query(` - CREATE TABLE "users" ( - "id" uuid NOT NULL DEFAULT gen_random_uuid(), - "stellarAddress" character varying(56) NOT NULL, - "email" character varying(255), - "userType" "public"."users_usertype_enum" NOT NULL DEFAULT 'investor', - "kycStatus" "public"."users_kycstatus_enum" NOT NULL DEFAULT 'pending', - "is_kyc_verified" boolean NOT NULL DEFAULT false, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "updated_at" TIMESTAMP NOT NULL DEFAULT now(), - "deleted_at" TIMESTAMP, - CONSTRAINT "UQ_users_stellarAddress" UNIQUE ("stellarAddress"), - CONSTRAINT "PK_users" PRIMARY KEY ("id") - ); - CREATE UNIQUE INDEX "idx_users_stellar_address" ON "users" ("stellarAddress"); - CREATE INDEX "idx_users_user_type" ON "users" ("userType"); - CREATE INDEX "idx_users_kyc_status" ON "users" ("kycStatus"); - CREATE INDEX "idx_users_email" ON "users" ("email"); - `); + 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 "invoices" ( - "id" uuid NOT NULL DEFAULT gen_random_uuid(), - "seller_id" uuid NOT NULL, - "invoice_number" character varying(64) NOT NULL, - "customer_name" character varying(255) NOT NULL, - "amount" decimal(18,4) NOT NULL DEFAULT 0, - "discount_rate" decimal(5,2) NOT NULL DEFAULT 0, - "net_amount" decimal(18,4) NOT NULL DEFAULT 0, - "due_date" date NOT NULL, - "ipfs_hash" character varying(128), - "risk_score" decimal(5,2), - "status" "public"."invoices_invoicestatus_enum" NOT NULL DEFAULT 'draft', - "smart_contract_id" character varying(64), - "rejection_reason" text, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "updated_at" TIMESTAMP NOT NULL DEFAULT now(), - "deleted_at" TIMESTAMP, - CONSTRAINT "UQ_invoices_invoice_number" UNIQUE ("invoice_number"), - 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"); - `); + await queryRunner.query(` + CREATE TABLE "users" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "stellarAddress" character varying(56) NOT NULL, + "email" character varying(255), + "userType" "public"."users_usertype_enum" NOT NULL DEFAULT 'investor', + "kycStatus" "public"."users_kycstatus_enum" NOT NULL DEFAULT 'pending', + "is_kyc_verified" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "UQ_users_stellarAddress" UNIQUE ("stellarAddress"), + CONSTRAINT "PK_users" PRIMARY KEY ("id") + ); + CREATE UNIQUE INDEX "idx_users_stellar_address" ON "users" ("stellarAddress"); + CREATE INDEX "idx_users_user_type" ON "users" ("userType"); + CREATE INDEX "idx_users_kyc_status" ON "users" ("kycStatus"); + CREATE INDEX "idx_users_email" ON "users" ("email"); + `); - await queryRunner.query(` - CREATE TABLE "investments" ( - "id" uuid NOT NULL DEFAULT gen_random_uuid(), - "invoice_id" uuid NOT NULL, - "investor_id" uuid NOT NULL, - "investment_amount" decimal(18,4) NOT NULL, - "expected_return" decimal(18,4) NOT NULL, - "actual_return" decimal(18,4), - "status" "public"."investments_investmentstatus_enum" NOT NULL DEFAULT 'pending', - "transaction_hash" character varying(64), - "stellar_operation_index" integer, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "updated_at" TIMESTAMP NOT NULL DEFAULT now(), - "deleted_at" TIMESTAMP, - CONSTRAINT "PK_investments" PRIMARY KEY ("id"), - 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"); - `); + await queryRunner.query(` + CREATE TABLE "invoices" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "seller_id" uuid NOT NULL, + "invoice_number" character varying(64) NOT NULL, + "customer_name" character varying(255) NOT NULL, + "amount" decimal(18,4) NOT NULL DEFAULT 0, + "discount_rate" decimal(5,2) NOT NULL DEFAULT 0, + "net_amount" decimal(18,4) NOT NULL DEFAULT 0, + "due_date" date NOT NULL, + "ipfs_hash" character varying(128), + "risk_score" decimal(5,2), + "status" "public"."invoices_invoicestatus_enum" NOT NULL DEFAULT 'draft', + "smart_contract_id" character varying(64), + "rejection_reason" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "UQ_invoices_invoice_number" UNIQUE ("invoice_number"), + 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 "transactions" ( - "id" uuid NOT NULL DEFAULT gen_random_uuid(), - "user_id" uuid NOT NULL, - "investment_id" uuid, - "invoice_id" uuid, - "type" "public"."transactions_transactiontype_enum" NOT NULL, - "amount" decimal(18,4) NOT NULL, - "stellar_tx_hash" character varying(64), - "stellar_operation_index" integer, - "status" "public"."transactions_transactionstatus_enum" NOT NULL DEFAULT 'pending', - "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "PK_transactions" PRIMARY KEY ("id"), - CONSTRAINT "FK_transactions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, - CONSTRAINT "FK_transactions_investment" FOREIGN KEY ("investment_id") REFERENCES "investments"("id") ON DELETE SET NULL, - CONSTRAINT "FK_transactions_invoice" FOREIGN KEY ("invoice_id") REFERENCES "invoices"("id") ON DELETE SET NULL - ); - CREATE INDEX "idx_transactions_user_id" ON "transactions" ("user_id"); - CREATE INDEX "idx_transactions_investment_id" ON "transactions" ("investment_id"); - CREATE INDEX "idx_transactions_invoice_id" ON "transactions" ("invoice_id"); - CREATE INDEX "idx_transactions_type" ON "transactions" ("type"); - CREATE INDEX "idx_transactions_status" ON "transactions" ("status"); - `); + await queryRunner.query(` + CREATE TABLE "investments" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL, + "investor_id" uuid NOT NULL, + "investment_amount" decimal(18,4) NOT NULL, + "expected_return" decimal(18,4) NOT NULL, + "actual_return" decimal(18,4), + "status" "public"."investments_investmentstatus_enum" NOT NULL DEFAULT 'pending', + "transaction_hash" character varying(64), + "stellar_operation_index" integer, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "PK_investments" PRIMARY KEY ("id"), + 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 "kyc_verifications" ( - "id" uuid NOT NULL DEFAULT gen_random_uuid(), - "user_id" uuid NOT NULL, - "verification_type" "public"."kyc_verifications_verificationtype_enum" NOT NULL, - "status" "public"."users_kycstatus_enum" NOT NULL DEFAULT 'pending', - "documents" jsonb, - "verified_at" TIMESTAMP WITH TIME ZONE, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "updated_at" TIMESTAMP NOT NULL DEFAULT now(), - "deleted_at" TIMESTAMP, - CONSTRAINT "PK_kyc_verifications" PRIMARY KEY ("id"), - CONSTRAINT "FK_kyc_verifications_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE - ); - CREATE INDEX "idx_kyc_verifications_user_id" ON "kyc_verifications" ("user_id"); - CREATE INDEX "idx_kyc_verifications_type" ON "kyc_verifications" ("verification_type"); - CREATE INDEX "idx_kyc_verifications_status" ON "kyc_verifications" ("status"); - `); + await queryRunner.query(` + CREATE TABLE "transactions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "investment_id" uuid, + "invoice_id" uuid, + "type" "public"."transactions_transactiontype_enum" NOT NULL, + "amount" decimal(18,4) NOT NULL, + "stellar_tx_hash" character varying(64), + "stellar_operation_index" integer, + "status" "public"."transactions_transactionstatus_enum" NOT NULL DEFAULT 'pending', + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_transactions" PRIMARY KEY ("id"), + CONSTRAINT "FK_transactions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_transactions_investment" FOREIGN KEY ("investment_id") REFERENCES "investments"("id") ON DELETE SET NULL, + CONSTRAINT "FK_transactions_invoice" FOREIGN KEY ("invoice_id") REFERENCES "invoices"("id") ON DELETE SET NULL + ); + CREATE INDEX "idx_transactions_user_id" ON "transactions" ("user_id"); + CREATE INDEX "idx_transactions_investment_id" ON "transactions" ("investment_id"); + CREATE INDEX "idx_transactions_invoice_id" ON "transactions" ("invoice_id"); + CREATE INDEX "idx_transactions_type" ON "transactions" ("type"); + CREATE INDEX "idx_transactions_status" ON "transactions" ("status"); + CREATE INDEX "idx_transactions_user_status_timestamp" ON "transactions" ("user_id", "status", "timestamp"); + CREATE INDEX "idx_transactions_investment_status" ON "transactions" ("investment_id", "status"); + CREATE INDEX "idx_transactions_invoice_status" ON "transactions" ("invoice_id", "status"); + `); - await queryRunner.query(` - CREATE TABLE "notifications" ( - "id" uuid NOT NULL DEFAULT gen_random_uuid(), - "user_id" uuid NOT NULL, - "type" "public"."notifications_notificationtype_enum" NOT NULL, - "title" character varying(255) NOT NULL, - "message" text NOT NULL, - "read" boolean NOT NULL DEFAULT false, - "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "updated_at" TIMESTAMP NOT NULL DEFAULT now(), - "deleted_at" TIMESTAMP, - CONSTRAINT "PK_notifications" PRIMARY KEY ("id"), - CONSTRAINT "FK_notifications_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE - ); - CREATE INDEX "idx_notifications_user_id" ON "notifications" ("user_id"); - CREATE INDEX "idx_notifications_type" ON "notifications" ("type"); - `); + await queryRunner.query(` + CREATE TABLE "kyc_verifications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "verification_type" "public"."kyc_verifications_verificationtype_enum" NOT NULL, + "status" "public"."users_kycstatus_enum" NOT NULL DEFAULT 'pending', + "documents" jsonb, + "verified_at" TIMESTAMP WITH TIME ZONE, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "PK_kyc_verifications" PRIMARY KEY ("id"), + CONSTRAINT "FK_kyc_verifications_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ); + CREATE INDEX "idx_kyc_verifications_user_id" ON "kyc_verifications" ("user_id"); + CREATE INDEX "idx_kyc_verifications_type" ON "kyc_verifications" ("verification_type"); + CREATE INDEX "idx_kyc_verifications_status" ON "kyc_verifications" ("status"); + `); + + await queryRunner.query(` + CREATE TABLE "notifications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "type" "public"."notifications_notificationtype_enum" NOT NULL, + "title" character varying(255) NOT NULL, + "message" text NOT NULL, + "read" boolean NOT NULL DEFAULT false, + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "PK_notifications" PRIMARY KEY ("id"), + CONSTRAINT "FK_notifications_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ); + CREATE INDEX "idx_notifications_user_id" ON "notifications" ("user_id"); + CREATE INDEX "idx_notifications_type" ON "notifications" ("type"); + `); + + logger.info("Successfully executed up migration for InitialSchema1731513600000"); + } catch (error) { + logger.error("Failed to execute InitialSchema migration up", { + error: error instanceof Error ? error.message : String(error), + }); + if (error instanceof AppError) { + throw error; + } + throw new AppError(500, "Initial schema migration failed", "INITIAL_SCHEMA_MIGRATION_FAILED", error); + } } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "notifications"`); - await queryRunner.query(`DROP TABLE "kyc_verifications"`); - await queryRunner.query(`DROP TABLE "transactions"`); - await queryRunner.query(`DROP TABLE "investments"`); - await queryRunner.query(`DROP TABLE "invoices"`); - await queryRunner.query(`DROP TABLE "users"`); - await queryRunner.query(`DROP TYPE "public"."notifications_notificationtype_enum"`); - await queryRunner.query(`DROP TYPE "public"."kyc_verifications_verificationtype_enum"`); - await queryRunner.query(`DROP TYPE "public"."transactions_transactionstatus_enum"`); - await queryRunner.query(`DROP TYPE "public"."transactions_transactiontype_enum"`); - await queryRunner.query(`DROP TYPE "public"."investments_investmentstatus_enum"`); - await queryRunner.query(`DROP TYPE "public"."invoices_invoicestatus_enum"`); - await queryRunner.query(`DROP TYPE "public"."users_kycstatus_enum"`); - await queryRunner.query(`DROP TYPE "public"."users_usertype_enum"`); + try { + logger.info("Executing down migration for InitialSchema1731513600000"); + + await queryRunner.query(`DROP TABLE "notifications"`); + await queryRunner.query(`DROP TABLE "kyc_verifications"`); + await queryRunner.query(`DROP TABLE "transactions"`); + await queryRunner.query(`DROP TABLE "investments"`); + await queryRunner.query(`DROP TABLE "invoices"`); + await queryRunner.query(`DROP TABLE "users"`); + await queryRunner.query(`DROP TYPE "public"."notifications_notificationtype_enum"`); + await queryRunner.query(`DROP TYPE "public"."kyc_verifications_verificationtype_enum"`); + await queryRunner.query(`DROP TYPE "public"."transactions_transactionstatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."transactions_transactiontype_enum"`); + await queryRunner.query(`DROP TYPE "public"."investments_investmentstatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."invoices_invoicestatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."users_kycstatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."users_usertype_enum"`); + + logger.info("Successfully executed down migration for InitialSchema1731513600000"); + } catch (error) { + logger.error("Failed to execute InitialSchema migration down", { + error: error instanceof Error ? error.message : String(error), + }); + if (error instanceof AppError) { + throw error; + } + throw new AppError(500, "Initial schema migration revert failed", "INITIAL_SCHEMA_REVERT_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); + }); +}); From 3700f4a15755ac9ab40f4b75b8c29082a79fd90e Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 14:56:06 +0100 Subject: [PATCH 4/4] feat(models): Enhance and optimize Invoice.model.ts (#324) --- src/models/Invoice.model.ts | 64 ++++++++++++++++++++++++++ tests/models/invoice-model-324.test.ts | 50 ++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/models/invoice-model-324.test.ts diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 70f9725..5aee537 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -144,6 +144,34 @@ export class Invoice { } } + isExpired(): boolean { + return new Date(this.dueDate).getTime() < Date.now(); + } + + isPublishable(): boolean { + return ( + (this.status === InvoiceStatus.DRAFT || this.status === InvoiceStatus.PENDING) && + Boolean(this.ipfsHash) && + !this.isExpired() + ); + } + + isFundable(): boolean { + return this.status === InvoiceStatus.PUBLISHED && !this.isExpired(); + } + + isSettlable(): boolean { + return this.status === InvoiceStatus.FUNDED; + } + + canBeCancelled(): boolean { + return this.status === InvoiceStatus.DRAFT || this.status === InvoiceStatus.PENDING || this.status === InvoiceStatus.PUBLISHED; + } + + canBeRejected(): boolean { + return this.status === InvoiceStatus.PENDING || this.status === InvoiceStatus.DRAFT; + } + static async processBatch(invoices: Invoice[]): Promise { try { logger.info("Processing batch of invoices", { count: invoices.length }); @@ -159,4 +187,40 @@ export class Invoice { throw new AppError(500, "Processing invoice batch failed", "INVOICE_BATCH_PROCESSING_FAILED", error); } } + + static async batchUpdateStatus( + invoices: Invoice[], + targetStatus: InvoiceStatus, + reason?: string + ): Promise { + try { + logger.info("Performing batch status update for invoices", { + count: invoices.length, + targetStatus, + }); + + for (const invoice of invoices) { + if (targetStatus === InvoiceStatus.REJECTED) { + if (!invoice.canBeRejected()) { + throw new AppError(400, `Invoice ${invoice.id} cannot be rejected from status ${invoice.status}`, "INVALID_STATUS_TRANSITION"); + } + if (reason) { + invoice.rejectionReason = reason; + } + } + invoice.status = targetStatus; + } + + return invoices; + } catch (error) { + logger.error("Failed to perform batch status update", { + targetStatus, + error: error instanceof Error ? error.message : String(error), + }); + if (error instanceof AppError) { + throw error; + } + throw new AppError(500, "Batch status update failed", "BATCH_STATUS_UPDATE_FAILED", error); + } + } } 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); + }); +});