Changelog
What shipped, when. One PR per milestone.
- M67
Enterprise pack — CAPTCHA, passkeys, per-org 2FA, verified-domain auto-join, SSO
featureJul 7, 2026M67 adds the five sign-in-security features enterprise buyers ask for first. Every one is opt-in and inert until configured — the kit ships and deploys cold with no new required env, and each feature stays dark until an operator sets an env or an org admin turns it on. Nothing here changes behavior for an existing install until you enable it. Two migrations (
0075,0076) carry the schema; no new cron or background job was added (the worker inventory is unchanged at 22 crons + 10 jobs).-
Bot protection (Cloudflare Turnstile) — set
TURNSTILE_SECRET_KEY+NEXT_PUBLIC_TURNSTILE_SITE_KEYand the BetterAuthcaptchaplugin gates/sign-up/email,/sign-in/email, and/request-password-reset(fail-closed once the secret is set). With either env unset there is no widget and no verification — the auth forms behave exactly as before. The secret is server-only; the site key is public. -
Passkeys / WebAuthn — phishing-resistant, per-user, web-only sign-in. Gated by
SAASKIT_PASSKEYS_ENABLED(loads only when exactly"1"; the kit ships it enabled). Users manage credentials under Account → Passkeys; the login page offers a passkey affordance. rpID/origin derive fromBETTER_AUTH_URL. Desktop/Tauri passkeys are deferred (the webview origin differs) — follow-up #295. -
Per-org two-factor enforcement — an org admin can require every member to have 2FA from Settings → Security. BetterAuth has no built-in per-org policy, so this is a custom gate stored on
organization_security_policy.require_two_factor: a member without 2FA is redirected to/account/two-factorby the org shell (reads) and hard- blocked at the server-action mutation chokepoint (writes). An org that never sets the toggle is unenforced. -
Verified-domain auto-join — an org proves it owns an email domain via a DNS-TXT record (
<prefix>=<token>, prefix fromSAASKIT_DOMAIN_VERIFICATION_TXT_PREFIX), and thereafter a new user who verifies an email on that domain auto-joins the org (subject to the org's member quota). Claiming, verifying, and the auto-join are all audited. Until a domain is claimed and verified, nothing auto-joins. -
Single sign-on (SAML + OIDC), with optional require-SSO — gated by
SAASKIT_SSO_ENABLED(ON by default,=0removes the plugin and every/api/auth/sso/*endpoint). It is inert until an org admin registers an IdP for a domain it has verified (the DNS-TXT proof above is the prerequisite). OIDC discovers endpoints from the issuer's well-known document; SAML uses the plugin's pure-JS schema validator (no native/Java toolchain). Turning on require-SSO blocks every non-SSO session vector (password, magic-link, social, passkey) for that domain — a self- identifying operator impersonation session is still allowed. Note the documented residual: require-SSO gates session creation, so an already-signed-in user keeps their existing session until it expires. Removing the last provider while require-SSO is on is a known self-lock — follow-up #296.
Operators get a read-only org security panel at
/admin/orgs/<orgId>showing each org's 2FA/require-SSO posture, verified domain, member-2FA coverage, and registered providers (protocol + issuer + domain only — provider secrets and certs never leave the server). See the admin runbook's Enterprise pack section for the enable-and-register walkthroughs. -
- M62
Revenue mechanics — free trials, dunning reminders, cancellation survey, coupons
featureJul 4, 2026M62 adds four revenue-lifecycle mechanics, each behind its own flag so an operator opts in per deployment. None of them move money on their own — trials are zero-revenue, dunning only sends reminders, and coupon math is applied at checkout under the existing renewal and grace machinery.
-
Card-less free trials (
SAASKIT_TRIAL_DAYS) — an org can start ONE free trial that grants a chosen plan's entitlements and credits without collecting a card. The trial is a provider-less subscription that expires through the same renewal cron every paid plan rides, so there is no separate expiry path to trust. A trial banner shows days remaining, and a reminder email goes out two days before it ends. One trial per org, ever. (These are kit-level trials; native card-collected trials on the Polar hosted checkout are a follow-up.) -
Multi-step dunning reminders (
SAASKIT_DUNNING_REMINDER_DAYS) — when a recurring payment goes past-due, the customer gets a chain of reminder emails (default at +3 and +7 days; the last one carries final-warning copy) plus an in-app past-due banner with a pay CTA. If the payment recovers, the remaining reminders cancel themselves automatically. Set the value to empty to turn reminders off entirely. -
Cancellation survey — cancelling a subscription now shows a short retention step and then asks for a reason (and an optional comment). The response is saved and visible to operators, so churn reasons are captured instead of lost. For recurring Polar subscriptions the cancellation is passed through to Polar; if that call fails, the customer is handed off to the billing portal rather than left stuck.
-
Coupons (
SAASKIT_BILLING_COUPONS) — operators can create and manage discount coupons from the admin console. On flat PayPal and Sepay checkouts the discount is applied with exact integer VND-safe math and VAT is charged on the discounted amount; on Polar the discount is applied natively. Customers enter a code at checkout and see the discounted price before paying. Each coupon is redeemable once per organization on the kit-managed paths.
Everything above is off by default. Turn on only the mechanics you want, per deployment.
-
- M63
Customer support portal + Zalo OA channel
featureJul 4, 2026M56 shipped the operator support inbox but the customer side was write-only — a "Contact support" ticket vanished the moment it was created. M63 completes the loop: a customer can now read and continue their own threads, and the support inbox gains its first external channel, Zalo Official Account, live for the VN market.
- Customer support portal (
/{orgSlug}/support) — a member sees their own tickets in a list, opens a thread, and replies with optional attachments. The read model is bound to both the requester and the org in the repo, so a member never sees a co-member's ticket and never sees a ticket from another org. Operator internal notes (a newvisibilityflag) are filtered out at the repo layer — a UI bug cannot leak one. New threads and operator replies refresh the customer's view live over the existing realtime transport, and the support bell notification now deep-links straight to the thread instead of dead-ending. - Attachment downloads — support attachments (uploaded in-app or arriving by email) are
now served through
GET /api/support/attachments/[id], authorized for either the ticket's requester (public messages only) or an operator, and streamed via a short-lived presigned URL. - Zalo OA channel (Part B) — inbound Zalo messages land in the operator inbox as first-class conversations (one thread per Zalo user), and operators reply through the Zalo OA Customer Service API. Inbound is text-only with media fetched by a background job behind an SSRF host-allowlist; sends are deduped, and a failed send surfaces the same durable "Send failed — Retry" affordance M61 introduced for email replies. The channel ships inert until provisioned — with no OA credentials the webhook returns 503 and nothing sends, so non-VN deploys are byte-identical.
- OA token management — the OA's strictly single-use refresh token is rotated by a
15-minute cron with an atomic compare-and-swap so a concurrent refresh can never brick the
chain, and a failed refresh raises a deduplicated
zalo_token_refresh_failedoperator alert. Operators provision and monitor the OA from a write-only card at/admin/inbox/zalo(tokens are never echoed back).
Honest scope: Zalo threads are operator-facing only (a Zalo user is not an app account, so they never appear in the customer portal), replies are always operator-initiated (nothing auto-sends), and the operator inbox stays English. No chatbot, no ZNS/broadcast, no Messenger, no SLA timers — those remain future work. Zalo's charged 48 h–7 d send window and its 7-day hard cutoff are shown to the operator before they send; see the admin runbook for the full operational detail.
- Customer support portal (
- M65
Ops hygiene — maintenance mode, retention pruning, async data export, incidents
breakingJul 4, 2026M65 is an operator-reliability sweep: an env-gated maintenance window, two retention prune crons that stop operational-log tables growing forever, an asynchronous data export that no longer streams a giant ZIP on the request thread, a zero-downtime webhook-signing-key rotation, and an operator-curated public incident history. Two of these change externally observable behavior — read the breaking notes below before you upgrade an existing install. One migration (
0072) carries every schema addition.-
Maintenance mode (
SAASKIT_MAINTENANCE_MODE) — setting this to1makes the proxy return a fully-headed 503 (Retry-After: 300,no-store, per-request CSP, correlationx-request-id) for most routes — an HTML page for browser navigations, a{ "error": "maintenance_mode" }body for/api/*. It is env-only and read at request time, but the process must restart to pick up the flag (no runtime toggle). An allowlist stays reachable so the platform doesn't cycle the app and operators keep working:/status,/api/ready,/api/health,/admin,/.well-known/, and/api/csp-report; everything else503s. Note the deliberate escape hatches — proxy-excluded routes (api/auth,api/webhooks,api/og,_next) keep serving (billing webhooks must not be dropped mid-window), and/share/<token>public links bypass the gate — so a window is not a hard total blackout. See the admin runbook for the flip procedure and consequences. -
Retention prune crons — two always-on daily
maintenancecrons cap unbounded growth:webhooks/prunedeleteswebhook_out_eventrows older thanSAASKIT_WEBHOOK_RETENTION_DAYS(default 90d; delivery children cascade, endpoints untouched), andnotifications/prunedeletesnotificationrows older thanSAASKIT_NOTIFICATION_RETENTION_DAYS(default 180d, read-state-blind). Both delete in 10k-row chunks up to a 10M-row-per-run ceiling and warn if retention is falling behind. -
Asynchronous data export (BREAKING for API clients) —
POST /api/account/exportno longer streams a200ZIP. It now records the request, enqueues a worker build via the transactional outbox, and returns202 { requestId, status }. The worker builds the ZIP (size-capped bySAASKIT_EXPORT_MAX_BYTES, default 128 MiB), uploads it to private storage, marks the rowreadyfor aSAASKIT_EXPORT_DOWNLOAD_TTL_HOURSwindow (default 48h), and notifies the user. The ZIP is then fetched from the newGET /api/account/export/<requestId>/downloadroute, which 302-redirects to a 300-second presigned URL (uniform404for unknown/foreign/unready,410once expired). Any client that expected the old200ZIP from the request must adapt: poll forready, then follow the download route. -
Zero-downtime webhook-key rotation — the outbound-webhook AES master key (
WEBHOOKS_OUT_SECRET) now rotates without dropping deliveries. SetWEBHOOKS_OUT_SECRET_OLDto the previous key so the decrypt path falls back to it while an idempotent re-encrypt script rewrites every stored secret, then unset it. Full 5-step runbook indocs/webhooks-out/README.md. -
Operator incident history — operators post + resolve incidents from
/admin/health; the public/statuspage renders an active-incident banner and the last 10 resolved from curated fields only (title/publicBody/severity/ timestamps) — it never touches alert internals. Posting is audited and fail-closed.
-
- M61
Reliability sweep — stuck-state re-drive + idempotent retries
fixJul 3, 2026A post-M59 audit found six reliability defects that share one shape: a committed intermediate state with no re-drive path, or a retry that is not idempotent. An operator approval could crash mid-apply and strand in
approvedwith nothing to detect it; an announcement fan-out could die mid-broadcast and sit insendingforever; an approved-but-uncaptured PayPal order could authorize money and never settle; a failed support reply had no durable "this never sent" marker. M61 closes each gap with a detector (so a stuck state is never silent) and a re-drive lever — automatic where replay is provably idempotent, operator-driven where it is not. No new feature surface; one migration (0071) carries every schema addition.-
Announcements re-drive automatically (#239) — the
announcements/redrive-stalecron (every 5 min) reclaims a broadcast stranded insendingpastSAASKIT_ANNOUNCEMENT_REDRIVE_AFTER_MINUTES(default 15) via an atomic CAS and re-enqueues the fan-out. This one is fully automatic because the fan-out is already replay-idempotent (it subtracts recipients that already have a notification), so a re-drive never double-delivers. A live broadcast heartbeats its claim per chunk, so a long run is never reclaimed mid-flight; the operator stop lever iscancel. -
Operator approvals: detect automatically, re-drive by hand (#246, #248) — a new
ops/approval-stuck-checkcron (every 15 min) fires anapproval_stuckoperator alert for any approval stranded inapprovedpastSAASKIT_APPROVAL_STUCK_ALERT_MINUTES(default 30, always-on). But re-applying it is deliberately operator-driven, not an unattended auto-replay, for two code-verified reasons: the apply-cores need a live operator request context (headers(), the ALS user id, the approver's cookie session — a worker has none of these), and the money cores are not replay-idempotent (a credit adjust appends a ledger entry, a comp creates a subscription). A blind worker replay would crash half the action types and could double-move money. Instead an operator uses a verify-first Retry apply in/admin/billing/approvals: check the target state first (the crash may have already landed the apply), then retry — gated by the same staleness window (a fresh row may still be applying), a Separation-of-Duties re-check against the retrying operator, and a CAS claim so only one dispatcher wins. The raw apply error is now recorded on the row. -
PayPal capture backstop (#230) — Orders v2 does not auto-capture, so a customer who closes the tab between approval and capture leaves an order APPROVED but never captured (money authorized, nothing settled). A delayed
billing/paypal-capture-backstopjob — outbox-enqueued on everyCHECKOUT.ORDER.APPROVED, default 10 min later (SAASKIT_PAYPAL_CAPTURE_BACKSTOP_DELAY_MS) — re-checks the order and captures it if still APPROVED, under a deterministicPayPal-Request-Idso a retry replays the same capture instead of issuing a second one. The worker process now runs the billing boot guard too. -
Durable support-reply retry (#264) — a failed operator email reply now stamps a durable
send_failed_atand shows an inline Retry in the/admin/inboxthread. The retry re-sends the SAME row under the Resend idempotency key minted at the first attempt, with a byte-identical payload rebuild, so a retry within Resend's 24h window dedupes at the provider even if the first send actually landed — and a Resend409is read as "already delivered", not re-sent. -
Dispute ingest two-key payment lookup (#271) — one PayPal payment can be referenced by either its order id or its capture id, and a dispute webhook may carry either. Ingest now resolves the payment by both keys (capture id first, order id fallback), and every PayPal paid writer stores the order id (
0071backfills history). An unmatched reference still fails loud (a FAILED, PayPal-redeliveredwebhook_event) — a money signal is surfaced, never swallowed. -
Deprovision revokes operator-authored org keys (#249) — one-click operator deprovision now also revokes the org-scoped API keys the operator created (attributed via
metadata.createdByUserId), closing a live backdoor a role-revoke left open. Keys created before the attribution convention can't be found by query and need a manual review — called out in the runbook.
Honest scope. The approval re-drive is human-in-the-loop by design — the runbook documents the verify-first procedure and why unattended replay is unsafe here. The PayPal capture and dispute-ingest wire contracts are validated by a live sandbox round-trip (hard pre-prod gate), not by CI, which runs these paths in mock mode.
-
- M64
Email deliverability + SEO quick-wins
featureJul 3, 2026M64 is a batch of deliverability and discoverability quick-wins — the small, standards-shaped things that make outbound email land in the inbox and public pages surface correctly to crawlers, feed readers, and security researchers.
- One-click unsubscribe (RFC 8058) — every preference-gated notification email
now carries
List-Unsubscribe+List-Unsubscribe-Postheaders, so Gmail and Yahoo show a native one-click "Unsubscribe" button. The link is a stateless, signed HMAC token (180-day expiry, no new secret — it signs with your existing auth secret); the POST flips the user's email preference for that category and audits it. Transactional mail — sign-in, password reset, GDPR, support replies — is deliberately exempt, so a click can never opt a user out of account-critical email. hreflang+ locale-aware canonicals — English and Vietnamese pages now emit reciprocalhreflangalternates (en,vi,x-default) and a per-locale canonical, so search engines index the right language for the right audience instead of treating the two locales as duplicate content.- Sitemap locale alternates — each locale URL is its own sitemap entry with
reciprocal
en/vi/x-defaultlinks, matching the on-pagehreflang. - RSS 2.0 feeds — the blog (
/feed.xml) and changelog (/changelog.xml) are now subscribable, advertised via<link rel="alternate">on their index pages so feed readers auto-discover them. security.txt— an RFC 9116/.well-known/security.txtpublishes a contact URI (setSAASKIT_SECURITY_CONTACT) with a freshly computedExpireson every request, giving security researchers a standard way to report vulnerabilities.x-request-idechoed on every response — the proxy now returns the request id on all responses (2xx/3xx/4xx), so a customer or operator can quote it and support can correlate it to logs.- Branded email footer — notification emails now close with a "Sent by
<your kit name>" line (from
NEXT_PUBLIC_KIT_NAME), falling back tosaas-kitwhen the kit name is unset.
- One-click unsubscribe (RFC 8058) — every preference-gated notification email
now carries
- M58
Money-ledger unification
featureJul 2, 2026Money movement used to be stitched from three tables at read time —
payment(money-in),refund_request(money-out), and the M57disputecase — and every operator and finance surface re-derived the stitch differently. There was no single record that said "this much money moved, this direction, this provider, for this org, at this time, because of this event." M58 introduces one: an append-only, org-scopedmoney_ledgerthat is the derived reporting authority for revenue and treasury. The source tables stay the transactional truth; the ledger is the single read authority.- One append-only row per money event —
charge,refund,chargeback,chargeback_hold,chargeback_reversal,comp, each carrying adirection(in/out) and anis_revenueflag, nativeamount_minor+currency, source links (payment/refund_request/dispute/einvoice), and a deterministicentry_keyUNIQUE constraint. Idempotency is the load-bearing invariant: a redelivered webhook, a re-run backfill, or a double-fulfillment can never double-count. Migration0068backfills the fullpayment/refund_requesthistory. - Written at the repo seam, atomically — the ledger row appends on the same
transaction that mutates the source table (
paymentsRepo.recordfor charge/comp, all threerefund_requestcompletion writers, the dispute engine's hold/reversal), so the two commit together. No provider behaviour changes and no new webhook subscriptions — the ledger is derived from events the kit already ingests. - Operator revenue re-pointed — M26
revenueSeriesnow reads the ledger instead of the ad-hocpayment FULL OUTER JOIN refund_request. As part of this,comp(paid entitlement, zero revenue — M28) is now correctly excluded from revenue; the old join would have counted a non-zero comp payment as revenue. No real report changes (comp never created payment rows in practice), but the correction is now structural rather than incidental. - Treasury holds are now visible — a chargeback provisionally debits cash at open
and reverses on a win. That intra-dispute hold/reversal, invisible before, is now
recorded as
chargeback_hold/chargeback_reversaltreasury rows (is_revenue false, they net to zero on a won dispute). An operator can finally see cash currently held pending a dispute. - Per-org drill-down — a read-only
/admin/orgs/[orgId]/ledgertab shows the money-event stream that explains the numbers, gated onbilling.reconcile(finance / admin / superadmin — not read-only / refund-approver / support, who may not see dispute treasury data).
Honest scope — VAT is linked and reconciled, not migrated.
- VAT is linked, not re-sourced. Charge rows carry the Net / VAT /
tax_rate_codebreakdown copied from the linked e-invoice, and a new integration test reconciles ledger charge gross against e-invoice gross so drift is caught. But M31's legal VAT report stays sourced from the e-invoice — the e-invoice remains the filing authority (issued_at + adjusted credit-note semantics the ledger deliberately does not model). This is permanent by design, not a pending migration. - FX stays read-time. The ledger stores native
amount_minor+currency; VND conversion remains an M30 read-time layer. No per-event FX snapshot. - Single-entry, not double-entry. Direction carries the sign; there is no offsetting account or trial balance.
- Deferred: the customer-facing "money movements" view on the receipt / VAT surface
is deferred to a follow-up (#272). Operator credit adjustments (M27) stay out of the
ledger — they move the prepaid
creditsbalance, not provider cash.
- One append-only row per money event —
- M56
Operator support inbox + ticketing core
featureJul 1, 2026The operator console gains a unified support inbox at
/admin/inbox— the first milestone of a support-inbox epic. One model (a conversation IS the ticket) gathers customer messages into a single place to see "what has a customer said, and have we replied", with a channel discriminator so future channels add an adapter, not a schema rewrite.- In-app support, live day-one (F3) — a logged-in customer opens a ticket from the
"Contact support" entry point in the app shell; an authenticated server action
creates the conversation + first message with org + user context already stitched, audits
SUPPORT_CONVERSATION_OPENED(id-only metadata), and nudges the operator inbox live over the existing realtime transport. Zero third-party — it works the moment M56 merges. - Email-to-ticket, behind provisioning (F4) —
POST /api/webhooks/email-inboundingests inbound customer email into the same model: Svix-verified, idempotent on the provideremail_id, threaded via Message-ID with an anti-hijack guard (join only when the root Message-ID and the sender match). Resend Inbound is GA but metadata-only, so the route does a 2-phase fetch (Received Emails + Attachments API) for body + attachments, reusingRESEND_API_KEY. Inbound is text-only (no HTML stored or rendered → no sanitizer dep, no inbound-HTML XSS). The route ships inert (503) untilSAASKIT_INBOUND_EMAIL_SECRET+ the receiving-domain MX + the Resend webhook are provisioned (docs/operator-support-inbox.md). /admin/inboxconsole (F5) — a self-gated cross-tenant list + conversation detail + reply composer. Reply routes back to the originating channel (in-app → the bell; email → a threaded email reply with a minted Message-ID). Assign + status changes are gated, rate-limited, and audited.- Operator-ac
inboxstatement — a new BetterAuth-nativeinbox: ["read","reply","assign"]grant.readis broad (read_only + support + admin + superadmin) so analytics operators can see the backlog;reply/assignis the support tier and up.financeandrefund_approverget nothing on the inbox.
Honest scope: lifecycle is status + assignee + read-only tags only — tag-editing, SLA timers, CSAT, macros/canned-responses, and auto-routing are deferred to a future milestone, as are all external channels (Zalo OA is next for the VN market, then FB Messenger) and chatbot / AI-deflection. The in-app channel is live on merge; email-to-ticket is a documented runbook step away.
- In-app support, live day-one (F3) — a logged-in customer opens a ticket from the
"Contact support" entry point in the app shell; an authenticated server action
creates the conversation + first message with org + user context already stitched, audits
- M57
Dispute & chargeback automation
featureJul 1, 2026M55 shipped a dispute console, but it was bookkeeping only — an operator recorded accept/contest and stored evidence in S3, and nothing ever reached the payment provider. An operator could believe a chargeback was handled while the provider's response deadline silently passed. M57 closes the loop: a dispute is now a first-class domain aggregate with a provider-driven lifecycle, and PayPal disputes integrate with the Customer Disputes API for both ingest and evidence submission.
- Dispute is a first-class aggregate — no longer a
refund_requestwithsource='dispute'. It has its owndisputerow, a single Stripe-alignedstatus(inquiry → needs_response → under_review → won | lost | accepted), anevidence_due_bydeadline, an evidence-submission count, and structured evidence categories. The console list surfaces the status, a deadline-urgency indicator, and whether evidence has been assembled. - PayPal Disputes API automation —
CUSTOMER.DISPUTE.CREATED/UPDATED/RESOLVEDwebhooks auto-ingest the dispute and its deadline (no manual seeding). Operators assemble evidence by category and explicitly submit it to PayPal (re-submittable until the deadline) or accept the claim — both reach PayPal over the raw Disputes REST API (the repo's PayPal SDK has no DisputesController), reusing the existing OAuth token. - Deadline alerts — an opt-in cron
(
SAASKIT_DISPUTE_DEADLINE_ALERT_HOURS) fires adispute_deadline_approachingoperator alert for PayPal disputes whose response window is closing, plus adispute_openedalert on ingest. - The M17 untangle (money vs. entitlement) — M17 conflated a dispute with a refund and
revoked entitlement the moment a chargeback opened, which is wrong (no money has
settled yet and the merchant may win). M57 separates the two: money-out is recorded
exactly once, at
lost, as arefund_request(source='chargeback')linked from the dispute; entitlement is policy-driven — alert on open, revoke onlostby default (mirrors Stripe), with an opt-inSAASKIT_DISPUTE_SUSPEND_ON_OPENfor stricter operators. Accept-claim concedes the dispute (status →accepted, terminal) but records no money-out and does not itself revoke — the money-out + entitlement revoke then follow the provider's actual refund via the M17 reconcile path.
Honest scope — automation is PayPal-only.
- Polar is Merchant of Record: it emits no dispute webhook and contests chargebacks
itself, so there is no merchant evidence path. When Polar loses it issues a refund,
and the M17 reconcile records a
dispute(status='lost'). The console shows an explanatory banner — there is nothing to submit. - Sepay (VietQR bank transfer) has no card chargebacks; disputes are recorded and transitioned manually, with no provider round-trip.
- The live PayPal wire contract is not certified by CI. CI runs the handlers in mock
mode, which cannot verify the real
evidence_typestrings, the multipart file→evidence association, or the accept-claim body. Those are validated by a runbook-documented PayPal sandbox round-trip that is a hard pre-prod gate, not a CI check. - Deferred: PayPal
send-message/make-offer/escalate/appeal(onlyprovide-evidence+accept-claimship); auto-submit (submission is always an explicit operator action); and money-ledger unification → M58, which will make money-movement a first-class ledger that payment/refund/dispute all reference and re-point analytics / FX / VAT at it. Folding that into M57 would couple disputes to a billing-wide analytics rewrite — out of scope by design, not by omission.
- Dispute is a first-class aggregate — no longer a
- M54
Operator observability & incident ops
featureJun 30, 2026Operator-side observability and incident-response tooling lands: a
/admin/healthcockpit, an operational-symptom alerting engine, alert acknowledgement, and a disaster-recovery / rollback runbook with a restore-check drill.- Operator health cockpit (
/admin/health) — a self-gated (observability:read) console that surfaces scheduler/cron freshness, an outbound-webhook rollup with top-failing endpoints, DB latency, and the list of currently-firing operational alerts. Secret-free projections only (no signing secrets, no customer PII). A dead-man's-switch notice is rendered inline. - Operational-symptom alerting — four opt-in alert kinds (
scheduler_stale,job_failed,webhook_endpoint_down,error_rate_threshold) evaluated on a schedule, with atomic race-free dedup (upsertObservation), flap damping via a re-arm cooldown, and a system-actor fan-out to the notification bell + email. Every kind is uniformly opt-in (enable flag + threshold) — a fresh deploy fires nothing until an operator opts each in. - Alert acknowledgement —
observability:acknowledge-gated, rate-limited, audited (ADMIN_OPS_ALERT_ACKNOWLEDGED); an ack survives a flap inside the cooldown. - DR & rollback runbook (
docs/dr-runbook.md) — a disaster-recovery + rollback runbook for the DigitalOcean-only deploy: backup reality (Managed PG PITR, Spaces, Valkey), the point-in-time restore procedure, a one-command App Platform rollback recipe, and an external dead-man's-switch step (the only mitigation for a total scheduler outage, which is invisible in-product). - Restore-check drill (
scripts/restore-check.mjs) — a read-only probe that proves a forked backup is restorable: asserts connectivity, that the drizzle migration journal matches the codebase, that sentinel core tables exist, and a smoke query — failing loud on drift or a corrupt restore. Run monthly + pre-major-release.
Honest scope: the DR runbook is discipline + recipes (not automated DR); the in-product evaluator is itself a scheduled job, so a total scheduler outage is invisible in-product and requires the external dead-man's-switch documented in the runbook.
- Operator health cockpit (
- M55
Operator customer-care surface
featureJun 30, 2026The operator console gains the support / customer-care tools the GOVERN + OPERATE loops were missing: support staff can now raise refund/credit requests through the existing 4-eyes machinery, a dispute / chargeback console lands, and operators can re-send a payment receipt.
- Support raises refund & credit requests (F1) — the
supportoperator role now holdsbilling:["refund.create"]+credits:["adjust"](the REQUESTER side only). It does not gainrefund.approveorapprovals.approve, so the M17 4-eyes split is preserved: support requests, a distinct finance/admin operator approves. - Dispute / chargeback console (
/admin/billing/disputes, F2) — a self-gated (billing:dispute.manage, held by finance / admin / superadmin) cross-tenant list + detail ofsource='dispute'refund requests. Operators record an accept or contest disposition (auditedADMIN_DISPUTE_ACCEPTED/ADMIN_DISPUTE_CONTESTED, alerted via the newdispute_accepted/dispute_contestedkinds), attach free-text and file evidence (presigned S3 upload, keys namespaced to the dispute's own org), and download stored evidence. - Resend payment receipt (F3) — an org-scoped,
communications:["resend"]-gated control on the org-detail payment rows re-sends a paid payment's receipt to the customer's email on file (never the operator's), rate-limited and audited (ADMIN_RECEIPT_RESENT).
Honest scope: the dispute console is bookkeeping + evidence storage only. There is no PayPal/Polar Disputes API client in the kit, so contest evidence is not auto-submitted to the provider — an operator must still file it in the provider's dashboard before the deadline (the console banner and the operator runbook say so). Provider-evidence submission is deferred to a follow-up. The operator-triggered email-verification resend was likewise deferred (BetterAuth exposes no clean operator-to-customer verification-token path) — receipt resend ships; verification resend is tracked as a follow-up.
- Support raises refund & credit requests (F1) — the
- M29
Operator console hardening
featureJun 18, 2026Four follow-ups from the M26–M28 operator-console trilogy are now closed:
- Polar provider-side cancel on force-cancel — when an operator force-cancels a Polar recurring subscription the kit now also calls the Polar cancel API so the customer is not billed at the next renewal. PayPal and Sepay are one-time providers (no auto-billing), so for them the local cancel is already complete and no provider call is made. If the Polar API call fails the local cancel is still authoritative and the operator sees a clear warning to verify in the Polar dashboard.
- Admin/operator actions visible in the per-org audit tab (#200) — actions such as
comp, extend, force-cancel, credit adjust, ban, and internal notes are now surfaced in
the
/admin/orgs/[orgId]/auditview alongside the org's own activity. Previously those rows (which carry noorganization_id) only appeared in the global/admin/auditlog. This is a query-side widening — no schema change. - Daily metric snapshots → MRR/ARR/ARPA deltas + subscriber churn (#194) — a new
metric_snapshottable (migration 0056) and a daily Inngest cron (billing/metric-snapshot, 01:00 UTC) capture point-in-time run-rate metrics. The operator analytics dashboard now shows period-over-period MRR/ARR/ARPA deltas, net subscriber change, and an approximate gross churn rate derived from the two most recent snapshots. Deltas and churn display as "n/a" until at least two daily snapshots exist (≥ 24 h post-deploy). - Cached operator analytics aggregates for faster dashboard loads (#195) — the
eight aggregate queries that back the operator analytics dashboard are now wrapped with
unstable_cache(no app-wide Cache Components flag change). The cache is busted automatically after each daily snapshot write. Aggregate staleness is bounded to 5 minutes between snapshot runs.
Deferred: #196 (real FX feed to replace the static VND rate table) remains a standalone follow-up — MRR/ARR figures continue to use the static rate table until that issue ships.
- M20
Cursor pagination for /api/v2 list endpoints
featureJun 11, 2026The
/api/v2list endpoints now return a cursor-pagination envelope:- Additive (non-breaking) —
GET /api/v2/orgs/{slug}/membersandGET /api/v2/orgs/{slug}/api-keysgain ametablock (next_cursor/has_more/limit) alongside the existingdataarray, and accept an optional opaque?cursor=token to page forward.api-keysalso gains a?limit=param (clamped1..100, default50) — it previously had none. Existing callers that only readdataare unaffected. The typed SDK (@saas-kit/api-client) now exposes thecursorparam and themetaenvelope. - Behavior change — neither route previously declared an
ORDER BY, so the default result order was undefined. Both now sort deterministically: members oldest-first (member.idascending, UUIDv7 time-ordered) and api-keys newest-first (created_atdescending). Consumers that relied on the previous, implicit ordering should review this — the order callers observe today will change.
- Additive (non-breaking) —
- M15
Tenant-mode foundation + workspace admin UX
featureMay 29, 2026A configurable multi-tenant foundation plus the full workspace-admin surface, shipped across seven sub-PRs:
- Tenant mode — pick
personal-default(private workspace per signup, team surfaces revealed on the first invite),solo(single-user; team surfaces 404), orteam-required(every signup runs the team-creation wizard). One env var,SAASKIT_TENANT_MODE, plus an optionalSAASKIT_TENANT_LABELto rename the tenant noun. Seedocs/extending/tenant-mode.md. - Members — filterable, paginated member table with search, role/2FA/last-seen filters, bulk remove, and owner-only ownership transfer (typed-confirm).
- Invitations — email invites, CSV bulk import, resend/cancel, and shareable invite links (
/join/[slug]) with usage caps. - Roles — per-org custom-role editor: resource-grouped permissions, role templates, rename, and delete-when-empty. Built-in roles stay read-only.
- Audit log — a tenant-scoped explorer with full-text metadata search (Postgres
tsvector+ GIN), faceted filters, a drill-down drawer, and streaming CSV export. - Platform-admin parity — super-admins inspect any org's Members / Invitations / Roles / Audit at
/admin/orgs/[orgId]/*using the same UI in a read-only posture. - Migration
0037ships the auditmetadata_tsvindex as a two-stepCREATE INDEX CONCURRENTLYrunbook to keep the deploy lock-free.
Adopting
personal-defaulton an existing install? Run the one-shot backfill:BACKFILL_PERSONAL_WORKSPACES=1 pnpm --filter @saas-kit/db backfill:personal. - Tenant mode — pick
- v1.2.3
Patch hardening
fixMay 22, 2026- Fixed next-intl proxy handling for rewrites and top-level non-i18n routes
- Cleaned up pre-existing TypeScript errors around proxy, typed routes, theme provider props, and implementation casts
- Added permanent-failure operational follow-ups for outbound webhooks
- Translated the signup divider copy in both English and Vietnamese message files
- M12
Follow-ups + polish
featureMay 21, 2026docs/extending/{ai,search,mcp}.mdwiring guides for downstream products- Webhook permanent-failure subscriber, audit action, notification type, and
permanent_failed_atmigration - Vietnamese app-shell translations for settings and dashboard surfaces
- Sentry v11 re-check recorded; kit stays on the current 10.x line until v11 ships
cacheComponentsdecision locked: wait for Next 17 or per-route opt-in signal/api/csp-reportendpoint added; CSP enforcement remains deferred until a real observation window
- M11
Completeness pass
featureMay 19, 2026@saas-kit/storagewith attachment schema, presigned uploads, and S3-compatible config@saas-kit/jobswith Inngest mount, tenant-aware job wrapper, and cron migrations- Org RBAC roles and permissions with settings UI
- Outbound webhooks package with signing, endpoint UI, retry delivery, and event history
/api/v2/*, OpenAPI JSON, Scalar docs, and generated typed SDK- i18n routing with
endefault andviscaffold - Public
/statuspage and security/CSP Report-Only baseline
- M10
Desktop foundation
featureMay 18, 2026- Tauri v2 desktop app scaffold with Vite, React, Tailwind, and shared UI
@saas-kit/desktop-bridgefor typed IPC, auth token storage, deep links, and update hooks/api/v1/*surface for desktop handoff, entitlements, consent, and client version checks- Desktop release, signing, setup, customization, and threat-model docs
- Desktop E2E scaffolds and smoke scripts for local/release validation
- M9
Polish + docs
featureMay 14, 2026- v1.0 polish pass across app-shell, marketing, API, and docs
- Deployment matrix docs for Vercel, Cloudflare Pages, Docker, and DigitalOcean
- Environment-variable reference and setup documentation
- E2E, visual, and Lighthouse gates hardened for release confidence
- v1.0.0 tag cut after the milestone suite
- M8
Tier 2 + GDPR
featureMay 13, 2026- Data export request and archive shape in
@saas-kit/gdpr - Account deletion flow with 30-day grace window and restore path
- Cookie consent defaults, geo helpers, and consent-log repository
- Storage attachment groundwork with S3-compatible provider docs
- GDPR runbook and operator-facing retention guidance
- Data export request and archive shape in
- M7
Marketing + content
featureMay 10, 2026- New
@saas-kit/contentpackage with velite collection schemas +makeReadersfactory - Fumadocs at
/docswith built-in Orama search + sidebar - Polished landing + refactored pricing + blog + changelog + 3 legal placeholders
- Sitemap, robots, dynamic OG via
next/og - Lighthouse CI gate ≥ 90 on
/,/pricing,/blog,/docs - CSS-only hero entrance (no
motionruntime dep) - Server-component MDX rendering via velite +
react/jsx-runtime
- New
- M6
Audit + admin
featureMay 9, 2026audit_logschema + transactionallogAudit()in new@saas-kit/auditpackage- Closed-enum action catalog covering all M1–M5 mutating sites
- Daily prune cron (365-day retention)
- BetterAuth
admin()plugin: roles, ban, signed 1-hour impersonation /admin/...console: orgs / users / audit-log with filters- Twin-gated server actions (kit
requireAdmin+ plugin's internal admin check)
- M5
Email + notifications + observability
featureMay 8, 2026- Resend client with SPF/DKIM/DMARC docs
- Seven React Email templates
- Pino logger with
request_id/user_id/org_id - Sentry stub (env-gated, server + client)
- PostHog stub (env-gated, browser + Node)
- In-app notification table + bell UI with ETag-cached polling
- Preferences page toggles per channel × category
- M3
UI library + theme
featureMay 7, 2026- 20 shadcn primitives + 7 composites + 7 layouts in
@saas-kit/ui - Warm-gray OKLCH tokens; 4/8/12 radii; 32px density
- Storybook stories for every primitive + composite + layout
- Playwright snapshot regression on
/componentsdemo page
- 20 shadcn primitives + 7 composites + 7 layouts in
- M4
Billing — 3 providers
featureMay 7, 2026- Polar.sh recurring (USD/EUR) via
@polar-sh/better-authplugin - PayPal one-time (Orders API v2) with webhook signature verification
- Sepay PG one-time (VND) via
sepay-pg-node - Unified
subscription+paymentschema - Daily cron for expiry email (T-7d) + status flip (T+0)
- Per-org
referenceIdpattern keeps subscriptions strictly tenant-scoped
- Polar.sh recurring (USD/EUR) via
- M2
Multi-tenancy
featureMay 6, 2026/[orgSlug]/...routing with proxy-driven membership resolution- AsyncLocalStorage context +
withOrg()repo helper @saas-kit/eslint-rules/no-raw-tenant-queryenforced in CI- OrgSwitcher + Members + Invitations UI
next-safe-actionv8 wired- Cross-tenant leak suite gates M2 DoD
- M1
DB + Auth foundation
featureMay 1, 2026- Drizzle ORM + UUIDv7 (client-generated)
- BetterAuth: email + pw, Google, GitHub, magic link, 2FA TOTP
/login,/signup,/forgot-password,/reset-password,/verify-email,/magic-link,/two-factor/account/{profile,email,password,two-factor,sessions,danger}user settings- In-process LRU rate limit on
/api/auth/*(Upstash swap in M8) - Email transport via Mailpit (Resend swap in M5)
- M0
Bootstrap
featureApr 30, 2026- Turborepo 2 + pnpm 10 workspace
- Docker Compose: Postgres 18, Mailpit, MinIO
- Node 22 LTS pinned via
engines+packageManager - CI runs lint + typecheck on every PR
- Next 16 + React 19 + App Router boots