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) => (
+
+ ))}
+