Skip to content

✨ feat: the caller's idempotency key on create, capture, cancel and refund - #8

Merged
edgarmesquita merged 8 commits into
mainfrom
feat/caller-idempotency-keys
Oct 1, 2026
Merged

edgarmesquita merged 8 commits into
mainfrom
feat/caller-idempotency-keys

Conversation

@edgarmesquita

@edgarmesquita edgarmesquita commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What

  • The unified model:

    • CreateChargeRequest.IdempotencyKey and RefundRequest.IdempotencyKey
    • CaptureRequest and CancelRequest, each with its own IdempotencyKey
    • IChargeOperations gains CaptureAsync(CaptureRequest) and CancelAsync(CancelRequest); their default implementations capture and cancel as the 1.x methods do, so no provider has to change for them
    • one key per operation: the requests and the README say to derive it from the attempt and the operation (attempt-42:create, attempt-42:refund) and reuse it only to retry that same request, since a gateway refuses a key reused with other parameters
  • A retry sends the same request: CreateChargeRequest.AttemptedAt pins the moment of the first try, and everything counted from the moment of the request comes from it instead of the clock:

    • a Pix expiry from PixDetails.ExpiresIn (Mercado Pago Payments and Orders, PagSeguro, Adyen, Pagar.me v4)
    • a boleto's default due date (PagSeguro, Asaas, Efí) and Stripe's boleto days
    • an Adyen payment without a ReferenceId takes the idempotency key as its reference, instead of a fresh id
  • What each gateway gets:

    Gateway Create, capture, cancel, refund
    Stripe Idempotency-Key, the caller's; none without one, as before
    Mercado Pago X-Idempotency-Key, the caller's; without one, a fresh key, except on a Payments API capture or cancel, which carries none, as before
    PagSeguro x-idempotency-key, the caller's or a fresh one
    Adyen Idempotency-Key, the caller's or a fresh one
    Pagar.me, Cielo, Asaas, Efí none sent; the key is ignored
  • Compatibility with 1.x: every client keeps its signatures, with overloads that take the key beside them and require their token, so a 1.x call with a positional token, default included, has the 1.x overload as its only candidate. The operations' 1.x methods delegate to the new ones.

  • READMEs: the core package has a Retries section and the table of what each gateway gets; the Stripe, Mercado Pago, PagSeguro and Adyen packages say how they send the key; the root README lists the new requests.

Why

Closes eQuantic/equantic-subscription-api#60.

Retrying a create after a timeout charged twice: Mercado Pago, PagSeguro and Adyen sent a fresh key on every call, and Stripe sent none. A subscription engine retries every charge it is not sure of, so the key has to be the caller's, derived from the payment attempt's id, and the retry has to be the same request.

Proof

  • Build: dotnet build -c Release, 0 warnings, 0 errors.
  • Tests: dotnet test --no-build -c Release, 70 of 70, 8 of them new (IdempotencyKeyTests). Each of the first five sends a create, a capture, a cancel and a refund through the provider, and reads the header off every request:
    • Stripe sends the caller's key, and none without one
    • Mercado Pago Payments sends the caller's key on all four; without one, a create and a refund still get a fresh key, and a capture and a cancel get none
    • Mercado Pago Orders sends the caller's key on all four
    • PagSeguro and Adyen send the caller's key, or a fresh one without it
    • Pagar.me, which takes no key, still captures and cancels through the new requests
    • the 19 client calls 1.x code can make with a positional default token compile and send what 1.x sent
    • a keyed request sent twice, 20 ms apart, sends the same body to Stripe (boleto), Mercado Pago Payments and Orders (Pix), PagSeguro (Pix and boleto) and Adyen (Pix), with its expiries counted from AttemptedAt (2026-01-01) rather than the clock, and Adyen's reference taken from the key
  • Release: squash-merged under this title, semantic-release reads ✨ feat and publishes a minor version.

…efund

A retry after a timeout charged twice: Mercado Pago, PagSeguro and Adyen
got a fresh key on every call, and Stripe none. CreateChargeRequest and
RefundRequest now take an IdempotencyKey, and IChargeOperations gains
CaptureAsync(CaptureRequest) and CancelAsync(CancelRequest), which carry
one; their default implementations capture and cancel as before, so no
provider has to change for them.

Stripe sends the key as Idempotency-Key. Mercado Pago, PagSeguro and
Adyen send the caller's key, or a fresh one when there is none, as
before. Every client keeps its 1.x signatures, with overloads that take
the key beside them.

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 review overview

🟡 Changes recommended

The new public overloads introduce source ambiguity for existing positional default token calls, and retry documentation contains unsafe or inaccurate guidance.

Review effort: Balanced
Findings: 5 High severity · 2 Low severity

Open (7)
What changed in this PR

Adds caller-controlled idempotency keys across money-moving operations while preserving legacy operation methods.

Changes:

  • Adds idempotency keys and request models for capture and cancellation.
  • Propagates keys through Stripe, Mercado Pago, PagSeguro, and Adyen clients.
  • Adds provider tests and retry documentation.
File Description
README.md Lists the new request models.
src/​eQuantic.Payment/​README.md Documents retries and provider behavior.
src/​eQuantic.Payment/​Abstractions/​IChargeOperations.cs Adds request-based capture and cancellation methods.
src/​eQuantic.Payment/​Models/​Requests/​CreateChargeRequest.cs Adds an idempotency key.
src/​eQuantic.Payment/​Models/​Requests/​RefundRequest.cs Adds an idempotency key.
src/​eQuantic.Payment/​Models/​Requests/​CaptureRequest.cs Introduces the capture request model.
src/​eQuantic.Payment/​Models/​Requests/​CancelRequest.cs Introduces the cancellation request model.
src/​eQuantic.Payment.Stripe/​README.md Documents Stripe idempotency.
src/​eQuantic.Payment.Stripe/​V1/​StripeClientV1.cs Adds idempotent POST overloads.
src/​eQuantic.Payment.Stripe/​V1/​StripeV1Operations.cs Propagates operation keys to Stripe.
src/​eQuantic.Payment.PagSeguro/​README.md Documents PagSeguro idempotency.
src/​eQuantic.Payment.PagSeguro/​Orders/​PagSeguroOrdersClient.cs Accepts caller-provided keys.
src/​eQuantic.Payment.PagSeguro/​Orders/​PagSeguroOrdersOperations.cs Propagates keys to PagSeguro.
src/​eQuantic.Payment.MercadoPago/​README.md Documents Mercado Pago behavior.
src/​eQuantic.Payment.MercadoPago/​MercadoPagoClientBase.cs Supports caller-provided or generated keys.
src/​eQuantic.Payment.MercadoPago/​Payments/​MercadoPagoPaymentsClient.cs Adds keyed Payments API overloads.
src/​eQuantic.Payment.MercadoPago/​Payments/​MercadoPagoPaymentsOperations.cs Propagates Payments API keys.
src/​eQuantic.Payment.MercadoPago/​Orders/​MercadoPagoOrdersClient.cs Adds keyed Orders API overloads.
src/​eQuantic.Payment.MercadoPago/​Orders/​MercadoPagoOrdersOperations.cs Propagates Orders API keys.
src/​eQuantic.Payment.Adyen/​README.md Documents Adyen idempotency.
src/​eQuantic.Payment.Adyen/​V71/​AdyenClientV71.cs Accepts caller-provided keys.
src/​eQuantic.Payment.Adyen/​V71/​AdyenV71Operations.cs Propagates keys to Adyen.
tests/​eQuantic.Payment.Tests/​IdempotencyKeyTests.cs Tests headers across providers and operations.
tests/​eQuantic.Payment.Tests/​Fakes/​StubHttpMessageHandler.cs Records requests for header assertions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/eQuantic.Payment.Adyen/V71/AdyenClientV71.cs Outdated
Comment thread src/eQuantic.Payment.MercadoPago/Orders/MercadoPagoOrdersClient.cs Outdated
Comment thread src/eQuantic.Payment.MercadoPago/Payments/MercadoPagoPaymentsClient.cs Outdated
Comment thread src/eQuantic.Payment.PagSeguro/Orders/PagSeguroOrdersClient.cs Outdated
Comment thread src/eQuantic.Payment.Stripe/V1/StripeClientV1.cs Outdated
Comment thread src/eQuantic.Payment/README.md Outdated
Comment thread src/eQuantic.Payment/README.md Outdated
A 1.x call with a positional token, default included, now has a single
candidate, the 1.x overload, instead of two that both take two arguments.
A gateway refuses a key reused with other parameters, so one key for an
attempt's create and refund would fail the refund. The requests and the
README say to derive a key per operation (attempt-42:create,
attempt-42:refund), and the table says that Mercado Pago gets no key on a
keyless capture or cancel.

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 review overview

🟡 Changes recommended

Time-dependent request mapping can make keyed retries send different parameters, and Mercado Pago documentation misstates Orders behavior.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (7)
Previously missed (1)

In code that hasn't changed since last review

Low severity Package guidance omits Orders idempotency headers

src/​eQuantic.Payment.MercadoPago/​README.md:38

This describes the Payments client only. The Orders client always sends X-Idempotency-Key on capture and cancel too, generating a fresh value when IdempotencyKey is null, so the package-level guidance is misleading.

Comment thread src/eQuantic.Payment/Models/Requests/CreateChargeRequest.cs
Comment thread src/eQuantic.Payment/README.md Outdated
A key only replays the first answer when the retry's parameters are the
first try's, and Stripe refuses a key reused with others. Expiries were
counted from the moment of each send (a Pix expiry on Mercado Pago,
PagSeguro and Adyen, PagSeguro's default boleto due date, Stripe's boleto
days), and Adyen's reference was a fresh id whenever the request had
none. CreateChargeRequest.AttemptedAt now pins that moment, and an Adyen
payment without a ReferenceId takes the idempotency key as its reference.

The gateways that take no key count from AttemptedAt too, so a request
means the same thing everywhere. Mercado Pago's README and the core's
table say that the Orders API gets a fresh key on every call without one.

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 review overview

🔵 Needs a closer look

An omitted AttemptedAt is recalculated on every retry, potentially changing keyed request parameters.

Review effort: Balanced
Findings: None

Resolved since last review (2)

@edgarmesquita
edgarmesquita merged commit ae0d5b5 into main Oct 1, 2026
2 checks passed
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

edgarmesquita added a commit that referenced this pull request Oct 1, 2026
…ripe (#9)

## What

**Saved cards, charged with the customer away** (eQuantic/equantic-billing#118):

- `IPaymentProvider.PaymentMethods` (`IPaymentMethodOperations`), with Stripe's:
  - `SetupAsync`: a SetupIntent for off-session use (`usage=off_session`) whose client secret the customer's browser confirms with Stripe.js, so no card number reaches the caller; under the caller's idempotency key. The secret comes back in `ClientSecret` only: the raw response kept for auditing has it redacted, an error's embedded SetupIntent included
  - `GetSetupAsync`: once it succeeds, the card it saved (`PaymentMethodId`)
  - `ListAsync` and `DetachAsync`: every one of a customer's saved cards (brand, last four digits, expiry, funding), page after page, and removing one
- **Customers** (eQuantic/equantic-billing#133):
  - `CustomerRequest.IdempotencyKey`, sent when Stripe creates the customer, so a create retried after a timeout, or sent twice, answers with the customer the first try made; an update sets the same values again and sends none
  - `CustomerRequest.Metadata`, kept on the customer at create and update (the caller's id for it, for one), beside the `document` entry the package writes, which a `document` entry of the caller's replaces
  - `ICustomerOperations.UpdateAsync`: Stripe updates the customer from a name, an email, a phone, a CPF or CNPJ and an address, and clears what the request leaves out (a phone, an address or its second line, a document), so it stays in step with the caller's
- `CreateChargeRequest.CustomerId`, `CardDetails.PaymentMethodId` and `OffSession`: a charge against the customer's saved card, confirmed with the customer away (`off_session=true`). A `pm_` id passed as `Token`, as 1.x documented, is charged as the saved card too.
- **A decline is an outcome**: a declined card, or one that asks for authentication with the customer away, answers `Success = false` with the decline code in `Error.Code` (`insufficient_funds`, `authentication_required`…) and the charge in `Data`, its id and its `Failed` status, read from the PaymentIntent Stripe returns with its 402.
- **Refused before the call**: a charge with the customer away without a customer or a saved card, or with either blank (`saved_payment_method_required`).
- The README says what a Brazilian Stripe account takes: Visa and Mastercard credit and foreign debit; no Elo, Hipercard, Amex, Brazilian debit or installments.

**Boleto** (eQuantic/equantic-billing#119):

- the payer whole (name, email, CPF or CNPJ, full address with its country), none of it blank, or the request is refused before the call (`boleto_payer_incomplete`); the name and address go in ASCII (`São João` becomes `Sao Joao`)
- `BoletoDetails.DueDate` becomes `expires_after_days` counted in São Paulo days, where Stripe ends the voucher at 23:59: a boleto made after 21:00 in São Paulo no longer expires a day early, as it did when the days were counted in UTC
- the voucher back: `DigitableLine`, `PdfUrl`, `HostedUrl`, `ExpiresAt`, and the `DueDate` as São Paulo's day
- a PaymentIntent waiting for a payment method after an attempt reads as `Expired` when the attempt expired (an unpaid boleto or Pix, `payment_intent_payment_attempt_expired`) and `Failed` otherwise; one never attempted still reads as `Pending`
- the README says that Stripe confirms a payment up to one business day after it is made (`payment_intent.succeeded`), that an expired voucher fails the PaymentIntent (`payment_intent.payment_failed`), and that a boleto cannot be refunded or disputed through Stripe

**Along the way**:

- every Stripe POST carries a form body, empty or not: a detach went without one, and so without its content type
- `PaymentResponse` can carry the object on a failure (`Fail(provider, error, data, raw)`)
- `IPaymentProvider.PaymentMethods` and `ICustomerOperations.UpdateAsync` are default members, so providers built against 1.x still load; Pagar.me, Mercado Pago and Asaas answer `customer_update_unsupported`, and every provider but Stripe answers `payment_methods_unsupported`
- `AttemptedAt`'s documentation and the core README say to set it with the idempotency key and send it again with every retry: left out, each call counts a Pix expiry or a boleto's days from its own moment, which #8's last review pointed out

## Why

Closes eQuantic/equantic-billing#133.

Part of eQuantic/equantic-billing#118 and eQuantic/equantic-billing#119: their *done when* asks for these flows in Stripe's test mode, which needs a test secret key in the environment. They close once that run is done.

A subscription engine charges every cycle without its customer, against a card saved once, and bills by boleto the customers who pay that way. Both are what eQuantic's products need to bill in Brazil on Stripe, and each billing account needs one Stripe customer that a retry or a double click does not duplicate.

## Proof

- **Build**: `dotnet build -c Release`, 0 warnings, 0 errors.
- **Tests**: `dotnet test --no-build -c Release`, 92 of 92, 22 of them new:
  - `StripeSavedCardTests` (15), on Stripe's documented objects and errors: the customer updated, and cleared of what the request no longer carries; a customer created under the caller's key with its metadata, and a `document` entry of the caller's kept; a SetupIntent created under its key, read back succeeded with its card, and its client secret in the result but not in the raw body, an error's included; every saved card listed over two pages, and one detached; a saved card charged with the customer away; a decline (`insufficient_funds`) and an authentication required with the customer away, each with its code and its charge; a charge with the customer away refused without a card or a customer, or with either blank; a `pm_` token charged as a saved card; a provider that saves no cards and updates no customers saying so
  - `StripeBoletoTests` (7), on a clock stopped at 22:30 in São Paulo, already the next day in UTC: the payer in ASCII and the days counted in São Paulo; the voucher with its number, PDF, hosted page and expiry; a boleto refused before the call without its whole payer, with a blank name or without a country; a PaymentIntent read again as paid, expired, failed or pending
  - the existing boleto test runs on the same stopped clock, since it counted from UTC's today, and #8's keyed-retry test now expects six days to 6 January from midnight UTC, still 31 December in São Paulo
- **Against Stripe's schema**: every request the provider builds, sent through [stripe-mock](https://github.com/stripe/stripe-mock) 0.206.0, which checks each parameter against Stripe's OpenAPI spec, was accepted, and every fixture it answered with deserialized: a customer created under a key with metadata, updated, and updated clearing its phone, address and document, then its address's second line; SetupIntent create and read, saved cards listed and detached, a card charged with the customer away, an authorized saved-card charge, a boleto, a Pix, a capture, a cancel and a refund under their keys, and a read. stripe-mock's spec is the latest API version's, which does not list `payment_method_types` for creation; the versions this package pins take it, so the run left it out.
- **Release**: squash-merged under this title, semantic-release reads `✨ feat` and publishes a minor version.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants