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
+161 -1
View File
@@ -8,8 +8,10 @@ import {
SCA_REFUSAL_MESSAGE_TILL,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
buildCashTillPaymentBody,
campaignDiscountPence,
canSaveCardsForRole,
cashChargeBasePence,
depositChargePence,
isAmbiguousPaymentFailure,
isNonceStale,
@@ -20,11 +22,13 @@ import {
newCardTokenizeResult,
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
resendEmailVerification,
sanitizeDecimalInput,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
type SavedCardVerificationOutcome
type SavedCardVerificationOutcome,
verifyEmailCode
} from './square';
import type * as SquareModule from './square';
@@ -199,6 +203,74 @@ describe('canSaveCardsForRole', () => {
});
});
describe('buildCashTillPaymentBody', () => {
it('sends the due amount alone with no tip flag when no tip applies', () => {
// `tip_enabled` is intentionally absent (undefined) — the old inline
// bodies only set it when a tip actually existed.
expect(buildCashTillPaymentBody(4000, 0)).toEqual({
amount: 4000,
payment_type: 'full',
payment_method: 'cash'
});
expect(buildCashTillPaymentBody(4000, 0).tip_enabled).toBeUndefined();
});
it('folds the tip into the amount and flags tip_enabled (backend has no tip_amount field)', () => {
// CreateTerminalPaymentRequest derives the tip from `amount - remaining`
// when tip_enabled — the tip must ride inside `amount`, never as a dead
// `tip_amount` key.
expect(buildCashTillPaymentBody(4000, 500)).toEqual({
amount: 4500,
payment_type: 'full',
payment_method: 'cash',
tip_enabled: true
});
});
it('always carries the full/cash payment contract the backend keys on', () => {
expect(buildCashTillPaymentBody(4000, 0)).toMatchObject({
payment_type: 'full',
payment_method: 'cash'
});
});
it('rounds via the caller; the helper sums the pence inputs verbatim', () => {
expect(buildCashTillPaymentBody(4000, 50).amount).toBe(4050);
});
});
describe('cashChargeBasePence', () => {
// Concrete arithmetic pinned to the FIX-1 scenario: booking £100, pending
// 10% campaign preview (£10), £10 loyalty redemption, £100 cash tender with
// the keep-change-as-tip checkbox on. netTotal = £90 (campaign subtracted),
// so the current charge base of £85 (totalDue loyalty) understates the
// backend's remaining basis and absorbs the tip.
it('restores the pending campaign credit into the charge base (tip carve basis)', () => {
// totalDuePence = £90 net (campaign subtracted, pre-loyalty)
expect(cashChargeBasePence(9000, 1000, 1000)).toBe(9000);
});
it('keeps the plain net total when no campaign is eligible', () => {
// totalDue = £90 (no campaign), loyalty £10 → base = £80 = the net obligation
expect(cashChargeBasePence(9000, 0, 1000)).toBe(8000);
});
it('never goes below zero (fully covered by discounts + loyalty)', () => {
expect(cashChargeBasePence(1000, 0, 5000)).toBe(0);
});
it('the folded tip body uses the base, so UI tip == backend-recorded tip', () => {
// Booking £100, campaign £10, loyalty £10: base = 9000 (100 20 + 10).
// Tender £100 → tip £10 → amount £100. The backend carves against the
// full £100 remaining, so it records £0 tip — matching the UI claim
// that only the amount above the charge base is a tip.
const basePence = cashChargeBasePence(9000, 1000, 1000);
const tipPence = 10000 - basePence;
expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(10000);
expect(tipPence).toBe(1000);
});
});
describe('payment failure classification', () => {
it('a definitive 402 on a saved-card charge is an issuer verification failure', () => {
expect(isSavedCardVerificationRequired(402, true)).toBe(true);
@@ -746,6 +818,94 @@ describe('adminRequestNewTwoFactorCode', () => {
});
});
describe('resendEmailVerification', () => {
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to /api/verify/generate with the email and email_verify purpose', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true, message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
const response = await resendEmailVerification('user@example.com');
expect(response.ok).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/verify/generate');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect(init.body).toBe(JSON.stringify({ email: 'user@example.com', purpose: 'email_verify' }));
});
it('passes through the backend response (the caller surfaces success/message)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ success: false, message: 'cooldown' }, 429));
vi.stubGlobal('fetch', fetchMock);
const response = await resendEmailVerification('user@example.com');
expect(response.status).toBe(429);
expect(response.ok).toBe(false);
});
});
describe('verifyEmailCode', () => {
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to /api/verify/check with the email, code and email_verify purpose', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' }));
vi.stubGlobal('fetch', fetchMock);
const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6');
expect(response.ok).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/verify/check');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect(init.body).toBe(
JSON.stringify({
email: 'user@example.com',
code: 'a1b2c3d4e5f6',
purpose: 'email_verify'
})
);
});
it('passes through the backend success response body', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' }));
vi.stubGlobal('fetch', fetchMock);
const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6');
const data = await response.json();
expect(data).toEqual({ success: true, message: 'Email verified successfully' });
});
it('passes through a failed verification (invalid/expired code)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('invalid or expired code', { status: 400 }));
vi.stubGlobal('fetch', fetchMock);
const response = await verifyEmailCode('user@example.com', 'wrong-code');
expect(response.status).toBe(400);
expect(response.ok).toBe(false);
});
});
describe('SCA-unavailable fallback consent (C6)', () => {
it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => {
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);