✨ feat: saved cards charged with the customer away, and boleto, on Stripe - #9
Merged
Merged
Conversation
…ripe A subscription saves a card once and charges it on every cycle. A SetupIntent, which the customer's browser confirms with Stripe.js, saves it (PaymentMethods.SetupAsync, then GetSetupAsync names the card saved); ListAsync and DetachAsync list and remove a customer's cards, and Customers.UpdateAsync keeps the customer in step. A charge takes the customer and the saved card (CustomerId, CardDetails.PaymentMethodId) and runs with the customer away (OffSession). A decline, or a card that asks for authentication then, is an outcome: the failure carries the decline code and the charge, with its id and its Failed status. A boleto needs its whole payer, refused before the call otherwise, and sends the payer's name and address in ASCII. Its days are counted in São Paulo, where Stripe ends the voucher, so a boleto made late in the evening no longer expires a day early, and its due date reads back as São Paulo's day. The voucher comes back with its number, PDF, hosted page and expiry. A PaymentIntent back to waiting for a payment method after an attempt reads as Failed, or Expired when the attempt expired. POSTs always carry a form body: Stripe's form endpoints expect its content type, which a detach sent without. IPaymentProvider and ICustomerOperations take the new members as defaults, so 1.x providers still load.
…ripe The responses replay Stripe's documented objects and errors: a declined card and one that asks for authentication come back as 402s carrying their PaymentIntent. The boleto tests run on a clock stopped at 22:30 in São Paulo, already the next day in UTC, and the existing boleto test does too.
Left out, AttemptedAt is the time of each call, so a retry counts a Pix expiry or a boleto's days from its own moment and a gateway can refuse it for carrying other parameters under the same key. The request's documentation and the core README now say to set it with the key and send it again with every retry.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Setup secrets can leak through audit payloads, while pagination, replacement updates, and preflight validation remain incomplete.
Review effort: Balanced
Findings: 1
Open (5)
Raw SetupIntent responses expose client_secret · New ListAsync drops saved cards beyond the first Stripe page · New Preflight accepts blank customer and payment method IDs · New Boleto payer validation accepts blank fields and missing country · New Customer updates cannot clear omitted optional fields · New
What changed in this PR
Adds Stripe saved-card/off-session charging and expanded boleto support while preserving compatibility for other providers.
Changes:
- Adds payment-method setup, listing, detachment, customer updates, and off-session charges.
- Improves Stripe boleto validation, expiry handling, voucher output, and status mapping.
- Adds provider fallbacks, documentation, and comprehensive Stripe tests.
| File | Description |
|---|---|
tests/eQuantic.Payment.Tests/StripeSavedCardTests.cs |
Tests saved-card and customer-update flows. |
tests/eQuantic.Payment.Tests/StripeNotificationTests.cs |
Removes the local clock implementation. |
tests/eQuantic.Payment.Tests/StripeLatestChargeTests.cs |
Tests São Paulo boleto date calculations. |
tests/eQuantic.Payment.Tests/StripeBoletoTests.cs |
Tests boleto creation, validation, vouchers, and statuses. |
tests/eQuantic.Payment.Tests/IdempotencyKeyTests.cs |
Updates boleto retry expectations. |
tests/eQuantic.Payment.Tests/Fakes/FixedClock.cs |
Adds a shared deterministic clock. |
src/eQuantic.Payment/README.md |
Documents new contracts and retry timing. |
src/eQuantic.Payment/Models/Results/SavedPaymentMethod.cs |
Defines saved payment-method results. |
src/eQuantic.Payment/Models/Results/PaymentMethodSetup.cs |
Defines setup results and statuses. |
src/eQuantic.Payment/Models/Results/Charge.cs |
Expands boleto voucher output. |
src/eQuantic.Payment/Models/Requests/PaymentMethodSetupRequest.cs |
Defines setup requests. |
src/eQuantic.Payment/Models/Requests/CreateChargeRequest.cs |
Adds customer, saved-card, and off-session fields. |
src/eQuantic.Payment/Models/PaymentResponse.cs |
Allows failures to carry normalized data. |
src/eQuantic.Payment/Abstractions/UnsupportedPaymentMethodOperations.cs |
Provides unsupported payment-method responses. |
src/eQuantic.Payment/Abstractions/UnsupportedCustomerOperations.cs |
Adds unsupported customer updates. |
src/eQuantic.Payment/Abstractions/IPaymentProvider.cs |
Exposes payment-method operations. |
src/eQuantic.Payment/Abstractions/IPaymentMethodOperations.cs |
Defines saved-payment-method operations. |
src/eQuantic.Payment/Abstractions/ICustomerOperations.cs |
Adds customer updates. |
src/eQuantic.Payment/Abstractions/CustomerUpdates.cs |
Centralizes unsupported update responses. |
src/eQuantic.Payment.Stripe/V1/StripeV1Operations.cs |
Implements validation, updates, timing, and decline data. |
src/eQuantic.Payment.Stripe/V1/StripeProviderV1.cs |
Registers Stripe payment-method operations. |
src/eQuantic.Payment.Stripe/V1/StripePaymentMethodOperations.cs |
Implements Stripe setup, listing, and detachment. |
src/eQuantic.Payment.Stripe/V1/StripeErrorMapper.cs |
Exposes parsed Stripe error details. |
src/eQuantic.Payment.Stripe/V1/StripeClientV1.cs |
Adds relevant Stripe endpoints and POST bodies. |
src/eQuantic.Payment.Stripe/V1/StripeBoleto.cs |
Handles São Paulo dates and ASCII conversion. |
src/eQuantic.Payment.Stripe/V1/Models/StripeV1Models.cs |
Adds SetupIntent and PaymentMethod wire models. |
src/eQuantic.Payment.Stripe/V1/Mapping/StripeSetupIntentMapper.cs |
Maps SetupIntent responses. |
src/eQuantic.Payment.Stripe/V1/Mapping/StripeSetupIntentFormMapper.cs |
Builds SetupIntent forms. |
src/eQuantic.Payment.Stripe/V1/Mapping/StripeRequestContext.cs |
Uses São Paulo’s current date. |
src/eQuantic.Payment.Stripe/V1/Mapping/StripePaymentMethodMapper.cs |
Maps saved cards. |
src/eQuantic.Payment.Stripe/V1/Mapping/StripePaymentIntentFormMapper.cs |
Builds saved-card and boleto PaymentIntents. |
src/eQuantic.Payment.Stripe/V1/Mapping/StripeChargeMapper.cs |
Maps boleto URLs, expiry, and statuses. |
src/eQuantic.Payment.Stripe/StripeStatusMapper.cs |
Maps failed and expired attempts. |
src/eQuantic.Payment.Stripe/README.md |
Documents saved cards and boleto behavior. |
src/eQuantic.Payment.Pagarme/V5/PagarmeV5Operations.cs |
Returns unsupported customer updates. |
src/eQuantic.Payment.Pagarme/V4/PagarmeV4Operations.cs |
Returns unsupported customer updates. |
src/eQuantic.Payment.MercadoPago/Customers/MercadoPagoCustomerOperations.cs |
Returns unsupported customer updates. |
src/eQuantic.Payment.Asaas/V3/AsaasV3CustomerOperations.cs |
Returns unsupported customer updates. |
README.md |
Updates the repository structure documentation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ller's metadata CustomerRequest takes an IdempotencyKey, which Stripe's customer create sends, so a create retried after a timeout, or sent twice, answers with the customer the first try made instead of a second one; an update sets the same values again and sends none. Its Metadata goes on the customer at create and update, beside the document entry the package writes, which a document entry of the caller's replaces. The 1.x client signature stays, beside an overload that takes the key.
…efused, updates clear What the first review found: - a SetupIntent's raw body, kept for auditing, carried its client_secret; it is redacted there, at any depth (an error carries its SetupIntent), and stays in ClientSecret only - ListAsync read the first page of up to 100 cards; it follows starting_after while Stripe says has_more - an off-session charge with a blank customer or card id, and a boleto with a blank payer field or no address country, reached Stripe; both are refused before the call - a customer update left at Stripe the phone, address, address line and document the request no longer carried; it sends them empty, which clears them
|
🎉 This PR is included in version 1.3.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
edgarmesquita
added a commit
that referenced
this pull request
Oct 1, 2026
…n-start hook and the central OpenSpec store (#11) ## What - **The working agreement** is a `## Workflow` section, the same text in `CLAUDE.md` and `AGENTS.md`: `<type>/<slug>` branches and nothing straight to `main`; commits in English as `emoji type: description`, with no co-authorship or attribution; the owner's identity; a typed issue on the board ([#16](https://github.com/orgs/eQuantic/projects/16)) behind every change, closed by its pull request; Copilot's review until a round brings nothing new, then the squash merge on green CI, under the pull request's title; CI on eQuantic Space (`eqs runs`) while GitHub Actions has no credits; English in everything committed and posted, Portuguese only in the chat; the docs and `docs/LEDGER.md` in the same pull request; OpenSpec in the central store; what a session starts from. `WorkflowSectionTests` fails when the two copies differ, naming the first line that does. - **Planning in the central store**, eQuantic/equantic-specs, as the `core` workstream. `openspec/config.yaml` holds `store: equantic-specs` and nothing else, `openspec init --tools claude,agents` generated `.claude/commands/opsx/`, `.claude/skills/` and `.agents/skills/`, and the CLI is pinned by `tools/openspec/package-lock.json` to the store's 1.14.0 (the store's own lockfile, renamed), with telemetry off. This carries everything #6 did, which can close once this merges. - **No attribution**: `.claude/settings.json` sets `attribution` to `{ "commit": "", "pr": "", "sessionUrl": false }`, so Claude Code adds no commit trailer, no pull request footer and no session link. - **The session-start hook**, `.claude/hooks/session-start.sh`, registered in `.claude/settings.json`: - in a cloud container: the owner's identity and commit and tag signing off in the repository's git config (and in the store's), the identity also exported to the session; the pinned OpenSpec CLI on PATH; the store cloned beside the repository over HTTPS and registered; the .NET SDK 10.0.401 installed in `~/.dotnet` once its SHA-256 matches the pin (linux-x64 and linux-arm64); Docker started - on a laptop: only the pinned OpenSpec CLI, installed under `tools/openspec` and put on the session's PATH, and a check that the store is registered; the identity, the signing, the SDKs, Docker and the store registry are left as they are - it never blocks the session: what it prepared goes to the agent, and whatever it could not prepare (the identity in the repository or in the store's checkout, the CLI, the store, the SDK, Docker, an env file it cannot write) also goes to the person, with the command that fixes it - **CI**: - `openspec` fails when the pointer says more than `store: equantic-specs` (unquoted, or in balanced quotes), when `openspec/specs` or `openspec/changes` appears here, or when the lockfile pins a CLI older than 1.14.0; it installs the pinned CLI from its lockfile. The store's own CI runs `openspec validate --all --strict` on every push, and this repository's CI cannot read the private store. - `session-start` runs the hook the way a fresh cloud container would, with a hostile global git config, and the way a laptop would, and checks each promise, a refused SDK archive, an unreachable store and a store checkout that cannot take the identity included. - the `Pull request` workflow checks the branch, the title and the body, and runs again when the title or the body is edited. - every check runs its self-test first, which proves it still fails where it should. - **Docs**: the pull request template (What, Why with the issue and the OpenSpec change, Proof, a checklist), `docs/LEDGER.md` with the history so far, one line per event, and the README (the new CI jobs, a Contributing section). ## Why Closes #10. A rule that is not in the repository does not survive the next session: a cloud container starts with another git identity, signs commits with a key that is not the owner's, has no .NET SDK, and knows nothing of the flow. And every eQuantic product now plans in eQuantic/equantic-specs (eQuantic/equantic-specs#1), which this repository did not point at yet. No OpenSpec change: nothing a consumer of the packages observes moves (this is tooling, docs and CI), and the store's rules ask no proposal for that. ## Proof - **Build and tests**: `dotnet build -c Release`, 0 warnings, 0 errors; `dotnet test --no-build -c Release`, 93 of 93, 1 of them new. `WorkflowSectionTests` fails on a one-line edit to `AGENTS.md`, naming `CLAUDE.md:32` and `AGENTS.md:15`, and on a `CLAUDE.md` with no Workflow section. `dotnet pack` still puts the README and the icon in all 9 packages. - **`scripts/check-openspec.sh`**: its self-test passes its 13 cases, unbalanced quotes and a value glued to the colon among the failures; the check passes on this tree and fails with an `openspec/changes/` folder; a mutant whose check never fails is refused by the self-test. - **`scripts/check-pull-request.sh`**: its self-test passes its 19 cases in the C, C.UTF-8 and en_US.UTF-8 locales, and the branches, titles and bodies of #6 and #9 pass it. On this pull request it already bit: the tool that opened it appended a generated-by footer with a session link, the `Pull request` workflow failed on it, and it passed once the footer was removed. The footer's wording is now one of its cases. - **`scripts/check-session-start.sh`**: all 28 of its checks pass, locally in about 30 s and on GitHub Actions in 25 s. Two mutant hooks fail it: one that skips the identity ("after the hook, a commit still fails"), and one that stays silent when the store's checkout cannot take the identity. - **The hook in a real cloud container** (this session's): in 12 s it set the identity, installed the SDK (`dotnet --version` answers 10.0.401 in the repository), put `openspec` 1.14.0 on PATH, registered the store and started Docker, and said nothing to the person. With `docker` off its PATH, or with an env file it cannot write, it tells the person so. `openspec list --specs`, run in the repository, lists the store's specs. - **The SDK pins**: each SHA-256 was taken from a download of Microsoft's 10.0.401 archive whose SHA-512 matched `https://builds.dotnet.microsoft.com/dotnet/release-metadata/10.0/releases.json`. - **shellcheck** 0.11.0 is clean on the hook and the three scripts. - **Copilot**, round 1: three findings (a store identity failure and a missing Docker reported as notes, and a pointer with unbalanced quotes accepted), all fixed in f586727 and b27d8e8, each with a case in the checks above. Round 2 found nothing. - **Release**: squash-merged under this title, semantic-release reads `🔧 chore` and releases nothing.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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
Saved cards, charged with the customer away (eQuantic/equantic-subscription-api#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 inClientSecretonly: the raw response kept for auditing has it redacted, an error's embedded SetupIntent includedGetSetupAsync: once it succeeds, the card it saved (PaymentMethodId)ListAsyncandDetachAsync: every one of a customer's saved cards (brand, last four digits, expiry, funding), page after page, and removing oneCustomerRequest.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 noneCustomerRequest.Metadata, kept on the customer at create and update (the caller's id for it, for one), beside thedocumententry the package writes, which adocumententry of the caller's replacesICustomerOperations.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'sCreateChargeRequest.CustomerId,CardDetails.PaymentMethodIdandOffSession: a charge against the customer's saved card, confirmed with the customer away (off_session=true). Apm_id passed asToken, as 1.x documented, is charged as the saved card too.Success = falsewith the decline code inError.Code(insufficient_funds,authentication_required…) and the charge inData, its id and itsFailedstatus, read from the PaymentIntent Stripe returns with its 402.saved_payment_method_required).Boleto (eQuantic/equantic-subscription-api#119):
boleto_payer_incomplete); the name and address go in ASCII (São JoãobecomesSao Joao)BoletoDetails.DueDatebecomesexpires_after_dayscounted 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 UTCDigitableLine,PdfUrl,HostedUrl,ExpiresAt, and theDueDateas São Paulo's dayExpiredwhen the attempt expired (an unpaid boleto or Pix,payment_intent_payment_attempt_expired) andFailedotherwise; one never attempted still reads asPendingpayment_intent.succeeded), that an expired voucher fails the PaymentIntent (payment_intent.payment_failed), and that a boleto cannot be refunded or disputed through StripeAlong the way:
PaymentResponsecan carry the object on a failure (Fail(provider, error, data, raw))IPaymentProvider.PaymentMethodsandICustomerOperations.UpdateAsyncare default members, so providers built against 1.x still load; Pagar.me, Mercado Pago and Asaas answercustomer_update_unsupported, and every provider but Stripe answerspayment_methods_unsupportedAttemptedAt'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 ✨ feat: the caller's idempotency key on create, capture, cancel and refund #8's last review pointed outWhy
Closes eQuantic/equantic-subscription-api#133.
Part of eQuantic/equantic-subscription-api#118 and eQuantic/equantic-subscription-api#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
dotnet build -c Release, 0 warnings, 0 errors.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 adocumententry 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; apm_token charged as a saved card; a provider that saves no cards and updates no customers saying soStripeBoletoTests(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 pendingpayment_method_typesfor creation; the versions this package pins take it, so the run left it out.✨ featand publishes a minor version.