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

@@ -239,7 +239,7 @@ function mapTicketCatalogItem(row: DbTicketCatalogItemRow): TicketCatalogItemRec
}
}
function mapBookingTransactionDocument(row: DbBookingTransactionDocumentRow, bookingId: string): BookingTransactionDocumentRecord | null {
function mapBookingTransactionDocument(row: DbBookingTransactionDocumentRow, url: string): BookingTransactionDocument | null {
if (
row.payment_method !== 'bank'
|| !row.transaction_document_original_name
@@ -253,16 +253,29 @@ function mapBookingTransactionDocument(row: DbBookingTransactionDocumentRow, boo
return {
originalName: row.transaction_document_original_name,
storageName: row.transaction_document_storage_name,
mimeType: row.transaction_document_mime_type,
size: parseInteger(row.transaction_document_size),
uploadedAt: toIsoString(row.transaction_document_uploaded_at) ?? new Date().toISOString(),
url: `/api/bookings/${bookingId}/transaction-document`
url
}
}
function mapBookingTransactionDocumentRecord(row: DbBookingTransactionDocumentRow, url: string): BookingTransactionDocumentRecord | null {
const document = mapBookingTransactionDocument(row, url)
if (!document || !row.transaction_document_storage_name) {
return null
}
return {
...document,
storageName: row.transaction_document_storage_name
}
}
function mapBooking(row: DbBookingRow, options?: {
includeTransactionDocument?: boolean
transactionDocumentUrl?: string
}): PublicBooking {
const seatCount = parseInteger(row.seat_count)
const status = isBookingStatus(row.status) ? row.status : 'pending'
@@ -293,7 +306,9 @@ function mapBooking(row: DbBookingRow, options?: {
personInChargeName: row.person_in_charge_name || '',
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
paymentMethod,
transactionDocument: options?.includeTransactionDocument ? mapBookingTransactionDocument(row, row.id) : null,
transactionDocument: options?.includeTransactionDocument
? mapBookingTransactionDocument(row, options.transactionDocumentUrl || `/api/bookings/${row.id}/transaction-document`)
: null,
remark: row.remark || null,
status,
statusLabel: row.status_label || getBookingStatusLabel(status),
@@ -603,7 +618,9 @@ export async function createBooking(input: {
}
}
export async function getBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
export async function getBookingByConfirmationToken(confirmationToken: string, options?: {
includeTransactionDocument?: boolean
}): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
@@ -616,7 +633,12 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
limit 1
`
return row ? mapBooking(row) : null
return row
? mapBooking(row, {
includeTransactionDocument: options?.includeTransactionDocument,
transactionDocumentUrl: `/api/public/bookings/${confirmationToken}/transaction-document`
})
: null
}
export async function getBookingByReceiptToken(receiptToken: string): Promise<PublicBooking | null> {
@@ -924,7 +946,164 @@ export async function getBookingTransactionDocument(input: {
limit 1
`
return row ? mapBookingTransactionDocument(row, row.id) : null
return row ? mapBookingTransactionDocumentRecord(row, `/api/bookings/${row.id}/transaction-document`) : null
}
export async function getBookingTransactionDocumentByConfirmationToken(confirmationToken: string): Promise<BookingTransactionDocumentRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<(DbBookingTransactionDocumentRow & { confirmation_token: string })[]>`
select
confirmation_token,
payment_method,
transaction_document_original_name,
transaction_document_storage_name,
transaction_document_mime_type,
transaction_document_size,
transaction_document_uploaded_at
from bookings
where confirmation_token = ${confirmationToken}
and deleted_at is null
limit 1
`
return row ? mapBookingTransactionDocumentRecord(row, `/api/public/bookings/${row.confirmation_token}/transaction-document`) : null
}
export async function updateBookingPaymentMethodByConfirmationToken(input: {
confirmationToken: string
paymentMethod: PaymentMethod
}): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
with updated_booking as (
update bookings
set
payment_method = ${input.paymentMethod},
updated_at = now()
where confirmation_token = ${input.confirmationToken}
and deleted_at is null
and status = 'pending'
returning *
)
select ${bookingSelectColumns(sql)}
from updated_booking as bookings
${bookingJoins(sql)}
limit 1
`
return row
? mapBooking(row, {
includeTransactionDocument: true,
transactionDocumentUrl: `/api/public/bookings/${input.confirmationToken}/transaction-document`
})
: null
}
export async function replaceBookingTransactionDocumentByConfirmationToken(input: {
confirmationToken: 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 confirmation_token = ${input.confirmationToken}
and deleted_at is null
and status = 'pending'
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 confirmation_token = ${input.confirmationToken}
and deleted_at is null
and status = 'pending'
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,
transactionDocumentUrl: `/api/public/bookings/${input.confirmationToken}/transaction-document`
}),
previousStorageName: row.previous_storage_name
}
}
export async function clearBookingTransactionDocumentByConfirmationToken(confirmationToken: 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 confirmation_token = ${confirmationToken}
and deleted_at is null
and status = 'pending'
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 confirmation_token = ${confirmationToken}
and deleted_at is null
and status = 'pending'
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,
transactionDocumentUrl: `/api/public/bookings/${confirmationToken}/transaction-document`
}),
previousStorageName: row.previous_storage_name
}
}
export async function replaceBookingTransactionDocument(input: {