Skip to content

Resources · Changelog

v6.1.5 · Xero + Stripe Connect hardening

Released 2026-04-22 · 13 changes · 3 contributors · ~9 minute read

Stablev6.1.5ShareRSS

Headline

Two correctness bugs that mattered, fixed properly.

v6.1.5 is a hardening release. It ships per-line tax types on Xero invoices (KARO-222 — Xero was silently double-charging GST on the GST line), a check-constraint that prevents cs_* values from ever sitting in a stripe_payment_intent_id column (KARO-387), and six smaller fixes. No new headline features, just things that should always have been true.

Section 1

Highlights

Two correctness issues drove this release. Both were caught by reconciliation rather than by alerts, which is the wrong order, and both are now load-bearing invariants enforced at the database layer.

  • Xero TaxType per line (KARO-222).Local invoice lines used to include a standalone “GST (15%)” row alongside the booking fee and Stripe fee. Sending all three to Xero with line-amount-type Exclusive + a per-row OUTPUT2 caused Xero to calculate 15% on top of the GST line itself — silently producing invoices ~15% too high. The fix drops the GST line, sets explicit TaxType per line, and back-fills the affected window.
  • pi_ vs cs_ check constraint (KARO-387).Several historical bugs had written a Checkout Session id (cs_*) into a column intended to hold a PaymentIntent id (pi_*). The success-page lookup and the webhook overwrite are semantically different, and the columns are now constrained at the database layer so the regression class can't come back.
Xero reconciliation diff — 12 invoices identified in the affected window, all back-filled to the correct totals.

Section 2

Billing

Two changes here, both worth understanding if you have custom downstream accounting integrations.

Stripe PaymentIntent column hygiene

We added two CHECK constraints — bookings_pi_not_checkout_session and invoices_pi_not_checkout_session — that reject any insert/update where stripe_payment_intent_id starts with the string cs_. The constraints are validated against existing rows; nothing in production was sitting in violation by the time we shipped this, thanks to the back-fill we ran in advance.

migration 20260514083734_pi_not_checkout_session.sql (excerpt)
sql
ALTER TABLE public.bookings
  ADD CONSTRAINT bookings_pi_not_checkout_session
  CHECK (stripe_payment_intent_id IS NULL
      OR stripe_payment_intent_id NOT LIKE 'cs\_%' ESCAPE '\');

ALTER TABLE public.invoices
  ADD CONSTRAINT invoices_pi_not_checkout_session
  CHECK (stripe_payment_intent_id IS NULL
      OR stripe_payment_intent_id NOT LIKE 'cs\_%' ESCAPE '\');

Operationally: if you have a Postgres-listening worker that mirrors these tables, and you were previously tolerating the wrong shape because “sometimes it's a cs and sometimes it's a pi” — you can drop that branch. The columns are now monomorphic.

Xero line-level TaxType

Per KARO-222, Xero invoices now ship with explicit TaxType per line. Booking fees get OUTPUT2 (standard 15% GST), Stripe fees get NONE(exempt; Stripe handles their own tax). The standalone “GST (15%)” line is gone. A defensive filter at the same call site (xero-client.ts:933) skips any legacy “GST (15%)” rows still sitting in the local DB.

Xero invoice — before/after, with the GST line removed and per-line TaxType visible in the detail expander.

Section 3

Integrations

Xero, Stripe Connect, and Resend all received small but meaningful upgrades.

  • Xero paid-invoice edit pattern documented. Xero silently refuses LineItem edits on paid invoices — the validation message is buried in a nested array and the request returns 200 even though nothing changed. We documented the 3-step dance (delete payment → edit invoice → re-apply payment) in our XeroClient wrapper, with a strict re-fetch + verify after every edit.
  • Stripe webhook dual-secret verification. The platform and Connect webhooks share a single API route. We now try the platform secret first, fall through to the Connect secret on failure, and tag each successful verification with the layer in Sentry. Confirmed paid bookings get a source tag of either platform_webhook or connect_webhook.
  • cron-xero-sync explicit terminal-state filter. The cron now explicitly skips local invoices in paid, void, or cancelled — sync direction for terminal states is Xero → local only. Code paths that flip a synced invoice to void must call XeroClient.voidInvoice() inline; the cron will not catch it later.
  • Resend bounce + complaint mirroring. Hard bounces and spam complaints now write a row into our local email_deliveries table. Reputation health is visible on the operator dashboard.
supabase/functions/_shared/xero-client.ts (excerpt)
ts
// Edit a paid invoice: 3-step dance.
async editPaidInvoice(invoiceId: string, newLines: LineItem[]) {
  const payment = await this.getPaymentForInvoice(invoiceId)
  await this.postPayment(payment.PaymentID, { Status: 'DELETED' })
  await this.postInvoice(invoiceId, { LineItems: newLines })
  await this.putPayment({
    Invoice: { InvoiceID: invoiceId },
    Account: payment.Account,
    Amount:  payment.Amount,
    Date:    payment.Date,
  })
  // ALWAYS re-fetch and verify — silent no-op is the failure mode.
  return this.getInvoiceAndVerify(invoiceId, newLines)
}

Section 4

Security

Three security-relevant changes; one tightens existing isolation, two are new guard rails.

  • Webhook signature redaction. When payload verification fails, the error message used to include the expected signature in the body. It now returns a stable 400 with no detail in the body, and the full context (with expected/received signatures) is logged to Sentry behind a sensitive-data processor.
  • Rate limit on cron callbacks. The scheduler self-heal cron is now rate-limited by the distributed Postgres limiter to 1 invocation per 5 minutes per tenant. Reduces the blast radius of a misconfigured cron schedule.
  • Audit log on webhook secret rotation. Rotating either STRIPE_WEBHOOK_SECRET or STRIPE_WEBHOOK_SECRET_CONNECT now writes a row to settings_audit_log with the actor + timestamp.

Section 5

Fixes & polish

  • verify_payment propagation into invoices — the booking-manager edge function used to leave the invoice row pointing at the Checkout Session id after payment confirmation. It now overwrites with the PaymentIntent id.
  • Stripe Connect onboarding skipped legal-entity step— operators who skipped the legal-entity step in Express used to see a 500 on return. They now land on a friendly “finish your onboarding” screen with a deep link back to Stripe.
  • Xero reference shape canonicalised — invoice references are now consistently LiteHQ.com #N [— <user-ref>] (per KARO-440). The KARO-248 operator-billing suffix was reverted; legacy LiteHQ #N references are still matched for dedup back-compat.
  • Xero void path — local invoice → void now also calls XeroClient.voidInvoice() inline. Affects the checkout.session.expired webhook handler.
  • Invoice PDF currency symbol— invoices issued in AUD used to show NZ$ on the PDF when the tenant was in NZ. The PDF now respects the invoice's own currency, not the tenant's locale.
  • Membership-cancel two-column contract — fixed two read sites (host dashboard and member portal) that were checking memberships.statusonly, and missing memberships in the “soft cancelled” window (status active, end_date set in the past).

Section 6

Migration notes

Two notes, both for integrators with direct DB access.

1. CHECK constraints on pi_ columns

Migration 20260514083734 adds CHECK constraints on bookings.stripe_payment_intent_id and invoices.stripe_payment_intent_id rejecting cs_* values. If you write directly to these tables, ensure you have the right field — Checkout Session ids belong in stripe_checkout_session_id, not here.

2. Xero invoice reference back-fill

A new edge action, backfill_reference, exists on the XeroClient wrapper to canonicalise legacy references. We will not run this across your tenant without consent — invoke it from your operator dashboard, or ask support. Background: LiteHQ #N is the older shape from pre-2025; LiteHQ.com #N [— <user-ref>] is the canonical shape going forward.

$ litehq xero
bash
litehq xero backfill-reference --tenant north-quay --dry-run
                              ──────────────────────────────
    Would rewrite 247 invoice references.
    Pass without --dry-run to apply.

Xero gotcha: editing LineItems on a paid Xero invoice silently no-ops. The XeroClient wrapper always re-fetches and verifies after edits. If you wrap your own Xero calls, follow the same pattern — and never trust a 200 alone.

Found this useful?

Share the release notes with your team.

One short link per release, hosted publicly. No login wall.