Skip to main content

Invoiceninja

@shrug/invoiceninjav2026.10.27.2· 1d agoBEHAVIORSREPORTS
01README

Invoice Ninja v5 (/api/v1) model — read-side snapshots (invoices, payments, expenses, vendors, expense categories) plus an idempotent, gated write path that syncs bank transactions into Invoice Ninja as expense records.

Status: live in production, promoted to stable, driving the mercury-expense-sync / ar-nightly workflows.

Methods

  • Read — listInvoices, getInvoice, listPayments, listExpenses, expenseCategories, vendors, listClients (lean id/number/name discovery) / getClients (by-id batch fan-out, full PII), listProjects / getProject, listTasks / getTask, getCompanySettings (GET /companies/{id}: custom-field labels + currency/date-format ids). All list reads paginate to completion. The clients/clientList snapshots are short-lived (clientSnapshotLifetime, default 1h), gc=1, and PII-sensitive.
  • Write (gated behind dryRun, default true) — upsertExpenses (check-then-create per BankTxn, dedup on custom_value1), upsertVendors / upsertExpenseCategories (auto-create reference data for candidate transactions), buildMapping (pure derive of the frozen vendor/category Mapping; under dryRun it also projects would-create vendor/category names onto a sentinel id so an all-dry-run preview resolves them instead of reporting a phantom unmatched vendor/category), upsertInvoices (check-then-create invoices, dedup on number, full product/service line items, draft-only — never emails/sends), invoiceTasks (create ONE draft invoice from a set of tasks the way IN's UI does — task-linked service lines with precise hours + task_id, dedup on number, read-back confirms each task's invoice_id flipped, draft-only — never emails/sends), upsertClients / upsertProjects / upsertTasks (check-then-create, dedup on number), the stateful task timer startTask / stopTask (idempotent PUT /tasks/{id} rewriting time_log), and reconcileCompanySettings (declarative, dry-by-default reconcile of company custom-field labels + currency/date-format ids; strict relabel-only (an empty live slot is never populated); on dryRun=false it round-trips the FULL live settings/custom_fields JSON columns via PUT /companies/{id}, mutating only changed keys, because IN REPLACES those columns wholesale while MERGING omitted top-level company columns).

Dedup contract

Bank txn id → Invoice Ninja custom_value1 — the only field both writable and ?filter=-searchable — for expenses; the invoice number (unique and ?filter=-searchable) for invoices. Check-then-create; no upsert; single-writer fan-out.

Curated overlays

vendorOverlay / categoryOverlay translate raw vendor/category names; vendorCategoryOverlay hard-overrides a vendor's category assignment, winning over the bank's raw category.

Header token auth (vaulted), X-Requested-With mandatory on every request.

02Behaviors1
@shrug/invoiceninjav2026.10.27.2invoiceninja.ts
fn listInvoices(clientId?: string, clientStatus?: string, scope?: string)
GET /invoices (paginated, include=client) → invoices snapshot with
ArgumentTypeDescription
clientId?string
clientStatus?stringe.g. "1,2,3,4"
scope?stringOptional instance-name qualifier: suffixes the written snapshot
fn getInvoice(id: string)
GET /invoices/{id}?include=client,payments,activities → invoice.
ArgumentTypeDescription
idstring
fn listPayments(clientId?: string)
GET /payments (paginated, include=invoices,client) → payments
ArgumentTypeDescription
clientId?string
fn listExpenses(clientId?: string, vendorId?: string, categoryId?: string, filter?: string)
GET /expenses (paginated, include=vendor,category,client) → expenses
ArgumentTypeDescription
clientId?string
vendorId?string
categoryId?string
filter?string?filter= (searches custom_value1)
fn expenseCategories()
GET /expense_categories (paginated) → expenseCategories mapping snapshot.
fn vendors()
GET /vendors (paginated) → vendors mapping snapshot.
fn getClients(ids: array, scope?: string)
GET /clients/{id}?include=contacts per id (batch fan-out, sequential;
ArgumentTypeDescription
idsarrayIN client hashids; each validated /^[A-Za-z0-9]+$/. A blank/invalid
scope?stringOptional instance-name qualifier: writes the snapshot as
fn listClients(filter?: string)
GET /clients (paginated; NO include=contacts, optional ?filter=) →
ArgumentTypeDescription
filter?stringIN ?filter= search term (name/number/email substring). Query param
fn clientDomains()
GET /clients?include=contacts (paginated) → clientDomains: the DEDUPED
fn listProjects(clientId?: string, scope?: string)
GET /projects (paginated, include=client; optional client_id filter) →
ArgumentTypeDescription
clientId?string
scope?stringOptional instance-name qualifier: suffixes the written snapshot
fn getProject(id: string)
GET /projects/{id}?include=client → project.
ArgumentTypeDescription
idstring
fn getCompanySettings(companyId?: string, scope?: string)
GET /companies/{id} -> companySettings: normalized custom-field labels
ArgumentTypeDescription
companyId?string
scope?stringOptional instance-name qualifier: writes the snapshot as
fn upsertExpenses(txns: array, mapping: record, dryRun?: boolean, syncScope?: string)
Check-then-create expenses for a BankTxn[] (dedup on custom_value1).
ArgumentTypeDescription
txnsarrayBankTxn[] (§6)
mappingrecordvendor/category/currency mapping tables (§7.3)
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key (e.g. 'mercury-<accountId>' | 'ynab'). Names the
fn upsertInvoices(invoices: array, dryRun?: boolean, syncScope?: string)
Check-then-create invoices for an InvoiceInput[] (dedup on `number`).
ArgumentTypeDescription
invoicesarrayInvoiceInput[]: {number (dedup key), clientId, lineItems:[{type:
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key; names invoiceSync--<scope> /
fn applyPayments(payments: array, dryRun?: boolean, syncScope?: string)
Recognize received payments against existing invoices for a
ArgumentTypeDescription
paymentsarrayPaymentInput[]: {clientId (hashid), date:'YYYY-MM-DD',
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key; names paymentSync--<scope> /
fn markSent(invoices: array, dryRun?: boolean, syncScope?: string)
Transition Draft invoices (status_id 1) to Sent (status_id 2) via IN's
ArgumentTypeDescription
invoicesarrayTargets: [{invoiceId? | invoiceNumber?}] — each resolves to ONE live
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) -> preview only.
syncScope?stringCaller scope key; names markSentSync--<scope> /
fn invoiceTasks(number: string, clientId: string, taskIds: array, date?: string, dueDate?: string, poNumber?: string, publicNotes?: string, privateNotes?: string, currencyId?: string, status?: enum, rateOverride?: number, dryRun?: boolean, syncScope?: string)
Create ONE draft invoice from a set of IN tasks the way IN's UI does:
ArgumentTypeDescription
numberstringStable dedup key (the next sequential IN invoice number, e.g. 0073);
clientIdstringIN client hashid; charset-validated.
taskIdsarray>=1 IN task hashids to invoice (explicit — nothing over-included by a
date?string
dueDate?string
poNumber?string
publicNotes?string
privateNotes?string
currencyId?string
status?enumDefault 'draft'. 'sent' -> status_id 2 but NEVER emails either way.
rateOverride?numberOptional flat rate for every line; else each task's own rate.
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) -> preview only.
syncScope?stringCaller scope key; names invoiceTasksSync--<scope> /
fn upsertClients(clients: array, dryRun?: boolean, syncScope?: string)
Check-then-create clients for a ClientInput[] (dedup on `number`).
ArgumentTypeDescription
clientsarrayClientInput[]: {number (dedup key), name, idNumber?, contacts:[{
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key; names clientSync--<scope> /
fn upsertProjects(projects: array, dryRun?: boolean, syncScope?: string)
Check-then-create projects for a ProjectInput[] (dedup on `number`).
ArgumentTypeDescription
projectsarrayProjectInput[]: {number (dedup key), name, clientId, taskRate?,
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key; names projectSync--<scope> /
fn reconcileCompanySettings(desired?: object, companyId?: string, dryRun?: boolean, syncScope?: string)
Declarative, dry-by-default reconcile of company settings. Diffs a
ArgumentTypeDescription
desired?objectDesired company settings. Only keys PRESENT are reconciled; an absent
companyId?stringOptional; auto-resolves via GET /companies when absent. Charset-gated.
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key; names companySettingsSync--<scope> /
fn listTasks(clientId?: string, projectId?: string)
GET /tasks (paginated, include=client,project,status; optional
ArgumentTypeDescription
clientId?string
projectId?string
fn getTask(id: string)
GET /tasks/{id}?include=client,project,status → normalized task row
ArgumentTypeDescription
idstring
fn upsertTasks(tasks: array, dryRun?: boolean, syncScope?: string)
Check-then-create tasks for a TaskInput[] (dedup on `custom_value1`;
ArgumentTypeDescription
tasksarrayTaskInput[]: {number (dedup key), description, clientId?,
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
syncScope?stringCaller scope key; names taskSync--<scope> / taskSyncErrors--<scope>
fn upsertVendors(txns: array, dryRun?: boolean)
Auto-create IN vendors for the distinct counterparties of candidate
ArgumentTypeDescription
txnsarrayBankTxn[] (§6)
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
fn upsertExpenseCategories(txns: array, dryRun?: boolean, fallbackName?: string)
Auto-create IN expense categories for the distinct rawCategories of
ArgumentTypeDescription
txnsarrayBankTxn[] (§6)
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true).
fallbackName?stringFallback category name always ensured; default 'Uncategorized'.
fn buildMapping(currencyId: string, fallbackCategoryName?: string, dryRun?: boolean)
PURE derive (no IN fetch): read the latest vendors + expenseCategories
ArgumentTypeDescription
currencyIdstringIN currency hashid (USD-only v1, §12.4).
fallbackCategoryName?stringCategory whose id becomes fallbackCategoryId; default 'Uncategorized'.
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true). Gates the §7.6
fn finopsDigest(format?: enum)
Render the daily dry-run into swamp-owned views: summary/detailed
ArgumentTypeDescription
format?enumWhich view(s) to write. 'all' (default) writes all three:
fn archiveExpenses(ids: array, action?: enum, dryRun?: boolean)
Archive (default, reversible) or delete expenses by hashid via
ArgumentTypeDescription
idsarrayExpense hashids to act on.
action?enumarchive (reversible) or delete (permanent).
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) → preview only.
fn archiveTasks(ids: array, action?: enum, dryRun?: boolean)
Archive (default, reversible) or delete tasks by hashid via
ArgumentTypeDescription
idsarrayTask hashids (t.id) to act on.
action?enumarchive (reversible) or delete (permanent).
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) → preview only.
fn archiveClients(ids: array, action?: enum, dryRun?: boolean)
Archive (default, reversible) or delete clients by hashid via
ArgumentTypeDescription
idsarrayClient hashids (c.id) to act on.
action?enumarchive (reversible) or delete (permanent, cascades to children).
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) → preview only.
fn mergeExpenseCategories(merges?: array, deleteIds?: array, deleteEmptied?: boolean, fallbackName?: string, fallbackId?: string, dryRun?: boolean)
Consolidate redundant expense categories: reassign every non-deleted
ArgumentTypeDescription
merges?arrayMerge groups: reassign fromIds → toId. Default [].
deleteIds?arrayCategories to archive outright once they hold zero live expenses.
deleteEmptied?booleanAfter a group's reassignments, also archive the emptied fromIds.
fallbackName?stringThe buildMapping-ensured fallback category NAME — its id is guarded
fallbackId?stringExplicit hashid of the protected fallback category — bypasses the
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) → preview, no writes.
fn startTask(id: string, dryRun?: boolean)
Start a task's timer: GET /tasks/{id}, and if no open time_log pair
ArgumentTypeDescription
idstring
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) → no PUT.
fn stopTask(id: string, dryRun?: boolean)
Stop a task's timer: GET /tasks/{id}, and if an open time_log pair
ArgumentTypeDescription
idstring
dryRun?booleanOverrides cfg.dryRun; default cfg.dryRun (true) → no PUT.

Resources

invoices(infinite)— An invoice list snapshot (§4.3)
invoice(infinite)— A single detailed invoice (`/invoices/{id}`)
payments(infinite)— A payment list snapshot (§4.3)
expenses(infinite)— An expense list snapshot (§4.3; carries custom_value1)
expenseCategories(infinite)— Expense-category mapping list (`/expense_categories`)
vendors(infinite)— Vendor mapping list (`/vendors`)
expenseSync(infinite)— One row per txn processed by upsertExpense (§4.4)
expenseSyncErrors(infinite)— Durable audit of failed write attempts (additive, WSPEC-04) — one row
invoiceSync(infinite)— One row per invoice processed by upsertInvoice (invoice-write path);
invoiceSyncErrors(infinite)— Durable audit of failed/rejected invoice writes — one row per invoice
paymentSync(infinite)— One row per payment processed by applyPayment (payment-apply path);
paymentSyncErrors(infinite)— Durable audit of failed/rejected payment applications — one row per
markSentSync(infinite)— One row per invoice processed by markSent (mark-sent path); a Draft
markSentSyncErrors(infinite)— Durable audit of failed/rejected mark-sent transitions — one row per
invoiceTasksSync(infinite)— One row per invoice processed by invoiceTasks (invoice-from-tasks
invoiceTasksSyncErrors(infinite)— Durable audit of failed/rejected invoice-from-tasks writes — one row
clients(1h)— By-id client snapshot (getClients, §1) — deliberately short-lived +
clientList(1h)— Lean client discovery snapshot (listClients, §1) — id/number/name
clientDomains(1h)— Client email-DOMAIN snapshot (clientDomains, §7) — the deduped SET of
clientSync(infinite)— One row per client processed by upsertClient (client-write path); dedup
clientSyncErrors(infinite)— Durable audit of failed/rejected client writes — one row per client
projects(infinite)— A project list snapshot (§2 entity-crud; low-PII — infinite/GC-20).
project(infinite)— A single detailed project (`/projects/{id}`).
projectSync(infinite)— One row per project processed by upsertProject (§2); dedup on `number`.
projectSyncErrors(infinite)— Durable audit of failed/rejected project writes (§2) — flushed even
companySettings(infinite)— A normalized company-settings snapshot (getCompanySettings): custom-
companySettingsSync(infinite)— Per-field reconcile audit (reconcileCompanySettings): one row per diffed
companySettingsSyncErrors(infinite)— Durable audit of a failed company-settings reconcile (resolve/GET/PUT
tasks(infinite)— A task list snapshot (§3; timeLog + derived isRunning)
task(infinite)— A single normalized task (`/tasks/{id}`); same row shape as the list.
taskSync(infinite)— One row per task processed by upsertTask (task-write path); dedup on
taskSyncErrors(infinite)— Durable audit of failed/rejected task writes — one row per task whose
vendorSync(infinite)— One row per distinct vendor processed by upsertVendors (§7.4).
vendorSyncErrors(infinite)— Durable audit of failed vendor creates (§7.4) — flushed even when empty.
categorySync(infinite)— One row per distinct category processed by upsertExpenseCategories (§7.4).
categorySyncErrors(infinite)— Durable audit of failed category creates (§7.4) — flushed even when empty.
categoryMerge(infinite)— Durable audit of a mergeExpenseCategories run (reassignments, archived
mapping(infinite)— The derived Mapping (§7.3) emitted by buildMapping; the sync consumes it
finopsDigest(infinite)— A rendered markdown view (summary|detailed) of the daily dry-run, written
taskTimer(infinite)— Outcome of a startTask/stopTask timer flip (task time_log action, §4).

Files

finopsHtml(text/html)— Email-ready, self-contained HTML view of the daily dry-run, written by
03Reports6
@shrug/invoiceninja/ar-agingmodel
ar_aging.ts

Outstanding invoice balance bucketed by age past due (current/1-30/31-60/61-90/90+), per client and total

invoiceninjaaccountingar
@shrug/invoiceninja/expense-summarymodel
expense_summary.ts

Expenses grouped by category and vendor over a period, plus billable-but-uninvoiced backlog

invoiceninjaaccountingexpenses
@shrug/invoiceninja/finops-reviewmodel
finops_review.ts

Daily dry-run projection: per source (Mercury/YNAB), which sync candidates are new (need review) vs already synced

invoiceninjafinopsexpenses
@shrug/invoiceninja/reconcileworkflow
reconcile.ts

Gap / mismatch / duplicate report: source posted debits vs expenses recorded in Invoice Ninja — the 'am I caught up?' answer

invoiceninjaaccountingreconcile
@shrug/invoiceninja/snapshotmodel
snapshot.ts

Materialize live Invoice Ninja state (clients/projects/invoices over the declared scope) into canonical, committable *.state.yaml plus a round-trippable desired subset

invoiceninjasnapshot-syncmaterialize
@shrug/invoiceninja/snapshot-driftworkflow
snapshot_drift.ts

Classify each managed Invoice Ninja entity as in-sync / would-create / in-only / diverged by joining the tracked desired scope (upsert dryRun rows) against the observed read snapshots

invoiceninjasnapshot-syncdrift
04Previous Versions5
2026.10.27.1

Modified 1 behaviors

2026.09.22.1

Modified 1 behaviors. Added 3 reports

2026.07.22.1
2026.07.21.1
2026.07.19.2
05Stats
A
93 / 100
Downloads
37
Archive size
168.2 KB
  • Has README or module doc2/2earned
  • README has a code example1/1earned
  • README is substantive1/1earned
  • Most symbols documented1/1earned
  • No slow types (deprecated)1/1earned
  • Dependencies pass trust audit2/2earned
  • Docs keep secrets in a vault1/1earned
  • Has description1/1earned
  • Platform support declared (or universal)2/2earned
  • License1/2partial

    no SPDX license declared

  • Verified public repository2/2earned
06Platforms
07Labels