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">
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<h1 class="mb-2 border-b border-gray-200 pb-4 text-2xl font-bold">
|
||||
Booking, Deposit & Cancellation Policy
|
||||
</h1>
|
||||
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: 18 June 2026</p>
|
||||
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: 5 August 2026</p>
|
||||
|
||||
{#if format === 'pdf' && pdfNotice}
|
||||
<p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic">
|
||||
@@ -66,6 +66,11 @@
|
||||
on your account, appointments must be scheduled at least
|
||||
<strong>36 hours in advance</strong>.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
Payments toward your booking are capped at 100% of the total booking value based on service prices at time of booking.
|
||||
Once you have paid the full amount, any additional payments above 100% of the booking value will be processed as tips (see
|
||||
Section 8 below).
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
To maintain fairness and prevent scheduling abuse, accounts with outstanding deposit
|
||||
requirements are limited to <strong>one (1) active booking</strong> at any given time. No additional
|
||||
@@ -100,26 +105,28 @@
|
||||
<!-- Section 3 -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Cancellation & Refund Tiers</h2>
|
||||
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund before service</h3>
|
||||
<p class="mb-3">
|
||||
We understand that plans can change. Eligibility for a refund depends strictly on the amount
|
||||
We understand that plans can change. Eligibility for a refund before your service depends strictly on the amount
|
||||
of notice provided prior to your scheduled appointment time. These thresholds represent a
|
||||
genuine pre-estimate of the operational costs and loss of business incurred by late
|
||||
cancellations:
|
||||
cancellations. Refunds apply to <strong>booking payments only</strong>, up to 100% of the total
|
||||
booking value.
|
||||
</p>
|
||||
|
||||
|
||||
<div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200">
|
||||
<div class="bg-gray-50/50 p-4">
|
||||
<p class="font-semibold text-gray-900">Notice of more than 72 hours</p>
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
You are entitled to a full 100% refund of all deposits and advance payments made for the
|
||||
booking.
|
||||
You are entitled to a full 100% refund of all booking payments made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="font-semibold text-gray-900">Notice between 24 and 72 hours</p>
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
Any advance payments made up to 50% of the total booking value are treated as a
|
||||
Any booking payments made up to 50% of the total booking value are treated as a
|
||||
Protected Deposit. This Protected Deposit is retained to cover the short-notice vacancy,
|
||||
while any balance paid above 50% will be fully refunded.
|
||||
</p>
|
||||
@@ -128,12 +135,21 @@
|
||||
<div class="bg-gray-50/50 p-4">
|
||||
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
All processed payments and deposits are entirely non-refundable and will be retained.
|
||||
The cancellation will be logged as a missed appointment history strike.
|
||||
All booking payments and deposits are entirely non-refundable and will be retained. The cancellation will be logged as a missed
|
||||
appointment history strike.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund after service</h3>
|
||||
|
||||
<p class="mb-3 text-sm leading-relaxed text-gray-700">
|
||||
Refunds after booked appoinments have been carried out are at the salon owners discretion based on the booking and reason, to arrange a refund please <a
|
||||
href={resolve('/contact')}
|
||||
class="font-medium text-blue-600 underline hover:text-blue-800">contact</a> us to discuss a fair refund up to 100% of the value of the booking. Any paid tips will not be considered as part of the refund as they are processed differently.
|
||||
</p>
|
||||
|
||||
|
||||
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund Payment Method</h3>
|
||||
<p class="mb-3 text-sm leading-relaxed text-gray-700">
|
||||
Refunds are returned to the original payment method where possible:
|
||||
@@ -269,5 +285,30 @@
|
||||
> to get in touch.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Section 8: Tips -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Tips</h2>
|
||||
<p class="mb-3">
|
||||
Tips are voluntary payments made in addition to the booking total. Tips can only be added
|
||||
<strong>after your booking has started</strong> — they cannot be paid in advance.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
Unlike booking payments, <strong>tips are not refundable</strong>. Tips are not considered
|
||||
payment for the service itself, but rather a voluntary expression of satisfaction. If you
|
||||
cancel your booking, any tips already paid will not be included in the cancellation refund.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
Tips can be added via your account after the booking has started, or at the time of payment
|
||||
when paying in person at the salon. When paying by card at the terminal, you will be prompted
|
||||
to add a tip if you wish.
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500 italic">
|
||||
If you believe a tip was added in error, please contact us via our official
|
||||
<a href={resolve('/contact')} class="font-medium text-blue-600 underline hover:text-blue-800"
|
||||
>Contact Channels</a
|
||||
> and we will review your case.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,6 +176,15 @@
|
||||
booking_id?: string;
|
||||
changed_at: string;
|
||||
}>;
|
||||
disputes?: Array<{
|
||||
dispute_id: string;
|
||||
payment_id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
reason?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
login_audit?: Array<{
|
||||
attempt_type: string;
|
||||
ip_address: string;
|
||||
@@ -1212,6 +1221,44 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Disputes -->
|
||||
{#if gdprData.disputes && gdprData.disputes.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
<Card.Header><Card.Title>Payment Disputes</Card.Title></Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Date</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Amount</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Reason</th><th
|
||||
class="px-4 py-3 font-medium text-gray-500">Payment</th
|
||||
><th class="px-4 py-3 font-medium text-gray-500">Status</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each gdprData.disputes as d (d.dispute_id)}
|
||||
<tr class="border-b last:border-b-0">
|
||||
<td class="px-4 py-3 whitespace-nowrap">{fmtDate(d.created_at)}</td>
|
||||
<td class="px-4 py-3 font-medium whitespace-nowrap">{fmt(d.amount)}</td>
|
||||
<td class="px-4 py-3">{d.reason || '—'}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500">{d.payment_id}</td>
|
||||
<td class="px-4 py-3"
|
||||
><span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {badge(d.status)}"
|
||||
>{d.status}</span
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Requests -->
|
||||
{#if gdprData.edit_requests && gdprData.edit_requests.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
|
||||
Reference in New Issue
Block a user