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:
2026-08-22 00:34:49 +01:00
parent 5e3dc9b428
commit 4b28e93710
23 changed files with 1953 additions and 366 deletions
@@ -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>
+47
View File
@@ -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">