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
}
})

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[]> {

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)
}