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:
@@ -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+$/);
|
||||
|
||||
Reference in New Issue
Block a user