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:
@@ -73,6 +73,24 @@ const messages = {
|
|||||||
'confirm.picPhone': 'PIC Phone',
|
'confirm.picPhone': 'PIC Phone',
|
||||||
'confirm.ticketCategory': 'Ticket Category',
|
'confirm.ticketCategory': 'Ticket Category',
|
||||||
'confirm.seatsCovered': 'Seats Covered',
|
'confirm.seatsCovered': 'Seats Covered',
|
||||||
|
'confirm.payment': 'Payment',
|
||||||
|
'confirm.paymentPendingDescription': 'Choose the payment method before confirming.',
|
||||||
|
'confirm.paymentConfirmedDescription': 'Payment method selected for this booking.',
|
||||||
|
'confirm.paymentCash': 'Cash',
|
||||||
|
'confirm.paymentBank': 'Bank',
|
||||||
|
'confirm.transactionDocument': 'Transaction Document',
|
||||||
|
'confirm.documentHelp': 'PDF, JPG, PNG, WEBP, HEIC, or HEIF - max 10MB.',
|
||||||
|
'confirm.upload': 'Upload',
|
||||||
|
'confirm.download': 'Download',
|
||||||
|
'confirm.delete': 'Delete',
|
||||||
|
'confirm.deleteDocumentPrompt': 'Delete transaction document?',
|
||||||
|
'confirm.documentUploaded': 'Document uploaded',
|
||||||
|
'confirm.documentUploadedDescription': 'The transaction document has been saved.',
|
||||||
|
'confirm.documentDeleted': 'Document deleted',
|
||||||
|
'confirm.documentDeletedDescription': 'The transaction document has been removed.',
|
||||||
|
'confirm.uploadFailed': 'Upload failed',
|
||||||
|
'confirm.deleteFailed': 'Delete failed',
|
||||||
|
'confirm.documentSizeInvalid': 'Transaction document must be 10MB or smaller.',
|
||||||
'confirm.submittedLabel': 'Submitted',
|
'confirm.submittedLabel': 'Submitted',
|
||||||
'confirm.confirmedAt': 'Confirmed At',
|
'confirm.confirmedAt': 'Confirmed At',
|
||||||
'confirm.backToForm': 'Back To Booking Form',
|
'confirm.backToForm': 'Back To Booking Form',
|
||||||
@@ -223,6 +241,24 @@ const messages = {
|
|||||||
'confirm.picPhone': '负责人电话',
|
'confirm.picPhone': '负责人电话',
|
||||||
'confirm.ticketCategory': '票券类别',
|
'confirm.ticketCategory': '票券类别',
|
||||||
'confirm.seatsCovered': '座位数量',
|
'confirm.seatsCovered': '座位数量',
|
||||||
|
'confirm.payment': '付款方式',
|
||||||
|
'confirm.paymentPendingDescription': '请在确认前选择付款方式。',
|
||||||
|
'confirm.paymentConfirmedDescription': '此预订已选择的付款方式。',
|
||||||
|
'confirm.paymentCash': 'Cash',
|
||||||
|
'confirm.paymentBank': 'Bank',
|
||||||
|
'confirm.transactionDocument': 'Transaction Document',
|
||||||
|
'confirm.documentHelp': 'PDF、JPG、PNG、WEBP、HEIC 或 HEIF,最大 10MB。',
|
||||||
|
'confirm.upload': '上传',
|
||||||
|
'confirm.download': '下载',
|
||||||
|
'confirm.delete': '删除',
|
||||||
|
'confirm.deleteDocumentPrompt': '确定删除 transaction document?',
|
||||||
|
'confirm.documentUploaded': '文件已上传',
|
||||||
|
'confirm.documentUploadedDescription': 'Transaction document 已保存。',
|
||||||
|
'confirm.documentDeleted': '文件已删除',
|
||||||
|
'confirm.documentDeletedDescription': 'Transaction document 已移除。',
|
||||||
|
'confirm.uploadFailed': '上传失败',
|
||||||
|
'confirm.deleteFailed': '删除失败',
|
||||||
|
'confirm.documentSizeInvalid': 'Transaction document 必须是 10MB 或以下。',
|
||||||
'confirm.submittedLabel': '提交时间',
|
'confirm.submittedLabel': '提交时间',
|
||||||
'confirm.confirmedAt': '确认时间',
|
'confirm.confirmedAt': '确认时间',
|
||||||
'confirm.backToForm': '返回预订表格',
|
'confirm.backToForm': '返回预订表格',
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { CancelBookingConfirmationResponse, ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
import type {
|
||||||
|
CancelBookingConfirmationResponse,
|
||||||
|
ConfirmBookingResponse,
|
||||||
|
PaymentMethod,
|
||||||
|
PublicBooking,
|
||||||
|
UpdateBookingDetailsResponse
|
||||||
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
@@ -17,6 +23,9 @@ const { locale, t } = useLocale()
|
|||||||
const token = String(route.params.token || '')
|
const token = String(route.params.token || '')
|
||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const cancelling = ref(false)
|
const cancelling = ref(false)
|
||||||
|
const savingPayment = ref(false)
|
||||||
|
const uploadingTransactionDocument = ref(false)
|
||||||
|
const deletingTransactionDocument = ref(false)
|
||||||
|
|
||||||
let initialBooking: PublicBooking
|
let initialBooking: PublicBooking
|
||||||
|
|
||||||
@@ -31,11 +40,39 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const booking = ref(initialBooking)
|
const booking = ref(initialBooking)
|
||||||
|
const paymentForm = reactive({
|
||||||
|
paymentMethod: initialBooking.paymentMethod
|
||||||
|
})
|
||||||
|
const transactionDocumentFile = ref<File | null>(null)
|
||||||
|
const transactionDocumentInputKey = ref(0)
|
||||||
|
|
||||||
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
|
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
|
||||||
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
|
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
|
||||||
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice, locale.value))
|
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice, locale.value))
|
||||||
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
||||||
|
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 selectedTransactionDocumentName = computed(() => transactionDocumentFile.value?.name || '')
|
||||||
|
const paymentMethodItems = computed<{ label: string, value: PaymentMethod }[]>(() => [
|
||||||
|
{
|
||||||
|
label: t('confirm.paymentCash'),
|
||||||
|
value: 'cash'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('confirm.paymentBank'),
|
||||||
|
value: 'bank'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => paymentForm.paymentMethod,
|
||||||
|
(paymentMethod) => {
|
||||||
|
if (paymentMethod === 'cash') {
|
||||||
|
transactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: () => `${t('confirm.title')} - ${booking.value.customerName}`,
|
title: () => `${t('confirm.title')} - ${booking.value.customerName}`,
|
||||||
@@ -71,6 +108,10 @@ const detailRows = computed(() => {
|
|||||||
label: t('confirm.seatsCovered'),
|
label: t('confirm.seatsCovered'),
|
||||||
value: String(booking.value.seatCount)
|
value: String(booking.value.seatCount)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('confirm.payment'),
|
||||||
|
value: booking.value.paymentMethod === 'bank' ? t('confirm.paymentBank') : t('confirm.paymentCash')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('confirm.submittedLabel'),
|
label: t('confirm.submittedLabel'),
|
||||||
value: formatDateTime(booking.value.createdAt, t('date.notAvailable'), locale.value)
|
value: formatDateTime(booking.value.createdAt, t('date.notAvailable'), locale.value)
|
||||||
@@ -87,6 +128,167 @@ const detailRows = computed(() => {
|
|||||||
return rows
|
return rows
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isTableBooking = computed(() => {
|
||||||
|
return booking.value.bookingMode.toLowerCase().includes('table')
|
||||||
|
|| booking.value.bookingModeLabel.toLowerCase().includes('table')
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDetailValueClass(row: { label: string }) {
|
||||||
|
return [
|
||||||
|
'min-w-0 font-medium text-highlighted break-words',
|
||||||
|
row.label === t('confirm.guestOrganizer') && isTableBooking.value ? 'max-h-16 overflow-hidden' : ''
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 onTransactionDocumentChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
transactionDocumentFile.value = input.files?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBooking(nextBooking: PublicBooking) {
|
||||||
|
booking.value = nextBooking
|
||||||
|
paymentForm.paymentMethod = nextBooking.paymentMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePaymentDetails() {
|
||||||
|
if (savingPayment.value || booking.value.status !== 'pending') {
|
||||||
|
return booking.value
|
||||||
|
}
|
||||||
|
|
||||||
|
savingPayment.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient<UpdateBookingDetailsResponse>(
|
||||||
|
`/api/public/bookings/${token}/payment`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
paymentMethod: paymentForm.paymentMethod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
applyBooking(response.booking)
|
||||||
|
|
||||||
|
return response.booking
|
||||||
|
} finally {
|
||||||
|
savingPayment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadTransactionDocument(options: { silent?: boolean } = {}) {
|
||||||
|
if (!transactionDocumentFile.value || uploadingTransactionDocument.value || booking.value.status !== 'pending') {
|
||||||
|
return booking.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionDocumentFile.value.size > transactionDocumentLimit) {
|
||||||
|
toast.add({
|
||||||
|
title: t('confirm.uploadFailed'),
|
||||||
|
description: t('confirm.documentSizeInvalid'),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-alert'
|
||||||
|
})
|
||||||
|
return booking.value
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingTransactionDocument.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
paymentForm.paymentMethod = 'bank'
|
||||||
|
await savePaymentDetails()
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('document', transactionDocumentFile.value)
|
||||||
|
|
||||||
|
const response = await apiClient<UpdateBookingDetailsResponse>(
|
||||||
|
`/api/public/bookings/${token}/transaction-document`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
applyBooking(response.booking)
|
||||||
|
transactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
|
|
||||||
|
if (!options.silent) {
|
||||||
|
toast.add({
|
||||||
|
title: t('confirm.documentUploaded'),
|
||||||
|
description: t('confirm.documentUploadedDescription'),
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-file-check'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.booking
|
||||||
|
} catch (error) {
|
||||||
|
if (options.silent) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: t('confirm.uploadFailed'),
|
||||||
|
description: getErrorMessage(error, t('booking.tryAgain')),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-alert'
|
||||||
|
})
|
||||||
|
|
||||||
|
return booking.value
|
||||||
|
} finally {
|
||||||
|
uploadingTransactionDocument.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTransactionDocument() {
|
||||||
|
if (!booking.value.transactionDocument || deletingTransactionDocument.value || booking.value.status !== 'pending') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.client && !window.confirm(t('confirm.deleteDocumentPrompt'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingTransactionDocument.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient<UpdateBookingDetailsResponse>(
|
||||||
|
`/api/public/bookings/${token}/transaction-document`,
|
||||||
|
{
|
||||||
|
method: 'DELETE'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
applyBooking(response.booking)
|
||||||
|
transactionDocumentFile.value = null
|
||||||
|
transactionDocumentInputKey.value += 1
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: t('confirm.documentDeleted'),
|
||||||
|
description: t('confirm.documentDeletedDescription'),
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-trash-2'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
title: t('confirm.deleteFailed'),
|
||||||
|
description: getErrorMessage(error, t('booking.tryAgain')),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-alert'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deletingTransactionDocument.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmBooking() {
|
async function confirmBooking() {
|
||||||
if (booking.value.status === 'confirmed') {
|
if (booking.value.status === 'confirmed') {
|
||||||
return
|
return
|
||||||
@@ -95,6 +297,11 @@ async function confirmBooking() {
|
|||||||
confirming.value = true
|
confirming.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await savePaymentDetails()
|
||||||
|
if (paymentForm.paymentMethod === 'bank') {
|
||||||
|
await uploadTransactionDocument({ silent: true })
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient<ConfirmBookingResponse>(
|
const response = await apiClient<ConfirmBookingResponse>(
|
||||||
`/api/public/bookings/${token}/confirm`,
|
`/api/public/bookings/${token}/confirm`,
|
||||||
{
|
{
|
||||||
@@ -102,7 +309,7 @@ async function confirmBooking() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
booking.value = response.booking
|
applyBooking(response.booking)
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: response.alreadyConfirmed ? t('confirm.alreadyConfirmedToast') : t('confirm.confirmedToast'),
|
title: response.alreadyConfirmed ? t('confirm.alreadyConfirmedToast') : t('confirm.confirmedToast'),
|
||||||
@@ -145,7 +352,7 @@ async function cancelBookingConfirmation() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
booking.value = response.booking
|
applyBooking(response.booking)
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: response.alreadyPending ? t('confirm.alreadyPending') : t('confirm.cancelled'),
|
title: response.alreadyPending ? t('confirm.alreadyPending') : t('confirm.cancelled'),
|
||||||
@@ -210,17 +417,17 @@ async function cancelBookingConfirmation() {
|
|||||||
<div
|
<div
|
||||||
v-for="row in detailRows"
|
v-for="row in detailRows"
|
||||||
:key="row.label"
|
:key="row.label"
|
||||||
class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[11rem_minmax(0,1fr)]"
|
class="grid gap-1 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-3"
|
||||||
>
|
>
|
||||||
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
|
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
|
||||||
{{ row.label }}
|
{{ row.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 font-medium text-highlighted break-words">
|
<div :class="getDetailValueClass(row)">
|
||||||
{{ row.value }}
|
{{ row.value }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)]">
|
<div class="grid gap-1 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-3">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
|
<div class="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
|
||||||
{{ t('common.totalPrice') }}
|
{{ t('common.totalPrice') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,6 +437,99 @@ async function cancelBookingConfirmation() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 rounded-lg border border-default p-4">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold text-highlighted">
|
||||||
|
{{ t('confirm.payment') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ booking.status === 'pending' ? t('confirm.paymentPendingDescription') : t('confirm.paymentConfirmedDescription') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UBadge :label="paymentForm.paymentMethod === 'bank' ? t('confirm.paymentBank') : t('confirm.paymentCash')" :color="paymentForm.paymentMethod === 'bank' ? 'info' : 'neutral'" variant="soft" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<URadioGroup
|
||||||
|
v-model="paymentForm.paymentMethod"
|
||||||
|
orientation="horizontal"
|
||||||
|
variant="card"
|
||||||
|
indicator="hidden"
|
||||||
|
:items="paymentMethodItems"
|
||||||
|
:disabled="booking.status !== 'pending' || confirming || savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
|
||||||
|
:ui="{
|
||||||
|
fieldset: 'grid grid-cols-2 gap-3',
|
||||||
|
item: 'rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="paymentForm.paymentMethod === 'bank'" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-if="booking.transactionDocument"
|
||||||
|
class="surface-panel flex flex-col gap-2 rounded-lg px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-highlighted">
|
||||||
|
{{ booking.transactionDocument.originalName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ formatFileSize(booking.transactionDocument.size) }} - {{ formatDateTime(booking.transactionDocument.uploadedAt, t('date.notAvailable'), locale) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<UButton
|
||||||
|
:to="booking.transactionDocument.url"
|
||||||
|
:label="t('confirm.download')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-file-down"
|
||||||
|
size="sm"
|
||||||
|
class="justify-center"
|
||||||
|
:disabled="confirming || deletingTransactionDocument"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="booking.status === 'pending'"
|
||||||
|
:label="t('confirm.delete')"
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
size="sm"
|
||||||
|
class="justify-center"
|
||||||
|
:loading="deletingTransactionDocument"
|
||||||
|
:disabled="confirming || uploadingTransactionDocument"
|
||||||
|
@click="deleteTransactionDocument"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField v-if="booking.status === 'pending'" name="transactionDocument" :label="t('confirm.transactionDocument')">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<UInput
|
||||||
|
:key="transactionDocumentInputKey"
|
||||||
|
type="file"
|
||||||
|
:accept="transactionDocumentAccept"
|
||||||
|
:disabled="confirming || uploadingTransactionDocument || deletingTransactionDocument"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
@change="onTransactionDocumentChange"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:label="t('confirm.upload')"
|
||||||
|
icon="i-lucide-upload"
|
||||||
|
class="justify-center"
|
||||||
|
:loading="uploadingTransactionDocument"
|
||||||
|
:disabled="!transactionDocumentFile || confirming || deletingTransactionDocument"
|
||||||
|
@click="uploadTransactionDocument()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #help>
|
||||||
|
{{ selectedTransactionDocumentName || t('confirm.documentHelp') }}
|
||||||
|
</template>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<UButton
|
<UButton
|
||||||
to="/"
|
to="/"
|
||||||
@@ -255,6 +555,7 @@ async function cancelBookingConfirmation() {
|
|||||||
icon="i-lucide-check-check"
|
icon="i-lucide-check-check"
|
||||||
class="justify-center"
|
class="justify-center"
|
||||||
:loading="confirming"
|
:loading="confirming"
|
||||||
|
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
|
||||||
@click="confirmBooking"
|
@click="confirmBooking"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -266,6 +567,7 @@ async function cancelBookingConfirmation() {
|
|||||||
icon="i-lucide-x-circle"
|
icon="i-lucide-x-circle"
|
||||||
class="justify-center"
|
class="justify-center"
|
||||||
:loading="cancelling"
|
:loading="cancelling"
|
||||||
|
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
|
||||||
@click="cancelBookingConfirmation"
|
@click="cancelBookingConfirmation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getBookingByConfirmationToken } from '../../../utils/booking-repository
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
const booking = await getBookingByConfirmationToken(token)
|
const booking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
httpError(404, 'Booking not found')
|
httpError(404, 'Booking not found')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<CancelBookingConfirmationResponse> => {
|
export default defineEventHandler(async (event): Promise<CancelBookingConfirmationResponse> => {
|
||||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
const existingBooking = await getBookingByConfirmationToken(token)
|
const existingBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
if (!existingBooking) {
|
if (!existingBooking) {
|
||||||
httpError(404, 'Booking not found')
|
httpError(404, 'Booking not found')
|
||||||
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event): Promise<CancelBookingConfirmati
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
booking,
|
booking: await getBookingByConfirmationToken(token, { includeTransactionDocument: true }) || booking,
|
||||||
alreadyPending: false
|
alreadyPending: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { sendBookingTicketReceiptViaWhatsApp } from '../../../../utils/whatsapp'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<ConfirmBookingResponse> => {
|
export default defineEventHandler(async (event): Promise<ConfirmBookingResponse> => {
|
||||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
const existingBooking = await getBookingByConfirmationToken(token)
|
const existingBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
if (!existingBooking) {
|
if (!existingBooking) {
|
||||||
httpError(404, 'Booking not found')
|
httpError(404, 'Booking not found')
|
||||||
@@ -39,9 +39,10 @@ export default defineEventHandler(async (event): Promise<ConfirmBookingResponse>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ticketReceiptWhatsApp = await sendBookingTicketReceiptViaWhatsApp(event, booking)
|
const ticketReceiptWhatsApp = await sendBookingTicketReceiptViaWhatsApp(event, booking)
|
||||||
|
const refreshedBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
booking,
|
booking: refreshedBooking || booking,
|
||||||
alreadyConfirmed: false,
|
alreadyConfirmed: false,
|
||||||
ticketReceiptWhatsApp
|
ticketReceiptWhatsApp
|
||||||
}
|
}
|
||||||
|
|||||||
51
server/api/public/bookings/[token]/payment.patch.ts
Normal file
51
server/api/public/bookings/[token]/payment.patch.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearBookingTransactionDocumentByConfirmationToken,
|
||||||
|
getBookingByConfirmationToken,
|
||||||
|
updateBookingPaymentMethodByConfirmationToken
|
||||||
|
} from '../../../../utils/booking-repository'
|
||||||
|
import { parsePaymentMethodInput } from '../../../../utils/bookings'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||||
|
import { deleteTransactionDocument } from '../../../../utils/transaction-documents'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
||||||
|
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
|
const existingBooking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
|
if (!existingBooking) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingBooking.status !== 'pending') {
|
||||||
|
httpError(409, 'Payment details can only be changed before confirmation')
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ paymentMethod?: string | null }>(event)
|
||||||
|
const input = parsePaymentMethodInput(body)
|
||||||
|
|
||||||
|
const booking = await updateBookingPaymentMethodByConfirmationToken({
|
||||||
|
confirmationToken: token,
|
||||||
|
paymentMethod: input.paymentMethod
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.paymentMethod === 'cash') {
|
||||||
|
const cleared = await clearBookingTransactionDocumentByConfirmationToken(token)
|
||||||
|
|
||||||
|
if (cleared) {
|
||||||
|
await deleteTransactionDocument(cleared.previousStorageName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: cleared.booking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearBookingTransactionDocumentByConfirmationToken,
|
||||||
|
getBookingByConfirmationToken
|
||||||
|
} from '../../../../utils/booking-repository'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||||
|
import { deleteTransactionDocument } from '../../../../utils/transaction-documents'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<UpdateBookingDetailsResponse> => {
|
||||||
|
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
|
const booking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'pending') {
|
||||||
|
httpError(409, 'Transaction document can only be changed before confirmation')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await clearBookingTransactionDocumentByConfirmationToken(token)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTransactionDocument(result.previousStorageName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: result.booking
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { sendStream, setHeader } from 'h3'
|
||||||
|
|
||||||
|
import { getBookingTransactionDocumentByConfirmationToken } from '../../../../utils/booking-repository'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||||
|
import {
|
||||||
|
getSafeDownloadName,
|
||||||
|
getTransactionDocumentFile
|
||||||
|
} from '../../../../utils/transaction-documents'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
|
const document = await getBookingTransactionDocumentByConfirmationToken(token)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { getHeader, readMultipartFormData } from 'h3'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBookingByConfirmationToken,
|
||||||
|
replaceBookingTransactionDocumentByConfirmationToken
|
||||||
|
} 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 token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||||
|
const booking = await getBookingByConfirmationToken(token, { includeTransactionDocument: true })
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'pending') {
|
||||||
|
httpError(409, 'Transaction document can only be changed before confirmation')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 replaceBookingTransactionDocumentByConfirmationToken({
|
||||||
|
confirmationToken: token,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 (
|
if (
|
||||||
row.payment_method !== 'bank'
|
row.payment_method !== 'bank'
|
||||||
|| !row.transaction_document_original_name
|
|| !row.transaction_document_original_name
|
||||||
@@ -253,16 +253,29 @@ function mapBookingTransactionDocument(row: DbBookingTransactionDocumentRow, boo
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
originalName: row.transaction_document_original_name,
|
originalName: row.transaction_document_original_name,
|
||||||
storageName: row.transaction_document_storage_name,
|
|
||||||
mimeType: row.transaction_document_mime_type,
|
mimeType: row.transaction_document_mime_type,
|
||||||
size: parseInteger(row.transaction_document_size),
|
size: parseInteger(row.transaction_document_size),
|
||||||
uploadedAt: toIsoString(row.transaction_document_uploaded_at) ?? new Date().toISOString(),
|
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?: {
|
function mapBooking(row: DbBookingRow, options?: {
|
||||||
includeTransactionDocument?: boolean
|
includeTransactionDocument?: boolean
|
||||||
|
transactionDocumentUrl?: string
|
||||||
}): PublicBooking {
|
}): PublicBooking {
|
||||||
const seatCount = parseInteger(row.seat_count)
|
const seatCount = parseInteger(row.seat_count)
|
||||||
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||||
@@ -293,7 +306,9 @@ function mapBooking(row: DbBookingRow, options?: {
|
|||||||
personInChargeName: row.person_in_charge_name || '',
|
personInChargeName: row.person_in_charge_name || '',
|
||||||
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
||||||
paymentMethod,
|
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,
|
remark: row.remark || null,
|
||||||
status,
|
status,
|
||||||
statusLabel: row.status_label || getBookingStatusLabel(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()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|
||||||
@@ -616,7 +633,12 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
|||||||
limit 1
|
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> {
|
export async function getBookingByReceiptToken(receiptToken: string): Promise<PublicBooking | null> {
|
||||||
@@ -924,7 +946,164 @@ export async function getBookingTransactionDocument(input: {
|
|||||||
limit 1
|
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: {
|
export async function replaceBookingTransactionDocument(input: {
|
||||||
|
|||||||
@@ -106,6 +106,20 @@ export function parseBookingPicTransferInput(body: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parsePaymentMethodInput(body: {
|
||||||
|
paymentMethod?: PaymentMethod | string | null
|
||||||
|
}) {
|
||||||
|
const paymentMethod = typeof body.paymentMethod === 'string'
|
||||||
|
? body.paymentMethod.trim().toLowerCase()
|
||||||
|
: body.paymentMethod
|
||||||
|
|
||||||
|
assertBadRequest(isPaymentMethod(paymentMethod), 'Payment method must be Cash or Bank')
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentMethod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||||
if (booking.locale === 'zh') {
|
if (booking.locale === 'zh') {
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user