# Payment Items

**Portal:** Manage · **Nav:** In the **Operations suite**, Sales hub → **Payment** main tab → **Payment Items** pill ([Components/SalesTabs.vue](/resources/js/Components/SalesTabs.vue)); the Project / Subsale suites keep their standalone **Payment** sidebar entry with the flat `payments` strip (pages pick the strip via [Components/PaymentTabs.vue](/resources/js/Components/PaymentTabs.vue)). Gateway CREDENTIALS are elsewhere — Setting → [Payment Gateways](/docs/modules_handbook/manage/payments/gateways/readMe.md) — because keys are configuration, not sales data.

> **Renamed from “Payment Links” (2026-08-07).** A link was all this page made until it also began choosing the payment METHOD — our own `/pay` page, a gateway-hosted checkout, or a Touch ’n Go transfer the buyer makes by hand. A screen that decides *what is for sale* and *how the money arrives* is not a list of links, and the old name hid half of it.
>
> ⚠️ **The route, the folder and the classes keep the old name on purpose.** `/manage/payment-links` has been sent to people, every tab strip resolves its active tab by that path prefix, and `PaymentLink` is a table other modules key on. Renaming code to chase a label is churn that breaks bookmarks and buys nothing.

## What it does

Collects money for ONE payable — a **Membership tier**, a **Project fee** or a **Course** — through a shareable link, sent to the customer (typically over WhatsApp). A link is hosted one of two ways:

| Mode | Where it lives | When to use |
|---|---|---|
| **In-app** (`MODE_IN_APP`) | our own `/pay/{uuid}` page, which opens a checkout at click time | gateway-portable — the same URL keeps working if the provider changes |
| **Gateway-hosted** (`MODE_PROVIDER`) | at the provider (e.g. `buy.stripe.com/...`), **created** through our UI or **adopted** from one already in the provider's dashboard | keeps the link visible in the provider's own dashboard; the only way to capture links made by hand there |

Membership and Project links are created from the **Payments tab** of that payable's Show page or from the Payment Items index; a **Course** link is created from the index only — a Course Show page has no Payments tab yet, though the payable-scoped endpoint already accepts `courses`. Either way the customer pays with **no login** and the system does the rest automatically:

- **Membership paid** → the buyer is enrolled as a member (`member_subscriptions` + role promotion via `EnrollMemberAction`).
- **Project fee paid** → the buyer's **engagement** for that project is opened at status **NEW** (`EngagementRepository::open`, idempotent on the unique `(lead_id, project_id)`), entering the sales pipeline. An [Activity Log](/docs/modules_handbook/shared/activity-log/readMe.md) entry records the payment on the lead.
- **Course paid** → the buyer gets a standing entitlement to open it (`CourseAccessRepository::grant`, idempotent on `(lead, course)`). The grant is its own row rather than something derived from the payment, so an admin can comp a course without inventing a ledger entry. On refund a course access **is** revoked — the buyer got their money back, so the lessons go with it — where a Project engagement is deliberately left to the sales team.
Every fulfilled purchase — link, portal checkout or backfill — also reports the sale to Meta before the payable's own grant runs (`ReportPurchaseToMetaAction`, with `purchase_histories.meta_reported_at` recording that it went). Every gate lives inside the action and it never throws, so a Meta outage can never cost a customer the thing they paid for. What it sends and why is documented with the Meta/marketing integration, not here.

Links are **reusable** (any number of buyers) unless **locked to a lead**; each successful payment writes its own row in the [Payments ledger](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md). A link can be disabled, given an expiry, and edited; deleting it keeps its recorded payments (but the URL then 404s — disable it instead to keep it readable).

A link stops accepting money when it is disabled, expired, **or its payable has been deleted** (`PaymentLink::isPayable`) — without that last check a live page would keep charging for a tier/project that fulfilment can no longer resolve. The public page never prefills the buyer's email, even on a lead-locked link: the URL is meant to be forwarded, so prefilling would show that person's address to whoever opens it.

## How it works

**Admin side** — `/manage/payment-links` is a standard §14 DataTable index: search + status/type filters, create/edit **modal** (`PaymentLinkFormModal` — a **How** row, a three-way kind toggle Membership / Project fee / Course, the matching picker, title/description, amount prefilled from `memberships.price` or `lms_courses.price`, an optional server-searched lead lock + expiry), row actions View / Copy URL / Open / Enable–Disable / Edit / Delete. Writes go through `PaymentLinkRepository` in `DB::transaction`.

**Each item has its own Show page (2026-09-01)** — `GET /manage/payment-links/{uuid}` (`Pages/Manage/Payment/Links/Show.vue`, §14 ShowTabs): **Buyers** (every purchase, with what the item's automation did per buyer and the item's AI call, plus manual Call now / Send now) and **Automation** (the item's AI caller brain — `payment_links.ai_call_profile_id` — and its WhatsApp rules: when they pay, after the AI call, AI call not answered). Documented in its own module: [Payment Items · Automation](/docs/modules_handbook/manage/payments/payment-automation/readMe.md).

**Collected is a list, not a number.** A link's own `currency` is only what we recorded when it was made; each payment carries what the gateway **actually settled in** — `purchase_histories.currency`, overwritten at confirmation from the session itself, because what Stripe charged outranks what the checkout asked for. So the Collected column comes from `PurchaseHistory::collectedByLink()` as `[{currency, amount}, …]` — one grouped query for the whole page — and renders through `currencyAmountList`. The payable's Payments tab groups its headline the same way. ⚠️ Never simplify this to `withSum('total_amount')`: that adds MYR to SGD and then labels the result with the *link's* code, producing a figure that is true in no currency at all — and a total is exactly where nobody would notice.

**Enable / Disable flips both sides.** Both actions route to `PayableLinksController`, not the index controller, so a gateway-hosted link is turned off at the gateway as well (`deactivateHostedLink()` / `activateHostedLink()`). ⚠️ Our status alone stops nothing on a `buy.stripe.com` URL — the buyer never touches our app — so disabling only here would read *Active: no* while the link kept collecting. When the gateway refuses, exactly ONE flash describes the outcome (*"… was disabled here, but the gateway refused — its own link may still be live"*), never a warning followed by a contradicting success toast.

**Buyer side (public, `routes/main.php`)**
1. `GET /pay/{uuid}` renders `Pages/Pay/Show.vue` (standalone card, no shell). A disabled/expired link renders an "unavailable" state. **A gateway-hosted link redirects to its own gateway URL instead** — and `POST checkout` 410s for one — because our stored amount is a reporting figure that may differ from what the gateway actually charges; one link must never sell at two prices.
2. `POST /pay/{uuid}/checkout` (throttled) → `CheckoutOpener::open()` writes an **UNCONFIRMED** `purchase_histories` row with a fresh `payment_reference`, then redirects to the gateway (`Inertia::location`). A gateway failure deletes the pending row before surfacing — no orphans.
3. The gateway lands the buyer on `GET /pay/{uuid}/return?ref={reference}` (`Pages/Pay/Result.vue`), which **polls** `GET /pay/{uuid}/status` until the webhook confirms — the page never claims "paid" before the server does.
4. The **Stripe webhook** (below) confirms + fulfills. For an **unlocked** link the buyer is resolved into a lead by **both** keys Stripe collected — email *and* phone — through `LeadRepository::firstOrCreateForIdentity` (`WebhookProcessor::resolveBuyer`). The email owner wins a disagreement and the phone is dropped; staff are never fabricated into leads. Resolving on email alone used to split a buyer already known here by phone into a second lead.

**Gateway abstraction** — `Src\Payment\Gateways\PaymentGateway` (bound in `AppServiceProvider` to `GatewayManager::driver()` — the row flagged `payment_gateways.is_default`): `StripeGateway` (Checkout Session, inline `price_data`/`product_data`, our `payment_reference` in `client_reference_id` + metadata) and `PayExGateway` (EzBeli/Xendit — **catalogued but not available**: the driver has never been proved against a live transaction, so `PROVIDERS['payex']['available']` is `false` and every path refuses it — `isUsable()`, `GatewayManager::assertAvailable()`, a 403 on the credential write, and a **503** on its callback. It is listed so its absence is explained rather than mysterious, not so it can be switched on.).

**Stripe webhook** — public `POST /webhooks/stripe` (`StripeWebhookController`): verifies `Stripe-Signature`, drops unsupported types, dedupes on `payment_webhook_events` (unique `event_id`), then **queues** `ProcessStripeWebhook` and answers immediately (a failed dispatch un-claims the dedupe row and 500s so Stripe retries). The job's `WebhookProcessor` handles `checkout.session.completed` / `checkout.session.async_payment_succeeded` (→ `confirmByReference`: match `payment_reference`, gate on `payment_status=paid`, confirm, `PurchaseFulfiller::fulfill`) and `charge.refunded` (full refunds only → purchase INACTIVE + `PurchaseFulfiller::revoke`; a membership is cancelled + role re-synced, a project engagement is deliberately left to the sales team). No Stripe API round-trips and no product matching — every checkout this app opens carries our reference.

**A link made by hand in the gateway's dashboard is captured once it is ADOPTED.** An inbound checkout session names the payment link it came from, so `confirmByHostedLink` matches on that external id, creates the ledger row from the session itself and fulfils it. An **unadopted** link matches nothing: the event is recorded **UNMATCHED with a reason**, payload retained, and surfaces on [Payment → Unreconciled](/docs/modules_handbook/manage/payments/unreconciled/readMe.md) — where one adoption records and grants every payment that link has taken.

Three ordering rules make the lane safe, because **Stripe does not order its events** and the job retries:
- The reference rides on the **PaymentIntent** metadata too (`payment_intent_data`), so a `charge.refunded` that overtakes its own `completed` event still matches — the Stripe ids are only stamped at confirmation.
- A purchase already **INACTIVE** (refunded) is never re-confirmed or re-fulfilled by a late confirm event.
- A **paid** session whose buyer cannot be resolved (no email, or a staff address the identity gate refuses to fabricate a lead for) is still recorded **ACTIVE but unattributed** — the money was taken, so it must never fall through to the abandoned-checkout sweeper. An admin attaches the buyer from the ledger, and that edit fulfils it.

**Abandoned checkouts** — `purchases:expire-pending` (scheduled hourly) flips UNCONFIRMED rows older than 24h to **EXPIRED**.

**Adoption (the lane that rescues dashboard-made links)** — the modal lists the gateway's own links (minus the ones already adopted), binds the chosen one to this payable, stamps our uuid onto it at the gateway (so later refunds trace back, since a Charge carries no link id), and can **import the payments it has already taken** (refunded and disputed ones are imported too, marked **Refunded** and granting nothing — a session's `payment_status` stays `paid` after a refund, so the charge's real state is read). Backfill and the live webhook both route through `recordHostedLinkPayment()` and key on the gateway's session id, so a payment seen twice is recorded once. The ↓ action on the link opens the **review screen** (below) and re-runs it anytime.

⚠️ `(provider, external_id)` is uniquely indexed and that index **ignores soft deletes**, so `adopt()` looks with `withTrashed()` first: re-adopting a link that was deleted here restores that row and re-points it at whatever it now sells, instead of dying on a raw SQL error. For the same reason the picker filters out trashed adoptions — offering one as "fresh" would bait the operator straight into that collision. Stamping our uuid onto the gateway link is **best-effort**: a failure is logged and the adoption still stands, because future payments match on the link id — the tag only improves *refund* matching.

**Config** — none in `.env`. Gateway credentials live encrypted in the database, edited at [Setting → Payment Gateways](/docs/modules_handbook/manage/payments/gateways/readMe.md). A link's currency comes from `PaymentLinkService::currencyFor()`: **what the caller supplied wins, else what the payable is priced in** (`PurchaseHistory::currencyOf()` — a Membership tier's or a Course's own `currency`; `MYR` for a Project, which has none). Only adoption supplies one, and it is the gateway's own code read off the picked link, because that link already exists and already charges in something. ⚠️ The field is optional: adopt without it and the link is recorded in the payable's currency, which may not be what it collects.

### Three ways a link exists, one place to make any of them

| Mode | Where the buyer pays | Who collects |
|---|---|---|
| **In-app** (`mode 1`) | our own `/pay/{uuid}` page, which opens a gateway checkout at click time | the gateway |
| **Gateway-hosted** (`mode 2`) | at the provider (`buy.stripe.com/…`) | the gateway |
| **Manual transfer** (`mode 3`) | our own `/pay/{uuid}` page, which shows **where to send money by hand** | nobody — the buyer transfers it themselves |

**A manual link collects nothing online, and three separate things now enforce that.** It exists for Touch 'n Go, which has no API and no webhook: the page prints the amount and the payee wallet number, the buyer transfers in their own app, and the payment only enters the ledger when the wallet statement is read and a human resolves it.

⚠️ **The failure this shape is defended against is a DOUBLE CHARGE.** `isPayable()` originally knew only about status, expiry and the payable — so a manual link satisfied it, `PayController::checkout()`'s `abort_unless` let the request through, and `CheckoutOpener` resolved the **default gateway**. A customer who had already transferred the money by hand would have been sent to a real Stripe checkout and billed a second time. `isProviderHosted()` does not cover it: that is `mode === MODE_PROVIDER`, and this is a third mode. So:

1. `isPayable()` returns **false** for `MODE_OFFLINE` (`isOpenForTransfer()` is the separate question the public page asks — a manual link is never "payable" yet is very much open for business);
2. `PayController::checkout()` carries its own `abort_if($link->isOffline(), 410)`, stated explicitly so a future change to `isPayable()` cannot quietly reopen it;
3. `PayController::show()` renders `Pages/Pay/Tng.vue`, which has no Pay button to press.

⚠️ **An edit may never change how a link collects**, and that is guarded in four places because the form makes it easy to get wrong: the modal's *How* row is **create-only** and `hydrate()` never restores it, so a saved edit re-submits the form's DEFAULT (`in_app`). Honoured anywhere, that would turn a manual link into a charging one. The four: `submit()` strips `how` from the edit payload; `UpdateRequest` drops the rule entirely; `PaymentLinksController` merges `mode` in **`store()` only**, never in the shared `mapInput()`; and `PaymentLinkRepository::update()` has **no `mode` or `provider` in its whitelist** at all. The one lane that legitimately re-points those columns — re-adopting a soft-deleted gateway link — has its own method, `adoptInto()`. Pinned, and mutation-verified, by `ManualTransferLinkTest`.

⚠️ **`isGatewayRoute` in the modal is an explicit allow-list** (`['stripe','adopt'].includes(form.how)`), not `!== 'in_app'`. It decides whether the form posts to the payable-scoped endpoint, which creates a **real link on the live Stripe account** — so under the old test any new *How* option defaulted straight into charging cards.

### "I've paid" — the customer's claim (2026-08-07)

A manual-transfer page carries a form: **name, email, phone and a MANDATORY receipt** (image or PDF). Submitting writes a `payment_claims` row and **nothing else**.

**Where a claim goes next: [Transfer Receipts](/docs/modules_handbook/manage/payments/transfer-receipts/readMe.md)** — the admin queue where a claim is joined to a wallet-statement line, given a buyer and an item, and only then becomes a sale. Nothing on this public page grants anything.

**Why the form exists at all.** A wallet statement names the sender and the amount and nothing more — it cannot say *which* purchase the money was for, or which CRM record the sender is. The claim supplies exactly that, and the statement supplies what the claim cannot: proof the money actually arrived. **Neither is trusted alone** — a receipt screenshot is trivially forged, and a statement row is anonymous. An admin joins them (Phase C).

**The invariant, and it is the whole design: a claim is EVIDENCE, not money.** Submitting creates no user, no lead, no `purchase_histories` row and no merge request; it reaches no revenue figure and grants nothing. Pinned by `PaymentClaimTest`.

⚠️ **This is the project's only unauthenticated file upload.** Every other `MediaService::storeUpload()` call site sits behind `auth` in Manage. Consequences that are not optional:

- **It writes NO identity, deliberately.** `LeadRepository::adoptEmail()` grafts an email onto an email-less account, and passwordless sign-in looks a user up **by email with no verification check** — so an identity write triggered by an anonymous stranger is an account-takeover primitive, and production holds **211 email-less accounts carrying a phone**. Resolving who the claimant is happens on the admin's screen, at resolve time, through `LeadLinker` — never here.
- **The accepted types are narrower than the admin uploader's** (`jpg,jpeg,png,webp,heic,pdf`, 8 MB): a receipt is a picture or a PDF, so archives, office documents and video have no business arriving on an anonymous endpoint.
- **Bounded three ways** — `throttle:5,1` on the route, a **20-claims-per-link-per-hour** cap in the controller (an IP throttle alone is defeated by a phone's mobile data), and a **10-minute double-tap window** that returns the original reference, because pressing the button twice is one transfer and an admin chasing a phantom payment is worse than a lax dedupe.
  - ⚠️ **The hourly cap is counted INSIDE the same transaction as the insert**, behind a `lockForUpdate()` on the link row. It was a `count()` in the controller followed by an insert in the repository — the textbook check-then-insert race, reproduced at **60 rows against a cap of 20**, every one of the 60 having read a count of zero. A cache counter is not a fix either: `CACHE_DRIVER=file`, and `FileStore::increment()` is an unlocked read-modify-write that races identically. The lock deliberately does **not** span the upload — it is released with the row, long before the bytes go to Google.
  - ⚠️ **A capped submission returns the buyer to the pay page with a `busy` prop, never `redirect()->back()` with a flash.** This page mounts no `FlashToast` (the only one is in `AppShell`, the Manage layout), so the flash was invisible and `back()` followed the `Referer` — a buyer who **had already sent the money** watched the form quietly empty itself somewhere else and would either resubmit or assume it worked. The page says plainly that nothing was saved and not to transfer again.
  - ⚠️ **The double-tap window keys on the SESSION, never the email**, and that is a security property rather than a detail. An email-keyed dedupe was reproduced doing two things — the matching address is sitting on the same WhatsApp thread the link was forwarded on. It handed a stranger the buyer's **reference** (confirming that buyer had paid — the one leak this page must not have); and, worse, a claim pre-seeded on the buyer's address **silently swallowed the buyer's real submission**: their genuine receipt was never stored and they were told it had been received. Evidence suppression, executed by anyone who knows an email address. A session cookie is not proof of identity and does not need to be — it only has to be something the *other* person does not have. Only a claim still **AWAITING** is reusable, so a resubmission an admin explicitly asked for is never dropped. All three are pinned, and mutation-verified, by `PaymentClaimTest`.
- **The page reveals nothing about earlier claims.** The submitter's own next render carries their reference and only that; a forwarded link shows a fresh form.

**The phone is stored twice on purpose.** `phone_raw` is exactly what the customer typed — the evidence an admin reads, and a mistyped number has to stay visible — while `phone` holds canonical digits, or NULL when unparseable. ⚠️ Canonicalising *in place* (in `prepareForValidation`) would return null for an unparseable value, which then fails the `required` rule: the customer is told the phone field is empty on a field they filled in. `StoreRequest::canonicalPhone()` computes it separately for exactly that reason.

**The upload sits OUTSIDE the database transaction** (`PaymentClaimRepository::submit()`), the house rule for `MediaService`: a rolled-back transaction cannot un-write a bucket object. Order is row → object → link, and a failure at the object step `forceDelete`s the row it just made — the receipt is mandatory, so a claim that could not keep one is worse than no claim (the customer would be told it was received and an admin would have nothing to check).

**A resolved claim's receipt is destroyed with its person.** `payment_claims.lead_id` is classified `PURGE_DELETE_BY_LEAD` in `IdentityChildMap`, and `LeadRepository::purgeStorage()` carries a matching `morphedMediaIds(PaymentClaim::class, …)` leg — without it the row would be deleted and the **image** would survive in the bucket carrying a payer's name, bank details and amount, which is worse than not purging at all. ⚠️ An *unresolved* claim has `lead_id = NULL` and so belongs to nobody: a per-person purge cannot reach it. That is a retention question, not a purge one, and there is no retention rule today — by decision, claims never expire.

**Staff are told** through `payment.claim_submitted` (`config/notify.php`). ⚠️ The message carries the **amount, the item and the reference — never the buyer's name, email or phone**: the event can be routed to a shared Telegram group, and every notification body is stored in `notify_deliveries` in plain text with nothing pruning it. The person's details stay on the admin screen, one click away.

**The payee wallet number lives in `tng_settings`**, not in `payment_gateways`: Touch 'n Go opens no checkout, so a row in the driver registry would need a `DRIVERS` entry (which fatals without one), could be elected `is_default` for real card checkouts, and would be read by the System Health payments check. Its public columns (`wallet_number`, `wallet_display_name`, `instructions`) are deliberately **plain**, because `/pay/{uuid}` is anonymous and forwardable and must render them without reaching for a credential accessor; the encrypted `credentials` blob holds the statement mailbox and PDF password. ⚠️ Those two are **the same value** — the owner's mobile is both the payee number we print publicly and the statement PDF's password — so that password is not a security boundary for us.

**Where it is configured: Setting → Payment Gateways, the Touch 'n Go card.** It shares that screen with Stripe because that page is *"the credentials this system uses to collect money"*, which is exactly what an operator goes there looking for — but it is **not** a `payment_gateways` row (see above), and its own route is `PUT payment-gateways/touch-n-go`, declared **before** `PUT {provider}` or the literal segment is swallowed as a provider name and answers 404.
⚠️ This form did not exist until 2026-08-07, and nothing failed to say so: the New-payment-link modal told operators to set the wallet *"under Setting → Touch 'n Go"* — a page that was never built — while the pay page quietly showed buyers no transfer details. **A feature can be complete, tested and documented and still be unusable because the one form that turns it on was never written.** The modal's warning now links to the real place and only appears when the wallet genuinely is not ready (`tngReady`), because a warning that fires when everything is fine teaches the reader to ignore it.

**`TngSetting::canReceive()` is the KILL SWITCH, and every public surface must ask it.** It means *switched on **and** we know the number*, both halves together — a caller that had to remember to check two things would eventually check one. It was originally written and then **never called anywhere**, so switching Touch 'n Go off (wallet changed, wallet compromised, the owner travelling) left a live public page printing the wallet number and a live endpoint booking claims against it; the only way to actually stop collecting was to blank the number or disable every link by hand. A control that exists in the UI and does nothing is worse than no control at all. It is now asked in two places, and both are load-bearing: `PayController::show()` withholds the `payee` prop, and `PayClaimController::store()` **410s** — the endpoint is reachable by anyone holding the URL long after the page that offered it was rendered, so gating only the page gates nothing.
⚠️ It gates the **payee**, never `open`. `open` is the *link's* state; telling a buyer "this payment link is no longer active" when the link is perfectly fine and our wallet is off is a lie that sends them hunting for a new link. Withholding the payee lands them on the page's honest branch — *"payment details are not available yet, contact the person who sent you this link"* — which is true whether the wallet was never configured or was deliberately switched off, and does not tell a stranger which.

The older two modes:

The **Payment Items index** can now produce all three outcomes, not just the first: its New-payment-link modal opens with a **How** row — *Our own page* / *Create at Stripe* / *Use an existing one*. The two gateway routes post to the payable-scoped endpoint (`POST manage/payables/{type}/{id}/payment-links`) rather than reimplementing create/adopt, so adoption, link tagging and the queued history import all behave identically to the payable's own Payments tab. The gateway buttons are disabled, with a reason, when no default gateway supports hosted links (`gateway` prop from `PaymentLinksController::index`). **Edit never shows the How row** — an in-app link cannot become a gateway one, or the reverse.

### Importing a link's existing payments

Adoption only binds the link; the import that pulls the payments it already took runs on **two lanes**. The ↓ action on a link opens the **review screen** (below) and then runs `backfill()` **inline in the request** — ⚠️ bounded by PHP's execution limit, so a first import of a very long-lived link may need the queued lane instead: ticking *"Import its past payments straight away, without reviewing them"* at adoption dispatches **`ImportHostedLinkPayments`**. That checkbox is **off by default** — an unreviewed import is exactly how a duplicate person gets made silently. Three things about the gateway read are load-bearing, all learned the hard way — adopting a link with 200+ payments once imported **13**:

- **Ask the gateway for COMPLETED sessions only** (`status => complete`). A link live for months holds one checkout session per *click*, and most are abandoned: on the real Elite link, **86 of the newest 100 sessions were `unpaid`**. Fetching everything and filtering locally spent the whole page on junk.
- **Follow `has_more`** (`autoPagingIterator`). It used to read one page and stop, silently capping every import.
- **Import refunded payments too, marked as such.** They used to be skipped, which kept the grants right but made the ledger unreconcilable: for a real link Stripe counted **271** and we counted **266**, and the five that differed appeared nowhere — "correctly excluded" and "silently lost" looked identical. A returned payment is now written with status **Refunded**, grants nothing, and is excluded from Collected but shown in its own Refunded figure. Re-running the import also **reconciles**: a payment refunded before the link was adopted reached us by no other route, so a re-run voids the row and withdraws what it granted.

The refund check **fails closed**: if the charge behind a session cannot be read, the payment is treated as returned and skipped, with a warning in the log. Importing a refunded payment re-grants what was already revoked; skipping a good one is a visible, correctable omission. ⚠️ So a run against a flaky gateway imports *fewer* rows, not wrong ones — read the log before concluding a link never took those payments.
- **Expand `data.payment_intent.latest_charge` in the list.** The refund check needs the charge (a session's `payment_status` stays `paid` forever after a refund), and fetching it per session was one API round-trip *each*.
- **Stop at a ceiling.** `MAX_HOSTED_LINK_PAYMENTS` (1000) bounds one import, and is the default `$limit` of `backfill()` — high enough that no real link reaches it, low enough that a runaway can never walk a whole gateway account. Hitting it is **logged**, never silent, because a silently truncated import is the exact failure this whole section exists to prevent.

It is **queued** because a link's history can be hundreds of payments, each creating a lead, a ledger row and a membership — a request-bound import would hit PHP's execution limit, commit half, and report nothing. Re-running is safe: every payment keys on the gateway's session id. `backfill()` returns `{read, recorded, failed}` — **recorded is what was written**, not what the gateway offered — and a payment that cannot be imported is logged and skipped rather than aborting the run. Pinned by `HostedLinkImportTest`.

⚠️ …and a payment whose ledger row an admin **deleted** counts as seen: `payment_reference` is uniquely indexed ignoring soft deletes, so a trashed match means *"seen, and deliberately removed"* and is left alone. A re-run will not resurrect a row you removed on purpose — and could not, without the insert colliding forever and the link silently ceasing to ingest anything at all.

The job retries **3×** with a 2-minute backoff and a 15-minute timeout, and re-reads the link by id when it runs — a link deleted in the meantime is skipped with a warning, not an error. ⚠️ Its `{read, recorded, failed}` outcome only reaches the **log**: the adopting admin sees an optimistic *"importing in the background"* flash. Only the ↓ re-run reports those three numbers on screen, so that is the action to use when you need to see what an import did.

### Review before importing (`HostedLinkImportPreview`)

The import **creates leads and grants memberships** from data nobody here has seen. Run blind, *"6 new customers"* and *"6 duplicates of people already in the CRM"* are indistinguishable afterwards — and a duplicate person is expensive to undo, because the payment, the membership and the activity trail all end up on the wrong lead. So the ↓ action opens a **dry run** first: `POST {id}/backfill/preview` → `HostedLinkImportPreview::build()`, which reads the gateway and predicts every outcome **without writing anything**, and `PaymentImportModal.vue` renders it.

Each payment is classified against the **same identity gate the import itself uses**, so the preview can never promise a lead the import would not produce:

| Outcome | Means |
| --- | --- |
| `new_lead` | Neither key matches — a lead + sign-in account is created, then the purchase is granted |
| `existing_lead` | The email **or the phone** matched somebody here; the payment files under them, no duplicate |
| `conflict` | The email is on one account and the phone on **another** — one person, two accounts |
| `staff` | The email belongs to an admin; staff are never fabricated into customer leads, so the payment lands unattributed |
| `no_identity` | The gateway collected neither key |
| `duplicate` | Already in the ledger (keyed on the gateway session id) — not written twice |

Three things this screen gets right that a bare confirm dialog cannot:

- **The headline counts PEOPLE, not rows.** A buyer with four payments is *one* lead; counting rows would promise four new customers and deliver one. `leads_new` / `leads_existing` are deduped on the identity key.
- **A re-run is a reconciliation pass, and says so.** A payment refunded at the gateway *after* it was imported reached us by no other route, so `will_void` names exactly how many rows this run would void and withdraw the grant from — otherwise a re-run with nothing new looks like a no-op.
- **`importable`, not `total`, is what the button promises** — duplicates are excluded, so the count on the confirm button is what will actually be written.

**The phone is a second key — with a trust boundary.** `StripeGateway` carries `customer_details.phone` through (and `WebhookProcessor` does the same on the live lane, or the same person would land on two leads depending on which route saw them first). But a checkout phone is **self-typed, never proven**, so before it reaches `firstOrCreateForIdentity` it passes `LeadRepository::selfTypedPhoneForGate` — the same rule the public capture form and LeadLinker's `TRUST_UNVERIFIED` level apply: it may resolve a person only as a *fallback* (the email resolves nobody), and then only a record **with no email of its own**. A buyer already here as a **phone-only** lead is therefore recognised and gains the email, instead of being duplicated on email alone — while a number owned by an **email-holding** customer is structurally withheld: the payment can never bind that account on a typed digit (a mistyped number would file the money and the membership under the wrong human). In that withheld shape the buyer gets their own lead, the number stays where it is, and a **pending merge request** is filed automatically — the maybe-same-human question goes to a person, exactly like the capture form's row 5. Two consequences the preview mirrors exactly (intra-batch too — it simulates the phones the run itself will store): two *different* emails sharing one phone become **two leads + one merge request**, and a bare customer-owned phone with no email leaves the payment **unattributed**. See [Users · Leads · Merge](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) §9 and [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md).

#### The two decisions the gate cannot make alone

Both are optional, both default to **change nothing**, and both are carried by `decisions` — a JSON object keyed by the gateway session id, whitelisted in `BackfillRequest::decisions()` and threaded `backfill()` → `recordHostedLinkPayment($link, $payment, $decision)`. An unreviewed run (the queued adoption lane, the live webhook) passes an empty set, which is the old behaviour exactly.

1. **Whose details win.** When the gateway holds a different `name` or `phone` for a matched person, the row is listed and the **CRM keeps its values** unless the admin ticks the field — a checkout form is typed in a hurry and is not automatically the better record. Adopting writes through `UserRepository::updateProfile()`. Two guards: a phone that **already belongs to another account** is never taken (one real number on two profiles is the duplicate this screen exists to prevent, and the phone is a sign-in key), and a changed phone **loses its Verified stamp** via the model hook — a number typed at a checkout was never proven by its owner. **Email is never adoptable**: it is the account's identity, and changing it belongs on the Lead page.
2. **One person, two accounts.** The identity gate resolves a `conflict` by giving the payment to the **email owner** and silently dropping the phone — correct, but it means nothing else will ever notice the duplicate. Ticking *"Queue these two for merging"* calls `LeadRepository::detectMergePair()` + `createMergeRequest()`, filing a **PENDING** `verified_identity_pairs` row for [Setting → Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md). ⚠️ **It never merges on the spot.** A gateway checkout *collects* typed values, it does not *prove* them, so nothing here authorises retiring an account — a human picks the survivor. The pair is filed **before** the gate runs (the gate drops the phone, after which there is nothing left to detect) and is fail-soft: a bookkeeping failure logs and the payment still lands. Rows already queued show *"Already queued"* instead of a checkbox, and `createMergeRequest` is a `firstOrCreate` on the pair, so a re-run can never spam the queue.

#### "Already imported" and re-granting — fixed structurally, with a legacy window ⚠️

A duplicate row writes no second ledger row — but the import still **re-runs fulfilment** on it (`WebhookProcessor::recordHostedLinkPayment` → `PurchaseFulfiller::fulfill`). Every entitlement now records **which payment granted it** (`purchase_history_id` on `member_subscriptions`, `engagements` and `lms_course_access`), and fulfilment's first guard asks *"did **this payment** already grant **this payable** to **this lead**?"* — so a linked payment replayed any number of times grants nothing, however its membership ended in between, and a closed engagement stays closed. Refunds revoke **precisely**: only the subscription/access the refunded payment created (the legacy fallback only ever touches UNLINKED rows, never one another payment owns).

**The legacy window**: a payment fulfilled *before* linking existed has `null` — fulfilment falls back to the old state question (*"does the buyer hold it now?"*), which **can** reinstate a lapsed membership. The one-shot backfill (`2026_07_31_000003_backfill_entitlement_purchase_links`) linked every unambiguous historical pair; whatever stayed null keeps the old behaviour, and the preview's `will_regrant` flag (mirroring the guards exactly) names those rows before the admin confirms. The population only shrinks: every new fulfilment writes its link, and guard (b) even self-heals — re-fulfilling over an unlinked ACTIVE grant attaches the receipt instead of creating anything.

#### Known limits of the prediction

The preview reads the CRM **as it stands** and the import **changes it as it goes**, so a few narrow cases are approximations rather than promises. They are listed here so nobody rediscovers them as bugs:

- **A link locked to a hard-deleted lead** (`payment_links.lead_id` pointing at a removed `leads` row — leads have no soft delete and there is no schema FK) previews as `existing_lead`; the import finds nothing.
- **Adopting a phone can be silently refused.** The preview offers the field; `LeadRepository::phoneOwnedByAnother` uses the *tolerant* matcher and may find a third profile the strict comparison missed, in which case the tick does nothing. Documented in that method's own comment.
- **A second payment for a tier the lead already holds is never linked** (the first grant keeps its receipt), so if the membership later lapses, replaying that second payment re-grants via the legacy fallback — arguably right (real money, undelivered), and the preview flags it honestly.
- **A legacy unlinked Project payment can still restore a closed engagement** once (the restore then self-heals the link via `open()`). Not predicted on screen — the case self-extinguishes and predicting it would cost a per-row trashed-engagement query. `will_regrant` covers the **Membership** arm only.
- **`will_regrant` over-warns for a non-enrollable buyer.** Fulfilment runs `ensureMainUser()` — a WRITE — before `canEnroll()`, so a read-only prediction cannot model that step: a buyer with no main-portal account is flagged, and the import then grants nothing. Over-warning, never under-warning; the delegation to `PurchaseFulfiller::wouldRegrantMembership()` keeps the rest exact.
- Everything else — who each payment lands on, the confirm-button count, the "N new leads" headline — is pinned by the contract tests below.

Pinned by `HostedLinkImportReviewTest` (19 tests). Eleven cover the prediction, the self-typed-phone trust rule and the two admin decisions; the rest are **the contract** — they run the preview and then the REAL import over the same gateway feed and assert the two agree, so editing either side alone turns them red. That pairing is deliberate: the preview and the import are separate code (the import's write half is shared with the live webhook lane and cannot adopt the import's "swallow one failure, keep going" semantics), so they share the *rules* — `LeadRepository::resolveUserByIdentity` is **called**, never re-implemented — while the contract tests hold the *predictions* honest. The per-purchase guards themselves are pinned by `PurchaseFulfillerTest` (18 tests).

## Related files

**Backend**
- `src/Payment/PaymentLink.php` (modes, `shareUrl()`, `isPayable()`, `isOffline()` / `isOpenForTransfer()` / `isExpired()`, `aiCallProfile()` / `automations()`) + `src/Payment/Repositories/PaymentLinkRepository.php` (+ facade; `update()` deliberately cannot write `mode`/`provider` — `adoptInto()` is the one lane that may; it does write `ai_call_profile_id`, the item's AI caller brain)
- The item's Show page, its automation rules and the per-buyer actions — see [Payment Items · Automation](/docs/modules_handbook/manage/payments/payment-automation/readMe.md) for their files
- `src/Payment/TngSetting.php` + `src/Payment/Repositories/TngSettingRepository.php` (+ facade) — the payee wallet number (public columns) and, from Phase 3, the statement mailbox + PDF password (encrypted blob)
- `src/Payment/PaymentClaim.php` + `src/Payment/Repositories/PaymentClaimRepository.php` (+ facade) — the customer's "I've paid" record; `submit()` keeps the upload outside the transaction, `purge()` is the only path that destroys the receipt's bucket object
- `app/Http/Controllers/Main/Payment/PayClaimController.php` + `app/Http/Requests/Main/Payment/Claims/StoreRequest.php` — the public form. A separate controller from `PayController` on purpose: that one opens a gateway session and charges a card
- `src/Lead/Support/IdentityChildMap.php` (`payment_claims.lead_id`) + `LeadRepository::purgeStorage()` — what destroys a resolved claim's receipt image with its person
- `app/Services/Payment/PaymentLinkService.php` — create / adopt / backfill / adoptable-list (the only place the gateway SDK is touched for links)
- `app/Services/Payment/HostedLinkImportPreview.php` — the dry run behind the review screen (**writes nothing**)
- `app/Http/Controllers/Manage/Payment/PayableLinksController.php` — the Payments-tab actions (`backfillPreview` + `backfill`)
- `app/Http/Requests/Manage/Payment/PaymentLinks/HostedStoreRequest.php`, `BackfillRequest.php` (whitelists the review screen's decisions)
- `app/Http/Controllers/Concerns/BuildsPayablePayments.php` — the Payments-tab payload shared by both Show pages
- `src/Payment/PurchaseHistory.php` — the ledger row (payable morph, statuses incl. UNCONFIRMED/EXPIRED, providers)
- `src/Payment/Contracts/Purchasable.php` — marker on `Src\Membership\Membership` + `Src\Property\Project` + `Src\Lms\Course`
- `src/Payment/Gateways/{PaymentGateway,PaymentContext,StripeGateway,PayExGateway}.php` (+ binding in `app/Providers/AppServiceProvider.php`)
- `app/Services/Payment/CheckoutOpener.php` — opens a checkout (pending row + gateway URL)
- `app/Services/Payment/PurchaseFulfiller.php` — grants/revokes what a paid purchase represents
- `app/Services/Stripe/WebhookProcessor.php` + `app/Jobs/Stripe/ProcessStripeWebhook.php`
- `app/Http/Controllers/Main/Payment/PayController.php` — the public pay pages
- `app/Http/Controllers/Manage/Payment/PaymentLinksController.php`
- `app/Http/Controllers/Webhooks/{StripeWebhookController,PayExCallbackController}.php`
- `app/Http/Requests/Manage/Payment/PaymentLinks/{StoreRequest,UpdateRequest,QueryRequest}.php`
- `app/Console/Commands/ExpirePendingPurchases.php` (scheduled in `app/Console/Kernel.php`)

**Frontend**
- `resources/js/Pages/Manage/Payment/Links/Index.vue` + `Partials/PaymentLinkFormModal.vue`
- `resources/js/Components/Payments/PaymentImportModal.vue` — the review screen behind the ↓ action
- `resources/js/Components/Payments/{PayablePaymentsTab,HostedLinkModal}.vue` — the payable's Payments tab and its create/adopt modal (both mount the ↓ action)
- `resources/js/Pages/Pay/Show.vue` + `resources/js/Pages/Pay/Result.vue` (public) + `resources/js/Pages/Pay/Tng.vue` (public, manual transfer — a separate page precisely so no Pay button exists on it; carries the transfer instructions, the "I've paid" form and the submitted state)

**Migrations**
- `database/migrations/2026_07_28_200001_create_payment_links_table.php`
- `database/migrations/2026_07_28_200002_rework_purchase_histories_for_payables.php`
- `database/migrations/2026_07_28_200003_drop_product_and_stripe_sync_tables.php` (retires the old Product wrapper + Stripe backfill sync)
- `database/migrations/2026_07_29_100003_add_provider_link_fields_to_payment_links.php` (`provider` / `mode` / `external_id` / `external_url` + the unique `(provider, external_id)` — the match key for inbound payments on created/adopted links)
- `database/migrations/2026_07_29_120001_add_currency_to_purchase_histories.php` (`char(3)` NOT NULL default MYR — what every Collected total is grouped by)
- `database/migrations/2026_07_30_000001_add_pricing_to_lms_courses.php` (a course's own `price` + `currency`, which is what makes a Course link record the right currency)
- `database/migrations/2026_08_06_300002_create_tng_settings_table.php` (the payee wallet number + the encrypted mailbox/PDF credentials — **not** a `payment_gateways` row, see above)
- `database/migrations/2026_08_07_300001_create_payment_claims_table.php` (what a customer says they transferred, with a receipt; `lead_id` and `purchase_history_id` stay NULL until an admin resolves it)
- `database/migrations/2026_07_31_0000{01,02}_add_purchase_history_id_to_{member_subscriptions,engagements}_table.php` + `2026_07_31_000003_backfill_entitlement_purchase_links.php` (the purchase→entitlement link behind per-purchase idempotent fulfilment; the backfill links only unambiguous historical pairs — pinned by `tests/Feature/Database/EntitlementPurchaseLinkBackfillTest`)

**Routes**
- `manage.payment.payment-links.*` (`routes/web.php`, `view-sales` read / `manage-sales` write)
- `main.pay.{show,checkout,claim,return,cancel,status}` (`routes/main.php`, public; `claim` is `throttle:5,1` and is the only unauthenticated upload in the app)
- `webhooks.stripe.handle` + `webhooks.payex.handle` (`routes/main.php`, public, CSRF-exempt via `webhooks/*`)

**Tests** — `tests/Feature/Payment/{PaymentLinkPayTest,ManualTransferLinkTest,PaymentClaimTest,CheckoutEndpointTest,StripeFulfillmentTest,RefundTest,PayExCallbackTest,PurchaseFulfillerTest,PurchaseGrantLinkTest,UnattributedPaymentTest,HostedLinkAdoptionTest,HostedLinkImportTest,HostedLinkImportReviewTest,PaymentCurrencyTest,PaymentIndexPayloadTest,PurchaseConfirmTest,PurchaseHistoryFieldsTest,MembershipBuyLinkTest,UnreconciledTest}.php`, `tests/Unit/Payment/*`. ⚠️ The suite must never reach Stripe: `tests/Support/FakeStripeHttpClient.php` pins the SDK in-process, so a new payment test that builds its own `StripeClient` is a test that will one day hit `api.stripe.com` from CI.

See also: [Payments (ledger)](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md) · [Memberships](/docs/modules_handbook/manage/membership/memberships/readMe.md).
