# Failed/rejected transactions logged with reason code

- **Pattern:** `ab-001391` (`finserv-audit-trail.retention-compliance.failed-transaction-logging`)
- **Severity:** low
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001391
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001391)

## Why it matters

A failed authorization denial, insufficient-funds rejection, or rate-limit block is not a neutral event — it is a potential indicator of credential stuffing, account takeover, or fraud probing. If it is not logged with a reason code, your security team has no signal to detect these patterns. PCI-DSS 4.0 Req-10.2.1.7 explicitly requires logging of invalid logical access attempts. NIST 800-53 AU-2 requires logging of failed login attempts and failed authentication events. FINRA Rule 4511 requires complete books and records, which include rejected transactions. CWE-778 (Insufficient Logging) covers the failure to record security-relevant events. An attacker probing for accounts with insufficient-funds errors on micro-transactions to enumerate valid account IDs will leave no trace if rejections are silently swallowed.

## Severity rationale

Low because failed-transaction logging is a detective control — its absence does not enable attacks directly, but it eliminates the forensic evidence needed to detect and investigate fraud after the fact.

## Remediation

Wrap every failure path in `src/services/transaction.ts` with an explicit log write before throwing, and define a fixed set of reason codes as a TypeScript union or enum:

```ts
type FailReason =
  | 'INSUFFICIENT_FUNDS'
  | 'ACCOUNT_FROZEN'
  | 'AUTHORIZATION_DENIED'
  | 'INVALID_INPUT'
  | 'RATE_LIMITED'
  | 'ACCOUNT_NOT_FOUND';

async function logFailure(
  userId: string, operation: string, amount: number, reason: FailReason
) {
  await db('transaction_logs').insert({
    user_id: userId, operation_type: operation, amount,
    result: 'fail', reason_code: reason,
    timestamp: new Date().toISOString()
  });
}

// In executeTransfer:
if (account.balance < amount) {
  await logFailure(userId, 'transfer', amount, 'INSUFFICIENT_FUNDS');
  throw new TransactionError('Insufficient funds');
}
```

A `catch`-all block at the route level does not satisfy this requirement — each named failure condition needs its own `reason_code` recorded before the exception propagates.

## Detection

- **ID:** `failed-transaction-logging`
- **Severity:** `low`
- **What to look for:** Count all error/failure handling paths in financial transaction endpoints. For each failure path, check whether a log entry is written with a reason code. Enumerate the distinct reason codes used (e.g., INSUFFICIENT_FUNDS, AUTHORIZATION_DENIED, INVALID_INPUT). Quote the actual reason code values found. A catch block that silently swallows errors without logging does not count as pass.
- **Pass criteria:** At least 90% of failure handling paths write to the audit log with a reason code. Count all distinct reason codes — at least 3 must exist. Report the ratio even on pass (e.g., "7 of 8 failure paths logged, 5 distinct reason codes: INSUFFICIENT_FUNDS, AUTH_DENIED, INVALID_INPUT, RATE_LIMITED, ACCOUNT_FROZEN").
- **Fail criteria:** Failed transactions are not logged, or failure reasons are omitted from the audit trail, or fewer than 3 distinct reason codes exist.
- **Skip (N/A) when:** Never — failures are regulatory events that must be tracked.
- **Detail on fail:** `"Failed transactions logged in 2 of 6 failure paths — reason codes present in 0 paths."` or `"Authorization denials not logged — 0 of 3 auth failure paths write to audit log."`.
- **Remediation:** Ensure all failures are logged with reasons (in `src/services/transaction.ts` or equivalent):

  ```ts
  async function executeTransfer(userId, toAccountId, amount) {
    // Validation
    const account = await db.accounts.findOne({ userId });
    if (!account) {
      await db.transactionLogs.create({
        userId,
        operationType: 'transfer',
        amount,
        result: 'fail',
        reason: 'ACCOUNT_NOT_FOUND',
        timestamp: new Date().toISOString()
      });
      throw new Error('Account not found');
    }

    if (account.balance < amount) {
      await db.transactionLogs.create({
        userId,
        operationType: 'transfer',
        amount,
        result: 'fail',
        reason: 'INSUFFICIENT_FUNDS',
        detail: `Balance: ${account.balance}, Requested: ${amount}`,
        timestamp: new Date().toISOString()
      });
      throw new Error('Insufficient funds');
    }

    // Execute transfer
    // ... success logging ...
  }
  ```

## External references

- cwe CWE-778
- nist AU-2
- pci-dss Req-10.2.1.7
- external FINRA-Rule-4511 — https://www.finra.org/rules-guidance/rulebook/finra-rules/4511

Taxons: regulatory-conformance, observability

HTML version: https://auditbuffet.com/patterns/ab-001391
