# Transfer Receipts

**Portal:** Manage · **Nav:** Sales hub → **Payment** main tab → **Transfer Receipts** pill (Operations suite; Project / Subsale suites reach it from their standalone Payment entry, [Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue) `payments` section) · **Permission:** `view-sales` (read) / `manage-sales` (resolve, discard, delete)

## What it does

Turns a customer's **"I've paid"** into a real sale — and is the only thing that can.

Touch 'n Go has no API, no webhook and no merchant account (the owner uses a **personal** wallet, deliberately: the whole point of this lane is that nothing had to be applied for). So there is no moment where the system learns that money arrived. Two half-facts exist instead, and neither is sufficient:

| | Comes from | Says | Forgeable? |
|---|---|---|---|
| The **claim** | the public form on `/pay/{uuid}` | *which* purchase, and *who* says they paid | **Yes** — anyone with the forwarded link |
| The **[wallet statement](/docs/modules_handbook/manage/payments/wallet-statements/readMe.md)** | the owner's own Touch 'n Go email | that the money genuinely **arrived**, and the sender's name | No |

This screen is where a human joins them. Until they do, a claim is **evidence, not money**: it grants nothing, creates nobody, and appears in no revenue figure.

> **"Zero application" and "automatic confirmation" cannot both be true.** Every design that keeps the first must put a person at the join. This screen is that person's desk — so it is built to make their two judgements explicit rather than to make them fast.

## How it works

### The queue

One row per claim, listing **what the customer typed** — and labelled exactly that, because the column is a claim about themselves, not a CRM record. Three statuses (`PaymentClaim::STATUSES`):

| Status | Meaning |
|---|---|
| `AWAITING` (1) | Nobody has looked. The only status that can be resolved or discarded. |
| `MATCHED` (2) | An admin named a buyer and an item; a ledger row exists and the product was granted. |
| `DISCARDED` (3) | An admin said this was not a genuine payment for this item. **The row survives** — "we said no" is a different fact from "nobody ever claimed it", and only one of them survives a delete. A repeat submitter has to stay visible. |

There is **no expiry**. An unresolved claim waits forever by design: it is approved, discarded, or left pending, and a clock deciding on its own is none of those.

### Resolving — the two questions

`PaymentClaimsController::resolve()` asks the admin for exactly two things the customer cannot be trusted to answer for themselves: **who** bought it (a lead) and **what** they bought (a payable). Everything else is prefilled from the claim and stays editable, because the figure that counts is what actually landed in the wallet — a customer can mistype, or transfer short.

Then, in this order:

1. `PurchaseHistoryRepository::create(...)` — a real ledger row, `payment_provider = tng`, `payment_reference` = the claim's reference.
2. `PurchaseFulfiller::fulfill($purchase)` — the membership starts, the course opens, the project engagement is created. **The same convergence point every other lane uses**, so an offline buyer is not a second, divergent way of granting anything.
3. `PaymentClaimRepository::match(...)` — only now is the claim marked.

⚠️ **The order is load-bearing.** Marking the claim first and then failing would hide a receipt nobody ever acted on — the one failure this queue exists to prevent.

⚠️ **Resolving twice is refused on the SERVER** (`isAwaiting()`). The queue is shared and the modal can sit open on two screens; a disabled button is not a guard. Without it, one claim files the payment twice and grants the product twice.

### The sender's name is kept forever

`counterparty_name` on the ledger row carries the customer's own words, unedited, permanently. It is the **only** handle tying that money back to a line on a wallet statement — and the statement is the only proof it ever arrived. Nothing may normalise, replace or drop it.

For the same reason the row's **amount, date and currency become read-only** once filed (`PurchaseHistory::READ_ONLY_FACT_PROVIDERS`). They are facts a human read off a wallet, not figures we chose.

### Identity — the hint that is *only* a hint

The claim carries a typed email and a typed phone. They are shown beside the picker and they **never** select anybody.

That is not caution, it is the [lead-linking](/docs/modules_handbook/shared/lead-linking/readMe.md) handbook's one unbreakable rule: an **unverified phone is structurally withheld** from the identity gate, never merely "checked first" — two account takeovers were reproduced on live code from exactly that shortcut. An anonymous form is the least trusted source there is, so it may *enrich* a person and must never *resolve* one.

What the hint does do is surface the interesting case **before** the admin chooses rather than after: `LeadRepository::detectMergePair($email, $phone)` returning non-null means the typed email points at one person and the typed phone at another. The queue shows a red chip; the modal offers both, one click each — which is a human confirming a suggestion, the same authority as typing the email by hand.

⚠️ On resolve, a split identity **files a PENDING merge request** ([Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md)) and stops. Never `mergeVerifiedPair()` — merging retires a sign-in key, and it is authorised by proof (dual-OTP at `/register`) or an explicit confirm on the merge queue. A payment being resolved is not that authority. The filing is fail-soft: a correctly resolved receipt must not be reported as failed because a duplicate could not be filed for later.

### Deleting vs discarding

- **Discard** — "not a payment for this item". Row kept, marked, nothing granted.
- **Delete** — destroys the row **and the uploaded image** (`PaymentClaimRepository::purge()` → `MediaService::delete()`, the only path that removes the bucket object; dropping the row alone leaves a customer's bank screenshot in GCS forever).

⚠️ **A MATCHED claim cannot be deleted.** It is the evidence behind a real sale, and deleting it leaves a granted membership with nothing standing behind it. Discard first if it was resolved in error. The button is hidden *and* the server refuses.

Deletion is `Log::warning`-audited **before** the fact, because afterwards there is nothing left to read.

### The receipt image

`GET {id}/receipt` redirects to a **short-lived signed URL**, never a stored one. The bucket is private, and a durable link to a customer's bank screenshot must not outlive the session that needed it. The page uses a plain `<a>` rather than Inertia's `<Link>` (GUIDELINES §13 — it leaves the app).

### Retention — deliberately not yet built

`payment_claims.lead_id` is NULL until an admin resolves, so an unresolved claim is unreachable by the lead purge ([`IdentityChildMap`](/src/Lead/Support/IdentityChildMap.php) says so itself: *"a retention question, not a purge one"*). `PaymentClaimRepository::purge()` exists and is correct; **no scheduled sweep calls it**. That is a decision waiting to be made (how long does an ignored receipt live?), not an oversight — model it on `rental-estimate:maintain --prune` when the answer exists.

An **approved** receipt is different and settled: it stays forever, as the evidence behind a payment.

## ⚠️ One transfer, one payment

Resolving a receipt no longer always creates a ledger row. When the wallet statement has already been read, the admin picks the matching line and this screen ATTACHES to the payment that already exists — see **[Phase E — the convergence rule](/docs/modules_handbook/manage/payments/phase-e-convergence.md)**, which also documents the duplicate-receipt refusal that stops one RM299 transfer being recorded as RM598.

## Related files

- [app/Http/Controllers/Manage/Payment/PaymentClaimsController.php](/app/Http/Controllers/Manage/Payment/PaymentClaimsController.php) — the queue, resolve, discard, delete, signed receipt URL, and the read-only identity hint
- [app/Http/Requests/Manage/Payment/PaymentClaims/ResolveRequest.php](/app/Http/Requests/Manage/Payment/PaymentClaims/ResolveRequest.php) — every field here is the ADMIN's answer, never the customer's
- [app/Http/Requests/Manage/Payment/PaymentClaims/QueryRequest.php](/app/Http/Requests/Manage/Payment/PaymentClaims/QueryRequest.php) — searches what the customer typed, because there is no CRM record yet
- [app/Http/Controllers/Concerns/ResolvesPayable.php](/app/Http/Controllers/Concerns/ResolvesPayable.php) — shared with the ledger's own *Record offline payment*, so one purchase can never be frozen under two different titles
- [src/Payment/PaymentClaim.php](/src/Payment/PaymentClaim.php) · [src/Payment/Repositories/PaymentClaimRepository.php](/src/Payment/Repositories/PaymentClaimRepository.php) — `submit()` (public), `match()` / `discard()` / `purge()` (this screen)
- [resources/js/Pages/Manage/Payment/Claims/Index.vue](/resources/js/Pages/Manage/Payment/Claims/Index.vue) · [Partials/ResolveClaimModal.vue](/resources/js/Pages/Manage/Payment/Claims/Partials/ResolveClaimModal.vue)
- [tests/Feature/Payment/ResolvePaymentClaimTest.php](/tests/Feature/Payment/ResolvePaymentClaimTest.php) — 10 tests; the four guards (double-resolve, delete-a-matched-claim, merge filing, and the typed email not choosing the buyer) are mutation-verified
- The public half that produces these rows: [Payment Links → manual transfer](/docs/modules_handbook/manage/payments/payment-links/readMe.md)
