# Runbook — Batch-match pending Shopify payouts + Meta invoices via Finanly (Pascucci)

> **Purpose**: repeatable, accounting-perfect procedure to match + submit every pending Shopify
> payout and Meta ad invoice for Pascucci USA Inc through the Finanly recon pipeline, then verify
> the GL to the cent. First executed 2026-06-18 (26 Shopify payouts + 16 Meta invoices, 0 errors).
> **Audience**: operator running the periodic reconciliation.
> **Scope**: Pascucci USA Inc only. Tenant `837eebaa-1ae8-5b79-95c3-26649fb25c42`.

---

## 0. Key identifiers (verify they still hold before a run)

| Thing | Value |
|---|---|
| Tenant id | `837eebaa-1ae8-5b79-95c3-26649fb25c42` |
| Owner user (for token) | `33b92537-2206-5203-a983-a429de8b57d7` (`luca@capula.co`) |
| Integration instances | erpnext `70b35b22-1430-5595-b698-6e551970a14c` · shopify `3c7e91d9-8a2d-5382-9da0-dd2d7bfb8213` · flexport `f79aaf08-e186-50b8-8efe-25eeef422307` · mercury `109f1d3d-4619-591f-a1c2-d593f6f22988` |
| Bank GL | `Mercury Checking - 9908 - PUI` |
| AP | `Accounts Payable (A/P) - PUI` |
| Meta supplier / account / cc | `Meta inc` / `Facebook - PUI` / `Advertising - PUI` |
| ERPNext site | `erp.capula.co` (container `erpnext-python`, run `env/bin/python` from `/home/frappe/frappe-bench`) |

> Terminal `grep`/`rg` output is **redacted on some tokens** in this workspace — write results to a
> file and open with the Read tool, or run audits via a `.py` file `docker cp`'d into the container.

---

## 1. Auth — mint an owner token (drive the real Finanly API)

Inside `finanly-core-api-1`:
```python
from finanly_core_api.core.settings import get_settings
from finanly_core_api.core.security import create_access_token
st=get_settings()
tok,_=create_access_token(secret=st.jwt_secret, user_id="33b92537-2206-5203-a983-a429de8b57d7",
                          tenant_id="837eebaa-1ae8-5b79-95c3-26649fb25c42", roles=["owner"], expires_in_seconds=3600)
# httpx.Client(base_url="http://localhost:8000"), header Authorization: Bearer <tok>
```
Roles `["owner"]` pass both the recon gates and `_require_business_route_scope`.

---

## 2. Enumerate + coverage-check (READ-ONLY — do this first, every time)

- **Pending BTs**: ERPNext `Bank Transaction` where `company='Pascucci USA Inc'`, `status='Pending'`.
  Classify by `description`: `shopify` → Shopify payout (deposit); `facebook`/`meta` → Meta charge (withdrawal).
- **Shopify payouts**: each pending deposit must match a `recon_items` row (`source_system='shopify'`,
  `object_type='shopify_payout'`) **by amount**. Build pending-amount → recon_item-count map; flag any gap.
- **Meta**: each pending Facebook withdrawal must match an Outstanding `Purchase Invoice`
  (`bill_no LIKE 'META-%'`, `docstatus=1`, `outstanding_amount>0`) **by amount**.
- **Known intentional skips / gaps**:
  - `$49.99 "Meta Verification for Business"` — a Meta *Verified* subscription, **not** an ad receipt; no
    invoice in the mailbox → cannot auto-match. Leave Pending unless its receipt is forwarded.
  - A receipt may simply be **missing from the mailbox** (e.g. the $499.68 on 2026-06-18). Ask the operator
    to re-forward it to `finanly@pascuccicoffee.com`, re-run the Meta ingest, then match. **Never** invent an invoice.
  - There can be **more $500 Meta PIs than $500 charges** (a receipt whose bank charge hasn't deposited yet) —
    leave the spare PI Outstanding.

---

## 3. Mapping (the only non-trivial part)

- **Unique-amount items → auto 1:1** (amount is the key; Finanly's preview asserts amount==payout/charge).
- **Same-amount items → disambiguate by date, deterministically — NEVER guess:**
  - **Shopify**: the payout's settlement date is `recon_item.raw_payload['payout']['date']`. The bank deposit
    lands ~1 business day later. Sort same-amount payouts by settlement date and same-amount BTs by date; pair
    in order. (2026-06-18: $286.13 → 6/1→00659, 6/2→00667; $285.15 → 6/7→00693, 6/12→00730; the 6/20 "scheduled"
    payout had no deposit yet and was excluded.)
  - **Meta**: all $500 charges hit the **same GL** (`Facebook - PUI`), so the GL is identical regardless of
    pairing — but for tidy linkage, sort Outstanding $500 PIs by `posting_date` and $500 BTs by date, pair in order.
- Get the **canonical_transaction_id** per BT from `canonical_transactions.erpnext_bank_transaction_name`
  (the deposits are Mercury-sourced — do **not** filter `source_system='shopify'`).

---

## 4. Per-item cycle (dry-run → submit → verify)

### Shopify payout (creates a multi-line JE + a Customer/SalesOrder/COGS Stock Entry per order)
1. **Dry-run** `POST /v1/recon/shopify/payouts/{payout_id}/preview` body `{"canonical_transaction_id": <ct>}`,
   header `Idempotency-Key`. Assert `data.preview.summary.reconciles == true` AND
   `round(sum(gl_entries.amount),2) == deposit`.
2. **Submit** `POST /v1/recon/shopify/payouts/{payout_id}/export` body
   `{"canonical_transaction_id": <ct>, "recon_preview_id": <from preview>, "submit": true}`.
3. **Poll** the returned `export_item_ids[0]` in `export_items.status` until `succeeded`.
   - NOTE: one payout export produces **many** export_items — one `shopify_fulfillment_stock_entry` per order
     **plus** the payout JE. Their `idempotency_key` **contains** your action token as a *substring*
     (`export:shopify_fulfillment_stock_entry:…:<your-key>:order:<id>`), so match with `LIKE '%<key>%'`, **not** a prefix.

### Meta invoice (creates a Payment Entry settling the Outstanding PI)
1. **Submit** `POST /v1/business/transactions/{canonical_transaction_id}/invoice-payment/export`
   body `{"invoice_doctype": "Purchase Invoice", "invoice_name": "<PI>"}`, header `Idempotency-Key`.
   (No separate preview; the dry-run check is "PI Outstanding, amount == BT withdrawal".)
2. **Poll** `export_items.status` until `succeeded`; `result.external_ids.payment_entry` is the PE.

### Pacing
Process **one at a time**. The in-container batch driver works (18+ payouts/run) but DON'T conclude it's
stuck from a bad `LIKE` query — verify via `export_items` (substring match) and JEs landing. Heavy payouts
(40–60 orders) take a few minutes each; set the poll timeout to ~600s.

---

## 5. Verification (the "check again" — run after EVERY item, and a full sweep at the end)

For each posted voucher (`erpnext-python`, `env/bin/python`):
- JE/PE **balanced**: `sum(GL Entry.debit) == sum(GL Entry.credit)` and `docstatus==1`.
- **Bank line exact**: Shopify JE → Mercury **debit** == deposit; Meta PE → Mercury **credit** == withdrawal AND AP **debit** == withdrawal.
- **No suspect accounts**: every non-bank GL account ends `- PUI` and is not `Uncategorized`.
- **BT** `status=="Unreconciled"` (NOT Reconciled — see §6), `allocated_amount == amount`.
- **Meta only**: the PI is now `status=="Paid"`, `outstanding_amount==0`.
- **Totals**: sum of Mercury debits == sum of Shopify deposits; sum of Mercury credits == sum of Meta charges.

A self-contained re-audit script template was used on 2026-06-18 (write to `temp/`, `docker cp` into
`erpnext-python`, run). Reuse it: it prints `PROBLEMS: 0 / VERDICT: PERFECT` when clean.

---

## 6. Critical invariant — Bank Transaction stays `Unreconciled`

A finanly-posted match must leave the BT **`Unreconciled`** (payment doc posted; the accountant runs
ERPNext's Bank Reconciliation Tool for the final statement match). ERPNext's `before_submit` runs
`set_status()` which promotes a fully-allocated BT to `Reconciled`, so the connector sets Unreconciled
**after** submit via `_force_bt_unreconciled()` (`connector-erpnext/app.py`). If you ever see a matched BT
land `Reconciled`, that fix regressed — see `plans/FLEXPORT_INVOICE_CREDITS_ROUTING_PLAN_V1.md`.

---

## 7. Idempotency / safety
- Shopify export keys on `recon_preview_id` + `canonical_transaction_id`; a re-run on an already-exported
  ct returns **HTTP 409 `transaction_already_exported`** (safe — nothing double-posts).
- Meta PE precheck is by `reference_no`/PI; re-running returns the existing PE.
- Books were never at risk during a mid-run kill on 2026-06-18: completed payouts were fully posted
  (JE + all per-order stock entries) and the rest untouched. Always reconcile partial state before re-running.
