Run the monthly accounting close in Pennylane
When to use it: you keep the books in Pennylane and want the same close every month: the bank reconciled, every unmatched transaction carried until it is settled, the balances that should be at zero checked, every correction approved by a person before it posts, and a report the finance team reads in Slack.
Scheduled routine, early each month, covering the month before
"Close last month's books and post the report to the finance channel."
│
▼
┌─────────────────────────────────────────────────┐
│ 1 · Confirm the company and pin the period │ pennylane_ref, oto_project
│ The project's bound Pennylane key, checked by │ oto_instance, data_rows
│ company name, then last month's baselines. │
└────────────────────────┬────────────────────────┘
├───────────────▶ ▪ stopped no key bound, or another company
▼ this company's key, with its scopes
┌─────────────────────────────────────────────────┐
│ 2 · Reconcile the bank │ pennylane_transactions, pennylane_match
│ Every open transaction, this month's or │ pennylane_invoice, pennylane_supplier_invoice
│ carried, matched to one unpaid invoice. │ data_rows, data_write
└────────────────────────┬────────────────────────┘
├───────────────▶ ▪ exception no single unpaid invoice to match
▼
┌─────────────────────────────────────────────────┐
│ 3 · Control the balances │ pennylane_trial_balance, pennylane_ledger_entry
│ Accounts that should be at zero, each bank │ pennylane_ledger_lettering
│ account, and invoices issued twice. │ pennylane_transactions
└────────────────────────┬────────────────────────┘
▼
╔═════════════════════════════════════════════════╗
║ 4 · A person approves every correction ║ pennylane_ledger_entry, pennylane_invoice
║ Entries, credit notes, draft invoices and ║ an entry cannot be deleted
║ exceptions closed unmatched, each on a yes. ║
╚════════════════════════╤════════════════════════╝
▼
┌─────────────────────────────────────────────────┐
│ 5 · Build the close report │ pennylane_trial_balance
│ Revenue, expenses, margin and cash against │ data_write
│ last month, plus every open exception. │
└────────────────────────┬────────────────────────┘
▼
┌─────────────────────────────────────────────────┐
│ 6 · Post the report │──▶ Slack your finance channel, split and threaded if long
│ One summary message, with the exception │ slack_post_message
│ detail threaded underneath it. │
└─────────────────────────────────────────────────┘
▪ terminal — a stopped close writes nothing; an exception stays open in its table and is retried next month
1. Confirm the company and pin the period
The close runs in one project per company, and every call below, Pennylane and data alike, carries _project on that project.
oto_project— op=get on that project, before any Pennylane call. Its links must include aconnecteurlink topennylanewith aninstance_refin itsconfig: that binds one Pennylane key to the project, and a call carrying_projectthen uses exactly that key, with no fallback (an account that cannot reach it is refused, never handed another key). Never pass_instance, which overrides the binding. Without a binding, a call uses whichever key the running account resolves on its own, so an account holding keys for several companies would run one company's close against another's books: its bank read, and matches, suppliers, drafts and ledger entries that cannot be deleted written there.- No binding yet (a first close):
oto_instanceop=list withconnectorpennylane. More than one key: stop before any Pennylane call and report each key'snameandlevel, so a person binds the right one. Exactly one: bind it withoto_projectop=link, target_type=connecteur, target_ref=pennylane andinstance_refset to that entry'sref(not itsid,inst:<n>, which the link refuses), then run the check below through the project. Once bound, a key added to the account later cannot take this close over. pennylane_ref— kind=company, the first Pennylane call.company.namemust be exactly [the company this project closes]: companies in one group often differ by a single word, so a near match is a stop, not a pass. A key bound this run that fails is removed at once withoto_projectop=unlink. The same answer carriesscopes, the key's real rights: the close needs write access (:all, not:readonly) on everything it writes, from invoices and suppliers to file attachments and the ledger, and read access on the rest. Pennylane splits the ledger intojournals,ledger_accountsandledger_entries, and a key that reads one may not read the others. A missing scope stops the run with its name, rather than a write refused halfway through a filing. The check runs again right before step 4 commits anything, because a call made while the project's links cannot be read falls back to the running account's own key without an error.pennylane_ref— kind=fiscal_years places the month inside its fiscal year (year-to-date figures, the cash balance and the transaction lookback start there, not in January), and kind=journals and kind=ledger_accounts return the ids a correction will need. These ids belong to the company: resolve them every run, never hard-code them.- The two tables: the close keeps two, each bound to the project under a slot:
close-snapshots(one row per month) andclose-exceptions(one row per transaction left unmatched). A tableau link under the slot name that carries adatastore_idmeans the table is set up, and that number is how every data call below addresses it, with_projecton the close's project. Neverslot:<name>and never a table name: the platform turns a slot into the table's name, a name resolves to the caller's own personal table first, and an account that owns a table with the same name would read and write that one instead of the project's. A link under the slot name with nodatastore_iddoes not resolve to a single table: stop and say so. - First-close setup, for each slot with no link:
data_create_datastorewith a name unique to the project,close-snapshots-<project_id>orclose-exceptions-<project_id>, and the reply carries the new table'sid. Table names are unique per owner across every project, so a generic name would collide with the same account's close for another company, and linking that table would merge two companies' months onto the same rows.data_set_schemaon that id declares the business key (periodfor snapshots,transaction_idfor exceptions), so a re-run merges onto the existing row instead of appending a second one; on exceptions it also declares the receipt columns of step 2, the two dates typeddate. A new table is private to whoever created it, sodata_shareit in write with every account that runs the close or edits exceptions, thenoto_projectop=link with target_type=tableau,target_refthe id andslotthe generic slot name. data_set_schema replaces the whole schema, so any later change goes throughdata_patch_schema, or the key disappears with it. - If the create answers that the name already exists, the error does not say which table: find its id with
data_list_datastores, the entry with that exact name andis_personaltrue. The project id in the name makes it most likely a setup of this project that stopped before linking, but check before using it: link it, then readcross_projecton the link that comes back. False: the table belongs to this project alone, so finish its schema and sharing as above. True: another project already links it, sooto_projectop=unlink it at once and stop, naming the table in the report. data_rows— onclose-snapshots, the row whoseperiodis the previous month: the baseline step 5 compares against. No row (a first close, or a month that was never closed) means no baseline: say so in the report instead of comparing with zero. Onclose-exceptions, the rows whosestatusisneresolved, paged bynext_cursor: every exception still open, and the oldest transaction date among them. With both links resolved, an error here is an access problem, not a first close: stop and say which table the running account cannot reach.
2. Reconcile the bank
The rule the close hangs on: one transaction, one matched invoice. What is not matched stays open in the exceptions table with its reason and comes back every month until it is settled. Nothing is matched to "the closest one".
pennylane_transactions—only_outstandingon,period_endon the month end, andperiod_starton the earlier of the fiscal-year start and the oldest open exception from step 1. A transaction left unmatched in an earlier month sits before this month's dates, so a range starting on the first of the month would drop it for good. On the first close nothing is carried yet, so the range starts at the fiscal-year start; widen it once by hand if older open transactions belong in. Leavemax_pagesunset:only_outstandingfilters after the pages are fetched, so a page cap silently drops open transactions on the pages it never fetched. If the answer is too large, call once per month across the same range instead. An upstream failure raises rather than returning a short list, so a transaction missing from the answer really is no longer open.data_rows— one call onclose-exceptionswithtransaction_idinthe ids that came back, paged bynext_cursor, sorts them. A row whosestatusis exactlyresolvedwith a person's reason inresolutionwas closed on purpose (no invoice will ever come) and is skipped; one the run itself resolved as matched but that still comes back outstanding is reopened, because the match did not settle it. A row with any other status is a carried exception and is tried again, because the invoice may have arrived since. No row means a new transaction. Only the exact valueresolvedcloses a row, so a mistyped status keeps the exception open instead of losing it.pennylane_invoice— op=list for customer invoices. The list carries only the customer's id and link, so resolve names once, before matching, withpennylane_customerop=list: the counterparty in a transaction label is a name, and the same names are reused in the report.pennylane_supplier_invoice— op=list for supplier invoices.- Paging the two invoice lists: neither filters by date, and neither states its sort order. Start with a small
max_pagesand compare the dates at the start and at the end of what came back. Newest first: widenmax_pagesuntil the oldest date returned is earlier than the oldest transaction being matched minus [your payment window], because an invoice issued well before a payment can still be the one it settles, and stopping short turns those payments into false "zero candidates" exceptions. Any other order: the last date says nothing about what is left, so page to the end. - Direction decides the side: money in is matched against customer invoices, money out against supplier invoices. A candidate is a finalized invoice, not a draft and not a credit note (negative amounts), that is still unpaid, with the exact amount, the counterparty named in the transaction label, and a date within [your payment window] before the transaction; an invoice number in the label settles it. An exact amount that fits only an invoice already paid is an exception, not a match: it may be a second payment of the same invoice, and matching it would hide the overpayment.
pennylane_match— only when there is exactly one candidate, with theinvoice_id, thetransaction_id, andinvoice_typeset to supplier for money out. The type defaults to customer, so a supplier match without it is attempted against customer invoices: always pass it for money out. The link is not an accounting entry, and no tool here removes it; a wrong match is unlinked by a person in Pennylane. It matters because a paid customer invoice left unmatched stays late in Pennylane, which then chases a customer who has already paid.- Zero candidates, or several: an exception, with the reason written down. A transfer that settles several invoices, a partial payment and a payment net of fees are exceptions for a person, not a guess.
data_write— onerowsbatch onclose-exceptions, merged ontransaction_id. Each transaction still unmatched gets its date, amount, direction, label, reason,statusopen andlast_seenon this period;first_seengoes only on a new row, because a merge overwrites every field it names. Each carried exception matched this run, or no longer returned as outstanding (settled in Pennylane by someone), getsstatusresolved and aresolutionsaying which.- Money out with no supplier invoice at all is a missing receipt. Pennylane reads nothing off a PDF, and the tools that fetch one hand back a link, not its content, so the person who holds the document fills its exception row, this month or any later one, and the next run files it.
receiptsays where the PDF is: a Drive file id, a Gmail message id with the file name, or a URL. Beside it go the fields filing cannot do without, since the import below is refused when one is missing:supplier_name,invoice_date,due_date(the invoice date again for a receipt paid on the spot),amount_before_tax,vat_amount,amount_totalandvat_code(Pennylane's code, such as FR_200, never a percentage).invoice_numberand the supplier's VAT or registration number go in too when the document shows them. - The row is checked before anything is filed: a row whose
receiptis still empty just waits. Once it is filled, the other required fields must be filled as well,amount_totalmust equal the transaction amount, andamount_before_taxplusvat_amountmust equalamount_totalto the cent. A row that fails files nothing: it stays open with the missing or inconsistent field named in its reason, and the report asks for it. A document with more than one VAT rate, or in another currency than the bank account, does not fit one row: a person files it and matches it in Pennylane, and the next run resolves the exception once the transaction stops coming back outstanding. - Filing resumes, never repeats: each stage below writes its id onto the row before the next stage starts, so a run that stops halfway picks up where it stopped. Filing runs without a yes, and no tool here removes a supplier or a supplier-invoice draft, which is why step 5 lists every one created.
pennylane_upload_file— takes thereceiptlocation as itssource(there is no local disk to upload from) and returnsfile_attachment_id, written onto the row at once. The reply is that id, the file name and a link, nothing read from the document, which is why its fields come from the row.pennylane_supplier— op=list returns only ids and names, so the supplier is found by the row'ssupplier_name. Reuse an exact match. Create one with op=create, which returns its id, only when no name on the list comes close, and passvat_numberorreg_noinfieldswhen the row carries one. A near miss (a legal-form suffix, an abbreviation, different spacing) goes to step 4 for a person to choose, because a duplicate supplier cannot be removed with any tool here; the exception stays open until then.pennylane_supplier_invoice— op=import creates a draft, and the call is refused when any of its required fields is missing:file_attachment_idstored on the row,supplier_id,dateanddeadlinefrom the two dates,currency_amount_before_tax,currency_taxandcurrency_amountfrom the three amounts, andinvoice_lines, here one line withcurrency_amountthe total,currency_taxthe VAT andvat_ratethe row'svat_code. Amounts are decimal strings with a dot, so a comma typed in the table is converted before the call.invoice_numbertakes the bare number from the row.external_referencetakes [your prefix]-supplier_id-invoice_number(the transaction id stands in for a document with no number), because invoice numbers are unique only within one supplier and short sequences repeat between suppliers. The tool does not look the reference up before importing, so the guard is the stored attachment id: Pennylane refuses a second import of the same uploaded file. A retry refused for that reason means the first import landed; find its draft on op=list by the reference. Writesupplier_invoice_idonto the row, then link the draft to the transaction it was filed for with pennylane_match andinvoice_typesupplier. That pair is already known, so the candidate rule above, which leaves drafts out, does not apply to it.
3. Control the balances
pennylane_trial_balance— three ranges: the month, the month before (start_dateandend_dateon the previous month), and the start of the fiscal year to the month end. Check, in this order:- Suspense and waiting accounts (the 47 class in the French chart) not at zero: something was booked without being understood.
- Customer accounts with a credit balance: an overpayment, or a receipt booked to the wrong customer.
- Supplier accounts with a debit balance: a credit note never received, or a bill paid twice.
- Each bank account against its own ledger account. No tool here reads a bank account balance, so compare movements: the month's net movement from
pennylane_transactionswithperiod_startandperiod_endon the month and withoutonly_outstanding(step 2 kept only open items, which is the wrong sum), against the movement on the bank ledger account in the month's trial balance. With several bank accounts, compare one account at a time, never one total, because one account's gap can cancel another's: group the transactions by the bank account each one carries, each paired with its ledger sub-account in [your bank-account-to-ledger map]. If the transactions carry nothing to group on, report the check as combined for all accounts, not as passed for each. Any gap is a transaction booked on one side only. A closing-balance check needs the statement's closing figure from a person. - Any account whose movement differs from last month's by more than [your threshold], compared account by account between the two monthly trial balances, with no explanation among this month's invoices. The snapshot keeps only totals, so this comparison always reads Pennylane.
- Customer invoices issued twice: among the invoice pages step 2 read, two finalized invoices to the same customer, with the same amount, dated within a few days of each other, one of them unpaid. The unpaid copy is a credit-note candidate for step 4, where a person confirms which copy is the mistake.
pennylane_ledger_entry— op=list finds the entries behind a flagged account, always withclausesondate(gteq and lteq for the month) and onjournal_idwhere you know it. Without clauses it returns the entire ledger. Clauses cannot filter on the ledger account (onlyid,dateandjournal_id), so list the month's entries, narrowed by journal where you can, then op=lines on each to find the lines that sit on the flagged account and their line ids.pennylane_ledger_lettering— op=set on at least two line ids when a payment line and an invoice line on the same third-party account balance exactly. Not the same gesture as step 2: matching links a bank transaction to an invoice, lettering links ledger lines to each other, and using the wrong one raises no error. Lettering absorbs: lettering A with C when A is already lettered with B produces A, B and C together, so read the result back with pennylane_ledger_entry op=lettered. Leave the unbalanced strategy at its default, which refuses an unbalanced lettering; an unbalanced pair is a correction for step 4. A wrong lettering is undone with op=unset.
4. A person approves every correction
Every ledger entry, every document finalized or sent, and every exception closed without a match is proposed here and committed one at a time on an explicit yes, once pennylane_ref kind=company has shown the right company again.
pennylane_ledger_entry— op=create posts an adjusting entry, which has no draft and cannot be deleted. Before the call, show the journal, the date, the label and every line with its account and amount, and wait. Amounts are decimal strings, debits must equal credits (the call is refused with the gap before it reaches Pennylane), and the journal and account ids come from step 1. op=update can delete lines too, so a fix to an entry is announced the same way.- A credit note is proposed in two cases only: the unpaid copy of an invoice issued twice (step 3), or an unpaid invoice a person says is not owed. It starts with a duplicate check on a key of its own: [your prefix] plus the number of the invoice it cancels. Never reuse the cancelled invoice's own
external_reference: invoices that come from a billing tool often carry one already, so the find would return the original invoice, read as "already credited", and a needed correction would be skipped and reported as done. pennylane_invoice— op=find with that key; it counts as a hit only when the document returned is a credit note (negative amounts). An ordinary invoice under that key means the key collides: stop and flag it. No hit: op=credit_note creates a draft for the person to review. The draft can still be removed with op=delete, so creating it needs no yes of its own. Pass thecustomer_id(required) and the key asexternal_reference, so the next run finds it, with lines in positive amounts (the server makes them negative). Each line is either a product id with a quantity, or a label, quantity, unit, unit price as a string and a VAT code, never a percentage; any other shape is refused with an opaque error. Passcredited_invoice_idand the link is posted in a second call after creation, because the create-time field does not hold. Switch the draft to the credit-note template (pennylane_refkind=invoice_templates, then op=update) before finalizing, so the PDF carries no payment block.- Draft customer invoices dated in the month, from the pages step 2 read: listed with customer, date and amount. The ones the person approves are finalized, then sent; the rest stay drafts.
pennylane_invoice— op=finalize and op=send, for an approved credit note or draft invoice, only after the person has read it and said yes. A finalized document cannot be corrected, only canceled by another credit note. A draft that will never be used is removed with op=delete, which refuses anything finalized.- A near-miss supplier from step 2: show both names, and the person picks the existing supplier or confirms a new one before op=create.
- Closing an exception without a match: when a person confirms no invoice will ever come (bank fees, tax, payroll booked another way),
data_writesets that row'sstatusto resolved and puts their reason inresolution. A person can also do it in the table between runs; the next run skips the transaction either way. - Pending corrections: whatever is not approved during the run stays pending, and a scheduled run with no one to answer leaves all of them pending. The report carries each one in full: journal, date, label and lines for an entry; the draft id and its key for a credit note. Posting one later first re-runs the duplicate find (credit note) or re-reads the flagged account's balance (entry), because the books may have moved since the report. The close does not wait on them.
5. Build the close report
pennylane_trial_balance— pulled again once the approved corrections are posted, on two ranges. The month gives revenue and expenses (account classes 7 and 6 in the French chart) and the margin between them. Cash at month end is the class-5 balance from the start of the fiscal year to the month end, so the opening entries are included; a month-only range gives the month's cash movement, not a balance. Each figure is compared with last month's snapshot.- The reconciliation: transactions matched this run, this month's and carried ones apart; every exception still open with its date, amount, counterparty, reason and the period it was first seen, so the old ones stand out; exceptions resolved this run and how. Missing receipts, each with the fields it still needs, and the suppliers created and supplier-invoice drafts imported this run. Customer invoices still unpaid past their deadline after matching, which is the list a payment chase starts from. Controls flagged in step 3, corrections posted, corrections still pending.
data_write— one row onclose-snapshots: the period, revenue, expenses, margin, cash and the number of exceptions still open. Theperiodkey declared at setup makes a re-run of the same close merge onto its row.
6. Post the report
slack_post_message— to [your finance channel] by its channel id (resolve it once withslack_list_channels, passingtypes=private_channel when the channel is private, since the default lists public channels only; a channel name is not an id). The summary goes first; the exception detail goes as a thread reply under the summary'sts.- Long text is split, never truncated. Past about 4,000 characters the message goes out in several parts, threaded, and the response carries
split_intoandts_all. A Slack message cannot be edited, only deleted, so read those fields before deciding anything was cut off: re-posting on a hunch leaves a duplicate report in the channel.
Output
The close report as posted: the company confirmed in step 1 and the period; revenue, expenses, margin and cash with the month-over-month change; transactions matched; every open exception with its reason and the period it was first seen, and the exceptions resolved; missing receipts with the fields each still needs, new suppliers and imported supplier-invoice drafts; unpaid customer invoices past deadline; controls flagged; corrections posted, and each correction pending approval in full. The run also confirms the snapshot row and the exceptions table were written for next month.