diff --git a/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql b/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql new file mode 100644 index 00000000..ae1ba532 --- /dev/null +++ b/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql @@ -0,0 +1,30 @@ +-- Add compliance columns to Merchant +ALTER TABLE "Merchant" + ADD COLUMN "deletion_requested_at" TIMESTAMP(3), + ADD COLUMN "anonymized_at" TIMESTAMP(3); + +-- Extend AuditActionType enum +ALTER TYPE "AuditActionType" ADD VALUE IF NOT EXISTS 'merchant_deletion_requested'; +ALTER TYPE "AuditActionType" ADD VALUE IF NOT EXISTS 'merchant_anonymized'; + +-- Extend AuditEntityType enum +ALTER TYPE "AuditEntityType" ADD VALUE IF NOT EXISTS 'merchant_account'; + +-- CreateTable: MerchantDeletionRequest +CREATE TABLE "MerchantDeletionRequest" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "reason" TEXT, + "requested_by" TEXT NOT NULL, + "executed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MerchantDeletionRequest_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "MerchantDeletionRequest_merchantId_key" + ON "MerchantDeletionRequest"("merchantId"); + +CREATE INDEX "MerchantDeletionRequest_merchantId_idx" + ON "MerchantDeletionRequest"("merchantId"); diff --git a/fluxapay_backend/prisma/schema.prisma b/fluxapay_backend/prisma/schema.prisma index b7295ef8..d5bf1ce5 100644 --- a/fluxapay_backend/prisma/schema.prisma +++ b/fluxapay_backend/prisma/schema.prisma @@ -37,6 +37,10 @@ model Merchant { email_notifications_enabled Boolean @default(true) notify_on_payment Boolean @default(false) notify_on_settlement Boolean @default(true) + /** Compliance: set when merchant requests account deletion */ + deletion_requested_at DateTime? + /** Compliance: set after PII anonymization is complete */ + anonymized_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt otps OTP[] @@ -519,6 +523,8 @@ enum AuditActionType { settlement_batch_initiate settlement_batch_complete settlement_batch_fail + merchant_deletion_requested + merchant_anonymized } enum AuditEntityType { @@ -529,6 +535,22 @@ enum AuditEntityType { settlement sweep_operation settlement_batch + merchant_account +} + +/// Tracks a merchant's right-to-erasure request. +/// Financial records are retained under legal hold; only PII is anonymized. +model MerchantDeletionRequest { + id String @id @default(cuid()) + merchantId String @unique + reason String? + requested_by String // "merchant" | "admin:" + /// Set when PII anonymization is executed + executed_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([merchantId]) } /// Async GDPR-style data export job for a merchant. diff --git a/fluxapay_backend/src/app.ts b/fluxapay_backend/src/app.ts index cfa3a1bb..b4739bde 100644 --- a/fluxapay_backend/src/app.ts +++ b/fluxapay_backend/src/app.ts @@ -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 merchantDeletionRoutes from "./routes/merchantDeletion.route"; import dataExportRoutes from "./routes/dataExport.route"; const app = express(); @@ -87,6 +88,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", merchantDeletionRoutes); app.use("/api/v1/merchants/export", dataExportRoutes); // Basic health check diff --git a/fluxapay_backend/src/controllers/merchantDeletion.controller.ts b/fluxapay_backend/src/controllers/merchantDeletion.controller.ts new file mode 100644 index 00000000..3e1d1ed5 --- /dev/null +++ b/fluxapay_backend/src/controllers/merchantDeletion.controller.ts @@ -0,0 +1,71 @@ +import { Response } from "express"; +import { AuthRequest } from "../types/express"; +import { validateUserId } from "../helpers/request.helper"; +import { + requestDeletion, + executeDeletion, + getDeletionRequest, +} from "../services/merchantDeletion.service"; + +/** + * POST /api/v1/merchants/me/deletion-request + * Merchant self-service: submit a right-to-erasure request. + */ +export async function selfRequestDeletion(req: AuthRequest, res: Response) { + try { + const merchantId = await validateUserId(req); + const { reason } = req.body ?? {}; + const result = await requestDeletion(merchantId, "merchant", reason); + res.status(202).json({ + message: "Deletion request recorded. An admin will review and execute anonymization.", + ...result, + }); + } catch (err: any) { + res.status(err.status || 500).json({ message: err.message || "Server error" }); + } +} + +/** + * GET /api/v1/merchants/me/deletion-request + * Merchant: check status of their deletion request. + */ +export async function selfGetDeletionRequest(req: AuthRequest, res: Response) { + try { + const merchantId = await validateUserId(req); + const request = await getDeletionRequest(merchantId); + res.json(request); + } catch (err: any) { + res.status(err.status || 500).json({ message: err.message || "Server error" }); + } +} + +/** + * POST /api/v1/admin/merchants/:merchantId/deletion-request + * Admin: submit a deletion request on behalf of a merchant. + */ +export async function adminRequestDeletion(req: AuthRequest, res: Response) { + try { + const { merchantId } = req.params as Record; + const adminId = req.user?.id ?? "admin"; + const { reason } = req.body ?? {}; + const result = await requestDeletion(merchantId, `admin:${adminId}`, reason); + res.status(202).json({ message: "Deletion request recorded.", ...result }); + } catch (err: any) { + res.status(err.status || 500).json({ message: err.message || "Server error" }); + } +} + +/** + * POST /api/v1/admin/merchants/:merchantId/anonymize + * Admin: execute PII anonymization (irreversible). + */ +export async function adminExecuteDeletion(req: AuthRequest, res: Response) { + try { + const { merchantId } = req.params as Record; + const adminId = req.user?.id ?? "admin"; + await executeDeletion(merchantId, adminId); + res.json({ message: "Merchant account anonymized. Financial records retained." }); + } catch (err: any) { + res.status(err.status || 500).json({ message: err.message || "Server error" }); + } +} diff --git a/fluxapay_backend/src/routes/merchantDeletion.route.ts b/fluxapay_backend/src/routes/merchantDeletion.route.ts new file mode 100644 index 00000000..f782c0c8 --- /dev/null +++ b/fluxapay_backend/src/routes/merchantDeletion.route.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import { authenticateApiKey } from "../middleware/apiKeyAuth.middleware"; +import { authenticateToken } from "../middleware/auth.middleware"; +import { adminAuth } from "../middleware/adminAuth.middleware"; +import { + selfRequestDeletion, + selfGetDeletionRequest, + adminRequestDeletion, + adminExecuteDeletion, +} from "../controllers/merchantDeletion.controller"; + +const router = Router(); + +// ── Merchant self-service ───────────────────────────────────────────────────── +router.post("/me/deletion-request", authenticateApiKey, selfRequestDeletion); +router.get("/me/deletion-request", authenticateApiKey, selfGetDeletionRequest); + +// ── Admin ───────────────────────────────────────────────────────────────────── +router.post("/admin/:merchantId/deletion-request", authenticateToken, adminAuth, adminRequestDeletion); +router.post("/admin/:merchantId/anonymize", authenticateToken, adminAuth, adminExecuteDeletion); + +export default router; diff --git a/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts b/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts new file mode 100644 index 00000000..eaa710b0 --- /dev/null +++ b/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts @@ -0,0 +1,174 @@ +import { AuditActionType, AuditEntityType } from "../../generated/client/client"; + +// ── Prisma mock ─────────────────────────────────────────────────────────────── +const merchant = { findUnique: jest.fn(), update: jest.fn() }; +const merchantDeletionRequest = { + upsert: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), +}; +const merchantKYC = { updateMany: jest.fn() }; +const kYCDocument = { deleteMany: jest.fn() }; +const webhookLog = { updateMany: jest.fn() }; +const oTP = { deleteMany: jest.fn() }; +const bankAccount = { deleteMany: jest.fn() }; +const merchantSubscription = { deleteMany: jest.fn() }; +const customer = { deleteMany: jest.fn() }; +const auditLog = { create: jest.fn() }; + +// $transaction executes the callback with the same mock client +const txClient = { + merchant, + merchantDeletionRequest, + merchantKYC, + kYCDocument, + webhookLog, + oTP, + bankAccount, + merchantSubscription, + customer, + auditLog, +}; + +jest.mock("../../generated/client/client", () => ({ + PrismaClient: jest.fn(() => ({ + ...txClient, + $transaction: jest.fn((fn: (tx: typeof txClient) => Promise) => fn(txClient)), + })), + AuditActionType: { + merchant_deletion_requested: "merchant_deletion_requested", + merchant_anonymized: "merchant_anonymized", + }, + AuditEntityType: { + merchant_account: "merchant_account", + }, +})); + +import { + requestDeletion, + executeDeletion, + getDeletionRequest, +} from "../merchantDeletion.service"; + +const MERCHANT_ID = "merchant-1"; +const ADMIN_ID = "admin-1"; + +const activeMerchant = { + id: MERCHANT_ID, + anonymized_at: null, + deletion_requested_at: null, +}; + +beforeEach(() => jest.clearAllMocks()); + +describe("requestDeletion", () => { + it("creates a deletion request and audit log", async () => { + merchant.findUnique.mockResolvedValue(activeMerchant); + merchantDeletionRequest.upsert.mockResolvedValue({ id: "req-1", merchantId: MERCHANT_ID }); + merchant.update.mockResolvedValue({}); + auditLog.create.mockResolvedValue({}); + + const result = await requestDeletion(MERCHANT_ID, "merchant", "closing business"); + + expect(merchantDeletionRequest.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { merchantId: MERCHANT_ID }, + create: expect.objectContaining({ requested_by: "merchant" }), + }), + ); + expect(merchant.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ deletion_requested_at: expect.any(Date) }) }), + ); + expect(auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action_type: AuditActionType.merchant_deletion_requested, + entity_type: AuditEntityType.merchant_account, + entity_id: MERCHANT_ID, + }), + }), + ); + expect(result.requestId).toBe("req-1"); + }); + + it("throws 404 when merchant not found", async () => { + merchant.findUnique.mockResolvedValue(null); + await expect(requestDeletion(MERCHANT_ID, "merchant")).rejects.toMatchObject({ status: 404 }); + }); + + it("throws 409 when already anonymized", async () => { + merchant.findUnique.mockResolvedValue({ ...activeMerchant, anonymized_at: new Date() }); + await expect(requestDeletion(MERCHANT_ID, "merchant")).rejects.toMatchObject({ status: 409 }); + }); +}); + +describe("executeDeletion", () => { + beforeEach(() => { + merchant.findUnique.mockResolvedValue(activeMerchant); + merchantDeletionRequest.findUnique.mockResolvedValue({ id: "req-1", merchantId: MERCHANT_ID }); + merchant.update.mockResolvedValue({}); + merchantKYC.updateMany.mockResolvedValue({}); + kYCDocument.deleteMany.mockResolvedValue({}); + webhookLog.updateMany.mockResolvedValue({}); + oTP.deleteMany.mockResolvedValue({}); + bankAccount.deleteMany.mockResolvedValue({}); + merchantSubscription.deleteMany.mockResolvedValue({}); + customer.deleteMany.mockResolvedValue({}); + merchantDeletionRequest.update.mockResolvedValue({}); + auditLog.create.mockResolvedValue({}); + }); + + it("anonymizes PII and writes audit log", async () => { + await executeDeletion(MERCHANT_ID, ADMIN_ID); + + expect(merchant.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + email: expect.stringContaining("anonymized.invalid"), + password: "REDACTED", + anonymized_at: expect.any(Date), + }), + }), + ); + expect(kYCDocument.deleteMany).toHaveBeenCalled(); + expect(oTP.deleteMany).toHaveBeenCalled(); + expect(bankAccount.deleteMany).toHaveBeenCalled(); + expect(auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action_type: AuditActionType.merchant_anonymized, + entity_id: MERCHANT_ID, + }), + }), + ); + }); + + it("throws 404 when merchant not found", async () => { + merchant.findUnique.mockResolvedValue(null); + await expect(executeDeletion(MERCHANT_ID, ADMIN_ID)).rejects.toMatchObject({ status: 404 }); + }); + + it("throws 409 when already anonymized", async () => { + merchant.findUnique.mockResolvedValue({ ...activeMerchant, anonymized_at: new Date() }); + await expect(executeDeletion(MERCHANT_ID, ADMIN_ID)).rejects.toMatchObject({ status: 409 }); + }); + + it("throws 400 when no deletion request exists", async () => { + merchantDeletionRequest.findUnique.mockResolvedValue(null); + await expect(executeDeletion(MERCHANT_ID, ADMIN_ID)).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe("getDeletionRequest", () => { + it("returns the request when found", async () => { + const req = { id: "req-1", merchantId: MERCHANT_ID }; + merchantDeletionRequest.findUnique.mockResolvedValue(req); + const result = await getDeletionRequest(MERCHANT_ID); + expect(result.id).toBe("req-1"); + }); + + it("throws 404 when not found", async () => { + merchantDeletionRequest.findUnique.mockResolvedValue(null); + await expect(getDeletionRequest(MERCHANT_ID)).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/fluxapay_backend/src/services/merchantDeletion.service.ts b/fluxapay_backend/src/services/merchantDeletion.service.ts new file mode 100644 index 00000000..180cee76 --- /dev/null +++ b/fluxapay_backend/src/services/merchantDeletion.service.ts @@ -0,0 +1,161 @@ +/** + * Merchant account deletion / anonymization service. + * + * Retention policy (legal hold): + * - Payment, Settlement, Refund, Invoice, AuditLog records are KEPT + * (financial / regulatory obligation — typically 7 years). + * - PII fields on Merchant are overwritten with anonymized placeholders. + * - Webhook logs endpoint URL is cleared (may contain PII). + * - KYC documents are deleted; KYC record is anonymized. + * - OTPs, BankAccount, Customers, Subscriptions are hard-deleted. + * - An AuditLog entry is written for both the request and the execution. + */ +import { PrismaClient, AuditActionType, AuditEntityType } from "../generated/client/client"; + +const prisma = new PrismaClient(); + +const ANON_EMAIL = (id: string) => `deleted-${id}@anonymized.invalid`; +const ANON_PHONE = (id: string) => `+000000${id.slice(-6)}`; +const ANON_NAME = "Anonymized Account"; + +/** + * Record a deletion request (step 1 — merchant self-service or admin). + * Does NOT anonymize yet; an admin must approve via executeDeletion(). + */ +export async function requestDeletion( + merchantId: string, + requestedBy: string, + reason?: string, +): Promise<{ requestId: string }> { + const merchant = await prisma.merchant.findUnique({ where: { id: merchantId } }); + if (!merchant) throw { status: 404, message: "Merchant not found" }; + if (merchant.anonymized_at) throw { status: 409, message: "Account already anonymized" }; + + // Upsert so re-requests are idempotent + const req = await prisma.merchantDeletionRequest.upsert({ + where: { merchantId }, + create: { merchantId, reason, requested_by: requestedBy }, + update: { reason, requested_by: requestedBy, executed_at: null }, + }); + + // Mark merchant as pending deletion + await prisma.merchant.update({ + where: { id: merchantId }, + data: { deletion_requested_at: new Date() }, + }); + + // Audit log + await prisma.auditLog.create({ + data: { + admin_id: requestedBy, + action_type: AuditActionType.merchant_deletion_requested, + entity_type: AuditEntityType.merchant_account, + entity_id: merchantId, + details: { + reason: reason ?? null, + requested_by: requestedBy, + requested_at: new Date().toISOString(), + }, + }, + }); + + return { requestId: req.id }; +} + +/** + * Execute anonymization (admin-only step 2). + * + * Financial records (payments, settlements, refunds, invoices) are retained. + * PII is overwritten. Hard-deletes non-financial data. + */ +export async function executeDeletion( + merchantId: string, + adminId: string, +): Promise { + const merchant = await prisma.merchant.findUnique({ where: { id: merchantId } }); + if (!merchant) throw { status: 404, message: "Merchant not found" }; + if (merchant.anonymized_at) throw { status: 409, message: "Account already anonymized" }; + + const deletionReq = await prisma.merchantDeletionRequest.findUnique({ + where: { merchantId }, + }); + if (!deletionReq) throw { status: 400, message: "No deletion request found for this merchant" }; + + await prisma.$transaction(async (tx) => { + // 1. Anonymize Merchant PII + await tx.merchant.update({ + where: { id: merchantId }, + data: { + business_name: ANON_NAME, + email: ANON_EMAIL(merchantId), + phone_number: ANON_PHONE(merchantId), + password: "REDACTED", + webhook_url: null, + webhook_secret: "REDACTED", + api_key_hashed: null, + api_key_last_four: null, + checkout_logo_url: null, + checkout_accent_color: null, + anonymized_at: new Date(), + }, + }); + + // 2. Anonymize KYC record (keep for audit trail, wipe PII) + await tx.merchantKYC.updateMany({ + where: { merchantId }, + data: { + legal_business_name: ANON_NAME, + director_full_name: ANON_NAME, + director_email: ANON_EMAIL(merchantId), + director_phone: ANON_PHONE(merchantId), + government_id_number: "REDACTED", + business_registration_number: null, + business_address: "REDACTED", + }, + }); + + // 3. Delete KYC documents (files already on Cloudinary — caller must purge separately) + await tx.kYCDocument.deleteMany({ where: { kyc: { merchantId } } }); + + // 4. Clear webhook log endpoint URLs (may contain PII in query params) + await tx.webhookLog.updateMany({ + where: { merchantId }, + data: { endpoint_url: "REDACTED" }, + }); + + // 5. Hard-delete non-financial / session data + await tx.oTP.deleteMany({ where: { merchantId } }); + await tx.bankAccount.deleteMany({ where: { merchantId } }); + await tx.merchantSubscription.deleteMany({ where: { merchantId } }); + await tx.customer.deleteMany({ where: { merchantId } }); + + // 6. Mark deletion request as executed + await tx.merchantDeletionRequest.update({ + where: { merchantId }, + data: { executed_at: new Date() }, + }); + + // 7. Audit log + await tx.auditLog.create({ + data: { + admin_id: adminId, + action_type: AuditActionType.merchant_anonymized, + entity_type: AuditEntityType.merchant_account, + entity_id: merchantId, + details: { + executed_by: adminId, + executed_at: new Date().toISOString(), + retained: ["payments", "settlements", "refunds", "invoices", "audit_logs"], + deleted: ["otps", "bank_account", "subscriptions", "customers", "kyc_documents"], + anonymized: ["merchant_profile", "kyc_record", "webhook_log_urls"], + }, + }, + }); + }); +} + +export async function getDeletionRequest(merchantId: string) { + const req = await prisma.merchantDeletionRequest.findUnique({ where: { merchantId } }); + if (!req) throw { status: 404, message: "No deletion request found" }; + return req; +}