{"id":"545f839d-54ae-4d15-88e4-19cb1245ff6c","entityType":"agent","slug":"clawhub-zmtucker-drivethru-payable-matching","name":"drivethru-payable-matching","canonicalUrl":"https://www.xpersona.co/agent/clawhub-zmtucker-drivethru-payable-matching","canonicalPath":"/agent/clawhub-zmtucker-drivethru-payable-matching","generatedAt":"2026-10-10T15:52:00.298Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":null},"description":"Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents app against their purchase orders and correct incorrect PO line pricing. Use for requests like \"check the Purchasing folder against the POs and fix the pricing\", \"match the vendor invoice / order confirmation / acknowledgement to its PO\", \"AP price matching / invoice-to-PO matching / three-way match\", \"reconcile the vendor documents and mark the POs checked\", or \"go through the Purchasing folder\". The flow: read every document in a Documents-app folder (extracting text out-of-context so large batches don't bloat the context window — falling back to a page render + OCR/vision for scanned or custom-encoded PDFs that won't extract as text), pull the PO number / line items / unit prices from each, compare to the purchase order line by line, correct any wrong `price_unit`, post a \"checked\" log note on the PO (internal, never a \"Send message\"), and FILE every document into the `Matched` or `Questions` subfolder — escalating genuine questions to a reviewer (default Zach Tucker). Also runs the buying-group payables flow: pull Sports Inc invoices from the SportsLink API (via the `sportsinc-sportslink` adapter), reconcile each to its PO, correct price variances, create the vendor bill and — when the bill total matches the invoice within tolerance — POST it, leaving any mismatch in draft for a human (\"get the Sports Inc invoices and bill them\", \"match the SI invoices to POs and post the payables\", \"match the vendor invoice and post the bill if it matches\"). Handles the multi-shipment case where one PO returns several Sports Inc invoices, splitting it into one vendor bill per shipment via `account.move.line` edits (the `ap_*_bill_line(s)` tools). Runs at volume on a low-cost model. Driven by the Odoo `drivethru_mcp` MCP server; complements the broader `drivethru-odoo` skill.","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.4K downloads reported by the source. Last updated 10/10/2026.","installCommand":"clawhub skill install s17fq291581evd78xzn1930b0n87bfny:drivethru-payable-matching","sourceUrl":"https://clawhub.ai/zmtucker/drivethru-payable-matching","homepage":"https://clawhub.ai/zmtucker/skills/drivethru-payable-matching","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/zmtucker/drivethru-payable-matching","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/zmtucker/skills/drivethru-payable-matching","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":63,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"drivethru-payable-matching technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":null},"stars":null,"forks":null,"downloads":1404,"packageName":null,"latestVersion":"0.10.0","tractionLabel":"1.4K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":null},"lastUpdatedAt":"2026-10-10T13:48:21.095Z","lastCrawledAt":"2026-10-10T13:48:21.095Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-11T13:48:21.095Z","lastVerifiedAt":null,"highlights":[{"version":"0.10.0","createdAt":"2026-09-23T18:16:20.449Z","changelog":"Version 0.10.0 - Added significant updates across documentation and scripts. - Improved matching procedure and references for Sports Inc payables. - Enhanced the paymatch.py script for better bulk PDF extraction and processing. - Removed outdated skill-card.md file. - Miscellaneous documentation improvements for clarity and accuracy.","fileCount":7,"zipByteSize":45699},{"version":"0.9.8","createdAt":"2026-09-18T18:38:39.703Z","changelog":"drivethru-payable-matching 0.9.8 - Enhanced PO candidate extraction: now lists all candidate PO reference tokens (e.g. both \"PO 14594\" and \"P14594\" as P14594), improving purchase order matching robustness. - Updated documentation to clarify that each extracted document includes `po_candidates` as distinct canonical PO refs.","fileCount":7,"zipByteSize":44162},{"version":"0.9.7","createdAt":"2026-09-18T12:01:46.047Z","changelog":"drivethru-payable-matching 0.9.7 - Adds support for detecting and flagging multi-invoice documents (documents with multiple POs or more than one invoice header) in the `extract` output. - The extracted data now includes a `multi_invoice` field for each document and a list of PO candidates found. - Documentation updated to describe the new `multi_invoice` and `po_candidates` fields in extraction results. - No breaking changes; normal extraction behavior is unchanged for single-invoice documents.","fileCount":7,"zipByteSize":42841},{"version":"0.9.5","createdAt":"2026-09-17T21:57:51.104Z","changelog":"drivethru-payable-matching 0.9.5 - Version bump to 0.9.5 - Documentation improvements and clarifications in SKILL.md - No functional code changes noted, minor updates for clarity and version consistency","fileCount":7,"zipByteSize":40771},{"version":"0.9.4","createdAt":"2026-09-17T14:43:12.062Z","changelog":"Version 0.9.4 - Updated version number in SKILL.md to 0.9.4. - Documentation and metadata maintained for clarity; no changes noted to functional logic or interfaces.","fileCount":7,"zipByteSize":40506},{"version":"0.9.3","createdAt":"2026-09-17T13:57:54.074Z","changelog":"drivethru-payable-matching 0.9.3 - Updated version to 0.9.3. - Documentation and metadata updates in SKILL.md; no functional changes to documented features. - No user-facing changes in scripts/paymatch.py.","fileCount":7,"zipByteSize":39858},{"version":"0.9.2","createdAt":"2026-09-17T13:34:29.403Z","changelog":"drivethru-payable-matching 0.9.2 - Updated package version to 0.9.2. - Documentation and metadata refresh in SKILL.md; no functional or interface changes. - No new commands or configuration required.","fileCount":7,"zipByteSize":39517},{"version":"0.9.1","createdAt":"2026-09-17T02:59:39.730Z","changelog":"Version 0.9.1 - Added document selection options to the folder extraction command (`scripts/paymatch.py extract`), allowing for filtering by document IDs, name substrings, and batching with max document limits. - Updated SKILL.md documentation to describe the new extraction filtering options. - Removed the obsolete skill-card.md file.","fileCount":7,"zipByteSize":38933}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17fq291581evd78xzn1930b0n87bfny:drivethru-payable-matching","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s17fq291581evd78xzn1930b0n87bfny:drivethru-payable-matching` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/zmtucker/drivethru-payable-matching before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-10T15:52:00.292Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-zmtucker-drivethru-payable-matching/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":null},"readme":"Skill: drivethru-payable-matching\n\nOwner: zmtucker\n\nSummary: Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents app against their purchase orders and correct incorrect PO line pricing. Use for requests like \"check the Purchasing folder against the POs and fix the pricing\", \"match the vendor invoice / order confirmation / acknowledgement to its PO\", \"AP price matching / invoice-to-PO matching / three-way match\", \"reconcile the vendor documents and mark the POs checked\", or \"go through the Purchasing folder\". The flow: read every document in a Documents-app folder (extracting text out-of-context so large batches don't bloat the context window — falling back to a page render + OCR/vision for scanned or custom-encoded PDFs that won't extract as text), pull the PO number / line items / unit prices from each, compare to the purchase order line by line, correct any wrong `price_unit`, post a \"checked\" log note on the PO (internal, never a \"Send message\"), and FILE every document into the `Matched` or `Questions` subfolder — escalating genuine questions to a reviewer (default Zach Tucker). Also runs the buying-group payables flow: pull Sports Inc invoices from the SportsLink API (via the `sportsinc-sportslink` adapter), reconcile each to its PO, correct price variances, create the vendor bill and — when the bill total matches the invoice within tolerance — POST it, leaving any mismatch in draft for a human (\"get the Sports Inc invoices and bill them\", \"match the SI invoices to POs and post the payables\", \"match the vendor invoice and post the bill if it matches\"). Handles the multi-shipment case where one PO returns several Sports Inc invoices, splitting it into one vendor bill per shipment via `account.move.line` edits (the `ap_*_bill_line(s)` tools). Runs at volume on a low-cost model. Driven by the Odoo `drivethru_mcp` MCP server; complements the broader `drivethru-odoo` skill.\n\nTags: latest:0.10.0\n\nVersion history:\n\nv0.10.0 | 2026-09-23T18:16:20.449Z | auto\n\nVersion 0.10.0\n\n- Added significant updates across documentation and scripts.\n- Improved matching procedure and references for Sports Inc payables.\n- Enhanced the paymatch.py script for better bulk PDF extraction and processing.\n- Removed outdated skill-card.md file.\n- Miscellaneous documentation improvements for clarity and accuracy.\n\nv0.9.8 | 2026-09-18T18:38:39.703Z | auto\n\ndrivethru-payable-matching 0.9.8\n\n- Enhanced PO candidate extraction: now lists all candidate PO reference tokens (e.g. both \"PO 14594\" and \"P14594\" as P14594), improving purchase order matching robustness.\n- Updated documentation to clarify that each extracted document includes `po_candidates` as distinct canonical PO refs.\n\nv0.9.7 | 2026-09-18T12:01:46.047Z | auto\n\ndrivethru-payable-matching 0.9.7\n\n- Adds support for detecting and flagging multi-invoice documents (documents with multiple POs or more than one invoice header) in the `extract` output.\n- The extracted data now includes a `multi_invoice` field for each document and a list of PO candidates found.\n- Documentation updated to describe the new `multi_invoice` and `po_candidates` fields in extraction results.\n- No breaking changes; normal extraction behavior is unchanged for single-invoice documents.\n\nv0.9.5 | 2026-09-17T21:57:51.104Z | auto\n\ndrivethru-payable-matching 0.9.5\n\n- Version bump to 0.9.5\n- Documentation improvements and clarifications in SKILL.md\n- No functional code changes noted, minor updates for clarity and version consistency\n\nv0.9.4 | 2026-09-17T14:43:12.062Z | auto\n\nVersion 0.9.4\n\n- Updated version number in SKILL.md to 0.9.4.\n- Documentation and metadata maintained for clarity; no changes noted to functional logic or interfaces.\n\nv0.9.3 | 2026-09-17T13:57:54.074Z | auto\n\ndrivethru-payable-matching 0.9.3\n\n- Updated version to 0.9.3.\n- Documentation and metadata updates in SKILL.md; no functional changes to documented features.\n- No user-facing changes in scripts/paymatch.py.\n\nv0.9.2 | 2026-09-17T13:34:29.403Z | auto\n\ndrivethru-payable-matching 0.9.2\n\n- Updated package version to 0.9.2.\n- Documentation and metadata refresh in SKILL.md; no functional or interface changes.\n- No new commands or configuration required.\n\nv0.9.1 | 2026-09-17T02:59:39.730Z | auto\n\nVersion 0.9.1\n\n- Added document selection options to the folder extraction command (`scripts/paymatch.py extract`), allowing for filtering by document IDs, name substrings, and batching with max document limits.\n- Updated SKILL.md documentation to describe the new extraction filtering options.\n- Removed the obsolete skill-card.md file.\n\nv0.9.0 | 2026-08-03T14:58:06.187Z | auto\n\ndrivethru-payable-matching 0.9.0\n\n- Documentation updated across SKILL.md and reference guides for clarity and detail.\n- Removed the obsolete skill-card.md file.\n- Improved instructions on connecting to Odoo and tool usage.\n- No functional (code) changes; this release focuses solely on documentation refinements.\n\nv0.8.0 | 2026-08-03T13:31:29.814Z | auto\n\ndrivethru-payable-matching v0.8.0\n\n- Adds support for splitting a purchase order into multiple vendor bills when reconciling POs with multiple Sports Inc invoices (multi-shipment/split-shipment handling).\n- Documents the use of `ap_*_bill_line(s)` tools for creating one vendor bill per shipment.\n- Updates reference documentation covering the matching procedure and Sports Inc payables flow.\n- Removes outdated skill-card documentation.\n\nv0.7.0 | 2026-07-28T18:18:21.694Z | auto\n\n- Adds automated bill posting for Sports Inc flow: bills are now posted (not left draft) when the vendor invoice total matches the bill within tolerance.\n- \"Create bill\" logic updated to \"post bill if it matches\"; mismatched bills remain in draft for review.\n- Documentation and SKILL.md updated to clarify new behavior and API usage.\n- Removes obsolete skill-card.md file.\n\nv0.6.0 | 2026-07-23T15:33:25.772Z | auto\n\n- Bumped version to 0.6.0.\n- Updated documentation in SKILL.md; clarified instructions and best practices.\n- Removed the skill-card.md file for cleanup.\n- No changes to core logic or functionality.\n\nv0.5.0 | 2026-07-23T14:17:24.192Z | auto\n\n- Bumped version to 0.5.0.\n- Removed the skill-card.md file.\n- Minor documentation/content updates to SKILL.md.\n\nv0.4.1 | 2026-07-23T11:41:45.143Z | auto\n\n- Added scripts/_bootstrap.py to enable script self-bootstrap capability.\n- Now requires the uv binary (in addition to python3) to support self-bootstrap.\n- Updated SKILL.md install instructions and metadata to reflect new dependency and bootstrap behavior.\n- Removed obsolete skill-card.md file.\n\nv0.4.0 | 2026-07-22T20:40:27.963Z | auto\n\ndrivethru-payable-matching 0.4.0\n\n- Switched PDF extraction to pymupdf for improved support of custom-encoded and scanned documents; pypdf now a fallback.\n- Added automatic vision/OCR workflow for PDFs that cannot be extracted as text (scanned or custom-encoded).\n- Enforced that all PO/document annotations are posted as internal log notes, never as \"Send message\" (avoids notifying vendors).\n- CLI helper now includes a \"render\" command for document page rasterization and OCR.\n- Updated installation requirements and documentation for new PDF extraction logic.\n- Removed obsolete skill-card.md file.\n\nv0.3.0 | 2026-07-21T23:53:25.057Z | auto\n\n- Added detailed guidance on connecting to Odoo, including using native MCP tools and shell options.\n- Clarified the preferred workflow based on available runtime (native tools vs. shell vs. operator setup).\n- Expanded troubleshooting and operational notes to prevent agents from guessing or giving incorrect status about system connectivity.\n- Removed the redundant skill-card.md file.\n\nv0.2.0 | 2026-07-21T21:26:32.153Z | auto\n\ndrivethru-payable-matching 0.2.0\n\n- Adds support for buying-group payable matching: integrates with the SportsLink API to automatically retrieve Sports Inc invoices, reconcile them to POs, correct price variances, and create draft vendor bills.\n- Updated documentation to describe the Sports Inc payable automation flow.\n- Added reference documentation for Sports Inc payables process.\n- Removed obsolete skill-card file.\n\nv0.1.0 | 2026-07-21T13:55:30.859Z | auto\n\ndrivethru-payable-matching 0.1.0 — initial release.\n\n- Introduces automated payable matching for BaconCo using Odoo's Documents app and purchase orders.\n- Reconciles vendor documents (invoices, confirmations, acknowledgements) against purchase orders, correcting line pricing when supported by the documents.\n- Files each document to a Matched or Questions folder based on reconciliation outcome; escalates genuine questions to a designated reviewer.\n- Performs efficient local PDF text extraction to minimize resource usage and keep context small.\n- Provides CLI helper script (paymatch.py) for batching extraction, retrieval, correction, and filing actions.\n- Runs via the drivethru_mcp Odoo server; requires ODOO_MCP_URL and ODOO_MCP_TOKEN.\n\nArchive index:\n\nArchive v0.10.0: 7 files, 45699 bytes\n\nFiles: references/matching_procedure.md (16769b), references/sportsinc_payables.md (18116b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (44371b), skill-card.md (2566b), SKILL.md (27815b), _meta.json (146b)\n\nFile v0.10.0:SKILL.md\n\n---\nname: drivethru-payable-matching\ndescription: >\n  Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents\n  app against their purchase orders and correct incorrect PO line pricing. Use\n  for requests like \"check the Purchasing folder against the POs and fix the\n  pricing\", \"match the vendor invoice / order confirmation / acknowledgement to\n  its PO\", \"AP price matching / invoice-to-PO matching / three-way match\",\n  \"reconcile the vendor documents and mark the POs checked\", or \"go through the\n  Purchasing folder\". The flow: read every document in a Documents-app folder\n  (extracting text out-of-context so large batches don't bloat the context\n  window — falling back to a page render + OCR/vision for scanned or\n  custom-encoded PDFs that won't extract as text), pull the PO number / line\n  items / unit prices from each, compare to the purchase order line by line,\n  correct any wrong `price_unit`, post a \"checked\" log note on the PO (internal,\n  never a \"Send message\"), and FILE every document into the `Matched` or\n  `Questions` subfolder — escalating genuine questions to a reviewer (default\n  Zach Tucker). Also runs the buying-group payables flow: pull Sports Inc\n  invoices from the SportsLink API (via the `sportsinc-sportslink` adapter),\n  reconcile each to its PO, correct price variances, create the vendor bill and\n  — when the bill total matches the invoice within tolerance — POST it, leaving\n  any mismatch in draft for a human (\"get the Sports Inc invoices and bill\n  them\", \"match the SI invoices to POs and post the payables\", \"match the vendor\n  invoice and post the bill if it matches\"). Handles the multi-shipment case\n  where one PO returns several Sports Inc invoices, splitting it into one vendor\n  bill per shipment via `account.move.line` edits (the `ap_*_bill_line(s)`\n  tools). Runs at volume on a low-cost model.\n  Driven by the Odoo `drivethru_mcp` MCP server; complements the broader\n  `drivethru-odoo` skill.\nversion: 0.10.0\nemoji: 🧾\nhomepage: https://www.odoo.com\nmetadata:\n  openclaw:\n    requires:\n      env: [ODOO_MCP_URL, ODOO_MCP_TOKEN]\n      bins: [python3, uv]   # uv powers the scripts' self-bootstrap fallback\n    primaryEnv: ODOO_MCP_TOKEN\n    envVars:\n      ODOO_MCP_URL:\n        required: true\n        description: >\n          Full URL of the Odoo MCP endpoint, e.g.\n          `https://odoo.example.com/drivethru_mcp/v1` — the MCP server exposed by\n          the `drivethru_mcp` Odoo module, not the Odoo base URL.\n      ODOO_MCP_TOKEN:\n        required: true\n        description: >\n          The `drivethru.mcp_key` value from the `drivethru_mcp` module, sent as\n          `Authorization: Bearer`. Treat as a secret; never paste into chat.\n    install:\n      uv:\n        - mcp>=1.9.0\n        - pymupdf>=1.24   # primary local PDF text extraction + page rasterization:\n                          # honours ToUnicode/Type3 (custom-encoded) fonts pypdf can't\n                          # read, and renders needs_vision docs without system poppler\n        - pypdf>=4.0      # last-resort text-extraction fallback\n---\n\n# Payable matching (Purchasing folder → purchase orders)\n\nReconcile vendor documents against their purchase orders and fix PO line\npricing. A **vendor document** is an order confirmation, shipment\nacknowledgement, or invoice filed in the Documents app's **Purchasing** folder;\nits authority for what BaconCo will be billed is the vendor's own numbers.\n\nThe task, per document: read it → extract the **PO number, line items, and unit\nprices** → find the PO in Odoo → compare **line by line** → correct any wrong\n`price_unit` → post a \"checked\" note on the PO → **file the document** into\n`Matched` (reconciled) or `Questions` (needs a human). Nothing stays in the\ninbox.\n\nThis changes live financial data (PO prices, chatter, activities). The\nstanding request *\"review the Purchasing folder and fix the pricing\"*\nauthorizes the corrections and the \"checked\" notes; still, **only correct a\nline when the vendor document unambiguously supports it** — when in doubt, route\nit to Questions rather than guessing.\n\n## Always use Log note, never Send message\n\nEvery annotation you leave on a PO or a document — the \"checked\" note, a\npartial-shipment note, a question, any comment — MUST be an internal **Log\nnote**, never a **Send message**. In Odoo chatter, \"Send message\" notifies the\nrecord's followers and can **email the vendor or customer**; a Log note stays\ninternal. These vendor documents are BaconCo's own AP working notes — nothing\nhere should ever leave Odoo as an email.\n\nThe helper's `matched` / `questions` commands and the `po_post_message` /\n`documents_post_message` MCP tools post internal **log notes** — use them. Do\n**not** reach for any \"send message\", email, or notify-followers path when\nannotating a PO or document, and if a tool ever exposes a note-vs-message\nchoice, always choose the internal note.\n\n## How you reach Odoo (runtime-aware — read this first)\n\nThis skill drives the same Odoo **`drivethru_mcp`** MCP as `drivethru-odoo`, and\n`ODOO_MCP_URL` / `ODOO_MCP_TOKEN` are already configured. **Never say you \"can't\nreach Odoo\" or \"don't have the tools in this thread\" and guess instead — call a\ntool, or state the exact call you tried and the error you got.**\n\n- **Native / callable MCP tools (preferred, no shell needed).** If the Odoo\n  tools are attached to you natively, **call them directly** — e.g.\n  `documents_list_folders {\"name\": \"Purchasing\"}`, whose `document_count` is how\n  many documents are waiting to be matched. They may be **deferred** — if your\n  runtime lazy-loads tools, search your tools for `documents_search`,\n  `ap_search_purchase_orders`, `ap_update_po_lines`, `po_post_message`,\n  `po_get_messages`, etc. and load them before calling. \"I don't see them yet\"\n  is not \"I don't have them.\"\n- **Shell (`scripts/paymatch.py`) — only if you have a shell.** The helper below\n  is a context-economy optimization (bulk PDF text extraction, one call per\n  folder); prefer it for large batches when a shell is available.\n- **Not connected at all?** Attaching the `drivethru_mcp` MCP natively — or\n  giving the agent a shell — is an **operator step**; env vars alone don't\n  attach the tools. See `drivethru-odoo`'s **Operator setup** and\n  **Troubleshooting** sections before concluding you can't reach Odoo.\n\n## The engine: `scripts/paymatch.py` (keep the work out of context)\n\nReading PDFs is the expensive part. `documents_get` returns each file as\n**base64**, and a multimodal PDF reader adds a **page image** — both dwarf the\nfew hundred characters of text that matter. Do that per file across a folder,\nmany times a day, and the context window fills with bytes and renders (and the\ntoken bill balloons). So **never loop `documents_get`/a PDF reader over the\nfolder.** Use the helper, which does the heavy, deterministic work locally and\nreturns only what the model needs.\n\n```bash\n# 1. Read the whole folder as TEXT (no base64, no render) — ONE call\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\"}'\n\n# 1a. Scheduled batch? Narrow the extraction instead of pulling every file:\n#     document_ids = only these docs; name_excludes = drop by name substring\n#     (case-insensitive); max_docs = cap the batch (extras reported as\n#     skipped_beyond_max_docs). folder still scopes the listing.\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"document_ids\": [7617, 7536]}'\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"name_excludes\": [\"sanmar\"], \"max_docs\": 8}'\n\n# 1b. Any document flagged needs_vision (scanned or custom-encoded) → render its\n#     page(s) to PNG (+ OCR if tesseract is present), then read the image(s)\npython3 scripts/paymatch.py render '{\"document_id\": 485}'\n#\n# Each extracted document also carries `po_candidates` (the distinct PO refs in\n# its text, each canonicalized to Odoo's `P<digits>` name — so a bare \"PO 14594\"\n# and a \"P14594\" token both surface as `P14594`) and `multi_invoice` (true when\n# it holds several invoices — >1 distinct PO, or >1 \"Invoice #/No/Number\"\n# header). On multi_invoice, treat the PDF as several invoices: process EACH\n# against its own PO (resolve by name, dedupe, bill/post per invoice) rather than\n# billing only the first — a batch PDF is usually one invoice per page. These are\n# HINTS; verify against the text.\n\n# 2. Per document: pull the PO trimmed to matchable fields (incl. qty_received)\npython3 scripts/paymatch.py po-lines '{\"po\": \"P13189\"}'\n\n# 2b. Partial shipment? Read the PO's prior log notes to see what's already checked\npython3 scripts/paymatch.py notes '{\"po\": \"P13137\"}'\n\n# 3. Correct any wrong line(s) in one call (PO must be confirmed)\npython3 scripts/paymatch.py apply '{\"po_id\": 13145, \"lines\": [{\"line_id\": 40941, \"price_unit\": 11.94}]}'\n\n# 4a. Clean/fixed → post the checked LOG NOTE AND file to Matched (one call)\npython3 scripts/paymatch.py matched '{\"po_id\": 13145, \"document_id\": 481, \"body\": \"Pricing checked against SanMar Order Confirmation ... corrected size S $13.94→$11.94.\"}'\n\n# 4b. Genuine question → post a LOG NOTE and file to Questions (one call).\n#     The note explains what happened; the folder IS the review queue. Do NOT\n#     pass `reviewer` unless a human genuinely needs a to-do assigned — a\n#     `reviewer` turns the note into an assigned activity, and blanket\n#     activities on every Questions doc just spam the reviewer's to-do list.\npython3 scripts/paymatch.py questions '{\"document_id\": 485, \"question\": \"Totals do not reconcile — vendor total $X vs PO $Y.\"}'\n```\n\n**PO lookup is BY DISPLAY NAME (matched on the number), never by id.** The\n`P#####` printed on an invoice is the purchase order's **name**, not its\ndatabase id — they are different numbers (name `P12774` lives at id `12730`; id\n`12774` is an unrelated PO). Always resolve with `po-lines '{\"po\": \"P12774\"}'`\nand use the returned record's numeric `id`. **Never** call\n`ap_get_purchase_order {po_id: 12774}` with the P-number's digits — it silently\nreturns the WRONG order.\n\n`po-lines` matches on the **canonical PO number**, so the `P` prefix is\noptional: vendors routinely print the number bare or labeled — `14594`,\n`PO 14594`, `P.O.#14594` — and all of them resolve to the PO named `P14594`\n(it searches Odoo by both spellings and filters to the one whose name has that\nnumber). Pass whatever the invoice shows; you do **not** need to prepend `P`\nyourself. It still requires **exactly one** number match — zero or several\nreturns `found: false` with `candidates` (and the `canonical` form it looked\nfor), which is the only case that goes to Questions. A PO whose number exists in\nOdoo must be resolved and processed, never escalated as \"no P-number\".\n\nEvery command prints one JSON object, or `{\"error\": {...}}` with a non-zero\nexit. Requires `ODOO_MCP_URL` / `ODOO_MCP_TOKEN` (if missing, the script exits\nwith `config_error` — stop and tell the user to configure them; never ask for\nthe key in chat).\n\n**Dependencies self-install.** The script's deps (`mcp`, `pymupdf`, `pypdf`)\nare declared in the frontmatter `install.uv`, but not every OpenClaw host honors\nit — so `scripts/_bootstrap.py` ensures them at startup: on a missing import it\nbuilds a cached `uv` venv (in `$PAYMATCH_DATA_DIR` or `~/.drivethru/paymatch`)\nand re-execs. Hosts that pre-install make it a no-op; otherwise the **first run\npays a one-time install**, then it's cached. So a `ModuleNotFoundError` (e.g.\n`No module named 'anyio'`) is not a dead end — it self-heals on the next run, as\nlong as **`uv` is on PATH**. If the script exits saying `uv` is missing, that's\nan operator step (install `uv`, or the host must honor `install.uv`); fall back\nto the native `documents_*` / `ap_*` / `po_*` MCP tools in the meantime.\n\nIf you have **no shell** (e.g. a chat agent), use the native `documents_*` /\n`ap_*` / `po_*` MCP tools directly — they do everything the script does; the\nscript is only a context-economy wrapper for bulk PDF extraction. Working\nnatively, still fetch **one document at a time** and drop the base64 — never\npull a whole folder's bytes into context. (If creds are unset or the MCP isn't\nattached, see `drivethru-odoo`'s Troubleshooting — don't guess.)\n\n## Procedure\n\n1. **Read the folder once.** `paymatch.py extract '{\"folder\": \"Purchasing\"}'`\n   → `documents[]` with `document_id`, `name`, and extracted `text`. Work from\n   that text. Extraction runs PyMuPDF → `pdftotext` → `pypdf` and **quality-gates\n   the result**, so a document flagged `needs_vision: true` is one no text pass\n   could read reliably — either scanned/image-only, OR a **custom-encoded PDF**\n   (Type3 / no usable ToUnicode — e.g. some Charles River Apparel shipment\n   confirmations) that renders perfectly but extracts as empty/garbage.\n   **Do not escalate a `needs_vision` document as \"unreadable\" — read it.**\n   Run `paymatch.py render '{\"document_id\": <id>}'` to rasterise its page(s) to\n   PNG (plus OCR text when `tesseract` is installed), then read the image(s)\n   with vision to pull the PO number and line items and match as normal. (No\n   shell? Fetch with `documents_get {\"document_id\"}` and read the bytes with a\n   vision reader — same idea.) The old failure mode — \"couldn't get a reliable\n   PO number from unattended extraction, please review manually\" — is exactly\n   this case, and the render/vision fallback resolves it instead of punting.\n\n2. **Extract from each document's text (not its filename).** The PO number is\n   **inside** the document; a filename may show the vendor's order number\n   instead (e.g. `Order Acknowledgement 48482500.pdf` whose real PO is\n   `P13183`). Capture item/style, color, size, quantity, and **unit price** per\n   line.\n\n3. **Pull the PO.** `paymatch.py po-lines '{\"po\": \"<PO#>\"}'` → the PO trimmed to\n   `{po_id, name, vendor, partner_ref, state, amount_untaxed, freight_cost,\n   fees_cost, lines:[{line_id, sku, style, description, qty, price_unit}]}`.\n   Pass the PO number **exactly as the invoice prints it** — the `P` prefix is\n   optional. Vendors often omit it (`14594`, `Cust PO 14594`, `PO#14594`); that\n   IS `P14594`, and `po-lines` resolves it by number. **Do not** decide \"there's\n   no P-number, escalate\": a number is a number. Confirm the vendor /\n   `partner_ref` line up with the document. The PO must be `state: \"purchase\"` to\n   edit lines. Only `found: false` (with `candidates` + the `canonical` form it\n   searched) means **no PO with that number exists** — that, and only that, is a\n   Questions case.\n\n4. **Compare line by line.** Pair each document line to a PO line by **(style/\n   item, color, size)** — never by row order. Size upcharges are normal (base\n   sizes one price; 2XL/3XL/4XL higher) — that's correct pricing, not an error.\n\n5. **Correct mismatches** in one call:\n   `paymatch.py apply '{\"po_id\", \"lines\":[{\"line_id\",\"price_unit\"}]}'`. Set\n   `freight_cost`/`fees_cost` only when the **document itself** gives an\n   authoritative figure — a pre-existing freight estimate or partner fee the\n   document doesn't itemize is not a line error; note it for the invoice match.\n\n6. **File the document — always.**\n   - **Reconciled** (matched, or corrected with confidence) →\n     `paymatch.py matched '{\"po_id\", \"document_id\", \"body\": \"<what you checked/fixed>\"}'`\n     (posts the checked note + moves the doc to `Matched`).\n   - **Genuine question** →\n     `paymatch.py questions '{\"document_id\", \"question\": \"<what to resolve>\"}'`\n     (posts a log note + moves the doc to `Questions`). The note is the record;\n     the folder is the queue. Add `reviewer` ONLY when a human genuinely needs a\n     to-do assigned — do not attach an activity to every Questions doc. Pass\n     `po_id` + `po_note` too if the PO also warrants a note.\n\n## Totals are a cross-check, not the source of truth\n\nA shipment acknowledgement is often **one box of a multi-shipment order**: it\ncovers a subset of the PO's lines, so its total is legitimately **less** than\nthe PO total — the rest ships later. Match on lines; a total gap fully explained\nby un-shipped lines is **not** a discrepancy. Say so in the checked note so a\nhuman isn't confused by the header total.\n\n## Partial shipments: track cumulative coverage and call the last one\n\nWhen a document is a **partial shipment** (it reconciles cleanly but covers only\nsome of the PO's lines), do two things before you file it:\n\n1. **Name the lines this shipment covers.** In the `matched` log note, list the\n   specific lines / SKUs this acknowledgement checks (e.g. *\"Shipment of P13137\n   — checked lines 3,4,7,9,10,11 (styles …), all $1.79 and matching\"*). That is\n   what turns the PO's chatter into a running ledger of what has been checked.\n\n2. **Look back before you post, and call the final shipment.** Read the PO's\n   prior log notes with `paymatch.py notes '{\"po\": \"P13137\"}'` (or the\n   `po_get_messages` tool) and cross-check `qty_received` / `qty` per line from\n   `po-lines`. If the lines you just checked, **unioned with the lines earlier\n   notes already checked, now cover every line on the PO** (nothing left on\n   back-order / un-received), then this is the last piece — **say so explicitly\n   in the note**, e.g. *\"✅ Final shipment — all 11 lines on P13137 are now fully\n   checked across all shipments; PO complete.\"* If lines still remain, state\n   which ones are still outstanding instead of implying completion.\n\nOnly claim \"fully checked\" when the prior log notes (and `qty_received`) actually\nshow the rest was checked — those notes are the evidence, which is exactly why\nevery annotation is an internal log note that accumulates on the PO.\n\n## When to escalate (Questions) vs. just fix (Matched)\n\nEscalate only a **real** ambiguity: can't read the PO#, the PO doesn't resolve,\nprices don't reconcile, unexpected/missing lines, or the wrong vendor. An\nunambiguous correction the vendor document plainly supports (a size priced $2\noff the vendor's own confirmation) is a **Matched** fix — applying it is exactly\nwhat the task asks. Don't manufacture a question where the document is clear;\ndon't guess where it isn't.\n\nWhen the reviewer **answers** an escalation, carry out their decision with your\ntools. Don't hand it back as a to-do list. For \"update the PO quantities to\nmatch the invoice\" on a dropship PO, follow\n[`references/sportsinc_payables.md`](references/sportsinc_payables.md) →\n*Acting on the reviewer's answer*: raise the qty, validate the new dropship\npicking, fix the bill, then post it.\n\n## Report\n\nPer document: PO number, lines changed (old → new) or \"no change\", whether the\nchecked note was posted, and Matched vs Questions (and why). End with a folder\ntally so the inbox state is obvious.\n\n## Creating and posting the payable, and buying-group sources\n\nThe same reconcile-then-file loop extends to **creating the vendor bill** once a\nPO's pricing is reconciled, and then **posting it when it matches**:\n\n```bash\n# Create the DRAFT bill from a reconciled PO\npython3 scripts/paymatch.py bill '{\"po_id\": 13145, \"vendor_bill_number\": \"<inv#>\", \"invoice_date\": \"2026-07-22\", \"expected_total\": 1041.90, \"tolerance\": 0.02, \"reviewer_user_id\": 6, \"review_note\": \"...\"}'\n\n# Post it — ONLY when the bill total matches the vendor invoice\npython3 scripts/paymatch.py post '{\"bill_id\": 8842, \"expected_total\": 1041.90, \"tolerance\": 0.02, \"note\": \"Matched to <inv#>; totals reconcile.\"}'\n```\n\n`bill` creates the bill in **draft** and schedules a review activity. Then\n`post` performs the **match & post** step through the guarded\n`ap_post_vendor_bill` tool:\n\n- **Post only what matches.** `expected_total` (the vendor invoice total) is\n  **required** for `post`, and the tool **refuses to post** a bill whose total\n  deviates beyond `tolerance` (an **absolute currency amount** — e.g. `0.02` is\n  two cents, not 2%). A mismatch comes back as an error and the bill stays in\n  draft — **route it to a human, never post it.** This is the whole safety\n  contract: a vendor bill only posts when its numbers reconcile to the invoice.\n- **It's live and hard to reverse.** Posting writes the bill to the ledger\n  (unposting needs a reversing entry). Only post a bill you created from a PO\n  you reconciled in this same run, for an invoice whose total you verified.\n  Anything ambiguous — wrong total, wrong vendor, unexpected lines, a PO that\n  didn't fully reconcile — is a **Questions** escalation, not a post.\n- **Dry-run first if unsure.** Pass `\"post\": false` to preview: it returns\n  `would_post` + `total_check` (expected vs actual vs tolerance) and posts\n  nothing, so you can confirm the match before committing.\n- **Idempotent + audited.** Re-posting an already-posted bill is a safe no-op\n  (`already_posted: true`); each post leaves an internal **log note** on the\n  bill (never a \"Send message\", so nothing is emailed to the vendor).\n\nPosting is **opt-in per run**: if the task is only \"create the payables\" or a\nhuman wants to review before posting, stop at `bill` (draft) and skip `post`.\n\n### Sports Inc (buying group — no per-invoice documents)\n\nSports Inc doesn't email individual invoices; they live in the **SportsLink\nAPI**. The `sportsinc-sportslink` adapter pulls them (normalised to the same\ninvoice shape a PDF would give) and marks them consumed. The end-to-end loop —\npull active SI invoices → reconcile to the PO → **auto-fix price variances,\nescalate quantity/line variances** → create the bill → **post it when the total\nmatches (`expected_total`), else leave it in draft for a human** → **mark the SI\ndoc consumed only after the bill exists** (exactly-once) — plus credit/scanned\nhandling and the SI-fee/`expected_total` nuance, is the dedicated procedure in\n[`references/sportsinc_payables.md`](references/sportsinc_payables.md). Read it\nbefore running the SI payables flow.\n\n**One PO, several invoices (multi-shipment).** When a PO shipped in several\nboxes, Sports Inc returns **several documents for one PO number** — each with its\nown lines, freight, and `si_upcharge`. That PO bills as **one vendor bill per\ndocument**: create the draft, then carve it to each shipment with the\n`account.move.line` tools (`ap_get_vendor_bill` to see the lines, then\n`ap_delete_bill_lines` / `ap_update_bill_lines` / `ap_create_bill_line` to make\neach bill equal exactly one document), and post each at its own `docTotal`.\nCreate **one bill per SI document but post only the ones the PO's quantities\nsupport**: a document the PO can't cover (an **over-invoice** — e.g. the vendor\ninvoiced replacements for units it never originally shipped, after earlier bills\nconsumed the PO's whole quantity) still becomes a bill via `ap_create_draft_bill`\n— built off the payload's product ids and left in **draft with a review\nactivity**, never posted. **Skip** a PO whose SI documents lack line-level\ndetail. The full carve procedure — and the `min_tracking_count` search filter\nthat surfaces these POs — is in `references/sportsinc_payables.md` →\n*Multiple invoices for one PO*.\n\nScope note: this skill reconciles, drafts, and — when the numbers match —\n**posts**. A bill only posts when its total reconciles to the vendor invoice\nwithin tolerance; anything that doesn't match stays in draft and goes to a\nhuman. Only create/post bills when the task is payables (folder pricing review\nalone stops at the \"checked\" note + filing), and stop at the draft (`bill`,\nskip `post`) whenever the task is \"create the payables\" or a human wants to\nreview before posting.\n\n#### Agent-to-Agent (A2A) Mode for Sports Inc\n\nFor deployments with a **dedicated Sports Inc agent**, this agent can fetch the\ninvoices by *delegating* to that agent over A2A instead of running\n`sportsinc-sportslink` itself. **This** agent owns `SPORTSINC_API_KEY` (it\nrepresents your company) and *shares* it with the Sports Inc agent for the\nduration of each delegated call. The A2A call is orchestrated by **you (the\nagent) using the platform MCP tools** — there is no Python helper for it.\n\n**Setup (platform console):**\n\n1. **Create the Sports Inc agent** and install the `sportsinc-sportslink` skill.\n   Do **not** bind `SPORTSINC_API_KEY` to it — the key lives on this agent.\n2. **Bind `SPORTSINC_API_KEY` to this agent** (the caller) on its Credentials tab.\n3. **Bind a delegation connection**: caller = this agent, target = the Sports Inc\n   agent, with a label/instructions describing when to use it.\n4. **Share the credential** on that connection: on this agent's Connections tab,\n   under the Sports Inc connection, check `SPORTSINC_API_KEY` in \"Credentials to\n   share with this connection\". Nothing is shared unless you check it.\n\n**A2A call flow (agent-executed MCP tools):**\n\n1. `get_my_bundle()` → its `structuredContent.connections[]` lists your bound\n   agents; each entry is `{ targetAgentUid, displayName, label, instructions }`.\n   Pick the Sports Inc one (match on `displayName`/`label`).\n2. `start_agent_conversation({ agent_uid: <targetAgentUid> })` → returns a\n   `conversation_id`.\n3. `send_message({ conversation_id, content })` where `content` is the JSON\n   request for the Sports Inc agent, e.g. `{\"action\": \"get-for-a2a\", \"params\":\n   {\"include_historical\": false}}`. The reply is the `get-for-a2a` envelope\n   (`{success, invoices, metadata, error}`) — on `success:false`, read\n   `error.retriable` to decide retry vs. escalate.\n\n**Async-task delegation (what the multi-invoice routine uses).** When you\ndelegate the lookup as a **task** (`start_task`) rather than a synchronous\n`send_message`, the Sports Inc agent replies in its task **summary**, which is\n**size-capped (~20k chars)** — a raw JSON dump of several POs overflows it and\nis silently truncated, handing you a half-parsed payload. So **ask for a compact\nmarkdown breakdown, not JSON**: one section per PO, each SI document with its\n`si_doc_number`, `invoice_number`, `invoice_date`, `due_date`, `is_credit`,\n`has_lines`, the money (`merchandise`, `freight`, `si_upcharge`, `total`), and a\nterse line per item (`item`, `upc`, `size`, `qty_shipped`, `net_price`,\n`extension`, `description`). Markdown carries all the same data — you don't need\nstrict JSON. If the reply looks cut off or is flagged truncated, treat it as\n**incomplete** and re-request (or fewer POs at a time) rather than billing a\npartial payload.\n\nThen continue the normal reconciliation/draft-bill loop with the returned\n`invoices` (same normalised shape as `sportslink.py list`).\n\n**How the handoff works (pull/broker):**\n\n- The delegation binding is the allowlist — only bound agents are reachable, and\n  the call chain is depth-limited (max 5) against loops.\n- The shared credential is **pulled on demand, not pushed**. When the Sports Inc\n  agent handles the delegated call, its runtime calls\n  `get_delegated_credentials({ conversation_id })`. The platform verifies it is\n  the target of that delegated conversation and that the connection shares the\n  credential, **logs the access** in `agent_connection_audit_log`, and returns a\n  `{ env_key: value }` map. The runtime exposes it as env for the turn, so the\n  Sports Inc agent's `sportsinc-sportslink` skill reads `SPORTSINC_API_KEY`\n  normally. The secret only moves when the target actually asks for it, and every\n  access is audited — nothing is provisioned permanently onto the Sports Inc agent.\n\n## Deep reference\n\nFull matching rules, the exact MCP tool payload shapes, a worked five-document\nexample, and the **low-cost model recommendation + per-match economics** are in\n[`references/matching_procedure.md`](references/matching_procedure.md). Read it\nwhen you need the details behind a step; the SKILL above is the operating loop.\n\nFile v0.10.0:_meta.json\n\n{\n  \"ownerId\": \"kn715tnf30wegyr6mdbfa17avd87bjr6\",\n  \"slug\": \"drivethru-payable-matching\",\n  \"version\": \"0.10.0\",\n  \"publishedAt\": 1790187380449\n}\n\nFile v0.10.0:references/matching_procedure.md\n\n# Payable matching — full procedure, tool shapes, example, economics\n\nReference behind `SKILL.md`. Read it when you need the detail behind a step,\nthe exact field names a payload carries, or the cost model for running this at\nvolume.\n\n---\n\n## 1. Why the design is shaped this way\n\nTwo forces drive every choice:\n\n- **Context economy.** Documents are PDFs. `documents_get` returns their bytes\n  as base64; a multimodal PDF reader adds a page image. Both are enormous next\n  to the ~300–600 characters of text that actually matter, and this runs over\n  many documents many times a day. So text extraction is pushed into\n  `scripts/paymatch.py` (`extract`), which decodes and reads the text **locally**\n  and returns text only. The model never sees base64 or a render. `po-lines`\n  likewise trims the verbose PO payload to the matchable fields. The result: a\n  document \"match\" costs the model a few thousand tokens, not tens of thousands.\n\n  Extraction runs **PyMuPDF → poppler `pdftotext -layout` → pypdf** and\n  quality-gates the output. PyMuPDF is first because it honours ToUnicode CMaps\n  and reads **Type3 / custom-encoded fonts** that pypdf returns as empty or\n  garbage (the failure that stranded Charles River Apparel confirmations). A\n  result that is empty or fails the reliability gate flags the document\n  `needs_vision: true`; the model then calls `render` to rasterise the page(s)\n  with PyMuPDF (no system poppler) and reads them with vision (plus tesseract OCR\n  if present) — keeping even the unreadable-text case out of a dead-end\n  escalation, while still never pulling raw base64 into context.\n\n- **Judgment stays with the model.** The script does deterministic I/O (fetch,\n  decode, extract, apply a price, move a file). The *matching decision* — which\n  document line pairs to which PO line, whether a total gap is a partial\n  shipment, whether a freight figure is authoritative, whether something is a\n  genuine question — needs the model, because vendor documents vary too much for\n  a rigid parser. Get this division wrong in either direction and you either\n  bloat context (model reading raw bytes) or get brittle matches (script\n  guessing intent).\n\n---\n\n## 2. The tool surface (MCP), and the payload shapes you'll see\n\nThe skill drives the Odoo `drivethru_mcp` MCP tools. `paymatch.py` wraps the\nones below; the field names are what the tools actually return (know them so you\ndon't waste calls discovering them).\n\n### Documents app\n\n- `documents_list_folders {name?, parent_id?}` → `{folders: [{id, name, parent,\n  document_count}]}`. Resolve `Purchasing` → its id; list its children with\n  `{parent_id}` to get the `Matched` / `Questions` folder ids.\n- `documents_search {folder_id, limit, offset, include_subfolders?}` →\n  `{documents: [{id, name, type, mimetype, file_size, folder:{id,name}, tags,\n  res_model, res_id, ...}], total_matched}`. Metadata only — **no bytes**.\n  Paginate on `total_matched`.\n- `documents_get {document_id}` → the metadata **plus** `data_base64` (the file\n  bytes) and `open_activities`. Files over the 20 MB guard return a\n  `download_url` instead of bytes. (`paymatch.py extract` calls this per file\n  and throws the base64 away after extracting text.)\n- `documents_update {document_id, fields:{folder_id}}` → moves the document\n  (this is how a document leaves the inbox — there is no delete tool).\n- `documents_post_message {document_id, body, activity_user?, activity_summary?,\n  activity_date_deadline?}` → posts a chatter note and, with `activity_user`,\n  schedules a To-Do for that reviewer. `activity_user` matches login/email\n  first, then name.\n\n### Accounts payable\n\n- `ap_search_purchase_orders {search?, vendor?, state?, limit?}` →\n  `{count, purchase_orders: [{id, name, partner_id, partner_name, partner_ref,\n  amount_untaxed, amount_total, freight_cost, fees_cost, state,\n  receipt_status, invoice_count, buying_group, ...}]}`. `search` matches PO name\n  / partner_ref / vendor order number.\n- `ap_get_purchase_order {po_id}` → the header fields above **plus**\n  `lines: [{id, product_id, product_name, product_sku, description,\n  product_qty, qty_received, qty_invoiced, price_unit, price_subtotal,\n  style_number, intelligent_id, ...}]` and `existing_bills`. `id` on a line is\n  the `line_id` you pass to update. (`paymatch.py po-lines` trims this to\n  `{line_id, sku, style, description, qty, qty_received, qty_invoiced,\n  price_unit, price_subtotal}` — `qty_received` vs `qty` is the per-line signal\n  for whether a partial shipment has now completed the PO.)\n- `ap_update_po_lines {po_id, lines:[{line_id, price_unit?, product_qty?}],\n  freight_cost?, fees_cost?, allow_non_dropship?}` → `{po_name,\n  lines_updated:[{line_id, old_price, new_price, old_qty?, new_qty?,\n  is_dropship?}], new_pickings, new_amount_total}`. The PO must be\n  `state: \"purchase\"` (confirmed). `product_qty` is **only** for carrying out a\n  reviewer-approved qty raise (increase-only, dropship lines unless\n  `allow_non_dropship`). Never use it to \"fix\" a variance on your own; see\n  `sportsinc_payables.md` → *Acting on the reviewer's answer*.\n- `ap_create_vendor_bill {po_id, vendor_bill_number?, invoice_date?, line_ids?,\n  expected_total?, tolerance?, reviewer_user_id?, review_note?}` → **draft**\n  `account.move`. The payables tail (`paymatch.py bill`). Bills the PO's\n  remaining `qty_to_invoice`; errors \"No billable lines\" when the PO has none\n  left.\n- `ap_create_draft_bill {po_id|vendor_id, vendor_bill_number?, invoice_date?,\n  lines?:[{product_id|product_name, quantity, price_unit, tax_ids?, …}],\n  reviewer_user_id?, review_note?}` → an **off-PO draft** bill, independent of\n  `qty_to_invoice`, that **never posts**. For the over-invoice case (SI billed\n  items the PO can't cover — e.g. replacements for units never received): seed\n  the lines from the payload with the PO's own product ids, and pass\n  `reviewer_user_id` + `review_note` to land it with a review activity. `po_id`\n  links `invoice_origin` (traceability + re-run idempotency).\n- `ap_post_vendor_bill {bill_id, post?, expected_total, tolerance?,\n  vendor_bill_number?, invoice_date?, note?}` → **posts** a draft vendor bill,\n  the **match & post** step (`paymatch.py post`). Guarded: `in_invoice` + draft\n  only, previews unless `post:true`, and **refuses to post** when the bill total\n  misses `expected_total` beyond `tolerance` (an absolute currency amount) —\n  returns `{posted, total_check{expected,actual,difference,within_tolerance},\n  already_posted?}`. A refused/mismatched bill stays in draft → escalate to\n  Questions, never post it.\n\n### Vendor-bill line CRUD (multi-shipment split — one PO, several SI invoices)\n\nOnly for the multi-invoice case (see `sportsinc_payables.md` →\n*Multiple invoices for one PO*). All three edit **draft `in_invoice`** moves only\nand never touch the auto-computed tax/payable journal items. `ap_get_vendor_bill`\nreturns each editable line with `purchase_line_id`, `product_sku`, `quantity`,\n`price_unit`, `tax_ids`, `account_id`, and `cost_center_id` — the handles below.\n\n- `ap_create_bill_line {bill_id, lines:[{product_id|product_name,\n  account_id|account_name, name?, quantity?, price_unit?, tax_ids?,\n  cost_center_id|cost_center_name?, display_type?}]}` → adds line(s); resolves\n  product/account by id **or** name. Use for a shipment's freight\n  (`Vendor Shipping Charge` / `Inbound Freight`, `tax_ids: []`).\n- `ap_update_bill_lines {bill_id, lines:[{line_id, quantity?, price_unit?,\n  name?, tax_ids?, …}]}` → patches line(s); absent keys unchanged, `tax_ids`\n  REPLACES (`[]` clears). Restate a split line's `quantity`, or the Sports Inc.\n  Fee line's `price_unit` to the document's `si_upcharge`.\n- `ap_delete_bill_lines {bill_id, line_ids:[…]}` → removes line(s); dropping a\n  merch line frees its PO line to bill on the next shipment's bill.\n\nAlso on `ap_search_purchase_orders`: `min_tracking_count` / `max_tracking_count`\nfilter on the count of `vendor.tracking` rows — `min:2` is the likely\nmulti-invoice slice, `max:1` the single-shipment slice.\n\n### PO chatter\n\n- `po_post_message {po_id, body, issue_type?, activity_user_id?}` → posts the\n  \"checked\" note (or an exception) onto the PO as an internal **log note** (never\n  a customer/vendor-facing \"Send message\"). `{message_id}`.\n- `po_get_messages {po_id, limit?}` → the PO's chatter, newest first as plain\n  text, plus open activities. Read it before posting a partial-shipment note to\n  see which lines earlier shipments already checked (`paymatch.py notes` wraps\n  this and also resolves a PO number → id).\n\n### Document render (vision / OCR fallback)\n\n- `paymatch.py render {document_id, pages?, dpi?}` pulls the file via\n  `documents_get` and rasterises its page(s) to PNG with **PyMuPDF** — no system\n  poppler needed — returning `{images:[paths], ocr_text, ocr_engine}` (OCR text\n  only when `tesseract` is installed). This is the escape hatch for a\n  `needs_vision` document: a scan, or a Type3 / custom-encoded PDF whose text\n  layer won't decode. Read the returned image(s) with vision to pull the PO\n  number and line items, then match as normal — never escalate it unread.\n\nOperator docs for deeper semantics (fetch via the MCP `docs_get` tool):\n`documents` and `invoices`.\n\n---\n\n## 3. Matching rules\n\n- **PO number comes from the document body, not the filename.** Filenames often\n  carry the vendor's order number; read the \"PO Number / PO #\" field in the\n  text.\n- **Pair lines by (style/item, color, size), never by row order.** Odoo and the\n  vendor sort differently, and a partial shipment matches only a subset of the\n  PO's lines.\n- **Size upcharges are legitimate pricing.** Base sizes (S–XL) at one price with\n  2XL/3XL/4XL higher is normal — not an error. A single base size priced off\n  the others (e.g. S at $13.94 when M/L/XL are $11.94 and the vendor confirms\n  $11.94) is the error.\n- **Totals cross-check, they don't decide.** A shipment acknowledgement is often\n  one box of a multi-shipment order; its total is legitimately below the PO\n  total by the value of the un-shipped lines. Reconcile lines; explain the gap\n  in the checked note.\n- **Partial shipments accumulate — name the lines and call the last one.** When a\n  shipment covers only some of the PO's lines, list the checked lines/SKUs in the\n  log note, then read the PO's prior notes (`po_get_messages` / `paymatch.py\n  notes`) and per-line `qty_received`. When this shipment's lines **unioned with\n  the previously-checked lines cover every line on the PO** (nothing on\n  back-order), annotate that the PO is **now fully checked across all shipments**;\n  otherwise state which lines are still outstanding. The log notes are the ledger\n  this relies on — never claim completion without them.\n- **Log notes only — never \"Send message\".** Every annotation on a PO or document\n  (checked note, partial-shipment note, question) is an internal Odoo log note; it\n  must never notify followers or email the vendor/customer. `po_post_message` /\n  `documents_post_message` (and the helper's `matched` / `questions`) post log\n  notes — use nothing that sends externally.\n- **Freight / fees are PO-level and invoice-time.** Correct them only when the\n  document gives an authoritative figure. A pre-existing freight estimate or a\n  partner-level fee the document doesn't itemize (e.g. an order acknowledgement\n  shipped \"UPS Ground Collect\") is not a line-pricing error — note it for the\n  eventual invoice match and leave it.\n- **Confirmed POs only.** `ap_update_po_lines` requires `state: \"purchase\"`. A\n  draft/other-state PO that needs a change is a Questions case.\n\n---\n\n## 4. Filing rule (non-negotiable)\n\nEvery reviewed document leaves the Purchasing inbox into a sibling subfolder:\n\n- **Matched** — prices reconciled, or corrected with confidence.\n  `paymatch.py matched '{\"po_id\", \"document_id\", \"body\"}'` posts the checked\n  note and moves the document in one call.\n- **Questions** — a genuine ambiguity (unreadable/unresolvable PO#, prices that\n  don't reconcile, unexpected/missing lines, wrong vendor).\n  `paymatch.py questions '{\"document_id\", \"question\", \"reviewer\": \"Zach Tucker\"}'`\n  raises the reviewer activity and moves the document in one call. Add\n  `po_id` + `po_note` to also annotate the PO.\n\nOnly escalate a **real** question. An unambiguous fix the vendor document\nsupports is a Matched correction — that is the job, not a question.\n\n---\n\n## 5. Worked example (the five-document run this skill was built from)\n\n`paymatch.py extract '{\"folder\": \"Purchasing\"}'` returned five documents as\ntext. Per document:\n\n| Document (text) | PO# (from body) | Finding | Action |\n|---|---|---|---|\n| SanMar Order Confirmation, SO-163291890 | **P13189** | Size **S** line reads $11.94 on the confirmation but $13.94 on the PO (M/L/XL $11.94, 2XL $12.94, 3XL $14.94 all match) | `apply {po_id:13145, lines:[{line_id:40941, price_unit:11.94}]}` (total $1,047.90→$1,041.90) → `matched` |\n| SanMar Order Confirmation, SO-163292190 | **P13193** | Both lines $1.87, match | `matched` (checked, no change) |\n| SanMar Order Confirmation, SO-163289633 | **P13194** | 3 lines $3.99, match | `matched` (checked, no change) |\n| SanMar **Shipment** Acknowledgement, SO-163260908 | **P13137** | Box 1 of a multi-shipment order: 6 of the PO's 11 lines, all $1.79 and matching; doc total $53.70 vs PO $98.45 is just the 5 un-shipped lines | `matched`, checked note explaining the partial shipment |\n| Workwear Outfitters Order Acknowledgement (filename `48482500`) | **P13183** (read from body) | Both lines match ($14.30, $40.32); PO also carries freight $2.00 and a −$2.38 fee the ack doesn't itemize (invoice-time) | `matched`, note the freight/fee for the invoice match |\n\nNone needed Questions. A document whose text won't extract (scanned, or\nType3/custom-encoded like the Charles River Apparel confirmations) is **not** a\nQuestions case — it flags `needs_vision`, and you `render` it and read the\npage image(s) with vision/OCR, then match normally. Only a genuine\nreconciliation failure (unresolvable PO#, prices that don't reconcile, wrong\nvendor) is `questions`-filed to Zach Tucker instead.\n\nThe pattern per document is 2–3 helper calls after the single `extract`:\n`po-lines` → (`apply` if a fix) → `matched`/`questions`.\n\n---\n\n## 6. Low-cost model recommendation + per-match economics\n\nBecause the heavy PDF work lives in `paymatch.py` and the PO payload is trimmed,\nthe model reads a few hundred characters of text plus a lean line list per\ndocument and drives a short deterministic tool sequence — light work a small\nmodel handles well, with the Questions→reviewer path as the safety net on live\nfinancial data.\n\n**Recommended: `claude-haiku-4-5` as the default; `claude-sonnet-5` as an\naccuracy step-up.** (IDs/pricing per the `claude-api` skill catalog; verify with\nthe Models API if unsure.)\n\n| Model | Price in/out (per 1M) | Est. cost / match | Use as |\n|---|---|---|---|\n| `claude-haiku-4-5` | $1 / $5 | **~$0.02–0.05** (~$0.03 typical) | Default — cheapest; ample here |\n| `claude-sonnet-5` | $3 / $15 (intro $2/$10 to 2026-08-31) | **~$0.04–0.14** (~$0.07 typical) | Step-up for messy/unfamiliar vendor layouts |\n\n**Per-match token shape** (one document reconciled): ~3–4K *new* input\n(extracted text + lean PO lines + tool results) + ~1K output, plus cached\nre-reads of the system/tools prefix at 0.1×. The `extract` step is a script\ncall — near-zero model tokens, shared across the whole folder. At **50\ndocs/day (~1,300/mo)** that's roughly **$40/mo on Haiku**, **~$90/mo on\nSonnet**. These are modeled from observed document/PO shapes, not billed\ncounts — **run one real batch on Haiku with usage logging to confirm COGS.**\n\n**Suggested pricing** (price off value/labor replaced, not inference cost — a\nclerk eyeballing a confirmation against a PO is ~3–8 min ≈ $1.25–3.30 loaded,\nand the tool also catches real overcharges — e.g. the $6.00 error above):\n\n| Package | Price | Gross margin on Haiku | Notes |\n|---|---|---|---|\n| Per-match (standard) | **$0.50** | ~94% | Haiku-backed default; obvious ROI vs. clerk time |\n| Per-match (high-accuracy) | **$1.00** | strong even on Sonnet | Sonnet 5 for accuracy-sensitive vendors |\n| Monthly plan | **~$499/mo, up to 1,500 matches** (~$0.33 effective) | ~90%+ | Predictable for the customer; overage ~$0.40 |\n\nLead with **$0.50/match on Haiku 4.5**; offer the **$1.00 Sonnet tier** as an\naccuracy upsell. Keep the cache warm and the loop tight (one `extract` per\nfolder, cache the prefix) — a bloated system prompt or extra turns is the main\ncost risk, and the skill already keeps the expensive PDF work out of the model.\n\nFile v0.10.0:references/sportsinc_payables.md\n\n# Sports Inc payables — end-to-end (SportsLink → match → bill → post-on-match)\n\nSports Inc is a buying group that doesn't send individual vendor invoices; the\ninvoices live in the SportsLink API. This is the automated payables loop for\nthem: pull the invoices, reconcile each to its Odoo PO, correct price variances,\ncreate the bill, **post it when its total matches the invoice** (else leave it\nin draft), and mark the SI document consumed — with a human handling anything\nflagged.\n\nThree skills cooperate (this is the ports-and-adapters split in practice):\n\n- **Source adapter** — `sportsinc-sportslink` (`sportslink.py`): fetch invoices,\n  mark consumed. Customer-agnostic.\n- **Workflow** — this skill: reconcile invoice ↔ PO, correct/escalate, create the\n  draft bill. Source- and ERP-agnostic.\n- **ERP adapter** — `drivethru-odoo` / `drivethru_mcp` (`paymatch.py`,\n  `ap_create_vendor_bill` / `ap_post_vendor_bill`): the Odoo writes.\n\n## Configured policy (BaconCo)\n\n- **Posting: post on match, draft on mismatch.** Create the bill, then **post it\n  when the bill total matches the invoice `expected_total` within tolerance**\n  (`paymatch.py post`, backed by the guarded `ap_post_vendor_bill`). A bill that\n  doesn't match is **left in draft** and escalated to the reviewer — never post a\n  mismatch. When a run should stay hands-off (a human reviews before posting),\n  skip the `post` step and leave every bill in draft.\n- **On variance: auto-fix price, escalate qty/line.** A unit-price difference is\n  treated as the SI invoice being authoritative — correct the PO line (like the\n  pricing review), then bill. A **quantity / missing-line / total-structure**\n  variance is **not** auto-fixed — escalate it (leave the SI doc active, raise an\n  activity to the reviewer, create no bill). Once the reviewer **answers** the\n  escalation, you carry out their decision yourself — see\n  [Acting on the reviewer's answer](#acting-on-the-reviewers-answer-quantity-escalations).\n- **Reviewer:** Zach Tucker (`reviewer_user_id: 6` in BaconCo's Odoo). Keep this\n  in tenant config, not hard-coded in prose.\n- **Tolerance:** a small **absolute** amount (a few cents, e.g. `0.02`) for the\n  `expected_total` match check on both create and post — once prices are\n  reconciled the SI docTotal should equal the computed bill to within rounding.\n- **Chatter: internal log notes only.** Every PO note or escalation this loop\n  posts (via `po_post_message`) is an internal Odoo **log note**, never a \"Send\n  message\" — nothing here is emailed to the vendor.\n\n## The exactly-once loop (do not deviate)\n\nYou are creating payables — double-billing is the cardinal sin. The SportsLink\n`active`/historical flag is the idempotency mechanism; the sequence is fixed:\n\n1. **Pull the inbox.** `sportslink.py list '{\"active\": true, \"lines\": true, \"ediOnly\": true}'`\n   → normalised invoices that are not yet imported and carry line items.\n2. **Process each invoice** (below). Create the draft bill in Odoo.\n3. **Mark consumed only after the draft is created.**\n   `sportslink.py mark-historical '{\"siDocNumbers\": [<si_doc_number>]}'`.\n4. **Anything that fails or is escalated stays active** — it simply reappears on\n   the next run. Never `mark-historical` a doc you didn't bill.\n\nNever use the API's `moveToHistorical=true` GET flag — it marks on read, before\nbilling, so a crash drops the invoice. Belt-and-suspenders on the Odoo side:\nbefore creating, check the PO's `invoice_count` / existing bills for the same\n`supplier_doc_number`, in case a prior run's mark-historical failed after the\nbill was created.\n\n## One PO can have several invoices — group by PO first\n\nInvoices are keyed by `si_doc_number` (one row per SI document). Sports Inc\nissues **one document per shipment**, so a PO that shipped in several boxes comes\nback as **several rows sharing one `po_number`**. Before billing, group the\nadapter's rows by `po_number`:\n\n- **Exactly one** SI document for the PO → the single-invoice path below\n  (steps 1–8).\n- **Two or more** SI documents for the PO → the PO bills as several vendor\n  bills, one per document. Follow **[Multiple invoices for one\n  PO](#multiple-invoices-for-one-po-multi-shipment-split)** instead of steps\n  5–8. Do **not** create one bill for the whole PO — its lines, freight, and\n  `si_upcharge` belong to different documents.\n\nThe `drivethru-ap-sports-inc-multi-invoice` routine pre-filters to the POs most\nlikely to be in this case with `ap_search_purchase_orders`\n`min_tracking_count: 2` (two-plus `vendor.tracking` rows ⇒ several shipments);\nthe single-invoice routine uses `max_tracking_count: 1`. The tracking count is\nonly a *net* — the SI payload's document count is what actually decides\nsingle-vs-multi.\n\n## Per-invoice procedure\n\nFor each normalised invoice from the adapter (single-document POs):\n\n1. **Credit?** `is_credit: true` → do not bill. Escalate to the reviewer (vendor\n   credit is a human decision). Leave active.\n2. **No lines?** `has_lines: false` (scanned/OCR doc) → can't line-verify.\n   Escalate to the reviewer for a header-only decision. Leave active.\n3. **Find the PO.** `paymatch.py po-lines '{\"po\": \"<po_number>\"}'`. If it doesn't\n   resolve (`found: false`) or the vendor doesn't line up → escalate, leave\n   active.\n4. **Reconcile lines** by (item/style, size, color): compare the invoice\n   `net_price` to the PO line `price_unit`, and `qty_shipped` to `qty`.\n   - **Price variance only** → `paymatch.py apply '{\"po_id\", \"lines\":[{\"line_id\",\n     \"price_unit\": <invoice net_price>}]}'` (SI invoice is authoritative on\n     price), then continue.\n   - **Quantity / missing line / extra line / total-structure variance** →\n     **escalate** (`paymatch.py questions '{\"document_id\"? , \"question\", ...}'`\n     or a PO note + reviewer activity), leave the SI doc active, create no bill.\n     (Sports Inc docs are API rows, not Documents-app files, so escalate on the\n     PO chatter via `po_post_message` and/or a tracked task — there's no\n     Documents folder to file.)\n5. **Verify the SI charges tie out.** `docTotal` includes SI-specific charges\n   (`si_upcharge`, `svc_handle`, `freight`, `sales_tax`, less `discount` /\n   `freight_allowance`). Odoo's bill create() **auto-appends the Sports Inc fee\n   lines** for `buying_group: \"si\"` POs — so pass the invoice `total` (docTotal)\n   as `expected_total`; do not pre-add the SI fees yourself (you'll double them).\n6. **Create the draft bill.**\n   ```\n   paymatch.py bill '{\n     \"po_id\": <id>,\n     \"vendor_bill_number\": \"<supplier_doc_number or si_doc_number>\",\n     \"invoice_date\": \"<invoice_date>\",\n     \"expected_total\": <invoice total>,\n     \"tolerance\": 0.02,\n     \"reviewer_user_id\": 6,\n     \"review_note\": \"SI SportsLink doc <si_doc_number>; matched PO <po_number>; <price fixes>.\"\n   }'\n   ```\n   If the create returns `success: false` (the computed bill total missed\n   `expected_total` beyond tolerance) → **do not** mark historical; escalate with\n   the discrepancy and leave the doc active.\n7. **Mark consumed.** On a successful draft, `sportslink.py mark-historical\n   '{\"siDocNumbers\": [<si_doc_number>]}'`.\n8. **Post it if it matches.** Post the draft (created bill `id` from step 6) so\n   it hits the ledger — unless this is a draft-only run:\n   ```\n   paymatch.py post '{\n     \"bill_id\": <created bill id>,\n     \"expected_total\": <invoice total>,\n     \"tolerance\": 0.02,\n     \"note\": \"SI SportsLink doc <si_doc_number>; matched PO <po_number>; posted on match.\"\n   }'\n   ```\n   `post` re-checks the total and **refuses** (leaving the bill in draft) if it\n   no longer matches within tolerance — treat a refusal as an escalation to the\n   reviewer, exactly like a create mismatch. Posting an already-posted bill is a\n   safe no-op (`already_posted: true`), so a re-run never double-posts.\n\n## Multiple invoices for one PO (multi-shipment split)\n\nWhen Sports Inc returns **2+ documents for one `po_number`**, the PO must bill as\n**one vendor bill per SI document**. Each document (`si_doc_number`) covers a\nsubset of the PO's lines, with its own `freight` and its own `si_upcharge`; its\n`docTotal` is the whole bill (merch + upcharge + freight). Odoo's PO→bill\n`create()`, by contrast, bills **every** received line at once and computes the\nSI fee on the **whole** PO — so you create that draft and then **carve it down**\nto each document with the `account.move.line` tools:\n\n- `ap_get_vendor_bill {bill_id}` — each editable line carries `purchase_line_id`,\n  `product_sku`, `quantity`, `price_unit`, `tax_ids`, `account_id`, and the fee\n  line's handle. These are the only ids the carve tools accept.\n- `ap_update_bill_lines {bill_id, lines:[{line_id, quantity?, price_unit?,\n  name?, tax_ids?}]}` — patch a line: drop a split line's `quantity` to this\n  shipment's qty, restate the **Sports Inc. Fee** line's `price_unit` to this\n  document's `si_upcharge`, clear `tax_ids`.\n- `ap_create_bill_line {bill_id, lines:[{product_name|account_name|…,\n  price_unit, tax_ids}]}` — add this shipment's freight\n  (`Vendor Shipping Charge` / `Inbound Freight`, `price_unit` = `freight`) when\n  the PO carried none.\n- `ap_delete_bill_lines {bill_id, line_ids:[…]}` — drop the merch lines that\n  belong to **later** shipments (that frees each dropped PO line to bill on the\n  next pass), or a duplicate auto-added fee line.\n\n**Precondition — line-level detail required.** Only run the split when **every**\ndocument for the PO has line items (`has_lines: true`). If any is header-only\n(scanned / OCR, `has_lines: false`), the split can't be verified line-by-line —\n**skip the whole PO and escalate** (leave every one of its SI docs active).\n\n**One bill per document; post only what the PO supports.** Create a bill for\n**every** SI document (so the bill count matches the document count), but only\n**post** the ones the PO's quantities cover. A document the PO can't cover — an\n**over-invoice**, e.g. the vendor invoiced replacements for units it never\noriginally shipped, after the earlier bills already consumed the PO's whole\nquantity — still becomes a bill, but a **draft with a review activity**, never a\nposted one.\n\n**Procedure** — process the documents oldest first; each pass creates one bill:\n\n1. **Reconcile prices first, once.** Compare each SI line's `net_price` to its PO\n   line `price_unit` across all the PO's documents; `ap_update_po_lines` any\n   price variance (SI is authoritative on price), escalate qty/line variances —\n   same rule as the single-invoice path.\n2. **Create the draft.** `ap_create_vendor_bill {po_id}` bills all lines still\n   billable (received, not already on a draft/posted bill). On the first pass\n   that's the whole PO; on later passes only what earlier bills left behind.\n   - **Over-invoice branch.** If this returns **\"No billable lines\"** (the PO has\n     no `qty_to_invoice` left) while SI still has an unbilled document, that\n     document is an over-invoice the PO can't substantiate. Create it **off-PO**\n     instead: `ap_create_draft_bill {po_id, vendor_bill_number, invoice_date,\n     lines: [{product_id, quantity, price_unit, tax_ids: []}], reviewer_user_id,\n     review_note}` — build `lines` from the SI payload using the **PO's own\n     product ids** (map by supplier item + size against `ap_get_purchase_order`),\n     add the document's freight + `si_upcharge` fee lines so the draft total\n     equals `docTotal`, and **do not post it** (`ap_create_draft_bill` never\n     posts). Make `review_note` say we were invoiced for items not on the PO and\n     a human must review. Then move to the next document (step 6).\n3. **Map lines to this document.** `ap_get_vendor_bill`; pair each bill line to\n   an SI line by **(supplier item, size)** via `product_sku` (e.g.\n   `…(JP1477)-S`) — never by row order.\n4. **Carve to exactly this document:**\n   - `ap_delete_bill_lines` the merch lines that belong to **other** shipments.\n   - For a PO line split across shipments, `ap_update_bill_lines` its `quantity`\n     down to this document's `quantity_shipped` (the remainder bills next pass).\n   - `ap_update_bill_lines` the **Sports Inc. Fee** line's `price_unit` to this\n     document's `si_upcharge` (verify exactly one fee line remains; delete a\n     duplicate). Do **not** re-derive the fee — use the number SI gave.\n   - If this document has `freight` and no freight line exists,\n     `ap_create_bill_line` a `Vendor Shipping Charge` line at that amount.\n5. **Set the reference + post.** `ap_post_vendor_bill {bill_id, post: true,\n   expected_total: <docTotal>, vendor_bill_number: <supplier_doc_number>,\n   invoice_date: <supplier_doc_date>, tolerance: 0.02}`. The gate refuses a\n   mis-carved bill (total ≠ `docTotal`); a refusal is an escalation, not a\n   retry-blindly.\n6. **Repeat** from step 2 for the next document until **every** document for the\n   PO has a bill — posted where the PO covers it, draft-with-review-activity\n   where it doesn't (the over-invoice branch). The bill count matches the SI\n   document count; only the reconciling ones are posted.\n7. **Mark consumed after all its bills exist.** `mark-historical` every\n   `si_doc_number` for the PO (each only after its bill is created), and flip\n   `is_pricing_checked = true` on the PO once all its shipments are billed. A\n   partially-billed PO stays active so the next run finishes it.\n\nIdempotency across a killed run: before creating, check the PO's existing bills\n(`ap_get_purchase_order` → `existing_bills`, and each `si_doc_number` against\nbill `ref`) so a re-run resumes where it stopped rather than double-billing an\nalready-posted document.\n\n## Acting on the reviewer's answer (quantity escalations)\n\nAn escalation is a question, not a hand-off. When the reviewer replies (in the\nPO chatter — read it with `po_get_messages` — or directly in your session) with\na decision, **you execute it**. Never turn their answer into a numbered list of\n\"manual steps\" assigned back to them.\n\nThe common answer to an over-shipped invoice (SI invoiced qty 2, PO line says 1)\nis *\"update the PO to match the invoice, receive it, bill it.\"* Do it like this:\n\n1. **Check the line is dropship.** `stock_dropship_pickings {po_id}` — the PO\n   line's moves sit on a dropship picking (vendor → customer).\n2. **Raise the PO line.** `ap_update_po_lines {po_id, lines: [{line_id,\n   product_qty: <invoiced qty>, price_unit: <invoice net_price>}]}` (or\n   `paymatch.py apply` with the same lines). It only increases, and refuses a\n   non-dropship line. Odoo creates a **new dropship picking** for the extra\n   units; the response lists it in `new_pickings`.\n3. **Receive the extras.** `stock_validate_dropship {picking_id: <new picking>}`\n   to preview, then again with `validate: true`. Because a dropship ships vendor\n   → customer, validating it **also updates the sale order's delivered qty**, so\n   the customer can be invoiced for the extra units. You don't need to touch the\n   sale order separately.\n4. **Fix the bill.** If a draft already exists for this SI doc, `ap_get_vendor_bill`\n   and `ap_update_bill_lines` the affected lines' `quantity` up to the invoiced\n   qty (or delete the draft and `ap_create_vendor_bill` again). Restate the\n   Sports Inc. Fee line to the document's `si_upcharge` if it moved.\n5. **Post at the SI total.** `ap_post_vendor_bill {bill_id, post: true,\n   expected_total: <docTotal>, vendor_bill_number, invoice_date, tolerance: 0.02}`.\n   Then `mark-historical` the SI doc, and log a note on the PO saying what you\n   changed (old → new qty, picking validated, bill posted and its total).\n   Mark any activity you opened for this escalation done, or say it can be\n   closed.\n\n**Non-dropship (stocked) lines.** Raising the qty creates a stock receipt\ninto our warehouse and does **not** touch the sale order. Treat that as a\nseparate decision: only pass `allow_non_dropship: true` when the reviewer's\nanswer explicitly covers a stocked line, and don't change the sale order\nunless they asked for it.\n\n**If a step is truly blocked** (a tool refuses, or no tool exists for it), do\nevery step you *can*, then escalate again naming the **exact step that's\nblocked and why** (tool name + error). Don't re-list steps you could have done.\n\n## Scheduling & resilience\n\n- **Run after ~10:30am ET** (SI processing completes first). This is a scheduled\n  batch — a good fit for a cron/Routine trigger.\n- The loop is self-healing: transient API failures retry (the adapter backs off);\n  anything unresolved stays active and is retried next run; a run can be killed\n  and restarted safely because nothing is marked consumed until its bill exists.\n- **Notify on exceptions.** Summarise per run: posted N bills and left M in\n  draft (list PO / amounts, posted vs draft), corrected P prices, escalated Q\n  (with reasons), and any hard failures. Draft bills + escalations are the\n  human's queue.\n\n## Why an agent, not a custom Odoo module\n\nThe deterministic mechanics (auth, paging, normalisation, mark-historical) live\nin `sportslink.py`; the ERP writes are single tool calls. The **agent** adds the\njudgment (which variances to auto-fix vs. escalate, credit/scanned handling, PO\nresolution) and the resilience (retry, anomaly detection, human-readable failure\nnotices) that a deterministic module would force you to hand-code and redeploy\nper edge case. Keep the mechanics in scripts (out of the model's context) and the\njudgment in the agent — the same division that makes the folder pricing review\ncheap.\n\n## Not testable without live access\n\n`sportslink.py` needs `SPORTSINC_API_KEY` and reaches `api.sportsinc.com`, and\nbilling writes to live Odoo. Smoke-test in stages: (1) `list` read-only against a\nrecent date; (2) one `bill` on a known PO with `SPORTSINC_DRY_RUN=1` so nothing\nis marked consumed; (3) confirm the draft in Odoo and the reviewer activity;\nthen enable `mark-historical`. Confirm the `vendor_bill_number` convention\n(supplier vs. SI doc number) and the bill's vendor/partner for `buying_group:si`\nPOs during that first pass.\n\nFile v0.10.0:skill-card.md\n\n## Description:\n\nReconciles vendor payable documents and Sports Inc invoices against Odoo purchase orders, corrects supported PO price variances, routes unresolved items for review, and creates or posts vendor bills only when totals match.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[zmtucker](https://clawhub.ai/user/zmtucker)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nAccounting and purchasing operators use this skill to reconcile vendor confirmations, acknowledgements, and invoices against Odoo purchase orders, update clearly supported price variances, file reviewed documents, and prepare or post matched payables.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The skill can change live Odoo purchasing and accounting records, including PO pricing and vendor bill state.\n\nMitigation: Install only where that authority is intended, use least-privilege Odoo/MCP credentials, and require human authorization for bill posting, quantity changes, dropship validation, and credential-sharing delegation.\n\nRisk: A mismatched payable could be posted if totals or invoice structure are not checked.\n\nMitigation: Prefer draft or dry-run operation until the workflow is proven, leave mismatches in draft, and post only when the bill total matches the source invoice within tolerance.\n\nRisk: Runtime dependency bootstrapping can install Python packages into a cached environment.\n\nMitigation: Review the dependency bootstrapping policy and preinstall or approve the declared packages before production use.\n\n## Reference(s):\n\n- [Payable matching procedure](references/matching_procedure.md)\n- [Sports Inc payables procedure](references/sportsinc_payables.md)\n- [Odoo](https://www.odoo.com)\n- [ClawHub skill page](https://clawhub.ai/zmtucker/skills/drivethru-payable-matching)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown guidance with JSON command payloads and concise reconciliation summaries]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May run Odoo MCP operations through native tools or scripts/paymatch.py; helper commands return JSON objects.]\n\n## Skill Version(s):\n\n0.10.0 (source: frontmatter and release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v0.9.8: 7 files, 44162 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (43799b), skill-card.md (2718b), SKILL.md (27425b), _meta.json (145b)\n\nFile v0.9.8:SKILL.md\n\n---\nname: drivethru-payable-matching\ndescription: >\n  Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents\n  app against their purchase orders and correct incorrect PO line pricing. Use\n  for requests like \"check the Purchasing folder against the POs and fix the\n  pricing\", \"match the vendor invoice / order confirmation / acknowledgement to\n  its PO\", \"AP price matching / invoice-to-PO matching / three-way match\",\n  \"reconcile the vendor documents and mark the POs checked\", or \"go through the\n  Purchasing folder\". The flow: read every document in a Documents-app folder\n  (extracting text out-of-context so large batches don't bloat the context\n  window — falling back to a page render + OCR/vision for scanned or\n  custom-encoded PDFs that won't extract as text), pull the PO number / line\n  items / unit prices from each, compare to the purchase order line by line,\n  correct any wrong `price_unit`, post a \"checked\" log note on the PO (internal,\n  never a \"Send message\"), and FILE every document into the `Matched` or\n  `Questions` subfolder — escalating genuine questions to a reviewer (default\n  Zach Tucker). Also runs the buying-group payables flow: pull Sports Inc\n  invoices from the SportsLink API (via the `sportsinc-sportslink` adapter),\n  reconcile each to its PO, correct price variances, create the vendor bill and\n  — when the bill total matches the invoice within tolerance — POST it, leaving\n  any mismatch in draft for a human (\"get the Sports Inc invoices and bill\n  them\", \"match the SI invoices to POs and post the payables\", \"match the vendor\n  invoice and post the bill if it matches\"). Handles the multi-shipment case\n  where one PO returns several Sports Inc invoices, splitting it into one vendor\n  bill per shipment via `account.move.line` edits (the `ap_*_bill_line(s)`\n  tools). Runs at volume on a low-cost model.\n  Driven by the Odoo `drivethru_mcp` MCP server; complements the broader\n  `drivethru-odoo` skill.\nversion: 0.9.8\nemoji: 🧾\nhomepage: https://www.odoo.com\nmetadata:\n  openclaw:\n    requires:\n      env: [ODOO_MCP_URL, ODOO_MCP_TOKEN]\n      bins: [python3, uv]   # uv powers the scripts' self-bootstrap fallback\n    primaryEnv: ODOO_MCP_TOKEN\n    envVars:\n      ODOO_MCP_URL:\n        required: true\n        description: >\n          Full URL of the Odoo MCP endpoint, e.g.\n          `https://odoo.example.com/drivethru_mcp/v1` — the MCP server exposed by\n          the `drivethru_mcp` Odoo module, not the Odoo base URL.\n      ODOO_MCP_TOKEN:\n        required: true\n        description: >\n          The `drivethru.mcp_key` value from the `drivethru_mcp` module, sent as\n          `Authorization: Bearer`. Treat as a secret; never paste into chat.\n    install:\n      uv:\n        - mcp>=1.9.0\n        - pymupdf>=1.24   # primary local PDF text extraction + page rasterization:\n                          # honours ToUnicode/Type3 (custom-encoded) fonts pypdf can't\n                          # read, and renders needs_vision docs without system poppler\n        - pypdf>=4.0      # last-resort text-extraction fallback\n---\n\n# Payable matching (Purchasing folder → purchase orders)\n\nReconcile vendor documents against their purchase orders and fix PO line\npricing. A **vendor document** is an order confirmation, shipment\nacknowledgement, or invoice filed in the Documents app's **Purchasing** folder;\nits authority for what BaconCo will be billed is the vendor's own numbers.\n\nThe task, per document: read it → extract the **PO number, line items, and unit\nprices** → find the PO in Odoo → compare **line by line** → correct any wrong\n`price_unit` → post a \"checked\" note on the PO → **file the document** into\n`Matched` (reconciled) or `Questions` (needs a human). Nothing stays in the\ninbox.\n\nThis changes live financial data (PO prices, chatter, activities). The\nstanding request *\"review the Purchasing folder and fix the pricing\"*\nauthorizes the corrections and the \"checked\" notes; still, **only correct a\nline when the vendor document unambiguously supports it** — when in doubt, route\nit to Questions rather than guessing.\n\n## Always use Log note, never Send message\n\nEvery annotation you leave on a PO or a document — the \"checked\" note, a\npartial-shipment note, a question, any comment — MUST be an internal **Log\nnote**, never a **Send message**. In Odoo chatter, \"Send message\" notifies the\nrecord's followers and can **email the vendor or customer**; a Log note stays\ninternal. These vendor documents are BaconCo's own AP working notes — nothing\nhere should ever leave Odoo as an email.\n\nThe helper's `matched` / `questions` commands and the `po_post_message` /\n`documents_post_message` MCP tools post internal **log notes** — use them. Do\n**not** reach for any \"send message\", email, or notify-followers path when\nannotating a PO or document, and if a tool ever exposes a note-vs-message\nchoice, always choose the internal note.\n\n## How you reach Odoo (runtime-aware — read this first)\n\nThis skill drives the same Odoo **`drivethru_mcp`** MCP as `drivethru-odoo`, and\n`ODOO_MCP_URL` / `ODOO_MCP_TOKEN` are already configured. **Never say you \"can't\nreach Odoo\" or \"don't have the tools in this thread\" and guess instead — call a\ntool, or state the exact call you tried and the error you got.**\n\n- **Native / callable MCP tools (preferred, no shell needed).** If the Odoo\n  tools are attached to you natively, **call them directly** — e.g.\n  `documents_list_folders {\"name\": \"Purchasing\"}`, whose `document_count` is how\n  many documents are waiting to be matched. They may be **deferred** — if your\n  runtime lazy-loads tools, search your tools for `documents_search`,\n  `ap_search_purchase_orders`, `ap_update_po_lines`, `po_post_message`,\n  `po_get_messages`, etc. and load them before calling. \"I don't see them yet\"\n  is not \"I don't have them.\"\n- **Shell (`scripts/paymatch.py`) — only if you have a shell.** The helper below\n  is a context-economy optimization (bulk PDF text extraction, one call per\n  folder); prefer it for large batches when a shell is available.\n- **Not connected at all?** Attaching the `drivethru_mcp` MCP natively — or\n  giving the agent a shell — is an **operator step**; env vars alone don't\n  attach the tools. See `drivethru-odoo`'s **Operator setup** and\n  **Troubleshooting** sections before concluding you can't reach Odoo.\n\n## The engine: `scripts/paymatch.py` (keep the work out of context)\n\nReading PDFs is the expensive part. `documents_get` returns each file as\n**base64**, and a multimodal PDF reader adds a **page image** — both dwarf the\nfew hundred characters of text that matter. Do that per file across a folder,\nmany times a day, and the context window fills with bytes and renders (and the\ntoken bill balloons). So **never loop `documents_get`/a PDF reader over the\nfolder.** Use the helper, which does the heavy, deterministic work locally and\nreturns only what the model needs.\n\n```bash\n# 1. Read the whole folder as TEXT (no base64, no render) — ONE call\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\"}'\n\n# 1a. Scheduled batch? Narrow the extraction instead of pulling every file:\n#     document_ids = only these docs; name_excludes = drop by name substring\n#     (case-insensitive); max_docs = cap the batch (extras reported as\n#     skipped_beyond_max_docs). folder still scopes the listing.\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"document_ids\": [7617, 7536]}'\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"name_excludes\": [\"sanmar\"], \"max_docs\": 8}'\n\n# 1b. Any document flagged needs_vision (scanned or custom-encoded) → render its\n#     page(s) to PNG (+ OCR if tesseract is present), then read the image(s)\npython3 scripts/paymatch.py render '{\"document_id\": 485}'\n#\n# Each extracted document also carries `po_candidates` (the distinct PO refs in\n# its text, each canonicalized to Odoo's `P<digits>` name — so a bare \"PO 14594\"\n# and a \"P14594\" token both surface as `P14594`) and `multi_invoice` (true when\n# it holds several invoices — >1 distinct PO, or >1 \"Invoice #/No/Number\"\n# header). On multi_invoice, treat the PDF as several invoices: process EACH\n# against its own PO (resolve by name, dedupe, bill/post per invoice) rather than\n# billing only the first — a batch PDF is usually one invoice per page. These are\n# HINTS; verify against the text.\n\n# 2. Per document: pull the PO trimmed to matchable fields (incl. qty_received)\npython3 scripts/paymatch.py po-lines '{\"po\": \"P13189\"}'\n\n# 2b. Partial shipment? Read the PO's prior log notes to see what's already checked\npython3 scripts/paymatch.py notes '{\"po\": \"P13137\"}'\n\n# 3. Correct any wrong line(s) in one call (PO must be confirmed)\npython3 scripts/paymatch.py apply '{\"po_id\": 13145, \"lines\": [{\"line_id\": 40941, \"price_unit\": 11.94}]}'\n\n# 4a. Clean/fixed → post the checked LOG NOTE AND file to Matched (one call)\npython3 scripts/paymatch.py matched '{\"po_id\": 13145, \"document_id\": 481, \"body\": \"Pricing checked against SanMar Order Confirmation ... corrected size S $13.94→$11.94.\"}'\n\n# 4b. Genuine question → post a LOG NOTE and file to Questions (one call).\n#     The note explains what happened; the folder IS the review queue. Do NOT\n#     pass `reviewer` unless a human genuinely needs a to-do assigned — a\n#     `reviewer` turns the note into an assigned activity, and blanket\n#     activities on every Questions doc just spam the reviewer's to-do list.\npython3 scripts/paymatch.py questions '{\"document_id\": 485, \"question\": \"Totals do not reconcile — vendor total $X vs PO $Y.\"}'\n```\n\n**PO lookup is BY DISPLAY NAME (matched on the number), never by id.** The\n`P#####` printed on an invoice is the purchase order's **name**, not its\ndatabase id — they are different numbers (name `P12774` lives at id `12730`; id\n`12774` is an unrelated PO). Always resolve with `po-lines '{\"po\": \"P12774\"}'`\nand use the returned record's numeric `id`. **Never** call\n`ap_get_purchase_order {po_id: 12774}` with the P-number's digits — it silently\nreturns the WRONG order.\n\n`po-lines` matches on the **canonical PO number**, so the `P` prefix is\noptional: vendors routinely print the number bare or labeled — `14594`,\n`PO 14594`, `P.O.#14594` — and all of them resolve to the PO named `P14594`\n(it searches Odoo by both spellings and filters to the one whose name has that\nnumber). Pass whatever the invoice shows; you do **not** need to prepend `P`\nyourself. It still requires **exactly one** number match — zero or several\nreturns `found: false` with `candidates` (and the `canonical` form it looked\nfor), which is the only case that goes to Questions. A PO whose number exists in\nOdoo must be resolved and processed, never escalated as \"no P-number\".\n\nEvery command prints one JSON object, or `{\"error\": {...}}` with a non-zero\nexit. Requires `ODOO_MCP_URL` / `ODOO_MCP_TOKEN` (if missing, the script exits\nwith `config_error` — stop and tell the user to configure them; never ask for\nthe key in chat).\n\n**Dependencies self-install.** The script's deps (`mcp`, `pymupdf`, `pypdf`)\nare declared in the frontmatter `install.uv`, but not every OpenClaw host honors\nit — so `scripts/_bootstrap.py` ensures them at startup: on a missing import it\nbuilds a cached `uv` venv (in `$PAYMATCH_DATA_DIR` or `~/.drivethru/paymatch`)\nand re-execs. Hosts that pre-install make it a no-op; otherwise the **first run\npays a one-time install**, then it's cached. So a `ModuleNotFoundError` (e.g.\n`No module named 'anyio'`) is not a dead end — it self-heals on the next run, as\nlong as **`uv` is on PATH**. If the script exits saying `uv` is missing, that's\nan operator step (install `uv`, or the host must honor `install.uv`); fall back\nto the native `documents_*` / `ap_*` / `po_*` MCP tools in the meantime.\n\nIf you have **no shell** (e.g. a chat agent), use the native `documents_*` /\n`ap_*` / `po_*` MCP tools directly — they do everything the script does; the\nscript is only a context-economy wrapper for bulk PDF extraction. Working\nnatively, still fetch **one document at a time** and drop the base64 — never\npull a whole folder's bytes into context. (If creds are unset or the MCP isn't\nattached, see `drivethru-odoo`'s Troubleshooting — don't guess.)\n\n## Procedure\n\n1. **Read the folder once.** `paymatch.py extract '{\"folder\": \"Purchasing\"}'`\n   → `documents[]` with `document_id`, `name`, and extracted `text`. Work from\n   that text. Extraction runs PyMuPDF → `pdftotext` → `pypdf` and **quality-gates\n   the result**, so a document flagged `needs_vision: true` is one no text pass\n   could read reliably — either scanned/image-only, OR a **custom-encoded PDF**\n   (Type3 / no usable ToUnicode — e.g. some Charles River Apparel shipment\n   confirmations) that renders perfectly but extracts as empty/garbage.\n   **Do not escalate a `needs_vision` document as \"unreadable\" — read it.**\n   Run `paymatch.py render '{\"document_id\": <id>}'` to rasterise its page(s) to\n   PNG (plus OCR text when `tesseract` is installed), then read the image(s)\n   with vision to pull the PO number and line items and match as normal. (No\n   shell? Fetch with `documents_get {\"document_id\"}` and read the bytes with a\n   vision reader — same idea.) The old failure mode — \"couldn't get a reliable\n   PO number from unattended extraction, please review manually\" — is exactly\n   this case, and the render/vision fallback resolves it instead of punting.\n\n2. **Extract from each document's text (not its filename).** The PO number is\n   **inside** the document; a filename may show the vendor's order number\n   instead (e.g. `Order Acknowledgement 48482500.pdf` whose real PO is\n   `P13183`). Capture item/style, color, size, quantity, and **unit price** per\n   line.\n\n3. **Pull the PO.** `paymatch.py po-lines '{\"po\": \"<PO#>\"}'` → the PO trimmed to\n   `{po_id, name, vendor, partner_ref, state, amount_untaxed, freight_cost,\n   fees_cost, lines:[{line_id, sku, style, description, qty, price_unit}]}`.\n   Pass the PO number **exactly as the invoice prints it** — the `P` prefix is\n   optional. Vendors often omit it (`14594`, `Cust PO 14594`, `PO#14594`); that\n   IS `P14594`, and `po-lines` resolves it by number. **Do not** decide \"there's\n   no P-number, escalate\": a number is a number. Confirm the vendor /\n   `partner_ref` line up with the document. The PO must be `state: \"purchase\"` to\n   edit lines. Only `found: false` (with `candidates` + the `canonical` form it\n   searched) means **no PO with that number exists** — that, and only that, is a\n   Questions case.\n\n4. **Compare line by line.** Pair each document line to a PO line by **(style/\n   item, color, size)** — never by row order. Size upcharges are normal (base\n   sizes one price; 2XL/3XL/4XL higher) — that's correct pricing, not an error.\n\n5. **Correct mismatches** in one call:\n   `paymatch.py apply '{\"po_id\", \"lines\":[{\"line_id\",\"price_unit\"}]}'`. Set\n   `freight_cost`/`fees_cost` only when the **document itself** gives an\n   authoritative figure — a pre-existing freight estimate or partner fee the\n   document doesn't itemize is not a line error; note it for the invoice match.\n\n6. **File the document — always.**\n   - **Reconciled** (matched, or corrected with confidence) →\n     `paymatch.py matched '{\"po_id\", \"document_id\", \"body\": \"<what you checked/fixed>\"}'`\n     (posts the checked note + moves the doc to `Matched`).\n   - **Genuine question** →\n     `paymatch.py questions '{\"document_id\", \"question\": \"<what to resolve>\"}'`\n     (posts a log note + moves the doc to `Questions`). The note is the record;\n     the folder is the queue. Add `reviewer` ONLY when a human genuinely needs a\n     to-do assigned — do not attach an activity to every Questions doc. Pass\n     `po_id` + `po_note` too if the PO also warrants a note.\n\n## Totals are a cross-check, not the source of truth\n\nA shipment acknowledgement is often **one box of a multi-shipment order**: it\ncovers a subset of the PO's lines, so its total is legitimately **less** than\nthe PO total — the rest ships later. Match on lines; a total gap fully explained\nby un-shipped lines is **not** a discrepancy. Say so in the checked note so a\nhuman isn't confused by the header total.\n\n## Partial shipments: track cumulative coverage and call the last one\n\nWhen a document is a **partial shipment** (it reconciles cleanly but covers only\nsome of the PO's lines), do two things before you file it:\n\n1. **Name the lines this shipment covers.** In the `matched` log note, list the\n   specific lines / SKUs this acknowledgement checks (e.g. *\"Shipment of P13137\n   — checked lines 3,4,7,9,10,11 (styles …), all $1.79 and matching\"*). That is\n   what turns the PO's chatter into a running ledger of what has been checked.\n\n2. **Look back before you post, and call the final shipment.** Read the PO's\n   prior log notes with `paymatch.py notes '{\"po\": \"P13137\"}'` (or the\n   `po_get_messages` tool) and cross-check `qty_received` / `qty` per line from\n   `po-lines`. If the lines you just checked, **unioned with the lines earlier\n   notes already checked, now cover every line on the PO** (nothing left on\n   back-order / un-received), then this is the last piece — **say so explicitly\n   in the note**, e.g. *\"✅ Final shipment — all 11 lines on P13137 are now fully\n   checked across all shipments; PO complete.\"* If lines still remain, state\n   which ones are still outstanding instead of implying completion.\n\nOnly claim \"fully checked\" when the prior log notes (and `qty_received`) actually\nshow the rest was checked — those notes are the evidence, which is exactly why\nevery annotation is an internal log note that accumulates on the PO.\n\n## When to escalate (Questions) vs. just fix (Matched)\n\nEscalate only a **real** ambiguity: can't read the PO#, the PO doesn't resolve,\nprices don't reconcile, unexpected/missing lines, or the wrong vendor. An\nunambiguous correction the vendor document plainly supports (a size priced $2\noff the vendor's own confirmation) is a **Matched** fix — applying it is exactly\nwhat the task asks. Don't manufacture a question where the document is clear;\ndon't guess where it isn't.\n\n## Report\n\nPer document: PO number, lines changed (old → new) or \"no change\", whether the\nchecked note was posted, and Matched vs Questions (and why). End with a folder\ntally so the inbox state is obvious.\n\n## Creating and posting the payable, and buying-group sources\n\nThe same reconcile-then-file loop extends to **creating the vendor bill** once a\nPO's pricing is reconciled, and then **posting it when it matches**:\n\n```bash\n# Create the DRAFT bill from a reconciled PO\npython3 scripts/paymatch.py bill '{\"po_id\": 13145, \"vendor_bill_number\": \"<inv#>\", \"invoice_date\": \"2026-07-22\", \"expected_total\": 1041.90, \"tolerance\": 0.02, \"reviewer_user_id\": 6, \"review_note\": \"...\"}'\n\n# Post it — ONLY when the bill total matches the vendor invoice\npython3 scripts/paymatch.py post '{\"bill_id\": 8842, \"expected_total\": 1041.90, \"tolerance\": 0.02, \"note\": \"Matched to <inv#>; totals reconcile.\"}'\n```\n\n`bill` creates the bill in **draft** and schedules a review activity. Then\n`post` performs the **match & post** step through the guarded\n`ap_post_vendor_bill` tool:\n\n- **Post only what matches.** `expected_total` (the vendor invoice total) is\n  **required** for `post`, and the tool **refuses to post** a bill whose total\n  deviates beyond `tolerance` (an **absolute currency amount** — e.g. `0.02` is\n  two cents, not 2%). A mismatch comes back as an error and the bill stays in\n  draft — **route it to a human, never post it.** This is the whole safety\n  contract: a vendor bill only posts when its numbers reconcile to the invoice.\n- **It's live and hard to reverse.** Posting writes the bill to the ledger\n  (unposting needs a reversing entry). Only post a bill you created from a PO\n  you reconciled in this same run, for an invoice whose total you verified.\n  Anything ambiguous — wrong total, wrong vendor, unexpected lines, a PO that\n  didn't fully reconcile — is a **Questions** escalation, not a post.\n- **Dry-run first if unsure.** Pass `\"post\": false` to preview: it returns\n  `would_post` + `total_check` (expected vs actual vs tolerance) and posts\n  nothing, so you can confirm the match before committing.\n- **Idempotent + audited.** Re-posting an already-posted bill is a safe no-op\n  (`already_posted: true`); each post leaves an internal **log note** on the\n  bill (never a \"Send message\", so nothing is emailed to the vendor).\n\nPosting is **opt-in per run**: if the task is only \"create the payables\" or a\nhuman wants to review before posting, stop at `bill` (draft) and skip `post`.\n\n### Sports Inc (buying group — no per-invoice documents)\n\nSports Inc doesn't email individual invoices; they live in the **SportsLink\nAPI**. The `sportsinc-sportslink` adapter pulls them (normalised to the same\ninvoice shape a PDF would give) and marks them consumed. The end-to-end loop —\npull active SI invoices → reconcile to the PO → **auto-fix price variances,\nescalate quantity/line variances** → create the bill → **post it when the total\nmatches (`expected_total`), else leave it in draft for a human** → **mark the SI\ndoc consumed only after the bill exists** (exactly-once) — plus credit/scanned\nhandling and the SI-fee/`expected_total` nuance, is the dedicated procedure in\n[`references/sportsinc_payables.md`](references/sportsinc_payables.md). Read it\nbefore running the SI payables flow.\n\n**One PO, several invoices (multi-shipment).** When a PO shipped in several\nboxes, Sports Inc returns **several documents for one PO number** — each with its\nown lines, freight, and `si_upcharge`. That PO bills as **one vendor bill per\ndocument**: create the draft, then carve it to each shipment with the\n`account.move.line` tools (`ap_get_vendor_bill` to see the lines, then\n`ap_delete_bill_lines` / `ap_update_bill_lines` / `ap_create_bill_line` to make\neach bill equal exactly one document), and post each at its own `docTotal`.\nCreate **one bill per SI document but post only the ones the PO's quantities\nsupport**: a document the PO can't cover (an **over-invoice** — e.g. the vendor\ninvoiced replacements for units it never originally shipped, after earlier bills\nconsumed the PO's whole quantity) still becomes a bill via `ap_create_draft_bill`\n— built off the payload's product ids and left in **draft with a review\nactivity**, never posted. **Skip** a PO whose SI documents lack line-level\ndetail. The full carve procedure — and the `min_tracking_count` search filter\nthat surfaces these POs — is in `references/sportsinc_payables.md` →\n*Multiple invoices for one PO*.\n\nScope note: this skill reconciles, drafts, and — when the numbers match —\n**posts**. A bill only posts when its total reconciles to the vendor invoice\nwithin tolerance; anything that doesn't match stays in draft and goes to a\nhuman. Only create/post bills when the task is payables (folder pricing review\nalone stops at the \"checked\" note + filing), and stop at the draft (`bill`,\nskip `post`) whenever the task is \"create the payables\" or a human wants to\nreview before posting.\n\n#### Agent-to-Agent (A2A) Mode for Sports Inc\n\nFor deployments with a **dedicated Sports Inc agent**, this agent can fetch the\ninvoices by *delegating* to that agent over A2A instead of running\n`sportsinc-sportslink` itself. **This** agent owns `SPORTSINC_API_KEY` (it\nrepresents your company) and *shares* it with the Sports Inc agent for the\nduration of each delegated call. The A2A call is orchestrated by **you (the\nagent) using the platform MCP tools** — there is no Python helper for it.\n\n**Setup (platform console):**\n\n1. **Create the Sports Inc agent** and install the `sportsinc-sportslink` skill.\n   Do **not** bind `SPORTSINC_API_KEY` to it — the key lives on this agent.\n2. **Bind `SPORTSINC_API_KEY` to this agent** (the caller) on its Credentials tab.\n3. **Bind a delegation connection**: caller = this agent, target = the Sports Inc\n   agent, with a label/instructions describing when to use it.\n4. **Share the credential** on that connection: on this agent's Connections tab,\n   under the Sports Inc connection, check `SPORTSINC_API_KEY` in \"Credentials to\n   share with this connection\". Nothing is shared unless you check it.\n\n**A2A call flow (agent-executed MCP tools):**\n\n1. `get_my_bundle()` → its `structuredContent.connections[]` lists your bound\n   agents; each entry is `{ targetAgentUid, displayName, label, instructions }`.\n   Pick the Sports Inc one (match on `displayName`/`label`).\n2. `start_agent_conversation({ agent_uid: <targetAgentUid> })` → returns a\n   `conversation_id`.\n3. `send_message({ conversation_id, content })` where `content` is the JSON\n   request for the Sports Inc agent, e.g. `{\"action\": \"get-for-a2a\", \"params\":\n   {\"include_historical\": false}}`. The reply is the `get-for-a2a` envelope\n   (`{success, invoices, metadata, error}`) — on `success:false`, read\n   `error.retriable` to decide retry vs. escalate.\n\n**Async-task delegation (what the multi-invoice routine uses).** When you\ndelegate the lookup as a **task** (`start_task`) rather than a synchronous\n`send_message`, the Sports Inc agent replies in its task **summary**, which is\n**size-capped (~20k chars)** — a raw JSON dump of several POs overflows it and\nis silently truncated, handing you a half-parsed payload. So **ask for a compact\nmarkdown breakdown, not JSON**: one section per PO, each SI document with its\n`si_doc_number`, `invoice_number`, `invoice_date`, `due_date`, `is_credit`,\n`has_lines`, the money (`merchandise`, `freight`, `si_upcharge`, `total`), and a\nterse line per item (`item`, `upc`, `size`, `qty_shipped`, `net_price`,\n`extension`, `description`). Markdown carries all the same data — you don't need\nstrict JSON. If the reply looks cut off or is flagged truncated, treat it as\n**incomplete** and re-request (or fewer POs at a time) rather than billing a\npartial payload.\n\nThen continue the normal reconciliation/draft-bill loop with the returned\n`invoices` (same normalised shape as `sportslink.py list`).\n\n**How the handoff works (pull/broker):**\n\n- The delegation binding is the allowlist — only bound agents are reachable, and\n  the call chain is depth-limited (max 5) against loops.\n- The shared credential is **pulled on demand, not pushed**. When the Sports Inc\n  agent handles the delegated call, its runtime calls\n  `get_delegated_credentials({ conversation_id })`. The platform verifies it is\n  the target of that delegated conversation and that the connection shares the\n  credential, **logs the access** in `agent_connection_audit_log`, and returns a\n  `{ env_key: value }` map. The runtime exposes it as env for the turn, so the\n  Sports Inc agent's `sportsinc-sportslink` skill reads `SPORTSINC_API_KEY`\n  normally. The secret only moves when the target actually asks for it, and every\n  access is audited — nothing is provisioned permanently onto the Sports Inc agent.\n\n## Deep reference\n\nFull matching rules, the exact MCP tool payload shapes, a worked five-document\nexample, and the **low-cost model recommendation + per-match economics** are in\n[`references/matching_procedure.md`](references/matching_procedure.md). Read it\nwhen you need the details behind a step; the SKILL above is the operating loop.\n\nFile v0.9.8:_meta.json\n\n{\n  \"ownerId\": \"kn715tnf30wegyr6mdbfa17avd87bjr6\",\n  \"slug\": \"drivethru-payable-matching\",\n  \"version\": \"0.9.8\",\n  \"publishedAt\": 1789756719703\n}\n\nFile v0.9.8:references/matching_procedure.md\n\n# Payable matching — full procedure, tool shapes, example, economics\n\nReference behind `SKILL.md`. Read it when you need the detail behind a step,\nthe exact field names a payload carries, or the cost model for running this at\nvolume.\n\n---\n\n## 1. Why the design is shaped this way\n\nTwo forces drive every choice:\n\n- **Context economy.** Documents are PDFs. `documents_get` returns their bytes\n  as base64; a multimodal PDF reader adds a page image. Both are enormous next\n  to the ~300–600 characters of text that actually matter, and this runs over\n  many documents many times a day. So text extraction is pushed into\n  `scripts/paymatch.py` (`extract`), which decodes and reads the text **locally**\n  and returns text only. The model never sees base64 or a render. `po-lines`\n  likewise trims the verbose PO payload to the matchable fields. The result: a\n  document \"match\" costs the model a few thousand tokens, not tens of thousands.\n\n  Extraction runs **PyMuPDF → poppler `pdftotext -layout` → pypdf** and\n  quality-gates the output. PyMuPDF is first because it honours ToUnicode CMaps\n  and reads **Type3 / custom-encoded fonts** that pypdf returns as empty or\n  garbage (the failure that stranded Charles River Apparel confirmations). A\n  result that is empty or fails the reliability gate flags the document\n  `needs_vision: true`; the model then calls `render` to rasterise the page(s)\n  with PyMuPDF (no system poppler) and reads them with vision (plus tesseract OCR\n  if present) — keeping even the unreadable-text case out of a dead-end\n  escalation, while still never pulling raw base64 into context.\n\n- **Judgment stays with the model.** The script does deterministic I/O (fetch,\n  decode, extract, apply a price, move a file). The *matching decision* — which\n  document line pairs to which PO line, whether a total gap is a partial\n  shipment, whether a freight figure is authoritative, whether something is a\n  genuine question — needs the model, because vendor documents vary too much for\n  a rigid parser. Get this division wrong in either direction and you either\n  bloat context (model reading raw bytes) or get brittle matches (script\n  guessing intent).\n\n---\n\n## 2. The tool surface (MCP), and the payload shapes you'll see\n\nThe skill drives the Odoo `drivethru_mcp` MCP tools. `paymatch.py` wraps the\nones below; the field names are what the tools actually return (know them so you\ndon't waste calls discovering them).\n\n### Documents app\n\n- `documents_list_folders {name?, parent_id?}` → `{folders: [{id, name, parent,\n  document_count}]}`. Resolve `Purchasing` → its id; list its children with\n  `{parent_id}` to get the `Matched` / `Questions` folder ids.\n- `documents_search {folder_id, limit, offset, include_subfolders?}` →\n  `{documents: [{id, name, type, mimetype, file_size, folder:{id,name}, tags,\n  res_model, res_id, ...}], total_matched}`. Metadata only — **no bytes**.\n  Paginate on `total_matched`.\n- `documents_get {document_id}` → the metadata **plus** `data_base64` (the file\n  bytes) and `open_activities`. Files over the 20 MB guard return a\n  `download_url` instead of bytes. (`paymatch.py extract` calls this per file\n  and throws the base64 away after extracting text.)\n- `documents_update {document_id, fields:{folder_id}}` → moves the document\n  (this is how a document leaves the inbox — there is no delete tool).\n- `documents_post_message {document_id, body, activity_user?, activity_summary?,\n  activity_date_deadline?}` → posts a chatter note and, with `activity_user`,\n  schedules a To-Do for that reviewer. `activity_user` matches login/email\n  first, then name.\n\n### Accounts payable\n\n- `ap_search_purchase_orders {search?, vendor?, state?, limit?}` →\n  `{count, purchase_orders: [{id, name, partner_id, partner_name, partner_ref,\n  amount_untaxed, amount_total, freight_cost, fees_cost, state,\n  receipt_status, invoice_count, buying_group, ...}]}`. `search` matches PO name\n  / partner_ref / vendor order number.\n- `ap_get_purchase_order {po_id}` → the header fields above **plus**\n  `lines: [{id, product_id, product_name, product_sku, description,\n  product_qty, qty_received, qty_invoiced, price_unit, price_subtotal,\n  style_number, intelligent_id, ...}]` and `existing_bills`. `id` on a line is\n  the `line_id` you pass to update. (`paymatch.py po-lines` trims this to\n  `{line_id, sku, style, description, qty, qty_received, qty_invoiced,\n  price_unit, price_subtotal}` — `qty_received` vs `qty` is the per-line signal\n  for whether a partial shipment has now completed the PO.)\n- `ap_update_po_lines {po_id, lines:[{line_id, price_unit}], freight_cost?,\n  fees_cost?}` → `{po_name, lines_updated:[{line_id, old_price, new_price}],\n  new_amount_total}`. The PO must be `state: \"purchase\"` (confirmed).\n- `ap_create_vendor_bill {po_id, vendor_bill_number?, invoice_date?, line_ids?,\n  expected_total?, tolerance?, reviewer_user_id?, review_note?}` → **draft**\n  `account.move`. The payables tail (`paymatch.py bill`). Bills the PO's\n  remaining `qty_to_invoice`; errors \"No billable lines\" when the PO has none\n  left.\n- `ap_create_draft_bill {po_id|vendor_id, vendor_bill_number?, invoice_date?,\n  lines?:[{product_id|product_name, quantity, price_unit, tax_ids?, …}],\n  reviewer_user_id?, review_note?}` → an **off-PO draft** bill, independent of\n  `qty_to_invoice`, that **never posts**. For the over-invoice case (SI billed\n  items the PO can't cover — e.g. replacements for units never received): seed\n  the lines from the payload with the PO's own product ids, and pass\n  `reviewer_user_id` + `review_note` to land it with a review activity. `po_id`\n  links `invoice_origin` (traceability + re-run idempotency).\n- `ap_post_vendor_bill {bill_id, post?, expected_total, tolerance?,\n  vendor_bill_number?, invoice_date?, note?}` → **posts** a draft vendor bill,\n  the **match & post** step (`paymatch.py post`). Guarded: `in_invoice` + draft\n  only, previews unless `post:true`, and **refuses to post** when the bill total\n  misses `expected_total` beyond `tolerance` (an absolute currency amount) —\n  returns `{posted, total_check{expected,actual,difference,within_tolerance},\n  already_posted?}`. A refused/mismatched bill stays in draft → escalate to\n  Questions, never post it.\n\n### Vendor-bill line CRUD (multi-shipment split — one PO, several SI invoices)\n\nOnly for the multi-invoice case (see `sportsinc_payables.md` →\n*Multiple invoices for one PO*). All three edit **draft `in_invoice`** moves only\nand never touch the auto-computed tax/payable journal items. `ap_get_vendor_bill`\nreturns each editable line with `purchase_line_id`, `product_sku`, `quantity`,\n`price_unit`, `tax_ids`, `account_id`, and `cost_center_id` — the handles below.\n\n- `ap_create_bill_line {bill_id, lines:[{product_id|product_name,\n  account_id|account_name, name?, quantity?, price_unit?, tax_ids?,\n  cost_center_id|cost_center_name?, display_type?}]}` → adds line(s); resolves\n  product/account by id **or** name. Use for a shipment's freight\n  (`Vendor Shipping Charge` / `Inbound Freight`, `tax_ids: []`).\n- `ap_update_bill_lines {bill_id, lines:[{line_id, quantity?, price_unit?,\n  name?, tax_ids?, …}]}` → patches line(s); absent keys unchanged, `tax_ids`\n  REPLACES (`[]` clears). Restate a split line's `quantity`, or the Sports Inc.\n  Fee line's `price_unit` to the document's `si_upcharge`.\n- `ap_delete_bill_lines {bill_id, line_ids:[…]}` → removes line(s); dropping a\n  merch line frees its PO line to bill on the next shipment's bill.\n\nAlso on `ap_search_purchase_orders`: `min_tracking_count` / `max_tracking_count`\nfilter on the count of `vendor.tracking` rows — `min:2` is the likely\nmulti-invoice slice, `max:1` the single-shipment slice.\n\n### PO chatter\n\n- `po_post_message {po_id, body, issue_type?, activity_user_id?}` → posts the\n  \"checked\" note (or an exception) onto the PO as an internal **log note** (never\n  a customer/vendor-facing \"Send message\"). `{message_id}`.\n- `po_get_messages {po_id, limit?}` → the PO's chatter, newest first as plain\n  text, plus open activities. Read it before posting a partial-shipment note to\n  see which lines earlier shipments already checked (`paymatch.py notes` wraps\n  this and also resolves a PO number → id).\n\n### Document render (vision / OCR fallback)\n\n- `paymatch.py render {document_id, pages?, dpi?}` pulls the file via\n  `documents_get` and rasterises its page(s) to PNG with **PyMuPDF** — no system\n  poppler needed — returning `{images:[paths], ocr_text, ocr_engine}` (OCR text\n  only when `tesseract` is installed). This is the escape hatch for a\n  `needs_vision` document: a scan, or a Type3 / custom-encoded PDF whose text\n  layer won't decode. Read the returned image(s) with vision to pull the PO\n  number and line items, then match as normal — never escalate it unread.\n\nOperator docs for deeper semantics (fetch via the MCP `docs_get` tool):\n`documents` and `invoices`.\n\n---\n\n## 3. Matching rules\n\n- **PO number comes from the document body, not the filename.** Filenames often\n  carry the vendor's order number; read the \"PO Number / PO #\" field in the\n  text.\n- **Pair lines by (style/item, color, size), never by row order.** Odoo and the\n  vendor sort differently, and a partial shipment matches only a subset of the\n  PO's lines.\n- **Size upcharges are legitimate pricing.** Base sizes (S–XL) at one price with\n  2XL/3XL/4XL higher is normal — not an error. A single base size priced off\n  the others (e.g. S at $13.94 when M/L/XL are $11.94 and the vendor confirms\n  $11.94) is the error.\n- **Totals cross-check, they don't decide.** A shipment acknowledgement is often\n  one box of a multi-shipment order; its total is legitimately below the PO\n  total by the value of the un-shipped lines. Reconcile lines; explain the gap\n  in the checked note.\n- **Partial shipments accumulate — name the lines and call the last one.** When a\n  shipment covers only some of the PO's lines, list the checked lines/SKUs in the\n  log note, then read the PO's prior notes (`po_get_messages` / `paymatch.py\n  notes`) and per-line `qty_received`. When this shipment's lines **unioned with\n  the previously-checked lines cover every line on the PO** (nothing on\n  back-order), annotate that the PO is **now fully checked across all shipments**;\n  otherwise state which lines are still outstanding. The log notes are the ledger\n  this relies on — never claim completion without them.\n- **Log notes only — never \"Send message\".** Every annotation on a PO or document\n  (checked note, partial-shipment note, question) is an internal Odoo log note; it\n  must never notify followers or email the vendor/customer. `po_post_message` /\n  `documents_post_message` (and the helper's `matched` / `questions`) post log\n  notes — use nothing that sends externally.\n- **Freight / fees are PO-level and invoice-time.** Correct them only when the\n  document gives an authoritative figure. A pre-existing freight estimate or a\n  partner-level fee the document doesn't itemize (e.g. an order acknowledgement\n  shipped \"UPS Ground Collect\") is not a line-pricing error — note it for the\n  eventual invoice match and leave it.\n- **Confirmed POs only.** `ap_update_po_lines` requires `state: \"purchase\"`. A\n  draft/other-state PO that needs a change is a Questions case.\n\n---\n\n## 4. Filing rule (non-negotiable)\n\nEvery reviewed document leaves the Purchasing inbox into a sibling subfolder:\n\n- **Matched** — prices reconciled, or corrected with confidence.\n  `paymatch.py matched '{\"po_id\", \"document_id\", \"body\"}'` posts the checked\n  note and moves the document in one call.\n- **Questions** — a genuine ambiguity (unreadable/unresolvable PO#, prices that\n  don't reconcile, unexpected/missing lines, wrong vendor).\n  `paymatch.py questions '{\"document_id\", \"question\", \"reviewer\": \"Zach Tucker\"}'`\n  raises the reviewer activity and moves the document in one call. Add\n  `po_id` + `po_note` to also annotate the PO.\n\nOnly escalate a **real** question. An unambiguous fix the vendor document\nsupports is a Matched correction — that is the job, not a question.\n\n---\n\n## 5. Worked example (the five-document run this skill was built from)\n\n`paymatch.py extract '{\"folder\": \"Purchasing\"}'` returned five documents as\ntext. Per document:\n\n| Document (text) | PO# (from body) | Finding | Action |\n|---|---|---|---|\n| SanMar Order Confirmation, SO-163291890 | **P13189** | Size **S** line reads $11.94 on the confirmation but $13.94 on the PO (M/L/XL $11.94, 2XL $12.94, 3XL $14.94 all match) | `apply {po_id:13145, lines:[{line_id:40941, price_unit:11.94}]}` (total $1,047.90→$1,041.90) → `matched` |\n| SanMar Order Confirmation, SO-163292190 | **P13193** | Both lines $1.87, match | `matched` (checked, no change) |\n| SanMar Order Confirmation, SO-163289633 | **P13194** | 3 lines $3.99, match | `matched` (checked, no change) |\n| SanMar **Shipment** Acknowledgement, SO-163260908 | **P13137** | Box 1 of a multi-shipment order: 6 of the PO's 11 lines, all $1.79 and matching; doc total $53.70 vs PO $98.45 is just the 5 un-shipped lines | `matched`, checked note explaining the partial shipment |\n| Workwear Outfitters Order Acknowledgement (filename `48482500`) | **P13183** (read from body) | Both lines match ($14.30, $40.32); PO also carries freight $2.00 and a −$2.38 fee the ack doesn't itemize (invoice-time) | `matched`, note the freight/fee for the invoice match |\n\nNone needed Questions. A document whose text won't extract (scanned, or\nType3/custom-encoded like the Charles River Apparel confirmations) is **not** a\nQuestions case — it flags `needs_vision`, and you `render` it and read the\npage image(s) with vision/OCR, then match normally. Only a genuine\nreconciliation failure (unresolvable PO#, prices that don't reconcile, wrong\nvendor) is `questions`-filed to Zach Tucker instead.\n\nThe pattern per document is 2–3 helper calls after the single `extract`:\n`po-lines` → (`apply` if a fix) → `matched`/`questions`.\n\n---\n\n## 6. Low-cost model recommendation + per-match economics\n\nBecause the heavy PDF work lives in `paymatch.py` and the PO payload is trimmed,\nthe model reads a few hundred characters of text plus a lean line list per\ndocument and drives a short deterministic tool sequence — light work a small\nmodel handles well, with the Questions→reviewer path as the safety net on live\nfinancial data.\n\n**Recommended: `claude-haiku-4-5` as the default; `claude-sonnet-5` as an\naccuracy step-up.** (IDs/pricing per the `claude-api` skill catalog; verify with\nthe Models API if unsure.)\n\n| Model | Price in/out (per 1M) | Est. cost / match | Use as |\n|---|---|---|---|\n| `claude-haiku-4-5` | $1 / $5 | **~$0.02–0.05** (~$0.03 typical) | Default — cheapest; ample here |\n| `claude-sonnet-5` | $3 / $15 (intro $2/$10 to 2026-08-31) | **~$0.04–0.14** (~$0.07 typical) | Step-up for messy/unfamiliar vendor layouts |\n\n**Per-match token shape** (one document reconciled): ~3–4K *new* input\n(extracted text + lean PO lines + tool results) + ~1K output, plus cached\nre-reads of the system/tools prefix at 0.1×. The `extract` step is a script\ncall — near-zero model tokens, shared across the whole folder. At **50\ndocs/day (~1,300/mo)** that's roughly **$40/mo on Haiku**, **~$90/mo on\nSonnet**. These are modeled from observed document/PO shapes, not billed\ncounts — **run one real batch on Haiku with usage logging to confirm COGS.**\n\n**Suggested pricing** (price off value/labor replaced, not inference cost — a\nclerk eyeballing a confirmation against a PO is ~3–8 min ≈ $1.25–3.30 loaded,\nand the tool also catches real overcharges — e.g. the $6.00 error above):\n\n| Package | Price | Gross margin on Haiku | Notes |\n|---|---|---|---|\n| Per-match (standard) | **$0.50** | ~94% | Haiku-backed default; obvious ROI vs. clerk time |\n| Per-match (high-accuracy) | **$1.00** | strong even on Sonnet | Sonnet 5 for accuracy-sensitive vendors |\n| Monthly plan | **~$499/mo, up to 1,500 matches** (~$0.33 effective) | ~90%+ | Predictable for the customer; overage ~$0.40 |\n\nLead with **$0.50/match on Haiku 4.5**; offer the **$1.00 Sonnet tier** as an\naccuracy upsell. Keep the cache warm and the loop tight (one `extract` per\nfolder, cache the prefix) — a bloated system prompt or extra turns is the main\ncost risk, and the skill already keeps the expensive PDF work out of the model.\n\nFile v0.9.8:references/sportsinc_payables.md\n\n# Sports Inc payables — end-to-end (SportsLink → match → bill → post-on-match)\n\nSports Inc is a buying group that doesn't send individual vendor invoices; the\ninvoices live in the SportsLink API. This is the automated payables loop for\nthem: pull the invoices, reconcile each to its Odoo PO, correct price variances,\ncreate the bill, **post it when its total matches the invoice** (else leave it\nin draft), and mark the SI document consumed — with a human handling anything\nflagged.\n\nThree skills cooperate (this is the ports-and-adapters split in practice):\n\n- **Source adapter** — `sportsinc-sportslink` (`sportslink.py`): fetch invoices,\n  mark consumed. Customer-agnostic.\n- **Workflow** — this skill: reconcile invoice ↔ PO, correct/escalate, create the\n  draft bill. Source- and ERP-agnostic.\n- **ERP adapter** — `drivethru-odoo` / `drivethru_mcp` (`paymatch.py`,\n  `ap_create_vendor_bill` / `ap_post_vendor_bill`): the Odoo writes.\n\n## Configured policy (BaconCo)\n\n- **Posting: post on match, draft on mismatch.** Create the bill, then **post it\n  when the bill total matches the invoice `expected_total` within tolerance**\n  (`paymatch.py post`, backed by the guarded `ap_post_vendor_bill`). A bill that\n  doesn't match is **left in draft** and escalated to the reviewer — never post a\n  mismatch. When a run should stay hands-off (a human reviews before posting),\n  skip the `post` step and leave every bill in draft.\n- **On variance: auto-fix price, escalate qty/line.** A unit-price difference is\n  treated as the SI invoice being authoritative — correct the PO line (like the\n  pricing review), then bill. A **quantity / missing-line / total-structure**\n  variance is **not** auto-fixed — escalate it (leave the SI doc active, raise an\n  activity to the reviewer, create no bill).\n- **Reviewer:** Zach Tucker (`reviewer_user_id: 6` in BaconCo's Odoo). Keep this\n  in tenant config, not hard-coded in prose.\n- **Tolerance:** a small **absolute** amount (a few cents, e.g. `0.02`) for the\n  `expected_total` match check on both create and post — once prices are\n  reconciled the SI docTotal should equal the computed bill to within rounding.\n- **Chatter: internal log notes only.** Every PO note or escalation this loop\n  posts (via `po_post_message`) is an internal Odoo **log note**, never a \"Send\n  message\" — nothing here is emailed to the vendor.\n\n## The exactly-once loop (do not deviate)\n\nYou are creating payables — double-billing is the cardinal sin. The SportsLink\n`active`/historical flag is the idempotency mechanism; the sequence is fixed:\n\n1. **Pull the inbox.** `sportslink.py list '{\"active\": true, \"lines\": true, \"ediOnly\": true}'`\n   → normalised invoices that are not yet imported and carry line items.\n2. **Process each invoice** (below). Create the draft bill in Odoo.\n3. **Mark consumed only after the draft is created.**\n   `sportslink.py mark-historical '{\"siDocNumbers\": [<si_doc_number>]}'`.\n4. **Anything that fails or is escalated stays active** — it simply reappears on\n   the next run. Never `mark-historical` a doc you didn't bill.\n\nNever use the API's `moveToHistorical=true` GET flag — it marks on read, before\nbilling, so a crash drops the invoice. Belt-and-suspenders on the Odoo side:\nbefore creating, check the PO's `invoice_count` / existing bills for the same\n`supplier_doc_number`, in case a prior run's mark-historical failed after the\nbill was created.\n\n## One PO can have several invoices — group by PO first\n\nInvoices are keyed by `si_doc_number` (one row per SI document). Sports Inc\nissues **one document per shipment**, so a PO that shipped in several boxes comes\nback as **several rows sharing one `po_number`**. Before billing, group the\nadapter's rows by `po_number`:\n\n- **Exactly one** SI document for the PO → the single-invoice path below\n  (steps 1–8).\n- **Two or more** SI documents for the PO → the PO bills as several vendor\n  bills, one per document. Follow **[Multiple invoices for one\n  PO](#multiple-invoices-for-one-po-multi-shipment-split)** instead of steps\n  5–8. Do **not** create one bill for the whole PO — its lines, freight, and\n  `si_upcharge` belong to different documents.\n\nThe `drivethru-ap-sports-inc-multi-invoice` routine pre-filters to the POs most\nlikely to be in this case with `ap_search_purchase_orders`\n`min_tracking_count: 2` (two-plus `vendor.tracking` rows ⇒ several shipments);\nthe single-invoice routine uses `max_tracking_count: 1`. The tracking count is\nonly a *net* — the SI payload's document count is what actually decides\nsingle-vs-multi.\n\n## Per-invoice procedure\n\nFor each normalised invoice from the adapter (single-document POs):\n\n1. **Credit?** `is_credit: true` → do not bill. Escalate to the reviewer (vendor\n   credit is a human decision). Leave active.\n2. **No lines?** `has_lines: false` (scanned/OCR doc) → can't line-verify.\n   Escalate to the reviewer for a header-only decision. Leave active.\n3. **Find the PO.** `paymatch.py po-lines '{\"po\": \"<po_number>\"}'`. If it doesn't\n   resolve (`found: false`) or the vendor doesn't line up → escalate, leave\n   active.\n4. **Reconcile lines** by (item/style, size, color): compare the invoice\n   `net_price` to the PO line `price_unit`, and `qty_shipped` to `qty`.\n   - **Price variance only** → `paymatch.py apply '{\"po_id\", \"lines\":[{\"line_id\",\n     \"price_unit\": <invoice net_price>}]}'` (SI invoice is authoritative on\n     price), then continue.\n   - **Quantity / missing line / extra line / total-structure variance** →\n     **escalate** (`paymatch.py questions '{\"document_id\"? , \"question\", ...}'`\n     or a PO note + reviewer activity), leave the SI doc active, create no bill.\n     (Sports Inc docs are API rows, not Documents-app files, so escalate on the\n     PO chatter via `po_post_message` and/or a tracked task — there's no\n     Documents folder to file.)\n5. **Verify the SI charges tie out.** `docTotal` includes SI-specific charges\n   (`si_upcharge`, `svc_handle`, `freight`, `sales_tax`, less `discount` /\n   `freight_allowance`). Odoo's bill create() **auto-appends the Sports Inc fee\n   lines** for `buying_group: \"si\"` POs — so pass the invoice `total` (docTotal)\n   as `expected_total`; do not pre-add the SI fees yourself (you'll double them).\n6. **Create the draft bill.**\n   ```\n   paymatch.py bill '{\n     \"po_id\": <id>,\n     \"vendor_bill_number\": \"<supplier_doc_number or si_doc_number>\",\n     \"invoice_date\": \"<invoice_date>\",\n     \"expected_total\": <invoice total>,\n     \"tolerance\": 0.02,\n     \"reviewer_user_id\": 6,\n     \"review_note\": \"SI SportsLink doc <si_doc_number>; matched PO <po_number>; <price fixes>.\"\n   }'\n   ```\n   If the create returns `success: false` (the computed bill total missed\n   `expected_total` beyond tolerance) → **do not** mark historical; escalate with\n   the discrepancy and leave the doc active.\n7. **Mark consumed.** On a successful draft, `sportslink.py mark-historical\n   '{\"siDocNumbers\": [<si_doc_number>]}'`.\n8. **Post it if it matches.** Post the draft (created bill `id` from step 6) so\n   it hits the ledger — unless this is a draft-only run:\n   ```\n   paymatch.py post '{\n     \"bill_id\": <created bill id>,\n     \"expected_total\": <invoice total>,\n     \"tolerance\": 0.02,\n     \"note\": \"SI SportsLink doc <si_doc_number>; matched PO <po_number>; posted on match.\"\n   }'\n   ```\n   `post` re-checks the total and **refuses** (leaving the bill in draft) if it\n   no longer matches within tolerance — treat a refusal as an escalation to the\n   reviewer, exactly like a create mismatch. Posting an already-posted bill is a\n   safe no-op (`already_posted: true`), so a re-run never double-posts.\n\n## Multiple invoices for one PO (multi-shipment split)\n\nWhen Sports Inc returns **2+ documents for one `po_number`**, the PO must bill as\n**one vendor bill per SI document**. Each document (`si_doc_number`) covers a\nsubset of the PO's lines, with its own `freight` and its own `si_upcharge`; its\n`docTotal` is the whole bill (merch + upcharge + freight). Odoo's PO→bill\n`create()`, by contrast, bills **every** received line at once and computes the\nSI fee on the **whole** PO — so you create that draft and then **carve it down**\nto each document with the `account.move.line` tools:\n\n- `ap_get_vendor_bill {bill_id}` — each editable line carries `purchase_line_id`,\n  `product_sku`, `quantity`, `price_unit`, `tax_ids`, `account_id`, and the fee\n  line's handle. These are the only ids the carve tools accept.\n- `ap_update_bill_lines {bill_id, lines:[{line_id, quantity?, price_unit?,\n  name?, tax_ids?}]}` — patch a line: drop a split line's `quantity` to this\n  shipment's qty, restate the **Sports Inc. Fee** line's `price_unit` to this\n  document's `si_upcharge`, clear `tax_ids`.\n- `ap_create_bill_line {bill_id, lines:[{product_name|account_name|…,\n  price_unit, tax_ids}]}` — add this shipment's freight\n  (`Vendor Shipping Charge` / `Inbound Freight`, `price_unit` = `freight`) when\n  the PO carried none.\n- `ap_delete_bill_lines {bill_id, line_ids:[…]}` — drop the merch lines that\n  belong to **later** shipments (that frees each dropped PO line to bill on the\n  next pass), or a duplicate auto-added fee line.\n\n**Precondition — line-level detail required.** Only run the split when **every**\ndocument for the PO has line items (`has_lines: true`). If any is header-only\n(scanned / OCR, `has_lines: false`), the split can't be verified line-by-line —\n**skip the whole PO and escalate** (leave every one of its SI docs active).\n\n**One bill per document; post only what the PO supports.** Create a bill for\n**every** SI document (so the bill count matches the document count), but only\n**post** the ones the PO's quantities cover. A document the PO can't cover — an\n**over-invoice**, e.g. the vendor invoiced replacements for units it never\noriginally shipped, after the earlier bills already consumed the PO's whole\nquantity — still becomes a bill, but a **draft with a review activity**, never a\nposted one.\n\n**Procedure** — process the documents oldest first; each pass creates one bill:\n\n1. **Reconcile prices first, once.** Compare each SI line's `net_price` to its PO\n   line `price_unit` across all the PO's documents; `ap_update_po_lines` any\n   price variance (SI is authoritative on price), escalate qty/line variances —\n   same rule as the single-invoice path.\n2. **Create the draft.** `ap_create_vendor_bill {po_id}` bills all lines still\n   billable (received, not already on a draft/posted bill). On the first pass\n   that's the whole PO; on later passes only what earlier bills left behind.\n   - **Over-invoice branch.** If this returns **\"No billable lines\"** (the PO has\n     no `qty_to_invoice` left) while SI still has an unbilled document, that\n     document is an over-invoice the PO can't substantiate. Create it **off-PO**\n     instead: `ap_create_draft_bill {po_id, vendor_bill_number, invoice_date,\n     lines: [{product_id, quantity, price_unit, tax_ids: []}], reviewer_user_id,\n     review_note}` — build `lines` from the SI payload using the **PO's own\n     product ids** (map by supplier item + size against `ap_get_purchase_order`),\n     add the document's freight + `si_upcharge` fee lines so the draft total\n     equals `docTotal`, and **do not post it** (`ap_create_draft_bill` never\n     posts). Make `review_note` say we were invoiced for items not on the PO and\n     a human must review. Then move to the next document (step 6).\n3. **Map lines to this document.** `ap_get_vendor_bill`; pair each bill line to\n   an SI line by **(supplier item, size)** via `product_sku` (e.g.\n   `…(JP1477)-S`) — never by row order.\n4. **Carve to exactly this document:**\n   - `ap_delete_bill_lines` the merch lines that belong to **other** shipments.\n   - For a PO line split across shipments, `ap_update_bill_lines` its `quantity`\n     down to this document's `quantity_shipped` (the remainder bills next pass).\n   - `ap_update_bill_lines` the **Sports Inc. Fee** line's `price_unit` to this\n     document's `si_upcharge` (verify exactly one fee line remains; delete a\n     duplicate). Do **not** re-derive the fee — use the number SI gave.\n   - If this document has `freight` and no freight line exists,\n     `ap_create_bill_line` a `Vendor Shipping Charge` line at that amount.\n5. **Set the reference + post.** `ap_post_vendor_bill {bill_id, post: true,\n   expected_total: <docTotal>, vendor_bill_number: <supplier_doc_number>,\n   invoice_date: <supplier_doc_date>, tolerance: 0.02}`. The gate refuses a\n   mis-carved bill (total ≠ `docTotal`); a refusal is an escalation, not a\n   retry-blindly.\n6. **Repeat** from step 2 for the next document until **every** document for the\n   PO has a bill — posted where the PO covers it, draft-with-review-activity\n   where it doesn't (the over-invoice branch). The bill count matches the SI\n   document count; only the reconciling ones are posted.\n7. **Mark consumed after all its bills exist.** `mark-historical` every\n   `si_doc_number` for the PO (each only after its bill is created), and flip\n   `is_pricing_checked = true` on the PO once all its shipments are billed. A\n   partially-billed PO stays active so the next run finishes it.\n\nIdempotency across a killed run: before creating, check the PO's existing bills\n(`ap_get_purchase_order` → `existing_bills`, and each `si_doc_number` against\nbill `ref`) so a re-run resumes where it stopped rather than double-billing an\nalready-posted document.\n\n## Scheduling & resilience\n\n- **Run after ~10:30am ET** (SI processing completes first). This is a scheduled\n  batch — a good fit for a cron/Routine trigger.\n- The loop is self-healing: transient API failures retry (the adapter backs off);\n  anything unresolved stays active and is retried next run; a run can be killed\n  and restarted safely because nothing is marked consumed until its bill exists.\n- **Notify on exceptions.** Summarise per run: posted N bills and left M in\n  draft (list PO / amounts, posted vs draft), corrected P prices, escalated Q\n  (with reasons), and any hard failures. Draft bills + escalations are the\n  human's queue.\n\n## Why an agent, not a custom Odoo module\n\nThe deterministic mechanics (auth, paging, normalisation, mark-historical) live\nin `sportslink.py`; the ERP writes are single tool calls. The **agent** adds the\njudgment (which variances to auto-fix vs. escalate, credit/scanned handling, PO\nresolution) and the resilience (retry, anomaly detection, human-readable failure\nnotices) that a deterministic module would force you to hand-code and redeploy\nper edge case. Keep the mechanics in scripts (out of the model's context) and the\njudgment in the agent — the same division that makes the folder pricing review\ncheap.\n\n## Not testable without live access\n\n`sportslink.py` needs `SPORTSINC_API_KEY` and reaches `api.sportsinc.com`, and\nbilling writes to live Odoo. Smoke-test in stages: (1) `list` read-only against a\nrecent date; (2) one `bill` on a known PO with `SPORTSINC_DRY_RUN=1` so nothing\nis marked consumed; (3) confirm the draft in Odoo and the reviewer activity;\nthen enable `mark-historical`. Confirm the `vendor_bill_number` convention\n(supplier vs. SI doc number) and the bill's vendor/partner for `buying_group:si`\nPOs during that first pass.\n\nFile v0.9.8:skill-card.md\n\n## Description:\n\nReconciles BaconCo vendor documents and Sports Inc invoices against Odoo purchase orders, corrects supported price variances, files documents for matched or human review, and posts vendor bills only when totals match.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[zmtucker](https://clawhub.ai/user/zmtucker)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nEmployees and finance operators use this skill to reconcile payable documents, update Odoo purchase order pricing when vendor evidence is clear, and create or post vendor bills only when invoice totals pass the configured match check. Ambiguous, unreadable, mismatched, or unsupported cases are routed for human review.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The skill can change live accounts-payable records, including PO prices, document filing, vendor bills, and matched bill posting.\n\nMitigation: Install only where the Odoo token is intentionally allowed to perform those actions, confirm the intended posting mode, and begin with dry-run or draft-only operation until the workflow is validated on real tenant data.\n\nRisk: A mismatched or unsupported invoice could be posted if operators bypass the documented match checks.\n\nMitigation: Post only when the bill total matches the vendor invoice within tolerance; leave mismatches in draft and route them to a human reviewer.\n\nRisk: The Odoo MCP token and optional Sports Inc credential-sharing setup grant access to sensitive financial workflows.\n\nMitigation: Treat credentials as secrets, scope installation to trusted environments, and review the optional credential-sharing setup before enabling it.\n\n## Reference(s):\n\n- [ClawHub skill page](https://clawhub.ai/zmtucker/skills/drivethru-payable-matching)\n- [Odoo](https://www.odoo.com)\n- [Payable matching procedure](references/matching_procedure.md)\n- [Sports Inc payables](references/sportsinc_payables.md)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown status reports with inline shell commands and JSON payload examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May update Odoo purchase orders, document folders, internal log notes, draft vendor bills, and matched posted bills through configured tools.]\n\n## Skill Version(s):\n\n0.9.8 (source: frontmatter and server release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v0.9.7: 7 files, 42841 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (41156b), skill-card.md (3134b), SKILL.md (26438b), _meta.json (145b)\n\nFile v0.9.7:SKILL.md\n\n---\nname: drivethru-payable-matching\ndescription: >\n  Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents\n  app against their purchase orders and correct incorrect PO line pricing. Use\n  for requests like \"check the Purchasing folder against the POs and fix the\n  pricing\", \"match the vendor invoice / order confirmation / acknowledgement to\n  its PO\", \"AP price matching / invoice-to-PO matching / three-way match\",\n  \"reconcile the vendor documents and mark the POs checked\", or \"go through the\n  Purchasing folder\". The flow: read every document in a Documents-app folder\n  (extracting text out-of-context so large batches don't bloat the context\n  window — falling back to a page render + OCR/vision for scanned or\n  custom-encoded PDFs that won't extract as text), pull the PO number / line\n  items / unit prices from each, compare to the purchase order line by line,\n  correct any wrong `price_unit`, post a \"checked\" log note on the PO (internal,\n  never a \"Send message\"), and FILE every document into the `Matched` or\n  `Questions` subfolder — escalating genuine questions to a reviewer (default\n  Zach Tucker). Also runs the buying-group payables flow: pull Sports Inc\n  invoices from the SportsLink API (via the `sportsinc-sportslink` adapter),\n  reconcile each to its PO, correct price variances, create the vendor bill and\n  — when the bill total matches the invoice within tolerance — POST it, leaving\n  any mismatch in draft for a human (\"get the Sports Inc invoices and bill\n  them\", \"match the SI invoices to POs and post the payables\", \"match the vendor\n  invoice and post the bill if it matches\"). Handles the multi-shipment case\n  where one PO returns several Sports Inc invoices, splitting it into one vendor\n  bill per shipment via `account.move.line` edits (the `ap_*_bill_line(s)`\n  tools). Runs at volume on a low-cost model.\n  Driven by the Odoo `drivethru_mcp` MCP server; complements the broader\n  `drivethru-odoo` skill.\nversion: 0.9.7\nemoji: 🧾\nhomepage: https://www.odoo.com\nmetadata:\n  openclaw:\n    requires:\n      env: [ODOO_MCP_URL, ODOO_MCP_TOKEN]\n      bins: [python3, uv]   # uv powers the scripts' self-bootstrap fallback\n    primaryEnv: ODOO_MCP_TOKEN\n    envVars:\n      ODOO_MCP_URL:\n        required: true\n        description: >\n          Full URL of the Odoo MCP endpoint, e.g.\n          `https://odoo.example.com/drivethru_mcp/v1` — the MCP server exposed by\n          the `drivethru_mcp` Odoo module, not the Odoo base URL.\n      ODOO_MCP_TOKEN:\n        required: true\n        description: >\n          The `drivethru.mcp_key` value from the `drivethru_mcp` module, sent as\n          `Authorization: Bearer`. Treat as a secret; never paste into chat.\n    install:\n      uv:\n        - mcp>=1.9.0\n        - pymupdf>=1.24   # primary local PDF text extraction + page rasterization:\n                          # honours ToUnicode/Type3 (custom-encoded) fonts pypdf can't\n                          # read, and renders needs_vision docs without system poppler\n        - pypdf>=4.0      # last-resort text-extraction fallback\n---\n\n# Payable matching (Purchasing folder → purchase orders)\n\nReconcile vendor documents against their purchase orders and fix PO line\npricing. A **vendor document** is an order confirmation, shipment\nacknowledgement, or invoice filed in the Documents app's **Purchasing** folder;\nits authority for what BaconCo will be billed is the vendor's own numbers.\n\nThe task, per document: read it → extract the **PO number, line items, and unit\nprices** → find the PO in Odoo → compare **line by line** → correct any wrong\n`price_unit` → post a \"checked\" note on the PO → **file the document** into\n`Matched` (reconciled) or `Questions` (needs a human). Nothing stays in the\ninbox.\n\nThis changes live financial data (PO prices, chatter, activities). The\nstanding request *\"review the Purchasing folder and fix the pricing\"*\nauthorizes the corrections and the \"checked\" notes; still, **only correct a\nline when the vendor document unambiguously supports it** — when in doubt, route\nit to Questions rather than guessing.\n\n## Always use Log note, never Send message\n\nEvery annotation you leave on a PO or a document — the \"checked\" note, a\npartial-shipment note, a question, any comment — MUST be an internal **Log\nnote**, never a **Send message**. In Odoo chatter, \"Send message\" notifies the\nrecord's followers and can **email the vendor or customer**; a Log note stays\ninternal. These vendor documents are BaconCo's own AP working notes — nothing\nhere should ever leave Odoo as an email.\n\nThe helper's `matched` / `questions` commands and the `po_post_message` /\n`documents_post_message` MCP tools post internal **log notes** — use them. Do\n**not** reach for any \"send message\", email, or notify-followers path when\nannotating a PO or document, and if a tool ever exposes a note-vs-message\nchoice, always choose the internal note.\n\n## How you reach Odoo (runtime-aware — read this first)\n\nThis skill drives the same Odoo **`drivethru_mcp`** MCP as `drivethru-odoo`, and\n`ODOO_MCP_URL` / `ODOO_MCP_TOKEN` are already configured. **Never say you \"can't\nreach Odoo\" or \"don't have the tools in this thread\" and guess instead — call a\ntool, or state the exact call you tried and the error you got.**\n\n- **Native / callable MCP tools (preferred, no shell needed).** If the Odoo\n  tools are attached to you natively, **call them directly** — e.g.\n  `documents_list_folders {\"name\": \"Purchasing\"}`, whose `document_count` is how\n  many documents are waiting to be matched. They may be **deferred** — if your\n  runtime lazy-loads tools, search your tools for `documents_search`,\n  `ap_search_purchase_orders`, `ap_update_po_lines`, `po_post_message`,\n  `po_get_messages`, etc. and load them before calling. \"I don't see them yet\"\n  is not \"I don't have them.\"\n- **Shell (`scripts/paymatch.py`) — only if you have a shell.** The helper below\n  is a context-economy optimization (bulk PDF text extraction, one call per\n  folder); prefer it for large batches when a shell is available.\n- **Not connected at all?** Attaching the `drivethru_mcp` MCP natively — or\n  giving the agent a shell — is an **operator step**; env vars alone don't\n  attach the tools. See `drivethru-odoo`'s **Operator setup** and\n  **Troubleshooting** sections before concluding you can't reach Odoo.\n\n## The engine: `scripts/paymatch.py` (keep the work out of context)\n\nReading PDFs is the expensive part. `documents_get` returns each file as\n**base64**, and a multimodal PDF reader adds a **page image** — both dwarf the\nfew hundred characters of text that matter. Do that per file across a folder,\nmany times a day, and the context window fills with bytes and renders (and the\ntoken bill balloons). So **never loop `documents_get`/a PDF reader over the\nfolder.** Use the helper, which does the heavy, deterministic work locally and\nreturns only what the model needs.\n\n```bash\n# 1. Read the whole folder as TEXT (no base64, no render) — ONE call\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\"}'\n\n# 1a. Scheduled batch? Narrow the extraction instead of pulling every file:\n#     document_ids = only these docs; name_excludes = drop by name substring\n#     (case-insensitive); max_docs = cap the batch (extras reported as\n#     skipped_beyond_max_docs). folder still scopes the listing.\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"document_ids\": [7617, 7536]}'\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"name_excludes\": [\"sanmar\"], \"max_docs\": 8}'\n\n# 1b. Any document flagged needs_vision (scanned or custom-encoded) → render its\n#     page(s) to PNG (+ OCR if tesseract is present), then read the image(s)\npython3 scripts/paymatch.py render '{\"document_id\": 485}'\n#\n# Each extracted document also carries `po_candidates` (the distinct P##### tokens\n# in its text) and `multi_invoice` (true when it holds several invoices — >1\n# distinct PO, or >1 \"Invoice #/No/Number\" header). On multi_invoice, treat the\n# PDF as several invoices: process EACH against its own PO (resolve by name,\n# dedupe, bill/post per invoice) rather than billing only the first — a batch\n# PDF is usually one invoice per page. These are HINTS; verify against the text.\n\n# 2. Per document: pull the PO trimmed to matchable fields (incl. qty_received)\npython3 scripts/paymatch.py po-lines '{\"po\": \"P13189\"}'\n\n# 2b. Partial shipment? Read the PO's prior log notes to see what's already checked\npython3 scripts/paymatch.py notes '{\"po\": \"P13137\"}'\n\n# 3. Correct any wrong line(s) in one call (PO must be confirmed)\npython3 scripts/paymatch.py apply '{\"po_id\": 13145, \"lines\": [{\"line_id\": 40941, \"price_unit\": 11.94}]}'\n\n# 4a. Clean/fixed → post the checked LOG NOTE AND file to Matched (one call)\npython3 scripts/paymatch.py matched '{\"po_id\": 13145, \"document_id\": 481, \"body\": \"Pricing checked against SanMar Order Confirmation ... corrected size S $13.94→$11.94.\"}'\n\n# 4b. Genuine question → post a LOG NOTE and file to Questions (one call).\n#     The note explains what happened; the folder IS the review queue. Do NOT\n#     pass `reviewer` unless a human genuinely needs a to-do assigned — a\n#     `reviewer` turns the note into an assigned activity, and blanket\n#     activities on every Questions doc just spam the reviewer's to-do list.\npython3 scripts/paymatch.py questions '{\"document_id\": 485, \"question\": \"Totals do not reconcile — vendor total $X vs PO $Y.\"}'\n```\n\n**PO lookup is BY DISPLAY NAME, never by id.** The `P#####` printed on an\ninvoice is the purchase order's **name**, not its database id — they are\ndifferent numbers (name `P12774` lives at id `12730`; id `12774` is an\nunrelated PO). Always resolve with `po-lines '{\"po\": \"P12774\"}'` (or\n`ap_search_purchase_orders {search: \"P12774\"}`) and use the returned record's\nnumeric `id`. **Never** call `ap_get_purchase_order {po_id: 12774}` with the\nP-number's digits — it silently returns the WRONG order. `po-lines` now requires\nan exact name match and returns `found: false` with candidates otherwise, so it\ncan never bill against a substring near-miss.\n\nEvery command prints one JSON object, or `{\"error\": {...}}` with a non-zero\nexit. Requires `ODOO_MCP_URL` / `ODOO_MCP_TOKEN` (if missing, the script exits\nwith `config_error` — stop and tell the user to configure them; never ask for\nthe key in chat).\n\n**Dependencies self-install.** The script's deps (`mcp`, `pymupdf`, `pypdf`)\nare declared in the frontmatter `install.uv`, but not every OpenClaw host honors\nit — so `scripts/_bootstrap.py` ensures them at startup: on a missing import it\nbuilds a cached `uv` venv (in `$PAYMATCH_DATA_DIR` or `~/.drivethru/paymatch`)\nand re-execs. Hosts that pre-install make it a no-op; otherwise the **first run\npays a one-time install**, then it's cached. So a `ModuleNotFoundError` (e.g.\n`No module named 'anyio'`) is not a dead end — it self-heals on the next run, as\nlong as **`uv` is on PATH**. If the script exits saying `uv` is missing, that's\nan operator step (install `uv`, or the host must honor `install.uv`); fall back\nto the native `documents_*` / `ap_*` / `po_*` MCP tools in the meantime.\n\nIf you have **no shell** (e.g. a chat agent), use the native `documents_*` /\n`ap_*` / `po_*` MCP tools directly — they do everything the script does; the\nscript is only a context-economy wrapper for bulk PDF extraction. Working\nnatively, still fetch **one document at a time** and drop the base64 — never\npull a whole folder's bytes into context. (If creds are unset or the MCP isn't\nattached, see `drivethru-odoo`'s Troubleshooting — don't guess.)\n\n## Procedure\n\n1. **Read the folder once.** `paymatch.py extract '{\"folder\": \"Purchasing\"}'`\n   → `documents[]` with `document_id`, `name`, and extracted `text`. Work from\n   that text. Extraction runs PyMuPDF → `pdftotext` → `pypdf` and **quality-gates\n   the result**, so a document flagged `needs_vision: true` is one no text pass\n   could read reliably — either scanned/image-only, OR a **custom-encoded PDF**\n   (Type3 / no usable ToUnicode — e.g. some Charles River Apparel shipment\n   confirmations) that renders perfectly but extracts as empty/garbage.\n   **Do not escalate a `needs_vision` document as \"unreadable\" — read it.**\n   Run `paymatch.py render '{\"document_id\": <id>}'` to rasterise its page(s) to\n   PNG (plus OCR text when `tesseract` is installed), then read the image(s)\n   with vision to pull the PO number and line items and match as normal. (No\n   shell? Fetch with `documents_get {\"document_id\"}` and read the bytes with a\n   vision reader — same idea.) The old failure mode — \"couldn't get a reliable\n   PO number from unattended extraction, please review manually\" — is exactly\n   this case, and the render/vision fallback resolves it instead of punting.\n\n2. **Extract from each document's text (not its filename).** The PO number is\n   **inside** the document; a filename may show the vendor's order number\n   instead (e.g. `Order Acknowledgement 48482500.pdf` whose real PO is\n   `P13183`). Capture item/style, color, size, quantity, and **unit price** per\n   line.\n\n3. **Pull the PO.** `paymatch.py po-lines '{\"po\": \"<PO#>\"}'` → the PO trimmed to\n   `{po_id, name, vendor, partner_ref, state, amount_untaxed, freight_cost,\n   fees_cost, lines:[{line_id, sku, style, description, qty, price_unit}]}`.\n   Confirm the vendor / `partner_ref` line up with the document. The PO must be\n   `state: \"purchase\"` to edit lines. (`found: false` with `candidates` means\n   the PO# didn't resolve — that's a Questions case.)\n\n\n\nArchive v0.9.5: 7 files, 40771 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (38556b), skill-card.md (2816b), SKILL.md (24875b), _meta.json (145b)\n\nArchive v0.9.4: 7 files, 40506 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (37678b), skill-card.md (3008b), SKILL.md (24875b), _meta.json (145b)\n\nArchive v0.9.3: 7 files, 39858 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (36346b), skill-card.md (2499b), SKILL.md (24875b), _meta.json (145b)\n\nArchive v0.9.2: 7 files, 39517 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (5217b), scripts/paymatch.py (35156b), skill-card.md (2801b), SKILL.md (24875b), _meta.json (145b)\n\nArchive v0.9.1: 7 files, 38933 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (4386b), scripts/paymatch.py (34641b), skill-card.md (2644b), SKILL.md (24875b), _meta.json (145b)\n\nArchive v0.9.0: 7 files, 38421 bytes\n\nFiles: references/matching_procedure.md (16428b), references/sportsinc_payables.md (15317b), scripts/_bootstrap.py (4386b), scripts/paymatch.py (33333b), skill-card.md (2814b), SKILL.md (24385b), _meta.json (145b)\n\nArchive v0.8.0: 7 files, 37009 bytes\n\nFiles: references/matching_procedure.md (15722b), references/sportsinc_payables.md (13764b), scripts/_bootstrap.py (4386b), scripts/paymatch.py (33333b), skill-card.md (3028b), SKILL.md (23011b), _meta.json (145b)","readmeExcerpt":"Skill: drivethru-payable-matching Owner: zmtucker Summary: Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents app against their purchase orders and correct incorrect PO line pricing. Use for requests like \"check the Purchasing folder against the POs and fix the pricing\", \"match the vendor invoice / order confirmation / acknowledgement to its PO\", \"AP price matching / invoice-to-PO matching ","codeSnippets":[],"executableExamples":[{"language":"bash","snippet":"# 1. Read the whole folder as TEXT (no base64, no render) — ONE call\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\"}'\n\n# 1a. Scheduled batch? Narrow the extraction instead of pulling every file:\n#     document_ids = only these docs; name_excludes = drop by name substring\n#     (case-insensitive); max_docs = cap the batch (extras reported as\n#     skipped_beyond_max_docs). folder still scopes the listing.\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"document_ids\": [7617, 7536]}'\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"name_excludes\": [\"sanmar\"], \"max_docs\": 8}'\n\n# 1b. Any document flagged needs_vision (scanned or custom-encoded) → render its\n#     page(s) to PNG (+ OCR if tesseract is present), then read the image(s)\npython3 scripts/paymatch.py render '{\"document_id\": 485}'\n#\n# Each extracted document also carries `po_candidates` (the distinct PO refs in\n# its text, each canonicalized to Odoo's `P<digits>` name — so a bare \"PO 14594\"\n# and a \"P14594\" token both surface as `P14594`) and `multi_invoice` (true when\n# it holds several invoices — >1 distinct PO, or >1 \"Invoice #/No/Number\"\n# header). On multi_invoice, treat the PDF as several invoices: process EACH\n# against its own PO (resolve by name, dedupe, bill/post per invoice) rather than\n# billing only the first — a batch PDF is usually one invoice per page. These are\n# HINTS; verify against the text.\n\n# 2. Per document: pull the PO trimmed to matchable fields (incl. qty_received)\npython3 scripts/paymatch.py po-lines '{\"po\": \"P13189\"}'\n\n# 2b. Partial shipment? Read the PO's prior log notes to see what's already checked\npython3 scripts/paymatch.py notes '{\"po\": \"P13137\"}'\n\n# 3. Correct any wrong line(s) in one call (PO must be confirmed)\npython3 scripts/paymatch.py apply '{\"po_id\": 13145, \"lines\": [{\"line_id\": 40941, \"price_unit\": 11.94}]}'\n\n# 4a. Clean/fixed → post the checked LOG NOTE AND file to Matched (one call)\npython3 scripts/paymatch.py matched '{\"po_id"},{"language":"bash","snippet":"# Create the DRAFT bill from a reconciled PO\npython3 scripts/paymatch.py bill '{\"po_id\": 13145, \"vendor_bill_number\": \"<inv#>\", \"invoice_date\": \"2026-07-22\", \"expected_total\": 1041.90, \"tolerance\": 0.02, \"reviewer_user_id\": 6, \"review_note\": \"...\"}'\n\n# Post it — ONLY when the bill total matches the vendor invoice\npython3 scripts/paymatch.py post '{\"bill_id\": 8842, \"expected_total\": 1041.90, \"tolerance\": 0.02, \"note\": \"Matched to <inv#>; totals reconcile.\"}'"},{"language":"text","snippet":"paymatch.py bill '{\n     \"po_id\": <id>,\n     \"vendor_bill_number\": \"<supplier_doc_number or si_doc_number>\",\n     \"invoice_date\": \"<invoice_date>\",\n     \"expected_total\": <invoice total>,\n     \"tolerance\": 0.02,\n     \"reviewer_user_id\": 6,\n     \"review_note\": \"SI SportsLink doc <si_doc_number>; matched PO <po_number>; <price fixes>.\"\n   }'"},{"language":"text","snippet":"paymatch.py post '{\n     \"bill_id\": <created bill id>,\n     \"expected_total\": <invoice total>,\n     \"tolerance\": 0.02,\n     \"note\": \"SI SportsLink doc <si_doc_number>; matched PO <po_number>; posted on match.\"\n   }'"},{"language":"bash","snippet":"# 1. Read the whole folder as TEXT (no base64, no render) — ONE call\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\"}'\n\n# 1a. Scheduled batch? Narrow the extraction instead of pulling every file:\n#     document_ids = only these docs; name_excludes = drop by name substring\n#     (case-insensitive); max_docs = cap the batch (extras reported as\n#     skipped_beyond_max_docs). folder still scopes the listing.\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"document_ids\": [7617, 7536]}'\npython3 scripts/paymatch.py extract '{\"folder\": \"Purchasing\", \"name_excludes\": [\"sanmar\"], \"max_docs\": 8}'\n\n# 1b. Any document flagged needs_vision (scanned or custom-encoded) → render its\n#     page(s) to PNG (+ OCR if tesseract is present), then read the image(s)\npython3 scripts/paymatch.py render '{\"document_id\": 485}'\n#\n# Each extracted document also carries `po_candidates` (the distinct PO refs in\n# its text, each canonicalized to Odoo's `P<digits>` name — so a bare \"PO 14594\"\n# and a \"P14594\" token both surface as `P14594`) and `multi_invoice` (true when\n# it holds several invoices — >1 distinct PO, or >1 \"Invoice #/No/Number\"\n# header). On multi_invoice, treat the PDF as several invoices: process EACH\n# against its own PO (resolve by name, dedupe, bill/post per invoice) rather than\n# billing only the first — a batch PDF is usually one invoice per page. These are\n# HINTS; verify against the text.\n\n# 2. Per document: pull the PO trimmed to matchable fields (incl. qty_received)\npython3 scripts/paymatch.py po-lines '{\"po\": \"P13189\"}'\n\n# 2b. Partial shipment? Read the PO's prior log notes to see what's already checked\npython3 scripts/paymatch.py notes '{\"po\": \"P13137\"}'\n\n# 3. Correct any wrong line(s) in one call (PO must be confirmed)\npython3 scripts/paymatch.py apply '{\"po_id\": 13145, \"lines\": [{\"line_id\": 40941, \"price_unit\": 11.94}]}'\n\n# 4a. Clean/fixed → post the checked LOG NOTE AND file to Matched (one call)\npython3 scripts/paymatch.py matched '{\"po_id"},{"language":"bash","snippet":"# Create the DRAFT bill from a reconciled PO\npython3 scripts/paymatch.py bill '{\"po_id\": 13145, \"vendor_bill_number\": \"<inv#>\", \"invoice_date\": \"2026-07-22\", \"expected_total\": 1041.90, \"tolerance\": 0.02, \"reviewer_user_id\": 6, \"review_note\": \"...\"}'\n\n# Post it — ONLY when the bill total matches the vendor invoice\npython3 scripts/paymatch.py post '{\"bill_id\": 8842, \"expected_total\": 1041.90, \"tolerance\": 0.02, \"note\": \"Matched to <inv#>; totals reconcile.\"}'"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: drivethru-payable-matching\ndescription: >\n  Payable matching for BaconCo — reconcile vendor documents in Odoo's Documents\n  app against their purchase orders and correct incorrect PO line pricing. Use\n  for requests like \"check the Purchasing folder against the POs and fix the\n  pricing\", \"match the vendor invoice / order confirmation / acknowledgement to\n  its PO\", \"AP price matching / invoice-to-PO matching / three-way match\",\n  \"reconcile the vendor documents and mark the POs checked\", or \"go through the\n  Purchasing folder\". The flow: read every document in a Documents-app folder\n  (extracting text out-of-context so large batches don't bloat the context\n  window — falling back to a page render + OCR/vision for scanned or\n  custom-encoded PDFs that won't extract as text), pull the PO number / line\n  items / unit prices from each, compare to the purchase order line by line,\n  correct any wrong `price_unit`, post a \"checked\" log note on the PO (internal,\n  never a \"Send message\"), and FILE every document into the `Matched` or\n  `Questions` subfolder — escalating genuine questions to a reviewer (default\n  Zach Tucker). Also runs the buying-group payables flow: pull Sports Inc\n  invoices from the SportsLink API (via the `sportsinc-sportslink` adapter),\n  reconcile each to its PO, correct price variances, create the vendor bill and\n  — when the bill total matches the invoice within tolerance — POST it, leaving\n  any mismatch in draft for a human (\"get the Sports Inc invoices and bill\n  them\", \"match the SI invoices to POs and post the payables\", \"match the vendor\n  invoice and post the bill if it matches\"). Handles the multi-shipment case\n  where one PO returns several Sports Inc invoices, splitting it into one vendor\n  bill per shipment via `account.move.line` edits (the `ap_*_bill_line(s)`\n  tools). Runs at volume on a low-cost model.\n  Driven by the Odoo `drivethru_mcp` MCP server; complements the broader\n  `drivethru-odoo` skill.\nversion: 0.10.0\nemoji: 🧾\nhomepage: https://www.odoo.com\nmetadata:\n  openclaw:\n    requires:\n      env: [ODOO_MCP_URL, ODOO_MCP_TOKEN]\n      bins: [python3, uv]   # uv powers the scripts' self-bootstrap fallback\n    primaryEnv: ODOO_MCP_TOKEN\n    envVars:\n      ODOO_MCP_URL:\n        required: true\n        description: >\n          Full URL of the Odoo MCP endpoint, e.g.\n          `https://odoo.example.com/drivethru_mcp/v1` — the MCP server exposed by\n          the `drivethru_mcp` Odoo module, not the Odoo base URL.\n      ODOO_MCP_TOKEN:\n        required: true\n        description: >\n          The `drivethru.mcp_key` value from the `drivethru_mcp` module, sent as\n          `Authorization: Bearer`. Treat as a secret; never paste into chat.\n    install:\n      uv:\n        - mcp>=1.9.0\n        - pymupdf>=1.24   # primary local PDF text extraction + page rasterization:\n                          # honours ToUnicode/Type3 (custom-encoded) fonts pypdf can't\n                          # read, and renders needs_vision docs"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn715tnf30wegyr6mdbfa17avd87bjr6\",\n  \"slug\": \"drivethru-payable-matching\",\n  \"version\": \"0.10.0\",\n  \"publishedAt\": 1790187380449\n}"},{"path":"references/matching_procedure.md","content":"# Payable matching — full procedure, tool shapes, example, economics\n\nReference behind `SKILL.md`. Read it when you need the detail behind a step,\nthe exact field names a payload carries, or the cost model for running this at\nvolume.\n\n---\n\n## 1. Why the design is shaped this way\n\nTwo forces drive every choice:\n\n- **Context economy.** Documents are PDFs. `documents_get` returns their bytes\n  as base64; a multimodal PDF reader adds a page image. Both are enormous next\n  to the ~300–600 characters of text that actually matter, and this runs over\n  many documents many times a day. So text extraction is pushed into\n  `scripts/paymatch.py` (`extract`), which decodes and reads the text **locally**\n  and returns text only. The model never sees base64 or a render. `po-lines`\n  likewise trims the verbose PO payload to the matchable fields. The result: a\n  document \"match\" costs the model a few thousand tokens, not tens of thousands.\n\n  Extraction runs **PyMuPDF → poppler `pdftotext -layout` → pypdf** and\n  quality-gates the output. PyMuPDF is first because it honours ToUnicode CMaps\n  and reads **Type3 / custom-encoded fonts** that pypdf returns as empty or\n  garbage (the failure that stranded Charles River Apparel confirmations). A\n  result that is empty or fails the reliability gate flags the document\n  `needs_vision: true`; the model then calls `render` to rasterise the page(s)\n  with PyMuPDF (no system poppler) and reads them with vision (plus tesseract OCR\n  if present) — keeping even the unreadable-text case out of a dead-end\n  escalation, while still never pulling raw base64 into context.\n\n- **Judgment stays with the model.** The script does deterministic I/O (fetch,\n  decode, extract, apply a price, move a file). The *matching decision* — which\n  document line pairs to which PO line, whether a total gap is a partial\n  shipment, whether a freight figure is authoritative, whether something is a\n  genuine question — needs the model, because vendor documents vary too much for\n  a rigid parser. Get this division wrong in either direction and you either\n  bloat context (model reading raw bytes) or get brittle matches (script\n  guessing intent).\n\n---\n\n## 2. The tool surface (MCP), and the payload shapes you'll see\n\nThe skill drives the Odoo `drivethru_mcp` MCP tools. `paymatch.py` wraps the\nones below; the field names are what the tools actually return (know them so you\ndon't waste calls discovering them).\n\n### Documents app\n\n- `documents_list_folders {name?, parent_id?}` → `{folders: [{id, name, parent,\n  document_count}]}`. Resolve `Purchasing` → its id; list its children with\n  `{parent_id}` to get the `Matched` / `Questions` folder ids.\n- `documents_search {folder_id, limit, offset, include_subfolders?}` →\n  `{documents: [{id, name, type, mimetype, file_size, folder:{id,name}, tags,\n  res_model, res_id, ...}], total_matched}`. Metadata only — **no bytes**.\n  Paginate on `total_matched`.\n- `documents_get {document_id}` → the metadata **plus** `data_bas"},{"path":"references/sportsinc_payables.md","content":"# Sports Inc payables — end-to-end (SportsLink → match → bill → post-on-match)\n\nSports Inc is a buying group that doesn't send individual vendor invoices; the\ninvoices live in the SportsLink API. This is the automated payables loop for\nthem: pull the invoices, reconcile each to its Odoo PO, correct price variances,\ncreate the bill, **post it when its total matches the invoice** (else leave it\nin draft), and mark the SI document consumed — with a human handling anything\nflagged.\n\nThree skills cooperate (this is the ports-and-adapters split in practice):\n\n- **Source adapter** — `sportsinc-sportslink` (`sportslink.py`): fetch invoices,\n  mark consumed. Customer-agnostic.\n- **Workflow** — this skill: reconcile invoice ↔ PO, correct/escalate, create the\n  draft bill. Source- and ERP-agnostic.\n- **ERP adapter** — `drivethru-odoo` / `drivethru_mcp` (`paymatch.py`,\n  `ap_create_vendor_bill` / `ap_post_vendor_bill`): the Odoo writes.\n\n## Configured policy (BaconCo)\n\n- **Posting: post on match, draft on mismatch.** Create the bill, then **post it\n  when the bill total matches the invoice `expected_total` within tolerance**\n  (`paymatch.py post`, backed by the guarded `ap_post_vendor_bill`). A bill that\n  doesn't match is **left in draft** and escalated to the reviewer — never post a\n  mismatch. When a run should stay hands-off (a human reviews before posting),\n  skip the `post` step and leave every bill in draft.\n- **On variance: auto-fix price, escalate qty/line.** A unit-price difference is\n  treated as the SI invoice being authoritative — correct the PO line (like the\n  pricing review), then bill. A **quantity / missing-line / total-structure**\n  variance is **not** auto-fixed — escalate it (leave the SI doc active, raise an\n  activity to the reviewer, create no bill). Once the reviewer **answers** the\n  escalation, you carry out their decision yourself — see\n  [Acting on the reviewer's answer](#acting-on-the-reviewers-answer-quantity-escalations).\n- **Reviewer:** Zach Tucker (`reviewer_user_id: 6` in BaconCo's Odoo). Keep this\n  in tenant config, not hard-coded in prose.\n- **Tolerance:** a small **absolute** amount (a few cents, e.g. `0.02`) for the\n  `expected_total` match check on both create and post — once prices are\n  reconciled the SI docTotal should equal the computed bill to within rounding.\n- **Chatter: internal log notes only.** Every PO note or escalation this loop\n  posts (via `po_post_message`) is an internal Odoo **log note**, never a \"Send\n  message\" — nothing here is emailed to the vendor.\n\n## The exactly-once loop (do not deviate)\n\nYou are creating payables — double-billing is the cardinal sin. The SportsLink\n`active`/historical flag is the idempotency mechanism; the sequence is fixed:\n\n1. **Pull the inbox.** `sportslink.py list '{\"active\": true, \"lines\": true, \"ediOnly\": true}'`\n   → normalised invoices that are not yet imported and carry line items.\n2. **Process each invoice** (below). Create the draft bill in Odoo.\n3. **Mark consume"},{"path":"skill-card.md","content":"## Description:\n\nReconciles vendor payable documents and Sports Inc invoices against Odoo purchase orders, corrects supported PO price variances, routes unresolved items for review, and creates or posts vendor bills only when totals match.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[zmtucker](https://clawhub.ai/user/zmtucker)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nAccounting and purchasing operators use this skill to reconcile vendor confirmations, acknowledgements, and invoices against Odoo purchase orders, update clearly supported price variances, file reviewed documents, and prepare or post matched payables.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The skill can change live Odoo purchasing and accounting records, including PO pricing and vendor bill state.\n\nMitigation: Install only where that authority is intended, use least-privilege Odoo/MCP credentials, and require human authorization for bill posting, quantity changes, dropship validation, and credential-sharing delegation.\n\nRisk: A mismatched payable could be posted if totals or invoice structure are not checked.\n\nMitigation: Prefer draft or dry-run operation until the workflow is proven, leave mismatches in draft, and post only when the bill total matches the source invoice within tolerance.\n\nRisk: Runtime dependency bootstrapping can install Python packages into a cached environment.\n\nMitigation: Review the dependency bootstrapping policy and preinstall or approve the declared packages before production use.\n\n## Reference(s):\n\n- [Payable matching procedure](references/matching_procedure.md)\n- [Sports Inc payables procedure](references/sportsinc_payables.md)\n- [Odoo](https://www.odoo.com)\n- [ClawHub skill page](https://clawhub.ai/zmtucker/skills/drivethru-payable-matching)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown guidance with JSON command payloads and concise reconciliation summaries]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May run Odoo MCP operations through native tools or scripts/paymatch.py; helper commands return JSON objects.]\n\n## Skill Version(s):\n\n0.10.0 (source: frontmatter and release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment."}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":2590,"uniquenessScore":37,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-10T13:48:21.095Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-10T15:52:00.298Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}