Skip to content

feat: exactly-once payment processing with transaction wrapping and recovery#26

Merged
oomokaro1 merged 3 commits into
OrbitStream:mainfrom
AbelOsaretin:feat/exactly-once-payment-processing
Jun 20, 2026
Merged

feat: exactly-once payment processing with transaction wrapping and recovery#26
oomokaro1 merged 3 commits into
OrbitStream:mainfrom
AbelOsaretin:feat/exactly-once-payment-processing

Conversation

@AbelOsaretin

Copy link
Copy Markdown
Contributor

Closes #14

Type of Change

  • New feature (non-breaking change that adds functionality)
  • Refactoring (no functional or behavioral changes)
  • Bug fix (non-breaking change that fixes an issue)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Performance improvement
  • Documentation update
  • Build / CI configuration change
  • Dependency update
  • Other:

Summary

Implements exactly-once payment processing with transaction wrapping, idempotency checks, and a periodic recovery job to handle stale or inconsistent session states.

Motivation / Context

Fixes #14

The current processPayment flow has no concurrency protection — two simultaneous Horizon events for the same session can both pass the status === 'pending' check, resulting in duplicate payment records and double webhook dispatches. Additionally, there is no recovery mechanism for sessions that get stuck in inconsistent states due to process crashes or network failures.

Detailed Changes

1. Transaction Wrapping (payment-detector.service.ts)

  • Wrapped session update, payment insert, and webhook dispatch in a single Drizzle transaction
  • Added SELECT ... FOR UPDATE to acquire row-level lock on checkout_sessions before processing
  • Added idempotency check on tx_hash before entering the transaction — duplicate payments are logged and skipped gracefully

2. Payment Recovery Service (payment-recovery.service.ts)

  • Created PaymentRecoveryService with @Cron(EVERY_5_MINUTES) that runs three recovery scenarios:
    1. Pending sessions with existing payment records: Mark as paid and dispatch webhook
    2. Sessions stuck in pending >5 minutes: Check if payment exists, mark as paid or expired
    3. Paid sessions without payment records: Log warning for manual investigation
  • Installed @nestjs/schedule and configured ScheduleModule.forRoot() in AppModule

3. Module Wiring

  • Added PaymentRecoveryService to PaymentsModule providers
  • Added ScheduleModule.forRoot() to AppModule imports

Current Behavior vs. New Behavior

Before:

  • Two concurrent Horizon events for the same session could both mark it as paid
  • No transaction wrapping — crash between session update and payment insert leaves inconsistent state
  • No recovery mechanism for stale or orphaned sessions

After:

  • SELECT FOR UPDATE ensures only one transaction processes a session at a time
  • Idempotency check on tx_hash prevents duplicate payment records
  • Transaction atomicity ensures session update + payment insert + webhook dispatch are all-or-nothing
  • Recovery job runs every 5 minutes to repair inconsistent states

Testing

  • Unit tests for idempotency check: duplicate tx_hash is skipped
  • Unit tests for transaction atomicity: session update, payment insert, and webhook dispatch are wrapped
  • Unit tests for SELECT FOR UPDATE: verifies row locking is used
  • Unit tests for amount/asset validation: mismatches are rejected
  • Unit tests for recovery service: pending with payments, stuck pending, paid without payments, empty results, error handling
  • All 209 tests pass locally
  • TypeScript compiles cleanly

Breaking Changes

No

Risks and Rollback

Risks:

  • The SELECT FOR UPDATE lock holds for the duration of the transaction — if the transaction is slow (e.g., webhook dispatch times out), it could block other payments. Mitigated by the 10-second webhook timeout.
  • The recovery job runs every 5 minutes — if it fails, stale sessions accumulate until the next run. Mitigated by error handling that continues processing other sessions.

Rollback:

  1. Revert the transaction wrapping in processPayment
  2. Remove PaymentRecoveryService from PaymentsModule
  3. Remove ScheduleModule.forRoot() from AppModule
  4. Remove @nestjs/schedule from package.json

Checklist

Self-Review

  • I have read the entire diff line by line as if a stranger wrote it
  • No debug code remains (console.log, print, debugger, commented-out blocks)
  • No hardcoded secrets, tokens, API keys, or internal URLs
  • Naming is consistent with the existing codebase
  • Error handling is present and produces meaningful messages
  • Edge cases are addressed (null/undefined, empty collections, boundary values)
  • No unused imports, dead code, or unnecessary dependencies

Testing

  • All existing tests pass locally
  • New tests added for new logic (functions, methods, branches)
  • Edge cases and failure paths are tested, not just the happy path
  • Manual testing steps documented above (if applicable)
  • Screenshots / recordings attached (for UI changes)

CI / Pipeline

  • All CI checks are passing (build, lint, test, type-check)
  • No new compiler warnings or linting errors introduced

Documentation

  • README updated if setup, usage, or installation changed
  • API documentation updated for any public interface changes
  • Inline comments added for non-obvious logic (explain "why", not "what")
  • Configuration / env var documentation updated (if applicable)

Security

  • User input is validated and sanitized at trust boundaries
  • No SQL injection, XSS, or injection vulnerabilities introduced
  • Authentication / authorization checks are in place for new endpoints
  • Dependencies have no known critical vulnerabilities

Reviewer Notes

  • The recovery job queries checkout_sessions with created_at < now() - interval '5 minutes' — this is a simple heuristic that works for the current scale. For high-volume deployments, consider adding an index on (status, created_at).
  • The SELECT FOR UPDATE lock is held for the entire transaction duration including webhook dispatch. If webhook dispatch becomes a bottleneck, consider moving it outside the transaction (at the cost of potential duplicate webhooks, which the idempotency check on tx_hash already prevents).

- Wrap session update, payment insert, and webhook dispatch in a single
  Drizzle transaction for atomicity
- Use SELECT ... FOR UPDATE to acquire row-level lock on checkout_sessions
  preventing concurrent payments for the same session
- Add idempotency check on tx_hash before transaction to skip duplicate
  payments gracefully
- Install @nestjs/schedule and configure ScheduleModule.forRoot() in AppModule
- Create PaymentRecoveryService with @Cron(EVERY_5_MINUTES) that runs three
  recovery scenarios:
  1. Pending sessions with existing payment records → mark as paid
  2. Sessions stuck in pending >5 minutes → check Horizon, recover or expire
  3. Paid sessions without payment records → log for manual investigation
- Add PaymentRecoveryService to PaymentsModule providers
- Transaction wrapping tests: idempotency check, SELECT FOR UPDATE lock,
  atomic session update + payment insert + webhook dispatch
- Amount and asset validation tests: mismatch rejection, XLM acceptance
- Recovery service tests: pending with payments, stuck pending, paid
  without payments, empty results, error handling

@oomokaro1 oomokaro1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All issue #14 requirements are implemented with clean transaction wrapping, idempotency checks, and a well-tested recovery service.

What's good

  • SELECT FOR UPDATE ensures only one transaction processes a session at a time
  • Idempotency check on tx_hash prevents duplicate payments
  • Recovery service handles three failure scenarios (pending with payments, stuck pending, paid without payments)
  • Comprehensive test coverage for all scenarios
  • Error handling continues processing other sessions if one fails

LGTM 👍

@oomokaro1 oomokaro1 merged commit 45ab1cd into OrbitStream:main Jun 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Exactly-Once Payment Processing with Conflict Resolution

2 participants