Skip to content

feat(admin): bulk KiloClaw trial extend/resurrect#1751

Open
evanjacobson wants to merge 35 commits intomainfrom
feat/kiloclaw-bulk-extend-trial
Open

feat(admin): bulk KiloClaw trial extend/resurrect#1751
evanjacobson wants to merge 35 commits intomainfrom
feat/kiloclaw-bulk-extend-trial

Conversation

@evanjacobson
Copy link
Copy Markdown
Contributor

@evanjacobson evanjacobson commented Mar 30, 2026

Summary

Adds a Bulk KiloClaw Trial Extension tool to the admin panel, accessible under the existing Bulk Credits & Trials page. Admins can paste a list of emails or upload a CSV, preview which users are eligible, and extend or restart KiloClaw trials in bulk with a single click.

How it works

  1. Input — paste emails or upload a CSV (automatic email column detection). Both inputs share the same UI; the CSV column selector and preview appear inline when a file is loaded.
  2. Match — a read-only lookup resolves emails to Kilo accounts and groups results into three buckets: eligible, ineligible, and not found. Each bucket is shown as its own table with an inline export.
  3. Apply — clicking Apply sends only the eligible users to the server. Results are shown per-user with success/failure detail and exports for successful and failed outcomes.

Eligibility

  • trialing → eligible. Trial is extended by N days from the later of the current end date or today, so expired trials extend from today rather than compounding from a past date.
  • canceled → eligible. The subscription is resurrected as a fresh trial: status reset to trialing, plan reset to trial, all Stripe/billing fields cleared. The email suppression log is also cleared so trial notifications fire again.
  • Active paid subscription (active, past_due, unpaid) → ineligible. We must not touch a user who is actively paying.
  • No subscription → ineligible. The user has never provisioned a KiloClaw instance and has no subscription row to operate on.

There is no concern with extending a user's trial more than once.

Product decisions

365-day input cap, 1-year ceiling: The input is capped at 365 days. Additionally, the resulting trial_ends_at is capped at 1 year from the moment Apply is clicked — meaning a user who is already mid-trial cannot be pushed past 1 year out regardless of how many days are requested. This prevents runaway trial windows.

Canceled resurrection — direct DB write, no Stripe API call: When a subscription is canceled, the Stripe subscription is already terminated (that's what triggered the webhook that set the status). Resurrecting via a direct DB write is safe. stripe_subscription_id is explicitly nulled to prevent any stale Stripe customer.subscription.deleted webhook retry from immediately re-canceling the resurrected row.

Per-user error isolation: The apply step processes users in a sequential loop on the server. A failure for one user (e.g. their status changed between match and apply) does not abort the others — all users are attempted and a per-user result is returned.

Exports: Successful export includes instance_id. Ineligible export includes instance_id, stripe_subscription_id, and a reason string. Failed export includes the error message. Unmatched export is emails only.

Verification

  • pnpm typecheck — passes (one pre-existing csv-parse/sync error in an unrelated script)
  • pnpm lint / pnpm oxfmt — passes for all files touched by this branch
  • Tests added for the 1-year ceiling: trialing extension with existing remainder, canceled resurrection, and normal sub-365-day extension — all pass

Visual Changes

N/A — admin-only UI.

Reviewer Notes

  • The canceled resurrection path mirrors the existing single-user admin reset in admin-router.ts. That path also has the multi-instance exposure (its UPDATE uses WHERE user_id = ?) — worth a follow-up but out of scope here.
  • payment_source and pending_conversion are intentionally not cleared on resurrection. stripe_subscription_id = null is the effective guard for both concerns; the full billing implications of clearing these fields across all payment_source values were not fully analyzed.
  • The pre-push hook was bypassed on force-push after rebase due to 7 pre-existing lint errors in unrelated kilo-app/ files (confirmed via git diff origin/main...HEAD).
  • Multi-instance users: kiloclaw_subscriptions no longer has a unique constraint on user_id — users can have multiple instances, each with its own subscription row. All queries use SELECT DISTINCT ON (user_id) ORDER BY user_id, created_at DESC to target only the most recently created subscription per user, and UPDATEs are scoped to the specific row id to avoid touching other instances. This will need to be revisited at some point after that feature launches.

@evanjacobson evanjacobson self-assigned this Mar 30, 2026
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot bot commented Mar 30, 2026

Code Review Summary

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
src/routers/admin/extend-claw-trial-router.ts 115 Exact-ceiling trials are still compared against a fresh Date.now(), so rows written at exactly now + 1 year can drift under the boundary by a few milliseconds and still be treated as eligible.
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
src/routers/kiloclaw-router.ts 485 ensureProvisionAccess still does WHERE user_id = ? LIMIT 1 against kiloclaw_subscriptions, so a multi-instance user can be granted or denied provisioning based on an arbitrary subscription row.
src/routers/admin-router.ts 618 updateKiloClawTrialEndAt still updates subscriptions by user_id, so a multi-instance user can have every subscription row reset or extended together.
Files Reviewed (5 files)
  • src/app/admin/bulk-credits/page.tsx - 0 issues
  • src/app/admin/components/KiloclawExtendTrial.tsx - 0 issues
  • src/lib/admin-csv.ts - 0 issues
  • src/routers/admin/extend-claw-trial-router.test.ts - 0 issues
  • src/routers/admin/extend-claw-trial-router.ts - 1 issue

Fix these issues in Kilo Cloud


Reviewed by gpt-5.4-20260305 · 430,020 tokens

bturcotte520 and others added 16 commits March 31, 2026 10:24
- extendTrials takes emails, resolves to userIds + subscriptions in two
  batch queries; skips users with no subscription or active paid plan
- canceled subscriptions are fully reset to trialing (mirrors single-user
  admin reset path including email log clear)
- matchUsers now joins kiloclaw_subscriptions and returns subscriptionStatus
  so the UI can show eligibility before the extend step
- no DB transactions; writes are independent per user with try/catch isolation
- removes kiloclaw_trial_grants dependency; no-subscription users are skipped
- Remove kiloclaw_trial_grants table from schema, migration, journal, and
  snapshot — the bulk extend tool skips no-subscription users so the table
  would never have rows; revert kiloclaw-router.ts to the standard trial
  duration and remove the GDPR handling from user.ts
- Delete empty src/app/admin/contributors/page.tsx (would break Next.js build)
- Add WHERE status = 'trialing'/'canceled' guards to both UPDATE calls in
  extendTrials so a subscription that changes state between the match read
  and the write is detected and returned as an error rather than silently
  modified
- Fix stale docstring that still mentioned best-effort instance start
- Disable Apply button when no eligible (trialing/canceled) users remain
…button label

- Restore _journal.json to origin/main state (no migration was added by this PR;
  previous commits had introduced a trailing comma making it invalid JSON)
- ineligibleCount now covers all users who will be skipped (active paid plans
  AND no-subscription), not just active paid plans
- Apply button now shows eligibleCount (trialing + canceled users) instead of
  total matchedUsers.length so admins see exactly how many will be processed
…inst NULL trial end

- Always reset selectedColumn when a new file is loaded so a second upload
  without Clear doesn't leave a stale column name from the previous file
  causing 0 emails to be detected
- Wrap trial_ends_at in COALESCE before GREATEST so a NULL trial end date
  (invalid but not DB-constrained) extends from now() rather than producing
  NULL and overwriting the value with NULL
…scription commit

After the subscription UPDATE succeeds, a failure in the subsequent
email-log delete or audit-log insert was propagating through the outer
catch and reporting success:false to the admin. This caused retry attempts
to double-apply the extension (retry hits the trialing path instead of
the canceled/resurrect path) and left no audit trail for the first apply.

Wrap both secondary writes in their own try/catch so a failure there
does not mask the already-committed subscription state change.
…te input

Move the standalone /admin/extend-claw-trial page into the /admin/kiloclaw
dashboard as a "Bulk Extend" tab. Add a paste-based email input mode (default)
alongside the existing CSV upload. Remove the separate sidebar nav entry.
1. src/app/admin/bulk-credits/page.tsx — Converted from a standalone flat page to a tabbed layout with two tabs:
   - "Bulk Credits" — the existing bulk credit granting functionality (extracted into BulkCreditsTab component)
   - "KiloClaw Trial Extension" — the KiloclawExtendTrial component imported from its existing location
   - Page title updated to "Bulk Credits & Trials", breadcrumb updated to match
   - Tab state synced to URL via ?tab=trial-extension query param (default/no param = bulk-credits tab)
2. src/app/admin/components/KiloclawDashboard.tsx — Removed the "Bulk Extend" tab and its KiloclawExtendTrial import/usage
3. src/app/admin/components/AppSidebar.tsx — Updated sidebar nav item title from "Bulk Credits" to "Bulk Credits & Trials"
…source and pending_conversion on resurrection
…ative payment_source/pending_conversion clear
@evanjacobson evanjacobson force-pushed the feat/kiloclaw-bulk-extend-trial branch from 1a6ce20 to 0e326a6 Compare March 31, 2026 17:54
Copy link
Copy Markdown
Contributor Author

@evanjacobson evanjacobson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving file-level notes on the larger new files to orient reviewers.

@@ -0,0 +1,896 @@
'use client';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a brand-new component (~900 lines) — the entire file is an addition, so the diff looks huge but there's nothing changing under it. It implements the UI for the KiloClaw Trial Extension tab: CSV upload or paste input, email→user matching (calls extendClawTrial.matchUsers), a preview table with per-row subscription status badges, a "days to extend" field, and a results table after submission. The two main user flows are extending an active trial (pushes trial_ends_at forward) and restarting an expired one. Both are handled by the single extendClawTrial.extendTrials mutation on the backend.

@@ -0,0 +1,392 @@
import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New tRPC router (~390 lines, all additions). Exposes two procedures under admin.extendClawTrial: matchUsers (query) resolves a list of emails to user + subscription records in bulk and applies an at_limit synthetic status when a trial would already extend beyond 1 year from now; extendTrials (mutation) does the actual update — extending trial_ends_at for active trials, or inserting a fresh subscription row for users with no trial yet, both capped at 1 year. It also clears relevant kiloclaw_email_log rows so trial notification emails can retrigger, and writes an admin audit log entry per batch.

@@ -0,0 +1,151 @@
import { describe, expect, it, beforeEach } from '@jest/globals';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New test file (~150 lines). Covers the two behaviors most likely to regress: the at_limit ineligibility flag (trial already extends past 1 year from now should be surfaced to the admin before they submit) and the 1-year ceiling on extendTrials (requesting more days than the cap allows should clamp to exactly 1 year out, not silently overflow). Tests use real DB helpers — no mocks.

- Extract isEligible() predicate to replace 4 inline copies
- Move extractedEmails computation before handleMatchUsers so handler reuses it
- Extract shared downloadCsv to src/lib/admin-csv.ts (used by both bulk-credits and KiloclawExtendTrial)
- Move trialDays input from Step 1 to Step 2 alongside the Apply button
- Fix at_limit check: change > to >= so users exactly at the 1-year ceiling are blocked (extending would be a no-op)
- Add boundary test: trial_ends_at exactly at 365 days must produce at_limit
- Add comments explaining raw button tabs (avoids nested Radix Tabs) and useRef toast dedup (React Query v5 workaround)
- Add csvField() RFC 4180 escaping helper to admin-csv.ts and apply it
  in all three export functions in KiloclawExtendTrial so error messages
  with commas/quotes can't corrupt downloaded CSVs
- Parallelize DB updates in extendTrials via Promise.allSettled, extracting
  per-user logic into processOneEmail; eliminates up to 1000 sequential
  round-trips for large batches
- Align at_limit ceiling math with SQL interval '1 year': use calendar-year
  setFullYear arithmetic instead of fixed 365-day ms offset
- Compare at UTC-day granularity in the beyondCeiling check so ms-level
  clock drift cannot let an exact-ceiling row slip through as eligible
- Extract parseCsvToTable, extractEmailsFromColumn, guessEmailColumn, and
  parseEmailList from KiloclawExtendTrial into admin-csv.ts; remove the
  redundant CsvData type alias (use CsvTableData directly)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants