| name | revolut-attach-justificatifs |
| description | Use this skill to attach PDF/JPG justificatives to Revolut Business transactions via Chromium piloted by Playwright MCP. Walks each transaction in the period, finds the matching YAML in `$WORKSPACE/receipts/YYYY-MM/`, uploads the PDF, fills the "Remarque" with a short description, and updates the "Catégorie" when relevant. Tracks done pieces in `_revolut_uploaded.md` and blockers in `_revolut_blockers.md`. Trigger when the user says « complÚte Revolut avec les justificatifs », « attache les PDF dans Revolut », « pousse les YAML vers Revolut ». |
Skill â Attaching receipts to Revolut Business transactions
Overview
Drives the Revolut Business web interface (business.revolut.com) via Playwright MCP to attach the PDF/JPG receipts to the bank transactions. Complementary to the tiime-upload-justificatifs skill: Tiime handles the accounting, Revolut handles the bank-side proofs at the bank. The skill enriches the transactions with status "Terminée · Informations de la dépense requises" with:
- the receipt PDF (from the
receipts/YYYY-MM/ folder)
- a short Remarque (nature of the expense)
- the most appropriate Catégorie (if the auto-detection is imprecise)
When to use
- The user has already run
justificatif-describe (existing YAML per month)
- The Revolut transactions still show "Informations de la dépense requises"
- The user wants to document the bank for internal use or URSSAF/tax audit
Do not use to: internal transfers (treasury), direct debits (Alan, URSSAF, Revolut subscriptions), Revolut fees. These transactions have no receipt to attach.
Prerequisites
- Playwright MCP configured and started.
- Revolut Business tab open (
business.revolut.com) and logged in (active session) in the driven Chromium.
- "Dépenses" module enabled in Revolut Business (otherwise the "Set up Expenses" button appears instead of the normal flow). To configure once on the manual UI side.
- Memory up to date (
bootstrap-projet) for the EURL context.
Lethal trifecta: Revolut Business = sensitive banking data. The hook can block WebFetch and certain browser_evaluate that look like data leaks. NEVER call an internal data source MCP (Asana/M365/Sentry/Slack) in the same session. Work 100% local + Playwright.
The hook can also block by asking the user for explicit authorization the first time you automate modifying actions on Revolut â accept this friction and ask the user for confirmation if needed.
Revolut Business navigation â stable landmarks
- Transactions list URL:
https://business.revolut.com/transactions
- Detail panel:
aside.PageSide__Container-rui__sc-10nkgiu-0 (appears on the right when a row is clicked)
- Open-panel URL:
?viewId={uuid}&viewSide=sell
- Observed transaction statuses:
TerminĂ©e â nothing to do
TerminĂ©e · Informations de la dĂ©pense requises â target of the skill
TerminĂ©e · PrĂ©lĂšvement automatique â do not touch (Alan, URSSAF, Revolut Business subscription, etc.)
Table columns
Transaction / Date / Créé par / Statut / Compte / Montant
Row selector: tr or [role="row"] (to confirm depending on the version). Row text contains: merchant name + date + created by + status + account + amount.
General procedure
Step 1 â Bootstrap & inventory
- Run
bootstrap-projet if the session is new.
- Read the
receipts/_revolut_uploaded.md file to exclude the already-processed transactions.
- Navigate to
/transactions and walk the history (paginate if needed).
- Inventory:
- All transactions with status
Informations de la dépense requises
- For each, identify the candidate YAML in
receipts/YYYY-MM/ by matching date ± 2 days + amount ± 0.01 ⏠+ merchant
- 3 cases: unique match / no match / several candidates
Step 2 â User validation
Present the recap to the user:
- N transactions to enrich
- N transactions with a unique YAML match (easy)
- N ambiguous transactions (several candidate YAML â require arbitration)
- N transactions without YAML (orphans â note in blockers)
Wait for explicit validation before engaging any modifications.
Step 3 â Per-transaction enrichment flow (reworked 2026-05-14 session 2)
â ïž Revolut changed its DĂ©penses UI between session 1 and session 2 (April â May 2026). The sub-form in the panel became a full-screen dialog that REPLACES the detail panel. The workflow below is validated on the new UI.
The workflow runs in 3-4 phases over 3-4 tool calls:
Phase A â Scroll-find + open the file chooser (1 eval)
- FIRST: Press Home via
browser_press_key('Home') BEFORE each scroll-find. window.scrollBy(0, X) does NOT work in Revolut â the scroll is on document.documentElement and can get stuck at the bottom. Home resets it.
- Scroll-find the row by textContent (merchant + date + amount). The Revolut virtual scroll unloads rows out of the viewport â you have to scroll SLOWLY to catch the transactions as they pass:
const scroller = document.scrollingElement || document.documentElement;
let row = null;
for (let i = 0; i < 50; i++) {
for (const r of document.querySelectorAll('[role="row"], tr')) {
if (matchTerms.every(m => r.textContent.includes(m))) { row = r; break; }
}
if (row) { row.scrollIntoView({ block: 'center' }); break; }
scroller.scrollTo({ top: scroller.scrollTop + 300, behavior: 'instant' });
await new Promise(r => setTimeout(r, 500));
}
row.click();
await new Promise(r => setTimeout(r, 1500));
- Click "DĂ©pensesDonnĂ©es requises" (concatenated text, no space) â opens a new full-screen dialog that replaces the right panel. This dialog contains sections: Reçus, Description, CatĂ©gorie accountant, Taux de TVA.
- Click DIRECTLY on
input[type="file"][accept*="application/pdf"] â the new UI has a hidden (1Ă1 px) file input that must be clicked programmatically. The visible "Charger" button can be misleading â prefer the input. DO NOT look for "Charger" like in the old UI.
Phase B â Upload via Playwright (1-2 tool calls)
browser_file_upload({ paths: ["/absolute/path/...pdf-or-jpg"] })
â ïž The file chooser sometimes closes too early. If browser_file_upload fails with "can only be used when there is related modal state present", re-run a light eval:
document.querySelectorAll('input[type="file"][accept*="application/pdf"]')[0].click();
then call browser_file_upload again. This double-click pattern is frequent (~50% of transactions).
â ïž File extension mandatory: setFiles fails with ENOENT if the extension is not exact. Check via ls receipts/YYYY-MM/ beforehand.
Phase C â Description + Envoyer (1 eval)
After upload, the full-screen dialog stays open. The UI varies depending on whether the OCR auto-filled or not:
- If Revolut auto-filled the description (Picard/Otera/etc. case with clear receipt OCR): "Modifier" button next to "Description" (top:521 left:1057 typical).
- If Description empty: "Ajouter" button next to "Description" (top:521 left:1084 typical).
- If transaction with warning "Le reçu ne correspond pas au montant" (StartFabrik case with a Revolut/YAML discrepancy): the panel is TALLER, the Description Ajouter button can be at top:597.
Unified selector: text === 'Ajouter' || 'Modifier', top between 500 and 800, left > 1000.
let descBtn = null;
for (const b of document.querySelectorAll('button')) {
if (!b.offsetParent) continue;
const t = b.textContent.trim();
if (t !== 'Ajouter' && t !== 'Modifier') continue;
const r = b.getBoundingClientRect();
if (r.top > 500 && r.top < 800 && r.left > 1000) { descBtn = b; break; }
}
Rest of the flow:
- Click the found button â opens a centered Description modal (top:174, left:400, w:400) with a
<textarea>.
- Set textarea value via native setter + dispatch
input + change events. If auto-filled, this replaces the OCR value (consistent with the "abandon batch cooking labels" strategy).
- Click "Enregistrer" in the Description modal (
left < 800 to target the modal and not another Enregistrer).
- Click "Envoyer" at the top of the full-screen dialog (
top < 260, left > 700).
- Click "Fermer" (top < 120) â returns to the list.
Status after: "TerminĂ©e · Informations de la dĂ©pense requises" â plain "TerminĂ©e". â
Step 4 â Special cases and stop rules
- Update
receipts/_revolut_uploaded.md (and receipts/_revolut_blockers.md if blocked).
Step 4 â Mandatory stop rules
Stop and note in blockers if:
- No candidate YAML found for a non-recurring debit transaction
- Several candidate YAML with an amount difference > 0.01 ⏠â user arbitration
- The transaction is a direct debit (Alan, URSSAF, etc.) with no expected receipt
- The Revolut Dépenses module is not enabled ("Set up Expenses" button visible)
- The Playwright upload fails or Revolut shows a network error
- The Revolut session expires
NEVER attach a PDF to a transaction without an exact YAML match.
Stable Playwright selectors (tested 2026-05)
| Action | Selector |
|---|
| Transactions list | tr or [role="row"] with textContent containing date + amount |
| Click a row | search by textContent (date + merchant + amount) |
| Detail panel | aside.PageSide__Container-rui__sc-10nkgiu-0 |
| Close-panel button | button with exact text "Fermer" (at the top of the panel) |
| Plus button (actions menu) | button with exact text "Plus" |
| Open expenses sub-form button | button with concatenated text "DépensesDonnées requises" |
| Receipt file input | input[type="file"][accept="image/jpeg,image/png,application/pdf"] (appears after clicking Dépenses) |
| Remarque zone | textarea or input near the "Remarque" label |
| Catégorie button | button with the current category's text (e.g. "Appareils de paiement") |
â ïž Revolut's CSS classes are generated styled-components (suffixes -rui__sc-XXXX-0). Prefer matching by textContent or by semantic attribute (accept, aria-label).
Mapping YAML â Revolut fields
| Revolut field | YAML source | Notes |
|---|
| Receipt (file) | PDF/JPG from the receipts/YYYY-MM/ folder (same base name as the YAML) | Upload via setInputFiles |
| Remarque | nature.short_description + merchant.trade_name if illuminating | ~50 characters max |
| CatĂ©gorie | mapping nature.category â Revolut category (to complete as cases arise) | Often OK by default |
Revolut category mapping (to be enriched)
To be completed as exploration goes. Observed Revolut categories:
- "Appareils de paiement" (e.g. Ubiquiti)
- (to complete)
Tracking files
The skill writes and reads two files at the root of receipts/. Their structure is also documented in _meta_docs.yaml at the workspace root (ids revolut_uploaded and revolut_blockers).
receipts/_revolut_uploaded.md
Exhaustive journal of the enriched transactions. Read at the start of the session to exclude the already-done ones.
Structure:
# Suivi attachements Revolut Business â session YYYY-MM-DD
Transactions Revolut Business dont le justificatif PDF/JPG a été attaché + description complétée + envoi (Envoyer). Statut passé de "Terminée · Informations de la dépense requises" à **"Terminée"** simple.
Format : `- [YYYY-MM-DD] Marchand TTC â yaml_attachĂ© â description`
## YYYY-MM (N transactions[, session ...])
- [YYYY-MM-DD] Marchand X,YZ ⏠â nom_du_yaml â Description courte
## Total session
**N transactions enrichies dans Revolut Business.**
To update after each validated transaction (or by month batches to limit the chain of writes).
receipts/_revolut_blockers.md
Unprocessed cases: no matching YAML, ambiguous, out of scope, open Gérard decisions.
Typical sections (suggested order):
## â
Bilan complet â global counter at the top
## đ DĂ©cision ouverte GĂ©rard â non-accounting pieces / forcing required
## đĄ Ă signaler au cabinet â accounting discrepancies (e.g. PayPal commission, USD/EUR exchange)
## đŽ Transactions sans YAML correspondant â to be provided later by GĂ©rard
## âčïž Hors pĂ©rimĂštre du skill â direct debits, transfers, documented duplicates
## â
Transactions dĂ©jĂ enrichies manuellement â plain "TerminĂ©e" status with no skill action
## Doublons documentĂ©s (audit) â for consistency with _tiime_blockers.md
For sessions in progress, also add ## âïž Queue exhaustive pour session future in copy-paste format: [transaction] â [YAML path] â [comment].
Regeneration from scratch
See _meta_docs.yaml â revolut_uploaded.regeneration / revolut_blockers.regeneration. In short: impossible to recreate the session chronology, but the current state of the Revolut transactions (status + attached receipts) is enough to reconstruct a functional _revolut_uploaded.md via a full audit.
Special cases
Transaction with no expected receipt (direct debits)
Do not try to attach. These transactions have the PrélÚvement automatique status and are implicitly justified (Alan = health insurance, URSSAF = TNS contributions, Revolut Business subscription).
To document in the comment for the accountant: these lines will never have a PDF but are deductible by nature documented by contract.
Transaction without YAML (orphan)
If a debit transaction has no candidate YAML (e.g. Sushi Vda 41,30 ⏠refund visible in the list â no PDF in receipts/), it's a signal:
- Either the receipt has not yet been photographed / deposited by Gérard
- Or the transaction is a refund to be adjusted
- Or it's a personal expense on the EURL card (to report as a benefit in kind or CCA rebilling)
â Note in blockers for GĂ©rard's decision.
Several candidate YAML (identical amount)
Observed case: Picard 23,25 ⏠on 24/12/2025 â 2 YAML with the same amount on the same day (documented duplicate). Ask GĂ©rard which YAML to attach to the Revolut transaction (probably only one, the other being to delete on the YAML/Tiime side).
Reusable patterns (tested 2026-05-14)
Helper A â Open and trigger file chooser (eval 1)
async function openTransactionAndChargeFileChooser(rowMatchTerms) {
let row = null;
for (let i = 0; i < 30; i++) {
for (const r of document.querySelectorAll('[role="row"], tr')) {
const t = r.textContent;
if (rowMatchTerms.every(m => t.includes(m))) { row = r; break; }
}
if (row) { row.scrollIntoView({ block: 'center' }); break; }
window.scrollBy(0, 350);
await new Promise(r => setTimeout(r, 350));
}
if (!row) return 'row not found';
await new Promise(r => setTimeout(r, 500));
row.click();
await new Promise(r => setTimeout(r, 1500));
for (const b of document.querySelectorAll('button')) {
const t = b.textContent.trim();
if (t.startsWith('Dépenses') && t.includes('requises')) { b.click(); break; }
}
await new Promise(r => setTimeout(r, 1500));
for (const b of document.querySelectorAll('button')) {
if (b.textContent.trim() === 'Charger') {
const r = b.getBoundingClientRect();
if (r.top > 350 && r.left > 1000) { b.click(); break; }
}
}
await new Promise(r => setTimeout(r, 800));
return 'file chooser ready';
}
Tool call B â Upload via Playwright MCP
{ paths: ["$WORKSPACE/receipts/YYYY-MM/nom.pdf-or-jpg"] }
Helper C â Description + Envoyer + Fermer (eval 3)
async function fillDescriptionAndSubmit(description) {
for (const b of document.querySelectorAll('button')) {
const t = b.textContent.trim();
if (t.startsWith('Dépenses') && t.includes('requises')) { b.click(); break; }
}
await new Promise(r => setTimeout(r, 1500));
const allBtns = Array.from(document.querySelectorAll('button')).filter(b => b.getBoundingClientRect().left > 700);
for (const b of allBtns) {
if (b.textContent.trim() === 'Ajouter') {
let p = b.parentElement;
while (p) {
if (p.textContent.includes('Description')) { b.click(); break; }
p = p.parentElement;
}
if (document.querySelector('textarea')) break;
}
}
await new Promise(r => setTimeout(r, 1000));
const ta = document.querySelector('textarea');
if (ta) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, description);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
}
await new Promise(r => setTimeout(r, 500));
for (const b of document.querySelectorAll('button')) {
if (b.textContent.trim() === 'Enregistrer') { b.click(); break; }
}
await new Promise(r => setTimeout(r, 1500));
for (const b of document.querySelectorAll('button')) {
if (b.textContent.trim() === 'Envoyer') {
const r = b.getBoundingClientRect();
if (r.width > 0 && r.top < 250) { b.click(); break; }
}
}
await new Promise(r => setTimeout(r, 2000));
for (const b of document.querySelectorAll('button')) {
if (b.textContent.trim() === 'Fermer') {
const r = b.getBoundingClientRect();
if (r.width > 0) { b.click(); break; }
}
}
return 'done';
}
Observed performance
- ~25 seconds per transaction (3 tool calls: open eval, file upload, end eval)
- File chooser open in ~800ms after clicking Charger
- Tiime runs the OCR quickly after sending (<1s on the list side)
â ïž The upload requires Playwright orchestration (browser_file_upload), not just JS in browser_evaluate. The pattern is: trigger the file chooser via a JS click, then call browser_file_upload at the tool level, then continue in eval.
Anti-patterns
- â Using
window.scrollBy(0, X) or window.scrollTo(0, 0) â does not work in Revolut. Use browser_press_key('Home') + document.scrollingElement.scrollTo({top: scrollTop+300}).
- â Looking for the "Charger" button like in the old UI â the new UI has a hidden 1Ă1 px file input that must be clicked directly via
input[type="file"][accept*="application/pdf"].
- â Clicking "Ajouter" without a position constraint â there are several "Ajouter" in the dialog (Description, CatĂ©gorie accountant, Taux de TVA). Use the position: top between 500 and 800, left > 1000. Without this precision, you open the wrong modal (typically "CatĂ©gories accountants") and the rest fails.
- â Clicking a modifying button without explicit authorization for the Revolut batch â the hook can block and that is legitimate.
- â Attaching a PDF to a transaction without checking the exact match (date + amount + merchant). Better to note in a blocker.
- â Assuming queue amount = Revolut amount â for foreign-currency transactions (USD, others), Revolut shows the card total_incl_tax with commission/exchange included, which may differ from the invoice total_excl_tax (~5% gap for PayPal). Observed case: StartFabrik 458,28 ⏠invoice total_excl_tax vs 484,45 ⏠Revolut card total_incl_tax. Document the gap in the description.
- â Modifying the Revolut auto-detected category without good reason â it's for their analytics, not for the accounting.
- â Trying WebFetch after Revolut access â blocked by lethal-trifecta.
- â Assuming
.pdf is the extension of all YAML â the folder mixes .pdf (invoices) and .jpg (receipt photos). Check ls beforehand.
- â Forcing the upload of a piece documented as "DO NOT upload" â the lethal-trifecta hook blocks it (rightly).
- â Relying on a single
browser_file_upload call â ~50% of cases require a re-click of the input then a second browser_file_upload. The modal file chooser sometimes closes prematurely after the Phase A eval.
- â Clicking "Envoyer" without having first saved the Description â the Enregistrer button of the Description modal must be clicked before the Envoyer of the main dialog.
Resuming an interrupted session
Final state 2026-05-14 (cf. receipts/_revolut_uploaded.md and receipts/_revolut_blockers.md):
- 35 transactions enriched in total (12 session 1 + 23 session 2 of 2026-05-14)
- 2 complete sessions: the exhaustive queue of 24 transactions is processed
- Residual blockers: 13 transactions without YAML + 1 Sushi Vda 16/04 decision
To resume:
- Load
bootstrap-projet for the EURL context.
- Read
receipts/_revolut_uploaded.md for the already-done ones.
- Read
receipts/_revolut_blockers.md for the work queue + the ambiguities to arbitrate with the user.
- Open the Revolut Business tab (
browser_tabs(action: list)) â check the active session.
- Navigate to
/transactions?sort=metadata.date:desc and scroll-find each remaining transaction.
Arbitrated decisions to apply in a future session
- Sushi Vda 16/04 + 27/04 47,40 âŹ: DO NOT attach (hook blocked). The
mobile_20260426 receipt is "Document non accountant" â either GĂ©rard provides the real PlanetSushi invoice, or we skip it definitively.
- MYCS 21/12/2025 1 439,67 âŹ: attach
mycs_20260307_FV090937.pdf (Gérard decision confirmed).
- Sushi Vda client meals (Planet Sushi 16/03 + 24/03): attach with comment
"Repas client â compte 625710 visĂ© (Ă recat cabinet)." (Tiime already uses the RECEPTIONS FRAIS DE REPRESENTATION label).
- Picard / Otera / Plaisirs courses: attach with the standardized comment
"Courses [marchand] â position contribuable AN nourriture (cabinet)." (consistent with Tiime).
- Picard 20/01 73,61 ⏠duplicate: use
restaurant_20260120_90038279.pdf rather than picard_20260122_90038215 (date more consistent with the Revolut transaction 20/01).
- Stad Gent 24/02 9,38 âŹ: SKIP (Ghent city â Molenbeek of the YAML). Ask GĂ©rard to provide the right receipt.
Transactions to process (queue ordered chrono asc)
Exhaustive list with YAML mapping, comment and paths in receipts/_revolut_blockers.md section "Ă traiter dans une prochaine session".
Standardized comments (consistency with Tiime)
| Piece category | Revolut comment |
|---|
| Courses Otera Sunday | Courses Otera dimanche â position contribuable AN nourriture (cabinet). |
| Courses Picard / Otera lunch | Courses Picard â position contribuable AN nourriture (cabinet). |
| Plaisirs et Gourmandises lunch | Repas indiv. midi â position contribuable AN nourriture (cabinet). |
| Client meal (PlanetSushi/Breton/Charivari) | Repas client â compte 625710 visĂ© (Ă recat cabinet). |
| CafĂ© Match office | CafĂ© MEO grains 100 % bureau â consommable (compte 606300). |
| Honoraires L-Expert | Honoraires mensuels expertise accountant. |
| Apple/UGREEN hardware | [Description objet] â matĂ©riel info. |
| EasyPark | Stationnement déplacement pro. |
| DDG trip (Mozza CDG, SSP Iceland) | [Type repas] CDG [direction] voyage DDG Reykjavik. |
| Ibis hotel | Taxes sĂ©jour [dates] â [confĂ©rence/motif]. |
Skill maintenance
To update when:
- Revolut changes its DOM (broken selectors)
- New Revolut Dépenses workflow (split, multi-receipt, etc.)
- New Revolut category encountered
See also
justificatif-describe (upstream skill â produces the YAML)
tiime-upload-justificatifs (twin skill on the Tiime side)
bootstrap-projet (context loading)
- Memory
audit_decisions_2026-05.md