Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "DataExportStatus" AS ENUM ('pending', 'processing', 'completed', 'failed');

-- CreateTable
CREATE TABLE "DataExportJob" (
"id" TEXT NOT NULL,
"merchantId" TEXT NOT NULL,
"status" "DataExportStatus" NOT NULL DEFAULT 'pending',
"payload" TEXT,
"error" TEXT,
"requested_by" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "DataExportJob_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "DataExportJob_merchantId_idx" ON "DataExportJob"("merchantId");

-- AddForeignKey
ALTER TABLE "DataExportJob" ADD CONSTRAINT "DataExportJob_merchantId_fkey"
FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
23 changes: 23 additions & 0 deletions fluxapay_backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ model Merchant {
discrepancyThresholds DiscrepancyThreshold[]
discrepancyAlerts DiscrepancyAlert[]
customers Customer[]
dataExportJobs DataExportJob[]
}

model Customer {
Expand Down Expand Up @@ -530,6 +531,28 @@ enum AuditEntityType {
settlement_batch
}

/// Async GDPR-style data export job for a merchant.
model DataExportJob {
id String @id @default(cuid())
merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade)
merchantId String
status DataExportStatus @default(pending)
/// Base64-encoded JSON payload — populated when status = completed.
payload String?
error String?
requested_by String
expires_at DateTime
created_at DateTime @default(now())
updated_at DateTime @updatedAt

@@index([merchantId])
}

enum DataExportStatus {
pending
processing
completed
failed
/// Internal operator accounts with role-based access control.
/// No production rollout until reviewed — spike only.
model AdminUser {
Expand Down
2 changes: 2 additions & 0 deletions fluxapay_backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import keysRoutes from "./routes/keys.route";
import settlementBatchRoutes from "./routes/settlementBatch.route";
import dashboardRoutes from "./routes/dashboard.route";
import auditRoutes from "./routes/audit.route";
import dataExportRoutes from "./routes/dataExport.route";

const app = express();
const prisma = new PrismaClient();
Expand Down Expand Up @@ -86,6 +87,7 @@ app.use("/api/v1/admin/reconciliation", reconciliationRoutes);
app.use("/api/v1/admin/settlement", settlementBatchRoutes);
app.use("/api/v1/admin/sweep", sweepRoutes);
app.use("/api/v1/admin", auditRoutes);
app.use("/api/v1/merchants/export", dataExportRoutes);

// Basic health check
app.get("/health", (req, res) => {
Expand Down
92 changes: 92 additions & 0 deletions fluxapay_backend/src/controllers/dataExport.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Response } from "express";
import { AuthRequest } from "../types/express";
import { validateUserId } from "../helpers/request.helper";
import {
requestDataExport,
getExportJob,
downloadExport,
} from "../services/dataExport.service";

/**
* POST /api/v1/merchants/export
* Merchant self-service: enqueue a data export for the authenticated merchant.
*/
export async function requestExport(req: AuthRequest, res: Response) {
try {
const merchantId = await validateUserId(req);
const result = await requestDataExport(merchantId, "merchant");
res.status(202).json({
message: "Export job queued. Poll /export/:jobId for status.",
...result,
});
} catch (err: any) {
res.status(err.status || 500).json({ message: err.message || "Server error" });
}
}

/**
* GET /api/v1/merchants/export/:jobId
* Poll job status.
*/
export async function getExportStatus(req: AuthRequest, res: Response) {
try {
const merchantId = await validateUserId(req);
const job = await getExportJob(req.params.jobId as string, merchantId);
res.json({
jobId: job.id,
status: job.status,
expires_at: job.expires_at,
error: job.error ?? undefined,
});
} catch (err: any) {
res.status(err.status || 500).json({ message: err.message || "Server error" });
}
}

/**
* GET /api/v1/merchants/export/:jobId/download
* Download the completed export as JSON.
*/
export async function downloadExportHandler(req: AuthRequest, res: Response) {
try {
const merchantId = await validateUserId(req);
const data = await downloadExport(req.params.jobId as string, merchantId);
res.setHeader("Content-Disposition", `attachment; filename="export-${req.params.jobId as string}.json"`);
res.json(data);
} catch (err: any) {
res.status(err.status || 500).json({ message: err.message || "Server error" });
}
}

/**
* POST /api/v1/admin/merchants/:merchantId/export
* Admin-triggered export on behalf of a merchant.
*/
export async function adminRequestExport(req: AuthRequest, res: Response) {
try {
const { merchantId } = req.params as Record<string, string>;
const adminId = req.adminUser?.id ?? req.user?.id ?? "admin";
const result = await requestDataExport(merchantId, `admin:${adminId}`);
res.status(202).json({
message: "Export job queued.",
...result,
});
} catch (err: any) {
res.status(err.status || 500).json({ message: err.message || "Server error" });
}
}

/**
* GET /api/v1/admin/merchants/:merchantId/export/:jobId/download
* Admin download of a completed export.
*/
export async function adminDownloadExport(req: AuthRequest, res: Response) {
try {
const { merchantId, jobId } = req.params as Record<string, string>;
const data = await downloadExport(jobId, merchantId);
res.setHeader("Content-Disposition", `attachment; filename="export-${jobId}.json"`);
res.json(data);
} catch (err: any) {
res.status(err.status || 500).json({ message: err.message || "Server error" });
}
}
23 changes: 23 additions & 0 deletions fluxapay_backend/src/routes/dataExport.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/auth.middleware";
import { authenticateApiKey } from "../middleware/apiKeyAuth.middleware";
import { adminAuth } from "../middleware/adminAuth.middleware";
import {
requestExport,
getExportStatus,
downloadExportHandler,
adminRequestExport,
adminDownloadExport,
} from "../controllers/dataExport.controller";

const router = Router();

router.post("/", authenticateApiKey, requestExport);
router.get("/:jobId", authenticateApiKey, getExportStatus);
router.get("/:jobId/download", authenticateApiKey, downloadExportHandler);

// ── Admin routes ──────────────────────────────────────────────────────────────
router.post("/admin/:merchantId", authenticateToken, adminAuth, adminRequestExport);
router.get("/admin/:merchantId/:jobId/download", authenticateToken, adminAuth, adminDownloadExport);

export default router;
173 changes: 173 additions & 0 deletions fluxapay_backend/src/services/__tests__/dataExport.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { DataExportStatus } from "../../generated/client/client";

// ── Prisma mock ───────────────────────────────────────────────────────────────
const mockJob = {
id: "job-1",
merchantId: "merchant-1",
status: DataExportStatus.pending,
payload: null,
error: null,
requested_by: "merchant",
expires_at: new Date(Date.now() + 86400000),
created_at: new Date(),
updated_at: new Date(),
};

const mockMerchant = {
id: "merchant-1",
business_name: "Acme Ltd",
email: "acme@example.com",
phone_number: "+1234567890",
country: "NG",
settlement_currency: "NGN",
status: "active",
created_at: new Date(),
kyc: null,
bankAccount: null,
};

const mockPayments = [
{
id: "pay-1",
amount: "100",
currency: "USDC",
status: "confirmed",
customer_email: "customer@example.com",
description: "Test",
createdAt: new Date(),
confirmed_at: new Date(),
settled_at: null,
transaction_hash: "abc123",
},
];

const mockWebhookLogs = [
{
id: "wh-1",
event_type: "payment_completed",
endpoint_url: "https://example.com/webhook",
http_status: 200,
status: "delivered",
retry_count: 0,
created_at: new Date(),
},
];

const dataExportJob = {
create: jest.fn(),
update: jest.fn(),
findFirst: jest.fn(),
};

const merchant = { findUnique: jest.fn() };
const payment = { findMany: jest.fn() };
const webhookLog = { findMany: jest.fn() };

jest.mock("../../generated/client/client", () => ({
PrismaClient: jest.fn(() => ({
dataExportJob,
merchant,
payment,
webhookLog,
})),
DataExportStatus: {
pending: "pending",
processing: "processing",
completed: "completed",
failed: "failed",
},
}));

import {
requestDataExport,
getExportJob,
downloadExport,
} from "../dataExport.service";

describe("dataExport.service", () => {
beforeEach(() => jest.clearAllMocks());

describe("requestDataExport", () => {
it("creates a job and returns jobId + pending status", async () => {
dataExportJob.create.mockResolvedValue(mockJob);
// processExport runs async — mock update to avoid unhandled rejection
dataExportJob.update.mockResolvedValue({ ...mockJob, status: "processing" });
merchant.findUnique.mockResolvedValue(mockMerchant);
payment.findMany.mockResolvedValue(mockPayments);
webhookLog.findMany.mockResolvedValue(mockWebhookLogs);

const result = await requestDataExport("merchant-1", "merchant");

expect(dataExportJob.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
merchantId: "merchant-1",
requested_by: "merchant",
}),
}),
);
expect(result.jobId).toBe("job-1");
expect(result.status).toBe(DataExportStatus.pending);
});
});

describe("getExportJob", () => {
it("returns the job when found", async () => {
dataExportJob.findFirst.mockResolvedValue(mockJob);
const job = await getExportJob("job-1", "merchant-1");
expect(job.id).toBe("job-1");
});

it("throws 404 when job not found", async () => {
dataExportJob.findFirst.mockResolvedValue(null);
await expect(getExportJob("missing", "merchant-1")).rejects.toMatchObject({
status: 404,
});
});
});

describe("downloadExport", () => {
it("throws 409 when job is not completed", async () => {
dataExportJob.findFirst.mockResolvedValue({
...mockJob,
status: DataExportStatus.pending,
});
await expect(downloadExport("job-1", "merchant-1")).rejects.toMatchObject({
status: 409,
});
});

it("throws 410 when export has expired", async () => {
dataExportJob.findFirst.mockResolvedValue({
...mockJob,
status: DataExportStatus.completed,
payload: Buffer.from("{}").toString("base64"),
expires_at: new Date(Date.now() - 1000), // expired
});
await expect(downloadExport("job-1", "merchant-1")).rejects.toMatchObject({
status: 410,
});
});

it("returns parsed JSON for a valid completed job", async () => {
const exportData = { exported_at: "2026-01-01", merchant_profile: {} };
const payload = Buffer.from(JSON.stringify(exportData)).toString("base64");
dataExportJob.findFirst.mockResolvedValue({
...mockJob,
status: DataExportStatus.completed,
payload,
expires_at: new Date(Date.now() + 86400000),
});

const result = await downloadExport("job-1", "merchant-1");
expect(result).toMatchObject({ exported_at: "2026-01-01" });
});

it("throws 404 when job not found", async () => {
dataExportJob.findFirst.mockResolvedValue(null);
await expect(downloadExport("missing", "merchant-1")).rejects.toMatchObject({
status: 404,
});
});
});
});
Loading
Loading