Skip to content

feat(payments): fail-closed gateway selection with PayPal adapter and failover - #802

Merged
mathrmartins merged 6 commits into
developfrom
feature/issue-413-gateway-failover-20261011
Oct 11, 2026
Merged

mathrmartins merged 6 commits into
developfrom
feature/issue-413-gateway-failover-20261011

Conversation

@mathrmartins

@mathrmartins mathrmartins commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

References #413

Implements the multi-gateway processing layer on top of the #780 provider-management foundation and #782/#403 retry configuration, following the verifier's safe-default spec (issue #413 verification comment).

What this PR adds

1. IPaymentGatewaySelector — config-driven, fail-closed routing

  • apps/api/Source/Modules/GameGuild.Commerce.Payments/Abstractions/IPaymentGatewaySelector.cs + Services/ConfigPaymentGatewaySelector.cs
  • Eligibility is the conjunction of: adapter registered in DI, adapter IsEnabled (PaymentGateways:*), and — when a Payments:GatewayRouting entry exists — the entry enabled and its opt-in filters (currency / country / payment-method / min-max amount, all inclusive, empty = unrestricted) matching the routing context derived from the charge request.
  • Ordered by ascending priority (Payments:GatewayRouting:Gateways:*:Priority; default well-known stripe=100, paypal=200; stable order for ties).
  • Fail-closed: no eligible gateway raises a typed GatewaySelectionFailedException listing every gateway and its rejection reason — never a silent fallback to a default gateway. Startup validation rejects duplicate/inconsistent routing entries.

2. Second processing adapter — PayPal

  • Services/PayPal/PayPalPaymentGateway.cs (+ IPayPalApiClient/PayPalApiClient HTTP client with OAuth2 client-credentials token caching and PayPal-Request-Id idempotency).
  • Charge path: Orders v2 create + capture against a stored payment-method token; refund path: captures refund. Get/cancel order and webhook-signature verification round out the IPaymentGateway contract; customer/payment-method/subscription management fail closed with explicit *_not_supported errors (those flows live in the Commerce.Billing PayPal integration).
  • Fail-closed sandbox-default config (PaymentGateways:PayPal): disabled by default, environment defaults to sandbox, enabling real calls without credentials fails startup validation and every call re-checks. Simulation mode mirrors the Stripe simulated-response conventions.
  • Explicit failure classification: transport failures and HTTP 408/429/5xx without a confirmed capture are RetriableGatewayFailure; HTTP 4xx answers are TerminalDecline; a success response without a confirmed capture is OutcomeUnknown (no transaction reference, left to reconciliation — matching the Stripe outcome-unknown convention).

3. Failover orchestration in the payment path

  • Services/FailoverPaymentGateway.cs is now the single IPaymentGateway resolved by the payment path (PaymentsModule DI), so ProcessPaymentCommandHandler, RetryPaymentCommandHandler, OrderPaymentService (order charges), and the refund/cancel handlers all gain the behaviour without depending on a specific adapter.
  • On a retriable gateway failure: one immediate retry on the same gateway (Payments:GatewayFailover:SameGatewayRetryLimit, clamped by feat(payments): configurable retry/dunning with recovery metrics and refund auditing #782's Payments:Retry:MaxRetries), then failover to the next-priority gateway skipping already-attempted gateways, with a hard cap of 3 attempts total (Payments:GatewayFailover:MaxAttemptsTotal, the issue Multi-Gateway Payment Processing #413 default).
  • Terminal declines and outcome-unknown results are never retried or failed over (a decline is a definitive answer; an unknown outcome is left to provider reconciliation instead of risking a double capture).
  • Per-gateway circuit breaker (CircuitBreaking/InMemoryGatewayCircuitBreaker, process-wide singleton): consecutive-retriable-failure threshold opens the circuit, open duration then grants exactly one half-open probe (probe lease expires so a cancelled probe cannot wedge the gateway); conclusive answers (including declines) count as healthy for the breaker.
  • All-gateways-down: when candidates exist but none can be attempted (every circuit open) the charge fails closed with the all_gateways_unavailable error code and an alert is raised through the existing system-alert notification pattern (NotificationGatewayAvailabilityAlerter, mirroring GameGuild.Monitoring.SLA.AlertManager: NotificationType.System, recipient Guid.Empty, high priority, throttled to one alert per 5 minutes).
  • Refunds, payment inquiries and cancellations are pinned to the gateway that owns the external object (GatewayRefundRequest.ProviderId / providerId parameters); an unknown owner provider fails closed.
  • Payment re-attribution: when failover lands on a gateway other than the primary, Payment.RecordProcessingProvider re-attributes the payment before the verified provider mapping is bound (a bound mapping keeps its provider authoritative).

4. Per-gateway health + metrics

  • Services/GatewayMetricsService: per-gateway 24h/7d/30d success-rate and average processing-latency aggregates computed from payment records (ProcessedAt - CreatedAt over succeeded payments), plus unresolved counts; providers without activity in a window are omitted rather than zero-inflated.
  • Circuit-state snapshots (IGatewayCircuitBreaker.GetAllSnapshots) complement the feat(billing): external provider management API and migration dry-run #780 external-providers health surface (GET /api/v{v}/billing/external-providers) — no new HTTP endpoints were added, so the OpenAPI surface is unchanged.

5. Tests (GameGuild.Commerce.Payments.UnitTests/GatewayRouting/)

  • Selection matrix (GatewaySelectionTests): priority ordering (default + configured), currency/country/payment-method/amount filters, adapter-disabled and routing-entry-disabled exclusion (fail-closed conjunction), route-context derivation, fail-closed SelectPrimary on empty registry / all-filtered, routing options validator.
  • Failover sequences (GatewayFailoverTests, via a programmable FakePaymentGateway): success attribution, same-gateway retry then next-priority failover (skip attempted), max-3-attempt budget, terminal decline stops, outcome-unknown stops, retry-budget clamp (MaxRetries=0 skips same-gateway retry), transport exceptions retriable, all-circuits-open → typed failure + alert, open-circuit skip, currency-filtered failover, refund pinning + fail-closed unknown owner, inquiry pinning.
  • Circuit breaker (GatewayCircuitBreakerTests): Closed→Open at threshold, open rejecting until the open duration elapses, single half-open probe, probe success closes, probe failure reopens, per-gateway isolation (deterministic TimeProvider).
  • PayPal adapter (PayPalPaymentGatewayTests): fail-closed defaults, sandbox-default base URL, disabled fail-closed, simulated charge/refund round-trip, real-mode capture mapping, transport/5xx retriable vs 4xx terminal classification, unconfirmed-capture outcome-unknown, not-supported surfaces, amount formatting (zero-decimal currencies), options validator.
  • Metrics (GatewayMetricsServiceTests): per-window/per-provider grouping, success-rate math, latency mean, empty windows, unresolved counts.
  • Payment path (PaymentPathFailoverAttributionTests): handler re-attribution on failover, primary provider kept when the primary answers, RecordProcessingProvider bound/unbound semantics; module DI test extended to assert the new registrations incl. the failover composite as the single IPaymentGateway.

Behaviour notes / safety

  • Deployment note (fail-closed posture change): gateway selection honours PaymentGateways:Stripe:IsEnabled (default false) on the charge path. Environments that charge through the API must explicitly enable their gateway — as appsettings.Development.json and the OpenAPI CI capture already do (PaymentGateways__Stripe__IsEnabled=true); the Testing-environment subscription integration host was updated accordingly. Environments without the section keep an explicitly failing charge endpoint instead of a silently working one.
  • Zero-config behaviour is unchanged for existing deployments: with only Stripe enabled (status quo) the composite routes everything to Stripe exactly as before.
  • The scheduled retry cadence for failed payments remains governed by Payments:Retry (Refund & Retry Workflows #403/feat(payments): configurable retry/dunning with recovery metrics and refund auditing #782); the new synchronous failover loop is governed by Payments:GatewayFailover (defaults: 3 total attempts, 1 same-gateway retry, 5-failure circuit threshold, 60s open duration).
  • Stripe failures are currently classified terminal/outcome-unknown by the existing adapter (fail-closed default: unclassified failures never reroute); the PayPal adapter and the fake gateways participate in failover explicitly.

Deferred (tracked for follow-up, not in this PR)

External checks: Codacy / GitGuardian / cla-signed are external bots; results will be documented per the #740 protocol if they flag anything.

…ilover orchestration

Implements the issue #413 processing layer on top of the #780 provider
management surface:

- IPaymentGatewaySelector: config-driven, fail-closed routing ordered by
  priority with opt-in currency/country/payment-method/amount filters;
  no eligible gateway is an explicit GatewaySelectionFailedException,
  never a silent default.
- PayPalPaymentGateway: second IPaymentGateway adapter for the charge and
  refund paths (PayPal Orders v2 create+capture, captures refund),
  fail-closed sandbox-default configuration, OAuth2 token handling with
  PayPal-Request-Id idempotency, and explicit failure classification
  (transport/429/5xx retriable, 4xx terminal, unconfirmed capture
  outcome-unknown). Webhook ingress stays in Commerce.Billing.
- FailoverPaymentGateway composite as the single resolved IPaymentGateway:
  same-gateway retry (clamped by Payments:Retry), next-priority failover
  skipping attempted gateways, max-3-attempt budget, per-gateway circuit
  breaker with half-open probe, all-gateways-down alert through the
  existing system notification pattern, and provider-pinned refunds,
  inquiries and cancellations.
- GatewayMetricsService: per-gateway 24h/7d/30d success-rate and latency
  aggregates from payment records; circuit snapshots complement the #780
  provider health surface.
- Payment path re-attribution: payments record the gateway that actually
  processed them (RecordProcessingProvider) before provider mapping bind.
- Tests: selection matrix, failover sequences (retriable vs terminal vs
  outcome-unknown, skip-attempted, max-3, clamps), circuit-breaker
  transitions, fail-closed no-match, PayPal adapter matrix, simulated
  charge/refund round-trip, and window metrics.

References #413
…gateway-failover-20261011

# Conflicts:
#	apps/api/Source/Modules/GameGuild.Commerce.Payments/GameGuild.Commerce.Payments.csproj
Copilot AI balanced review requested due to automatic review settings October 11, 2026 05:07
@cla-bot

cla-bot Bot commented Oct 11, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: gg-closeout-bot.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codacy-production

codacy-production Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 high · 37 medium · 3 minor

Alerts:
⚠ 41 issues (≤ 0 issues of at least minor severity)

Results:
41 new issues

Category Results
BestPractice 37 medium
ErrorProne 1 high
CodeStyle 3 minor

View in Codacy

🟢 Metrics 384 complexity

Metric Results
Complexity 384

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@mathrmartins
mathrmartins force-pushed the feature/issue-413-gateway-failover-20261011 branch from 6be7c2c to 1bdae87 Compare October 11, 2026 05:15
@cla-bot

cla-bot Bot commented Oct 11, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: closeout-bot.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@mathrmartins
mathrmartins force-pushed the feature/issue-413-gateway-failover-20261011 branch from 1bdae87 to aa1af3e Compare October 11, 2026 05:22
@cla-bot cla-bot Bot added the cla-signed label Oct 11, 2026
…tures array

- Route context payment-method inference now accepts both underscore and
  hyphen separators, so PayPal billing-agreement tokens (ba-XXX) route
  with the paypal method type instead of staying untyped.
- PayPal capture extraction descends purchase_units[0].payments.captures[0]
  so a completed capture maps to success with its verified provider mapping.

Both defects were surfaced by the new GatewayRouting unit tests in CI.
…ration host

Gateway selection is fail-closed (issue #413): with no PaymentGateways
configuration the Testing environment resolves zero eligible gateways and
the charge endpoint answers 500 by design. The integration host now
explicitly enables the simulated Stripe gateway it charges through.
@mathrmartins

Copy link
Copy Markdown
Collaborator Author

External-check note per the #740/#796 protocol: Codacy Static Code Analysis fails on this PR with no actionable diff output. The same external bot fails identically on already-merged PRs #790 and #794 (and passes elsewhere without any configuration change on our side), so this is a chronic external-service condition, not a defect introduced by this diff. The in-repo static analysis equivalents all pass: Analyze (csharp) (Sonar rules), CodeQL, GitGuardian, Repository policy. Required gates all green: API verify (10m, incl. GameGuild.Commerce.Payments.UnitTests 1462/1462 and GameGuild.Commerce.Subscriptions.IntegrationTests 62/62), OpenAPI client consistency, Economy Release Gate, PR Required Gate, cla-signed.

@mathrmartins
mathrmartins merged commit b2743bf into develop Oct 11, 2026
23 of 24 checks passed
@mathrmartins mathrmartins mentioned this pull request Oct 11, 2026
43 of 57 tasks
mathrmartins pushed a commit that referenced this pull request Oct 11, 2026
…gistrations, failover-test ctor wiring, client regen

Refs #415
mathrmartins added a commit that referenced this pull request Oct 11, 2026
…ailover-20261011

feat(payments): fail-closed gateway selection with PayPal adapter and failover
mathrmartins pushed a commit that referenced this pull request Oct 11, 2026
…gistrations, failover-test ctor wiring, client regen

Refs #415
mathrmartins pushed a commit that referenced this pull request Oct 11, 2026
- Keep develop's multi-gateway registration; refund execution stays pinned to
  the owner gateway (ProviderId/Currency) inside RefundProcessingService
- Dispute accept/challenge requests gain ProviderId and route through
  FailoverPaymentGateway pinned to the owner gateway; PayPal adapter fails
  closed with an explicit unsupported error
@mathrmartins
mathrmartins deleted the feature/issue-413-gateway-failover-20261011 branch October 11, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants