> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/billing/payments.md).

# Stripe, PayPal and payment methods

Stripe is the live order-payment path.

## 4.4 Stripe <a href="#id-44-stripe" id="id-44-stripe"></a>

Stripe is the live order-payment path. Provider settings live in the console; **secrets are write-only in forms** (`PasswordInput(render_value=False)`, blank = keep existing) and must never be rendered back.

`stripe_webhook` at `/client/webhooks/stripe/` is `@csrf_exempt` **by design** but MUST verify the provider signature and be **idempotent per `(provider, event_id)`** (`ProviderWebhookEvent`). Handled events: `checkout.session.completed`, `invoice.paid`, `invoice.payment_succeeded`, `invoice.payment_failed`, `payment_intent.succeeded`/`.payment_failed`, `setup_intent.succeeded` ([4.6](#id-46-payment-methods)), `charge.refunded`, `charge.dispute.created`/`.funds_withdrawn`, `payment_method.automatically_updated`, and `customer.subscription.created`/`.updated`/`.deleted` (logged and ignored, see [4.8](/platform/billing/ledger-and-metering.md#id-48-billing-records-subscription)). `STRIPE_PROCESSED_EVENTS` is the authoritative list; an event the Stripe endpoint does not send is simply never delivered, so **a newly handled type has to be added to the endpoint in the Stripe Dashboard as well** — the code alone does not subscribe to it.

Rules that are load-bearing:

* **Failure bookkeeping must be written&#x20;*****outside*****&#x20;the handler's `transaction.atomic()` block** (`_record_stripe_webhook_failure`), or the rollback discards the FAILED record along with the partial work.
* **Both local identifiers are environment-scoped.** `_find_order_for_stripe_object` and `_find_invoice_for_stripe_object` each run `_recorded_environment_matches` against the row's recorded `checkout_environment`. Without the invoice-side check, a validly signed **test-mode** event carrying `metadata.invoice_id` for a **live** invoice marked that live invoice PAID and upserted a SUCCEEDED payment attempt behind it — forging paid state for anyone holding the low-trust test credentials. A cross-environment id is treated as no local invoice at all.
* **A PAID invoice is never reopened.** `_upsert_invoice_from_stripe` leaves status *and amounts* alone when an OPEN projection lands on a settled invoice and stamps `metadata["stripe_out_of_order_failure_ignored"]`. A delayed `invoice.payment_failed` arriving after `invoice.paid` used to flip the invoice back to OPEN with `paid_at` still set and the credit already posted, putting a paying customer straight into suspension. `_handle_invoice_payment_failed` returns `invoice_payment_failed_ignored`: the failure is still a billing event, but the succeeded attempt, the credit entry and the subscription status stay put and no payment-failed email goes out.
* **The duplicate-event insert owns a savepoint.** `_lock_or_create_webhook_event` wraps its `create()` in `transaction.atomic()`. The handler already runs inside a transaction, so an unguarded unique violation left it unusable: the recovery `.get()` itself errored, the delivery answered 5xx, and a spurious FAILED row plus a `stripe_webhook_failed` event were written for an event another worker was processing correctly.
* **Saved-card retries rotate the Stripe `Idempotency-Key`.** The `checkout-saved:{order}:{method}` PaymentAttempt is reused across retries, but the key sent to Stripe is `checkout-saved-{attempt.id}-{call_count}` (`services.next_saved_checkout_request_id`). Stripe replays the original response for 24 hours without contacting the card network, so one fixed key meant a customer declined for insufficient funds, who then funded the account, kept getting the cached decline all day. The key is reused in exactly one case: when Stripe **never answered** (`StripeTransportError`, surfaced as `StripeChargeError.outcome_known is False`), because the replay is then the only thing preventing a double charge. The counter is written before the call and the outcome recorded as unknown, so a worker that dies mid-request also retries with the same key.

Confirmation can enter a **verifying** state when the provider returns an ambiguous response, so the receipt page can retry verification without double-charging.

## 4.5 PayPal <a href="#id-45-paypal" id="id-45-paypal"></a>

**PayPal is reinstated for account credit only.** Two native options on the receipt for a credit order: **PayPal one-time** (Orders v2) and **PayPal monthly subscription** (Subscriptions v1). The customer chooses a USD amount from $5 to $1,000 on Add credit. A monthly subscription collects that fixed amount on approval and every month until cancelled; each completed payment adds the same credit. It does not recalculate when usage changes.

**Reference Transactions remain disabled.** The historical adapter lives in `apps/billing/paypal_reference_transactions.py` with unconditional guards at its public entry points and transport boundaries. Provider configuration cannot enable vaulting, saved PayPal methods, billing-agreement charges, or balance-triggered PayPal recharge. Stripe's saved methods are unaffected. Legacy customer-linked PayPal payment methods are retired with `manage.py revoke_paypal_payment_methods --apply`.

`PayPalCreditSubscription` holds the approved amount, originating credit order, provider configuration/environment, product/plan/subscription IDs, status, next billing time and cancellation history. It is **separate from `Subscription`**, which remains the arrangement for a hosting service. At most one nonterminal PayPal credit subscription per account, enforced in the database; changing the amount means cancel and create.

**Approval and browser returns never deposit credit.** Verified subscription payment webhooks retrieve the subscription and its transaction history from the configured API and check recorded IDs, custom account identifier, plan, environment, amount and currency. Each completed sale gets a paid invoice, a successful `PaymentAttempt` and a `PayPalCreditSubscriptionPayment` — the last uniquely identifies the PayPal sale even when PayPal delivers it under multiple webhook event IDs. Credit is posted through `record_account_credit_for_paid_invoice`, whose invoice uniqueness constraint is the final duplicate-deposit boundary. Orders v2 purchases verify the original order, invoice, account, currency, amount, merchant (when configured) and a completed final capture; a verified `CHECKOUT.ORDER.APPROVED` can complete an approved order even if the customer closes the browser.

Request IDs are persisted before provider calls and reused for ambiguous retries; unresolved creation requests outside the idempotency window require operator reconciliation rather than silently creating another charge. Checkout prevents using another payment flow for an order already handed to PayPal. Terminal subscription states cannot be resurrected by delayed deliveries.

Webhook URL `https://layeronecloud.com/client/webhooks/paypal/`, CSRF-exempt, verified with PayPal's signature-verification API before writing billing state. API hosts are fixed; redirects cannot forward credentials to another host. Failed processing is recorded outside the rolled-back handler transaction and returns a retryable 5xx. Public credit orders always use Live; Test and Live configuration/record IDs are strictly separated.

Refunds and reversals are recorded for **operator review**; there is no automatic clawback. Account closure and termination reject nonterminal monthly subscriptions, including unresolved creation attempts.

## 4.6 Payment methods <a href="#id-46-payment-methods" id="id-46-payment-methods"></a>

Stripe SetupIntents attach a card for future off-session use — **saving a card is not a charge**.

**A card setup is confirmed twice, and the browser is not the trustworthy copy.** `Stripe.confirmSetup` runs in the customer's tab and the page then posts to `client-stripe-payment-method-complete`, which is what writes the `PaymentMethodReference`. A tab closed on the spinner, a dropped connection or an abandoned 3DS redirect leaves the card attached to the Stripe customer with nothing local to show for it: the account lists no saved method, hourly billing stays locked, and nothing retries, because the browser was the only witness. `setup_intent.succeeded` is the server-side copy of that confirmation (`_handle_setup_intent_succeeded`). It calls the same `confirm_stripe_payment_method_setup`, with the customer recorded on the attempt as `payment_method_owner_id` as the actor, so the rule about who may save a card into which payer's pool has one implementation rather than two; ownership that has moved since is logged and ignored rather than retried forever. `manage.py recover_stripe_card_setups` applies the same confirmation to setups abandoned before that event was subscribed, and prints how many setups in the window succeeded, failed and were never confirmed against how many saved methods exist — "nothing to recover" means something different when no account has ever saved a card. Its `--from-stripe` mode asks the provider which methods are attached to each account's customer and records the ones with no local row, which is the only recovery that does not depend on local state being intact. Attachment is the filter that makes it safe: Stripe lists only methods that can be charged off-session, so a single-use method from a PaymentIntent without `setup_future_usage` never appears, and `one_time_only` is honoured on top of that.

**An account may not remove its last working payment method, and removal is a revoke.** `payment_method_is_removable` is the single predicate: the guard in `remove_payment_method` and the `can_remove` flag the client page renders both call it, so the button cannot drift from the check.

| Method state                                     | Removable                 |
| ------------------------------------------------ | ------------------------- |
| `ACTIVE`, another `ACTIVE` method on the account | yes                       |
| `ACTIVE`, the only `ACTIVE` method               | **no**                    |
| `PENDING` / `FAILED`                             | yes, even as the last row |
| `REVOKED`                                        | no, already gone          |

A `FAILED` card is **not cover**, so it does not unlock removing the working one. The rule exists because losing the last card does not stop the balance falling — it only stops the recharge that would have covered it — so an account that removes its only card silently loses the mechanism that was keeping it funded. It now finds out at $0.00 rather than at suspension, because the grace countdown goes out regardless of whether a card is on file ([4.10](/platform/billing/credit-automation.md#id-410-credit-automation-the-000-floor-grace-window-suspension-termination)), but the last-card rule stands: hearing about it three days before the servers stop is worse than not being able to get there.

**Removal sets `REVOKED` + `revoked_at` and never deletes,** because `PaymentAttempt.payment_method` is `SET_NULL` and a delete would blank the method on historical attempts and lose the reconciliation trail. Revoked methods are filtered out of the client list and stay visible in the console.

**Removing the default must promote a survivor.** Every predicate that decides whether auto-recharge is armed keys off `is_default`, so revoking the default without promoting leaves an account that still has a usable card but is silently disarmed. `remove_payment_method` promotes the newest remaining active method and writes `is_default` **directly** rather than calling `_make_payment_method_default`, which also stamps `last_used_at` — promotion is not a use, and `last_used_at` orders the saved-method checkout lookup.

**Ordering against Stripe:** the local revoke commits first, then `_detach_provider_payment_method` calls `POST /v1/payment_methods/{id}/detach` best-effort, logging `payment_method_detach_failed`. A method we failed to detach is unusable here but still stored at Stripe, which an operator can finish by hand; detaching first and then failing to write leaves a method the portal offers and Stripe always declines.

`remove_payment_method` takes `select_for_update()` on the `BillingAccount` before evaluating the guard, so two concurrent removals of two different active methods cannot each see the other as cover.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/billing/payments.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
