> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Period b zero debit recovery

# Period B zero-debit recovery (manual, proof-gated)

Internal runbook for the `recovery` mode of `POST /api/admin/collect-approved-credit-charges`.
Not customer-facing. Owner: Brock. Nothing here is scheduled or automatic.

## The problem

Every Period B `credit-pull-{requestId}` key has a SUCCESS `billingCharges` audit for \$5, but
the CRM reported no wallet debit. The normal collect path therefore refuses each key as
`already_charged`. Some keys also have a lifetime `approvedCollectionAttempts` row. None of
that history may be edited or cleared.

Marketplace `getCharges` returning `amountCharged: 0` is **not** sufficient to admit a key:
it is a provider charge record, not the Credit Reports wallet ledger. A key whose only
zero-evidence is `getCharges` is labelled `provider_zero_wallet_unverified` and is never
charged.

## What recovery mode does

* Charges the same approved \$5 under a **new** idempotency key
  `recovery-credit-pull-{locationId}-{requestId}`. The key carries the Location, and because
  it is not a `credit-pull-*` key it sends exactly the approved amount on the wire,
  independent of the credit-pull wire rule.
* Bypasses `already_charged` on the **original** key only after the proof gate passes.
* Appends a recovery row to the same `approvedCollectionAttempts` ledger (`recoveryOfEventId`,
  `proofRef`) and a normal `billingCharges` audit under the recovery key. Original rows are
  read, never patched or deleted.
* Shares the hard `$50 / Location / day` pool with normal collections via the same atomic
  claim mutation. Recovery cannot raise it.
* One Location per call. Credit-pull rows only. Explicit per-row allowlist (you post the rows).
* Independently requires each `(locationId, requestId)` pair to appear in the audited
  82-row / \$410 server allowlist at `convex/lib/periodBRecoveryAllowlist.json`.

## Proof gate (all required, per row)

1. **Approved target and original audit** — the exact `(locationId, requestId)` pair is in
   the 82-row unpaid-eligible allowlist, and exactly one SUCCESS `billingCharges` row exists
   for `credit-pull-{requestId}`,
   same Location, created within the approved Period B window (September 11 19:01 through
   September 15 14:34 America/Denver), `amountCharged` absent or 0.
2. **Marketplace getCharges** — fully paginated over the Location's recovery interval
   (below), the original `chargeId` (or `meta.eventID`) appears, belongs to this Location,
   and has an explicit numeric `amountCharged` of 0. Incomplete pagination, a fetch error, or
   a missing/malformed `amountCharged` is *no evidence*.
3. **CRM Wallet & Transactions ledger packet** — an operator-supplied, read-only export of the
   company-billing `wallet_transactions` ledger that:

   * has `source: "crm_wallet_transactions"` (payments `listTransactions` is a different
     ledger and is rejected),
   * records its exact as-of (`capturedAt`, which must be on or after the last day any
     coverage span claims — a packet cannot vouch for days after its capture), `timeZone:
     "America/Denver"`, `ledgerView`
     (`agency_export_center` or `location_wallet_transactions`) and `filter`
     (`productScope`: `all_products` | `credit_reports_only`; `locationScope`: `location` |
     `agency_all_sub_accounts`),
   * may be `credit_reports_only`, which is sufficient to classify this Credit Reports
     cohort in dry-run; it does **not** prove another product did not collect separately,
   * for live recovery, a `credit_reports_only` packet must also carry honest
     `includesResellingProfit: true` evidence, or the packet must be `all_products`;
     otherwise the row is `separate_product_collection_unverified`,
   * covers this Location for at least the whole recovery interval (a stale export that
     ends before the cutoff is a `ledger_coverage_gap`),
   * every entry carries `occurredAt` and a non-negative `amount` (debits are positive), and
     no debit joins to this pull.
     For a live run, `capturedAt` must also be no more than five minutes old (and not in the
     future) when the route is ready to claim rows. This exact-time check runs after the
     read-only provider gate so a same-day packet cannot remain eligible indefinitely.
     Its SHA-256 (`proofRef`) is stored on the recovery attempt row.

   **Join rule.** A marketplace `chargeId` is a different identifier space from a CRM wallet
   transaction id, so "no chargeId in the CSV" is never zero-debit proof. A debit blocks the
   row as `wallet_debit_found` when it is linked by `chargeId` / `eventId`, or when Location +
   the wallet transaction/provider charge ObjectId timestamps attribute a CR Apps $5 entry
   within the validated 600-second window. Entries explicitly allocated to another event are
   not residuals for the current row. An unlinked debit within ±1 Denver civil day of the
   original audit that either matches the $5 amount or mentions Credit Reports is
   `wallet_debit_ambiguous`: it isolates that row as an evidence gap without claiming the debit
   paid it. An unmatched \$3 Credit Reports leftover is a Location-level partial/recovery
   confounder and isolates every posted row for that Location.

Block reasons: `original_audit_missing`, `original_audit_ambiguous`, `original_collected`,
`original_location_mismatch`, `not_in_approved_period_b_cohort`,
`outside_recovery_cohort`, `prior_attempt_not_credit`,
`provider_coverage_incomplete`,
`provider_charge_missing`,
`provider_collected`, `provider_debit_unreadable`, `provider_location_mismatch`,
`provider_zero_wallet_unverified`, `separate_product_collection_unverified`,
`ledger_coverage_gap`, `wallet_debit_found`, `wallet_debit_ambiguous`. Any blocked row is
identified in `failures`; keep ambiguous rows out of an approved batch. The request remains
all-or-nothing, the same as the collect pre-flight.

## September 16 CR-cohort read-only reassessment

The Brock-scoped census based on Credit Reports export coverage through September 15 plus
the original/recovery ledger and provider joins is:

* supported unpaid eligible: **129 / \$645**
* paid: **0**
* `prior_attempt_not_credit`: **59 / \$295** (September 14 attempts are not debt credits)
* ambiguous unmatched $3 leftovers: **51 / $255\*\*
* overall candidate until row-substantiated: **239 / \$1,195**

Evidence basis from the attached read-only supported-subset packet:

* The exact approved keys and declared totals are committed in
  `convex/lib/periodBRecoveryAllowlist.json` (129 / $645 at this census; 82 / $410 after the
  September 17 trim below); module-load integrity checks reject count, total, or duplicate
  drift.
* Existing agency file `agency-wallet-credit-reports-2026-09-11_to_2026-09-15.csv`
  contains 110 rows, 108 labeled CR Apps.
* Period A validates the ObjectId/time join: 67/67 attributable CR Apps $5 entries fall
  within 600 seconds. Period B has 0/239 attributable $5 joins, with no leftover \$5 after
  allocating Period A and Period C entries. Period C remains excluded from recovery.
* Westmere Financial has an affirmative CR Apps `$0` wallet entry at the pull
  (approximately one second from its chargeId timestamp), which is unpaid evidence.
* The five Locations with unmatched `$3` CR residuals are Gap Funded
  (`isqlsHX3ljyFgKYAxPTQ`), FundRocket.io (`fB3CtoRrGiOUuhOm6cSd`), Top Notch Consulting,
  LLC (`uDaWpJXgD9fERbwSZs0J`), CEO CAPITAL CONNECTION (`uZ3XxusaE8dSjJDcnL3l`), and
  Patricia Millan's Account (`XX5EUeDb8RKXnDkSQet0`). Their 51 non-59 rows stay isolated.

Do not erase the CR classification for lack of an all-products Export Center packet. The
129 supported rows meet the CR evidence bar for dry-run planning, but CR absence is not
proof that another product never collected: they remain blocked from live recovery as
`separate_product_collection_unverified` until honest Profit-exclusion evidence exists or
Brock explicitly accepts that residual risk. The 59 prior attempts do not reduce the
candidate debt because an attempt is not a wallet credit or debit, but they remain outside
the supported admit batch pending their separate release policy. Keep each of the 51
unmatched \$3 rows isolated as `wallet_debit_ambiguous` until its own join is substantiated;
do not mark it paid and do not silently charge it. A row with no Credit Reports wallet
evidence remains `provider_zero_wallet_unverified`.

## September 17 allowlist trim: 129 → 82 rows ($645 → $410)

A read-only dry run of all 129 rows on tip `457c3169` (#1757 reservation→CRS fallback,
\#1759 getCharges meter fallback) admitted 48 as `would_charge` and left 47 uniformly
`report_not_verifiable` (`No creditReportRequests row for requestId+locationId`). Every one
of the 47 has a `credit-pull-{attemptId}` reservation at the correct Location whose
`requestId` was never patched to a CRS id, so the #1757 bridge (which requires
`finalized` plus a different `reservation.requestId`) correctly returns nothing. They split:

* **33 charged-stuck** — reservation `status: charged`, never finalized, SUCCESS
  `billingCharges`, no `creditReportRequests` row under the attemptId or any alternate charge
  requestId. This is undelivered charge-first debt for ops to reverse or owe-credit, not a
  Period B recovery collect.
* **14 `release_failed` non-billable** — reason `non_billable: CRS marked this pull as
  non-billable`; the contemporaneous same-contact CR is `billable: false`. Never \$5 recovery
  candidates; they stay a `release_failed` liability tracked elsewhere.

The 47 pairs were removed from `convex/lib/periodBRecoveryAllowlist.json`. The 48 admitted
rows and the other 34 entries (wallet-ambiguous and otherwise) are untouched. `verifyRows`
was **not** widened to accept `status: charged` without a CRS bridge, and no attemptId was
remapped to a later unrelated contact CR.

## Recovery interval and getCharges semantics

* Start: Denver (`America/Denver`) civil day of the earliest original SUCCESS audit for the
  Location, **minus one day**.
* End: `reconciliationCutoff` (civil `YYYY-MM-DD`) if supplied, else the day after today in
  Denver.
* The marketplace OpenAPI documents `startDate` as "AFTER" and `endDate` as "BEFORE" a civil
  date with **no stated timezone**. Empirically the end bound is exclusive: pass the day
  *after* the last day you need. The one-day start pad exists so an edge charge cannot fall
  outside either bound whichever timezone the provider applies.
* Pagination is `limit=100`, `skip` stepping, until a short page or `total` is reached; more
  than 50 pages fails closed. The response records `pages`, `chargesSeen`, `total`, `asOf`,
  `meterFilter`, and on failure an `error` that carries the upstream HTTP status
  (`Error fetching charges (status=500)`).
* The read asks the provider to filter by the Credit Reports `meterId`. The provider has
  returned HTTP 500 `Error fetching charges` for any metered request while the same window
  unfiltered returns 200 with `meterId` on each row (Midas canary, 2026-09-17). On a 5xx for
  the **first** metered page the read is retried once without `meterId`, every later page
  stays unfiltered, and rows are narrowed to the meter client-side; the receipt shows
  `meterFilter: "client"`. A 4xx/auth error never strips the meter, and a 5xx after metered
  pages already landed fails closed rather than mixing the two series.

## Request shape

```json theme={null}
{
  "mode": "recovery",
  "dryRun": true,
  "rows": [
    {
      "type": "credit-pull",
      "requestId": "cbaf8b5b-2c76-4772-abb0-d3727ed880d9",
      "locationId": "gLBCDedBUd08YmqW7BNL",
      "amountUSD": 5,
      "contactId": "MWh68GhQLSIb3jbQ4FaS"
    }
  ],
  "reconciliationCutoff": "2026-09-16",
  "proof": {
    "source": "crm_wallet_transactions",
    "ledgerView": "location_wallet_transactions",
    "capturedAt": "2026-09-16T05:00:00.000Z",
    "timeZone": "America/Denver",
    "filter": { "productScope": "credit_reports_only", "locationScope": "location" },
    "attestedBy": "who exported the ledger",
    "coverage": [{ "locationId": "gLBCDedBUd08YmqW7BNL", "startDate": "2026-09-10", "endDate": "2026-09-16" }],
    "entries": [
      {
        "locationId": "gLBCDedBUd08YmqW7BNL",
        "transactionId": "wallet transaction id from the ledger",
        "occurredAt": "2026-09-10T15:04:00.000Z",
        "amount": 5,
        "kind": "wallet_debit",
        "description": "My Funding Machine - Credit Reports"
      }
    ]
  }
}
```

The example above is the Midas canary, used as a fixture only.

## Dry run vs live

| | Dry run (default) | Live |
| - | - | - |
| `dryRun` | omitted or `true` | `false` |
| `confirm` | not needed | must be exactly `RECOVER-PERIOD-B-ZERO-DEBIT` (the collect phrase does not work) |
| `proof` | CR-only accepted for classification; without it rows report `provider_zero_wallet_unverified` | must exclude separate-product collection via `all_products` or honest `includesResellingProfit: true`; also validated, no more than five minutes old at claim time, and hashed onto the claim |
| `reconciliationCutoff` | any civil date (exploration) | must reach through today in Denver (the day after today, exclusive) |
| Pre-flight + proof gate | run | run |
| Wallet read, claim, charge, settle, read-back | **never** | run, sequentially, same halts as collect |
| Localhost billing safety | allowed anywhere | refused (409) |

Live still requires an explicit approved target and amount at execution time. CR-only
classification does not satisfy the live gate. Do not run a live recovery from this
runbook without Profit-exclusion evidence or a later explicit Brock GO accepting the
residual risk.

## Out of scope (separate tracks)

* Wire \$5 hotfix (#1751), nightly mismatch alerts, bulk multi-Location sweeps, any cron or
  webhook, broader all-products reconciliation, and any change to historical audits or attempts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.