# Payments (Purchase Histories ledger)

**Portal:** Manage · **Nav:** In the **Operations suite**, Payment is a **main tab of the Sales hub** (sidebar → Sales → Payment; [Components/SalesTabs.vue](/resources/js/Components/SalesTabs.vue)) whose pill sub-tabs are **Payment History** (default) · [Payment Items](/docs/modules_handbook/manage/payments/payment-links/readMe.md) · [Transfer Receipts](/docs/modules_handbook/manage/payments/transfer-receipts/readMe.md) · [Wallet Statements](/docs/modules_handbook/manage/payments/wallet-statements/readMe.md) · [Unreconciled](/docs/modules_handbook/manage/payments/unreconciled/readMe.md). The Project / Subsale suites keep their own standalone **Payment** sidebar entry with the flat `payments` strip ([Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue)); each page picks the right strip via [Components/PaymentTabs.vue](/resources/js/Components/PaymentTabs.vue). The **Traffics** page's strip also carries a **Payment** tab jumping here (2026-08-04 — ad spend next to the money it produced); the landing keeps this page's own Sales-hub identity. Gateway CREDENTIALS are deliberately not a tab here — keys are configuration, not sales data, so they live under Setting → [Payment Gateways](/docs/modules_handbook/manage/payments/gateways/readMe.md).

## What it does

The money ledger: one `purchase_histories` row per payment for a **payable** — a Membership tier, a Project fee or an LMS Course (polymorphic `payable_type`/`payable_id`; the old Product wrapper is retired). Rows arrive four ways:

1. **Payment links** — the public `/pay/{uuid}` flow (see [Payment Links](/docs/modules_handbook/manage/payments/payment-links/readMe.md)) opens the row **UNCONFIRMED**; the gateway webhook confirms + fulfills it.
2. **Portal checkout** — a signed-in lead buys a priced membership tier on Portal → **Profile → Membership** (`POST /memberships/{uuid}/checkout`, charging `memberships.price` — the single source of truth; a server-side guard blocks buying a tier the lead already holds).
3. **Manual entry** — an admin records an offline payment (bank transfer, cash). **Manual entries fulfill exactly like gateway payments** — the membership is granted / the engagement is opened.
4. **A gateway-hosted link** — a Stripe Payment Link created here or adopted from the gateway's dashboard. There is no pending row to confirm, so `WebhookProcessor::recordHostedLinkPayment()` writes the row **already ACTIVE**, keyed on the gateway's own session id as `payment_reference` (which is what makes a replay a no-op). The queued `ImportHostedLinkPayments` backfill writes a link's *historical* payments through that same method — see [Payment Links](/docs/modules_handbook/manage/payments/payment-links/readMe.md). ⚠️ A row from this lane is **never UNCONFIRMED**, and may be **unattributed** (`lead_id` NULL) when the buyer's email resolves to nobody.
5. **A manually-reconciled wallet transfer** (`PROVIDER_TNG`, Touch 'n Go eWallet — the ingest lane itself is not built yet; the ledger side is). Touch 'n Go has no API and no webhook, so money is read out of the wallet statement the owner emails in and recorded as a bare **fact**: amount, `counterparty_name` (who sent it), timestamp. The row is **ACTIVE with no payable and no lead** — deliberately. Nothing about a wallet transfer says what it was for, so a human decides, and the row is not a sale until they do. See *The unattributed row* below.

### The unattributed row

A row can be ACTIVE with `payable_type`, `payable_id` **and** `lead_id` all NULL. That is not a broken record — it is money whose purpose nobody has established yet, which on a personal-wallet rail is often not a sale at all (someone repaying the owner).

**An unattributed payment is not revenue, and that rule is now written down rather than inferred.** `DashboardController::activePurchases()` carries an explicit `whereNotNull('payable_type')`. It changes nothing today — `whereNotIn('payable_type', […])` already drops a NULL, because SQL's `NULL NOT IN (…)` is UNKNOWN rather than true — and that is exactly why the line exists: the behaviour was an accident of three-valued logic, and the obvious future "fix" (`orWhereNull('payable_type')`) would book every one of those transfers as project-fee revenue. `UnattributedPaymentTest` pins the behaviour, so that edit turns red.

Every other consumer already excludes them for its own reason, and each is worth knowing: `BuildsPayablePayments` scopes by `payable_type` + `payable_id`; `FunnelDashboardService` and `AdPerformanceService` key on `lead_id`; `PayController::status()` scopes by `payment_link_id` + `payment_reference`, so a sender's name can never leak onto the public pay page. There is no purchase export.

**Resolving one is an ordinary `@update`** — the admin picks the buyer and the item, and the existing re-fulfil at `:154-156` grants it. Two things make that safe:

- **`counterparty_name` is its own column, not `title`.** `@update` **rewrites `title`** with the payable's generated name the instant an admin resolves the row — the very request that is supposed to be preserving who sent the money. The column is in the repository's **create** whitelist only: what the provider called the payer is a fact, not an editable field.
- **Amount, currency and date are read-only for these rows** — `PurchaseHistory::READ_ONLY_FACT_PROVIDERS` / `hasProviderStatedFacts()`. The admin's job here is attribution; a retyped figure would silently contradict the record it was read from. Stripe is deliberately **not** in that list — its rows have been editable since the module shipped and locking them is a separate decision.
  ⚠️ **Enforced in TWO places, and both are needed.** `UpdateRequest::withValidator` rejects a *changed* value so the admin is told. But `@update` writes `resolveCurrency()` **unconditionally**, and the edit form need not submit a currency at all — when it does not, that helper falls back to the **payable's** currency, so resolving a MYR 299 transfer against an SGD-priced course would rewrite the statement's own figure to SGD 299. Validation cannot catch a field that was never sent, so the controller refuses the rewrite outright. Both arms are pinned by `UnattributedPaymentTest`, the currency one mutation-verified.
- ⚠️ **A deleted row stays deleted.** `payment_reference` is uniquely indexed **ignoring soft deletes**, so an admin's "this was not a customer payment" survives a re-import of an overlapping statement. Any ingester must pre-check `withTrashed()` — the house idiom is at `HostedLinkImportPreview.php:88-95`.

The row freezes a `title` at purchase time (so the ledger survives renames/deletes), carries `payment_provider` + unique `payment_reference` (the webhook correlation key), provider transaction ids (`provider_payment_id` / `provider_charge_id`, unique per `payment_provider`) for refund matching, and `payment_link_id` when it came through a link.

**`total_amount` is the only figure to read.** The table also carries `price` (per unit) and `quantity`, both inherited from the retired Product catalogue where a row could be "3 × RM 200". Nothing sells by quantity any more: `PurchaseHistoriesController@store`, `CheckoutOpener` and `WebhookProcessor` all stamp `quantity = 1` and `price = total_amount`. ⚠️ `@update` is the exception — it re-stamps `price = total_amount` but **never touches `quantity`**, relying on create having set it to 1. Keep them in step if you write a row by hand.

**Currency** — every row stores the ISO code it was actually charged in (`currency`, NOT NULL, default `MYR`). Amounts are never bare numbers with ringgit assumed: a checkout stamps what it asked for, and **confirmation overwrites it with what the gateway actually settled in** — our value was an intention, theirs is the money that moved. An offline entry falls back to the tier's own currency. Read it through `$purchase->currency_code` / `$purchase->formatted_amount`, and on the frontend through `currencyAmount()` in [utils/formatters.js](/resources/js/utils/formatters.js) — **no payment screen may hardcode `RM`**. Totals are grouped **per currency**, never summed across them — the payable's headline (`summary.collected`), each link's own (`links[].collected`) and the Unreconciled page (`summary.amounts`) all emit a list of `{currency, amount}`, rendered by `currencyAmountList()`. `PurchaseHistory::collectedByLink()` is the shared grouped query behind the per-link figures.

**`provider_charge_id` is now actually written.** It used to be searched by the refund matcher but never populated, because a `checkout.session.*` payload names only the PaymentIntent. It is filled from three directions: free on the backfill lane (the intent is already expanded), by a fail-soft lookup at confirmation (`WebhookProcessor::resolveChargeId()` — one API call, and a failure never blocks the payment), and stamped from the payload when a refund matches a row that lacks it.

> **Still summed across currencies:** the **Sales Dashboard** revenue tiles (`DashboardController::revenue()`) add `total_amount` / `price_paid` with no currency grouping and render them as `RM`. Correct while everything is MYR; it is the one place that would need rework before a second currency goes live. Its per-row *recent sales* list already renders each row in its own currency.

**Statuses** — `Active` (paid) · `Refunded` (refunded/voided — the label the ledger shows) · `Unconfirmed` (checkout opened, not confirmed) · `Expired` (abandoned checkout). The sweep — `purchases:expire-pending`, scheduled **hourly** with `withoutOverlapping` — only expires rows **older than 24h** (`--hours=24`): hourly is how often it looks, not how long a buyer has. The window is Stripe's own — a checkout session dies in 24h, so past that no late webhook can arrive for it and an open intention is genuinely abandoned.

## How it works

- **The buyer picker searches server-side.** Both this page and Payment Items use `ComboBox` against the shared `/manage/leads/search` endpoint (20 results, `LeadVisibility`-filtered, 2-char minimum) — they do **not** ship a lead list as a prop. They briefly did, and `Lead::with('user.profile')->get()` cost **192 MB / 1.6 MB of JSON** at ~10k leads, which blew PHP-FPM's 128 MB limit in production and returned a 500 (invisible in `laravel.log`, because a memory fatal dies before Laravel can log it) while passing locally on a smaller dataset and a 512 MB limit. `PaymentIndexPayloadTest` pins the payload lead-free.
- ⚠️ **Repointing a payment used to grant twice.** Each arm's first guard asks *"did THIS payment already grant THIS payable?"* — scoped to the payable's own id, which is what makes a replay idempotent. Its blind spot is a **correction**: an admin moving a mis-assigned payment from tier A to tier B, where the guard correctly answers "not granted" for B, grants it, and leaves A's subscription ACTIVE. One payment, two memberships. `fulfill()` now runs `revokeSupersededGrants()` first: it cancels a membership / revokes a course access this payment made for something it no longer points at, and **detaches** (never closes) a project engagement — whether a deal is dead is the sales team's call, the same rule refunds follow. It only ever touches rows carrying this purchase's own `purchase_history_id`, so an admin's comp or another payment's grant can never be caught by it. Pinned — and mutation-verified — by `UnattributedPaymentTest`.
- ⚠️ **`PurchaseFulfiller::fulfill()` carries one non-money side effect**: it calls `ReportPurchaseToMetaAction`, which reports the sale to Meta and stamps `purchase_histories.meta_reported_at`. That column is the idempotency guard for the re-run above — an edit of an ACTIVE row re-enters `fulfill`, so without a marker one sale would be reported again on every save. Every gate lives inside the action (ACTIVE only; not already reported) and it never throws at its caller, so a Meta outage cannot cost a buyer the thing they paid for. Note the column is **not in `$fillable`** — it is written with `forceFill`, so adding it to a repository payload silently does nothing.
- `/manage/purchase-histories` is a §14 DataTable index: search (title / buyer / **sender**), status + type (Membership / Project fee / Course) + **Source** filters, create/edit **modal**, delete confirm. Two header buttons: **Sync Touch 'n Go** (beside Record Payment; shown only while the wallet is collecting — it reads the statement mailbox NOW instead of waiting for the hour, and reports even when nothing was found, because "checked, nothing new" and "the button did not work" are otherwise identical) and **Deleted** (the `?deleted=1` view — deleted payments in their own list, each with a **Restore** action; a deleted payment is not money, so it is never mixed into the live list). The **Source** column reads `payment_provider` (`Stripe` / `PayEx / EzBeli` / `Touch 'n Go eWallet` / `Manual`). ⚠️ A provider missing from `PurchaseHistory::PROVIDERS` renders as the grey **Manual** chip — indistinguishable from a hand-typed offline payment, on the one screen whose job is telling them apart. Add the catalogue entry with the constant. The Source filter uses `applyInString`, **not** `applyIn`: the latter intvals its values and would turn `'stripe'` into `0`.
- **Unresolved rows read differently, on purpose.** With no buyer, the Buyer column shows `From <sender>` in amber with a **Needs buyer** chip, and the pencil is titled *"Resolve — pick the buyer and what they bought"* rather than *Edit* — the adjacent 🔗 action already says "Link" and means something else entirely (attach a receipt to an existing entitlement). The delete confirmation for such a row is worded as *"this transfer was not a customer payment"*, because that is what deleting it means.
- **Whether the sale reached Meta is shown on the row** (`meta_report`), with the reason in the tooltip. It is surfaced because in practice it usually does **not** fire and used to do so invisibly: `ReportPurchaseToMetaAction` refuses an `event_time` older than `MAX_EVENT_AGE_DAYS`, and a payment read out of a mailed statement is routinely older than that by the time a human attributes it. The controller's `metaReport()` mirrors the action's gates in order and reads the real constant, so the explanation cannot drift from the rule. Its last arm is a residual — whether an ad actually brought the buyer in costs a query the list will not spend per row.
- **View payment details (the 👁 row action)** — [`PaymentDetailModal`](/resources/js/Components/Payments/PaymentDetailModal.vue), backed by `GET {id}/detail`. One read-only view of a payment: the buyer (linked to their lead), what was bought and what it **granted** (pipeline / membership / course, or an amber "not linked yet" pointing at the 🔗 action), the gateway trail (provider, reference, payment + charge ids, each copyable for pasting into Stripe), and the timeline.
  - **It is mounted by BOTH surfaces that list payments** — this ledger and a payable's own [Payments tab](/resources/js/Components/Payments/PayablePaymentsTab.vue) — so a payment reads identically wherever it is opened.
  - ⚠️ **It takes a UUID and FETCHES; it is never fed a row.** The two lists serialise their rows differently (the ledger carries `kind` / `payable_name` / `provider`, the tab carries `link_title` / `linked_engagement`), so passing a row would show a different set of facts depending on where the reader clicked. Fetching also keeps the gateway ids and references off a 100-row list that never displays them.
  - Gated by the group's own **`view-sales`** — the same door as the page it opens from; every other row action stays behind `manage-sales`. Pinned by `PaymentDetailEndpointTest` (payload, the granted block, and both refusal paths).
- **Manual receipt repair (the 🔗 row action)** — `GrantLinkModal` on the index, backed by `PurchaseGrantLinker` (`GET/POST/DELETE {id}/grant`). Shows what this payment granted; **links** it to an existing subscription / engagement / course access, or **unlinks** a wrong one. Its purpose is the records fulfilment can't reach: a LEGACY entitlement the one-shot backfill left unlinked. Linking a payment to a record — *even a cancelled or deleted one* — marks the payment "already did its work", so a re-import leaves that record exactly as it is: **this is how "I removed that membership on purpose" is made to stick across re-imports.** Three fences: candidates are the purchase's **own lead + own payable only**; a record **owned by another payment is never offered or taken** (re-pointing = unlink there first, deliberately two steps); and the whole lane is **bookkeeping only** — linking/unlinking never enrols, revokes, restores or syncs a role. ⚠️ Unlinking returns the payment to legacy semantics: a later replay may re-grant it (the modal warns). Pinned by `PurchaseGrantLinkTest`.

PayEx / EzBeli stays in `PurchaseHistory::PROVIDERS` because old rows must still render their own label, but the provider is catalogued **not available** (`PROVIDERS['payex']['available'] => false`) and its callback answers **503** — so no new row can arrive through it. See [Payment Gateways](/docs/modules_handbook/manage/payments/gateways/readMe.md) for what unlocks it.
- Manual create maps kind (membership / project / course) + payable + buyer + datetime + amount + **currency** → `PurchaseHistoryRepository::create` (status ACTIVE) → `PurchaseFulfiller::fulfill`. The currency box is optional: what the admin typed wins, else the payable's own currency (`PurchaseHistoriesController::resolveCurrency`) — an offline payment has no gateway to report what it settled in, so the payable is the only other honest source. **Update never touches status** — editing a refunded row must not silently reactivate it — but an update of an ACTIVE row **does** re-run `fulfill`, which is how an admin completes a paid-but-unattributed row by attaching its buyer. That re-run is **idempotent per purchase**: every entitlement records the payment that granted it (`purchase_history_id` on `member_subscriptions` / `engagements` / `lms_course_access`), and fulfilment's first guard asks *"did this payment already grant this?"* — so an ordinary edit of a fulfilled row re-grants nothing, even when the membership it once granted has since lapsed. (Before the link existed the guard could only ask "does the buyer hold it now?", and an edit could silently reinstate a cancelled membership.) Refunds use the same link to revoke exactly the grant their payment made.
- The tier / project / course pickers offer the sellable catalogue **plus whatever the listed rows already reference**, labelled `(inactive)` / `(archived)` / `(not for sale)` — otherwise a payment for a since-retired tier would open its edit modal with an empty payable field and be silently repointed on save.
- Confirmation (`PurchaseHistoryRepository::confirm`) and refund (`::refund`) are driven by the webhook lane described in the Payment Links doc.
- The **Sales Dashboard** reads this table **twice, as two separate streams**. *Project Fees* is `status=Active` with `payable_type` NOT IN (Membership, Course) (`DashboardController::purchaseQuery`); *Courses* is `status=Active` with `payable_type = Course` (`::courseQuery`). Membership money is excluded because it is counted from `member_subscriptions`, so a portal membership purchase is never double-counted; course money is excluded because a course and a project fee are different businesses — one blended figure answers neither "how are the courses doing" nor "how are the projects doing". Both streams are `LeadVisibility`-scoped, so a salesperson only sees their own leads' money.

### Selling a Course

A `Src\Lms\Course` is a payable like any other. What is specific to it:

- **Access mode lives on `lms_courses.visibility`** — one axis, five mutually exclusive states: `Free (1)`, `Members only (2)`, `Internal (3)`, `Members or buy (4)`, `Buy only (5)`. `SELLABLE_VISIBILITIES` = 4 and 5; `MEMBERSHIP_GATED_VISIBILITIES` = 2 and 4. A separate `access_mode` column was rejected: there is no coherent "Internal + priced", so a second column would only create cells the form must forbid, and values 4/5 fit the existing `unsignedInteger` with **zero data migration**.
- **Price on the course** (`price` + `currency`, both nullable) — the `memberships` precedent, which is what makes `PurchaseHistory::currencyOf()` work for a course automatically.
- **A paid course grants a row in `lms_course_access`**, not an inference from `purchase_histories`. That ledger is soft-deletable with four statuses including UNCONFIRMED and EXPIRED, so deriving ownership from it would open a course to anyone who merely *started* a checkout; and it leaves no way to comp a course. Grants are perpetual (`expires_at` exists but is always NULL), revoked on refund, and the row is kept as history.
- ⚠️ **`Course::isAccessibleTo()` fails CLOSED, deliberately.** It used to end on `empty($required) || array_intersect(...)`, which returns TRUE for any active member whenever the membership pivot is empty — and the admin form empties that pivot for every non-Members mode. Shipping a paid mode without the explicit `VISIBILITY_BUY → false` branch would have made every paid course free to every member, silently. `CourseAccessGateTest` exists so that can never ship green again.
- **The portal Buy button opens a fresh checkout**, never an adopted gateway link — a checkout carries our `payment_reference` and is bound to the signed-in lead before any money moves.

## Related files

**Backend**
- `src/Payment/PurchaseHistory.php` + `src/Payment/Repositories/PurchaseHistoryRepository.php` (+ facade)
- `app/Http/Controllers/Manage/Payment/PurchaseHistoriesController.php`
- `app/Http/Requests/Manage/Payment/PurchaseHistories/{StoreRequest,UpdateRequest,QueryRequest,LinkGrantRequest}.php` (`LinkGrantRequest` = the 🔗 receipt-repair post)
- `app/Http/Controllers/Main/Portal/{MembershipController,CheckoutController}.php` — the portal buy lane
- `app/Http/Requests/Main/Portal/Checkout/StoreRequest.php`
- `app/Services/Payment/{CheckoutOpener,PurchaseFulfiller,PurchaseGrantLinker}.php` — checkout open, per-purchase fulfilment/revoke, and the manual receipt-repair lane
- `app/Services/Stripe/WebhookProcessor.php` — confirmation, refund, hosted-link recording (`recordHostedLinkPayment()`) and the fail-soft `resolveChargeId()` lookup
- `app/Console/Commands/ExpirePendingPurchases.php` — the hourly `purchases:expire-pending` sweep
- `src/Payment/PaymentWebhookEvent.php` + `src/Payment/Repositories/PaymentWebhookEventRepository.php` (+ facade) — the shared per-provider inbound idempotency log (a NULL `processed_at` marks money this system could not place)
- `src/Lead/Repositories/LeadRepository.php` — `firstOrCreateForIdentity()` (open-link buyer → lead resolution, on email **and** phone; `resolveUserByIdentity()` is the shared read-only gate the import preview calls)
- `app/Actions/Marketing/ReportPurchaseToMetaAction.php` — the non-money side effect of `fulfill()` (stamps `meta_reported_at`)

**Frontend**
- `resources/js/Pages/Manage/Payment/PurchaseHistories/Index.vue` + `Partials/PurchaseHistoryFormModal.vue` + `Components/Payments/GrantLinkModal.vue` (the 🔗 receipt-repair modal) + [`Components/Payments/PaymentDetailModal.vue`](/resources/js/Components/Payments/PaymentDetailModal.vue) (the 👁 shared detail view, also mounted by `PayablePaymentsTab` and by the Team cell's payment-closing tag)
- `resources/js/Components/Profile/MembershipTab.vue` (the Buy button — the tier grid moved into Profile → Membership on 2026-08-24; `/membership` is now only a redirect, and the props come from `app/Actions/BuildPortalMembershipTiers.php`) + `resources/js/Pages/Main/Portal/Checkout/Result.vue`

**Migrations** (oldest first — mostly `purchase_histories`, plus the two payment-lane migrations it depends on)
- `database/migrations/2026_07_02_130003_create_purchase_histories_table.php` (incl. `price` + `quantity`, kept but effectively frozen — see above)
- `database/migrations/2026_07_02_130004_create_stripe_webhook_events_table.php`
- `database/migrations/2026_07_23_000002_add_payment_fields_to_purchase_histories.php` (`payment_provider` + `payment_reference`)
- `database/migrations/2026_07_28_200002_rework_purchase_histories_for_payables.php` (payable morph + `payment_link_id` + `title`; drops `product_id`)
- `database/migrations/2026_07_29_100002_generalise_payment_provider_columns.php` (`stripe_*_id` → `provider_payment_id` / `provider_charge_id`, unique **per provider**; `stripe_webhook_events` → `payment_webhook_events`)
- `database/migrations/2026_07_29_120001_add_currency_to_purchase_histories.php` (`currency` char(3) NOT NULL default `MYR`)
- `database/migrations/2026_07_29_200002_add_meta_reported_at_to_purchase_histories.php` (the send-once marker for the Meta report; written with `forceFill`, not `$fillable`)
- `database/migrations/2026_07_31_0000{01,02,03}_*` (`purchase_history_id` on `member_subscriptions` / `engagements` + the one-shot conservative backfill — what makes `fulfill()` idempotent per purchase and `revoke()` precise)
- `database/migrations/2026_08_06_300001_add_counterparty_name_to_purchase_histories.php` (who sent the money, in the provider's words — kept out of `title` because `@update` rewrites that on resolve)

**Routes**
- `manage.payment.purchase-histories.*` (`routes/web.php`) — index/store/update/destroy, the read-only `detail` (GET `{id}/detail`, JSON — the 👁 modal's whole payload), plus the receipt-repair trio `grant` (GET, JSON) / `grant-link` (POST) / `grant-unlink` (DELETE) on `{id}/grant` (`view-sales` read / `manage-sales` write). Two additions (2026-08-08): **`restore`** (POST `{id}/restore`) — the only way back from a deletion, because re-reading a wallet statement deliberately never recreates a deleted payment; restoring grants nothing again, and re-pins the wallet line `tng:recheck` released. And **`sync-tng`** (POST `sync-tng`, literal segment — safe today only because no `POST {id}` exists in the group) — reads the Touch 'n Go statement mailbox on demand behind a `Cache::lock`, then runs the same bounded, fault-isolated ingest loop as `tng:ingest-lines` (`TngLedger::ingestNewLines()`)
- `main.portal.checkout.store` (`POST /memberships/{id}/checkout`) + `main.portal.checkout.{return,cancel}` (`routes/main.php`, auth+main)
- `webhooks.stripe.handle` (`routes/main.php`) — public `POST /webhooks/stripe`, CSRF-exempt via the `webhooks/*` exception

**Tests** — `tests/Feature/Payment/*` (checkout, confirm, fulfil, refund, PayEx callback, payment-link pay flow, `PaymentDetailEndpointTest` for the 👁 detail endpoint + its two refusal paths, plus `PaymentCurrencyTest` for the currency rules and `provider_charge_id`, `HostedLinkImportTest` for the queued import, `HostedLinkImportReviewTest` for the review screen + its preview↔import contract, `PurchaseFulfillerTest` for the per-purchase guards, `PurchaseGrantLinkTest` for the 🔗 receipt-repair lane, `PaymentIndexPayloadTest` for the lead-free payload, `PurchaseHistoryFieldsTest` for the stored payable/provider/reference columns, `MembershipBuyLinkTest` for "a tier with no price is not buyable", `UnattributedPaymentTest` for the NULL-payable row (not revenue, still listed, resolvable, read-only facts) **and the repoint double-grant** — its three repoint cases were mutation-verified by disabling the fix and watching them go red; `tests/Feature/Database/EntitlementPurchaseLinkBackfillTest` pins the one-shot backfill's pairing rules). ⚠️ **No test here reaches Stripe.** `Tests\Concerns\ConfiguresPaymentGateways` installs `tests/Support/FakeStripeHttpClient.php` into the SDK globally (`ApiRequestor::setHttpClient`, restored on teardown), because these tests configure a REAL gateway row — so the real `StripeGateway` is what the registry hands out and its lookups were genuinely hitting `api.stripe.com` with a dummy key, swallowed by the callers' try/catch. Stub with `stub()` / `stubSequence()` and assert with `callCount()`; an unstubbed URL gets a plausible default (a PaymentIntent answers with a derived `latest_charge`, list endpoints answer empty) rather than a network call.

**Retired (2026-07-28):** the Product/ProductCategory catalogue, its `stripe_product_id` matching lane, the Stripe backfill sync (`stripe_sync_statuses`), and the purchase CSV import — see `database/migrations/2026_07_28_200003_drop_product_and_stripe_sync_tables.php`.
