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:
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user