diff --git a/package.json b/package.json index 9bfffec..b865cfd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "index.js", "scripts": { - "dev": "ts-node-dev --respawn src/index.ts", + "dev": "ts-node-dev --respawn --files src/index.ts", "build": "tsc", "start": "node dist/index.js", "test": "jest", diff --git a/src/controllers/MultiAssetPaymentController.ts b/src/controllers/MultiAssetPaymentController.ts new file mode 100644 index 0000000..2671928 --- /dev/null +++ b/src/controllers/MultiAssetPaymentController.ts @@ -0,0 +1,343 @@ +import { Request, Response } from "express"; +import { AssetConfigurationService } from "../services/AssetConfigurationService"; +import { AssetPriceService } from "../services/AssetPriceService"; +import { MultiAssetPaymentService } from "../services/MultiAssetPaymentService"; +import { AppError } from "../utils/AppError"; +import logger from "../utils/logger"; + +export class MultiAssetPaymentController { + private assetConfigService: AssetConfigurationService; + private assetPriceService: AssetPriceService; + private multiAssetPaymentService: MultiAssetPaymentService; + + constructor() { + this.assetConfigService = new AssetConfigurationService(); + this.assetPriceService = new AssetPriceService(); + this.multiAssetPaymentService = new MultiAssetPaymentService(); + } + + createAssetConfig = async (req: Request, res: Response): Promise => { + try { + const { + merchantId, + asset, + isEnabled, + minAmount, + maxAmount, + priority, + autoConvert, + settlementAsset, + } = req.body; + + const config = await this.assetConfigService.createAssetConfig({ + merchantId, + assetCode: asset.code, + assetIssuer: asset.issuer || null, + isEnabled, + minAmount, + maxAmount, + priority, + autoConvert, + settlementAssetCode: settlementAsset?.code || null, + settlementAssetIssuer: settlementAsset?.issuer || null, + }); + + res.status(201).json({ + success: true, + message: "Asset configuration created successfully", + data: config, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error creating asset config:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + updateAssetConfig = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { merchantId } = req.body; + const { + isEnabled, + minAmount, + maxAmount, + priority, + autoConvert, + settlementAsset, + } = req.body; + + const config = await this.assetConfigService.updateAssetConfig( + id, + merchantId, + { + isEnabled, + minAmount, + maxAmount, + priority, + autoConvert, + settlementAssetCode: settlementAsset?.code || null, + settlementAssetIssuer: settlementAsset?.issuer || null, + }, + ); + + res.status(200).json({ + success: true, + message: "Asset configuration updated successfully", + data: config, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error updating asset config:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + deleteAssetConfig = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { merchantId } = req.body; + + await this.assetConfigService.deleteAssetConfig(id, merchantId); + + res.status(200).json({ + success: true, + message: "Asset configuration deleted successfully", + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error deleting asset config:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + getMerchantAssetConfigs = async ( + req: Request, + res: Response, + ): Promise => { + try { + const { merchantId } = req.params; + + const configs = + await this.assetConfigService.getMerchantAssetConfigs(merchantId); + + res.status(200).json({ + success: true, + data: configs, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error fetching merchant configs:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + getExchangeRate = async (req: Request, res: Response): Promise => { + try { + const { sourceAsset, destinationAsset, amount } = req.body; + + const quote = await this.assetPriceService.getExchangeRate( + sourceAsset.code, + sourceAsset.issuer, + destinationAsset.code, + destinationAsset.issuer, + amount, + ); + + res.status(200).json({ + success: true, + data: quote, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error getting exchange rate:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + executePathPayment = async (req: Request, res: Response): Promise => { + try { + const { + merchantId, + sourceAddress, + destinationAddress, + sourceAsset, + destinationAsset, + amount, + sourceSecret, + memo, + } = req.body; + + const result = await this.multiAssetPaymentService.executePathPayment({ + merchantId, + sourceAddress, + destinationAddress, + sourceAssetCode: sourceAsset.code, + sourceAssetIssuer: sourceAsset.issuer, + destAssetCode: destinationAsset?.code || sourceAsset.code, + destAssetIssuer: destinationAsset?.issuer || sourceAsset.issuer, + amount, + sourceSecret, + memo, + }); + + res.status(200).json({ + success: true, + message: "Path payment executed successfully", + data: result, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error executing path payment:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + estimatePayment = async (req: Request, res: Response): Promise => { + try { + const { merchantId, sourceAsset, destinationAsset, amount } = req.body; + + const estimate = await this.multiAssetPaymentService.estimatePayment({ + merchantId, + sourceAssetCode: sourceAsset.code, + sourceAssetIssuer: sourceAsset.issuer, + destAssetCode: destinationAsset.code, + destAssetIssuer: destinationAsset.issuer, + amount, + }); + + res.status(200).json({ + success: true, + data: estimate, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error estimating payment:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + getSupportedAssetPairs = async ( + req: Request, + res: Response, + ): Promise => { + try { + const { merchantId } = req.params; + + const pairs = + await this.multiAssetPaymentService.getSupportedAssetPairs(merchantId); + + res.status(200).json({ + success: true, + data: pairs, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error getting supported pairs:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; + + getOrderbook = async (req: Request, res: Response): Promise => { + try { + const { sourceAsset, destinationAsset } = req.body; + + const orderbook = await this.assetPriceService.getOrderbook( + sourceAsset.code, + sourceAsset.issuer, + destinationAsset.code, + destinationAsset.issuer, + ); + + res.status(200).json({ + success: true, + data: orderbook, + }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } else { + logger.error("Error getting orderbook:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + }); + } + } + }; +} diff --git a/src/controllers/RateLimitController.ts b/src/controllers/RateLimitController.ts index a80a81a..bf4a657 100644 --- a/src/controllers/RateLimitController.ts +++ b/src/controllers/RateLimitController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import RateLimitMonitoringService from "../services/rateLimitMonitoring.service"; import rateLimitConfigService from "../services/rateLimitConfigService"; import whitelistBlacklistService from "../services/whitelistBlacklistService"; -import { WhitelistType } from "src/entities/RateLimitWhiteList"; +import { WhitelistType } from "../entities/RateLimitWhiteList"; import { BlacklistType, BlacklistReason } from "../entities/RateLimitBlacklist"; import logger from "../utils/logger"; diff --git a/src/dtos/MultiAssetPaymentDTO.ts b/src/dtos/MultiAssetPaymentDTO.ts new file mode 100644 index 0000000..70ded8a --- /dev/null +++ b/src/dtos/MultiAssetPaymentDTO.ts @@ -0,0 +1,125 @@ +import { + IsString, + IsNumber, + IsOptional, + IsBoolean, + Min, + Max, + IsArray, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; + +export class AssetDTO { + @IsString() + code: string; + + @IsString() + @IsOptional() + issuer?: string; +} + +export class CreateAssetConfigDTO { + @IsString() + merchantId: string; + + @ValidateNested() + @Type(() => AssetDTO) + asset: AssetDTO; + + @IsBoolean() + @IsOptional() + isEnabled?: boolean; + + @IsString() + @IsOptional() + minAmount?: string; + + @IsString() + @IsOptional() + maxAmount?: string; + + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + priority?: number; + + @IsBoolean() + @IsOptional() + autoConvert?: boolean; + + @ValidateNested() + @Type(() => AssetDTO) + @IsOptional() + settlementAsset?: AssetDTO; +} + +export class UpdateAssetConfigDTO { + @IsBoolean() + @IsOptional() + isEnabled?: boolean; + + @IsString() + @IsOptional() + minAmount?: string; + + @IsString() + @IsOptional() + maxAmount?: string; + + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + priority?: number; + + @IsBoolean() + @IsOptional() + autoConvert?: boolean; + + @ValidateNested() + @Type(() => AssetDTO) + @IsOptional() + settlementAsset?: AssetDTO; +} + +export class MultiAssetPaymentDTO { + @IsString() + merchantId: string; + + @IsString() + amount: string; + + @ValidateNested() + @Type(() => AssetDTO) + sourceAsset: AssetDTO; + + @ValidateNested() + @Type(() => AssetDTO) + @IsOptional() + destinationAsset?: AssetDTO; + + @IsString() + sourceAddress: string; + + @IsString() + destinationAddress: string; + + @IsString() + @IsOptional() + memo?: string; +} + +export class GetExchangeRateDTO { + @ValidateNested() + @Type(() => AssetDTO) + sourceAsset: AssetDTO; + + @ValidateNested() + @Type(() => AssetDTO) + destinationAsset: AssetDTO; + + @IsString() + amount: string; +} diff --git a/src/entities/AssetConfiguration.ts b/src/entities/AssetConfiguration.ts new file mode 100644 index 0000000..d35eb03 --- /dev/null +++ b/src/entities/AssetConfiguration.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +@Entity("asset_configurations") +export class AssetConfiguration { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Index() + @Column({ name: "merchant_id", type: "uuid" }) + merchantId: string; + + @Column({ name: "asset_code", type: "varchar", length: 12 }) + assetCode: string; + + @Column({ name: "asset_issuer", type: "varchar", length: 56, nullable: true }) + assetIssuer: string | null; + + @Column({ name: "is_enabled", type: "boolean", default: true }) + isEnabled: boolean; + + @Column({ + name: "min_amount", + type: "decimal", + precision: 20, + scale: 7, + default: "0", + }) + minAmount: string; + + @Column({ + name: "max_amount", + type: "decimal", + precision: 20, + scale: 7, + nullable: true, + }) + maxAmount: string | null; + + @Column({ name: "priority", type: "integer", default: 0 }) + priority: number; + + @Column({ name: "auto_convert", type: "boolean", default: false }) + autoConvert: boolean; + + @Column({ + name: "settlement_asset_code", + type: "varchar", + length: 12, + nullable: true, + }) + settlementAssetCode: string | null; + + @Column({ + name: "settlement_asset_issuer", + type: "varchar", + length: 56, + nullable: true, + }) + settlementAssetIssuer: string | null; + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 53469e8..31bf542 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ import walletRoutes from "./wallet"; import { subscriptionRouter } from "./subscriptionRoutes"; import teamRoutes from "./teamRoutes"; import rateLimitRoutes from "./rateLimitRoutes"; +import multiAssetPaymentRoutes from "./multiAssetPayment.routes"; const router = Router(); @@ -20,5 +21,6 @@ router.use("/subscriptions", subscriptionRouter); router.use("/audit", auditRoutes); router.use("/wallet", walletRoutes); router.use("/rate-limit", rateLimitRoutes); +router.use("/api/multi-asset", multiAssetPaymentRoutes); export default router; diff --git a/src/routes/multiAssetPayment.routes.ts b/src/routes/multiAssetPayment.routes.ts new file mode 100644 index 0000000..d3d009b --- /dev/null +++ b/src/routes/multiAssetPayment.routes.ts @@ -0,0 +1,205 @@ +import express from "express"; +import { MultiAssetPaymentController } from "../controllers/MultiAssetPaymentController"; +import { + authenticateMerchant, + asyncHandler, +} from "../middlewares/merchantAuth"; +import { handleValidationErrors } from "../middlewares/validationErrorHandler"; +import { body, param } from "express-validator"; + +const router = express.Router(); +const controller = new MultiAssetPaymentController(); + +const validateCreateAssetConfig = [ + body("merchantId").isUUID().withMessage("Valid merchant ID is required"), + body("asset").isObject().withMessage("Asset is required"), + body("asset.code") + .isString() + .notEmpty() + .withMessage("Asset code is required"), + body("asset.issuer").optional().isString(), + body("isEnabled").optional().isBoolean(), + body("minAmount").optional().isString(), + body("maxAmount").optional().isString(), + body("priority").optional().isInt({ min: 0, max: 100 }), + body("autoConvert").optional().isBoolean(), + body("settlementAsset").optional().isObject(), + handleValidationErrors, +]; + +const validateUpdateAssetConfig = [ + param("id").isUUID().withMessage("Valid configuration ID is required"), + body("merchantId").isUUID().withMessage("Valid merchant ID is required"), + body("isEnabled").optional().isBoolean(), + body("minAmount").optional().isString(), + body("maxAmount").optional().isString(), + body("priority").optional().isInt({ min: 0, max: 100 }), + body("autoConvert").optional().isBoolean(), + body("settlementAsset").optional().isObject(), + handleValidationErrors, +]; + +const validateExchangeRate = [ + body("sourceAsset").isObject().withMessage("Source asset is required"), + body("sourceAsset.code").isString().notEmpty(), + body("sourceAsset.issuer").optional().isString(), + body("destinationAsset") + .isObject() + .withMessage("Destination asset is required"), + body("destinationAsset.code").isString().notEmpty(), + body("destinationAsset.issuer").optional().isString(), + body("amount").isString().notEmpty().withMessage("Amount is required"), + handleValidationErrors, +]; + +const validatePathPayment = [ + body("merchantId").isUUID().withMessage("Valid merchant ID is required"), + body("sourceAddress") + .isString() + .notEmpty() + .withMessage("Source address is required"), + body("destinationAddress") + .isString() + .notEmpty() + .withMessage("Destination address is required"), + body("sourceAsset").isObject().withMessage("Source asset is required"), + body("sourceAsset.code").isString().notEmpty(), + body("sourceAsset.issuer").optional().isString(), + body("destinationAsset").optional().isObject(), + body("amount").isString().notEmpty().withMessage("Amount is required"), + body("sourceSecret") + .isString() + .notEmpty() + .withMessage("Source secret is required"), + body("memo").optional().isString(), + handleValidationErrors, +]; + +const validateEstimate = [ + body("merchantId").isUUID().withMessage("Valid merchant ID is required"), + body("sourceAsset").isObject().withMessage("Source asset is required"), + body("sourceAsset.code").isString().notEmpty(), + body("sourceAsset.issuer").optional().isString(), + body("destinationAsset") + .isObject() + .withMessage("Destination asset is required"), + body("destinationAsset.code").isString().notEmpty(), + body("destinationAsset.issuer").optional().isString(), + body("amount").isString().notEmpty().withMessage("Amount is required"), + handleValidationErrors, +]; + +/** + * @route POST /api/multi-asset/config + * @desc Create asset configuration for merchant + * @access Private (Merchant) + */ +router.post( + "/config", + authenticateMerchant, + validateCreateAssetConfig, + asyncHandler(controller.createAssetConfig), +); + +/** + * @route PUT /api/multi-asset/config/:id + * @desc Update asset configuration + * @access Private (Merchant) + */ +router.put( + "/config/:id", + authenticateMerchant, + validateUpdateAssetConfig, + asyncHandler(controller.updateAssetConfig), +); + +/** + * @route DELETE /api/multi-asset/config/:id + * @desc Delete asset configuration + * @access Private (Merchant) + */ +router.delete( + "/config/:id", + authenticateMerchant, + [ + param("id").isUUID().withMessage("Valid configuration ID is required"), + body("merchantId").isUUID().withMessage("Valid merchant ID is required"), + handleValidationErrors, + ], + asyncHandler(controller.deleteAssetConfig), +); + +/** + * @route GET /api/multi-asset/config/merchant/:merchantId + * @desc Get all asset configurations for merchant + * @access Public + */ +router.get( + "/config/merchant/:merchantId", + [ + param("merchantId").isUUID().withMessage("Valid merchant ID is required"), + handleValidationErrors, + ], + asyncHandler(controller.getMerchantAssetConfigs), +); + +/** + * @route POST /api/multi-asset/exchange-rate + * @desc Get exchange rate between two assets + * @access Public + */ +router.post( + "/exchange-rate", + validateExchangeRate, + asyncHandler(controller.getExchangeRate), +); + +/** + * @route POST /api/multi-asset/payment + * @desc Execute multi-asset path payment + * @access Private (Merchant) + */ +router.post( + "/payment", + authenticateMerchant, + validatePathPayment, + asyncHandler(controller.executePathPayment), +); + +/** + * @route POST /api/multi-asset/estimate + * @desc Estimate payment amount and fees + * @access Public + */ +router.post( + "/estimate", + validateEstimate, + asyncHandler(controller.estimatePayment), +); + +/** + * @route GET /api/multi-asset/pairs/:merchantId + * @desc Get supported asset pairs for merchant + * @access Public + */ +router.get( + "/pairs/:merchantId", + [ + param("merchantId").isUUID().withMessage("Valid merchant ID is required"), + handleValidationErrors, + ], + asyncHandler(controller.getSupportedAssetPairs), +); + +/** + * @route POST /api/multi-asset/orderbook + * @desc Get DEX orderbook for asset pair + * @access Public + */ +router.post( + "/orderbook", + validateExchangeRate, + asyncHandler(controller.getOrderbook), +); + +export default router; diff --git a/src/scripts/create-asset-configuration-table.sql b/src/scripts/create-asset-configuration-table.sql new file mode 100644 index 0000000..36f63f9 --- /dev/null +++ b/src/scripts/create-asset-configuration-table.sql @@ -0,0 +1,75 @@ +-- Create asset_configurations table for multi-asset payment gateway +CREATE TABLE IF NOT EXISTS asset_configurations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + merchant_id UUID NOT NULL, + asset_code VARCHAR(12) NOT NULL, + asset_issuer VARCHAR(56), + is_enabled BOOLEAN DEFAULT true NOT NULL, + min_amount DECIMAL(20, 7) DEFAULT '0' NOT NULL, + max_amount DECIMAL(20, 7), + priority INTEGER DEFAULT 0 NOT NULL, + auto_convert BOOLEAN DEFAULT false NOT NULL, + settlement_asset_code VARCHAR(12), + settlement_asset_issuer VARCHAR(56), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_asset_config_merchant +ON asset_configurations(merchant_id); + +CREATE INDEX IF NOT EXISTS idx_asset_config_enabled +ON asset_configurations(merchant_id, is_enabled); + +CREATE INDEX IF NOT EXISTS idx_asset_config_priority +ON asset_configurations(merchant_id, priority DESC); + +-- Unique constraint to prevent duplicate asset configs +CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_config_unique +ON asset_configurations(merchant_id, asset_code, COALESCE(asset_issuer, '')); + +-- Add foreign key constraint to merchants table if it exists +ALTER TABLE asset_configurations +ADD CONSTRAINT fk_asset_config_merchant +FOREIGN KEY (merchant_id) +REFERENCES merchants(id) +ON DELETE CASCADE; + +-- Add check constraints +ALTER TABLE asset_configurations +ADD CONSTRAINT chk_min_amount_positive +CHECK (min_amount::numeric >= 0); + +ALTER TABLE asset_configurations +ADD CONSTRAINT chk_max_amount_greater_than_min +CHECK (max_amount IS NULL OR max_amount::numeric > min_amount::numeric); + +ALTER TABLE asset_configurations +ADD CONSTRAINT chk_priority_range +CHECK (priority >= 0 AND priority <= 100); + +-- Create trigger to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_asset_config_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_asset_config_timestamp +BEFORE UPDATE ON asset_configurations +FOR EACH ROW +EXECUTE FUNCTION update_asset_config_timestamp(); + +-- Insert default configurations for testing (optional) +-- Uncomment the following lines for development/testing + +-- INSERT INTO asset_configurations (merchant_id, asset_code, asset_issuer, min_amount, max_amount, priority) +-- VALUES +-- ('your-merchant-uuid', 'XLM', NULL, '10', '100000', 1), +-- ('your-merchant-uuid', 'USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', '1', '50000', 2); + +-- Grant permissions (adjust based on your database user) +-- GRANT SELECT, INSERT, UPDATE, DELETE ON asset_configurations TO your_app_user; \ No newline at end of file diff --git a/src/services/AssetConfigurationService.ts b/src/services/AssetConfigurationService.ts new file mode 100644 index 0000000..8af9bc6 --- /dev/null +++ b/src/services/AssetConfigurationService.ts @@ -0,0 +1,225 @@ +import { Repository, IsNull } from "typeorm"; +import AppDataSource from "../config/db"; +import { AssetConfiguration } from "../entities/AssetConfiguration"; +import { AppError } from "../utils/AppError"; +import logger from "../utils/logger"; + +export class AssetConfigurationService { + private assetConfigRepo: Repository; + + constructor() { + this.assetConfigRepo = AppDataSource.getRepository(AssetConfiguration); + } + + async createAssetConfig(data: { + merchantId: string; + assetCode: string; + assetIssuer: string | null; + isEnabled?: boolean; + minAmount?: string; + maxAmount?: string | null; + priority?: number; + autoConvert?: boolean; + settlementAssetCode?: string | null; + settlementAssetIssuer?: string | null; + }): Promise { + try { + const existing = await this.assetConfigRepo.findOne({ + where: { + merchantId: data.merchantId, + assetCode: data.assetCode, + assetIssuer: data.assetIssuer || IsNull(), + }, + }); + + if (existing) { + throw new AppError( + "Asset configuration already exists for this merchant", + 409, + ); + } + + const config = this.assetConfigRepo.create({ + merchantId: data.merchantId, + assetCode: data.assetCode, + assetIssuer: data.assetIssuer, + isEnabled: data.isEnabled ?? true, + minAmount: data.minAmount || "0", + maxAmount: data.maxAmount || null, + priority: data.priority || 0, + autoConvert: data.autoConvert || false, + settlementAssetCode: data.settlementAssetCode || null, + settlementAssetIssuer: data.settlementAssetIssuer || null, + }); + + const saved = await this.assetConfigRepo.save(config); + logger.info( + `Asset configuration created for merchant ${data.merchantId}: ${data.assetCode}`, + ); + return saved; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error creating asset configuration:", error); + throw new AppError("Failed to create asset configuration", 500); + } + } + + async updateAssetConfig( + id: string, + merchantId: string, + data: { + isEnabled?: boolean; + minAmount?: string; + maxAmount?: string | null; + priority?: number; + autoConvert?: boolean; + settlementAssetCode?: string | null; + settlementAssetIssuer?: string | null; + }, + ): Promise { + try { + const config = await this.assetConfigRepo.findOne({ + where: { id, merchantId }, + }); + + if (!config) { + throw new AppError("Asset configuration not found", 404); + } + + Object.assign(config, data); + const updated = await this.assetConfigRepo.save(config); + logger.info(`Asset configuration updated: ${id}`); + return updated; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error updating asset configuration:", error); + throw new AppError("Failed to update asset configuration", 500); + } + } + + async deleteAssetConfig(id: string, merchantId: string): Promise { + try { + const result = await this.assetConfigRepo.delete({ id, merchantId }); + if (!result.affected || result.affected === 0) { + throw new AppError("Asset configuration not found", 404); + } + logger.info(`Asset configuration deleted: ${id}`); + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error deleting asset configuration:", error); + throw new AppError("Failed to delete asset configuration", 500); + } + } + + async getMerchantAssetConfigs( + merchantId: string, + ): Promise { + try { + return await this.assetConfigRepo.find({ + where: { merchantId }, + order: { priority: "DESC", createdAt: "ASC" }, + }); + } catch (error) { + logger.error("Error fetching merchant asset configurations:", error); + throw new AppError("Failed to fetch asset configurations", 500); + } + } + + async getEnabledAssetConfigs( + merchantId: string, + ): Promise { + try { + return await this.assetConfigRepo.find({ + where: { merchantId, isEnabled: true }, + order: { priority: "DESC", createdAt: "ASC" }, + }); + } catch (error) { + logger.error("Error fetching enabled asset configurations:", error); + throw new AppError("Failed to fetch enabled asset configurations", 500); + } + } + + async getAssetConfig( + id: string, + merchantId: string, + ): Promise { + try { + const config = await this.assetConfigRepo.findOne({ + where: { id, merchantId }, + }); + + if (!config) { + throw new AppError("Asset configuration not found", 404); + } + + return config; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error fetching asset configuration:", error); + throw new AppError("Failed to fetch asset configuration", 500); + } + } + + async isAssetSupported( + merchantId: string, + assetCode: string, + assetIssuer: string | null, + ): Promise { + try { + const config = await this.assetConfigRepo.findOne({ + where: { + merchantId, + assetCode, + assetIssuer: assetIssuer || IsNull(), + isEnabled: true, + }, + }); + + return !!config; + } catch (error) { + logger.error("Error checking asset support:", error); + return false; + } + } + + async validatePaymentAmount( + merchantId: string, + assetCode: string, + assetIssuer: string | null, + amount: string, + ): Promise { + try { + const config = await this.assetConfigRepo.findOne({ + where: { + merchantId, + assetCode, + assetIssuer: assetIssuer || IsNull(), + isEnabled: true, + }, + }); + + if (!config) { + return false; + } + + const amountNum = parseFloat(amount); + const minAmount = parseFloat(config.minAmount); + + if (amountNum < minAmount) { + return false; + } + + if (config.maxAmount) { + const maxAmount = parseFloat(config.maxAmount); + if (amountNum > maxAmount) { + return false; + } + } + + return true; + } catch (error) { + logger.error("Error validating payment amount:", error); + return false; + } + } +} diff --git a/src/services/AssetPriceService.ts b/src/services/AssetPriceService.ts new file mode 100644 index 0000000..e67e142 --- /dev/null +++ b/src/services/AssetPriceService.ts @@ -0,0 +1,198 @@ +import { Horizon, Asset, Networks } from "@stellar/stellar-sdk"; +import { AppError } from "../utils/AppError"; +import logger from "../utils/logger"; + +interface PathAsset { + asset_type: string; + asset_code?: string; + asset_issuer?: string; +} + +interface PriceQuote { + sourceAsset: string; + destinationAsset: string; + sourceAmount: string; + destinationAmount: string; + rate: string; + path: PathAsset[]; + timestamp: Date; +} + +interface OrderbookEntry { + price: string; + amount: string; +} + +interface OrderbookResponse { + bids: OrderbookEntry[]; + asks: OrderbookEntry[]; + source: { code: string; issuer: string | undefined }; + destination: { code: string; issuer: string | undefined }; +} + +export class AssetPriceService { + private horizon: Horizon.Server; + private networkPassphrase: string; + + constructor() { + const horizonUrl = + process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"; + this.horizon = new Horizon.Server(horizonUrl); + this.networkPassphrase = + process.env.STELLAR_NETWORK === "PUBLIC" + ? Networks.PUBLIC + : Networks.TESTNET; + } + + private createAsset(code: string, issuer?: string): Asset { + if (code === "XLM" || !issuer) { + return Asset.native(); + } + return new Asset(code, issuer); + } + + async getExchangeRate( + sourceAssetCode: string, + sourceAssetIssuer: string | undefined, + destAssetCode: string, + destAssetIssuer: string | undefined, + amount: string, + ): Promise { + try { + const sourceAsset = this.createAsset(sourceAssetCode, sourceAssetIssuer); + const destAsset = this.createAsset(destAssetCode, destAssetIssuer); + + const pathsResponse = await this.horizon + .strictSendPaths(sourceAsset, amount, [destAsset]) + .call(); + + if (!pathsResponse.records || pathsResponse.records.length === 0) { + throw new AppError("No payment path found between these assets", 404); + } + + const bestPath = pathsResponse.records[0]; + + const rate = ( + parseFloat(bestPath.destination_amount) / parseFloat(amount) + ).toString(); + + return { + sourceAsset: sourceAssetCode, + destinationAsset: destAssetCode, + sourceAmount: amount, + destinationAmount: bestPath.destination_amount, + rate, + path: bestPath.path, + timestamp: new Date(), + }; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error getting exchange rate:", error); + throw new AppError("Failed to fetch exchange rate", 500); + } + } + + async findBestPath( + sourceAssetCode: string, + sourceAssetIssuer: string | undefined, + destAssetCode: string, + destAssetIssuer: string | undefined, + amount: string, + ): Promise<{ destination_amount: string; path: PathAsset[] } | null> { + try { + const sourceAsset = this.createAsset(sourceAssetCode, sourceAssetIssuer); + const destAsset = this.createAsset(destAssetCode, destAssetIssuer); + + const pathsResponse = await this.horizon + .strictSendPaths(sourceAsset, amount, [destAsset]) + .call(); + + if (!pathsResponse.records || pathsResponse.records.length === 0) { + return null; + } + + return pathsResponse.records[0]; + } catch (error) { + logger.error("Error finding best path:", error); + return null; + } + } + + async getMultiplePrices( + sourceAssetCode: string, + sourceAssetIssuer: string | undefined, + destinationAssets: Array<{ code: string; issuer?: string }>, + amount: string, + ): Promise { + try { + const quotes: PriceQuote[] = []; + + for (const destAsset of destinationAssets) { + try { + const quote = await this.getExchangeRate( + sourceAssetCode, + sourceAssetIssuer, + destAsset.code, + destAsset.issuer, + amount, + ); + quotes.push(quote); + } catch (error) { + logger.warn(`Failed to get price for ${destAsset.code}:`, error); + } + } + + return quotes; + } catch (error) { + logger.error("Error getting multiple prices:", error); + throw new AppError("Failed to fetch multiple prices", 500); + } + } + + async validateAssetExists( + assetCode: string, + assetIssuer?: string, + ): Promise { + try { + if (assetCode === "XLM") { + return true; + } + + if (!assetIssuer) { + return false; + } + + const account = await this.horizon.loadAccount(assetIssuer); + return !!account; + } catch (error) { + logger.error("Error validating asset:", error); + return false; + } + } + + async getOrderbook( + sourceAssetCode: string, + sourceAssetIssuer: string | undefined, + destAssetCode: string, + destAssetIssuer: string | undefined, + ): Promise { + try { + const sourceAsset = this.createAsset(sourceAssetCode, sourceAssetIssuer); + const destAsset = this.createAsset(destAssetCode, destAssetIssuer); + + const orderbook = await this.horizon + .orderbook(sourceAsset, destAsset) + .call(); + + return { + bids: orderbook.bids.slice(0, 5), + asks: orderbook.asks.slice(0, 5), + source: { code: sourceAssetCode, issuer: sourceAssetIssuer }, + destination: { code: destAssetCode, issuer: destAssetIssuer }, + }; + } catch (error) { + logger.error("Error getting orderbook:", error); + throw new AppError("Failed to fetch orderbook", 500); + } + } +} diff --git a/src/services/MultiAssetPaymentService.ts b/src/services/MultiAssetPaymentService.ts new file mode 100644 index 0000000..82d68d3 --- /dev/null +++ b/src/services/MultiAssetPaymentService.ts @@ -0,0 +1,298 @@ +import { + Horizon, + Asset, + Keypair, + TransactionBuilder, + Operation, + Networks, + BASE_FEE, + Memo, +} from "@stellar/stellar-sdk"; +import { AssetConfigurationService } from "./AssetConfigurationService"; +import { AssetPriceService } from "./AssetPriceService"; +import { AppError } from "../utils/AppError"; +import logger from "../utils/logger"; + +interface PathAsset { + asset_type: string; + asset_code?: string; + asset_issuer?: string; +} + +interface PathPaymentResult { + transactionHash: string; + sourceAmount: string; + destinationAmount: string; + path: PathAsset[]; + fee: string; + timestamp: Date; +} + +interface PaymentEstimate { + estimatedDestAmount: string; + rate: string; + path: PathAsset[]; + fees: string; +} + +interface AssetPair { + sourceAsset: string; + sourceIssuer: string | null; + destinationAsset: string; + destinationIssuer: string | null; + minAmount: string; + maxAmount: string | null; +} + +export class MultiAssetPaymentService { + private horizon: Horizon.Server; + private networkPassphrase: string; + private assetConfigService: AssetConfigurationService; + private assetPriceService: AssetPriceService; + + constructor() { + const horizonUrl = + process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"; + this.horizon = new Horizon.Server(horizonUrl); + this.networkPassphrase = + process.env.STELLAR_NETWORK === "PUBLIC" + ? Networks.PUBLIC + : Networks.TESTNET; + this.assetConfigService = new AssetConfigurationService(); + this.assetPriceService = new AssetPriceService(); + } + + private createAsset(code: string, issuer?: string): Asset { + if (code === "XLM" || !issuer) { + return Asset.native(); + } + return new Asset(code, issuer); + } + + async executePathPayment(data: { + merchantId: string; + sourceAddress: string; + destinationAddress: string; + sourceAssetCode: string; + sourceAssetIssuer?: string; + destAssetCode: string; + destAssetIssuer?: string; + amount: string; + sourceSecret: string; + memo?: string; + }): Promise { + try { + const isSupported = await this.assetConfigService.isAssetSupported( + data.merchantId, + data.sourceAssetCode, + data.sourceAssetIssuer || null, + ); + + if (!isSupported) { + throw new AppError("Source asset not supported by merchant", 400); + } + + const isValidAmount = await this.assetConfigService.validatePaymentAmount( + data.merchantId, + data.sourceAssetCode, + data.sourceAssetIssuer || null, + data.amount, + ); + + if (!isValidAmount) { + throw new AppError("Payment amount outside configured limits", 400); + } + + const bestPath = await this.assetPriceService.findBestPath( + data.sourceAssetCode, + data.sourceAssetIssuer, + data.destAssetCode, + data.destAssetIssuer, + data.amount, + ); + + if (!bestPath) { + throw new AppError("No payment path available", 404); + } + + const sourceKeypair = Keypair.fromSecret(data.sourceSecret); + const sourceAccount = await this.horizon.loadAccount(data.sourceAddress); + + const sourceAsset = this.createAsset( + data.sourceAssetCode, + data.sourceAssetIssuer, + ); + const destAsset = this.createAsset( + data.destAssetCode, + data.destAssetIssuer, + ); + + const txBuilder = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }); + + if (data.memo) { + txBuilder.addMemo(Memo.text(data.memo)); + } + + txBuilder.addOperation( + Operation.pathPaymentStrictSend({ + sendAsset: sourceAsset, + sendAmount: data.amount, + destination: data.destinationAddress, + destAsset: destAsset, + destMin: bestPath.destination_amount, + path: bestPath.path.map((p: PathAsset) => { + if (p.asset_type === "native") { + return Asset.native(); + } + return new Asset(p.asset_code!, p.asset_issuer!); + }), + }), + ); + + txBuilder.setTimeout(180); + const transaction = txBuilder.build(); + transaction.sign(sourceKeypair); + + const result = await this.horizon.submitTransaction(transaction); + + logger.info(`Path payment executed: ${result.hash}`); + + return { + transactionHash: result.hash, + sourceAmount: data.amount, + destinationAmount: bestPath.destination_amount, + path: bestPath.path, + fee: transaction.fee, + timestamp: new Date(), + }; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error executing path payment:", error); + throw new AppError("Failed to execute path payment", 500); + } + } + + async executeWithFallback(data: { + merchantId: string; + sourceAddress: string; + destinationAddress: string; + sourceAssetCode: string; + sourceAssetIssuer?: string; + destAssetCode: string; + destAssetIssuer?: string; + amount: string; + sourceSecret: string; + memo?: string; + }): Promise { + try { + return await this.executePathPayment(data); + } catch (error) { + logger.warn("Primary path payment failed, trying fallback:", error); + + try { + const configs = await this.assetConfigService.getEnabledAssetConfigs( + data.merchantId, + ); + + for (const config of configs) { + if (config.assetCode === data.sourceAssetCode) continue; + + try { + const fallbackResult = await this.executePathPayment({ + ...data, + sourceAssetCode: config.assetCode, + sourceAssetIssuer: config.assetIssuer || undefined, + }); + + logger.info(`Fallback payment succeeded with ${config.assetCode}`); + return fallbackResult; + } catch (fallbackError) { + logger.warn( + `Fallback with ${config.assetCode} failed:`, + fallbackError, + ); + continue; + } + } + + throw new AppError("All payment paths failed", 500); + } catch (fallbackError) { + if (fallbackError instanceof AppError) throw fallbackError; + logger.error("Fallback execution failed:", fallbackError); + throw new AppError("Payment execution failed", 500); + } + } + } + + async estimatePayment(data: { + merchantId: string; + sourceAssetCode: string; + sourceAssetIssuer?: string; + destAssetCode: string; + destAssetIssuer?: string; + amount: string; + }): Promise { + try { + const isSupported = await this.assetConfigService.isAssetSupported( + data.merchantId, + data.sourceAssetCode, + data.sourceAssetIssuer || null, + ); + + if (!isSupported) { + throw new AppError("Source asset not supported", 400); + } + + const quote = await this.assetPriceService.getExchangeRate( + data.sourceAssetCode, + data.sourceAssetIssuer, + data.destAssetCode, + data.destAssetIssuer, + data.amount, + ); + + return { + estimatedDestAmount: quote.destinationAmount, + rate: quote.rate, + path: quote.path, + fees: BASE_FEE, + }; + } catch (error) { + if (error instanceof AppError) throw error; + logger.error("Error estimating payment:", error); + throw new AppError("Failed to estimate payment", 500); + } + } + + async getSupportedAssetPairs(merchantId: string): Promise { + try { + const configs = + await this.assetConfigService.getEnabledAssetConfigs(merchantId); + + const pairs: AssetPair[] = []; + + for (let i = 0; i < configs.length; i++) { + for (let j = 0; j < configs.length; j++) { + if (i === j) continue; + + pairs.push({ + sourceAsset: configs[i].assetCode, + sourceIssuer: configs[i].assetIssuer, + destinationAsset: configs[j].assetCode, + destinationIssuer: configs[j].assetIssuer, + minAmount: configs[i].minAmount, + maxAmount: configs[i].maxAmount, + }); + } + } + + return pairs; + } catch (error) { + logger.error("Error getting supported asset pairs:", error); + throw new AppError("Failed to fetch supported asset pairs", 500); + } + } +} diff --git a/src/services/merchantWebhookQueue.service.ts b/src/services/merchantWebhookQueue.service.ts index 414723d..f2dd405 100644 --- a/src/services/merchantWebhookQueue.service.ts +++ b/src/services/merchantWebhookQueue.service.ts @@ -13,7 +13,8 @@ import { NotificationType, } from "../entities/InAppNotification.entity"; import { WebhookNotificationService } from "./webhookNotification.service"; - +import { MerchantAuthService } from "./merchant.service"; +import { CryptoGeneratorService } from "./cryptoGenerator.service"; interface QueueJobData { merchantWebhook: MerchantWebhook; webhookPayload: WebhookPayload; @@ -61,6 +62,8 @@ export class MerchantWebhookQueueService { removeOnFail: false, // Keep failed jobs for manual retries }, }); + const merchantAuthService = new MerchantAuthService(); + const cryptoGeneratorService = new CryptoGeneratorService(); // Initialize repository for database operations this.merchantWebhookEventRepository = AppDataSource.getRepository( @@ -68,7 +71,10 @@ export class MerchantWebhookQueueService { ); // Create webhook notification service for sending webhooks - this.webhookNotificationService = new WebhookNotificationService(); + this.webhookNotificationService = new WebhookNotificationService( + merchantAuthService, + cryptoGeneratorService, + ); // Set up queue processing and event handling this.setupQueueProcessor(); diff --git a/src/tests/services/AssetConfigurationService.test.ts b/src/tests/services/AssetConfigurationService.test.ts new file mode 100644 index 0000000..a91aa28 --- /dev/null +++ b/src/tests/services/AssetConfigurationService.test.ts @@ -0,0 +1,376 @@ +import { AssetConfigurationService } from "../../services/AssetConfigurationService"; +import AppDataSource from "../../config/db"; + +jest.mock("../../config/db"); +jest.mock("../../utils/logger"); + +describe("AssetConfigurationService", () => { + let service: AssetConfigurationService; + let mockRepo: { + create: jest.Mock; + save: jest.Mock; + findOne: jest.Mock; + find: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(() => { + mockRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + }; + + (AppDataSource.getRepository as jest.Mock).mockReturnValue(mockRepo); + service = new AssetConfigurationService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("createAssetConfig", () => { + it("should create asset configuration successfully", async () => { + const mockConfig = { + id: "config-123", + merchantId: "merchant-123", + assetCode: "USDC", + assetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + isEnabled: true, + minAmount: "1", + maxAmount: "10000", + priority: 1, + autoConvert: false, + }; + + mockRepo.findOne.mockResolvedValue(null); + mockRepo.create.mockReturnValue(mockConfig); + mockRepo.save.mockResolvedValue(mockConfig); + + const result = await service.createAssetConfig({ + merchantId: "merchant-123", + assetCode: "USDC", + assetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + minAmount: "1", + maxAmount: "10000", + priority: 1, + }); + + expect(result).toEqual(mockConfig); + expect(mockRepo.findOne).toHaveBeenCalled(); + expect(mockRepo.create).toHaveBeenCalled(); + expect(mockRepo.save).toHaveBeenCalled(); + }); + + it("should throw error if asset config already exists", async () => { + mockRepo.findOne.mockResolvedValue({ id: "existing-123" }); + + await expect( + service.createAssetConfig({ + merchantId: "merchant-123", + assetCode: "USDC", + assetIssuer: + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }), + ).rejects.toThrow("Asset configuration already exists for this merchant"); + }); + + it("should create config with default values", async () => { + const mockConfig = { + id: "config-123", + merchantId: "merchant-123", + assetCode: "XLM", + assetIssuer: null, + isEnabled: true, + minAmount: "0", + maxAmount: null, + priority: 0, + autoConvert: false, + }; + + mockRepo.findOne.mockResolvedValue(null); + mockRepo.create.mockReturnValue(mockConfig); + mockRepo.save.mockResolvedValue(mockConfig); + + const result = await service.createAssetConfig({ + merchantId: "merchant-123", + assetCode: "XLM", + assetIssuer: null, + }); + + expect(result.isEnabled).toBe(true); + expect(result.minAmount).toBe("0"); + expect(result.priority).toBe(0); + }); + + it("should handle native XLM asset", async () => { + mockRepo.findOne.mockResolvedValue(null); + mockRepo.create.mockReturnValue({ assetCode: "XLM", assetIssuer: null }); + mockRepo.save.mockResolvedValue({ assetCode: "XLM", assetIssuer: null }); + + const result = await service.createAssetConfig({ + merchantId: "merchant-123", + assetCode: "XLM", + assetIssuer: null, + }); + + expect(result.assetCode).toBe("XLM"); + expect(result.assetIssuer).toBeNull(); + }); + }); + + describe("updateAssetConfig", () => { + it("should update asset configuration successfully", async () => { + const existing = { + id: "config-123", + merchantId: "merchant-123", + isEnabled: true, + minAmount: "1", + }; + + const updated = { ...existing, isEnabled: false, minAmount: "5" }; + + mockRepo.findOne.mockResolvedValue(existing); + mockRepo.save.mockResolvedValue(updated); + + const result = await service.updateAssetConfig( + "config-123", + "merchant-123", + { + isEnabled: false, + minAmount: "5", + }, + ); + + expect(result.isEnabled).toBe(false); + expect(result.minAmount).toBe("5"); + }); + + it("should throw error if config not found", async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateAssetConfig("config-123", "merchant-123", { + isEnabled: false, + }), + ).rejects.toThrow("Asset configuration not found"); + }); + + it("should update only provided fields", async () => { + const existing = { + id: "config-123", + merchantId: "merchant-123", + isEnabled: true, + minAmount: "1", + priority: 0, + }; + + mockRepo.findOne.mockResolvedValue(existing); + mockRepo.save.mockResolvedValue({ ...existing, priority: 5 }); + + const result = await service.updateAssetConfig( + "config-123", + "merchant-123", + { + priority: 5, + }, + ); + + expect(result.priority).toBe(5); + expect(mockRepo.save).toHaveBeenCalled(); + }); + }); + + describe("deleteAssetConfig", () => { + it("should delete asset configuration successfully", async () => { + mockRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.deleteAssetConfig("config-123", "merchant-123"); + + expect(mockRepo.delete).toHaveBeenCalledWith({ + id: "config-123", + merchantId: "merchant-123", + }); + }); + + it("should throw error if config not found", async () => { + mockRepo.delete.mockResolvedValue({ affected: 0 }); + + await expect( + service.deleteAssetConfig("config-123", "merchant-123"), + ).rejects.toThrow("Asset configuration not found"); + }); + }); + + describe("getMerchantAssetConfigs", () => { + it("should return all configs for merchant", async () => { + const configs = [ + { id: "config-1", assetCode: "USDC", priority: 2 }, + { id: "config-2", assetCode: "XLM", priority: 1 }, + ]; + + mockRepo.find.mockResolvedValue(configs); + + const result = await service.getMerchantAssetConfigs("merchant-123"); + + expect(result).toEqual(configs); + expect(mockRepo.find).toHaveBeenCalledWith({ + where: { merchantId: "merchant-123" }, + order: { priority: "DESC", createdAt: "ASC" }, + }); + }); + + it("should return empty array if no configs", async () => { + mockRepo.find.mockResolvedValue([]); + + const result = await service.getMerchantAssetConfigs("merchant-123"); + + expect(result).toEqual([]); + }); + }); + + describe("getEnabledAssetConfigs", () => { + it("should return only enabled configs", async () => { + const configs = [ + { id: "config-1", assetCode: "USDC", isEnabled: true }, + { id: "config-2", assetCode: "XLM", isEnabled: true }, + ]; + + mockRepo.find.mockResolvedValue(configs); + + const result = await service.getEnabledAssetConfigs("merchant-123"); + + expect(result).toEqual(configs); + expect(mockRepo.find).toHaveBeenCalledWith({ + where: { merchantId: "merchant-123", isEnabled: true }, + order: { priority: "DESC", createdAt: "ASC" }, + }); + }); + }); + + describe("isAssetSupported", () => { + it("should return true if asset is supported and enabled", async () => { + mockRepo.findOne.mockResolvedValue({ id: "config-123", isEnabled: true }); + + const result = await service.isAssetSupported( + "merchant-123", + "USDC", + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + ); + + expect(result).toBe(true); + }); + + it("should return false if asset not found", async () => { + mockRepo.findOne.mockResolvedValue(null); + + const result = await service.isAssetSupported( + "merchant-123", + "USDC", + "issuer-123", + ); + + expect(result).toBe(false); + }); + + it("should handle native XLM", async () => { + mockRepo.findOne.mockResolvedValue({ + id: "config-123", + assetCode: "XLM", + }); + + const result = await service.isAssetSupported( + "merchant-123", + "XLM", + null, + ); + + expect(result).toBe(true); + }); + }); + + describe("validatePaymentAmount", () => { + it("should return true for valid amount", async () => { + mockRepo.findOne.mockResolvedValue({ + minAmount: "1", + maxAmount: "1000", + isEnabled: true, + }); + + const result = await service.validatePaymentAmount( + "merchant-123", + "USDC", + "issuer-123", + "500", + ); + + expect(result).toBe(true); + }); + + it("should return false if amount below minimum", async () => { + mockRepo.findOne.mockResolvedValue({ + minAmount: "10", + maxAmount: "1000", + isEnabled: true, + }); + + const result = await service.validatePaymentAmount( + "merchant-123", + "USDC", + "issuer-123", + "5", + ); + + expect(result).toBe(false); + }); + + it("should return false if amount above maximum", async () => { + mockRepo.findOne.mockResolvedValue({ + minAmount: "1", + maxAmount: "1000", + isEnabled: true, + }); + + const result = await service.validatePaymentAmount( + "merchant-123", + "USDC", + "issuer-123", + "2000", + ); + + expect(result).toBe(false); + }); + + it("should return true if no maximum set", async () => { + mockRepo.findOne.mockResolvedValue({ + minAmount: "1", + maxAmount: null, + isEnabled: true, + }); + + const result = await service.validatePaymentAmount( + "merchant-123", + "USDC", + "issuer-123", + "999999", + ); + + expect(result).toBe(true); + }); + + it("should return false if asset not found", async () => { + mockRepo.findOne.mockResolvedValue(null); + + const result = await service.validatePaymentAmount( + "merchant-123", + "USDC", + "issuer-123", + "100", + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/tests/services/AssetPriceService.test.ts b/src/tests/services/AssetPriceService.test.ts new file mode 100644 index 0000000..701a208 --- /dev/null +++ b/src/tests/services/AssetPriceService.test.ts @@ -0,0 +1,373 @@ +import { AssetPriceService } from "../../services/AssetPriceService"; +import { Horizon } from "@stellar/stellar-sdk"; + +jest.mock("@stellar/stellar-sdk"); +jest.mock("../../utils/logger"); + +describe("AssetPriceService", () => { + let service: AssetPriceService; + let mockHorizon: { + strictSendPaths: jest.Mock; + loadAccount: jest.Mock; + orderbook: jest.Mock; + }; + + beforeEach(() => { + mockHorizon = { + strictSendPaths: jest.fn(), + loadAccount: jest.fn(), + orderbook: jest.fn(), + }; + + ( + Horizon.Server as jest.MockedClass + ).mockImplementation(() => mockHorizon as unknown as Horizon.Server); + service = new AssetPriceService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getExchangeRate", () => { + it("should get exchange rate successfully", async () => { + const mockPathResponse = { + records: [ + { + destination_amount: "95.5", + path: [], + }, + ], + }; + + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockPathResponse), + }); + + const result = await service.getExchangeRate( + "USDC", + "issuer-123", + "XLM", + undefined, + "100", + ); + + expect(result).toMatchObject({ + sourceAsset: "USDC", + destinationAsset: "XLM", + sourceAmount: "100", + destinationAmount: "95.5", + }); + expect(parseFloat(result.rate)).toBeCloseTo(0.955, 3); + }); + + it("should throw error if no path found", async () => { + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue({ records: [] }), + }); + + await expect( + service.getExchangeRate( + "USDC", + "issuer-123", + "UNKNOWN", + "issuer-456", + "100", + ), + ).rejects.toThrow("No payment path found between these assets"); + }); + + it("should handle native XLM as source", async () => { + const mockPathResponse = { + records: [ + { + destination_amount: "100", + path: [], + }, + ], + }; + + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockPathResponse), + }); + + const result = await service.getExchangeRate( + "XLM", + undefined, + "USDC", + "issuer-123", + "100", + ); + + expect(result.sourceAsset).toBe("XLM"); + expect(result.destinationAsset).toBe("USDC"); + }); + + it("should handle native XLM as destination", async () => { + const mockPathResponse = { + records: [ + { + destination_amount: "100", + path: [], + }, + ], + }; + + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockPathResponse), + }); + + const result = await service.getExchangeRate( + "USDC", + "issuer-123", + "XLM", + undefined, + "100", + ); + + expect(result.destinationAsset).toBe("XLM"); + }); + + it("should include timestamp in response", async () => { + const mockPathResponse = { + records: [ + { + destination_amount: "100", + path: [], + }, + ], + }; + + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockPathResponse), + }); + + const result = await service.getExchangeRate( + "USDC", + "issuer-123", + "XLM", + undefined, + "100", + ); + + expect(result.timestamp).toBeInstanceOf(Date); + }); + }); + + describe("findBestPath", () => { + it("should find best path successfully", async () => { + const mockPathResponse = { + records: [ + { + destination_amount: "95.5", + path: [{ asset_code: "EUR", asset_issuer: "issuer-eur" }], + }, + ], + }; + + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockPathResponse), + }); + + const result = await service.findBestPath( + "USDC", + "issuer-123", + "XLM", + undefined, + "100", + ); + + expect(result).toEqual(mockPathResponse.records[0]); + }); + + it("should return null if no path found", async () => { + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue({ records: [] }), + }); + + const result = await service.findBestPath( + "USDC", + "issuer-123", + "UNKNOWN", + "issuer-456", + "100", + ); + + expect(result).toBeNull(); + }); + + it("should return null on error", async () => { + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockRejectedValue(new Error("Network error")), + }); + + const result = await service.findBestPath( + "USDC", + "issuer-123", + "XLM", + undefined, + "100", + ); + + expect(result).toBeNull(); + }); + }); + + describe("getMultiplePrices", () => { + it("should get prices for multiple destinations", async () => { + const mockPathResponse = { + records: [ + { + destination_amount: "95.5", + path: [], + }, + ], + }; + + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockPathResponse), + }); + + const destinations = [ + { code: "XLM" }, + { code: "EUR", issuer: "issuer-eur" }, + ]; + + const results = await service.getMultiplePrices( + "USDC", + "issuer-123", + destinations, + "100", + ); + + expect(results).toHaveLength(2); + expect(results[0].destinationAsset).toBe("XLM"); + expect(results[1].destinationAsset).toBe("EUR"); + }); + + it("should skip failed price fetches", async () => { + mockHorizon.strictSendPaths.mockReturnValueOnce({ + call: jest.fn().mockResolvedValue({ + records: [{ destination_amount: "95.5", path: [] }], + }), + }); + + mockHorizon.strictSendPaths.mockReturnValueOnce({ + call: jest.fn().mockRejectedValue(new Error("Failed")), + }); + + const destinations = [ + { code: "XLM" }, + { code: "UNKNOWN", issuer: "bad-issuer" }, + ]; + + const results = await service.getMultiplePrices( + "USDC", + "issuer-123", + destinations, + "100", + ); + + expect(results).toHaveLength(1); + expect(results[0].destinationAsset).toBe("XLM"); + }); + + it("should return empty array if all fail", async () => { + mockHorizon.strictSendPaths.mockReturnValue({ + call: jest.fn().mockRejectedValue(new Error("Failed")), + }); + + const destinations = [{ code: "UNKNOWN", issuer: "bad-issuer" }]; + + const results = await service.getMultiplePrices( + "USDC", + "issuer-123", + destinations, + "100", + ); + + expect(results).toEqual([]); + }); + }); + + describe("validateAssetExists", () => { + it("should return true for XLM", async () => { + const result = await service.validateAssetExists("XLM"); + + expect(result).toBe(true); + expect(mockHorizon.loadAccount).not.toHaveBeenCalled(); + }); + + it("should return true for valid asset", async () => { + mockHorizon.loadAccount.mockResolvedValue({ id: "issuer-123" }); + + const result = await service.validateAssetExists("USDC", "issuer-123"); + + expect(result).toBe(true); + expect(mockHorizon.loadAccount).toHaveBeenCalledWith("issuer-123"); + }); + + it("should return false for invalid issuer", async () => { + mockHorizon.loadAccount.mockRejectedValue(new Error("Not found")); + + const result = await service.validateAssetExists("USDC", "bad-issuer"); + + expect(result).toBe(false); + }); + + it("should return false if no issuer provided for non-XLM", async () => { + const result = await service.validateAssetExists("USDC"); + + expect(result).toBe(false); + }); + }); + + describe("getOrderbook", () => { + it("should get orderbook successfully", async () => { + const mockOrderbook = { + bids: [ + { price: "1.05", amount: "100" }, + { price: "1.04", amount: "200" }, + { price: "1.03", amount: "150" }, + { price: "1.02", amount: "300" }, + { price: "1.01", amount: "250" }, + { price: "1.00", amount: "400" }, + ], + asks: [ + { price: "1.06", amount: "80" }, + { price: "1.07", amount: "120" }, + { price: "1.08", amount: "200" }, + { price: "1.09", amount: "150" }, + { price: "1.10", amount: "300" }, + { price: "1.11", amount: "250" }, + ], + }; + + mockHorizon.orderbook.mockReturnValue({ + call: jest.fn().mockResolvedValue(mockOrderbook), + }); + + const result = await service.getOrderbook( + "USDC", + "issuer-123", + "XLM", + undefined, + ); + + expect(result.bids).toHaveLength(5); + expect(result.asks).toHaveLength(5); + expect(result.source.code).toBe("USDC"); + expect(result.destination.code).toBe("XLM"); + }); + + it("should throw error on failure", async () => { + mockHorizon.orderbook.mockReturnValue({ + call: jest.fn().mockRejectedValue(new Error("Network error")), + }); + + await expect( + service.getOrderbook("USDC", "issuer-123", "XLM", undefined), + ).rejects.toThrow("Failed to fetch orderbook"); + }); + }); +}); diff --git a/src/tests/services/MultiAssetPaymentService.test.ts b/src/tests/services/MultiAssetPaymentService.test.ts new file mode 100644 index 0000000..150d6de --- /dev/null +++ b/src/tests/services/MultiAssetPaymentService.test.ts @@ -0,0 +1,415 @@ +import { MultiAssetPaymentService } from "../../services/MultiAssetPaymentService"; +import { AssetConfigurationService } from "../../services/AssetConfigurationService"; +import { AssetPriceService } from "../../services/AssetPriceService"; +import { Horizon, Keypair, TransactionBuilder } from "@stellar/stellar-sdk"; + +jest.mock("@stellar/stellar-sdk"); +jest.mock("../../services/AssetConfigurationService"); +jest.mock("../../services/AssetPriceService"); +jest.mock("../../utils/logger"); + +describe("MultiAssetPaymentService", () => { + let service: MultiAssetPaymentService; + let mockHorizon: { + loadAccount: jest.Mock; + submitTransaction: jest.Mock; + }; + let mockAssetConfigService: jest.Mocked; + let mockAssetPriceService: jest.Mocked; + + beforeEach(() => { + mockHorizon = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + ( + Horizon.Server as jest.MockedClass + ).mockImplementation(() => mockHorizon as unknown as Horizon.Server); + + // Mock TransactionBuilder with proper chaining + const mockTransaction = { + sign: jest.fn(), + fee: "100", + }; + + const mockBuilder = { + addOperation: jest.fn().mockReturnThis(), + addMemo: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue(mockTransaction), + }; + + ( + TransactionBuilder as jest.MockedClass + ).mockImplementation(() => mockBuilder as unknown as TransactionBuilder); + + mockAssetConfigService = + new AssetConfigurationService() as jest.Mocked; + mockAssetPriceService = + new AssetPriceService() as jest.Mocked; + + service = new MultiAssetPaymentService(); + Object.defineProperty(service, "assetConfigService", { + value: mockAssetConfigService, + writable: true, + }); + Object.defineProperty(service, "assetPriceService", { + value: mockAssetPriceService, + writable: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("executePathPayment", () => { + const mockPaymentData = { + merchantId: "merchant-123", + sourceAddress: "GDSOURCE123", + destinationAddress: "GDDEST456", + sourceAssetCode: "USDC", + sourceAssetIssuer: + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + destAssetCode: "XLM", + amount: "100", + sourceSecret: "SBSECRET123", + }; + + it("should execute path payment successfully", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(true); + mockAssetPriceService.findBestPath.mockResolvedValue({ + destination_amount: "95.5", + path: [], + }); + + const mockAccount = { + sequenceNumber: jest.fn().mockReturnValue("1"), + incrementSequenceNumber: jest.fn(), + }; + + mockHorizon.loadAccount.mockResolvedValue(mockAccount); + mockHorizon.submitTransaction.mockResolvedValue({ + hash: "tx-hash-123", + }); + + (Keypair.fromSecret as jest.Mock).mockReturnValue({ + publicKey: jest.fn().mockReturnValue("GDSOURCE123"), + }); + + const result = await service.executePathPayment(mockPaymentData); + + expect(result).toMatchObject({ + transactionHash: "tx-hash-123", + sourceAmount: "100", + destinationAmount: "95.5", + }); + expect(mockAssetConfigService.isAssetSupported).toHaveBeenCalled(); + expect(mockAssetConfigService.validatePaymentAmount).toHaveBeenCalled(); + }); + + it("should throw error if asset not supported", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(false); + + await expect(service.executePathPayment(mockPaymentData)).rejects.toThrow( + "Source asset not supported by merchant", + ); + }); + + it("should throw error if amount invalid", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(false); + + await expect(service.executePathPayment(mockPaymentData)).rejects.toThrow( + "Payment amount outside configured limits", + ); + }); + + it("should throw error if no path available", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(true); + mockAssetPriceService.findBestPath.mockResolvedValue(null); + + await expect(service.executePathPayment(mockPaymentData)).rejects.toThrow( + "No payment path available", + ); + }); + + it("should handle path with intermediary assets", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(true); + mockAssetPriceService.findBestPath.mockResolvedValue({ + destination_amount: "95.5", + path: [ + { + asset_type: "credit_alphanum4", + asset_code: "EUR", + asset_issuer: "issuer-eur", + }, + ], + }); + + const mockAccount = { + sequenceNumber: jest.fn().mockReturnValue("1"), + incrementSequenceNumber: jest.fn(), + }; + + mockHorizon.loadAccount.mockResolvedValue(mockAccount); + mockHorizon.submitTransaction.mockResolvedValue({ hash: "tx-hash-123" }); + + (Keypair.fromSecret as jest.Mock).mockReturnValue({ + publicKey: jest.fn().mockReturnValue("GDSOURCE123"), + }); + + const result = await service.executePathPayment(mockPaymentData); + + expect(result.path).toHaveLength(1); + }); + + it("should include memo if provided", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(true); + mockAssetPriceService.findBestPath.mockResolvedValue({ + destination_amount: "95.5", + path: [], + }); + + const mockAccount = { + sequenceNumber: jest.fn().mockReturnValue("1"), + incrementSequenceNumber: jest.fn(), + }; + + mockHorizon.loadAccount.mockResolvedValue(mockAccount); + mockHorizon.submitTransaction.mockResolvedValue({ hash: "tx-hash-123" }); + + (Keypair.fromSecret as jest.Mock).mockReturnValue({ + publicKey: jest.fn().mockReturnValue("GDSOURCE123"), + }); + + await service.executePathPayment({ + ...mockPaymentData, + memo: "Payment for order #123", + }); + + expect(mockHorizon.submitTransaction).toHaveBeenCalled(); + }); + }); + + describe("executeWithFallback", () => { + const mockPaymentData = { + merchantId: "merchant-123", + sourceAddress: "GDSOURCE123", + destinationAddress: "GDDEST456", + sourceAssetCode: "USDC", + sourceAssetIssuer: + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + destAssetCode: "XLM", + amount: "100", + sourceSecret: "SBSECRET123", + }; + + it("should execute primary payment if successful", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(true); + mockAssetPriceService.findBestPath.mockResolvedValue({ + destination_amount: "95.5", + path: [], + }); + + const mockAccount = { + sequenceNumber: jest.fn().mockReturnValue("1"), + incrementSequenceNumber: jest.fn(), + }; + + mockHorizon.loadAccount.mockResolvedValue(mockAccount); + mockHorizon.submitTransaction.mockResolvedValue({ hash: "tx-hash-123" }); + + (Keypair.fromSecret as jest.Mock).mockReturnValue({ + publicKey: jest.fn().mockReturnValue("GDSOURCE123"), + }); + + const result = await service.executeWithFallback(mockPaymentData); + + expect(result.transactionHash).toBe("tx-hash-123"); + }); + + it("should try fallback assets if primary fails", async () => { + mockAssetConfigService.isAssetSupported + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(true); + mockAssetPriceService.findBestPath + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + destination_amount: "95.5", + path: [], + }); + + mockAssetConfigService.getEnabledAssetConfigs.mockResolvedValue([ + { + assetCode: "XLM", + assetIssuer: null, + isEnabled: true, + id: "config-1", + merchantId: "merchant-123", + minAmount: "10", + maxAmount: null, + priority: 1, + autoConvert: false, + settlementAssetCode: null, + settlementAssetIssuer: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const mockAccount = { + sequenceNumber: jest.fn().mockReturnValue("1"), + incrementSequenceNumber: jest.fn(), + }; + + mockHorizon.loadAccount.mockResolvedValue(mockAccount); + mockHorizon.submitTransaction.mockResolvedValue({ + hash: "tx-hash-fallback", + }); + + (Keypair.fromSecret as jest.Mock).mockReturnValue({ + publicKey: jest.fn().mockReturnValue("GDSOURCE123"), + }); + + const result = await service.executeWithFallback(mockPaymentData); + + expect(result.transactionHash).toBe("tx-hash-fallback"); + }); + + it("should throw error if all paths fail", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetConfigService.validatePaymentAmount.mockResolvedValue(false); + mockAssetConfigService.getEnabledAssetConfigs.mockResolvedValue([]); + + await expect( + service.executeWithFallback(mockPaymentData), + ).rejects.toThrow("All payment paths failed"); + }); + }); + + describe("estimatePayment", () => { + it("should estimate payment successfully", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(true); + mockAssetPriceService.getExchangeRate.mockResolvedValue({ + destinationAmount: "95.5", + rate: "0.955", + path: [], + sourceAsset: "USDC", + destinationAsset: "XLM", + sourceAmount: "100", + timestamp: new Date(), + }); + + const result = await service.estimatePayment({ + merchantId: "merchant-123", + sourceAssetCode: "USDC", + sourceAssetIssuer: "issuer-123", + destAssetCode: "XLM", + amount: "100", + }); + + expect(result).toMatchObject({ + estimatedDestAmount: "95.5", + rate: "0.955", + }); + }); + + it("should throw error if asset not supported", async () => { + mockAssetConfigService.isAssetSupported.mockResolvedValue(false); + + await expect( + service.estimatePayment({ + merchantId: "merchant-123", + sourceAssetCode: "UNKNOWN", + destAssetCode: "XLM", + amount: "100", + }), + ).rejects.toThrow("Source asset not supported"); + }); + }); + + describe("getSupportedAssetPairs", () => { + it("should return all possible asset pairs", async () => { + mockAssetConfigService.getEnabledAssetConfigs.mockResolvedValue([ + { + id: "config-1", + merchantId: "merchant-123", + assetCode: "USDC", + assetIssuer: "issuer-123", + isEnabled: true, + minAmount: "1", + maxAmount: "10000", + priority: 1, + autoConvert: false, + settlementAssetCode: null, + settlementAssetIssuer: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "config-2", + merchantId: "merchant-123", + assetCode: "XLM", + assetIssuer: null, + isEnabled: true, + minAmount: "10", + maxAmount: null, + priority: 2, + autoConvert: false, + settlementAssetCode: null, + settlementAssetIssuer: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const result = await service.getSupportedAssetPairs("merchant-123"); + + expect(result).toHaveLength(2); + expect(result[0].sourceAsset).toBe("USDC"); + expect(result[0].destinationAsset).toBe("XLM"); + expect(result[1].sourceAsset).toBe("XLM"); + expect(result[1].destinationAsset).toBe("USDC"); + }); + + it("should return empty array if no configs", async () => { + mockAssetConfigService.getEnabledAssetConfigs.mockResolvedValue([]); + + const result = await service.getSupportedAssetPairs("merchant-123"); + + expect(result).toEqual([]); + }); + + it("should handle single asset config", async () => { + mockAssetConfigService.getEnabledAssetConfigs.mockResolvedValue([ + { + id: "config-1", + merchantId: "merchant-123", + assetCode: "USDC", + assetIssuer: "issuer-123", + isEnabled: true, + minAmount: "1", + maxAmount: "10000", + priority: 1, + autoConvert: false, + settlementAssetCode: null, + settlementAssetIssuer: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const result = await service.getSupportedAssetPairs("merchant-123"); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 01e5cd3..27db22f 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -43,3 +43,5 @@ declare module "express-serve-static-core" { }; } } + +export {};