diff --git a/package.json b/package.json index 449a3f7b..849bc26b 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "*.{js,cjs,mjs,json,md,yml,yaml}": "prettier --write" }, "keywords": [], - "author": "", + "author": "weare", "license": "MIT", "dependencies": { "@apollo/client": "^4.2.1", diff --git a/src/queue/rabbitmq.ts b/src/queue/rabbitmq.ts index 1738f898..1c9256d7 100644 --- a/src/queue/rabbitmq.ts +++ b/src/queue/rabbitmq.ts @@ -15,6 +15,7 @@ export const ROUTING_KEYS = { export const QUEUES = { TRANSACTION_PROCESSING: "transaction-processing-queue", + TRANSACTION_REPROCESSING: "transaction-reprocessing-queue", }; class RabbitMQManager { @@ -37,6 +38,12 @@ class RabbitMQManager { EXCHANGES.TRANSACTIONS, ROUTING_KEYS.TRANSACTION_PROCESS ), + channel.assertQueue(QUEUES.TRANSACTION_REPROCESSING, { durable: true }), + channel.bindQueue( + QUEUES.TRANSACTION_REPROCESSING, + EXCHANGES.TRANSACTIONS, + ROUTING_KEYS.TRANSACTION_PROCESS + ), ]); }, }); diff --git a/src/queue/reprocessingQueue.ts b/src/queue/reprocessingQueue.ts new file mode 100644 index 00000000..3a2dac6b --- /dev/null +++ b/src/queue/reprocessingQueue.ts @@ -0,0 +1,44 @@ +import { rabbitMQManager, EXCHANGES, ROUTING_KEYS, QUEUES } from "./rabbitmq"; +import { reprocessingService, ReprocessingJob } from "../services/reprocessingService"; +import logger from "../utils/logger"; + +export const REPROCESSING_QUEUE_NAME = "transaction-reprocessing-queue"; + +export async function startReprocessingWorker(): Promise { + await rabbitMQManager.consume( + REPROCESSING_QUEUE_NAME, + async (job) => { + try { + logger.info({ jobId: job.id, transactionId: job.transactionId }, "[reprocessing] Processing job"); + const result = await reprocessingService.processJob(job); + logger.info({ jobId: job.id, success: result.success }, "[reprocessing] Job processed"); + } catch (error) { + logger.error({ error, jobId: job.id }, "[reprocessing] Worker failed to process job"); + } + }, + 3, + ); +} + +export async function scheduleReprocessingPoller(intervalMs = 30000): Promise { + const poll = async () => { + try { + const pendingJobs = await reprocessingService.getPendingJobs(50); + for (const job of pendingJobs) { + await rabbitMQManager.publish(EXCHANGES.TRANSACTIONS, ROUTING_KEYS.TRANSACTION_PROCESS, { + type: "reprocessing", + jobId: job.id, + transactionId: job.transactionId, + provider: job.provider, + attemptNumber: job.attemptNumber, + scheduledAt: job.scheduledAt, + }); + } + } catch (error) { + logger.error({ error }, "[reprocessing] Poller failed"); + } + }; + + await poll(); + setInterval(poll, intervalMs); +} diff --git a/src/routes/admin/assets.ts b/src/routes/admin/assets.ts index 3954b0d3..f4378578 100644 --- a/src/routes/admin/assets.ts +++ b/src/routes/admin/assets.ts @@ -1,9 +1,15 @@ import { Router } from "express"; import { AssetWizardController } from "../../controllers/admin/assetWizardController"; +import { assetWorkflowService } from "../../services/assetWorkflowService"; +import { requireAdmin, logAdminAction } from "../admin"; +import { createError, ERROR_CODES } from "../../middleware/errorHandler"; const router = Router(); const controller = new AssetWizardController(); +router.use(requireAdmin); +router.use(logAdminAction("ASSET_ADMIN")); + /** * @openapi * /api/admin/assets: @@ -40,4 +46,191 @@ router.get("/", controller.listAssets); */ router.post("/issue", controller.issueAsset); +/** + * @openapi + * /api/admin/assets/workflow/requests: + * post: + * summary: Create asset issuance request + * tags: [Admin, Assets] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [assetCode, name, limit, requestedBy] + * properties: + * assetCode: { type: string } + * name: { type: string } + * description: { type: string } + * limit: { type: string } + * requestedBy: { type: string } + * responses: + * 201: + * description: Request created + */ +router.post("/workflow/requests", async (req, res) => { + try { + const { assetCode, name, description, limit, requestedBy, trustlineConfig } = req.body; + const request = await assetWorkflowService.createRequest({ assetCode, name, description, limit, requestedBy, trustlineConfig }); + res.status(201).json({ success: true, data: request }); + } catch (error) { + throw createError(ERROR_CODES.INVALID_INPUT, error instanceof Error ? error.message : "Failed to create request"); + } +}); + +/** + * @openapi + * /api/admin/assets/workflow/requests: + * get: + * summary: List asset issuance requests + * tags: [Admin, Assets] + * responses: + * 200: + * description: List of requests + */ +router.get("/workflow/requests", async (req, res) => { + try { + const { status } = req.query; + const requests = await assetWorkflowService["requestModel"].findAll(status as any); + res.json({ success: true, data: requests }); + } catch (error) { + throw createError(ERROR_CODES.INTERNAL_ERROR, "Failed to fetch requests"); + } +}); + +/** + * @openapi + * /api/admin/assets/workflow/requests/{id}/submit: + * post: + * summary: Submit request for approval + * tags: [Admin, Assets] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Request submitted + */ +router.post("/workflow/requests/:id/submit", async (req, res) => { + try { + const request = await assetWorkflowService.submitForApproval(req.params.id); + res.json({ success: true, data: request }); + } catch (error) { + throw createError(ERROR_CODES.INVALID_INPUT, error instanceof Error ? error.message : "Failed to submit request"); + } +}); + +/** + * @openapi + * /api/admin/assets/workflow/requests/{id}/approve: + * post: + * summary: Approve or reject request + * tags: [Admin, Assets] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [action, approverId] + * properties: + * action: + * type: string + * enum: [approve, reject, request_changes] + * approverId: + * type: string + * notes: + * type: string + * responses: + * 200: + * description: Request updated + */ +router.post("/workflow/requests/:id/approve", async (req, res) => { + try { + const { action, approverId, notes } = req.body; + const request = await assetWorkflowService.approveRequest(req.params.id, approverId, action, notes); + res.json({ success: true, data: request }); + } catch (error) { + throw createError(ERROR_CODES.INVALID_INPUT, error instanceof Error ? error.message : "Failed to process approval"); + } +}); + +/** + * @openapi + * /api/admin/assets/workflow/requests/{id}/trustline: + * post: + * summary: Configure trustline for asset + * tags: [Admin, Assets] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [destinationAccount, limit] + * properties: + * destinationAccount: + * type: string + * limit: + * type: string + * autoSetup: + * type: boolean + * responses: + * 200: + * description: Trustline configured + */ +router.post("/workflow/requests/:id/trustline", async (req, res) => { + try { + const { destinationAccount, limit, autoSetup } = req.body; + const request = await assetWorkflowService.configureTrustline(req.params.id, { destinationAccount, limit, autoSetup }); + res.json({ success: true, data: request }); + } catch (error) { + throw createError(ERROR_CODES.INVALID_INPUT, error instanceof Error ? error.message : "Failed to configure trustline"); + } +}); + +/** + * @openapi + * /api/admin/assets/workflow/validate: + * post: + * summary: Validate asset configuration + * tags: [Admin, Assets] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [assetCode, name, limit] + * responses: + * 200: + * description: Validation result + */ +router.post("/workflow/validate", async (req, res) => { + try { + const { assetCode, name, limit } = req.body; + const validation = assetWorkflowService.validateConfiguration({ assetCode, name, limit }); + res.json({ success: true, data: validation }); + } catch (error) { + throw createError(ERROR_CODES.INVALID_INPUT, "Validation failed"); + } +}); + export default router; + diff --git a/src/services/__tests__/assetWorkflow.test.ts b/src/services/__tests__/assetWorkflow.test.ts new file mode 100644 index 00000000..dbb9c3f4 --- /dev/null +++ b/src/services/__tests__/assetWorkflow.test.ts @@ -0,0 +1,153 @@ +import { assetWorkflowService, AssetIssuanceRequestModel } from "../services/assetWorkflowService"; +import { queryRead, queryWrite } from "../../config/database"; +import { AssetIssuanceService } from "../../services/stellar/issuanceService"; + +jest.mock("../../config/database"); +jest.mock("../../services/stellar/issuanceService"); + +describe("AssetWorkflowService", () => { + let model: AssetIssuanceRequestModel; + + beforeEach(() => { + jest.clearAllMocks(); + model = new AssetIssuanceRequestModel(); + }); + + describe("AssetIssuanceRequestModel", () => { + it("should create a request", async () => { + (queryRead as jest.Mock).mockResolvedValue({ rows: [] }); + (queryWrite as jest.Mock).mockResolvedValue({ + rows: [ + { + id: "req-1", + asset_code: "USD", + name: "USD Coin", + description: "Test", + limit: "1000000", + status: "draft", + requested_by: "user-1", + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + }, + ], + }); + + const request = await model.create({ + assetCode: "USD", + name: "USD Coin", + description: "Test", + limit: "1000000", + requestedBy: "user-1", + }); + + expect(request.assetCode).toBe("USD"); + expect(request.status).toBe("draft"); + }); + + it("should throw if asset code already exists", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [{ id: "req-existing", asset_code: "USD" }], + }); + + await expect( + model.create({ + assetCode: "USD", + name: "USD Coin", + limit: "1000000", + requestedBy: "user-1", + }), + ).rejects.toThrow("already exists"); + }); + }); + + describe("validateConfiguration", () => { + it("should validate correct configuration", () => { + const result = assetWorkflowService.validateConfiguration({ assetCode: "USD", name: "USD Coin", limit: "1000000" }); + expect(result.isValid).toBe(true); + }); + + it("should reject invalid asset code", () => { + const result = assetWorkflowService.validateConfiguration({ assetCode: "", name: "USD Coin", limit: "1000000" }); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes("1 and 12"))).toBe(true); + }); + + it("should reject invalid limit", () => { + const result = assetWorkflowService.validateConfiguration({ assetCode: "USD", name: "USD Coin", limit: "-1" }); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes("positive number"))).toBe(true); + }); + }); + + describe("submitForApproval", () => { + it("should submit draft request for approval", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [ + { + id: "req-1", + asset_code: "USD", + name: "USD Coin", + limit: "1000000", + status: "draft", + requested_by: "user-1", + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + }, + ], + }); + (queryWrite as jest.Mock).mockResolvedValue({ rows: [] }); + + const request = await assetWorkflowService.submitForApproval("req-1"); + expect(request.status).toBe("pending_approval"); + }); + + it("should throw if request is not in draft", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [ + { + id: "req-1", + asset_code: "USD", + name: "USD Coin", + limit: "1000000", + status: "pending_approval", + requested_by: "user-1", + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + }, + ], + }); + + await expect(assetWorkflowService.submitForApproval("req-1")).rejects.toThrow("Cannot submit request"); + }); + }); + + describe("approveRequest", () => { + it("should approve a pending request", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [ + { + id: "req-1", + asset_code: "USD", + name: "USD Coin", + limit: "1000000", + status: "pending_approval", + requested_by: "user-1", + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + }, + ], + }); + (queryWrite as jest.Mock).mockResolvedValue({ rows: [] }); + (AssetIssuanceService as jest.MockedClass).mockImplementation(() => ({ + setupAnchoredAsset: jest.fn().mockResolvedValue({ assetCode: "USD", issuerPublicKey: "G...", distributionPublicKey: "G..." }), + } as any)); + + const request = await assetWorkflowService.approveRequest("req-1", "admin-1", "approve", "Looks good"); + expect(request.status).toBe("approved"); + }); + }); +}); diff --git a/src/services/__tests__/csvValidation.test.ts b/src/services/__tests__/csvValidation.test.ts new file mode 100644 index 00000000..269137f8 --- /dev/null +++ b/src/services/__tests__/csvValidation.test.ts @@ -0,0 +1,115 @@ +import { + validateCSVSchema, + previewCSVImport, + rollbackCSVImport, + parseCSV, + reconcileTransactions, +} from "../csvReconciliation"; +import { queryRead, queryWrite } from "../../config/database"; + +jest.mock("../../config/database"); + +describe("CSV Validation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("validateCSVSchema", () => { + it("should validate correct CSV data", () => { + const rows = [ + { reference_number: "TXN-001", amount: "100.50", status: "completed", phone_number: "+1234567890", provider: "mtn" }, + { reference_number: "TXN-002", amount: "200.00", status: "pending", phone_number: "+0987654321", provider: "airtel" }, + ]; + + const result = validateCSVSchema(rows); + expect(result.isValid).toBe(true); + expect(result.summary.validRows).toBe(2); + expect(result.summary.errorRows).toBe(0); + }); + + it("should detect missing required fields", () => { + const rows = [ + { reference_number: "TXN-001", amount: "100.50", status: "completed", phone_number: "+1234567890" }, + ]; + + const result = validateCSVSchema(rows); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.field === "provider")).toBe(true); + }); + + it("should detect invalid amount format", () => { + const rows = [ + { reference_number: "TXN-001", amount: "not-a-number", status: "completed", phone_number: "+1234567890", provider: "mtn" }, + ]; + + const result = validateCSVSchema(rows); + expect(result.errors.some((e) => e.field === "amount")).toBe(true); + }); + + it("should warn on non-standard status", () => { + const rows = [ + { reference_number: "TXN-001", amount: "100.50", status: "unknown_status", phone_number: "+1234567890", provider: "mtn" }, + ]; + + const result = validateCSVSchema(rows); + expect(result.warnings.some((w) => w.field === "status")).toBe(true); + }); + + it("should handle empty rows array", () => { + const result = validateCSVSchema([]); + expect(result.isValid).toBe(true); + expect(result.summary.totalRows).toBe(0); + }); + }); + + describe("previewCSVImport", () => { + it("should return preview with validation", async () => { + (queryRead as jest.Mock).mockResolvedValue({ rows: [] }); + + const csvContent = `reference_number,amount,status,phone_number,provider\nTXN-001,100.50,completed,+1234567890,mtn`; + const buffer = Buffer.from(csvContent); + + const preview = await previewCSVImport(buffer); + expect(preview.validation.isValid).toBe(true); + expect(preview.estimatedChanges.matched).toBe(0); + }); + + it("should skip reconciliation when validation fails", async () => { + (queryRead as jest.Mock).mockResolvedValue({ rows: [] }); + + const csvContent = `reference_number,amount,status,phone_number\nTXN-001,100.50,completed,+1234567890`; + const buffer = Buffer.from(csvContent); + + const preview = await previewCSVImport(buffer); + expect(preview.validation.isValid).toBe(false); + expect(preview.preview.matched).toHaveLength(0); + }); + }); + + describe("rollbackCSVImport", () => { + it("should rollback an import", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [{ id: "import-1", backup_snapshot: [{ id: "1", reference_number: "TXN-001" }] }], + }); + (queryWrite as jest.Mock).mockResolvedValue({}); + + const result = await rollbackCSVImport("import-1"); + expect(result.importId).toBe("import-1"); + expect(result.recordsRestored).toBe(1); + }); + + it("should throw for non-existent import", async () => { + (queryRead as jest.Mock).mockResolvedValue({ rows: [] }); + + await expect(rollbackCSVImport("non-existent")).rejects.toThrow("Import not found"); + }); + + it("should throw for already rolled back import", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [{ id: "import-1", rolled_back_at: new Date().toISOString() }], + }); + + await expect(rollbackCSVImport("import-1")).rejects.toThrow("already been rolled back"); + }); + }); +}); diff --git a/src/services/__tests__/providerErrorMap.test.ts b/src/services/__tests__/providerErrorMap.test.ts new file mode 100644 index 00000000..b2d7edd0 --- /dev/null +++ b/src/services/__tests__/providerErrorMap.test.ts @@ -0,0 +1,75 @@ +import { providerErrorMapService, ProviderErrorMapping } from "../providerErrorMap"; + +describe("ProviderErrorMapService", () => { + describe("mapError", () => { + it("should map known MTN error code", () => { + const mapping = providerErrorMapService.mapError("mtn", "4001", "Invalid credentials"); + expect(mapping).not.toBeNull(); + expect(mapping!.mappedCode).toBe("INVALID_CREDENTIALS"); + expect(mapping!.userMessage).toContain("authentication failed"); + expect(mapping!.isRetryable).toBe(false); + }); + + it("should map known Airtel error code", () => { + const mapping = providerErrorMapService.mapError("airtel", "INSUFFICIENT", "Insufficient funds"); + expect(mapping).not.toBeNull(); + expect(mapping!.mappedCode).toBe("INSUFFICIENT_FUNDS"); + expect(mapping!.userMessage).toContain("Insufficient balance"); + }); + + it("should return fallback for unknown error code", () => { + const mapping = providerErrorMapService.mapError("mtn", "9999", "Unknown error"); + expect(mapping).not.toBeNull(); + expect(mapping!.mappedCode).toBe("PROVIDER_ERROR"); + expect(mapping!.isRetryable).toBe(true); + }); + + it("should return null for completely unknown provider without fallback", () => { + const mapping = providerErrorMapService.mapError("unknown" as any, "123", "Error"); + expect(mapping).not.toBeNull(); + expect(mapping!.provider).toBe("unknown"); + }); + }); + + describe("getLocalizedError", () => { + it("should return English message by default", () => { + const error = providerErrorMapService.getLocalizedError("mtn", "4001"); + expect(error.locale).toBe("en"); + expect(error.code).toBe("INVALID_CREDENTIALS"); + }); + + it("should return French message when requested", () => { + const error = providerErrorMapService.getLocalizedError("mtn", "4001", "fr"); + expect(error.locale).toBe("fr"); + expect(error.message).toContain("prestataire"); + }); + + it("should return fallback for unmapped error code", () => { + const error = providerErrorMapService.getLocalizedError("mtn", "9999", "en"); + expect(error.code).toBe("PROVIDER_ERROR"); + }); + }); + + describe("getErrorDocumentation", () => { + it("should return all mappings for a provider", () => { + const docs = providerErrorMapService.getErrorDocumentation("mtn"); + expect(docs.length).toBeGreaterThan(0); + expect(docs.every((d) => d.provider === "mtn")).toBe(true); + }); + + it("should return empty array for unknown provider", () => { + const docs = providerErrorMapService.getErrorDocumentation("unknown" as any); + expect(docs).toEqual([]); + }); + }); + + describe("getAllMappings", () => { + it("should return all error mappings", () => { + const all = providerErrorMapService.getAllMappings(); + expect(all.length).toBeGreaterThan(0); + expect(all.some((m) => m.provider === "mtn")).toBe(true); + expect(all.some((m) => m.provider === "airtel")).toBe(true); + expect(all.some((m) => m.provider === "orange")).toBe(true); + }); + }); +}); diff --git a/src/services/assetWorkflowService.ts b/src/services/assetWorkflowService.ts new file mode 100644 index 00000000..bd816cd3 --- /dev/null +++ b/src/services/assetWorkflowService.ts @@ -0,0 +1,250 @@ +import { queryRead, queryWrite } from "../config/database"; +import { v4 as uuidv4 } from "uuid"; +import { AssetIssuanceService } from "../services/stellar/issuanceService"; +import logger from "../utils/logger"; + +export type AssetWorkflowStatus = "draft" | "pending_approval" | "approved" | "rejected" | "issuing" | "completed" | "failed"; +export type ApprovalAction = "approve" | "reject" | "request_changes"; + +export interface AssetIssuanceRequest { + id: string; + assetCode: string; + name: string; + description?: string; + limit: string; + status: AssetWorkflowStatus; + requestedBy: string; + approvedBy?: string; + approvalNotes?: string; + metadata: Record; + trustlineConfig?: { + destinationAccount: string; + limit: string; + autoSetup: boolean; + }; + createdAt: Date; + updatedAt: Date; +} + +export interface AssetConfigurationValidation { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +export class AssetIssuanceRequestModel { + async create(input: { + assetCode: string; + name: string; + description?: string; + limit: string; + requestedBy: string; + trustlineConfig?: { destinationAccount: string; limit: string; autoSetup: boolean }; + }): Promise { + const id = uuidv4(); + + const existing = await this.findByCode(input.assetCode); + if (existing) { + throw new Error(`Asset code ${input.assetCode} already exists`); + } + + const result = await queryWrite( + `INSERT INTO asset_issuance_requests (id, asset_code, name, description, limit, status, requested_by, trustline_config, metadata) + VALUES ($1, $2, $3, $4, $5, 'draft', $6, $7, $8) + RETURNING *`, + [ + id, + input.assetCode, + input.name, + input.description || null, + input.limit, + input.requestedBy, + input.trustlineConfig ? JSON.stringify(input.trustlineConfig) : null, + JSON.stringify({}), + ], + ); + + return this.mapRow(result.rows[0]); + } + + async findById(id: string): Promise { + const result = await queryRead("SELECT * FROM asset_issuance_requests WHERE id = $1", [id]); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + async findByCode(assetCode: string): Promise { + const result = await queryRead("SELECT * FROM asset_issuance_requests WHERE asset_code = $1", [assetCode]); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + async findAll(status?: AssetWorkflowStatus): Promise { + let query = "SELECT * FROM asset_issuance_requests"; + if (status) { + query += ` WHERE status = $1`; + const result = await queryRead(query, [status]); + return result.rows.map((r) => this.mapRow(r)); + } + const result = await queryRead(query); + return result.rows.map((r) => this.mapRow(r)); + } + + async updateStatus(id: string, status: AssetWorkflowStatus, approvedBy?: string, approvalNotes?: string): Promise { + await queryWrite( + `UPDATE asset_issuance_requests SET status = $1, approved_by = $2, approval_notes = $3, updated_at = NOW() WHERE id = $4`, + [status, approvedBy || null, approvalNotes || null, id], + ); + } + + async updateTrustlineConfig(id: string, config: { destinationAccount: string; limit: string; autoSetup: boolean }): Promise { + await queryWrite( + `UPDATE asset_issuance_requests SET trustline_config = $1, updated_at = NOW() WHERE id = $2`, + [JSON.stringify(config), id], + ); + } + + private mapRow(row: any): AssetIssuanceRequest { + return { + id: row.id, + assetCode: row.asset_code, + name: row.name, + description: row.description, + limit: row.limit, + status: row.status, + requestedBy: row.requested_by, + approvedBy: row.approved_by, + approvalNotes: row.approval_notes, + metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata, + trustlineConfig: row.trustline_config ? (typeof row.trustline_config === "string" ? JSON.parse(row.trustline_config) : row.trustline_config) : undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} + +export class AssetWorkflowService { + private requestModel = new AssetIssuanceRequestModel(); + private issuanceService = new AssetIssuanceService(); + + async createRequest(input: { + assetCode: string; + name: string; + description?: string; + limit: string; + requestedBy: string; + trustlineConfig?: { destinationAccount: string; limit: string; autoSetup: boolean }; + }): Promise { + const validation = this.validateConfiguration({ assetCode: input.assetCode, name: input.name, limit: input.limit }); + if (!validation.isValid) { + throw new Error(`Invalid asset configuration: ${validation.errors.join(", ")}`); + } + + const request = await this.requestModel.create(input); + logger.info({ requestId: request.id, assetCode: input.assetCode }, "[asset-workflow] Request created"); + + return request; + } + + async approveRequest(id: string, approverId: string, action: ApprovalAction, notes?: string): Promise { + const request = await this.requestModel.findById(id); + if (!request) { + throw new Error("Asset issuance request not found"); + } + + if (request.status !== "pending_approval") { + throw new Error(`Cannot ${action} request in status: ${request.status}`); + } + + if (action === "approve") { + await this.requestModel.updateStatus(id, "approved", approverId, notes); + await this.issueAsset(request); + } else if (action === "reject") { + await this.requestModel.updateStatus(id, "rejected", approverId, notes); + } else { + await this.requestModel.updateStatus(id, "draft", approverId, notes); + } + + const updated = await this.requestModel.findById(id); + logger.info({ requestId: id, action, approverId }, "[asset-workflow] Request updated"); + return updated!; + } + + async submitForApproval(id: string): Promise { + const request = await this.requestModel.findById(id); + if (!request) { + throw new Error("Asset issuance request not found"); + } + + if (request.status !== "draft") { + throw new Error(`Cannot submit request in status: ${request.status}`); + } + + await this.requestModel.updateStatus(id, "pending_approval"); + logger.info({ requestId: id }, "[asset-workflow] Request submitted for approval"); + return (await this.requestModel.findById(id))!; + } + + async configureTrustline(id: string, config: { destinationAccount: string; limit: string; autoSetup: boolean }): Promise { + const request = await this.requestModel.findById(id); + if (!request) { + throw new Error("Asset issuance request not found"); + } + + if (!config.autoSetup) { + await this.requestModel.updateTrustlineConfig(id, config); + return (await this.requestModel.findById(id))!; + } + + await this.setupTrustlineAutomatically(request.assetCode, config.destinationAccount, config.limit); + await this.requestModel.updateTrustlineConfig(id, config); + const updated = await this.requestModel.findById(id); + logger.info({ requestId: id, destinationAccount: config.destinationAccount }, "[asset-workflow] Trustline configured"); + return updated!; + } + + private async issueAsset(request: AssetIssuanceRequest): Promise { + await this.requestModel.updateStatus(request.id, "issuing"); + try { + const setupResult = await this.issuanceService.setupAnchoredAsset(request.assetCode, request.limit); + await this.requestModel.updateStatus(request.id, "completed"); + logger.info({ requestId: request.id, assetCode: request.assetCode }, "[asset-workflow] Asset issued successfully"); + } catch (error) { + await this.requestModel.updateStatus(request.id, "failed"); + logger.error({ error, requestId: request.id }, "[asset-workflow] Asset issuance failed"); + throw error; + } + } + + private async setupTrustlineAutomatically(assetCode: string, destinationAccount: string, limit: string): Promise { + logger.info({ assetCode, destinationAccount, limit }, "[asset-workflow] Setting up trustline automatically"); + } + + validateConfiguration(config: { assetCode: string; name: string; limit: string }): AssetConfigurationValidation { + const errors: string[] = []; + const warnings: string[] = []; + + if (!config.assetCode || config.assetCode.length < 1 || config.assetCode.length > 12) { + errors.push("Asset code must be between 1 and 12 characters"); + } + if (!/^[a-zA-Z0-9]+$/.test(config.assetCode)) { + errors.push("Asset code must be alphanumeric"); + } + if (!config.name || config.name.trim().length < 1) { + errors.push("Asset name is required"); + } + const limitNum = parseFloat(config.limit); + if (isNaN(limitNum) || limitNum <= 0) { + errors.push("Limit must be a positive number"); + } + if (limitNum > 1000000000) { + warnings.push("Limit is very high, please verify"); + } + + return { isValid: errors.length === 0, errors, warnings }; + } + + async getPendingApprovals(): Promise { + return this.requestModel.findAll("pending_approval"); + } +} + +export const assetWorkflowService = new AssetWorkflowService(); diff --git a/src/services/csvReconciliation.ts b/src/services/csvReconciliation.ts index 0b4390b3..6300c6fb 100644 --- a/src/services/csvReconciliation.ts +++ b/src/services/csvReconciliation.ts @@ -97,6 +97,208 @@ function normalizeAmount(amount?: string): string | null { return amount.replace(/[^0-9.]/g, "").trim(); } +export interface CSVValidationError { + row: number; + field: string; + value: unknown; + message: string; + severity: "error" | "warning"; +} + +export interface CSVValidationResult { + isValid: boolean; + errors: CSVValidationError[]; + warnings: CSVValidationError[]; + summary: { + totalRows: number; + validRows: number; + errorRows: number; + warningRows: number; + }; +} + +export interface CSVImportPreview { + preview: ReconciliationResult; + validation: CSVValidationResult; + estimatedChanges: { + matched: number; + discrepancies: number; + orphanedProvider: number; + orphanedDb: number; + }; +} + +export interface CSVImportRollback { + importId: string; + rolledBackAt: string; + recordsRestored: number; +} + +const REQUIRED_FIELDS = ["reference_number", "amount", "status", "phone_number", "provider"]; +const VALID_STATUSES = ["completed", "pending", "failed", "cancelled"]; +const PHONE_REGEX = /^\+?[1-9]\d{1,14}$/; +const AMOUNT_REGEX = /^\d+(\.\d+)?$/; + +export function validateCSVSchema(rows: ProviderCSVRow[]): CSVValidationResult { + const errors: CSVValidationError[] = []; + const warnings: CSVValidationError[] = []; + let errorRows = 0; + let warningRows = 0; + + if (rows.length === 0) { + return { + isValid: true, + errors: [], + warnings: [], + summary: { totalRows: 0, validRows: 0, errorRows: 0, warningRows: 0 }, + }; + } + + const sampleKeys = Object.keys(rows[0]); + for (const field of REQUIRED_FIELDS) { + if (!sampleKeys.includes(field) && !sampleKeys.includes("reference_id")) { + errors.push({ + row: 0, + field: "schema", + value: sampleKeys, + message: `Missing required field: ${field}`, + severity: "error", + }); + } + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNumber = i + 2; + let rowHasError = false; + let rowHasWarning = false; + + const ref = row.reference_number || row.reference_id; + if (!ref || ref.trim() === "") { + errors.push({ row: rowNumber, field: "reference_number", value: ref, message: "Reference number is required", severity: "error" }); + rowHasError = true; + } + + if (!row.amount || row.amount.trim() === "") { + errors.push({ row: rowNumber, field: "amount", value: row.amount, message: "Amount is required", severity: "error" }); + rowHasError = true; + } else if (!AMOUNT_REGEX.test(row.amount.trim())) { + errors.push({ row: rowNumber, field: "amount", value: row.amount, message: "Amount must be a valid number", severity: "error" }); + rowHasError = true; + } + + if (!row.status || row.status.trim() === "") { + errors.push({ row: rowNumber, field: "status", value: row.status, message: "Status is required", severity: "error" }); + rowHasError = true; + } else if (!VALID_STATUSES.includes(row.status.toLowerCase().trim())) { + warnings.push({ + row: rowNumber, + field: "status", + value: row.status, + message: `Non-standard status: ${row.status}. Expected one of: ${VALID_STATUSES.join(", ")}`, + severity: "warning", + }); + rowHasWarning = true; + } + + if (!row.phone_number || row.phone_number.trim() === "") { + errors.push({ row: rowNumber, field: "phone_number", value: row.phone_number, message: "Phone number is required", severity: "error" }); + rowHasError = true; + } else if (!PHONE_REGEX.test(row.phone_number.trim())) { + warnings.push({ + row: rowNumber, + field: "phone_number", + value: row.phone_number, + message: "Phone number format may be invalid", + severity: "warning", + }); + rowHasWarning = true; + } + + if (!row.provider || row.provider.trim() === "") { + errors.push({ row: rowNumber, field: "provider", value: row.provider, message: "Provider is required", severity: "error" }); + rowHasError = true; + } + + if (rowHasError) errorRows++; + if (rowHasWarning) warningRows++; + } + + return { + isValid: errors.length === 0, + errors, + warnings, + summary: { + totalRows: rows.length, + validRows: rows.length - errorRows, + errorRows, + warningRows, + }, + }; +} + +export async function previewCSVImport(buffer: Buffer, dateRange?: { start?: string; end?: string }): Promise { + const rows = await parseCSV(buffer); + const validation = validateCSVSchema(rows); + const preview = validation.isValid ? await reconcileTransactions(rows, dateRange) : { + total_provider_rows: rows.length, + total_db_records: 0, + matched: [], + discrepancies: [], + orphaned_provider: [], + orphaned_db: [], + summary: { match_rate: "0.00%", total_matched: 0, total_discrepancies: 0, total_orphaned_provider: 0, total_orphaned_db: 0 }, + }; + + return { + preview, + validation, + estimatedChanges: { + matched: preview.summary.total_matched, + discrepancies: preview.summary.total_discrepancies, + orphanedProvider: preview.summary.total_orphaned_provider, + orphanedDb: preview.summary.total_orphaned_db, + }, + }; +} + +export async function rollbackCSVImport(importId: string): Promise { + const result = await queryRead("SELECT * FROM csv_imports WHERE id = $1", [importId]); + if (!result.rows.length) { + throw new Error("Import not found"); + } + + const importRecord = result.rows[0]; + if (importRecord.rolled_back_at) { + throw new Error("Import has already been rolled back"); + } + + let recordsRestored = 0; + if (importRecord.backup_snapshot) { + const snapshot = importRecord.backup_snapshot as any[]; + for (const record of snapshot) { + await queryWrite( + `INSERT INTO transactions (id, reference_number, amount, status, phone_number, provider, user_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (reference_number) DO UPDATE SET + amount = EXCLUDED.amount, + status = EXCLUDED.status, + updated_at = NOW()`, + [record.id, record.reference_number, record.amount, record.status, record.phone_number, record.provider, record.user_id, record.created_at, record.updated_at], + ); + recordsRestored++; + } + } + + await queryWrite(`UPDATE csv_imports SET rolled_back_at = NOW() WHERE id = $1`, [importId]); + + return { + importId, + rolledBackAt: new Date().toISOString(), + recordsRestored, + }; +} + /** * Reconcile provider CSV against database transactions */ diff --git a/src/services/providerErrorMap.ts b/src/services/providerErrorMap.ts new file mode 100644 index 00000000..65ddacf4 --- /dev/null +++ b/src/services/providerErrorMap.ts @@ -0,0 +1,182 @@ +export type ProviderName = "mtn" | "airtel" | "orange" | "generic"; + +export interface ProviderErrorMapping { + provider: ProviderName; + providerErrorCode: string; + providerErrorMessage: string; + mappedCode: string; + userMessage: string; + recoverySuggestion: string; + isRetryable: boolean; + severity: "low" | "medium" | "high" | "critical"; +} + +export interface LocalizedError { + code: string; + message: string; + recoverySuggestion: string; + locale: string; +} + +const ERROR_MAPPINGS: ProviderErrorMapping[] = [ + { + provider: "mtn", + providerErrorCode: "4001", + providerErrorMessage: "Invalid credentials", + mappedCode: "INVALID_CREDENTIALS", + userMessage: "Payment provider authentication failed. Please try again later.", + recoverySuggestion: "Contact support if the issue persists.", + isRetryable: false, + severity: "high", + }, + { + provider: "mtn", + providerErrorCode: "4002", + providerErrorMessage: "Insufficient balance", + mappedCode: "INSUFFICIENT_FUNDS", + userMessage: "Insufficient balance to complete this transaction.", + recoverySuggestion: "Top up your account and try again.", + isRetryable: false, + severity: "medium", + }, + { + provider: "mtn", + providerErrorCode: "4003", + providerErrorMessage: "Transaction expired", + mappedCode: "TRANSACTION_EXPIRED", + userMessage: "This transaction has expired.", + recoverySuggestion: "Initiate a new transaction.", + isRetryable: false, + severity: "medium", + }, + { + provider: "mtn", + providerErrorCode: "5001", + providerErrorMessage: "Internal server error", + mappedCode: "PROVIDER_ERROR", + userMessage: "Payment provider is experiencing issues. Please try again in a few minutes.", + recoverySuggestion: "Retry after 5 minutes. If it fails again, contact support.", + isRetryable: true, + severity: "high", + }, + { + provider: "airtel", + providerErrorCode: "AUTH_FAILED", + providerErrorMessage: "Authentication failed", + mappedCode: "INVALID_CREDENTIALS", + userMessage: "Payment provider authentication failed. Please try again later.", + recoverySuggestion: "Contact support if the issue persists.", + isRetryable: false, + severity: "high", + }, + { + provider: "airtel", + providerErrorCode: "INSUFFICIENT", + providerErrorMessage: "Insufficient funds", + mappedCode: "INSUFFICIENT_FUNDS", + userMessage: "Insufficient balance to complete this transaction.", + recoverySuggestion: "Top up your account and try again.", + isRetryable: false, + severity: "medium", + }, + { + provider: "orange", + providerErrorCode: "ORANGE_401", + providerErrorMessage: "Unauthorized", + mappedCode: "UNAUTHORIZED", + userMessage: "Payment provider authentication failed.", + recoverySuggestion: "Contact support if the issue persists.", + isRetryable: false, + severity: "high", + }, + { + provider: "orange", + providerErrorCode: "ORANGE_503", + providerErrorMessage: "Service unavailable", + mappedCode: "SERVICE_UNAVAILABLE", + userMessage: "Payment provider is temporarily unavailable.", + recoverySuggestion: "Retry after a few minutes.", + isRetryable: true, + severity: "high", + }, +]; + +const LOCALIZED_MESSAGES: Record> = { + en: { + INVALID_CREDENTIALS: { message: "Payment provider authentication failed. Please try again later.", recovery: "Contact support if the issue persists." }, + INSUFFICIENT_FUNDS: { message: "Insufficient balance to complete this transaction.", recovery: "Top up your account and try again." }, + TRANSACTION_EXPIRED: { message: "This transaction has expired.", recovery: "Initiate a new transaction." }, + PROVIDER_ERROR: { message: "Payment provider is experiencing issues. Please try again in a few minutes.", recovery: "Retry after 5 minutes. If it fails again, contact support." }, + SERVICE_UNAVAILABLE: { message: "Payment provider is temporarily unavailable.", recovery: "Retry after a few minutes." }, + }, + fr: { + INVALID_CREDENTIALS: { message: "L'authentification du prestataire de paiement a échoué. Veuillez réessayer plus tard.", recovery: "Contactez le support si le problème persiste." }, + INSUFFICIENT_FUNDS: { message: "Solde insuffisant pour effectuer cette transaction.", recovery: "Rechargez votre compte et réessayez." }, + TRANSACTION_EXPIRED: { message: "Cette transaction a expiré.", recovery: "Initiez une nouvelle transaction." }, + PROVIDER_ERROR: { message: "Le prestataire de paiement rencontre des problèmes. Veuillez réessayer dans quelques minutes.", recovery: "Réessayez après 5 minutes. Si cela échoue à nouveau, contactez le support." }, + SERVICE_UNAVAILABLE: { message: "Le prestataire de paiement est temporairement indisponible.", recovery: "Réessayez dans quelques minutes." }, + }, +}; + +export class ProviderErrorMapService { + private mappings: Map = new Map(); + + constructor() { + for (const mapping of ERROR_MAPPINGS) { + const key = `${mapping.provider}:${mapping.providerErrorCode}`; + this.mappings.set(key, mapping); + } + } + + mapError(provider: ProviderName, providerErrorCode: string, providerErrorMessage?: string): ProviderErrorMapping | null { + const key = `${provider}:${providerErrorCode}`; + const mapping = this.mappings.get(key); + + if (mapping) return mapping; + + const fallback = this.mappings.get(`${provider}:default`); + if (fallback) return fallback; + + return { + provider, + providerErrorCode, + providerErrorMessage: providerErrorMessage || "Unknown error", + mappedCode: "PROVIDER_ERROR", + userMessage: "An unexpected error occurred. Please try again.", + recoverySuggestion: "Contact support if the issue persists.", + isRetryable: true, + severity: "medium", + }; + } + + getLocalizedError(provider: ProviderName, providerErrorCode: string, locale = "en"): LocalizedError { + const mapping = this.mapError(provider, providerErrorCode); + if (!mapping) { + return { + code: "PROVIDER_ERROR", + message: "An unexpected error occurred. Please try again.", + recoverySuggestion: "Contact support if the issue persists.", + locale, + }; + } + + const localized = LOCALIZED_MESSAGES[locale]?.[mapping.mappedCode] || LOCALIZED_MESSAGES["en"]?.[mapping.mappedCode]; + + return { + code: mapping.mappedCode, + message: localized?.message || mapping.userMessage, + recoverySuggestion: localized?.recovery || mapping.recoverySuggestion, + locale, + }; + } + + getErrorDocumentation(provider: ProviderName): ProviderErrorMapping[] { + return ERROR_MAPPINGS.filter((m) => m.provider === provider); + } + + getAllMappings(): ProviderErrorMapping[] { + return [...ERROR_MAPPINGS]; + } +} + +export const providerErrorMapService = new ProviderErrorMapService(); diff --git a/src/services/reprocessingService.ts b/src/services/reprocessingService.ts new file mode 100644 index 00000000..9b253f1d --- /dev/null +++ b/src/services/reprocessingService.ts @@ -0,0 +1,246 @@ +import { queryRead, queryWrite } from "../config/database"; +import { TransactionModel, TransactionStatus } from "../models/transaction"; +import { rabbitMQManager, EXCHANGES, ROUTING_KEYS } from "../queue/rabbitmq"; +import logger from "../utils/logger"; +import { withRetry } from "../services/retry"; +import { MobileMoneyService } from "../services/mobilemoney/mobileMoneyService"; +import { StellarService } from "../services/stellar/stellarService"; + +export interface ReprocessingPolicy { + provider: string; + maxAttempts: number; + baseDelayMs: number; + backoffStrategy: "exponential" | "linear" | "fixed"; + retryableStatuses: TransactionStatus[]; +} + +export interface ReprocessingJob { + id: string; + transactionId: string; + provider: string; + attemptNumber: number; + maxAttempts: number; + status: "pending" | "processing" | "completed" | "failed" | "cancelled"; + failureReason?: string; + scheduledAt: Date; + processedAt?: Date; + completedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface ReprocessingResult { + success: boolean; + transactionId: string; + attemptNumber: number; + error?: string; +} + +const DEFAULT_POLICIES: ReprocessingPolicy[] = [ + { + provider: "mtn", + maxAttempts: 5, + baseDelayMs: 5000, + backoffStrategy: "exponential", + retryableStatuses: [TransactionStatus.Failed], + }, + { + provider: "airtel", + maxAttempts: 4, + baseDelayMs: 3000, + backoffStrategy: "exponential", + retryableStatuses: [TransactionStatus.Failed], + }, + { + provider: "orange", + maxAttempts: 4, + baseDelayMs: 3000, + backoffStrategy: "exponential", + retryableStatuses: [TransactionStatus.Failed], + }, +]; + +export class ReprocessingService { + private policies: Map = new Map( + DEFAULT_POLICIES.map((p) => [p.provider, p]), + ); + + async getPolicy(provider: string): Promise { + const cached = await this.loadPolicyFromDb(provider); + if (cached) return cached; + const policy = this.policies.get(provider.toLowerCase()) || DEFAULT_POLICIES[0]; + return policy; + } + + async updatePolicy(provider: string, updates: Partial): Promise { + const existing = await this.getPolicy(provider); + const merged: ReprocessingPolicy = { ...existing, ...updates, provider }; + this.policies.set(provider.toLowerCase(), merged); + + await queryWrite( + `INSERT INTO reprocessing_policies (provider, max_attempts, base_delay_ms, backoff_strategy, retryable_statuses) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (provider) DO UPDATE SET + max_attempts = EXCLUDED.max_attempts, + base_delay_ms = EXCLUDED.base_delay_ms, + backoff_strategy = EXCLUDED.backoff_strategy, + retryable_statuses = EXCLUDED.retryable_statuses, + updated_at = NOW() + RETURNING *`, + [ + merged.provider, + merged.maxAttempts, + merged.baseDelayMs, + merged.backoffStrategy, + JSON.stringify(merged.retryableStatuses), + ], + ); + + return merged; + } + + async enqueueFailedTransaction(transactionId: string, provider: string): Promise { + const policy = await this.getPolicy(provider); + const existing = await this.findActiveJob(transactionId); + if (existing) { + throw new Error(`Transaction ${transactionId} is already in reprocessing queue`); + } + + const id = `repro-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const scheduledAt = new Date(); + const delayMs = this.calculateDelay(policy, 1); + scheduledAt.setMilliseconds(scheduledAt.getMilliseconds() + delayMs); + + const result = await queryWrite( + `INSERT INTO reprocessing_jobs (id, transaction_id, provider, attempt_number, max_attempts, status, scheduled_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [id, transactionId, provider, 0, policy.maxAttempts, "pending", scheduledAt], + ); + + const job = result.rows[0] as ReprocessingJob; + await this.publishJob(job); + return job; + } + + async processJob(job: ReprocessingJob): Promise { + const policy = await this.getPolicy(job.provider); + const transactionModel = new TransactionModel(); + const transaction = await transactionModel.findById(job.transactionId); + + if (!transaction) { + return { success: false, transactionId: job.transactionId, attemptNumber: job.attemptNumber, error: "Transaction not found" }; + } + + if (!policy.retryableStatuses.includes(transaction.status as TransactionStatus)) { + return { success: false, transactionId: job.transactionId, attemptNumber: job.attemptNumber, error: "Transaction status not retryable" }; + } + + try { + const result = await withRetry( + async () => { + const mobileMoneyService = new MobileMoneyService(); + return await mobileMoneyService.retryTransaction(transaction); + }, + { + maxAttempts: 1, + baseDelayMs: 0, + provider: job.provider, + }, + ); + + await queryWrite( + `UPDATE reprocessing_jobs SET status = 'completed', attempt_number = attempt_number + 1, processed_at = NOW(), completed_at = NOW() WHERE id = $1`, + [job.id], + ); + + return { success: true, transactionId: job.transactionId, attemptNumber: job.attemptNumber + 1 }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const shouldRetry = job.attemptNumber + 1 < policy.maxAttempts; + + await queryWrite( + `UPDATE reprocessing_jobs SET status = $1, attempt_number = attempt_number + 1, failure_reason = $2, processed_at = NOW() WHERE id = $3`, + [shouldRetry ? "pending" : "failed", errorMessage, job.id], + ); + + if (shouldRetry) { + const nextDelay = this.calculateDelay(policy, job.attemptNumber + 1); + const nextScheduledAt = new Date(); + nextScheduledAt.setMilliseconds(nextScheduledAt.getMilliseconds() + nextDelay); + + await queryWrite(`UPDATE reprocessing_jobs SET scheduled_at = $1 WHERE id = $2`, [nextScheduledAt, job.id]); + await this.publishJob({ ...job, scheduledAt: nextScheduledAt }); + } + + return { success: false, transactionId: job.transactionId, attemptNumber: job.attemptNumber + 1, error: errorMessage }; + } + } + + async cancelJob(jobId: string): Promise { + await queryWrite(`UPDATE reprocessing_jobs SET status = 'cancelled' WHERE id = $1`, [jobId]); + } + + async getPendingJobs(limit = 100): Promise { + const result = await queryRead( + `SELECT * FROM reprocessing_jobs WHERE status = 'pending' AND scheduled_at <= NOW() ORDER BY scheduled_at ASC LIMIT $1`, + [limit], + ); + return result.rows as ReprocessingJob[]; + } + + async getJobStats(): Promise<{ pending: number; processing: number; completed: number; failed: number; cancelled: number }> { + const result = await queryRead( + `SELECT status, COUNT(*) as count FROM reprocessing_jobs GROUP BY status`, + ); + const stats = { pending: 0, processing: 0, completed: 0, failed: 0, cancelled: 0 }; + for (const row of result.rows) { + stats[row.status as keyof typeof stats] = parseInt(row.count, 10); + } + return stats; + } + + private async loadPolicyFromDb(provider: string): Promise { + const result = await queryRead(`SELECT * FROM reprocessing_policies WHERE provider = $1`, [provider.toLowerCase()]); + if (!result.rows.length) return null; + const row = result.rows[0]; + return { + provider: row.provider, + maxAttempts: row.max_attempts, + baseDelayMs: row.base_delay_ms, + backoffStrategy: row.backoff_strategy, + retryableStatuses: row.retryable_statuses, + }; + } + + private async findActiveJob(transactionId: string): Promise { + const result = await queryRead( + `SELECT * FROM reprocessing_jobs WHERE transaction_id = $1 AND status IN ('pending', 'processing')`, + [transactionId], + ); + return result.rows[0] || null; + } + + private calculateDelay(policy: ReprocessingPolicy, attempt: number): number { + if (policy.backoffStrategy === "exponential") { + return policy.baseDelayMs * Math.pow(2, attempt - 1); + } + if (policy.backoffStrategy === "linear") { + return policy.baseDelayMs * attempt; + } + return policy.baseDelayMs; + } + + private async publishJob(job: ReprocessingJob): Promise { + await rabbitMQManager.publish(EXCHANGES.TRANSACTIONS, ROUTING_KEYS.TRANSACTION_PROCESS, { + type: "reprocessing", + jobId: job.id, + transactionId: job.transactionId, + provider: job.provider, + attemptNumber: job.attemptNumber, + scheduledAt: job.scheduledAt, + }); + } +} + +export const reprocessingService = new ReprocessingService(); diff --git a/src/tests/queue/reprocessingQueue.test.ts b/src/tests/queue/reprocessingQueue.test.ts new file mode 100644 index 00000000..0ff9b12a --- /dev/null +++ b/src/tests/queue/reprocessingQueue.test.ts @@ -0,0 +1,107 @@ +import { reprocessingService } from "../services/reprocessingService"; +import { queryRead, queryWrite } from "../../config/database"; + +jest.mock("../../config/database"); +jest.mock("../services/retry"); +jest.mock("../services/mobilemoney/mobileMoneyService"); +jest.mock("../queue/rabbitmq"); + +describe("ReprocessingService", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getPolicy", () => { + it("should return default policy for known provider", async () => { + const policy = await reprocessingService.getPolicy("mtn"); + expect(policy.provider).toBe("mtn"); + expect(policy.maxAttempts).toBe(5); + expect(policy.baseDelayMs).toBe(5000); + }); + + it("should return default policy for unknown provider", async () => { + const policy = await reprocessingService.getPolicy("unknown"); + expect(policy.provider).toBe("unknown"); + expect(policy.maxAttempts).toBe(5); + }); + }); + + describe("enqueueFailedTransaction", () => { + it("should enqueue a transaction for reprocessing", async () => { + (queryRead as jest.Mock).mockResolvedValue({ rows: [] }); + (queryWrite as jest.Mock).mockResolvedValue({ + rows: [ + { + id: "repro-1", + transaction_id: "txn-1", + provider: "mtn", + attempt_number: 0, + max_attempts: 5, + status: "pending", + }, + ], + }); + + const result = await reprocessingService.enqueueFailedTransaction("txn-1", "mtn"); + expect(result.transactionId).toBe("txn-1"); + expect(result.status).toBe("pending"); + }); + + it("should throw if transaction is already in reprocessing queue", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [{ id: "repro-existing", transaction_id: "txn-1", status: "pending" }], + }); + + await expect(reprocessingService.enqueueFailedTransaction("txn-1", "mtn")).rejects.toThrow( + "already in reprocessing queue", + ); + }); + }); + + describe("processJob", () => { + it("should return failure if transaction not found", async () => { + (queryRead as jest.Mock).mockResolvedValue({ rows: [] }); + + const job = { + id: "repro-1", + transactionId: "txn-missing", + provider: "mtn", + attemptNumber: 0, + maxAttempts: 5, + status: "pending" as const, + }; + + const result = await reprocessingService.processJob(job); + expect(result.success).toBe(false); + expect(result.error).toBe("Transaction not found"); + }); + }); + + describe("getJobStats", () => { + it("should return stats grouped by status", async () => { + (queryRead as jest.Mock).mockResolvedValue({ + rows: [ + { status: "pending", count: "3" }, + { status: "completed", count: "10" }, + { status: "failed", count: "2" }, + ], + }); + + const stats = await reprocessingService.getJobStats(); + expect(stats.pending).toBe(3); + expect(stats.completed).toBe(10); + expect(stats.failed).toBe(2); + }); + }); + + describe("updatePolicy", () => { + it("should update provider policy", async () => { + (queryWrite as jest.Mock).mockResolvedValue({ + rows: [{ provider: "mtn", max_attempts: 10, base_delay_ms: 1000, backoff_strategy: "exponential" }], + }); + + const policy = await reprocessingService.updatePolicy("mtn", { maxAttempts: 10 }); + expect(policy.maxAttempts).toBe(10); + }); + }); +});