fix: tip double-count, fully-paid auto-completion, discount-refund hardening
Tip double-count (root cause of £33.75 vs £28.75 display):
- Remove mock's fixed +500p auto-tip when AllowTipping is true (square_dev.go) —
real Square only enables a terminal prompt, it never adds a tip to the amount
- Set AllowTipping=false in CreateTerminalPayment: the frontend already embeds
the tip in the amount, so the terminal must not prompt for a second tip
- M4 tip split now derives the tip as charged amount minus remaining booking
value ('after 100% is tips'), not from Square's TipAmount field
- Success screens divide paymentResult.amount by 100 (pence -> pounds) in both
PaymentModal and UserPaymentModal
Fully-paid bookings auto-complete:
- Extract ApplyBookingCompletionSideEffects into payments package (shared by
admin progress endpoint and payment paths; avoids circular import)
- Add bookingIsFullyPaid + completeFullyPaidBooking: when completed non-tip
payments reach 100% of the booking total, an active booking transitions to
'completed' so it leaves the admin Current Appointment view
- Wired into CreateBookingPayment (inside tx) and GetCheckoutStatus (terminal,
after commit); completion side-effects (loyalty, campaigns, deposits_required)
fire identically to the manual progress endpoint
- Add /admin/bookings/{id}/refund route (AdminRefundBooking)
Discount-refund hardening:
- RefundPayment explicitly rejects discount/on_the_house payments (was relying
on the incidental NULL-square_payment_id guard)
- Hide the Refund button for discount/on_the_house payments in EditBookingModal
- Cancel-refund estimate in BookingModal also excludes on_the_house
- Cancellation refund loop + GetBookingPaymentInfo + GetBookingRefundableAmountCents
exclude payment_type='tip' from refundable totals
Tip flow (start-time guard) fixes tests:
- Tip tests updated to use past-dated bookings (tips now require booking started)
Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
This commit is contained in:
@@ -37,7 +37,12 @@
|
||||
// Derived values for cancel confirmation
|
||||
const totalPaid = $derived(
|
||||
(selectedBooking?.payments ?? [])
|
||||
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
||||
.filter(
|
||||
(p) =>
|
||||
p.payment_method !== 'discount' &&
|
||||
p.payment_method !== 'on_the_house' &&
|
||||
p.status === 'completed'
|
||||
)
|
||||
.reduce((sum, p) => sum + p.amount, 0)
|
||||
);
|
||||
const totalRefunds = $derived(
|
||||
|
||||
@@ -606,7 +606,7 @@
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{#if payment.status === 'completed'}
|
||||
{#if payment.status === 'completed' && payment.payment_method !== 'discount' && payment.payment_method !== 'on_the_house'}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -339,8 +339,11 @@ import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
last4: data.card_last4,
|
||||
amount: data.amount
|
||||
};
|
||||
toast.success('Payment successful');
|
||||
onComplete(paymentResult);
|
||||
// Deliberately do NOT call onComplete() here: it would make
|
||||
// the parent close this modal instantly, so the green-tick
|
||||
// success state would never be seen. The modal stays open
|
||||
// showing the tick until the operator clicks Done, which
|
||||
// fires handleSuccessDone() → onComplete + onClose.
|
||||
} else if (data.status === 'FAILED') {
|
||||
stopPolling();
|
||||
status = 'error';
|
||||
@@ -368,6 +371,17 @@ import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Called from the success state's Done button: notify the parent (so it can
|
||||
// refresh the booking/payment data) and then close the modal. Kept separate
|
||||
// from handleClose so a success state never closes without the callback.
|
||||
function handleSuccessDone() {
|
||||
stopPolling();
|
||||
if (paymentResult) {
|
||||
onComplete(paymentResult);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function resetToSelect() {
|
||||
stopPolling();
|
||||
status = 'idle';
|
||||
@@ -1379,7 +1393,7 @@ import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Amount</span>
|
||||
<span class="font-semibold text-gray-900">
|
||||
{formatCurrency(paymentResult.amount)}
|
||||
{formatCurrency(paymentResult.amount / 100)}
|
||||
</span>
|
||||
</div>
|
||||
{#if paymentResult.card_brand}
|
||||
@@ -1397,7 +1411,7 @@ import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={handleClose} class="w-full">Done</Button>
|
||||
<Button onclick={handleSuccessDone} class="w-full">Done</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -964,7 +964,7 @@
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Amount</span>
|
||||
<span class="font-semibold text-gray-900">
|
||||
{formatCurrency(paymentResult.amount)}
|
||||
{formatCurrency(paymentResult.amount / 100)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
|
||||
Reference in New Issue
Block a user