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
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:
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 d’assurance', 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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 l’abonnement dû dans 14 jours', DUE_7D: 'Paiement de l’abonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de l’abonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
|
||||
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période d’abonnement actuelle expire le ${expiration}.`,
|
||||
grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement n’est pas confirmé.`,
|
||||
action: 'Ouvrez Abonnement dans le tableau de bord pour payer par Stripe ou consulter les instructions de virement/chèque.',
|
||||
action: 'Ouvrez Abonnement dans le tableau de bord pour consulter les instructions de virement/chèque.',
|
||||
callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, d’un montant de ${amount}. L’abonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`,
|
||||
},
|
||||
ar: {
|
||||
titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' },
|
||||
due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`,
|
||||
grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`,
|
||||
action: 'افتح صفحة الاشتراك في لوحة التحكم للدفع عبر Stripe أو لعرض تعليمات التحويل البنكي أو الشيك.',
|
||||
action: 'افتح صفحة الاشتراك في لوحة التحكم لعرض تعليمات التحويل البنكي أو الشيك.',
|
||||
callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`,
|
||||
},
|
||||
}
|
||||
@@ -296,7 +296,8 @@ export async function ensureRenewalCollectionsCases(now = new Date()) {
|
||||
select: { collectionMethod: true },
|
||||
})
|
||||
const enabledMethods = getPaymentOptions(coerceNotificationLocale(account.defaultCommunicationLocale)).methods.filter((item: any) => item.enabled).map((item: any) => item.method)
|
||||
const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : (enabledMethods[0] ?? 'STRIPE')
|
||||
if (enabledMethods.length === 0) continue
|
||||
const collectionMethod = enabledMethods.includes(lastInvoice?.collectionMethod) ? lastInvoice!.collectionMethod : enabledMethods[0]
|
||||
const assignee = account.collectionsOwnerAdminId
|
||||
? await prisma.adminUser.findFirst({ where: { id: account.collectionsOwnerAdminId, isActive: true } })
|
||||
: await prisma.adminUser.findFirst({ where: { isActive: true, role: { in: ['FINANCE', 'ADMIN', 'SUPER_ADMIN'] } }, orderBy: { createdAt: 'asc' } })
|
||||
@@ -329,7 +330,7 @@ export async function ensureRenewalCollectionsCases(now = new Date()) {
|
||||
billingName: account.legalName,
|
||||
billingEmail: account.billingEmail,
|
||||
billingAddress: account.billingAddress ?? undefined,
|
||||
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
|
||||
paymentProvider: 'MANUAL',
|
||||
collectionMethod,
|
||||
requestedPlan: subscription.plan,
|
||||
requestedBillingPeriod: subscription.billingPeriod,
|
||||
@@ -365,7 +366,7 @@ export async function ensureRenewalCollectionsCases(now = new Date()) {
|
||||
amount: tax.totalAmount,
|
||||
currency: subscription.currency,
|
||||
status: 'PENDING',
|
||||
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL',
|
||||
paymentProvider: 'MANUAL',
|
||||
billingInvoiceId: invoice!.id,
|
||||
dueAt: subscription.currentPeriodEnd,
|
||||
},
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
Reference in New Issue
Block a user