feat: exactly-once payment processing with transaction wrapping and recovery#26
Merged
oomokaro1 merged 3 commits intoJun 20, 2026
Conversation
- 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
approved these changes
Jun 20, 2026
oomokaro1
left a comment
Contributor
There was a problem hiding this comment.
All issue #14 requirements are implemented with clean transaction wrapping, idempotency checks, and a well-tested recovery service.
What's good
SELECT FOR UPDATEensures only one transaction processes a session at a time- Idempotency check on
tx_hashprevents 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 👍
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #14
Type of Change
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
processPaymentflow has no concurrency protection — two simultaneous Horizon events for the same session can both pass thestatus === '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)SELECT ... FOR UPDATEto acquire row-level lock oncheckout_sessionsbefore processingtx_hashbefore entering the transaction — duplicate payments are logged and skipped gracefully2. Payment Recovery Service (
payment-recovery.service.ts)PaymentRecoveryServicewith@Cron(EVERY_5_MINUTES)that runs three recovery scenarios:@nestjs/scheduleand configuredScheduleModule.forRoot()inAppModule3. Module Wiring
PaymentRecoveryServicetoPaymentsModuleprovidersScheduleModule.forRoot()toAppModuleimportsCurrent Behavior vs. New Behavior
Before:
After:
SELECT FOR UPDATEensures only one transaction processes a session at a timetx_hashprevents duplicate payment recordsTesting
Breaking Changes
No
Risks and Rollback
Risks:
SELECT FOR UPDATElock 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.Rollback:
processPaymentPaymentRecoveryServicefromPaymentsModuleScheduleModule.forRoot()fromAppModule@nestjs/schedulefrompackage.jsonChecklist
Self-Review
console.log,print,debugger, commented-out blocks)Testing
CI / Pipeline
Documentation
Security
Reviewer Notes
checkout_sessionswithcreated_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).SELECT FOR UPDATElock 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 ontx_hashalready prevents).