← all doctrines
financeoto

Run the monthly accounting close in Pennylane

Each month, matches bank transactions to Pennylane invoices, checks the balances, asks you to approve each fix and posts a month-end report to Slack.

connectors used

Ooto2
PennylanePennylane11
Ddata7
SlackSlack2

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 a connecteur link to pennylane with an instance_ref in its config: that binds one Pennylane key to the project, and a call carrying _project then 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_instance op=list with connector pennylane. More than one key: stop before any Pennylane call and report each key's name and level, so a person binds the right one. Exactly one: bind it with oto_project op=link, target_type=connecteur, target_ref=pennylane and instance_ref set to that entry's ref (not its id, 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.name must 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 with oto_project op=unlink. The same answer carries scopes, 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 into journals, ledger_accounts and ledger_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) and close-exceptions (one row per transaction left unmatched). A tableau link under the slot name that carries a datastore_id means the table is set up, and that number is how every data call below addresses it, with _project on the close's project. Never slot:<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 no datastore_id does not resolve to a single table: stop and say so.
  • First-close setup, for each slot with no link: data_create_datastore with a name unique to the project, close-snapshots-<project_id> or close-exceptions-<project_id>, and the reply carries the new table's id. 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_schema on that id declares the business key (period for snapshots, transaction_id for 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 typed date. A new table is private to whoever created it, so data_share it in write with every account that runs the close or edits exceptions, then oto_project op=link with target_type=tableau, target_ref the id and slot the generic slot name. data_set_schema replaces the whole schema, so any later change goes through data_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 and is_personal true. 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 read cross_project on 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, so oto_project op=unlink it at once and stop, naming the table in the report.
  • data_rows — on close-snapshots, the row whose period is 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. On close-exceptions, the rows whose status is ne resolved, paged by next_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_transactionsonly_outstanding on, period_end on the month end, and period_start on 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. Leave max_pages unset: only_outstanding filters 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 on close-exceptions with transaction_id in the ids that came back, paged by next_cursor, sorts them. A row whose status is exactly resolved with a person's reason in resolution was 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 value resolved closes 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, with pennylane_customer op=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_pages and compare the dates at the start and at the end of what came back. Newest first: widen max_pages until 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 the invoice_id, the transaction_id, and invoice_type set 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 — one rows batch on close-exceptions, merged on transaction_id. Each transaction still unmatched gets its date, amount, direction, label, reason, status open and last_seen on this period; first_seen goes 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), gets status resolved and a resolution saying 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. receipt says 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_total and vat_code (Pennylane's code, such as FR_200, never a percentage). invoice_number and 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 receipt is still empty just waits. Once it is filled, the other required fields must be filled as well, amount_total must equal the transaction amount, and amount_before_tax plus vat_amount must equal amount_total to 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 the receipt location as its source (there is no local disk to upload from) and returns file_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's supplier_name. Reuse an exact match. Create one with op=create, which returns its id, only when no name on the list comes close, and pass vat_number or reg_no in fields when 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_id stored on the row, supplier_id, date and deadline from the two dates, currency_amount_before_tax, currency_tax and currency_amount from the three amounts, and invoice_lines, here one line with currency_amount the total, currency_tax the VAT and vat_rate the row's vat_code. Amounts are decimal strings with a dot, so a comma typed in the table is converted before the call. invoice_number takes the bare number from the row. external_reference takes [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. Write supplier_invoice_id onto the row, then link the draft to the transaction it was filed for with pennylane_match and invoice_type supplier. 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_date and end_date on 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_transactions with period_start and period_end on the month and without only_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 with clauses on date (gteq and lteq for the month) and on journal_id where you know it. Without clauses it returns the entire ledger. Clauses cannot filter on the ledger account (only id, date and journal_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 the customer_id (required) and the key as external_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. Pass credited_invoice_id and 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_ref kind=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_write sets that row's status to resolved and puts their reason in resolution. 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 on close-snapshots: the period, revenue, expenses, margin, cash and the number of exceptions still open. The period key 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 with slack_list_channels, passing types=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's ts.
  • Long text is split, never truncated. Past about 4,000 characters the message goes out in several parts, threaded, and the response carries split_into and ts_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.

fork this doctrine into your oto space.

add to oto