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,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");
22 changes: 22 additions & 0 deletions fluxapay_backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -519,6 +523,8 @@ enum AuditActionType {
settlement_batch_initiate
settlement_batch_complete
settlement_batch_fail
merchant_deletion_requested
merchant_anonymized
}

enum AuditEntityType {
Expand All @@ -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:<id>"
/// 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.
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 merchantDeletionRoutes from "./routes/merchantDeletion.route";
import dataExportRoutes from "./routes/dataExport.route";

const app = express();
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions fluxapay_backend/src/controllers/merchantDeletion.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<string, string>;
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" });
}
}
22 changes: 22 additions & 0 deletions fluxapay_backend/src/routes/merchantDeletion.route.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<void>) => 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 });
});
});
Loading
Loading