diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 013878d..708d38f 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -3,6 +3,7 @@ import type { AuthenticatedRequest } from "../types/auth"; import { HttpError, AppError } from "../utils/http-error"; import { logger as defaultLogger, type AppLogger } from "../observability/logger"; import { isValidStellarPublicKey } from "../utils/stellar-address.utils"; +import { UserType } from "../types/enums"; export interface UserRepositoryContract { findById(id: string): Promise; @@ -82,7 +83,7 @@ export function createUserController(deps: UserControllerDeps) { if (!rawId) { throw new HttpError(400, "Invalid user id"); } - // Prevent enumeration via timing-safe consistent behavior: still 404 on not found + // Consistent 404 response if user does not exist const user = await userRepository.findById(rawId); if (!user) { throw new HttpError(404, "User not found"); @@ -113,6 +114,7 @@ export function createUserController(deps: UserControllerDeps) { const email = sanitizeString(req.body?.email, 255); const stellarAddressRaw = sanitizeString(req.body?.stellarAddress, 56); + const userTypeRaw = req.body?.userType; if (email !== null) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -125,7 +127,15 @@ export function createUserController(deps: UserControllerDeps) { throw new HttpError(400, "Invalid Stellar public key"); } - if (email === null && stellarAddressRaw === null) { + if ( + userTypeRaw !== undefined && + userTypeRaw !== null && + !Object.values(UserType).includes(userTypeRaw) + ) { + throw new HttpError(400, "Invalid user type"); + } + + if (email === null && stellarAddressRaw === null && (userTypeRaw === undefined || userTypeRaw === null)) { throw new HttpError(400, "No valid fields to update"); } @@ -135,8 +145,9 @@ export function createUserController(deps: UserControllerDeps) { } const patch: Partial = {}; - if (email !== null) patch.email = email; + if (email !== null) patch.email = email.toLowerCase(); if (stellarAddressRaw !== null) patch.stellarAddress = stellarAddressRaw; + if (userTypeRaw) patch.userType = userTypeRaw; // Use immutable update pattern: create new object const updated = await userRepository.save({ ...existing, ...patch }); @@ -178,8 +189,14 @@ export function createUserController(deps: UserControllerDeps) { let total = 0; try { - users = await userRepository.findAll({ skip: (page - 1) * limit, take: limit }); - total = userRepository.count ? await userRepository.count() : users.length; + // Parallelized data and count fetching for high concurrency performance + const [fetchedUsers, fetchedCount] = await Promise.all([ + userRepository.findAll({ skip: (page - 1) * limit, take: limit }), + userRepository.count ? userRepository.count() : Promise.resolve(-1), + ]); + + users = fetchedUsers; + total = fetchedCount >= 0 ? fetchedCount : users.length; } catch (error) { appLogger.error("Failed to list users", { error }); throw new AppError(500, "Failed to list users", "USER_LIST_FAILED"); diff --git a/src/migrations/1731513600000-InitialSchema.ts b/src/migrations/1731513600000-InitialSchema.ts index 0e707af..c2758e7 100644 --- a/src/migrations/1731513600000-InitialSchema.ts +++ b/src/migrations/1731513600000-InitialSchema.ts @@ -1,169 +1,247 @@ 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'); - `); - - 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 "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 "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 "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 "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 + try { + // 1. Create Enum Types safely + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "public"."users_usertype_enum" AS ENUM('seller', 'investor', 'both'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."users_kycstatus_enum" AS ENUM('pending', 'in_review', 'approved', 'rejected'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."invoices_invoicestatus_enum" AS ENUM('draft', 'pending', 'published', 'funded', 'settled', 'cancelled', 'rejected'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."investments_investmentstatus_enum" AS ENUM('pending', 'confirmed', 'settled', 'cancelled'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."transactions_transactiontype_enum" AS ENUM('investment', 'payment', 'withdrawal', 'refund'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."transactions_transactionstatus_enum" AS ENUM('pending', 'completed', 'failed'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."kyc_verifications_verificationtype_enum" AS ENUM('identity', 'address', 'business'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + + DO $$ BEGIN + CREATE TYPE "public"."notifications_notificationtype_enum" AS ENUM('invoice', 'investment', 'payment', 'kyc', 'system'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + + // 2. Create Users Table & Indexes + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "idx_users_stellar_address" ON "users" ("stellarAddress"); + CREATE INDEX IF NOT EXISTS "idx_users_user_type" ON "users" ("userType"); + CREATE INDEX IF NOT EXISTS "idx_users_kyc_status" ON "users" ("kycStatus"); + CREATE INDEX IF NOT EXISTS "idx_users_email" ON "users" ("email"); + `); + + // 3. Create Invoices Table & Optimized Indexes + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "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 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"); + CREATE INDEX IF NOT EXISTS "idx_invoices_due_date" ON "invoices" ("due_date"); + CREATE INDEX IF NOT EXISTS "idx_invoices_status" ON "invoices" ("status"); + CREATE INDEX IF NOT EXISTS "idx_invoices_seller_status_created" ON "invoices" ("seller_id", "status", "created_at"); + CREATE INDEX IF NOT EXISTS "idx_invoices_status_due_date" ON "invoices" ("status", "due_date"); + CREATE INDEX IF NOT EXISTS "idx_invoices_status_created_at" ON "invoices" ("status", "created_at"); + CREATE INDEX IF NOT EXISTS "idx_invoices_status_amount" ON "invoices" ("status", "amount"); + CREATE INDEX IF NOT EXISTS "idx_invoices_ipfs_hash" ON "invoices" ("ipfs_hash"); + CREATE INDEX IF NOT EXISTS "idx_invoices_smart_contract_id" ON "invoices" ("smart_contract_id"); + `); + + // 4. Create Investments Table & Optimized Indexes + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "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 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"); + CREATE INDEX IF NOT EXISTS "idx_investments_investor_status" ON "investments" ("investor_id", "status"); + CREATE INDEX IF NOT EXISTS "idx_investments_invoice_status" ON "investments" ("invoice_id", "status"); + CREATE INDEX IF NOT EXISTS "idx_investments_transaction_hash" ON "investments" ("transaction_hash"); + `); + + // 5. Create Transactions Table & Optimized Indexes + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "idx_transactions_user_id" ON "transactions" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_transactions_investment_id" ON "transactions" ("investment_id"); + CREATE INDEX IF NOT EXISTS "idx_transactions_invoice_id" ON "transactions" ("invoice_id"); + CREATE INDEX IF NOT EXISTS "idx_transactions_type" ON "transactions" ("type"); + CREATE INDEX IF NOT EXISTS "idx_transactions_status" ON "transactions" ("status"); + CREATE INDEX IF NOT EXISTS "idx_transactions_user_type_status" ON "transactions" ("user_id", "type", "status"); + CREATE INDEX IF NOT EXISTS "idx_transactions_stellar_tx_hash" ON "transactions" ("stellar_tx_hash"); + `); + + // 6. Create KYC Verifications Table & Indexes + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "idx_kyc_verifications_user_id" ON "kyc_verifications" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_kyc_verifications_type" ON "kyc_verifications" ("verification_type"); + CREATE INDEX IF NOT EXISTS "idx_kyc_verifications_status" ON "kyc_verifications" ("status"); + `); + + // 7. Create Notifications Table & Indexes + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "idx_notifications_user_id" ON "notifications" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_notifications_type" ON "notifications" ("type"); + CREATE INDEX IF NOT EXISTS "idx_notifications_user_read" ON "notifications" ("user_id", "read"); + `); + + logger.info("InitialSchema migration completed successfully", { migration: this.name }); + } catch (error) { + logger.error("Failed to execute InitialSchema migration", { + error: error instanceof Error ? error.message : String(error), + migration: this.name, + }); + if (error instanceof AppError) { + throw error; + } + throw new AppError( + 500, + `InitialSchema migration failed: ${error instanceof Error ? error.message : String(error)}`, + "MIGRATION_EXECUTION_FAILED", ); - CREATE INDEX "idx_notifications_user_id" ON "notifications" ("user_id"); - CREATE INDEX "idx_notifications_type" ON "notifications" ("type"); - `); + } } 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "notifications" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "kyc_verifications" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "transactions" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "investments" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "invoices" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "users" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."notifications_notificationtype_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."kyc_verifications_verificationtype_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."transactions_transactionstatus_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."transactions_transactiontype_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."investments_investmentstatus_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."invoices_invoicestatus_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."users_kycstatus_enum" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."users_usertype_enum" CASCADE`); + + logger.info("InitialSchema rollback completed successfully", { migration: this.name }); + } catch (error) { + logger.error("Failed to rollback InitialSchema migration", { + error: error instanceof Error ? error.message : String(error), + migration: this.name, + }); + if (error instanceof AppError) { + throw error; + } + throw new AppError( + 500, + `InitialSchema rollback failed: ${error instanceof Error ? error.message : String(error)}`, + "MIGRATION_ROLLBACK_FAILED", + ); + } } } diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 3bcd7b6..99a06e4 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -10,9 +10,44 @@ import { JoinColumn, Index, } from "typeorm"; +import Decimal from "decimal.js"; import { InvoiceStatus } from "../types/enums"; +import { logger } from "../observability/logger"; +import { AppError } from "../utils/http-error"; + +export const VALID_INVOICE_TRANSITIONS: Record = { + [InvoiceStatus.DRAFT]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED], + [InvoiceStatus.PENDING]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED], + [InvoiceStatus.PUBLISHED]: [InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED], + [InvoiceStatus.FUNDED]: [InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED], + [InvoiceStatus.SETTLED]: [InvoiceStatus.CANCELLED], + [InvoiceStatus.CANCELLED]: [], + [InvoiceStatus.REJECTED]: [], +}; + +export interface PublicInvoiceDTO { + id: string; + sellerId: string; + invoiceNumber: string; + customerName: string; + amount: string; + discountRate: string; + netAmount: string; + dueDate: Date; + ipfsHash: string | null; + riskScore: string | null; + status: InvoiceStatus; + smartContractId: string | null; + rejectionReason: string | null; + createdAt: Date; + updatedAt: Date; +} @Entity("invoices") +@Index("idx_invoices_seller_status_created", ["sellerId", "status", "createdAt"]) +@Index("idx_invoices_status_due_date", ["status", "dueDate"]) +@Index("idx_invoices_status_created_at", ["status", "createdAt"]) +@Index("idx_invoices_status_amount", ["status", "amount"]) export class Invoice { @PrimaryGeneratedColumn("uuid") id!: string; @@ -43,6 +78,7 @@ export class Invoice { dueDate!: Date; @Column({ name: "ipfs_hash", type: "varchar", length: 128, nullable: true }) + @Index("idx_invoices_ipfs_hash") ipfsHash!: string | null; @Column({ name: "risk_score", type: "decimal", precision: 5, scale: 2, nullable: true }) @@ -57,6 +93,7 @@ export class Invoice { status!: InvoiceStatus; @Column({ name: "smart_contract_id", type: "varchar", length: 64, nullable: true }) + @Index("idx_invoices_smart_contract_id") smartContractId!: string | null; @Column({ name: "rejection_reason", type: "text", nullable: true }) @@ -80,4 +117,278 @@ export class Invoice { @OneToMany("Transaction", "invoice") transactions!: import("./Transaction.model").Transaction[]; + + /** + * Calculates the exact net amount using arbitrary-precision decimal arithmetic. + * Net Amount = amount * (1 - discountRate / 100) rounded to 4 decimal places. + */ + static calculateNetAmount( + amount: string | number | Decimal, + discountRate: string | number | Decimal, + ): string { + try { + const amt = new Decimal(amount); + const disc = new Decimal(discountRate); + + if ( + !amt.isFinite() || + !disc.isFinite() || + amt.isNegative() || + disc.isNegative() || + disc.gt(100) + ) { + throw new AppError( + 400, + "Amount must be non-negative and discount rate must be between 0 and 100", + "INVALID_AMOUNT_OR_DISCOUNT", + ); + } + + const net = amt.minus(amt.times(disc.dividedBy(100))); + return net.toFixed(4); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + logger.error("Failed to calculate invoice net amount", { + error: error instanceof Error ? error.message : String(error), + amount: String(amount), + discountRate: String(discountRate), + }); + throw new AppError( + 500, + "Failed to calculate invoice net amount", + "NET_AMOUNT_CALCULATION_FAILED", + ); + } + } + + /** + * Sanitizes and normalizes invoice fields to prevent data corruption. + */ + static sanitizeAndNormalize(invoice: Partial): void { + try { + if (invoice.invoiceNumber !== undefined && invoice.invoiceNumber !== null) { + invoice.invoiceNumber = String(invoice.invoiceNumber).trim().slice(0, 64); + } + + if (invoice.customerName !== undefined && invoice.customerName !== null) { + invoice.customerName = String(invoice.customerName).trim().slice(0, 255); + } + + if (invoice.ipfsHash !== undefined && invoice.ipfsHash !== null) { + const trimmed = String(invoice.ipfsHash).trim(); + invoice.ipfsHash = trimmed.length > 0 ? trimmed.slice(0, 128) : null; + } + + if (invoice.smartContractId !== undefined && invoice.smartContractId !== null) { + const trimmed = String(invoice.smartContractId).trim(); + invoice.smartContractId = trimmed.length > 0 ? trimmed.slice(0, 64) : null; + } + + if (invoice.rejectionReason !== undefined && invoice.rejectionReason !== null) { + const trimmed = String(invoice.rejectionReason).trim(); + invoice.rejectionReason = trimmed.length > 0 ? trimmed : null; + } + + if ( + invoice.amount !== undefined && + invoice.discountRate !== undefined && + invoice.amount !== null && + invoice.discountRate !== null + ) { + const amtStr = String(invoice.amount).trim(); + const discStr = String(invoice.discountRate).trim(); + if (amtStr && discStr && !isNaN(Number(amtStr)) && !isNaN(Number(discStr))) { + if (!invoice.netAmount || invoice.netAmount === "0" || invoice.netAmount === "0.0000") { + invoice.netAmount = Invoice.calculateNetAmount(amtStr, discStr); + } + } + } + } catch (error) { + logger.error("Failed to sanitize/normalize invoice entity", { + error: error instanceof Error ? error.message : String(error), + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + }); + if (error instanceof AppError) { + throw error; + } + throw new AppError( + 500, + `Invoice normalization failed: ${error instanceof Error ? error.message : String(error)}`, + "INVOICE_NORMALIZATION_FAILED", + ); + } + } + + /** + * Checks if a transition from current status to target status is valid. + */ + static isValidTransition(currentStatus: InvoiceStatus, targetStatus: InvoiceStatus): boolean { + const current = currentStatus ?? InvoiceStatus.DRAFT; + const allowed = VALID_INVOICE_TRANSITIONS[current] ?? []; + return allowed.includes(targetStatus); + } + + /** + * Safely transitions the invoice status with validation. + */ + static transitionTo( + invoice: Invoice, + targetStatus: InvoiceStatus, + rejectionReason?: string | null, + ): void { + try { + if (!Invoice.isValidTransition(invoice.status, targetStatus)) { + throw new AppError( + 400, + `Cannot transition invoice from ${invoice.status} to ${targetStatus}`, + "INVALID_STATUS_TRANSITION", + ); + } + + invoice.status = targetStatus; + if (targetStatus === InvoiceStatus.REJECTED && rejectionReason) { + invoice.rejectionReason = rejectionReason.trim(); + } + } catch (error) { + if (error instanceof AppError) { + throw error; + } + logger.error("Failed to transition invoice status", { + error: error instanceof Error ? error.message : String(error), + invoiceId: invoice.id, + fromStatus: invoice.status, + toStatus: targetStatus, + }); + throw new AppError( + 500, + "Failed to execute invoice status transition", + "STATUS_TRANSITION_FAILED", + ); + } + } + + /** + * Evaluates if the invoice is eligible to be published to the marketplace. + */ + static isPublishable( + invoice: Partial, + options: { referenceDate?: Date; minRunwayHours?: number } = {}, + ): { + publishable: boolean; + errors: string[]; + } { + const errors: string[] = []; + const minRunwayHours = options.minRunwayHours ?? 24; + const now = options.referenceDate ?? new Date(); + + if (invoice.status !== InvoiceStatus.DRAFT && invoice.status !== InvoiceStatus.PENDING) { + errors.push(`Status must be draft or pending, currently ${invoice.status}`); + } + + try { + const amt = new Decimal(invoice.amount || 0); + if (amt.lte(0)) { + errors.push("Invoice amount must be greater than zero"); + } + } catch { + errors.push("Invalid amount format"); + } + + if (!invoice.customerName || !invoice.customerName.trim()) { + errors.push("Customer name is required"); + } + + if (!invoice.dueDate) { + errors.push("Due date is required"); + } else { + const dueTime = new Date(invoice.dueDate).getTime(); + const minDueTime = now.getTime() + minRunwayHours * 60 * 60 * 1000; + if (isNaN(dueTime)) { + errors.push("Invalid due date format"); + } else if (dueTime < minDueTime) { + errors.push(`Due date must be at least ${minRunwayHours} hours in the future`); + } + } + + if (!invoice.ipfsHash || !invoice.ipfsHash.trim()) { + errors.push("Invoice document IPFS hash is required"); + } + + return { + publishable: errors.length === 0, + errors, + }; + } + + /** + * Checks if the invoice due date has passed. + */ + static isOverdue(invoice: Partial, referenceDate: Date = new Date()): boolean { + if (!invoice.dueDate) return false; + const dueTime = new Date(invoice.dueDate).getTime(); + return !isNaN(dueTime) && dueTime < referenceDate.getTime(); + } + + /** + * Calculates the remaining funding runway in hours. + */ + static getFundingRunwayHours( + invoice: Partial, + referenceDate: Date = new Date(), + ): number { + if (!invoice.dueDate) return 0; + const dueTime = new Date(invoice.dueDate).getTime(); + if (isNaN(dueTime)) return 0; + const diffMs = dueTime - referenceDate.getTime(); + return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60))); + } + + /** + * Static factory method to safely construct and initialize an Invoice instance. + */ + static create(data: Partial): Invoice { + try { + const invoice = new Invoice(); + Object.assign(invoice, data); + Invoice.sanitizeAndNormalize(invoice); + return invoice; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Failed to construct invoice entity", { + error: error instanceof Error ? error.message : String(error), + }); + throw new AppError( + 500, + "Failed to construct invoice entity", + "INVOICE_CONSTRUCTION_FAILED", + ); + } + } + + /** + * Serializes entity to a clean DTO payload. + */ + static toDTO(invoice: Invoice): PublicInvoiceDTO { + return { + id: invoice.id, + sellerId: invoice.sellerId, + invoiceNumber: invoice.invoiceNumber, + customerName: invoice.customerName, + amount: invoice.amount, + discountRate: invoice.discountRate, + netAmount: invoice.netAmount, + dueDate: invoice.dueDate, + ipfsHash: invoice.ipfsHash, + riskScore: invoice.riskScore, + status: invoice.status, + smartContractId: invoice.smartContractId, + rejectionReason: invoice.rejectionReason, + createdAt: invoice.createdAt, + updatedAt: invoice.updatedAt, + }; + } } + diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index e10ffd6..1875f5b 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -595,18 +595,18 @@ export class InvoiceService { const publishable: Array<{ invoice: Invoice; sellerWallet: string }> = []; const rejections: BatchPublishRejection[] = []; - // Parallel fetch: avoids N sequential round-trips under heavy load (was ~N*~50ms) + // Batch fetch: single query with In(uniqueIds) avoids N round-trips let fetched: Array<{ invoiceId: string; invoice: Invoice | null }>; try { - fetched = await Promise.all( - uniqueIds.map(async (invoiceId) => ({ - invoiceId, - invoice: await this.invoiceRepository.findOne({ - where: { id: invoiceId }, - relations: ["seller"], - }), - })), - ); + const invoices = await this.invoiceRepository.find({ + where: { id: In(uniqueIds) }, + relations: ["seller"], + }); + const byId = new Map(invoices.map((inv) => [inv.id, inv])); + fetched = uniqueIds.map((invoiceId) => ({ + invoiceId, + invoice: byId.get(invoiceId) ?? null, + })); } catch (error) { logger.error("Failed to fetch batch invoices", { error, sellerId }); throw new ServiceError("batch_fetch_failed", "Failed to fetch invoices for batch publish", 500); diff --git a/src/services/stellar/invoice-escrow-contract.service.ts b/src/services/stellar/invoice-escrow-contract.service.ts index 144573a..f6866e2 100644 --- a/src/services/stellar/invoice-escrow-contract.service.ts +++ b/src/services/stellar/invoice-escrow-contract.service.ts @@ -35,6 +35,8 @@ export interface InvoiceEscrowContractServiceDependencies { platformSecretKey?: string; server?: SorobanRpc.Server; logger?: AppLogger; + confirmationPollMs?: number; + confirmationAttempts?: number; } export class InvoiceEscrowContractService { @@ -44,24 +46,28 @@ export class InvoiceEscrowContractService { private readonly networkPassphrase?: string; private readonly platformSecretKey?: string; private readonly logger: AppLogger; + private readonly confirmationPollMs: number; + private readonly confirmationAttempts: number; constructor( dependenciesOrContractId: string | InvoiceEscrowContractServiceDependencies, logger?: AppLogger, ) { if (typeof dependenciesOrContractId === "string") { - if (!dependenciesOrContractId) { + if (!dependenciesOrContractId || !dependenciesOrContractId.trim()) { throw new Error("contractId is required."); } - this.contractId = dependenciesOrContractId; - this.contract = new Contract(dependenciesOrContractId); + this.contractId = dependenciesOrContractId.trim(); + this.contract = new Contract(this.contractId); this.logger = logger ?? globalLogger; + this.confirmationPollMs = 1000; + this.confirmationAttempts = 20; } else { - if (!dependenciesOrContractId.contractId) { + if (!dependenciesOrContractId.contractId || !dependenciesOrContractId.contractId.trim()) { throw new Error("contractId is required."); } - this.contractId = dependenciesOrContractId.contractId; - this.contract = new Contract(dependenciesOrContractId.contractId); + this.contractId = dependenciesOrContractId.contractId.trim(); + this.contract = new Contract(this.contractId); this.networkPassphrase = dependenciesOrContractId.networkPassphrase; this.platformSecretKey = dependenciesOrContractId.platformSecretKey; if (dependenciesOrContractId.server) { @@ -72,6 +78,23 @@ export class InvoiceEscrowContractService { }); } this.logger = dependenciesOrContractId.logger ?? logger ?? globalLogger; + this.confirmationPollMs = dependenciesOrContractId.confirmationPollMs ?? 1000; + this.confirmationAttempts = dependenciesOrContractId.confirmationAttempts ?? 20; + } + } + + private parseStroopAmount(amount: bigint | number | string, fieldName = "amountStroops"): bigint { + try { + const parsed = typeof amount === "bigint" ? amount : BigInt(amount); + if (parsed <= 0n) { + throw new Error(`${fieldName} must be positive.`); + } + return parsed; + } catch (error) { + if (error instanceof Error && error.message.includes("must be positive")) { + throw error; + } + throw new Error(`Invalid ${fieldName}: ${String(amount)}`); } } @@ -85,16 +108,28 @@ export class InvoiceEscrowContractService { dueDateTimestamp: number, paymentTokenAddress: string, ): xdr.Operation { - const amountBigInt = - typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { + throw new Error("invoiceId is required."); + } + if (!sellerAddress || typeof sellerAddress !== "string" || !sellerAddress.trim()) { + throw new Error("sellerAddress is required."); + } + if (!Number.isFinite(dueDateTimestamp) || dueDateTimestamp <= 0) { + throw new Error("dueDateTimestamp must be a positive number."); + } + if (!paymentTokenAddress || typeof paymentTokenAddress !== "string" || !paymentTokenAddress.trim()) { + throw new Error("paymentTokenAddress is required."); + } + + const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); return this.contract.call( "create_escrow", - nativeToScVal(invoiceId, { type: "symbol" }), - new Address(sellerAddress).toScVal(), + nativeToScVal(invoiceId.trim(), { type: "symbol" }), + new Address(sellerAddress.trim()).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), nativeToScVal(dueDateTimestamp, { type: "u64" }), - new Address(paymentTokenAddress).toScVal(), + new Address(paymentTokenAddress.trim()).toScVal(), ); } @@ -106,13 +141,19 @@ export class InvoiceEscrowContractService { investorAddress: string, amountStroops: bigint | number | string, ): xdr.Operation { - const amountBigInt = - typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { + throw new Error("invoiceId is required."); + } + if (!investorAddress || typeof investorAddress !== "string" || !investorAddress.trim()) { + throw new Error("investorAddress is required."); + } + + const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); return this.contract.call( "fund_escrow", - nativeToScVal(invoiceId, { type: "symbol" }), - new Address(investorAddress).toScVal(), + nativeToScVal(invoiceId.trim(), { type: "symbol" }), + new Address(investorAddress.trim()).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), ); } @@ -125,13 +166,19 @@ export class InvoiceEscrowContractService { payerAddress: string, amountStroops: bigint | number | string, ): xdr.Operation { - const amountBigInt = - typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { + throw new Error("invoiceId is required."); + } + if (!payerAddress || typeof payerAddress !== "string" || !payerAddress.trim()) { + throw new Error("payerAddress is required."); + } + + const amountBigInt = this.parseStroopAmount(amountStroops, "amountStroops"); return this.contract.call( "record_payment", - nativeToScVal(invoiceId, { type: "symbol" }), - new Address(payerAddress).toScVal(), + nativeToScVal(invoiceId.trim(), { type: "symbol" }), + new Address(payerAddress.trim()).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), ); } @@ -140,9 +187,13 @@ export class InvoiceEscrowContractService { * Build the Soroban contract invocation operation for settling an escrow. */ public buildSettleEscrowTx(invoiceId: string): xdr.Operation { + if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { + throw new Error("invoiceId is required."); + } + return this.contract.call( "settle_escrow", - nativeToScVal(invoiceId, { type: "symbol" }), + nativeToScVal(invoiceId.trim(), { type: "symbol" }), ); } @@ -226,6 +277,63 @@ export class InvoiceEscrowContractService { }; } + /** + * Polls for transaction confirmation until it reaches SUCCESS, FAILED, or times out. + */ + public async waitForTransactionConfirmation( + txHash: string, + ): Promise<{ status: "SUCCESS" | "FAILED" | "NOT_FOUND"; ledger: number | null }> { + if (!this.rpcServer) { + throw new Error("Soroban RPC server is not configured for transaction confirmation polling."); + } + if (!txHash || !txHash.trim()) { + throw new Error("txHash is required."); + } + + for (let attempt = 0; attempt < this.confirmationAttempts; attempt++) { + try { + const result = await this.rpcServer.getTransaction(txHash); + if (result.status === "SUCCESS") { + this.logger.info("Soroban transaction confirmed on-chain.", { + txHash, + sorobanContractId: this.contractId, + ledger: "ledger" in result ? Number(result.ledger) : null, + }); + return { + status: "SUCCESS", + ledger: "ledger" in result ? Number(result.ledger) : null, + }; + } + if (result.status === "FAILED") { + this.logger.error("Soroban transaction reverted on-chain.", { + txHash, + sorobanContractId: this.contractId, + }); + return { status: "FAILED", ledger: null }; + } + } catch (error) { + this.logger.warn("Transient error while checking transaction status", { + txHash, + attempt: attempt + 1, + error: error instanceof Error ? error.message : String(error), + }); + } + + await new Promise((resolve) => setTimeout(resolve, this.confirmationPollMs)); + } + + this.logger.error("Timed out waiting for transaction confirmation.", { + txHash, + sorobanContractId: this.contractId, + attempts: this.confirmationAttempts, + }); + throw new ServiceError( + "transaction_confirmation_timeout", + "Timed out waiting for transaction confirmation on-chain.", + 504, + ); + } + /** * Creates/initializes an escrow on-chain and logs the structured completion event. * Ensures that only sanitized metadata (invoiceId, sorobanContractId, sellerAddress, amountStroops) @@ -234,10 +342,7 @@ export class InvoiceEscrowContractService { public async createEscrowOnChain( input: CreateEscrowInput, ): Promise { - const amountBigInt = - typeof input.amountStroops === "bigint" - ? input.amountStroops - : BigInt(input.amountStroops); + const amountBigInt = this.parseStroopAmount(input.amountStroops, "amountStroops"); const operation = this.buildCreateEscrowTx( input.invoiceId, diff --git a/tests/rate-limit-wallet.test.ts b/tests/rate-limit-wallet.test.ts index 84c3184..a139f22 100644 --- a/tests/rate-limit-wallet.test.ts +++ b/tests/rate-limit-wallet.test.ts @@ -239,7 +239,7 @@ describe("Wallet-based rate limiting", () => { shortApp.use(express.json()); const limiter = createWalletRateLimiter( - { windowMs: 150, maxRequests: 3 }, + { windowMs: 400, maxRequests: 3 }, "reset-test", ); @@ -267,7 +267,7 @@ describe("Wallet-based rate limiting", () => { await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(429); // Wait for window to expire - await new Promise((resolve) => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 500)); // Counter should have reset — 3 more requests should succeed for (let i = 0; i < 3; i++) { @@ -285,7 +285,7 @@ describe("Wallet-based rate limiting", () => { shortApp.use(express.json()); const limiter = createWalletRateLimiter( - { windowMs: 150, maxRequests: 2 }, + { windowMs: 400, maxRequests: 2 }, "per-wallet-test", ); @@ -316,7 +316,7 @@ describe("Wallet-based rate limiting", () => { await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenB}`).expect(429); // Wait for window to expire - await new Promise((resolve) => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 500)); // Both wallets should have fresh quotas await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenA}`).expect(200); diff --git a/tests/unit/invoice.model.test.ts b/tests/unit/invoice.model.test.ts new file mode 100644 index 0000000..36d85d6 --- /dev/null +++ b/tests/unit/invoice.model.test.ts @@ -0,0 +1,295 @@ +import { Invoice } from "@/models/Invoice.model"; +import { InvoiceStatus } from "@/types/enums"; +import { AppError } from "@/utils/http-error"; + +describe("Invoice Model", () => { + describe("Invoice.calculateNetAmount", () => { + it("calculates net amount accurately with standard inputs", () => { + const net = Invoice.calculateNetAmount("1000.0000", "5.00"); + expect(net).toBe("950.0000"); + }); + + it("calculates net amount accurately with zero discount rate", () => { + const net = Invoice.calculateNetAmount("500.5000", "0.00"); + expect(net).toBe("500.5000"); + }); + + it("calculates net amount accurately with 100% discount rate", () => { + const net = Invoice.calculateNetAmount("1000.0000", "100.00"); + expect(net).toBe("0.0000"); + }); + + it("calculates net amount precisely for values where floating point arithmetic rounds wrong", () => { + // 29.8401 * (1 - 0/100) = 29.8401 + const net = Invoice.calculateNetAmount("29.8401", "0.00"); + expect(net).toBe("29.8401"); + + const net2 = Invoice.calculateNetAmount("100.0000", "33.33"); + expect(net2).toBe("66.6700"); + }); + + it("accepts numeric values as inputs", () => { + const net = Invoice.calculateNetAmount(250, 10); + expect(net).toBe("225.0000"); + }); + + it("throws AppError 400 when amount is negative", () => { + expect(() => Invoice.calculateNetAmount("-100.0000", "5.00")).toThrow(AppError); + try { + Invoice.calculateNetAmount("-100.0000", "5.00"); + } catch (err) { + const appErr = err as AppError; + expect(appErr.statusCode).toBe(400); + expect(appErr.code).toBe("INVALID_AMOUNT_OR_DISCOUNT"); + } + }); + + it("throws AppError 400 when discount rate is negative", () => { + expect(() => Invoice.calculateNetAmount("100.0000", "-5.00")).toThrow(AppError); + }); + + it("throws AppError 400 when discount rate exceeds 100%", () => { + expect(() => Invoice.calculateNetAmount("100.0000", "105.00")).toThrow(AppError); + }); + + it("throws AppError 400 for non-numeric input strings", () => { + expect(() => Invoice.calculateNetAmount("abc", "5.00")).toThrow(AppError); + expect(() => Invoice.calculateNetAmount("100.00", "xyz")).toThrow(AppError); + }); + }); + + describe("Invoice.sanitizeAndNormalize", () => { + it("trims strings and normalizes whitespace", () => { + const invoice = new Invoice(); + invoice.invoiceNumber = " INV-2026-001 "; + invoice.customerName = " Acme Corp Inc. "; + invoice.ipfsHash = " QmHash12345 "; + invoice.smartContractId = " CA1234567890 "; + invoice.rejectionReason = " Invalid document signature "; + + Invoice.sanitizeAndNormalize(invoice); + + expect(invoice.invoiceNumber).toBe("INV-2026-001"); + expect(invoice.customerName).toBe("Acme Corp Inc."); + expect(invoice.ipfsHash).toBe("QmHash12345"); + expect(invoice.smartContractId).toBe("CA1234567890"); + expect(invoice.rejectionReason).toBe("Invalid document signature"); + }); + + it("normalizes empty string fields to null for nullable properties", () => { + const invoice = new Invoice(); + invoice.ipfsHash = " "; + invoice.smartContractId = " "; + invoice.rejectionReason = " "; + + Invoice.sanitizeAndNormalize(invoice); + + expect(invoice.ipfsHash).toBeNull(); + expect(invoice.smartContractId).toBeNull(); + expect(invoice.rejectionReason).toBeNull(); + }); + + it("auto-calculates netAmount if amount and discountRate are present", () => { + const invoice = new Invoice(); + invoice.amount = "2000.0000"; + invoice.discountRate = "10.00"; + invoice.netAmount = "0"; + + Invoice.sanitizeAndNormalize(invoice); + + expect(invoice.netAmount).toBe("1800.0000"); + }); + }); + + describe("State machine transitions (Invoice.isValidTransition & Invoice.transitionTo)", () => { + it("correctly identifies valid transitions from DRAFT", () => { + expect(Invoice.isValidTransition(InvoiceStatus.DRAFT, InvoiceStatus.PUBLISHED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.DRAFT, InvoiceStatus.REJECTED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.DRAFT, InvoiceStatus.FUNDED)).toBe(false); + expect(Invoice.isValidTransition(InvoiceStatus.DRAFT, InvoiceStatus.SETTLED)).toBe(false); + }); + + it("correctly identifies valid transitions from PENDING", () => { + expect(Invoice.isValidTransition(InvoiceStatus.PENDING, InvoiceStatus.PUBLISHED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.PENDING, InvoiceStatus.CANCELLED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.PENDING, InvoiceStatus.REJECTED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.PENDING, InvoiceStatus.FUNDED)).toBe(false); + }); + + it("correctly identifies valid transitions from PUBLISHED", () => { + expect(Invoice.isValidTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.DRAFT)).toBe(false); + expect(Invoice.isValidTransition(InvoiceStatus.PUBLISHED, InvoiceStatus.REJECTED)).toBe(false); + }); + + it("correctly identifies valid transitions from FUNDED", () => { + expect(Invoice.isValidTransition(InvoiceStatus.FUNDED, InvoiceStatus.SETTLED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.FUNDED, InvoiceStatus.PUBLISHED)).toBe(false); + }); + + it("correctly identifies valid transitions from SETTLED (terminal except cancel)", () => { + expect(Invoice.isValidTransition(InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED)).toBe(true); + expect(Invoice.isValidTransition(InvoiceStatus.SETTLED, InvoiceStatus.DRAFT)).toBe(false); + expect(Invoice.isValidTransition(InvoiceStatus.SETTLED, InvoiceStatus.PUBLISHED)).toBe(false); + expect(Invoice.isValidTransition(InvoiceStatus.SETTLED, InvoiceStatus.FUNDED)).toBe(false); + }); + + it("correctly identifies terminal state for CANCELLED and REJECTED", () => { + expect(Invoice.isValidTransition(InvoiceStatus.CANCELLED, InvoiceStatus.DRAFT)).toBe(false); + expect(Invoice.isValidTransition(InvoiceStatus.CANCELLED, InvoiceStatus.PUBLISHED)).toBe(false); + + expect(Invoice.isValidTransition(InvoiceStatus.REJECTED, InvoiceStatus.DRAFT)).toBe(false); + expect(Invoice.isValidTransition(InvoiceStatus.REJECTED, InvoiceStatus.PUBLISHED)).toBe(false); + }); + + it("executes Invoice.transitionTo successfully on valid transition", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.DRAFT; + + Invoice.transitionTo(invoice, InvoiceStatus.PUBLISHED); + expect(invoice.status).toBe(InvoiceStatus.PUBLISHED); + }); + + it("records rejectionReason when transitioning to REJECTED", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.PENDING; + + Invoice.transitionTo(invoice, InvoiceStatus.REJECTED, "Risk score exceeds maximum allowable limit"); + expect(invoice.status).toBe(InvoiceStatus.REJECTED); + expect(invoice.rejectionReason).toBe("Risk score exceeds maximum allowable limit"); + }); + + it("throws AppError 400 when attempting an invalid transition", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.FUNDED; + + expect(() => Invoice.transitionTo(invoice, InvoiceStatus.PUBLISHED)).toThrow(AppError); + try { + Invoice.transitionTo(invoice, InvoiceStatus.PUBLISHED); + } catch (err) { + const appErr = err as AppError; + expect(appErr.statusCode).toBe(400); + expect(appErr.code).toBe("INVALID_STATUS_TRANSITION"); + } + }); + }); + + describe("Invoice.isPublishable verification", () => { + const validFutureDate = new Date(Date.now() + 48 * 60 * 60 * 1000); + + it("returns publishable: true for a valid draft invoice", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.DRAFT; + invoice.amount = "5000.0000"; + invoice.customerName = "Global Logistics Ltd"; + invoice.dueDate = validFutureDate; + invoice.ipfsHash = "QmValidDocumentHash123"; + + const check = Invoice.isPublishable(invoice); + expect(check.publishable).toBe(true); + expect(check.errors).toHaveLength(0); + }); + + it("returns errors when amount is 0 or negative", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.DRAFT; + invoice.amount = "0"; + invoice.customerName = "Customer"; + invoice.dueDate = validFutureDate; + invoice.ipfsHash = "QmHash"; + + const check = Invoice.isPublishable(invoice); + expect(check.publishable).toBe(false); + expect(check.errors).toContain("Invoice amount must be greater than zero"); + }); + + it("returns errors when dueDate is less than 24 hours in the future", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.DRAFT; + invoice.amount = "1000.0000"; + invoice.customerName = "Customer"; + invoice.dueDate = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now + invoice.ipfsHash = "QmHash"; + + const check = Invoice.isPublishable(invoice); + expect(check.publishable).toBe(false); + expect(check.errors.some((e) => e.includes("at least 24 hours"))).toBe(true); + }); + + it("returns errors when IPFS document is missing", () => { + const invoice = new Invoice(); + invoice.status = InvoiceStatus.DRAFT; + invoice.amount = "1000.0000"; + invoice.customerName = "Customer"; + invoice.dueDate = validFutureDate; + invoice.ipfsHash = null; + + const check = Invoice.isPublishable(invoice); + expect(check.publishable).toBe(false); + expect(check.errors).toContain("Invoice document IPFS hash is required"); + }); + }); + + describe("Invoice.isOverdue & Invoice.getFundingRunwayHours", () => { + it("identifies past due dates as overdue", () => { + const invoice = new Invoice(); + invoice.dueDate = new Date(Date.now() - 24 * 60 * 60 * 1000); + expect(Invoice.isOverdue(invoice)).toBe(true); + }); + + it("identifies future due dates as not overdue", () => { + const invoice = new Invoice(); + invoice.dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + expect(Invoice.isOverdue(invoice)).toBe(false); + }); + + it("computes funding runway in hours accurately", () => { + const invoice = new Invoice(); + const reference = new Date("2026-01-01T00:00:00.000Z"); + invoice.dueDate = new Date("2026-01-03T12:00:00.000Z"); // 60 hours later + + expect(Invoice.getFundingRunwayHours(invoice, reference)).toBe(60); + }); + }); + + describe("Invoice.create factory & Invoice.toDTO", () => { + it("creates an instance and normalizes inputs", () => { + const invoice = Invoice.create({ + sellerId: "seller-123", + invoiceNumber: " INV-FACTORY-001 ", + customerName: " Test Factory Client ", + amount: "1000.0000", + discountRate: "5.00", + dueDate: new Date("2026-12-31"), + }); + + expect(invoice).toBeInstanceOf(Invoice); + expect(invoice.invoiceNumber).toBe("INV-FACTORY-001"); + expect(invoice.customerName).toBe("Test Factory Client"); + expect(invoice.netAmount).toBe("950.0000"); + }); + + it("serializes to DTO correctly", () => { + const invoice = Invoice.create({ + id: "inv-uuid-123", + sellerId: "seller-uuid-456", + invoiceNumber: "INV-001", + customerName: "Client ABC", + amount: "1000.0000", + discountRate: "5.00", + dueDate: new Date("2026-12-31"), + status: InvoiceStatus.DRAFT, + }); + + const dto = Invoice.toDTO(invoice); + expect(dto.id).toBe("inv-uuid-123"); + expect(dto.sellerId).toBe("seller-uuid-456"); + expect(dto.invoiceNumber).toBe("INV-001"); + expect(dto.netAmount).toBe("950.0000"); + expect(dto.status).toBe(InvoiceStatus.DRAFT); + }); + }); +}); diff --git a/tests/unit/migrations/initial-schema.migration.test.ts b/tests/unit/migrations/initial-schema.migration.test.ts new file mode 100644 index 0000000..eb583d9 --- /dev/null +++ b/tests/unit/migrations/initial-schema.migration.test.ts @@ -0,0 +1,74 @@ +import { InitialSchema1731513600000 } from "@/migrations/1731513600000-InitialSchema"; +import { AppError } from "@/utils/http-error"; +import type { QueryRunner } from "typeorm"; + +describe("InitialSchema Migration", () => { + let migration: InitialSchema1731513600000; + let mockQueryRunner: Partial; + + beforeEach(() => { + migration = new InitialSchema1731513600000(); + mockQueryRunner = { + query: jest.fn().mockResolvedValue([]), + }; + }); + + describe("up", () => { + it("executes schema creation queries with table and index definitions", async () => { + await migration.up(mockQueryRunner as QueryRunner); + + expect(mockQueryRunner.query).toHaveBeenCalled(); + const queries = (mockQueryRunner.query as jest.Mock).mock.calls.map((c) => c[0]); + + // Verify enum types, tables, and composite indexes are created + expect(queries.some((q) => q.includes("users_usertype_enum"))).toBe(true); + expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS \"users\""))).toBe(true); + expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS \"invoices\""))).toBe(true); + expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS \"investments\""))).toBe(true); + expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS \"transactions\""))).toBe(true); + expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS \"kyc_verifications\""))).toBe(true); + expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS \"notifications\""))).toBe(true); + + // Verify composite indexes for high-volume queries + expect(queries.some((q) => q.includes("idx_invoices_seller_status_created"))).toBe(true); + expect(queries.some((q) => q.includes("idx_invoices_status_due_date"))).toBe(true); + expect(queries.some((q) => q.includes("idx_invoices_status_created_at"))).toBe(true); + expect(queries.some((q) => q.includes("idx_invoices_status_amount"))).toBe(true); + expect(queries.some((q) => q.includes("idx_investments_investor_status"))).toBe(true); + expect(queries.some((q) => q.includes("idx_transactions_user_type_status"))).toBe(true); + }); + + it("wraps and logs errors when queryRunner fails during up", async () => { + (mockQueryRunner.query as jest.Mock).mockRejectedValue(new Error("Database connection lost")); + + await expect(migration.up(mockQueryRunner as QueryRunner)).rejects.toBeInstanceOf(AppError); + await expect(migration.up(mockQueryRunner as QueryRunner)).rejects.toMatchObject({ + statusCode: 500, + code: "MIGRATION_EXECUTION_FAILED", + }); + }); + }); + + describe("down", () => { + it("executes safe idempotent drop statements with CASCADE", async () => { + await migration.down(mockQueryRunner as QueryRunner); + + expect(mockQueryRunner.query).toHaveBeenCalled(); + const queries = (mockQueryRunner.query as jest.Mock).mock.calls.map((c) => c[0]); + + expect(queries.some((q) => q.includes("DROP TABLE IF EXISTS \"invoices\" CASCADE"))).toBe(true); + expect(queries.some((q) => q.includes("DROP TABLE IF EXISTS \"users\" CASCADE"))).toBe(true); + expect(queries.some((q) => q.includes("DROP TYPE IF EXISTS \"public\".\"users_usertype_enum\" CASCADE"))).toBe(true); + }); + + it("wraps and logs errors when queryRunner fails during down", async () => { + (mockQueryRunner.query as jest.Mock).mockRejectedValue(new Error("Permission denied")); + + await expect(migration.down(mockQueryRunner as QueryRunner)).rejects.toBeInstanceOf(AppError); + await expect(migration.down(mockQueryRunner as QueryRunner)).rejects.toMatchObject({ + statusCode: 500, + code: "MIGRATION_ROLLBACK_FAILED", + }); + }); + }); +}); diff --git a/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts b/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts index 8ad71e4..37a8929 100644 --- a/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts +++ b/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts @@ -49,6 +49,9 @@ describe("InvoiceEscrowContractService", () => { expect( () => new InvoiceEscrowContractService({ contractId: "" }), ).toThrow("contractId is required."); + expect( + () => new InvoiceEscrowContractService({ contractId: " " }), + ).toThrow("contractId is required."); }); }); @@ -89,6 +92,32 @@ describe("InvoiceEscrowContractService", () => { // Argument 4: paymentTokenAddress (Address) expect(Address.fromScVal(args[4]).toString()).toBe(TEST_TOKEN); }); + + it("validates required inputs for buildCreateEscrowTx", () => { + expect(() => + service.buildCreateEscrowTx("", TEST_SELLER, TEST_AMOUNT_STROOPS, TEST_DUE_DATE, TEST_TOKEN), + ).toThrow("invoiceId is required."); + + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, "", TEST_AMOUNT_STROOPS, TEST_DUE_DATE, TEST_TOKEN), + ).toThrow("sellerAddress is required."); + + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, 0n, TEST_DUE_DATE, TEST_TOKEN), + ).toThrow("amountStroops must be positive."); + + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, -100n, TEST_DUE_DATE, TEST_TOKEN), + ).toThrow("amountStroops must be positive."); + + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS, 0, TEST_TOKEN), + ).toThrow("dueDateTimestamp must be a positive number."); + + expect(() => + service.buildCreateEscrowTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS, TEST_DUE_DATE, ""), + ).toThrow("paymentTokenAddress is required."); + }); }); describe("createEscrowOnChain - Structured Logging", () => { @@ -163,6 +192,18 @@ describe("InvoiceEscrowContractService", () => { expect(Address.fromScVal(args[1]).toString()).toBe(TEST_SELLER); expect(BigInt(scValToNative(args[2]))).toBe(TEST_AMOUNT_STROOPS); }); + + it("validates required inputs for buildFundEscrowTx", () => { + expect(() => service.buildFundEscrowTx("", TEST_SELLER, TEST_AMOUNT_STROOPS)).toThrow( + "invoiceId is required.", + ); + expect(() => service.buildFundEscrowTx(TEST_INVOICE_ID, "", TEST_AMOUNT_STROOPS)).toThrow( + "investorAddress is required.", + ); + expect(() => service.buildFundEscrowTx(TEST_INVOICE_ID, TEST_SELLER, 0n)).toThrow( + "amountStroops must be positive.", + ); + }); }); describe("buildRecordPaymentTx", () => { @@ -179,6 +220,18 @@ describe("InvoiceEscrowContractService", () => { expect(args).toHaveLength(3); expect(scValToNative(args[0])).toBe(TEST_INVOICE_ID); }); + + it("validates required inputs for buildRecordPaymentTx", () => { + expect(() => service.buildRecordPaymentTx("", TEST_SELLER, TEST_AMOUNT_STROOPS)).toThrow( + "invoiceId is required.", + ); + expect(() => service.buildRecordPaymentTx(TEST_INVOICE_ID, "", TEST_AMOUNT_STROOPS)).toThrow( + "payerAddress is required.", + ); + expect(() => service.buildRecordPaymentTx(TEST_INVOICE_ID, TEST_SELLER, -5n)).toThrow( + "amountStroops must be positive.", + ); + }); }); describe("buildSettleEscrowTx", () => { @@ -195,6 +248,10 @@ describe("InvoiceEscrowContractService", () => { expect(args).toHaveLength(1); expect(scValToNative(args[0])).toBe(TEST_INVOICE_ID); }); + + it("validates required inputs for buildSettleEscrowTx", () => { + expect(() => service.buildSettleEscrowTx("")).toThrow("invoiceId is required."); + }); }); describe("RPC simulation and submission", () => { @@ -281,4 +338,78 @@ describe("InvoiceEscrowContractService", () => { ); }); }); + + describe("waitForTransactionConfirmation", () => { + it("polls and returns SUCCESS on successful on-chain confirmation", async () => { + const mockServer = { + getTransaction: jest.fn().mockResolvedValue({ + status: "SUCCESS", + ledger: "12345", + }), + } as any; + + const rpcService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + confirmationPollMs: 1, + confirmationAttempts: 3, + }); + + const result = await rpcService.waitForTransactionConfirmation("hash-123"); + expect(result.status).toBe("SUCCESS"); + expect(result.ledger).toBe(12345); + expect(mockLogger.info).toHaveBeenCalledWith( + "Soroban transaction confirmed on-chain.", + expect.objectContaining({ txHash: "hash-123", ledger: 12345 }), + ); + }); + + it("returns FAILED when transaction reverts on-chain", async () => { + const mockServer = { + getTransaction: jest.fn().mockResolvedValue({ + status: "FAILED", + }), + } as any; + + const rpcService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + confirmationPollMs: 1, + confirmationAttempts: 3, + }); + + const result = await rpcService.waitForTransactionConfirmation("hash-failed"); + expect(result.status).toBe("FAILED"); + expect(mockLogger.error).toHaveBeenCalledWith( + "Soroban transaction reverted on-chain.", + expect.objectContaining({ txHash: "hash-failed" }), + ); + }); + + it("throws ServiceError 504 on confirmation timeout", async () => { + const mockServer = { + getTransaction: jest.fn().mockResolvedValue({ + status: "NOT_FOUND", + }), + } as any; + + const rpcService = new InvoiceEscrowContractService({ + contractId: ESCROW_CONTRACT_ID, + server: mockServer, + logger: mockLogger, + confirmationPollMs: 1, + confirmationAttempts: 2, + }); + + await expect(rpcService.waitForTransactionConfirmation("hash-timeout")).rejects.toBeInstanceOf( + ServiceError, + ); + await expect(rpcService.waitForTransactionConfirmation("hash-timeout")).rejects.toMatchObject({ + code: "transaction_confirmation_timeout", + statusCode: 504, + }); + }); + }); }); diff --git a/tests/unit/user.controller.test.ts b/tests/unit/user.controller.test.ts new file mode 100644 index 0000000..a1e424e --- /dev/null +++ b/tests/unit/user.controller.test.ts @@ -0,0 +1,308 @@ +import { createUserController, type UserRepositoryContract } from "@/controllers/user.controller"; +import { UserType, KYCStatus } from "@/types/enums"; +import { HttpError, AppError } from "@/utils/http-error"; +import type { AuthenticatedRequest } from "@/types/auth"; +import type { Response, NextFunction } from "express"; +import type { AppLogger } from "@/observability/logger"; + +describe("UserController", () => { + const mockUser = { + id: "550e8400-e29b-41d4-a716-446655440000", + stellarAddress: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + email: "user@example.com", + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + isKycVerified: true, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-02"), + invoices: [], + investments: [], + transactions: [], + kycVerifications: [], + notifications: [], + }; + + let mockRepo: jest.Mocked; + let mockLogger: AppLogger; + let mockRes: Partial; + let mockNext: jest.MockedFunction; + + beforeEach(() => { + mockRepo = { + findById: jest.fn().mockResolvedValue(mockUser), + findByStellarAddress: jest.fn().mockResolvedValue(mockUser), + findAll: jest.fn().mockResolvedValue([mockUser]), + count: jest.fn().mockResolvedValue(1), + save: jest.fn().mockImplementation(async (u) => ({ ...mockUser, ...u })), + }; + + mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; + + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + mockNext = jest.fn(); + }); + + describe("getProfile", () => { + it("returns 401 when unauthenticated", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: undefined } as unknown as AuthenticatedRequest; + + await controller.getProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 401 }); + }); + + it("returns 404 when user is not found in database", async () => { + mockRepo.findById.mockResolvedValue(null); + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: "non-existent-id", walletAddress: "GBZ..." } } as unknown as AuthenticatedRequest; + + await controller.getProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 404 }); + }); + + it("returns 200 with public user DTO when authenticated", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id, walletAddress: mockUser.stellarAddress } } as unknown as AuthenticatedRequest; + + await controller.getProfile(req, mockRes as Response, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith({ + success: true, + data: { + id: mockUser.id, + stellarAddress: mockUser.stellarAddress, + email: mockUser.email, + userType: mockUser.userType, + kycStatus: mockUser.kycStatus, + isKycVerified: true, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }, + }); + }); + + it("logs error and passes AppError 500 when repository throws unexpected error", async () => { + mockRepo.findById.mockRejectedValue(new Error("DB connection timeout")); + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id, walletAddress: mockUser.stellarAddress } } as unknown as AuthenticatedRequest; + + await controller.getProfile(req, mockRes as Response, mockNext); + + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to fetch user profile", + expect.objectContaining({ error: "DB connection timeout", userId: mockUser.id }), + ); + expect(mockNext).toHaveBeenCalledWith(expect.any(AppError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 500 }); + }); + }); + + describe("getUserById", () => { + it("returns 401 when request is unauthenticated", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: undefined, params: { id: mockUser.id } } as unknown as AuthenticatedRequest & { params: { id: string } }; + + await controller.getUserById(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 401 }); + }); + + it("returns 400 when id param is empty or whitespace", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: "req-user" }, params: { id: " " } } as unknown as AuthenticatedRequest & { params: { id: string } }; + + await controller.getUserById(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 400 }); + }); + + it("returns 404 when target user is not found", async () => { + mockRepo.findById.mockResolvedValue(null); + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: "req-user" }, params: { id: "some-id" } } as unknown as AuthenticatedRequest & { params: { id: string } }; + + await controller.getUserById(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 404 }); + }); + + it("returns 200 with public user data when target user exists", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: "req-user" }, params: { id: mockUser.id } } as unknown as AuthenticatedRequest & { params: { id: string } }; + + await controller.getUserById(req, mockRes as Response, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ id: mockUser.id }) })); + }); + }); + + describe("updateProfile", () => { + it("returns 401 when request is unauthenticated", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: undefined, body: { email: "new@example.com" } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 401 }); + }); + + it("returns 400 when email format is invalid", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, body: { email: "invalid-email" } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 400, message: "Invalid email format" }); + }); + + it("returns 400 when Stellar public key format is invalid", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, body: { stellarAddress: "INVALID_KEY" } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 400, message: "Invalid Stellar public key" }); + }); + + it("returns 400 when userType is invalid", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, body: { userType: "superadmin" } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 400, message: "Invalid user type" }); + }); + + it("returns 400 when no valid update fields are provided", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, body: {} } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 400, message: "No valid fields to update" }); + }); + + it("returns 404 when user is not found in database", async () => { + mockRepo.findById.mockResolvedValue(null); + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: "missing-id" }, body: { email: "valid@example.com" } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 404 }); + }); + + it("updates email in lowercase and returns 200 with updated DTO", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, body: { email: " NEW.EMAIL@EXAMPLE.COM " } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({ email: "new.email@example.com" })); + expect(mockLogger.info).toHaveBeenCalledWith("User profile updated", expect.objectContaining({ userId: mockUser.id })); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it("updates valid Stellar public key and returns 200", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const validAddress = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + const req = { user: { id: mockUser.id }, body: { stellarAddress: validAddress } } as unknown as AuthenticatedRequest; + + await controller.updateProfile(req, mockRes as Response, mockNext); + + expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({ stellarAddress: validAddress })); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + }); + + describe("listUsers", () => { + it("returns 401 when request is unauthenticated", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: undefined, query: {} } as unknown as AuthenticatedRequest & { query: { page?: string; limit?: string } }; + + await controller.listUsers(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(HttpError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 401 }); + }); + + it("fetches users and total count concurrently with default pagination", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, query: {} } as unknown as AuthenticatedRequest & { query: { page?: string; limit?: string } }; + + await controller.listUsers(req, mockRes as Response, mockNext); + + expect(mockRepo.findAll).toHaveBeenCalledWith({ skip: 0, take: 20 }); + expect(mockRepo.count).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith({ + success: true, + data: expect.arrayContaining([expect.objectContaining({ id: mockUser.id })]), + meta: { total: 1, page: 1, limit: 20, totalPages: 1 }, + }); + }); + + it("handles clamped pagination bounds correctly", async () => { + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, query: { page: "-5", limit: "5000" } } as unknown as AuthenticatedRequest & { query: { page?: string; limit?: string } }; + + await controller.listUsers(req, mockRes as Response, mockNext); + + expect(mockRepo.findAll).toHaveBeenCalledWith({ skip: 0, take: 100 }); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it("falls back gracefully when repository does not define count method", async () => { + const repoWithoutCount: UserRepositoryContract = { + findById: mockRepo.findById, + findByStellarAddress: mockRepo.findByStellarAddress, + findAll: mockRepo.findAll, + save: mockRepo.save, + }; + + const controller = createUserController({ userRepository: repoWithoutCount, logger: mockLogger }); + const req = { user: { id: mockUser.id }, query: { page: "1", limit: "10" } } as unknown as AuthenticatedRequest & { query: { page?: string; limit?: string } }; + + await controller.listUsers(req, mockRes as Response, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ meta: { total: 1, page: 1, limit: 10, totalPages: 1 } })); + }); + + it("catches repository errors and forwards AppError 500", async () => { + mockRepo.findAll.mockRejectedValue(new Error("Database offline")); + const controller = createUserController({ userRepository: mockRepo, logger: mockLogger }); + const req = { user: { id: mockUser.id }, query: {} } as unknown as AuthenticatedRequest & { query: { page?: string; limit?: string } }; + + await controller.listUsers(req, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(AppError)); + expect(mockNext.mock.calls[0][0]).toMatchObject({ statusCode: 500 }); + }); + }); +});