From fcedc4f35333a2231779332eb828aac2626b79f5 Mon Sep 17 00:00:00 2001 From: Listoncrypt Date: Wed, 26 Aug 2026 11:49:34 +0100 Subject: [PATCH] fix(backend): consolidate duplicate merchant modules and update client endpoints (#486) --- .../src/merchant/merchant.controller.spec.ts | 181 ----- backend/src/merchant/merchant.controller.ts | 97 --- backend/src/merchant/merchant.module.ts | 17 - backend/src/merchant/merchant.service.spec.ts | 712 ------------------ backend/src/merchant/merchant.service.ts | 274 ------- .../dto/update-checklist.dto.ts | 1 - .../dto/update-merchant-settings.dto.ts | 0 .../entities/merchant-member.entity.ts | 1 - .../merchant-profile-alias.controller.ts | 77 ++ .../merchants/merchants.controller.spec.ts | 304 ++++++-- backend/src/merchants/merchants.controller.ts | 107 ++- backend/src/merchants/merchants.module.ts | 8 +- .../src/merchants/merchants.service.spec.ts | 387 ++++++++-- backend/src/merchants/merchants.service.ts | 289 ++++++- 14 files changed, 1021 insertions(+), 1434 deletions(-) delete mode 100644 backend/src/merchant/merchant.controller.spec.ts delete mode 100644 backend/src/merchant/merchant.controller.ts delete mode 100644 backend/src/merchant/merchant.module.ts delete mode 100644 backend/src/merchant/merchant.service.spec.ts delete mode 100644 backend/src/merchant/merchant.service.ts rename backend/src/{merchant => merchants}/dto/update-checklist.dto.ts (87%) rename backend/src/{merchant => merchants}/dto/update-merchant-settings.dto.ts (100%) rename backend/src/{merchant => merchants}/entities/merchant-member.entity.ts (99%) create mode 100644 backend/src/merchants/merchant-profile-alias.controller.ts diff --git a/backend/src/merchant/merchant.controller.spec.ts b/backend/src/merchant/merchant.controller.spec.ts deleted file mode 100644 index 52899c7f..00000000 --- a/backend/src/merchant/merchant.controller.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { INestApplication, ValidationPipe } from "@nestjs/common"; -import request from "supertest"; -import { MerchantController } from "./merchant.controller"; -import { MerchantService } from "./merchant.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { - jwtAuthImports, - jwtAuthProviders, - signUserToken, -} from "../auth/guard/auth-testing.util"; -import { MerchantRole } from "../common/enums/merchant-role.enum"; - -describe("MerchantController", () => { - let app: INestApplication; - let module: TestingModule; - - const mockMerchant = { - id: "merchant-1", - name: "Test Merchant", - stellarPublicKey: "GABC123", - payoutPublicKey: null, - preferredAsset: "USDC", - webhookUrl: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const mockMerchantService = { - getProfile: jest.fn().mockResolvedValue(mockMerchant), - updateSettings: jest.fn().mockResolvedValue({ - ...mockMerchant, - name: "Updated Name", - payoutPublicKey: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - preferredAsset: "EURC", - }), - updateChecklist: jest.fn().mockResolvedValue({ success: true }), - }; - - const auth = (user: { - id?: string; - merchantId?: string; - role?: MerchantRole; - }) => { - const token = signUserToken(module as any, { - id: user.id ?? "user-1", - merchantId: user.merchantId ?? "merchant-1", - role: user.role ?? MerchantRole.OWNER, - }); - return `Bearer ${token}`; - }; - - beforeAll(async () => { - module = await Test.createTestingModule({ - controllers: [MerchantController], - imports: [...jwtAuthImports], - providers: [ - { provide: MerchantService, useValue: mockMerchantService }, - { - provide: PrismaService, - useValue: { - runWithMerchantScope: (_id: string, cb: () => unknown) => cb(), - }, - }, - ...jwtAuthProviders, - ], - }).compile(); - - app = module.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ whitelist: true })); - await app.init(); - }); - - afterAll(async () => { - if (app) await app.close(); - }); - - it("GET /merchants/profile should return merchant profile for any authenticated role", async () => { - const res = await request(app.getHttpServer()) - .get("/merchants/profile") - .set("Authorization", auth({ role: MerchantRole.VIEWER })) - .expect(200); - - expect(res.body).toMatchObject({ - id: "merchant-1", - name: "Test Merchant", - preferredAsset: "USDC", - }); - }); - - it("GET /merchants/profile should reject unauthenticated requests", async () => { - await request(app.getHttpServer()).get("/merchants/profile").expect(401); - }); - - it("PATCH /merchants/settings should allow merchant owner", async () => { - const res = await request(app.getHttpServer()) - .patch("/merchants/settings") - .set("Authorization", auth({ role: MerchantRole.OWNER })) - .send({ - name: "Updated Name", - payoutPublicKey: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - preferredAsset: "EURC", - }) - .expect(200); - - expect(res.body.name).toBe("Updated Name"); - expect(res.body.preferredAsset).toBe("EURC"); - }); - - it("PATCH /merchants/settings should allow merchant admin", async () => { - await request(app.getHttpServer()) - .patch("/merchants/settings") - .set("Authorization", auth({ role: MerchantRole.ADMIN })) - .send({ name: "Updated Name" }) - .expect(200); - }); - - it("PATCH /merchants/settings should forbid viewer", async () => { - await request(app.getHttpServer()) - .patch("/merchants/settings") - .set("Authorization", auth({ role: MerchantRole.VIEWER })) - .send({ name: "Nope" }) - .expect(403); - }); - - it("PATCH /merchants/settings should forbid operator", async () => { - await request(app.getHttpServer()) - .patch("/merchants/settings") - .set("Authorization", auth({ role: MerchantRole.OPERATOR })) - .send({ name: "Nope" }) - .expect(403); - }); - - it("PATCH /merchants/settings should reject unauthenticated requests", async () => { - await request(app.getHttpServer()) - .patch("/merchants/settings") - .send({ name: "Nope" }) - .expect(401); - }); - - it("PATCH /merchants/settings should reject invalid payout key", async () => { - await request(app.getHttpServer()) - .patch("/merchants/settings") - .set("Authorization", auth({ role: MerchantRole.OWNER })) - .send({ payoutPublicKey: "INVALID_KEY" }) - .expect(400); - }); - - it("PATCH /merchants/settings should reject invalid preferredAsset", async () => { - await request(app.getHttpServer()) - .patch("/merchants/settings") - .set("Authorization", auth({ role: MerchantRole.OWNER })) - .send({ preferredAsset: "DOGE" }) - .expect(400); - }); - - it("PATCH /merchants/checklist should allow operator", async () => { - await request(app.getHttpServer()) - .patch("/merchants/checklist") - .set("Authorization", auth({ role: MerchantRole.OPERATOR })) - .send({ profileCompleted: true }) - .expect(200); - }); - - it("PATCH /merchants/checklist should forbid viewer", async () => { - await request(app.getHttpServer()) - .patch("/merchants/checklist") - .set("Authorization", auth({ role: MerchantRole.VIEWER })) - .send({ profileCompleted: true }) - .expect(403); - }); - - it("PATCH /merchants/checklist/sync should forbid viewer", async () => { - await request(app.getHttpServer()) - .patch("/merchants/checklist/sync") - .set("Authorization", auth({ role: MerchantRole.VIEWER })) - .expect(403); - }); -}); diff --git a/backend/src/merchant/merchant.controller.ts b/backend/src/merchant/merchant.controller.ts deleted file mode 100644 index 248e65b2..00000000 --- a/backend/src/merchant/merchant.controller.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Controller, Get, Patch, Body, UseGuards } from "@nestjs/common"; -import { Auth, CurrentUser } from "../auth/guard/auth.guard"; -import { User } from "../users/user.entity"; -import { MerchantService } from "./merchant.service"; -import { UpdateMerchantSettingsDto } from "./dto/update-merchant-settings.dto"; -import { UpdateChecklistDto } from "./dto/update-checklist.dto"; -import { PrismaService } from "../prisma/prisma.service"; -import { Roles } from "../common/decorators/roles.decorator"; -import { MerchantRole } from "../common/enums/merchant-role.enum"; -import { MerchantRolesGuard } from "../common/guards/merchant-roles.guard"; - -/** - * MerchantController - * Exposes merchant profile and settings management endpoints. - * All routes require JWT authentication; the merchant is scoped - * to the authenticated user's merchantId. - */ -@Controller("merchants") -export class MerchantController { - constructor( - private readonly merchantService: MerchantService, - private readonly prisma: PrismaService, - ) {} - - /** - * GET /merchants/profile - * Returns the merchant profile for the authenticated user. - */ - @Auth() - @Get("profile") - async getProfile(@CurrentUser() user: User) { - return this.prisma.runWithMerchantScope(user.merchantId, () => - this.merchantService.getProfile(user.merchantId), - ); - } - - /** - * PATCH /merchants/settings - * Updates merchant settings (name, payout key, preferred asset, webhook). - * Validates Stellar public key format before persisting. - * Restricted to merchant OWNERs and ADMINs. - */ - @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) - @UseGuards(MerchantRolesGuard) - @Patch("settings") - async updateSettings( - @CurrentUser() user: User, - @Body() dto: UpdateMerchantSettingsDto, - ) { - return this.prisma.runWithMerchantScope(user.merchantId, () => - this.merchantService.updateSettings(user.merchantId, dto), - ); - } - - /** - * GET /merchants/checklist - * Returns the activation checklist for the authenticated merchant. - */ - @Auth() - @Get("checklist") - async getChecklist(@CurrentUser() user: User) { - return this.prisma.runWithMerchantScope(user.merchantId, () => - this.merchantService.getChecklist(user.merchantId), - ); - } - - /** - * PATCH /merchants/checklist - * Updates checklist completion status. - * Restricted to merchant OWNERs, ADMINs and OPERATORs. - */ - @Roles(MerchantRole.OWNER, MerchantRole.ADMIN, MerchantRole.OPERATOR) - @UseGuards(MerchantRolesGuard) - @Patch("checklist") - async updateChecklist( - @CurrentUser() user: User, - @Body() dto: UpdateChecklistDto, - ) { - return this.prisma.runWithMerchantScope(user.merchantId, () => - this.merchantService.updateChecklist(user.merchantId, dto), - ); - } - - /** - * POST /merchants/checklist/sync - * Syncs checklist based on current merchant state. - * Restricted to merchant OWNERs, ADMINs and OPERATORs. - */ - @Roles(MerchantRole.OWNER, MerchantRole.ADMIN, MerchantRole.OPERATOR) - @UseGuards(MerchantRolesGuard) - @Patch("checklist/sync") - async syncChecklist(@CurrentUser() user: User) { - return this.prisma.runWithMerchantScope(user.merchantId, () => - this.merchantService.syncChecklist(user.merchantId), - ); - } -} diff --git a/backend/src/merchant/merchant.module.ts b/backend/src/merchant/merchant.module.ts deleted file mode 100644 index fc9f842d..00000000 --- a/backend/src/merchant/merchant.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Module } from "@nestjs/common"; -import { MerchantController } from "./merchant.controller"; -import { MerchantService } from "./merchant.service"; -import { PrismaModule } from "../prisma/prisma.module"; -import { AuthModule } from "../auth/auth.module"; - -/** - * MerchantModule - * Provides merchant profile and settings management. - */ -@Module({ - imports: [PrismaModule, AuthModule], - controllers: [MerchantController], - providers: [MerchantService], - exports: [MerchantService], -}) -export class MerchantModule {} diff --git a/backend/src/merchant/merchant.service.spec.ts b/backend/src/merchant/merchant.service.spec.ts deleted file mode 100644 index a66120e4..00000000 --- a/backend/src/merchant/merchant.service.spec.ts +++ /dev/null @@ -1,712 +0,0 @@ -/** - * merchant.service.spec.ts - * - * Unit tests for MerchantService.syncChecklist and updateSettings. - * - * Focus: verifying that default/auto-generated values do NOT inflate the - * onboarding checklist, while intentionally configured values DO complete - * the corresponding steps. - * - * Three merchant states are exercised: - * 1. Empty / fresh-signup — only placeholder defaults, no real data - * 2. Default-only — still only defaults (same as fresh; confirms - * the "XLM" asset default and placeholder name - * never auto-complete steps) - * 3. Fully configured — all four steps meaningfully completed - * - * Additional cases cover partial configuration and updateSettings stamp - * behaviour for nameConfiguredAt / assetConfiguredAt. - */ - -import { NotFoundException, BadRequestException } from "@nestjs/common"; -import { PrismaService } from "../prisma/prisma.service"; -import { MerchantService } from "./merchant.service"; - -// ── Helpers ──────────────────────────────────────────────────────────────── - -/** - * Build a minimal Merchant record as Prisma would return it. - * All optional/nullable fields default to the "nothing configured" state - * so individual tests only need to override what they care about. - */ -function merchantFixture( - overrides: Partial<{ - id: string; - name: string; - stellarPublicKey: string; - nameConfiguredAt: Date | null; - assetConfiguredAt: Date | null; - payoutWallet: string | null; - preferredAsset: string; - invoices: unknown[]; - }> = {}, -) { - return { - id: "merchant-test", - name: "Merchant GABC12", // auto-generated placeholder - stellarPublicKey: - "GABC123456789012345678901234567890123456789012345678901234", - nameConfiguredAt: null, // NOT configured yet - assetConfiguredAt: null, // NOT configured yet - payoutWallet: null, - preferredAsset: "XLM", // schema default - invoices: [], - ...overrides, - }; -} - -/** - * Build a minimal MerchantActivationChecklist record. - */ -function checklistFixture( - overrides: Partial<{ - id: string; - merchantId: string; - profileCompleted: boolean; - payoutKeyCompleted: boolean; - assetPreferenceCompleted: boolean; - firstInvoiceCompleted: boolean; - isCompleted: boolean; - completedAt: Date | null; - }> = {}, -) { - return { - id: "checklist-1", - merchantId: "merchant-test", - profileCompleted: false, - payoutKeyCompleted: false, - assetPreferenceCompleted: false, - firstInvoiceCompleted: false, - isCompleted: false, - completedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -// ── Mock factory ─────────────────────────────────────────────────────────── - -/** - * Creates a Prisma mock preconfigured for a given merchant state. - * Stores the "database" in plain objects so tests can assert on them. - */ -function buildPrismaMock( - merchantData: ReturnType, - checklistData?: ReturnType, -) { - // Simulate DB with a mutable reference - const db = { - merchant: { ...merchantData }, - checklist: checklistData - ? { ...checklistData } - : (null as ReturnType | null), - }; - - const prisma = { - merchant: { - findUnique: jest.fn(async ({ where }: { where: { id?: string } }) => { - if (where.id !== db.merchant.id) return null; - return db.merchant; - }), - update: jest.fn( - async ({ - where, - data, - }: { - where: { id: string }; - data: Record; - }) => { - if (where.id !== db.merchant.id) throw new Error("Not found"); - Object.assign(db.merchant, data); - return db.merchant; - }, - ), - }, - merchantActivationChecklist: { - findUnique: jest.fn( - async ({ where }: { where: { merchantId: string } }) => { - if (!db.checklist || db.checklist.merchantId !== where.merchantId) { - return null; - } - return db.checklist; - }, - ), - create: jest.fn(async ({ data }: { data: { merchantId: string } }) => { - db.checklist = checklistFixture({ merchantId: data.merchantId }); - return db.checklist; - }), - update: jest.fn( - async ({ - where, - data, - }: { - where: { merchantId: string }; - data: Record; - }) => { - if (!db.checklist || db.checklist.merchantId !== where.merchantId) { - throw new Error("Checklist not found"); - } - Object.assign(db.checklist, data); - return db.checklist; - }, - ), - }, - _db: db, // expose for assertions - } as unknown as PrismaService & { _db: typeof db }; - - return prisma; -} - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe("MerchantService.syncChecklist — checklist completion rules", () => { - describe("fresh merchant (empty / default-only state)", () => { - it("leaves all checklist steps incomplete when the merchant has only default values", async () => { - const merchant = merchantFixture(); // all defaults, no intentional config - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(false); - expect(result.assetPreferenceCompleted).toBe(false); - expect(result.payoutKeyCompleted).toBe(false); - expect(result.firstInvoiceCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - }); - - it("does not write to the DB when nothing changed", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - await service.syncChecklist("merchant-test"); - - // updateChecklist → merchantActivationChecklist.update should NOT be called - expect(prisma.merchantActivationChecklist.update).not.toHaveBeenCalled(); - }); - - it("creates the checklist if it does not exist yet (first sync for a new merchant)", async () => { - const merchant = merchantFixture(); - // No checklist in DB yet - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - await service.syncChecklist("merchant-test"); - - expect(prisma.merchantActivationChecklist.create).toHaveBeenCalledWith( - expect.objectContaining({ data: { merchantId: "merchant-test" } }), - ); - }); - }); - - describe("default-only state — explicit verification that defaults never auto-complete", () => { - it("a non-empty name string alone does NOT complete profileCompleted", async () => { - // Old (buggy) logic: `name.length > 0` was truthy — caught by this test - const merchant = merchantFixture({ - name: "Merchant GABC12", // placeholder, nameConfiguredAt is still null - nameConfiguredAt: null, - }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(false); - }); - - it("the default 'XLM' preferredAsset alone does NOT complete assetPreferenceCompleted", async () => { - // Old (buggy) logic: `preferredAsset` was truthy → auto-completed - const merchant = merchantFixture({ - preferredAsset: "XLM", // schema default, assetConfiguredAt is still null - assetConfiguredAt: null, - }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.assetPreferenceCompleted).toBe(false); - }); - - it("any non-null preferredAsset alone does NOT complete assetPreferenceCompleted without assetConfiguredAt", async () => { - // Even if somehow a non-XLM asset ended up in the DB without going through - // updateSettings (e.g. a data migration), no timestamp → not completed. - const merchant = merchantFixture({ - preferredAsset: "USDC", - assetConfiguredAt: null, // no explicit save - }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.assetPreferenceCompleted).toBe(false); - }); - }); - - describe("partially configured merchant", () => { - it("completes only profileCompleted when nameConfiguredAt is set", async () => { - const now = new Date(); - const merchant = merchantFixture({ nameConfiguredAt: now }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(true); - expect(result.assetPreferenceCompleted).toBe(false); - expect(result.payoutKeyCompleted).toBe(false); - expect(result.firstInvoiceCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - }); - - it("completes only assetPreferenceCompleted when assetConfiguredAt is set", async () => { - const now = new Date(); - const merchant = merchantFixture({ - assetConfiguredAt: now, - preferredAsset: "USDC", - }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(false); - expect(result.assetPreferenceCompleted).toBe(true); - expect(result.payoutKeyCompleted).toBe(false); - }); - - it("completes only payoutKeyCompleted when payoutWallet is set", async () => { - const merchant = merchantFixture({ - payoutWallet: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.payoutKeyCompleted).toBe(true); - expect(result.profileCompleted).toBe(false); - expect(result.assetPreferenceCompleted).toBe(false); - }); - - it("completes only firstInvoiceCompleted when at least one invoice exists", async () => { - const merchant = merchantFixture({ invoices: [{ id: "inv-1" }] }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.firstInvoiceCompleted).toBe(true); - expect(result.profileCompleted).toBe(false); - expect(result.assetPreferenceCompleted).toBe(false); - expect(result.payoutKeyCompleted).toBe(false); - }); - }); - - describe("fully configured merchant", () => { - it("completes all steps and marks isCompleted when everything is set", async () => { - const now = new Date(); - const merchant = merchantFixture({ - nameConfiguredAt: now, - assetConfiguredAt: now, - preferredAsset: "USDC", - payoutWallet: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - invoices: [{ id: "inv-1" }], - }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(true); - expect(result.assetPreferenceCompleted).toBe(true); - expect(result.payoutKeyCompleted).toBe(true); - expect(result.firstInvoiceCompleted).toBe(true); - expect(result.isCompleted).toBe(true); - expect(result.completedAt).not.toBeNull(); - }); - }); - - describe("error handling", () => { - it("throws NotFoundException when merchant does not exist", async () => { - const merchant = merchantFixture({ id: "different-id" }); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - await expect( - service.syncChecklist("nonexistent-id"), - ).rejects.toBeInstanceOf(NotFoundException); - }); - }); -}); - -// ── updateSettings stamp tests ───────────────────────────────────────────── - -describe("MerchantService.updateSettings — configuration timestamp stamping", () => { - it("stamps nameConfiguredAt when name is updated", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - await service.updateSettings("merchant-test", { name: "My Real Business" }); - - const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; - expect(updateCall.data.name).toBe("My Real Business"); - expect(updateCall.data.nameConfiguredAt).toBeInstanceOf(Date); - }); - - it("stamps assetConfiguredAt when preferredAsset is updated", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - await service.updateSettings("merchant-test", { preferredAsset: "USDC" }); - - const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; - expect(updateCall.data.preferredAsset).toBe("USDC"); - expect(updateCall.data.assetConfiguredAt).toBeInstanceOf(Date); - }); - - it("does NOT stamp nameConfiguredAt when name is not in the DTO", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - // Only updating webhookUrl — name is untouched - await service.updateSettings("merchant-test", { - webhookUrl: "https://example.com/wh", - }); - - const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; - expect(updateCall.data.nameConfiguredAt).toBeUndefined(); - }); - - it("does NOT stamp assetConfiguredAt when preferredAsset is not in the DTO", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - // Only updating name - await service.updateSettings("merchant-test", { name: "New Name" }); - - const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; - expect(updateCall.data.assetConfiguredAt).toBeUndefined(); - }); - - it("throws NotFoundException when merchant does not exist", async () => { - const merchant = merchantFixture({ id: "other-id" }); - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - await expect( - service.updateSettings("nonexistent-id", { name: "Test" }), - ).rejects.toBeInstanceOf(NotFoundException); - }); - - it("throws BadRequestException for an invalid Stellar payout public key", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant); - const service = new MerchantService(prisma); - - await expect( - service.updateSettings("merchant-test", { - payoutPublicKey: "NOT_A_STELLAR_KEY", - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); -}); - -// ── syncChecklist × updateSettings integration path ──────────────────────── - -describe("MerchantService — updateSettings → syncChecklist round-trip", () => { - it("fresh merchant: profile step stays incomplete before updateSettings, completes after", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - // Before explicit name save - const before = await service.syncChecklist("merchant-test"); - expect(before.profileCompleted).toBe(false); - - // Simulate updateSettings stamping nameConfiguredAt - ( - prisma._db as { merchant: ReturnType } - ).merchant.nameConfiguredAt = new Date(); - - // After explicit name save - const after = await service.syncChecklist("merchant-test"); - expect(after.profileCompleted).toBe(true); - }); - - it("fresh merchant: asset step stays incomplete before updateSettings, completes after", async () => { - const merchant = merchantFixture(); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - const before = await service.syncChecklist("merchant-test"); - expect(before.assetPreferenceCompleted).toBe(false); - - // Simulate updateSettings stamping assetConfiguredAt - ( - prisma._db as { merchant: ReturnType } - ).merchant.assetConfiguredAt = new Date(); - ( - prisma._db as { merchant: ReturnType } - ).merchant.preferredAsset = "EURC"; - - const after = await service.syncChecklist("merchant-test"); - expect(after.assetPreferenceCompleted).toBe(true); - }); -}); - -// ── Checklist Reopening & Regression Tests ───────────────────────────────── - -describe("MerchantService — checklist reopening on setup regression", () => { - const completedDate = new Date("2026-08-01T12:00:00Z"); - - const completedMerchant = () => - merchantFixture({ - name: "Acme Corp", - nameConfiguredAt: new Date("2026-08-01T10:00:00Z"), - assetConfiguredAt: new Date("2026-08-01T10:00:00Z"), - preferredAsset: "USDC", - payoutWallet: "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - invoices: [{ id: "inv-1" }], - }); - - const completedChecklist = () => - checklistFixture({ - profileCompleted: true, - payoutKeyCompleted: true, - assetPreferenceCompleted: true, - firstInvoiceCompleted: true, - isCompleted: true, - completedAt: completedDate, - }); - - describe("updateChecklist direct regression", () => { - it("revokes isCompleted and clears completedAt when profileCompleted is set to false", async () => { - const prisma = buildPrismaMock(completedMerchant(), completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.updateChecklist("merchant-test", { - profileCompleted: false, - }); - - expect(result.profileCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - expect(result.payoutKeyCompleted).toBe(true); - expect(result.assetPreferenceCompleted).toBe(true); - expect(result.firstInvoiceCompleted).toBe(true); - }); - - it("revokes isCompleted and clears completedAt when payoutKeyCompleted is set to false", async () => { - const prisma = buildPrismaMock(completedMerchant(), completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.updateChecklist("merchant-test", { - payoutKeyCompleted: false, - }); - - expect(result.payoutKeyCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - - it("revokes isCompleted and clears completedAt when assetPreferenceCompleted is set to false", async () => { - const prisma = buildPrismaMock(completedMerchant(), completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.updateChecklist("merchant-test", { - assetPreferenceCompleted: false, - }); - - expect(result.assetPreferenceCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - - it("revokes isCompleted and clears completedAt when firstInvoiceCompleted is set to false", async () => { - const prisma = buildPrismaMock(completedMerchant(), completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.updateChecklist("merchant-test", { - firstInvoiceCompleted: false, - }); - - expect(result.firstInvoiceCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - }); - - describe("syncChecklist regression when merchant setup is undone", () => { - it("reopens checklist when payout wallet is removed", async () => { - const merchant = completedMerchant(); - merchant.payoutWallet = null; // payout wallet undone - - const prisma = buildPrismaMock(merchant, completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.payoutKeyCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - expect(result.profileCompleted).toBe(true); - expect(result.assetPreferenceCompleted).toBe(true); - expect(result.firstInvoiceCompleted).toBe(true); - }); - - it("reopens checklist when merchant name is cleared to empty string", async () => { - const merchant = completedMerchant(); - merchant.name = ""; // empty name - - const prisma = buildPrismaMock(merchant, completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - - it("reopens checklist when nameConfiguredAt is reset to null", async () => { - const merchant = completedMerchant(); - merchant.nameConfiguredAt = null; - - const prisma = buildPrismaMock(merchant, completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.profileCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - - it("reopens checklist when assetConfiguredAt is reset to null", async () => { - const merchant = completedMerchant(); - merchant.assetConfiguredAt = null; - - const prisma = buildPrismaMock(merchant, completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.assetPreferenceCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - - it("reopens checklist when invoices list becomes empty", async () => { - const merchant = completedMerchant(); - merchant.invoices = []; // invoices removed/deleted - - const prisma = buildPrismaMock(merchant, completedChecklist()); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.firstInvoiceCompleted).toBe(false); - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - - it("reopens checklist if database checklist isCompleted flag was stale/inconsistent", async () => { - const merchant = completedMerchant(); - merchant.payoutWallet = null; - - // Checklist record has payoutKeyCompleted already false in DB, but isCompleted was true (stale) - const staleChecklist = checklistFixture({ - profileCompleted: true, - payoutKeyCompleted: false, - assetPreferenceCompleted: true, - firstInvoiceCompleted: true, - isCompleted: true, - completedAt: completedDate, - }); - - const prisma = buildPrismaMock(merchant, staleChecklist); - const service = new MerchantService(prisma); - - const result = await service.syncChecklist("merchant-test"); - - expect(result.isCompleted).toBe(false); - expect(result.completedAt).toBeNull(); - }); - }); - - describe("full lifecycle: completion -> regression -> re-completion", () => { - it("completes, reopens on regression, and re-completes with a new timestamp when fixed", async () => { - const merchant = completedMerchant(); - const prisma = buildPrismaMock(merchant, checklistFixture()); - const service = new MerchantService(prisma); - - // Phase 1: Initial sync completing all steps - const phase1 = await service.syncChecklist("merchant-test"); - expect(phase1.isCompleted).toBe(true); - expect(phase1.completedAt).toBeInstanceOf(Date); - const initialCompletedAt = phase1.completedAt; - - // Phase 2: Merchant removes payout wallet (regression) - ( - prisma._db as { merchant: ReturnType } - ).merchant.payoutWallet = null; - - const phase2 = await service.syncChecklist("merchant-test"); - expect(phase2.payoutKeyCompleted).toBe(false); - expect(phase2.isCompleted).toBe(false); - expect(phase2.completedAt).toBeNull(); - - // Phase 3: Idempotent sync while in regressed state (no DB writes) - (prisma.merchantActivationChecklist.update as jest.Mock).mockClear(); - const phase3 = await service.syncChecklist("merchant-test"); - expect(phase3.isCompleted).toBe(false); - expect(phase3.completedAt).toBeNull(); - expect(prisma.merchantActivationChecklist.update).not.toHaveBeenCalled(); - - // Phase 4: Merchant reconfigures payout wallet (re-completion) - ( - prisma._db as { merchant: ReturnType } - ).merchant.payoutWallet = - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R"; - - const phase4 = await service.syncChecklist("merchant-test"); - expect(phase4.payoutKeyCompleted).toBe(true); - expect(phase4.isCompleted).toBe(true); - expect(phase4.completedAt).toBeInstanceOf(Date); - expect(phase4.completedAt).not.toBe(initialCompletedAt); - }); - }); - - describe("updateSettings clearing name invalidates profileCompleted and reopens checklist", () => { - it("resets nameConfiguredAt to null when name is updated to empty string", async () => { - const merchant = completedMerchant(); - const prisma = buildPrismaMock(merchant, completedChecklist()); - const service = new MerchantService(prisma); - - await service.updateSettings("merchant-test", { name: " " }); - - const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; - expect(updateCall.data.nameConfiguredAt).toBeNull(); - - // Simulate DB updated with nameConfiguredAt: null - ( - prisma._db as { merchant: ReturnType } - ).merchant.nameConfiguredAt = null; - - const syncResult = await service.syncChecklist("merchant-test"); - expect(syncResult.profileCompleted).toBe(false); - expect(syncResult.isCompleted).toBe(false); - expect(syncResult.completedAt).toBeNull(); - }); - }); -}); diff --git a/backend/src/merchant/merchant.service.ts b/backend/src/merchant/merchant.service.ts deleted file mode 100644 index 4f63c233..00000000 --- a/backend/src/merchant/merchant.service.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, -} from "@nestjs/common"; -import { PrismaService } from "../prisma/prisma.service"; -import { UpdateMerchantSettingsDto } from "./dto/update-merchant-settings.dto"; -import { UpdateChecklistDto } from "./dto/update-checklist.dto"; - -/** - * MerchantService - * Provides merchant profile and settings management. - */ -@Injectable() -export class MerchantService { - constructor(private readonly prisma: PrismaService) {} - - /** - * Get merchant profile by merchant ID. - * Returns the merchant record with relevant settings fields. - */ - async getProfile(merchantId: string) { - const merchant = await this.prisma.merchant.findUnique({ - where: { id: merchantId }, - select: { - id: true, - name: true, - stellarPublicKey: true, - payoutWallet: true, - preferredAsset: true, - webhookUrl: true, - createdAt: true, - updatedAt: true, - }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant not found"); - } - - return this.toSettingsResponse(merchant); - } - - /** - * Update merchant settings (name, payout public key, preferred asset, webhook URL). - * Validates the payout public key format before persisting. - * - * When a name or preferredAsset is explicitly saved, the corresponding - * "configuredAt" timestamp is stamped so the checklist can distinguish - * intentional configuration from auto-generated defaults. - */ - async updateSettings(merchantId: string, dto: UpdateMerchantSettingsDto) { - // Verify the merchant exists - const existing = await this.prisma.merchant.findUnique({ - where: { id: merchantId }, - }); - - if (!existing) { - throw new NotFoundException("Merchant not found"); - } - - // Additional Stellar key validation beyond DTO regex (checksum-level) - if (dto.payoutPublicKey) { - try { - const StellarSdk = await import("@stellar/stellar-sdk"); - StellarSdk.Keypair.fromPublicKey(dto.payoutPublicKey); - } catch { - throw new BadRequestException( - "payoutPublicKey failed Stellar checksum validation", - ); - } - } - - const now = new Date(); - - const updated = await this.prisma.merchant.update({ - where: { id: merchantId }, - data: { - ...(dto.name !== undefined && { - name: dto.name, - // Stamp the configuration timestamp so checklist knows this was - // an intentional update, not an auto-generated placeholder. - // If name is emptied, revoke the configuration timestamp. - nameConfiguredAt: dto.name.trim().length > 0 ? now : null, - }), - ...(dto.payoutPublicKey !== undefined && { - payoutWallet: dto.payoutPublicKey || null, - }), - ...(dto.preferredAsset !== undefined && { - preferredAsset: dto.preferredAsset, - // Same intent tracking for the asset choice. - assetConfiguredAt: now, - }), - ...(dto.webhookUrl !== undefined && { webhookUrl: dto.webhookUrl }), - }, - select: { - id: true, - name: true, - stellarPublicKey: true, - payoutWallet: true, - preferredAsset: true, - webhookUrl: true, - createdAt: true, - updatedAt: true, - }, - }); - - return this.toSettingsResponse(updated); - } - - /** - * Get or create merchant activation checklist - */ - async getChecklist(merchantId: string) { - let checklist = await this.prisma.merchantActivationChecklist.findUnique({ - where: { merchantId }, - }); - - // Create checklist if it doesn't exist - if (!checklist) { - checklist = await this.prisma.merchantActivationChecklist.create({ - data: { merchantId }, - }); - } - - return checklist; - } - - /** - * Update checklist completion status - */ - async updateChecklist(merchantId: string, dto: UpdateChecklistDto) { - const checklist = await this.prisma.merchantActivationChecklist.findUnique({ - where: { merchantId }, - }); - - if (!checklist) { - throw new NotFoundException("Checklist not found"); - } - - const updated = await this.prisma.merchantActivationChecklist.update({ - where: { merchantId }, - data: { - ...(dto.profileCompleted !== undefined && { - profileCompleted: dto.profileCompleted, - }), - ...(dto.payoutKeyCompleted !== undefined && { - payoutKeyCompleted: dto.payoutKeyCompleted, - }), - ...(dto.assetPreferenceCompleted !== undefined && { - assetPreferenceCompleted: dto.assetPreferenceCompleted, - }), - ...(dto.firstInvoiceCompleted !== undefined && { - firstInvoiceCompleted: dto.firstInvoiceCompleted, - }), - }, - }); - - // Check if all steps are completed - const allCompleted = - updated.profileCompleted && - updated.payoutKeyCompleted && - updated.assetPreferenceCompleted && - updated.firstInvoiceCompleted; - - if (allCompleted && !updated.isCompleted) { - return this.prisma.merchantActivationChecklist.update({ - where: { merchantId }, - data: { - isCompleted: true, - completedAt: new Date(), - }, - }); - } - - if (!allCompleted && updated.isCompleted) { - return this.prisma.merchantActivationChecklist.update({ - where: { merchantId }, - data: { - isCompleted: false, - completedAt: null, - }, - }); - } - - return updated; - } - - /** - * Auto-update checklist based on merchant state. - * - * Completion rules (intentional-configuration-only): - * - profileCompleted: nameConfiguredAt is NOT NULL and name is non-empty - * (name was explicitly saved, not the auto-generated placeholder) - * - assetPreferenceCompleted: assetConfiguredAt is NOT NULL - * (asset was explicitly chosen, not left at the schema default "XLM") - * - payoutKeyCompleted: payoutWallet is NOT NULL and non-empty - * - firstInvoiceCompleted: at least one invoice exists - * - * Reopening / Regression: - * - If previously completed steps become invalid/undone, completion is revoked: - * isCompleted is set to false and completedAt is cleared (null). - */ - async syncChecklist(merchantId: string) { - const merchant = await this.prisma.merchant.findUnique({ - where: { id: merchantId }, - include: { invoices: { take: 1 } }, - }); - - if (!merchant) { - throw new NotFoundException("Merchant not found"); - } - - const checklist = await this.getChecklist(merchantId); - - const targetProfileCompleted = - merchant.nameConfiguredAt !== null && - merchant.name !== null && - merchant.name.trim().length > 0; - const targetPayoutKeyCompleted = - merchant.payoutWallet !== null && merchant.payoutWallet.trim().length > 0; - const targetAssetPreferenceCompleted = merchant.assetConfiguredAt !== null; - const targetFirstInvoiceCompleted = merchant.invoices.length > 0; - - const updates: Partial<{ - profileCompleted: boolean; - payoutKeyCompleted: boolean; - assetPreferenceCompleted: boolean; - firstInvoiceCompleted: boolean; - }> = {}; - - if (checklist.profileCompleted !== targetProfileCompleted) { - updates.profileCompleted = targetProfileCompleted; - } - - if (checklist.payoutKeyCompleted !== targetPayoutKeyCompleted) { - updates.payoutKeyCompleted = targetPayoutKeyCompleted; - } - - if (checklist.assetPreferenceCompleted !== targetAssetPreferenceCompleted) { - updates.assetPreferenceCompleted = targetAssetPreferenceCompleted; - } - - if (checklist.firstInvoiceCompleted !== targetFirstInvoiceCompleted) { - updates.firstInvoiceCompleted = targetFirstInvoiceCompleted; - } - - const allCompleted = - targetProfileCompleted && - targetPayoutKeyCompleted && - targetAssetPreferenceCompleted && - targetFirstInvoiceCompleted; - - // Only write to the DB when there are actual step changes or overall completion status is out of sync. - if ( - Object.keys(updates).length > 0 || - checklist.isCompleted !== allCompleted - ) { - return this.updateChecklist(merchantId, updates); - } - - return checklist; - } - - private toSettingsResponse( - merchant: T, - ): Omit & { payoutPublicKey: string | null } { - const { payoutWallet, ...rest } = merchant; - return { - ...rest, - payoutPublicKey: payoutWallet, - }; - } -} diff --git a/backend/src/merchant/dto/update-checklist.dto.ts b/backend/src/merchants/dto/update-checklist.dto.ts similarity index 87% rename from backend/src/merchant/dto/update-checklist.dto.ts rename to backend/src/merchants/dto/update-checklist.dto.ts index 16780ed8..e209090c 100644 --- a/backend/src/merchant/dto/update-checklist.dto.ts +++ b/backend/src/merchants/dto/update-checklist.dto.ts @@ -1,4 +1,3 @@ -import { PartialType } from "@nestjs/mapped-types"; import { IsBoolean, IsOptional } from "class-validator"; export class UpdateChecklistDto { diff --git a/backend/src/merchant/dto/update-merchant-settings.dto.ts b/backend/src/merchants/dto/update-merchant-settings.dto.ts similarity index 100% rename from backend/src/merchant/dto/update-merchant-settings.dto.ts rename to backend/src/merchants/dto/update-merchant-settings.dto.ts diff --git a/backend/src/merchant/entities/merchant-member.entity.ts b/backend/src/merchants/entities/merchant-member.entity.ts similarity index 99% rename from backend/src/merchant/entities/merchant-member.entity.ts rename to backend/src/merchants/entities/merchant-member.entity.ts index 8a343c2d..69332e28 100644 --- a/backend/src/merchant/entities/merchant-member.entity.ts +++ b/backend/src/merchants/entities/merchant-member.entity.ts @@ -1,5 +1,4 @@ import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"; - import { MerchantRole } from "../../common/enums/merchant-role.enum"; @Entity("merchant_members") diff --git a/backend/src/merchants/merchant-profile-alias.controller.ts b/backend/src/merchants/merchant-profile-alias.controller.ts new file mode 100644 index 00000000..ab4a6776 --- /dev/null +++ b/backend/src/merchants/merchant-profile-alias.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Patch, + Put, + UseGuards, +} from "@nestjs/common"; +import { Auth, CurrentUser } from "../auth/guard/auth.guard"; +import { User } from "../users/user.entity"; +import { + UpdateMerchantProfileDto, + UpsertMerchantProfileDto, +} from "./dto/merchant-profile.dto"; +import { MerchantsService } from "./merchants.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { Roles } from "../common/decorators/roles.decorator"; +import { MerchantRole } from "../common/enums/merchant-role.enum"; +import { MerchantRolesGuard } from "../common/guards/merchant-roles.guard"; + +/** + * MerchantProfileAliasController + * Deprecated alias controller re-routing legacy calls from `/merchant/profile` + * to the consolidated `MerchantsService` logic for backwards compatibility. + */ +@Controller("merchant/profile") +@UseGuards(MerchantRolesGuard) +export class MerchantProfileAliasController { + constructor( + private readonly merchantsService: MerchantsService, + private readonly prisma: PrismaService, + ) {} + + /** + * GET /merchant/profile + * Deprecated alias for GET /merchants/profile. + */ + @Auth() + @Get() + async getProfile(@CurrentUser() user: User) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.findProfile(user.merchantId), + ); + } + + /** + * PUT /merchant/profile + * Deprecated alias for PUT /merchants/profile. + * Restricted to merchant OWNERs and ADMINs. + */ + @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) + @Put() + async upsertProfile( + @CurrentUser() user: User, + @Body() dto: UpsertMerchantProfileDto, + ) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.upsertProfile(user.merchantId, dto), + ); + } + + /** + * PATCH /merchant/profile + * Deprecated alias for PATCH /merchants/profile. + * Restricted to merchant OWNERs and ADMINs. + */ + @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) + @Patch() + async updateProfile( + @CurrentUser() user: User, + @Body() dto: UpdateMerchantProfileDto, + ) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.updateProfile(user.merchantId, dto), + ); + } +} diff --git a/backend/src/merchants/merchants.controller.spec.ts b/backend/src/merchants/merchants.controller.spec.ts index 936e9cfb..047b4d78 100644 --- a/backend/src/merchants/merchants.controller.spec.ts +++ b/backend/src/merchants/merchants.controller.spec.ts @@ -1,7 +1,8 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { INestApplication } from "@nestjs/common"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; import request from "supertest"; import { MerchantsController } from "./merchants.controller"; +import { MerchantProfileAliasController } from "./merchant-profile-alias.controller"; import { MerchantsService } from "./merchants.service"; import { PrismaService } from "../prisma/prisma.service"; import { @@ -11,7 +12,7 @@ import { } from "../auth/guard/auth-testing.util"; import { MerchantRole } from "../common/enums/merchant-role.enum"; -describe("MerchantsController (merchant profile RBAC)", () => { +describe("MerchantsController & AliasController", () => { let app: INestApplication; let module: TestingModule; @@ -22,23 +23,40 @@ describe("MerchantsController (merchant profile RBAC)", () => { businessEmail: "admin@example.com", preferredAsset: "USDC", payoutWallet: null, + payoutPublicKey: null, + webhookUrl: null, createdAt: new Date(), updatedAt: new Date(), }; const mockMerchantsService = { + getProfile: jest.fn().mockResolvedValue(mockProfile), findProfile: jest.fn().mockResolvedValue(mockProfile), upsertProfile: jest.fn().mockResolvedValue(mockProfile), updateProfile: jest.fn().mockResolvedValue({ ...mockProfile, name: "Updated Name", }), + updateSettings: jest.fn().mockResolvedValue({ + ...mockProfile, + name: "Updated Name", + payoutPublicKey: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + preferredAsset: "EURC", + }), + getChecklist: jest.fn().mockResolvedValue({ id: "checklist-1" }), + updateChecklist: jest.fn().mockResolvedValue({ success: true }), + syncChecklist: jest.fn().mockResolvedValue({ success: true }), }; - const auth = (user: { role?: MerchantRole }) => { + const auth = (user: { + id?: string; + merchantId?: string; + role?: MerchantRole; + }) => { const token = signUserToken(module as any, { - id: "user-1", - merchantId: "merchant-1", + id: user.id ?? "user-1", + merchantId: user.merchantId ?? "merchant-1", role: user.role ?? MerchantRole.OWNER, }); return `Bearer ${token}`; @@ -46,7 +64,7 @@ describe("MerchantsController (merchant profile RBAC)", () => { beforeAll(async () => { module = await Test.createTestingModule({ - controllers: [MerchantsController], + controllers: [MerchantsController, MerchantProfileAliasController], imports: [...jwtAuthImports], providers: [ { provide: MerchantsService, useValue: mockMerchantsService }, @@ -61,6 +79,7 @@ describe("MerchantsController (merchant profile RBAC)", () => { }).compile(); app = module.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init(); }); @@ -68,71 +87,228 @@ describe("MerchantsController (merchant profile RBAC)", () => { if (app) await app.close(); }); - it("GET /merchant/profile should allow viewer (read-only)", async () => { - const res = await request(app.getHttpServer()) - .get("/merchant/profile") - .set("Authorization", auth({ role: MerchantRole.VIEWER })) - .expect(200); + describe("Primary Routes (/merchants/*)", () => { + it("GET /merchants/profile should return merchant profile for any authenticated role", async () => { + const res = await request(app.getHttpServer()) + .get("/merchants/profile") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .expect(200); - expect(res.body).toMatchObject({ id: "merchant-1" }); - }); + expect(res.body).toMatchObject({ + id: "merchant-1", + name: "Test Merchant", + preferredAsset: "USDC", + }); + }); - it("PATCH /merchant/profile should allow merchant owner", async () => { - const res = await request(app.getHttpServer()) - .patch("/merchant/profile") - .set("Authorization", auth({ role: MerchantRole.OWNER })) - .send({ name: "Updated Name" }) - .expect(200); + it("GET /merchants/profile should reject unauthenticated requests", async () => { + await request(app.getHttpServer()).get("/merchants/profile").expect(401); + }); - expect(res.body.name).toBe("Updated Name"); - }); + it("PUT /merchants/profile should allow owner & admin", async () => { + await request(app.getHttpServer()) + .put("/merchants/profile") + .set("Authorization", auth({ role: MerchantRole.ADMIN })) + .send({ + name: "Test Merchant", + businessEmail: "admin@example.com", + preferredAsset: "USDC", + payoutWallet: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + }) + .expect(200); + }); - it("PATCH /merchant/profile should forbid viewer", async () => { - await request(app.getHttpServer()) - .patch("/merchant/profile") - .set("Authorization", auth({ role: MerchantRole.VIEWER })) - .send({ name: "Nope" }) - .expect(403); - }); + it("PUT /merchants/profile should forbid operator", async () => { + await request(app.getHttpServer()) + .put("/merchants/profile") + .set("Authorization", auth({ role: MerchantRole.OPERATOR })) + .send({ + name: "Test Merchant", + businessEmail: "admin@example.com", + preferredAsset: "USDC", + payoutWallet: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + }) + .expect(403); + }); - it("PUT /merchant/profile should allow merchant admin", async () => { - await request(app.getHttpServer()) - .put("/merchant/profile") - .set("Authorization", auth({ role: MerchantRole.ADMIN })) - .send({ - name: "Test Merchant", - businessEmail: "admin@example.com", - preferredAsset: "USDC", - payoutWallet: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - }) - .expect(200); - }); + it("PATCH /merchants/profile should allow merchant owner", async () => { + const res = await request(app.getHttpServer()) + .patch("/merchants/profile") + .set("Authorization", auth({ role: MerchantRole.OWNER })) + .send({ name: "Updated Name" }) + .expect(200); - it("PUT /merchant/profile should forbid operator", async () => { - await request(app.getHttpServer()) - .put("/merchant/profile") - .set("Authorization", auth({ role: MerchantRole.OPERATOR })) - .send({ - name: "Test Merchant", - businessEmail: "admin@example.com", - preferredAsset: "USDC", - payoutWallet: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - }) - .expect(403); + expect(res.body.name).toBe("Updated Name"); + }); + + it("PATCH /merchants/profile should forbid viewer", async () => { + await request(app.getHttpServer()) + .patch("/merchants/profile") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .send({ name: "Nope" }) + .expect(403); + }); + + it("PATCH /merchants/settings should allow merchant owner", async () => { + const res = await request(app.getHttpServer()) + .patch("/merchants/settings") + .set("Authorization", auth({ role: MerchantRole.OWNER })) + .send({ + name: "Updated Name", + payoutPublicKey: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + preferredAsset: "EURC", + }) + .expect(200); + + expect(res.body.name).toBe("Updated Name"); + expect(res.body.preferredAsset).toBe("EURC"); + }); + + it("PATCH /merchants/settings should allow merchant admin", async () => { + await request(app.getHttpServer()) + .patch("/merchants/settings") + .set("Authorization", auth({ role: MerchantRole.ADMIN })) + .send({ name: "Updated Name" }) + .expect(200); + }); + + it("PATCH /merchants/settings should forbid viewer", async () => { + await request(app.getHttpServer()) + .patch("/merchants/settings") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .send({ name: "Nope" }) + .expect(403); + }); + + it("PATCH /merchants/settings should forbid operator", async () => { + await request(app.getHttpServer()) + .patch("/merchants/settings") + .set("Authorization", auth({ role: MerchantRole.OPERATOR })) + .send({ name: "Nope" }) + .expect(403); + }); + + it("PATCH /merchants/settings should reject unauthenticated requests", async () => { + await request(app.getHttpServer()) + .patch("/merchants/settings") + .send({ name: "Nope" }) + .expect(401); + }); + + it("PATCH /merchants/settings should reject invalid payout key", async () => { + await request(app.getHttpServer()) + .patch("/merchants/settings") + .set("Authorization", auth({ role: MerchantRole.OWNER })) + .send({ payoutPublicKey: "INVALID_KEY" }) + .expect(400); + }); + + it("PATCH /merchants/settings should reject invalid preferredAsset", async () => { + await request(app.getHttpServer()) + .patch("/merchants/settings") + .set("Authorization", auth({ role: MerchantRole.OWNER })) + .send({ preferredAsset: "DOGE" }) + .expect(400); + }); + + it("PATCH /merchants/checklist should allow operator", async () => { + await request(app.getHttpServer()) + .patch("/merchants/checklist") + .set("Authorization", auth({ role: MerchantRole.OPERATOR })) + .send({ profileCompleted: true }) + .expect(200); + }); + + it("PATCH /merchants/checklist should forbid viewer", async () => { + await request(app.getHttpServer()) + .patch("/merchants/checklist") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .send({ profileCompleted: true }) + .expect(403); + }); + + it("PATCH /merchants/checklist/sync should forbid viewer", async () => { + await request(app.getHttpServer()) + .patch("/merchants/checklist/sync") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .expect(403); + }); }); - it("PUT /merchant/profile should reject unauthenticated requests", async () => { - await request(app.getHttpServer()) - .put("/merchant/profile") - .send({ - name: "Test Merchant", - businessEmail: "admin@example.com", - preferredAsset: "USDC", - payoutWallet: - "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", - }) - .expect(401); + describe("Deprecated Alias Routes (/merchant/profile)", () => { + it("GET /merchant/profile should allow viewer (read-only)", async () => { + const res = await request(app.getHttpServer()) + .get("/merchant/profile") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .expect(200); + + expect(res.body).toMatchObject({ id: "merchant-1" }); + }); + + it("GET /merchant/profile should reject unauthenticated requests", async () => { + await request(app.getHttpServer()).get("/merchant/profile").expect(401); + }); + + it("PATCH /merchant/profile should allow merchant owner", async () => { + const res = await request(app.getHttpServer()) + .patch("/merchant/profile") + .set("Authorization", auth({ role: MerchantRole.OWNER })) + .send({ name: "Updated Name" }) + .expect(200); + + expect(res.body.name).toBe("Updated Name"); + }); + + it("PATCH /merchant/profile should forbid viewer", async () => { + await request(app.getHttpServer()) + .patch("/merchant/profile") + .set("Authorization", auth({ role: MerchantRole.VIEWER })) + .send({ name: "Nope" }) + .expect(403); + }); + + it("PUT /merchant/profile should allow merchant admin", async () => { + await request(app.getHttpServer()) + .put("/merchant/profile") + .set("Authorization", auth({ role: MerchantRole.ADMIN })) + .send({ + name: "Test Merchant", + businessEmail: "admin@example.com", + preferredAsset: "USDC", + payoutWallet: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + }) + .expect(200); + }); + + it("PUT /merchant/profile should forbid operator", async () => { + await request(app.getHttpServer()) + .put("/merchant/profile") + .set("Authorization", auth({ role: MerchantRole.OPERATOR })) + .send({ + name: "Test Merchant", + businessEmail: "admin@example.com", + preferredAsset: "USDC", + payoutWallet: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + }) + .expect(403); + }); + + it("PUT /merchant/profile should reject unauthenticated requests", async () => { + await request(app.getHttpServer()) + .put("/merchant/profile") + .send({ + name: "Test Merchant", + businessEmail: "admin@example.com", + preferredAsset: "USDC", + payoutWallet: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + }) + .expect(401); + }); }); }); diff --git a/backend/src/merchants/merchants.controller.ts b/backend/src/merchants/merchants.controller.ts index 56526e0e..3109dd03 100644 --- a/backend/src/merchants/merchants.controller.ts +++ b/backend/src/merchants/merchants.controller.ts @@ -1,63 +1,136 @@ -import { Body, Controller, Get, Patch, Put, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Patch, + Put, + UseGuards, +} from "@nestjs/common"; import { Auth, CurrentUser } from "../auth/guard/auth.guard"; import { User } from "../users/user.entity"; import { UpdateMerchantProfileDto, UpsertMerchantProfileDto, } from "./dto/merchant-profile.dto"; -import { MerchantProfile } from "./entities/merchant-profile.entity"; +import { UpdateMerchantSettingsDto } from "./dto/update-merchant-settings.dto"; +import { UpdateChecklistDto } from "./dto/update-checklist.dto"; import { MerchantsService } from "./merchants.service"; import { PrismaService } from "../prisma/prisma.service"; import { Roles } from "../common/decorators/roles.decorator"; import { MerchantRole } from "../common/enums/merchant-role.enum"; import { MerchantRolesGuard } from "../common/guards/merchant-roles.guard"; -@Controller("merchant/profile") +/** + * MerchantsController + * Primary controller exposing merchant profile, settings, and checklist management endpoints. + * Base route: /merchants + */ +@Controller("merchants") +@UseGuards(MerchantRolesGuard) export class MerchantsController { constructor( private readonly merchantsService: MerchantsService, private readonly prisma: PrismaService, ) {} + /** + * GET /merchants/profile + * Returns the merchant profile for the authenticated user. + */ @Auth() - @Get() - getProfile(@CurrentUser() user: User): Promise { + @Get("profile") + async getProfile(@CurrentUser() user: User) { return this.prisma.runWithMerchantScope(user.merchantId, () => - this.merchantsService.findProfile(user.merchantId), + this.merchantsService.getProfile(user.merchantId), ); } /** - * PUT /merchant/profile + * PUT /merchants/profile * Creates or replaces the merchant profile. * Restricted to merchant OWNERs and ADMINs. */ @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) - @UseGuards(MerchantRolesGuard) - @Put() - upsertProfile( + @Put("profile") + async upsertProfile( @CurrentUser() user: User, @Body() dto: UpsertMerchantProfileDto, - ): Promise { + ) { return this.prisma.runWithMerchantScope(user.merchantId, () => this.merchantsService.upsertProfile(user.merchantId, dto), ); } /** - * PATCH /merchant/profile - * Updates the merchant profile. + * PATCH /merchants/profile + * Updates partial merchant profile. * Restricted to merchant OWNERs and ADMINs. */ @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) - @UseGuards(MerchantRolesGuard) - @Patch() - updateProfile( + @Patch("profile") + async updateProfile( @CurrentUser() user: User, @Body() dto: UpdateMerchantProfileDto, - ): Promise { + ) { return this.prisma.runWithMerchantScope(user.merchantId, () => this.merchantsService.updateProfile(user.merchantId, dto), ); } + + /** + * PATCH /merchants/settings + * Updates merchant settings (name, payout key, preferred asset, webhook). + * Restricted to merchant OWNERs and ADMINs. + */ + @Roles(MerchantRole.OWNER, MerchantRole.ADMIN) + @Patch("settings") + async updateSettings( + @CurrentUser() user: User, + @Body() dto: UpdateMerchantSettingsDto, + ) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.updateSettings(user.merchantId, dto), + ); + } + + /** + * GET /merchants/checklist + * Returns the activation checklist for the authenticated merchant. + */ + @Auth() + @Get("checklist") + async getChecklist(@CurrentUser() user: User) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.getChecklist(user.merchantId), + ); + } + + /** + * PATCH /merchants/checklist + * Updates checklist completion status. + * Restricted to merchant OWNERs, ADMINs and OPERATORs. + */ + @Roles(MerchantRole.OWNER, MerchantRole.ADMIN, MerchantRole.OPERATOR) + @Patch("checklist") + async updateChecklist( + @CurrentUser() user: User, + @Body() dto: UpdateChecklistDto, + ) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.updateChecklist(user.merchantId, dto), + ); + } + + /** + * PATCH /merchants/checklist/sync + * Syncs checklist based on current merchant state. + * Restricted to merchant OWNERs, ADMINs and OPERATORs. + */ + @Roles(MerchantRole.OWNER, MerchantRole.ADMIN, MerchantRole.OPERATOR) + @Patch("checklist/sync") + async syncChecklist(@CurrentUser() user: User) { + return this.prisma.runWithMerchantScope(user.merchantId, () => + this.merchantsService.syncChecklist(user.merchantId), + ); + } } diff --git a/backend/src/merchants/merchants.module.ts b/backend/src/merchants/merchants.module.ts index 2b193456..e161e80a 100644 --- a/backend/src/merchants/merchants.module.ts +++ b/backend/src/merchants/merchants.module.ts @@ -1,12 +1,16 @@ import { Module } from "@nestjs/common"; import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; import { MerchantsController } from "./merchants.controller"; +import { MerchantProfileAliasController } from "./merchant-profile-alias.controller"; import { MerchantsService } from "./merchants.service"; @Module({ - imports: [PrismaModule], - controllers: [MerchantsController], + imports: [PrismaModule, AuthModule], + controllers: [MerchantsController, MerchantProfileAliasController], providers: [MerchantsService], exports: [MerchantsService], }) export class MerchantsModule {} + +export { MerchantsModule as MerchantModule }; diff --git a/backend/src/merchants/merchants.service.spec.ts b/backend/src/merchants/merchants.service.spec.ts index 5cdc4c3e..3746c823 100644 --- a/backend/src/merchants/merchants.service.spec.ts +++ b/backend/src/merchants/merchants.service.spec.ts @@ -1,88 +1,373 @@ -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; import { MerchantsService } from "./merchants.service"; import { PrismaService } from "../prisma/prisma.service"; import { StellarValidator } from "../stellar/utils/stellar.validator"; -describe("MerchantsService", () => { - const merchantId = "merchant-1"; - const payoutWallet = StellarValidator.generateKeypair().publicKey; +// ── Helpers ──────────────────────────────────────────────────────────────── - const merchant = { - id: merchantId, - name: "Acme Studio", - stellarPublicKey: StellarValidator.generateKeypair().publicKey, +function merchantFixture( + overrides: Partial<{ + id: string; + name: string; + stellarPublicKey: string; + businessEmail: string | null; + nameConfiguredAt: Date | null; + assetConfiguredAt: Date | null; + payoutWallet: string | null; + preferredAsset: string; + webhookUrl: string | null; + invoices: unknown[]; + }> = {}, +) { + return { + id: "merchant-test", + name: "Merchant GABC12", // auto-generated placeholder + stellarPublicKey: + "GABC123456789012345678901234567890123456789012345678901234", businessEmail: "billing@acme.test", - preferredAsset: "USDC", - payoutWallet, + nameConfiguredAt: null, + assetConfiguredAt: null, + payoutWallet: null, + preferredAsset: "XLM", webhookUrl: null, + invoices: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function checklistFixture( + overrides: Partial<{ + id: string; + merchantId: string; + profileCompleted: boolean; + payoutKeyCompleted: boolean; + assetPreferenceCompleted: boolean; + firstInvoiceCompleted: boolean; + isCompleted: boolean; + completedAt: Date | null; + }> = {}, +) { + return { + id: "checklist-1", + merchantId: "merchant-test", + profileCompleted: false, + payoutKeyCompleted: false, + assetPreferenceCompleted: false, + firstInvoiceCompleted: false, + isCompleted: false, + completedAt: null, createdAt: new Date(), updatedAt: new Date(), + ...overrides, + }; +} + +function buildPrismaMock( + merchantData: ReturnType, + checklistData?: ReturnType, +) { + const db = { + merchant: { ...merchantData }, + checklist: checklistData + ? { ...checklistData } + : (null as ReturnType | null), }; const prisma = { merchant: { - findUniqueOrThrow: jest.fn(), - update: jest.fn(), + findUnique: jest.fn(async ({ where }: { where: { id?: string } }) => { + if (where.id !== db.merchant.id) return null; + return db.merchant; + }), + findUniqueOrThrow: jest.fn(async ({ where }: { where: { id?: string } }) => { + if (where.id !== db.merchant.id) throw new NotFoundException("Merchant not found"); + return db.merchant; + }), + update: jest.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + if (where.id !== db.merchant.id) throw new Error("Not found"); + Object.assign(db.merchant, data); + return db.merchant; + }, + ), }, - }; + merchantActivationChecklist: { + findUnique: jest.fn( + async ({ where }: { where: { merchantId: string } }) => { + if (!db.checklist || db.checklist.merchantId !== where.merchantId) { + return null; + } + return db.checklist; + }, + ), + create: jest.fn(async ({ data }: { data: { merchantId: string } }) => { + db.checklist = checklistFixture({ merchantId: data.merchantId }); + return db.checklist; + }), + update: jest.fn( + async ({ + where, + data, + }: { + where: { merchantId: string }; + data: Record; + }) => { + if (!db.checklist || db.checklist.merchantId !== where.merchantId) { + throw new Error("Checklist not found"); + } + Object.assign(db.checklist, data); + return db.checklist; + }, + ), + }, + _db: db, + } as unknown as PrismaService & { _db: typeof db }; - let service: MerchantsService; + return prisma; +} - beforeEach(() => { - jest.clearAllMocks(); - prisma.merchant.findUniqueOrThrow.mockResolvedValue(merchant); - prisma.merchant.update.mockResolvedValue(merchant); - service = new MerchantsService(prisma as unknown as PrismaService); - }); +// ── Tests ────────────────────────────────────────────────────────────────── - it("returns the merchant profile", async () => { - await expect(service.findProfile(merchantId)).resolves.toEqual(merchant); - expect(prisma.merchant.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { id: merchantId }, - }); - }); +describe("MerchantsService", () => { + describe("Profile & Settings CRUD", () => { + const merchantId = "merchant-1"; + const payoutWallet = StellarValidator.generateKeypair().publicKey; - it("creates or replaces profile setup data", async () => { - await service.upsertProfile(merchantId, { + const merchant = merchantFixture({ + id: merchantId, name: "Acme Studio", - businessEmail: "billing@acme.test", - preferredAsset: "usdc", payoutWallet, }); - expect(prisma.merchant.update).toHaveBeenCalledWith({ - where: { id: merchantId }, - data: { + const prisma = { + merchant: { + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + update: jest.fn(), + }, + }; + + let service: MerchantsService; + + beforeEach(() => { + jest.clearAllMocks(); + prisma.merchant.findUnique.mockResolvedValue(merchant); + prisma.merchant.findUniqueOrThrow.mockResolvedValue(merchant); + prisma.merchant.update.mockResolvedValue(merchant); + service = new MerchantsService(prisma as unknown as PrismaService); + }); + + it("returns the merchant profile via findProfile", async () => { + const res = await service.findProfile(merchantId); + expect(res).toMatchObject({ id: merchantId, name: "Acme Studio" }); + expect(prisma.merchant.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: merchantId }, + }); + }); + + it("returns the merchant profile via getProfile", async () => { + const res = await service.getProfile(merchantId); + expect(res).toMatchObject({ id: merchantId, name: "Acme Studio" }); + expect(prisma.merchant.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: merchantId } }), + ); + }); + + it("creates or replaces profile setup data via upsertProfile", async () => { + await service.upsertProfile(merchantId, { name: "Acme Studio", businessEmail: "billing@acme.test", - preferredAsset: "USDC", + preferredAsset: "usdc", payoutWallet, - }, + }); + + expect(prisma.merchant.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: merchantId }, + data: expect.objectContaining({ + name: "Acme Studio", + businessEmail: "billing@acme.test", + preferredAsset: "USDC", + payoutWallet, + }), + }), + ); + }); + + it("updates partial profile setup data via updateProfile", async () => { + await service.updateProfile(merchantId, { + preferredAsset: "XLM", + payoutWallet, + }); + + expect(prisma.merchant.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: merchantId }, + data: expect.objectContaining({ + preferredAsset: "XLM", + payoutWallet, + }), + }), + ); + }); + + it("rejects invalid Stellar payout wallets before saving in updateProfile", async () => { + await expect( + service.updateProfile(merchantId, { + payoutWallet: "not-a-stellar-key", + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.merchant.update).not.toHaveBeenCalled(); }); }); - it("updates partial profile setup data", async () => { - await service.updateProfile(merchantId, { - preferredAsset: "XLM", - payoutWallet, + describe("syncChecklist — completion rules", () => { + it("leaves all checklist steps incomplete when the merchant has only default values", async () => { + const merchant = merchantFixture(); + const prisma = buildPrismaMock(merchant, checklistFixture()); + const service = new MerchantsService(prisma); + + const result = await service.syncChecklist("merchant-test"); + + expect(result.profileCompleted).toBe(false); + expect(result.assetPreferenceCompleted).toBe(false); + expect(result.payoutKeyCompleted).toBe(false); + expect(result.firstInvoiceCompleted).toBe(false); + expect(result.isCompleted).toBe(false); + }); + + it("does not write to the DB when nothing changed", async () => { + const merchant = merchantFixture(); + const prisma = buildPrismaMock(merchant, checklistFixture()); + const service = new MerchantsService(prisma); + + await service.syncChecklist("merchant-test"); + + expect(prisma.merchantActivationChecklist.update).not.toHaveBeenCalled(); }); - expect(prisma.merchant.update).toHaveBeenCalledWith({ - where: { id: merchantId }, - data: { + it("creates the checklist if it does not exist yet", async () => { + const merchant = merchantFixture(); + const prisma = buildPrismaMock(merchant); + const service = new MerchantsService(prisma); + + await service.syncChecklist("merchant-test"); + + expect(prisma.merchantActivationChecklist.create).toHaveBeenCalledWith( + expect.objectContaining({ data: { merchantId: "merchant-test" } }), + ); + }); + }); + + describe("default-only state — defaults never auto-complete", () => { + it("a non-empty name string alone does NOT complete profileCompleted without timestamp", async () => { + const merchant = merchantFixture({ + name: "Merchant GABC12", + nameConfiguredAt: null, + }); + const prisma = buildPrismaMock(merchant, checklistFixture()); + const service = new MerchantsService(prisma); + + const result = await service.syncChecklist("merchant-test"); + + expect(result.profileCompleted).toBe(false); + }); + + it("the default 'XLM' preferredAsset alone does NOT complete assetPreferenceCompleted", async () => { + const merchant = merchantFixture({ preferredAsset: "XLM", - payoutWallet, - }, + assetConfiguredAt: null, + }); + const prisma = buildPrismaMock(merchant, checklistFixture()); + const service = new MerchantsService(prisma); + + const result = await service.syncChecklist("merchant-test"); + + expect(result.assetPreferenceCompleted).toBe(false); }); }); - it("rejects invalid Stellar payout wallets before saving", async () => { - await expect( - service.updateProfile(merchantId, { - payoutWallet: "not-a-stellar-key", - }), - ).rejects.toBeInstanceOf(BadRequestException); + describe("partially & fully configured merchant", () => { + it("completes profileCompleted when nameConfiguredAt is set", async () => { + const now = new Date(); + const merchant = merchantFixture({ nameConfiguredAt: now }); + const prisma = buildPrismaMock(merchant, checklistFixture()); + const service = new MerchantsService(prisma); - expect(prisma.merchant.update).not.toHaveBeenCalled(); + const result = await service.syncChecklist("merchant-test"); + + expect(result.profileCompleted).toBe(true); + expect(result.isCompleted).toBe(false); + }); + + it("completes all steps and marks isCompleted when everything is set", async () => { + const now = new Date(); + const merchant = merchantFixture({ + nameConfiguredAt: now, + assetConfiguredAt: now, + preferredAsset: "USDC", + payoutWallet: + "GCKFBEIYTKGLP4V4EMMZHHQVBNHGVTCNQJOWP4SUXFJTMW74VDAD5Z6R", + invoices: [{ id: "inv-1" }], + }); + const prisma = buildPrismaMock(merchant, checklistFixture()); + const service = new MerchantsService(prisma); + + const result = await service.syncChecklist("merchant-test"); + + expect(result.profileCompleted).toBe(true); + expect(result.assetPreferenceCompleted).toBe(true); + expect(result.payoutKeyCompleted).toBe(true); + expect(result.firstInvoiceCompleted).toBe(true); + expect(result.isCompleted).toBe(true); + expect(result.completedAt).not.toBeNull(); + }); + }); + + describe("updateSettings — configuration timestamp stamping", () => { + it("stamps nameConfiguredAt when name is updated", async () => { + const merchant = merchantFixture(); + const prisma = buildPrismaMock(merchant); + const service = new MerchantsService(prisma); + + await service.updateSettings("merchant-test", { name: "My Real Business" }); + + const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; + expect(updateCall.data.name).toBe("My Real Business"); + expect(updateCall.data.nameConfiguredAt).toBeInstanceOf(Date); + }); + + it("stamps assetConfiguredAt when preferredAsset is updated", async () => { + const merchant = merchantFixture(); + const prisma = buildPrismaMock(merchant); + const service = new MerchantsService(prisma); + + await service.updateSettings("merchant-test", { preferredAsset: "USDC" }); + + const updateCall = (prisma.merchant.update as jest.Mock).mock.calls[0][0]; + expect(updateCall.data.preferredAsset).toBe("USDC"); + expect(updateCall.data.assetConfiguredAt).toBeInstanceOf(Date); + }); + + it("throws BadRequestException for invalid Stellar payout key", async () => { + const merchant = merchantFixture(); + const prisma = buildPrismaMock(merchant); + const service = new MerchantsService(prisma); + + await expect( + service.updateSettings("merchant-test", { + payoutPublicKey: "NOT_A_STELLAR_KEY", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); }); diff --git a/backend/src/merchants/merchants.service.ts b/backend/src/merchants/merchants.service.ts index d775a55d..1cf46a53 100644 --- a/backend/src/merchants/merchants.service.ts +++ b/backend/src/merchants/merchants.service.ts @@ -1,60 +1,306 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { + Injectable, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; +import { UpdateMerchantSettingsDto } from "./dto/update-merchant-settings.dto"; +import { UpdateChecklistDto } from "./dto/update-checklist.dto"; import { UpdateMerchantProfileDto, UpsertMerchantProfileDto, } from "./dto/merchant-profile.dto"; -import { MerchantProfile } from "./entities/merchant-profile.entity"; import { StellarValidator } from "../stellar/utils/stellar.validator"; +/** + * MerchantsService + * Provides merchant profile, settings, and activation checklist management. + */ @Injectable() export class MerchantsService { constructor(private readonly prisma: PrismaService) {} - async findProfile(merchantId: string): Promise { - return this.prisma.merchant.findUniqueOrThrow({ + /** + * Get merchant profile by merchant ID. + */ + async getProfile(merchantId: string) { + const merchant = await this.prisma.merchant.findUnique({ + where: { id: merchantId }, + select: { + id: true, + name: true, + stellarPublicKey: true, + businessEmail: true, + payoutWallet: true, + preferredAsset: true, + webhookUrl: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!merchant) { + throw new NotFoundException("Merchant not found"); + } + + return this.toProfileResponse(merchant); + } + + /** + * Find profile by merchant ID (alias for getProfile / findUniqueOrThrow). + */ + async findProfile(merchantId: string) { + const merchant = await this.prisma.merchant.findUniqueOrThrow({ where: { id: merchantId }, }); + return this.toProfileResponse(merchant); } - async upsertProfile( - merchantId: string, - dto: UpsertMerchantProfileDto, - ): Promise { + /** + * Update merchant settings (name, payout public key, preferred asset, webhook URL). + */ + async updateSettings(merchantId: string, dto: UpdateMerchantSettingsDto) { + const existing = await this.prisma.merchant.findUnique({ + where: { id: merchantId }, + }); + + if (!existing) { + throw new NotFoundException("Merchant not found"); + } + + if (dto.payoutPublicKey) { + if (!StellarValidator.isValidPublicKey(dto.payoutPublicKey)) { + throw new BadRequestException( + "payoutPublicKey failed Stellar checksum validation", + ); + } + } + + const now = new Date(); + + const updated = await this.prisma.merchant.update({ + where: { id: merchantId }, + data: { + ...(dto.name !== undefined && { + name: dto.name, + nameConfiguredAt: dto.name.trim().length > 0 ? now : null, + }), + ...(dto.payoutPublicKey !== undefined && { + payoutWallet: dto.payoutPublicKey || null, + }), + ...(dto.preferredAsset !== undefined && { + preferredAsset: dto.preferredAsset, + assetConfiguredAt: now, + }), + ...(dto.webhookUrl !== undefined && { webhookUrl: dto.webhookUrl }), + }, + select: { + id: true, + name: true, + stellarPublicKey: true, + businessEmail: true, + payoutWallet: true, + preferredAsset: true, + webhookUrl: true, + createdAt: true, + updatedAt: true, + }, + }); + + return this.toProfileResponse(updated); + } + + /** + * Creates or replaces the merchant profile. + */ + async upsertProfile(merchantId: string, dto: UpsertMerchantProfileDto) { this.assertValidPayoutWallet(dto.payoutWallet); - return this.prisma.merchant.update({ + const now = new Date(); + const updated = await this.prisma.merchant.update({ where: { id: merchantId }, data: { name: dto.name, + nameConfiguredAt: dto.name.trim().length > 0 ? now : null, businessEmail: dto.businessEmail, preferredAsset: this.normalizeAsset(dto.preferredAsset), + assetConfiguredAt: now, payoutWallet: dto.payoutWallet, }, }); + + return this.toProfileResponse(updated); } - async updateProfile( - merchantId: string, - dto: UpdateMerchantProfileDto, - ): Promise { + /** + * Updates partial merchant profile. + */ + async updateProfile(merchantId: string, dto: UpdateMerchantProfileDto) { if (dto.payoutWallet !== undefined) { this.assertValidPayoutWallet(dto.payoutWallet); } + const now = new Date(); const data: Prisma.MerchantUpdateInput = {}; - if (dto.name !== undefined) data.name = dto.name; - if (dto.businessEmail !== undefined) data.businessEmail = dto.businessEmail; + + if (dto.name !== undefined) { + data.name = dto.name; + data.nameConfiguredAt = dto.name.trim().length > 0 ? now : null; + } + if (dto.businessEmail !== undefined) { + data.businessEmail = dto.businessEmail; + } if (dto.preferredAsset !== undefined) { data.preferredAsset = this.normalizeAsset(dto.preferredAsset); + data.assetConfiguredAt = now; + } + if (dto.payoutWallet !== undefined) { + data.payoutWallet = dto.payoutWallet; } - if (dto.payoutWallet !== undefined) data.payoutWallet = dto.payoutWallet; - return this.prisma.merchant.update({ + const updated = await this.prisma.merchant.update({ where: { id: merchantId }, data, }); + + return this.toProfileResponse(updated); + } + + /** + * Get or create merchant activation checklist. + */ + async getChecklist(merchantId: string) { + let checklist = await this.prisma.merchantActivationChecklist.findUnique({ + where: { merchantId }, + }); + + if (!checklist) { + checklist = await this.prisma.merchantActivationChecklist.create({ + data: { merchantId }, + }); + } + + return checklist; + } + + /** + * Update checklist completion status. + */ + async updateChecklist(merchantId: string, dto: UpdateChecklistDto) { + const checklist = await this.prisma.merchantActivationChecklist.findUnique({ + where: { merchantId }, + }); + + if (!checklist) { + throw new NotFoundException("Checklist not found"); + } + + const updated = await this.prisma.merchantActivationChecklist.update({ + where: { merchantId }, + data: { + ...(dto.profileCompleted !== undefined && { + profileCompleted: dto.profileCompleted, + }), + ...(dto.payoutKeyCompleted !== undefined && { + payoutKeyCompleted: dto.payoutKeyCompleted, + }), + ...(dto.assetPreferenceCompleted !== undefined && { + assetPreferenceCompleted: dto.assetPreferenceCompleted, + }), + ...(dto.firstInvoiceCompleted !== undefined && { + firstInvoiceCompleted: dto.firstInvoiceCompleted, + }), + }, + }); + + const allCompleted = + updated.profileCompleted && + updated.payoutKeyCompleted && + updated.assetPreferenceCompleted && + updated.firstInvoiceCompleted; + + if (allCompleted && !updated.isCompleted) { + return this.prisma.merchantActivationChecklist.update({ + where: { merchantId }, + data: { + isCompleted: true, + completedAt: new Date(), + }, + }); + } + + if (!allCompleted && updated.isCompleted) { + return this.prisma.merchantActivationChecklist.update({ + where: { merchantId }, + data: { + isCompleted: false, + completedAt: null, + }, + }); + } + + return updated; + } + + /** + * Auto-update checklist based on merchant state. + */ + async syncChecklist(merchantId: string) { + const merchant = await this.prisma.merchant.findUnique({ + where: { id: merchantId }, + include: { invoices: { take: 1 } }, + }); + + if (!merchant) { + throw new NotFoundException("Merchant not found"); + } + + const checklist = await this.getChecklist(merchantId); + + const targetProfileCompleted = + merchant.nameConfiguredAt !== null && + merchant.name !== null && + merchant.name.trim().length > 0; + const targetPayoutKeyCompleted = + merchant.payoutWallet !== null && merchant.payoutWallet.trim().length > 0; + const targetAssetPreferenceCompleted = merchant.assetConfiguredAt !== null; + const targetFirstInvoiceCompleted = merchant.invoices.length > 0; + + const updates: Partial<{ + profileCompleted: boolean; + payoutKeyCompleted: boolean; + assetPreferenceCompleted: boolean; + firstInvoiceCompleted: boolean; + }> = {}; + + if (checklist.profileCompleted !== targetProfileCompleted) { + updates.profileCompleted = targetProfileCompleted; + } + if (checklist.payoutKeyCompleted !== targetPayoutKeyCompleted) { + updates.payoutKeyCompleted = targetPayoutKeyCompleted; + } + if (checklist.assetPreferenceCompleted !== targetAssetPreferenceCompleted) { + updates.assetPreferenceCompleted = targetAssetPreferenceCompleted; + } + if (checklist.firstInvoiceCompleted !== targetFirstInvoiceCompleted) { + updates.firstInvoiceCompleted = targetFirstInvoiceCompleted; + } + + const allCompleted = + targetProfileCompleted && + targetPayoutKeyCompleted && + targetAssetPreferenceCompleted && + targetFirstInvoiceCompleted; + + if ( + Object.keys(updates).length > 0 || + checklist.isCompleted !== allCompleted + ) { + return this.updateChecklist(merchantId, updates); + } + + return checklist; } private assertValidPayoutWallet(payoutWallet: string): void { @@ -68,4 +314,13 @@ export class MerchantsService { private normalizeAsset(assetCode: string): string { return assetCode.toUpperCase(); } + + private toProfileResponse( + merchant: T, + ): T & { payoutPublicKey: string | null } { + return { + ...merchant, + payoutPublicKey: merchant.payoutWallet, + }; + } }