Skip to content

Releases: jakethehoffer/ledgerly

v0.18.1: clearer description for developers

Choose a tag to compare

@jakethehoffer jakethehoffer released this 08 Oct 05:22

No code changes. The npm description, keywords and README opening now speak to developers writing their own Stripe to QuickBooks or Xero code, and name the cases the engine and the receiver already handle. Install with npx ledgerly@0.18.1 or docker pull ghcr.io/jakethehoffer/ledgerly:v0.18.1.

v0.18.0: preview your own recent Stripe activity

Choose a tag to compare

@jakethehoffer jakethehoffer released this 08 Oct 04:02

Added

  • ledgerly --stripe [--days N] previews your own recent Stripe activity. It
    lists the last 1 to 30 days of events Ledgerly maps, fetches the nested
    objects each one needs with the same expansion the receiver uses, and prints
    the entries with a summary. It only reads, stores nothing, and sends nothing
    to QuickBooks or Xero. Use a restricted key with Read access in
    STRIPE_SECRET_KEY. One event that cannot be mapped is reported and the rest
    continue. Each event is mapped on its own, so later annual plan changes and
    refunds can differ from the receiver, which uses saved history.

Changed

  • Piping a batch of events now skips event types Ledgerly does not map and
    lists them, instead of stopping at the first one. A single unmapped event is
    still reported as an error.
  • The readable table shows a currency code for amounts that are not in US
    dollars, instead of a $ sign on every amount.
  • The README no longer describes a stripe events list export as a way to
    backfill older history. Stripe only lists the last 30 days of events.

Install: npx ledgerly@0.18.0, or docker pull ghcr.io/jakethehoffer/ledgerly:v0.18.0.

Checked after publishing: the README sample and --stripe preview ran from empty folders against the published npm package, in bash and PowerShell. The preview was checked on a Stripe test-mode account with one test payment and one partial refund. The Docker image was built and published by the release workflow. Its receiver code is unchanged in this release, and it was not separately run.

v0.17.0: payment, refund and receiver reliability fixes

Choose a tag to compare

@jakethehoffer jakethehoffer released this 19 Sep 21:38

Download status

Release complete, verified September 21, 2026. npm install ledgerly now resolves to 0.17.0. Docker ghcr.io/jakethehoffer/ledgerly:v0.17.0 is published for linux/amd64 and linux/arm64. The actual npm download and downloaded amd64 Docker image passed the checks below.

Verification

  • 912 tests, type checking, lint and build passed on Linux Node22 and24. The npm release gate passed again on Node24, and release run 35470570202 attempt 3 completed successfully.
  • Clean npm consumer install: public import, balanced payment, annual schedule, mapping launcher, and refusal of missing or incomplete receiver setup passed.
  • Downloaded npm receiver: early refund survived a process restart, protected retry succeeded after its invoice arrived, duplicate refund and invoice charge notices were counted once, and remaining deferral was 30000 cents. Health, readiness and metrics returned 200; invalid signatures and unauthorized admin calls were rejected.
  • npm registry signatures and attestations passed verification. Package provenance names the existing v0.17.0 tag at commit b1e2650.
  • Downloaded amd64 image: early refund retained across a process restart, protected retry succeeds after its invoice arrives, repeat refund and invoice charge notices do not duplicate entries, remaining deferral is 30000 cents.
  • Synthetic database made with SQLite binding11 retained its record under binding13, passed integrity checking and held an uncertain old send for review.
  • Actual server launcher, health/readiness/metrics and mapping launcher work. Build attestation verified against commit b1e2650.
  • Both architectures built and published. The arm64 download was pulled, but this verification host cannot execute ARM binaries, so arm64 runtime behavior was not checked here.
  • No live payment or accounting accounts were used. These checks do not prove every accounting case or close issue2's remaining test gap.

The release includes the payment, refund, retry, and OAuth fixes below, plus the SQLite binding update needed for current Node.

Fixed

  • Upgrade better-sqlite3 to ^13.0.3, whose N-API implementation avoids the
    native statement cleanup crash seen with the old binding on current Node 24.
    Closing test databases alone did not resolve the crash during publication.

Upgrade notes

  • Node 22 or newer is now required. The Docker image includes Node 24. Direct
    receiver installations must also update better-sqlite3 to ^13.0.3.
    This new minimum is a pre-1.0 breaking change, so it uses a minor release.
  • Includes all payment, refund, retry, and OAuth fixes listed under 0.16.0.
    Follow those database backup and reconciliation notes when upgrading from
    0.15.1 or earlier. Existing SQLite data is retained.
  • Supersedes the partial 0.16.0 release: its Docker image was published, but
    npm publication stopped at the native test crash. Existing tags are preserved.

Included reliability and security changes

[0.16.0] — 2026-09-19

Upgrade notes

  • Back up SQLite before upgrading. The first startup holds previously attempted,
    unposted sends whose outcome is uncertain. Reconcile those rows with the
    accounting provider before releasing or recreating them. This release does
    not repair entries already sent by older versions.
  • Custom Storage implementations now require inbox and
    persistRefundReversal. Use a bundled backend or implement the updated
    contract before upgrading. This is a pre-1.0 interface change.
  • Configure the accounting dispatcher before enabling the scheduler. There is
    no automatic console fallback. The receiver can collect events with
    LEDGERLY_SCHEDULER_ENABLED=false. OAuth setup also requires an admin token.
  • Failed verified webhook payloads remain in SQLite until successfully handled.
    Protect that database and its backups as payment data. Refunds of deferred
    invoices from before Ledgerly require reconciled opening balances and
    recognition history before retry.

Fixed

  • Keep deferred-invoice refunds retryable until their invoice schedule exists. Out-of-order refunds no longer reverse unearned revenue or leave the full schedule to be recognized later. Applies to both bundled storage backends and the legacy deduplicator path.
  • Count invoice payments once across charge and invoice notices, and deduplicate refunds by refund ID in every bundled storage path. Preserve FX context when reducing recognition schedules.
  • Retain failed signed webhooks for protected operator retry. Contain storage errors and keep unreadable scheduled rows for repair without blocking healthy rows.
  • Fetch complete invoice line lists and retrieve newer invoice payloads in the supported schema. Keep unsettled refunds pending instead of prematurely booking them.
  • Stop incomplete accounting setup before dispatch. Console-only posting now requires explicit opt-in, and the sample configuration starts with the scheduler disabled.
  • Hold uncertain pre-upgrade dispatch attempts for manual reconciliation instead of automatically replaying them with new request identities.
  • Give QBO sends stable request IDs and check Xero narration markers before retries, including retries after Xero's short idempotency window. Reject Xero responses that do not confirm a saved journal.

Security

  • Only the operator can connect an accounting company now.
    GET /oauth/<provider>/start had no authentication. Anyone who found the URL
    could complete consent with their own QuickBooks or Xero company: connecting
    after the operator added a second token row, which stops every dispatch, and
    connecting first made their company the only row, so the receiver would post
    the operator's journal entries — amounts and memos carrying Stripe customer,
    subscription and charge references — into a stranger's books. start is now
    gated behind LEDGERLY_ADMIN_TOKEN, and is not mounted at all when that token
    is unset. The callback cannot be gated the same way (it arrives as a redirect
    from the provider, not from the operator), so it now requires a state nonce
    that an authenticated start on this process issued, and accepts each one only
    once — a leaked or replayed consent link no longer works.

Added

  • Operator endpoints for connected companies. GET /admin/oauth lists the
    connected QBO / Xero companies (provider, tenant id, token expiry, scope —
    never the token values) and DELETE /admin/oauth/<provider>/<tenantId> removes
    one. Before this there was no supported way to clear a token row, so two rows
    for one provider stopped dispatch with no recovery short of editing storage by
    hand. The "multiple token rows" error now names these endpoints.

  • Storage.persistRefundReversal — the atomic read + deferred-first split +
    cancel + re-spread used by the refund reconciliation below, alongside the
    existing persistCreditReversal. Both bundled backends implement it. Custom
    Storage implementations must add it (pre-1.0 interface change).

Changed

  • The server CLI now exits at startup if OAuth client config is set without
    LEDGERLY_ADMIN_TOKEN
    , rather than starting with a connect flow that cannot
    be reached — the same treatment the CLI already gives other partial OAuth
    configuration. Upgrade impact: a deployment currently running OAuth client
    config with no admin token will not boot until you set one (openssl rand -base64 48), or drop the OAuth client variables and use the static-token
    dispatchers. Already-stored tokens are untouched and keep dispatching. That
    deployment is also the one exposed to the issue above, so setting the token is
    the fix either way.
  • README correction: connecting a different QBO realm or Xero org does not
    overwrite the existing token row. It adds a second row, which stops dispatch
    until one is removed.

Fixed

  • Refunding a deferred-schedule invoice no longer strands a phantom deferred
    liability.
    A cash refund against an invoice that deferred revenue was booked
    entirely to 4900 Refunds Issued, reversing revenue the ledger had never
    recognized. 2100 kept the refunded amount forever and net revenue went
    negative — refunding the unrecognized $900 of a $1,200 annual plan three months
    in left 2100 at $900 and revenue at −$600 on $300 of delivered service. The
    bundled receiver now reconciles such a refund against the schedule: it repays
    the still-deferred balance first (Dr 2100), posts to 4900 only the excess over
    all remaining deferred, and re-spreads whatever stays deferred across the
    remaining months. The cash leg, the proportional tax drain, realized FX, and the
    cumulative basis across a multi-refund charge are unchanged, so refunds of
    non-deferred invoices book exactly as before. A refund after a subscription
    cancellation now also closes out the held schedule instead of leaving it
    stranded. The pure engine keeps its stateless 4900 behaviour and its documented
    limitation.

  • A scheduled-entry race can no longer stop the webhook receiver. If a
    refund or other reconciliation changes a due row just before dispatch starts,
    the scheduler now contains that row's error and continues the batch. Temporary
    storage read failures are logged and retried on the next tick instead of
    escaping as an unhandled promise rejection.

v0.1.13 — automated npm publishing

Choose a tag to compare

@jakethehoffer jakethehoffer released this 22 May 20:09

Added

  • ledgerly is published to npm — pnpm add ledgerly /
    npm install ledgerly now installs the engine and server. (v0.1.12
    was the first release, hand-published; from v0.1.13 on, publishing
    is automated — see below.)
  • Automated npm publishing. release.yml now publishes the npm
    package on every v* tag push, alongside the existing GHCR image
    job. It runs npm publish --provenance, so the package carries a
    Sigstore-signed build-provenance attestation — the same supply-chain
    guarantee the container images already have. The npm job is
    push-only: npm rejects republishing an existing version, so a
    workflow_dispatch image re-run skips it.
  • package.json metadata for a complete npm listing — license
    (Apache-2.0), repository, homepage, bugs, keywords,
    author — plus CHANGELOG.md in the published files and a
    prepublishOnly gate (typecheck + lint + test + build) so a broken
    or stale build can't be published.
  • docs/cross-currency-payouts.md — design notes for the deferred
    cross-currency payout accounting: the analysis, the recommended
    journal-entry shape, and the exact Stripe payload to capture when
    reporting one.

Fixed

  • bin path no longer carries a ./ prefix
    ("ledgerly-server": "dist/server/cli.js") — npm flagged the
    prefixed form at publish time. The published v0.1.12 bin still works
    (npm auto-corrected it); v0.1.13 onward publishes warning-free.

Changed

  • README Quick start restructured to lead with docker pull (the
    install path that always works) and to reflect the now-published
    npm package.

v0.1.12 — docker-compose + dispatcher coverage

Choose a tag to compare

@jakethehoffer jakethehoffer released this 20 May 04:09

Added

  • docker-compose.yml for running ledgerly locally without
    installing Node. Single ledgerly service using the published GHCR
    image, a named volume (ledgerly-data) for SQLite persistence, and
    env_file: .env for configuration. The README's new "Docker Compose
    (local dev)" subsection documents the two-terminal workflow: run
    stripe listen on the host to mint a webhook signing secret, paste
    it into .env, then docker compose up.

    No stripe-cli sidecar by design — stripe listen mints a fresh
    signing secret per session, which ledgerly needs at boot to verify
    signatures; bridging them inside Compose would need a shared volume

    • entrypoint wait script for more complexity than it earns. The
      compose file's header comment and the README both explain this so
      the next reader doesn't retry that approach.

Changed

  • Test coverage on the managed dispatchers (managedQbo,
    managedXero) raised from 80% to 100% branch coverage. Three tests
    per file now exercise the previously-untested branches: the
    globalThis.fetch fallback when no fetch is configured, the
    custom-apiBase forwarding path, and the String(err) coercion in
    the 401-retry catch when a non-Error value is thrown. No source
    changes — the branches were always reachable, just untested.
    Overall coverage: 93.23% statements, 88.94% branches.

v0.1.11 — cross-currency payouts detect-and-reject

Choose a tag to compare

@jakethehoffer jakethehoffer released this 19 May 20:11

Changed

  • Cross-currency payouts are now detected and rejected with a clear
    error
    instead of silently producing wrong journal entries. Previously,
    a CAD-settling account paying out to a USD bank account would produce
    a clean-looking 1000 debit / 1010 credit transfer in CAD that didn't
    account for Stripe's FX conversion fee or the destination amount. The
    journal balanced internally but didn't match the operator's actual
    books. The receiver's expand.ts now requests
    expand: ['destination'] on payout events; both payoutPaid and
    payoutFailed compare destination.currency to payout.currency and
    throw on mismatch with the actual currencies plus a pointer to open an
    issue with the BT shape so the case can be modeled against real data.

    This is not an implementation of cross-currency payouts — the
    Stripe BT shape (FX fee inline on the payout BT vs separate adjustment
    BT, destination_amount semantics, reverse-conversion behavior on
    payout.failed) isn't documented well enough to implement against
    without real payloads. Refusing loudly is the honest interim: operators
    hitting this know immediately that their setup isn't supported, can
    open an issue with their actual BT structure, and the implementation
    can follow against real data.

Added

  • New src/events/payouts/crossCurrency.ts with
    detectCrossCurrencyPayout (returns the destination currency on
    mismatch, null otherwise) and rejectCrossCurrencyPayout (throw-on-
    mismatch convenience wrapper). Both payout handlers call the rejector
    immediately after extracting the payout, before any accounting work.

  • New test/events/payouts/crossCurrency.spec.ts covering the detection
    logic across all 5 input shapes (string destination, null destination,
    same-currency object, mismatched currency, object missing currency)
    plus integration tests asserting mapEvent throws on cross-currency
    payout.paid / payout.failed and the actionable error message
    format. Sanity check: same-currency expanded destination still
    produces a normal 1000/1010 entry.

Compatibility

Existing payout_paid_standard and payout_failed_standard fixtures
use string destination IDs (predate this expansion). They pass
byte-identical because the detector returns null on string
destinations, falling through to the same accounting as v0.1.10.
The server.spec.ts test suite mocks stripe.payouts.retrieve at
module scope so the existing webhook-flow tests stay off the Stripe
network now that payout events get expanded.

v0.1.10 — fxContext consistency across all FX handlers

Choose a tag to compare

@jakethehoffer jakethehoffer released this 19 May 19:31

Added

  • fxContext is now populated consistently across all four
    FX-affected handlers
    , not just invoicePaymentSucceeded. The field
    was introduced in v0.1.9 but only on invoice payments; this release
    closes the consistency hole by populating it on chargeSucceeded,
    chargeRefunded, and disputeFundsWithdrawn as well.

    Per-handler semantics:

    Handler customerAmount settlementAmount
    chargeSucceeded charge.amount bt.amount
    chargeRefunded refund.amount (per refund) `
    disputeFundsWithdrawn dispute.amount actualClawback
    invoicePaymentSucceeded invoice.amount_paid (+ pro-rated per schedule entry) bt.amount (+ pro-rated)

    Note on dispute design: fxContext.settlementAmount is the
    actual clawback at dispute time, not the expectedClawback
    computed at the original-charge rate. fxContext describes the
    conversion that actually happened on the event; the realized FX
    gain/loss against the original is already captured by the 7000 line
    (when present). Two pieces of information, two slots.

  • New src/util/fxContext.ts houses the shared buildFxContext +
    withFx helpers. Previously inlined in invoicePaymentSucceeded;
    now imported by all four FX-aware handlers.

  • New fixture charge_succeeded_fx exercises the chargeSucceeded FX
    path end-to-end: USD-50 charge converted to CAD at rate 1.375 →
    CAD 68.75 entry with fxContext { USD 5000 / CAD 6875 }. Plus QBO
    and Xero exporter goldens.

Changed

  • Existing FX fixtures' expected.json regenerated to include the
    new fxContext field: charge_refunded_fx,
    dispute_funds_withdrawn_fx, dispute_funds_withdrawn_fx_rate_drift.
    Exporter (.qbo.json / .xero.json) goldens unchanged — the
    exporters ignore fxContext; it's engine-output metadata only.

Compatibility

Same-currency events on all four handlers continue to omit fxContext
from their JSON output (buildFxContext returns undefined, withFx
returns the entry untouched). Every existing same-currency fixture
passes byte-identical to v0.1.9.

v0.1.9 — JournalEntry.fxContext for FX recognition

Choose a tag to compare

@jakethehoffer jakethehoffer released this 19 May 19:00

Added

  • JournalEntry.fxContext (optional). When the source event involved
    an FX conversion (charge.currency ≠ bt.currency), entries now carry
    a structured FxContext field exposing both sides of the conversion:

    interface FxContext {
      readonly customerCurrency: string;     // uppercase ISO 4217
      readonly customerAmount: Cents;
      readonly settlementCurrency: string;
      readonly settlementAmount: Cents;
    }

    This is the engine's contribution to multi-period FX recognition.
    Ledgerly can't auto-compute month-by-month FX gain/loss on annual
    recognition schedules without external monthly rate lookups (Stripe
    only tells us the rate at the original charge moment). But it can —
    and now does — expose enough FX provenance on every recognition entry
    that a downstream tool with a home-currency rate source can compute
    the revaluation itself.

  • Populated by invoicePaymentSucceeded on FX invoices:

    • Cash entry: full conversion (customerAmount = invoice
      amount_paid, settlementAmount = bt.amount).
    • Each monthly recognition entry: PRO-RATED amounts so per-month
      customer/settlement totals across the 12-entry schedule sum to the
      invoice's preTax exactly. Month 12 absorbs both customer-side AND
      settlement-side rounding remainders.
  • New fixture invoice_payment_succeeded_annual_fx and its QBO + Xero
    cash and .schedule goldens. Scenario: USD-1200 invoice settled to
    CAD at rate 1.30, no tax. Cash entry: 1010 debit CAD 1513.20 / 6000 debit CAD 46.80 / 2100 credit CAD 1560.00. Each of the 12
    recognition entries: 2100 debit CAD 130 / 4000 credit CAD 130 plus
    fxContext { USD 10000 / CAD 13000 }.

Changed

  • README's currency caveats updated. Multi-period FX recognition moved
    from "spec-deferred" to "engine exposes the data; downstream computes
    the revaluation." Cross-currency payouts (Stripe converting between
    settlement currencies before bank delivery) remain deferred — they
    need real fixture data on Stripe's BT shape that ledgerly doesn't
    have.

Compatibility

fxContext is omitted entirely from JournalEntry JSON when undefined
(same-currency events). All existing same-currency fixtures —
charge_succeeded_, invoice_payment_succeeded_monthly / annual /
annual_with_tax / prorated_upgrade / prorated_downgrade / with_app_fee /
with_tax, the charge_refunded_
family, dispute_* — are byte-identical
to v0.1.8.

v0.1.8 — FX gain/loss on dispute withdrawals

Choose a tag to compare

@jakethehoffer jakethehoffer released this 19 May 05:02

Added

  • Realized FX gain/loss is now recognized on dispute withdrawals, not
    just refunds. When the FX rate moved between the original charge and
    the dispute, the chargeDisputeFundsWithdrawn handler routes the
    rate-movement delta to account 7000 FX Gain/Loss — same accounting
    model as the refund path shipped in v0.1.7:

    • 1200 Disputes Receivable debits the original-rate settlement
      amount (dispute.amount × originalRate), so it cleanly releases the
      receivable the original chargeSucceeded booked.
    • 1010 Stripe Clearing credits the dispute-time-rate settlement
      that Stripe actually clawed back (|clawbackBt.amount| + feeTotal).
    • 6100 Payment Disputes debits the dispute fee at the dispute rate.
    • 7000 FX Gain/Loss absorbs the difference — debit on rate move
      against the operator (realized loss), credit on rate move in their
      favor (realized gain).

    See the new dispute_funds_withdrawn_fx_rate_drift fixture for a
    worked example: USD-50 charge at CAD 1.30 (CAD 65 receivable),
    disputed later at CAD 1.40 (CAD 70 clawed back) + C$20 dispute fee →
    1200 debit 65, 6100 debit 20, 7000 debit 5, 1010 credit 90.

  • expand.ts now requests charge.balance_transaction on every
    dispute event (added to the existing balance_transactions
    expansion). The original charge's BT is what the handler needs to
    compute the rate Stripe used at charge time. Same one-line pattern
    that enabled the refund FX gain/loss work in v0.1.7.

  • New fixture dispute_funds_withdrawn_fx_rate_drift plus QBO and
    Xero exporter goldens.

Changed

  • README's currency caveats updated. Refunds and dispute
    withdrawals are now both 7000-aware. The remaining deferrals
    (multi-period recognition rate drift, cross-currency payouts) are
    documented with their genuine blockers — multi-period drift would
    need a home-currency config or month-by-month rate lookups;
    cross-currency payouts need real fixture data on Stripe's BT shape.

Compatibility

Same-currency dispute fixtures and the existing
dispute_funds_withdrawn_fx fixture (which has a string
dispute.charge) are byte-identical to v0.1.7. The handler's
graceful fallback — when charge.balance_transaction isn't expanded,
treat expectedClawback = actualClawback and skip the 7000 line —
preserves v0.1.6/v0.1.7 behavior for any caller bypassing expand.ts.
Same-currency disputes through expand.ts also produce no 7000 line
because their true rate is 1.0 and fxDelta = 0 arithmetically.

v0.1.7 — account 7000 FX gain/loss on refunds

Choose a tag to compare

@jakethehoffer jakethehoffer released this 19 May 02:52

Added

  • Account 7000 FX Gain/Loss is now actually posted to. Realized FX
    gain/loss is recognized on refunds when the FX rate moved between the
    original charge and the refund. The chargeRefunded handler now reads
    the original charge's balance_transaction (already expanded by the
    receiver's expand.ts), computes the rate Stripe used at charge time,
    and books each refund's three legs at the rates that match the
    underlying movement:

    • 4900 Refunds Issued debits the original-rate settlement
      amount, so it cleanly offsets the original revenue booking (revenue
      nets to zero on a full refund).
    • 2000 Sales Tax Payable debits the proportional tax portion at the
      same original-rate basis.
    • 1010 Stripe Clearing credits the actual settlement (refund-rate)
      that Stripe clawed back.
    • 7000 FX Gain/Loss absorbs the difference — debit when the rate
      moved against the operator (realized loss), credit when it moved in
      their favor (realized gain).

    See the charge_refunded_fx fixture for a worked example:
    $100 USD charge originally at rate 1.35 (CAD 135 booked), fully
    refunded later at rate 1.40 (CAD 140 clawed back), producing
    4900 debit CAD 135 / 7000 debit CAD 5 / 1010 credit CAD 140.

  • New fixture charge_refunded_fx plus QBO and Xero exporter goldens.

Changed

  • README's currency caveat replaced with a precise scope statement:
    refunds are recognized; multi-period recognition rate drift, disputes
    settled at a different rate than the original charge, and
    cross-currency payouts still post in BT settlement currency without
    7000 lines (those would require state across events or additional
    config).

Compatibility

Same-currency refunds are byte-identical to v0.1.6: when
charge.currency == bt.currency, both rates are exactly 1.0, the
expected and actual settlements match, and no 7000 line is emitted.
All 5 existing same-currency refund fixtures pass unchanged.

Callers bypassing expand.ts (where charge.balance_transaction is a
string ID rather than an expanded object) fall back to rate-1.0 and
skip the 7000 line — same-currency behavior unchanged; FX behavior in
that path just loses the FX recognition (not worse than v0.1.6).