fix: adminnotify observability — money-critical rows sort first, flood-cap suppression surfaced to operator, stale coordination doc fixed

- notifications priority ordering: money-critical reasons (webhooks, sweeps, refunds, gift-card, manual-refund failures) above routine
- admin notifications page exposes the flood-cap suppressed count
- adminnotify.go contract doc: removed stale 2FA reissue-fail site, current insert-site list

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent e9b34d0ad6
commit 049c361e16
16 changed files with 1048 additions and 314 deletions
@@ -10,8 +10,10 @@
import { apiFetch } from '$lib/utils/api';
import { formatCurrency } from '$lib/utils/format';
import {
buildCashTillPaymentBody,
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
cashChargeBasePence,
isOverflowTipConfirmationRequired,
isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD,
@@ -558,7 +560,20 @@
let cashAmount = $state<string>('');
const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
const changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0);
// Cash charge base in pence. The backend's CreateTerminalPayment derives the
// tip from `amount remaining`, and `remaining` (GetBookingRemainingBalancePence)
// does NOT subtract the pending campaign discount — so the campaign preview
// must be restored into the charge base or the tip is absorbed into booking
// credit. Shared with the change/tip display so every cash figure agrees.
const cashDuePence = $derived(
cashChargeBasePence(
Math.round(totalDue * 100),
campaignDiscountPence(discountPreview),
loyaltyDiscount
)
);
const cashDue = $derived(cashDuePence / 100);
const changeDue = $derived(cashAmountNum > cashDue ? cashAmountNum - cashDue : 0);
let extraAsTip = $state(false);
function handleCashInput(e: Event) {
@@ -571,7 +586,6 @@
async function handleCashPayment() {
if (isProcessingPaymentSync) return;
const cashDue = totalDue - loyaltyDiscount / 100;
if (cashDue <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
@@ -591,15 +605,11 @@
try {
await applyLoyaltyRedemption();
const body: Record<string, unknown> = {
amount: Math.round(cashDue * 100),
payment_type: 'full',
payment_method: 'cash'
};
if (tipAmount > 0) {
body.tip_enabled = true;
body.tip_amount = Math.round(tipAmount * 100);
}
// Cash till-sale body: the tip is FOLDED into the amount (the backend's
// CreateTerminalPaymentRequest derives the tip from `amount - remaining`
// when tip_enabled; it has no tip_amount field, so sending one would
// silently drop the tip).
const body = buildCashTillPaymentBody(Math.round(cashDue * 100), Math.round(tipAmount * 100));
const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
@@ -890,22 +900,21 @@
status = 'saved-card-processing';
}
const response = await submitPaymentWithRetry(
() =>
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
// C1: the SCA tokenize-result token is the charge SOURCE
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
idempotency_key: savedCardIdempotencyKey
})
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
// C1: the SCA tokenize-result token is the charge SOURCE
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
idempotency_key: savedCardIdempotencyKey
})
})
);
@@ -996,7 +1005,7 @@
$effect(() => {
if (selectedMethod === 'cash') {
cashAmount = totalDue.toFixed(2);
cashAmount = cashDue.toFixed(2);
extraAsTip = false;
}
if (selectedMethod === 'giftcard') {
@@ -1387,7 +1396,7 @@
{#each tipPercentages as tip (tip.pct)}
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none hover:bg-fuchsia-50 {selectedTipPercent ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedTipPercent ===
tip.pct
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
@@ -1447,7 +1456,7 @@
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(cashDue)}</span>
</div>
<div>
@@ -1465,7 +1474,7 @@
</div>
</div>
{#if cashAmountNum >= totalDue}
{#if cashAmountNum >= cashDue}
<div class="rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">Change Due</span>
@@ -1487,7 +1496,7 @@
<Button
onclick={handleCashPayment}
class="min-h-11 flex-1"
disabled={cashAmountNum < totalDue || nothingToCharge}
disabled={cashAmountNum < cashDue || nothingToCharge}
>
Confirm Cash
</Button>