fix bug 14
Build & Push / Pipeline Tests (push) Successful in 1m52s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 6m34s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 50s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m6s

This commit is contained in:
root
2026-07-26 01:39:11 -04:00
parent 2b422ca197
commit 8046a1d447
14 changed files with 164 additions and 15 deletions
@@ -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',
@@ -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'),
@@ -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)
+23 -1
View File
@@ -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 })
}
@@ -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)
@@ -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)
@@ -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(),
@@ -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')
}
@@ -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)
@@ -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(),