Repository navigation
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.