Skip to content

Conversation

@onahprosper
Copy link
Collaborator

@onahprosper onahprosper commented Dec 31, 2025

Description

This PR fixes a race condition where orders become orphaned when refunds occur during OrderCreated event validation.

Problem:
When an OrderCreated event is indexed and ProcessPaymentOrderFromBlockchain is called:

  1. An existing order with messageHash but no gatewayID (status="initiated") is found
  2. validateAndPreparePaymentOrderData is called BEFORE updating the order in the database
  3. If validation fails (e.g., message hash decryption fails, institution lookup fails), createBasicPaymentOrderAndCancel is called
  4. HandleCancellation creates a NEW order with gatewayID and status "cancelled"
  5. The original order remains orphaned at "initiated" status with no gatewayID
  6. When OrderRefunded events are processed, they can't find the original order because it doesn't have gatewayID

Solution:

  1. Update existing orders with gatewayID BEFORE validation: If an order exists with messageHash but no gatewayID, update it immediately with gatewayID, txHash, and blockNumber before calling validation
  2. Pass existing order through validation chain: Pass the existing order to validateAndPreparePaymentOrderData and through to createBasicPaymentOrderAndCancel
  3. Update instead of create: Modify createBasicPaymentOrderAndCancel to check if an order with the matching gatewayID exists, and if so, pass it to HandleCancellation with paymentOrderFields = nil so it updates the existing order instead of creating a duplicate

Impact:

  • Prevents orphaned orders when refunds occur during validation
  • Ensures orders always have gatewayID in the database before any refund processing
  • Fixes the race condition where OrderRefunded events can't find orders that were refunded during OrderCreated processing

References

N/A - This is a bug fix for an internal race condition issue.

Testing

Manual Testing Steps:

  1. Create a payment order via API (status="initiated", has messageHash, no gatewayID)
  2. Index an OrderCreated event for this order
  3. Simulate a validation failure (e.g., invalid message hash) that triggers cancellation
  4. Verify that the existing order is updated to "cancelled" status with gatewayID set
  5. Verify that no duplicate order is created
  6. If a refund occurs during this process, verify that OrderRefunded events can find and update the order

Environment:

  • Go 1.25.0

  • PostgreSQL (via Ent ORM)

  • Redis

  • This change adds test coverage for new/changed/fixed functionality

Checklist

  • I have added documentation and tests for new/changed/fixed functionality in this PR
  • All active GitHub checks for tests, formatting, and security are passing
  • The correct base branch is being used, if not main (base: stable)

By submitting a PR, I agree to Paycrest's Contributor Code of Conduct and Contribution Guide.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed payment order processing to properly update existing orders with gateway information when it becomes available, preventing duplicate order creation
    • Enhanced order cancellation handling to intelligently reuse existing orders instead of always creating new records, improving system reliability

✏️ Tip: You can customize this high-level summary in your review settings.

…alidation

- Update existing orders with gatewayID before validation to ensure they exist in DB
- Pass existing order through validation chain to HandleCancellation
- Update createBasicPaymentOrderAndCancel to update existing orders instead of creating duplicates
- Fixes race condition where orders get refunded during validation but remain orphaned at 'initiated' status
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 31, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

When API-created orders lacking a gatewayID already exist in the system, the code now updates the existing record with gatewayID, transaction hash, and block number before validation, then reloads and passes this updated order through subsequent processing paths. Function signatures extended to accept optional existing orders, influencing cancellation and validation logic.

Changes

Cohort / File(s) Summary
Order Creation & Validation Logic
services/common/order.go
Extended validateAndPreparePaymentOrderData and createBasicPaymentOrderAndCancel signatures to accept optional existingOrder parameter. Added logic to detect and update existing orders (lacking gatewayID) with gateway data before validation. Updated call sites to propagate existingOrder through cancellation and validation paths.

Sequence Diagram

sequenceDiagram
    actor Client
    participant OrderService as Order Service
    participant DB as Database
    participant Validator as Validator

    Client->>OrderService: Trigger order processing
    activate OrderService
    OrderService->>DB: Check for existing order by message hash
    alt Order exists but lacks gatewayID
        DB-->>OrderService: Return existing order
        OrderService->>OrderService: Update order with gatewayID,<br/>tx hash, block number
        OrderService->>DB: Persist updates
        DB-->>OrderService: Confirmation
        OrderService->>DB: Reload updated order
        DB-->>OrderService: Return refreshed order
        rect rgb(200, 240, 255)
        note over OrderService,Validator: Pass updated order to validation
        OrderService->>Validator: Validate with existing order context
        end
    else Order doesn't exist or has gatewayID
        rect rgb(240, 240, 240)
        note over OrderService,Validator: Standard validation path
        OrderService->>Validator: Validate order data
        end
    end
    Validator-->>OrderService: Validation result
    OrderService->>OrderService: Process cancellation if needed
    OrderService-->>Client: Complete
    deactivate OrderService
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A gateway tale, so neat and true,
Where orders old get gifted new,
With hashes, blocks, and IDs aligned,
No duplicates left behind!
The rabbit hops through code so clean,
The finest order flow we've seen!

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing orphaned orders when refunds occur during OrderCreated validation, which aligns with the core problem and solution in the changeset.
Description check ✅ Passed The description is comprehensive and well-structured, covering the problem, solution, impact, and manual testing steps. However, the testing checklist box is unchecked, indicating no test coverage was added for this significant logic change.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

chibie
chibie previously approved these changes Dec 31, 2025
@chibie
Copy link
Contributor

chibie commented Dec 31, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 31, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/common/order.go (1)

1134-1160: Potential issue: Inconsistent handling may create duplicate orders.

Line 1117 correctly passes existingOrder to HandleCancellation to update the existing order:

err := HandleCancellation(ctx, existingOrder, nil, "Amount is less than the minimum bucket", refundOrder)

However, the rate validation cancellations at lines 1135, 1143, and 1155 still pass nil, paymentOrderFields:

err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder)

If existingOrderWithMessageHash was present and updated with gatewayID at lines 94-100, calling HandleCancellation with paymentOrderFields will create a new cancelled order with the same gatewayID, potentially resulting in duplicates.

Consider applying the same pattern consistently:

🔎 Suggested fix
 	if rateResult.Rate == decimal.NewFromInt(1) && paymentOrderFields.Rate != decimal.NewFromInt(1) {
-		err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder)
+		err := HandleCancellation(ctx, existingOrder, paymentOrderFields, "Rate validation failed", refundOrder)
 		if err != nil {
 			return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
 		}
 		return nil, nil, nil, nil, nil, nil
 	}

 	if rateErr != nil {
-		err := HandleCancellation(ctx, nil, paymentOrderFields, fmt.Sprintf("Rate validation failed: %s", rateErr.Error()), refundOrder)
+		err := HandleCancellation(ctx, existingOrder, paymentOrderFields, fmt.Sprintf("Rate validation failed: %s", rateErr.Error()), refundOrder)
 		if err != nil {
 			return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
 		}
 		return nil, nil, nil, nil, nil, nil
 	}

 	// Check if event rate is within 0.1% tolerance of validated rate
 	tolerance := rateResult.Rate.Mul(decimal.NewFromFloat(0.001)) // 0.1% tolerance
 	rateDiff := event.Rate.Sub(rateResult.Rate).Abs()

 	if rateDiff.GreaterThan(tolerance) {
-		err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder)
+		err := HandleCancellation(ctx, existingOrder, paymentOrderFields, "Rate validation failed", refundOrder)
 		if err != nil {
 			return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err)
 		}
 		return nil, nil, nil, nil, nil, nil
 	}

Note: HandleCancellation currently treats createdPaymentOrder and paymentOrderFields as mutually exclusive (line 629). If both are provided, it returns early. This may need adjustment if you want to support updating an existing order with additional fields from paymentOrderFields.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d3c43e and 59cf6f1.

📒 Files selected for processing (1)
  • services/common/order.go
🧰 Additional context used
🧬 Code graph analysis (1)
services/common/order.go (2)
ent/paymentorder.go (2)
  • PaymentOrder (24-107)
  • PaymentOrder (204-235)
types/types.go (1)
  • OrderCreatedEvent (71-81)
🔇 Additional comments (5)
services/common/order.go (5)

90-113: LGTM - Core fix for the race condition.

Updating the order with gatewayID before validation ensures the order exists in the DB with the gateway identifier before any refund processing can occur. The reload after update correctly fetches the updated entity state.

One edge case to verify: if the update at line 94-100 succeeds but subsequent validation fails and the entire ProcessPaymentOrderFromBlockchain returns an error, the order will remain with gatewayID set but potentially incomplete data. This appears intentional given the PR objective, but ensure downstream code handles this partial state correctly.


116-117: LGTM - Correctly propagates existing order through validation.

Passing the existing order enables proper cancellation handling without creating duplicates.


1014-1023: LGTM - Function signature update is well-documented.

The added parameter and comment clearly explain the purpose of the optional existing order.


1056-1056: LGTM - Existing order correctly passed for early validation failures.

These changes ensure that if an existing order was updated with gatewayID before validation, the cancellation path will update that order instead of creating a duplicate.


1225-1236: LGTM - Function signature and documentation updated appropriately.

The comment clearly explains the conditional behavior based on whether existingOrder is provided.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@chibie chibie merged commit c9b68f0 into stable Dec 31, 2025
2 checks 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.

3 participants