Repository navigation
Releases: jakethehoffer/ledgerly
Release list
v0.18.1: clearer description for developers
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
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 listexport 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
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-sqlite3to^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 updatebetter-sqlite3to^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
Storageimplementations now requireinboxand
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>/starthad 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.startis now
gated behindLEDGERLY_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 astatenonce
that an authenticatedstarton 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/oauthlists the
connected QBO / Xero companies (provider, tenant id, token expiry, scope —
never the token values) andDELETE /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
existingpersistCreditReversal. Both bundled backends implement it. Custom
Storageimplementations 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
Added
- ledgerly is published to npm —
pnpm add ledgerly/
npm install ledgerlynow 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.ymlnow publishes the npm
package on everyv*tag push, alongside the existing GHCR image
job. It runsnpm 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_dispatchimage re-run skips it. package.jsonmetadata for a complete npm listing —license
(Apache-2.0),repository,homepage,bugs,keywords,
author— plusCHANGELOG.mdin the publishedfilesand a
prepublishOnlygate (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
binpath 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
Added
-
docker-compose.ymlfor running ledgerly locally without
installing Node. Singleledgerlyservice using the published GHCR
image, a named volume (ledgerly-data) for SQLite persistence, and
env_file: .envfor configuration. The README's new "Docker Compose
(local dev)" subsection documents the two-terminal workflow: run
stripe listenon the host to mint a webhook signing secret, paste
it into.env, thendocker compose up.No
stripe-clisidecar by design —stripe listenmints 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.
- entrypoint wait script for more complexity than it earns. The
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.fetchfallback when nofetchis configured, the
custom-apiBaseforwarding path, and theString(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
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-looking1000 debit / 1010 credittransfer 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'sexpand.tsnow requests
expand: ['destination']on payout events; bothpayoutPaidand
payoutFailedcomparedestination.currencytopayout.currencyand
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_amountsemantics, 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.tswith
detectCrossCurrencyPayout(returns the destination currency on
mismatch, null otherwise) andrejectCrossCurrencyPayout(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.tscovering the detection
logic across all 5 input shapes (string destination, null destination,
same-currency object, mismatched currency, object missing currency)
plus integration tests assertingmapEventthrows on cross-currency
payout.paid/payout.failedand 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
Added
-
fxContextis now populated consistently across all four
FX-affected handlers, not justinvoicePaymentSucceeded. The field
was introduced in v0.1.9 but only on invoice payments; this release
closes the consistency hole by populating it onchargeSucceeded,
chargeRefunded, anddisputeFundsWithdrawnas well.Per-handler semantics:
Handler customerAmountsettlementAmountchargeSucceededcharge.amountbt.amountchargeRefundedrefund.amount(per refund)` disputeFundsWithdrawndispute.amountactualClawbackinvoicePaymentSucceededinvoice.amount_paid(+ pro-rated per schedule entry)bt.amount(+ pro-rated)Note on dispute design:
fxContext.settlementAmountis the
actual clawback at dispute time, not theexpectedClawback
computed at the original-charge rate.fxContextdescribes 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.tshouses the sharedbuildFxContext+
withFxhelpers. Previously inlined ininvoicePaymentSucceeded;
now imported by all four FX-aware handlers. -
New fixture
charge_succeeded_fxexercises the chargeSucceeded FX
path end-to-end: USD-50 charge converted to CAD at rate 1.375 →
CAD 68.75 entry withfxContext { USD 5000 / CAD 6875 }. Plus QBO
and Xero exporter goldens.
Changed
- Existing FX fixtures'
expected.jsonregenerated to include the
newfxContextfield:charge_refunded_fx,
dispute_funds_withdrawn_fx,dispute_funds_withdrawn_fx_rate_drift.
Exporter (.qbo.json/.xero.json) goldens unchanged — the
exporters ignorefxContext; 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
Added
-
JournalEntry.fxContext(optional). When the source event involved
an FX conversion (charge.currency ≠ bt.currency), entries now carry
a structuredFxContextfield 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
invoicePaymentSucceededon 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.
- Cash entry: full conversion (
-
New fixture
invoice_payment_succeeded_annual_fxand its QBO + Xero
cash and.schedulegoldens. 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 130plus
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
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, thechargeDisputeFundsWithdrawnhandler routes the
rate-movement delta to account7000 FX Gain/Loss— same accounting
model as the refund path shipped in v0.1.7:1200 Disputes Receivabledebits the original-rate settlement
amount (dispute.amount × originalRate), so it cleanly releases the
receivable the originalchargeSucceededbooked.1010 Stripe Clearingcredits the dispute-time-rate settlement
that Stripe actually clawed back (|clawbackBt.amount| + feeTotal).6100 Payment Disputesdebits the dispute fee at the dispute rate.7000 FX Gain/Lossabsorbs 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_driftfixture 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.tsnow requestscharge.balance_transactionon every
dispute event (added to the existingbalance_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_driftplus 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
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. ThechargeRefundedhandler now reads
the original charge'sbalance_transaction(already expanded by the
receiver'sexpand.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 Issueddebits the original-rate settlement
amount, so it cleanly offsets the original revenue booking (revenue
nets to zero on a full refund).2000 Sales Tax Payabledebits the proportional tax portion at the
same original-rate basis.1010 Stripe Clearingcredits the actual settlement (refund-rate)
that Stripe clawed back.7000 FX Gain/Lossabsorbs 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_fxfixture 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_fxplus 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).