feat(bookings): add payment and document upload to confirmation page

Allow users to select payment method and upload receipts before confirming.
Add public API endpoints for payment updates and document management.
This commit is contained in:
2026-05-09 13:15:45 +08:00
parent b64a2b4c1c
commit a56a6706b0
11 changed files with 746 additions and 18 deletions

View File

@@ -5,7 +5,7 @@ import { getRequiredRouteParam, httpError } from '../../../../utils/http'
export default defineEventHandler(async (event): Promise<CancelBookingConfirmationResponse> => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const existingBooking = await getBookingByConfirmationToken(token)
const existingBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
if (!existingBooking) {
httpError(404, 'Booking not found')
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event): Promise<CancelBookingConfirmati
}
return {
booking,
booking: await getBookingByConfirmationToken(token, { includeTransactionDocument: true }) || booking,
alreadyPending: false
}
})

View File

@@ -6,7 +6,7 @@ import { sendBookingTicketReceiptViaWhatsApp } from '../../../../utils/whatsapp'
export default defineEventHandler(async (event): Promise<ConfirmBookingResponse> => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const existingBooking = await getBookingByConfirmationToken(token)
const existingBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
if (!existingBooking) {
httpError(404, 'Booking not found')
@@ -39,9 +39,10 @@ export default defineEventHandler(async (event): Promise<ConfirmBookingResponse>
}
const ticketReceiptWhatsApp = await sendBookingTicketReceiptViaWhatsApp(event, booking)
const refreshedBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
return {
booking,
booking: refreshedBooking || booking,
alreadyConfirmed: false,
ticketReceiptWhatsApp
}

View File

@@ -0,0 +1,51 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import {
clearBookingTransactionDocumentByConfirmationToken,
getBookingByConfirmationToken,
updateBookingPaymentMethodByConfirmationToken
} from '../../../../utils/booking-repository'
import { parsePaymentMethodInput } from '../../../../utils/bookings'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import { deleteTransactionDocument } from '../../../../utils/transaction-documents'
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const existingBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
if (!existingBooking) {
httpError(404, 'Booking not found')
}
if (existingBooking.status !== 'pending') {
httpError(409, 'Payment details can only be changed before confirmation')
}
const body = await readBody<{ paymentMethod?: string | null }>(event)
const input = parsePaymentMethodInput(body)
const booking = await updateBookingPaymentMethodByConfirmationToken({
confirmationToken: token,
paymentMethod: input.paymentMethod
})
if (!booking) {
httpError(404, 'Booking not found')
}
if (input.paymentMethod === 'cash') {
const cleared = await clearBookingTransactionDocumentByConfirmationToken(token)
if (cleared) {
await deleteTransactionDocument(cleared.previousStorageName)
return {
booking: cleared.booking
}
}
}
return {
booking
}
})

View File

@@ -0,0 +1,33 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import {
clearBookingTransactionDocumentByConfirmationToken,
getBookingByConfirmationToken
} from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import { deleteTransactionDocument } from '../../../../utils/transaction-documents'
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const booking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
if (!booking) {
httpError(404, 'Booking not found')
}
if (booking.status !== 'pending') {
httpError(409, 'Transaction document can only be changed before confirmation')
}
const result = await clearBookingTransactionDocumentByConfirmationToken(token)
if (!result) {
httpError(404, 'Booking not found')
}
await deleteTransactionDocument(result.previousStorageName)
return {
booking: result.booking
}
})

View File

@@ -0,0 +1,29 @@
import { sendStream, setHeader } from 'h3'
import { getBookingTransactionDocumentByConfirmationToken } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import {
getSafeDownloadName,
getTransactionDocumentFile
} from '../../../../utils/transaction-documents'
export default defineEventHandler(async (event) => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const document = await getBookingTransactionDocumentByConfirmationToken(token)
if (!document) {
httpError(404, 'Transaction document not found')
}
const file = await getTransactionDocumentFile(document.storageName)
const downloadName = getSafeDownloadName(document.originalName)
setHeader(event, 'content-type', document.mimeType)
setHeader(event, 'content-length', String(file.size))
setHeader(event, 'content-disposition', `attachment; filename="${downloadName}"`)
setHeader(event, 'x-content-type-options', 'nosniff')
setHeader(event, 'cache-control', 'private, no-store')
setHeader(event, 'content-security-policy', 'sandbox')
return sendStream(event, file.stream)
})

View File

@@ -0,0 +1,83 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { getHeader, readMultipartFormData } from 'h3'
import {
getBookingByConfirmationToken,
replaceBookingTransactionDocumentByConfirmationToken
} from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import {
deleteTransactionDocument,
saveTransactionDocument,
TRANSACTION_DOCUMENT_MAX_SIZE,
validateTransactionDocumentUpload
} from '../../../../utils/transaction-documents'
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const booking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
if (!booking) {
httpError(404, 'Booking not found')
}
if (booking.status !== 'pending') {
httpError(409, 'Transaction document can only be changed before confirmation')
}
if (booking.paymentMethod !== 'bank') {
httpError(400, 'Transaction document can only be uploaded for Bank payments')
}
const contentType = String(getHeader(event, 'content-type') || '').toLowerCase()
if (!contentType.startsWith('multipart/form-data;')) {
httpError(400, 'Transaction document upload must use multipart form data')
}
const contentLength = Number(getHeader(event, 'content-length') || 0)
if (contentLength > TRANSACTION_DOCUMENT_MAX_SIZE + 1024 * 1024) {
httpError(413, 'Transaction document must be 10MB or smaller')
}
const formData = await readMultipartFormData(event)
const filePart = formData?.find((part) => part.name === 'document' && part.filename)
if (!filePart) {
httpError(400, 'Transaction document is required')
}
const upload = validateTransactionDocumentUpload({
data: filePart.data,
filename: filePart.filename,
contentType: filePart.type
})
const storageName = await saveTransactionDocument(filePart.data, upload.fileType)
try {
const result = await replaceBookingTransactionDocumentByConfirmationToken({
confirmationToken: token,
originalName: upload.originalName,
storageName,
mimeType: upload.fileType.mimeType,
size: filePart.data.length
})
if (!result) {
await deleteTransactionDocument(storageName)
httpError(404, 'Booking not found')
}
await deleteTransactionDocument(result.previousStorageName)
return {
booking: result.booking
}
} catch (error) {
await deleteTransactionDocument(storageName)
throw error
}
})