You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
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.
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.
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.
…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
CloseseQuantic/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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The unified model:
CreateChargeRequest.IdempotencyKeyandRefundRequest.IdempotencyKeyCaptureRequestandCancelRequest, each with its ownIdempotencyKeyIChargeOperationsgainsCaptureAsync(CaptureRequest)andCancelAsync(CancelRequest); their default implementations capture and cancel as the 1.x methods do, so no provider has to change for themattempt-42:create,attempt-42:refund) and reuse it only to retry that same request, since a gateway refuses a key reused with other parametersA retry sends the same request:
CreateChargeRequest.AttemptedAtpins the moment of the first try, and everything counted from the moment of the request comes from it instead of the clock:PixDetails.ExpiresIn(Mercado Pago Payments and Orders, PagSeguro, Adyen, Pagar.me v4)ReferenceIdtakes the idempotency key as its reference, instead of a fresh idWhat each gateway gets:
Idempotency-Key, the caller's; none without one, as beforeX-Idempotency-Key, the caller's; without one, a fresh key, except on a Payments API capture or cancel, which carries none, as beforex-idempotency-key, the caller's or a fresh oneIdempotency-Key, the caller's or a fresh oneCompatibility 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,
defaultincluded, 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
dotnet build -c Release, 0 warnings, 0 errors.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:defaulttoken compile and send what 1.x sentAttemptedAt(2026-01-01) rather than the clock, and Adyen's reference taken from the key✨ featand publishes a minor version.