Skip to main content

Runbook: Void Unfulfilled MFM Processing-Fee Invoices

Status: operational runbook (internal). Approved: Jul 1 2026 (see VISION.md Locked Decision — MFM fee only when 7 Figures completed processing).

Why this exists

Before Phase 1’s fulfilment gate shipped, MFM operator processing-fee invoices were generated whenever a client’s invoice reached paid — regardless of whether 7 Figures actually processed that client’s file. That over-billed operators for files 7 Figures never fulfilled and, because an overdue fee blocks new submissions (convex/lib/unpaidFeeGate.ts), it could also lock the operator out of submitting. This runbook voids those already-generated fees so the operator is not billed and the submission gate releases. It is deliberately a two-step, human-in-the-loop process: diagnose (read-only) → review → execute on an explicit id list.

Scope guardrails (what is and isn’t voided)

The classifier (lib/billing/feeRemediation.ts::classifyFeeInvoiceForRemediation) buckets every fee row. Only void_candidate rows are ever voided: “Processed by 7 Figures” = a processingSubmissions row for the client in tl_processing_complete or card_processing_complete (lib/fundingQueue/processingSubmissions.ts).

Endpoint

POST /api/billing/processing-fee-remediation Auth: CONVEX_INTERNAL_SECRET bearer OR a super-admin session (brock@…, or an admin location) — same gate as the processing-fee backfill route. Body:

Procedure

1. Diagnose (read-only)

Scope to the affected operator when known (e.g. the Ken Zion / Michael Wadley case):
Review report.counts and the report.voidCandidates / report.refundReview / report.unresolvedClient arrays. Confirm each void candidate is genuinely a file 7 Figures never processed. Escalate any refund_review (money was collected) and unresolved_client rows to Brock — the tool will not touch them.

2. Execute (void the reviewed ids)

Copy the operatorInvoiceIds you confirmed from voidCandidates into invoiceIds:
For each id the tool: (a) voids the CRM invoice via the GHL SDK (voidInvoice), then (b) writes the ledger row to written_off via operatorInvoices.voidUnfulfilledProcessingFeeInternal, which re-runs the classifier in-transaction and refuses anything that is not still a void_candidate. This makes a stale or mistaken id safe — it is skipped, never force-voided. Read summary (voided, skipped, crmVoided, crmFailed) and per-row results. A non-empty top-level crmFailedInvoices array lists the rows whose CRM void failed — each one requires the manual follow-up below.

3. Verify the unlock

Voiding sets the ledger status to written_off, which is not in the submission gate’s blocking set (BLOCKING_CANDIDATE_OPERATOR_INVOICE_STATUSES), so the operator can submit immediately. Confirm by re-checking the operator’s outstanding fees (Admin → Processing Fees) or by having them retry a submission.

Failure handling

  • crmFailed > 0: the CRM void call failed (e.g. already void, or a transient GHL error). The ledger row is still written off (submission unblocked). Manually void the CRM invoice in the MFM admin sub-account for every mfmInvoiceId listed in the response’s top-level crmFailedInvoices array. The InvoiceVoided webhook also converges the ledger, so a later manual void is safe and idempotent.
  • skipped with not_in_current_void_candidates: the id was not in the fresh diagnostic’s void-candidate set (already voided, now fulfilled, or a refund case). Re-diagnose and review.
  • ledger.outcome = skipped_not_void_candidate: processing completed, or money was collected, between diagnosis and execution. Correct behavior — do not override.

Notes

  • This touches billing and the CRM invoice API — Out of Scope areas covered only by the Jul 1 2026 approval. Keep runs scoped and reviewed.
  • The diagnostic (convex/processingFeeRemediation.ts) is read-only; the only writes are the per-id void mutation and the CRM voidInvoice call, both driven from this route.