# Stripe

Source module: `app/connectors/stripe_conn.py`. Kind: **facts** only. Display name: "Stripe".

This is the **data connector** that reads a customer's own Stripe account for refund/credit facts. It is unrelated to CommitLayer's own subscription billing (`app/billing/stripe_billing.py`), which uses the platform's Stripe account and separate keys.

## Purpose

Reads refunds, credit notes and subscription cancellations so that the `refund_or_credit_within`, `refund_posted_within` and subscription-related signals can be evaluated against claims from help-desk vendors.

Since 0.9.5 it also reads **outbound payments** from the account's financial accounts (Treasury v1 and Money Management v2, the API behind Global Payouts and `stripe pay`), so that a transfer an agent sends can be verified under the `stripe_b2b_transfer` library contract: did it exist, go to the intended recipient, once, for the amount asked, reach `posted`, and stay there.

## Auth and scopes

| Item | Value |
|---|---|
| Method | Restricted API key, sent as `Authorization: Bearer`, against `https://api.stripe.com` |
| Credentials stored (encrypted) | `api_key`; optional `base_url` |
| Least-privilege permissions for the restricted key | **Refunds: read**, **Credit notes: read**, **Subscriptions: read**, **Customers: read** (needed only to expand the customer on refunds/credit notes for email hashing), **Charges: read** (implied by expanding `charge.customer` on refunds). To verify transfers an agent sends, add **Financial accounts: read** and **Outbound payments: read** (Treasury), and **Money Management Outbound Payments: read** (v2). Everything else: none. A key without the transfer permissions still syncs refunds; the connection's health message says what to grant. |
| Connection test | `GET /v1/refunds?limit=1` |
| Non-secret config | none |

## Fields pulled and why

| Endpoint | Fields | Used for |
|---|---|---|
| `GET /v1/refunds?created[gt]=since&expand[]=data.charge.customer` | `id`, `amount`, `currency`, `created`, `charge.customer.id`, `charge.customer.email` | Refund facts, subject id, hashed email, watermark |
| `GET /v1/credit_notes?created[gt]=since&expand[]=data.customer` | `id`, `amount`, `currency`, `created`, `customer.id`, `customer.email` | Credit facts, subject id, hashed email |
| `GET /v1/subscriptions?created[gt]=since` | `id`, `status`, `canceled_at`, `customer` | Subscription-cancellation facts |
| `GET /v1/treasury/financial_accounts` then `GET /v1/treasury/outbound_payments?financial_account=&created[gt]=since-35d` | `id`, `amount`, `currency`, `status`, `status_transitions`, `customer` / `destination_payment_method`, `financial_account`, `created`, `returned_details.code` | Outbound-payment facts (Treasury v1) |
| `GET /v2/money_management/outbound_payments?created_gt=since-35d` (header `Stripe-Version: 2026-04-22.preview`, pages followed by `next_page_url`) | `id`, `amount.value/currency`, `status`, `status_transitions`, `to.recipient` / `to.payout_method`, `from.financial_account`, `created`, `status_details.returned.reason` | Outbound-payment facts (Money Management v2) |

Amounts are converted from minor units (`amount / 100`). Outbound payments are re-read 35 days behind the watermark on every sync, because a payment changes status after it is created (`posted`, `returned`) and the facts for those statuses are keyed on the payment; the re-read is idempotent.

## What is hashed / never stored

- Customer email (from the expanded customer object) → `customer_hid` (HMAC-SHA256, per-tenant key); the email is discarded.
- Card details, payment method data, customer names, addresses, invoice line items, charge descriptions and metadata are never requested or stored.
- The restricted key is stored only as ciphertext.

## Incremental sync and watermark

- All three lists use `created[gt] = <watermark epoch>` (0 on first sync), page size 100, cursor via `starting_after = <last id>` while `has_more`.
- Watermark = maximum refund `created` (credit notes and subscriptions do not advance it).
- Upserts are idempotent.

## Rate-limit handling

Shared `HttpClient` (Retry-After, exponential backoff on `429`/`5xx`, 6 attempts, immediate `reauth` failure on `401`/`403`).

## Fact types produced

Subject id is the Stripe customer: `stripe_customer_{customer_id}`.

| `fact_type` | Subject kind | When | Attributes | `raw_ref` |
|---|---|---|---|---|
| `refund` | `customer` | Each refund | `amount` (major units), `currency`, `customer_hid` | `stripe:refund:{refund_id}` |
| `credit` | `customer` | Each credit note | `amount`, `currency`, `customer_hid` | `stripe:credit_note:{credit_note_id}` |
| `subscription_change` | `subscription` | Each listed subscription with `canceled_at` set | `status`, `change: "canceled"` | `stripe:subscription:{subscription_id}:canceled` |
| `outbound_payment_created` | `recipient` | Each outbound payment, at `created`; attributes refresh on every sync | `amount`, `currency`, `payment_id`, `status`, `financial_account` | `stripe:outbound_payment:{id}` |
| `outbound_payment_posted` | `recipient` | Status `posted`, at `status_transitions.posted_at` | `amount`, `currency`, `payment_id` | `stripe:outbound_payment:{id}:posted` |
| `outbound_payment_returned` | `recipient` | Status `returned`, at `returned_at` | `amount`, `currency`, `payment_id`, `reason` | `stripe:outbound_payment:{id}:returned` |
| `outbound_payment_failed` | `recipient` | Status `failed` or `canceled`, at `failed_at` / `canceled_at` | `amount`, `currency`, `payment_id`, `status` | `stripe:outbound_payment:{id}:{status}` |

Outbound-payment facts use the subject `stripe_recipient_{id}`, where the id is the recipient as Stripe names it: `to.recipient` (an `acct_…`) or `to.payout_method` on v2, the `customer` or `destination_payment_method` on v1. An agent that records a `stripe_b2b_transfer` action uses the same subject (the `--json` output of `stripe pay` carries it), or pushes a `links` entry from the profile handle it was told to pay to that id.

## Claims produced

None.

## Subject id format

`stripe_customer_{cus_…}`. Because help-desk claims name a conversation or ticket, Stripe facts only become relevant to a claim through a **subject link** between the conversation subject and the Stripe customer subject. The link is made by matching `customer_hid` (hashed email) between the two sources; the library function `app.connectors.base.resolve_identities` performs this, and a scheduled identity-resolution pass is a planned addition (see ARCHITECTURE.md §4). Until then, links can be supplied via a CSV facts upload whose `subject_id` equals the conversation subject.

## Known limitations

- Subscription cancellations are discovered through `created[gt]`, i.e. only subscriptions **created** after the watermark are listed; cancellations of older subscriptions are missed. This is a known gap.
- Refunds without an expandable charge/customer (e.g. some Checkout or PaymentIntent flows) produce a subject id with an empty customer id and cannot be linked.
- No `customer_hid` on subscription facts (customer is referenced by id only).
- Partial refunds and full refunds are not distinguished in the fact type; `amount` is available in attributes.
- Disputes/chargebacks are not read.
- A recipient Stripe has never paid has no record at all, so an agent's claim to have paid it, when nothing was created, answers UNKNOWN (`identity_unmatched`, "subject not found") rather than FAIL; once any payment to that recipient exists, a missing one is a FAIL. The v2 list is read under a pinned preview API version (`V2_VERSION` in the module); Stripe may move it.
- Financial-account balances are not read: Stripe's `posted` status is its own statement that funds left the account, and a balance snapshot taken at sync time cannot be aligned with the action's moment.
