Repository navigation
refactor(payments): split ProcessPayment into Acceptor + Gateway - #40
Conversation
…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>
WalkthroughPayment 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. ChangesTwo-Stage Payment Processing Architecture
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…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>
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
PaymentService/Endpoints/PaymentEndpoints.csPaymentService/Features/ProcessPayment.cs
There was a problem hiding this comment.
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 winUpdate 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
📒 Files selected for processing (1)
docs/STATUS.md
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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>
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:ProcessPaymentHandler(Acceptor — fast):OrderIdPayment(Pending)+ publishPaymentProcessingRequested+SaveChanges(atomic: row + staged envelope in one DB tx)PaymentIdPaymentProcessingRequestedHandler(Gateway — slow, runs on Wolverine worker):Payment, status-guard for at-least-once redelivery (no-op if already Completed/Failed)MarkAsCompleted/MarkAsFailed, publishPaymentCompletedEvent/PaymentFailedEventSaveChanges(atomic: state transition + staged event)PaymentProcessingRequestedis an internal Wolverine message — lives inPaymentService/Features/(notNextAurora.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
AutoApplyTransactions+UseEntityFrameworkCoreTransactionscommit entity + envelope in one tx)PaymentRecoveryJobsweeper still handles stuck Pendings (no behavior regression in failure-mode recovery)OrderPlacedHandlercascade pattern (still returnsProcessPaymentCommand)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 plumbingpayment.Id.ToString()into the gateway as anIdempotency-Keyheader. Tracked in STATUS.md as a separate open issue (this refactor doesn't make it worse).Test plan
dotnet buildclean (1 pre-existing benchmark warning, unrelated)PaymentService.Tests.Unit(19) confirms no regressions in validator +OrderPlacedHandlertests.PaymentService.Tests.Integrationdoesn't exist yet (paired STATUS.md gap withShippingService.Tests.Integration). Per CLAUDE.md "Testing," mockingDbContextfor unit-level handler tests is explicitly out — Testcontainers is the right axis.PaymentIdimmediately; 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 inmain.After both merge, STATUS.md's "Open issues →
POST /api/v1/payments/processis 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
Improvements
Documentation