# 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). It also makes the admin answer a third: **which wallet line is this money** — exactly one of `statement_line_id` (a line picked in the `StatementLinePicker`) or `no_statement_yet` (`ResolveRequest`). Without a line, amount / currency / date are prefilled from the claim and stay editable, because a customer can mistype or transfer short; **with a line picked, the wallet's figures win** and whatever was typed is ignored.

Then, in this order (updated 2026-10-02 to match the code — it no longer calls `PurchaseHistoryRepository::create()` directly):

1. **The ledger row — ATTACH or CREATE.**
   - **ATTACH** — the picked line already produced an unattributed payment (statement read first): that row is UPDATED with the payable, the lead and the title (`PurchaseHistoryRepository::update()`, amount / currency / date deliberately absent), then `PurchaseHistoryRepository::adoptClaim()` writes the claim's `payment_reference` + `payment_link_id`. No second row.
   - **CREATE** — otherwise, through the single Touch 'n Go writer `TngLedger::record()`: `payment_provider = tng`, `payment_reference` = the claim's reference, `provider_payment_id` = the picked line's `dedupe_key` (null when "not in a statement yet" — an *unbacked* row), `counterparty_name` = the wallet's sender name when a line is picked, else the name the customer typed.
2. `PurchaseFulfiller::fulfill($purchase)` — the membership starts, the course opens, the project engagement is created (and the item's *When they pay* automation fires). **The same convergence point every other lane uses**, so an offline buyer is not a second, divergent way of granting anything.
3. `TngStatementRepository::markIngested()` — when a line was picked and was not yet linked.
4. `PaymentClaimRepository::match(...)` — only now is the claim marked.
5. A merge request is filed if the typed email and phone belong to two different records (below).

Before step 1 the server refuses: a claim that is not AWAITING; a look-alike of an already-MATCHED claim (same item, amount and email-or-phone within `tng.match.duplicate_claim_days`) unless `confirm_duplicate`; and a picked line that is not a settled customer payment, whose payment was deleted, already carries a receipt, or already has a buyer — the full table is in [Phase E](/docs/modules_handbook/manage/payments/phase-e-convergence.md).

⚠️ **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.

⚠️ **Known gap, not fixed as of 2026-10-02 — `resolve()` is not one transaction.** Each step commits on its own (every repository call has its own `DB::transaction`). If step 2 or 3 throws after step 1 committed, the claim stays AWAITING while a ledger row already holds its `payment_reference`; a second Resolve on the CREATE branch then dies on the unique `payment_reference` index (an SQL error, not a refusal). Recovery today is by hand: find the row by the claim's reference on Payment History and finish it there.

⚠️ **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 is written once and never edited (it is in the repository's create whitelist only). Since Phase E its source depends on the evidence: when a wallet line was picked it is **the wallet's own sender name** (`TngStatementLine::counterparty_name` — the fact), and only when "not in a statement yet" is it the name the customer typed (their assertion, which also survives on the claim itself). On an ATTACH the row keeps the name the statement lane already wrote. It is the 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) — the payable + frozen title (`"{name} Membership"` / `"{title} — Course"` / `"{name} — Project Fee"`), the currency fallback and the pickers. ⚠️ As of 2026-10-02 only `PaymentClaimsController` uses this trait; `PurchaseHistoriesController` (*Record payment*) still carries its own private `resolvePayable()` / `resolveCurrency()` copies with the same title strings — keep the two in step until it is switched over
- [src/Payment/Services/TngLedger.php](/src/Payment/Services/TngLedger.php) — `record()` (the CREATE branch) and `candidatesForClaim()` (the picker's wallet lines); [src/Payment/Repositories/PurchaseHistoryRepository.php](/src/Payment/Repositories/PurchaseHistoryRepository.php) — `update()` + `adoptClaim()` (the ATTACH branch)
- [resources/js/Pages/Manage/Payment/Claims/Partials/StatementLinePicker.vue](/resources/js/Pages/Manage/Payment/Claims/Partials/StatementLinePicker.vue) — the wallet-line picker inside the resolve modal
- [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)
