Skip to content

refactor(payments): split ProcessPayment into Acceptor + Gateway - #40

Merged
emeraldleaf merged 2 commits into
mainfrom
refactor/payment-process-async-shape
May 27, 2026
Merged

emeraldleaf merged 2 commits into
mainfrom
refactor/payment-process-async-shape

Conversation

@emeraldleaf

@emeraldleaf emeraldleaf commented May 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

First application of the long-running-work / 202 Accepted rule landed in #39 (CLAUDE.md "Performance Rules"). The Stripe gateway call is the slow part (sub-second in the happy path, seconds-to-30s on degraded states); doing it inline held an HTTP request open (and a Wolverine handler slot + DbContext + Service Bus message lease on the saga path) for the full duration.

The split

Entry points are unchanged — both still go through ProcessPaymentCommand:

POST /api/v1/payments/process  ─┐
                                ├─► ProcessPaymentCommand
OrderPlacedHandler (saga)      ─┘

ProcessPaymentHandler (Acceptor — fast):

  • Idempotency check on OrderId
  • Re-publish terminal event for Completed/Failed re-entry (defensive)
  • Persist Payment(Pending) + publish PaymentProcessingRequested + SaveChanges (atomic: row + staged envelope in one DB tx)
  • Return PaymentId
  • No gateway call. Sub-second.

PaymentProcessingRequestedHandler (Gateway — slow, runs on Wolverine worker):

  • Re-fetch Payment, status-guard for at-least-once redelivery (no-op if already Completed/Failed)
  • Call Stripe
  • MarkAsCompleted/MarkAsFailed, publish PaymentCompletedEvent/PaymentFailedEvent
  • SaveChanges (atomic: state transition + staged event)

PaymentProcessingRequested is an internal Wolverine message — lives in PaymentService/Features/ (not NextAurora.Contracts) because no other service subscribes to it. No explicit routing config → Wolverine defaults to a local in-memory queue, exactly as intended.

What's preserved

  • All existing idempotency semantics (OrderId existence check, terminal-state re-publish, unique-index race recovery)
  • Outbox atomicity (publish-before-save on both handlers; AutoApplyTransactions + UseEntityFrameworkCoreTransactions commit entity + envelope in one tx)
  • The PaymentRecoveryJob sweeper still handles stuck Pendings (no behavior regression in failure-mode recovery)
  • OrderPlacedHandler cascade pattern (still returns ProcessPaymentCommand)
  • The HTTP endpoint still returns 202 Accepted — but now the response actually reflects work moved to the bus, not just "the downstream saga happens to be async"

What's NOT fixed (separate STATUS.md item)

Gateway-side idempotency keys. The "Stripe charged but process crashed before MarkAsCompleted → redelivery double-charges" race still exists; fixing it requires plumbing payment.Id.ToString() into the gateway as an Idempotency-Key header. Tracked in STATUS.md as a separate open issue (this refactor doesn't make it worse).

Test plan

  • dotnet build clean (1 pre-existing benchmark warning, unrelated)
  • All 6 unit test projects pass (112 tests total). PaymentService.Tests.Unit (19) confirms no regressions in validator + OrderPlacedHandler tests.
  • Integration tests at the EF + Wolverine outbox level are the right tier for verifying this refactor end-to-end. PaymentService.Tests.Integration doesn't exist yet (paired STATUS.md gap with ShippingService.Tests.Integration). Per CLAUDE.md "Testing," mocking DbContext for unit-level handler tests is explicitly out — Testcontainers is the right axis.
  • Manual verification via smoke test once the AppHost is running (Acceptor returns 202 with PaymentId immediately; gateway handler publishes Completed/Failed later)

Merge order

This PR is the first application of #39's rule. #39 should merge first so the rule reference in the new endpoint comment (See CLAUDE.md "Performance Rules → Long-running work...") points at a canonical rule that exists in main.

After both merge, STATUS.md's "Open issues → POST /api/v1/payments/process is a partial Should-consider..." entry (added in #39) should be updated to mark resolved by this PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Payment processing requests now return immediately with 202 Accepted and a PaymentId to poll for results.
  • Improvements

    • Processing split into a fast accept stage and a background gateway stage for better responsiveness.
    • Improved duplicate payment detection and race recovery to avoid concurrent conflicts.
    • Added detailed failure reason tracking and outcome metrics for processed payments.
  • Documentation

    • Updated status notes for the payment endpoint to reflect the new two-stage flow.

Review Change Stack

…lers

First application of the long-running-work / 202 Accepted rule landed in
PR #39: the Stripe gateway call is the slow part (sub-second in the happy
path, seconds-to-30s on degraded states); doing it inline in the handler
held an HTTP request open (and a Wolverine handler slot + DbContext + SB
message lease on the saga path) for the full duration.

The refactored shape:

  Entry points (unchanged):
    POST /api/v1/payments/process  ─┐
                                    ├─► ProcessPaymentCommand
    OrderPlacedHandler (saga)      ─┘

  ProcessPaymentHandler (Acceptor — fast):
    - idempotency check on OrderId
    - re-publish terminal event for completed/failed re-entry
    - persist Payment(Pending) + publish PaymentProcessingRequested
    - SaveChanges (atomic: Payment row + staged envelope)
    - return PaymentId
    No gateway call here. Sub-second.

  PaymentProcessingRequestedHandler (Gateway — slow, runs on Wolverine worker):
    - re-fetch Payment, status-guard for at-least-once redelivery
    - call Stripe
    - mark Completed/Failed, publish PaymentCompletedEvent/PaymentFailedEvent
    - SaveChanges (atomic: state transition + staged event)

PaymentProcessingRequested is an internal Wolverine message — lives in
PaymentService/Features/ (not NextAurora.Contracts) because no other
service is supposed to subscribe to it. No explicit routing config means
Wolverine defaults to a local in-memory queue, exactly as intended.

What's preserved:
- All existing idempotency semantics (OrderId existence check, terminal-state
  re-publish, unique-index race recovery).
- Outbox atomicity: both handler paths use the publish-before-save pattern;
  AutoApplyTransactions + UseEntityFrameworkCoreTransactions commit entity
  writes + staged envelopes in one DB transaction.
- The PaymentRecoveryJob sweeper still handles stuck Pendings (no behavior
  regression in the failure-mode recovery path).
- OrderPlacedHandler cascade pattern (still returns ProcessPaymentCommand).
- The HTTP endpoint still returns 202 Accepted; what changes is that the
  response now reflects work actually moved to the bus, not just "the
  downstream saga happens to be async."

What's NOT fixed by this PR (separate STATUS.md item):
- Gateway-side idempotency keys. The "Stripe charged but process crashed
  before MarkAsCompleted → redelivery double-charges" race still exists;
  fixing it requires plumbing payment.Id.ToString() into the gateway as
  an Idempotency-Key header. Tracked in STATUS.md as a separate open issue.

Testing:
- All 6 unit test projects pass (112 tests). PaymentService.Tests.Unit
  (19) confirms no regressions in validator + OrderPlacedHandler tests.
- The right tier for verifying this refactor at the EF + Wolverine outbox
  level is PaymentService.Tests.Integration — a project that doesn't
  exist yet (paired STATUS.md gap with ShippingService.Tests.Integration).
- Per CLAUDE.md "Testing", mocking DbContext for unit-level handler tests
  is explicitly out — integration tests with Testcontainers are the
  right axis.

Note on merge order: this PR is the first application of PR #39's rule.
PR #39 should land first so the rule reference in the new endpoint comment
("See CLAUDE.md Performance Rules → Long-running work...") points at a
canonical rule that exists in main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 27, 2026 •

Copy link
Copy Markdown

Walkthrough

Payment processing refactored from single synchronous handler into two-stage async flow: a fast idempotent Acceptor that persists Pending payments and publishes continuation messages, and a slow Gateway handler that consumes those messages and executes external payment gateway calls. Endpoint documentation clarified to reflect 202 Accepted response and asynchronous processing.

Changes

Two-Stage Payment Processing Architecture

Layer / File(s) Summary
Endpoint Documentation and Async Contract
PaymentService/Endpoints/PaymentEndpoints.cs
Endpoint documentation for POST /api/v1/payments/process updated to clarify that the HTTP call returns 202 Accepted with a PaymentId, triggers asynchronous processing on the message bus, and does not perform synchronous gateway calls on this path.
Acceptor Handler and Idempotent Fast Path
PaymentService/Features/ProcessPayment.cs
ProcessPaymentHandler rewritten as idempotent acceptor: checks for existing Payment by OrderId, republishes terminal events if found, otherwise creates new Pending payment, publishes PaymentProcessingRequested continuation message before persisting, and recovers from concurrent insert races by detaching and re-fetching. Architecture documentation describes the split flow and outbox/event publishing atomicity.
Gateway Handler and Slow Path Message Processing
PaymentService/Features/ProcessPayment.cs
New PaymentProcessingRequestedHandler consumes PaymentProcessingRequested messages: loads Payment by PaymentId, no-ops if missing or not Pending, calls IPaymentGateway.ProcessPaymentAsync, publishes PaymentCompletedEvent or PaymentFailedEvent before SaveChangesAsync in success and failure branches respectively, and increments PaymentsProcessed counter with outcome label.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor(payments): split ProcessPayment into Acceptor + Gateway' accurately and specifically describes the main architectural change in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/payment-process-async-shape

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

…solved

#39 landed the "Long-running work belongs on the message bus" rule along
with a STATUS.md "Open issues" entry flagging POST /api/v1/payments/process
as a partial Should-consider under that rule. The Acceptor + Gateway split
in this PR's preceding commit (c7d293b) is the resolution.

Strike-through + "Resolved 2026-05-26" preserves the deferred-then-fixed
trail per the STATUS.md convention used by the prior read/write data-access
split resolution entry.

The separate "Payment recovery: gateway-side idempotency keys not yet used"
open issue (above) is unchanged — that's a different concern (Stripe-side
idempotency for the redelivery double-charge race) that this refactor
doesn't address and doesn't make worse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@PaymentService/Features/ProcessPayment.cs`:
- Around line 88-90: The existing-payment branch returns immediately after
calling RepublishTerminalEventAsync, which can publish a staged outbox envelope
without flushing it; update the logic in ProcessPayment (the branch that calls
RepublishTerminalEventAsync) to call SaveChangesAsync on the DbContext
immediately after RepublishTerminalEventAsync (or after the underlying
PublishAsync) so the staged envelope is persisted/flushed before returning
existing.Id; apply the same SaveChangesAsync flush in the other similar block
around lines 121-156 where PublishAsync/RepublishTerminalEventAsync is used.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 670eb3cf-a80c-4e20-88d4-aa5a7b67632d

📥 Commits

Reviewing files that changed from the base of the PR and between 961a684 and c7d293b.

📒 Files selected for processing (2)
  • PaymentService/Endpoints/PaymentEndpoints.cs
  • PaymentService/Features/ProcessPayment.cs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
docs/STATUS.md (1)

5-5: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the "Last updated:" date to today (2026-05-27).

The STATUS.md file is being modified in this PR, but the "Last updated:" date still shows 2026-05-26. As per coding guidelines, the date should be updated to today (2026-05-27) when this file changes.

📅 Proposed fix
-**Last updated:** 2026-05-26 (CatalogService Clean Architecture → VSA collapse; carve-out retired — all five services now share one shape)
+**Last updated:** 2026-05-27 (PaymentService process endpoint refactored to Acceptor + Gateway pattern)

Note: You may also want to adjust the parenthetical description to reflect today's primary change (the payment processing refactor) rather than the previous session's work.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/STATUS.md` at line 5, Update the "Last updated:" line in STATUS.md by
changing the date in the existing line "**Last updated:** 2026-05-26
(CatalogService Clean Architecture → VSA collapse; carve-out retired — all five
services now share one shape)" to "**Last updated:** 2026-05-27" and, if
appropriate, revise the parenthetical note to mention today's primary change
(payment processing refactor) instead of the previous description; modify the
same line text shown in the diff to reflect these edits.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/STATUS.md`:
- Line 5: Update the "Last updated:" line in STATUS.md by changing the date in
the existing line "**Last updated:** 2026-05-26 (CatalogService Clean
Architecture → VSA collapse; carve-out retired — all five services now share one
shape)" to "**Last updated:** 2026-05-27" and, if appropriate, revise the
parenthetical note to mention today's primary change (payment processing
refactor) instead of the previous description; modify the same line text shown
in the diff to reflect these edits.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 609021c2-d5f8-4594-a05b-0037926c6dfa

📥 Commits

Reviewing files that changed from the base of the PR and between c7d293b and f32f15f.

📒 Files selected for processing (1)
  • docs/STATUS.md

@emeraldleaf
emeraldleaf merged commit 7f8da83 into main May 27, 2026
6 checks passed
@codecov

codecov Bot commented May 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 44 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
PaymentService/Features/ProcessPayment.cs 0.00% 44 Missing ⚠️

📢 Thoughts on this report? Let us know!

emeraldleaf added a commit that referenced this pull request May 28, 2026
Closes the at-least-once redelivery double-charge race on the normal
handler path. PR #40 split ProcessPayment into Acceptor + Gateway, which
moved the slow work off the HTTP request but left one window open: if
PaymentProcessingRequested gets redelivered after a successful Stripe call
but BEFORE the handler's MarkAsCompleted commits, the status-guard pre-check
("if Status != Pending return") doesn't fire — the Payment is still Pending
in the DB. The redelivery makes the gateway call again, charging the buyer
twice. With a stable idempotency key derived from payment.Id, Stripe
recognizes the duplicate request and returns the original response.

Plumbing:
- IPaymentGateway.ProcessPaymentAsync now takes a required `string
  idempotencyKey` parameter. XML doc explains the provider semantics
  (Stripe's 24h key cache, why payment.Id is the right stable key,
  what happens on the wire) so the next person doesn't have to re-derive
  it from Stripe's docs.
- StripePaymentGateway (still a stub today — sleeps 100ms, returns
  success) accepts + logs the key. Class XML doc now includes a sketch
  of what a real Stripe.net call would look like with
  RequestOptions.IdempotencyKey set — so when the stub is replaced with
  the real SDK call, the shape is documented at the implementation site.
- PaymentProcessingRequestedHandler passes payment.Id.ToString() at the
  one call site. Inline comment captures why the key matters.
- ProcessPayment class XML doc tightened: the "Idempotency on the Gateway
  handler" paragraph now describes both layers explicitly (local status
  guard + gateway-side key), not just the status guard with a TODO.

What's NOT in this PR (out of scope, kept in STATUS.md):
- PaymentRecoveryJob retry-with-key. The sweeper still marks stuck
  Pendings as Failed and loses one charge attempt. The proper enhancement
  is for the recovery path to call the gateway again WITH the same key —
  if Stripe already charged, the cached response stitches the Payment
  back to Completed. That depends on knowing how to interpret Stripe's
  duplicate-key response shape (Adyen/Braintree differ slightly), which
  is gateway-provider work, not key-plumbing work. STATUS.md updated to
  reflect partial resolution: plumbing landed today, recovery-job retry
  remains pending.

Tests: PaymentService.Tests.Unit (19) passes. The new parameter doesn't
break the validator + OrderPlacedHandler tests (they don't touch the
gateway). End-to-end verification of the duplicate-key behavior lives
on PaymentService.Tests.Integration once that project lands (separate
STATUS.md gap).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@emeraldleaf
emeraldleaf deleted the refactor/payment-process-async-shape branch June 4, 2026 00:40
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.

1 participant