# Unreconciled

**Portal:** Manage · **Nav:** Sales hub → **Payment** main tab → **Unreconciled** pill (Operations suite; Project / Subsale suites reach it from their standalone Payment entry) · **Permission:** `view-sales` (read) / `manage-sales` (adopt + replay)

## What it does

Shows **money the gateway reports that this system could not attach to anything** — and turns it back into real ledger rows.

It exists because the failure it covers is *silent*. A customer paying through a payment link made by hand in the Stripe dashboard produces a perfectly valid webhook, but nothing here knows what that link sells, so no ledger row is written, no membership is granted, and no screen says a word. This page is the screen that says it.

**The page is built around the fix, not the symptom.** Every payment from the same unadopted link is ONE card with one *"Link this to…"* button, because they are one problem: name what the link sells, and every payment it has ever taken is recorded and granted.

## How it works

Each inbound event is written to `payment_webhook_events` with a **status**, so the two failure modes that look identical are told apart:

| Status | Meaning |
|---|---|
| `QUEUED` (1) | Accepted, worker has not finished. A row **stuck** here means the queue stopped — a different problem, reported in its own banner. |
| `HANDLED` (2) | Dealt with: recorded, fulfilled, deferred, or deliberately left alone (a partial refund keeps its grant — that IS the decision, so it must not be filed as unreconciled). |
| `UNMATCHED` (3) | Real gateway activity that could not be placed. Listed here, payload retained. |

> `processed_at` means "the worker finished with it", **not** "the money was placed". Conflating the two made a stopped queue indistinguishable from unplaceable money.

Only the newest **200** unmatched events are read (`EVENT_ROWS`); when that ceiling is hit `summary.truncated` flips and the Events card says *(showing the newest)*.

⚠️ The link groups are built by partitioning that same 200 — they are **not** unlimited. On a truncated page a group's payment count and total are the newest ones only, so clear the backlog and reload before quoting a group's figure at anyone. (Adopting is still safe: the replay re-queries every held event for the link, and the queued backfill re-reads the gateway.)

⚠️ `payment_webhook_events` is the shared log for every gateway, but today only the **Stripe** lane writes to it — `ProcessStripeWebhook` is its sole producer. PayEx/EzBeli is catalogued but not available, and its callback answers `503` before anything is recorded, so an empty page never means 'PayEx is reconciled' — it means PayEx produced nothing to reconcile.

The reason is machine-readable (`reason`) plus a human sentence (`note`), produced by [`IngestOutcome`](/src/Payment/Support/IngestOutcome.php):

| Reason | When |
|---|---|
| `unadopted_link` | Paid through a gateway link nobody adopted here — **the common one**, and the one with a one-click fix |
| `foreign_checkout` | A checkout this system never opened and not from a payment link (a Stripe invoice, a dashboard charge) |
| `unknown_purchase` | Our reference was present but no ledger row carries it (hard-deleted row, test keys pointed at live, restored backup) |
| `unmatched_refund` | A refund for a payment never recorded — usually the other half of an unadopted link |
| `unidentifiable` | Nothing in the event to match on |

The gateway's own link id is lifted onto `external_link_id` at ingest time, so grouping is a plain column read rather than JSON digging.

### Amounts are per currency, never summed

Unplaceable money is exactly where a mixed-currency sum would be believed, so nothing here adds two currencies together. The headline is `summary.**amounts**` — an array of `{currency, amount}`, biggest first — and the Unaccounted card shows the largest with the rest listed beneath it. There is no `summary.amount`.

Every row carries its own currency too: an event row has `currency`, and a link group has ONE `currency` for the whole card. A **group** total *is* safe to add up — one gateway link collects in one currency — which is precisely what the page-wide total is not.

⚠️ The currency is read out of the stored payload and falls back to `MYR` when the payload states none, so an event that arrived without one is grouped and totalled as ringgit rather than shown bare.

### Adopting from here

`POST /manage/payment-unreconciled/adopt` binds the link to a **Membership, a Course or a Project**, then starts **both** recovery passes:

1. **Replay** every held event for that link through the same `WebhookProcessor` — so the payments already sitting here become ledger rows and grant what each buyer paid for.
2. **Backfill** from the gateway, **queued** as `ImportHostedLinkPayments` — so payments taken *before* the webhook ever existed are pulled in too (refunded and disputed ones are imported too, marked **Refunded** and granting nothing, so the ledger tallies against the gateway; the read is capped at `MAX_HOSTED_LINK_PAYMENTS` = 1000, and hitting the cap is logged). ⚠️ This lane is **unreviewed** — it exists to rescue money that is already stranded, so it runs without the payment-import review screen and keeps every CRM value. Use the ↓ action on the link when you want to see the leads it will create first. ⚠️ Only pass 1 is finished when the page reloads: the success flash counts the **held events replayed**, and says the earlier history is still importing. It has to be queued — a link that has been selling for a year holds hundreds of payments, each creating a lead, a ledger row and a grant, so a request-bound import would hit PHP's execution limit, commit half, and report nothing at all.

The adopted link is recorded in the **held events' own currency** (`$sample->currency_code`), not the payable's. Without that, a link taking SGD bound to a ringgit-priced tier would be filed as ringgit and label real SGD takings as MYR. The modal's Amount field is labelled in that same currency, and it is for reporting only — each recovered payment keeps whatever the gateway actually charged.

The modal only offers what can actually be granted: **ACTIVE** memberships, courses whose `visibility` is sellable, and **ACTIVE** projects. ⚠️ A link that sold something outside those three lists cannot be adopted from here at all — make it active (or sellable) first, or its payments stay held.

Both routes go through `recordHostedLinkPayment()` and key on the gateway's session id, so a payment seen by more than one pass is recorded once. A successful replay **clears** the reason rather than leaving a stale one.

Both passes end in `PurchaseFulfiller::fulfill()`, which also reports the sale to Meta. Recovered *history* is not reported: the action returns early for anything paid more than 7 days ago and leaves `meta_reported_at` null, so adopting a link that has been selling for two years does not fire a burst of conversions.

`POST {event}/replay` re-runs a single held event — for the loose cases fixed by hand. Replays (single or bulk) are **idempotent per purchase**: each entitlement records the payment that granted it, so replaying an event whose payment already did its work grants nothing — even when the membership it once granted has since been cancelled.

## Related files

**Backend**
- `app/Http/Controllers/Manage/Payment/UnreconciledController.php` — index, adopt, replay
- `app/Http/Requests/Manage/Payment/Unreconciled/AdoptRequest.php`
- `src/Payment/PaymentWebhookEvent.php` — statuses, `unmatched()` / `stuck()` scopes, payload readers
- `src/Payment/Support/IngestOutcome.php` — the reason vocabulary
- `app/Services/Stripe/WebhookProcessor.php` — `outcome()` / `unmatched()`
- `app/Jobs/Stripe/ProcessStripeWebhook.php` — records the verdict

- `app/Services/Payment/PaymentLinkService.php` — `adopt()` (restores and re-points a soft-deleted link row rather than colliding on the unique index) and `backfill()` → `{read, recorded, failed}`
- `app/Jobs/Payment/ImportHostedLinkPayments.php` — the queued history import that adoption dispatches (3 tries, 900s timeout, safe to retry)

**Frontend**
- `resources/js/Pages/Manage/Payment/Unreconciled/Index.vue` + `Partials/AdoptUnmatchedModal.vue`

**Migration**
- `database/migrations/2026_07_29_110001_add_reconciliation_to_payment_webhook_events.php`

**Routes** — `manage.payment.unreconciled.{index,adopt,replay}`

**Tests** — `tests/Feature/Payment/UnreconciledTest.php` (held with a reason · one adoption recovers every held payment · a partial refund is not unreconciled · a stopped worker is reported separately)

See also: [Payment Links](/docs/modules_handbook/manage/payments/payment-links/readMe.md) · [Payment Gateways](/docs/modules_handbook/manage/payments/gateways/readMe.md) · [Payments ledger](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md).
