@@ -390,7 +415,7 @@
v-model="detailsForm.quantity"
:min="1"
:step="1"
- :disabled="savingDetails"
+ :disabled="savingDetails || deletingTransactionDocument"
size="lg"
class="w-full"
/>
@@ -412,6 +437,76 @@
/>
+
+
+
+
+
+
+
+
+ {{ detailsBooking.transactionDocument.originalName }}
+
+
+ {{ formatFileSize(detailsBooking.transactionDocument.size) }} - {{ formatDateTime(detailsBooking.transactionDocument.uploadedAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedTransactionDocumentName || 'PDF, JPG, PNG, WEBP, HEIC, or HEIF - max 10MB.' }}
+
+
+
+
Updated total
@@ -433,7 +528,7 @@
:maxlength="remarkLimit"
autoresize
class="w-full"
- placeholder="Internal handling note"
+ placeholder="Handling note"
/>
{{ detailsForm.remark.length }}/{{ remarkLimit }}
@@ -449,7 +544,7 @@
color="neutral"
variant="ghost"
class="justify-center"
- :disabled="savingDetails"
+ :disabled="savingDetails || deletingTransactionDocument"
@click="closeBookingEditor"
/>
@@ -487,7 +583,7 @@
:maxlength="remarkLimit"
autoresize
class="w-full"
- placeholder="Internal handling note"
+ placeholder="Handling note"
/>
{{ remarkForm.remark.length }}/{{ remarkLimit }}
@@ -580,6 +676,7 @@ import type {
BookingMode,
CancelBookingConfirmationResponse,
DeleteBookingResponse,
+ PaymentMethod,
PublicBooking,
PublicBookingConfig,
TicketCatalogItem,
@@ -626,6 +723,7 @@ const savingCapacity = ref(false)
const savingDetails = ref(false)
const savingRemark = ref(false)
const savingTransfer = ref(false)
+const deletingTransactionDocument = ref(false)
const cancellingBookingId = ref(null)
const deletingBookingId = ref(null)
const detailsModalOpen = ref(false)
@@ -654,8 +752,11 @@ const detailsForm = reactive({
bookingMode: '' as BookingMode,
quantity: 1,
ticketType: '' as TicketType,
+ paymentMethod: 'cash' as PaymentMethod,
remark: ''
})
+const detailsTransactionDocumentFile = ref(null)
+const transactionDocumentInputKey = ref(0)
const remarkForm = reactive({
remark: ''
})
@@ -663,6 +764,19 @@ const transferForm = reactive({
personInChargeId: ''
})
const remarkLimit = 1000
+const transactionDocumentLimit = 10 * 1024 * 1024
+const transactionDocumentAccept = '.pdf,.jpg,.jpeg,.png,.webp,.heic,.heif,application/pdf,image/jpeg,image/png,image/webp,image/heic,image/heif'
+
+const paymentMethodItems: { label: string, value: PaymentMethod }[] = [
+ {
+ label: 'Cash',
+ value: 'cash'
+ },
+ {
+ label: 'Bank',
+ value: 'bank'
+ }
+]
const bookingModeItems = computed(() => {
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
@@ -689,12 +803,14 @@ const selectedDetailsTicket = computed(() => {
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
+const selectedTransactionDocumentName = computed(() => detailsTransactionDocumentFile.value?.name || '')
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'quantity', header: 'Booking' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
+ { accessorKey: 'paymentMethod', header: 'Payment' },
{ accessorKey: 'remark', header: 'Remark' },
{ id: 'status', header: 'Status' },
{ accessorKey: 'createdAt', header: 'Submitted' },
@@ -737,6 +853,8 @@ const filteredBookings = computed(() => {
booking.personInChargePhoneNumber,
booking.ticketType,
booking.ticketLabel,
+ getPaymentMethodLabel(booking.paymentMethod),
+ booking.transactionDocument?.originalName || '',
booking.remark || '',
booking.status
].some((value) => value.toLowerCase().includes(keyword))
@@ -772,6 +890,18 @@ function ticketLabel(booking: PublicBooking) {
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
+function getPaymentMethodLabel(paymentMethod: PaymentMethod) {
+ return paymentMethod === 'bank' ? 'Bank' : 'Cash'
+}
+
+function formatFileSize(size: number) {
+ if (size >= 1024 * 1024) {
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`
+ }
+
+ return `${Math.max(Math.round(size / 1024), 1)} KB`
+}
+
function remarkPreview(remark: string) {
const normalized = remark.trim()
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
@@ -845,12 +975,15 @@ function openBookingEditor(booking: PublicBooking) {
detailsForm.bookingMode = booking.bookingMode
detailsForm.quantity = booking.quantity
detailsForm.ticketType = booking.ticketType
+ detailsForm.paymentMethod = booking.paymentMethod
detailsForm.remark = booking.remark || ''
+ detailsTransactionDocumentFile.value = null
+ transactionDocumentInputKey.value += 1
detailsModalOpen.value = true
}
function closeBookingEditor() {
- if (savingDetails.value) {
+ if (savingDetails.value || deletingTransactionDocument.value) {
return
}
@@ -861,7 +994,10 @@ function closeBookingEditor() {
detailsForm.bookingMode = ''
detailsForm.quantity = 1
detailsForm.ticketType = ''
+ detailsForm.paymentMethod = 'cash'
detailsForm.remark = ''
+ detailsTransactionDocumentFile.value = null
+ transactionDocumentInputKey.value += 1
}
function validateBookingDetails(state: typeof detailsForm): FormError[] {
@@ -889,6 +1025,14 @@ function validateBookingDetails(state: typeof detailsForm): FormError[] {
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
}
+ if (state.paymentMethod !== 'cash' && state.paymentMethod !== 'bank') {
+ errors.push({ name: 'paymentMethod', message: 'Please select Cash or Bank.' })
+ }
+
+ if (detailsTransactionDocumentFile.value && detailsTransactionDocumentFile.value.size > transactionDocumentLimit) {
+ errors.push({ name: 'transactionDocument', message: 'Transaction document must be 10MB or smaller.' })
+ }
+
return errors
}
@@ -958,12 +1102,6 @@ function removeBooking(bookingId: string) {
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
}
-function applyCancelledConfirmationToSummary(booking: PublicBooking) {
- summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0)
- summary.pendingSeats += booking.seatCount
- summary.leftSeats = summary.totalSeats === null ? null : Math.max(summary.totalSeats - summary.soldSeats, 0)
-}
-
function openRemarkEditor(booking: PublicBooking) {
editingBooking.value = booking
remarkForm.remark = booking.remark || ''
@@ -1002,6 +1140,53 @@ function closeTransferEditor() {
transferForm.personInChargeId = ''
}
+function onTransactionDocumentChange(event: Event) {
+ const input = event.target as HTMLInputElement
+ detailsTransactionDocumentFile.value = input.files?.[0] ?? null
+}
+
+async function deleteTransactionDocumentForDetails() {
+ const booking = detailsBooking.value
+
+ if (!booking?.transactionDocument || deletingTransactionDocument.value || savingDetails.value) {
+ return
+ }
+
+ if (import.meta.client && !window.confirm(`Delete transaction document for ${booking.customerName}?`)) {
+ return
+ }
+
+ deletingTransactionDocument.value = true
+
+ try {
+ const response = await apiClient(`/api/bookings/${booking.id}/transaction-document`, {
+ method: 'DELETE'
+ })
+
+ replaceBooking(response.booking)
+ detailsBooking.value = response.booking
+ detailsTransactionDocumentFile.value = null
+ transactionDocumentInputKey.value += 1
+ await refreshBookings()
+
+ toast.add({
+ title: 'Document deleted',
+ description: 'The transaction document has been removed.',
+ color: 'success',
+ icon: 'i-lucide-trash-2'
+ })
+ } catch (error: any) {
+ toast.add({
+ title: 'Delete failed',
+ description: getErrorMessage(error, 'Unable to delete the transaction document.'),
+ color: 'error',
+ icon: 'i-lucide-circle-alert'
+ })
+ } finally {
+ deletingTransactionDocument.value = false
+ }
+}
+
async function refreshBookings() {
if (loadingBookings.value) {
return
@@ -1058,7 +1243,7 @@ async function saveBookingDetails(event: FormSubmitEvent) {
event.preventDefault()
- if (!booking || savingDetails.value) {
+ if (!booking || savingDetails.value || deletingTransactionDocument.value) {
return
}
@@ -1073,14 +1258,31 @@ async function saveBookingDetails(event: FormSubmitEvent) {
bookingMode: detailsForm.bookingMode,
quantity: detailsForm.quantity,
ticketType: detailsForm.ticketType,
+ paymentMethod: detailsForm.paymentMethod,
remark: detailsForm.remark
}
})
- replaceBooking(response.booking)
+ let updatedBooking = response.booking
+
+ if (detailsForm.paymentMethod === 'bank' && detailsTransactionDocumentFile.value) {
+ const formData = new FormData()
+ formData.append('document', detailsTransactionDocumentFile.value)
+
+ const uploadResponse = await apiClient(`/api/bookings/${booking.id}/transaction-document`, {
+ method: 'POST',
+ body: formData
+ })
+
+ updatedBooking = uploadResponse.booking
+ }
+
+ replaceBooking(updatedBooking)
await refreshBookings()
detailsModalOpen.value = false
detailsBooking.value = null
+ detailsTransactionDocumentFile.value = null
+ transactionDocumentInputKey.value += 1
toast.add({
title: 'Booking updated',
@@ -1280,10 +1482,7 @@ async function cancelBookingConfirmation(booking: PublicBooking) {
})
replaceBooking(response.booking)
-
- if (!response.alreadyPending) {
- applyCancelledConfirmationToSummary(booking)
- }
+ await refreshBookings()
toast.add({
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index d61b305..d0a5152 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -15,6 +15,7 @@ services:
NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system}
NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
NUXT_SESSION_COOKIE_NAME: ${NUXT_SESSION_COOKIE_NAME:-dinner_ticket_session}
+ NUXT_TRANSACTION_DOCUMENT_DIR: ${NUXT_TRANSACTION_DOCUMENT_DIR:-/app/.data/transaction-documents}
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
diff --git a/docker-compose.yml b/docker-compose.yml
index aae8a94..80042e7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -48,6 +48,7 @@ services:
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
NUXT_REDIS_URL: redis://redis:6379
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session
+ NUXT_TRANSACTION_DOCUMENT_DIR: /data/transaction-documents
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
@@ -55,6 +56,8 @@ services:
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
ports:
- "20013:20013"
+ volumes:
+ - transaction_documents:/data/transaction-documents
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:20013/api/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
@@ -65,3 +68,4 @@ services:
volumes:
postgres_data:
redis_data:
+ transaction_documents:
diff --git a/nuxt.config.ts b/nuxt.config.ts
index ef931e9..9b9e02e 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -25,6 +25,7 @@ export default defineNuxtConfig({
databaseUrl: '',
redisUrl: '',
sessionCookieName: 'dinner_ticket_session',
+ transactionDocumentDir: '.data/transaction-documents',
whatsappAccessToken: '',
whatsappPhoneNumberId: '',
whatsappApiVersion: 'v23.0',
diff --git a/server/api/bookings/[id].patch.ts b/server/api/bookings/[id].patch.ts
index 41f2619..ea9e37b 100644
--- a/server/api/bookings/[id].patch.ts
+++ b/server/api/bookings/[id].patch.ts
@@ -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 => {
@@ -21,6 +23,7 @@ export default defineEventHandler(async (event): Promise(event)
@@ -74,6 +77,7 @@ export default defineEventHandler(async (event): Promise => {
+ 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
+ }
+})
diff --git a/server/api/bookings/[id]/transaction-document.get.ts b/server/api/bookings/[id]/transaction-document.get.ts
new file mode 100644
index 0000000..c4166f8
--- /dev/null
+++ b/server/api/bookings/[id]/transaction-document.get.ts
@@ -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)
+})
diff --git a/server/api/bookings/[id]/transaction-document.post.ts b/server/api/bookings/[id]/transaction-document.post.ts
new file mode 100644
index 0000000..a4788f4
--- /dev/null
+++ b/server/api/bookings/[id]/transaction-document.post.ts
@@ -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 => {
+ 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
+ }
+})
diff --git a/server/utils/booking-repository.ts b/server/utils/booking-repository.ts
index 3953131..9e10218 100644
--- a/server/utils/booking-repository.ts
+++ b/server/utils/booking-repository.ts
@@ -6,6 +6,8 @@ import type {
BookingInventorySummary,
DinnerEvent,
BookingMode,
+ BookingTransactionDocument,
+ PaymentMethod,
BookingStatus,
PublicBookingConfig,
PublicBooking,
@@ -16,7 +18,7 @@ import type {
} from '~~/shared/booking'
import type { AppLocale } from '~~/shared/i18n'
-import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus } from '~~/shared/booking'
+import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus, isPaymentMethod } from '~~/shared/booking'
import { resolveLocale } from '~~/shared/i18n'
import { randomToken, toIsoString } from './base64url'
@@ -51,6 +53,12 @@ type DbBookingRow = {
person_in_charge_id: string
person_in_charge_name: string | null
person_in_charge_phone_number: string | null
+ payment_method: PaymentMethod | string
+ transaction_document_original_name: string | null
+ transaction_document_storage_name: string | null
+ transaction_document_mime_type: string | null
+ transaction_document_size: number | string | null
+ transaction_document_uploaded_at: Date | string | null
remark?: string | null
status: BookingStatus | string
status_label: string | null
@@ -74,6 +82,19 @@ type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit mapBooking(row, { includeTransactionDocument: true }))
}
export async function updateBookingRemark(input: {
@@ -652,7 +706,7 @@ export async function updateBookingRemark(input: {
limit 1
`
- return rows[0] ? mapBooking(rows[0]) : null
+ return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
}
export async function updateBookingPersonInCharge(input: {
@@ -698,7 +752,7 @@ export async function updateBookingPersonInCharge(input: {
limit 1
`
- return rows[0] ? mapBooking(rows[0]) : null
+ return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
}
export async function getBookingById(bookingId: string, options?: {
@@ -726,7 +780,7 @@ export async function getBookingById(bookingId: string, options?: {
limit 1
`
- return rows[0] ? mapBooking(rows[0]) : null
+ return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
}
async function syncBookingSeats(tx: ReturnType, bookingId: string, currentSeatCount: number, nextSeatCount: number) {
@@ -772,6 +826,7 @@ export async function updateBookingDetails(input: {
ticketType: TicketType
unitPrice: number
totalPrice: number
+ paymentMethod: PaymentMethod
remark: string | null
personInChargeId?: string
}) {
@@ -807,6 +862,7 @@ export async function updateBookingDetails(input: {
ticket_type = ${input.ticketType},
unit_price = ${input.unitPrice},
total_price = ${input.totalPrice},
+ payment_method = ${input.paymentMethod},
remark = ${input.remark},
updated_at = now()
where id = ${input.bookingId}
@@ -826,10 +882,152 @@ export async function updateBookingDetails(input: {
await syncBookingSeats(tx, input.bookingId, currentSeatCount, input.seatCount)
- return mapBooking(row)
+ return mapBooking(row, { includeTransactionDocument: true })
})
}
+export async function getBookingTransactionDocument(input: {
+ bookingId: string
+ personInChargeId?: string
+}): Promise {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+
+ const [row] = input.personInChargeId
+ ? await sql<(DbBookingTransactionDocumentRow & { id: string })[]>`
+ select
+ id,
+ payment_method,
+ transaction_document_original_name,
+ transaction_document_storage_name,
+ transaction_document_mime_type,
+ transaction_document_size,
+ transaction_document_uploaded_at
+ from bookings
+ where id = ${input.bookingId}
+ and deleted_at is null
+ and person_in_charge_id = ${input.personInChargeId}
+ limit 1
+ `
+ : await sql<(DbBookingTransactionDocumentRow & { id: string })[]>`
+ select
+ id,
+ payment_method,
+ transaction_document_original_name,
+ transaction_document_storage_name,
+ transaction_document_mime_type,
+ transaction_document_size,
+ transaction_document_uploaded_at
+ from bookings
+ where id = ${input.bookingId}
+ and deleted_at is null
+ limit 1
+ `
+
+ return row ? mapBookingTransactionDocument(row, row.id) : null
+}
+
+export async function replaceBookingTransactionDocument(input: {
+ bookingId: string
+ personInChargeId?: string
+ originalName: string
+ storageName: string
+ mimeType: string
+ size: number
+}): Promise<{ booking: PublicBooking, previousStorageName: string | null } | null> {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+
+ const [row] = await sql<(DbBookingRow & { previous_storage_name: string | null })[]>`
+ with current_booking as (
+ select transaction_document_storage_name
+ from bookings
+ where id = ${input.bookingId}
+ and deleted_at is null
+ ${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
+ limit 1
+ ),
+ updated_booking as (
+ update bookings
+ set
+ payment_method = 'bank',
+ transaction_document_original_name = ${input.originalName},
+ transaction_document_storage_name = ${input.storageName},
+ transaction_document_mime_type = ${input.mimeType},
+ transaction_document_size = ${input.size},
+ transaction_document_uploaded_at = now(),
+ updated_at = now()
+ where id = ${input.bookingId}
+ and deleted_at is null
+ ${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
+ returning *
+ )
+ select
+ ${bookingSelectColumns(sql)},
+ (select transaction_document_storage_name from current_booking) as previous_storage_name
+ from updated_booking as bookings
+ ${bookingJoins(sql)}
+ limit 1
+ `
+
+ if (!row) {
+ return null
+ }
+
+ return {
+ booking: mapBooking(row, { includeTransactionDocument: true }),
+ previousStorageName: row.previous_storage_name
+ }
+}
+
+export async function clearBookingTransactionDocument(input: {
+ bookingId: string
+ personInChargeId?: string
+}): Promise<{ booking: PublicBooking, previousStorageName: string | null } | null> {
+ await ensureDatabaseReady()
+ const sql = getSqlClient()
+
+ const [row] = await sql<(DbBookingRow & { previous_storage_name: string | null })[]>`
+ with current_booking as (
+ select transaction_document_storage_name
+ from bookings
+ where id = ${input.bookingId}
+ and deleted_at is null
+ ${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
+ limit 1
+ ),
+ updated_booking as (
+ update bookings
+ set
+ transaction_document_original_name = null,
+ transaction_document_storage_name = null,
+ transaction_document_mime_type = null,
+ transaction_document_size = null,
+ transaction_document_uploaded_at = null,
+ updated_at = now()
+ where id = ${input.bookingId}
+ and deleted_at is null
+ ${input.personInChargeId ? sql`and person_in_charge_id = ${input.personInChargeId}` : sql``}
+ returning *
+ )
+ select
+ ${bookingSelectColumns(sql)},
+ (select transaction_document_storage_name from current_booking) as previous_storage_name
+ from updated_booking as bookings
+ ${bookingJoins(sql)}
+ limit 1
+ `
+
+ if (!row) {
+ return null
+ }
+
+ return {
+ booking: mapBooking(row, { includeTransactionDocument: true }),
+ previousStorageName: row.previous_storage_name
+ }
+}
+
export async function softDeleteBooking(input: {
bookingId: string
personInChargeId?: string
@@ -870,7 +1068,7 @@ export async function softDeleteBooking(input: {
limit 1
`
- return rows[0] ? mapBooking(rows[0]) : null
+ return rows[0] ? mapBooking(rows[0], { includeTransactionDocument: true }) : null
}
export async function listBookingSeats(bookingId: string): Promise {
diff --git a/server/utils/bookings.ts b/server/utils/bookings.ts
index 5be4c37..a731d1f 100644
--- a/server/utils/bookings.ts
+++ b/server/utils/bookings.ts
@@ -1,7 +1,8 @@
-import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
+import type { BookingCapacitySettings, BookingMode, PaymentMethod, PublicBooking, TicketType } from '~~/shared/booking'
import {
- formatBookingCurrency
+ formatBookingCurrency,
+ isPaymentMethod
} from '~~/shared/booking'
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
import { resolveLocale } from '~~/shared/i18n'
@@ -49,12 +50,14 @@ export function parseUpdateBookingDetailsInput(body: {
bookingMode?: BookingMode | string | null
quantity?: number
ticketType?: TicketType
+ paymentMethod?: PaymentMethod | string | null
remark?: string | null
}) {
const customerName = normalizeFullName(body.customerName || '')
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
+ const paymentMethod = typeof body.paymentMethod === 'string' ? body.paymentMethod.trim().toLowerCase() : body.paymentMethod
const quantity = Number(body.quantity)
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
@@ -63,6 +66,7 @@ export function parseUpdateBookingDetailsInput(body: {
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
+ assertBadRequest(isPaymentMethod(paymentMethod), 'Payment method must be Cash or Bank')
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
return {
@@ -71,6 +75,7 @@ export function parseUpdateBookingDetailsInput(body: {
bookingMode,
quantity,
ticketType,
+ paymentMethod,
remark: remark || null
}
}
diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts
index 4912473..f4a3ead 100644
--- a/server/utils/db-init.ts
+++ b/server/utils/db-init.ts
@@ -284,6 +284,12 @@ async function initializeDatabase() {
person_in_charge_id text not null references users(id) on delete restrict,
person_in_charge_name text not null,
person_in_charge_phone_number text not null,
+ payment_method text not null default 'cash',
+ transaction_document_original_name text,
+ transaction_document_storage_name text,
+ transaction_document_mime_type text,
+ transaction_document_size integer,
+ transaction_document_uploaded_at timestamptz,
remark text,
status text not null default 'pending',
confirmed_at timestamptz,
@@ -328,6 +334,53 @@ async function initializeDatabase() {
add column if not exists deleted_at timestamptz
`
+ await sql`
+ alter table bookings
+ add column if not exists payment_method text not null default 'cash'
+ `
+
+ await sql`
+ alter table bookings
+ add column if not exists transaction_document_original_name text
+ `
+
+ await sql`
+ alter table bookings
+ add column if not exists transaction_document_storage_name text
+ `
+
+ await sql`
+ alter table bookings
+ add column if not exists transaction_document_mime_type text
+ `
+
+ await sql`
+ alter table bookings
+ add column if not exists transaction_document_size integer
+ `
+
+ await sql`
+ alter table bookings
+ add column if not exists transaction_document_uploaded_at timestamptz
+ `
+
+ await sql`
+ update bookings
+ set payment_method = 'cash'
+ where payment_method is null
+ or payment_method not in ('cash', 'bank')
+ `
+
+ await sql`
+ alter table bookings
+ alter column payment_method set not null
+ `
+
+ await sql`
+ alter table bookings
+ alter column payment_method set default 'cash'
+ `
+
await sql`
create unique index if not exists bookings_receipt_token_idx
on bookings (receipt_token)
@@ -353,6 +406,17 @@ async function initializeDatabase() {
on bookings (deleted_at)
`
+ await sql`
+ create index if not exists bookings_payment_method_idx
+ on bookings (payment_method)
+ `
+
+ await sql`
+ create unique index if not exists bookings_transaction_document_storage_name_idx
+ on bookings (transaction_document_storage_name)
+ where transaction_document_storage_name is not null
+ `
+
await sql`
create table if not exists booking_seats (
id text primary key,
@@ -444,6 +508,17 @@ async function initializeDatabase() {
drop constraint if exists bookings_status_check
`
+ await sql`
+ alter table bookings
+ drop constraint if exists bookings_payment_method_check
+ `
+
+ await sql`
+ alter table bookings
+ add constraint bookings_payment_method_check
+ check (payment_method in ('cash', 'bank'))
+ `
+
const [activeEvent] = await sql<{ id: string }[]>`
select id
from dinner_events
diff --git a/server/utils/transaction-documents.ts b/server/utils/transaction-documents.ts
new file mode 100644
index 0000000..df6a700
--- /dev/null
+++ b/server/utils/transaction-documents.ts
@@ -0,0 +1,192 @@
+import { randomUUID } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+import { httpError } from './http'
+
+export const TRANSACTION_DOCUMENT_MAX_SIZE = 10 * 1024 * 1024
+
+export type TransactionDocumentFileType = {
+ mimeType: string
+ extension: string
+}
+
+const pdfFileType = { mimeType: 'application/pdf', extension: 'pdf' }
+const jpegFileType = { mimeType: 'image/jpeg', extension: 'jpg' }
+const pngFileType = { mimeType: 'image/png', extension: 'png' }
+const webpFileType = { mimeType: 'image/webp', extension: 'webp' }
+const heicFileType = { mimeType: 'image/heic', extension: 'heic' }
+const heifFileType = { mimeType: 'image/heif', extension: 'heif' }
+
+const allowedFileTypes: TransactionDocumentFileType[] = [
+ pdfFileType,
+ jpegFileType,
+ pngFileType,
+ webpFileType,
+ heicFileType,
+ heifFileType
+]
+
+export function getTransactionDocumentDirectory() {
+ const config = useRuntimeConfig()
+ const configured = String(config.transactionDocumentDir || '.data/transaction-documents').trim()
+ return path.resolve(process.cwd(), configured || '.data/transaction-documents')
+}
+
+export async function saveTransactionDocument(data: Buffer, fileType: TransactionDocumentFileType) {
+ const directory = getTransactionDocumentDirectory()
+ await mkdir(directory, { recursive: true })
+
+ const storageName = `${randomUUID()}.${fileType.extension}`
+ const filePath = getTransactionDocumentPath(storageName)
+
+ await writeFile(filePath, data, { flag: 'wx' })
+
+ return storageName
+}
+
+export function getTransactionDocumentPath(storageName: string) {
+ if (!/^[a-f0-9-]+\.(pdf|jpg|png|webp|heic|heif)$/.test(storageName)) {
+ httpError(404, 'Transaction document not found')
+ }
+
+ const directory = getTransactionDocumentDirectory()
+ const filePath = path.resolve(directory, storageName)
+ const directoryPrefix = directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`
+
+ if (!filePath.startsWith(directoryPrefix)) {
+ httpError(404, 'Transaction document not found')
+ }
+
+ return filePath
+}
+
+export async function getTransactionDocumentFile(storageName: string) {
+ const filePath = getTransactionDocumentPath(storageName)
+ const fileStat = await stat(filePath).catch(() => null)
+
+ if (!fileStat?.isFile()) {
+ httpError(404, 'Transaction document not found')
+ }
+
+ return {
+ filePath,
+ size: fileStat.size,
+ stream: createReadStream(filePath)
+ }
+}
+
+export async function deleteTransactionDocument(storageName: string | null | undefined) {
+ if (!storageName) {
+ return
+ }
+
+ try {
+ await unlink(getTransactionDocumentPath(storageName))
+ } catch {
+ // A missing or invalid old file should not block the booking update.
+ }
+}
+
+export function sanitizeTransactionDocumentName(filename: string | undefined) {
+ const baseName = path.basename(filename || 'transaction-document')
+ const normalized = baseName
+ .replace(/[\u0000-\u001f\u007f"\\/:*?<>|]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+
+ return (normalized || 'transaction-document').slice(0, 180)
+}
+
+export function getSafeDownloadName(filename: string) {
+ const sanitized = sanitizeTransactionDocumentName(filename)
+ return sanitized.replace(/[^\x20-\x7e]+/g, '_').replace(/"/g, '')
+}
+
+export function validateTransactionDocumentUpload(input: {
+ data: Buffer
+ filename?: string
+ contentType?: string
+}) {
+ if (!input.data.length) {
+ httpError(400, 'Transaction document is empty')
+ }
+
+ if (input.data.length > TRANSACTION_DOCUMENT_MAX_SIZE) {
+ httpError(413, 'Transaction document must be 10MB or smaller')
+ }
+
+ const detectedType = detectTransactionDocumentFileType(input.data)
+
+ if (!detectedType) {
+ httpError(400, 'Transaction document must be a PDF or supported image file')
+ }
+
+ const declaredType = String(input.contentType || '').toLowerCase()
+
+ if (declaredType && !isCompatibleDeclaredType(declaredType, detectedType)) {
+ httpError(400, 'Transaction document file type does not match the uploaded file')
+ }
+
+ return {
+ originalName: sanitizeTransactionDocumentName(input.filename),
+ fileType: detectedType
+ }
+}
+
+function isCompatibleDeclaredType(declaredType: string, detectedType: TransactionDocumentFileType) {
+ if (declaredType === detectedType.mimeType) {
+ return true
+ }
+
+ return detectedType.mimeType === 'image/jpeg' && declaredType === 'image/jpg'
+}
+
+function detectTransactionDocumentFileType(data: Buffer): TransactionDocumentFileType | null {
+ if (data.subarray(0, 5).toString('ascii') === '%PDF-') {
+ return pdfFileType
+ }
+
+ if (data.length >= 3 && data.readUInt8(0) === 0xff && data.readUInt8(1) === 0xd8 && data.readUInt8(2) === 0xff) {
+ return jpegFileType
+ }
+
+ if (
+ data.length >= 8
+ && data.readUInt8(0) === 0x89
+ && data.readUInt8(1) === 0x50
+ && data.readUInt8(2) === 0x4e
+ && data.readUInt8(3) === 0x47
+ && data.readUInt8(4) === 0x0d
+ && data.readUInt8(5) === 0x0a
+ && data.readUInt8(6) === 0x1a
+ && data.readUInt8(7) === 0x0a
+ ) {
+ return pngFileType
+ }
+
+ if (
+ data.length >= 12
+ && data.subarray(0, 4).toString('ascii') === 'RIFF'
+ && data.subarray(8, 12).toString('ascii') === 'WEBP'
+ ) {
+ return webpFileType
+ }
+
+ if (isHeifFamily(data)) {
+ const brand = data.subarray(8, 12).toString('ascii')
+ return brand === 'heif' || brand === 'mif1' || brand === 'msf1' ? heifFileType : heicFileType
+ }
+
+ return null
+}
+
+function isHeifFamily(data: Buffer) {
+ if (data.length < 12 || data.subarray(4, 8).toString('ascii') !== 'ftyp') {
+ return false
+ }
+
+ const brand = data.subarray(8, 12).toString('ascii')
+ return ['heic', 'heix', 'hevc', 'hevx', 'heif', 'mif1', 'msf1'].includes(brand)
+}
diff --git a/shared/booking.ts b/shared/booking.ts
index 85bcfdf..0d2b68b 100644
--- a/shared/booking.ts
+++ b/shared/booking.ts
@@ -3,6 +3,7 @@ import type { AppLocale } from './i18n'
export type BookingMode = string
export type TicketType = string
export type BookingStatus = 'pending' | 'confirmed'
+export type PaymentMethod = 'cash' | 'bank'
export interface DinnerEvent {
id: string
@@ -58,6 +59,8 @@ export interface PublicBooking {
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
+ paymentMethod: PaymentMethod
+ transactionDocument: BookingTransactionDocument | null
remark: string | null
status: BookingStatus
statusLabel: string
@@ -65,6 +68,14 @@ export interface PublicBooking {
confirmedAt: string | null
}
+export interface BookingTransactionDocument {
+ originalName: string
+ mimeType: string
+ size: number
+ uploadedAt: string
+ url: string
+}
+
export interface ReceiptBooking {
id: string
receiptToken: string
@@ -170,6 +181,10 @@ export function isBookingStatus(value: string | null | undefined): value is Book
return value === 'pending' || value === 'confirmed'
}
+export function isPaymentMethod(value: string | null | undefined): value is PaymentMethod {
+ return value === 'cash' || value === 'bank'
+}
+
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null, locale: AppLocale = 'en') {
if (label && locale !== 'zh') {
return label