> ## Documentation Index
> Fetch the complete documentation index at: https://help.agatabo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency & Duplicate Prevention

> Prevent duplicate financial transactions using idempotency keys

## Overview

**Idempotency** ensures that performing the same operation multiple times has the same effect as performing it once. Agatabo uses idempotency keys to prevent duplicate financial transactions from network issues, accidental double-clicks, or system retries.

<Note>
  **How it works**: Every financial operation requires a unique `x-idempotency-key` header. If the same key is used twice, the second request is rejected as a duplicate.
</Note>

***

## Why Idempotency Matters

**Without idempotency protection:**

```
User clicks "Record Deposit"
  → Network timeout (no response)
  → User clicks again (thinking it didn't work)
  → Result: Deposit recorded TWICE ❌
```

**With Agatabo's idempotency:**

```
User clicks "Record Deposit" (key: abc123)
  → Network timeout
  → User clicks again (same key: abc123)
  → Backend detects duplicate key
  → Result: Deposit recorded ONCE ✓
```

***

## How It Works

### Database Constraint

**JournalEntry model** has unique constraint:

```prisma theme={null}
@@unique([organizationId, idempotencyKey])
```

**What this means:**

* Within an organization, each `idempotencyKey` can only be used once
* Attempting to create a duplicate journal entry fails with database constraint error
* Second request with same key is rejected automatically

***

### API Requirement

**All mutation operations require `x-idempotency-key` header:**

```http theme={null}
POST /savings
Headers:
  x-organization-id: org-abc123
  x-idempotency-key: unique-key-here
Body:
  { ... }
```

**Operations requiring idempotency key:**

* Recording deposits (`POST /savings`)
* Recording withdrawals (`DELETE /savings/{id}`)
* Creating loans (`POST /loans`)
* Recording loan payments (`POST /loans/{id}/repay`)
* Recording expenses (`POST /expenses`)
* Creating assets (`POST /assets`)
* Reserve allocations (`POST /reserve-allocations`)
* Reserve releases (`POST /reserve-allocations/{id}/release`)
* Dividend distributions (`POST /dividends/pools`, `POST /dividends/pools/{id}/distribute`)
* Period closing (`POST /period-closing/close`, `POST /period-closing/undo`)

**Operations NOT requiring idempotency key:**

* GET requests (read-only)
* Reports generation
* Viewing data

***

### Request Validation

**Backend validates idempotency key:**

```typescript theme={null}
if (!idempotencyKey?.trim()) {
  throw new BadRequestException('x-idempotency-key header is required');
}
```

**Missing key = 400 Bad Request error**

***

## Generating Idempotency Keys

**Frontend responsibility**: Generate unique key for each operation.

**Recommended format:**

```typescript theme={null}
// Option 1: UUID v4
const idempotencyKey = crypto.randomUUID();
// Example: "550e8400-e29b-41d4-a716-446655440000"

// Option 2: Timestamp + Random
const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Example: "1686567890123-k3j4h5g6f"

// Option 3: Operation-specific prefix
const idempotencyKey = `deposit-${userId}-${Date.now()}-${Math.random()}`;
// Example: "deposit-user123-1686567890123-0.123456789"
```

**Best practices:**

* ✅ Use UUID v4 (cryptographically random)
* ✅ Include timestamp for easier debugging
* ✅ Store key in application state during request
* ✅ Reuse same key for retries of the SAME request
* ✅ Generate new key for new operations
* ❌ Never reuse keys across different operations
* ❌ Don't use sequential numbers (predictable)

***

## Idempotency Scope

**Keys are scoped to organization:**

```
Organization A:
  idempotencyKey: "abc123" → Deposit 10,000 RWF ✓

Organization B:
  idempotencyKey: "abc123" → Deposit 5,000 RWF ✓
```

**Different organizations can use same key** (unique constraint includes `organizationId`)

**Within same organization:**

```
Organization A:
  idempotencyKey: "abc123" → Deposit 10,000 RWF ✓
  idempotencyKey: "abc123" → Deposit 20,000 RWF ❌ DUPLICATE
```

***

## Duplicate Detection

**When duplicate detected:**

**Request 1 (first time):**

```http theme={null}
POST /savings
Headers:
  x-idempotency-key: abc123

Response: 200 OK
{
  "message": "Savings recorded successfully",
  "data": { ... }
}
```

**Request 2 (duplicate - same key):**

```http theme={null}
POST /savings
Headers:
  x-idempotency-key: abc123

Response: 400 Bad Request or 409 Conflict
{
  "message": "Duplicate transaction detected",
  "error": "A transaction with this idempotency key already exists"
}
```

**Database error** (unique constraint violation) caught and returned as API error.

***

## Common Scenarios

### Scenario 1: Network Timeout

**Situation:**

1. User submits deposit form
2. Network timeout (30 seconds, no response)
3. Frontend retries with **same idempotency key**

**Result:**

```
Request 1: idempotency-key = "xyz789"
  → Processing... (slow network)
  → Success! Deposit created

Request 2 (retry): idempotency-key = "xyz789"
  → Duplicate key detected
  → Rejected: "Transaction already exists"
  → Deposit recorded ONCE ✓
```

**Frontend handling:**

```typescript theme={null}
const idempotencyKey = generateKey();

try {
  await createDeposit({ idempotencyKey });
} catch (error) {
  if (error.status === 408 || isNetworkTimeout(error)) {
    // Retry with SAME key
    await createDeposit({ idempotencyKey });
  }
}
```

***

### Scenario 2: Accidental Double-Click

**Situation:**

1. User double-clicks "Record Payment" button
2. Two requests sent rapidly

**Frontend implementation:**

```typescript theme={null}
let requestInProgress = false;
const idempotencyKey = generateKey();

async function handleSubmit() {
  if (requestInProgress) return; // Prevent second click

  requestInProgress = true;
  try {
    await recordPayment({ idempotencyKey });
  } finally {
    requestInProgress = false;
  }
}
```

**Even if both requests reach backend:**

```
Request 1: idempotency-key = "abc123"
  → Processing...
  → Success! Payment created

Request 2: idempotency-key = "abc123"
  → Duplicate key detected
  → Rejected immediately
  → Payment recorded ONCE ✓
```

***

### Scenario 3: Failed Request Retry

**Situation:**

1. Transaction fails due to validation error
2. User fixes error and resubmits

**Can retry with same key if transaction failed:**

```typescript theme={null}
const idempotencyKey = generateKey();

// First attempt - validation error
try {
  await createLoan({
    amount: -5000, // Invalid (negative amount)
    idempotencyKey
  });
} catch (error) {
  if (error.status === 400) {
    // Validation error - transaction NOT created
    // Can retry with same key after fixing
    await createLoan({
      amount: 5000, // Fixed
      idempotencyKey // Same key OK - first attempt failed
    });
  }
}
```

**Key point**: Idempotency protection only blocks **successful** transactions.

***

### Scenario 4: New Transaction

**Situation:**

1. User successfully records deposit
2. Wants to record another deposit

**Must generate new key:**

```typescript theme={null}
// First deposit
const key1 = generateKey(); // "abc123"
await createDeposit({ amount: 10000, idempotencyKey: key1 });

// Second deposit - MUST use new key
const key2 = generateKey(); // "def456"
await createDeposit({ amount: 20000, idempotencyKey: key2 });
```

**Using same key would be rejected:**

```typescript theme={null}
// Wrong - reusing key
await createDeposit({ amount: 10000, idempotencyKey: key1 }); // ✓
await createDeposit({ amount: 20000, idempotencyKey: key1 }); // ❌ DUPLICATE
```

***

## Error Handling

### Missing Idempotency Key

**Request without key:**

```http theme={null}
POST /savings
Headers:
  x-organization-id: org-abc123
  # Missing x-idempotency-key

Response: 400 Bad Request
{
  "message": "x-idempotency-key header is required"
}
```

**Frontend must include key for all mutations.**

***

### Duplicate Key Error

**Duplicate request detected:**

```http theme={null}
POST /savings
Headers:
  x-idempotency-key: already-used-key

Response: 400 Bad Request or 409 Conflict
{
  "message": "Duplicate transaction detected",
  "error": "Unique constraint violation on idempotencyKey"
}
```

**What to do:**

1. Check if original transaction succeeded
2. If succeeded: Don't retry, show success message
3. If failed: Retry with same key
4. If creating new transaction: Generate new key

***

## Frontend Implementation Example

**Complete idempotency handling:**

```typescript theme={null}
import { v4 as uuidv4 } from 'uuid';

class TransactionService {
  private pendingRequests = new Map<string, Promise<any>>();

  async createDeposit(data: DepositData) {
    // Generate idempotency key
    const idempotencyKey = uuidv4();

    // Prevent duplicate in-flight requests
    if (this.pendingRequests.has(idempotencyKey)) {
      return this.pendingRequests.get(idempotencyKey);
    }

    // Create request
    const request = fetch('/savings', {
      method: 'POST',
      headers: {
        'x-organization-id': organizationId,
        'x-idempotency-key': idempotencyKey,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    })
    .then(async (response) => {
      if (!response.ok) {
        const error = await response.json();

        // Check if duplicate
        if (error.message?.includes('duplicate') ||
            error.message?.includes('idempotency')) {
          // Duplicate detected - check if original succeeded
          const original = await this.checkTransactionStatus(idempotencyKey);
          if (original) {
            return original; // Return original transaction
          }
        }

        throw error;
      }

      return response.json();
    })
    .finally(() => {
      this.pendingRequests.delete(idempotencyKey);
    });

    // Store pending request
    this.pendingRequests.set(idempotencyKey, request);

    return request;
  }

  async checkTransactionStatus(idempotencyKey: string) {
    // Query backend for transaction with this key
    // Implementation depends on your API
  }
}
```

***

## Retry Logic

**Safe retry strategy:**

```typescript theme={null}
async function safeRetry(operation: () => Promise<any>, maxRetries = 3) {
  const idempotencyKey = generateKey();

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation(idempotencyKey);
    } catch (error) {
      // Network errors - retry with same key
      if (isNetworkError(error) && attempt < maxRetries) {
        await delay(1000 * attempt); // Exponential backoff
        continue;
      }

      // Validation errors - don't retry
      if (error.status === 400 && !isDuplicateError(error)) {
        throw error;
      }

      // Duplicate detected - check if original succeeded
      if (isDuplicateError(error)) {
        const original = await checkStatus(idempotencyKey);
        if (original) return original;
      }

      throw error;
    }
  }
}

// Usage
await safeRetry((key) =>
  createDeposit({ amount: 10000, idempotencyKey: key })
);
```

***

## Best Practices

<Note>
  **Idempotency implementation checklist:**

  **Key generation:**

  * ✅ Use UUID v4 (crypto.randomUUID())
  * ✅ Generate key in frontend before request
  * ✅ Store key in component state during request
  * ✅ Include key in all mutation API calls
  * ✅ Log keys for debugging (don't expose to users)

  **Retry handling:**

  * ✅ Reuse same key for network timeouts
  * ✅ Reuse same key for 5xx server errors
  * ✅ Generate new key for new operations
  * ✅ Check transaction status before retrying duplicates
  * ✅ Implement exponential backoff for retries

  **Error handling:**

  * ✅ Detect duplicate errors (400/409 with "idempotency" message)
  * ✅ Verify original transaction status before showing error
  * ✅ Show "Transaction already recorded" instead of generic error
  * ✅ Prevent user confusion (don't say "failed" if it succeeded)

  **UI/UX:**

  * ✅ Disable submit button during request (prevent double-click)
  * ✅ Show loading spinner while processing
  * ✅ Clear form only after confirmed success
  * ✅ Handle network timeouts gracefully (retry automatically)
  * ✅ Provide clear error messages

  **Testing:**

  * ✅ Test duplicate key rejection
  * ✅ Test network timeout retry
  * ✅ Test concurrent requests with same key
  * ✅ Test key uniqueness across operations
</Note>

***

## Troubleshooting

**Q: Getting "idempotency-key header is required" error**

A: Add `x-idempotency-key` header to request:

```typescript theme={null}
headers: {
  'x-idempotency-key': crypto.randomUUID()
}
```

All POST, PUT, DELETE requests for financial operations require this header.

***

**Q: Getting "duplicate transaction" error immediately**

A: The idempotency key was already used. Either:

1. Original transaction succeeded (check transaction history)
2. Reusing key from previous operation (generate new key)

**Solution:**

```typescript theme={null}
// Don't reuse keys across operations
const key1 = generateKey();
await createDeposit({ idempotencyKey: key1 }); // ✓

const key2 = generateKey(); // New key!
await createDeposit({ idempotencyKey: key2 }); // ✓
```

***

**Q: Need to record identical transactions**

A: Use different idempotency keys:

```typescript theme={null}
// Two identical deposits - different keys
await createDeposit({
  member: "Jane",
  amount: 10000,
  idempotencyKey: generateKey() // Unique key 1
});

await createDeposit({
  member: "Jane",
  amount: 10000,
  idempotencyKey: generateKey() // Unique key 2
});
```

Idempotency prevents duplicate *requests*, not duplicate *transactions*.

***

**Q: Transaction failed but can't resubmit**

A: If transaction failed (not created), you can retry with **same key**:

```typescript theme={null}
const key = generateKey();

try {
  await createLoan({ amount: 10000, idempotencyKey: key });
} catch (error) {
  if (isValidationError(error)) {
    // Fix validation issue, retry with same key
    await createLoan({ amount: 10000, idempotencyKey: key }); // OK
  }
}
```

***

**Q: How long are keys valid?**

A: Idempotency keys are permanent. Once used successfully, they cannot be reused in that organization.

**Implication**: Don't use predictable keys (sequential numbers, dates alone).

***

**Q: Can I use same key in different organizations?**

A: Yes. Unique constraint is `(organizationId, idempotencyKey)`:

```typescript theme={null}
// Organization A
await createDeposit({
  organization: "org-A",
  idempotencyKey: "abc123" // ✓
});

// Organization B - same key OK
await createDeposit({
  organization: "org-B",
  idempotencyKey: "abc123" // ✓ Different organization
});
```

***

## Related Topics

<CardGroup cols={2}>
  <Card title="Recording Deposits" icon="money-bill-wave" href="/tontines/deposits-savings/recording-deposits">
    Deposit API with idempotency
  </Card>

  <Card title="Creating Loans" icon="hand-holding-dollar" href="/tontines/loans/creating-loans">
    Loan API with idempotency
  </Card>

  <Card title="Period Closing" icon="lock" href="/tontines/advanced/period-closing">
    Period close with idempotency
  </Card>

  <Card title="Audit Trail" icon="file-magnifying-glass" href="/tontines/advanced/audit-trail">
    Track duplicate prevention
  </Card>
</CardGroup>
