TRES — Invoice/Bill Matching & ERP Sync
End-to-end workflow that lets the user close an open ERP invoice or bill against a blockchain transaction in
the TRES ledger, then optionally push the matched entry to the connected ERP.
The flow is the same regardless of which side the user starts from (a transaction hash or an invoice/bill ID).
The skill walks through seven conversational steps (verify ERP → identify input → fetch & suggest → user picks
→ configure payment account & fiat → confirm & apply → loop). Stay terse — show numbered options, capture the
user's pick, move on. Never run a mutation without explicit "yes" from the user.
Ground rules
- Identify the org first. Begin with
get_viewer and tell the user "You're connected to {orgName}."
This makes mistakes recoverable when someone has the wrong token.
- Read before write. Always fetch the current state of the transaction, invoice/bill, and payment account
before showing a change summary. Surprises are worse than slow.
- Mutations require explicit approval. Show a summary table (Transaction · Invoice/Bill · Payment Account ·
Fiat alignment · Sync) and ask "Apply these changes?" before any mutation. Only proceed on a clear yes.
- Use schema introspection when in doubt. Field names, enum values, and argument shapes can drift. If a
query/mutation errors with "unknown field" or "invalid enum", call
introspect(<TypeName>) or
build_query(<operationName>) and adjust — don't guess. Operations specifically called out as "verify at
runtime" below are the ones most likely to need this.
- The skill is a loop. After a successful match, ask "Match another?" and restart from Step 2. Don't
re-check the ERP — that only happens once per session.
Step 1 — Verify ERP is connected
Run:
query { integration(first: 50) { results { id integratedApp isErp connectionStatus companyName } } }
Filter the results where isErp == true and connectionStatus == "ACTIVE". The supported ERPs you should
recognize are Xero, QuickBooks Online (QBO), and NetSuite — integratedApp values are XERO,
QUICKBOOKS, and NETSUITE. (If you encounter unknown values, introspect("IntegrationsQueryNode") will
confirm the enum.) Ignore rows where integratedApp is empty — the API occasionally returns a null row.
- No connected ERP: Tell the user they need to connect one before matching can happen, point them at
https://app.tres.finance/settings/integrations, and stop.
- One connected ERP: Use it implicitly and just mention "Matching against {companyName} ({integratedApp})."
- Multiple connected ERPs: Ask which one to use — different ERPs have separate invoice/bill stores.
Cache the chosen ERP's id, integratedApp, and companyName for later steps and the loop.
Step 2 — Identify what the user has
Ask whether they have:
- a transaction hash (e.g.
0x…) — best case, pins the match immediately,
- an invoice/bill ID or number (numeric internal ID, or the human-facing
invoiceNumber/billNumber),
- or both.
If they have neither, require at least one. If they're not sure whether their identifier is an invoice or a
bill, accept it and try both lookups in Step 3.
Also accept loose forms — "INV-123", "Bill 9988", "the bill for Acme last week". The freeText filter on
erpInvoices / erpBills handles these.
Before moving on, always ask for the transaction date (or an approximate date range) if they haven't given
a tx hash. Ledger volume is high — date is the single most useful filter for narrowing candidates. Also
offer: "If you happen to have the tx hash, paste it now — it pins the match exactly." Date matters both
directions (tx→bill and bill→tx).
Step 3 — Fetch the known object and produce ranked match suggestions
There are two branches. Pick by what the user provided. If they provided both a tx hash and an invoice/bill
ID, skip ahead to Step 5 (match is already determined).
Branch A — User has a transaction hash
- Fetch the transaction with its sub-transactions:
query GetTx($hash: String!) {
transaction(identifier: $hash, currency: "usd", limit: 1) {
results {
id identifier timestamp platform
children {
id amount balanceFactor isInternalTransfer
fiatValue
sender { identifier displayName isInternal }
recipient { identifier displayName isInternal }
asset { symbol identifier }
}
}
}
}
- Pick the relevant sub-transaction. Skip gas, skip internal transfers, prefer the one with the user's
wallet on one side and an external counterparty on the other. Determine direction:
balanceFactor negative → outflow → look for a bill to close.
balanceFactor positive → inflow → look for an invoice to close.
If multiple sub-txs qualify (e.g., a swap with multiple legs), present them and let the user pick one.
- For invoices (inflow), try backend match suggestions first — they are pre-computed and ranked:
query Suggest($subTxIds: [String]!) {
subTransactionToInvoiceMatchSuggestions(
subTransactionId_In: $subTxIds, minScore: 0.3, ordering: "-score", first: 10
) {
results {
id score confidenceTier
scoreBreakdown { txHashMatch primaryMatchFactors }
invoice {
id invoiceId invoiceNumber customerName origAmount balance dueDate billingStatus
integration { integratedApp companyName }
}
}
}
}
Important: this endpoint is invoice-only. For bills (outflow), skip straight to the fallback below
— there is no backend bill-suggestion query exposed in the schema.
- Fallback (zero invoice suggestions, or always for bills): query
erpInvoices (inflow) or erpBills
(outflow), filtered by:
- amount window:
±20% of the sub-tx fiat value,
- date window:
dateCreated_Range = [tx.timestamp − 60d, tx.timestamp] — bills/invoices are issued at or
before the payment, never after. Don't bother with future-dated invoices/bills.
- contact: if the user has named a vendor/customer, pass that as
freeText. Do NOT try to match the
on-chain recipient address to an ERP contact — ERP contact identifiers (from QBO/Xero/NetSuite) are
internal IDs, never wallet addresses. Some contact labels happen to resemble the vendor name in text,
but the schema has no "resembles" filter, so rely on freeText over vendor/customer fields.
Rank the fallback set client-side by: amount proximity → date proximity (prefer the most recent
bill/invoice before the tx) → name hits in freeText output.
- Present the top ≤5 as a numbered list (never more — too many choices is worse than too few). For each row
show: confidence tier, $ amount, customer/vendor name,
invoiceNumber/billNumber, due date, and the
primaryMatchFactors (e.g. amount_exact, contact_match). Best-fit first.
Branch B — User has an invoice/bill ID or number
- Resolve the entity. Try
erpInvoices first, then erpBills if it's not an invoice (or vice versa if the
user said "bill"). For invoices, include the embedded suggestions:query GetInvoice($id: Float, $text: String) {
erpInvoices(id: $id, freeText: $text, first: 1) {
results {
id invoiceNumber customerName origAmount balance dateCreated dueDate billingStatus
integration { integratedApp companyName }
suggestedSubTransactionMatches {
id score confidenceTier
subTransaction {
id amount fiatValue
asset { symbol identifier }
tx { identifier timestamp platform }
}
}
}
}
}
For bills, query erpBills — note that bills do not expose suggestedSubTransactionMatches, so
just fetch the bill metadata (id billNumber vendorName vendorId origAmount balance dateCreated dueDate billingStatus integration { integratedApp companyName }) and go straight to the fallback step. If id
lookup returns nothing, retry with freeText: $userInput.
- For invoices, use the embedded
suggestedSubTransactionMatches as the ranked list.
- Fallback (zero invoice suggestions, or always for bills):
- First, ask the user for an approximate date of payment (and remind them: "if you have the tx hash,
that's fastest"). Payments happen on or after the bill/invoice date, usually within days — not before.
Default to a window of
[dateCreated, dateCreated + 60 days] if they can't be specific.
- Query
transaction with server-side filters so we don't pull the entire ledger. Use:
platform: <configuredNetwork or user's network> (e.g. "ethereum"),
timestamp_Gte / timestamp_Lte → the user-supplied date window,
children_Asset_Identifier_In: [<assetIdentifier>] → the bill's configuredAsset.identifier (e.g. the
USDC contract), or the invoice's expected asset,
children_FiatValue_Between: "<low>,<high>" → fiat window around origAmount (e.g. ±20%). This is
a string of two comma-separated numbers. Example: "0.4,0.6" for a $0.50 bill.
- Optionally
children_BalanceFactor: -1 (outflow, for bills) or 1 (inflow, for invoices).
- Use
children_Amount_Between as an additional filter only when the fiat price can't be trusted
(pre-priced historical assets, etc.).
- If the resulting set is still large, ask the user for a tighter date or an amount refinement before
presenting.
- Present up to 5 candidate transactions as a numbered list — show for each:
tx hash (truncated
0xabcd…1234), sender → recipient addresses (same shortened form), token amount +
symbol, fiat value, and timestamp. The tx hash is the user-facing identifier they recognize; the raw
addresses and token amounts disambiguate when fiat alone is too ambiguous (many txs cluster near the same
amount). Never show internal IDs like subTxId to the user — they're meaningless to them. Hold the
subTxId internally for the mutation; reference the tx by its hash in all user-visible output.
If no good matches, say so and ask for a tx hash — don't guess-dump a big list.
Step 4 — User picks a match (or asks for more)
Prompt: "Pick a number, or type more to widen the search (give me a contact name, date, or amount to focus
on)."
- On a number: capture the chosen
(subTxId, invoiceOrBillId, entityType) and continue to Step 5.
- On
more: re-query with looser windows or with the user's added hints (contact filter, expanded date range,
amount range), present a fresh list, repeat.
- On "none of these" or similar: stop gracefully — "OK, no match made. Tell me when you have more info."
Step 5 — Configure payment account and (optional) fiat alignment
Once a match pair is chosen:
Deposit account (for matchApAr.depositAccountId). This is an ERP integration account
(the GL account in Xero/QBO/NetSuite that the bill will post against — e.g. "Crypto Wallet – ETH",
"Cash and cash equivalents"). It is NOT a tres internal/wallet account. The schema FK is against
schema_integrationaccount; the value lives on ErpBill.depositAccount / ErpInvoice.depositAccount
as IntegrationAccountQuery { id name type value }.
Resolution order:
- Use the pre-set one on the bill/invoice. When the entity was created in the ERP, the user
typically already picked a deposit account. Read
erpBills.results[].depositAccount.id (or
erpInvoices.results[].depositAccount.id). If it's non-null, use that id as depositAccountId
and just mention it in the summary — no prompt.
- Otherwise ask. Query
integrationAccount(integration: <erpId>, first: 100) and show the
user the ASSET-type accounts (cash/wallet/crypto GL accounts) as a numbered list; capture the
chosen integrationAccount.id.
paymentToInternalAccounts maps wallet → asset on the tres side and is useful for payout
workflows — it is not the source of depositAccountId for matchApAr. Don't reach for it
here.
Fiat alignment. Compare the sub-tx fiatValue to the invoice/bill origAmount. Three cases:
fiatValue is null or 0: ask "This transaction has no fiat value set. Align it to ${origAmount}
so the {bill/invoice} closes fully? (y/n)" — no default, require an explicit yes.
fiatValue differs from origAmount: ask "Align tx fiat value from $X to $Y so the AP/AR
closes fully? (y/n)" — no default, require an explicit yes.
- Values match: skip.
Never run
setManualFiatValue without a clear yes — even if the user's original request implied
"close the invoice", the fiat alignment is a separate write and needs its own consent. If they say no,
the match still proceeds but the entity may stay partially paid.
Step 6 — Confirm and apply
Show this summary table — use human-readable values only: tx hash (truncated), deposit account name
(never its id), invoice/bill number or customer/vendor name. Internal IDs (subTxId, depositAccountId,
DB entityId) are for the mutation payload, never for the user.
|
|
| Transaction |
0xabcd…1234 · {asset.symbol} {amount} · ${fiatValue} |
| Invoice/Bill |
{invoiceNumber or billNumber} · {customer/vendor} · ${origAmount} {currency} |
| Payment account |
{depositAccount.name} |
| Align fiat |
yes → ${origAmount} / no |
| Sync to ERP after match |
yes / no |
Ask: *"Apply these changes to {orgName}? (yes/no)"* — only proceed on yes. Treat this as approval for
the match only. Fiat alignment and ERP sync each need their own explicit yes captured earlier (Step 5
for fiat, the prompt below for sync) — even if the user's opening request said "sync to QBO", still confirm
here, because the request was issued before they saw the actual match details.
Then run mutations in this order, reporting the result of each:
Match (always):
mutation Match($entityType: String!, $matches: [SubTxMatchInput]!) {
matchApAr(entityType: $entityType, matches: $matches) {
results { subTxId entityId depositAccountId }
}
}
Variables:
entityType: lowercase — "invoice" or "bill". Uppercase values are rejected with
Invalid entity_type.
matches[].subTxId: string (e.g. "4548595830").
matches[].entityId: integer — the DB id of the invoice/bill (from erpInvoices.results[].id
/ erpBills.results[].id), NOT the human-facing invoiceNumber/billNumber. Passing a string
errors with "Int cannot represent non-integer value"; passing a bill number looks up nothing
and errors with "One or more Bill IDs not found".
matches[].depositAccountId: integer — the integration account id (see Step 5).
Fiat alignment (only if user opted in):
mutation AlignFiat($id: String!, $newFiatValue: String!, $currency: String) {
setManualFiatValue(id: $id, newFiatValue: $newFiatValue, currency: $currency) { success }
}
Pass the sub-transaction ID as id, the target value as a string in newFiatValue (e.g. "123.45", the
invoice/bill origAmount), and currency: "usd". If the response returns an error about a "locked
period", the accounting period containing the tx is closed — surface that to the user; don't try to force.
ERP sync (ask first, every time): "Sync this transaction to {erpName} now? (y/n)" — never sync
without an explicit yes at this point, even if the user's original ask included "and sync to QBO". The
sync call is visible in the ERP and harder to undo than the match itself, so it gets its own gate. If yes:
mutation Sync($txIds: [String]!, $entityType: String!) {
syncSpecificTransactions(transactionIds: $txIds, entitySourceType: $entityType) { status }
}
entitySourceType mirrors entityType from the match call.
When reporting results back to the user, refer to the transaction by its tx hash (truncated) and the
deposit account by name — never by subTxId or depositAccountId. Those internal IDs are for debugging,
not user output.
If any mutation errors, surface the error message verbatim and offer to retry. The match is the one that
actually links the records — if it fails, the rest is moot.
Step 7 — Loop
Ask: "Match another transaction or invoice/bill? (y/n)"
- yes → restart from Step 2 (skip Step 1 — keep the cached ERP from this session).
- no → wrap up: "Done. Closed {n} item(s) this session."
Verified TRES MCP operations used
| Purpose |
Operation |
Type |
| Org identity |
get_viewer |
MCP tool |
| Schema discovery |
introspect, build_query |
MCP tool |
| Check ERP connections |
integration (filter isErp=true) |
query |
| Fetch tx by hash |
transaction(identifier:) |
query |
| Fetch invoices (with embedded suggestions) |
erpInvoices |
query |
| Fetch bills (with embedded suggestions) |
erpBills |
query |
| Pre-computed sub-tx → invoice match suggestions |
subTransactionToInvoiceMatchSuggestions |
query |
| COA-mapped payment accounts |
paymentToInternalAccounts |
query |
| Match sub-tx ↔ invoice/bill |
matchApAr |
mutation |
| Align fiat value |
setManualFiatValue (verify args at runtime) |
mutation |
| Sync transactions to the ERP |
syncSpecificTransactions |
mutation |
| Undo a match (on user request) |
manualUnmatchSubtransactions |
mutation |
Out of scope (politely redirect if asked)
- Bulk matching (many ↔ many) — not yet supported by this skill.
- Sending payments (
sendBillPayment, sendInvoicePayment) — separate workflow.
- Connecting or revoking the ERP itself — use the
tres-settings-management skill.
- Explaining a transaction in narrative form — use
tres-tx-story.
- Importing an explorer link into the ledger — use
tres-explorer-tx-to-ledger.
1---2name: tres-invoice-bill-matching3description: Match TRES ledger transactions to ERP invoices/bills (AP/AR) and optionally sync them to the connected ERP (Xero, QuickBooks Online, NetSuite). Trigger this skill whenever the user wants to match, link, close, reconcile, or sync an invoice or bill against a blockchain transaction — even if they don't say "skill" or use those exact words. Trigger phrases include: "match this invoice to a transaction", "match a bill to tx", "close invoice INV-123", "close bill 9988", "link this tx to a bill", "pay this invoice from this transaction", "set up this tx as AP", "set up this tx as AR", "sync this transaction as AP/AR", "match AP/AR", "find the invoice/bill for this hash", "what bill does this tx pay". Trigger ONLY for explicit AP/AR matching/closing intent — do NOT trigger for general transaction explanations (use tres-tx-story), for ingesting an explorer link into the ledger (use tres-explorer-tx-to-ledger), or for ERP connection setup itself (use tres-settings-management).4---5
6# TRES — Invoice/Bill Matching & ERP Sync
7
8End-to-end workflow that lets the user close an open ERP invoice or bill against a blockchain transaction in
9the TRES ledger, then optionally push the matched entry to the connected ERP.
10
11The flow is the same regardless of which side the user starts from (a transaction hash or an invoice/bill ID).
12The skill walks through seven conversational steps (verify ERP → identify input → fetch & suggest → user picks
13→ configure payment account & fiat → confirm & apply → loop). Stay terse — show numbered options, capture the
14user's pick, move on. Never run a mutation without explicit "yes" from the user.
15
16---
17
18## Ground rules
19
201. **Identify the org first.** Begin with `get_viewer` and tell the user "You're connected to **{orgName}**."
21 This makes mistakes recoverable when someone has the wrong token.
222. **Read before write.** Always fetch the current state of the transaction, invoice/bill, and payment account
23 before showing a change summary. Surprises are worse than slow.
243. **Mutations require explicit approval.** Show a summary table (Transaction · Invoice/Bill · Payment Account ·
25 Fiat alignment · Sync) and ask "Apply these changes?" before any mutation. Only proceed on a clear yes.
264. **Use schema introspection when in doubt.** Field names, enum values, and argument shapes can drift. If a
27 query/mutation errors with "unknown field" or "invalid enum", call `introspect(<TypeName>)` or
28 `build_query(<operationName>)` and adjust — don't guess. Operations specifically called out as "verify at
29 runtime" below are the ones most likely to need this.
305. **The skill is a loop.** After a successful match, ask "Match another?" and restart from Step 2. Don't
31 re-check the ERP — that only happens once per session.
32
33---
34
35## Step 1 — Verify ERP is connected
36
37Run:
38```graphql
39query { integration(first: 50) { results { id integratedApp isErp connectionStatus companyName } } }
40```
41
42Filter the results where `isErp == true` and `connectionStatus == "ACTIVE"`. The supported ERPs you should
43recognize are **Xero**, **QuickBooks Online (QBO)**, and **NetSuite** — `integratedApp` values are `XERO`,
44`QUICKBOOKS`, and `NETSUITE`. (If you encounter unknown values, `introspect("IntegrationsQueryNode")` will
45confirm the enum.) Ignore rows where `integratedApp` is empty — the API occasionally returns a null row.
46
47- **No connected ERP:** Tell the user they need to connect one before matching can happen, point them at
48 `https://app.tres.finance/settings/integrations`, and stop.
49- **One connected ERP:** Use it implicitly and just mention "Matching against **{companyName}** ({integratedApp})."
50- **Multiple connected ERPs:** Ask which one to use — different ERPs have separate invoice/bill stores.
51
52Cache the chosen ERP's `id`, `integratedApp`, and `companyName` for later steps and the loop.
53
54---
55
56## Step 2 — Identify what the user has
57
58Ask whether they have:
59- a **transaction hash** (e.g. `0x…`) — best case, pins the match immediately,
60- an **invoice/bill ID or number** (numeric internal ID, or the human-facing `invoiceNumber`/`billNumber`),
61- or **both**.
62
63If they have neither, require at least one. If they're not sure whether their identifier is an invoice or a
64bill, accept it and try both lookups in Step 3.
65
66Also accept loose forms — "INV-123", "Bill 9988", "the bill for Acme last week". The `freeText` filter on
67`erpInvoices` / `erpBills` handles these.
68
69**Before moving on, always ask for the transaction date (or an approximate date range) if they haven't given
70a tx hash.** Ledger volume is high — date is the single most useful filter for narrowing candidates. Also
71offer: *"If you happen to have the tx hash, paste it now — it pins the match exactly."* Date matters both
72directions (tx→bill and bill→tx).
73
74---
75
76## Step 3 — Fetch the known object and produce ranked match suggestions
77
78There are two branches. Pick by what the user provided. If they provided both a tx hash *and* an invoice/bill
79ID, skip ahead to Step 5 (match is already determined).
80
81### Branch A — User has a transaction hash
82
831. Fetch the transaction with its sub-transactions:
84 ```graphql
85 query GetTx($hash: String!) {
86 transaction(identifier: $hash, currency: "usd", limit: 1) {
87 results {
88 id identifier timestamp platform
89 children {
90 id amount balanceFactor isInternalTransfer
91 fiatValue
92 sender { identifier displayName isInternal }
93 recipient { identifier displayName isInternal }
94 asset { symbol identifier }
95 }
96 }
97 }
98 }
99 ```
1002. Pick the **relevant sub-transaction**. Skip gas, skip internal transfers, prefer the one with the user's
101 wallet on one side and an external counterparty on the other. Determine direction:
102 - `balanceFactor` negative → outflow → look for a **bill** to close.
103 - `balanceFactor` positive → inflow → look for an **invoice** to close.
104 If multiple sub-txs qualify (e.g., a swap with multiple legs), present them and let the user pick one.
1053. **For invoices (inflow), try backend match suggestions first** — they are pre-computed and ranked:
106 ```graphql
107 query Suggest($subTxIds: [String]!) {
108 subTransactionToInvoiceMatchSuggestions(
109 subTransactionId_In: $subTxIds, minScore: 0.3, ordering: "-score", first: 10
110 ) {
111 results {
112 id score confidenceTier
113 scoreBreakdown { txHashMatch primaryMatchFactors }
114 invoice {
115 id invoiceId invoiceNumber customerName origAmount balance dueDate billingStatus
116 integration { integratedApp companyName }
117 }
118 }
119 }
120 }
121 ```
122 Important: this endpoint is **invoice-only**. For **bills (outflow), skip straight to the fallback below**
123 — there is no backend bill-suggestion query exposed in the schema.
1244. **Fallback (zero invoice suggestions, or always for bills):** query `erpInvoices` (inflow) or `erpBills`
125 (outflow), filtered by:
126 - amount window: `±20%` of the sub-tx fiat value,
127 - date window: `dateCreated_Range = [tx.timestamp − 60d, tx.timestamp]` — bills/invoices are issued at or
128 *before* the payment, never after. Don't bother with future-dated invoices/bills.
129 - contact: if the user has named a vendor/customer, pass that as `freeText`. Do NOT try to match the
130 on-chain recipient address to an ERP contact — ERP contact identifiers (from QBO/Xero/NetSuite) are
131 internal IDs, never wallet addresses. Some contact labels *happen to resemble* the vendor name in text,
132 but the schema has no "resembles" filter, so rely on `freeText` over `vendor`/`customer` fields.
133 Rank the fallback set client-side by: amount proximity → date proximity (prefer the most recent
134 bill/invoice before the tx) → name hits in `freeText` output.
1355. Present the top ≤5 as a numbered list (never more — too many choices is worse than too few). For each row
136 show: confidence tier, $ amount, customer/vendor name, `invoiceNumber`/`billNumber`, due date, and the
137 `primaryMatchFactors` (e.g. `amount_exact`, `contact_match`). Best-fit first.
138
139### Branch B — User has an invoice/bill ID or number
140
1411. Resolve the entity. Try `erpInvoices` first, then `erpBills` if it's not an invoice (or vice versa if the
142 user said "bill"). For **invoices**, include the embedded suggestions:
143 ```graphql
144 query GetInvoice($id: Float, $text: String) {
145 erpInvoices(id: $id, freeText: $text, first: 1) {
146 results {
147 id invoiceNumber customerName origAmount balance dateCreated dueDate billingStatus
148 integration { integratedApp companyName }
149 suggestedSubTransactionMatches {
150 id score confidenceTier
151 subTransaction {
152 id amount fiatValue
153 asset { symbol identifier }
154 tx { identifier timestamp platform }
155 }
156 }
157 }
158 }
159 }
160 ```
161 For **bills**, query `erpBills` — note that bills do **not** expose `suggestedSubTransactionMatches`, so
162 just fetch the bill metadata (`id billNumber vendorName vendorId origAmount balance dateCreated dueDate
163 billingStatus integration { integratedApp companyName }`) and go straight to the fallback step. If `id`
164 lookup returns nothing, retry with `freeText: $userInput`.
1652. For invoices, use the embedded `suggestedSubTransactionMatches` as the ranked list.
1663. **Fallback (zero invoice suggestions, or always for bills):**
167 - First, **ask the user for an approximate date of payment** (and remind them: "if you have the tx hash,
168 that's fastest"). Payments happen on or after the bill/invoice date, usually within days — not before.
169 Default to a window of `[dateCreated, dateCreated + 60 days]` if they can't be specific.
170 - Query `transaction` with **server-side filters** so we don't pull the entire ledger. Use:
171 - `platform: <configuredNetwork or user's network>` (e.g. `"ethereum"`),
172 - `timestamp_Gte` / `timestamp_Lte` → the user-supplied date window,
173 - `children_Asset_Identifier_In: [<assetIdentifier>]` → the bill's `configuredAsset.identifier` (e.g. the
174 USDC contract), or the invoice's expected asset,
175 - `children_FiatValue_Between: "<low>,<high>"` → fiat window around `origAmount` (e.g. `±20%`). This is
176 a **string** of two comma-separated numbers. Example: `"0.4,0.6"` for a $0.50 bill.
177 - Optionally `children_BalanceFactor: -1` (outflow, for bills) or `1` (inflow, for invoices).
178 - Use `children_Amount_Between` as an additional filter only when the fiat price can't be trusted
179 (pre-priced historical assets, etc.).
180 - If the resulting set is still large, ask the user for a tighter date or an amount refinement before
181 presenting.
1824. Present **up to 5** candidate transactions as a numbered list — show for each:
183 **tx hash (truncated `0xabcd…1234`), sender → recipient addresses (same shortened form), token amount +
184 symbol, fiat value, and timestamp.** The tx hash is the user-facing identifier they recognize; the raw
185 addresses and token amounts disambiguate when fiat alone is too ambiguous (many txs cluster near the same
186 amount). **Never show internal IDs like `subTxId` to the user — they're meaningless to them.** Hold the
187 `subTxId` internally for the mutation; reference the tx by its hash in all user-visible output.
188 If no good matches, say so and ask for a tx hash — don't guess-dump a big list.
189
190---
191
192## Step 4 — User picks a match (or asks for more)
193
194Prompt: *"Pick a number, or type `more` to widen the search (give me a contact name, date, or amount to focus
195on)."*
196
197- On a number: capture the chosen `(subTxId, invoiceOrBillId, entityType)` and continue to Step 5.
198- On `more`: re-query with looser windows or with the user's added hints (contact filter, expanded date range,
199 amount range), present a fresh list, repeat.
200- On "none of these" or similar: stop gracefully — *"OK, no match made. Tell me when you have more info."*
201
202---
203
204## Step 5 — Configure payment account and (optional) fiat alignment
205
206Once a match pair is chosen:
207
2081. **Deposit account (for `matchApAr.depositAccountId`).** This is an **ERP integration account**
209 (the GL account in Xero/QBO/NetSuite that the bill will post against — e.g. "Crypto Wallet – ETH",
210 "Cash and cash equivalents"). It is NOT a tres internal/wallet account. The schema FK is against
211 `schema_integrationaccount`; the value lives on `ErpBill.depositAccount` / `ErpInvoice.depositAccount`
212 as `IntegrationAccountQuery { id name type value }`.
213
214 Resolution order:
215 1. **Use the pre-set one on the bill/invoice.** When the entity was created in the ERP, the user
216 typically already picked a deposit account. Read `erpBills.results[].depositAccount.id` (or
217 `erpInvoices.results[].depositAccount.id`). If it's non-null, use that id as `depositAccountId`
218 and just mention it in the summary — no prompt.
219 2. **Otherwise ask.** Query `integrationAccount(integration: <erpId>, first: 100)` and show the
220 user the ASSET-type accounts (cash/wallet/crypto GL accounts) as a numbered list; capture the
221 chosen `integrationAccount.id`.
222 3. `paymentToInternalAccounts` maps *wallet → asset* on the tres side and is useful for payout
223 workflows — it is **not** the source of `depositAccountId` for `matchApAr`. Don't reach for it
224 here.
225
2262. **Fiat alignment.** Compare the sub-tx `fiatValue` to the invoice/bill `origAmount`. Three cases:
227 - `fiatValue` is **null or 0**: ask *"This transaction has no fiat value set. Align it to ${origAmount}
228 so the {bill/invoice} closes fully? (y/n)"* — **no default**, require an explicit yes.
229 - `fiatValue` differs from `origAmount`: ask *"Align tx fiat value from $X to $Y so the AP/AR
230 closes fully? (y/n)"* — **no default**, require an explicit yes.
231 - Values match: skip.
232 **Never run `setManualFiatValue` without a clear yes** — even if the user's original request implied
233 "close the invoice", the fiat alignment is a separate write and needs its own consent. If they say no,
234 the match still proceeds but the entity may stay partially paid.
235
236---
237
238## Step 6 — Confirm and apply
239
240Show this summary table — use **human-readable values only**: tx hash (truncated), deposit account **name**
241(never its id), invoice/bill number or customer/vendor name. Internal IDs (`subTxId`, `depositAccountId`,
242DB `entityId`) are for the mutation payload, never for the user.
243
244| | |
245|---|---|
246| Transaction | `0xabcd…1234` · {asset.symbol} {amount} · ${fiatValue} |
247| Invoice/Bill | `{invoiceNumber or billNumber}` · {customer/vendor} · ${origAmount} {currency} |
248| Payment account | {depositAccount.name} |
249| Align fiat | yes → ${origAmount} / no |
250| Sync to ERP after match | yes / no |
251
252Ask: *"Apply these changes to **{orgName}**? (yes/no)"* — only proceed on yes. Treat this as approval for
253the **match only**. Fiat alignment and ERP sync each need their own explicit yes captured earlier (Step 5
254for fiat, the prompt below for sync) — even if the user's opening request said "sync to QBO", still confirm
255here, because the request was issued before they saw the actual match details.
256
257Then run mutations in this order, reporting the result of each:
258
2591. **Match (always):**
260 ```graphql
261 mutation Match($entityType: String!, $matches: [SubTxMatchInput]!) {
262 matchApAr(entityType: $entityType, matches: $matches) {
263 results { subTxId entityId depositAccountId }
264 }
265 }
266 ```
267 Variables:
268 - `entityType`: **lowercase** — `"invoice"` or `"bill"`. Uppercase values are rejected with
269 `Invalid entity_type`.
270 - `matches[].subTxId`: string (e.g. `"4548595830"`).
271 - `matches[].entityId`: **integer** — the DB `id` of the invoice/bill (from `erpInvoices.results[].id`
272 / `erpBills.results[].id`), NOT the human-facing `invoiceNumber`/`billNumber`. Passing a string
273 errors with *"Int cannot represent non-integer value"*; passing a bill number looks up nothing
274 and errors with *"One or more Bill IDs not found"*.
275 - `matches[].depositAccountId`: **integer** — the integration account id (see Step 5).
276
2772. **Fiat alignment (only if user opted in):**
278 ```graphql
279 mutation AlignFiat($id: String!, $newFiatValue: String!, $currency: String) {
280 setManualFiatValue(id: $id, newFiatValue: $newFiatValue, currency: $currency) { success }
281 }
282 ```
283 Pass the sub-transaction ID as `id`, the target value as a string in `newFiatValue` (e.g. `"123.45"`, the
284 invoice/bill `origAmount`), and `currency: "usd"`. If the response returns an error about a "locked
285 period", the accounting period containing the tx is closed — surface that to the user; don't try to force.
286
2873. **ERP sync (ask first, every time):** *"Sync this transaction to {erpName} now? (y/n)"* — **never sync
288 without an explicit yes at this point**, even if the user's original ask included "and sync to QBO". The
289 sync call is visible in the ERP and harder to undo than the match itself, so it gets its own gate. If yes:
290 ```graphql
291 mutation Sync($txIds: [String]!, $entityType: String!) {
292 syncSpecificTransactions(transactionIds: $txIds, entitySourceType: $entityType) { status }
293 }
294 ```
295 `entitySourceType` mirrors `entityType` from the match call.
296
297When reporting results back to the user, refer to the transaction by its **tx hash** (truncated) and the
298deposit account by **name** — never by `subTxId` or `depositAccountId`. Those internal IDs are for debugging,
299not user output.
300
301If any mutation errors, surface the error message verbatim and offer to retry. The match is the one that
302actually links the records — if it fails, the rest is moot.
303
304---
305
306## Step 7 — Loop
307
308Ask: *"Match another transaction or invoice/bill? (y/n)"*
309
310- yes → restart from Step 2 (skip Step 1 — keep the cached ERP from this session).
311- no → wrap up: *"Done. Closed {n} item(s) this session."*
312
313---
314
315## Verified TRES MCP operations used
316
317| Purpose | Operation | Type |
318|---|---|---|
319| Org identity | `get_viewer` | MCP tool |
320| Schema discovery | `introspect`, `build_query` | MCP tool |
321| Check ERP connections | `integration` (filter `isErp=true`) | query |
322| Fetch tx by hash | `transaction(identifier:)` | query |
323| Fetch invoices (with embedded suggestions) | `erpInvoices` | query |
324| Fetch bills (with embedded suggestions) | `erpBills` | query |
325| Pre-computed sub-tx → invoice match suggestions | `subTransactionToInvoiceMatchSuggestions` | query |
326| COA-mapped payment accounts | `paymentToInternalAccounts` | query |
327| Match sub-tx ↔ invoice/bill | `matchApAr` | mutation |
328| Align fiat value | `setManualFiatValue` (verify args at runtime) | mutation |
329| Sync transactions to the ERP | `syncSpecificTransactions` | mutation |
330| Undo a match (on user request) | `manualUnmatchSubtransactions` | mutation |
331
332---
333
334## Out of scope (politely redirect if asked)
335
336- Bulk matching (many ↔ many) — not yet supported by this skill.
337- Sending payments (`sendBillPayment`, `sendInvoicePayment`) — separate workflow.
338- Connecting or revoking the ERP itself — use the `tres-settings-management` skill.
339- Explaining a transaction in narrative form — use `tres-tx-story`.
340- Importing an explorer link into the ledger — use `tres-explorer-tx-to-ledger`.