From fe8ffbeb9fc1376b2774a08b73b80662c4ec6960 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 17 Aug 2026 21:05:29 -0400 Subject: [PATCH] update payment method, remove stripe, paypal, amanapay --- .env.docker.dev | 6 - .env.docker.production.example | 9 - .env.example | 19 -- .vscode/mcp.json | 7 +- MANUAL_SUBSCRIPTION_PAYMENTS.md | 2 +- apps/admin/src/app/dashboard/billing/page.tsx | 9 +- apps/api/package.json | 1 - apps/api/src/app.ts | 2 - apps/api/src/lib/emailTranslations.test.ts | 5 +- apps/api/src/lib/emailTranslations.ts | 7 +- .../admin/admin.manual-payments.service.ts | 1 - apps/api/src/modules/admin/admin.repo.ts | 3 +- .../src/modules/auth/auth.account.schemas.ts | 1 - .../src/modules/auth/auth.company.schemas.ts | 1 - .../modules/auth/auth.company.service.test.ts | 2 - .../src/modules/auth/auth.company.service.ts | 1 - .../auth/auth.role-specific-schemas.test.ts | 6 +- .../api/src/modules/auth/auth.schemas.test.ts | 2 - .../modules/billing/billing.schemas.test.ts | 18 +- .../src/modules/billing/billing.schemas.ts | 2 +- .../modules/billing/billing.service.test.ts | 2 +- .../src/modules/billing/billing.service.ts | 4 +- .../companies/company.presenter.test.ts | 98 +------- .../modules/companies/company.presenter.ts | 8 +- .../companies/company.schemas.edge.test.ts | 1 - .../src/modules/companies/company.schemas.ts | 4 - .../companies/company.service.edge.test.ts | 39 +-- .../src/modules/companies/company.service.ts | 24 +- .../api/src/modules/companies/company.test.ts | 3 - .../modules/companies/settingsEntitlements.ts | 6 +- .../src/modules/operations.schemas.test.ts | 16 +- apps/api/src/modules/payments/payment.repo.ts | 96 +------- .../src/modules/payments/payment.routes.ts | 66 +---- .../payments/payment.schemas.edge.test.ts | 22 +- .../src/modules/payments/payment.schemas.ts | 14 +- .../modules/payments/payment.service.test.ts | 230 ++---------------- .../src/modules/payments/payment.service.ts | 215 +--------------- .../reservation.presenter.boundary.test.ts | 6 +- .../reservation.schemas.edge.test.ts | 9 + .../reservations/reservation.schemas.ts | 6 +- apps/api/src/modules/search/search.service.ts | 4 +- .../src/modules/site/site.presenter.test.ts | 8 - apps/api/src/modules/site/site.presenter.ts | 3 - .../src/modules/site/site.repo.edge.test.ts | 42 ---- apps/api/src/modules/site/site.repo.ts | 26 -- apps/api/src/modules/site/site.routes.ts | 23 +- .../api/src/modules/site/site.schemas.test.ts | 9 +- apps/api/src/modules/site/site.schemas.ts | 10 - .../site/site.service.boundary.test.ts | 48 +--- apps/api/src/modules/site/site.service.ts | 104 -------- apps/api/src/modules/site/site.test.ts | 116 +-------- .../subscription.collections.service.ts | 13 +- .../subscription.manual.service.ts | 170 +------------ .../subscriptions/subscription.repo.ts | 38 --- .../subscriptions/subscription.routes.ts | 76 +----- .../subscription.schemas.edge.test.ts | 40 +-- .../subscriptions/subscription.schemas.ts | 23 -- .../subscription.service.edge.test.ts | 100 -------- .../subscriptions/subscription.service.ts | 209 ---------------- apps/api/src/security/webhookIdempotency.ts | 12 - .../services/financialReportService.test.ts | 4 +- .../src/services/invoicePdfService.test.ts | 2 +- apps/api/src/swagger/openapi.ts | 30 +-- .../src/tests/api/auth-boundaries.api.test.ts | 2 - .../api/company-configuration.api.test.ts | 4 +- apps/api/src/tests/api/payments.api.test.ts | 107 +------- .../tests/api/public-validation.api.test.ts | 26 +- .../api/site-webhook-boundaries.api.test.ts | 2 - .../subscription-team-boundaries.api.test.ts | 25 +- .../src/tests/api/subscriptions.api.test.ts | 70 +----- .../e2e/payments-webhook-public.e2e.test.ts | 13 +- .../e2e/public-validation-smoke.e2e.test.ts | 5 +- .../e2e/site-webhook-boundaries.e2e.test.ts | 2 - .../subscription-team-boundaries.e2e.test.ts | 2 +- .../e2e/subscriptions-public.e2e.test.ts | 34 +-- apps/api/src/tests/helpers/fixtures.ts | 7 +- .../integration/auth-company-signup.test.ts | 1 - .../src/tests/integration/companies.test.ts | 86 +------ .../src/tests/integration/payments.test.ts | 76 ++---- .../integration/schema-boundaries.test.ts | 17 +- .../tests/integration/subscriptions.test.ts | 58 +---- .../src/app/(dashboard)/billing/page.tsx | 12 +- .../app/(dashboard)/contracts/[id]/page.tsx | 6 +- .../(dashboard)/reservations/[id]/page.tsx | 30 ++- .../src/app/(dashboard)/settings/page.tsx | 28 +-- .../src/app/(dashboard)/subscription/page.tsx | 12 +- apps/dashboard/src/app/onboarding/page.tsx | 60 +---- .../reservations/new/ReservationWizard.tsx | 2 +- .../new/reservationWizard.copy.ts | 15 +- .../new/reservationWizard.reducer.ts | 2 +- .../new/reservationWizard.submit.test.ts | 2 +- .../new/reservationWizard.test.ts | 2 +- docs/DOCKER.md | 8 - docs/README.md | 22 +- docs/create-account-guide.md | 7 +- docs/manual-subscription-payment-plan.md | 6 +- ...RentalDriveGo_Production_Readiness_Plan.md | 4 +- docs/ops/key-rotation-drill.md | 1 - package-lock.json | 26 -- .../migration.sql | 129 ++++++++++ .../migration.sql | 66 +++++ .../migration.sql | 25 ++ packages/database/prisma/schema.prisma | 26 +- packages/database/scripts/db-deploy.cjs | 26 +- production/.env.docker.production.example | 6 - production/scripts/docker-prod-common.sh | 24 +- scripts/describe-env-values.sh | 8 - scripts/docker-prod-common.sh | 27 +- skills-lock.json | 28 +-- 109 files changed, 568 insertions(+), 2662 deletions(-) create mode 100644 packages/database/prisma/migrations/20260817190000_remove_stripe_payment_provider/migration.sql create mode 100644 packages/database/prisma/migrations/20260817200000_remove_online_payment_providers/migration.sql create mode 100644 packages/database/prisma/migrations/20260817210000_limit_billing_payment_method_types/migration.sql diff --git a/.env.docker.dev b/.env.docker.dev index fc580e6..aa10963 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -51,12 +51,6 @@ MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_NAME=RentalDriveGo -# Stripe subscription checkout -# STRIPE_API_KEY must be a Stripe secret/restricted key (sk_ or rk_). -# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_). -STRIPE_API_KEY=sk_test_51TvTsb9SpDRZn9yJyBAlUSXcTp9zpwQfhJYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00OcYvj28K -STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211 - # Manual subscription payments for local development MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true diff --git a/.env.docker.production.example b/.env.docker.production.example index b823937..103d100 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -87,15 +87,6 @@ MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_NAME=RentalDriveGo -# Stripe subscription checkout -# STRIPE_API_KEY must be a live Stripe secret/restricted key (sk_live_ or rk_live_). -# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_). -# Gitea deploys can override these from STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET secrets. -# Set STRIPE_BILLING_REQUIRED=false only when deploying before Stripe billing is ready. -STRIPE_BILLING_REQUIRED=true -STRIPE_API_KEY=placeholder -STRIPE_WEBHOOK_SECRET=placeholder - # ── Firebase push notifications (optional) ──────────────────────────────────── # FIREBASE_PROJECT_ID=your-firebase-project-id # FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com diff --git a/.env.example b/.env.example index 606b890..3c87c23 100644 --- a/.env.example +++ b/.env.example @@ -34,25 +34,6 @@ NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding -# ─── AmanPay (Primary payment provider) ─────────────────────── -# RentalDriveGo's own AmanPay account (for collecting subscription fees) -AMANPAY_MERCHANT_ID=your-amanpay-merchant-id -AMANPAY_SECRET_KEY=placeholder -AMANPAY_BASE_URL=https://api.amanpay.net -AMANPAY_WEBHOOK_SECRET=placeholder - -# ─── PayPal (Secondary payment provider) ────────────────────── -# RentalDriveGo's own PayPal account (for collecting subscription fees) -PAYPAL_CLIENT_ID=your-paypal-client-id -PAYPAL_CLIENT_SECRET=placeholder -PAYPAL_BASE_URL=https://api-m.paypal.com -# Use https://api-m.sandbox.paypal.com for sandbox -NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id - -# ─── Stripe (subscription checkout) ──────────────────────────── -# Prefer a restricted API key (rk_) with the minimum Billing/Checkout permissions. -STRIPE_API_KEY=placeholder -STRIPE_WEBHOOK_SECRET=placeholder # ─── Cloudinary (Vehicle + brand photos) ────────────────────── CLOUDINARY_CLOUD_NAME=your-cloud-name diff --git a/.vscode/mcp.json b/.vscode/mcp.json index c16c70b..6df9c17 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,8 +1,3 @@ { - "servers": { - "stripe": { - "type": "http", - "url": "https://mcp.stripe.com" - } - } + "servers": {} } diff --git a/MANUAL_SUBSCRIPTION_PAYMENTS.md b/MANUAL_SUBSCRIPTION_PAYMENTS.md index 61eb961..b3d7a15 100644 --- a/MANUAL_SUBSCRIPTION_PAYMENTS.md +++ b/MANUAL_SUBSCRIPTION_PAYMENTS.md @@ -15,7 +15,7 @@ Manual methods remain hidden unless their instructions and evidence-scanning pip ## Operational invariants - Subscription billing has one canonical `BillingInvoice`; legacy `SubscriptionInvoice` rows are compatibility links only. -- A scheduled renewal is reused by Stripe checkout rather than duplicated. +- A scheduled renewal is reused by the next manual checkout rather than duplicated. - Manual confirmation requires the full current MAD balance, matching collection method, clean same-invoice evidence, a unique normalized external reference, a unique idempotency key, a clearance timestamp, and a funds-verified attestation. - Initial purchases activate from confirmation time. Renewals extend from the original expiration, including late confirmations after suspension. - Evidence is stored privately with no public URL, quarantined before scanning, served only through authorized routes, and immutable after submission. diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx index 836320e..cb2864f 100644 --- a/apps/admin/src/app/dashboard/billing/page.tsx +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -111,7 +111,7 @@ interface BillingInvoice { createdAt: string isSubscriptionBlocking: boolean subscriptionId?: string | null - collectionMethod?: 'STRIPE' | 'BANK_TRANSFER' | 'CHECK' + collectionMethod?: 'BANK_TRANSFER' | 'CHECK' requestedPlan?: string | null requestedBillingPeriod?: string | null lineItems: InvoiceLineItem[] @@ -370,7 +370,6 @@ export default function AdminBillingPage() { submittedReference: 'Submitted reference', rejectEvidence: 'Reject evidence', confirmCleared: 'Confirm cleared manual payment', - stripeWebhookOnly: 'Stripe subscription invoices are paid only by a verified provider webhook.', method: 'Method', fullBalance: 'Full balance', authoritativeReference: 'Authoritative bank/check reference', @@ -489,7 +488,6 @@ export default function AdminBillingPage() { submittedReference: 'Référence soumise', rejectEvidence: 'Refuser le justificatif', confirmCleared: 'Confirmer le paiement manuel encaissé', - stripeWebhookOnly: 'Les factures d’abonnement Stripe sont payées uniquement par un webhook fournisseur vérifié.', method: 'Mode', fullBalance: 'Solde intégral', authoritativeReference: 'Référence bancaire/chèque faisant foi', @@ -608,7 +606,6 @@ export default function AdminBillingPage() { submittedReference: 'المرجع المقدم', rejectEvidence: 'رفض المستند', confirmCleared: 'تأكيد تحصيل الدفع اليدوي', - stripeWebhookOnly: 'لا تُدفع فواتير اشتراك Stripe إلا عبر إشعار موثّق من مزود الدفع.', method: 'الطريقة', fullBalance: 'الرصيد الكامل', authoritativeReference: 'المرجع المعتمد للتحويل/الشيك', @@ -1492,9 +1489,7 @@ export default function AdminBillingPage() { {selectedInvoice.subscriptionId ? ( selectedInvoice.collectionMethod === 'BANK_TRANSFER' || selectedInvoice.collectionMethod === 'CHECK' ? ( - ) : ( -

{copy.stripeWebhookOnly}

- ) + ) : null ) : (
{ expect(signupEmail.subject('ar')).toContain('جاهزة') }) - it('renders localized signup text with billing and provider details', () => { + it('renders localized signup text with billing details', () => { const text = signupEmail.text({ firstName: 'Aya', companyName: 'Atlas Cars', plan: 'PRO', billingPeriod: 'ANNUAL', currency: 'MAD', - paymentProvider: 'AmanPay', trialEnd, }, 'fr') expect(text).toContain('Bonjour Aya') expect(text).toContain('Atlas Cars') expect(text).toContain('Forfait : PRO (annuel)') - expect(text).toContain('Fournisseur de paiement principal : AmanPay') + expect(text).toContain('Paiements : virement bancaire ou chèque.') }) it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => { diff --git a/apps/api/src/lib/emailTranslations.ts b/apps/api/src/lib/emailTranslations.ts index 07f0cf0..461350e 100644 --- a/apps/api/src/lib/emailTranslations.ts +++ b/apps/api/src/lib/emailTranslations.ts @@ -25,7 +25,6 @@ export const signupEmail = { plan: string billingPeriod: string currency: string - paymentProvider: string trialEnd: Date }, lang: Lang): string => { const trialStr = formatDate(opts.trialEnd, lang) @@ -36,7 +35,7 @@ export const signupEmail = { `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`, `Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`, `Currency: ${opts.currency}`, - `Primary payment provider: ${opts.paymentProvider}`, + 'Payments: bank transfer or check.', `Free trial ends on ${trialStr}.`, '', 'Your workspace is ready. Sign in with the email and password you chose during signup.', @@ -49,7 +48,7 @@ export const signupEmail = { `Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`, `Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`, `Devise : ${opts.currency}`, - `Fournisseur de paiement principal : ${opts.paymentProvider}`, + 'Paiements : virement bancaire ou chèque.', `La période d'essai gratuit se termine le ${trialStr}.`, '', "Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.", @@ -62,7 +61,7 @@ export const signupEmail = { `تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`, `الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`, `العملة: ${opts.currency}`, - `مزود الدفع الرئيسي: ${opts.paymentProvider}`, + 'الدفع: تحويل بنكي أو شيك.', `تنتهي الفترة التجريبية المجانية في ${trialStr}.`, '', 'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.', diff --git a/apps/api/src/modules/admin/admin.manual-payments.service.ts b/apps/api/src/modules/admin/admin.manual-payments.service.ts index 2ca1391..bb83ab4 100644 --- a/apps/api/src/modules/admin/admin.manual-payments.service.ts +++ b/apps/api/src/modules/admin/admin.manual-payments.service.ts @@ -60,7 +60,6 @@ function fmtDate(value?: Date | string | null) { function paymentMethodLabel(method?: string | null) { if (method === 'BANK_TRANSFER') return 'Bank transfer' if (method === 'CHECK') return 'Check' - if (method === 'STRIPE') return 'Online card payment' return method ?? 'Manual payment' } diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index 6cd2f7e..e6426db 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -157,7 +157,7 @@ export async function applyCompanyUpdate( name: string slug: string address?: unknown - brand?: { paymentMethodsEnabled?: any[] | null } | null + brand?: Record | null }, ) { return prisma.$transaction(async (tx: any) => { @@ -215,7 +215,6 @@ export async function applyCompanyUpdate( companyId: id, displayName: body.brand.displayName ?? current.name, subdomain: body.brand.subdomain ?? current.slug, - paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [], ...body.brand, } as any, }) diff --git a/apps/api/src/modules/auth/auth.account.schemas.ts b/apps/api/src/modules/auth/auth.account.schemas.ts index d8b0aee..3b8051c 100644 --- a/apps/api/src/modules/auth/auth.account.schemas.ts +++ b/apps/api/src/modules/auth/auth.account.schemas.ts @@ -46,7 +46,6 @@ export const legalIdentitySchema = z.object({ }) export const paymentSetupSchema = z.object({ - paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), responsibleName: z.string().min(1).max(160), responsibleRole: z.string().min(1).max(120), responsibleIdentityNumber: z.string().min(1).max(120), diff --git a/apps/api/src/modules/auth/auth.company.schemas.ts b/apps/api/src/modules/auth/auth.company.schemas.ts index dcbf0da..0292c64 100644 --- a/apps/api/src/modules/auth/auth.company.schemas.ts +++ b/apps/api/src/modules/auth/auth.company.schemas.ts @@ -34,5 +34,4 @@ export const companySignupSchema = z.object({ plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), currency: z.literal('MAD'), - paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), }) diff --git a/apps/api/src/modules/auth/auth.company.service.test.ts b/apps/api/src/modules/auth/auth.company.service.test.ts index fcfb483..1808037 100644 --- a/apps/api/src/modules/auth/auth.company.service.test.ts +++ b/apps/api/src/modules/auth/auth.company.service.test.ts @@ -45,7 +45,6 @@ const body = { plan: 'PRO' as const, billingPeriod: 'MONTHLY' as const, currency: 'MAD' as const, - paymentProvider: 'PAYPAL' as const, } describe('auth.company.service', () => { @@ -105,7 +104,6 @@ describe('auth.company.service', () => { templateVariables: expect.objectContaining({ firstName: 'Aya', companyName: 'Atlas & Desert Cars!!!', - paymentProvider: 'PAYPAL', }), })) }) diff --git a/apps/api/src/modules/auth/auth.company.service.ts b/apps/api/src/modules/auth/auth.company.service.ts index 4a42c2a..9c247d6 100644 --- a/apps/api/src/modules/auth/auth.company.service.ts +++ b/apps/api/src/modules/auth/auth.company.service.ts @@ -100,7 +100,6 @@ export async function signup(body: CompanySignupInput) { planName: localizePlanName(body.plan, lang), billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang), currency: body.currency, - paymentProvider: body.paymentProvider, trialEndDate: trialEndAt, }, }).catch(() => []) diff --git a/apps/api/src/modules/auth/auth.role-specific-schemas.test.ts b/apps/api/src/modules/auth/auth.role-specific-schemas.test.ts index d57661b..6fb87bb 100644 --- a/apps/api/src/modules/auth/auth.role-specific-schemas.test.ts +++ b/apps/api/src/modules/auth/auth.role-specific-schemas.test.ts @@ -32,13 +32,11 @@ const validCompanySignup = { plan: 'GROWTH', billingPeriod: 'MONTHLY', currency: 'MAD', - paymentProvider: 'AMANPAY', } describe('role-specific auth schema contracts', () => { - it('defaults company signup language and keeps billing provider constrained', () => { - expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' }) - expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow() + it('defaults company signup language and keeps billing currency constrained', () => { + expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en' }) expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow() }) diff --git a/apps/api/src/modules/auth/auth.schemas.test.ts b/apps/api/src/modules/auth/auth.schemas.test.ts index 3442c73..d7aafd3 100644 --- a/apps/api/src/modules/auth/auth.schemas.test.ts +++ b/apps/api/src/modules/auth/auth.schemas.test.ts @@ -34,7 +34,6 @@ describe('auth schemas', () => { plan: 'GROWTH', billingPeriod: 'MONTHLY', currency: 'MAD', - paymentProvider: 'AMANPAY', } as const it('defaults company signup language and validates commercial choices', () => { @@ -43,7 +42,6 @@ describe('auth schemas', () => { expect(parsed.preferredLanguage).toBe('en') expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(true) expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false) - expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false) }) it('accepts optional subscription plan for minimal account start', () => { diff --git a/apps/api/src/modules/billing/billing.schemas.test.ts b/apps/api/src/modules/billing/billing.schemas.test.ts index 7257f5a..45033cf 100644 --- a/apps/api/src/modules/billing/billing.schemas.test.ts +++ b/apps/api/src/modules/billing/billing.schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { billingListQuerySchema } from './billing.schemas' +import { billingListQuerySchema, manualBillingPaymentSchema } from './billing.schemas' describe('billing.schemas billingListQuerySchema', () => { it('parses explicit outstandingOnly query strings as booleans', () => { @@ -11,3 +11,19 @@ describe('billing.schemas billingListQuerySchema', () => { expect(billingListQuerySchema.parse({}).outstandingOnly).toBe(false) }) }) + +describe('billing.schemas manualBillingPaymentSchema', () => { + const base = { + amountMinor: 5000, + type: 'CHARGE' as const, + method: 'BANK_TRANSFER' as const, + idempotencyKey: '11111111-1111-1111-1111-111111111111', + } + + it('accepts bank transfer and check only', () => { + expect(manualBillingPaymentSchema.parse(base).method).toBe('BANK_TRANSFER') + expect(manualBillingPaymentSchema.parse({ ...base, method: 'CHECK' }).method).toBe('CHECK') + expect(manualBillingPaymentSchema.safeParse({ ...base, method: 'OTHER' }).success).toBe(false) + expect(manualBillingPaymentSchema.safeParse({ ...base, method: 'CASH' }).success).toBe(false) + }) +}) diff --git a/apps/api/src/modules/billing/billing.schemas.ts b/apps/api/src/modules/billing/billing.schemas.ts index 5020d61..7986b4a 100644 --- a/apps/api/src/modules/billing/billing.schemas.ts +++ b/apps/api/src/modules/billing/billing.schemas.ts @@ -26,7 +26,7 @@ export const manualBillingPaymentSchema = z.object({ amountMinor: z.number().int().positive(), currency: z.literal('MAD').default('MAD'), type: z.enum(['CHARGE', 'DEPOSIT']), - method: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL', 'OTHER']), + method: z.enum(['CHECK', 'BANK_TRANSFER']), receivedAt: z.string().datetime().optional(), reference: z.string().trim().max(120).optional(), note: z.string().trim().max(1000).optional(), diff --git a/apps/api/src/modules/billing/billing.service.test.ts b/apps/api/src/modules/billing/billing.service.test.ts index b302f0c..e4a363c 100644 --- a/apps/api/src/modules/billing/billing.service.test.ts +++ b/apps/api/src/modules/billing/billing.service.test.ts @@ -24,7 +24,7 @@ function payment(overrides: Partial) { status: 'SUCCEEDED', type: 'CHARGE', paymentProvider: 'MANUAL', - paymentMethod: 'CASH', + paymentMethod: 'BANK_TRANSFER', paidAt: new Date('2026-06-01T12:00:00.000Z'), createdAt: new Date('2026-06-01T12:00:00.000Z'), ...overrides, diff --git a/apps/api/src/modules/billing/billing.service.ts b/apps/api/src/modules/billing/billing.service.ts index 8bc9723..3e001bb 100644 --- a/apps/api/src/modules/billing/billing.service.ts +++ b/apps/api/src/modules/billing/billing.service.ts @@ -49,7 +49,7 @@ type ManualPaymentInput = { amountMinor: number currency: 'MAD' type: 'CHARGE' | 'DEPOSIT' - method: 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER' + method: 'CHECK' | 'BANK_TRANSFER' receivedAt?: string reference?: string note?: string @@ -98,7 +98,7 @@ export function buildBillingInvoice(reservation: BillingReservation) { amountMinor: payment.amount, currency: payment.currency, type: payment.type, - channel: payment.paymentProvider === 'MANUAL' ? 'OFFLINE' : 'ONLINE', + channel: 'OFFLINE', provider: payment.paymentProvider, method: payment.paymentMethod, status: payment.status, diff --git a/apps/api/src/modules/companies/company.presenter.test.ts b/apps/api/src/modules/companies/company.presenter.test.ts index 0e111ba..d87b7c1 100644 --- a/apps/api/src/modules/companies/company.presenter.test.ts +++ b/apps/api/src/modules/companies/company.presenter.test.ts @@ -1,98 +1,20 @@ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { presentBrand } from './company.presenter' -const fullBrand = { - id: 'brand_1', - companyId: 'comp_1', - displayName: 'Test Rentals', - subdomain: 'test-rentals', - logoUrl: 'https://example.com/logo.jpg', - primaryColor: '#1A56DB', - defaultLocale: 'en', - defaultCurrency: 'MAD', - amanpayMerchantId: 'merchant-abc', - amanpaySecretKey: 'super-secret-key', - paypalEmail: 'paypal@example.com', - paypalMerchantId: 'paypal-merchant-xyz', - isListedOnCarplace: true, -} +describe('company.presenter', () => { + const fullBrand = { + displayName: 'Atlas Cars', + subdomain: 'atlas', + primaryColor: '#2563eb', + } -describe('presentBrand', () => { - it('strips amanpaySecretKey from the response', () => { + it('returns brand fields as-is', () => { const result = presentBrand(fullBrand) - expect(result).not.toHaveProperty('amanpaySecretKey') + expect(result).toEqual(fullBrand) }) - it('strips amanpayMerchantId from the response', () => { - const result = presentBrand(fullBrand) - expect(result).not.toHaveProperty('amanpayMerchantId') - }) - - it('strips paypalEmail from the response', () => { - const result = presentBrand(fullBrand) - expect(result).not.toHaveProperty('paypalEmail') - }) - - it('strips paypalMerchantId from the response', () => { - const result = presentBrand(fullBrand) - expect(result).not.toHaveProperty('paypalMerchantId') - }) - - it('sets amanpayConfigured=true when both amanpay credentials are present', () => { - const result = presentBrand(fullBrand) - expect(result.amanpayConfigured).toBe(true) - }) - - it('sets amanpayConfigured=false when amanpaySecretKey is null', () => { - const result = presentBrand({ ...fullBrand, amanpaySecretKey: null }) - expect(result.amanpayConfigured).toBe(false) - }) - - it('sets amanpayConfigured=false when amanpayMerchantId is null', () => { - const result = presentBrand({ ...fullBrand, amanpayMerchantId: null }) - expect(result.amanpayConfigured).toBe(false) - }) - - it('sets amanpayConfigured=false when both amanpay fields are null', () => { - const result = presentBrand({ ...fullBrand, amanpayMerchantId: null, amanpaySecretKey: null }) - expect(result.amanpayConfigured).toBe(false) - }) - - it('sets paypalConfigured=true when paypalEmail is set', () => { - const result = presentBrand({ ...fullBrand, paypalMerchantId: null }) - expect(result.paypalConfigured).toBe(true) - }) - - it('sets paypalConfigured=true when paypalMerchantId is set', () => { - const result = presentBrand({ ...fullBrand, paypalEmail: null }) - expect(result.paypalConfigured).toBe(true) - }) - - it('sets paypalConfigured=false when both paypal fields are null', () => { - const result = presentBrand({ ...fullBrand, paypalEmail: null, paypalMerchantId: null }) - expect(result.paypalConfigured).toBe(false) - }) - - it('preserves all non-credential fields', () => { - const result = presentBrand(fullBrand) - expect(result).toMatchObject({ - id: 'brand_1', - companyId: 'comp_1', - displayName: 'Test Rentals', - subdomain: 'test-rentals', - logoUrl: 'https://example.com/logo.jpg', - primaryColor: '#1A56DB', - defaultLocale: 'en', - defaultCurrency: 'MAD', - isListedOnCarplace: true, - }) - }) - - it('returns null when brand is null', () => { + it('returns null/undefined brand unchanged', () => { expect(presentBrand(null)).toBeNull() - }) - - it('returns undefined when brand is undefined', () => { expect(presentBrand(undefined)).toBeUndefined() }) }) diff --git a/apps/api/src/modules/companies/company.presenter.ts b/apps/api/src/modules/companies/company.presenter.ts index 16d0498..54f0ddc 100644 --- a/apps/api/src/modules/companies/company.presenter.ts +++ b/apps/api/src/modules/companies/company.presenter.ts @@ -3,11 +3,5 @@ export function presentCompany(company: any) { } export function presentBrand(brand: any) { - if (!brand) return brand - const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand - return { - ...safe, - amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey), - paypalConfigured: !!(paypalEmail || paypalMerchantId), - } + return brand } diff --git a/apps/api/src/modules/companies/company.schemas.edge.test.ts b/apps/api/src/modules/companies/company.schemas.edge.test.ts index b64ca14..3b794b4 100644 --- a/apps/api/src/modules/companies/company.schemas.edge.test.ts +++ b/apps/api/src/modules/companies/company.schemas.edge.test.ts @@ -9,7 +9,6 @@ describe('company schemas edge cases', () => { const brand = brandSchema.parse({ displayName: 'Atlas', websiteUrl: 'https://atlas.example.test', - paypalEmail: 'paypal@example.test', defaultCurrency: 'MAD', homePageConfig: { showOffers: true, layout: { items: [{ id: 'hero-1', type: 'hero', x: 1, y: 1, w: 12, h: 4 }] } }, }) diff --git a/apps/api/src/modules/companies/company.schemas.ts b/apps/api/src/modules/companies/company.schemas.ts index db17783..e76a861 100644 --- a/apps/api/src/modules/companies/company.schemas.ts +++ b/apps/api/src/modules/companies/company.schemas.ts @@ -22,10 +22,6 @@ export const brandSchema = z.object({ whatsappNumber: z.string().optional(), defaultLocale: z.string().optional(), defaultCurrency: z.literal('MAD').optional(), - amanpayMerchantId: z.string().optional(), - amanpaySecretKey: z.string().optional(), - paypalEmail: optionalEmailField(), - paypalMerchantId: z.string().optional(), isListedOnCarplace: z.boolean().optional(), homePageConfig: z.object({ heroTitle: z.union([z.string(), z.null()]).optional(), diff --git a/apps/api/src/modules/companies/company.service.edge.test.ts b/apps/api/src/modules/companies/company.service.edge.test.ts index f1b0081..4087c88 100644 --- a/apps/api/src/modules/companies/company.service.edge.test.ts +++ b/apps/api/src/modules/companies/company.service.edge.test.ts @@ -73,11 +73,6 @@ const currentBrand = { companyId: 'company_1', displayName: 'Atlas Cars', subdomain: 'atlas', - amanpayMerchantId: 'merchant_1', - amanpaySecretKey: 'secret_1', - paypalEmail: null, - paypalMerchantId: null, - paymentMethodsEnabled: ['AMANPAY'], } beforeEach(() => { @@ -87,40 +82,18 @@ beforeEach(() => { }) describe('company.service edge behavior', () => { - it('preserves existing AmanPay credentials when recomputing enabled payment methods', async () => { + it('updates brand settings through the repository presenter', async () => { vi.mocked(repo.findBrand).mockResolvedValue(currentBrand as any) - vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, paypalEmail: 'billing@example.test' } as any) + vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, tagline: 'Premium rentals' } as any) - const result = await service.updateBrand('company_1', { paypalEmail: 'billing@example.test' }, 'Atlas Cars', 'atlas') + const result = await service.updateBrand('company_1', { tagline: 'Premium rentals' }, 'Atlas Cars', 'atlas') expect(repo.upsertBrand).toHaveBeenCalledWith( 'company_1', - expect.objectContaining({ - paypalEmail: 'billing@example.test', - paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'], - }), - expect.objectContaining({ - displayName: 'Atlas Cars', - subdomain: 'atlas', - paypalEmail: 'billing@example.test', - paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'], - }), - ) - expect(result).not.toHaveProperty('paypalEmail') - expect(result.paypalConfigured).toBe(true) - }) - - it('does not report AmanPay enabled when only one credential is available', async () => { - vi.mocked(repo.findBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any) - vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any) - - await service.updateBrand('company_1', {}, 'Atlas Cars', 'atlas') - - expect(repo.upsertBrand).toHaveBeenCalledWith( - 'company_1', - expect.objectContaining({ paymentMethodsEnabled: [] }), - expect.objectContaining({ paymentMethodsEnabled: [] }), + expect.objectContaining({ tagline: 'Premium rentals' }), + expect.objectContaining({ displayName: 'Atlas Cars', subdomain: 'atlas', tagline: 'Premium rentals' }), ) + expect(result).toMatchObject({ tagline: 'Premium rentals' }) }) it('normalizes custom domains and marks them pending verification', async () => { diff --git a/apps/api/src/modules/companies/company.service.ts b/apps/api/src/modules/companies/company.service.ts index a6b73c5..9ea641c 100644 --- a/apps/api/src/modules/companies/company.service.ts +++ b/apps/api/src/modules/companies/company.service.ts @@ -4,17 +4,6 @@ import { presentCompany, presentBrand } from './company.presenter' import * as repo from './company.repo' import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements' -function buildPaymentMethodsEnabled(input: { - amanpayMerchantId?: string | null - amanpaySecretKey?: string | null - paypalEmail?: string | null -}) { - const methods: Array<'AMANPAY' | 'PAYPAL'> = [] - if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY') - if (input.paypalEmail) methods.push('PAYPAL') - return methods -} - export async function getCompany(companyId: string) { return presentCompany(await repo.findCompany(companyId)) } @@ -30,20 +19,11 @@ export async function getBrand(companyId: string) { export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) { await assertSettingsFeature(companyId, 'settings.branding_basic') if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom') - if (body.amanpayMerchantId || body.amanpaySecretKey || body.paypalEmail || body.paypalMerchantId) { - await assertSettingsFeature(companyId, 'settings.renter_payments') - } - const current = await repo.findBrand(companyId) - const paymentMethodsEnabled = buildPaymentMethodsEnabled({ - amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId, - amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey, - paypalEmail: body.paypalEmail ?? current?.paypalEmail, - }) return presentBrand(await repo.upsertBrand( companyId, - { ...body, paymentMethodsEnabled }, - { displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body }, + body, + { displayName: body.displayName ?? companyName, subdomain: companySlug, ...body }, )) } diff --git a/apps/api/src/modules/companies/company.test.ts b/apps/api/src/modules/companies/company.test.ts index 8af7e7c..a6d6097 100644 --- a/apps/api/src/modules/companies/company.test.ts +++ b/apps/api/src/modules/companies/company.test.ts @@ -65,9 +65,6 @@ const mockBrand = { heroImageUrl: null, customDomain: null, customDomainVerified: false, - amanpayMerchantId: null, - amanpaySecretKey: null, - paypalEmail: null, } beforeEach(() => { diff --git a/apps/api/src/modules/companies/settingsEntitlements.ts b/apps/api/src/modules/companies/settingsEntitlements.ts index 8c22aa9..53c4895 100644 --- a/apps/api/src/modules/companies/settingsEntitlements.ts +++ b/apps/api/src/modules/companies/settingsEntitlements.ts @@ -52,7 +52,7 @@ const SECTION_COPY: Record { }) it('validates subscriptions, team roles, notifications, and analytics query defaults', () => { - const checkoutResult = checkoutSchema.safeParse({ + const checkoutResult = manualCheckoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', - provider: 'STRIPE', - successUrl: 'https://ok.example.test', - failureUrl: 'https://fail.example.test', + method: 'BANK_TRANSFER', + idempotencyKey: '11111111-1111-4111-8111-111111111111', }) expect(checkoutResult.success).toBe(true) - expect(checkoutSchema.safeParse({ + expect(manualCheckoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', - provider: 'PAYPAL', - successUrl: 'https://ok.example.test', - failureUrl: 'https://fail.example.test', + method: 'STRIPE', + idempotencyKey: '11111111-1111-4111-8111-111111111111', }).success).toBe(false) expect(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' }) expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' }) diff --git a/apps/api/src/modules/payments/payment.repo.ts b/apps/api/src/modules/payments/payment.repo.ts index 633ea9c..afe6e5f 100644 --- a/apps/api/src/modules/payments/payment.repo.ts +++ b/apps/api/src/modules/payments/payment.repo.ts @@ -13,33 +13,10 @@ export function findByReservation(reservationId: string, companyId: string) { return prisma.rentalPayment.findMany({ where: { reservationId, companyId }, orderBy: { createdAt: 'desc' } }) } -export function findByAmanpay(transactionId: string) { - return prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } }) -} - -export function findByPaypal(captureId: string) { - return prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } }) -} - -export function findByStripeCheckoutSession(sessionId: string) { - return prisma.rentalPayment.findFirst({ where: { stripeCheckoutSessionId: sessionId } as any }) -} - -export function findByPaypalForCompany(paypalOrderId: string, companyId: string) { - return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } }) -} - export function findPaymentOrThrow(paymentId: string, companyId: string, reservationId: string) { return prisma.rentalPayment.findFirstOrThrow({ where: { id: paymentId, companyId, reservationId } }) } -export function findReservationOrThrow(id: string, companyId: string) { - return prisma.reservation.findFirstOrThrow({ - where: { id, companyId }, - include: { vehicle: true, customer: true, rentalPayments: true }, - }) -} - export function findReservation(id: string, companyId: string) { return prisma.reservation.findFirstOrThrow({ where: { id, companyId }, @@ -47,85 +24,14 @@ export function findReservation(id: string, companyId: string) { }) } -export function markPaymentSucceeded(id: string) { - return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date() } }) -} - -export function markPaymentFailed(query: { amanpayTransactionId?: string; paypalCaptureId?: string }) { - return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } }) -} - -export function markStripePaymentSucceeded(id: string, paymentIntentId: string | null) { - return prisma.rentalPayment.update({ - where: { id }, - data: { - status: 'SUCCEEDED', - paidAt: new Date(), - ...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}), - } as any, - }) -} - -export function markStripePaymentFailed(sessionId: string) { - return prisma.rentalPayment.updateMany({ - where: { stripeCheckoutSessionId: sessionId } as any, - data: { status: 'FAILED' }, - }) -} - -export function incrementReservationPaid(reservationId: string, _amount: number) { - return prisma.$transaction(async (tx) => { - const reservation = await tx.reservation.findUniqueOrThrow({ - where: { id: reservationId }, - include: { - rentalPayments: { - where: { type: 'CHARGE', status: 'SUCCEEDED' }, - select: { amount: true }, - }, - }, - }) - const paidAmount = reservation.rentalPayments.reduce((total, payment) => total + payment.amount, 0) - return tx.reservation.update({ - where: { id: reservationId }, - data: { - paidAmount, - paymentStatus: paidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL', - }, - }) - }) -} - export function createPayment(data: { companyId: string; reservationId: string; amount: number; currency: string status: string; type: string; paymentProvider: string - amanpayTransactionId?: string | null; paypalCaptureId?: string | null; stripeCheckoutSessionId?: string | null; paymentMethod?: string; paidAt?: Date + paymentMethod?: string; paidAt?: Date }) { return prisma.rentalPayment.create({ data: data as any }) } -export function updatePaypalCapture(id: string, captureId: string) { - return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } }) -} - -export async function updatePendingPaypalCapture(id: string, captureId: string) { - return prisma.$transaction(async (tx) => { - const result = await tx.rentalPayment.updateMany({ - where: { id, status: 'PENDING' }, - data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId }, - }) - if (result.count !== 1) throw new Error('Payment is no longer pending') - return tx.rentalPayment.findUniqueOrThrow({ where: { id } }) - }) -} - export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) { return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } }) } - -export function setReservationRefunded(reservationId: string) { - return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'REFUNDED' } }) -} - -export function setPaymentRefunded(id: string, partial: boolean) { - return prisma.rentalPayment.update({ where: { id }, data: { status: partial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' } }) -} diff --git a/apps/api/src/modules/payments/payment.routes.ts b/apps/api/src/modules/payments/payment.routes.ts index 7913b13..80e1e44 100644 --- a/apps/api/src/modules/payments/payment.routes.ts +++ b/apps/api/src/modules/payments/payment.routes.ts @@ -5,59 +5,11 @@ import { requireSubscriptionRead, requireSubscriptionFull } from '../../middlewa import { requireRole } from '../../middleware/requireRole' import { parseBody, parseParams } from '../../http/validate' import { ok } from '../../http/respond' -import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as service from './payment.service' -import { chargeSchema, manualPaymentSchema, refundSchema, capturePaypalSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas' +import { manualPaymentSchema, refundSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas' const router = Router() -// ─── Webhooks (no auth) ──────────────────────────────────────── - -router.post('/webhooks/amanpay', async (req, res, next) => { - try { - const rawBody = getRawBodyString(req) - const payload = parseRawJsonBody(req) - const signature = (req.headers['x-amanpay-signature'] as string) ?? '' - if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) { - return res.status(401).json({ error: 'invalid_signature' }) - } - await service.handleAmanpayWebhook(payload, rawBody) - res.json({ received: true }) - } catch (err) { next(err) } -}) - -router.post('/webhooks/paypal', async (req, res, next) => { - try { - const rawBody = getRawBodyString(req) - const payload = parseRawJsonBody(req) - const isValid = await paypal.verifyWebhookEvent(req.headers as Record, rawBody) - if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' }) - await service.handlePaypalWebhook(payload, rawBody) - res.json({ received: true }) - } catch (err) { next(err) } -}) - -router.post('/webhooks/stripe', async (req, res, next) => { - try { - const rawBody = getRawBodyString(req) - const signature = (req.headers['stripe-signature'] as string) ?? '' - if (!stripe.isConfigured()) return res.status(401).json({ error: 'invalid_signature' }) - let event: any - try { - event = stripe.constructWebhookEvent(rawBody, signature) - } catch { - return res.status(401).json({ error: 'invalid_signature' }) - } - await service.handleStripeWebhook(event, rawBody) - res.json({ received: true }) - } catch (err) { next(err) } -}) - -// ─── Authenticated ──────────────────────────────────────────── - router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/company', requireRole('MANAGER'), async (req, res, next) => { @@ -73,22 +25,6 @@ router.get('/reservations/:id', requireRole('MANAGER'), async (req, res, next) = } catch (err) { next(err) } }) -router.post('/reservations/:id/charge', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => { - try { - const { id } = parseParams(reservationParamSchema, req) - const body = parseBody(chargeSchema, req) - ok(res, await service.initCharge(id, req.companyId, body)) - } catch (err) { next(err) } -}) - -router.post('/reservations/:id/capture-paypal', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => { - try { - const { id } = parseParams(reservationParamSchema, req) - const { paypalOrderId } = parseBody(capturePaypalSchema, req) - ok(res, await service.capturePaypal(id, req.companyId, paypalOrderId)) - } catch (err) { next(err) } -}) - router.post('/reservations/:id/manual', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(reservationParamSchema, req) diff --git a/apps/api/src/modules/payments/payment.schemas.edge.test.ts b/apps/api/src/modules/payments/payment.schemas.edge.test.ts index 8986fb0..dcb36b8 100644 --- a/apps/api/src/modules/payments/payment.schemas.edge.test.ts +++ b/apps/api/src/modules/payments/payment.schemas.edge.test.ts @@ -1,24 +1,18 @@ import { describe, expect, it } from 'vitest' -import { capturePaypalSchema, chargeSchema, manualPaymentSchema, paymentParamSchema, refundSchema, reservationParamSchema } from './payment.schemas' +import { manualPaymentSchema, paymentParamSchema, refundSchema, reservationParamSchema } from './payment.schemas' describe('payment schemas edge cases', () => { - it('defaults charge and manual payment currency/type while accepting supported providers', () => { - expect(chargeSchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' })).toMatchObject({ - provider: 'PAYPAL', - type: 'CHARGE', - currency: 'MAD', - }) - expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false) - expect(chargeSchema.safeParse({ provider: 'UNKNOWN', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false) - expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'CASH' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' }) - expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false) + it('defaults manual payment currency/type and accepts supported methods', () => { + expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'BANK_TRANSFER' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' }) + expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CHECK' }).success).toBe(false) + expect(manualPaymentSchema.safeParse({ amount: 100, paymentMethod: 'PAYPAL' }).success).toBe(false) + expect(manualPaymentSchema.safeParse({ amount: 100, paymentMethod: 'CASH' }).success).toBe(false) + expect(manualPaymentSchema.safeParse({ amount: 100, paymentMethod: 'STRIPE' as any }).success).toBe(false) }) - it('validates refund/capture/payment parameter payloads', () => { + it('validates refund and payment parameter payloads', () => { expect(refundSchema.parse({})).toEqual({}) expect(refundSchema.safeParse({ amount: -1 }).success).toBe(false) - expect(capturePaypalSchema.safeParse({ paypalOrderId: 'order_1' }).success).toBe(true) - expect(capturePaypalSchema.safeParse({}).success).toBe(false) expect(reservationParamSchema.safeParse({ id: '' }).success).toBe(false) expect(paymentParamSchema.safeParse({ reservationId: 'reservation_1', paymentId: 'payment_1' }).success).toBe(true) }) diff --git a/apps/api/src/modules/payments/payment.schemas.ts b/apps/api/src/modules/payments/payment.schemas.ts index 6fe5edf..d43ddcd 100644 --- a/apps/api/src/modules/payments/payment.schemas.ts +++ b/apps/api/src/modules/payments/payment.schemas.ts @@ -1,18 +1,10 @@ import { z } from 'zod' -export const chargeSchema = z.object({ - provider: z.enum(['AMANPAY', 'PAYPAL']), - type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), - currency: z.literal('MAD').default('MAD'), - successUrl: z.string().url(), - failureUrl: z.string().url(), -}) - export const manualPaymentSchema = z.object({ amount: z.number().int().positive(), currency: z.literal('MAD').default('MAD'), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), - paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']), + paymentMethod: z.enum(['CHECK', 'BANK_TRANSFER']), }) export const refundSchema = z.object({ @@ -20,10 +12,6 @@ export const refundSchema = z.object({ reason: z.string().optional(), }) -export const capturePaypalSchema = z.object({ - paypalOrderId: z.string(), -}) - export const reservationParamSchema = z.object({ id: z.string().min(1), }) diff --git a/apps/api/src/modules/payments/payment.service.test.ts b/apps/api/src/modules/payments/payment.service.test.ts index 0fa7b32..b19afaf 100644 --- a/apps/api/src/modules/payments/payment.service.test.ts +++ b/apps/api/src/modules/payments/payment.service.test.ts @@ -1,164 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn(), - createCheckout: vi.fn(), - refundTransaction: vi.fn(), -})) - -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn(), - createOrder: vi.fn(), - captureOrder: vi.fn(), - refundCapture: vi.fn(), -})) - -vi.mock('../../services/stripeService', () => ({ - isConfigured: vi.fn(), - createCheckoutSession: vi.fn(), - refundPaymentIntent: vi.fn(), -})) - vi.mock('./payment.repo', () => ({ - findByCompany: vi.fn(), - findByReservation: vi.fn(), - findByAmanpay: vi.fn(), - findByPaypal: vi.fn(), - findByStripeCheckoutSession: vi.fn(), - findByPaypalForCompany: vi.fn(), - findPaymentOrThrow: vi.fn(), - findReservationOrThrow: vi.fn(), findReservation: vi.fn(), - markPaymentSucceeded: vi.fn(), - markStripePaymentSucceeded: vi.fn(), - markPaymentFailed: vi.fn(), - markStripePaymentFailed: vi.fn(), - incrementReservationPaid: vi.fn(), createPayment: vi.fn(), - updatePaypalCapture: vi.fn(), - updatePendingPaypalCapture: vi.fn(), setReservationPaidAmount: vi.fn(), - setReservationRefunded: vi.fn(), - setPaymentRefunded: vi.fn(), + findPaymentOrThrow: vi.fn(), })) import { ConflictError, ValidationError } from '../../http/errors' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as repo from './payment.repo' -import { - capturePaypal, - handleAmanpayWebhook, - handlePaypalWebhook, - handleStripeWebhook, - initCharge, - recordManualPayment, - refundPayment, -} from './payment.service' - -const reservation = { - id: 'reservation_1', - companyId: 'company_1', - paymentStatus: 'UNPAID', - depositAmount: 300, - totalAmount: 1200, - paidAmount: 200, - rentalPayments: [], - vehicle: { make: 'Dacia', model: 'Duster' }, - customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' }, -} +import { recordManualPayment, refundPayment } from './payment.service' describe('payment.service', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z')) - delete process.env.API_URL - process.env.DASHBOARD_URL = 'https://app.example' }) afterEach(() => { vi.useRealTimers() - delete process.env.API_URL - delete process.env.DASHBOARD_URL - }) - - it('creates an AmanPay deposit checkout using reservation, customer, and webhook details', async () => { - vi.mocked(repo.findReservationOrThrow).mockResolvedValue(reservation as never) - vi.mocked(amanpay.isConfigured).mockReturnValue(true) - vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_1' } as never) - vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', status: 'PENDING' } as never) - - const result = await initCharge('reservation_1', 'company_1', { - provider: 'AMANPAY', - type: 'DEPOSIT', - currency: 'MAD', - successUrl: 'https://app.example/success', - failureUrl: 'https://app.example/failure', - }) - - expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ - amount: 300, - currency: 'MAD', - orderId: 'reservation_1-DEPOSIT-1780913700000', - description: 'Deposit: Dacia Duster', - customerEmail: 'nora@example.com', - customerName: 'Nora Driver', - webhookUrl: 'http://localhost:4000/api/v1/payments/webhooks/amanpay', - })) - expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ - companyId: 'company_1', - reservationId: 'reservation_1', - amount: 300, - status: 'PENDING', - type: 'DEPOSIT', - paymentProvider: 'AMANPAY', - amanpayTransactionId: 'aman_txn_1', - paypalCaptureId: null, - })) - expect(result).toEqual({ payment: { id: 'payment_1', status: 'PENDING' }, checkoutUrl: 'https://pay.example/checkout' }) - }) - - it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => { - vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ - ...reservation, - paymentStatus: 'PAID', - rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }], - } as never) - - await expect(initCharge('reservation_1', 'company_1', { - provider: 'PAYPAL', - type: 'CHARGE', - currency: 'MAD', - successUrl: 'https://app.example/success', - failureUrl: 'https://app.example/failure', - })).rejects.toBeInstanceOf(ConflictError) - - expect(paypal.isConfigured).not.toHaveBeenCalled() - expect(repo.createPayment).not.toHaveBeenCalled() - }) - - it('allows an outstanding deposit when the rental invoice is fully paid', async () => { - vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ - ...reservation, - paymentStatus: 'PAID', - rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }], - } as never) - vi.mocked(amanpay.isConfigured).mockReturnValue(true) - vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_2' } as never) - vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_2', status: 'PENDING' } as never) - - await initCharge('reservation_1', 'company_1', { - provider: 'AMANPAY', - type: 'DEPOSIT', - currency: 'MAD', - successUrl: 'https://app.example/success', - failureUrl: 'https://app.example/failure', - }) - - expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ amount: 300 })) - expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ type: 'DEPOSIT' })) }) it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => { @@ -175,13 +36,13 @@ describe('payment.service', () => { amount: 300, currency: 'MAD', type: 'CHARGE', - paymentMethod: 'CASH', + paymentMethod: 'CHECK', }) expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ status: 'SUCCEEDED', paymentProvider: 'MANUAL', - paymentMethod: 'CASH', + paymentMethod: 'CHECK', paidAt: expect.any(Date), })) expect(repo.setReservationPaidAmount).toHaveBeenCalledWith('reservation_1', 1000, 'PAID') @@ -208,82 +69,33 @@ describe('payment.service', () => { expect(repo.setReservationPaidAmount).not.toHaveBeenCalled() }) - it('applies paid AmanPay, PayPal, and Stripe webhook events to the matching records', async () => { - vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450, type: 'CHARGE' } as never) - vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500, type: 'CHARGE' } as never) - vi.mocked(repo.findByStripeCheckoutSession).mockResolvedValue({ id: 'payment_3', reservationId: 'reservation_3', amount: 600, type: 'CHARGE', status: 'PENDING' } as never) - - await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' }) - await handlePaypalWebhook({ id: 'paypal_event_1', event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } }) - await handlePaypalWebhook({ id: 'paypal_event_2', event_type: 'PAYMENT.CAPTURE.DENIED', resource: { id: 'paypal_capture_2' } }) - await handleStripeWebhook({ id: 'evt_1', type: 'checkout.session.completed', data: { object: { id: 'cs_test_123', payment_intent: 'pi_test_123' } } }) - - expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_1') - expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 450) - expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_2') - expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_2', 500) - expect(repo.markPaymentFailed).toHaveBeenCalledWith({ paypalCaptureId: 'paypal_capture_2' }) - expect(repo.markStripePaymentSucceeded).toHaveBeenCalledWith('payment_3', 'pi_test_123') - expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_3', 600) - }) - - it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => { - vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800, currency: 'MAD', status: 'PENDING', type: 'CHARGE' } as never) - vi.mocked(paypal.captureOrder).mockResolvedValue({ - status: 'COMPLETED', - purchase_units: [{ - reference_id: 'reservation_1-CHARGE-1780913700000', - payments: { captures: [{ id: 'capture_123', amount: { value: '8.00', currency_code: 'MAD' } }] }, - }], - } as never) - vi.mocked(repo.updatePendingPaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never) - - const result = await capturePaypal('reservation_1', 'company_1', 'order_123') - - expect(repo.findByPaypalForCompany).toHaveBeenCalledWith('order_123', 'company_1') - expect(repo.updatePendingPaypalCapture).toHaveBeenCalledWith('payment_1', 'capture_123') - expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 800) - expect(result).toEqual({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' }) - }) - - it('refunds gateway payments and only marks the reservation refunded on full refunds', async () => { + it('rejects refunds for manual payments', async () => { vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', status: 'SUCCEEDED', amount: 1000, - currency: 'MAD', - paymentProvider: 'PAYPAL', - paypalCaptureId: 'capture_123', - amanpayTransactionId: null, + paymentProvider: 'MANUAL', } as never) - vi.mocked(repo.setPaymentRefunded).mockResolvedValue({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' } as never) - const result = await refundPayment('reservation_1', 'payment_1', 'company_1', 250, 'Customer request') - - expect(paypal.refundCapture).toHaveBeenCalledWith('capture_123', 250, 'MAD', 'Customer request') - expect(repo.setPaymentRefunded).toHaveBeenCalledWith('payment_1', true) - expect(repo.setReservationRefunded).not.toHaveBeenCalled() - expect(result).toEqual({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' }) + await expect(refundPayment('reservation_1', 'payment_1', 'company_1', 250, 'Customer request')) + .rejects.toBeInstanceOf(ValidationError) }) - it('refunds Stripe payments by PaymentIntent', async () => { - vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({ - id: 'payment_1', - reservationId: 'reservation_1', - status: 'SUCCEEDED', - amount: 1000, - currency: 'MAD', - paymentProvider: 'STRIPE', - stripePaymentIntentId: 'pi_test_123', + it('rejects manual payments when the reservation is already fully paid', async () => { + vi.mocked(repo.findReservation).mockResolvedValue({ + id: 'reservation_1', + totalAmount: 1000, + depositAmount: 300, + paidAmount: 1000, + rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1000 }], } as never) - vi.mocked(repo.setPaymentRefunded).mockResolvedValue({ id: 'payment_1', status: 'REFUNDED' } as never) - const result = await refundPayment('reservation_1', 'payment_1', 'company_1', undefined, 'Customer request') - - expect(stripe.refundPaymentIntent).toHaveBeenCalledWith('pi_test_123', 1000, 'Customer request') - expect(repo.setPaymentRefunded).toHaveBeenCalledWith('payment_1', false) - expect(repo.setReservationRefunded).toHaveBeenCalledWith('reservation_1') - expect(result).toEqual({ id: 'payment_1', status: 'REFUNDED' }) + await expect(recordManualPayment('reservation_1', 'company_1', { + amount: 100, + currency: 'MAD', + type: 'CHARGE', + paymentMethod: 'CHECK', + })).rejects.toBeInstanceOf(ConflictError) }) }) diff --git a/apps/api/src/modules/payments/payment.service.ts b/apps/api/src/modules/payments/payment.service.ts index 27c2247..ec905bb 100644 --- a/apps/api/src/modules/payments/payment.service.ts +++ b/apps/api/src/modules/payments/payment.service.ts @@ -1,10 +1,5 @@ import { ConflictError, ValidationError } from '../../http/errors' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as repo from './payment.repo' -import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency' -import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects' export function listByCompany(companyId: string) { return repo.findByCompany(companyId) @@ -25,183 +20,6 @@ function getInvoicePaid(reservation: any, rentalPayments: any[]) { return Math.max(sumSucceededPayments(rentalPayments, 'CHARGE'), reservation.paidAmount ?? 0) } -async function applyAmanpayWebhook(event: any) { - const transactionId = event.transaction_id ?? event.id - const status = event.status?.toUpperCase() - if (status === 'PAID' || status === 'SUCCEEDED') { - const payment = await repo.findByAmanpay(transactionId) - if (payment && payment.status !== 'SUCCEEDED') { - await repo.markPaymentSucceeded(payment.id) - if (payment.type === 'CHARGE') { - await repo.incrementReservationPaid(payment.reservationId, payment.amount) - } - } - } else if (status === 'FAILED') { - await repo.markPaymentFailed({ amanpayTransactionId: transactionId }) - } -} - -async function applyPaypalWebhook(event: any) { - const eventType = event.event_type as string - if (eventType === 'PAYMENT.CAPTURE.COMPLETED') { - const captureId = event.resource?.id as string - const payment = await repo.findByPaypal(captureId) - if (payment && payment.status !== 'SUCCEEDED') { - await repo.markPaymentSucceeded(payment.id) - if (payment.type === 'CHARGE') { - await repo.incrementReservationPaid(payment.reservationId, payment.amount) - } - } - } else if (eventType === 'PAYMENT.CAPTURE.DENIED') { - await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id }) - } -} - -async function applyStripeWebhook(event: any) { - const session = event.data?.object - const sessionId = session?.id as string | undefined - if (!sessionId) return - - if (event.type === 'checkout.session.completed') { - const payment = await repo.findByStripeCheckoutSession(sessionId) - if (payment && payment.status !== 'SUCCEEDED') { - await repo.markStripePaymentSucceeded(payment.id, typeof session.payment_intent === 'string' ? session.payment_intent : null) - if (payment.type === 'CHARGE') { - await repo.incrementReservationPaid(payment.reservationId, payment.amount) - } - } - } else if (event.type === 'checkout.session.expired') { - await repo.markStripePaymentFailed(sessionId) - } -} - -export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) { - return processWebhookOnce({ - provider: 'amanpay:payments', - providerEventId: getWebhookEventId('amanpay', event), - eventType: String(event.status ?? 'unknown'), - rawBody, - handle: () => applyAmanpayWebhook(event), - }) -} - -export async function handlePaypalWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) { - return processWebhookOnce({ - provider: 'paypal:payments', - providerEventId: getWebhookEventId('paypal', event), - eventType: String(event.event_type ?? 'unknown'), - rawBody, - handle: () => applyPaypalWebhook(event), - }) -} - -export async function handleStripeWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) { - return processWebhookOnce({ - provider: 'stripe:payments', - providerEventId: String(event.id), - eventType: String(event.type ?? 'unknown'), - rawBody, - handle: () => applyStripeWebhook(event), - }) -} - -export async function initCharge(reservationId: string, companyId: string, body: { - provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT' - currency: 'MAD'; successUrl: string; failureUrl: string -}) { - const reservation = await repo.findReservationOrThrow(reservationId, companyId) - const rentalPayments = (reservation as any).rentalPayments ?? [] - const invoicePaid = getInvoicePaid(reservation, rentalPayments) - const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT') - const balanceDue = body.type === 'DEPOSIT' - ? reservation.depositAmount - depositCollected - : reservation.totalAmount - invoicePaid - - if (balanceDue <= 0) { - throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid') - } - - assertAllowedPaymentRedirect(body.successUrl) - assertAllowedPaymentRedirect(body.failureUrl) - - const amount = balanceDue - const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}` - const orderId = `${reservationId}-${body.type}-${Date.now()}` - const webhookBase = process.env.API_URL ?? 'http://localhost:4000' - - let checkoutUrl: string - let amanpayTransactionId: string | null = null - let paypalCaptureId: string | null = null - - if (body.provider === 'AMANPAY') { - if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured') - const result = await amanpay.createCheckout({ - amount, currency: body.currency, orderId, description, - customerEmail: reservation.customer.email, - customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, - successUrl: body.successUrl, failureUrl: body.failureUrl, - webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`, - }) - checkoutUrl = result.checkoutUrl - amanpayTransactionId = result.transactionId - } else if (body.provider === 'PAYPAL') { - if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured') - const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl }) - checkoutUrl = result.approveUrl - paypalCaptureId = result.orderId - } else { - throw new ValidationError('Unsupported payment provider') - } - - const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId }) - return { payment, checkoutUrl } -} - -function amountToMinorUnits(value: unknown) { - const numeric = typeof value === 'number' ? value : Number(value) - if (!Number.isFinite(numeric)) return null - return Math.round(numeric * 100) -} - -export async function capturePaypal(reservationId: string, companyId: string, paypalOrderId: string) { - const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId) - - if (payment.reservationId !== reservationId) { - throw new ValidationError('PayPal order does not belong to this reservation') - } - if (payment.status !== 'PENDING') { - throw new ConflictError('This payment has already been processed') - } - - const capture = await paypal.captureOrder(paypalOrderId) as Record - if (capture.status !== 'COMPLETED') { - throw new ValidationError('PayPal capture did not complete') - } - - const unit = capture.purchase_units?.[0] - const providerCapture = unit?.payments?.captures?.[0] - const captureId = providerCapture?.id - const providerAmount = amountToMinorUnits(providerCapture?.amount?.value) - const providerCurrency = providerCapture?.amount?.currency_code - - if (!captureId) throw new ValidationError('PayPal capture response did not include a capture id') - if (unit?.reference_id && !String(unit.reference_id).startsWith(reservationId)) { - throw new ValidationError('PayPal order reference does not match reservation') - } - if (providerAmount !== payment.amount) { - throw new ValidationError('PayPal capture amount does not match expected amount') - } - if (providerCurrency !== payment.currency) { - throw new ValidationError('PayPal capture currency does not match expected currency') - } - - const updated = await repo.updatePendingPaypalCapture(payment.id, captureId) - if (payment.type === 'CHARGE') { - await repo.incrementReservationPaid(payment.reservationId, payment.amount) - } - return updated -} - export async function recordManualPayment(reservationId: string, companyId: string, body: { amount: number; currency: string; type: string; paymentMethod: string }) { @@ -223,7 +41,17 @@ export async function recordManualPayment(reservationId: string, companyId: stri throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance') } - const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: 'MANUAL', paymentMethod: body.paymentMethod, paidAt: new Date() }) + const payment = await repo.createPayment({ + companyId, + reservationId, + amount: body.amount, + currency: body.currency, + status: 'SUCCEEDED', + type: body.type, + paymentProvider: 'MANUAL', + paymentMethod: body.paymentMethod, + paidAt: new Date(), + }) if (body.type === 'CHARGE') { const newPaidAmount = invoicePaid + body.amount await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL') @@ -234,24 +62,5 @@ export async function recordManualPayment(reservationId: string, companyId: stri export async function refundPayment(reservationId: string, paymentId: string, companyId: string, amount?: number, reason?: string) { const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId) if (payment.status !== 'SUCCEEDED') throw new ValidationError('Only succeeded payments can be refunded') - if (!payment.amanpayTransactionId && !payment.paypalCaptureId && !payment.stripePaymentIntentId) throw new ValidationError('Manual payments must be refunded outside the online gateway flow') - - const refundAmount = amount ?? payment.amount - if (payment.paymentProvider === 'AMANPAY') { - if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID') - await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason) - } else if (payment.paymentProvider === 'PAYPAL') { - if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID') - await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason) - } else if (payment.paymentProvider === 'STRIPE') { - if (!payment.stripePaymentIntentId) throw new Error('No Stripe PaymentIntent ID') - await stripe.refundPaymentIntent(payment.stripePaymentIntentId, refundAmount, reason) - } else { - throw new ValidationError('Manual payments must be refunded outside the online gateway flow') - } - - const isPartial = refundAmount < payment.amount - const updated = await repo.setPaymentRefunded(payment.id, isPartial) - if (!isPartial) await repo.setReservationRefunded(payment.reservationId) - return updated + throw new ValidationError('Manual payments must be refunded outside the online gateway flow') } diff --git a/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts b/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts index a3e56f1..8467120 100644 --- a/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts +++ b/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts @@ -16,7 +16,7 @@ describe('reservation.presenter boundary behavior', () => { expect(parseReservationExtras(null)).toEqual({}) expect(parseReservationExtras(['paymentMode'])).toEqual({}) expect(parseReservationExtras('cash')).toEqual({}) - expect(parseReservationExtras({ paymentMode: 'CASH' })).toEqual({ paymentMode: 'CASH' }) + expect(parseReservationExtras({ paymentMode: 'BANK_TRANSFER' })).toEqual({ paymentMode: 'BANK_TRANSFER' }) }) it('normalizes optional strings without preserving whitespace-only values', () => { @@ -64,7 +64,7 @@ describe('reservation.presenter boundary behavior', () => { contractNumber: null, invoiceNumber: null, paymentStatus: 'UNPAID', - extras: { paymentMode: 'CARD' }, + extras: { paymentMode: 'CHECK' }, customer: { id: 'customer_1', driverLicense: null, @@ -75,7 +75,7 @@ describe('reservation.presenter boundary behavior', () => { }, }) - expect(result.paymentMode).toBe('CARD') + expect(result.paymentMode).toBe('CHECK') expect(result.customer?.licenseImageUrl).toBe('/api/v1/customers/customer_1/license-image') expect(result.workflow.coreEditable).toBe(true) }) diff --git a/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts b/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts index afc6fe9..8a9cd0e 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts @@ -37,6 +37,15 @@ describe('reservation schemas edge cases', () => { expect(listQuerySchema.safeParse({ pageSize: 101 }).success).toBe(false) expect(listQuerySchema.safeParse({ search: 'x'.repeat(101) }).success).toBe(false) expect(updateSchema.safeParse({ depositAmount: -1 }).success).toBe(false) + expect(updateSchema.safeParse({ paymentMode: 'CASH' }).success).toBe(false) + expect(updateSchema.safeParse({ paymentMode: 'BANK_TRANSFER' }).success).toBe(true) + expect(createSchema.safeParse({ + vehicleId: 'ckvvehicle000000000000001', + customerId: 'ckvcustomer00000000000001', + startDate: '2026-07-01T10:00:00.000Z', + endDate: '2026-07-03T10:00:00.000Z', + paymentMode: 'CARD', + }).success).toBe(false) expect(extendSchema.safeParse({ newEndDate: '2026-07-05T10:00:00.000Z', reason: '' }).success).toBe(false) expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true }) expect(inspectionParamSchema.parse({ id: 'reservation_1', type: 'CHECKIN' })).toEqual({ id: 'reservation_1', type: 'CHECKIN' }) diff --git a/apps/api/src/modules/reservations/reservation.schemas.ts b/apps/api/src/modules/reservations/reservation.schemas.ts index 4a0ce2f..1a64f52 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation' +const rentalPaymentModeSchema = z.enum(['BANK_TRANSFER', 'CHECK']) + export const additionalDriverSchema = z.object({ firstName: textField('name'), lastName: textField('name'), @@ -41,7 +43,7 @@ export const createSchema = z.object({ offerId: z.string().cuid().optional(), promoCodeUsed: z.string().optional(), depositAmount: z.number().int().min(0).default(0), - paymentMode: z.string().max(50).optional(), + paymentMode: rentalPaymentModeSchema.optional(), spareWheel: z.boolean().optional(), radioCd: z.boolean().optional(), notes: z.string().optional(), @@ -59,7 +61,7 @@ export const updateSchema = z.object({ returnLocation: optionalTextField('returnLocation').nullable(), depositAmount: z.number().int().min(0).optional(), notes: z.string().optional().nullable(), - paymentMode: z.string().max(50).optional().nullable(), + paymentMode: rentalPaymentModeSchema.optional().nullable(), spareWheel: z.boolean().optional().nullable(), radioCd: z.boolean().optional().nullable(), contractFields: contractFieldsSchema, diff --git a/apps/api/src/modules/search/search.service.ts b/apps/api/src/modules/search/search.service.ts index a709826..44b8c60 100644 --- a/apps/api/src/modules/search/search.service.ts +++ b/apps/api/src/modules/search/search.service.ts @@ -190,8 +190,6 @@ export async function globalSearch(companyId: string, query: string, limit = 5): { reference: textContains(search) }, { note: textContains(search) }, { paymentMethod: textContains(search) }, - { amanpayTransactionId: textContains(search) }, - { paypalCaptureId: textContains(search) }, { reservation: { is: { invoiceNumber: textContains(search) } } }, { reservation: { is: { contractNumber: textContains(search) } } }, { reservation: { is: { customer: { is: { email: textContains(search) } } } } }, @@ -301,7 +299,7 @@ export async function globalSearch(companyId: string, query: string, limit = 5): groups.payment = payments.map((payment: any) => ({ id: payment.id, type: 'payment', - title: payment.reference || payment.amanpayTransactionId || payment.paypalCaptureId || payment.id, + title: payment.reference || payment.paymentMethod || payment.id, subtitle: compact([payment.status, payment.type, payment.reservation?.customer?.email]), href: `/billing?search=${encodeURIComponent(payment.reference || payment.reservation?.invoiceNumber || payment.id)}`, meta: `${payment.amount} ${payment.currency}`, diff --git a/apps/api/src/modules/site/site.presenter.test.ts b/apps/api/src/modules/site/site.presenter.test.ts index d167b7d..e21f229 100644 --- a/apps/api/src/modules/site/site.presenter.test.ts +++ b/apps/api/src/modules/site/site.presenter.test.ts @@ -12,10 +12,6 @@ describe('site.presenter', () => { displayName: 'Atlas Cars', tagline: 'Premium rentals', logoUrl: 'https://cdn.test/logo.png', - amanpaySecretKey: 'must-not-leak', - amanpayMerchantId: 'must-not-leak', - paypalEmail: 'billing@example.com', - paymentMethodsEnabled: ['PAYPAL'], defaultLocale: 'fr', defaultCurrency: 'MAD', isListedOnCarplace: true, @@ -28,15 +24,11 @@ describe('site.presenter', () => { displayName: 'Atlas Cars', tagline: 'Premium rentals', logoUrl: 'https://cdn.test/logo.png', - paypalEmail: 'billing@example.com', - paymentMethodsEnabled: ['PAYPAL'], defaultLocale: 'fr', defaultCurrency: 'MAD', isListedOnCarplace: true, }, }) - expect(result.brand).not.toHaveProperty('amanpaySecretKey') - expect(result.brand).not.toHaveProperty('amanpayMerchantId') }) it('returns a null brand when company branding has not been configured', () => { diff --git a/apps/api/src/modules/site/site.presenter.ts b/apps/api/src/modules/site/site.presenter.ts index f4ddf6e..afb2f23 100644 --- a/apps/api/src/modules/site/site.presenter.ts +++ b/apps/api/src/modules/site/site.presenter.ts @@ -27,9 +27,6 @@ export function presentBrand(company: { instagramUrl: brand.instagramUrl, defaultLocale: brand.defaultLocale, defaultCurrency: brand.defaultCurrency, - paypalEmail: brand.paypalEmail, - paypalMerchantId: brand.paypalMerchantId, - paymentMethodsEnabled: brand.paymentMethodsEnabled, isListedOnCarplace: brand.isListedOnCarplace, carplaceRating: brand.carplaceRating, homePageConfig: brand.homePageConfig, diff --git a/apps/api/src/modules/site/site.repo.edge.test.ts b/apps/api/src/modules/site/site.repo.edge.test.ts index 68b600a..f7d3b43 100644 --- a/apps/api/src/modules/site/site.repo.edge.test.ts +++ b/apps/api/src/modules/site/site.repo.edge.test.ts @@ -68,46 +68,4 @@ describe('site.repo public booking boundaries', () => { }), })) }) - - it('creates pending rental payments with normalized charge metadata', async () => { - await repo.createRentalPayment({ - companyId: 'company_1', - reservationId: 'reservation_1', - amount: 1200, - currency: 'MAD', - paymentProvider: 'PAYPAL', - paypalCaptureId: 'order_1', - }) - - expect(prisma.rentalPayment.create).toHaveBeenCalledWith({ - data: { - companyId: 'company_1', - reservationId: 'reservation_1', - amount: 1200, - currency: 'MAD', - paymentProvider: 'PAYPAL', - paypalCaptureId: 'order_1', - status: 'PENDING', - type: 'CHARGE', - }, - }) - }) - - it('captures PayPal payments by updating payment and reservation atomically in sequence', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-11-10T09:30:00.000Z')) - - await repo.capturePaypalPayment('payment_1', 'capture_1', 'reservation_1', 1500) - - expect(prisma.rentalPayment.update).toHaveBeenCalledWith({ - where: { id: 'payment_1' }, - data: { status: 'SUCCEEDED', paidAt: new Date('2026-11-10T09:30:00.000Z'), paypalCaptureId: 'capture_1' }, - }) - expect(prisma.reservation.update).toHaveBeenCalledWith({ - where: { id: 'reservation_1' }, - data: { paymentStatus: 'PAID', paidAmount: { increment: 1500 } }, - }) - - vi.useRealTimers() - }) }) diff --git a/apps/api/src/modules/site/site.repo.ts b/apps/api/src/modules/site/site.repo.ts index 4812527..a14e84e 100644 --- a/apps/api/src/modules/site/site.repo.ts +++ b/apps/api/src/modules/site/site.repo.ts @@ -124,32 +124,6 @@ export async function findBooking(reservationId: string, companyId: string) { }) } -export async function findReservationForPayment(reservationId: string, companyId: string) { - return prisma.reservation.findFirstOrThrow({ - where: { id: reservationId, companyId }, - include: { vehicle: true, customer: true, additionalDrivers: true }, - }) -} - -export async function createRentalPayment(data: { - companyId: string; reservationId: string; amount: number; currency: string - paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null -}) { - return prisma.rentalPayment.create({ - data: { ...data, paymentProvider: data.paymentProvider as any, status: 'PENDING', type: 'CHARGE' }, - }) -} - -export async function findPaymentByPaypalOrderId(paypalOrderId: string, companyId: string) { - return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } }) -} - -export async function capturePaypalPayment(paymentId: string, captureId: string, reservationId: string, amount: number) { - await prisma.rentalPayment.update({ where: { id: paymentId }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } }) - await prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } }) -} - - export async function createReservationPublicAccess(reservationId: string, tokenHash: string, expiresAt: Date) { return (prisma as any).reservationPublicAccess.create({ data: { reservationId, tokenHash, expiresAt }, diff --git a/apps/api/src/modules/site/site.routes.ts b/apps/api/src/modules/site/site.routes.ts index 268321c..080f12f 100644 --- a/apps/api/src/modules/site/site.routes.ts +++ b/apps/api/src/modules/site/site.routes.ts @@ -6,7 +6,7 @@ import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable' import * as service from './site.service' import { slugParamSchema, bookingParamSchema, - availabilitySchema, validateCodeSchema, bookSchema, paySchema, capturePaypalSchema, contactSchema, + availabilitySchema, validateCodeSchema, bookSchema, contactSchema, } from './site.schemas' const router = Router() @@ -135,27 +135,6 @@ router.get('/:slug/booking/:id', async (req, res, next) => { } }) -router.post('/:slug/booking/:id/pay', async (req, res, next) => { - try { - const { slug, id } = parseParams(bookingParamSchema, req) - const body = parseBody(paySchema, req) - ok(res, await service.initPayment(slug, id, body)) - } catch (err) { - if (isDatabaseUnavailableError(err)) { - return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 }) - } - next(err) - } -}) - -router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => { - try { - const { slug } = parseParams(slugParamSchema, req) - const { paypalOrderId } = parseBody(capturePaypalSchema, req) - ok(res, await service.capturePaypal(slug, paypalOrderId)) - } catch (err) { next(err) } -}) - router.post('/:slug/contact', async (req, res, next) => { try { const { slug } = parseParams(slugParamSchema, req) diff --git a/apps/api/src/modules/site/site.schemas.test.ts b/apps/api/src/modules/site/site.schemas.test.ts index 08a7f32..a8df7b9 100644 --- a/apps/api/src/modules/site/site.schemas.test.ts +++ b/apps/api/src/modules/site/site.schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { availabilitySchema, bookSchema, capturePaypalSchema, contactSchema, paySchema } from './site.schemas' +import { availabilitySchema, bookSchema, contactSchema } from './site.schemas' describe('site.schemas', () => { const baseBooking = { @@ -34,7 +34,7 @@ describe('site.schemas', () => { })).toThrow() }) - it('rejects malformed availability and payment payloads at the schema layer', () => { + it('rejects malformed availability payloads at the schema layer', () => { expect(availabilitySchema.parse({ vehicleId: 'ckvvehicle000000000000001', startDate: '2026-07-01T10:00:00.000Z', @@ -42,15 +42,12 @@ describe('site.schemas', () => { })).toMatchObject({ vehicleId: 'ckvvehicle000000000000001' }) expect(() => availabilitySchema.parse({ vehicleId: 'not-cuid', startDate: 'x', endDate: 'y' })).toThrow() - expect(() => paySchema.parse({ provider: 'STRIPE', successUrl: 'https://ok.test', failureUrl: 'https://fail.test' })).toThrow() - expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD') }) - it('keeps contact, PayPal capture, and guest booking essentials strict', () => { + it('keeps contact and guest booking essentials strict', () => { expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({ name: 'Aya', email: 'aya@example.com', message: 'Hello', }) - expect(capturePaypalSchema.parse({ paypalOrderId: 'ORDER-1' })).toEqual({ paypalOrderId: 'ORDER-1' }) expect(() => bookSchema.parse({ ...baseBooking, email: 'bad-email' })).toThrow() expect(() => contactSchema.parse({ name: '', email: 'bad', message: '' })).toThrow() }) diff --git a/apps/api/src/modules/site/site.schemas.ts b/apps/api/src/modules/site/site.schemas.ts index 4b403a4..4295749 100644 --- a/apps/api/src/modules/site/site.schemas.ts +++ b/apps/api/src/modules/site/site.schemas.ts @@ -50,16 +50,6 @@ export const bookSchema = z.object({ })).default([]), }) -export const paySchema = z.object({ - provider: z.enum(['AMANPAY', 'PAYPAL']), - currency: z.literal('MAD').default('MAD'), - successUrl: z.string().url(), - failureUrl: z.string().url(), - accessToken: z.string().min(20), -}) - -export const capturePaypalSchema = z.object({ paypalOrderId: z.string() }) - export const contactSchema = z.object({ name: z.string().min(1), email: z.string().email(), diff --git a/apps/api/src/modules/site/site.service.boundary.test.ts b/apps/api/src/modules/site/site.service.boundary.test.ts index 15a0a44..fb6ac83 100644 --- a/apps/api/src/modules/site/site.service.boundary.test.ts +++ b/apps/api/src/modules/site/site.service.boundary.test.ts @@ -16,8 +16,6 @@ vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() })) vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn() })) vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) })) -vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() })) -vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() })) vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })), presentPublicBooking: vi.fn((reservation: any) => ({ @@ -36,25 +34,17 @@ vi.mock('./site.repo', () => ({ createReservation: vi.fn(), findReservationWithDetails: vi.fn(), findBooking: vi.fn(), - findReservationForPayment: vi.fn(), createReservationPublicAccess: vi.fn(), findReservationPublicAccess: vi.fn(), markReservationPublicAccessUsed: vi.fn(), consumeReservationPublicAccess: vi.fn(), - createRentalPayment: vi.fn(), - findPaymentByPaypalOrderId: vi.fn(), - capturePaypalPayment: vi.fn(), })) -import { AppError } from '../../http/errors' import { prisma } from '../../lib/prisma' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { applyPricingRules } from '../../services/pricingRuleService' import { applyInsurancesToReservation } from '../../services/insuranceService' -import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService' -import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' +import { validateAndFlagLicense } from '../../services/licenseValidationService' import * as repo from './site.repo' import * as service from './site.service' @@ -83,7 +73,7 @@ function bookingBody(overrides: Record = {}) { } as any } -describe('site.service public booking/payment boundaries', () => { +describe('site.service public booking boundaries', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never) @@ -93,7 +83,6 @@ describe('site.service public booking/payment boundaries', () => { vi.mocked(repo.findCompanyBySlug).mockResolvedValue(company as never) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } as never) vi.mocked(applyPricingRules).mockResolvedValue({ applied: [{ code: 'WEEKEND' }], total: 90 } as never) - vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false, expired: false, expiringSoon: false } as never) }) it('uses configured pricing and configured feature labels when platform pricing exists', async () => { @@ -154,44 +143,11 @@ describe('site.service public booking/payment boundaries', () => { source: 'PUBLIC_SITE', })) expect(applyInsurancesToReservation).not.toHaveBeenCalled() - expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled() expect(validateAndFlagLicense).not.toHaveBeenCalled() expect(result.status).toBe('PENDING') expect(result.bookingReference).toBe('BK-2026-AB12CD') }) - it('rejects payment initialization for already-paid reservations before provider calls', async () => { - vi.mocked(repo.findReservationForPayment).mockResolvedValue({ paymentStatus: 'PAID' } as never) - - await expect(service.initPayment('atlas', 'reservation_1', { - provider: 'AMANPAY', - accessToken: 'booking-access-token-123', - successUrl: 'https://example.test/success', - failureUrl: 'https://example.test/failure', - })).rejects.toMatchObject({ statusCode: 409, error: 'already_paid' }) - expect(amanpay.createCheckout).not.toHaveBeenCalled() - }) - - it('blocks payment when the primary or additional driver license requires review', async () => { - vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRING_SOON', requiresApproval: true } as never) - vi.mocked(repo.findReservationForPayment).mockResolvedValue({ - id: 'reservation_1', - paymentStatus: 'UNPAID', - totalAmount: 400, - customer: { licenseExpiry: new Date('2026-07-01'), licenseValidationStatus: 'PENDING' }, - additionalDrivers: [], - vehicle: { make: 'Dacia', model: 'Duster' }, - } as never) - - await expect(service.initPayment('atlas', 'reservation_1', { - provider: 'PAYPAL', - accessToken: 'booking-access-token-123', - successUrl: 'https://example.test/success', - failureUrl: 'https://example.test/failure', - })).rejects.toMatchObject({ statusCode: 409, error: 'license_review_required' }) - expect(paypal.createOrder).not.toHaveBeenCalled() - }) - it('routes public contact messages to the brand public email when configured', async () => { await expect(service.handleContact('atlas', { name: 'Visitor', email: 'visitor@example.test', message: 'Hi' })).resolves.toEqual({ success: true, diff --git a/apps/api/src/modules/site/site.service.ts b/apps/api/src/modules/site/site.service.ts index 4c25a15..25f69a3 100644 --- a/apps/api/src/modules/site/site.service.ts +++ b/apps/api/src/modules/site/site.service.ts @@ -5,22 +5,9 @@ import { applyPricingRules } from '../../services/pricingRuleService' import { validateLicense } from '../../services/licenseValidationService' import { getCarplaceHomepageContent } from '../../services/platformContentService' import { prisma } from '../../lib/prisma' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' import * as repo from './site.repo' import { presentBrand, presentPublicBooking } from './site.presenter' import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens' -import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects' - -function assertCompanyPaymentRedirect(urlValue: string, company: any) { - const brand = company.brand as any - assertAllowedPaymentRedirect(urlValue, { - customDomain: brand?.customDomain, - customDomainVerified: brand?.customDomainVerified, - subdomain: brand?.subdomain, - }) -} - function assertPublicBookingCompanyAllowed(company: any) { if (!['ACTIVE', 'TRIALING'].includes(company.status)) { @@ -243,97 +230,6 @@ export async function getBooking(slug: string, reservationId: string, accessToke return presentPublicBooking(await repo.findBooking(reservationId, company.id)) } -export async function initPayment(slug: string, reservationId: string, body: { - provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string; accessToken?: string -}) { - const company = await repo.findCompanyBySlug(slug) - assertPublicBookingCompanyAllowed(company) - await assertPublicBookingAccess(reservationId, body.accessToken, { consume: true }) - const reservation = await repo.findReservationForPayment(reservationId, company.id) - - if (reservation.paymentStatus === 'PAID') { - throw new AppError('This reservation is already paid', 409, 'already_paid') - } - - const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry) - const licenseBlocked = - reservation.customer.licenseValidationStatus === 'DENIED' || - customerLicenseResult.status === 'EXPIRED' || - (customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') || - reservation.additionalDrivers.some((d: any) => d.licenseExpired || (d.requiresApproval && !d.approvedAt)) - - if (licenseBlocked) { - throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required') - } - - assertCompanyPaymentRedirect(body.successUrl, company) - assertCompanyPaymentRedirect(body.failureUrl, company) - - const currency = body.currency ?? 'MAD' - const amount = reservation.totalAmount - const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}` - const orderId = `res-${reservation.id}-${Date.now()}` - const webhookBase = process.env.API_URL ?? 'http://localhost:4000' - - let checkoutUrl: string - let amanpayTransactionId: string | null = null - let paypalCaptureId: string | null = null - - if (body.provider === 'AMANPAY') { - if (!amanpay.isConfigured()) { - throw new AppError('Online payment is not available for this company', 503, 'provider_not_configured') - } - const brand = company.brand as any - const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? '' - const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? '' - if (!merchantId || !secretKey) { - throw new AppError('AmanPay is not configured for this company', 503, 'provider_not_configured') - } - const result = await amanpay.createCheckout({ - amount, currency, orderId, description, - customerEmail: reservation.customer.email, - customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, - successUrl: body.successUrl, - failureUrl: body.failureUrl, - webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`, - }) - checkoutUrl = result.checkoutUrl - amanpayTransactionId = result.transactionId - } else { - if (!paypal.isConfigured()) { - throw new AppError('PayPal is not available for this company', 503, 'provider_not_configured') - } - const result = await paypal.createOrder({ - amount, currency, orderId, description, - returnUrl: body.successUrl, - cancelUrl: body.failureUrl, - }) - checkoutUrl = result.approveUrl - paypalCaptureId = result.orderId - } - - await repo.createRentalPayment({ - companyId: company.id, - reservationId: reservation.id, - amount, - currency, - paymentProvider: body.provider, - amanpayTransactionId, - paypalCaptureId, - }) - - return { checkoutUrl } -} - -export async function capturePaypal(slug: string, paypalOrderId: string) { - const company = await repo.findCompanyBySlug(slug) - const payment = await repo.findPaymentByPaypalOrderId(paypalOrderId, company.id) - const capture = await paypal.captureOrder(paypalOrderId) as Record - const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId - await repo.capturePaypalPayment(payment.id, captureId, payment.reservationId, payment.amount) - return { success: true } -} - export async function handleContact(slug: string, body: { name: string; email: string; message: string }) { const company = await repo.findCompanyBySlug(slug) return { diff --git a/apps/api/src/modules/site/site.test.ts b/apps/api/src/modules/site/site.test.ts index 79712e3..b365348 100644 --- a/apps/api/src/modules/site/site.test.ts +++ b/apps/api/src/modules/site/site.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { AppError, NotFoundError } from '../../http/errors' +import { NotFoundError } from '../../http/errors' vi.mock('@rentaldrivego/types', () => ({ PLAN_FEATURES: { STARTER: ['fallback feature'] }, @@ -24,14 +24,10 @@ vi.mock('./site.repo', () => ({ createReservation: vi.fn(), findReservationWithDetails: vi.fn(), findBooking: vi.fn(), - findReservationForPayment: vi.fn(), createReservationPublicAccess: vi.fn(), findReservationPublicAccess: vi.fn(), markReservationPublicAccessUsed: vi.fn(), consumeReservationPublicAccess: vi.fn(), - createRentalPayment: vi.fn(), - findPaymentByPaypalOrderId: vi.fn(), - capturePaypalPayment: vi.fn(), })) vi.mock('../../services/vehicleAvailabilityService', () => ({ @@ -55,17 +51,6 @@ vi.mock('../../services/licenseValidationService', () => ({ validateAndFlagLicense: vi.fn().mockResolvedValue(undefined), })) -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn(), - createCheckout: vi.fn(), -})) - -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn(), - createOrder: vi.fn(), - captureOrder: vi.fn(), -})) - vi.mock('../../services/platformContentService', () => ({ getCarplaceHomepageContent: vi.fn(), })) @@ -73,12 +58,9 @@ vi.mock('../../services/platformContentService', () => ({ import * as repo from './site.repo' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { applyPricingRules } from '../../services/pricingRuleService' -import { validateLicense } from '../../services/licenseValidationService' -import * as amanpay from '../../services/amanpayService' -import * as paypalSvc from '../../services/paypalService' import { getBrand, getPublicVehicles, checkAvailability, validatePromoCode, - createBooking, initPayment, + createBooking, } from './site.service' const SLUG = 'test-company' @@ -93,7 +75,7 @@ function makeCompany(overrides: object = {}) { return { id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com', status: 'ACTIVE', - brand: { publicEmail: null }, contractSettings: null, + brand: { publicEmail: null, displayName: 'Test Co' }, contractSettings: null, ...overrides, } } @@ -114,32 +96,18 @@ beforeEach(() => { vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never) }) -// ──────────────────────────────────────────────────────────────────────────── describe('getBrand', () => { it('returns company and public brand data only', async () => { vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany({ - brand: { - displayName: 'Test Co', - paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'], - amanpayMerchantId: 'merchant-id', - amanpaySecretKey: 'top-secret', - paypalMerchantId: 'paypal-merchant-id', - }, + brand: { displayName: 'Test Co', defaultCurrency: 'MAD' }, }) as any) const result = await getBrand(SLUG) expect(result.company.id).toBe('co-1') - expect(result.brand).toMatchObject({ - displayName: 'Test Co', - paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'], - paypalMerchantId: 'paypal-merchant-id', - }) - expect(result.brand).not.toHaveProperty('amanpayMerchantId') - expect(result.brand).not.toHaveProperty('amanpaySecretKey') + expect(result.brand).toMatchObject({ displayName: 'Test Co', defaultCurrency: 'MAD' }) }) }) -// ──────────────────────────────────────────────────────────────────────────── describe('getPublicVehicles', () => { it('returns vehicles enriched with availability', async () => { vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) @@ -151,7 +119,6 @@ describe('getPublicVehicles', () => { }) }) -// ──────────────────────────────────────────────────────────────────────────── describe('checkAvailability', () => { it('returns availability result for given date range', async () => { vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) @@ -164,7 +131,6 @@ describe('checkAvailability', () => { }) }) -// ──────────────────────────────────────────────────────────────────────────── describe('validatePromoCode', () => { it('returns offer when code is valid', async () => { vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) @@ -180,7 +146,6 @@ describe('validatePromoCode', () => { }) }) -// ──────────────────────────────────────────────────────────────────────────── describe('createBooking', () => { const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3) const baseBody = { @@ -237,74 +202,3 @@ describe('createBooking', () => { await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' }) }) }) - -// ──────────────────────────────────────────────────────────────────────────── -describe('initPayment — payment guard paths', () => { - const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://localhost:3000/ok', failureUrl: 'http://localhost:3000/fail', accessToken: 'booking-access-token-123' } - - it('throws AppError when reservation is already paid', async () => { - vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) - vi.mocked(repo.findReservationForPayment).mockResolvedValue({ - paymentStatus: 'PAID', totalAmount: 100, - customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'A', lastName: 'B' }, - additionalDrivers: [], - vehicle: { make: 'Toyota', model: 'Camry' }, - } as any) - vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) - - await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'already_paid' }) - }) - - it('throws AppError when license review is required', async () => { - vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) - vi.mocked(repo.findReservationForPayment).mockResolvedValue({ - paymentStatus: 'UNPAID', totalAmount: 100, - customer: { licenseExpiry: null, licenseValidationStatus: 'DENIED', email: 'a@b.com', firstName: 'A', lastName: 'B' }, - additionalDrivers: [], - vehicle: { make: 'Toyota', model: 'Camry' }, - } as any) - vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) - - await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'license_review_required' }) - }) - - it('throws AppError when PayPal is not configured', async () => { - vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) - vi.mocked(repo.findReservationForPayment).mockResolvedValue({ - paymentStatus: 'UNPAID', totalAmount: 30000, - customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' }, - additionalDrivers: [], - vehicle: { make: 'Toyota', model: 'Camry' }, - } as any) - vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) - vi.mocked(paypalSvc.isConfigured).mockReturnValue(false) - - await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'provider_not_configured' }) - }) - - it('returns checkoutUrl when PayPal is configured', async () => { - vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) - vi.mocked(repo.findReservationForPayment).mockResolvedValue({ - id: 'r-1', paymentStatus: 'UNPAID', totalAmount: 30000, companyId: 'co-1', - customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' }, - additionalDrivers: [], - vehicle: { make: 'Toyota', model: 'Camry' }, - } as any) - vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) - vi.mocked(paypalSvc.isConfigured).mockReturnValue(true) - vi.mocked(paypalSvc.createOrder).mockResolvedValue({ approveUrl: 'https://paypal.com/approve', orderId: 'pp-1' } as any) - vi.mocked(repo.createRentalPayment).mockResolvedValue(undefined as any) - - const result = await initPayment(SLUG, 'r-1', payBody) - expect(result.checkoutUrl).toBe('https://paypal.com/approve') - expect(repo.createRentalPayment).toHaveBeenCalledOnce() - expect(repo.consumeReservationPublicAccess).toHaveBeenCalled() - }) - - it('rejects payment when public access token was already consumed (S12)', async () => { - vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) - vi.mocked(repo.findReservationPublicAccess).mockResolvedValue(null as never) - - await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'not_found' }) - }) -}) diff --git a/apps/api/src/modules/subscriptions/subscription.collections.service.ts b/apps/api/src/modules/subscriptions/subscription.collections.service.ts index 89a48f0..c4ba67a 100644 --- a/apps/api/src/modules/subscriptions/subscription.collections.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.collections.service.ts @@ -57,21 +57,21 @@ const labels: Record `Invoice ${invoice} for ${amount} remains unpaid. Your current subscription period expires on ${expiration}.`, grace: (invoice, amount, day, remaining, suspension) => `Invoice ${invoice} for ${amount} is overdue. Grace day ${day}; ${remaining} day(s) remain. Service is scheduled for suspension on ${suspension} unless payment is confirmed.`, - action: 'Open Subscription in the dashboard to pay by Stripe or view the configured bank/check instructions.', + action: 'Open Subscription in the dashboard to view the configured bank/check instructions.', callScript: (company, invoice, amount, expiration) => `Hello, I am calling RentalDriveGo regarding ${company}'s invoice ${invoice} for ${amount}. The subscription expires on ${expiration}. Can I help confirm the payment plan?`, }, fr: { titles: { DUE_14D: 'Paiement de l’abonnement dû dans 14 jours', DUE_7D: 'Paiement de l’abonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de l’abonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' }, due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période d’abonnement actuelle expire le ${expiration}.`, grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement n’est pas confirmé.`, - action: 'Ouvrez Abonnement dans le tableau de bord pour payer par Stripe ou consulter les instructions de virement/chèque.', + action: 'Ouvrez Abonnement dans le tableau de bord pour consulter les instructions de virement/chèque.', callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, d’un montant de ${amount}. L’abonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`, }, ar: { titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' }, due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`, grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`, - action: 'افتح صفحة الاشتراك في لوحة التحكم للدفع عبر Stripe أو لعرض تعليمات التحويل البنكي أو الشيك.', + action: 'افتح صفحة الاشتراك في لوحة التحكم لعرض تعليمات التحويل البنكي أو الشيك.', callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`, }, } @@ -296,7 +296,8 @@ export async function ensureRenewalCollectionsCases(now = new Date()) { select: { collectionMethod: true }, }) const enabledMethods = getPaymentOptions(coerceNotificationLocale(account.defaultCommunicationLocale)).methods.filter((item: any) => item.enabled).map((item: any) => item.method) - const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : (enabledMethods[0] ?? 'STRIPE') + if (enabledMethods.length === 0) continue + const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : enabledMethods[0] const assignee = account.collectionsOwnerAdminId ? await prisma.adminUser.findFirst({ where: { id: account.collectionsOwnerAdminId, isActive: true } }) : await prisma.adminUser.findFirst({ where: { isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } }, orderBy: { createdAt: 'asc' } }) @@ -329,7 +330,7 @@ export async function ensureRenewalCollectionsCases(now = new Date()) { billingName: account.legalName, billingEmail: account.billingEmail, billingAddress: account.billingAddress ?? undefined, - paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL', + paymentProvider: 'MANUAL', collectionMethod, requestedPlan: subscription.plan, requestedBillingPeriod: subscription.billingPeriod, @@ -365,7 +366,7 @@ export async function ensureRenewalCollectionsCases(now = new Date()) { amount: tax.totalAmount, currency: subscription.currency, status: 'PENDING', - paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL', + paymentProvider: 'MANUAL', billingInvoiceId: invoice!.id, dueAt: subscription.currentPeriodEnd, }, diff --git a/apps/api/src/modules/subscriptions/subscription.manual.service.ts b/apps/api/src/modules/subscriptions/subscription.manual.service.ts index 6784b53..ca0bb51 100644 --- a/apps/api/src/modules/subscriptions/subscription.manual.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.manual.service.ts @@ -63,7 +63,6 @@ function fmtDate(value?: Date | string | null) { function paymentMethodLabel(method?: string | null) { if (method === 'BANK_TRANSFER') return 'Bank transfer' if (method === 'CHECK') return 'Check' - if (method === 'STRIPE') return 'Online card payment' return method ?? 'Manual payment' } @@ -410,7 +409,7 @@ export async function createManualCheckout(companyId: string, employeeId: string if (scheduled.legacySubscriptionInvoice) { await tx.subscriptionInvoice.update({ where: { id: scheduled.legacySubscriptionInvoice.id }, - data: { paymentProvider: 'MANUAL', stripeCheckoutSessionId: null }, + data: { paymentProvider: 'MANUAL' }, }) } await createBillingEvent(tx, { @@ -538,163 +537,6 @@ export async function createManualCheckout(companyId: string, employeeId: string }) } -export async function createCanonicalStripeCheckoutInvoice(data: { - companyId: string - subscriptionId: string - plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE' - billingPeriod: 'MONTHLY' | 'ANNUAL' - amount: number - currency: 'MAD' - stripeCheckoutSessionId: string - dueAt: Date -}) { - const account = await ensurePrimaryBillingAccount(data.companyId) - const platformBillingSettings = await getPlatformBillingSettings() - const tax = calculateTaxForAccount(data.amount, account, platformBillingSettings.taxRate) - return prisma.$transaction(async (tx: any) => { - const duplicate = await tx.billingInvoice.findFirst({ - where: { metadata: { path: ['stripeCheckoutSessionId'], equals: data.stripeCheckoutSessionId } }, - }) - if (duplicate) return duplicate - const subscription = await tx.subscription.findUniqueOrThrow({ where: { id: data.subscriptionId } }) - const now = new Date() - const isRenewal = subscription.status === 'ACTIVE' && subscription.currentPeriodEnd && subscription.currentPeriodEnd > now - const renewalKey = isRenewal ? `${subscription.id}:${subscription.currentPeriodEnd!.toISOString()}` : null - if (renewalKey) { - const scheduled = await tx.billingInvoice.findUnique({ - where: { renewalKey }, - include: { legacySubscriptionInvoice: true }, - }) - if (scheduled) { - if (!PAYABLE_INVOICE_STATUSES.includes(scheduled.status)) { - throw new ConflictError('The renewal invoice is not payable') - } - if (scheduled.amountDue !== tax.totalAmount || scheduled.requestedPlan !== data.plan || scheduled.requestedBillingPeriod !== data.billingPeriod) { - throw new ConflictError('An existing renewal invoice must be resolved before changing the renewal terms') - } - const invoice = await tx.billingInvoice.update({ - where: { id: scheduled.id }, - data: { - collectionMethod: 'STRIPE', - paymentProvider: 'STRIPE', - metadata: { - ...((scheduled.metadata as Record) ?? {}), - source: 'stripe_checkout', - stripeCheckoutSessionId: data.stripeCheckoutSessionId, - }, - }, - }) - if (scheduled.legacySubscriptionInvoice) { - await tx.subscriptionInvoice.update({ - where: { id: scheduled.legacySubscriptionInvoice.id }, - data: { paymentProvider: 'STRIPE', stripeCheckoutSessionId: data.stripeCheckoutSessionId }, - }) - } else { - await tx.subscriptionInvoice.create({ - data: { - companyId: data.companyId, - subscriptionId: data.subscriptionId, - requestedPlan: data.plan, - requestedBillingPeriod: data.billingPeriod, - amount: tax.totalAmount, - currency: data.currency, - status: 'PENDING', - paymentProvider: 'STRIPE', - stripeCheckoutSessionId: data.stripeCheckoutSessionId, - billingInvoiceId: invoice.id, - dueAt: scheduled.dueAt ?? data.dueAt, - }, - }) - } - await createBillingEvent(tx, { - billingAccountId: account.id, - invoiceId: invoice.id, - subscriptionId: data.subscriptionId, - companyId: data.companyId, - eventType: 'stripe_checkout.created', - source: 'customer', - payload: { stripeCheckoutSessionId: data.stripeCheckoutSessionId, reusedRenewalInvoice: true }, - }) - return invoice - } - } - const invoiceSequence = await getNextInvoiceSequence(tx) - const invoiceNumber = buildSequentialInvoiceNumber(invoiceSequence, now) - const invoice = await tx.billingInvoice.create({ - data: { - billingAccountId: account.id, - companyId: data.companyId, - subscriptionId: data.subscriptionId, - invoiceNumber, - invoiceSequence, - invoiceType: isRenewal ? 'SUBSCRIPTION_RENEWAL' : 'SUBSCRIPTION_INITIAL', - status: 'OPEN', - currency: data.currency, - subtotalAmount: data.amount, - taxAmount: tax.taxAmount, - totalAmount: tax.totalAmount, - amountDue: tax.totalAmount, - invoiceDate: now, - dueAt: data.dueAt, - finalizedAt: now, - billingName: account.legalName, - billingEmail: account.billingEmail, - billingAddress: account.billingAddress ?? undefined, - paymentProvider: 'STRIPE', - collectionMethod: 'STRIPE', - requestedPlan: data.plan, - requestedBillingPeriod: data.billingPeriod, - renewalKey, - isSubscriptionBlocking: true, - metadata: { source: 'stripe_checkout', stripeCheckoutSessionId: data.stripeCheckoutSessionId }, - lineItems: { - create: [ - { - subscriptionId: data.subscriptionId, - plan: data.plan, - type: 'SUBSCRIPTION_FEE', - description: `${data.plan} subscription — ${data.billingPeriod}`, - quantity: 1, - unitAmount: data.amount, - amount: data.amount, - currency: data.currency, - periodStart: isRenewal ? subscription.currentPeriodEnd : now, - periodEnd: isRenewal ? addBillingPeriod(subscription.currentPeriodEnd!, data.billingPeriod) : addBillingPeriod(now, data.billingPeriod), - }, - ...taxLineItem(tax, data.currency), - ], - }, - ...(taxRecordCreate(account, tax) ? { taxRecords: taxRecordCreate(account, tax) } : {}), - }, - }) - await tx.subscriptionInvoice.create({ - data: { - companyId: data.companyId, - subscriptionId: data.subscriptionId, - requestedPlan: data.plan, - requestedBillingPeriod: data.billingPeriod, - amount: tax.totalAmount, - currency: data.currency, - status: 'PENDING', - paymentProvider: 'STRIPE', - stripeCheckoutSessionId: data.stripeCheckoutSessionId, - billingInvoiceId: invoice.id, - dueAt: data.dueAt, - }, - }) - await createBillingEvent(tx, { - billingAccountId: account.id, - invoiceId: invoice.id, - subscriptionId: data.subscriptionId, - companyId: data.companyId, - eventType: 'stripe_checkout.created', - source: 'customer', - payload: { stripeCheckoutSessionId: data.stripeCheckoutSessionId }, - }) - return invoice - }) -} - export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, providerPaymentId?: string) { let collectionsCaseId: string | null = null const handled = await prisma.$transaction(async (tx: any) => { @@ -711,7 +553,7 @@ export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, pr data: { invoiceId: invoice.id, billingAccountId: invoice.billingAccountId, - providerPaymentId: providerPaymentId ?? legacy.stripeCheckoutSessionId ?? legacy.providerInvoiceId, + providerPaymentId: providerPaymentId ?? legacy.providerInvoiceId, channel: 'ONLINE', status: 'SUCCEEDED', amount: invoice.amountDue, @@ -833,7 +675,7 @@ export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, pr invoice: invoiceLabel, amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount, currency: invoice.currency, - paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'STRIPE', + paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'BANK_TRANSFER', paymentReference: paymentAttempt?.providerPaymentId ?? null, paidAt: invoice.paidAt, plan: invoice.requestedPlan ?? invoice.subscription?.plan ?? null, @@ -854,7 +696,7 @@ export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, pr invoiceId: invoice.id, amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount, currency: invoice.currency, - paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'STRIPE', + paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'BANK_TRANSFER', paymentReference: paymentAttempt?.providerPaymentId ?? null, subscriptionStart: periodStart, subscriptionEnd: periodEnd, @@ -889,7 +731,7 @@ export async function recordCanonicalOnlinePaymentFailure( data: { invoiceId: invoice.id, billingAccountId: invoice.billingAccountId, - providerPaymentId: legacy.stripeCheckoutSessionId ?? legacy.providerInvoiceId, + providerPaymentId: legacy.providerInvoiceId, channel: 'ONLINE', status: 'FAILED', amount: invoice.amountDue, @@ -997,7 +839,7 @@ export async function getCanonicalInvoices(companyId: string) { currency: invoice.currency, status: invoice.status, paymentProvider: invoice.paymentProvider, - collectionMethod: invoice.paymentProvider === 'STRIPE' ? 'STRIPE' : 'BANK_TRANSFER', + collectionMethod: 'BANK_TRANSFER', requestedPlan: invoice.requestedPlan, requestedBillingPeriod: invoice.requestedBillingPeriod, dueAt: invoice.dueAt, diff --git a/apps/api/src/modules/subscriptions/subscription.repo.ts b/apps/api/src/modules/subscriptions/subscription.repo.ts index 70ed588..45fe6fa 100644 --- a/apps/api/src/modules/subscriptions/subscription.repo.ts +++ b/apps/api/src/modules/subscriptions/subscription.repo.ts @@ -27,34 +27,6 @@ export function findInvoices(companyId: string) { }) } -export function findInvoiceByAmanpay(transactionId: string) { - return prisma.subscriptionInvoice.findFirst({ - where: { amanpayTransactionId: transactionId }, - include: { subscription: true }, - }) -} - -export function findInvoiceByPaypal(captureId: string) { - return prisma.subscriptionInvoice.findFirst({ - where: { paypalCaptureId: captureId }, - include: { subscription: true }, - }) -} - -export function findInvoiceByStripe(sessionId: string) { - return prisma.subscriptionInvoice.findFirst({ - where: { stripeCheckoutSessionId: sessionId }, - include: { subscription: true }, - }) -} - -export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) { - return prisma.subscriptionInvoice.findFirstOrThrow({ - where: { paypalCaptureId: paypalOrderId, companyId }, - include: { subscription: true }, - }) -} - export function findInvoiceById(id: string) { return prisma.subscriptionInvoice.findUniqueOrThrow({ where: { id }, @@ -212,9 +184,6 @@ export function createInvoice(data: { amount: number currency: string paymentProvider: string - amanpayTransactionId?: string | null - paypalCaptureId?: string | null - stripeCheckoutSessionId?: string | null dueAt?: Date | null }) { return prisma.subscriptionInvoice.create({ @@ -249,13 +218,6 @@ export function markInvoiceVoided(id: string) { }) } -export function updateInvoicePaypal(id: string, captureId: string) { - return prisma.subscriptionInvoice.update({ - where: { id }, - data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId, failedAt: null }, - }) -} - // ─── Payment attempts ───────────────────────────────────────── export function createPaymentAttempt(data: { diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index 2d3d9ff..d6f017a 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -6,18 +6,11 @@ import { requireRole } from '../../middleware/requireRole' import { parseBody, parseParams } from '../../http/validate' import { created, ok } from '../../http/respond' import { paymentEvidenceUpload } from '../../http/upload/paymentEvidence' -import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as service from './subscription.service' import { - checkoutSchema, changePlanSchema, - capturePaypalSchema, startTrialSchema, cancelSchema, - reactivateSchema, manualCheckoutSchema, invoiceIdParamSchema, submissionIdParamSchema, @@ -33,9 +26,8 @@ import { import * as manualService from './subscription.manual.service' import * as upgradeService from './subscription.upgrade.service' -const publicRouter = Router() -const webhookRouter = Router() -const router = Router() +const publicRouter = Router() +const router = Router() // ─── Public ──────────────────────────────────────────────────── @@ -43,51 +35,10 @@ publicRouter.get('/plans', (_req, res, next) => { service.getPlans().then((d: any) => ok(res, d)).catch(next) }) -publicRouter.get('/providers', (_req, res) => { - ok(res, service.getProviders()) -}) - publicRouter.get('/features', (_req, res, next) => { service.getPlanFeatures().then((d: any) => ok(res, d)).catch(next) }) -// ─── Webhooks (no auth) ──────────────────────────────────────── - -webhookRouter.post('/webhooks/amanpay', async (req, res, next) => { - try { - const rawBody = getRawBodyString(req) - const payload = parseRawJsonBody(req) - const signature = (req.headers['x-amanpay-signature'] as string) ?? '' - if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) { - return res.status(401).json({ error: 'invalid_signature' }) - } - await service.handleAmanpayWebhook(payload, rawBody) - res.json({ received: true }) - } catch (err) { next(err) } -}) - -webhookRouter.post('/webhooks/paypal', async (req, res, next) => { - try { - const rawBody = getRawBodyString(req) - const payload = parseRawJsonBody(req) - const isValid = await paypal.verifyWebhookEvent(req.headers as Record, rawBody) - if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' }) - await service.handlePaypalWebhook(payload, rawBody) - res.json({ received: true }) - } catch (err) { next(err) } -}) - -webhookRouter.post('/webhooks/stripe', async (req, res, next) => { - try { - const rawBody = getRawBodyString(req) - const signature = (req.headers['stripe-signature'] as string) ?? '' - if (!stripe.isConfigured()) return res.status(401).json({ error: 'invalid_signature' }) - const event = stripe.constructWebhookEvent(rawBody, signature) - await service.handleStripeWebhook(event, rawBody) - res.json({ received: true }) - } catch (err) { next(err) } -}) - // ─── Authenticated billing recovery/self-service ─────────────── router.use(requireCompanyAuth, requireTenant) @@ -163,13 +114,6 @@ router.post('/trial', requireRole('OWNER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/checkout', requireRole('OWNER'), async (req, res, next) => { - try { - const body = parseBody(checkoutSchema, req) - ok(res, await service.checkout(req.companyId, body)) - } catch (err) { next(err) } -}) - router.post('/manual-checkout', requireRole('OWNER'), async (req, res, next) => { try { created(res, await manualService.createManualCheckout( @@ -247,13 +191,6 @@ router.put('/communication-settings', requireRole('OWNER'), async (req, res, nex } catch (err) { next(err) } }) -router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => { - try { - const body = parseBody(reactivateSchema, req) - ok(res, await service.reactivate(req.companyId, body)) - } catch (err) { next(err) } -}) - router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(changePlanSchema, req) @@ -261,13 +198,6 @@ router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/capture-paypal', requireRole('OWNER'), async (req, res, next) => { - try { - const { paypalOrderId } = parseBody(capturePaypalSchema, req) - ok(res, await service.capturePaypal(req.companyId, paypalOrderId)) - } catch (err) { next(err) } -}) - // ─── Active subscription actions ─────────────────────────────── router.use(requireSubscriptionRead) @@ -284,4 +214,4 @@ router.post('/resume', requireSubscriptionFull, requireRole('OWNER'), async (req }) export default router -export { publicRouter as subscriptionPublicRouter, webhookRouter as subscriptionWebhookRouter } +export { publicRouter as subscriptionPublicRouter } diff --git a/apps/api/src/modules/subscriptions/subscription.schemas.edge.test.ts b/apps/api/src/modules/subscriptions/subscription.schemas.edge.test.ts index ee8d316..60dd4e5 100644 --- a/apps/api/src/modules/subscriptions/subscription.schemas.edge.test.ts +++ b/apps/api/src/modules/subscriptions/subscription.schemas.edge.test.ts @@ -1,30 +1,28 @@ import { describe, expect, it } from 'vitest' import { cancelSchema, - capturePaypalSchema, changePlanSchema, - checkoutSchema, - reactivateSchema, + manualCheckoutSchema, startTrialSchema, } from './subscription.schemas' describe('subscription.schemas edge contracts', () => { - it('accepts checkout only for MAD and valid hosted payment urls', () => { + it('accepts manual checkout for MAD bank transfer or check with a UUID idempotency key', () => { const payload = { - plan: 'PRO', - billingPeriod: 'ANNUAL', - currency: 'MAD', - provider: 'STRIPE', - successUrl: 'https://app.example.test/success', - failureUrl: 'https://app.example.test/failure', + plan: 'PRO' as const, + billingPeriod: 'ANNUAL' as const, + currency: 'MAD' as const, + method: 'BANK_TRANSFER' as const, + idempotencyKey: '11111111-1111-4111-8111-111111111111', } - expect(checkoutSchema.parse(payload)).toEqual(payload) - expect(checkoutSchema.safeParse({ ...payload, currency: 'USD' }).success).toBe(false) - expect(checkoutSchema.safeParse({ ...payload, successUrl: '/relative' }).success).toBe(false) + expect(manualCheckoutSchema.parse(payload)).toEqual(payload) + expect(manualCheckoutSchema.safeParse({ ...payload, currency: 'USD' }).success).toBe(false) + expect(manualCheckoutSchema.safeParse({ ...payload, method: 'STRIPE' }).success).toBe(false) + expect(manualCheckoutSchema.safeParse({ ...payload, idempotencyKey: 'not-a-uuid' }).success).toBe(false) }) - it('allows plan changes across supported currencies while trial and reactivation stay MAD-only', () => { + it('allows plan changes across supported currencies while trial stays MAD-only', () => { expect(changePlanSchema.parse({ plan: 'STARTER', billingPeriod: 'MONTHLY', currency: 'USD' })).toEqual({ plan: 'STARTER', billingPeriod: 'MONTHLY', @@ -36,15 +34,6 @@ describe('subscription.schemas edge contracts', () => { billingPeriod: 'MONTHLY', currency: 'MAD', }) - - expect(reactivateSchema.safeParse({ - plan: 'GROWTH', - billingPeriod: 'MONTHLY', - currency: 'EUR', - provider: 'PAYPAL', - successUrl: 'https://app.example.test/success', - failureUrl: 'https://app.example.test/failure', - }).success).toBe(false) }) it('defaults cancellation to period-end and caps cancellation reasons', () => { @@ -53,9 +42,4 @@ describe('subscription.schemas edge contracts', () => { expect(cancelSchema.safeParse({ mode: 'tomorrow' }).success).toBe(false) expect(cancelSchema.safeParse({ reason: 'x'.repeat(501) }).success).toBe(false) }) - - it('requires a PayPal order id for manual capture', () => { - expect(capturePaypalSchema.parse({ paypalOrderId: 'order_1' })).toEqual({ paypalOrderId: 'order_1' }) - expect(capturePaypalSchema.safeParse({}).success).toBe(false) - }) }) diff --git a/apps/api/src/modules/subscriptions/subscription.schemas.ts b/apps/api/src/modules/subscriptions/subscription.schemas.ts index 67dcc30..7fb3a30 100644 --- a/apps/api/src/modules/subscriptions/subscription.schemas.ts +++ b/apps/api/src/modules/subscriptions/subscription.schemas.ts @@ -2,7 +2,6 @@ import { z } from 'zod' const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']) const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL']) -const providerEnum = z.enum(['STRIPE']) const currencyEnum = z.enum(['MAD', 'EUR', 'USD']) const manualMethodEnum = z.enum(['BANK_TRANSFER', 'CHECK']) const localeEnum = z.enum(['ar', 'en', 'fr']) @@ -12,25 +11,12 @@ const referenceSchema = z.string() .max(120) .refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters') -export const checkoutSchema = z.object({ - plan: planEnum, - billingPeriod: billingPeriodEnum, - currency: z.literal('MAD'), - provider: providerEnum, - successUrl: z.string().url(), - failureUrl: z.string().url(), -}) - export const changePlanSchema = z.object({ plan: planEnum, billingPeriod: billingPeriodEnum, currency: currencyEnum, }) -export const capturePaypalSchema = z.object({ - paypalOrderId: z.string(), -}) - export const startTrialSchema = z.object({ plan: planEnum, billingPeriod: billingPeriodEnum, @@ -42,15 +28,6 @@ export const cancelSchema = z.object({ reason: z.string().max(500).optional(), }) -export const reactivateSchema = z.object({ - plan: planEnum, - billingPeriod: billingPeriodEnum, - currency: z.literal('MAD'), - provider: providerEnum, - successUrl: z.string().url(), - failureUrl: z.string().url(), -}) - export const manualCheckoutSchema = z.object({ plan: planEnum, billingPeriod: billingPeriodEnum, diff --git a/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts b/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts index b3a892b..01d522a 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts @@ -8,19 +8,6 @@ vi.mock('../../lib/prisma', () => ({ subscription: { update: vi.fn() }, }, })) -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn(), - createCheckout: vi.fn(), -})) -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn(), - createOrder: vi.fn(), - captureOrder: vi.fn(), -})) -vi.mock('../../services/stripeService', () => ({ - isConfigured: vi.fn(), - createCheckoutSession: vi.fn(), -})) vi.mock('./subscription.repo', () => ({ findByCompany: vi.fn(), findById: vi.fn(), @@ -28,10 +15,6 @@ vi.mock('./subscription.repo', () => ({ getEvents: vi.fn(), startTrial: vi.fn(), createEvent: vi.fn(), - findInvoiceByAmanpay: vi.fn(), - findInvoiceByPaypal: vi.fn(), - findInvoiceByStripe: vi.fn(), - findInvoiceByPaypalForCompany: vi.fn(), findOrCreateSubscription: vi.fn(), createInvoice: vi.fn(), markInvoicePaid: vi.fn(), @@ -40,7 +23,6 @@ vi.mock('./subscription.repo', () => ({ incrementRetryCount: vi.fn(), activateSubscription: vi.fn(), setPaymentPending: vi.fn(), - updateInvoicePaypal: vi.fn(), updatePlan: vi.fn(), setCancelled: vi.fn(), setCancelAtPeriodEnd: vi.fn(), @@ -54,26 +36,8 @@ vi.mock('./subscription.repo', () => ({ setSuspended: vi.fn(), })) -vi.mock('./subscription.manual.service', () => ({ - createCanonicalStripeCheckoutInvoice: vi.fn(), - finalizeCanonicalOnlinePayment: vi.fn().mockResolvedValue(false), - recordCanonicalOnlinePaymentFailure: vi.fn().mockResolvedValue(false), -})) - -vi.mock('../../security/webhookIdempotency', () => ({ - getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`), - processWebhookOnce: vi.fn(async ({ handle }: { handle: () => Promise }) => { - const result = await handle() - return { duplicate: false, result } - }), -})) - import { prisma } from '../../lib/prisma' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as repo from './subscription.repo' -import * as manualService from './subscription.manual.service' import * as service from './subscription.service' describe('subscription.service operational edges', () => { @@ -81,14 +45,10 @@ describe('subscription.service operational edges', () => { vi.clearAllMocks() vi.useFakeTimers() vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z')) - process.env.API_URL = 'https://api.example.test' - process.env.DASHBOARD_URL = 'https://app.example.test' }) afterEach(() => { vi.useRealTimers() - delete process.env.API_URL - delete process.env.DASHBOARD_URL }) it('builds plans from pricing config rows when platform overrides exist', async () => { @@ -134,66 +94,6 @@ describe('subscription.service operational edges', () => { })) }) - it('rejects Stripe subscription checkout before creating sessions or invoices', async () => { - await expect(service.checkout('company_1', { - plan: 'GROWTH', - billingPeriod: 'MONTHLY', - currency: 'MAD', - provider: 'STRIPE', - successUrl: 'https://app.example.test/success', - failureUrl: 'https://app.example.test/failure', - })).rejects.toThrow('Stripe subscription checkout is disabled') - - expect(stripe.createCheckoutSession).not.toHaveBeenCalled() - expect(manualService.createCanonicalStripeCheckoutInvoice).not.toHaveBeenCalled() - }) - - it('activates the plan purchased through Stripe instead of keeping the previous subscription plan', async () => { - vi.mocked(repo.findInvoiceByStripe).mockResolvedValue({ - id: 'invoice_1', - subscriptionId: 'sub_1', - status: 'PENDING', - requestedPlan: 'PRO', - requestedBillingPeriod: 'ANNUAL', - currency: 'MAD', - } as never) - vi.mocked(repo.findById).mockResolvedValue({ - id: 'sub_1', - companyId: 'company_1', - plan: 'STARTER', - billingPeriod: 'MONTHLY', - status: 'ACTIVE', - } as never) - - await service.handleStripeWebhook({ - id: 'evt_1', - type: 'checkout.session.completed', - data: { object: { id: 'cs_test_123' } }, - }) - - expect(repo.markInvoicePaid).toHaveBeenCalledWith('invoice_1') - expect(repo.activateSubscription).toHaveBeenCalledWith( - 'sub_1', - new Date('2027-06-01T00:00:00.000Z'), - { plan: 'PRO', billingPeriod: 'ANNUAL', currency: 'MAD' }, - ) - expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({ - subscriptionId: 'sub_1', - companyId: 'company_1', - eventType: 'subscription.activated', - payload: expect.objectContaining({ invoiceId: 'invoice_1', purchasedPlan: 'PRO' }), - })) - }) - - it('keeps paid PayPal capture idempotent and avoids provider capture', async () => { - vi.mocked(repo.findInvoiceByPaypalForCompany).mockResolvedValue({ status: 'PAID' } as never) - - await expect(service.capturePaypal('company_1', 'order_1')).resolves.toEqual({ success: true }) - - expect(paypal.captureOrder).not.toHaveBeenCalled() - expect(repo.activateSubscription).not.toHaveBeenCalled() - }) - it('advances payment-pending subscriptions to past-due in the scheduled job', async () => { vi.mocked(repo.findPaymentPendingTimedOut).mockResolvedValue([ { id: 'sub_1', companyId: 'company_1', paymentPendingSince: new Date('2026-05-20T00:00:00.000Z') }, diff --git a/apps/api/src/modules/subscriptions/subscription.service.ts b/apps/api/src/modules/subscriptions/subscription.service.ts index 7d537c2..39cc85a 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.ts @@ -1,16 +1,8 @@ import { PLAN_PRICES } from '@rentaldrivego/types' import { prisma } from '../../lib/prisma' import { ValidationError } from '../../http/errors' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as repo from './subscription.repo' import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy' -import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency' -import { - finalizeCanonicalOnlinePayment, - recordCanonicalOnlinePaymentFailure, -} from './subscription.manual.service' // ─── Helpers ────────────────────────────────────────────────── @@ -33,13 +25,6 @@ export async function getPlans() { return result } -export function getProviders() { - return { - stripe: false, - stripeProblems: [], - } -} - export function getPlanFeatures() { return prisma.planFeature.findMany({ orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], @@ -110,190 +95,6 @@ export async function startTrial( return sub } -// ─── Payment success (shared by all providers) ─────────────── - -async function handlePaymentSuccess(subscriptionId: string, invoiceId: string, purchasedPlan?: { - plan?: string | null - billingPeriod?: string | null - currency?: string | null -}) { - if (await finalizeCanonicalOnlinePayment(invoiceId)) return - const sub = await repo.findById(subscriptionId) - if (!sub) return - - await repo.markInvoicePaid(invoiceId) - await repo.createPaymentAttempt({ - invoiceId, - subscriptionId, - status: 'succeeded', - }) - - const billingPeriod = purchasedPlan?.billingPeriod ?? sub.billingPeriod - const periodEnd = addPeriod(new Date(), billingPeriod) - await repo.activateSubscription(subscriptionId, periodEnd, purchasedPlan) - await repo.createEvent({ - subscriptionId, - companyId: sub.companyId, - eventType: sub.status === 'TRIALING' ? 'trial.converted' : 'subscription.activated', - source: 'webhook', - payload: { invoiceId, periodEnd, purchasedPlan: purchasedPlan?.plan ?? sub.plan }, - }) -} - -// ─── Payment failure ────────────────────────────────────────── - -async function handlePaymentFailure( - subscriptionId: string, - invoiceId: string, - failureCode?: string, - failureMessage?: string, -) { - if (await recordCanonicalOnlinePaymentFailure(invoiceId, failureCode, failureMessage)) return - const sub = await repo.findById(subscriptionId) - if (!sub) return - - await repo.markInvoiceFailed(invoiceId) - await repo.createPaymentAttempt({ - invoiceId, - subscriptionId, - status: 'failed', - failureCode, - failureMessage, - }) - await repo.incrementRetryCount(sub.id) - - if (sub.status !== 'PAYMENT_PENDING') { - await repo.setPaymentPending(sub.id) - await repo.createEvent({ - subscriptionId: sub.id, - companyId: sub.companyId, - eventType: 'subscription.payment_pending', - source: 'webhook', - payload: { invoiceId, failureCode }, - }) - } -} - -// ─── Webhook handlers ───────────────────────────────────────── - -async function applyAmanpayWebhook(event: any) { - const transactionId = event.transaction_id ?? event.id - const status = event.status?.toUpperCase() - - if (status === 'PAID' || status === 'SUCCEEDED') { - const invoice = await repo.findInvoiceByAmanpay(transactionId) - if (!invoice || invoice.status === 'PAID') return // idempotent - await handlePaymentSuccess(invoice.subscriptionId, invoice.id, { - plan: invoice.requestedPlan, - billingPeriod: invoice.requestedBillingPeriod, - currency: invoice.currency, - }) - } else if (status === 'FAILED' || status === 'DECLINED') { - const invoice = await repo.findInvoiceByAmanpay(transactionId) - if (!invoice || invoice.status === 'PAID') return - await handlePaymentFailure(invoice.subscriptionId, invoice.id, status, event.failure_reason) - } -} - -async function applyPaypalWebhook(event: any) { - if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { - const captureId = event.resource?.id as string - const invoice = await repo.findInvoiceByPaypal(captureId) - if (!invoice || invoice.status === 'PAID') return // idempotent - await handlePaymentSuccess(invoice.subscriptionId, invoice.id, { - plan: invoice.requestedPlan, - billingPeriod: invoice.requestedBillingPeriod, - currency: invoice.currency, - }) - } else if (event.event_type === 'PAYMENT.CAPTURE.DENIED') { - const captureId = event.resource?.id as string - const invoice = await repo.findInvoiceByPaypal(captureId) - if (!invoice || invoice.status === 'PAID') return - await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'capture_denied') - } -} - -async function applyStripeWebhook(event: any) { - if (event.type === 'checkout.session.completed') { - const sessionId = event.data?.object?.id as string - const invoice = await repo.findInvoiceByStripe(sessionId) - if (!invoice || invoice.status === 'PAID') return - await handlePaymentSuccess(invoice.subscriptionId, invoice.id, { - plan: invoice.requestedPlan, - billingPeriod: invoice.requestedBillingPeriod, - currency: invoice.currency, - }) - } else if (event.type === 'checkout.session.expired') { - const sessionId = event.data?.object?.id as string - const invoice = await repo.findInvoiceByStripe(sessionId) - if (!invoice || invoice.status === 'PAID') return - await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'checkout_session_expired') - } -} - -export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) { - return processWebhookOnce({ - provider: 'amanpay:subscriptions', - providerEventId: getWebhookEventId('amanpay', event), - eventType: String(event.status ?? 'unknown'), - rawBody, - handle: () => applyAmanpayWebhook(event), - }) -} - -export async function handlePaypalWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) { - return processWebhookOnce({ - provider: 'paypal:subscriptions', - providerEventId: getWebhookEventId('paypal', event), - eventType: String(event.event_type ?? 'unknown'), - rawBody, - handle: () => applyPaypalWebhook(event), - }) -} - -export async function handleStripeWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) { - return processWebhookOnce({ - provider: 'stripe:subscriptions', - providerEventId: String(event.id), - eventType: String(event.type ?? 'unknown'), - rawBody, - handle: () => applyStripeWebhook(event), - }) -} - -// ─── Checkout ───────────────────────────────────────────────── - -export async function checkout(companyId: string, body: { - plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL' - currency: 'MAD'; provider: 'STRIPE' - successUrl: string; failureUrl: string -}) { - throw new ValidationError('Stripe subscription checkout is disabled') -} - -export async function capturePaypal(companyId: string, paypalOrderId: string) { - const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId) - if (invoice.status === 'PAID') return { success: true } // idempotent - const capture = await paypal.captureOrder(paypalOrderId) as Record - const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId - await repo.updateInvoicePaypal(invoice.id, captureId) - const billingPeriod = invoice.requestedBillingPeriod ?? invoice.subscription.billingPeriod - const periodEnd = addPeriod(new Date(), billingPeriod) - await repo.activateSubscription(invoice.subscriptionId, periodEnd, { - plan: invoice.requestedPlan, - billingPeriod: invoice.requestedBillingPeriod, - currency: invoice.currency, - }) - await repo.createEvent({ - subscriptionId: invoice.subscriptionId, - companyId, - eventType: 'subscription.activated', - source: 'user', - payload: { invoiceId: invoice.id }, - }) - return { success: true } -} - // ─── Plan changes ───────────────────────────────────────────── export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) { @@ -332,16 +133,6 @@ export async function resume(companyId: string) { return updated } -// ─── Reactivation ──────────────────────────────────────────── - -export async function reactivate(companyId: string, body: { - plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL' - currency: 'MAD'; provider: 'STRIPE' - successUrl: string; failureUrl: string -}) { - throw new ValidationError('Stripe subscription checkout is disabled') -} - // ─── Scheduled job actions ──────────────────────────────────── export async function runTrialExpirationJob() { diff --git a/apps/api/src/security/webhookIdempotency.ts b/apps/api/src/security/webhookIdempotency.ts index 90d3e60..2ecb036 100644 --- a/apps/api/src/security/webhookIdempotency.ts +++ b/apps/api/src/security/webhookIdempotency.ts @@ -97,15 +97,3 @@ export async function processWebhookOnce({ throw err } } - -export function getWebhookEventId(provider: 'amanpay' | 'paypal', payload: any) { - const value = provider === 'paypal' - ? payload.id ?? payload.event_id ?? null - : payload.event_id ?? payload.id ?? payload.transaction_id ?? null - - if (!value || String(value).trim() === '' || String(value).trim() === 'missing') { - throw new Error('Webhook event id is required') - } - - return String(value) -} diff --git a/apps/api/src/services/financialReportService.test.ts b/apps/api/src/services/financialReportService.test.ts index 0319539..2646aef 100644 --- a/apps/api/src/services/financialReportService.test.ts +++ b/apps/api/src/services/financialReportService.test.ts @@ -33,7 +33,7 @@ describe('financialReportService', () => { endDate: new Date('2026-06-13T10:00:00.000Z'), vehicle: { year: 2024, make: 'Dacia', model: 'Duster', licensePlate: 'A-123' }, customer: { firstName: 'Nora', lastName: 'Saidi', email: 'nora@example.com' }, - rentalPayments: [{ status: 'SUCCEEDED', paymentMethod: 'CARD' }], + rentalPayments: [{ status: 'SUCCEEDED', paymentMethod: 'BANK_TRANSFER' }], insurances: [], additionalDrivers: [], }, @@ -100,7 +100,7 @@ describe('financialReportService', () => { endDate: '2026-06-13', baseAmount: 1500, paymentStatus: 'SUCCEEDED', - paymentMethod: 'CARD', + paymentMethod: 'BANK_TRANSFER', }), expect.objectContaining({ reservationId: 'reservation_2', diff --git a/apps/api/src/services/invoicePdfService.test.ts b/apps/api/src/services/invoicePdfService.test.ts index c6e02c4..dbb1406 100644 --- a/apps/api/src/services/invoicePdfService.test.ts +++ b/apps/api/src/services/invoicePdfService.test.ts @@ -33,7 +33,7 @@ describe('invoicePdfService', () => { amount: 99000, currency: 'MAD', status: 'PAID', - paymentProvider: 'PAYPAL', + paymentProvider: 'MANUAL', transactionId: 'txn_1', paidAt: '2026-06-09T12:00:00.000Z', lineItems: [{ description: 'Pro monthly subscription', amount: 99000, currency: 'MAD', quantity: 1, unitAmount: 99000 }], diff --git a/apps/api/src/swagger/openapi.ts b/apps/api/src/swagger/openapi.ts index 1353ba8..e172f46 100644 --- a/apps/api/src/swagger/openapi.ts +++ b/apps/api/src/swagger/openapi.ts @@ -36,7 +36,6 @@ import { offerSchema } from '../modules/offers/offer.schemas' // ── Payments ───────────────────────────────────────────────────────────────── import { - chargeSchema, manualPaymentSchema, refundSchema, } from '../modules/payments/payment.schemas' @@ -116,7 +115,7 @@ export const openApiDocument: JsonObject = { { name: 'Reservations', description: 'Booking lifecycle' }, { name: 'Customers', description: 'Customer profiles & documents' }, { name: 'Offers', description: 'Discounts & promo codes' }, - { name: 'Payments', description: 'Charges, manual payments, refunds' }, + { name: 'Payments', description: 'Manual payments and refunds' }, { name: 'Reviews', description: 'Customer reviews' }, { name: 'Complaints', description: 'Complaint tracking' }, { name: 'Analytics', description: 'Dashboard & reports' }, @@ -156,7 +155,6 @@ export const openApiDocument: JsonObject = { // ── Offers ────────────────────────────────────────────────── OfferInput: s(offerSchema), // ── Payments ──────────────────────────────────────────────── - PaymentCharge: s(chargeSchema), ManualPayment: s(manualPaymentSchema), Refund: s(refundSchema), // ── Reviews ───────────────────────────────────────────────── @@ -755,23 +753,6 @@ export const openApiDocument: JsonObject = { responses: { '200': ok }, }, }, - '/payments/reservations/{id}/charge': { - post: { - tags: ['Payments'], - summary: 'Initiate online payment (AmanPay / PayPal)', - parameters: [idPath()], - requestBody: jsonBody('#/components/schemas/PaymentCharge'), - responses: { '200': ok, '400': err4 }, - }, - }, - '/payments/reservations/{id}/capture-paypal': { - post: { - tags: ['Payments'], - summary: 'Capture PayPal order', - parameters: [idPath()], - responses: { '200': ok }, - }, - }, '/payments/reservations/{id}/manual': { post: { tags: ['Payments'], @@ -1011,9 +992,6 @@ export const openApiDocument: JsonObject = { '/subscriptions/trial': { post: { tags: ['Subscriptions'], summary: 'Start trial (Owner)', responses: { '200': ok } }, }, - '/subscriptions/checkout': { - post: { tags: ['Subscriptions'], summary: 'Checkout new subscription (Owner)', responses: { '200': ok } }, - }, '/subscriptions/payment-options': { get: { tags: ['Subscriptions'], summary: 'Available subscription collection methods and safe payer instructions', responses: { '200': ok } }, }, @@ -1043,9 +1021,6 @@ export const openApiDocument: JsonObject = { '/subscriptions/cancel': { post: { tags: ['Subscriptions'], summary: 'Cancel subscription (Owner)', responses: { '200': ok } }, }, - '/subscriptions/reactivate': { - post: { tags: ['Subscriptions'], summary: 'Reactivate subscription (Owner)', responses: { '200': ok } }, - }, '/subscriptions/resume': { post: { tags: ['Subscriptions'], summary: 'Resume subscription (Owner)', responses: { '200': ok } }, }, @@ -1138,9 +1113,6 @@ export const openApiDocument: JsonObject = { '/site/{slug}/booking/{id}': { get: { tags: ['Site'], summary: 'Get booking status', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } }, }, - '/site/{slug}/booking/{id}/pay': { - post: { tags: ['Site'], summary: 'Pay booking', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } }, - }, '/site/{slug}/contact': { post: { tags: ['Site'], summary: 'Contact form', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, }, diff --git a/apps/api/src/tests/api/auth-boundaries.api.test.ts b/apps/api/src/tests/api/auth-boundaries.api.test.ts index 40be173..ded2c45 100644 --- a/apps/api/src/tests/api/auth-boundaries.api.test.ts +++ b/apps/api/src/tests/api/auth-boundaries.api.test.ts @@ -89,7 +89,6 @@ const signupPayload = { plan: 'STARTER', billingPeriod: 'MONTHLY', currency: 'MAD', - paymentProvider: 'PAYPAL', } describe('auth API boundaries', () => { @@ -114,7 +113,6 @@ describe('auth API boundaries', () => { companyName: 'Atlas Cars', currency: 'MAD', preferredLanguage: 'fr', - paymentProvider: 'PAYPAL', })) }) diff --git a/apps/api/src/tests/api/company-configuration.api.test.ts b/apps/api/src/tests/api/company-configuration.api.test.ts index cf6960f..e670685 100644 --- a/apps/api/src/tests/api/company-configuration.api.test.ts +++ b/apps/api/src/tests/api/company-configuration.api.test.ts @@ -85,14 +85,14 @@ describe('company configuration API contracts', () => { const res = await request(app).patch('/api/v1/companies/me/brand').send({ displayName: 'Atlas Premium Cars', defaultCurrency: 'MAD', - paypalEmail: 'billing@example.test', + tagline: 'Premium rentals', }) expect(res.status).toBe(200) expect(companyService.updateBrand).toHaveBeenCalledWith('company_1', { displayName: 'Atlas Premium Cars', defaultCurrency: 'MAD', - paypalEmail: 'billing@example.test', + tagline: 'Premium rentals', }, 'Atlas Cars', 'atlas') }) diff --git a/apps/api/src/tests/api/payments.api.test.ts b/apps/api/src/tests/api/payments.api.test.ts index bf736d9..fcd31b9 100644 --- a/apps/api/src/tests/api/payments.api.test.ts +++ b/apps/api/src/tests/api/payments.api.test.ts @@ -3,38 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../lib/prisma', () => ({ prisma: {} })) vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } })) -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn(), - verifyWebhookSignature: vi.fn(), -})) - -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn(), - verifyWebhookEvent: vi.fn(), -})) - -vi.mock('../../services/stripeService', () => ({ - isConfigured: vi.fn(), - constructWebhookEvent: vi.fn(), -})) - vi.mock('../../modules/payments/payment.service', () => ({ - handleAmanpayWebhook: vi.fn(), - handlePaypalWebhook: vi.fn(), - handleStripeWebhook: vi.fn(), listByCompany: vi.fn(), listByReservation: vi.fn(), - initCharge: vi.fn(), - capturePaypal: vi.fn(), recordManualPayment: vi.fn(), refundPayment: vi.fn(), })) import request from 'supertest' import { createApp } from '../../app' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as service from '../../modules/payments/payment.service' const app = createApp() @@ -44,94 +21,20 @@ describe('payments API contract', () => { vi.clearAllMocks() }) - it('rejects AmanPay webhooks when signature validation fails and does not invoke service handlers', async () => { - vi.mocked(amanpay.isConfigured).mockReturnValue(true) - vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(false) - - const payload = { transaction_id: 'txn_1', status: 'PAID' } + it('returns 404 for removed AmanPay webhook route', async () => { const res = await request(app) .post('/api/v1/payments/webhooks/amanpay') - .set('x-amanpay-signature', 'bad') - .send(payload) + .send({ transaction_id: 'txn_1', status: 'PAID' }) - expect(res.status).toBe(401) - expect(res.body).toEqual({ error: 'invalid_signature' }) - expect(amanpay.verifyWebhookSignature).toHaveBeenCalledWith(JSON.stringify(payload), 'bad') - expect(service.handleAmanpayWebhook).not.toHaveBeenCalled() + expect(res.status).toBe(404) }) - it('accepts valid AmanPay webhooks and delegates the exact payload', async () => { - vi.mocked(amanpay.isConfigured).mockReturnValue(true) - vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(true) - - const payload = { transaction_id: 'txn_1', status: 'PAID' } - const res = await request(app) - .post('/api/v1/payments/webhooks/amanpay') - .set('x-amanpay-signature', 'good') - .send(payload) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ received: true }) - expect(service.handleAmanpayWebhook).toHaveBeenCalledWith(payload, JSON.stringify(payload)) - }) - - it('rejects PayPal webhooks when provider verification fails', async () => { - vi.mocked(paypal.isConfigured).mockReturnValue(true) - vi.mocked(paypal.verifyWebhookEvent).mockResolvedValue(false) - + it('returns 404 for removed PayPal webhook route', async () => { const res = await request(app) .post('/api/v1/payments/webhooks/paypal') - .set('paypal-transmission-id', 'transmission_1') .send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } }) - expect(res.status).toBe(401) - expect(res.body).toEqual({ error: 'invalid_signature' }) - expect(service.handlePaypalWebhook).not.toHaveBeenCalled() - }) - - it('accepts verified PayPal webhooks and delegates handling', async () => { - vi.mocked(paypal.isConfigured).mockReturnValue(true) - vi.mocked(paypal.verifyWebhookEvent).mockResolvedValue(true) - - const payload = { event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } } - const res = await request(app) - .post('/api/v1/payments/webhooks/paypal') - .send(payload) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ received: true }) - expect(service.handlePaypalWebhook).toHaveBeenCalledWith(payload, JSON.stringify(payload)) - }) - - it('rejects Stripe webhooks when signature validation fails', async () => { - vi.mocked(stripe.isConfigured).mockReturnValue(true) - vi.mocked(stripe.constructWebhookEvent).mockImplementation(() => { - throw new Error('bad signature') - }) - - const res = await request(app) - .post('/api/v1/payments/webhooks/stripe') - .set('stripe-signature', 'bad') - .send({ id: 'evt_1', type: 'checkout.session.completed' }) - - expect(res.status).toBe(401) - expect(res.body).toEqual({ error: 'invalid_signature' }) - expect(service.handleStripeWebhook).not.toHaveBeenCalled() - }) - - it('accepts verified Stripe webhooks and delegates handling', async () => { - vi.mocked(stripe.isConfigured).mockReturnValue(true) - vi.mocked(stripe.constructWebhookEvent).mockReturnValue({ id: 'evt_1', type: 'checkout.session.completed' } as never) - - const payload = { id: 'evt_1', type: 'checkout.session.completed' } - const res = await request(app) - .post('/api/v1/payments/webhooks/stripe') - .set('stripe-signature', 'good') - .send(payload) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ received: true }) - expect(service.handleStripeWebhook).toHaveBeenCalledWith({ id: 'evt_1', type: 'checkout.session.completed' }, JSON.stringify(payload)) + expect(res.status).toBe(404) }) it('keeps authenticated payment routes behind auth before any payment service call', async () => { diff --git a/apps/api/src/tests/api/public-validation.api.test.ts b/apps/api/src/tests/api/public-validation.api.test.ts index 8cde4dd..b87b330 100644 --- a/apps/api/src/tests/api/public-validation.api.test.ts +++ b/apps/api/src/tests/api/public-validation.api.test.ts @@ -25,8 +25,6 @@ vi.mock('../../modules/site/site.service', () => ({ validatePromoCode: vi.fn(), createBooking: vi.fn(), getBooking: vi.fn(), - initPayment: vi.fn(), - capturePaypal: vi.fn(), handleContact: vi.fn(), })) @@ -58,7 +56,6 @@ describe('public validation API contracts', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never) - vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never) vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never) vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({ items: [], @@ -95,19 +92,7 @@ describe('public validation API contracts', () => { expect(siteService.checkAvailability).toHaveBeenCalledWith('atlas-cars', payload.vehicleId, payload.startDate, payload.endDate) }) - it('rejects unsupported public payment providers before payment initialization', async () => { - const res = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({ - provider: 'STRIPE', - successUrl: 'https://example.test/success', - failureUrl: 'https://example.test/fail', - accessToken: 'booking-access-token-123', - }) - - expect(res.status).toBe(400) - expect(siteService.initPayment).not.toHaveBeenCalled() - }) - - it('defaults site payment currency to MAD for valid public payment requests', async () => { + it('returns 404 for removed public online payment route', async () => { const res = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({ provider: 'PAYPAL', successUrl: 'https://example.test/success', @@ -115,14 +100,7 @@ describe('public validation API contracts', () => { accessToken: 'booking-access-token-123', }) - expect(res.status).toBe(200) - expect(siteService.initPayment).toHaveBeenCalledWith('atlas-cars', 'reservation_1', { - provider: 'PAYPAL', - currency: 'MAD', - successUrl: 'https://example.test/success', - failureUrl: 'https://example.test/fail', - accessToken: 'booking-access-token-123', - }) + expect(res.status).toBe(404) }) it('coerces carplace search pagination and price filters', async () => { diff --git a/apps/api/src/tests/api/site-webhook-boundaries.api.test.ts b/apps/api/src/tests/api/site-webhook-boundaries.api.test.ts index 7042f7c..32080fb 100644 --- a/apps/api/src/tests/api/site-webhook-boundaries.api.test.ts +++ b/apps/api/src/tests/api/site-webhook-boundaries.api.test.ts @@ -14,8 +14,6 @@ vi.mock('../../modules/site/site.service', () => ({ validatePromoCode: vi.fn(), createBooking: vi.fn(), getBooking: vi.fn(), - initPayment: vi.fn(), - capturePaypal: vi.fn(), handleContact: vi.fn(), })) diff --git a/apps/api/src/tests/api/subscription-team-boundaries.api.test.ts b/apps/api/src/tests/api/subscription-team-boundaries.api.test.ts index ccd0a43..765c2c0 100644 --- a/apps/api/src/tests/api/subscription-team-boundaries.api.test.ts +++ b/apps/api/src/tests/api/subscription-team-boundaries.api.test.ts @@ -24,21 +24,15 @@ vi.mock('../../middleware/requireSubscription', () => { vi.mock('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() })) vi.mock('../../modules/subscriptions/subscription.service', () => ({ getPlans: vi.fn(), - getProviders: vi.fn(), getPlanFeatures: vi.fn(), getSubscription: vi.fn(), getInvoices: vi.fn(), getEvents: vi.fn(), getEntitlement: vi.fn(), startTrial: vi.fn(), - checkout: vi.fn(), - reactivate: vi.fn(), changePlan: vi.fn(), cancel: vi.fn(), resume: vi.fn(), - capturePaypal: vi.fn(), - handleAmanpayWebhook: vi.fn(), - handlePaypalWebhook: vi.fn(), })) vi.mock('../../modules/team/team.service', () => ({ getMembers: vi.fn(), @@ -61,9 +55,7 @@ describe('subscription and team API validation contracts', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(subscriptionService.startTrial).mockResolvedValue({ id: 'sub_trial' } as never) - vi.mocked(subscriptionService.checkout).mockResolvedValue({ invoice: { id: 'invoice_1' }, checkoutUrl: 'https://pay.example.test' } as never) vi.mocked(subscriptionService.cancel).mockResolvedValue({ id: 'sub_1', cancelAtPeriodEnd: true } as never) - vi.mocked(subscriptionService.capturePaypal).mockResolvedValue({ success: true } as never) vi.mocked(teamService.inviteEmployee).mockResolvedValue({ id: 'employee_2' } as never) vi.mocked(teamService.updateEmployeeRole).mockResolvedValue({ id: 'employee_2', role: 'MANAGER' } as never) }) @@ -75,18 +67,16 @@ describe('subscription and team API validation contracts', () => { expect(subscriptionService.startTrial).toHaveBeenCalledWith('company_1', 'STARTER', 'MONTHLY', 'MAD') }) - it('rejects checkout payloads with unsupported currency before service execution', async () => { - const res = await request(app).post('/api/v1/subscriptions/checkout').send({ + it('rejects manual checkout payloads with an unsupported collection method before service execution', async () => { + const res = await request(app).post('/api/v1/subscriptions/manual-checkout').send({ plan: 'PRO', billingPeriod: 'ANNUAL', - currency: 'USD', - provider: 'PAYPAL', - successUrl: 'https://app.example.test/success', - failureUrl: 'https://app.example.test/failure', + currency: 'MAD', + method: 'STRIPE', + idempotencyKey: '11111111-1111-4111-8111-111111111111', }) expect(res.status).toBe(400) - expect(subscriptionService.checkout).not.toHaveBeenCalled() }) it('defaults cancellation mode at the route boundary', async () => { @@ -96,11 +86,10 @@ describe('subscription and team API validation contracts', () => { expect(subscriptionService.cancel).toHaveBeenCalledWith('company_1', 'period_end', 'not needed') }) - it('requires PayPal order id for capture before service execution', async () => { + it('returns 404 for removed PayPal capture route', async () => { const res = await request(app).post('/api/v1/subscriptions/capture-paypal').send({}) - expect(res.status).toBe(400) - expect(subscriptionService.capturePaypal).not.toHaveBeenCalled() + expect(res.status).toBe(404) }) it('normalizes team invites through validation before service execution', async () => { diff --git a/apps/api/src/tests/api/subscriptions.api.test.ts b/apps/api/src/tests/api/subscriptions.api.test.ts index 48ca53a..55e041a 100644 --- a/apps/api/src/tests/api/subscriptions.api.test.ts +++ b/apps/api/src/tests/api/subscriptions.api.test.ts @@ -14,35 +14,13 @@ vi.mock('../../lib/redis', () => ({ }, })) -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn(), - verifyWebhookSignature: vi.fn(), -})) - -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn(), - verifyWebhookEvent: vi.fn(), -})) - -vi.mock('../../services/stripeService', () => ({ - isConfigured: vi.fn(), - constructWebhookEvent: vi.fn(), -})) - vi.mock('../../modules/subscriptions/subscription.service', () => ({ getPlans: vi.fn(), - getProviders: vi.fn(), getPlanFeatures: vi.fn(), - handleAmanpayWebhook: vi.fn(), - handlePaypalWebhook: vi.fn(), - handleStripeWebhook: vi.fn(), })) import request from 'supertest' import { createApp } from '../../app' -import * as amanpay from '../../services/amanpayService' -import * as paypal from '../../services/paypalService' -import * as stripe from '../../services/stripeService' import * as service from '../../modules/subscriptions/subscription.service' const app = createApp() @@ -50,21 +28,12 @@ const app = createApp() describe('subscriptions public API', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(service.getProviders).mockReturnValue({ stripe: false, stripeProblems: [] }) vi.mocked(service.getPlans).mockResolvedValue({ STARTER: { MONTHLY: { MAD: 9900 } } } as never) vi.mocked(service.getPlanFeatures).mockResolvedValue([ { id: 'feature_1', plan: 'STARTER', label: 'Vehicles', sortOrder: 1 }, ] as never) }) - it('GET /api/v1/subscriptions/providers reports Stripe unavailable', async () => { - const res = await request(app).get('/api/v1/subscriptions/providers') - - expect(res.status).toBe(200) - expect(res.body).toEqual({ data: { stripe: false, stripeProblems: [] } }) - expect(service.getProviders).toHaveBeenCalledOnce() - }) - it('GET /api/v1/subscriptions/plans wraps plan pricing in the standard API envelope', async () => { const res = await request(app).get('/api/v1/subscriptions/plans') @@ -82,48 +51,19 @@ describe('subscriptions public API', () => { ]) }) - it('rejects AmanPay webhooks when provider config or signature validation fails', async () => { - vi.mocked(amanpay.isConfigured).mockReturnValue(true) - vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(false) - + it('returns 404 for removed AmanPay subscription webhook route', async () => { const res = await request(app) .post('/api/v1/subscriptions/webhooks/amanpay') - .set('x-amanpay-signature', 'bad-signature') .send({ id: 'txn_1', status: 'PAID' }) - expect(res.status).toBe(401) - expect(res.body).toEqual({ error: 'invalid_signature' }) - expect(service.handleAmanpayWebhook).not.toHaveBeenCalled() + expect(res.status).toBe(404) }) - it('accepts verified PayPal webhooks and delegates handling to the subscription service', async () => { - vi.mocked(paypal.isConfigured).mockReturnValue(true) - vi.mocked(paypal.verifyWebhookEvent).mockResolvedValue(true) - - const payload = { event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } } + it('returns 404 for removed PayPal subscription webhook route', async () => { const res = await request(app) .post('/api/v1/subscriptions/webhooks/paypal') - .send(payload) + .send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } }) - expect(res.status).toBe(200) - expect(res.body).toEqual({ received: true }) - expect(service.handlePaypalWebhook).toHaveBeenCalledWith(payload, JSON.stringify(payload)) - }) - - it('accepts verified Stripe webhooks and delegates handling to the subscription service', async () => { - vi.mocked(stripe.isConfigured).mockReturnValue(true) - vi.mocked(stripe.constructWebhookEvent).mockReturnValue({ id: 'evt_1', type: 'checkout.session.completed' } as never) - - const res = await request(app) - .post('/api/v1/subscriptions/webhooks/stripe') - .set('stripe-signature', 'good') - .send({ id: 'evt_1' }) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ received: true }) - expect(service.handleStripeWebhook).toHaveBeenCalledWith( - { id: 'evt_1', type: 'checkout.session.completed' }, - JSON.stringify({ id: 'evt_1' }), - ) + expect(res.status).toBe(404) }) }) diff --git a/apps/api/src/tests/e2e/payments-webhook-public.e2e.test.ts b/apps/api/src/tests/e2e/payments-webhook-public.e2e.test.ts index 7fd240a..9d0d4fa 100644 --- a/apps/api/src/tests/e2e/payments-webhook-public.e2e.test.ts +++ b/apps/api/src/tests/e2e/payments-webhook-public.e2e.test.ts @@ -2,14 +2,6 @@ import { vi } from 'vitest' vi.mock('../../lib/prisma', () => ({ prisma: {} })) vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } })) -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn().mockReturnValue(false), - verifyWebhookSignature: vi.fn().mockReturnValue(false), -})) -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn().mockReturnValue(false), - verifyWebhookEvent: vi.fn().mockResolvedValue(false), -})) import { describe, expect, it } from 'vitest' import request from 'supertest' @@ -18,12 +10,11 @@ import { createApp } from '../../app' const app = createApp() describe('payments webhook public e2e smoke', () => { - it('does not process anonymous AmanPay webhook payloads without a valid provider configuration/signature', async () => { + it('returns 404 for removed AmanPay rental webhook route', async () => { const res = await request(app) .post('/api/v1/payments/webhooks/amanpay') .send({ transaction_id: 'untrusted_txn', status: 'PAID' }) - expect(res.status).toBe(401) - expect(res.body).toEqual({ error: 'invalid_signature' }) + expect(res.status).toBe(404) }) }) diff --git a/apps/api/src/tests/e2e/public-validation-smoke.e2e.test.ts b/apps/api/src/tests/e2e/public-validation-smoke.e2e.test.ts index 4ad8533..f918510 100644 --- a/apps/api/src/tests/e2e/public-validation-smoke.e2e.test.ts +++ b/apps/api/src/tests/e2e/public-validation-smoke.e2e.test.ts @@ -20,8 +20,6 @@ vi.mock('../../modules/site/site.service', () => ({ validatePromoCode: vi.fn(), createBooking: vi.fn(), getBooking: vi.fn(), - initPayment: vi.fn(), - capturePaypal: vi.fn(), handleContact: vi.fn(), })) vi.mock('../../modules/carplace/carplace.service', () => ({ @@ -58,8 +56,7 @@ describe('public validation e2e smoke', () => { successUrl: 'not-a-url', failureUrl: 'also-not-a-url', }) - expect(payment.status).toBe(400) - expect(siteService.initPayment).not.toHaveBeenCalled() + expect(payment.status).toBe(404) const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 }) expect(review.status).toBe(400) diff --git a/apps/api/src/tests/e2e/site-webhook-boundaries.e2e.test.ts b/apps/api/src/tests/e2e/site-webhook-boundaries.e2e.test.ts index 619e5b7..b13997b 100644 --- a/apps/api/src/tests/e2e/site-webhook-boundaries.e2e.test.ts +++ b/apps/api/src/tests/e2e/site-webhook-boundaries.e2e.test.ts @@ -14,8 +14,6 @@ vi.mock('../../modules/site/site.service', () => ({ validatePromoCode: vi.fn(), createBooking: vi.fn(), getBooking: vi.fn(), - initPayment: vi.fn(), - capturePaypal: vi.fn(), handleContact: vi.fn(), })) diff --git a/apps/api/src/tests/e2e/subscription-team-boundaries.e2e.test.ts b/apps/api/src/tests/e2e/subscription-team-boundaries.e2e.test.ts index f8e7dac..89d0825 100644 --- a/apps/api/src/tests/e2e/subscription-team-boundaries.e2e.test.ts +++ b/apps/api/src/tests/e2e/subscription-team-boundaries.e2e.test.ts @@ -12,7 +12,7 @@ const app = createApp() describe('subscription and team public boundary smoke', () => { it('rejects malformed protected subscription checkout without leaking internals', async () => { - const res = await request(app).post('/api/v1/subscriptions/checkout').send({ provider: 'PAYPAL' }) + const res = await request(app).post('/api/v1/subscriptions/manual-checkout').send({ method: 'STRIPE' }) expect([400, 401]).toContain(res.status) expect(JSON.stringify(res.body)).not.toContain('stack') diff --git a/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts b/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts index 34c154b..5b58a05 100644 --- a/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts +++ b/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts @@ -17,29 +17,6 @@ vi.mock('../../lib/redis', () => ({ }, })) -vi.mock('../../services/amanpayService', () => ({ - isConfigured: vi.fn().mockReturnValue(false), - verifyWebhookSignature: vi.fn().mockReturnValue(false), - createCheckout: vi.fn(), -})) - -vi.mock('../../services/paypalService', () => ({ - isConfigured: vi.fn().mockReturnValue(false), - verifyWebhookEvent: vi.fn().mockResolvedValue(false), - createOrder: vi.fn(), - captureOrder: vi.fn(), -})) - -vi.mock('../../services/stripeService', () => ({ - getConfigurationStatus: vi.fn().mockReturnValue({ - configured: false, - problems: ['STRIPE_API_KEY is missing', 'STRIPE_WEBHOOK_SECRET is missing'], - }), - isConfigured: vi.fn().mockReturnValue(false), - constructWebhookEvent: vi.fn(), - createCheckoutSession: vi.fn(), -})) - import request from 'supertest' import { describe, expect, it } from 'vitest' import { createApp } from '../../app' @@ -47,16 +24,7 @@ import { createApp } from '../../app' const app = createApp() describe('subscriptions public e2e smoke', () => { - it('lets an anonymous client inspect providers, plans, and features without crossing authenticated subscription routes', async () => { - const providers = await request(app).get('/api/v1/subscriptions/providers') - expect(providers.status).toBe(200) - expect(providers.body).toEqual({ - data: { - stripe: false, - stripeProblems: [], - }, - }) - + it('lets an anonymous client inspect plans and features without crossing authenticated subscription routes', async () => { const plans = await request(app).get('/api/v1/subscriptions/plans') expect(plans.status).toBe(200) expect(plans.body.data).toHaveProperty('STARTER') diff --git a/apps/api/src/tests/helpers/fixtures.ts b/apps/api/src/tests/helpers/fixtures.ts index 84ebcfc..e9c89da 100644 --- a/apps/api/src/tests/helpers/fixtures.ts +++ b/apps/api/src/tests/helpers/fixtures.ts @@ -150,8 +150,8 @@ export async function createRentalPayment( currency: 'MAD', status: 'SUCCEEDED', type: 'CHARGE', - paymentProvider: 'AMANPAY', - amanpayTransactionId: `aman-${uid()}`, + paymentProvider: 'MANUAL', + paymentMethod: 'BANK_TRANSFER', paidAt: new Date(), ...overrides, } as any, @@ -170,8 +170,7 @@ export async function createSubscriptionInvoice( amount: 2990, currency: 'MAD', status: 'PENDING', - paymentProvider: 'AMANPAY', - amanpayTransactionId: `sub-${uid()}`, + paymentProvider: 'MANUAL', ...overrides, } as any, }) diff --git a/apps/api/src/tests/integration/auth-company-signup.test.ts b/apps/api/src/tests/integration/auth-company-signup.test.ts index 07ec4f4..a387d29 100644 --- a/apps/api/src/tests/integration/auth-company-signup.test.ts +++ b/apps/api/src/tests/integration/auth-company-signup.test.ts @@ -46,7 +46,6 @@ describe('Company signup API', () => { plan: 'STARTER', billingPeriod: 'MONTHLY', currency: 'MAD', - paymentProvider: 'AMANPAY', }) expect(res.status).toBe(201) diff --git a/apps/api/src/tests/integration/companies.test.ts b/apps/api/src/tests/integration/companies.test.ts index a06a295..4ecde46 100644 --- a/apps/api/src/tests/integration/companies.test.ts +++ b/apps/api/src/tests/integration/companies.test.ts @@ -9,7 +9,7 @@ import { const app = createApp() -describe('Companies API — brand credential exposure (VF-01)', () => { +describe('Companies API — brand access', () => { let companyId: string let ownerToken: string let agentToken: string @@ -31,17 +31,12 @@ describe('Companies API — brand credential exposure (VF-01)', () => { }) agentToken = signEmployeeToken(agent.id, companyId, 'AGENT') - // Seed brand with real-looking payment credentials await prisma.brandSettings.create({ data: { companyId, - displayName: company.name, - subdomain: `brand-test-${Date.now()}`, - amanpayMerchantId: 'merchant-secret-id', - amanpaySecretKey: 'aman-super-secret-key', - paypalEmail: 'payments@example.com', - paypalMerchantId: 'paypal-merchant-secret', - } as any, + displayName: company.name, + subdomain: `brand-test-${Date.now()}`, + }, }) }) @@ -51,58 +46,13 @@ describe('Companies API — brand credential exposure (VF-01)', () => { expect(res.status).toBe(401) }) - it('does not expose amanpaySecretKey in the response', async () => { + it('returns brand settings for owners', async () => { const res = await request(app) .get('/api/v1/companies/me/brand') .set(authHeader(ownerToken)) expect(res.status).toBe(200) - expect(res.body.data).not.toHaveProperty('amanpaySecretKey') - }) - - it('does not expose amanpayMerchantId in the response', async () => { - const res = await request(app) - .get('/api/v1/companies/me/brand') - .set(authHeader(ownerToken)) - - expect(res.status).toBe(200) - expect(res.body.data).not.toHaveProperty('amanpayMerchantId') - }) - - it('does not expose paypalEmail in the response', async () => { - const res = await request(app) - .get('/api/v1/companies/me/brand') - .set(authHeader(ownerToken)) - - expect(res.status).toBe(200) - expect(res.body.data).not.toHaveProperty('paypalEmail') - }) - - it('does not expose paypalMerchantId in the response', async () => { - const res = await request(app) - .get('/api/v1/companies/me/brand') - .set(authHeader(ownerToken)) - - expect(res.status).toBe(200) - expect(res.body.data).not.toHaveProperty('paypalMerchantId') - }) - - it('returns amanpayConfigured=true when credentials are set', async () => { - const res = await request(app) - .get('/api/v1/companies/me/brand') - .set(authHeader(ownerToken)) - - expect(res.status).toBe(200) - expect(res.body.data.amanpayConfigured).toBe(true) - }) - - it('returns paypalConfigured=true when credentials are set', async () => { - const res = await request(app) - .get('/api/v1/companies/me/brand') - .set(authHeader(ownerToken)) - - expect(res.status).toBe(200) - expect(res.body.data.paypalConfigured).toBe(true) + expect(res.body.data).toHaveProperty('displayName') }) it('is accessible to AGENT role (no role gate on this endpoint)', async () => { @@ -111,29 +61,7 @@ describe('Companies API — brand credential exposure (VF-01)', () => { .set(authHeader(agentToken)) expect(res.status).toBe(200) - expect(res.body.data).not.toHaveProperty('amanpaySecretKey') - expect(res.body.data).not.toHaveProperty('amanpayMerchantId') - }) - - it('returns amanpayConfigured=false when credentials are absent', async () => { - const { company: c2, employee: e2 } = await createCompanyWithEmployee({ role: 'OWNER' }) - const t2 = signEmployeeToken(e2.id, c2.id, 'OWNER') - - await prisma.brandSettings.create({ - data: { - companyId: c2.id, - displayName: c2.name, - subdomain: `no-creds-${Date.now()}`, - } as any, - }) - - const res = await request(app) - .get('/api/v1/companies/me/brand') - .set(authHeader(t2)) - - expect(res.status).toBe(200) - expect(res.body.data.amanpayConfigured).toBe(false) - expect(res.body.data.paypalConfigured).toBe(false) + expect(res.body.data).toHaveProperty('displayName') }) }) }) diff --git a/apps/api/src/tests/integration/payments.test.ts b/apps/api/src/tests/integration/payments.test.ts index fb07680..e4e8288 100644 --- a/apps/api/src/tests/integration/payments.test.ts +++ b/apps/api/src/tests/integration/payments.test.ts @@ -18,52 +18,19 @@ function uniqueEmail(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com` } -describe('Payment webhooks — signature bypass fix (VF-02)', () => { - describe('POST /api/v1/payments/webhooks/amanpay', () => { - it('returns 401 when AmanPay is not configured (env vars absent)', async () => { - // In the test environment AMANPAY_MERCHANT_ID and AMANPAY_SECRET_KEY are not set, - // so isConfigured() returns false. The fix ensures this returns 401, not 200. - const res = await request(app) - .post('/api/v1/payments/webhooks/amanpay') - .send({ transaction_id: 'txn_fake', status: 'PAID' }) - - expect(res.status).toBe(401) - expect(res.body.error).toBe('invalid_signature') - }) - - it('does not process the webhook payload when provider is not configured', async () => { - const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' }) - const vehicle = await createVehicle(company.id) - const customer = await createCustomer(company.id) - const reservation = await createReservation(company.id, vehicle.id, customer.id, { - totalAmount: 1000, paidAmount: 0, - }) - const payment = await createRentalPayment(company.id, reservation.id, { - status: 'PENDING', - amanpayTransactionId: `txn-webhook-${Date.now()}`, - }) - - const res = await request(app) - .post('/api/v1/payments/webhooks/amanpay') - .send({ transaction_id: payment.amanpayTransactionId, status: 'PAID' }) - - expect(res.status).toBe(401) - - // Reservation must remain unpaid — the handler was not invoked - const unchanged = await prisma.reservation.findUniqueOrThrow({ where: { id: reservation.id } }) - expect(unchanged.paymentStatus).toBe('UNPAID') - }) +describe('Removed payment webhooks', () => { + it('returns 404 for legacy AmanPay rental webhook path', async () => { + const res = await request(app) + .post('/api/v1/payments/webhooks/amanpay') + .send({ transaction_id: 'txn_fake', status: 'PAID' }) + expect(res.status).toBe(404) }) - describe('POST /api/v1/payments/webhooks/paypal', () => { - it('returns 401 when PayPal is not configured (env vars absent)', async () => { - const res = await request(app) - .post('/api/v1/payments/webhooks/paypal') - .send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'pp-fake' } }) - - expect(res.status).toBe(401) - expect(res.body.error).toBe('invalid_signature') - }) + it('returns 404 for legacy PayPal rental webhook path', async () => { + const res = await request(app) + .post('/api/v1/payments/webhooks/paypal') + .send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'pp-fake' } }) + expect(res.status).toBe(404) }) }) @@ -128,7 +95,7 @@ describe('Payments API', () => { amount: 400, currency: 'MAD', type: 'CHARGE', - paymentMethod: 'CASH', + paymentMethod: 'CHECK', }) expect(res.status).toBe(200) @@ -152,7 +119,7 @@ describe('Payments API', () => { amount: 300, currency: 'MAD', type: 'CHARGE', - paymentMethod: 'CASH', + paymentMethod: 'CHECK', }) expect(res.status).toBe(403) @@ -160,14 +127,10 @@ describe('Payments API', () => { }) describe('POST /api/v1/payments/reservations/:id/charge', () => { - it('returns 409 when the reservation is already fully paid', async () => { + it('returns 404 because online checkout was removed', async () => { const vehicle = await createVehicle(companyId) const customer = await createCustomer(companyId) - const reservation = await createReservation(companyId, vehicle.id, customer.id, { - totalAmount: 900, - paidAmount: 900, - paymentStatus: 'PAID', - }) + const reservation = await createReservation(companyId, vehicle.id, customer.id) const res = await request(app) .post(`/api/v1/payments/reservations/${reservation.id}/charge`) @@ -180,8 +143,7 @@ describe('Payments API', () => { failureUrl: 'https://example.com/failure', }) - expect(res.status).toBe(409) - expect(res.body.error).toBe('conflict') + expect(res.status).toBe(404) }) }) @@ -191,10 +153,8 @@ describe('Payments API', () => { const customer = await createCustomer(companyId) const reservation = await createReservation(companyId, vehicle.id, customer.id) const payment = await createRentalPayment(companyId, reservation.id, { - paymentMethod: 'CASH', - paymentProvider: 'AMANPAY', - amanpayTransactionId: null, - paypalCaptureId: null, + paymentMethod: 'CHECK', + paymentProvider: 'MANUAL', }) const res = await request(app) diff --git a/apps/api/src/tests/integration/schema-boundaries.test.ts b/apps/api/src/tests/integration/schema-boundaries.test.ts index a2d4999..b013962 100644 --- a/apps/api/src/tests/integration/schema-boundaries.test.ts +++ b/apps/api/src/tests/integration/schema-boundaries.test.ts @@ -1,12 +1,25 @@ import { describe, expect, it } from 'vitest' import { companySignupSchema } from '../../modules/auth/auth.company.schemas' import { createSchema as reservationCreateSchema } from '../../modules/reservations/reservation.schemas' -import { checkoutSchema as subscriptionCheckoutSchema } from '../../modules/subscriptions/subscription.schemas' +import { manualCheckoutSchema } from '../../modules/subscriptions/subscription.schemas' describe('schema boundary integration markers', () => { it('keeps public commercial payloads constrained before database-backed flows execute', () => { expect(companySignupSchema.safeParse({}).success).toBe(false) expect(reservationCreateSchema.safeParse({ vehicleId: 'bad', customerId: 'bad', startDate: 'bad', endDate: 'bad' }).success).toBe(false) - expect(subscriptionCheckoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true) + expect(manualCheckoutSchema.safeParse({ + plan: 'PRO', + billingPeriod: 'MONTHLY', + currency: 'MAD', + method: 'BANK_TRANSFER', + idempotencyKey: '11111111-1111-4111-8111-111111111111', + }).success).toBe(true) + expect(manualCheckoutSchema.safeParse({ + plan: 'PRO', + billingPeriod: 'MONTHLY', + currency: 'MAD', + method: 'STRIPE', + idempotencyKey: '11111111-1111-4111-8111-111111111111', + }).success).toBe(false) }) }) diff --git a/apps/api/src/tests/integration/subscriptions.test.ts b/apps/api/src/tests/integration/subscriptions.test.ts index ce439cc..4fbcbfd 100644 --- a/apps/api/src/tests/integration/subscriptions.test.ts +++ b/apps/api/src/tests/integration/subscriptions.test.ts @@ -14,48 +14,19 @@ function uniqueEmail(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com` } -describe('Subscription webhooks — signature bypass fix (VF-02)', () => { - describe('POST /api/v1/subscriptions/webhooks/amanpay', () => { - it('returns 401 when AmanPay is not configured (env vars absent)', async () => { - // In the test environment AMANPAY_MERCHANT_ID / SECRET_KEY are not set, - // so isConfigured() returns false. The fix ensures this returns 401, not 200. - const res = await request(app) - .post('/api/v1/subscriptions/webhooks/amanpay') - .send({ transaction_id: 'txn_fake_sub', status: 'PAID' }) - - expect(res.status).toBe(401) - expect(res.body.error).toBe('invalid_signature') - }) - - it('does not activate a subscription for an unconfigured-provider request', async () => { - const { company } = await createCompanyWithEmployee({ role: 'OWNER' }) - const sub = await prisma.subscription.findUniqueOrThrow({ where: { companyId: company.id } }) - const invoice = await createSubscriptionInvoice(company.id, sub.id, { - status: 'PENDING', - amanpayTransactionId: `sub-txn-bypass-${Date.now()}`, - }) - - const res = await request(app) - .post('/api/v1/subscriptions/webhooks/amanpay') - .send({ transaction_id: (invoice as any).amanpayTransactionId, status: 'PAID' }) - - expect(res.status).toBe(401) - - // Invoice must remain PENDING — the handler was not invoked - const unchanged = await prisma.subscriptionInvoice.findUniqueOrThrow({ where: { id: invoice.id } }) - expect(unchanged.status).toBe('PENDING') - }) +describe('Removed subscription webhooks', () => { + it('returns 404 for legacy AmanPay subscription webhook path', async () => { + const res = await request(app) + .post('/api/v1/subscriptions/webhooks/amanpay') + .send({ transaction_id: 'txn_fake_sub', status: 'PAID' }) + expect(res.status).toBe(404) }) - describe('POST /api/v1/subscriptions/webhooks/paypal', () => { - it('returns 401 when PayPal is not configured (env vars absent)', async () => { - const res = await request(app) - .post('/api/v1/subscriptions/webhooks/paypal') - .send({ event_type: 'BILLING.SUBSCRIPTION.ACTIVATED', resource: { id: 'sub-fake' } }) - - expect(res.status).toBe(401) - expect(res.body.error).toBe('invalid_signature') - }) + it('returns 404 for legacy PayPal subscription webhook path', async () => { + const res = await request(app) + .post('/api/v1/subscriptions/webhooks/paypal') + .send({ event_type: 'BILLING.SUBSCRIPTION.ACTIVATED', resource: { id: 'sub-fake' } }) + expect(res.status).toBe(404) }) }) @@ -96,13 +67,6 @@ describe('Subscriptions API', () => { expect(res.body.data).toHaveProperty('PRO') expect(res.body.data).toHaveProperty('ENTERPRISE') }) - - it('returns provider availability', async () => { - const res = await request(app).get('/api/v1/subscriptions/providers') - - expect(res.status).toBe(200) - expect(typeof res.body.data.stripe).toBe('boolean') - }) }) describe('Authenticated endpoints', () => { diff --git a/apps/dashboard/src/app/(dashboard)/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/billing/page.tsx index ea79980..b9d8340 100644 --- a/apps/dashboard/src/app/(dashboard)/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/billing/page.tsx @@ -8,7 +8,7 @@ import { apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' type BillingPaymentType = 'CHARGE' | 'DEPOSIT' -type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER' +type ManualPaymentMethod = 'CHECK' | 'BANK_TRANSFER' type BillingPayment = { id: string @@ -92,7 +92,7 @@ export default function BillingPage() { const [error, setError] = useState(null) const [paymentInvoice, setPaymentInvoice] = useState(null) const [paymentType, setPaymentType] = useState('CHARGE') - const [paymentMethod, setPaymentMethod] = useState('CASH') + const [paymentMethod, setPaymentMethod] = useState('BANK_TRANSFER') const [paymentAmount, setPaymentAmount] = useState('') const [receivedAt, setReceivedAt] = useState('') const [reference, setReference] = useState('') @@ -153,7 +153,7 @@ export default function BillingPage() { invalidAmount: 'Enter a valid amount within the selected remaining balance.', paymentStatusLabels: { ALL: 'All', UNPAID: 'Unpaid', PARTIAL: 'Partial', PAID: 'Paid' } as Record, depositStatusLabels: { NOT_REQUIRED: 'Not required', OUTSTANDING: 'Outstanding', PARTIALLY_COLLECTED: 'Partially collected', HELD: 'Held' } as Record, - paymentMethodLabels: { CASH: 'Cash', CHECK: 'Check', BANK_TRANSFER: 'Bank transfer', CARD: 'Card', PAYPAL: 'PayPal', OTHER: 'Other' } as Record, + paymentMethodLabels: { CHECK: 'Check', BANK_TRANSFER: 'Bank transfer' } as Record, }, fr: { heading: 'Facturation clients', @@ -207,7 +207,7 @@ export default function BillingPage() { invalidAmount: 'Saisissez un montant valide dans la limite du solde sélectionné.', paymentStatusLabels: { ALL: 'Tous', UNPAID: 'Non payé', PARTIAL: 'Partiel', PAID: 'Payé' } as Record, depositStatusLabels: { NOT_REQUIRED: 'Non requis', OUTSTANDING: 'À collecter', PARTIALLY_COLLECTED: 'Partiellement collecté', HELD: 'Détenu' } as Record, - paymentMethodLabels: { CASH: 'Espèces', CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire', CARD: 'Carte', PAYPAL: 'PayPal', OTHER: 'Autre' } as Record, + paymentMethodLabels: { CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire' } as Record, }, ar: { heading: 'فوترة العملاء', @@ -261,7 +261,7 @@ export default function BillingPage() { invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المحدد.', paymentStatusLabels: { ALL: 'الكل', UNPAID: 'غير مدفوع', PARTIAL: 'جزئي', PAID: 'مدفوع' } as Record, depositStatusLabels: { NOT_REQUIRED: 'غير مطلوب', OUTSTANDING: 'مستحق', PARTIALLY_COLLECTED: 'محصل جزئياً', HELD: 'محتجز' } as Record, - paymentMethodLabels: { CASH: 'نقداً', CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي', CARD: 'بطاقة', PAYPAL: 'PayPal', OTHER: 'أخرى' } as Record, + paymentMethodLabels: { CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي' } as Record, }, }[language]), [language]) @@ -331,7 +331,7 @@ export default function BillingPage() { const remaining = nextType === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue setPaymentInvoice(invoice) setPaymentType(nextType) - setPaymentMethod('CASH') + setPaymentMethod('BANK_TRANSFER') setPaymentAmount((remaining / 100).toFixed(2)) setReceivedAt(new Date().toISOString().slice(0, 16)) setReference('') diff --git a/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx index 3b3f0f3..f497682 100644 --- a/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx @@ -524,7 +524,7 @@ export default function ContractDetailPage() { damageTypeLabels: { SCRATCH: 'Scratch', DENT: 'Dent', CRACK: 'Crack', CHIP: 'Chip', MISSING: 'Missing', STAIN: 'Stain', OTHER: 'Other' }, severityLabels: { MINOR: 'Minor', MODERATE: 'Moderate', MAJOR: 'Major' }, fuelLevelLabels: { FULL: 'Full', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Empty' }, - paymentProviderLabels: { CASH: 'Cash', STRIPE: 'Stripe', BANK_TRANSFER: 'Bank transfer', CHECK: 'Check', PAYPAL: 'PayPal', CREDIT_CARD: 'Credit card', DEBIT_CARD: 'Debit card' }, + paymentProviderLabels: { BANK_TRANSFER: 'Bank transfer', CHECK: 'Check' }, }, fr: { back: 'Retour aux contrats', @@ -656,7 +656,7 @@ export default function ContractDetailPage() { damageTypeLabels: { SCRATCH: 'Rayure', DENT: 'Bosse', CRACK: 'Fissure', CHIP: 'Éclat', MISSING: 'Manquant', STAIN: 'Tache', OTHER: 'Autre' }, severityLabels: { MINOR: 'Léger', MODERATE: 'Modéré', MAJOR: 'Majeur' }, fuelLevelLabels: { FULL: 'Plein', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Vide' }, - paymentProviderLabels: { CASH: 'Espèces', STRIPE: 'Stripe', BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque', PAYPAL: 'PayPal', CREDIT_CARD: 'Carte de crédit', DEBIT_CARD: 'Carte de débit' }, + paymentProviderLabels: { BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque' }, }, ar: { back: 'العودة إلى العقود', @@ -788,7 +788,7 @@ export default function ContractDetailPage() { damageTypeLabels: { SCRATCH: 'خدش', DENT: 'انبعاج', CRACK: 'تشقق', CHIP: 'تقشر', MISSING: 'مفقود', STAIN: 'بقعة', OTHER: 'أخرى' }, severityLabels: { MINOR: 'طفيف', MODERATE: 'متوسط', MAJOR: 'شديد' }, fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' }, - paymentProviderLabels: { CASH: 'نقداً', STRIPE: 'Stripe', BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك', PAYPAL: 'PayPal', CREDIT_CARD: 'بطاقة ائتمان', DEBIT_CARD: 'بطاقة خصم' }, + paymentProviderLabels: { BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك' }, }, } as const diff --git a/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx index 26e4cbe..fd82035 100644 --- a/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx @@ -108,6 +108,13 @@ interface ReservationBilling { type EditMode = 'booking' | 'return' | null +const RENTAL_PAYMENT_MODES = ['BANK_TRANSFER', 'CHECK'] as const +type RentalPaymentMode = (typeof RENTAL_PAYMENT_MODES)[number] + +function isRentalPaymentMode(value: string): value is RentalPaymentMode { + return (RENTAL_PAYMENT_MODES as readonly string[]).includes(value) +} + type ReservationFormState = { startDate: string endDate: string @@ -233,6 +240,7 @@ const detailCopy = { returnLabel: 'Return location', depositLabel: 'Deposit', paymentModeLabel: 'Payment mode', + paymentModeLabels: { BANK_TRANSFER: 'Bank transfer', CHECK: 'Check' } as Record, spareWheelLabel: 'Spare wheel', radioCdLabel: 'Radio and CD', bookingNotesLabel: 'Booking notes', @@ -348,6 +356,7 @@ const detailCopy = { returnLabel: 'Lieu de retour', depositLabel: 'Dépôt', paymentModeLabel: 'Mode de paiement', + paymentModeLabels: { BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque' } as Record, spareWheelLabel: 'Roue de secours', radioCdLabel: 'Poste radio et CD', bookingNotesLabel: 'Notes de réservation', @@ -463,6 +472,7 @@ const detailCopy = { returnLabel: 'موقع الإرجاع', depositLabel: 'العربون', paymentModeLabel: 'طريقة الدفع', + paymentModeLabels: { BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك' } as Record, spareWheelLabel: 'العجلة الاحتياطية', radioCdLabel: 'الراديو و CD', bookingNotesLabel: 'ملاحظات الحجز', @@ -691,7 +701,11 @@ export default function ReservationDetailPage() { pickupLocation: form.pickupLocation || null, returnLocation: form.returnLocation || null, depositAmount: Number(form.depositAmount || 0), - paymentMode: form.paymentMode || null, + paymentMode: isRentalPaymentMode(form.paymentMode) + ? form.paymentMode + : form.paymentMode + ? undefined + : null, spareWheel: form.spareWheel, radioCd: form.radioCd, contractFields: form.contractFields, @@ -972,7 +986,7 @@ export default function ReservationDetailPage() {

{reservation.vehicle.make} {reservation.vehicle.model}

{reservation.vehicle.licensePlate}

{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}

-

{copy.paymentModeLabel}: {reservation.paymentMode || copy.paymentModeEmpty}

+

{copy.paymentModeLabel}: {reservation.paymentMode ? (copy.paymentModeLabels[reservation.paymentMode] ?? reservation.paymentMode) : copy.paymentModeEmpty}

@@ -1033,12 +1047,20 @@ export default function ReservationDetailPage() {
- setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)} - /> + > + + {form.paymentMode && !isRentalPaymentMode(form.paymentMode) && ( + + )} + {RENTAL_PAYMENT_MODES.map((mode) => ( + + ))} +