feat(bookings): add transaction document uploads for bank payments

Add payment method selection (Cash/Bank) to booking details
Support uploading, downloading, and deleting transaction documents
Update database schema and API endpoints to handle file storage
This commit is contained in:
2026-05-09 12:56:32 +08:00
parent 3710216346
commit b64a2b4c1c
14 changed files with 888 additions and 31 deletions

View File

@@ -7,9 +7,11 @@ import {
getBookingInventorySummary,
getActiveBookingModeOptionByCode,
getActiveTicketCatalogItemByCode,
clearBookingTransactionDocument,
updateBookingDetails
} from '../../utils/booking-repository'
import { parseUpdateBookingDetailsInput } from '../../utils/bookings'
import { deleteTransactionDocument } from '../../utils/transaction-documents'
import { getRequiredRouteParam, httpError } from '../../utils/http'
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
@@ -21,6 +23,7 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
bookingMode?: string | null
quantity?: number
ticketType?: string
paymentMethod?: string | null
remark?: string | null
}>(event)
@@ -74,6 +77,7 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
ticketType: ticket.value,
unitPrice: ticket.price,
totalPrice,
paymentMethod: input.paymentMethod,
remark: input.remark,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
@@ -82,6 +86,21 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
httpError(404, 'Booking not found')
}
if (input.paymentMethod === 'cash') {
const cleared = await clearBookingTransactionDocument({
bookingId,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
if (cleared) {
await deleteTransactionDocument(cleared.previousStorageName)
return {
booking: cleared.booking
}
}
}
return {
booking
}

View File

@@ -0,0 +1,26 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { requireAuth } from '../../../utils/auth'
import { clearBookingTransactionDocument } from '../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../utils/http'
import { deleteTransactionDocument } from '../../../utils/transaction-documents'
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
const auth = await requireAuth(event)
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
const result = await clearBookingTransactionDocument({
bookingId,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
if (!result) {
httpError(404, 'Booking not found')
}
await deleteTransactionDocument(result.previousStorageName)
return {
booking: result.booking
}
})

View File

@@ -0,0 +1,35 @@
import { sendStream, setHeader } from 'h3'
import { requireAuth } from '../../../utils/auth'
import { getBookingTransactionDocument } from '../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../utils/http'
import {
getSafeDownloadName,
getTransactionDocumentFile
} from '../../../utils/transaction-documents'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
const document = await getBookingTransactionDocument({
bookingId,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
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,86 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { getHeader, readMultipartFormData } from 'h3'
import { requireAuth } from '../../../utils/auth'
import {
getBookingById,
replaceBookingTransactionDocument
} 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 auth = await requireAuth(event)
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
const accessScope = auth.user.role === 'super_admin'
? undefined
: { personInChargeId: auth.user.id }
const booking = await getBookingById(bookingId, accessScope)
if (!booking) {
httpError(404, 'Booking not found')
}
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 replaceBookingTransactionDocument({
bookingId,
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id,
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
}
})