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
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:
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,5 +7,6 @@ export default defineConfig({
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['src/tests/integration/**'],
|
||||
setupFiles: [],
|
||||
fileParallelism: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user