From 4a6ab32a1a23bdd16655e713f0d7185d0dd2e862 Mon Sep 17 00:00:00 2001 From: Kenechukwu Frank Date: Tue, 30 Jun 2026 04:23:39 +0000 Subject: [PATCH] feat: implement transaction chargeback handling API (issue #124) - Add ChargedBack status to TransactionStatus enum - Add chargeback_reason, chargeback_reference, chargeback_at columns - Create chargebacks tracking table - Add database migration (20260630_add_chargeback_support.sql) - Create ChargebackService with atomic ledger reversal - Add POST /callbacks/chargeback endpoint with Zod validation - Register route in index.ts - Add transaction.chargeback WebhookEvent type - Notify organization via webhook and email on chargeback --- .../20260630_add_chargeback_support.sql | 32 +++ src/index.ts | 2 + src/models/transaction.ts | 1 + src/routes/chargebackCallbacks.ts | 108 ++++++++ src/services/chargebackService.ts | 244 ++++++++++++++++++ src/services/webhook.ts | 2 +- 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 database/migrations/20260630_add_chargeback_support.sql create mode 100644 src/routes/chargebackCallbacks.ts create mode 100644 src/services/chargebackService.ts diff --git a/database/migrations/20260630_add_chargeback_support.sql b/database/migrations/20260630_add_chargeback_support.sql new file mode 100644 index 00000000..50bc6f74 --- /dev/null +++ b/database/migrations/20260630_add_chargeback_support.sql @@ -0,0 +1,32 @@ +-- Migration: Add chargeback support for mobile money transactions +-- Adds charged_back transaction status, chargeback_reference column, and +-- a chargebacks table for tracking incoming operator chargeback notifications. + +ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_status_check; +ALTER TABLE transactions + ADD CONSTRAINT transactions_status_check + CHECK (status IN ( + 'pending', 'completed', 'failed', 'cancelled', 'review', + 'dispute', 'reversed', 'clawed_back', 'charged_back' + )); + +ALTER TABLE transactions + ADD COLUMN IF NOT EXISTS chargeback_reference VARCHAR(255) DEFAULT NULL, + ADD COLUMN IF NOT EXISTS chargeback_reason TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS chargeback_at TIMESTAMP DEFAULT NULL; + +CREATE TABLE IF NOT EXISTS chargebacks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + transaction_id UUID NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + chargeback_reference VARCHAR(255) NOT NULL, + reason TEXT, + amount NUMERIC(20,8) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + operator_callback_ref VARCHAR(255), + metadata JSONB DEFAULT '{}', + processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_chargebacks_transaction_id ON chargebacks(transaction_id); +CREATE INDEX IF NOT EXISTS idx_chargebacks_reference ON chargebacks(chargeback_reference); diff --git a/src/index.ts b/src/index.ts index d95a5bfc..15560eea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ import { privacyRoutes } from "./routes/privacy"; import { developerDashboardRoutes } from "./routes/developerDashboard"; import { travelRuleRoutes } from "./routes/travelRule"; import mtnCallbacksRouter from "./routes/mtnCallbacks"; +import chargebackCallbacksRouter from "./routes/chargebackCallbacks"; import sep31Router from "./stellar/sep31"; import sep24Router from "./stellar/sep24"; import sep38Router from "./stellar/sep38"; @@ -372,6 +373,7 @@ app.use("/api/disputes", disputeRoutes); app.use("/api/stats", statsRoutes); app.use("/api/contacts", contactsRoutes); app.use("/api/mtn", mtnCallbacksRouter); +app.use("/api", chargebackCallbacksRouter); app.use("/api/reports", reportsRoutes); app.use("/api/fees", feesRoutes); app.use("/api/users", userRoutes); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 0d299395..2a4afc4f 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -21,6 +21,7 @@ export enum TransactionStatus { Dispute = "dispute", Reversed = "reversed", ClawedBack = "clawed_back", + ChargedBack = "charged_back", } export interface Transaction { diff --git a/src/routes/chargebackCallbacks.ts b/src/routes/chargebackCallbacks.ts new file mode 100644 index 00000000..c58e4c5d --- /dev/null +++ b/src/routes/chargebackCallbacks.ts @@ -0,0 +1,108 @@ +import { Router, Request, Response } from "express"; +import { z } from "zod"; +import { chargebackService, ChargebackNotification } from "../services/chargebackService"; +import { createError } from "../middleware/errorHandler"; +import { ERROR_CODES } from "../constants/errorCodes"; +import logger from "../utils/logger"; + +const router = Router(); + +/** + * POST /callbacks/chargeback + * + * Receives incoming chargeback notifications from mobile money operators. + * Validates the payload, processes the chargeback atomically, and returns + * the result. + */ +const chargebackCallbackSchema = z.object({ + chargeback_reference: z.string().min(1, "chargeback_reference is required"), + transaction_reference: z.string().min(1, "transaction_reference is required"), + reason: z.string().optional(), + amount: z.number().positive().optional(), + currency: z.string().length(3).optional(), + operator_callback_ref: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +router.post("/callbacks/chargeback", async (req: Request, res: Response) => { + try { + // Validate the incoming payload + const parseResult = chargebackCallbackSchema.safeParse(req.body); + + if (!parseResult.success) { + const errors = parseResult.error.issues.map( + (issue) => `${issue.path.join(".")}: ${issue.message}`, + ); + throw createError(ERROR_CODES.INVALID_INPUT, "Invalid chargeback payload", { + error: "Validation Error", + message: `Chargeback payload validation failed: ${errors.join("; ")}`, + }); + } + + const notification: ChargebackNotification = parseResult.data; + + logger.info( + { + chargebackReference: notification.chargeback_reference, + transactionReference: notification.transaction_reference, + }, + "[chargeback-callback] Processing chargeback notification", + ); + + const result = await chargebackService.processChargeback(notification); + + res.status(200).json({ + status: "accepted", + chargeback_id: result.chargebackId, + transaction_id: result.transactionId, + message: result.message, + }); + } catch (error: any) { + // Map known errors to appropriate HTTP status codes + if ( + error.message?.includes("Transaction not found") + ) { + res.status(404).json({ + status: "error", + error: "Not Found", + message: error.message, + }); + return; + } + + if ( + error.message?.includes("not eligible for chargeback") + ) { + res.status(409).json({ + status: "error", + error: "Conflict", + message: error.message, + }); + return; + } + + // Re-throw if it's our structured error + if (error.statusCode) { + res.status(error.statusCode).json({ + status: "error", + error: error.error || "Bad Request", + message: error.message, + }); + return; + } + + // Unexpected error + logger.error( + { err: error, body: req.body }, + "[chargeback-callback] Unexpected error processing chargeback", + ); + + res.status(500).json({ + status: "error", + error: "Internal Server Error", + message: "An unexpected error occurred processing the chargeback", + }); + } +}); + +export default router; diff --git a/src/services/chargebackService.ts b/src/services/chargebackService.ts new file mode 100644 index 00000000..7418cf5a --- /dev/null +++ b/src/services/chargebackService.ts @@ -0,0 +1,244 @@ +import { pool } from "../config/database"; +import { TransactionModel, TransactionStatus } from "../models/transaction"; +import { LedgerService, LedgerEntry } from "./ledgerService"; +import { notifyTransactionWebhook } from "./webhook"; +import { emailService } from "./email"; +import logger from "../utils/logger"; + +/** + * Chargeback Service + * + * Handles incoming chargeback notifications from mobile money operators. + * Atomically reverses ledger entries, updates transaction status, and + * notifies the affected organization via webhook and email. + */ + +export interface ChargebackNotification { + chargeback_reference: string; + transaction_reference: string; + reason?: string; + amount?: number; + currency?: string; + operator_callback_ref?: string; + metadata?: Record; +} + +export interface ChargebackResult { + success: boolean; + chargebackId: string; + transactionId: string; + status: TransactionStatus; + message: string; +} + +export class ChargebackService { + private transactionModel: TransactionModel; + private ledgerService: LedgerService; + + constructor( + transactionModel?: TransactionModel, + ledgerService?: LedgerService, + ) { + this.transactionModel = transactionModel ?? new TransactionModel(); + this.ledgerService = ledgerService ?? new LedgerService(); + } + + async processChargeback( + notification: ChargebackNotification, + ): Promise { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // Step 1: Look up the original transaction + const transaction = await this.transactionModel.findByReferenceNumber( + notification.transaction_reference, + ); + + if (!transaction) { + throw new Error( + `Transaction not found for reference: ${notification.transaction_reference}`, + ); + } + + // Step 2: Validate chargeback eligibility + const eligibleStatuses: TransactionStatus[] = [ + TransactionStatus.Completed, + TransactionStatus.Pending, + TransactionStatus.Review, + ]; + + if (!eligibleStatuses.includes(transaction.status)) { + throw new Error( + `Transaction ${transaction.id} is in status "${transaction.status}" and is not eligible for chargeback`, + ); + } + + // Step 3: Reverse original ledger entries + const originalEntries = + await this.ledgerService.getEntriesByTransaction(transaction.id); + + if (originalEntries.length > 0) { + const reversalEntries: LedgerEntry[] = originalEntries.map( + (entry: any) => ({ + account_code: entry.account_code, + debit_amount: entry.credit_amount + ? parseFloat(entry.credit_amount) + : undefined, + credit_amount: entry.debit_amount + ? parseFloat(entry.debit_amount) + : undefined, + description: `Chargeback reversal: ${notification.chargeback_reference} - ${notification.reason || "operator chargeback"}`, + metadata: { + original_entry_id: entry.id, + chargeback_reference: notification.chargeback_reference, + }, + }), + ); + + await this.ledgerService.postTransaction( + `CHGBK-${notification.chargeback_reference}`, + `Chargeback: ${notification.chargeback_reference} for transaction ${transaction.referenceNumber}`, + reversalEntries, + transaction.id, + "system-chargeback", + ); + } + + // Step 4: Update transaction status + const amount = notification.amount ?? parseFloat(transaction.amount); + const chargebackRef = notification.chargeback_reference; + await client.query( + `UPDATE transactions + SET status = $1, + chargeback_reference = $2, + chargeback_reason = $3, + chargeback_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $4`, + [ + TransactionStatus.ChargedBack, + chargebackRef, + notification.reason || null, + transaction.id, + ], + ); + + // Step 5: Record the chargeback event + const chargebackInsert = await client.query( + `INSERT INTO chargebacks + (transaction_id, chargeback_reference, reason, amount, currency, + operator_callback_ref, metadata, processed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP) + RETURNING id`, + [ + transaction.id, + chargebackRef, + notification.reason || null, + amount, + notification.currency || "USD", + notification.operator_callback_ref || null, + JSON.stringify(notification.metadata || {}), + ], + ); + + const chargebackId = chargebackInsert.rows[0].id; + + await client.query("COMMIT"); + + // Step 6: Notify the organization (fire-and-forget) + this.sendChargebackNotifications(transaction.id, transaction.userId, { + chargebackId, + chargebackReference: chargebackRef, + transactionReference: transaction.referenceNumber, + reason: notification.reason || "Operator chargeback", + amount, + currency: notification.currency || "USD", + }).catch((err) => { + logger.error( + { err, chargebackId, transactionId: transaction.id }, + "[chargeback] Failed to send chargeback notifications", + ); + }); + + logger.info( + { + chargebackId, + transactionId: transaction.id, + chargebackReference: chargebackRef, + }, + "[chargeback] Chargeback processed successfully", + ); + + return { + success: true, + chargebackId, + transactionId: transaction.id, + status: TransactionStatus.ChargedBack, + message: `Chargeback processed for transaction ${transaction.referenceNumber}`, + }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error( + { err: error, transactionRef: notification.transaction_reference }, + "[chargeback] Chargeback processing failed", + ); + throw error; + } finally { + client.release(); + } + } + + private async sendChargebackNotifications( + transactionId: string, + userId: string, + details: { + chargebackId: string; + chargebackReference: string; + transactionReference: string; + reason: string; + amount: number; + currency: string; + }, + ): Promise { + // Send webhook notification with chargeback event + await notifyTransactionWebhook(transactionId, "transaction.chargeback", { + transactionModel: this.transactionModel, + }); + + // Send email notification + if (userId) { + try { + const { UserModel } = await import("../models/users.js"); + const userModel = new UserModel(); + const user = await userModel.findById(userId); + + if (user?.email) { + await emailService.sendEmail({ + to: user.email, + templateId: + process.env.SENDGRID_CHARGEBACK_TEMPLATE_ID || "", + dynamicTemplateData: { + chargebackReference: details.chargebackReference, + transactionReference: details.transactionReference, + reason: details.reason, + amount: details.amount.toFixed(2), + currency: details.currency, + chargebackId: details.chargebackId, + timestamp: new Date().toISOString(), + year: new Date().getFullYear(), + }, + }); + } + } catch (err) { + logger.error( + { err, userId, transactionId }, + "[chargeback] Failed to send chargeback email notification", + ); + } + } + } +} + +export const chargebackService = new ChargebackService(); diff --git a/src/services/webhook.ts b/src/services/webhook.ts index 743f10a6..7757ffeb 100644 --- a/src/services/webhook.ts +++ b/src/services/webhook.ts @@ -6,7 +6,7 @@ import { Transaction, WebhookDeliveryUpdate } from "../models/transaction"; const gzipAsync = promisify(gzip); -export type WebhookEvent = "transaction.completed" | "transaction.failed"; +export type WebhookEvent = "transaction.completed" | "transaction.failed" | "transaction.chargeback"; export type WebhookDeliveryStatus = | "pending" | "delivered"