diff --git a/apps/api/src/lib/zodValidation.ts b/apps/api/src/lib/zodValidation.ts index 0141a69..ba6abe4 100644 --- a/apps/api/src/lib/zodValidation.ts +++ b/apps/api/src/lib/zodValidation.ts @@ -16,7 +16,7 @@ const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/ function sanitizePhone(raw: string): string { let out = '' for (const ch of raw) { - if (/[\d\s+]/.test(ch)) out += ch + if (/[\d\s+().-]/.test(ch)) out += ch } return out.trim() } @@ -153,3 +153,16 @@ export function optionalPhoneField() { ) ) } + +export function optionalContactPhoneField() { + return z + .string() + .optional() + .transform((val) => { + if (val === undefined || val === null || val.trim() === '') return undefined + return sanitizePhone(val) + }) + .pipe( + z.string().max(30, { message: 'Maximum 30 characters allowed' }).optional() + ) +} diff --git a/apps/api/src/modules/customers/customer.schemas.contract.test.ts b/apps/api/src/modules/customers/customer.schemas.contract.test.ts index 7224b39..e90b245 100644 --- a/apps/api/src/modules/customers/customer.schemas.contract.test.ts +++ b/apps/api/src/modules/customers/customer.schemas.contract.test.ts @@ -39,6 +39,17 @@ describe('customer schema contracts', () => { expect(() => customerSchema.parse({ firstName: 'Aya', lastName: 'Haddad', email: 'aya@example.test', notes: 'x'.repeat(2001) })).toThrow() }) + it('accepts international customer contact phone numbers from booking forms', () => { + const parsed = customerSchema.parse({ + firstName: 'Aya', + lastName: 'Haddad', + email: 'aya@example.test', + phone: '+1 (555) 123-4567', + }) + + expect(parsed.phone).toBe('+1 (555) 123-4567') + }) + it('limits license approvals to explicit approve or deny decisions', () => { expect(approveLicenseSchema.parse({ decision: 'APPROVE', note: 'Verified manually' })).toEqual({ decision: 'APPROVE', diff --git a/apps/api/src/modules/customers/customer.schemas.ts b/apps/api/src/modules/customers/customer.schemas.ts index f1a673c..570a834 100644 --- a/apps/api/src/modules/customers/customer.schemas.ts +++ b/apps/api/src/modules/customers/customer.schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { textField, optionalTextField, emailField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation' +import { textField, optionalTextField, emailField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation' export const paginationSchema = z.object({ page: z.coerce.number().int().min(1).max(10000).default(1), @@ -10,7 +10,7 @@ export const customerSchema = z.object({ firstName: textField('name'), lastName: textField('name'), email: emailField(), - phone: optionalPhoneField(), + phone: optionalContactPhoneField(), driverLicense: optionalUpperField('driverLicenseNumber'), dateOfBirth: z.string().datetime().optional(), nationality: optionalTextField('nationality'), diff --git a/apps/api/src/modules/operations.schemas.test.ts b/apps/api/src/modules/operations.schemas.test.ts index 6c7beaf..88d8655 100644 --- a/apps/api/src/modules/operations.schemas.test.ts +++ b/apps/api/src/modules/operations.schemas.test.ts @@ -26,13 +26,31 @@ describe('operational schemas', () => { }) it('validates subscriptions, team roles, notifications, and analytics query defaults', () => { - expect(checkoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true) + const checkoutResult = checkoutSchema.safeParse({ + plan: 'PRO', + billingPeriod: 'MONTHLY', + currency: 'MAD', + provider: 'STRIPE', + successUrl: 'https://ok.example.test', + failureUrl: 'https://fail.example.test', + }) + expect(checkoutResult.success).toBe(true) + expect(checkoutSchema.safeParse({ + plan: 'PRO', + billingPeriod: 'MONTHLY', + currency: 'MAD', + provider: 'PAYPAL', + successUrl: 'https://ok.example.test', + failureUrl: 'https://fail.example.test', + }).success).toBe(false) expect(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' }) expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' }) expect(inviteSchema.safeParse({ firstName: 'A', lastName: 'B', email: 'agent@example.test', role: 'OWNER' }).success).toBe(false) expect(roleSchema.safeParse({ role: 'AGENT' }).success).toBe(true) - expect(preferencesSchema.safeParse([{ notificationType: 'BOOKING', channel: 'EMAIL', enabled: true }]).success).toBe(true) + const preferencesResult = preferencesSchema.safeParse([{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: true }]) + expect(preferencesResult.success).toBe(true) + expect(preferencesSchema.safeParse([{ notificationType: 'BOOKING', channel: 'EMAIL', enabled: true }]).success).toBe(false) expect(unreadQuerySchema.parse({ unread: 'true' })).toEqual({ unread: 'true' }) expect(historyQuerySchema.parse({ limit: '100' })).toEqual({ limit: 100 }) expect(historyQuerySchema.safeParse({ limit: '501' }).success).toBe(false) diff --git a/apps/api/src/modules/payments/payment.repo.ts b/apps/api/src/modules/payments/payment.repo.ts index b8eb819..633ea9c 100644 --- a/apps/api/src/modules/payments/payment.repo.ts +++ b/apps/api/src/modules/payments/payment.repo.ts @@ -21,6 +21,10 @@ 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 } }) } @@ -51,6 +55,24 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal 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({ @@ -76,7 +98,7 @@ export function incrementReservationPaid(reservationId: string, _amount: number) export function createPayment(data: { companyId: string; reservationId: string; amount: number; currency: string status: string; type: string; paymentProvider: string - amanpayTransactionId?: string | null; paypalCaptureId?: string | null; paymentMethod?: string; paidAt?: Date + amanpayTransactionId?: string | null; paypalCaptureId?: string | null; stripeCheckoutSessionId?: string | null; paymentMethod?: string; paidAt?: Date }) { return prisma.rentalPayment.create({ data: data as any }) } diff --git a/apps/api/src/modules/payments/payment.routes.ts b/apps/api/src/modules/payments/payment.routes.ts index 9fb2d2b..7913b13 100644 --- a/apps/api/src/modules/payments/payment.routes.ts +++ b/apps/api/src/modules/payments/payment.routes.ts @@ -8,6 +8,7 @@ import { ok } from '../../http/respond' import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks' import * as amanpay from '../../services/amanpayService' import * as paypal from '../../services/paypalService' +import * as stripe from '../../services/stripeService' import * as service from './payment.service' import { chargeSchema, manualPaymentSchema, refundSchema, capturePaypalSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas' @@ -39,6 +40,22 @@ router.post('/webhooks/paypal', async (req, res, next) => { } 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) diff --git a/apps/api/src/modules/payments/payment.schemas.edge.test.ts b/apps/api/src/modules/payments/payment.schemas.edge.test.ts index 8986fb0..ee1b83a 100644 --- a/apps/api/src/modules/payments/payment.schemas.edge.test.ts +++ b/apps/api/src/modules/payments/payment.schemas.edge.test.ts @@ -8,7 +8,7 @@ describe('payment schemas edge cases', () => { type: 'CHARGE', currency: 'MAD', }) - expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false) + expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true) 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) diff --git a/apps/api/src/modules/payments/payment.schemas.ts b/apps/api/src/modules/payments/payment.schemas.ts index 6fe5edf..bef4dd3 100644 --- a/apps/api/src/modules/payments/payment.schemas.ts +++ b/apps/api/src/modules/payments/payment.schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod' export const chargeSchema = z.object({ - provider: z.enum(['AMANPAY', 'PAYPAL']), + provider: z.enum(['AMANPAY', 'PAYPAL', 'STRIPE']), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), currency: z.literal('MAD').default('MAD'), successUrl: z.string().url(), diff --git a/apps/api/src/modules/payments/payment.service.ts b/apps/api/src/modules/payments/payment.service.ts index 31ff45f..6298e89 100644 --- a/apps/api/src/modules/payments/payment.service.ts +++ b/apps/api/src/modules/payments/payment.service.ts @@ -1,6 +1,7 @@ import { ConflictError, ValidationError } from '../../http/errors' import * as amanpay from '../../services/amanpayService' import * as paypal from '../../services/paypalService' +import * as stripe from '../../services/stripeService' import * as repo from './payment.repo' import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency' @@ -55,6 +56,24 @@ async function applyPaypalWebhook(event: any) { } } +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', @@ -75,8 +94,18 @@ export async function handlePaypalWebhook(event: any, rawBody: string | Buffer = }) } +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' + provider: 'AMANPAY' | 'PAYPAL' | 'STRIPE'; type: 'CHARGE' | 'DEPOSIT' currency: 'MAD'; successUrl: string; failureUrl: string }) { const reservation = await repo.findReservationOrThrow(reservationId, companyId) @@ -99,6 +128,7 @@ export async function initCharge(reservationId: string, companyId: string, body: let checkoutUrl: string let amanpayTransactionId: string | null = null let paypalCaptureId: string | null = null + let stripeCheckoutSessionId: string | null = null if (body.provider === 'AMANPAY') { if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured') @@ -111,14 +141,30 @@ export async function initCharge(reservationId: string, companyId: string, body: }) checkoutUrl = result.checkoutUrl amanpayTransactionId = result.transactionId - } else { + } 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 { + if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured') + const result = await stripe.createCheckoutSession({ + amount, + currency: body.currency, + orderId, + description, + customerEmail: reservation.customer.email, + successUrl: body.successUrl, + cancelUrl: body.failureUrl, + reservationId, + companyId, + type: body.type, + }) + checkoutUrl = result.checkoutUrl + stripeCheckoutSessionId = result.sessionId } - const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId }) + const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId }) return { payment, checkoutUrl } } @@ -199,7 +245,7 @@ export async function recordManualPayment(reservationId: string, companyId: stri export async function refundPayment(reservationId: string, paymentId: string, companyId: string, amount?: number, reason?: string) { const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId) if (payment.status !== 'SUCCEEDED') throw new ValidationError('Only succeeded payments can be refunded') - if (!payment.amanpayTransactionId && !payment.paypalCaptureId) throw new ValidationError('Manual payments must be refunded outside the online gateway flow') + if (!payment.amanpayTransactionId && !payment.paypalCaptureId && !payment.stripePaymentIntentId) throw new ValidationError('Manual payments must be refunded outside the online gateway flow') const refundAmount = amount ?? payment.amount if (payment.paymentProvider === 'AMANPAY') { @@ -208,6 +254,9 @@ export async function refundPayment(reservationId: string, paymentId: string, co } 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') } diff --git a/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts b/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts index 3b348ac..afc6fe9 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.edge.test.ts @@ -18,6 +18,7 @@ describe('reservation schemas edge cases', () => { it('validates additional drivers and inspection damage defaults', () => { expect(additionalDriverSchema.parse({ firstName: 'A', lastName: 'B', driverLicense: 'DL-1' })).toMatchObject({ driverLicense: 'DL-1' }) + expect(additionalDriverSchema.parse({ firstName: 'A', lastName: 'B', driverLicense: 'DL-1', phone: '+1 (555) 123-4567' })).toMatchObject({ phone: '+1 (555) 123-4567' }) expect(additionalDriverSchema.safeParse({ firstName: '', lastName: 'B', driverLicense: 'DL-1' }).success).toBe(false) expect(additionalDriverSchema.safeParse({ firstName: 'A', lastName: 'B', driverLicense: 'DL-1', licenseExpiry: 'tomorrow' }).success).toBe(false) diff --git a/apps/api/src/modules/reservations/reservation.schemas.ts b/apps/api/src/modules/reservations/reservation.schemas.ts index c6530dc..6e63060 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.ts @@ -1,11 +1,11 @@ import { z } from 'zod' -import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalPhoneField } from '../../lib/zodValidation' +import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation' export const additionalDriverSchema = z.object({ firstName: textField('name'), lastName: textField('name'), email: optionalEmailField(), - phone: optionalPhoneField(), + phone: optionalContactPhoneField(), driverLicense: upperField('driverLicenseNumber'), licenseExpiry: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(), diff --git a/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts b/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts index 668ba12..a20624d 100644 --- a/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts +++ b/apps/api/src/tests/e2e/subscriptions-public.e2e.test.ts @@ -30,6 +30,16 @@ vi.mock('../../services/paypalService', () => ({ captureOrder: vi.fn(), })) +vi.mock('../../services/stripeService', () => ({ + getConfigurationStatus: vi.fn().mockReturnValue({ + configured: false, + problems: ['STRIPE_API_KEY is missing', 'STRIPE_WEBHOOK_SECRET is missing'], + }), + isConfigured: vi.fn().mockReturnValue(false), + constructWebhookEvent: vi.fn(), + createCheckoutSession: vi.fn(), +})) + import request from 'supertest' import { describe, expect, it } from 'vitest' import { createApp } from '../../app' @@ -40,7 +50,12 @@ describe('subscriptions public e2e smoke', () => { it('lets an anonymous client inspect providers, plans, and features without crossing authenticated subscription routes', async () => { const providers = await request(app).get('/api/v1/subscriptions/providers') expect(providers.status).toBe(200) - expect(providers.body).toEqual({ data: { amanpay: false, paypal: false } }) + expect(providers.body).toEqual({ + data: { + stripe: false, + stripeProblems: ['STRIPE_API_KEY is missing', 'STRIPE_WEBHOOK_SECRET is missing'], + }, + }) const plans = await request(app).get('/api/v1/subscriptions/plans') expect(plans.status).toBe(200) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 0da2fd6..24af89b 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ include: ['src/**/*.test.ts'], exclude: ['src/tests/integration/**'], setupFiles: [], + fileParallelism: false, }, }) diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f7c6312..0d5c6c2 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1431,6 +1431,8 @@ model RentalPayment { paymentProvider PaymentProvider amanpayTransactionId String? @unique paypalCaptureId String? @unique + stripeCheckoutSessionId String? @unique + stripePaymentIntentId String? @unique paymentMethod String? reference String? note String?