Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions database/migrations/20260630_add_chargeback_support.sql
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/models/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum TransactionStatus {
Dispute = "dispute",
Reversed = "reversed",
ClawedBack = "clawed_back",
ChargedBack = "charged_back",
}

export interface Transaction {
Expand Down
108 changes: 108 additions & 0 deletions src/routes/chargebackCallbacks.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading