-// elements, so we scope to the table cell to avoid ambiguity.
-function getBadgeText(status) {
- return screen.getAllByText(status).find(
- (el) => el.tagName === 'SPAN' && el.className.includes('rounded-full')
- );
-}
-
describe('KycReview Component', () => {
it('renders KYC profiles and handles approval mutation', async () => {
render(
diff --git a/apps/admin/src/pages/TransactionDetail.jsx b/apps/admin/src/pages/TransactionDetail.jsx
index 3a1a521d..5fc144f1 100644
--- a/apps/admin/src/pages/TransactionDetail.jsx
+++ b/apps/admin/src/pages/TransactionDetail.jsx
@@ -5,6 +5,15 @@ import { formatDate } from '@shared/formatDate';
import StatusBadge from '@/components/StatusBadge';
import Loader from '@shared/Loader';
+const Field = ({ label, value, mono = false, children }) => (
+
+
{label}
+
+ {children ?? (value !== undefined && value !== null ? String(value) : — )}
+
+
+);
+
/**
* Transaction detail / drill-down page.
* Route: /transactions/:id
@@ -60,15 +69,6 @@ export default function TransactionDetail() {
if (!tx) return null;
- const Field = ({ label, value, mono = false, children }) => (
-
-
{label}
-
- {children ?? (value !== undefined && value !== null ? String(value) : — )}
-
-
- );
-
return (
{/* Header */}
diff --git a/apps/admin/src/pages/Users.jsx b/apps/admin/src/pages/Users.jsx
index af43c073..2c387e11 100644
--- a/apps/admin/src/pages/Users.jsx
+++ b/apps/admin/src/pages/Users.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import {
getAdminUsers,
getUserOnboardingStatus,
@@ -37,7 +37,7 @@ export default function Users() {
const [reactivateNotes, setReactivateNotes] = useState('');
const [reactivateApprovedBy, setReactivateApprovedBy] = useState('');
- const fetchUsers = async () => {
+ const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminUsers(params);
@@ -48,11 +48,13 @@ export default function Users() {
} finally {
setLoading(false);
}
- };
+ }, [params]);
useEffect(() => {
- fetchUsers();
- }, [params]);
+ // Defer out of the synchronous effect body to avoid cascading re-renders.
+ const timer = setTimeout(fetchUsers, 0);
+ return () => clearTimeout(timer);
+ }, [fetchUsers]);
const handleViewOnboarding = async (user) => {
setOnboardingUser(user);
diff --git a/apps/api/prisma/migrations/20260829_add_reconciliation_support_multitenancy/migration.sql b/apps/api/prisma/migrations/20260829_add_reconciliation_support_multitenancy/migration.sql
index 9cfc5ac1..b2c6a472 100644
--- a/apps/api/prisma/migrations/20260829_add_reconciliation_support_multitenancy/migration.sql
+++ b/apps/api/prisma/migrations/20260829_add_reconciliation_support_multitenancy/migration.sql
@@ -67,7 +67,8 @@ CREATE INDEX "SupportCase_userId_status_idx" ON "SupportCase"("userId", "status"
CREATE INDEX "SupportCase_status_createdAt_idx" ON "SupportCase"("status", "createdAt");
CREATE INDEX "SupportCase_priority_status_idx" ON "SupportCase"("priority", "status");
CREATE INDEX "SupportCase_assignedTo_idx" ON "SupportCase"("assignedTo");
-CREATE UNIQUE INDEX "SupportCase_caseNumber_key" ON "SupportCase"("caseNumber");
+-- caseNumber is declared UNIQUE inline, which already creates the
+-- SupportCase_caseNumber_key index; an explicit index with that name would collide.
ALTER TABLE "SupportCase" ADD CONSTRAINT "SupportCase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/20260831120000_alert_delivery_verification/migration.sql b/apps/api/prisma/migrations/20260831120000_alert_delivery_verification/migration.sql
new file mode 100644
index 00000000..6b5fd961
--- /dev/null
+++ b/apps/api/prisma/migrations/20260831120000_alert_delivery_verification/migration.sql
@@ -0,0 +1,56 @@
+-- Migration: Continuous alert-delivery verification
+-- Issue #228 — synthetic end-to-end alert-delivery tests with fallback routing,
+-- delivery acknowledgement tracking, missed-test detection, and a persisted
+-- last-successful-verification state.
+
+-- ─── AlertDeliveryTest: one row per synthetic end-to-end alert test ─────────
+-- `testId` is deterministic per interval epoch so duplicate scheduler
+-- executions collide on the unique key and can never create an alert storm.
+-- `routes` is a JSON array of per-route outcomes (primary text + optional
+-- template fallback); delivery confirmation is reconciled from the linked
+-- Notification's provider delivery status.
+CREATE TABLE "AlertDeliveryTest" (
+ "id" TEXT NOT NULL,
+ "testId" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'dispatched',
+ "recipient" TEXT NOT NULL,
+ "routes" JSONB NOT NULL DEFAULT '[]',
+ "primaryRoute" TEXT NOT NULL DEFAULT 'whatsapp-text',
+ "fallbackUsed" BOOLEAN NOT NULL DEFAULT false,
+ "providerMessageId" TEXT,
+ "syncOutcome" TEXT,
+ "attemptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "acceptedAt" TIMESTAMP(3),
+ "confirmedAt" TIMESTAMP(3),
+ "failedAt" TIMESTAMP(3),
+ "timeoutAt" TIMESTAMP(3),
+ "failureReason" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AlertDeliveryTest_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "AlertDeliveryTest_testId_key" ON "AlertDeliveryTest"("testId");
+CREATE INDEX "AlertDeliveryTest_status_idx" ON "AlertDeliveryTest"("status");
+CREATE INDEX "AlertDeliveryTest_attemptedAt_idx" ON "AlertDeliveryTest"("attemptedAt");
+
+-- ─── AlertDeliveryState: singleton operational status ───────────────────────
+-- Holds the current health (healthy|degraded|failed|unknown|disabled) and the
+-- last successful end-to-end verification timestamp. A failed test updates
+-- failure fields but never clears lastSuccessfulTestAt.
+CREATE TABLE "AlertDeliveryState" (
+ "id" TEXT NOT NULL DEFAULT 'main',
+ "enabled" BOOLEAN NOT NULL DEFAULT false,
+ "overallStatus" TEXT NOT NULL DEFAULT 'unknown',
+ "lastSuccessfulTestAt" TIMESTAMP(3),
+ "lastTestId" TEXT,
+ "lastDispatchAt" TIMESTAMP(3),
+ "lastFailureAt" TIMESTAMP(3),
+ "lastFailureReason" TEXT,
+ "lastFailureDetail" JSONB NOT NULL DEFAULT '{}',
+ "routesDiagnostics" JSONB NOT NULL DEFAULT '{}',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AlertDeliveryState_pkey" PRIMARY KEY ("id")
+);
\ No newline at end of file
diff --git a/apps/api/prisma/migrations/20260831130000_wallet_key_version/migration.sql b/apps/api/prisma/migrations/20260831130000_wallet_key_version/migration.sql
new file mode 100644
index 00000000..821ac816
--- /dev/null
+++ b/apps/api/prisma/migrations/20260831130000_wallet_key_version/migration.sql
@@ -0,0 +1,7 @@
+-- Add versioned KMS key metadata to Wallet (schema drift fix).
+-- The schema declares keyVersion (default 'v1') for the versioned-key crypto
+-- rotation feature, but no migration ever added the column, so fresh
+-- `prisma migrate deploy` databases are out of sync with the schema and the
+-- seed script (which upserts keyVersion) fails. Existing rows default to v1.
+ALTER TABLE "Wallet"
+ ADD COLUMN "keyVersion" TEXT DEFAULT 'v1';
diff --git a/apps/api/prisma/migrations/20260831140000_sync_schema_to_migrations/migration.sql b/apps/api/prisma/migrations/20260831140000_sync_schema_to_migrations/migration.sql
new file mode 100644
index 00000000..cc7acb1d
--- /dev/null
+++ b/apps/api/prisma/migrations/20260831140000_sync_schema_to_migrations/migration.sql
@@ -0,0 +1,140 @@
+-- Sync the migration-produced database with the current Prisma schema.
+--
+-- Several schema changes were committed without a matching migration:
+-- • KycApproval, SanctionsScreeningResult, DepositOutboxRecord models have no
+-- CREATE TABLE migration (the seed and integration tests hit them)
+-- • AuditLog.hash / previousHash (hash-chained audit trail) have no migration
+-- • Agent / Escrow / CashoutLocation were removed from the schema but the
+-- init migration still creates them
+-- • AdminUser_mustChangePassword_idx was dropped from the schema
+-- • WhatsappStatusEvent unique index name was normalized by Prisma
+--
+-- This migration applies the same DDL `prisma migrate dev` would have
+-- generated so fresh `prisma migrate deploy` databases match the schema.
+
+-- DropForeignKey
+ALTER TABLE "Agent" DROP CONSTRAINT "Agent_locationId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "Escrow" DROP CONSTRAINT "Escrow_arbiterId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "Escrow" DROP CONSTRAINT "Escrow_creatorId_fkey";
+
+-- DropIndex
+DROP INDEX "AdminUser_mustChangePassword_idx";
+
+-- AlterTable
+ALTER TABLE "AuditLog" ADD COLUMN "hash" TEXT,
+ADD COLUMN "previousHash" TEXT;
+
+-- DropTable
+DROP TABLE "Agent";
+
+-- DropTable
+DROP TABLE "CashoutLocation";
+
+-- DropTable
+DROP TABLE "Escrow";
+
+-- CreateTable
+CREATE TABLE "SanctionsScreeningResult" (
+ "id" TEXT NOT NULL,
+ "profileId" TEXT NOT NULL,
+ "subjectId" TEXT NOT NULL,
+ "subjectType" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "listVersion" TEXT NOT NULL,
+ "status" TEXT NOT NULL,
+ "reason" TEXT NOT NULL,
+ "matches" JSONB NOT NULL DEFAULT '[]',
+ "screenedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "decisionOwner" TEXT,
+ "resolvedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "SanctionsScreeningResult_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "KycApproval" (
+ "id" TEXT NOT NULL,
+ "profileId" TEXT NOT NULL,
+ "proposedChanges" JSONB NOT NULL,
+ "requestedBy" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "approvedBy" TEXT,
+ "decidedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "KycApproval_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DepositOutboxRecord" (
+ "id" TEXT NOT NULL,
+ "stellarPaymentId" TEXT NOT NULL,
+ "walletId" TEXT NOT NULL,
+ "userId" TEXT,
+ "phoneNumber" TEXT NOT NULL,
+ "amount" TEXT NOT NULL,
+ "asset" TEXT NOT NULL,
+ "fiatRate" DOUBLE PRECISION,
+ "message" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "attempts" INTEGER NOT NULL DEFAULT 0,
+ "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+ "lastError" TEXT,
+ "providerMessageId" TEXT,
+ "deliveredAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "DepositOutboxRecord_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "SanctionsScreeningResult_profileId_screenedAt_idx" ON "SanctionsScreeningResult"("profileId", "screenedAt");
+
+-- CreateIndex
+CREATE INDEX "SanctionsScreeningResult_subjectId_screenedAt_idx" ON "SanctionsScreeningResult"("subjectId", "screenedAt");
+
+-- CreateIndex
+CREATE INDEX "SanctionsScreeningResult_status_screenedAt_idx" ON "SanctionsScreeningResult"("status", "screenedAt");
+
+-- CreateIndex
+CREATE INDEX "SanctionsScreeningResult_provider_listVersion_idx" ON "SanctionsScreeningResult"("provider", "listVersion");
+
+-- CreateIndex
+CREATE INDEX "KycApproval_profileId_idx" ON "KycApproval"("profileId");
+
+-- CreateIndex
+CREATE INDEX "KycApproval_status_idx" ON "KycApproval"("status");
+
+-- CreateIndex
+CREATE INDEX "KycApproval_requestedBy_idx" ON "KycApproval"("requestedBy");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DepositOutboxRecord_stellarPaymentId_key" ON "DepositOutboxRecord"("stellarPaymentId");
+
+-- CreateIndex
+CREATE INDEX "DepositOutboxRecord_status_createdAt_idx" ON "DepositOutboxRecord"("status", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "DepositOutboxRecord_walletId_idx" ON "DepositOutboxRecord"("walletId");
+
+-- CreateIndex
+CREATE INDEX "DepositOutboxRecord_stellarPaymentId_idx" ON "DepositOutboxRecord"("stellarPaymentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AuditLog_previousHash_key" ON "AuditLog"("previousHash");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AuditLog_hash_key" ON "AuditLog"("hash");
+
+-- AddForeignKey
+ALTER TABLE "SanctionsScreeningResult" ADD CONSTRAINT "SanctionsScreeningResult_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "KycProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- RenameIndex
+ALTER INDEX "WhatsappStatusEvent_providerMessageId_status_statusTimest_key" RENAME TO "WhatsappStatusEvent_providerMessageId_status_statusTimestam_key";
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 519116ef..8accd0f1 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -929,6 +929,55 @@ model AccountStatusRecord {
@@index([status, createdAt])
}
+// ─── Issue #228: Continuous alert-delivery verification ─────────────────────
+// Synthetic end-to-end alert-delivery test runs. A test is dispatched on a
+// configured schedule to an internal test recipient through the real outbound
+// pipeline (primary WhatsApp text route with an optional template fallback),
+// then confirmed from the linked Notification's provider delivery status
+// (delivered/read) or marked failed/timed-out. `testId` is deterministic per
+// interval epoch so duplicate scheduler executions collide on the unique key
+// and can never create an alert storm.
+model AlertDeliveryTest {
+ id String @id @default(cuid())
+ testId String @unique
+ status String @default("dispatched") // dispatched|accepted|confirmed|failed|timed_out
+ recipient String
+ routes Json @default("[]") // per-route outcome: [{name,outcome,providerMessageId?,error?,attemptedAt}]
+ primaryRoute String @default("whatsapp-text")
+ fallbackUsed Boolean @default(false)
+ providerMessageId String?
+ syncOutcome String? // accepted|failed|unknown
+ attemptedAt DateTime @default(now())
+ acceptedAt DateTime?
+ confirmedAt DateTime?
+ failedAt DateTime?
+ timeoutAt DateTime?
+ failureReason String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([status])
+ @@index([attemptedAt])
+}
+
+// Singleton operational state exposed to operators via the admin API. Keeps the
+// last successful end-to-end verification and current health so a failed test
+// never overwrites the last successful timestamp (a missed verification stays
+// actionable).
+model AlertDeliveryState {
+ id String @id @default("main")
+ enabled Boolean @default(false)
+ overallStatus String @default("unknown") // healthy|degraded|failed|unknown|disabled
+ lastSuccessfulTestAt DateTime?
+ lastTestId String?
+ lastDispatchAt DateTime?
+ lastFailureAt DateTime?
+ lastFailureReason String?
+ lastFailureDetail Json @default("{}")
+ routesDiagnostics Json @default("{}")
+ updatedAt DateTime @updatedAt
+}
+
// Multi-tenant / partner isolation marker for routing and authorization scoping.
// Allows future onboarding of partners while enforcing data isolation server-side.
model Partner {
diff --git a/apps/api/scripts/rotate-wallet-keys.js b/apps/api/scripts/rotate-wallet-keys.js
index 7f145062..431b6444 100644
--- a/apps/api/scripts/rotate-wallet-keys.js
+++ b/apps/api/scripts/rotate-wallet-keys.js
@@ -39,7 +39,6 @@ const rotateWalletKeys = async ({
encryptedSecretKey: { not: null },
},
select: {
- id: { select: false }, // avoid logging sensitive identifiers unnecessarily
id: true,
publicKey: true,
encryptedSecretKey: true,
diff --git a/apps/api/src/common/prisma.js b/apps/api/src/common/prisma.js
index ce2050c8..071be7ba 100644
--- a/apps/api/src/common/prisma.js
+++ b/apps/api/src/common/prisma.js
@@ -4,7 +4,7 @@ const { Pool } = require('pg');
const config = require('../config/env');
const { increment, observeDuration, setGauge } = require('../observability/metrics');
-const dbUrl = config.databaseUrl || process.env.DATABASE_URL || 'postgresql://user:pass@localhost:5432/sendam_dev';
+const dbUrl = config.databaseUrl || process.env.DATABASE_URL || 'postgresql://localhost:5432/sendam_dev';
if (!dbUrl) {
throw new Error('DATABASE_URL must be set. Use your Neon PostgreSQL connection string.');
@@ -28,6 +28,10 @@ pool.connect = (...args) => {
}
const started = process.hrtime.bigint();
let expired = false;
+ // pg-pool invokes connect() both promise-style (no args) and callback-style
+ // (a callback argument, e.g. from Pool#query). Callback mode returns
+ // undefined and resolves the callback later, so the timeout race below only
+ // applies to the promise path.
const connection = connectFromPool(...args);
if (connection && typeof connection.then === 'function') {
connection.then((client) => {
diff --git a/apps/api/src/common/validation.js b/apps/api/src/common/validation.js
index 16a317e1..17cbcff5 100644
--- a/apps/api/src/common/validation.js
+++ b/apps/api/src/common/validation.js
@@ -243,7 +243,7 @@ const validatePayload = (schemaName, body) => {
* @param {string} schemaName
* @param {{ allowUnknown?: boolean }} [options]
*/
-const validateExternalPayload = (schemaName, options = {}) => (req, res, next) => {
+const validateExternalPayload = (schemaName, _options = {}) => (req, res, next) => {
const { valid, errors } = validatePayload(schemaName, req.body);
if (!valid) {
diff --git a/apps/api/src/compliance/compliance.controller.js b/apps/api/src/compliance/compliance.controller.js
index 84aa39f6..59deeb98 100644
--- a/apps/api/src/compliance/compliance.controller.js
+++ b/apps/api/src/compliance/compliance.controller.js
@@ -18,7 +18,6 @@ const {
TransitionError,
} = require('./kyc.transitions');
const { getOnboardingStatus: computeOnboardingStatus } = require('./onboarding.service');
-const logger = require('../utils/logger');
const getProfile = async (req, res, next) => {
try {
diff --git a/apps/api/src/compliance/compliance.service.js b/apps/api/src/compliance/compliance.service.js
index 3da2e32c..0d84f0ab 100644
--- a/apps/api/src/compliance/compliance.service.js
+++ b/apps/api/src/compliance/compliance.service.js
@@ -13,6 +13,7 @@ const defaultTierLimits = {
};
const policyCurrency = () => String(config.compliance?.policyCurrency || 'NGN').trim().toUpperCase();
+const getPolicyCurrency = policyCurrency;
const canonicalizePolicyAmount = (value, currency) => {
const rule = getAssetRule(currency);
@@ -300,8 +301,6 @@ const processSmileIdCallback = async (payload) => {
}
};
-const getPolicyCurrency = policyCurrency;
-
const calculateRiskScore = ({ amount, asset, routeType, destinationCountry, profileRiskScore = 0 }) => {
const riskAsset = asset || policyCurrency();
const normalizedAmount = canonicalizePolicyAmount(amount, riskAsset);
diff --git a/apps/api/src/compliance/consent.service.js b/apps/api/src/compliance/consent.service.js
index b090b32f..cf4ae88a 100644
--- a/apps/api/src/compliance/consent.service.js
+++ b/apps/api/src/compliance/consent.service.js
@@ -53,7 +53,7 @@ const updateUserConsent = async ({ userId, phoneNumber, consent, source = 'whats
},
},
});
- } catch (auditError) {
+ } catch (_auditError) {
// Non-blocking for notification workflow, log failure if needed
}
diff --git a/apps/api/src/compliance/privacy.service.js b/apps/api/src/compliance/privacy.service.js
index ae0220ad..2f3e0f96 100644
--- a/apps/api/src/compliance/privacy.service.js
+++ b/apps/api/src/compliance/privacy.service.js
@@ -2,7 +2,6 @@ const crypto = require('crypto');
const prisma = require('../common/prisma');
const { writeAuditLog } = require('../common/audit.service');
const retention = require('./retention');
-const { ProviderSkippedError } = require('./providerErrors');
const smileId = require('./smileId.provider');
const whatsapp = require('../services/whatsapp.service');
const voice = require('../voice/voice.service');
@@ -270,7 +269,7 @@ const buildTarget = (request) => {
// Idempotent: a second call on an already-anonymized user does not re-run local
// anonymization but still allows retrying failed provider tasks.
-const fulfillErasure = async (userId, { requestId, approvedBy } = {}) => {
+const fulfillErasure = async (userId, { requestId, approvedBy: _approvedBy } = {}) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
const error = new Error('User not found');
diff --git a/apps/api/src/config/env.js b/apps/api/src/config/env.js
index 09b5a632..cb6842b9 100644
--- a/apps/api/src/config/env.js
+++ b/apps/api/src/config/env.js
@@ -237,6 +237,20 @@ module.exports = {
// Default timeout in milliseconds for outgoing upstream HTTP calls.
upstreamTimeoutMs: Number(process.env.UPSTREAM_TIMEOUT_MS || 10000),
},
+ // Continuous alert-delivery verification (issue #228). Enabled automatically
+ // when ALERT_TEST_RECIPIENT is set; ALERT_DELIVERY_ENABLED=false is the hard
+ // kill switch. See docs/ALERT-DELIVERY-VERIFICATION.md.
+ alertDelivery: {
+ enabled: process.env.ALERT_DELIVERY_ENABLED != null
+ ? process.env.ALERT_DELIVERY_ENABLED === 'true'
+ : Boolean(process.env.ALERT_TEST_RECIPIENT),
+ recipient: process.env.ALERT_TEST_RECIPIENT,
+ intervalMs: Number(process.env.ALERT_DELIVERY_INTERVAL_MS || 3600000),
+ ackTimeoutMs: Number(process.env.ALERT_DELIVERY_ACK_TIMEOUT_MS || 600000),
+ missedFactor: Number(process.env.ALERT_DELIVERY_MISSED_FACTOR || 3),
+ templateName: process.env.ALERT_TEST_TEMPLATE_NAME,
+ templateLanguage: process.env.ALERT_TEST_TEMPLATE_LANGUAGE || 'en',
+ },
features: {
// Rollout/incident kill switch for SEP-10-authenticated REST operations.
// WhatsApp remains independently protected by verified webhook identity.
diff --git a/apps/api/src/config/validateEnv.js b/apps/api/src/config/validateEnv.js
index 36efa460..ac542b1a 100644
--- a/apps/api/src/config/validateEnv.js
+++ b/apps/api/src/config/validateEnv.js
@@ -131,6 +131,26 @@ const validateEnv = (config) => {
}
}
+ // Continuous alert-delivery verification (issue #228).
+ if (config.alertDelivery?.enabled) {
+ const pos = (v) => Number.isFinite(v) && v > 0;
+ if (!config.alertDelivery.recipient) {
+ problems.push('ALERT_TEST_RECIPIENT must be set when ALERT_DELIVERY_ENABLED is true.');
+ }
+ if (!pos(config.alertDelivery.intervalMs)) {
+ problems.push('ALERT_DELIVERY_INTERVAL_MS must be a positive number.');
+ }
+ if (!pos(config.alertDelivery.ackTimeoutMs)) {
+ problems.push('ALERT_DELIVERY_ACK_TIMEOUT_MS must be a positive number.');
+ }
+ if (!Number.isFinite(config.alertDelivery.missedFactor) || config.alertDelivery.missedFactor < 2) {
+ problems.push('ALERT_DELIVERY_MISSED_FACTOR must be a number of at least 2 (intervals of silence before a test is considered missed).');
+ }
+ if (config.alertDelivery.templateName && config.alertDelivery.templateName.length > 512) {
+ problems.push('ALERT_TEST_TEMPLATE_NAME must be 512 characters or fewer.');
+ }
+ }
+
if (problems.length > 0) {
throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`);
}
diff --git a/apps/api/src/controllers/admin.controller.js b/apps/api/src/controllers/admin.controller.js
index e10ee3d9..63bb6963 100644
--- a/apps/api/src/controllers/admin.controller.js
+++ b/apps/api/src/controllers/admin.controller.js
@@ -1,7 +1,7 @@
const { sendSuccess, sendError, sendCursorPaginated } = require('../utils/response');
const { authenticate, createInvitation, acceptInvitation, revokeSessions, hashPassword, changeOwnPassword } = require('../services/adminAuth.service');
const { writeAuditLog } = require('../common/audit.service');
-const { appendEvent, EVENT_TYPES, queryEvents, verifyEventChain: verifyEventChainService } = require('../common/event.service');
+const { queryEvents, verifyEventChain: verifyEventChainService } = require('../common/event.service');
const { deactivateAccount, reactivateAccount, getAccountStatusHistory, DEACTIVATION_REASONS } = require('../compliance/account.service');
const { getOnboardingStatus } = require('../compliance/onboarding.service');
const { buildUserEvidencePackage, exportWorkflowEventsCsv, exportKycEvidenceCsv, exportAccountStatusHistoryCsv } = require('../compliance/evidence.service');
@@ -560,12 +560,21 @@ const exportAuditLogs = async (req, res, next) => {
const getSystemHealth = async (_req, res, next) => {
try {
+ const { getStatus: getAlertDeliveryStatus } = require('../observability/alertDelivery.service');
+ let alertDelivery = 'disabled';
+ try {
+ const status = await getAlertDeliveryStatus({ db: prisma });
+ alertDelivery = status.overallStatus || 'unknown';
+ } catch (_error) {
+ alertDelivery = 'unavailable';
+ }
sendSuccess(res, {
api: 'ok',
database: 'ok',
queues: process.env.REDIS_URL || process.env.UPSTASH_REDIS_URL ? 'redis-configured' : 'unavailable',
settlementRail: 'stellar',
custodyModel: 'direct',
+ alertDelivery,
timestamp: new Date().toISOString(),
});
} catch (error) {
diff --git a/apps/api/src/controllers/alertDelivery.controller.js b/apps/api/src/controllers/alertDelivery.controller.js
new file mode 100644
index 00000000..3149d016
--- /dev/null
+++ b/apps/api/src/controllers/alertDelivery.controller.js
@@ -0,0 +1,22 @@
+'use strict';
+
+/**
+ * Admin surface for continuous alert-delivery verification (#228).
+ * Exposes the persisted operational state (overall health + last successful
+ * end-to-end verification) so operators can tell at a glance whether the
+ * alert-routing pipeline has been verified recently. Read-only; never returns
+ * recipients or secrets.
+ */
+const prisma = require('../common/prisma');
+const { getStatus } = require('../observability/alertDelivery.service');
+const { sendSuccess } = require('../utils/response');
+
+const getAlertDeliveryStatus = async (_req, res, next) => {
+ try {
+ return sendSuccess(res, await getStatus({ db: prisma }));
+ } catch (error) {
+ return next(error);
+ }
+};
+
+module.exports = { getAlertDeliveryStatus };
\ No newline at end of file
diff --git a/apps/api/src/i18n/formatters.js b/apps/api/src/i18n/formatters.js
index 8913d6c9..57301346 100644
--- a/apps/api/src/i18n/formatters.js
+++ b/apps/api/src/i18n/formatters.js
@@ -24,7 +24,7 @@ const formatDateByLocale = (dateInput, locale = 'en', options = {}) => {
try {
return new Intl.DateTimeFormat(targetLocale, defaultOptions).format(date);
- } catch (err) {
+ } catch (_err) {
return date.toISOString();
}
};
@@ -68,7 +68,7 @@ const formatAmountByLocale = (amountStr, currencyOrAsset = 'USDC', locale = 'en'
return `${sym}${formattedNum}`;
}
return `${formattedNum} ${currencyOrAsset.toUpperCase()}`;
- } catch (err) {
+ } catch (_err) {
return `${amountStr} ${currencyOrAsset}`;
}
};
diff --git a/apps/api/src/jobs/alertDelivery.jobs.js b/apps/api/src/jobs/alertDelivery.jobs.js
new file mode 100644
index 00000000..37b9d82e
--- /dev/null
+++ b/apps/api/src/jobs/alertDelivery.jobs.js
@@ -0,0 +1,67 @@
+'use strict';
+
+/**
+ * Continuous alert-delivery verification poller (#228)
+ * ---------------------------------------------------
+ * Runs on a configurable interval in the worker process (like the audit,
+ * deposit and verification-expiry pollers). Each tick reconciles outstanding
+ * synthetic tests against provider delivery status, detects missed/stalled
+ * verification, and dispatches the next synthetic alert if one is due.
+ *
+ * When continuous verification is not enabled/configured this is a safe no-op
+ * that simply reflects the disabled state.
+ */
+const logger = require('../utils/logger');
+const config = require('../config/env');
+const prisma = require('../common/prisma');
+const { runAlertDeliveryCycle, isEnabled } = require('../observability/alertDelivery.service');
+
+/** Default: every hour. Override via ALERT_DELIVERY_INTERVAL_MS. */
+const DEFAULT_INTERVAL_MS = 3600000;
+
+const startAlertDeliveryPoller = ({ intervalMs } = {}) => {
+ if (!isEnabled(config)) {
+ logger.info('alert_delivery_poller_disabled', { reason: 'not_configured' });
+ return { stop: () => {}, started: false };
+ }
+
+ const interval = intervalMs ?? Number(config.alertDelivery?.intervalMs ?? DEFAULT_INTERVAL_MS);
+ logger.info('alert_delivery_poller_started', { intervalMs: interval });
+
+ let running = false;
+ let timer;
+
+ const tick = async () => {
+ if (running) return; // never overlap ticks
+ running = true;
+ try {
+ const result = await runAlertDeliveryCycle({ db: prisma, cfg: config });
+ if (result.dispatched?.dispatched) {
+ logger.info('synthetic_alert_dispatched_summary', {
+ testId: result.dispatched.testId,
+ syncOutcome: result.dispatched.syncOutcome,
+ fallbackUsed: result.dispatched.fallbackUsed,
+ });
+ }
+ } catch (error) {
+ logger.error('alert_delivery_poller_error', { error: String(error?.message || error) });
+ } finally {
+ running = false;
+ }
+ };
+
+ // Run once immediately, then on interval (matches audit/deposit pollers).
+ tick();
+ timer = setInterval(tick, interval);
+ if (timer.unref) timer.unref();
+
+ return {
+ stop: () => {
+ clearInterval(timer);
+ logger.info('alert_delivery_poller_stopped');
+ },
+ started: true,
+ };
+};
+
+module.exports = { startAlertDeliveryPoller };
\ No newline at end of file
diff --git a/apps/api/src/jobs/deposits.jobs.js b/apps/api/src/jobs/deposits.jobs.js
index 65528eb8..7710c226 100644
--- a/apps/api/src/jobs/deposits.jobs.js
+++ b/apps/api/src/jobs/deposits.jobs.js
@@ -195,7 +195,6 @@ const pollWallet = async (wallet, deps) => {
let cursor = paymentCursor;
// Drain pages until Horizon returns <200 records or empty.
- // eslint-disable-next-line no-constant-condition
while (true) {
const { records, nextCursor } = await fetchPaymentsPage(horizon, publicKey, cursor);
diff --git a/apps/api/src/observability/alertDelivery.service.js b/apps/api/src/observability/alertDelivery.service.js
new file mode 100644
index 00000000..729be2a6
--- /dev/null
+++ b/apps/api/src/observability/alertDelivery.service.js
@@ -0,0 +1,484 @@
+'use strict';
+
+/**
+ * Continuous alert-delivery verification (#228)
+ * -------------------------------------------------
+ * Proves the actual outbound alert-routing pipeline works end-to-end instead
+ * of merely checking that monitoring components are up.
+ *
+ * On a configured interval a synthetic test message is dispatched to an
+ * INTERNAL test recipient (ALERT_TEST_RECIPIENT) through the real WhatsApp
+ * Cloud API outbound pipeline, marked clearly as synthetic so it can never be
+ * mistaken for a customer alert or page a customer. Delivery is confirmed from
+ * the provider's status webhook (via the linked Notification row reaching
+ * `delivered`/`read`). If the primary text route fails synchronously, a
+ * configured template route is tried once as a bounded fallback. A persisted
+ * singleton state exposes the last successful end-to-end verification, and a
+ * stalled scheduler (no test, no success for several intervals) surfaces as an
+ * actionable `failed`/"missed_test" state.
+ *
+ * Idempotency / anti-storm: `testId` is deterministic per interval epoch and
+ * unique, so duplicate scheduler executions collide on the constraint and only
+ * one test per interval can ever be created; dispatch is additionally gated on
+ * an in-flight test and the persisted last-dispatch timestamp.
+ */
+const logger = require('../utils/logger');
+const config = require('../config/env');
+const { increment, setGauge } = require('../observability/metrics');
+
+// Lazy requires keep this module dependency-injectable (and unit-testable
+// without a generated Prisma client or a live WhatsApp module): the real
+// `common/prisma` and `services/whatsapp.service` are only loaded when a caller
+// does not pass its own injected `db` / `whatsappImpl`.
+const prismaDefault = () => require('../common/prisma');
+const whatsappDefault = () => require('../services/whatsapp.service');
+
+const TEST_REFERENCE_TYPE = 'alert-test';
+const TEST_CHANNEL = 'whatsapp';
+const TEST_TYPE = 'synthetic_test';
+const STATE_ID = 'main';
+const TEST_PREFIX = 'synthetic-alert';
+
+const ROUTES = { TEXT: 'whatsapp-text', TEMPLATE: 'whatsapp-template' };
+
+const TEST_STATUS = {
+ DISPATCHED: 'dispatched',
+ ACCEPTED: 'accepted',
+ CONFIRMED: 'confirmed',
+ FAILED: 'failed',
+ TIMED_OUT: 'timed_out',
+};
+
+const HEALTH = {
+ HEALTHY: 'healthy',
+ DEGRADED: 'degraded',
+ FAILED: 'failed',
+ UNKNOWN: 'unknown',
+ DISABLED: 'disabled',
+};
+
+// Notification statuses that constitute end-to-end delivery confirmation.
+const CONFIRMING_STATUSES = new Set(['delivered', 'read']);
+
+/**
+ * Whether continuous verification is active. Requires the recipient to be set,
+ * the kill switch not to be disabled, and a real transport that can report
+ * provider delivery status (the `sim` transport has no delivery webhook, so
+ * confirmation would be impossible and it is deliberately left disabled).
+ */
+const isEnabled = (cfg = config) => Boolean(
+ cfg.alertDelivery?.enabled
+ && cfg.alertDelivery?.recipient
+ && (cfg.messageTransport == null || cfg.messageTransport === 'meta'),
+);
+
+/** Deterministic, unique-per-interval test id → hard anti-storm guarantee. */
+const testIdForEpoch = (now, cfg) => (
+ `${TEST_PREFIX}:${Math.floor(now.getTime() / cfg.alertDelivery.intervalMs)}`
+);
+
+const syntheticBody = (testId) => (
+ `[SendAm alert-delivery test] correlationId=${TEST_PREFIX}:${testId}`
+);
+
+const syntheticNotification = (testId) => ({
+ type: TEST_TYPE,
+ channel: TEST_CHANNEL,
+ referenceType: TEST_REFERENCE_TYPE,
+ referenceId: testId,
+});
+
+/** Ordered routes to exercise. Primary = free-form text; fallback = template (if configured). */
+const buildRoutes = (cfg = config) => {
+ const routes = [{ name: ROUTES.TEXT, kind: 'text' }];
+ if (cfg.alertDelivery?.templateName) {
+ routes.push({
+ name: ROUTES.TEMPLATE,
+ kind: 'template',
+ templateName: cfg.alertDelivery.templateName,
+ templateLanguage: cfg.alertDelivery.templateLanguage || 'en',
+ });
+ }
+ return routes;
+};
+
+const readState = async (db) => db.alertDeliveryState.findUnique({ where: { id: STATE_ID } });
+
+const getOrCreateState = async (db) => {
+ const existing = await readState(db);
+ if (existing) return existing;
+ return db.alertDeliveryState.create({
+ data: { id: STATE_ID, enabled: false, overallStatus: HEALTH.UNKNOWN },
+ });
+};
+
+const persistState = async (db, update, fallbackCreate = {}) => db.alertDeliveryState.upsert({
+ where: { id: STATE_ID },
+ update,
+ create: { id: STATE_ID, ...buildStateDefaults(), ...fallbackCreate },
+});
+
+const buildStateDefaults = () => ({ enabled: false, overallStatus: HEALTH.UNKNOWN });
+
+/** Update Prometheus gauges from the persisted state. Never includes recipients/secrets. */
+const updateGauges = (state) => {
+ const score = { healthy: 1, degraded: 0.5, failed: 0, unknown: 0.5, disabled: 0 }[state?.overallStatus];
+ setGauge('sendam_alert_delivery_status', Number.isFinite(score) ? score : 0, {});
+ const last = state?.lastSuccessfulTestAt ? new Date(state.lastSuccessfulTestAt).getTime() : null;
+ if (last) setGauge('sendam_alert_delivery_last_success_timestamp_seconds', Math.floor(last / 1000), {});
+ const age = last ? Math.max(0, (Date.now() - last) / 1000) : -1;
+ setGauge('sendam_alert_delivery_age_seconds', age, {});
+ increment('sendam_alert_delivery_checks_total', {});
+};
+
+/**
+ * Dispatch one synthetic alert test through every configured route.
+ * Returns the synchronous dispatch summary. Delivery confirmation happens
+ * asynchronously via the status webhook and is reconciled later.
+ */
+const dispatchSyntheticTest = async ({
+ db = prismaDefault(),
+ cfg = config,
+ now = new Date(),
+ whatsappImpl = whatsappDefault(),
+} = {}) => {
+ const recipient = cfg.alertDelivery.recipient;
+
+ // In-flight guard: only one test outstanding at a time prevents storms while
+ // the provider recovers.
+ const inflight = await db.alertDeliveryTest.findFirst({
+ where: { status: { in: [TEST_STATUS.DISPATCHED, TEST_STATUS.ACCEPTED] } },
+ });
+ if (inflight) {
+ logger.info('synthetic_alert_started', { testId: inflight.testId, outcome: 'skipped_in_flight' });
+ return { dispatched: false, reason: 'in_flight', testId: inflight.testId };
+ }
+
+ const state = await getOrCreateState(db);
+ if (state.lastDispatchAt && now.getTime() - new Date(state.lastDispatchAt).getTime() < cfg.alertDelivery.intervalMs) {
+ logger.info('synthetic_alert_started', { outcome: 'skipped_not_due' });
+ return { dispatched: false, reason: 'not_due' };
+ }
+
+ const testId = testIdForEpoch(now, cfg);
+ logger.info('synthetic_alert_started', { testId, outcome: 'starting' });
+
+ let record;
+ try {
+ record = await db.alertDeliveryTest.create({
+ data: {
+ testId,
+ recipient,
+ status: TEST_STATUS.DISPATCHED,
+ routes: [],
+ primaryRoute: ROUTES.TEXT,
+ attemptedAt: now,
+ },
+ });
+ } catch (err) {
+ // Unique epoch collision → another replica already dispatched this interval.
+ if (err?.code === 'P2002') {
+ logger.info('synthetic_alert_started', { testId, outcome: 'skipped_duplicate' });
+ return { dispatched: false, reason: 'duplicate', testId };
+ }
+ throw err;
+ }
+
+ const notification = syntheticNotification(testId);
+ const routes = buildRoutes(cfg);
+ const perRoute = [];
+ let providerMessageId = null;
+ let lastError = null;
+
+ for (let index = 0; index < routes.length; index += 1) {
+ const route = routes[index];
+ const isPrimary = index === 0;
+ let result;
+ try {
+ if (route.kind === 'text') {
+ result = await whatsappImpl.sendTextMessage(recipient, syntheticBody(testId), {
+ correlationId: `${TEST_PREFIX}:${testId}:${route.name}`,
+ notification,
+ prisma: db,
+ enforceWindow: false,
+ });
+ } else {
+ result = await whatsappImpl.sendTemplateMessage(
+ recipient,
+ route.templateName,
+ route.templateLanguage,
+ [],
+ {
+ notification,
+ prisma: db,
+ correlationId: `${TEST_PREFIX}:${testId}:${route.name}`,
+ },
+ );
+ const wid = result?.messages?.[0]?.id;
+ result = wid
+ ? { outcome: 'accepted', providerMessageId: wid }
+ : { outcome: 'failed', error: { kind: 'template', message: 'template send did not return a message id' } };
+ }
+ } catch (error) {
+ result = { outcome: 'failed', error: { kind: 'route_error', message: String(error?.message || error || 'send failed') } };
+ }
+
+ if (result.outcome === 'accepted' && result.providerMessageId) {
+ providerMessageId = result.providerMessageId;
+ perRoute.push({
+ name: route.name,
+ outcome: 'accepted',
+ providerMessageId,
+ attemptedAt: now.toISOString(),
+ });
+ logger.info('synthetic_alert_dispatched', { testId, route: route.name, providerMessageId });
+ break;
+ }
+
+ const reason = result.error?.message || result.error?.kind || 'send_failed';
+ lastError = reason;
+ perRoute.push({ name: route.name, outcome: 'failed', attemptedAt: now.toISOString(), error: reason });
+ if (isPrimary) {
+ logger.info('synthetic_alert_route_failed', { testId, route: route.name, reason });
+ if (index < routes.length - 1) {
+ logger.info('synthetic_alert_fallback_started', { testId, fallbackRoute: routes[index + 1].name, primaryReason: reason });
+ }
+ }
+ }
+
+ const syncOutcome = providerMessageId ? 'accepted' : 'failed';
+ const fallbackUsed = perRoute.length > 1;
+ const successful = syncOutcome === 'accepted';
+ const overallStatus = !successful ? HEALTH.FAILED : (fallbackUsed ? HEALTH.DEGRADED : HEALTH.HEALTHY);
+
+ await db.alertDeliveryTest.update({
+ where: { id: record.id },
+ data: {
+ status: successful ? TEST_STATUS.ACCEPTED : TEST_STATUS.FAILED,
+ routes: perRoute,
+ fallbackUsed,
+ syncOutcome,
+ providerMessageId,
+ acceptedAt: successful ? now : null,
+ failedAt: successful ? null : now,
+ failureReason: successful ? null : 'all_routes_failed_synchronously',
+ },
+ });
+
+ const stateUpdate = {
+ enabled: true,
+ lastDispatchAt: now,
+ lastTestId: testId,
+ overallStatus,
+ routesDiagnostics: { updatedAt: now.toISOString(), overallStatus, fallbackUsed, routes: routes.map((r) => r.name) },
+ };
+ if (!successful) {
+ stateUpdate.lastFailureAt = now;
+ stateUpdate.lastFailureReason = 'all_routes_failed_synchronously';
+ stateUpdate.lastFailureDetail = { routes: perRoute, lastError };
+ }
+ await persistState(db, stateUpdate, stateUpdate);
+
+ if (fallbackUsed && successful) {
+ logger.info('synthetic_alert_fallback_succeeded', { testId, fallbackRoute: perRoute[1].name });
+ logger.info('synthetic_alert_verification_degraded', { testId });
+ } else if (!successful) {
+ logger.error('synthetic_alert_verification_failed', { testId, routes: perRoute });
+ }
+
+ return {
+ dispatched: true,
+ testId,
+ syncOutcome,
+ providerMessageId,
+ fallbackUsed,
+ overallStatus,
+ };
+};
+
+/** Confirm a resolved test: update the test row and never overwrite lastSuccessfulTestAt on failure. */
+const confirmTest = async (db, test, note, now) => {
+ const fallbackUsed = Boolean(test.fallbackUsed);
+ const health = fallbackUsed ? HEALTH.DEGRADED : HEALTH.HEALTHY;
+ await db.alertDeliveryTest.update({
+ where: { id: test.id },
+ data: { status: TEST_STATUS.CONFIRMED, confirmedAt: note.deliveredAt || note.readAt || now },
+ });
+ await persistState(db, {
+ enabled: true,
+ overallStatus: health,
+ lastSuccessfulTestAt: now,
+ lastTestId: test.testId,
+ routesDiagnostics: { updatedAt: now.toISOString(), overallStatus: health, fallbackUsed },
+ }, { enabled: true, overallStatus: health, lastSuccessfulTestAt: now, lastTestId: test.testId });
+ logger.info(fallbackUsed ? 'synthetic_alert_fallback_succeeded' : 'synthetic_alert_delivery_confirmed', {
+ testId: test.testId, fallbackUsed, providerStatus: note.status,
+ });
+ logger.info('synthetic_alert_acknowledged', { testId: test.testId, status: note.status });
+ return { outcome: 'confirmed', providerStatus: note.status, degraded: fallbackUsed };
+};
+
+const markFailed = async (db, test, reason, detail, now, status = TEST_STATUS.FAILED) => {
+ await db.alertDeliveryTest.update({
+ where: { id: test.id },
+ data: {
+ status,
+ failedAt: now,
+ timeoutAt: status === TEST_STATUS.TIMED_OUT ? now : null,
+ failureReason: reason,
+ },
+ });
+ await persistState(db, {
+ enabled: true,
+ overallStatus: HEALTH.FAILED,
+ lastFailureAt: now,
+ lastFailureReason: reason,
+ lastFailureDetail: detail,
+ }, { enabled: true, overallStatus: HEALTH.FAILED, lastFailureAt: now, lastFailureReason: reason, lastFailureDetail: detail });
+ if (status === TEST_STATUS.TIMED_OUT) {
+ logger.error('synthetic_alert_verification_timed_out', { testId: test.testId, reason });
+ } else {
+ logger.error('synthetic_alert_verification_failed', { testId: test.testId, reason, detail });
+ }
+ return { outcome: status, reason };
+};
+
+/**
+ * Reconcile outstanding (dispatched/accepted) tests against their Notification's
+ * provider delivery status: confirm on delivered/read, fail on provider failure,
+ * or time out when no acknowledgement arrives within ALERT_DELIVERY_ACK_TIMEOUT_MS.
+ */
+const reconcileInFlightTests = async ({ db = prismaDefault(), cfg = config, now = new Date() } = {}) => {
+ const ackTimeoutMs = cfg.alertDelivery.ackTimeoutMs;
+ const inflight = await db.alertDeliveryTest.findMany({
+ where: { status: { in: [TEST_STATUS.DISPATCHED, TEST_STATUS.ACCEPTED] } },
+ });
+ const results = [];
+ for (const test of inflight) {
+ const note = await db.notification.findFirst({
+ where: { referenceType: TEST_REFERENCE_TYPE, referenceId: test.testId },
+ orderBy: { createdAt: 'desc' },
+ });
+ let change = null;
+ if (CONFIRMING_STATUSES.has(note?.status)) {
+ change = await confirmTest(db, test, note, now);
+ } else if (note?.status === 'failed') {
+ const detail = { providerMessageId: note.providerMessageId, providerError: note.failureMessage || note.error || null };
+ change = await markFailed(db, test, 'provider_failed', detail, now);
+ } else if (now.getTime() - new Date(test.attemptedAt).getTime() > ackTimeoutMs) {
+ change = await markFailed(db, test, `acknowledgement_timeout:${ackTimeoutMs}ms`, { ackTimeoutMs }, now, TEST_STATUS.TIMED_OUT);
+ }
+ if (change) results.push({ testId: test.testId, ...change });
+ increment('sendam_alert_delivery_outcomes_total', { outcome: change?.outcome || 'pending' });
+ }
+ return results;
+};
+
+/**
+ * Missed-test detection: if no successful end-to-end verification has happened
+ * for `intervalMs * missedFactor` and no test is in flight (e.g. the scheduler
+ * stopped), the verification is unhealthy. A failure never clears the last
+ * successful timestamp.
+ */
+const detectMissedVerification = async ({ db = prismaDefault(), cfg = config, now = new Date() } = {}) => {
+ const state = await getOrCreateState(db);
+ if (!state.lastSuccessfulTestAt) return null; // nothing succeeded yet → not "missed", just not-yet-verified
+ const expectedMs = cfg.alertDelivery.intervalMs * cfg.alertDelivery.missedFactor;
+ const sinceSuccessMs = now.getTime() - new Date(state.lastSuccessfulTestAt).getTime();
+ if (sinceSuccessMs <= expectedMs) return null;
+
+ const inflight = await db.alertDeliveryTest.count({
+ where: { status: { in: [TEST_STATUS.DISPATCHED, TEST_STATUS.ACCEPTED] } },
+ });
+ if (inflight > 0) return null; // a test is in progress; reconciliation owns it
+
+ await persistState(db, {
+ enabled: true,
+ overallStatus: HEALTH.FAILED,
+ lastFailureAt: now,
+ lastFailureReason: 'missed_test',
+ lastFailureDetail: {
+ lastSuccessfulTestAt: state.lastSuccessfulTestAt.toISOString(),
+ expectedMs,
+ sinceSuccessMs,
+ },
+ }, { enabled: true, overallStatus: HEALTH.FAILED, lastFailureAt: now, lastFailureReason: 'missed_test' });
+ logger.error('synthetic_alert_verification_missed', {
+ testId: state.lastTestId,
+ lastSuccessfulTestAt: state.lastSuccessfulTestAt,
+ expectedMs,
+ sinceSuccessMs,
+ });
+ return { missed: true, lastSuccessfulTestAt: state.lastSuccessfulTestAt, sinceSuccessMs };
+};
+
+/** Disable/reflect the disabled state (no-op kept for observability). */
+const ensureDisabledState = async (db, now) => {
+ logger.info('synthetic_alert_disabled', { reason: 'not_configured', transport: config.messageTransport });
+ await persistState(db, { enabled: false, overallStatus: HEALTH.DISABLED, routesDiagnostics: { updatedAt: now.toISOString() } },
+ { enabled: false, overallStatus: HEALTH.DISABLED });
+ return HEALTH.DISABLED;
+};
+
+/**
+ * Run one full verification cycle (reconcile → missed-detection → maybe dispatch).
+ * Safe to call on every interval even when disabled.
+ */
+const runAlertDeliveryCycle = async ({
+ db = prismaDefault(),
+ cfg = config,
+ now = new Date(),
+ whatsappImpl = whatsappDefault(),
+} = {}) => {
+ if (!isEnabled(cfg)) {
+ await ensureDisabledState(db, now);
+ updateGauges(await readState(db));
+ return { enabled: false, status: HEALTH.DISABLED };
+ }
+ const reconciled = await reconcileInFlightTests({ db, cfg, now });
+ const missed = await detectMissedVerification({ db, cfg, now });
+ const dispatched = await dispatchSyntheticTest({ db, cfg, now, whatsappImpl });
+ const state = await readState(db);
+ updateGauges(state);
+ return { enabled: true, status: state?.overallStatus || HEALTH.UNKNOWN, reconciled, missed, dispatched };
+};
+
+/**
+ * Read the current status/history for the admin API. Read-only: never creates
+ * rows and never exposes recipients or secrets.
+ */
+const getStatus = async ({ db = prismaDefault(), cfg = config } = {}) => {
+ const state = await readState(db);
+ const recentTests = await db.alertDeliveryTest.findMany({ orderBy: { attemptedAt: 'desc' }, take: 10 });
+ // Never expose the internal test recipient (or any secret-bearing field).
+ const sanitize = (t) => ({ ...t, recipient: undefined });
+ return {
+ enabled: isEnabled(cfg),
+ overallStatus: state?.overallStatus || HEALTH.UNKNOWN,
+ lastSuccessfulTestAt: state?.lastSuccessfulTestAt || null,
+ lastTestId: state?.lastTestId || null,
+ lastDispatchAt: state?.lastDispatchAt || null,
+ lastFailureAt: state?.lastFailureAt || null,
+ lastFailureReason: state?.lastFailureReason || null,
+ recentTests: recentTests.map(sanitize),
+ };
+};
+
+module.exports = {
+ isEnabled,
+ buildRoutes,
+ dispatchSyntheticTest,
+ reconcileInFlightTests,
+ detectMissedVerification,
+ runAlertDeliveryCycle,
+ getStatus,
+ updateGauges,
+ testIdForEpoch,
+ // Constants exposed for tests.
+ TEST_STATUS,
+ HEALTH,
+ TEST_REFERENCE_TYPE,
+ TEST_PREFIX,
+ STATE_ID,
+ ROUTES,
+};
\ No newline at end of file
diff --git a/apps/api/src/payment/reconciliation.controller.js b/apps/api/src/payment/reconciliation.controller.js
index 068eadff..7aac8b27 100644
--- a/apps/api/src/payment/reconciliation.controller.js
+++ b/apps/api/src/payment/reconciliation.controller.js
@@ -1,6 +1,6 @@
const logger = require('../utils/logger');
const { response } = require('../utils/response');
-const { reconcileStaleTransactions, listLedgerDiscrepancies, listStuckPayments, operatorResolveStuckPayment } = require('./payment.reconciler');
+const { reconcileStaleTransactions, listLedgerDiscrepancies } = require('./payment.reconciler');
const triggerReconciliation = async (req, res) => {
try {
diff --git a/apps/api/src/pricing/pricing.service.js b/apps/api/src/pricing/pricing.service.js
index ef455fa9..1f16270e 100644
--- a/apps/api/src/pricing/pricing.service.js
+++ b/apps/api/src/pricing/pricing.service.js
@@ -430,6 +430,10 @@ module.exports = {
validateProviderPayload,
resetPricingPolicyState,
getPolicyConversionSnapshot,
+ fetchExchangeRateQuote,
+ validateProviderPayload,
+ PricingProviderError,
+ resetPricingPolicyState,
assertConfiguredCurrency,
validateQuoteForExecution,
requote,
diff --git a/apps/api/src/support/support.controller.js b/apps/api/src/support/support.controller.js
index 30fa8a08..8bcda29a 100644
--- a/apps/api/src/support/support.controller.js
+++ b/apps/api/src/support/support.controller.js
@@ -1,6 +1,5 @@
const logger = require('../utils/logger');
const { response } = require('../utils/response');
-const { v4: uuidv4 } = require('uuid');
const generateCaseNumber = () => {
const prefix = 'CASE';
diff --git a/apps/api/src/whatsapp/assistant.service.js b/apps/api/src/whatsapp/assistant.service.js
index 5e264915..9138ab2f 100644
--- a/apps/api/src/whatsapp/assistant.service.js
+++ b/apps/api/src/whatsapp/assistant.service.js
@@ -12,7 +12,7 @@ const defaultPrisma = require('../common/prisma');
const { canonicalizePhoneNumber } = require('../utils/validators');
const { parseConsentCommand, applyConsentKeyword, isMessageAllowed } = require('../compliance/consent.service');
const { t, SUPPORTED_LOCALES } = require('../i18n/messages');
-const { formatDateByLocale, formatAmountByLocale } = require('../i18n/formatters');
+const { formatAmountByLocale } = require('../i18n/formatters');
const { buildStandardReceipt, formatChannelReceiptMessage, recordReceiptDeliveryEvent } = require('../services/receipt.service');
const PENDING_SEND_TTL_MS = 10 * 60 * 1000;
diff --git a/apps/api/src/whatsapp/recipientResolver.js b/apps/api/src/whatsapp/recipientResolver.js
index a8cc8b29..6499cc5f 100644
--- a/apps/api/src/whatsapp/recipientResolver.js
+++ b/apps/api/src/whatsapp/recipientResolver.js
@@ -1,7 +1,7 @@
const { isValidPhoneNumber, canonicalizePhoneNumber } = require('../utils/validators');
const StellarSdk = require('@stellar/stellar-sdk');
-const PHONE_SHAPE = /^\+?\d[\ds]-{4,17}$/;
+const PHONE_SHAPE = /^\+?\d[\d\s-]{4,17}$/;
const looksLikePhoneNumber = (raw) => PHONE_SHAPE.test(raw) && isValidPhoneNumber(raw);
const PREFLIGHT_CACHE_TTL_MS = 30 * 1000;
@@ -60,7 +60,7 @@ async function preflightDestination({ stellarService, destination, asset, memo,
errors.push('Destination is a muxed account and requires a memo.');
}
}
- } catch (error) {
+ } catch (_error) {
errors.push('Unable to verify destination account.');
}
diff --git a/apps/api/test/adminAuthLifecycle.integration.test.js b/apps/api/test/adminAuthLifecycle.integration.test.js
index 1071d4cf..dfadc682 100644
--- a/apps/api/test/adminAuthLifecycle.integration.test.js
+++ b/apps/api/test/adminAuthLifecycle.integration.test.js
@@ -24,7 +24,6 @@ let sessionSeq = 0;
let adminSeq = 0;
const now = () => new Date();
-const future = () => new Date(Date.now() + 12 * 60 * 60 * 1000);
const roleByName = (name) => Object.values(db.roles).find((r) => r.name === name);
const roleById = (id) => db.roles[id] || roleByName(id);
const storeAdmin = (a) => { db.admins[a.id] = a; return a; };
diff --git a/apps/api/test/adminLists.integration.test.js b/apps/api/test/adminLists.integration.test.js
index 9a1ecfb0..8222bf83 100644
--- a/apps/api/test/adminLists.integration.test.js
+++ b/apps/api/test/adminLists.integration.test.js
@@ -2,6 +2,10 @@ const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
+const eq = (a, b) => (a instanceof Date || b instanceof Date)
+ ? new Date(a).getTime() === new Date(b).getTime()
+ : a === b;
+
const matches = (row, where) => {
if (!where || Object.keys(where).length === 0) return true;
return Object.entries(where).every(([key, cond]) => {
@@ -11,9 +15,6 @@ const matches = (row, where) => {
const value = row[key];
if (cond && typeof cond === 'object' && !Array.isArray(cond)) {
return Object.entries(cond).every(([op, operand]) => {
- const eq = (a, b) => (a instanceof Date || b instanceof Date)
- ? new Date(a).getTime() === new Date(b).getTime()
- : a === b;
switch (op) {
case 'equals': return eq(value, operand);
case 'contains': return String(value).toLowerCase().includes(String(operand).toLowerCase());
diff --git a/apps/api/test/alertDelivery.jobs.test.js b/apps/api/test/alertDelivery.jobs.test.js
new file mode 100644
index 00000000..8310f36d
--- /dev/null
+++ b/apps/api/test/alertDelivery.jobs.test.js
@@ -0,0 +1,78 @@
+'use strict';
+
+// Alert-delivery verification poller (#228) — lifecycle tests.
+// Verifies that the worker poller honours the enabled state and wires the
+// verification cycle, without touching Redis, Postgres, or the network. The
+// service is stubbed after config/prisma are injected.
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+
+const injectMock = (relFromSrc, factory) => {
+ const abs = path.resolve(__dirname, '../src', `${relFromSrc}.js`);
+ require.cache[abs] = { id: abs, filename: abs, loaded: true, exports: factory() };
+};
+
+const makeConfig = (overrides = {}) => ({
+ env: 'test',
+ isProduction: false,
+ messageTransport: 'meta',
+ alertDelivery: {
+ enabled: true,
+ recipient: '+15551234567',
+ intervalMs: 3600000,
+ ...overrides,
+ },
+});
+
+describe('alert delivery poller', () => {
+ test('disabled configuration → safe no-op poller', () => {
+ injectMock('config/env', () => makeConfig({ enabled: false }));
+ injectMock('common/prisma', () => ({}));
+ const { startAlertDeliveryPoller } = require('../src/jobs/alertDelivery.jobs');
+ const poller = startAlertDeliveryPoller();
+ assert.equal(poller.started, false);
+ assert.equal(typeof poller.stop, 'function');
+ poller.stop(); // no-op must not throw
+ });
+
+ test('enabled configuration → starts and runs the verification cycle', async () => {
+ let cycleCalls = 0;
+ injectMock('config/env', () => makeConfig());
+ injectMock('common/prisma', () => ({}));
+ injectMock('observability/alertDelivery.service', () => ({
+ isEnabled: () => true,
+ runAlertDeliveryCycle: async () => {
+ cycleCalls += 1;
+ return { enabled: true, status: 'healthy', dispatched: { dispatched: true, testId: 'synthetic-alert:x' } };
+ },
+ }));
+
+ // Re-require with fresh cache after injecting stubs.
+ delete require.cache[require.resolve('../src/jobs/alertDelivery.jobs')];
+ const { startAlertDeliveryPoller } = require('../src/jobs/alertDelivery.jobs');
+ const poller = startAlertDeliveryPoller({ intervalMs: 60000 });
+ assert.equal(poller.started, true);
+ // The immediate first tick runs the cycle (await a macrotask so the async
+ // tick from startAlertDeliveryPoller settles).
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.ok(cycleCalls >= 1, 'verification cycle should run on start');
+ poller.stop();
+ });
+
+ test('enabled configuration where cycle throws → poller still starts, error is contained', async () => {
+ injectMock('config/env', () => makeConfig());
+ injectMock('common/prisma', () => ({}));
+ injectMock('observability/alertDelivery.service', () => ({
+ isEnabled: () => true,
+ runAlertDeliveryCycle: async () => { throw new Error('boom'); },
+ }));
+ delete require.cache[require.resolve('../src/jobs/alertDelivery.jobs')];
+ const { startAlertDeliveryPoller } = require('../src/jobs/alertDelivery.jobs');
+ const poller = startAlertDeliveryPoller({ intervalMs: 60000 });
+ assert.equal(poller.started, true);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ poller.stop();
+ });
+});
\ No newline at end of file
diff --git a/apps/api/test/alertDelivery.service.test.js b/apps/api/test/alertDelivery.service.test.js
new file mode 100644
index 00000000..8eaa1df6
--- /dev/null
+++ b/apps/api/test/alertDelivery.service.test.js
@@ -0,0 +1,441 @@
+'use strict';
+
+// Continuous alert-delivery verification (#228) — service unit tests.
+// Exercises dispatch, route/fallback behaviour, delivery acknowledgement
+// reconciliation, missed-test detection, idempotency/anti-storm guarantees,
+// and the customer-safety invariant (synthetic messages only reach the
+// internal test recipient). The SUT is dependency-injected (db/cfg/whatsapp),
+// so nothing touches the network or a real database.
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ dispatchSyntheticTest,
+ reconcileInFlightTests,
+ detectMissedVerification,
+ runAlertDeliveryCycle,
+ getStatus,
+ testIdForEpoch,
+ TEST_REFERENCE_TYPE,
+ TEST_PREFIX,
+} = require('../src/observability/alertDelivery.service');
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const RECIPIENT = '+15551234567';
+const CUSTOMER = '+15550000001'; // must NEVER be used as a recipient
+const NOW = new Date('2026-08-30T00:00:00.000Z');
+
+const makeCfg = (overrides = {}) => ({
+ messageTransport: 'meta',
+ alertDelivery: {
+ enabled: true,
+ recipient: RECIPIENT,
+ intervalMs: 3600000,
+ ackTimeoutMs: 600000,
+ missedFactor: 3,
+ templateName: '',
+ templateLanguage: 'en',
+ ...overrides,
+ },
+});
+
+// In-memory fake Prisma client supporting only what the service touches.
+const makeDb = (seed = {}) => {
+ const tests = [...(seed.tests || [])];
+ const states = [...(seed.states || [])];
+ const notifications = [...(seed.notifications || [])];
+ let id = 100;
+ const findTest = (pred) => tests.find(pred) || null;
+
+ const db = {
+ _tests: tests,
+ _states: states,
+ _notifications: notifications,
+ alertDeliveryTest: {
+ create: async ({ data }) => {
+ if (tests.some((t) => t.testId === data.testId)) {
+ const e = new Error('Unique constraint failed');
+ e.code = 'P2002';
+ throw e;
+ }
+ const row = { id: `t${id++}`, createdAt: NOW, ...data };
+ tests.push(row);
+ return row;
+ },
+ findFirst: async ({ where }) => {
+ let list = tests;
+ if (where.status?.in) list = list.filter((t) => where.status.in.includes(t.status));
+ return list[0] || null;
+ },
+ findMany: async ({ where, orderBy } = {}) => {
+ let list = [...tests];
+ if (where?.status?.in) list = list.filter((t) => where.status.in.includes(t.status));
+ if (orderBy?.attemptedAt === 'desc') list.sort((a, b) => new Date(b.attemptedAt) - new Date(a.attemptedAt));
+ return list;
+ },
+ update: async ({ where, data }) => {
+ const row = findTest((t) => t.id === where.id);
+ Object.assign(row, data);
+ return row;
+ },
+ count: async ({ where } = {}) => {
+ let list = tests;
+ if (where?.status?.in) list = list.filter((t) => where.status.in.includes(t.status));
+ return list.length;
+ },
+ },
+ alertDeliveryState: {
+ findUnique: async ({ where }) => states.find((s) => s.id === where.id) || null,
+ create: async ({ data }) => { states.push({ ...data }); return states.at(-1); },
+ upsert: async ({ where, update, create }) => {
+ const existing = states.find((s) => s.id === where.id);
+ if (existing) Object.assign(existing, update);
+ else states.push({ ...create });
+ return states.find((s) => s.id === where.id);
+ },
+ },
+ notification: {
+ findFirst: async ({ where }) => (
+ notifications.find((n) => n.referenceType === where.referenceType && n.referenceId === where.referenceId)
+ || null
+ ),
+ },
+ };
+ return db;
+};
+
+// Fake WhatsApp transport; each send records its target so tests can assert the
+// customer-safety invariant.
+const makeWhatsapp = ({ textResult, templateResult } = {}) => {
+ const calls = [];
+ const sendTextMessage = async (to, body, opts) => {
+ calls.push({ type: 'text', to, body, opts });
+ return textResult || { outcome: 'accepted', providerMessageId: 'wamid-text', correlationId: opts.correlationId, attempts: 1 };
+ };
+ const sendTemplateMessage = async (to, templateName, lang, components, opts) => {
+ calls.push({ type: 'template', to, templateName, opts });
+ return templateResult === undefined
+ ? { messages: [{ id: 'wamid-template' }] }
+ : templateResult;
+ };
+ return { impl: { sendTextMessage, sendTemplateMessage, calls }, calls };
+};
+
+const stateHas = (db, key) => {
+ const s = db._states.find((x) => x.id === 'main');
+ return s ? s[key] : undefined;
+};
+
+// ---------------------------------------------------------------------------
+// Scheduling & dispatch
+// ---------------------------------------------------------------------------
+
+describe('dispatch — scheduling, routes, anti-storm', () => {
+ test('disabled when recipient unset', async () => {
+ const db = makeDb();
+ const result = await runAlertDeliveryCycle({ db, cfg: makeCfg({ recipient: '' }), now: NOW });
+ assert.equal(result.enabled, false);
+ assert.equal(result.status, 'disabled');
+ assert.equal(stateHas(db, 'overallStatus'), 'disabled');
+ });
+
+ test('disabled on sim transport (no provider delivery confirmation possible)', async () => {
+ const db = makeDb();
+ const simCfg = { ...makeCfg(), messageTransport: 'sim' };
+ const result = await runAlertDeliveryCycle({ db, cfg: simCfg, now: NOW });
+ assert.equal(result.enabled, false);
+ });
+
+ test('primary route success → accepted, healthy, exactly one message to the internal recipient', async () => {
+ const db = makeDb();
+ const { impl, calls } = makeWhatsapp();
+ const result = await dispatchSyntheticTest({ db, cfg: makeCfg(), now: NOW, whatsappImpl: impl });
+ assert.equal(result.dispatched, true);
+ assert.equal(result.syncOutcome, 'accepted');
+ assert.equal(result.fallbackUsed, false);
+ assert.equal(result.overallStatus, 'healthy');
+ assert.equal(calls.length, 1, 'only the primary route should be used on success');
+ assert.equal(calls[0].to, RECIPIENT, 'synthetic message must go to the internal test recipient');
+ assert.equal(calls[0].type, 'text');
+ assert.match(calls[0].body, /SendAm alert-delivery test/);
+ assert.match(calls[0].opts.correlationId, /synthetic-alert:/);
+ const test = db._tests.find((t) => t.testId === result.testId);
+ assert.equal(test.status, 'accepted');
+ assert.equal(test.recipient, RECIPIENT);
+ assert.equal(test.routes.length, 1);
+ assert.equal(stateHas(db, 'overallStatus'), 'healthy');
+ assert.ok(stateHas(db, 'lastDispatchAt'));
+ });
+
+ test('every configured route is discovered and primary failure triggers the fallback route', async () => {
+ const db = makeDb();
+ const { impl, calls } = makeWhatsapp({
+ textResult: {
+ outcome: 'permanent_failure', retryable: false,
+ error: { kind: 'conversation_window', message: 'outside window', code: null, status: 400 },
+ },
+ templateResult: { messages: [{ id: 'wamid-template' }] },
+ });
+ const cfg = makeCfg({ templateName: 'sendam_alert_test' });
+ const result = await dispatchSyntheticTest({ db, cfg, now: NOW, whatsappImpl: impl });
+ assert.equal(result.dispatched, true);
+ assert.equal(result.syncOutcome, 'accepted'); // fallback accepted
+ assert.equal(result.fallbackUsed, true);
+ assert.equal(result.overallStatus, 'degraded'); // primary failure stays visible
+ assert.equal(calls.length, 2, 'primary failed then fallback template attempted');
+ assert.equal(calls[0].type, 'text');
+ assert.equal(calls[1].type, 'template');
+ assert.equal(calls[1].to, RECIPIENT);
+ const test = db._tests.find((t) => t.testId === result.testId);
+ assert.equal(test.fallbackUsed, true);
+ assert.equal(test.routes.length, 2);
+ assert.equal(test.routes[0].outcome, 'failed');
+ assert.equal(test.routes[1].outcome, 'accepted');
+ });
+
+ test('primary AND fallback both fail → verification failed, bounded (no recursion)', async () => {
+ const db = makeDb();
+ const { impl, calls } = makeWhatsapp({
+ textResult: { outcome: 'permanent_failure', retryable: false, error: { kind: 'http', message: 'down' } },
+ templateResult: null, // sendTemplateMessage returns null on error
+ });
+ const cfg = makeCfg({ templateName: 'sendam_alert_test' });
+ const result = await dispatchSyntheticTest({ db, cfg, now: NOW, whatsappImpl: impl });
+ assert.equal(result.syncOutcome, 'failed');
+ assert.equal(result.overallStatus, 'failed');
+ assert.equal(calls.length, 2, 'exactly primary + one fallback; never recursive');
+ const test = db._tests.find((t) => t.testId === result.testId);
+ assert.equal(test.status, 'failed');
+ assert.equal(test.failureReason, 'all_routes_failed_synchronously');
+ assert.equal(stateHas(db, 'overallStatus'), 'failed');
+ assert.equal(stateHas(db, 'lastFailureReason'), 'all_routes_failed_synchronously');
+ });
+
+ test('primary failure with no fallback configured → failed', async () => {
+ const db = makeDb();
+ const { impl, calls } = makeWhatsapp({
+ textResult: { outcome: 'permanent_failure', retryable: false, error: { kind: 'http', message: 'down' } },
+ });
+ const result = await dispatchSyntheticTest({ db, cfg: makeCfg({ templateName: '' }), now: NOW, whatsappImpl: impl });
+ assert.equal(result.syncOutcome, 'failed');
+ assert.equal(result.overallStatus, 'failed');
+ assert.equal(calls.length, 1, 'no fallback attempted when none is configured');
+ });
+
+ test('in-flight guard prevents a second concurrent test (anti-storm)', async () => {
+ const db = makeDb({ tests: [{ id: 't1', testId: `${TEST_PREFIX}:old`, status: 'accepted', attemptedAt: NOW }] });
+ const { impl, calls } = makeWhatsapp();
+ const result = await dispatchSyntheticTest({ db, cfg: makeCfg(), now: NOW, whatsappImpl: impl });
+ assert.equal(result.dispatched, false);
+ assert.equal(result.reason, 'in_flight');
+ assert.equal(calls.length, 0, 'no message sent while a test is in flight');
+ });
+
+ test('interval gate prevents dispatching more often than the configured schedule', async () => {
+ const soon = new Date(NOW.getTime() - 1000);
+ const db = makeDb({ states: [{ id: 'main', lastDispatchAt: soon }] });
+ const { impl, calls } = makeWhatsapp();
+ const result = await dispatchSyntheticTest({ db, cfg: makeCfg(), now: NOW, whatsappImpl: impl });
+ assert.equal(result.dispatched, false);
+ assert.equal(result.reason, 'not_due');
+ assert.equal(calls.length, 0);
+ });
+
+ test('duplicate epoch collision is skipped (idempotent across scheduler replicas)', async () => {
+ const db = makeDb();
+ const cfg = makeCfg();
+ const testId = testIdForEpoch(NOW, cfg);
+ const { impl } = makeWhatsapp();
+ await dispatchSyntheticTest({ db, cfg, now: NOW, whatsappImpl: impl });
+ // Simulate another replica racing on the same epoch: force a P2002 by re-creating.
+ const second = await dispatchSyntheticTest({ db, cfg, now: new Date(NOW.getTime() + 1), whatsappImpl: impl });
+ assert.equal(testId.startsWith(TEST_PREFIX), true);
+ assert.ok(['in_flight', 'duplicate', 'not_due'].includes(second.reason), `unexpected skip reason ${second.reason}`);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Delivery acknowledgement / confirmation reconciliation
+// ---------------------------------------------------------------------------
+
+describe('acknowledgement reconciliation', () => {
+ const acceptedTest = (overrides = {}) => ({
+ id: 't1',
+ testId: `${TEST_PREFIX}:epoch`,
+ status: 'accepted',
+ recipient: RECIPIENT,
+ routes: [],
+ primaryRoute: 'whatsapp-text',
+ fallbackUsed: false,
+ syncOutcome: 'accepted',
+ attemptedAt: new Date(NOW.getTime() - 60 * 1000),
+ ...overrides,
+ });
+ const notification = (status, overrides = {}) => ({
+ referenceType: TEST_REFERENCE_TYPE,
+ referenceId: `${TEST_PREFIX}:epoch`,
+ status,
+ ...overrides,
+ });
+
+ test('delivered → confirmed end-to-end, lastSuccessfulTestAt updated, healthy', async () => {
+ const db = makeDb({
+ tests: [acceptedTest()],
+ notifications: [notification('delivered', { deliveredAt: NOW })],
+ });
+ const results = await reconcileInFlightTests({ db, cfg: makeCfg(), now: NOW });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].outcome, 'confirmed');
+ assert.equal(db._tests[0].status, 'confirmed');
+ assert.equal(db._tests[0].confirmedAt.getTime(), NOW.getTime());
+ assert.equal(stateHas(db, 'overallStatus'), 'healthy');
+ assert.equal(stateHas(db, 'lastSuccessfulTestAt').getTime(), NOW.getTime());
+ });
+
+ test('read → confirmed; fallback-used delivery is marked degraded not healthy', async () => {
+ const db = makeDb({
+ tests: [acceptedTest({ fallbackUsed: true })],
+ notifications: [notification('read', { readAt: NOW })],
+ });
+ const [result] = await reconcileInFlightTests({ db, cfg: makeCfg(), now: NOW });
+ assert.equal(result.outcome, 'confirmed');
+ assert.equal(result.degraded, true);
+ assert.equal(stateHas(db, 'overallStatus'), 'degraded');
+ assert.equal(stateHas(db, 'lastSuccessfulTestAt').getTime(), NOW.getTime());
+ });
+
+ test('provider reports failed → test failed; lastSuccessfulTestAt is NOT overwritten', async () => {
+ const prior = new Date('2026-08-01T00:00:00.000Z');
+ const db = makeDb({
+ tests: [acceptedTest()],
+ notifications: [notification('failed', { providerMessageId: 'wamid', failureMessage: 'rejected' })],
+ states: [{ id: 'main', overallStatus: 'healthy', lastSuccessfulTestAt: prior }],
+ });
+ const results = await reconcileInFlightTests({ db, cfg: makeCfg(), now: NOW });
+ assert.equal(results[0].outcome, 'failed');
+ assert.equal(results[0].reason, 'provider_failed');
+ assert.equal(db._tests[0].status, 'failed');
+ assert.equal(stateHas(db, 'overallStatus'), 'failed');
+ assert.equal(stateHas(db, 'lastSuccessfulTestAt').getTime(), prior.getTime(), 'failed verification must not clear last success');
+ });
+
+ test('acknowledgement timeout after ackTimeoutMs → timed out and actionable', async () => {
+ const age = 10 * 60 * 1000 + 1; // > ackTimeoutMs (10 min)
+ const db = makeDb({
+ tests: [acceptedTest({ attemptedAt: new Date(NOW.getTime() - age) })],
+ notifications: [notification('sent', { deliveredAt: null })], // accepted but never delivered
+ });
+ const [result] = await reconcileInFlightTests({ db, cfg: makeCfg({ ackTimeoutMs: 600000 }), now: NOW });
+ assert.equal(result.outcome, 'timed_out');
+ assert.equal(db._tests[0].status, 'timed_out');
+ assert.equal(stateHas(db, 'overallStatus'), 'failed');
+ assert.match(stateHas(db, 'lastFailureReason'), /acknowledgement_timeout/);
+ });
+
+ test('pending within timeout → left in flight (no premature failure)', async () => {
+ const db = makeDb({
+ tests: [acceptedTest()],
+ notifications: [notification('sent')],
+ });
+ const results = await reconcileInFlightTests({ db, cfg: makeCfg(), now: NOW });
+ assert.equal(results.length, 0);
+ assert.equal(db._tests[0].status, 'accepted');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Missed-test detection
+// ---------------------------------------------------------------------------
+
+describe('missed-test detection', () => {
+ test('stale last success with no in-flight test → failed/missed_test, last success preserved', async () => {
+ const prior = new Date('2026-08-01T00:00:00.000Z');
+ const db = makeDb({ states: [{ id: 'main', lastSuccessfulTestAt: prior, overallStatus: 'healthy' }] });
+ const cfg = makeCfg({ intervalMs: 3600000, missedFactor: 3 });
+ const result = await detectMissedVerification({ db, cfg, now: NOW });
+ assert.ok(result.missed);
+ assert.equal(stateHas(db, 'overallStatus'), 'failed');
+ assert.equal(stateHas(db, 'lastFailureReason'), 'missed_test');
+ assert.equal(stateHas(db, 'lastSuccessfulTestAt').getTime(), prior.getTime());
+ });
+
+ test('fresh last success → not missed', async () => {
+ const recent = new Date(NOW.getTime() - 60 * 1000);
+ const db = makeDb({ states: [{ id: 'main', lastSuccessfulTestAt: recent, overallStatus: 'healthy' }] });
+ const result = await detectMissedVerification({ db, cfg: makeCfg({ intervalMs: 3600000, missedFactor: 3 }), now: NOW });
+ assert.equal(result, null);
+ });
+
+ test('in-flight test → not flagged as missed (reconciliation owns it)', async () => {
+ const prior = new Date('2026-08-01T00:00:00.000Z');
+ const db = makeDb({
+ states: [{ id: 'main', lastSuccessfulTestAt: prior, overallStatus: 'healthy' }],
+ tests: [{ id: 't1', testId: `${TEST_PREFIX}:epoch`, status: 'accepted', attemptedAt: NOW }],
+ });
+ const result = await detectMissedVerification({ db, cfg: makeCfg(), now: NOW });
+ assert.equal(result, null);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Idempotency, status, customer-safety
+// ---------------------------------------------------------------------------
+
+describe('idempotency, status surface & customer safety', () => {
+ test('failed run never overwrites lastSuccessfulTestAt during dispatch', async () => {
+ const prior = new Date('2026-08-01T00:00:00.000Z');
+ const db = makeDb({ states: [{ id: 'main', lastSuccessfulTestAt: prior, overallStatus: 'healthy' }] });
+ const { impl } = makeWhatsapp({
+ textResult: { outcome: 'permanent_failure', retryable: false, error: { kind: 'http', message: 'down' } },
+ });
+ await dispatchSyntheticTest({ db, cfg: makeCfg({ templateName: '' }), now: NOW, whatsappImpl: impl });
+ assert.equal(stateHas(db, 'overallStatus'), 'failed');
+ assert.equal(stateHas(db, 'lastSuccessfulTestAt').getTime(), prior.getTime());
+ assert.ok(stateHas(db, 'lastFailureAt'));
+ });
+
+ test('synthetic alerts can never target a customer number', async () => {
+ const db = makeDb();
+ const { impl, calls } = makeWhatsapp();
+ // Reconfigure with a recipient that differs from customers; assert only it is used.
+ const cfg = makeCfg();
+ await dispatchSyntheticTest({ db, cfg, now: NOW, whatsappImpl: impl });
+ for (const call of calls) {
+ assert.equal(call.to, RECIPIENT, 'every synthetic send goes to the internal recipient');
+ assert.notEqual(call.to, CUSTOMER);
+ }
+ const test = db._tests[0];
+ // Marked unmistakably as a synthetic test in the persisted reference.
+ assert.equal(test.status, 'accepted');
+ assert.ok(db._tests.every((t) => t.testId.startsWith(TEST_PREFIX)));
+ });
+
+ test('getStatus returns diagnostics without recipients or secrets', async () => {
+ const db = makeDb({
+ states: [{ id: 'main', overallStatus: 'healthy', lastSuccessfulTestAt: NOW, lastTestId: `${TEST_PREFIX}:epoch` }],
+ tests: [{ id: 't1', testId: `${TEST_PREFIX}:epoch`, status: 'confirmed', recipient: RECIPIENT, routes: [], attemptedAt: NOW }],
+ });
+ const status = await getStatus({ db, cfg: makeCfg() });
+ assert.equal(status.overallStatus, 'healthy');
+ assert.equal(status.enabled, true);
+ assert.equal(status.lastTestId, `${TEST_PREFIX}:epoch`);
+ assert.equal(status.lastSuccessfulTestAt.getTime(), NOW.getTime());
+ assert.equal(status.recentTests.length, 1);
+ const json = JSON.stringify(status);
+ assert.equal(json.includes(RECIPIENT), false, 'status must not leak the recipient');
+ });
+
+ test('full enabled cycle returns healthy summary after a fresh dispatch', async () => {
+ const db = makeDb();
+ const { impl } = makeWhatsapp();
+ const result = await runAlertDeliveryCycle({ db, cfg: makeCfg(), now: NOW, whatsappImpl: impl });
+ assert.equal(result.enabled, true);
+ assert.equal(result.status, 'healthy');
+ assert.ok(result.dispatched.dispatched);
+ assert.equal(result.dispatched.syncOutcome, 'accepted');
+ });
+});
\ No newline at end of file
diff --git a/apps/api/test/cors.test.js b/apps/api/test/cors.test.js
index 0eb7a275..a1944587 100644
--- a/apps/api/test/cors.test.js
+++ b/apps/api/test/cors.test.js
@@ -6,6 +6,8 @@ const http = require('node:http');
process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret';
process.env.CORS_ORIGINS = 'https://dashboard.example.com,http://localhost:3000';
+process.env.ENCRYPTION_KEY = 'a'.repeat(64); // 32 bytes hex
+process.env.JWT_SECRET = 'cors-test-jwt-secret-that-is-at-least-32-characters-long-';
const app = require('../src/app');
diff --git a/apps/api/test/cursorPagination.test.js b/apps/api/test/cursorPagination.test.js
index 275365a0..4508b8ac 100644
--- a/apps/api/test/cursorPagination.test.js
+++ b/apps/api/test/cursorPagination.test.js
@@ -4,6 +4,10 @@ const { encodeCursor, decodeCursor, cursorQuery, parseLimit, MAX_PAGE_SIZE } = r
// Minimal in-memory Prisma delegate that honours the where/orderBy/take subset
// the cursor helper emits, so we can assert real pagination behaviour.
+const eq = (a, b) => (a instanceof Date || b instanceof Date)
+ ? new Date(a).getTime() === new Date(b).getTime()
+ : a === b;
+
const matches = (row, where) => {
if (!where || Object.keys(where).length === 0) return true;
return Object.entries(where).every(([key, cond]) => {
@@ -13,9 +17,6 @@ const matches = (row, where) => {
const value = row[key];
if (cond && typeof cond === 'object' && !Array.isArray(cond)) {
return Object.entries(cond).every(([op, operand]) => {
- const eq = (a, b) => (a instanceof Date || b instanceof Date)
- ? new Date(a).getTime() === new Date(b).getTime()
- : a === b;
switch (op) {
case 'equals': return eq(value, operand);
case 'contains': return String(value).toLowerCase().includes(String(operand).toLowerCase());
diff --git a/apps/api/test/deposits.outbox.test.js b/apps/api/test/deposits.outbox.test.js
index 03081667..37b1e2d2 100644
--- a/apps/api/test/deposits.outbox.test.js
+++ b/apps/api/test/deposits.outbox.test.js
@@ -187,7 +187,7 @@ describe('Deposit Notification Outbox (#158)', () => {
resetId = args.where.id;
return { id: resetId, ...args.data };
},
- updateMany: async (args) => {
+ updateMany: async (_args) => {
resetAll = true;
return { count: 3 };
},
diff --git a/apps/api/test/enhancedConfirmation.test.js b/apps/api/test/enhancedConfirmation.test.js
index 1613aa93..2b27ad9b 100644
--- a/apps/api/test/enhancedConfirmation.test.js
+++ b/apps/api/test/enhancedConfirmation.test.js
@@ -21,26 +21,21 @@ const prismaMock = {
findFirst: async () => null, // default: never transacted successfully
},
alias: {
- findUnique: async () => null,
+ findUnique: async () => null, // default: not a saved contact
findFirst: async () => null, // default: not a saved contact
- findUnique: async () => null,
},
user: {
findUnique: async () => userMock,
- updateMany: async () => {
- userMock.pendingSend = null;
- return { count: 1 };
- },
update: async ({ data }) => {
userMock.pendingSend = data.pendingSend;
return userMock;
},
- updateMany: async ({ where, data }) => {
- if (userMock.pendingSend) {
- userMock.pendingSend = data.pendingSend;
- return { count: 1 };
- }
- return { count: 0 };
+ updateMany: async (args) => {
+ // The payment flow clears/updates `pendingSend` via `user.update`; this
+ // `updateMany` stub keeps the claim helpers usable if exercised.
+ if (args?.data) userMock.pendingSend = args.data.pendingSend;
+ else userMock.pendingSend = null;
+ return { count: 1 };
},
},
};
diff --git a/apps/api/test/helpers/setup.js b/apps/api/test/helpers/setup.js
new file mode 100644
index 00000000..bc14b1c4
--- /dev/null
+++ b/apps/api/test/helpers/setup.js
@@ -0,0 +1,18 @@
+'use strict';
+
+// Shared live-database client for the integration tests that exercise real
+// ORM flows (e.g. support.workflow.test.js). Connects to DATABASE_URL, which CI
+// migrates (prisma:deploy) before running `npm test`. Mirrors the driver
+// adapter setup used by src/common/prisma.js so the client initializes without
+// a network round-trip at import time.
+const { PrismaClient } = require('@prisma/client');
+const { PrismaPg } = require('@prisma/adapter-pg');
+const { Pool } = require('pg');
+
+const dbUrl = process.env.DATABASE_URL;
+
+const prisma = new PrismaClient({
+ adapter: new PrismaPg(new Pool({ connectionString: dbUrl })),
+});
+
+module.exports = { prisma };
diff --git a/apps/api/test/horizon.test.js b/apps/api/test/horizon.test.js
index 70c8573c..df490799 100644
--- a/apps/api/test/horizon.test.js
+++ b/apps/api/test/horizon.test.js
@@ -4,7 +4,6 @@ const assert = require('node:assert/strict');
const {
attachHorizonResilience,
HorizonOutageError,
- HorizonWriteUncertainError,
isHorizonWriteUncertain,
} = require('../src/config/horizon');
diff --git a/apps/api/test/i18n.locale.test.js b/apps/api/test/i18n.locale.test.js
index ab9097d1..75193c3e 100644
--- a/apps/api/test/i18n.locale.test.js
+++ b/apps/api/test/i18n.locale.test.js
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'a'.repeat(64);
-const { t, catalog, SUPPORTED_LOCALES } = require('../src/i18n/messages');
+const { t } = require('../src/i18n/messages');
const { formatDateByLocale, formatAmountByLocale } = require('../src/i18n/formatters');
test('t returns correct translations and replaces template params', () => {
diff --git a/apps/api/test/kyc.review.concurrency.test.js b/apps/api/test/kyc.review.concurrency.test.js
index 2c5d1ac2..5e65231a 100644
--- a/apps/api/test/kyc.review.concurrency.test.js
+++ b/apps/api/test/kyc.review.concurrency.test.js
@@ -107,7 +107,7 @@ const makeReq = (overrides = {}) => ({
body: {},
admin: { id: 'admin_1', role: 'compliance_officer' },
ip: '127.0.0.1',
- get: (h) => 'test-agent',
+ get: (_h) => 'test-agent',
...overrides,
});
diff --git a/apps/api/test/kyc.review.e2e.test.js b/apps/api/test/kyc.review.e2e.test.js
index 47d38524..cd021ca3 100644
--- a/apps/api/test/kyc.review.e2e.test.js
+++ b/apps/api/test/kyc.review.e2e.test.js
@@ -304,8 +304,6 @@ test('KYC Review Approval and Denial E2E', { skip: !process.env.TEST_DATABASE_UR
},
});
- const adminId = 'admin-1';
-
// Resubmit for review
const resubmitted = await prisma.$transaction(async (tx) => {
const updated = await tx.kycProfile.update({
diff --git a/apps/api/test/kyc.transitions.test.js b/apps/api/test/kyc.transitions.test.js
index 6a7b42b2..37fc17d2 100644
--- a/apps/api/test/kyc.transitions.test.js
+++ b/apps/api/test/kyc.transitions.test.js
@@ -12,10 +12,6 @@ const {
KYC_STATUS_TRANSITIONS,
SANCTIONS_STATUS_TRANSITIONS,
CUSTODY_STATUS_TRANSITIONS,
- TIER_MIN,
- TIER_MAX,
- RISK_SCORE_MIN,
- RISK_SCORE_MAX,
POLICY_VERSION,
} = require('../src/compliance/kyc.transitions');
@@ -282,9 +278,8 @@ describe('makerCheckerRequired', () => {
test('returns true for sanctions unblock (blocked → cleared)', () => {
const profile = baseProfile({ sanctionsStatus: 'blocked' });
const { required } = makerCheckerRequired(profile, { sanctionsStatus: 'cleared' });
- // blocked → cleared is invalid in matrix, but if it were to slip through, it should require maker-checker.
- // Actually blocked can only go to review, so let's test blocked → review.
- // blocked → review is the only valid transition and should NOT require maker-checker.
+ // Unblocking a sanctions-blocked profile (other than to review) is a high-impact override.
+ assert.equal(required, true);
});
test('returns false for sanctions blocked → review (the only valid path)', () => {
diff --git a/apps/api/test/payment.reconciler.test.js b/apps/api/test/payment.reconciler.test.js
index 9eefd7b1..d392d986 100644
--- a/apps/api/test/payment.reconciler.test.js
+++ b/apps/api/test/payment.reconciler.test.js
@@ -3,8 +3,8 @@ const assert = require('node:assert/strict');
const path = require('path');
// ---------------------------------------------------------------------------
-// Mock config/stellar and wallet/stellar.adapter before requiring the SUT
-// so the Stellar SDK (not installed locally) is never touched.
+// Stub heavy/optional modules (Stellar SDK, Horizon, logger) before requiring
+// the SUT so the tests stay offline and dependency-light.
// ---------------------------------------------------------------------------
const injectMock = (relativeFromSrc, factory) => {
const abs = path.resolve(__dirname, '../src', `${relativeFromSrc}.js`);
@@ -143,15 +143,8 @@ test('reconcileStaleTransactions: txHash on ledger but successful=false → fail
// Transient 404: window still open → no update, retry next cycle
// ---------------------------------------------------------------------------
test('reconcileStaleTransactions: Horizon 404 while ledger sequence window is open → no status change (retry)', async () => {
- // Transaction is 6 min old: past staleAgeMs(5m) but inside LEDGER_SEQUENCE_WINDOW_MS(5m)?
- // Actually 6 min > 5 min, so sequence window IS closed for the default.
- // Use a transaction only 5.5 min old to stay within the window.
- const tx = recentPendingTx({
- id: 'tx_fresh',
- createdAt: new Date(Date.now() - 5.5 * 60 * 1000), // 5.5 min > staleAgeMs but < window
- });
- // Override staleAgeMs to 5min but window is 5min — need tx inside the window.
- // Use a 2-min-old tx with staleAgeMs=1min so it's stale but window still open.
+ // Use a 2-min-old tx with staleAgeMs=1min so it's stale (past the cutoff) but
+ // still well inside the open ledger sequence window, so a 404 stays transient.
const freshTx = recentPendingTx({
id: 'tx_fresh2',
createdAt: new Date(Date.now() - 2 * 60 * 1000), // 2 min old
diff --git a/apps/api/test/privacy.service.test.js b/apps/api/test/privacy.service.test.js
index d368f353..6513bca1 100644
--- a/apps/api/test/privacy.service.test.js
+++ b/apps/api/test/privacy.service.test.js
@@ -132,7 +132,6 @@ inject('../src/compliance/smileId.provider', { deleteSubject: makeProviderMock()
inject('../src/services/whatsapp.service', makeWhatsappMock());
inject('../src/voice/voice.service', { processVoiceMessage: async () => {}, deleteUserData: makeProviderMock().deleteSubject });
inject('../src/compliance/providers/monitoring', { deleteUserData: makeProviderMock().deleteSubject });
-// eslint-disable-next-line global-require
const service = require('../src/compliance/privacy.service');
beforeEach(() => {
diff --git a/apps/api/test/refund.test.js b/apps/api/test/refund.test.js
index 0fc2d8ed..569ca03f 100644
--- a/apps/api/test/refund.test.js
+++ b/apps/api/test/refund.test.js
@@ -39,7 +39,6 @@ const mockRecipientWallet = {
const createdTransactions = new Map();
// Database queries/mocks
-let mockCreatedRefund;
const prismaMock = {
transaction: {
findUnique: async ({ where }) => {
diff --git a/apps/api/test/restProtectedRoutes.integration.test.js b/apps/api/test/restProtectedRoutes.integration.test.js
index c1dffa8e..bf192786 100644
--- a/apps/api/test/restProtectedRoutes.integration.test.js
+++ b/apps/api/test/restProtectedRoutes.integration.test.js
@@ -130,13 +130,24 @@ test('all customer operations use only the authenticated owner despite caller ph
test('Smile ID callback route reaches verification and rejects an invalid signature', async () => {
await withServer(async (base) => {
+ // The route enforces the smileid.callback payload schema before handing
+ // off to signature verification, so include the full set of required
+ // fields.
+ const baseBody = {
+ ResultCode: '1020',
+ ResultText: 'Success',
+ SmileJobID: '0000000001',
+ PartnerParams: { job_id: 'job-1', user_id: 'user-1' },
+ signature: 'valid',
+ timestamp: new Date().toISOString(),
+ };
const valid = await request(base, '/api/compliance/kyc/callback/smileid', {
- method: 'POST', body: { signature: 'valid', ResultCode: '1020' },
+ method: 'POST', body: baseBody,
});
assert.equal(valid.status, 200);
assert.equal(calls.callback.ResultCode, '1020');
const invalid = await request(base, '/api/compliance/kyc/callback/smileid', {
- method: 'POST', body: { signature: 'invalid' },
+ method: 'POST', body: { ...baseBody, signature: 'invalid' },
});
assert.equal(invalid.status, 401);
});
diff --git a/apps/api/test/stellarAdapter.test.js b/apps/api/test/stellarAdapter.test.js
index 113f3079..4c99431d 100644
--- a/apps/api/test/stellarAdapter.test.js
+++ b/apps/api/test/stellarAdapter.test.js
@@ -347,6 +347,85 @@ test('establishTrustline gives a readable error for an unfunded account', async
);
});
+test('fundTestnetAccount is blocked on mainnet', async () => {
+ const originalIsMainnet = config.stellar.isMainnet;
+ config.stellar.isMainnet = true;
+
+ await assert.rejects(
+ stellarAdapter.fundTestnetAccount(SOURCE_PUBLIC_KEY),
+ /Friendbot funding is not available on mainnet/,
+ );
+
+ config.stellar.isMainnet = originalIsMainnet;
+});
+
+test('fundTestnetAccount allows on testnet', async () => {
+ const originalIsMainnet = config.stellar.isMainnet;
+ config.stellar.isMainnet = false;
+
+ mock.method(require('axios'), 'get', async () => ({
+ data: { result: { id: 'mock' } },
+ }));
+
+ const result = await stellarAdapter.fundTestnetAccount(SOURCE_PUBLIC_KEY);
+ assert.equal(result.funded, true);
+
+ config.stellar.isMainnet = originalIsMainnet;
+});
+
+test('submitPayment handles timeout and tx_bad_seq gracefully by reusing envelope and querying Horizon', async () => {
+ mockSuccessfulPaymentSetup();
+
+ mock.method(server, 'fetchBaseFee', async () => '100');
+
+ let callCount = 0;
+ mock.method(server, 'submitTransaction', async (_tx) => {
+ callCount += 1;
+ if (callCount === 1) {
+ const error = new Error('timeout');
+ error.isHorizonWriteUncertain = true;
+ throw error;
+ } else {
+ const error = new Error('tx_bad_seq');
+ error.response = {
+ data: {
+ extras: {
+ result_codes: {
+ transaction: 'tx_bad_seq',
+ },
+ },
+ },
+ };
+ throw error;
+ }
+ });
+
+ let horizonCheckCount = 0;
+ const txEndpointMock = {
+ transactionHash: (h) => ({
+ call: async () => {
+ horizonCheckCount += 1;
+ if (horizonCheckCount === 1) {
+ throw new Error('Not found');
+ }
+ return { hash: h };
+ }
+ })
+ };
+ mock.method(server, 'transactions', () => txEndpointMock);
+
+ const result = await stellarAdapter.submitPayment({
+ secretKey: SOURCE_SECRET,
+ destination: DESTINATION_PUBLIC_KEY,
+ amount: '10',
+ asset: 'XLM',
+ });
+
+ assert.ok(result.txHash);
+ assert.equal(callCount, 2);
+ assert.equal(horizonCheckCount, 2);
+});
+
test('getFundingAccountHealth reports fee and reserve pressure with operator thresholds', async () => {
mock.method(server, 'fetchBaseFee', async () => '300');
mock.method(server, 'loadAccount', async () => ({
diff --git a/apps/api/test/whatsapp.dlq.test.js b/apps/api/test/whatsapp.dlq.test.js
index 76956028..781e5f3a 100644
--- a/apps/api/test/whatsapp.dlq.test.js
+++ b/apps/api/test/whatsapp.dlq.test.js
@@ -14,7 +14,6 @@ injectMock('utils/logger', { info: () => {}, warn: () => {}, error: () => {} });
const {
moveToDeadLetterQueue,
listDeadLetterJobs,
- getDeadLetterJob,
replayDeadLetterJob,
clearDlq,
sanitizePayload,
@@ -117,7 +116,6 @@ test('replayDeadLetterJob re-enqueues job and records audit event when job is va
const record = await moveToDeadLetterQueue(dummyJob, new Error('Network failure'));
let enqueued = false;
- let auditCreated = false;
const mockQueueService = {
enqueue: async (queue, jobName, data) => {
@@ -134,7 +132,6 @@ test('replayDeadLetterJob re-enqueues job and records audit event when job is va
},
auditLog: {
create: async ({ data }) => {
- auditCreated = true;
assert.equal(data.action, 'whatsapp.dlq.replayed');
assert.equal(data.entityId, record.id);
return { id: 'audit-2' };
diff --git a/apps/landing/src/App.jsx b/apps/landing/src/App.jsx
index d3937138..81349c12 100644
--- a/apps/landing/src/App.jsx
+++ b/apps/landing/src/App.jsx
@@ -3,7 +3,6 @@ import ErrorBoundary from '@shared/ErrorBoundary.jsx';
import Navbar from './components/Navbar.jsx';
import Footer from './components/Footer.jsx';
import Home from './pages/Home.jsx';
-import OnboardingStatus from './pages/OnboardingStatus.jsx';
import NotFound from './pages/NotFound.jsx';
export default function App() {
diff --git a/apps/landing/src/pages/OnboardingStatus.jsx b/apps/landing/src/pages/OnboardingStatus.jsx
index 9b3b969d..6c942bd9 100644
--- a/apps/landing/src/pages/OnboardingStatus.jsx
+++ b/apps/landing/src/pages/OnboardingStatus.jsx
@@ -49,7 +49,9 @@ export default function OnboardingStatus() {
};
useEffect(() => {
- fetchStatus();
+ // Defer out of the synchronous effect body to avoid cascading re-renders.
+ const timer = setTimeout(fetchStatus, 0);
+ return () => clearTimeout(timer);
}, []);
const getStageBadge = (stage) => {
diff --git a/docs/ALERT-DELIVERY-VERIFICATION.md b/docs/ALERT-DELIVERY-VERIFICATION.md
new file mode 100644
index 00000000..0a743200
--- /dev/null
+++ b/docs/ALERT-DELIVERY-VERIFICATION.md
@@ -0,0 +1,227 @@
+# Continuous alert-delivery verification
+
+> **Issue #228 — Continuously test alert delivery.** Monitoring can look healthy
+> while the alert-routing pipeline is actually broken. This feature proves the
+> real outbound alert path works end-to-end on a schedule instead of merely
+> checking that components are running.
+
+SendAm's only production alert/notification channel is the WhatsApp outbound
+pipe (`sendTextMessage` / `sendTemplateMessage` via the Meta Cloud API). This
+verification system continuously proves that pipe works by dispatching
+clearly-marked **synthetic** test messages to an internal operator number
+through the real outbound pipeline, confirming end-to-end delivery from the
+provider's status webhook, using a bounded fallback route when the primary
+route fails, and surfacing every miss as an actionable failure.
+
+## How it works
+
+A poller (`apps/api/src/jobs/alertDelivery.jobs.js`) runs in the **worker**
+process on a configurable interval. Each tick:
+
+1. **Reconcile** — any outstanding synthetic test is checked against its
+ linked `Notification`'s provider delivery status. `delivered`/`read` ⇒ the
+ test is confirmed end-to-end. Provider `failed` ⇒ the test fails. No
+ confirmation within the acknowledgement timeout ⇒ the test times out and
+ becomes an actionable failure.
+2. **Missed-test detection** — if no successful end-to-end verification has
+ happened for `interval × missedFactor` and no test is in flight (for example
+ the scheduler stopped), overall health flips to `failed`/`missed_test`. A
+ stopped scheduler can therefore never look healthy.
+3. **Dispatch** — if enabled, not already in flight, not before the due time,
+ and a test is not already running for this interval epoch, a new synthetic
+ test is dispatched through every configured route.
+
+The core logic lives in `apps/api/src/observability/alertDelivery.service.js`
+and is fully unit-tested.
+
+## Synthetic alerts
+
+Each synthetic test:
+
+- is dispatched to **only** `ALERT_TEST_RECIPIENT` — an internal operator
+ WhatsApp number, never a customer;
+- is clearly marked as synthetic via:
+ - `biz_opaque_callback_data` / correlation id prefixed `synthetic-alert:`,
+ - the linked `Notification` row with `type=synthetic_test` and
+ `referenceType=alert-test`,
+ - the message body `[SendAm alert-delivery test] …`;
+- uses the **real** outbound pipeline (`whatsapp.service`), so it exercises the
+ exact same path real alerts take.
+
+### Customer paging is impossible
+
+Synthetic tests never run the customer-facing responder (`assistant.service` /
+`processMessage`), never touch user wallets, and are only ever sent to the
+configured internal recipient. Because the recipient is a single operator value
+(coupled to the hard kill switch), there is no route by which a synthetic alert
+can reach a customer. The service refuses to dispatch when no recipient is
+configured, and the tests assert the recipient invariant.
+
+## Routes and fallback
+
+Routes are the delivery variants a synthetic test can use, ordered so the
+**primary** route is tried first:
+
+| Order | Route | Delivery mechanism |
+|-------|-------|--------------------|
+| 1 (primary) | `whatsapp-text` | free-form `sendTextMessage` |
+| 2 (fallback, only if `ALERT_TEST_TEMPLATE_NAME` is set) | `whatsapp-template` | approved `sendTemplateMessage` |
+
+Because WhatsApp only allows free-form text inside an open 24-hour customer
+window, the primary text route can fail (e.g. window expired) while the approved
+template route still succeeds — exactly the real-world failure the fallback is
+for.
+
+```
+Synthetic alert
+ │
+ ▼
+Primary route (whatsapp-text)
+ │
+ ├── success ──────────────────► verified (healthy)
+ │
+ └── failure ──────────────────► fallback route (whatsapp-template)
+ │
+ ├── success ─► verified (degraded — primary failure kept visible)
+ └── failure ─► verification failed
+```
+
+- The fallback is **bounded**: it is attempted at most once per test (ordered
+ route list, no recursion).
+- A primary failure is **never hidden**: the overall status becomes `degraded`
+ (still an end-to-end success, but the primary route needs attention), and the
+ run records each route's outcome.
+- If both routes fail, the overall status becomes `failed` and the failure
+ reason (`all_routes_failed_synchronously`) plus per-route detail are
+ persisted.
+
+## Confirmation boundary
+
+The WhatsApp Cloud API returns a provider message id synchronously when it has
+*accepted* a message, but actual delivery arrives later via the status webhook
+(`sent` → `delivered` → `read`, or terminal `failed`). Consequently:
+
+- **Synchronous acceptance** (`providerMessageId`) is *not* treated as
+ end-to-end delivery. A test that is only accepted remains in the `accepted`
+ state awaiting confirmation.
+- **Confirmation** means the provider-reported status is `delivered` or `read`
+ for the linked `Notification` — i.e. the message reached the recipient's
+ device. (Apache/Meta `delivered` is the strongest signal the current provider
+ exposes; "read" is not required, so the check never depends on read
+ receipts.)
+- If no confirmation arrives within `ALERT_DELIVERY_ACK_TIMEOUT_MS`, the test
+ is marked `timed_out` and overall health is `failed`.
+
+Per-test status lifecycle:
+`dispatched` → `accepted` → `confirmed`, or `failed`, or `timed_out`.
+
+## Health states
+
+| State | Meaning |
+|-------|---------|
+| `healthy` | Last end-to-end verification succeeded on the primary route. |
+| `degraded` | Last end-to-end verification succeeded but only after a fallback route (primary route failing). |
+| `failed` | A test failed, an acknowledgement timed out, a test was missed (`missed_test`), or all routes are unavailable. |
+| `unknown` | The feature is enabled but no verification has completed yet (or history is absent). |
+| `disabled` | Not configured (no recipient / kill switch off / `sim` transport). |
+
+A **failed** verification never overwrites the last successful verification
+timestamp, so operators can always tell when alert delivery was last proven
+working.
+
+## Anti-storm & idempotency
+
+- `testId` is **deterministic per interval epoch** and unique — duplicate or
+ overlapping scheduler executions collide on the constraint, so at most one
+ test per interval is ever created.
+- Dispatch is additionally gated on an **in-flight** test and the persisted
+ **last-dispatch timestamp**, so a recovering provider is never flooded.
+- A single confirmation update is what advances a test to `confirmed`; late or
+ out-of-order delivery callbacks cannot duplicate a test.
+
+## Persistence
+
+Two tables live in PostgreSQL (via Prisma, same store as everything else):
+
+- `AlertDeliveryTest` — append-only history of each synthetic run (per-route
+ outcomes, timestamps, failure reasons).
+- `AlertDeliveryState` — the singleton operational summary: overall health,
+ `lastSuccessfulTestAt`, last dispatch/failure, and diagnostic detail.
+
+## Operators viewing the last successful test
+
+Authenticated admin API:
+
+```
+GET /api/admin/alert-delivery
+```
+
+Returns `overallStatus`, `lastSuccessfulTestAt`, `lastTestId`,
+`lastDispatchAt`, `lastFailureAt`, `lastFailureReason`, and the 10 most recent
+tests (with per-route outcomes). Never returns the test recipient or any
+secret. The overall status is also surfaced on the existing health view:
+
+```
+GET /api/admin/system-health → { "alertDelivery": "healthy" | "degraded" | "failed" | "unknown" | "disabled" }
+```
+
+## Metrics & alerts
+
+Prometheus gauges (label-free, no PII):
+
+- `sendam_alert_delivery_status` — `1` healthy, `0.5` degraded, `0` failed/unknown/disabled.
+- `sendam_alert_delivery_last_success_timestamp_seconds` — last successful end-to-end verification.
+- `sendam_alert_delivery_age_seconds` — seconds since last success (`-1` if never).
+- `sendam_alert_delivery_checks_total` and `sendam_alert_delivery_outcomes_total{outcome}`.
+
+Alert rules are included in `observability/prometheus-rules.yml`:
+`SendAmAlertDeliveryFailed` (critical), `SendAmAlertDeliveryDegraded`
+(warning), and `SendAmAlertDeliveryNeverVerified` (warning when enabled but
+never successful for several hours).
+
+Key structured log events: `synthetic_alert_started`, `synthetic_alert_dispatched`,
+`synthetic_alert_route_failed`, `synthetic_alert_fallback_started`,
+`synthetic_alert_fallback_succeeded`, `synthetic_alert_delivery_confirmed`,
+`synthetic_alert_acknowledged`, `synthetic_alert_verification_failed`,
+`synthetic_alert_verification_timed_out`, `synthetic_alert_verification_missed`,
+`alert_delivery_poller_started` / `_stopped` / `_disabled`.
+
+## Configuration
+
+See `apps/api/.env.example`. All variables are optional; the feature is off
+until enabled.
+
+| Variable | Default | Meaning |
+|----------|---------|---------|
+| `ALERT_TEST_RECIPIENT` | — | **Internal** operator WhatsApp number the synthetic alerts are sent to. Setting this enables the feature. |
+| `ALERT_DELIVERY_ENABLED` | on when recipient set | Hard kill switch (`false` disables). |
+| `ALERT_DELIVERY_INTERVAL_MS` | `3600000` (1h) | How often to run the end-to-end test. |
+| `ALERT_DELIVERY_ACK_TIMEOUT_MS` | `600000` (10m) | How long to await provider delivery confirmation before the test times out. Keep it below the interval. |
+| `ALERT_DELIVERY_MISSED_FACTOR` | `3` | Intervals of silence before a stalled/missed verification becomes `failed`. |
+| `ALERT_TEST_TEMPLATE_NAME` | — | Approved WhatsApp template used by the fallback route. Leaving it empty disables the fallback. |
+| `ALERT_TEST_TEMPLATE_LANGUAGE` | `en` | Language code for the fallback template. |
+
+Invalid values are rejected at startup by `config/validateEnv.js`.
+
+> Note: end-to-end delivery confirmation requires the real `meta` transport.
+> With `MESSAGE_TRANSPORT=sim` the feature is left disabled because there is no
+> provider delivery webhook to confirm against.
+
+## Troubleshooting
+
+1. **`alertDelivery: "failed"`, `lastFailureReason: "all_routes_failed_synchronously"`**
+ — both routes rejected the send. Check the WhatsApp token/phone id, message
+ transport, and the per-route errors in `GET /api/admin/alert-delivery`.
+2. **`acknowledgement_timeout`** — the provider accepted the message but no
+ `delivered`/`read` webhook arrived within the timeout. Verify the webhook is
+ configured and delivering status callbacks
+ (`docs/PRODUCTION-WHATSAPP-WEBHOOK.md`), and that the test recipient has
+ delivery receipts enabled.
+3. **`missed_test`** — no verification ran for several intervals with nothing
+ in flight. The worker/poller is likely stopped; confirm the worker process
+ is running and `alert_delivery_poller_started` appears in its logs.
+4. **`degraded`** — the primary text route is failing and fallback delivery is
+ keeping verification alive. Investigate the primary route (e.g. 24h window,
+ send policy) before the fallback itself fails.
+5. **No metric / always `disabled`** — `ALERT_TEST_RECIPIENT` is unset,
+ `ALERT_DELIVERY_ENABLED=false`, or `MESSAGE_TRANSPORT=sim`.
\ No newline at end of file
diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md
index b26bcc58..cf3869e1 100644
--- a/docs/OBSERVABILITY.md
+++ b/docs/OBSERVABILITY.md
@@ -85,6 +85,18 @@ The primary metrics are:
- `sendam_health_checks_total`
- process uptime and resident memory gauges
+Continuous alert-delivery verification (see
+`docs/ALERT-DELIVERY-VERIFICATION.md`) adds label-free gauges that prove the
+outbound alert-routing pipeline itself is delivering end-to-end:
+
+- `sendam_alert_delivery_status` — 1 healthy, 0.5 degraded (primary failing,
+ fallback in use), 0 failed/unknown/disabled
+- `sendam_alert_delivery_last_success_timestamp_seconds` — last verified
+ end-to-end delivery
+- `sendam_alert_delivery_age_seconds` — seconds since last success (-1 = never)
+- `sendam_alert_delivery_checks_total` and
+ `sendam_alert_delivery_outcomes_total{outcome}`
+
Redis availability and recovery signals (see
`apps/api/src/config/redis.js` and `test/redis.safeguards.test.js`):
@@ -130,9 +142,12 @@ Alertmanager, credentials, and log ingestion. Backend Engineering owns metric
semantics, correlation propagation, redaction tests, and exception triage.
Payments/Compliance must join incidents involving financial or KYC operations.
-Alert delivery itself must be monitored. Run a synthetic alert at least weekly,
-and alert through an independent channel when Prometheus, Alertmanager, the log
-drain, or the error-monitor endpoint is unavailable.
+Alert delivery itself must be monitored. SendAm continuously sends synthetic
+alerts through the real outbound pipeline on a schedule and confirms delivery
+end-to-end (`sendam_alert_delivery_*`, `docs/ALERT-DELIVERY-VERIFICATION.md`),
+so a bogus "all monitoring components up" picture cannot hide a broken
+alert-routing path. Alert through an independent channel when Prometheus,
+Alertmanager, the log drain, or the error-monitor endpoint is unavailable.
## Operator recovery
diff --git a/observability/prometheus-rules.yml b/observability/prometheus-rules.yml
index 6e8a4bae..b6689949 100644
--- a/observability/prometheus-rules.yml
+++ b/observability/prometheus-rules.yml
@@ -160,6 +160,36 @@ groups:
summary: SendAm Redis reconnect budget exhausted; ensure Redis is reachable
runbook_url: https://github.com/EF-CHAIN/SendAm/blob/main/docs/OBSERVABILITY.md#redis-disconnected
+ - alert: SendAmAlertDeliveryFailed
+ expr: sendam_alert_delivery_status == 0
+ for: 5m
+ labels:
+ severity: critical
+ service: sendam-worker
+ annotations:
+ summary: Continuous alert-delivery verification is failing (all routes failed, ack timed out, or a test was missed)
+ runbook_url: https://github.com/EF-CHAIN/SendAm/blob/main/docs/ALERT-DELIVERY-VERIFICATION.md#troubleshooting
+
+ - alert: SendAmAlertDeliveryDegraded
+ expr: sendam_alert_delivery_status == 0.5
+ for: 10m
+ labels:
+ severity: warning
+ service: sendam-worker
+ annotations:
+ summary: Alert delivery is degraded (primary route failing, fallback in use)
+ runbook_url: https://github.com/EF-CHAIN/SendAm/blob/main/docs/ALERT-DELIVERY-VERIFICATION.md#troubleshooting
+
+ - alert: SendAmAlertDeliveryNeverVerified
+ expr: sendam_alert_delivery_age_seconds < 0
+ for: 6h
+ labels:
+ severity: warning
+ service: sendam-worker
+ annotations:
+ summary: Continuous alert-delivery verification is enabled but has never succeeded end-to-end
+ runbook_url: https://github.com/EF-CHAIN/SendAm/blob/main/docs/ALERT-DELIVERY-VERIFICATION.md#troubleshooting
+
- alert: SendAmQueueInlineFallback
expr: increase(sendam_queue_inline_fallback_total[10m]) > 0
for: 5m
diff --git a/scripts/secret-scan-self-test.sh b/scripts/secret-scan-self-test.sh
index 8d784d27..ad8b0e10 100755
--- a/scripts/secret-scan-self-test.sh
+++ b/scripts/secret-scan-self-test.sh
@@ -60,7 +60,7 @@ info "Created seeded fake secrets in $TMPDIR/fake-secrets.env"
info "Running gitleaks detect..."
set +e
-OUTPUT="$(gitleaks detect --source="$TMPDIR" --no-banner --redact 2>&1)"
+OUTPUT="$(gitleaks detect --source="$TMPDIR" --no-banner --redact --no-git 2>&1)"
EXIT_CODE=$?
set -e