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 {
|
function sanitizePhone(raw: string): string {
|
||||||
let out = ''
|
let out = ''
|
||||||
for (const ch of raw) {
|
for (const ch of raw) {
|
||||||
if (/[\d\s+]/.test(ch)) out += ch
|
if (/[\d\s+().-]/.test(ch)) out += ch
|
||||||
}
|
}
|
||||||
return out.trim()
|
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()
|
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', () => {
|
it('limits license approvals to explicit approve or deny decisions', () => {
|
||||||
expect(approveLicenseSchema.parse({ decision: 'APPROVE', note: 'Verified manually' })).toEqual({
|
expect(approveLicenseSchema.parse({ decision: 'APPROVE', note: 'Verified manually' })).toEqual({
|
||||||
decision: 'APPROVE',
|
decision: 'APPROVE',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod'
|
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({
|
export const paginationSchema = z.object({
|
||||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||||
@@ -10,7 +10,7 @@ export const customerSchema = z.object({
|
|||||||
firstName: textField('name'),
|
firstName: textField('name'),
|
||||||
lastName: textField('name'),
|
lastName: textField('name'),
|
||||||
email: emailField(),
|
email: emailField(),
|
||||||
phone: optionalPhoneField(),
|
phone: optionalContactPhoneField(),
|
||||||
driverLicense: optionalUpperField('driverLicenseNumber'),
|
driverLicense: optionalUpperField('driverLicenseNumber'),
|
||||||
dateOfBirth: z.string().datetime().optional(),
|
dateOfBirth: z.string().datetime().optional(),
|
||||||
nationality: optionalTextField('nationality'),
|
nationality: optionalTextField('nationality'),
|
||||||
|
|||||||
@@ -26,13 +26,31 @@ describe('operational schemas', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('validates subscriptions, team roles, notifications, and analytics query defaults', () => {
|
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(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' })
|
||||||
expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' })
|
expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' })
|
||||||
expect(inviteSchema.safeParse({ firstName: 'A', lastName: 'B', email: 'agent@example.test', role: 'OWNER' }).success).toBe(false)
|
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(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(unreadQuerySchema.parse({ unread: 'true' })).toEqual({ unread: 'true' })
|
||||||
expect(historyQuerySchema.parse({ limit: '100' })).toEqual({ limit: 100 })
|
expect(historyQuerySchema.parse({ limit: '100' })).toEqual({ limit: 100 })
|
||||||
expect(historyQuerySchema.safeParse({ limit: '501' }).success).toBe(false)
|
expect(historyQuerySchema.safeParse({ limit: '501' }).success).toBe(false)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export function findByPaypal(captureId: string) {
|
|||||||
return prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
|
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) {
|
export function findByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||||
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
|
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' } })
|
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) {
|
export function incrementReservationPaid(reservationId: string, _amount: number) {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const reservation = await tx.reservation.findUniqueOrThrow({
|
const reservation = await tx.reservation.findUniqueOrThrow({
|
||||||
@@ -76,7 +98,7 @@ export function incrementReservationPaid(reservationId: string, _amount: number)
|
|||||||
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; 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 })
|
return prisma.rentalPayment.create({ data: data as any })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ok } from '../../http/respond'
|
|||||||
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
|
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
|
||||||
import * as amanpay from '../../services/amanpayService'
|
import * as amanpay from '../../services/amanpayService'
|
||||||
import * as paypal from '../../services/paypalService'
|
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 { 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) }
|
} 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 ────────────────────────────────────────────
|
// ─── Authenticated ────────────────────────────────────────────
|
||||||
|
|
||||||
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
|
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('payment schemas edge cases', () => {
|
|||||||
type: 'CHARGE',
|
type: 'CHARGE',
|
||||||
currency: 'MAD',
|
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(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.parse({ amount: 500, paymentMethod: 'CASH' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' })
|
||||||
expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false)
|
expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const chargeSchema = z.object({
|
export const chargeSchema = z.object({
|
||||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
provider: z.enum(['AMANPAY', 'PAYPAL', 'STRIPE']),
|
||||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||||
currency: z.literal('MAD').default('MAD'),
|
currency: z.literal('MAD').default('MAD'),
|
||||||
successUrl: z.string().url(),
|
successUrl: z.string().url(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ConflictError, ValidationError } from '../../http/errors'
|
import { ConflictError, ValidationError } from '../../http/errors'
|
||||||
import * as amanpay from '../../services/amanpayService'
|
import * as amanpay from '../../services/amanpayService'
|
||||||
import * as paypal from '../../services/paypalService'
|
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 { 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)) {
|
export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
|
||||||
return processWebhookOnce({
|
return processWebhookOnce({
|
||||||
provider: 'amanpay:payments',
|
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: {
|
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
|
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||||
}) {
|
}) {
|
||||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||||
@@ -99,6 +128,7 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
|||||||
let checkoutUrl: string
|
let checkoutUrl: string
|
||||||
let amanpayTransactionId: string | null = null
|
let amanpayTransactionId: string | null = null
|
||||||
let paypalCaptureId: string | null = null
|
let paypalCaptureId: string | null = null
|
||||||
|
let stripeCheckoutSessionId: string | null = null
|
||||||
|
|
||||||
if (body.provider === 'AMANPAY') {
|
if (body.provider === 'AMANPAY') {
|
||||||
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured')
|
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
|
checkoutUrl = result.checkoutUrl
|
||||||
amanpayTransactionId = result.transactionId
|
amanpayTransactionId = result.transactionId
|
||||||
} else {
|
} else if (body.provider === 'PAYPAL') {
|
||||||
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured')
|
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 })
|
const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl })
|
||||||
checkoutUrl = result.approveUrl
|
checkoutUrl = result.approveUrl
|
||||||
paypalCaptureId = result.orderId
|
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 }
|
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) {
|
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) 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
|
const refundAmount = amount ?? payment.amount
|
||||||
if (payment.paymentProvider === 'AMANPAY') {
|
if (payment.paymentProvider === 'AMANPAY') {
|
||||||
@@ -208,6 +254,9 @@ export async function refundPayment(reservationId: string, paymentId: string, co
|
|||||||
} else if (payment.paymentProvider === 'PAYPAL') {
|
} else if (payment.paymentProvider === 'PAYPAL') {
|
||||||
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
|
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
|
||||||
await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason)
|
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 {
|
} else {
|
||||||
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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('reservation schemas edge cases', () => {
|
|||||||
|
|
||||||
it('validates additional drivers and inspection damage defaults', () => {
|
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' })).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: '', lastName: 'B', driverLicense: 'DL-1' }).success).toBe(false)
|
||||||
expect(additionalDriverSchema.safeParse({ firstName: 'A', lastName: 'B', driverLicense: 'DL-1', licenseExpiry: 'tomorrow' }).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 { 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({
|
export const additionalDriverSchema = z.object({
|
||||||
firstName: textField('name'),
|
firstName: textField('name'),
|
||||||
lastName: textField('name'),
|
lastName: textField('name'),
|
||||||
email: optionalEmailField(),
|
email: optionalEmailField(),
|
||||||
phone: optionalPhoneField(),
|
phone: optionalContactPhoneField(),
|
||||||
driverLicense: upperField('driverLicenseNumber'),
|
driverLicense: upperField('driverLicenseNumber'),
|
||||||
licenseExpiry: z.string().datetime().optional(),
|
licenseExpiry: z.string().datetime().optional(),
|
||||||
licenseIssuedAt: z.string().datetime().optional(),
|
licenseIssuedAt: z.string().datetime().optional(),
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ vi.mock('../../services/paypalService', () => ({
|
|||||||
captureOrder: 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'
|
||||||
@@ -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 () => {
|
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')
|
const providers = await request(app).get('/api/v1/subscriptions/providers')
|
||||||
expect(providers.status).toBe(200)
|
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')
|
const plans = await request(app).get('/api/v1/subscriptions/plans')
|
||||||
expect(plans.status).toBe(200)
|
expect(plans.status).toBe(200)
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
exclude: ['src/tests/integration/**'],
|
exclude: ['src/tests/integration/**'],
|
||||||
setupFiles: [],
|
setupFiles: [],
|
||||||
|
fileParallelism: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1431,6 +1431,8 @@ model RentalPayment {
|
|||||||
paymentProvider PaymentProvider
|
paymentProvider PaymentProvider
|
||||||
amanpayTransactionId String? @unique
|
amanpayTransactionId String? @unique
|
||||||
paypalCaptureId String? @unique
|
paypalCaptureId String? @unique
|
||||||
|
stripeCheckoutSessionId String? @unique
|
||||||
|
stripePaymentIntentId String? @unique
|
||||||
paymentMethod String?
|
paymentMethod String?
|
||||||
reference String?
|
reference String?
|
||||||
note String?
|
note String?
|
||||||
|
|||||||
Reference in New Issue
Block a user