update payment method, remove stripe, paypal, amanapay
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped

This commit is contained in:
root
2026-08-17 21:05:29 -04:00
parent c9915a8315
commit fe8ffbeb9f
109 changed files with 568 additions and 2662 deletions
-2
View File
@@ -25,7 +25,6 @@ import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter, {
subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
import billingRouter from './modules/billing/billing.routes'
@@ -279,7 +278,6 @@ export function createApp() {
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
+2 -3
View File
@@ -15,21 +15,20 @@ describe('emailTranslations', () => {
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', () => {
+3 -4
View File
@@ -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}.`,
'',
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
@@ -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'
}
+1 -2
View File
@@ -157,7 +157,7 @@ export async function applyCompanyUpdate(
name: string
slug: string
address?: unknown
brand?: { paymentMethodsEnabled?: any[] | null } | null
brand?: Record<string, unknown> | 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,
})
@@ -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),
@@ -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']),
})
@@ -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',
}),
}))
})
@@ -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(() => [])
@@ -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()
})
@@ -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', () => {
@@ -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)
})
})
@@ -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(),
@@ -24,7 +24,7 @@ function payment(overrides: Partial<any>) {
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,
@@ -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,
@@ -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()
})
})
@@ -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
}
@@ -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 }] } },
})
@@ -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(),
@@ -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 () => {
@@ -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 },
))
}
@@ -65,9 +65,6 @@ const mockBrand = {
heroImageUrl: null,
customDomain: null,
customDomainVerified: false,
amanpayMerchantId: null,
amanpaySecretKey: null,
paypalEmail: null,
}
beforeEach(() => {
@@ -52,7 +52,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
en: {
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' },
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
payments: { label: 'Payment Methods', description: 'Record renter payments by bank transfer or check.' },
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
pricing: { label: 'Pricing Rules', description: 'Automate surcharges, discounts, and driver-based pricing.' },
@@ -61,7 +61,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
fr: {
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
carplace: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
payments: { label: 'Méthodes de paiement', description: 'Enregistrez les paiements locataires par virement ou chèque.' },
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
pricing: { label: 'Règles tarifaires', description: 'Automatisez les suppléments, remises et règles conducteur.' },
@@ -70,7 +70,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
ar: {
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
payments: { label: 'طرق الدفع', description: 'تسجيل دفعات المستأجرين بالتحويل البنكي أو الشيك.' },
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
pricing: { label: 'قواعد التسعير', description: 'أتمتة الرسوم والخصومات وقواعد السائق.' },
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { createSchema as complaintCreateSchema, listQuerySchema as complaintListQuerySchema, updateSchema as complaintUpdateSchema } from './complaints/complaint.schemas'
import { historyQuerySchema, preferencesSchema, unreadQuerySchema } from './notifications/notification.schemas'
import { offerSchema } from './offers/offer.schemas'
import { cancelSchema, checkoutSchema, startTrialSchema } from './subscriptions/subscription.schemas'
import { cancelSchema, manualCheckoutSchema, startTrialSchema } from './subscriptions/subscription.schemas'
import { inviteSchema, roleSchema } from './team/team.schemas'
import { listQuerySchema as reviewListQuerySchema, replySchema } from './reviews/review.schemas'
import { reportQuerySchema, summaryQuerySchema } from './analytics/analytics.schemas'
@@ -26,22 +26,20 @@ describe('operational schemas', () => {
})
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' })
+1 -95
View File
@@ -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' } })
}
@@ -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<string, string>, 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)
@@ -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)
})
@@ -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),
})
@@ -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)
})
})
+12 -203
View File
@@ -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<string, any>
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')
}
@@ -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)
})
@@ -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' })
@@ -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,
@@ -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}`,
@@ -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', () => {
@@ -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,
@@ -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()
})
})
-26
View File
@@ -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 },
+1 -22
View File
@@ -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)
@@ -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()
})
-10
View File
@@ -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(),
@@ -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<string, unknown> = {}) {
} 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,
-104
View File
@@ -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<string, any>
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 {
+5 -111
View File
@@ -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' })
})
})
@@ -57,21 +57,21 @@ const labels: Record<NotificationLocale, {
titles: { DUE_14D: 'Subscription payment due in 14 days', DUE_7D: 'Subscription payment due in 7 days', DUE_48H: 'Payment due in 48 hours', DUE_24H: 'Final expiration warning', GRACE_DAILY: 'Subscription payment overdue', GRACE_FINAL: 'Final warning before suspension' },
due: (invoice, amount, expiration) => `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 labonnement dû dans 14 jours', DUE_7D: 'Paiement de labonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de labonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période dabonnement 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 nest 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}, dun montant de ${amount}. Labonnement 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,
},
@@ -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<string, unknown>) ?? {}),
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,
@@ -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: {
@@ -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<string, string>, 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 }
@@ -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)
})
})
@@ -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,
@@ -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<unknown> }) => {
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') },
@@ -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<string, any>
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() {
@@ -97,15 +97,3 @@ export async function processWebhookOnce<T>({
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)
}
@@ -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',
@@ -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 }],
+1 -29
View File
@@ -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 } },
},
@@ -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',
}))
})
@@ -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')
})
+5 -102
View File
@@ -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 () => {
@@ -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 () => {
@@ -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(),
}))
@@ -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 () => {
@@ -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)
})
})
@@ -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)
})
})
@@ -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)
@@ -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(),
}))
@@ -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')
@@ -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')
+3 -4
View File
@@ -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,
})
@@ -46,7 +46,6 @@ describe('Company signup API', () => {
plan: 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
})
expect(res.status).toBe(201)
@@ -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')
})
})
})
+18 -58
View File
@@ -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)
@@ -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)
})
})
@@ -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', () => {