Skip to content

feat(kilo-pass): integrate Churnkey for cancel flow and payment recovery#1799

Closed
iscekic wants to merge 31 commits intomainfrom
feat/churnkey-integration
Closed

feat(kilo-pass): integrate Churnkey for cancel flow and payment recovery#1799
iscekic wants to merge 31 commits intomainfrom
feat/churnkey-integration

Conversation

@iscekic
Copy link
Copy Markdown
Contributor

@iscekic iscekic commented Mar 31, 2026

Summary

Replace the custom 2-step cancel/feedback modals with Churnkey's SDK for intelligent cancel flows (surveys, targeted discount offers, A/B testing) and failed payment recovery. Add full pause/resume subscription support for Churnkey's pause offers.

Hybrid approach: Churnkey handles the cancel flow UI and applies discount coupons directly on Stripe, but actual cancellation goes through our existing kiloPass.cancelSubscription tRPC mutation via the SDK's handleCancel callback. This preserves the subscription schedule release logic — Stripe coupons don't interact with subscription schedules, so letting Churnkey apply them directly is safe.

Churnkey integration

  • New src/lib/churnkey/auth.ts — server-side HMAC-SHA256 auth hash for Churnkey SDK security
  • New src/lib/churnkey/loader.ts — client-side SDK script loader with showCancelFlow() and fallback
  • New kiloPass.getChurnkeyAuthHash tRPC query — returns auth hash + Stripe customer ID
  • Rewired cancel button in KiloPassSubscriptionSettingsModal to open Churnkey flow
  • Deleted CancelKiloPassSubscriptionModal.tsx and KiloPassCancellationFeedbackModal.tsx
  • Falls back to window.confirm + direct cancellation if Churnkey SDK fails to load
  • Added NEXT_PUBLIC_CHURNKEY_APP_ID and CHURNKEY_API_SECRET env vars

Pause/resume subscription support

  • New kilo_pass_pause_events table with partial unique index (one open pause per sub) and CHECK constraint (resumed_at >= paused_at)
  • Pause event DB operations: openPauseEvent, closePauseEvent, getOpenPauseEvent, getPausedMonthSet — all idempotent
  • Detect pause_collection transitions in subscription webhook handler (open/close pause events)
  • New kiloPass.resumePausedSubscription mutation — clears pause_collection on Stripe, updates DB status to active, closes pause event
  • Monthly streak calculation skips paused months instead of breaking the streak
  • Yearly cron includes paused subs with 12-issuance cap
  • UI shows paused state with resumesAt date and resume button
  • Renamed resumeSubscriptionresumeCancelledSubscription to distinguish from pause resume

What doesn't change

cancelSubscription mutation, scheduled changes system, all other Stripe webhook handlers, credit issuance logic, mobile app.

Verification

  • pnpm run typecheck — passed
  • pnpm run lint — passed (0 warnings, 0 errors)
  • pnpm run format:changed — passed
  • pnpm run test — passed (pre-existing kiloclaw-billing-router timeouts unrelated)
  • Code review — all issues fixed (stale DB status on resume, double-fire guard, useCallback deps)

Reviewer Notes

  • Requires Churnkey account setup: NEXT_PUBLIC_CHURNKEY_APP_ID and CHURNKEY_API_SECRET must be set. Without them, the cancel flow falls back to window.confirm + direct mutation.
  • Churnkey dashboard config needed: Cancel flow steps, discount coupon selection, and A/B tests are configured in Churnkey's UI — not in code.
  • Failed payment recovery: Configured entirely in Churnkey's dashboard (dunning emails, retry logic). No code changes needed — our existing Stripe webhooks handle the outcomes.
  • Edge case: cancel with pending scheduled change: Handled correctly — handleCancel calls our mutation which releases the schedule before cancelling.
  • Edge case: discount + pending scheduled change: Safe — Stripe coupons are independent of subscription schedules.

Replace the custom cancel/feedback modals with Churnkey's SDK for
intelligent cancel flows (surveys, discount offers, A/B testing).
Cancellation still goes through our kiloPass.cancelSubscription mutation
via handleCancel callback, preserving subscription schedule release logic.
Churnkey applies discounts directly on Stripe (safe — coupons don't
interact with subscription schedules).
@iscekic iscekic self-assigned this Mar 31, 2026
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot bot commented Mar 31, 2026

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
src/lib/kilo-pass/stripe-handlers-subscription-events.ts 94 Refetching the live Stripe subscription can apply newer pause_collection state to an older webhook and corrupt pause-event history.
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
src/lib/kilo-pass/state.ts 142 Resuming after Stripe has already advanced the subscription to paused still leaves the UI stuck in paused until the webhook updates the DB status.
Files Reviewed (2 files)
  • packages/db/src/migrations/0071_hot_the_professor.sql - 0 issues
  • packages/db/src/schema.ts - 0 issues

Fix these issues in Kilo Cloud


Reviewed by gpt-5.4-20260305 · 1,147,688 tokens

Comment thread src/routers/kilo-pass-router.ts
iscekic added 4 commits April 1, 2026 17:45
…ription

The resumePausedSubscription mutation was only updating Stripe and
closing the pause event, but not updating the DB status. This caused
the UI to show stale 'paused' status until the webhook arrived.
…ix useCallback deps

Add isResumingSubscription guard to prevent concurrent resume calls,
matching the pattern used by other action handlers. Also add missing
onClose to handleOpenCancelFlow dependency array.
When Stripe sets pause_collection, it keeps the subscription status as
'active' until the current period ends. Our webhook handler was storing
that 'active' status, so the UI never showed the subscription as paused
even though a pause event was created. Now we eagerly set the DB status
to 'paused' when pause_collection is present.
…f DB status

Stripe keeps the subscription status as 'active' when pause_collection
is first set — it only becomes 'paused' at the next billing cycle. The
state query now checks for open pause events to determine if a
subscription is paused, rather than relying on the DB status column.
This keeps the DB in sync with Stripe's reported status while ensuring
the UI reflects the user's intent immediately.
Comment thread src/lib/kilo-pass/state.ts
…f casting webhook payload

Replace unsafe cast of webhook subscription object with an explicit
stripe.subscriptions.retrieve() call to get pause_collection data.
Comment thread src/lib/kilo-pass/stripe-handlers-subscription-events.ts
iscekic added 4 commits April 2, 2026 13:32
…f string comparison

Drizzle returns timestamps as "YYYY-MM-DD HH:MM:SS+00" (space separator)
while dayjs generates "YYYY-MM-DDT00:00:00.000Z" (T separator). String
comparison fails at date boundaries because ASCII space (32) < T (84),
causing paused months to be excluded from the streak walk.

Switch to millisecond-based numeric comparison via dayjs().valueOf().
Preparing to merge main by removing 0067_silky_dark_phoenix migration
and restoring schema.ts to main's version. Will re-add pause_events
table and regenerate after merge.
…tion

# Conflicts:
#	.env.development.local.example
Regenerated migration as 0071 after merging main (which added 0067-0070).
@iscekic iscekic requested a review from RSO April 2, 2026 11:50
@iscekic iscekic requested a review from jeanduplessis April 2, 2026 11:50
iscekic added 7 commits April 2, 2026 13:52
Churnkey env vars are configured via Vercel, no need in dotfiles.
Regenerated migration as 0072 after merging main (which added 0071).
Broken merge from #1839 left conflict markers on main. Combined both
sides: callback target from RSO/fluff-pan + gitlabProject validation.
@jeanduplessis
Copy link
Copy Markdown
Contributor

Due to the monorepo restructure you will need to recreate this PR on a new branch from main. Pass the prompt found at, https://github.com/Kilo-Org/cloud/blob/main/plans/monorepo-migration-prompt.md, to your coding agent while running in this branch. Please close this PR once done or if you don't plan to proceed with it.

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