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

This commit is contained in:
root
2026-08-17 21:05:29 -04:00
parent c9915a8315
commit fe8ffbeb9f
109 changed files with 568 additions and 2662 deletions
-6
View File
@@ -51,12 +51,6 @@ MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo MAIL_REPLY_TO_NAME=RentalDriveGo
# Stripe subscription checkout
# STRIPE_API_KEY must be a Stripe secret/restricted key (sk_ or rk_).
# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_).
STRIPE_API_KEY=sk_test_51TvTsb9SpDRZn9yJyBAlUSXcTp9zpwQfhJYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00OcYvj28K
STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211
# Manual subscription payments for local development # Manual subscription payments for local development
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true
-9
View File
@@ -87,15 +87,6 @@ MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo MAIL_REPLY_TO_NAME=RentalDriveGo
# Stripe subscription checkout
# STRIPE_API_KEY must be a live Stripe secret/restricted key (sk_live_ or rk_live_).
# STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_).
# Gitea deploys can override these from STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET secrets.
# Set STRIPE_BILLING_REQUIRED=false only when deploying before Stripe billing is ready.
STRIPE_BILLING_REQUIRED=true
STRIPE_API_KEY=placeholder
STRIPE_WEBHOOK_SECRET=placeholder
# ── Firebase push notifications (optional) ──────────────────────────────────── # ── Firebase push notifications (optional) ────────────────────────────────────
# FIREBASE_PROJECT_ID=your-firebase-project-id # FIREBASE_PROJECT_ID=your-firebase-project-id
# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com # FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
-19
View File
@@ -34,25 +34,6 @@ NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
# ─── AmanPay (Primary payment provider) ───────────────────────
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
AMANPAY_SECRET_KEY=placeholder
AMANPAY_BASE_URL=https://api.amanpay.net
AMANPAY_WEBHOOK_SECRET=placeholder
# ─── PayPal (Secondary payment provider) ──────────────────────
# RentalDriveGo's own PayPal account (for collecting subscription fees)
PAYPAL_CLIENT_ID=your-paypal-client-id
PAYPAL_CLIENT_SECRET=placeholder
PAYPAL_BASE_URL=https://api-m.paypal.com
# Use https://api-m.sandbox.paypal.com for sandbox
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
# ─── Stripe (subscription checkout) ────────────────────────────
# Prefer a restricted API key (rk_) with the minimum Billing/Checkout permissions.
STRIPE_API_KEY=placeholder
STRIPE_WEBHOOK_SECRET=placeholder
# ─── Cloudinary (Vehicle + brand photos) ────────────────────── # ─── Cloudinary (Vehicle + brand photos) ──────────────────────
CLOUDINARY_CLOUD_NAME=your-cloud-name CLOUDINARY_CLOUD_NAME=your-cloud-name
+1 -6
View File
@@ -1,8 +1,3 @@
{ {
"servers": { "servers": {}
"stripe": {
"type": "http",
"url": "https://mcp.stripe.com"
}
}
} }
+1 -1
View File
@@ -15,7 +15,7 @@ Manual methods remain hidden unless their instructions and evidence-scanning pip
## Operational invariants ## Operational invariants
- Subscription billing has one canonical `BillingInvoice`; legacy `SubscriptionInvoice` rows are compatibility links only. - Subscription billing has one canonical `BillingInvoice`; legacy `SubscriptionInvoice` rows are compatibility links only.
- A scheduled renewal is reused by Stripe checkout rather than duplicated. - A scheduled renewal is reused by the next manual checkout rather than duplicated.
- Manual confirmation requires the full current MAD balance, matching collection method, clean same-invoice evidence, a unique normalized external reference, a unique idempotency key, a clearance timestamp, and a funds-verified attestation. - Manual confirmation requires the full current MAD balance, matching collection method, clean same-invoice evidence, a unique normalized external reference, a unique idempotency key, a clearance timestamp, and a funds-verified attestation.
- Initial purchases activate from confirmation time. Renewals extend from the original expiration, including late confirmations after suspension. - Initial purchases activate from confirmation time. Renewals extend from the original expiration, including late confirmations after suspension.
- Evidence is stored privately with no public URL, quarantined before scanning, served only through authorized routes, and immutable after submission. - Evidence is stored privately with no public URL, quarantined before scanning, served only through authorized routes, and immutable after submission.
@@ -111,7 +111,7 @@ interface BillingInvoice {
createdAt: string createdAt: string
isSubscriptionBlocking: boolean isSubscriptionBlocking: boolean
subscriptionId?: string | null subscriptionId?: string | null
collectionMethod?: 'STRIPE' | 'BANK_TRANSFER' | 'CHECK' collectionMethod?: 'BANK_TRANSFER' | 'CHECK'
requestedPlan?: string | null requestedPlan?: string | null
requestedBillingPeriod?: string | null requestedBillingPeriod?: string | null
lineItems: InvoiceLineItem[] lineItems: InvoiceLineItem[]
@@ -370,7 +370,6 @@ export default function AdminBillingPage() {
submittedReference: 'Submitted reference', submittedReference: 'Submitted reference',
rejectEvidence: 'Reject evidence', rejectEvidence: 'Reject evidence',
confirmCleared: 'Confirm cleared manual payment', confirmCleared: 'Confirm cleared manual payment',
stripeWebhookOnly: 'Stripe subscription invoices are paid only by a verified provider webhook.',
method: 'Method', method: 'Method',
fullBalance: 'Full balance', fullBalance: 'Full balance',
authoritativeReference: 'Authoritative bank/check reference', authoritativeReference: 'Authoritative bank/check reference',
@@ -489,7 +488,6 @@ export default function AdminBillingPage() {
submittedReference: 'Référence soumise', submittedReference: 'Référence soumise',
rejectEvidence: 'Refuser le justificatif', rejectEvidence: 'Refuser le justificatif',
confirmCleared: 'Confirmer le paiement manuel encaissé', confirmCleared: 'Confirmer le paiement manuel encaissé',
stripeWebhookOnly: 'Les factures dabonnement Stripe sont payées uniquement par un webhook fournisseur vérifié.',
method: 'Mode', method: 'Mode',
fullBalance: 'Solde intégral', fullBalance: 'Solde intégral',
authoritativeReference: 'Référence bancaire/chèque faisant foi', authoritativeReference: 'Référence bancaire/chèque faisant foi',
@@ -608,7 +606,6 @@ export default function AdminBillingPage() {
submittedReference: 'المرجع المقدم', submittedReference: 'المرجع المقدم',
rejectEvidence: 'رفض المستند', rejectEvidence: 'رفض المستند',
confirmCleared: 'تأكيد تحصيل الدفع اليدوي', confirmCleared: 'تأكيد تحصيل الدفع اليدوي',
stripeWebhookOnly: 'لا تُدفع فواتير اشتراك Stripe إلا عبر إشعار موثّق من مزود الدفع.',
method: 'الطريقة', method: 'الطريقة',
fullBalance: 'الرصيد الكامل', fullBalance: 'الرصيد الكامل',
authoritativeReference: 'المرجع المعتمد للتحويل/الشيك', authoritativeReference: 'المرجع المعتمد للتحويل/الشيك',
@@ -1492,9 +1489,7 @@ export default function AdminBillingPage() {
{selectedInvoice.subscriptionId ? ( {selectedInvoice.subscriptionId ? (
selectedInvoice.collectionMethod === 'BANK_TRANSFER' || selectedInvoice.collectionMethod === 'CHECK' ? ( selectedInvoice.collectionMethod === 'BANK_TRANSFER' || selectedInvoice.collectionMethod === 'CHECK' ? (
<button onClick={openManualConfirmation} disabled={!canConfirmSelectedManualPayment} className={`${ACTION_PRIMARY_CLASS} disabled:cursor-not-allowed disabled:opacity-50`}>{copy.confirmCleared}</button> <button onClick={openManualConfirmation} disabled={!canConfirmSelectedManualPayment} className={`${ACTION_PRIMARY_CLASS} disabled:cursor-not-allowed disabled:opacity-50`}>{copy.confirmCleared}</button>
) : ( ) : null
<p className="rounded-xl border border-stone-200 p-3 text-xs text-stone-600 dark:border-zinc-800 dark:text-zinc-400">{copy.stripeWebhookOnly}</p>
)
) : ( ) : (
<div className="grid grid-cols-[1fr,auto] gap-2"> <div className="grid grid-cols-[1fr,auto] gap-2">
<input <input
-1
View File
@@ -49,7 +49,6 @@
"react": "^18.3.1", "react": "^18.3.1",
"resend": "^3.2.0", "resend": "^3.2.0",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"stripe": "^22.3.2",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"turbo": "2.10.0", "turbo": "2.10.0",
"twilio": "^5.1.0", "twilio": "^5.1.0",
-2
View File
@@ -25,7 +25,6 @@ import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes' import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter, { import subscriptionsRouter, {
subscriptionPublicRouter, subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes' } from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes' import paymentsRouter from './modules/payments/payment.routes'
import billingRouter from './modules/billing/billing.routes' import billingRouter from './modules/billing/billing.routes'
@@ -279,7 +278,6 @@ export function createApp() {
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter) app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter) app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter) app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter) app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter) app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
+2 -3
View File
@@ -15,21 +15,20 @@ describe('emailTranslations', () => {
expect(signupEmail.subject('ar')).toContain('جاهزة') 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({ const text = signupEmail.text({
firstName: 'Aya', firstName: 'Aya',
companyName: 'Atlas Cars', companyName: 'Atlas Cars',
plan: 'PRO', plan: 'PRO',
billingPeriod: 'ANNUAL', billingPeriod: 'ANNUAL',
currency: 'MAD', currency: 'MAD',
paymentProvider: 'AmanPay',
trialEnd, trialEnd,
}, 'fr') }, 'fr')
expect(text).toContain('Bonjour Aya') expect(text).toContain('Bonjour Aya')
expect(text).toContain('Atlas Cars') expect(text).toContain('Atlas Cars')
expect(text).toContain('Forfait : PRO (annuel)') 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', () => { it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
+3 -4
View File
@@ -25,7 +25,6 @@ export const signupEmail = {
plan: string plan: string
billingPeriod: string billingPeriod: string
currency: string currency: string
paymentProvider: string
trialEnd: Date trialEnd: Date
}, lang: Lang): string => { }, lang: Lang): string => {
const trialStr = formatDate(opts.trialEnd, lang) const trialStr = formatDate(opts.trialEnd, lang)
@@ -36,7 +35,7 @@ export const signupEmail = {
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`, `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`, `Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`, `Currency: ${opts.currency}`,
`Primary payment provider: ${opts.paymentProvider}`, 'Payments: bank transfer or check.',
`Free trial ends on ${trialStr}.`, `Free trial ends on ${trialStr}.`,
'', '',
'Your workspace is ready. Sign in with the email and password you chose during signup.', '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.`, `Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`, `Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
`Devise : ${opts.currency}`, `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}.`, `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.", "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} بنجاح.`, `تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`, `الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
`العملة: ${opts.currency}`, `العملة: ${opts.currency}`,
`مزود الدفع الرئيسي: ${opts.paymentProvider}`, 'الدفع: تحويل بنكي أو شيك.',
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`, `تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
'', '',
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.', 'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
@@ -60,7 +60,6 @@ function fmtDate(value?: Date | string | null) {
function paymentMethodLabel(method?: string | null) { function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer' if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check' if (method === 'CHECK') return 'Check'
if (method === 'STRIPE') return 'Online card payment'
return method ?? 'Manual payment' return method ?? 'Manual payment'
} }
+1 -2
View File
@@ -157,7 +157,7 @@ export async function applyCompanyUpdate(
name: string name: string
slug: string slug: string
address?: unknown address?: unknown
brand?: { paymentMethodsEnabled?: any[] | null } | null brand?: Record<string, unknown> | null
}, },
) { ) {
return prisma.$transaction(async (tx: any) => { return prisma.$transaction(async (tx: any) => {
@@ -215,7 +215,6 @@ export async function applyCompanyUpdate(
companyId: id, companyId: id,
displayName: body.brand.displayName ?? current.name, displayName: body.brand.displayName ?? current.name,
subdomain: body.brand.subdomain ?? current.slug, subdomain: body.brand.subdomain ?? current.slug,
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
...body.brand, ...body.brand,
} as any, } as any,
}) })
@@ -46,7 +46,6 @@ export const legalIdentitySchema = z.object({
}) })
export const paymentSetupSchema = z.object({ export const paymentSetupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
responsibleName: z.string().min(1).max(160), responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120), responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: 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']), plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'), currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
}) })
@@ -45,7 +45,6 @@ const body = {
plan: 'PRO' as const, plan: 'PRO' as const,
billingPeriod: 'MONTHLY' as const, billingPeriod: 'MONTHLY' as const,
currency: 'MAD' as const, currency: 'MAD' as const,
paymentProvider: 'PAYPAL' as const,
} }
describe('auth.company.service', () => { describe('auth.company.service', () => {
@@ -105,7 +104,6 @@ describe('auth.company.service', () => {
templateVariables: expect.objectContaining({ templateVariables: expect.objectContaining({
firstName: 'Aya', firstName: 'Aya',
companyName: 'Atlas & Desert Cars!!!', companyName: 'Atlas & Desert Cars!!!',
paymentProvider: 'PAYPAL',
}), }),
})) }))
}) })
@@ -100,7 +100,6 @@ export async function signup(body: CompanySignupInput) {
planName: localizePlanName(body.plan, lang), planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang), billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency, currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt, trialEndDate: trialEndAt,
}, },
}).catch(() => []) }).catch(() => [])
@@ -32,13 +32,11 @@ const validCompanySignup = {
plan: 'GROWTH', plan: 'GROWTH',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
paymentProvider: 'AMANPAY',
} }
describe('role-specific auth schema contracts', () => { describe('role-specific auth schema contracts', () => {
it('defaults company signup language and keeps billing provider constrained', () => { it('defaults company signup language and keeps billing currency constrained', () => {
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' }) expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en' })
expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow()
expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow() expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow()
}) })
@@ -34,7 +34,6 @@ describe('auth schemas', () => {
plan: 'GROWTH', plan: 'GROWTH',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
paymentProvider: 'AMANPAY',
} as const } as const
it('defaults company signup language and validates commercial choices', () => { it('defaults company signup language and validates commercial choices', () => {
@@ -43,7 +42,6 @@ describe('auth schemas', () => {
expect(parsed.preferredLanguage).toBe('en') expect(parsed.preferredLanguage).toBe('en')
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(true) expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(true)
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false) 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', () => { it('accepts optional subscription plan for minimal account start', () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { billingListQuerySchema } from './billing.schemas' import { billingListQuerySchema, manualBillingPaymentSchema } from './billing.schemas'
describe('billing.schemas billingListQuerySchema', () => { describe('billing.schemas billingListQuerySchema', () => {
it('parses explicit outstandingOnly query strings as booleans', () => { it('parses explicit outstandingOnly query strings as booleans', () => {
@@ -11,3 +11,19 @@ describe('billing.schemas billingListQuerySchema', () => {
expect(billingListQuerySchema.parse({}).outstandingOnly).toBe(false) 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(), amountMinor: z.number().int().positive(),
currency: z.literal('MAD').default('MAD'), currency: z.literal('MAD').default('MAD'),
type: z.enum(['CHARGE', 'DEPOSIT']), 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(), receivedAt: z.string().datetime().optional(),
reference: z.string().trim().max(120).optional(), reference: z.string().trim().max(120).optional(),
note: z.string().trim().max(1000).optional(), note: z.string().trim().max(1000).optional(),
@@ -24,7 +24,7 @@ function payment(overrides: Partial<any>) {
status: 'SUCCEEDED', status: 'SUCCEEDED',
type: 'CHARGE', type: 'CHARGE',
paymentProvider: 'MANUAL', paymentProvider: 'MANUAL',
paymentMethod: 'CASH', paymentMethod: 'BANK_TRANSFER',
paidAt: new Date('2026-06-01T12:00:00.000Z'), paidAt: new Date('2026-06-01T12:00:00.000Z'),
createdAt: new Date('2026-06-01T12:00:00.000Z'), createdAt: new Date('2026-06-01T12:00:00.000Z'),
...overrides, ...overrides,
@@ -49,7 +49,7 @@ type ManualPaymentInput = {
amountMinor: number amountMinor: number
currency: 'MAD' currency: 'MAD'
type: 'CHARGE' | 'DEPOSIT' type: 'CHARGE' | 'DEPOSIT'
method: 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER' method: 'CHECK' | 'BANK_TRANSFER'
receivedAt?: string receivedAt?: string
reference?: string reference?: string
note?: string note?: string
@@ -98,7 +98,7 @@ export function buildBillingInvoice(reservation: BillingReservation) {
amountMinor: payment.amount, amountMinor: payment.amount,
currency: payment.currency, currency: payment.currency,
type: payment.type, type: payment.type,
channel: payment.paymentProvider === 'MANUAL' ? 'OFFLINE' : 'ONLINE', channel: 'OFFLINE',
provider: payment.paymentProvider, provider: payment.paymentProvider,
method: payment.paymentMethod, method: payment.paymentMethod,
status: payment.status, status: payment.status,
@@ -1,98 +1,20 @@
import { describe, it, expect } from 'vitest' import { describe, expect, it } from 'vitest'
import { presentBrand } from './company.presenter' import { presentBrand } from './company.presenter'
const fullBrand = { describe('company.presenter', () => {
id: 'brand_1', const fullBrand = {
companyId: 'comp_1', displayName: 'Atlas Cars',
displayName: 'Test Rentals', subdomain: 'atlas',
subdomain: 'test-rentals', primaryColor: '#2563eb',
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('presentBrand', () => { it('returns brand fields as-is', () => {
it('strips amanpaySecretKey from the response', () => {
const result = presentBrand(fullBrand) const result = presentBrand(fullBrand)
expect(result).not.toHaveProperty('amanpaySecretKey') expect(result).toEqual(fullBrand)
}) })
it('strips amanpayMerchantId from the response', () => { it('returns null/undefined brand unchanged', () => {
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', () => {
expect(presentBrand(null)).toBeNull() expect(presentBrand(null)).toBeNull()
})
it('returns undefined when brand is undefined', () => {
expect(presentBrand(undefined)).toBeUndefined() expect(presentBrand(undefined)).toBeUndefined()
}) })
}) })
@@ -3,11 +3,5 @@ export function presentCompany(company: any) {
} }
export function presentBrand(brand: any) { export function presentBrand(brand: any) {
if (!brand) return brand return brand
const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
return {
...safe,
amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
paypalConfigured: !!(paypalEmail || paypalMerchantId),
}
} }
@@ -9,7 +9,6 @@ describe('company schemas edge cases', () => {
const brand = brandSchema.parse({ const brand = brandSchema.parse({
displayName: 'Atlas', displayName: 'Atlas',
websiteUrl: 'https://atlas.example.test', websiteUrl: 'https://atlas.example.test',
paypalEmail: 'paypal@example.test',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
homePageConfig: { showOffers: true, layout: { items: [{ id: 'hero-1', type: 'hero', x: 1, y: 1, w: 12, h: 4 }] } }, 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(), whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(), defaultLocale: z.string().optional(),
defaultCurrency: z.literal('MAD').optional(), defaultCurrency: z.literal('MAD').optional(),
amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(),
paypalEmail: optionalEmailField(),
paypalMerchantId: z.string().optional(),
isListedOnCarplace: z.boolean().optional(), isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.object({ homePageConfig: z.object({
heroTitle: z.union([z.string(), z.null()]).optional(), heroTitle: z.union([z.string(), z.null()]).optional(),
@@ -73,11 +73,6 @@ const currentBrand = {
companyId: 'company_1', companyId: 'company_1',
displayName: 'Atlas Cars', displayName: 'Atlas Cars',
subdomain: 'atlas', subdomain: 'atlas',
amanpayMerchantId: 'merchant_1',
amanpaySecretKey: 'secret_1',
paypalEmail: null,
paypalMerchantId: null,
paymentMethodsEnabled: ['AMANPAY'],
} }
beforeEach(() => { beforeEach(() => {
@@ -87,40 +82,18 @@ beforeEach(() => {
}) })
describe('company.service edge behavior', () => { 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.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( expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1', 'company_1',
expect.objectContaining({ expect.objectContaining({ tagline: 'Premium rentals' }),
paypalEmail: 'billing@example.test', expect.objectContaining({ displayName: 'Atlas Cars', subdomain: 'atlas', tagline: 'Premium rentals' }),
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(result).toMatchObject({ tagline: 'Premium rentals' })
}) })
it('normalizes custom domains and marks them pending verification', async () => { 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 * as repo from './company.repo'
import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements' 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) { export async function getCompany(companyId: string) {
return presentCompany(await repo.findCompany(companyId)) 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) { export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
await assertSettingsFeature(companyId, 'settings.branding_basic') await assertSettingsFeature(companyId, 'settings.branding_basic')
if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom') 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( return presentBrand(await repo.upsertBrand(
companyId, companyId,
{ ...body, paymentMethodsEnabled }, body,
{ displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body }, { displayName: body.displayName ?? companyName, subdomain: companySlug, ...body },
)) ))
} }
@@ -65,9 +65,6 @@ const mockBrand = {
heroImageUrl: null, heroImageUrl: null,
customDomain: null, customDomain: null,
customDomainVerified: false, customDomainVerified: false,
amanpayMerchantId: null,
amanpaySecretKey: null,
paypalEmail: null,
} }
beforeEach(() => { beforeEach(() => {
@@ -52,7 +52,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
en: { en: {
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' }, company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' }, 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.' }, 'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' }, insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
pricing: { label: 'Pricing Rules', description: 'Automate surcharges, discounts, and driver-based pricing.' }, 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: { fr: {
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' }, 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.' }, 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.' }, 'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' }, insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
pricing: { label: 'Règles tarifaires', description: 'Automatisez les suppléments, remises et règles conducteur.' }, 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: { ar: {
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' }, company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' }, carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' }, payments: { label: 'طرق الدفع', description: 'تسجيل دفعات المستأجرين بالتحويل البنكي أو الشيك.' },
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' }, 'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' }, insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
pricing: { 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 { createSchema as complaintCreateSchema, listQuerySchema as complaintListQuerySchema, updateSchema as complaintUpdateSchema } from './complaints/complaint.schemas'
import { historyQuerySchema, preferencesSchema, unreadQuerySchema } from './notifications/notification.schemas' import { historyQuerySchema, preferencesSchema, unreadQuerySchema } from './notifications/notification.schemas'
import { offerSchema } from './offers/offer.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 { inviteSchema, roleSchema } from './team/team.schemas'
import { listQuerySchema as reviewListQuerySchema, replySchema } from './reviews/review.schemas' import { listQuerySchema as reviewListQuerySchema, replySchema } from './reviews/review.schemas'
import { reportQuerySchema, summaryQuerySchema } from './analytics/analytics.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', () => { it('validates subscriptions, team roles, notifications, and analytics query defaults', () => {
const checkoutResult = checkoutSchema.safeParse({ const checkoutResult = manualCheckoutSchema.safeParse({
plan: 'PRO', plan: 'PRO',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
provider: 'STRIPE', method: 'BANK_TRANSFER',
successUrl: 'https://ok.example.test', idempotencyKey: '11111111-1111-4111-8111-111111111111',
failureUrl: 'https://fail.example.test',
}) })
expect(checkoutResult.success).toBe(true) expect(checkoutResult.success).toBe(true)
expect(checkoutSchema.safeParse({ expect(manualCheckoutSchema.safeParse({
plan: 'PRO', plan: 'PRO',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
provider: 'PAYPAL', method: 'STRIPE',
successUrl: 'https://ok.example.test', idempotencyKey: '11111111-1111-4111-8111-111111111111',
failureUrl: 'https://fail.example.test',
}).success).toBe(false) }).success).toBe(false)
expect(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' }) expect(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' })
expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' }) expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' })
+1 -95
View File
@@ -13,33 +13,10 @@ export function findByReservation(reservationId: string, companyId: string) {
return prisma.rentalPayment.findMany({ where: { reservationId, companyId }, orderBy: { createdAt: 'desc' } }) 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) { export function findPaymentOrThrow(paymentId: string, companyId: string, reservationId: string) {
return prisma.rentalPayment.findFirstOrThrow({ where: { id: paymentId, companyId, reservationId } }) 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) { export function findReservation(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ return prisma.reservation.findFirstOrThrow({
where: { id, companyId }, 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: { export function createPayment(data: {
companyId: string; reservationId: string; amount: number; currency: string companyId: string; reservationId: string; amount: number; currency: string
status: string; type: string; paymentProvider: 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 }) 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) { export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } }) 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 { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate' import { parseBody, parseParams } from '../../http/validate'
import { ok } from '../../http/respond' 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 * 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() 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.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/company', requireRole('MANAGER'), async (req, res, next) => { 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) } } 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) => { router.post('/reservations/:id/manual', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
try { try {
const { id } = parseParams(reservationParamSchema, req) const { id } = parseParams(reservationParamSchema, req)
@@ -1,24 +1,18 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('payment schemas edge cases', () => {
it('defaults charge and manual payment currency/type while accepting supported providers', () => { it('defaults manual payment currency/type and accepts supported methods', () => {
expect(chargeSchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' })).toMatchObject({ expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'BANK_TRANSFER' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' })
provider: 'PAYPAL', expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CHECK' }).success).toBe(false)
type: 'CHARGE', expect(manualPaymentSchema.safeParse({ amount: 100, paymentMethod: 'PAYPAL' }).success).toBe(false)
currency: 'MAD', expect(manualPaymentSchema.safeParse({ amount: 100, paymentMethod: 'CASH' }).success).toBe(false)
}) expect(manualPaymentSchema.safeParse({ amount: 100, paymentMethod: 'STRIPE' as any }).success).toBe(false)
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('validates refund/capture/payment parameter payloads', () => { it('validates refund and payment parameter payloads', () => {
expect(refundSchema.parse({})).toEqual({}) expect(refundSchema.parse({})).toEqual({})
expect(refundSchema.safeParse({ amount: -1 }).success).toBe(false) 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(reservationParamSchema.safeParse({ id: '' }).success).toBe(false)
expect(paymentParamSchema.safeParse({ reservationId: 'reservation_1', paymentId: 'payment_1' }).success).toBe(true) expect(paymentParamSchema.safeParse({ reservationId: 'reservation_1', paymentId: 'payment_1' }).success).toBe(true)
}) })
@@ -1,18 +1,10 @@
import { z } from 'zod' 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({ export const manualPaymentSchema = z.object({
amount: z.number().int().positive(), amount: z.number().int().positive(),
currency: z.literal('MAD').default('MAD'), currency: z.literal('MAD').default('MAD'),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), 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({ export const refundSchema = z.object({
@@ -20,10 +12,6 @@ export const refundSchema = z.object({
reason: z.string().optional(), reason: z.string().optional(),
}) })
export const capturePaypalSchema = z.object({
paypalOrderId: z.string(),
})
export const reservationParamSchema = z.object({ export const reservationParamSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
}) })
@@ -1,164 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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', () => ({ 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(), findReservation: vi.fn(),
markPaymentSucceeded: vi.fn(),
markStripePaymentSucceeded: vi.fn(),
markPaymentFailed: vi.fn(),
markStripePaymentFailed: vi.fn(),
incrementReservationPaid: vi.fn(),
createPayment: vi.fn(), createPayment: vi.fn(),
updatePaypalCapture: vi.fn(),
updatePendingPaypalCapture: vi.fn(),
setReservationPaidAmount: vi.fn(), setReservationPaidAmount: vi.fn(),
setReservationRefunded: vi.fn(), findPaymentOrThrow: vi.fn(),
setPaymentRefunded: vi.fn(),
})) }))
import { ConflictError, ValidationError } from '../../http/errors' 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 * as repo from './payment.repo'
import { import { recordManualPayment, refundPayment } from './payment.service'
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' },
}
describe('payment.service', () => { describe('payment.service', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z')) vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z'))
delete process.env.API_URL
process.env.DASHBOARD_URL = 'https://app.example'
}) })
afterEach(() => { afterEach(() => {
vi.useRealTimers() 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 () => { 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, amount: 300,
currency: 'MAD', currency: 'MAD',
type: 'CHARGE', type: 'CHARGE',
paymentMethod: 'CASH', paymentMethod: 'CHECK',
}) })
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
status: 'SUCCEEDED', status: 'SUCCEEDED',
paymentProvider: 'MANUAL', paymentProvider: 'MANUAL',
paymentMethod: 'CASH', paymentMethod: 'CHECK',
paidAt: expect.any(Date), paidAt: expect.any(Date),
})) }))
expect(repo.setReservationPaidAmount).toHaveBeenCalledWith('reservation_1', 1000, 'PAID') expect(repo.setReservationPaidAmount).toHaveBeenCalledWith('reservation_1', 1000, 'PAID')
@@ -208,82 +69,33 @@ describe('payment.service', () => {
expect(repo.setReservationPaidAmount).not.toHaveBeenCalled() expect(repo.setReservationPaidAmount).not.toHaveBeenCalled()
}) })
it('applies paid AmanPay, PayPal, and Stripe webhook events to the matching records', async () => { it('rejects refunds for manual payments', 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 () => {
vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({ vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({
id: 'payment_1', id: 'payment_1',
reservationId: 'reservation_1', reservationId: 'reservation_1',
status: 'SUCCEEDED', status: 'SUCCEEDED',
amount: 1000, amount: 1000,
currency: 'MAD', paymentProvider: 'MANUAL',
paymentProvider: 'PAYPAL',
paypalCaptureId: 'capture_123',
amanpayTransactionId: null,
} as never) } 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') await expect(refundPayment('reservation_1', 'payment_1', 'company_1', 250, 'Customer request'))
.rejects.toBeInstanceOf(ValidationError)
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' })
}) })
it('refunds Stripe payments by PaymentIntent', async () => { it('rejects manual payments when the reservation is already fully paid', async () => {
vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({ vi.mocked(repo.findReservation).mockResolvedValue({
id: 'payment_1', id: 'reservation_1',
reservationId: 'reservation_1', totalAmount: 1000,
status: 'SUCCEEDED', depositAmount: 300,
amount: 1000, paidAmount: 1000,
currency: 'MAD', rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1000 }],
paymentProvider: 'STRIPE',
stripePaymentIntentId: 'pi_test_123',
} as never) } 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') await expect(recordManualPayment('reservation_1', 'company_1', {
amount: 100,
expect(stripe.refundPaymentIntent).toHaveBeenCalledWith('pi_test_123', 1000, 'Customer request') currency: 'MAD',
expect(repo.setPaymentRefunded).toHaveBeenCalledWith('payment_1', false) type: 'CHARGE',
expect(repo.setReservationRefunded).toHaveBeenCalledWith('reservation_1') paymentMethod: 'CHECK',
expect(result).toEqual({ id: 'payment_1', status: 'REFUNDED' }) })).rejects.toBeInstanceOf(ConflictError)
}) })
}) })
+12 -203
View File
@@ -1,10 +1,5 @@
import { ConflictError, ValidationError } from '../../http/errors' 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 * as repo from './payment.repo'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
export function listByCompany(companyId: string) { export function listByCompany(companyId: string) {
return repo.findByCompany(companyId) return repo.findByCompany(companyId)
@@ -25,183 +20,6 @@ function getInvoicePaid(reservation: any, rentalPayments: any[]) {
return Math.max(sumSucceededPayments(rentalPayments, 'CHARGE'), reservation.paidAmount ?? 0) 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: { export async function recordManualPayment(reservationId: string, companyId: string, body: {
amount: number; currency: string; type: string; paymentMethod: string 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') 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') { if (body.type === 'CHARGE') {
const newPaidAmount = invoicePaid + body.amount const newPaidAmount = invoicePaid + body.amount
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL') 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) { export async function refundPayment(reservationId: string, paymentId: string, companyId: string, amount?: number, reason?: string) {
const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId) const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId)
if (payment.status !== 'SUCCEEDED') throw new ValidationError('Only succeeded payments can be refunded') 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') 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
} }
@@ -16,7 +16,7 @@ describe('reservation.presenter boundary behavior', () => {
expect(parseReservationExtras(null)).toEqual({}) expect(parseReservationExtras(null)).toEqual({})
expect(parseReservationExtras(['paymentMode'])).toEqual({}) expect(parseReservationExtras(['paymentMode'])).toEqual({})
expect(parseReservationExtras('cash')).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', () => { it('normalizes optional strings without preserving whitespace-only values', () => {
@@ -64,7 +64,7 @@ describe('reservation.presenter boundary behavior', () => {
contractNumber: null, contractNumber: null,
invoiceNumber: null, invoiceNumber: null,
paymentStatus: 'UNPAID', paymentStatus: 'UNPAID',
extras: { paymentMode: 'CARD' }, extras: { paymentMode: 'CHECK' },
customer: { customer: {
id: 'customer_1', id: 'customer_1',
driverLicense: null, 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.customer?.licenseImageUrl).toBe('/api/v1/customers/customer_1/license-image')
expect(result.workflow.coreEditable).toBe(true) 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({ pageSize: 101 }).success).toBe(false)
expect(listQuerySchema.safeParse({ search: 'x'.repeat(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({ 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(extendSchema.safeParse({ newEndDate: '2026-07-05T10:00:00.000Z', reason: '' }).success).toBe(false)
expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true }) expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true })
expect(inspectionParamSchema.parse({ id: 'reservation_1', type: 'CHECKIN' })).toEqual({ id: 'reservation_1', type: 'CHECKIN' }) expect(inspectionParamSchema.parse({ id: 'reservation_1', type: 'CHECKIN' })).toEqual({ id: 'reservation_1', type: 'CHECKIN' })
@@ -1,6 +1,8 @@
import { z } from 'zod' import { z } from 'zod'
import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation' import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation'
const rentalPaymentModeSchema = z.enum(['BANK_TRANSFER', 'CHECK'])
export const additionalDriverSchema = z.object({ export const additionalDriverSchema = z.object({
firstName: textField('name'), firstName: textField('name'),
lastName: textField('name'), lastName: textField('name'),
@@ -41,7 +43,7 @@ export const createSchema = z.object({
offerId: z.string().cuid().optional(), offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(), promoCodeUsed: z.string().optional(),
depositAmount: z.number().int().min(0).default(0), depositAmount: z.number().int().min(0).default(0),
paymentMode: z.string().max(50).optional(), paymentMode: rentalPaymentModeSchema.optional(),
spareWheel: z.boolean().optional(), spareWheel: z.boolean().optional(),
radioCd: z.boolean().optional(), radioCd: z.boolean().optional(),
notes: z.string().optional(), notes: z.string().optional(),
@@ -59,7 +61,7 @@ export const updateSchema = z.object({
returnLocation: optionalTextField('returnLocation').nullable(), returnLocation: optionalTextField('returnLocation').nullable(),
depositAmount: z.number().int().min(0).optional(), depositAmount: z.number().int().min(0).optional(),
notes: z.string().optional().nullable(), notes: z.string().optional().nullable(),
paymentMode: z.string().max(50).optional().nullable(), paymentMode: rentalPaymentModeSchema.optional().nullable(),
spareWheel: z.boolean().optional().nullable(), spareWheel: z.boolean().optional().nullable(),
radioCd: z.boolean().optional().nullable(), radioCd: z.boolean().optional().nullable(),
contractFields: contractFieldsSchema, contractFields: contractFieldsSchema,
@@ -190,8 +190,6 @@ export async function globalSearch(companyId: string, query: string, limit = 5):
{ reference: textContains(search) }, { reference: textContains(search) },
{ note: textContains(search) }, { note: textContains(search) },
{ paymentMethod: textContains(search) }, { paymentMethod: textContains(search) },
{ amanpayTransactionId: textContains(search) },
{ paypalCaptureId: textContains(search) },
{ reservation: { is: { invoiceNumber: textContains(search) } } }, { reservation: { is: { invoiceNumber: textContains(search) } } },
{ reservation: { is: { contractNumber: textContains(search) } } }, { reservation: { is: { contractNumber: textContains(search) } } },
{ reservation: { is: { customer: { is: { email: 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) => ({ groups.payment = payments.map((payment: any) => ({
id: payment.id, id: payment.id,
type: 'payment', 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]), subtitle: compact([payment.status, payment.type, payment.reservation?.customer?.email]),
href: `/billing?search=${encodeURIComponent(payment.reference || payment.reservation?.invoiceNumber || payment.id)}`, href: `/billing?search=${encodeURIComponent(payment.reference || payment.reservation?.invoiceNumber || payment.id)}`,
meta: `${payment.amount} ${payment.currency}`, meta: `${payment.amount} ${payment.currency}`,
@@ -12,10 +12,6 @@ describe('site.presenter', () => {
displayName: 'Atlas Cars', displayName: 'Atlas Cars',
tagline: 'Premium rentals', tagline: 'Premium rentals',
logoUrl: 'https://cdn.test/logo.png', logoUrl: 'https://cdn.test/logo.png',
amanpaySecretKey: 'must-not-leak',
amanpayMerchantId: 'must-not-leak',
paypalEmail: 'billing@example.com',
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr', defaultLocale: 'fr',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
isListedOnCarplace: true, isListedOnCarplace: true,
@@ -28,15 +24,11 @@ describe('site.presenter', () => {
displayName: 'Atlas Cars', displayName: 'Atlas Cars',
tagline: 'Premium rentals', tagline: 'Premium rentals',
logoUrl: 'https://cdn.test/logo.png', logoUrl: 'https://cdn.test/logo.png',
paypalEmail: 'billing@example.com',
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr', defaultLocale: 'fr',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
isListedOnCarplace: true, 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', () => { it('returns a null brand when company branding has not been configured', () => {
@@ -27,9 +27,6 @@ export function presentBrand(company: {
instagramUrl: brand.instagramUrl, instagramUrl: brand.instagramUrl,
defaultLocale: brand.defaultLocale, defaultLocale: brand.defaultLocale,
defaultCurrency: brand.defaultCurrency, defaultCurrency: brand.defaultCurrency,
paypalEmail: brand.paypalEmail,
paypalMerchantId: brand.paypalMerchantId,
paymentMethodsEnabled: brand.paymentMethodsEnabled,
isListedOnCarplace: brand.isListedOnCarplace, isListedOnCarplace: brand.isListedOnCarplace,
carplaceRating: brand.carplaceRating, carplaceRating: brand.carplaceRating,
homePageConfig: brand.homePageConfig, homePageConfig: brand.homePageConfig,
@@ -68,46 +68,4 @@ describe('site.repo public booking boundaries', () => {
}), }),
})) }))
}) })
it('creates pending rental payments with normalized charge metadata', async () => {
await repo.createRentalPayment({
companyId: 'company_1',
reservationId: 'reservation_1',
amount: 1200,
currency: 'MAD',
paymentProvider: 'PAYPAL',
paypalCaptureId: 'order_1',
})
expect(prisma.rentalPayment.create).toHaveBeenCalledWith({
data: {
companyId: 'company_1',
reservationId: 'reservation_1',
amount: 1200,
currency: 'MAD',
paymentProvider: 'PAYPAL',
paypalCaptureId: 'order_1',
status: 'PENDING',
type: 'CHARGE',
},
})
})
it('captures PayPal payments by updating payment and reservation atomically in sequence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-11-10T09:30:00.000Z'))
await repo.capturePaypalPayment('payment_1', 'capture_1', 'reservation_1', 1500)
expect(prisma.rentalPayment.update).toHaveBeenCalledWith({
where: { id: 'payment_1' },
data: { status: 'SUCCEEDED', paidAt: new Date('2026-11-10T09:30:00.000Z'), paypalCaptureId: 'capture_1' },
})
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { paymentStatus: 'PAID', paidAmount: { increment: 1500 } },
})
vi.useRealTimers()
})
}) })
-26
View File
@@ -124,32 +124,6 @@ export async function findBooking(reservationId: string, companyId: string) {
}) })
} }
export async function findReservationForPayment(reservationId: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { vehicle: true, customer: true, additionalDrivers: true },
})
}
export async function createRentalPayment(data: {
companyId: string; reservationId: string; amount: number; currency: string
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
}) {
return prisma.rentalPayment.create({
data: { ...data, paymentProvider: data.paymentProvider as any, status: 'PENDING', type: 'CHARGE' },
})
}
export async function findPaymentByPaypalOrderId(paypalOrderId: string, companyId: string) {
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
}
export async function capturePaypalPayment(paymentId: string, captureId: string, reservationId: string, amount: number) {
await prisma.rentalPayment.update({ where: { id: paymentId }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
await prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
}
export async function createReservationPublicAccess(reservationId: string, tokenHash: string, expiresAt: Date) { export async function createReservationPublicAccess(reservationId: string, tokenHash: string, expiresAt: Date) {
return (prisma as any).reservationPublicAccess.create({ return (prisma as any).reservationPublicAccess.create({
data: { reservationId, tokenHash, expiresAt }, data: { reservationId, tokenHash, expiresAt },
+1 -22
View File
@@ -6,7 +6,7 @@ import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './site.service' import * as service from './site.service'
import { import {
slugParamSchema, bookingParamSchema, slugParamSchema, bookingParamSchema,
availabilitySchema, validateCodeSchema, bookSchema, paySchema, capturePaypalSchema, contactSchema, availabilitySchema, validateCodeSchema, bookSchema, contactSchema,
} from './site.schemas' } from './site.schemas'
const router = Router() 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) => { router.post('/:slug/contact', async (req, res, next) => {
try { try {
const { slug } = parseParams(slugParamSchema, req) const { slug } = parseParams(slugParamSchema, req)
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('site.schemas', () => {
const baseBooking = { const baseBooking = {
@@ -34,7 +34,7 @@ describe('site.schemas', () => {
})).toThrow() })).toThrow()
}) })
it('rejects malformed availability and payment payloads at the schema layer', () => { it('rejects malformed availability payloads at the schema layer', () => {
expect(availabilitySchema.parse({ expect(availabilitySchema.parse({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
startDate: '2026-07-01T10:00:00.000Z', startDate: '2026-07-01T10:00:00.000Z',
@@ -42,15 +42,12 @@ describe('site.schemas', () => {
})).toMatchObject({ vehicleId: 'ckvvehicle000000000000001' }) })).toMatchObject({ vehicleId: 'ckvvehicle000000000000001' })
expect(() => availabilitySchema.parse({ vehicleId: 'not-cuid', startDate: 'x', endDate: 'y' })).toThrow() 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({ expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
name: 'Aya', email: 'aya@example.com', message: 'Hello', 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(() => bookSchema.parse({ ...baseBooking, email: 'bad-email' })).toThrow()
expect(() => contactSchema.parse({ name: '', email: 'bad', message: '' })).toThrow() expect(() => contactSchema.parse({ name: '', email: 'bad', message: '' })).toThrow()
}) })
-10
View File
@@ -50,16 +50,6 @@ export const bookSchema = z.object({
})).default([]), })).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({ export const contactSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email(), email: z.string().email(),
@@ -16,8 +16,6 @@ vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() })) vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn() })) vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn() }))
vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) })) 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', () => ({ vi.mock('./site.presenter', () => ({
presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })), presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })),
presentPublicBooking: vi.fn((reservation: any) => ({ presentPublicBooking: vi.fn((reservation: any) => ({
@@ -36,25 +34,17 @@ vi.mock('./site.repo', () => ({
createReservation: vi.fn(), createReservation: vi.fn(),
findReservationWithDetails: vi.fn(), findReservationWithDetails: vi.fn(),
findBooking: vi.fn(), findBooking: vi.fn(),
findReservationForPayment: vi.fn(),
createReservationPublicAccess: vi.fn(), createReservationPublicAccess: vi.fn(),
findReservationPublicAccess: vi.fn(), findReservationPublicAccess: vi.fn(),
markReservationPublicAccessUsed: vi.fn(), markReservationPublicAccessUsed: vi.fn(),
consumeReservationPublicAccess: 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 { prisma } from '../../lib/prisma'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService' import { applyPricingRules } from '../../services/pricingRuleService'
import { applyInsurancesToReservation } from '../../services/insuranceService' import { applyInsurancesToReservation } from '../../services/insuranceService'
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService' import { validateAndFlagLicense } from '../../services/licenseValidationService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo' import * as repo from './site.repo'
import * as service from './site.service' import * as service from './site.service'
@@ -83,7 +73,7 @@ function bookingBody(overrides: Record<string, unknown> = {}) {
} as any } as any
} }
describe('site.service public booking/payment boundaries', () => { describe('site.service public booking boundaries', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never) 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(repo.findCompanyBySlug).mockResolvedValue(company as never)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } 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(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 () => { 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', source: 'PUBLIC_SITE',
})) }))
expect(applyInsurancesToReservation).not.toHaveBeenCalled() expect(applyInsurancesToReservation).not.toHaveBeenCalled()
expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled()
expect(validateAndFlagLicense).not.toHaveBeenCalled() expect(validateAndFlagLicense).not.toHaveBeenCalled()
expect(result.status).toBe('PENDING') expect(result.status).toBe('PENDING')
expect(result.bookingReference).toBe('BK-2026-AB12CD') 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 () => { 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({ await expect(service.handleContact('atlas', { name: 'Visitor', email: 'visitor@example.test', message: 'Hi' })).resolves.toEqual({
success: true, success: true,
-104
View File
@@ -5,22 +5,9 @@ import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService' import { validateLicense } from '../../services/licenseValidationService'
import { getCarplaceHomepageContent } from '../../services/platformContentService' import { getCarplaceHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo' import * as repo from './site.repo'
import { presentBrand, presentPublicBooking } from './site.presenter' import { presentBrand, presentPublicBooking } from './site.presenter'
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens' 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) { function assertPublicBookingCompanyAllowed(company: any) {
if (!['ACTIVE', 'TRIALING'].includes(company.status)) { 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)) 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 }) { export async function handleContact(slug: string, body: { name: string; email: string; message: string }) {
const company = await repo.findCompanyBySlug(slug) const company = await repo.findCompanyBySlug(slug)
return { return {
+5 -111
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AppError, NotFoundError } from '../../http/errors' import { NotFoundError } from '../../http/errors'
vi.mock('@rentaldrivego/types', () => ({ vi.mock('@rentaldrivego/types', () => ({
PLAN_FEATURES: { STARTER: ['fallback feature'] }, PLAN_FEATURES: { STARTER: ['fallback feature'] },
@@ -24,14 +24,10 @@ vi.mock('./site.repo', () => ({
createReservation: vi.fn(), createReservation: vi.fn(),
findReservationWithDetails: vi.fn(), findReservationWithDetails: vi.fn(),
findBooking: vi.fn(), findBooking: vi.fn(),
findReservationForPayment: vi.fn(),
createReservationPublicAccess: vi.fn(), createReservationPublicAccess: vi.fn(),
findReservationPublicAccess: vi.fn(), findReservationPublicAccess: vi.fn(),
markReservationPublicAccessUsed: vi.fn(), markReservationPublicAccessUsed: vi.fn(),
consumeReservationPublicAccess: vi.fn(), consumeReservationPublicAccess: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
})) }))
vi.mock('../../services/vehicleAvailabilityService', () => ({ vi.mock('../../services/vehicleAvailabilityService', () => ({
@@ -55,17 +51,6 @@ vi.mock('../../services/licenseValidationService', () => ({
validateAndFlagLicense: vi.fn().mockResolvedValue(undefined), 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', () => ({ vi.mock('../../services/platformContentService', () => ({
getCarplaceHomepageContent: vi.fn(), getCarplaceHomepageContent: vi.fn(),
})) }))
@@ -73,12 +58,9 @@ vi.mock('../../services/platformContentService', () => ({
import * as repo from './site.repo' import * as repo from './site.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService' import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import * as amanpay from '../../services/amanpayService'
import * as paypalSvc from '../../services/paypalService'
import { import {
getBrand, getPublicVehicles, checkAvailability, validatePromoCode, getBrand, getPublicVehicles, checkAvailability, validatePromoCode,
createBooking, initPayment, createBooking,
} from './site.service' } from './site.service'
const SLUG = 'test-company' const SLUG = 'test-company'
@@ -93,7 +75,7 @@ function makeCompany(overrides: object = {}) {
return { return {
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com', id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
status: 'ACTIVE', status: 'ACTIVE',
brand: { publicEmail: null }, contractSettings: null, brand: { publicEmail: null, displayName: 'Test Co' }, contractSettings: null,
...overrides, ...overrides,
} }
} }
@@ -114,32 +96,18 @@ beforeEach(() => {
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never) vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
}) })
// ────────────────────────────────────────────────────────────────────────────
describe('getBrand', () => { describe('getBrand', () => {
it('returns company and public brand data only', async () => { it('returns company and public brand data only', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany({ vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany({
brand: { brand: { displayName: 'Test Co', defaultCurrency: 'MAD' },
displayName: 'Test Co',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
amanpayMerchantId: 'merchant-id',
amanpaySecretKey: 'top-secret',
paypalMerchantId: 'paypal-merchant-id',
},
}) as any) }) as any)
const result = await getBrand(SLUG) const result = await getBrand(SLUG)
expect(result.company.id).toBe('co-1') expect(result.company.id).toBe('co-1')
expect(result.brand).toMatchObject({ expect(result.brand).toMatchObject({ displayName: 'Test Co', defaultCurrency: 'MAD' })
displayName: 'Test Co',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
paypalMerchantId: 'paypal-merchant-id',
})
expect(result.brand).not.toHaveProperty('amanpayMerchantId')
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
}) })
}) })
// ────────────────────────────────────────────────────────────────────────────
describe('getPublicVehicles', () => { describe('getPublicVehicles', () => {
it('returns vehicles enriched with availability', async () => { it('returns vehicles enriched with availability', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
@@ -151,7 +119,6 @@ describe('getPublicVehicles', () => {
}) })
}) })
// ────────────────────────────────────────────────────────────────────────────
describe('checkAvailability', () => { describe('checkAvailability', () => {
it('returns availability result for given date range', async () => { it('returns availability result for given date range', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
@@ -164,7 +131,6 @@ describe('checkAvailability', () => {
}) })
}) })
// ────────────────────────────────────────────────────────────────────────────
describe('validatePromoCode', () => { describe('validatePromoCode', () => {
it('returns offer when code is valid', async () => { it('returns offer when code is valid', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any) vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
@@ -180,7 +146,6 @@ describe('validatePromoCode', () => {
}) })
}) })
// ────────────────────────────────────────────────────────────────────────────
describe('createBooking', () => { describe('createBooking', () => {
const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3) const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3)
const baseBody = { const baseBody = {
@@ -237,74 +202,3 @@ describe('createBooking', () => {
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' }) 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' }, 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}.`, 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.`, 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?`, 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: { fr: {
titles: { DUE_14D: 'Paiement de labonnement dû dans 14 jours', DUE_7D: 'Paiement de labonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de labonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' }, titles: { DUE_14D: 'Paiement de labonnement dû dans 14 jours', DUE_7D: 'Paiement de labonnement dû dans 7 jours', DUE_48H: 'Paiement dû dans 48 heures', DUE_24H: 'Dernier avertissement avant expiration', GRACE_DAILY: 'Paiement de labonnement en retard', GRACE_FINAL: 'Dernier avertissement avant suspension' },
due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période dabonnement actuelle expire le ${expiration}.`, due: (invoice, amount, expiration) => `La facture ${invoice} de ${amount} reste impayée. La période dabonnement actuelle expire le ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement nest pas confirmé.`, grace: (invoice, amount, day, remaining, suspension) => `La facture ${invoice} de ${amount} est en retard. Jour de grâce ${day} ; il reste ${remaining} jour(s). Le service sera suspendu le ${suspension} si le paiement nest pas confirmé.`,
action: 'Ouvrez Abonnement dans le tableau de bord pour payer par Stripe ou consulter les instructions de virement/chèque.', action: 'Ouvrez Abonnement dans le tableau de bord pour consulter les instructions de virement/chèque.',
callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, dun montant de ${amount}. Labonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`, callScript: (company, invoice, amount, expiration) => `Bonjour, je vous appelle de RentalDriveGo au sujet de la facture ${invoice} de ${company}, dun montant de ${amount}. Labonnement expire le ${expiration}. Puis-je vous aider à confirmer le mode de règlement ?`,
}, },
ar: { ar: {
titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' }, titles: { DUE_14D: 'استحقاق دفع الاشتراك خلال 14 يوماً', DUE_7D: 'استحقاق دفع الاشتراك خلال 7 أيام', DUE_48H: 'استحقاق الدفع خلال 48 ساعة', DUE_24H: 'التحذير الأخير قبل انتهاء الاشتراك', GRACE_DAILY: 'دفع الاشتراك متأخر', GRACE_FINAL: 'التحذير الأخير قبل تعليق الخدمة' },
due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`, due: (invoice, amount, expiration) => `لا تزال الفاتورة ${invoice} بمبلغ ${amount} غير مدفوعة. تنتهي فترة الاشتراك الحالية في ${expiration}.`,
grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`, grace: (invoice, amount, day, remaining, suspension) => `الفاتورة ${invoice} بمبلغ ${amount} متأخرة. يوم السماح ${day}، ويتبقى ${remaining} يوم. ستُعلّق الخدمة في ${suspension} ما لم يتم تأكيد الدفع.`,
action: 'افتح صفحة الاشتراك في لوحة التحكم للدفع عبر Stripe أو لعرض تعليمات التحويل البنكي أو الشيك.', action: 'افتح صفحة الاشتراك في لوحة التحكم لعرض تعليمات التحويل البنكي أو الشيك.',
callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`, callScript: (company, invoice, amount, expiration) => `مرحباً، أتصل بكم من RentalDriveGo بخصوص فاتورة شركة ${company} رقم ${invoice} بمبلغ ${amount}. ينتهي الاشتراك في ${expiration}. هل يمكنني مساعدتكم في تأكيد خطة الدفع؟`,
}, },
} }
@@ -296,7 +296,8 @@ export async function ensureRenewalCollectionsCases(now = new Date()) {
select: { collectionMethod: true }, select: { collectionMethod: true },
}) })
const enabledMethods = getPaymentOptions(coerceNotificationLocale(account.defaultCommunicationLocale)).methods.filter((item: any) => item.enabled).map((item: any) => item.method) 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 const assignee = account.collectionsOwnerAdminId
? await prisma.adminUser.findFirst({ where: { id: account.collectionsOwnerAdminId, isActive: true } }) ? 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' } }) : 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, billingName: account.legalName,
billingEmail: account.billingEmail, billingEmail: account.billingEmail,
billingAddress: account.billingAddress ?? undefined, billingAddress: account.billingAddress ?? undefined,
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL', paymentProvider: 'MANUAL',
collectionMethod, collectionMethod,
requestedPlan: subscription.plan, requestedPlan: subscription.plan,
requestedBillingPeriod: subscription.billingPeriod, requestedBillingPeriod: subscription.billingPeriod,
@@ -365,7 +366,7 @@ export async function ensureRenewalCollectionsCases(now = new Date()) {
amount: tax.totalAmount, amount: tax.totalAmount,
currency: subscription.currency, currency: subscription.currency,
status: 'PENDING', status: 'PENDING',
paymentProvider: collectionMethod === 'STRIPE' ? 'STRIPE' : 'MANUAL', paymentProvider: 'MANUAL',
billingInvoiceId: invoice!.id, billingInvoiceId: invoice!.id,
dueAt: subscription.currentPeriodEnd, dueAt: subscription.currentPeriodEnd,
}, },
@@ -63,7 +63,6 @@ function fmtDate(value?: Date | string | null) {
function paymentMethodLabel(method?: string | null) { function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer' if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check' if (method === 'CHECK') return 'Check'
if (method === 'STRIPE') return 'Online card payment'
return method ?? 'Manual payment' return method ?? 'Manual payment'
} }
@@ -410,7 +409,7 @@ export async function createManualCheckout(companyId: string, employeeId: string
if (scheduled.legacySubscriptionInvoice) { if (scheduled.legacySubscriptionInvoice) {
await tx.subscriptionInvoice.update({ await tx.subscriptionInvoice.update({
where: { id: scheduled.legacySubscriptionInvoice.id }, where: { id: scheduled.legacySubscriptionInvoice.id },
data: { paymentProvider: 'MANUAL', stripeCheckoutSessionId: null }, data: { paymentProvider: 'MANUAL' },
}) })
} }
await createBillingEvent(tx, { 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) { export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, providerPaymentId?: string) {
let collectionsCaseId: string | null = null let collectionsCaseId: string | null = null
const handled = await prisma.$transaction(async (tx: any) => { const handled = await prisma.$transaction(async (tx: any) => {
@@ -711,7 +553,7 @@ export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, pr
data: { data: {
invoiceId: invoice.id, invoiceId: invoice.id,
billingAccountId: invoice.billingAccountId, billingAccountId: invoice.billingAccountId,
providerPaymentId: providerPaymentId ?? legacy.stripeCheckoutSessionId ?? legacy.providerInvoiceId, providerPaymentId: providerPaymentId ?? legacy.providerInvoiceId,
channel: 'ONLINE', channel: 'ONLINE',
status: 'SUCCEEDED', status: 'SUCCEEDED',
amount: invoice.amountDue, amount: invoice.amountDue,
@@ -833,7 +675,7 @@ export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, pr
invoice: invoiceLabel, invoice: invoiceLabel,
amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount, amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount,
currency: invoice.currency, currency: invoice.currency,
paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'STRIPE', paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'BANK_TRANSFER',
paymentReference: paymentAttempt?.providerPaymentId ?? null, paymentReference: paymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt, paidAt: invoice.paidAt,
plan: invoice.requestedPlan ?? invoice.subscription?.plan ?? null, plan: invoice.requestedPlan ?? invoice.subscription?.plan ?? null,
@@ -854,7 +696,7 @@ export async function finalizeCanonicalOnlinePayment(legacyInvoiceId: string, pr
invoiceId: invoice.id, invoiceId: invoice.id,
amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount, amountPaid: invoice.amountPaid || paymentAttempt?.amount || invoice.totalAmount,
currency: invoice.currency, currency: invoice.currency,
paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'STRIPE', paymentType: invoice.collectionMethod ?? invoice.paymentProvider ?? 'BANK_TRANSFER',
paymentReference: paymentAttempt?.providerPaymentId ?? null, paymentReference: paymentAttempt?.providerPaymentId ?? null,
subscriptionStart: periodStart, subscriptionStart: periodStart,
subscriptionEnd: periodEnd, subscriptionEnd: periodEnd,
@@ -889,7 +731,7 @@ export async function recordCanonicalOnlinePaymentFailure(
data: { data: {
invoiceId: invoice.id, invoiceId: invoice.id,
billingAccountId: invoice.billingAccountId, billingAccountId: invoice.billingAccountId,
providerPaymentId: legacy.stripeCheckoutSessionId ?? legacy.providerInvoiceId, providerPaymentId: legacy.providerInvoiceId,
channel: 'ONLINE', channel: 'ONLINE',
status: 'FAILED', status: 'FAILED',
amount: invoice.amountDue, amount: invoice.amountDue,
@@ -997,7 +839,7 @@ export async function getCanonicalInvoices(companyId: string) {
currency: invoice.currency, currency: invoice.currency,
status: invoice.status, status: invoice.status,
paymentProvider: invoice.paymentProvider, paymentProvider: invoice.paymentProvider,
collectionMethod: invoice.paymentProvider === 'STRIPE' ? 'STRIPE' : 'BANK_TRANSFER', collectionMethod: 'BANK_TRANSFER',
requestedPlan: invoice.requestedPlan, requestedPlan: invoice.requestedPlan,
requestedBillingPeriod: invoice.requestedBillingPeriod, requestedBillingPeriod: invoice.requestedBillingPeriod,
dueAt: invoice.dueAt, 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) { export function findInvoiceById(id: string) {
return prisma.subscriptionInvoice.findUniqueOrThrow({ return prisma.subscriptionInvoice.findUniqueOrThrow({
where: { id }, where: { id },
@@ -212,9 +184,6 @@ export function createInvoice(data: {
amount: number amount: number
currency: string currency: string
paymentProvider: string paymentProvider: string
amanpayTransactionId?: string | null
paypalCaptureId?: string | null
stripeCheckoutSessionId?: string | null
dueAt?: Date | null dueAt?: Date | null
}) { }) {
return prisma.subscriptionInvoice.create({ 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 ───────────────────────────────────────── // ─── Payment attempts ─────────────────────────────────────────
export function createPaymentAttempt(data: { export function createPaymentAttempt(data: {
@@ -6,18 +6,11 @@ import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate' import { parseBody, parseParams } from '../../http/validate'
import { created, ok } from '../../http/respond' import { created, ok } from '../../http/respond'
import { paymentEvidenceUpload } from '../../http/upload/paymentEvidence' 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 * as service from './subscription.service'
import { import {
checkoutSchema,
changePlanSchema, changePlanSchema,
capturePaypalSchema,
startTrialSchema, startTrialSchema,
cancelSchema, cancelSchema,
reactivateSchema,
manualCheckoutSchema, manualCheckoutSchema,
invoiceIdParamSchema, invoiceIdParamSchema,
submissionIdParamSchema, submissionIdParamSchema,
@@ -33,9 +26,8 @@ import {
import * as manualService from './subscription.manual.service' import * as manualService from './subscription.manual.service'
import * as upgradeService from './subscription.upgrade.service' import * as upgradeService from './subscription.upgrade.service'
const publicRouter = Router() const publicRouter = Router()
const webhookRouter = Router() const router = Router()
const router = Router()
// ─── Public ──────────────────────────────────────────────────── // ─── Public ────────────────────────────────────────────────────
@@ -43,51 +35,10 @@ publicRouter.get('/plans', (_req, res, next) => {
service.getPlans().then((d: any) => ok(res, d)).catch(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) => { publicRouter.get('/features', (_req, res, next) => {
service.getPlanFeatures().then((d: any) => ok(res, d)).catch(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 ─────────────── // ─── Authenticated billing recovery/self-service ───────────────
router.use(requireCompanyAuth, requireTenant) router.use(requireCompanyAuth, requireTenant)
@@ -163,13 +114,6 @@ router.post('/trial', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) } } 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) => { router.post('/manual-checkout', requireRole('OWNER'), async (req, res, next) => {
try { try {
created(res, await manualService.createManualCheckout( created(res, await manualService.createManualCheckout(
@@ -247,13 +191,6 @@ router.put('/communication-settings', requireRole('OWNER'), async (req, res, nex
} catch (err) { next(err) } } 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) => { router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
try { try {
const body = parseBody(changePlanSchema, req) const body = parseBody(changePlanSchema, req)
@@ -261,13 +198,6 @@ router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) } } 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 ─────────────────────────────── // ─── Active subscription actions ───────────────────────────────
router.use(requireSubscriptionRead) router.use(requireSubscriptionRead)
@@ -284,4 +214,4 @@ router.post('/resume', requireSubscriptionFull, requireRole('OWNER'), async (req
}) })
export default router 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 { describe, expect, it } from 'vitest'
import { import {
cancelSchema, cancelSchema,
capturePaypalSchema,
changePlanSchema, changePlanSchema,
checkoutSchema, manualCheckoutSchema,
reactivateSchema,
startTrialSchema, startTrialSchema,
} from './subscription.schemas' } from './subscription.schemas'
describe('subscription.schemas edge contracts', () => { 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 = { const payload = {
plan: 'PRO', plan: 'PRO' as const,
billingPeriod: 'ANNUAL', billingPeriod: 'ANNUAL' as const,
currency: 'MAD', currency: 'MAD' as const,
provider: 'STRIPE', method: 'BANK_TRANSFER' as const,
successUrl: 'https://app.example.test/success', idempotencyKey: '11111111-1111-4111-8111-111111111111',
failureUrl: 'https://app.example.test/failure',
} }
expect(checkoutSchema.parse(payload)).toEqual(payload) expect(manualCheckoutSchema.parse(payload)).toEqual(payload)
expect(checkoutSchema.safeParse({ ...payload, currency: 'USD' }).success).toBe(false) expect(manualCheckoutSchema.safeParse({ ...payload, currency: 'USD' }).success).toBe(false)
expect(checkoutSchema.safeParse({ ...payload, successUrl: '/relative' }).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({ expect(changePlanSchema.parse({ plan: 'STARTER', billingPeriod: 'MONTHLY', currency: 'USD' })).toEqual({
plan: 'STARTER', plan: 'STARTER',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
@@ -36,15 +34,6 @@ describe('subscription.schemas edge contracts', () => {
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', 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', () => { 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({ mode: 'tomorrow' }).success).toBe(false)
expect(cancelSchema.safeParse({ reason: 'x'.repeat(501) }).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 planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL']) const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD']) const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
const manualMethodEnum = z.enum(['BANK_TRANSFER', 'CHECK']) const manualMethodEnum = z.enum(['BANK_TRANSFER', 'CHECK'])
const localeEnum = z.enum(['ar', 'en', 'fr']) const localeEnum = z.enum(['ar', 'en', 'fr'])
@@ -12,25 +11,12 @@ const referenceSchema = z.string()
.max(120) .max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters') .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({ export const changePlanSchema = z.object({
plan: planEnum, plan: planEnum,
billingPeriod: billingPeriodEnum, billingPeriod: billingPeriodEnum,
currency: currencyEnum, currency: currencyEnum,
}) })
export const capturePaypalSchema = z.object({
paypalOrderId: z.string(),
})
export const startTrialSchema = z.object({ export const startTrialSchema = z.object({
plan: planEnum, plan: planEnum,
billingPeriod: billingPeriodEnum, billingPeriod: billingPeriodEnum,
@@ -42,15 +28,6 @@ export const cancelSchema = z.object({
reason: z.string().max(500).optional(), 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({ export const manualCheckoutSchema = z.object({
plan: planEnum, plan: planEnum,
billingPeriod: billingPeriodEnum, billingPeriod: billingPeriodEnum,
@@ -8,19 +8,6 @@ vi.mock('../../lib/prisma', () => ({
subscription: { update: vi.fn() }, 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', () => ({ vi.mock('./subscription.repo', () => ({
findByCompany: vi.fn(), findByCompany: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
@@ -28,10 +15,6 @@ vi.mock('./subscription.repo', () => ({
getEvents: vi.fn(), getEvents: vi.fn(),
startTrial: vi.fn(), startTrial: vi.fn(),
createEvent: vi.fn(), createEvent: vi.fn(),
findInvoiceByAmanpay: vi.fn(),
findInvoiceByPaypal: vi.fn(),
findInvoiceByStripe: vi.fn(),
findInvoiceByPaypalForCompany: vi.fn(),
findOrCreateSubscription: vi.fn(), findOrCreateSubscription: vi.fn(),
createInvoice: vi.fn(), createInvoice: vi.fn(),
markInvoicePaid: vi.fn(), markInvoicePaid: vi.fn(),
@@ -40,7 +23,6 @@ vi.mock('./subscription.repo', () => ({
incrementRetryCount: vi.fn(), incrementRetryCount: vi.fn(),
activateSubscription: vi.fn(), activateSubscription: vi.fn(),
setPaymentPending: vi.fn(), setPaymentPending: vi.fn(),
updateInvoicePaypal: vi.fn(),
updatePlan: vi.fn(), updatePlan: vi.fn(),
setCancelled: vi.fn(), setCancelled: vi.fn(),
setCancelAtPeriodEnd: vi.fn(), setCancelAtPeriodEnd: vi.fn(),
@@ -54,26 +36,8 @@ vi.mock('./subscription.repo', () => ({
setSuspended: vi.fn(), 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 { 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 repo from './subscription.repo'
import * as manualService from './subscription.manual.service'
import * as service from './subscription.service' import * as service from './subscription.service'
describe('subscription.service operational edges', () => { describe('subscription.service operational edges', () => {
@@ -81,14 +45,10 @@ describe('subscription.service operational edges', () => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z')) 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(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
delete process.env.API_URL
delete process.env.DASHBOARD_URL
}) })
it('builds plans from pricing config rows when platform overrides exist', async () => { 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 () => { it('advances payment-pending subscriptions to past-due in the scheduled job', async () => {
vi.mocked(repo.findPaymentPendingTimedOut).mockResolvedValue([ vi.mocked(repo.findPaymentPendingTimedOut).mockResolvedValue([
{ id: 'sub_1', companyId: 'company_1', paymentPendingSince: new Date('2026-05-20T00:00:00.000Z') }, { 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 { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { ValidationError } from '../../http/errors' 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 * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy' import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import {
finalizeCanonicalOnlinePayment,
recordCanonicalOnlinePaymentFailure,
} from './subscription.manual.service'
// ─── Helpers ────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────
@@ -33,13 +25,6 @@ export async function getPlans() {
return result return result
} }
export function getProviders() {
return {
stripe: false,
stripeProblems: [],
}
}
export function getPlanFeatures() { export function getPlanFeatures() {
return prisma.planFeature.findMany({ return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
@@ -110,190 +95,6 @@ export async function startTrial(
return sub 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 ───────────────────────────────────────────── // ─── Plan changes ─────────────────────────────────────────────
export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) { export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
@@ -332,16 +133,6 @@ export async function resume(companyId: string) {
return updated 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 ──────────────────────────────────── // ─── Scheduled job actions ────────────────────────────────────
export async function runTrialExpirationJob() { export async function runTrialExpirationJob() {
@@ -97,15 +97,3 @@ export async function processWebhookOnce<T>({
throw err 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'), endDate: new Date('2026-06-13T10:00:00.000Z'),
vehicle: { year: 2024, make: 'Dacia', model: 'Duster', licensePlate: 'A-123' }, vehicle: { year: 2024, make: 'Dacia', model: 'Duster', licensePlate: 'A-123' },
customer: { firstName: 'Nora', lastName: 'Saidi', email: 'nora@example.com' }, customer: { firstName: 'Nora', lastName: 'Saidi', email: 'nora@example.com' },
rentalPayments: [{ status: 'SUCCEEDED', paymentMethod: 'CARD' }], rentalPayments: [{ status: 'SUCCEEDED', paymentMethod: 'BANK_TRANSFER' }],
insurances: [], insurances: [],
additionalDrivers: [], additionalDrivers: [],
}, },
@@ -100,7 +100,7 @@ describe('financialReportService', () => {
endDate: '2026-06-13', endDate: '2026-06-13',
baseAmount: 1500, baseAmount: 1500,
paymentStatus: 'SUCCEEDED', paymentStatus: 'SUCCEEDED',
paymentMethod: 'CARD', paymentMethod: 'BANK_TRANSFER',
}), }),
expect.objectContaining({ expect.objectContaining({
reservationId: 'reservation_2', reservationId: 'reservation_2',
@@ -33,7 +33,7 @@ describe('invoicePdfService', () => {
amount: 99000, amount: 99000,
currency: 'MAD', currency: 'MAD',
status: 'PAID', status: 'PAID',
paymentProvider: 'PAYPAL', paymentProvider: 'MANUAL',
transactionId: 'txn_1', transactionId: 'txn_1',
paidAt: '2026-06-09T12:00:00.000Z', paidAt: '2026-06-09T12:00:00.000Z',
lineItems: [{ description: 'Pro monthly subscription', amount: 99000, currency: 'MAD', quantity: 1, unitAmount: 99000 }], lineItems: [{ description: 'Pro monthly subscription', amount: 99000, currency: 'MAD', quantity: 1, unitAmount: 99000 }],
+1 -29
View File
@@ -36,7 +36,6 @@ import { offerSchema } from '../modules/offers/offer.schemas'
// ── Payments ───────────────────────────────────────────────────────────────── // ── Payments ─────────────────────────────────────────────────────────────────
import { import {
chargeSchema,
manualPaymentSchema, manualPaymentSchema,
refundSchema, refundSchema,
} from '../modules/payments/payment.schemas' } from '../modules/payments/payment.schemas'
@@ -116,7 +115,7 @@ export const openApiDocument: JsonObject = {
{ name: 'Reservations', description: 'Booking lifecycle' }, { name: 'Reservations', description: 'Booking lifecycle' },
{ name: 'Customers', description: 'Customer profiles & documents' }, { name: 'Customers', description: 'Customer profiles & documents' },
{ name: 'Offers', description: 'Discounts & promo codes' }, { 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: 'Reviews', description: 'Customer reviews' },
{ name: 'Complaints', description: 'Complaint tracking' }, { name: 'Complaints', description: 'Complaint tracking' },
{ name: 'Analytics', description: 'Dashboard & reports' }, { name: 'Analytics', description: 'Dashboard & reports' },
@@ -156,7 +155,6 @@ export const openApiDocument: JsonObject = {
// ── Offers ────────────────────────────────────────────────── // ── Offers ──────────────────────────────────────────────────
OfferInput: s(offerSchema), OfferInput: s(offerSchema),
// ── Payments ──────────────────────────────────────────────── // ── Payments ────────────────────────────────────────────────
PaymentCharge: s(chargeSchema),
ManualPayment: s(manualPaymentSchema), ManualPayment: s(manualPaymentSchema),
Refund: s(refundSchema), Refund: s(refundSchema),
// ── Reviews ───────────────────────────────────────────────── // ── Reviews ─────────────────────────────────────────────────
@@ -755,23 +753,6 @@ export const openApiDocument: JsonObject = {
responses: { '200': ok }, 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': { '/payments/reservations/{id}/manual': {
post: { post: {
tags: ['Payments'], tags: ['Payments'],
@@ -1011,9 +992,6 @@ export const openApiDocument: JsonObject = {
'/subscriptions/trial': { '/subscriptions/trial': {
post: { tags: ['Subscriptions'], summary: 'Start trial (Owner)', responses: { '200': ok } }, 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': { '/subscriptions/payment-options': {
get: { tags: ['Subscriptions'], summary: 'Available subscription collection methods and safe payer instructions', responses: { '200': ok } }, 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': { '/subscriptions/cancel': {
post: { tags: ['Subscriptions'], summary: 'Cancel subscription (Owner)', responses: { '200': ok } }, post: { tags: ['Subscriptions'], summary: 'Cancel subscription (Owner)', responses: { '200': ok } },
}, },
'/subscriptions/reactivate': {
post: { tags: ['Subscriptions'], summary: 'Reactivate subscription (Owner)', responses: { '200': ok } },
},
'/subscriptions/resume': { '/subscriptions/resume': {
post: { tags: ['Subscriptions'], summary: 'Resume subscription (Owner)', responses: { '200': ok } }, post: { tags: ['Subscriptions'], summary: 'Resume subscription (Owner)', responses: { '200': ok } },
}, },
@@ -1138,9 +1113,6 @@ export const openApiDocument: JsonObject = {
'/site/{slug}/booking/{id}': { '/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 } }, 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': { '/site/{slug}/contact': {
post: { tags: ['Site'], summary: 'Contact form', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, 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', plan: 'STARTER',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
paymentProvider: 'PAYPAL',
} }
describe('auth API boundaries', () => { describe('auth API boundaries', () => {
@@ -114,7 +113,6 @@ describe('auth API boundaries', () => {
companyName: 'Atlas Cars', companyName: 'Atlas Cars',
currency: 'MAD', currency: 'MAD',
preferredLanguage: 'fr', 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({ const res = await request(app).patch('/api/v1/companies/me/brand').send({
displayName: 'Atlas Premium Cars', displayName: 'Atlas Premium Cars',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
paypalEmail: 'billing@example.test', tagline: 'Premium rentals',
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(companyService.updateBrand).toHaveBeenCalledWith('company_1', { expect(companyService.updateBrand).toHaveBeenCalledWith('company_1', {
displayName: 'Atlas Premium Cars', displayName: 'Atlas Premium Cars',
defaultCurrency: 'MAD', defaultCurrency: 'MAD',
paypalEmail: 'billing@example.test', tagline: 'Premium rentals',
}, 'Atlas Cars', 'atlas') }, 'Atlas Cars', 'atlas')
}) })
+5 -102
View File
@@ -3,38 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({ prisma: {} })) vi.mock('../../lib/prisma', () => ({ prisma: {} }))
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } })) 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', () => ({ vi.mock('../../modules/payments/payment.service', () => ({
handleAmanpayWebhook: vi.fn(),
handlePaypalWebhook: vi.fn(),
handleStripeWebhook: vi.fn(),
listByCompany: vi.fn(), listByCompany: vi.fn(),
listByReservation: vi.fn(), listByReservation: vi.fn(),
initCharge: vi.fn(),
capturePaypal: vi.fn(),
recordManualPayment: vi.fn(), recordManualPayment: vi.fn(),
refundPayment: vi.fn(), refundPayment: vi.fn(),
})) }))
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' 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' import * as service from '../../modules/payments/payment.service'
const app = createApp() const app = createApp()
@@ -44,94 +21,20 @@ describe('payments API contract', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('rejects AmanPay webhooks when signature validation fails and does not invoke service handlers', async () => { it('returns 404 for removed AmanPay webhook route', async () => {
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(false)
const payload = { transaction_id: 'txn_1', status: 'PAID' }
const res = await request(app) const res = await request(app)
.post('/api/v1/payments/webhooks/amanpay') .post('/api/v1/payments/webhooks/amanpay')
.set('x-amanpay-signature', 'bad') .send({ transaction_id: 'txn_1', status: 'PAID' })
.send(payload)
expect(res.status).toBe(401) expect(res.status).toBe(404)
expect(res.body).toEqual({ error: 'invalid_signature' })
expect(amanpay.verifyWebhookSignature).toHaveBeenCalledWith(JSON.stringify(payload), 'bad')
expect(service.handleAmanpayWebhook).not.toHaveBeenCalled()
}) })
it('accepts valid AmanPay webhooks and delegates the exact payload', async () => { it('returns 404 for removed PayPal webhook route', 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)
const res = await request(app) const res = await request(app)
.post('/api/v1/payments/webhooks/paypal') .post('/api/v1/payments/webhooks/paypal')
.set('paypal-transmission-id', 'transmission_1')
.send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } }) .send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } })
expect(res.status).toBe(401) expect(res.status).toBe(404)
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))
}) })
it('keeps authenticated payment routes behind auth before any payment service call', async () => { 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(), validatePromoCode: vi.fn(),
createBooking: vi.fn(), createBooking: vi.fn(),
getBooking: vi.fn(), getBooking: vi.fn(),
initPayment: vi.fn(),
capturePaypal: vi.fn(),
handleContact: vi.fn(), handleContact: vi.fn(),
})) }))
@@ -58,7 +56,6 @@ describe('public validation API contracts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never) 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(siteService.handleContact).mockResolvedValue({ success: true } as never)
vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({ vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({
items: [], items: [],
@@ -95,19 +92,7 @@ describe('public validation API contracts', () => {
expect(siteService.checkAvailability).toHaveBeenCalledWith('atlas-cars', payload.vehicleId, payload.startDate, payload.endDate) expect(siteService.checkAvailability).toHaveBeenCalledWith('atlas-cars', payload.vehicleId, payload.startDate, payload.endDate)
}) })
it('rejects unsupported public payment providers before payment initialization', 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: '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 () => {
const res = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({ const res = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({
provider: 'PAYPAL', provider: 'PAYPAL',
successUrl: 'https://example.test/success', successUrl: 'https://example.test/success',
@@ -115,14 +100,7 @@ describe('public validation API contracts', () => {
accessToken: 'booking-access-token-123', accessToken: 'booking-access-token-123',
}) })
expect(res.status).toBe(200) expect(res.status).toBe(404)
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',
})
}) })
it('coerces carplace search pagination and price filters', async () => { it('coerces carplace search pagination and price filters', async () => {
@@ -14,8 +14,6 @@ vi.mock('../../modules/site/site.service', () => ({
validatePromoCode: vi.fn(), validatePromoCode: vi.fn(),
createBooking: vi.fn(), createBooking: vi.fn(),
getBooking: vi.fn(), getBooking: vi.fn(),
initPayment: vi.fn(),
capturePaypal: vi.fn(),
handleContact: 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('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() }))
vi.mock('../../modules/subscriptions/subscription.service', () => ({ vi.mock('../../modules/subscriptions/subscription.service', () => ({
getPlans: vi.fn(), getPlans: vi.fn(),
getProviders: vi.fn(),
getPlanFeatures: vi.fn(), getPlanFeatures: vi.fn(),
getSubscription: vi.fn(), getSubscription: vi.fn(),
getInvoices: vi.fn(), getInvoices: vi.fn(),
getEvents: vi.fn(), getEvents: vi.fn(),
getEntitlement: vi.fn(), getEntitlement: vi.fn(),
startTrial: vi.fn(), startTrial: vi.fn(),
checkout: vi.fn(),
reactivate: vi.fn(),
changePlan: vi.fn(), changePlan: vi.fn(),
cancel: vi.fn(), cancel: vi.fn(),
resume: vi.fn(), resume: vi.fn(),
capturePaypal: vi.fn(),
handleAmanpayWebhook: vi.fn(),
handlePaypalWebhook: vi.fn(),
})) }))
vi.mock('../../modules/team/team.service', () => ({ vi.mock('../../modules/team/team.service', () => ({
getMembers: vi.fn(), getMembers: vi.fn(),
@@ -61,9 +55,7 @@ describe('subscription and team API validation contracts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(subscriptionService.startTrial).mockResolvedValue({ id: 'sub_trial' } as never) 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.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.inviteEmployee).mockResolvedValue({ id: 'employee_2' } as never)
vi.mocked(teamService.updateEmployeeRole).mockResolvedValue({ id: 'employee_2', role: 'MANAGER' } 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') expect(subscriptionService.startTrial).toHaveBeenCalledWith('company_1', 'STARTER', 'MONTHLY', 'MAD')
}) })
it('rejects checkout payloads with unsupported currency before service execution', async () => { it('rejects manual checkout payloads with an unsupported collection method before service execution', async () => {
const res = await request(app).post('/api/v1/subscriptions/checkout').send({ const res = await request(app).post('/api/v1/subscriptions/manual-checkout').send({
plan: 'PRO', plan: 'PRO',
billingPeriod: 'ANNUAL', billingPeriod: 'ANNUAL',
currency: 'USD', currency: 'MAD',
provider: 'PAYPAL', method: 'STRIPE',
successUrl: 'https://app.example.test/success', idempotencyKey: '11111111-1111-4111-8111-111111111111',
failureUrl: 'https://app.example.test/failure',
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(subscriptionService.checkout).not.toHaveBeenCalled()
}) })
it('defaults cancellation mode at the route boundary', async () => { 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') 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({}) const res = await request(app).post('/api/v1/subscriptions/capture-paypal').send({})
expect(res.status).toBe(400) expect(res.status).toBe(404)
expect(subscriptionService.capturePaypal).not.toHaveBeenCalled()
}) })
it('normalizes team invites through validation before service execution', async () => { 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', () => ({ vi.mock('../../modules/subscriptions/subscription.service', () => ({
getPlans: vi.fn(), getPlans: vi.fn(),
getProviders: vi.fn(),
getPlanFeatures: vi.fn(), getPlanFeatures: vi.fn(),
handleAmanpayWebhook: vi.fn(),
handlePaypalWebhook: vi.fn(),
handleStripeWebhook: vi.fn(),
})) }))
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' 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' import * as service from '../../modules/subscriptions/subscription.service'
const app = createApp() const app = createApp()
@@ -50,21 +28,12 @@ const app = createApp()
describe('subscriptions public API', () => { describe('subscriptions public API', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(service.getProviders).mockReturnValue({ stripe: false, stripeProblems: [] })
vi.mocked(service.getPlans).mockResolvedValue({ STARTER: { MONTHLY: { MAD: 9900 } } } as never) vi.mocked(service.getPlans).mockResolvedValue({ STARTER: { MONTHLY: { MAD: 9900 } } } as never)
vi.mocked(service.getPlanFeatures).mockResolvedValue([ vi.mocked(service.getPlanFeatures).mockResolvedValue([
{ id: 'feature_1', plan: 'STARTER', label: 'Vehicles', sortOrder: 1 }, { id: 'feature_1', plan: 'STARTER', label: 'Vehicles', sortOrder: 1 },
] as never) ] 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 () => { 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') 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 () => { it('returns 404 for removed AmanPay subscription webhook route', async () => {
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(false)
const res = await request(app) const res = await request(app)
.post('/api/v1/subscriptions/webhooks/amanpay') .post('/api/v1/subscriptions/webhooks/amanpay')
.set('x-amanpay-signature', 'bad-signature')
.send({ id: 'txn_1', status: 'PAID' }) .send({ id: 'txn_1', status: 'PAID' })
expect(res.status).toBe(401) expect(res.status).toBe(404)
expect(res.body).toEqual({ error: 'invalid_signature' })
expect(service.handleAmanpayWebhook).not.toHaveBeenCalled()
}) })
it('accepts verified PayPal webhooks and delegates handling to the subscription service', async () => { it('returns 404 for removed PayPal subscription webhook route', 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) const res = await request(app)
.post('/api/v1/subscriptions/webhooks/paypal') .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.status).toBe(404)
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' }),
)
}) })
}) })
@@ -2,14 +2,6 @@ import { vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({ prisma: {} })) 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('../../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 { describe, expect, it } from 'vitest'
import request from 'supertest' import request from 'supertest'
@@ -18,12 +10,11 @@ import { createApp } from '../../app'
const app = createApp() const app = createApp()
describe('payments webhook public e2e smoke', () => { 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) const res = await request(app)
.post('/api/v1/payments/webhooks/amanpay') .post('/api/v1/payments/webhooks/amanpay')
.send({ transaction_id: 'untrusted_txn', status: 'PAID' }) .send({ transaction_id: 'untrusted_txn', status: 'PAID' })
expect(res.status).toBe(401) expect(res.status).toBe(404)
expect(res.body).toEqual({ error: 'invalid_signature' })
}) })
}) })
@@ -20,8 +20,6 @@ vi.mock('../../modules/site/site.service', () => ({
validatePromoCode: vi.fn(), validatePromoCode: vi.fn(),
createBooking: vi.fn(), createBooking: vi.fn(),
getBooking: vi.fn(), getBooking: vi.fn(),
initPayment: vi.fn(),
capturePaypal: vi.fn(),
handleContact: vi.fn(), handleContact: vi.fn(),
})) }))
vi.mock('../../modules/carplace/carplace.service', () => ({ vi.mock('../../modules/carplace/carplace.service', () => ({
@@ -58,8 +56,7 @@ describe('public validation e2e smoke', () => {
successUrl: 'not-a-url', successUrl: 'not-a-url',
failureUrl: 'also-not-a-url', failureUrl: 'also-not-a-url',
}) })
expect(payment.status).toBe(400) expect(payment.status).toBe(404)
expect(siteService.initPayment).not.toHaveBeenCalled()
const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 }) const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 })
expect(review.status).toBe(400) expect(review.status).toBe(400)
@@ -14,8 +14,6 @@ vi.mock('../../modules/site/site.service', () => ({
validatePromoCode: vi.fn(), validatePromoCode: vi.fn(),
createBooking: vi.fn(), createBooking: vi.fn(),
getBooking: vi.fn(), getBooking: vi.fn(),
initPayment: vi.fn(),
capturePaypal: vi.fn(),
handleContact: vi.fn(), handleContact: vi.fn(),
})) }))
@@ -12,7 +12,7 @@ const app = createApp()
describe('subscription and team public boundary smoke', () => { describe('subscription and team public boundary smoke', () => {
it('rejects malformed protected subscription checkout without leaking internals', async () => { 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([400, 401]).toContain(res.status)
expect(JSON.stringify(res.body)).not.toContain('stack') 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 request from 'supertest'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { createApp } from '../../app' import { createApp } from '../../app'
@@ -47,16 +24,7 @@ import { createApp } from '../../app'
const app = createApp() const app = createApp()
describe('subscriptions public e2e smoke', () => { describe('subscriptions public e2e smoke', () => {
it('lets an anonymous client inspect providers, plans, and features without crossing authenticated subscription routes', async () => { it('lets an anonymous client inspect 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: [],
},
})
const plans = await request(app).get('/api/v1/subscriptions/plans') const plans = await request(app).get('/api/v1/subscriptions/plans')
expect(plans.status).toBe(200) expect(plans.status).toBe(200)
expect(plans.body.data).toHaveProperty('STARTER') expect(plans.body.data).toHaveProperty('STARTER')
+3 -4
View File
@@ -150,8 +150,8 @@ export async function createRentalPayment(
currency: 'MAD', currency: 'MAD',
status: 'SUCCEEDED', status: 'SUCCEEDED',
type: 'CHARGE', type: 'CHARGE',
paymentProvider: 'AMANPAY', paymentProvider: 'MANUAL',
amanpayTransactionId: `aman-${uid()}`, paymentMethod: 'BANK_TRANSFER',
paidAt: new Date(), paidAt: new Date(),
...overrides, ...overrides,
} as any, } as any,
@@ -170,8 +170,7 @@ export async function createSubscriptionInvoice(
amount: 2990, amount: 2990,
currency: 'MAD', currency: 'MAD',
status: 'PENDING', status: 'PENDING',
paymentProvider: 'AMANPAY', paymentProvider: 'MANUAL',
amanpayTransactionId: `sub-${uid()}`,
...overrides, ...overrides,
} as any, } as any,
}) })
@@ -46,7 +46,6 @@ describe('Company signup API', () => {
plan: 'STARTER', plan: 'STARTER',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
paymentProvider: 'AMANPAY',
}) })
expect(res.status).toBe(201) expect(res.status).toBe(201)
@@ -9,7 +9,7 @@ import {
const app = createApp() const app = createApp()
describe('Companies API — brand credential exposure (VF-01)', () => { describe('Companies API — brand access', () => {
let companyId: string let companyId: string
let ownerToken: string let ownerToken: string
let agentToken: string let agentToken: string
@@ -31,17 +31,12 @@ describe('Companies API — brand credential exposure (VF-01)', () => {
}) })
agentToken = signEmployeeToken(agent.id, companyId, 'AGENT') agentToken = signEmployeeToken(agent.id, companyId, 'AGENT')
// Seed brand with real-looking payment credentials
await prisma.brandSettings.create({ await prisma.brandSettings.create({
data: { data: {
companyId, companyId,
displayName: company.name, displayName: company.name,
subdomain: `brand-test-${Date.now()}`, subdomain: `brand-test-${Date.now()}`,
amanpayMerchantId: 'merchant-secret-id', },
amanpaySecretKey: 'aman-super-secret-key',
paypalEmail: 'payments@example.com',
paypalMerchantId: 'paypal-merchant-secret',
} as any,
}) })
}) })
@@ -51,58 +46,13 @@ describe('Companies API — brand credential exposure (VF-01)', () => {
expect(res.status).toBe(401) 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) const res = await request(app)
.get('/api/v1/companies/me/brand') .get('/api/v1/companies/me/brand')
.set(authHeader(ownerToken)) .set(authHeader(ownerToken))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data).not.toHaveProperty('amanpaySecretKey') expect(res.body.data).toHaveProperty('displayName')
})
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)
}) })
it('is accessible to AGENT role (no role gate on this endpoint)', async () => { 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)) .set(authHeader(agentToken))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data).not.toHaveProperty('amanpaySecretKey') expect(res.body.data).toHaveProperty('displayName')
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)
}) })
}) })
}) })
+18 -58
View File
@@ -18,52 +18,19 @@ function uniqueEmail(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com` return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
} }
describe('Payment webhooks — signature bypass fix (VF-02)', () => { describe('Removed payment webhooks', () => {
describe('POST /api/v1/payments/webhooks/amanpay', () => { it('returns 404 for legacy AmanPay rental webhook path', async () => {
it('returns 401 when AmanPay is not configured (env vars absent)', async () => { const res = await request(app)
// In the test environment AMANPAY_MERCHANT_ID and AMANPAY_SECRET_KEY are not set, .post('/api/v1/payments/webhooks/amanpay')
// so isConfigured() returns false. The fix ensures this returns 401, not 200. .send({ transaction_id: 'txn_fake', status: 'PAID' })
const res = await request(app) expect(res.status).toBe(404)
.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('POST /api/v1/payments/webhooks/paypal', () => { it('returns 404 for legacy PayPal rental webhook path', async () => {
it('returns 401 when PayPal is not configured (env vars absent)', async () => { const res = await request(app)
const res = await request(app) .post('/api/v1/payments/webhooks/paypal')
.post('/api/v1/payments/webhooks/paypal') .send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'pp-fake' } })
.send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'pp-fake' } }) expect(res.status).toBe(404)
expect(res.status).toBe(401)
expect(res.body.error).toBe('invalid_signature')
})
}) })
}) })
@@ -128,7 +95,7 @@ describe('Payments API', () => {
amount: 400, amount: 400,
currency: 'MAD', currency: 'MAD',
type: 'CHARGE', type: 'CHARGE',
paymentMethod: 'CASH', paymentMethod: 'CHECK',
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -152,7 +119,7 @@ describe('Payments API', () => {
amount: 300, amount: 300,
currency: 'MAD', currency: 'MAD',
type: 'CHARGE', type: 'CHARGE',
paymentMethod: 'CASH', paymentMethod: 'CHECK',
}) })
expect(res.status).toBe(403) expect(res.status).toBe(403)
@@ -160,14 +127,10 @@ describe('Payments API', () => {
}) })
describe('POST /api/v1/payments/reservations/:id/charge', () => { 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 vehicle = await createVehicle(companyId)
const customer = await createCustomer(companyId) const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id, { const reservation = await createReservation(companyId, vehicle.id, customer.id)
totalAmount: 900,
paidAmount: 900,
paymentStatus: 'PAID',
})
const res = await request(app) const res = await request(app)
.post(`/api/v1/payments/reservations/${reservation.id}/charge`) .post(`/api/v1/payments/reservations/${reservation.id}/charge`)
@@ -180,8 +143,7 @@ describe('Payments API', () => {
failureUrl: 'https://example.com/failure', failureUrl: 'https://example.com/failure',
}) })
expect(res.status).toBe(409) expect(res.status).toBe(404)
expect(res.body.error).toBe('conflict')
}) })
}) })
@@ -191,10 +153,8 @@ describe('Payments API', () => {
const customer = await createCustomer(companyId) const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id) const reservation = await createReservation(companyId, vehicle.id, customer.id)
const payment = await createRentalPayment(companyId, reservation.id, { const payment = await createRentalPayment(companyId, reservation.id, {
paymentMethod: 'CASH', paymentMethod: 'CHECK',
paymentProvider: 'AMANPAY', paymentProvider: 'MANUAL',
amanpayTransactionId: null,
paypalCaptureId: null,
}) })
const res = await request(app) const res = await request(app)
@@ -1,12 +1,25 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { companySignupSchema } from '../../modules/auth/auth.company.schemas' import { companySignupSchema } from '../../modules/auth/auth.company.schemas'
import { createSchema as reservationCreateSchema } from '../../modules/reservations/reservation.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', () => { describe('schema boundary integration markers', () => {
it('keeps public commercial payloads constrained before database-backed flows execute', () => { it('keeps public commercial payloads constrained before database-backed flows execute', () => {
expect(companySignupSchema.safeParse({}).success).toBe(false) expect(companySignupSchema.safeParse({}).success).toBe(false)
expect(reservationCreateSchema.safeParse({ vehicleId: 'bad', customerId: 'bad', startDate: 'bad', endDate: 'bad' }).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` return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
} }
describe('Subscription webhooks — signature bypass fix (VF-02)', () => { describe('Removed subscription webhooks', () => {
describe('POST /api/v1/subscriptions/webhooks/amanpay', () => { it('returns 404 for legacy AmanPay subscription webhook path', async () => {
it('returns 401 when AmanPay is not configured (env vars absent)', async () => { const res = await request(app)
// In the test environment AMANPAY_MERCHANT_ID / SECRET_KEY are not set, .post('/api/v1/subscriptions/webhooks/amanpay')
// so isConfigured() returns false. The fix ensures this returns 401, not 200. .send({ transaction_id: 'txn_fake_sub', status: 'PAID' })
const res = await request(app) expect(res.status).toBe(404)
.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('POST /api/v1/subscriptions/webhooks/paypal', () => { it('returns 404 for legacy PayPal subscription webhook path', async () => {
it('returns 401 when PayPal is not configured (env vars absent)', async () => { const res = await request(app)
const res = await request(app) .post('/api/v1/subscriptions/webhooks/paypal')
.post('/api/v1/subscriptions/webhooks/paypal') .send({ event_type: 'BILLING.SUBSCRIPTION.ACTIVATED', resource: { id: 'sub-fake' } })
.send({ event_type: 'BILLING.SUBSCRIPTION.ACTIVATED', resource: { id: 'sub-fake' } }) expect(res.status).toBe(404)
expect(res.status).toBe(401)
expect(res.body.error).toBe('invalid_signature')
})
}) })
}) })
@@ -96,13 +67,6 @@ describe('Subscriptions API', () => {
expect(res.body.data).toHaveProperty('PRO') expect(res.body.data).toHaveProperty('PRO')
expect(res.body.data).toHaveProperty('ENTERPRISE') 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', () => { describe('Authenticated endpoints', () => {
@@ -8,7 +8,7 @@ import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
type BillingPaymentType = 'CHARGE' | 'DEPOSIT' type BillingPaymentType = 'CHARGE' | 'DEPOSIT'
type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER' type ManualPaymentMethod = 'CHECK' | 'BANK_TRANSFER'
type BillingPayment = { type BillingPayment = {
id: string id: string
@@ -92,7 +92,7 @@ export default function BillingPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [paymentInvoice, setPaymentInvoice] = useState<BillingInvoice | null>(null) const [paymentInvoice, setPaymentInvoice] = useState<BillingInvoice | null>(null)
const [paymentType, setPaymentType] = useState<BillingPaymentType>('CHARGE') const [paymentType, setPaymentType] = useState<BillingPaymentType>('CHARGE')
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH') const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('BANK_TRANSFER')
const [paymentAmount, setPaymentAmount] = useState('') const [paymentAmount, setPaymentAmount] = useState('')
const [receivedAt, setReceivedAt] = useState('') const [receivedAt, setReceivedAt] = useState('')
const [reference, setReference] = useState('') const [reference, setReference] = useState('')
@@ -153,7 +153,7 @@ export default function BillingPage() {
invalidAmount: 'Enter a valid amount within the selected remaining balance.', invalidAmount: 'Enter a valid amount within the selected remaining balance.',
paymentStatusLabels: { ALL: 'All', UNPAID: 'Unpaid', PARTIAL: 'Partial', PAID: 'Paid' } as Record<string, string>, paymentStatusLabels: { ALL: 'All', UNPAID: 'Unpaid', PARTIAL: 'Partial', PAID: 'Paid' } as Record<string, string>,
depositStatusLabels: { NOT_REQUIRED: 'Not required', OUTSTANDING: 'Outstanding', PARTIALLY_COLLECTED: 'Partially collected', HELD: 'Held' } as Record<string, string>, depositStatusLabels: { NOT_REQUIRED: 'Not required', OUTSTANDING: 'Outstanding', PARTIALLY_COLLECTED: 'Partially collected', HELD: 'Held' } as Record<string, string>,
paymentMethodLabels: { CASH: 'Cash', CHECK: 'Check', BANK_TRANSFER: 'Bank transfer', CARD: 'Card', PAYPAL: 'PayPal', OTHER: 'Other' } as Record<string, string>, paymentMethodLabels: { CHECK: 'Check', BANK_TRANSFER: 'Bank transfer' } as Record<string, string>,
}, },
fr: { fr: {
heading: 'Facturation clients', heading: 'Facturation clients',
@@ -207,7 +207,7 @@ export default function BillingPage() {
invalidAmount: 'Saisissez un montant valide dans la limite du solde sélectionné.', invalidAmount: 'Saisissez un montant valide dans la limite du solde sélectionné.',
paymentStatusLabels: { ALL: 'Tous', UNPAID: 'Non payé', PARTIAL: 'Partiel', PAID: 'Payé' } as Record<string, string>, paymentStatusLabels: { ALL: 'Tous', UNPAID: 'Non payé', PARTIAL: 'Partiel', PAID: 'Payé' } as Record<string, string>,
depositStatusLabels: { NOT_REQUIRED: 'Non requis', OUTSTANDING: 'À collecter', PARTIALLY_COLLECTED: 'Partiellement collecté', HELD: 'Détenu' } as Record<string, string>, depositStatusLabels: { NOT_REQUIRED: 'Non requis', OUTSTANDING: 'À collecter', PARTIALLY_COLLECTED: 'Partiellement collecté', HELD: 'Détenu' } as Record<string, string>,
paymentMethodLabels: { CASH: 'Espèces', CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire', CARD: 'Carte', PAYPAL: 'PayPal', OTHER: 'Autre' } as Record<string, string>, paymentMethodLabels: { CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire' } as Record<string, string>,
}, },
ar: { ar: {
heading: 'فوترة العملاء', heading: 'فوترة العملاء',
@@ -261,7 +261,7 @@ export default function BillingPage() {
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المحدد.', invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المحدد.',
paymentStatusLabels: { ALL: 'الكل', UNPAID: 'غير مدفوع', PARTIAL: 'جزئي', PAID: 'مدفوع' } as Record<string, string>, paymentStatusLabels: { ALL: 'الكل', UNPAID: 'غير مدفوع', PARTIAL: 'جزئي', PAID: 'مدفوع' } as Record<string, string>,
depositStatusLabels: { NOT_REQUIRED: 'غير مطلوب', OUTSTANDING: 'مستحق', PARTIALLY_COLLECTED: 'محصل جزئياً', HELD: 'محتجز' } as Record<string, string>, depositStatusLabels: { NOT_REQUIRED: 'غير مطلوب', OUTSTANDING: 'مستحق', PARTIALLY_COLLECTED: 'محصل جزئياً', HELD: 'محتجز' } as Record<string, string>,
paymentMethodLabels: { CASH: 'نقداً', CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي', CARD: 'بطاقة', PAYPAL: 'PayPal', OTHER: 'أخرى' } as Record<string, string>, paymentMethodLabels: { CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي' } as Record<string, string>,
}, },
}[language]), [language]) }[language]), [language])
@@ -331,7 +331,7 @@ export default function BillingPage() {
const remaining = nextType === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue const remaining = nextType === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue
setPaymentInvoice(invoice) setPaymentInvoice(invoice)
setPaymentType(nextType) setPaymentType(nextType)
setPaymentMethod('CASH') setPaymentMethod('BANK_TRANSFER')
setPaymentAmount((remaining / 100).toFixed(2)) setPaymentAmount((remaining / 100).toFixed(2))
setReceivedAt(new Date().toISOString().slice(0, 16)) setReceivedAt(new Date().toISOString().slice(0, 16))
setReference('') setReference('')
@@ -524,7 +524,7 @@ export default function ContractDetailPage() {
damageTypeLabels: { SCRATCH: 'Scratch', DENT: 'Dent', CRACK: 'Crack', CHIP: 'Chip', MISSING: 'Missing', STAIN: 'Stain', OTHER: 'Other' }, damageTypeLabels: { SCRATCH: 'Scratch', DENT: 'Dent', CRACK: 'Crack', CHIP: 'Chip', MISSING: 'Missing', STAIN: 'Stain', OTHER: 'Other' },
severityLabels: { MINOR: 'Minor', MODERATE: 'Moderate', MAJOR: 'Major' }, severityLabels: { MINOR: 'Minor', MODERATE: 'Moderate', MAJOR: 'Major' },
fuelLevelLabels: { FULL: 'Full', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Empty' }, fuelLevelLabels: { FULL: 'Full', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Empty' },
paymentProviderLabels: { CASH: 'Cash', STRIPE: 'Stripe', BANK_TRANSFER: 'Bank transfer', CHECK: 'Check', PAYPAL: 'PayPal', CREDIT_CARD: 'Credit card', DEBIT_CARD: 'Debit card' }, paymentProviderLabels: { BANK_TRANSFER: 'Bank transfer', CHECK: 'Check' },
}, },
fr: { fr: {
back: 'Retour aux contrats', back: 'Retour aux contrats',
@@ -656,7 +656,7 @@ export default function ContractDetailPage() {
damageTypeLabels: { SCRATCH: 'Rayure', DENT: 'Bosse', CRACK: 'Fissure', CHIP: 'Éclat', MISSING: 'Manquant', STAIN: 'Tache', OTHER: 'Autre' }, damageTypeLabels: { SCRATCH: 'Rayure', DENT: 'Bosse', CRACK: 'Fissure', CHIP: 'Éclat', MISSING: 'Manquant', STAIN: 'Tache', OTHER: 'Autre' },
severityLabels: { MINOR: 'Léger', MODERATE: 'Modéré', MAJOR: 'Majeur' }, severityLabels: { MINOR: 'Léger', MODERATE: 'Modéré', MAJOR: 'Majeur' },
fuelLevelLabels: { FULL: 'Plein', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Vide' }, fuelLevelLabels: { FULL: 'Plein', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Vide' },
paymentProviderLabels: { CASH: 'Espèces', STRIPE: 'Stripe', BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque', PAYPAL: 'PayPal', CREDIT_CARD: 'Carte de crédit', DEBIT_CARD: 'Carte de débit' }, paymentProviderLabels: { BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque' },
}, },
ar: { ar: {
back: 'العودة إلى العقود', back: 'العودة إلى العقود',
@@ -788,7 +788,7 @@ export default function ContractDetailPage() {
damageTypeLabels: { SCRATCH: 'خدش', DENT: 'انبعاج', CRACK: 'تشقق', CHIP: 'تقشر', MISSING: 'مفقود', STAIN: 'بقعة', OTHER: 'أخرى' }, damageTypeLabels: { SCRATCH: 'خدش', DENT: 'انبعاج', CRACK: 'تشقق', CHIP: 'تقشر', MISSING: 'مفقود', STAIN: 'بقعة', OTHER: 'أخرى' },
severityLabels: { MINOR: 'طفيف', MODERATE: 'متوسط', MAJOR: 'شديد' }, severityLabels: { MINOR: 'طفيف', MODERATE: 'متوسط', MAJOR: 'شديد' },
fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' }, fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' },
paymentProviderLabels: { CASH: 'نقداً', STRIPE: 'Stripe', BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك', PAYPAL: 'PayPal', CREDIT_CARD: 'بطاقة ائتمان', DEBIT_CARD: 'بطاقة خصم' }, paymentProviderLabels: { BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك' },
}, },
} as const } as const
@@ -108,6 +108,13 @@ interface ReservationBilling {
type EditMode = 'booking' | 'return' | null type EditMode = 'booking' | 'return' | null
const RENTAL_PAYMENT_MODES = ['BANK_TRANSFER', 'CHECK'] as const
type RentalPaymentMode = (typeof RENTAL_PAYMENT_MODES)[number]
function isRentalPaymentMode(value: string): value is RentalPaymentMode {
return (RENTAL_PAYMENT_MODES as readonly string[]).includes(value)
}
type ReservationFormState = { type ReservationFormState = {
startDate: string startDate: string
endDate: string endDate: string
@@ -233,6 +240,7 @@ const detailCopy = {
returnLabel: 'Return location', returnLabel: 'Return location',
depositLabel: 'Deposit', depositLabel: 'Deposit',
paymentModeLabel: 'Payment mode', paymentModeLabel: 'Payment mode',
paymentModeLabels: { BANK_TRANSFER: 'Bank transfer', CHECK: 'Check' } as Record<string, string>,
spareWheelLabel: 'Spare wheel', spareWheelLabel: 'Spare wheel',
radioCdLabel: 'Radio and CD', radioCdLabel: 'Radio and CD',
bookingNotesLabel: 'Booking notes', bookingNotesLabel: 'Booking notes',
@@ -348,6 +356,7 @@ const detailCopy = {
returnLabel: 'Lieu de retour', returnLabel: 'Lieu de retour',
depositLabel: 'Dépôt', depositLabel: 'Dépôt',
paymentModeLabel: 'Mode de paiement', paymentModeLabel: 'Mode de paiement',
paymentModeLabels: { BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque' } as Record<string, string>,
spareWheelLabel: 'Roue de secours', spareWheelLabel: 'Roue de secours',
radioCdLabel: 'Poste radio et CD', radioCdLabel: 'Poste radio et CD',
bookingNotesLabel: 'Notes de réservation', bookingNotesLabel: 'Notes de réservation',
@@ -463,6 +472,7 @@ const detailCopy = {
returnLabel: 'موقع الإرجاع', returnLabel: 'موقع الإرجاع',
depositLabel: 'العربون', depositLabel: 'العربون',
paymentModeLabel: 'طريقة الدفع', paymentModeLabel: 'طريقة الدفع',
paymentModeLabels: { BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك' } as Record<string, string>,
spareWheelLabel: 'العجلة الاحتياطية', spareWheelLabel: 'العجلة الاحتياطية',
radioCdLabel: 'الراديو و CD', radioCdLabel: 'الراديو و CD',
bookingNotesLabel: 'ملاحظات الحجز', bookingNotesLabel: 'ملاحظات الحجز',
@@ -691,7 +701,11 @@ export default function ReservationDetailPage() {
pickupLocation: form.pickupLocation || null, pickupLocation: form.pickupLocation || null,
returnLocation: form.returnLocation || null, returnLocation: form.returnLocation || null,
depositAmount: Number(form.depositAmount || 0), depositAmount: Number(form.depositAmount || 0),
paymentMode: form.paymentMode || null, paymentMode: isRentalPaymentMode(form.paymentMode)
? form.paymentMode
: form.paymentMode
? undefined
: null,
spareWheel: form.spareWheel, spareWheel: form.spareWheel,
radioCd: form.radioCd, radioCd: form.radioCd,
contractFields: form.contractFields, contractFields: form.contractFields,
@@ -972,7 +986,7 @@ export default function ReservationDetailPage() {
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p> <p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
<p>{reservation.vehicle.licensePlate}</p> <p>{reservation.vehicle.licensePlate}</p>
<p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p> <p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
<p>{copy.paymentModeLabel}: <span className="font-medium text-slate-900">{reservation.paymentMode || copy.paymentModeEmpty}</span></p> <p>{copy.paymentModeLabel}: <span className="font-medium text-slate-900">{reservation.paymentMode ? (copy.paymentModeLabels[reservation.paymentMode] ?? reservation.paymentMode) : copy.paymentModeEmpty}</span></p>
</div> </div>
</div> </div>
</div> </div>
@@ -1033,12 +1047,20 @@ export default function ReservationDetailPage() {
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paymentModeLabel}</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paymentModeLabel}</label>
<input <select
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500" className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.paymentMode} value={form.paymentMode}
disabled={bookingInputsDisabled} disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)} onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)}
/> >
<option value="">{copy.paymentModeEmpty}</option>
{form.paymentMode && !isRentalPaymentMode(form.paymentMode) && (
<option value={form.paymentMode}>{form.paymentMode}</option>
)}
{RENTAL_PAYMENT_MODES.map((mode) => (
<option key={mode} value={mode}>{copy.paymentModeLabels[mode]}</option>
))}
</select>
</div> </div>
<label className="flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700"> <label className="flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
<input <input
@@ -77,9 +77,6 @@ interface BrandSettings {
heroImageUrl?: string | null heroImageUrl?: string | null
websiteUrl?: string | null websiteUrl?: string | null
whatsappNumber?: string | null whatsappNumber?: string | null
paypalEmail: string | null
amanpayMerchantId: string | null
amanpaySecretKey?: string | null
defaultLocale?: string defaultLocale?: string
defaultCurrency?: string defaultCurrency?: string
isListedOnCarplace: boolean isListedOnCarplace: boolean
@@ -196,15 +193,14 @@ function SettingsPageContent() {
lockedTitle: 'This settings area is not included in your current plan.', lockedBody: 'Your saved configuration is preserved and becomes editable again after upgrade.', lockedTitle: 'This settings area is not included in your current plan.', lockedBody: 'Your saved configuration is preserved and becomes editable again after upgrade.',
readOnly: 'This section is read-only while your subscription access is restricted.', companyHint: 'Default language affects Carplace text and generated contracts when available.', readOnly: 'This section is read-only while your subscription access is restricted.', companyHint: 'Default language affects Carplace text and generated contracts when available.',
publicProfile: 'Public profile', carplaceBasics: 'Carplace basics', premiumBranding: 'Available on GROWTH', publicProfile: 'Public profile', carplaceBasics: 'Carplace basics', premiumBranding: 'Available on GROWTH',
paymentsBody: 'Configure renter payment providers. Secret values are never shown after saving.', paymentsBody: 'Rental payments are recorded as bank transfer or check. Online checkout is not available.',
policies: 'Fuel, driver, and damage policies', additionalDriver: 'Additional-driver automation', insuranceNew: 'New insurance policy', policies: 'Fuel, driver, and damage policies', additionalDriver: 'Additional-driver automation', insuranceNew: 'New insurance policy',
pricingNew: 'New pricing rule', accountingSchedule: 'Scheduled delivery is available on PRO.', noItems: 'No records configured yet.', pricingNew: 'New pricing rule', accountingSchedule: 'Scheduled delivery is available on PRO.', noItems: 'No records configured yet.',
carplace: 'Listed on Carplace', logo: 'Logo', hero: 'Hero image', upload: 'Upload', active: 'Active', inactive: 'Inactive', carplace: 'Listed on Carplace', logo: 'Logo', hero: 'Hero image', upload: 'Upload', active: 'Active', inactive: 'Inactive',
labels: { labels: {
displayName: 'Display name', tagline: 'Tagline', publicEmail: 'Public email', publicPhone: 'Public phone', whatsapp: 'WhatsApp number', displayName: 'Display name', tagline: 'Tagline', publicEmail: 'Public email', publicPhone: 'Public phone', whatsapp: 'WhatsApp number',
city: 'City', country: 'Country', websiteUrl: 'Website URL', defaultLocale: 'Default language', defaultCurrency: 'Default currency', city: 'City', country: 'Country', websiteUrl: 'Website URL', defaultLocale: 'Default language', defaultCurrency: 'Default currency',
primaryColor: 'Primary color', accentColor: 'Accent color', amanpayMerchantId: 'AmanPay merchant ID', amanpaySecretKey: 'AmanPay secret key', primaryColor: 'Primary color', accentColor: 'Accent color', fuelPolicy: 'Fuel policy', fuelPolicyType: 'Fuel policy type', fuelPolicyNote: 'Fuel policy note',
paypalEmail: 'PayPal business email', fuelPolicy: 'Fuel policy', fuelPolicyType: 'Fuel policy type', fuelPolicyNote: 'Fuel policy note',
damagePolicy: 'Damage policy', additionalDriverPolicy: 'Driver policy', additionalDriverCharge: 'Additional driver charge', damagePolicy: 'Damage policy', additionalDriverPolicy: 'Driver policy', additionalDriverCharge: 'Additional driver charge',
dailyRate: 'Daily rate', flatRate: 'Flat rate', accountantName: 'Accountant name', dailyRate: 'Daily rate', flatRate: 'Flat rate', accountantName: 'Accountant name',
accountantEmail: 'Accountant email', reportingPeriod: 'Reporting period', reportFormat: 'Report format', autoSend: 'Auto-send reports', accountantEmail: 'Accountant email', reportingPeriod: 'Reporting period', reportFormat: 'Report format', autoSend: 'Auto-send reports',
@@ -218,15 +214,14 @@ function SettingsPageContent() {
lockedTitle: 'Cette section nest pas incluse dans votre plan actuel.', lockedBody: 'La configuration enregistrée est conservée et redevient modifiable après mise à niveau.', lockedTitle: 'Cette section nest pas incluse dans votre plan actuel.', lockedBody: 'La configuration enregistrée est conservée et redevient modifiable après mise à niveau.',
readOnly: 'Cette section est en lecture seule pendant la restriction daccès.', companyHint: 'La langue par défaut affecte la vitrine et les contrats générés si disponibles.', readOnly: 'Cette section est en lecture seule pendant la restriction daccès.', companyHint: 'La langue par défaut affecte la vitrine et les contrats générés si disponibles.',
publicProfile: 'Profil public', carplaceBasics: 'Paramètres Carplace', premiumBranding: 'Disponible avec GROWTH', publicProfile: 'Profil public', carplaceBasics: 'Paramètres Carplace', premiumBranding: 'Disponible avec GROWTH',
paymentsBody: 'Configurez les prestataires de paiement. Les secrets ne sont jamais affichés après enregistrement.', paymentsBody: 'Les paiements de location sont enregistrés par virement bancaire ou chèque. Le paiement en ligne nest pas disponible.',
policies: 'Carburant, conducteur et dommages', additionalDriver: 'Automatisation conducteur additionnel', insuranceNew: 'Nouvelle police', policies: 'Carburant, conducteur et dommages', additionalDriver: 'Automatisation conducteur additionnel', insuranceNew: 'Nouvelle police',
pricingNew: 'Nouvelle règle', accountingSchedule: 'Lenvoi planifié est disponible avec PRO.', noItems: 'Aucun enregistrement.', pricingNew: 'Nouvelle règle', accountingSchedule: 'Lenvoi planifié est disponible avec PRO.', noItems: 'Aucun enregistrement.',
carplace: 'Publié sur Carplace', logo: 'Logo', hero: 'Image principale', upload: 'Téléverser', active: 'Actif', inactive: 'Inactif', carplace: 'Publié sur Carplace', logo: 'Logo', hero: 'Image principale', upload: 'Téléverser', active: 'Actif', inactive: 'Inactif',
labels: { labels: {
displayName: 'Nom affiché', tagline: 'Slogan', publicEmail: 'E-mail public', publicPhone: 'Téléphone public', whatsapp: 'Numéro WhatsApp', displayName: 'Nom affiché', tagline: 'Slogan', publicEmail: 'E-mail public', publicPhone: 'Téléphone public', whatsapp: 'Numéro WhatsApp',
city: 'Ville', country: 'Pays', websiteUrl: 'Site web', defaultLocale: 'Langue par défaut', defaultCurrency: 'Devise par défaut', city: 'Ville', country: 'Pays', websiteUrl: 'Site web', defaultLocale: 'Langue par défaut', defaultCurrency: 'Devise par défaut',
primaryColor: 'Couleur principale', accentColor: 'Couleur secondaire', amanpayMerchantId: 'ID marchand AmanPay', amanpaySecretKey: 'Clé secrète AmanPay', primaryColor: 'Couleur principale', accentColor: 'Couleur secondaire', fuelPolicy: 'Politique carburant', fuelPolicyType: 'Type de politique carburant', fuelPolicyNote: 'Note carburant',
paypalEmail: 'Email PayPal business', fuelPolicy: 'Politique carburant', fuelPolicyType: 'Type de politique carburant', fuelPolicyNote: 'Note carburant',
damagePolicy: 'Politique dommages', additionalDriverPolicy: 'Politique conducteur', additionalDriverCharge: 'Supplément conducteur', damagePolicy: 'Politique dommages', additionalDriverPolicy: 'Politique conducteur', additionalDriverCharge: 'Supplément conducteur',
dailyRate: 'Tarif journalier', flatRate: 'Tarif fixe', accountantName: 'Nom du comptable', dailyRate: 'Tarif journalier', flatRate: 'Tarif fixe', accountantName: 'Nom du comptable',
accountantEmail: 'E-mail du comptable', reportingPeriod: 'Période', reportFormat: 'Format', autoSend: 'Envoi automatique', accountantEmail: 'E-mail du comptable', reportingPeriod: 'Période', reportFormat: 'Format', autoSend: 'Envoi automatique',
@@ -240,15 +235,14 @@ function SettingsPageContent() {
lockedTitle: 'هذا القسم غير مشمول في خطتك الحالية.', lockedBody: 'يتم الاحتفاظ بالإعدادات المحفوظة وتعود قابلة للتعديل بعد الترقية.', lockedTitle: 'هذا القسم غير مشمول في خطتك الحالية.', lockedBody: 'يتم الاحتفاظ بالإعدادات المحفوظة وتعود قابلة للتعديل بعد الترقية.',
readOnly: 'هذا القسم للقراءة فقط أثناء تقييد الوصول.', companyHint: 'تؤثر اللغة الافتراضية على الواجهة والعقود عند توفرها.', readOnly: 'هذا القسم للقراءة فقط أثناء تقييد الوصول.', companyHint: 'تؤثر اللغة الافتراضية على الواجهة والعقود عند توفرها.',
publicProfile: 'الملف العام', carplaceBasics: 'أساسيات الواجهة', premiumBranding: 'متاح في GROWTH', publicProfile: 'الملف العام', carplaceBasics: 'أساسيات الواجهة', premiumBranding: 'متاح في GROWTH',
paymentsBody: 'إعداد مزودي دفع المستأجرين. لا يتم عرض المفاتيح السرية بعد الحفظ.', paymentsBody: 'يتم تسجيل مدفوعات الكراء بالتحويل البنكي أو الشيك. الدفع الإلكتروني غير متاح.',
policies: 'سياسات الوقود والسائق والأضرار', additionalDriver: 'أتمتة السائق الإضافي', insuranceNew: 'سياسة تأمين جديدة', policies: 'سياسات الوقود والسائق والأضرار', additionalDriver: 'أتمتة السائق الإضافي', insuranceNew: 'سياسة تأمين جديدة',
pricingNew: 'قاعدة تسعير جديدة', accountingSchedule: 'الإرسال المجدول متاح في PRO.', noItems: 'لا توجد سجلات بعد.', pricingNew: 'قاعدة تسعير جديدة', accountingSchedule: 'الإرسال المجدول متاح في PRO.', noItems: 'لا توجد سجلات بعد.',
carplace: 'مدرج على Carplace', logo: 'الشعار', hero: 'صورة الواجهة', upload: 'رفع', active: 'نشط', inactive: 'غير نشط', carplace: 'مدرج على Carplace', logo: 'الشعار', hero: 'صورة الواجهة', upload: 'رفع', active: 'نشط', inactive: 'غير نشط',
labels: { labels: {
displayName: 'اسم العرض', tagline: 'الشعار', publicEmail: 'البريد العام', publicPhone: 'الهاتف العام', whatsapp: 'رقم واتساب', displayName: 'اسم العرض', tagline: 'الشعار', publicEmail: 'البريد العام', publicPhone: 'الهاتف العام', whatsapp: 'رقم واتساب',
city: 'المدينة', country: 'الدولة', websiteUrl: 'الموقع', defaultLocale: 'اللغة الافتراضية', defaultCurrency: 'العملة الافتراضية', city: 'المدينة', country: 'الدولة', websiteUrl: 'الموقع', defaultLocale: 'اللغة الافتراضية', defaultCurrency: 'العملة الافتراضية',
primaryColor: 'اللون الأساسي', accentColor: 'لون التمييز', amanpayMerchantId: 'معرف تاجر AmanPay', amanpaySecretKey: فتاح AmanPay السري', primaryColor: 'اللون الأساسي', accentColor: 'لون التمييز', fuelPolicy: 'سياسة الوقود', fuelPolicyType: 'نوع سياسة الوقود', fuelPolicyNote: لاحظة الوقود',
paypalEmail: 'بريد PayPal التجاري', fuelPolicy: 'سياسة الوقود', fuelPolicyType: 'نوع سياسة الوقود', fuelPolicyNote: 'ملاحظة الوقود',
damagePolicy: 'سياسة الأضرار', additionalDriverPolicy: 'سياسة السائق', additionalDriverCharge: 'رسوم السائق الإضافي', damagePolicy: 'سياسة الأضرار', additionalDriverPolicy: 'سياسة السائق', additionalDriverCharge: 'رسوم السائق الإضافي',
dailyRate: 'السعر اليومي', flatRate: 'السعر الثابت', accountantName: 'اسم المحاسب', dailyRate: 'السعر اليومي', flatRate: 'السعر الثابت', accountantName: 'اسم المحاسب',
accountantEmail: 'بريد المحاسب', reportingPeriod: 'فترة التقرير', reportFormat: 'تنسيق التقرير', autoSend: 'إرسال تلقائي', accountantEmail: 'بريد المحاسب', reportingPeriod: 'فترة التقرير', reportFormat: 'تنسيق التقرير', autoSend: 'إرسال تلقائي',
@@ -349,9 +343,6 @@ function SettingsPageContent() {
websiteUrl: brand.websiteUrl || undefined, defaultLocale: brand.defaultLocale || undefined, websiteUrl: brand.websiteUrl || undefined, defaultLocale: brand.defaultLocale || undefined,
whatsappNumber: brand.whatsappNumber || undefined, defaultCurrency: brand.defaultCurrency || undefined, whatsappNumber: brand.whatsappNumber || undefined, defaultCurrency: brand.defaultCurrency || undefined,
isListedOnCarplace: brand.isListedOnCarplace, isListedOnCarplace: brand.isListedOnCarplace,
amanpayMerchantId: canEdit('settings.renter_payments') ? brand.amanpayMerchantId || undefined : undefined,
amanpaySecretKey: canEdit('settings.renter_payments') ? brand.amanpaySecretKey || undefined : undefined,
paypalEmail: canEdit('settings.renter_payments') ? brand.paypalEmail || undefined : undefined,
}), }),
}) })
setBrand(updated); setMessage(copy.saved) setBrand(updated); setMessage(copy.saved)
@@ -547,12 +538,7 @@ function SettingsPageContent() {
{activeSection === 'payments' && brand && ( {activeSection === 'payments' && brand && (
<SectionCard onSave={saveBrand} saving={saving} copy={copy} disabled={!canEdit('settings.renter_payments')}> <SectionCard onSave={saveBrand} saving={saving} copy={copy} disabled={!canEdit('settings.renter_payments')}>
<p className="mb-4 text-sm text-slate-500">{copy.paymentsBody}</p> <p className="text-sm text-slate-600">{copy.paymentsBody}</p>
<div className="grid gap-4 lg:grid-cols-2">
<Input label={copy.labels.amanpayMerchantId} value={brand.amanpayMerchantId ?? ''} disabled={!canEdit('settings.renter_payments')} onChange={(v) => setBrand({ ...brand, amanpayMerchantId: v })} />
<Input label={copy.labels.amanpaySecretKey} type="password" value={brand.amanpaySecretKey ?? ''} disabled={!canEdit('settings.renter_payments')} onChange={(v) => setBrand({ ...brand, amanpaySecretKey: v })} />
<Input label={copy.labels.paypalEmail} type="email" value={brand.paypalEmail ?? ''} disabled={!canEdit('settings.renter_payments')} onChange={(v) => setBrand({ ...brand, paypalEmail: v })} />
</div>
</SectionCard> </SectionCard>
)} )}
@@ -9,7 +9,7 @@ import { buildHomepageSignInPath } from '@/lib/dashboardPaths'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE' type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriod = 'MONTHLY' | 'ANNUAL' type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type CollectionMethod = 'STRIPE' | 'BANK_TRANSFER' | 'CHECK' type CollectionMethod = 'BANK_TRANSFER' | 'CHECK'
interface Subscription { interface Subscription {
id: string id: string
@@ -592,7 +592,7 @@ export default function SubscriptionPage() {
.then(([sub, inv, options, settings]) => { .then(([sub, inv, options, settings]) => {
setPaymentOptions(options.methods ?? []) setPaymentOptions(options.methods ?? [])
setCommunicationSettings(settings) setCommunicationSettings(settings)
const firstEnabled = options.methods?.find((option) => option.enabled && option.method !== 'STRIPE') const firstEnabled = options.methods?.find((option) => option.enabled)
if (firstEnabled) setSelectedMethod(firstEnabled.method) if (firstEnabled) setSelectedMethod(firstEnabled.method)
if (sub) { if (sub) {
setSubscription(sub) setSubscription(sub)
@@ -601,7 +601,7 @@ export default function SubscriptionPage() {
// currency is always MAD // currency is always MAD
} }
setInvoices(inv ?? []) setInvoices(inv ?? [])
const latestManual = inv?.find((invoice) => invoice.collectionMethod !== 'STRIPE' && ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE'].includes(invoice.status)) const latestManual = inv?.find((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE'].includes(invoice.status))
if (latestManual) { if (latestManual) {
const option = options.methods?.find((item) => item.method === latestManual.collectionMethod) const option = options.methods?.find((item) => item.method === latestManual.collectionMethod)
setSelectedMethod(latestManual.collectionMethod) setSelectedMethod(latestManual.collectionMethod)
@@ -975,7 +975,7 @@ export default function SubscriptionPage() {
{/* Plan selector + checkout */} {/* Plan selector + checkout */}
<div className="card p-6 space-y-6"> <div className="card p-6 space-y-6">
{!paymentOptions.some((option) => option.enabled && option.method !== 'STRIPE') ? ( {!paymentOptions.some((option) => option.enabled) ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700"> <div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured} {copy.noProviderConfigured}
</div> </div>
@@ -1081,7 +1081,7 @@ export default function SubscriptionPage() {
<div> <div>
<p className="text-sm font-medium text-slate-700 mb-2 dark:text-zinc-300">{copy.paymentProvider}</p> <p className="text-sm font-medium text-slate-700 mb-2 dark:text-zinc-300">{copy.paymentProvider}</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{paymentOptions.filter((option) => option.enabled && option.method !== 'STRIPE').map((option) => ( {paymentOptions.filter((option) => option.enabled).map((option) => (
<button <button
type="button" type="button"
key={option.method} key={option.method}
@@ -1123,7 +1123,7 @@ export default function SubscriptionPage() {
disabled={ disabled={
paying paying
|| loading || loading
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled && option.method !== 'STRIPE') || !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|| isInvalidActiveUpgradeSelection || isInvalidActiveUpgradeSelection
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0)) || (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
} }
+2 -58
View File
@@ -14,8 +14,6 @@ export default function OnboardingPage() {
const [step, setStep] = useState<Step>(1) const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showSecretKey, setShowSecretKey] = useState(false)
const [company, setCompany] = useState({ name: '', slug: '' }) const [company, setCompany] = useState({ name: '', slug: '' })
const [brand, setBrand] = useState({ const [brand, setBrand] = useState({
displayName: '', displayName: '',
@@ -25,9 +23,6 @@ export default function OnboardingPage() {
publicCountry: '', publicCountry: '',
}) })
const [payments, setPayments] = useState({ const [payments, setPayments] = useState({
amanpayMerchantId: '',
amanpaySecretKey: '',
paypalEmail: '',
isListedOnCarplace: true, isListedOnCarplace: true,
}) })
@@ -78,9 +73,6 @@ export default function OnboardingPage() {
await apiFetch('/companies/me/brand', { await apiFetch('/companies/me/brand', {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
amanpayMerchantId: payments.amanpayMerchantId || undefined,
amanpaySecretKey: payments.amanpaySecretKey || undefined,
paypalEmail: payments.paypalEmail || undefined,
isListedOnCarplace: payments.isListedOnCarplace, isListedOnCarplace: payments.isListedOnCarplace,
}), }),
}) })
@@ -213,56 +205,8 @@ export default function OnboardingPage() {
{step === 3 && ( {step === 3 && (
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Payment setup</h2> <h2 className="text-lg font-semibold text-slate-900">Finish setup</h2>
<p className="mt-1 text-sm text-slate-500">Connect payment providers so renters can book online. You can skip this for now.</p> <p className="mt-1 text-sm text-slate-500">Rental payments are recorded manually in the dashboard. Choose whether to list your company on Carplace.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Merchant ID <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="your-merchant-id"
value={payments.amanpayMerchantId}
onChange={(e) => setPayments({ ...payments, amanpayMerchantId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Secret Key <span className="text-slate-400">(optional)</span></label>
<div className="relative">
<input
type={showSecretKey ? 'text' : 'password'}
className="input-field pr-10"
placeholder="your-secret-key"
value={payments.amanpaySecretKey}
onChange={(e) => setPayments({ ...payments, amanpaySecretKey: e.target.value })}
/>
<button
type="button"
onClick={() => setShowSecretKey((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
tabIndex={-1}
>
{showSecretKey ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PayPal email <span className="text-slate-400">(optional)</span></label>
<input
type="email"
className="input-field"
placeholder="payments@yourcompany.com"
value={payments.paypalEmail}
onChange={(e) => setPayments({ ...payments, paypalEmail: e.target.value })}
/>
</div> </div>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
@@ -21,7 +21,7 @@ import type { BilingualField } from '@/components/ui/BilingualInput'
type Language = 'en' | 'fr' | 'ar' type Language = 'en' | 'fr' | 'ar'
const paymentModes = ['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const const paymentModes = ['BANK_TRANSFER', 'CHECK'] as const
export function ReservationWizard({ export function ReservationWizard({
customers, customers,
@@ -84,11 +84,8 @@ export function getReservationWizardCopy(language: Language) {
imageSize: 'Image must be 5 MB or smaller.', imageSize: 'Image must be 5 MB or smaller.',
}, },
paymentModes: { paymentModes: {
CASH: 'Cash',
CARD: 'Card',
BANK_TRANSFER: 'Bank transfer', BANK_TRANSFER: 'Bank transfer',
AMANPAY: 'AmanPay', CHECK: 'Check',
PAYPAL: 'PayPal',
} as Record<string, string>, } as Record<string, string>,
}, },
fr: { fr: {
@@ -171,11 +168,8 @@ export function getReservationWizardCopy(language: Language) {
imageSize: 'Limage doit faire 5 Mo ou moins.', imageSize: 'Limage doit faire 5 Mo ou moins.',
}, },
paymentModes: { paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement', BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay', CHECK: 'Chèque',
PAYPAL: 'PayPal',
} as Record<string, string>, } as Record<string, string>,
}, },
ar: { ar: {
@@ -258,11 +252,8 @@ export function getReservationWizardCopy(language: Language) {
imageSize: 'يجب ألا تتجاوز الصورة 5 ميغابايت.', imageSize: 'يجب ألا تتجاوز الصورة 5 ميغابايت.',
}, },
paymentModes: { paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي', BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay', CHECK: 'شيك',
PAYPAL: 'PayPal',
} as Record<string, string>, } as Record<string, string>,
}, },
}[language] }[language]
@@ -60,7 +60,7 @@ export function createInitialDraft(): ReservationDraft {
}, },
payment: { payment: {
depositAmount: '0', depositAmount: '0',
paymentMode: 'CASH', paymentMode: 'BANK_TRANSFER',
spareWheel: false, spareWheel: false,
radioCd: false, radioCd: false,
notes: '', notes: '',
@@ -44,7 +44,7 @@ function draft(): ReservationDraft {
}, },
payment: { payment: {
depositAmount: '0', depositAmount: '0',
paymentMode: 'CASH', paymentMode: 'BANK_TRANSFER',
spareWheel: false, spareWheel: false,
radioCd: false, radioCd: false,
notes: '', notes: '',
@@ -49,7 +49,7 @@ function validDraft() {
}, },
payment: { payment: {
depositAmount: '0', depositAmount: '0',
paymentMode: 'CASH', paymentMode: 'BANK_TRANSFER',
spareWheel: false, spareWheel: false,
radioCd: false, radioCd: false,
notes: '', notes: '',
-8
View File
@@ -163,10 +163,6 @@ Open `.env.docker.production` and fill in every value. The minimum required secr
| `MAIL_USERNAME` | Gmail address used to send transactional mail | | `MAIL_USERNAME` | Gmail address used to send transactional mail |
| `MAIL_PASSWORD` | Gmail app password, not the normal Google account password | | `MAIL_PASSWORD` | Gmail app password, not the normal Google account password |
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` | | `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
| `STRIPE_API_KEY` | Live Stripe secret key or restricted key for production billing (`sk_live_` or `rk_live_`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret for `/api/v1/subscriptions/webhooks/stripe` (`whsec_`) |
Direct production script runs require Stripe billing by default. If you need to deploy production before Stripe is ready, set `STRIPE_BILLING_REQUIRED=false` in the production env file. Gitea Actions deploys default this flag to `false` when the `STRIPE_BILLING_REQUIRED` secret is unset; set the secret to `true` when CI should fail unless live Stripe keys are configured. The app will still start with Stripe disabled, but Stripe subscription checkout and Stripe webhooks will remain unavailable until live Stripe keys are configured.
For Gitea Actions deploys, either store the completed production env file as the raw `ENV_DOCKER_PRODUCTION` secret or as the base64-encoded `ENV_DOCKER_PRODUCTION_B64` secret: For Gitea Actions deploys, either store the completed production env file as the raw `ENV_DOCKER_PRODUCTION` secret or as the base64-encoded `ENV_DOCKER_PRODUCTION_B64` secret:
@@ -176,10 +172,6 @@ base64 < .env.docker.production | tr -d '\n'
Paste that single-line output into `ENV_DOCKER_PRODUCTION_B64` when using the base64 option. During deploy, the workflow writes the env file to `/opt/rentaldrivego/.env.docker.production` on the VPS with `600` permissions before running `scripts/docker-prod-deploy.sh`. If neither production env secret is set, the workflow reuses `/opt/rentaldrivego/.env.docker.production` when it already exists on the VPS. Paste that single-line output into `ENV_DOCKER_PRODUCTION_B64` when using the base64 option. During deploy, the workflow writes the env file to `/opt/rentaldrivego/.env.docker.production` on the VPS with `600` permissions before running `scripts/docker-prod-deploy.sh`. If neither production env secret is set, the workflow reuses `/opt/rentaldrivego/.env.docker.production` when it already exists on the VPS.
You can also store `STRIPE_API_KEY` and `STRIPE_WEBHOOK_SECRET` as separate Gitea Actions secrets. When those secrets are present, the deploy workflow overwrites the Stripe values from `ENV_DOCKER_PRODUCTION`/`ENV_DOCKER_PRODUCTION_B64` before deploying. This is useful when the production env file secret still contains placeholders for billing secrets.
If `/opt/rentaldrivego/.env.docker.production` already exists on the VPS and a newly supplied env-file secret omits `STRIPE_API_KEY` or `STRIPE_WEBHOOK_SECRET`, the deploy workflow preserves the existing VPS values for those keys. Final production validation still fails if the merged env file does not contain a live `sk_live_`/`rk_live_` key and a `whsec_` webhook signing secret.
Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`. Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`.
The example file uses `rentaldrivego.ma` for the carplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`. The example file uses `rentaldrivego.ma` for the carplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.
+11 -11
View File
@@ -2,7 +2,7 @@
## Platform Model ## Platform Model
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get: **Rental Companies (B2B)** — pay RentalDriveGo a subscription via manual bank transfer or check, get:
- A fully private dashboard (zero data overlap with any other company) - A fully private dashboard (zero data overlap with any other company)
- Fleet management with vehicle photo upload (photos auto-appear on global carplace) - Fleet management with vehicle photo upload (photos auto-appear on global carplace)
- Promotional offers management - Promotional offers management
@@ -16,16 +16,16 @@
--- ---
## ⚠️ Payment Providers — Stripe Has Been Removed ## Payment Model — Manual Bank Transfer and Check Only
| Provider | Purpose | All subscription and rental payments are recorded manually:
|----------|---------|
| **AmanPay** (amanpay.net) | Primary. National/international cards, cash (3000+ points), e-wallet. Moroccan PCI-DSS Level-1 PSP. |
| **PayPal** | Secondary. Global coverage. |
**Two payment contexts:** | Context | Methods |
1. Company pays RentalDriveGosubscription → RentalDriveGo's own AmanPay/PayPal account |---------|---------|
2. Renter pays company for rental → Company's own AmanPay merchant + PayPal account (direct, no intermediary) | **Company subscription billing** | Bank transfer or check via manual checkout and payment submissions |
| **Rental payments (dashboard)** | Bank transfer or check recorded against reservations |
Online checkout providers (Stripe, AmanPay, PayPal) have been removed from the platform.
--- ---
@@ -85,8 +85,8 @@ See **`docs/design/README.md`** for what changed during the production-readiness
| Concern | Technology | | Concern | Technology |
|---------|-----------| |---------|-----------|
| Subscription payment | AmanPay (primary) + PayPal (secondary) | | Subscription payment | Manual bank transfer or check |
| Rental payment | Company's own AmanPay merchant + PayPal | | Rental payment | Manual bank transfer or check |
| PDF generation | `@react-pdf/renderer` — server-side, on-demand, never stored | | PDF generation | `@react-pdf/renderer` — server-side, on-demand, never stored |
| Damage diagrams | SVG top-down car map (22 zones, React interactive + PDF static render) | | Damage diagrams | SVG top-down car map (22 zones, React interactive + PDF static render) |
| Insurance | Per-company configurable policies with per-day/flat/% charge types | | Insurance | Per-company configurable policies with per-day/flat/% charge types |
+3 -4
View File
@@ -119,7 +119,7 @@ Additional options:
| Field | Options | | Field | Options |
|---|---| |---|---|
| Billing period | Monthly / Annual | | Billing period | Monthly / Annual |
| Primary provider | AmanPay / PayPal | | Subscription payment | Bank transfer or check |
Currency is fixed to **MAD**. Currency is fixed to **MAD**.
@@ -178,8 +178,7 @@ POST /api/v1/auth/company/signup
"preferredLanguage": "en | fr | ar", "preferredLanguage": "en | fr | ar",
"plan": "STARTER | GROWTH | PRO", "plan": "STARTER | GROWTH | PRO",
"billingPeriod": "MONTHLY | ANNUAL", "billingPeriod": "MONTHLY | ANNUAL",
"currency": "MAD", "currency": "MAD"
"paymentProvider": "AMANPAY | PAYPAL"
} }
``` ```
@@ -213,7 +212,7 @@ Navigate to `/en/light/sign-in`. Enter the owner email and the password set duri
After first sign-in, the user is directed to the **onboarding** page (`/onboarding`) where they can: After first sign-in, the user is directed to the **onboarding** page (`/onboarding`) where they can:
1. **Step 1 — Company profile**: Set display name, tagline, brand color, and public location. 1. **Step 1 — Company profile**: Set display name, tagline, brand color, and public location.
2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and carplace listing preference. 2. **Step 2 — Payments**: Review that rental payments are recorded as bank transfer or check, then set carplace listing preference.
3. **Step 3 — Completion**: Redirect to the dashboard home. 3. **Step 3 — Completion**: Redirect to the dashboard home.
### Subscription ### Subscription
+1 -5
View File
@@ -1,10 +1,6 @@
# Manual Subscription Payment Plan # Manual Subscription Payment Plan
**Methods:** Bank transfer and check **Status:** Historical design note. Stripe was later removed; subscription billing is bank transfer and check only.
**Existing online method:** Stripe remains available
**Scope:** Platform subscription payments, not rental-reservation/customer payments
**Status:** Implementation proposal based on the supplied code archive
**Revision:** 1.3 — adds company-driven Arabic, English, and French communication policy
## 1. Target outcome ## 1. Target outcome
@@ -75,7 +75,7 @@ Until then, the accurate label remains **capable beta / pre-scale**, not product
| Domain depth | Prisma: fleet, reservations, billing, payments, notifications, collections, admin | | Domain depth | Prisma: fleet, reservations, billing, payments, notifications, collections, admin |
| API shape | Express modular monolith | | API shape | Express modular monolith |
| Auth / tenancy | JWT actors, HttpOnly cookies, admin 2FA, company middleware, subscription gates | | Auth / tenancy | JWT actors, HttpOnly cookies, admin 2FA, company middleware, subscription gates |
| Payments foundation | Stripe / PayPal / AmanPay; webhook signature verify; `WebhookEvent` | | Payments foundation | Manual bank transfer and check for subscription and rental billing; `WebhookEvent` retained for other inbound webhooks |
| Upload validation | Magic bytes + MIME + size limits | | Upload validation | Magic bytes + MIME + size limits |
| Deploy intent | Compose + Traefik configs, backup/restore scripts present | | Deploy intent | Compose + Traefik configs, backup/restore scripts present |
@@ -127,7 +127,7 @@ flowchart LR
| Session cookies HttpOnly / Secure / SameSite | `sessionCookies.ts` | | Session cookies HttpOnly / Secure / SameSite | `sessionCookies.ts` |
| Hashed company API keys (legacy plaintext removed) | Hardening reports + schema | | Hashed company API keys (legacy plaintext removed) | Hardening reports + schema |
| Upload magic-byte validation | `http/upload` | | Upload magic-byte validation | `http/upload` |
| Payment webhook signatures | Stripe / PayPal / AmanPay paths | | Payment webhook signatures | Online payment webhooks removed; remaining webhooks still verify signatures |
| Forwarded-header scrubbing by default | `sanitizeForwardedHeaders` unless `TRUSTED_FORWARD_HEADERS=true` | | Forwarded-header scrubbing by default | `sanitizeForwardedHeaders` unless `TRUSTED_FORWARD_HEADERS=true` |
| Site payment redirect allowlist | `assertAllowedPaymentRedirect` in `site.service.ts` | | Site payment redirect allowlist | `assertAllowedPaymentRedirect` in `site.service.ts` |
-1
View File
@@ -9,7 +9,6 @@
|--------|---------|-----------------| |--------|---------|-----------------|
| `JWT_SECRET` | API token signing | Invalidates existing employee/admin/renter sessions | | `JWT_SECRET` | API token signing | Invalidates existing employee/admin/renter sessions |
| Company API keys | Partner/Carplace integrations | Per-company reissue via admin/API | | Company API keys | Partner/Carplace integrations | Per-company reissue via admin/API |
| Payment provider webhooks | Stripe/PayPal/AmanPay | Update dashboard + env together |
| DB / Redis passwords | Compose / managed services | Coordinated restart | | DB / Redis passwords | Compose / managed services | Coordinated restart |
| Object storage keys | S3/MinIO driver | Dual-key period preferred | | Object storage keys | S3/MinIO driver | Dual-key period preferred |
-26
View File
@@ -25,15 +25,6 @@
"engines": { "engines": {
"node": ">=20.0.0", "node": ">=20.0.0",
"npm": ">=10.0.0" "npm": ">=10.0.0"
},
"overrides": {
"@types/tough-cookie": "^4.0.5",
"js-beautify": {
"glob": "11.0.3"
},
"test-exclude": {
"glob": "11.0.3"
}
} }
}, },
"apps/admin": { "apps/admin": {
@@ -91,7 +82,6 @@
"react": "^18.3.1", "react": "^18.3.1",
"resend": "^3.2.0", "resend": "^3.2.0",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"stripe": "^22.3.2",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"turbo": "2.10.0", "turbo": "2.10.0",
"twilio": "^5.1.0", "twilio": "^5.1.0",
@@ -10385,22 +10375,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stripe": {
"version": "22.3.2",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz",
"integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/stubs": { "node_modules/stubs": {
"version": "3.0.0", "version": "3.0.0",
"license": "MIT", "license": "MIT",
@@ -0,0 +1,129 @@
-- Remap leftover Stripe rows before the enum values are dropped.
-- Compare as text so this stays valid if STRIPE is already absent from the enum.
UPDATE "billing_invoices"
SET "collectionMethod" = 'BANK_TRANSFER'
WHERE "collectionMethod"::text = 'STRIPE';
UPDATE "billing_invoices"
SET "paymentProvider" = 'MANUAL'
WHERE "paymentProvider"::text = 'STRIPE';
UPDATE "subscription_invoices"
SET "paymentProvider" = 'MANUAL'
WHERE "paymentProvider"::text = 'STRIPE';
UPDATE "rental_payments"
SET "paymentProvider" = 'MANUAL'
WHERE "paymentProvider"::text = 'STRIPE';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'brand_settings'
AND column_name = 'paymentMethodsEnabled'
) THEN
UPDATE "brand_settings"
SET "paymentMethodsEnabled" = ARRAY(
SELECT x
FROM unnest("paymentMethodsEnabled") AS x
WHERE x::text <> 'STRIPE'
)
WHERE EXISTS (
SELECT 1
FROM unnest("paymentMethodsEnabled") AS x
WHERE x::text = 'STRIPE'
);
END IF;
END $$;
DROP INDEX IF EXISTS "subscription_invoices_stripeCheckoutSessionId_key";
ALTER TABLE "subscription_invoices" DROP COLUMN IF EXISTS "stripeCheckoutSessionId";
DROP INDEX IF EXISTS "rental_payments_stripeCheckoutSessionId_key";
DROP INDEX IF EXISTS "rental_payments_stripePaymentIntentId_key";
ALTER TABLE "rental_payments" DROP COLUMN IF EXISTS "stripeCheckoutSessionId";
ALTER TABLE "rental_payments" DROP COLUMN IF EXISTS "stripePaymentIntentId";
-- Recreate SubscriptionCollectionMethod without STRIPE (DROP VALUE is not allowed in a Prisma transaction).
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'SubscriptionCollectionMethod'
AND e.enumlabel = 'STRIPE'
) THEN
DROP TYPE IF EXISTS "SubscriptionCollectionMethod_new";
CREATE TYPE "SubscriptionCollectionMethod_new" AS ENUM ('BANK_TRANSFER', 'CHECK');
ALTER TABLE "billing_invoices" ALTER COLUMN "collectionMethod" DROP DEFAULT;
ALTER TABLE "billing_invoices"
ALTER COLUMN "collectionMethod" TYPE "SubscriptionCollectionMethod_new"
USING ("collectionMethod"::text::"SubscriptionCollectionMethod_new");
DROP TYPE "SubscriptionCollectionMethod";
ALTER TYPE "SubscriptionCollectionMethod_new" RENAME TO "SubscriptionCollectionMethod";
ALTER TABLE "billing_invoices" ALTER COLUMN "collectionMethod" SET DEFAULT 'BANK_TRANSFER';
END IF;
END $$;
-- Recreate PaymentProvider without STRIPE.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'PaymentProvider'
AND e.enumlabel = 'STRIPE'
) THEN
DROP TYPE IF EXISTS "PaymentProvider_new";
CREATE TYPE "PaymentProvider_new" AS ENUM ('AMANPAY', 'PAYPAL', 'MANUAL');
ALTER TABLE "billing_invoices" ALTER COLUMN "paymentProvider" DROP DEFAULT;
ALTER TABLE "subscription_invoices" ALTER COLUMN "paymentProvider" DROP DEFAULT;
ALTER TABLE "rental_payments" ALTER COLUMN "paymentProvider" DROP DEFAULT;
ALTER TABLE "billing_invoices"
ALTER COLUMN "paymentProvider" TYPE "PaymentProvider_new"
USING ("paymentProvider"::text::"PaymentProvider_new");
ALTER TABLE "subscription_invoices"
ALTER COLUMN "paymentProvider" TYPE "PaymentProvider_new"
USING ("paymentProvider"::text::"PaymentProvider_new");
ALTER TABLE "rental_payments"
ALTER COLUMN "paymentProvider" TYPE "PaymentProvider_new"
USING ("paymentProvider"::text::"PaymentProvider_new");
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'brand_settings'
AND column_name = 'paymentMethodsEnabled'
) THEN
ALTER TABLE "brand_settings"
ALTER COLUMN "paymentMethodsEnabled" TYPE "PaymentProvider_new"[]
USING (
COALESCE(
ARRAY(
SELECT x::text::"PaymentProvider_new"
FROM unnest("paymentMethodsEnabled") AS x
WHERE x::text <> 'STRIPE'
),
ARRAY[]::"PaymentProvider_new"[]
)
);
END IF;
DROP TYPE "PaymentProvider";
ALTER TYPE "PaymentProvider_new" RENAME TO "PaymentProvider";
ALTER TABLE "subscription_invoices" ALTER COLUMN "paymentProvider" SET DEFAULT 'AMANPAY';
END IF;
END $$;

Some files were not shown because too many files have changed in this diff Show More