Skip to content

✨ feat: saved cards charged with the customer away, and boleto, on Stripe - #9

Merged
edgarmesquita merged 6 commits into
mainfrom
feat/stripe-off-session-cards-and-boleto
Oct 1, 2026
Merged

edgarmesquita merged 6 commits into
mainfrom
feat/stripe-off-session-cards-and-boleto

Conversation

@edgarmesquita

@edgarmesquita edgarmesquita commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

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 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-subscription-api#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-subscription-api#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 ✨ feat: the caller's idempotency key on create, capture, cancel and refund #8's last review pointed out

Why

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

  • 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 ✨ feat: the caller's idempotency key on create, capture, cancel and refund #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 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.

…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.

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

Setup secrets can leak through audit payloads, while pagination, replacement updates, and preflight validation remain incomplete.

Review effort: Balanced
Findings: 1 High severity · 4 Medium severity

Open (5)
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.

Comment thread src/eQuantic.Payment.Stripe/V1/StripePaymentMethodOperations.cs Outdated
Comment thread src/eQuantic.Payment.Stripe/V1/StripePaymentMethodOperations.cs Outdated
Comment thread src/eQuantic.Payment.Stripe/V1/StripeV1Operations.cs Outdated
Comment thread src/eQuantic.Payment.Stripe/V1/StripeV1Operations.cs Outdated
Comment thread src/eQuantic.Payment.Stripe/V1/StripeV1Operations.cs
…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

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

It changes externally coupled payment, customer, and voucher lifecycles, with live Stripe validation still pending.

Review effort: Balanced
Findings: None

Resolved since last review (5)

@edgarmesquita
edgarmesquita merged commit a0b3e61 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.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.
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