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

@@ -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<DbBookingRow, 'id' |
booking_created_at: Date | string
}
type DbBookingTransactionDocumentRow = {
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
}
type BookingTransactionDocumentRecord = BookingTransactionDocument & {
storageName: string
}
type DbBookingSettingsRow = {
event_id: string
total_tables: number | string | null
@@ -146,6 +167,12 @@ function bookingSelectColumns(sql: any) {
bookings.person_in_charge_id,
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
bookings.payment_method,
bookings.transaction_document_original_name,
bookings.transaction_document_storage_name,
bookings.transaction_document_mime_type,
bookings.transaction_document_size,
bookings.transaction_document_uploaded_at,
bookings.remark,
bookings.status,
booking_statuses.label as status_label,
@@ -212,11 +239,36 @@ function mapTicketCatalogItem(row: DbTicketCatalogItemRow): TicketCatalogItemRec
}
}
function mapBooking(row: DbBookingRow): PublicBooking {
function mapBookingTransactionDocument(row: DbBookingTransactionDocumentRow, bookingId: string): BookingTransactionDocumentRecord | null {
if (
row.payment_method !== 'bank'
|| !row.transaction_document_original_name
|| !row.transaction_document_storage_name
|| !row.transaction_document_mime_type
|| row.transaction_document_size === null
|| !row.transaction_document_uploaded_at
) {
return null
}
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`
}
}
function mapBooking(row: DbBookingRow, options?: {
includeTransactionDocument?: boolean
}): PublicBooking {
const seatCount = parseInteger(row.seat_count)
const status = isBookingStatus(row.status) ? row.status : 'pending'
const ticketType = row.ticket_type
const bookingMode = row.booking_mode
const paymentMethod = isPaymentMethod(row.payment_method) ? row.payment_method : 'cash'
return {
id: row.id,
@@ -240,6 +292,8 @@ function mapBooking(row: DbBookingRow): PublicBooking {
personInChargeId: row.person_in_charge_id,
personInChargeName: row.person_in_charge_name || '',
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
paymentMethod,
transactionDocument: options?.includeTransactionDocument ? mapBookingTransactionDocument(row, row.id) : null,
remark: row.remark || null,
status,
statusLabel: row.status_label || getBookingStatusLabel(status),
@@ -606,7 +660,7 @@ export async function listBookings(options?: {
order by bookings.created_at desc
`
return rows.map(mapBooking)
return rows.map((row) => 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<typeof getSqlClient>, 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<BookingTransactionDocumentRecord | null> {
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<PublicBookingSeat[]> {