# Ad-spend document automation (Pascucci) — Handoff V1

> **Last updated**: 2026-09-24
> **Status**: **ON in production** since 2026-09-24 (`FINANLY_ADSPEND_DOCS_ENABLED=1` on jobs; feature
> `ad_spend_document_automation` assigned to the `Internal` plan). It runs after every successful
> `erpnext`/`mercury` sync and in the 06:40 PT daily sweep. At the time of the 2026-09-24 regression audit it had
> not created any document yet (0 `ad_spend.*` audit events); every 2026 Pascucci Meta/Google/PayPal withdrawal
> was already fully allocated, so the first runs skip them as `not_pending`.
> **Code**: jobs `finanly_jobs/adspend_docs.py` (pure planner `plan_bank_documents`), `finanly_jobs/tasks.py`
> (`adspend_documents_prepare`, hook at the end of a sync run, beat `adspend-documents-daily-sweep-640am`);
> connector-erpnext `GET /v1/ledger/ad-spend/bank-transactions`, `GET /v1/ledger/ad-spend/purchase-invoices`,
> `POST /v1/ledger/ad-spend/purchase-invoices` (`attach_only_if_created`).
> **Scope note**: the feature sits on the whole `Internal` plan; the code limits it to `ADSPEND_COMPANY`
> (default `Pascucci USA Inc`).
> **Origin**: promoted from `temp/adspend_docs_notes_20260924d.md` (design notes + back-test evidence).
> Secrets (e.g. `META_MARKETING_API_TOKEN`) live only in `infra/docker/.env`; never copy values into docs.

## Goal
When a Google Ads / Google Workspace / PayPal / Meta charge lands on Mercury Checking 9908, the invoice of record
(Outstanding ERPNext Purchase Invoice + PDF) already exists, so bank reconciliation is only matching.

## Flow
1. Trigger: end of every successful `erpnext` or `mercury` `sync_run` (hourly via `nightly_sync_all`) enqueues
   `finanly.jobs.adspend_documents_prepare(tenant_id, trigger="sync:<type>")` (90 s countdown); daily safety sweep
   at 06:40 PT (`adspend-documents-daily-sweep-640am`, lookback `ADSPEND_SWEEP_LOOKBACK_DAYS`, default 30).
2. Reads (all read-only): connector-erpnext `GET /v1/ledger/ad-spend/bank-transactions` (withdrawals + linked
   vouchers), Finanly `canonical_transactions` (Mercury raw: descriptor, counterparty, kind, status, createdAt),
   connector-erpnext `GET /v1/ledger/ad-spend/purchase-invoices` (bill_no, remarks, outstanding, accounts, files),
   Meta Graph API (activities in 1-day windows, hourly Insights, funding label) only when a Meta line is pending.
3. Plan: `finanly_jobs.adspend_docs.plan_bank_documents` (pure). Execute: `POST /v1/ledger/ad-spend/purchase-invoices`
   with `attach_only_if_created=true`, one per line; hard gates: items total == bank amount to the cent, PDF starts
   with `%PDF`. Per-tenant redis lock (fail closed). Audit per write.

## Match rules (Mercury counterparty AND descriptor, card kind, not failed, bank account 9908)
| vendor | counterpartyName | bankDescription | bill_no | supplier | account / cost center |
|---|---|---|---|---|---|
| Google Ads | Google | `^GOOGLE ?* ?ADS<digits>` | GOOGLE-<BT> | Google | Google - PUI / Advertising - PUI |
| Workspace | Google Workspace | `^GOOGLE ?* ?WORKSPACE` or `^Google Workspace[_ ]` | GOOGLE-<BT> | Google | Subscriptions & Services - PUI / Admin and Overheads - PUI |
| PayPal | PayPal | `^PAYPAL *` | PAYPAL-<BT> | Paypal | Advertising/Promotional - PUI / Advertising - PUI |
| Meta | Facebook | `^FACEBK *<ref>` | META-<Meta txid> | Meta inc | Facebook - PUI / Advertising - PUI |
Excluded by the rules (verified on 9908 history): Google Play, `PAYPAL *UBER*` (counterparty Uber / Uber Eats, booked
to Supplies / Ride Share), `PAYPAL; INST XFER` (kind other: a reimbursement to Luca via PayPal, booked by JE ACC-JV-2026-01196, not a PayPal charge), Meta Verification for
Business (subscription), card international fees. 60-day check (2026-07-26..09-24): the rules select exactly the 56 bank lines
that were paid through a Payment Entry to Google / Paypal / Meta inc (6 Google Ads, 2 Workspace, 13 PayPal, 35 Meta):
56/56, nothing missing, nothing extra.

## Precedence (first match wins)
1. line linked to a voucher -> skip; 2. PI with the planned bill_no exists -> skip (Meta: the official-receipt PI
has the same bill_no, so it always wins); 3. PI remarks contain the Mercury txn id (manual backfill) -> skip;
4. unmatched vendor document (Workspace GOOGLE-WS-* +/-10 d, Google Ads statement GOOGLE-M* +/-5 d, same outstanding
amount; one bank line per document) -> skip; 5. Meta line not paired -> skip; else create. Own record PI without any
attachment -> re-send to attach (repair).
Reverse direction: Meta email ingest hits the same META-<txid> PI and attaches the official receipt (audit
`ad_spend.meta_official_receipt_attached`, incl. `posting_date_differs`); Workspace email ingest attaches the
invoice to the bank-record PI (`bill_no_override`, audit `ad_spend.google_workspace_invoice_attached_to_bank_record`),
never creates a second PI, and creates nothing when the PI listing is unavailable or 2+ candidates exist.
Google Ads statements are still a MANUAL process (no automated ingest exists): when a statement arrives, attach it
to the existing GOOGLE-<BT> PI (POST the ad-spend endpoint with that bill_no) instead of creating GOOGLE-M<txid>.

## Meta evidence (temp/meta_backtest_20260924d.py, output adspend_backtest_20260924d/backtest_output.txt)
- Pairing: Mercury `createdAt` (card authorization) == Meta activities `event_time` (max 0.2 min, 96 lines).
  Rule "same amount + auth time within 15 min": Aug-Sep 31/31 correct; May 27-Sep 24 96/96 correct, 1 unpaired
  (charge absent from the activities edge). Luca's chronological rule (amount + 0-3 days): Aug-Sep 19/31 correct,
  12 WRONG. Shipped: auth time; date fallback only without auth time; ambiguous fallback skipped by default
  (`ADSPEND_META_AMBIGUOUS_PAIRING=chronological` restores the chronological choice).
- Activities edge silently drops rows on wide ranges (a single May-Sep query returned 6 of 27 June charges):
  production pulls 1-day windows (+/-12 h) and de-duplicates.
- Receipt reconstruction (Insights spend in (previous charge, this charge], boundary hours pro-rated): 96 receipts,
  190 campaign lines: exact to the cent 2/190 (1.1%), 0/96 receipts fully exact, within $1 54/190, median error $3.78;
  campaign set exact 80/96; impressions exact 6/190. msg-133 subset (12 receipts, 24 lines): 1 exact. FIFO threshold
  model (information): 21/190 exact. => the split is NOT receipt-exact: the PI books ONE line = exact charge; the PDF
  shows Insights figures labelled "not billing-exact" and an explicit "Unallocated difference vs charge".
- Posting date: receipt period start = previous charge local date - 1 day in 68/96, -2 days in 27/96, same day 1/96.
  Rule shipped: previous charge local date - 1 day (FLAG: approximation, 70.8% exact, else 1 day later).

## Configuration (infra/docker/.env; compose passes them to `jobs`)
- `META_MARKETING_API_TOKEN=<system-user token with ads_read>` (secret; same value class as
  META_ADS_SYSTEM_USER_TOKEN in marketing-insights-mcp, set by Luca, never copied by the agent)
- `FINANLY_ADSPEND_DOCS_ENABLED=1` (master switch, default 0; production: `1`)
- optional (defaults shown): `META_AD_ACCOUNT_ID=1571969887201660`, `META_API_VERSION=v21.0`,
  `META_AD_ACCOUNT_TZ=America/Los_Angeles`, `ADSPEND_COMPANY=Pascucci USA Inc`,
  `ADSPEND_BANK_ACCOUNTS=Mercury Checking - 9908 - Mercury Bank`, `ADSPEND_SWEEP_LOOKBACK_DAYS=30`,
  `ADSPEND_META_AMBIGUOUS_PAIRING=skip`, `ADSPEND_PAYPAL_*`, `GOOGLE_ADS_*`.
- Feature row `ad_spend_document_automation` (id 2800d902-ab7c-56ea-8637-27ebe4bbbc79) + plan assignment (Internal).
