
Churn Prevention
- 80 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
churn-prevention is a skill that reduces SaaS churn through cancel-flow design, dynamic save offers, dunning sequences, and win-back campaigns.
About
This skill is a SaaS churn-reduction framework covering cancel-flow design, dynamic save offers, exit surveys, dunning sequences, payment recovery, and win-back campaigns. It addresses both voluntary churn and involuntary churn from failed payments, mapping exit reasons to save offers. Growth teams use it to build or optimize a cancel flow and recover failed payments.
- Designs a 5-stage cancel flow with exit survey and dynamic save offers
- Maps each exit reason to exactly one save offer (discount, pause, onboarding)
- Engineers dunning sequences and payment recovery for involuntary churn
Churn Prevention by the numbers
- 80 all-time installs (skills.sh)
- Ranked #472 of 853 Sales & Marketing skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
churn-prevention capabilities & compatibility
- Capabilities
- cancel flow design · save offers · dunning · win back
- Works with
- stripe
- Use cases
- marketing · email
- Pricing
- Free
What churn-prevention says it does
SaaS churn reduction covering cancel flow design, dynamic save offers, exit survey architecture, dunning sequences, payment recovery, win-back campaigns, and churn impact modeling.
The 5-Stage Cancel Flow
Map each exit reason to exactly one save offer:
npx skills add https://github.com/borghei/claude-skills --skill churn-preventionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 80 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Design a cancel flow with dynamic save offers and dunning sequences to reduce SaaS churn.
Who is it for?
SaaS teams building or optimizing a cancel flow and recovering failed payments.
Skip if: Non-subscription products without recurring billing.
When should I use this skill?
You need to design a cancel flow, add save offers, or recover failed subscription payments.
What you get
A cancel flow with mapped save offers, exit survey, and dunning sequence to reduce both churn types.
- cancel flow
- exit survey
- save offer map
By the numbers
- 5-stage cancel flow
- exit survey limited to 8 options
- PRICE offer is a 30-50% discount for 2-3 months
Files
Churn Prevention
Production-grade SaaS churn reduction framework covering cancel flow architecture, dynamic save offer mapping, exit survey design, dunning sequence engineering, payment recovery optimization, win-back campaigns, and churn impact modeling. Addresses both voluntary churn (customers who decide to leave) and involuntary churn (customers who leave due to payment failure).
---
Table of Contents
- Initial Assessment
- Churn Taxonomy
- Cancel Flow Architecture
- Exit Survey Design
- Dynamic Save Offer System
- Dunning Sequence Engineering
- Win-Back Campaign Framework
- Churn Health Scoring
- Metrics and Benchmarks
- Churn Impact Calculator
- Output Artifacts
- Related Skills
---
Initial Assessment
Required Context
| Question | Why It Matters |
|---|---|
| Current monthly churn rate? (voluntary vs involuntary split) | Determines which lever to pull |
| Do you have a cancel flow, or is cancellation instant/via support? | Determines build vs optimize mode |
| What payment processor? (Stripe, Braintree, Paddle) | Affects dunning implementation |
| Average contract value and billing cycle? | Sizes the save offer budget |
| Current MRR? | Calculates the dollar impact of churn reduction |
| SaaS model? (self-serve vs sales-assisted) | Determines intervention type |
| Do you collect exit reasons today? | Data availability for save offer mapping |
---
Churn Taxonomy
Voluntary Churn (Customer Decides to Leave)
| Type | Signal | Addressable? |
|---|---|---|
| Value gap | Not getting enough value for the price | Yes -- save offers, feature education |
| Product-market mismatch | Wrong ICP, product does not fit their use case | Partially -- downgrade or pivot |
| Competitor switch | Found a better alternative | Yes -- competitive counter-offers |
| Budget cut | Cannot afford it anymore | Yes -- discount or pause |
| Project completion | Seasonal or project-based need | Yes -- pause option |
| Poor experience | Bad support, bugs, frustration | Yes -- human intervention |
| Never activated | Signed up, never used it | Partially -- reactivation before cancel |
Involuntary Churn (Payment Fails)
| Cause | % of Failed Payments | Recoverable? |
|---|---|---|
| Expired card | 40-50% | Yes -- card updater service |
| Insufficient funds | 20-30% | Yes -- smart retry timing |
| Bank decline (fraud flag) | 10-15% | Sometimes -- customer must contact bank |
| Account closed | 5-10% | No -- customer must provide new card |
| Network error | 5-10% | Yes -- automatic retry |
---
Cancel Flow Architecture
The 5-Stage Cancel Flow
[Cancel Button] → [Exit Survey] → [Dynamic Save Offer] → [Confirmation] → [Post-Cancel]Stage 1: Cancel Trigger
- Cancel option is findable (Settings > Account > Cancel). Do not hide it.
- Clicking "Cancel" starts the flow -- it does not immediately cancel the account
- Works on both desktop and mobile
Stage 2: Exit Survey (Required, 1 Question)
Question: "What is the main reason you are cancelling?"
Present as radio buttons (not a dropdown). Maximum 8 options:
| Reason | Internal Code |
|---|---|
| Too expensive for the value I get | PRICE |
| Not using it enough | LOW_USAGE |
| Missing a feature I need | MISSING_FEATURE |
| Switching to a different product | COMPETITOR |
| My project or need ended | PROJECT_END |
| Too complicated to use | COMPLEXITY |
| Just testing, did not plan to keep it | TESTING |
| Other (with optional text field) | OTHER |
Rules:
- Survey is required before showing the save offer (the answer determines the offer)
- One question only. No multi-page surveys.
- Optional free-text field for "Other" and as a supplement to any selection
- Track response distribution monthly to identify systemic issues
Stage 3: Dynamic Save Offer
Map each exit reason to exactly one save offer:
| Exit Reason | Save Offer | Offer Copy |
|---|---|---|
| PRICE | 30-50% discount for 2-3 months | "We'd like to offer you [X]% off for the next [N] months" |
| LOW_USAGE | Pause account for 1-3 months | "Pause your account and come back when you need it" |
| MISSING_FEATURE | Roadmap preview + workaround | "[Feature] is coming in [Q]. Here's how to achieve it now" |
| COMPETITOR | Competitive comparison + discount | "Here's how we compare to [competitor]. Plus [X]% off" |
| PROJECT_END | Pause option | "Pause instead of cancel -- your data stays safe" |
| COMPLEXITY | Free onboarding session | "Let us set it up for you -- free 30-min session with our team" |
| TESTING | No offer -- let them go | "Thanks for trying us out. You're welcome back anytime." |
| OTHER | General retention offer | "Before you go -- we'd love to make this right. [Contact support]" |
Offer presentation rules:
- One clear offer per screen (not multiple choices)
- Quantify the value: "Save $120 over the next 3 months" not "Get a discount"
- CTA: "Accept Offer" vs "Continue Cancelling" (both clearly labeled)
- No countdown timers, no fake urgency
- No guilt-trip copy
Stage 4: Confirmation
If they decline the save offer or there is no offer to make:
┌────────────────────────────────────────┐
│ We're sorry to see you go │
│ │
│ What happens when you cancel: │
│ - Your data is saved for 90 days │
│ - Access continues until [date] │
│ - You can reactivate anytime │
│ │
│ [Yes, Cancel My Account] │
│ [Wait, I Changed My Mind] │
│ │
│ No pre-checked boxes. │
│ No confusing language. │
└────────────────────────────────────────┘Stage 5: Post-Cancel
| Timing | Channel | Message |
|---|---|---|
| Immediately | Cancellation confirmation + data retention policy + reactivation link | |
| Day 7 | "We miss you" + single CTA to reactivate + what they are missing | |
| Day 30 | Product update + relevant improvement + reactivation offer | |
| Day 60 | Final win-back with strongest offer (if applicable) |
---
Exit Survey Design
Data Analysis Framework
Track exit survey responses monthly and calculate:
| Metric | Formula | Action Threshold |
|---|---|---|
| Reason distribution | % of cancels per reason | Any reason > 30% = systemic issue |
| Save rate by reason | Saved / Cancel attempts per reason | Any reason < 5% save rate = wrong offer |
| Reason trend | Month-over-month change | Increasing trend = worsening problem |
| Feature gap frequency | Count of "missing feature" with specific feature named | Top 3 missing features = product roadmap input |
Competitive Intelligence from Exit Surveys
When users select "Switching to a different product":
- Ask a follow-up: "Which product are you switching to?" (optional, free text or dropdown)
- Track the top 3 competitors winning your churners
- Feed this data into competitive-teardown skill for quarterly analysis
---
Dynamic Save Offer System
Offer Economics
| Offer Type | Cost to Business | Save Rate Benchmark | When Profitable |
|---|---|---|---|
| 30% discount (3 months) | 30% of 3 months revenue | 15-25% | If LTV after save > discount cost |
| 50% discount (2 months) | 50% of 2 months revenue | 20-30% | If retained customer stays 6+ months |
| Pause (1-3 months) | $0 (no revenue during pause) | 25-40% | If 50%+ reactivate after pause |
| Free onboarding session | CS team time (~$50-100) | 10-20% | If ARPU > $100/month |
| Downgrade to lower tier | Revenue reduction | 30-50% | If some revenue > no revenue |
| Feature unlock | $0 (already built) | 5-15% | Always profitable |
Save Offer Decision Tree
User selects exit reason →
├── PRICE →
│ ├── Customer ARPU > median? → Offer 30% discount
│ └── Customer ARPU < median? → Offer downgrade to cheaper plan
├── LOW_USAGE →
│ ├── Last login > 30 days? → Offer pause
│ └── Last login < 30 days? → Offer usage tips + discount
├── MISSING_FEATURE →
│ ├── Feature on roadmap? → Share roadmap + workaround
│ └── Feature not planned? → Offer discount or acknowledge gap
├── COMPETITOR →
│ ├── Known competitor? → Show comparison + retention offer
│ └── Unknown competitor? → General retention offer
├── PROJECT_END →
│ └── Always → Offer pause
├── COMPLEXITY →
│ ├── Enterprise/high-value? → Offer dedicated onboarding session
│ └── SMB/low-value? → Offer guided tutorial link
└── TESTING →
└── Always → No offer, let go gracefully---
Dunning Sequence Engineering
Failed payments cause 20-40% of total churn. Most of it is recoverable with proper dunning.
Smart Retry Schedule
Do not retry immediately after failure. Cards often recover within 3-7 days.
| Retry | Timing | Why This Timing |
|---|---|---|
| Initial charge | Day 0 | Normal billing cycle |
| Retry 1 | Day 3 | Most card issues resolve within 72 hours |
| Retry 2 | Day 7 | Paycheck cycle alignment |
| Retry 3 | Day 12 | Second paycheck cycle |
| Retry 4 | Day 18 | Final attempt before service action |
| Service action | Day 21 | Downgrade or cancel |
Card Updater Services
Enable automatic card updating to prevent expired card churn:
| Processor | Service | How to Enable |
|---|---|---|
| Stripe | Automatic card updates | Enabled by default on most plans |
| Braintree | Account Updater | Must enable in merchant settings |
| Paddle | Built-in | Automatic |
| Recurly | Account Updater | Configuration required |
Dunning Email Sequence
| Day | Subject Line | Body Focus | CTA |
|---|---|---|---|
| 0 | "Your [Product] payment didn't go through" | Factual, no blame. Card may be expired or funds unavailable. | [Update Payment Method] |
| 3 | "Action needed: update your payment for [Product]" | Remind what they will lose access to. | [Update Payment Method] |
| 7 | "Your [Product] account is at risk" | List features/data they have created. Mild urgency. | [Update Payment Method] |
| 14 | "Final notice: your [Product] access ends in 7 days" | Clear deadline. Offer to help if bank issue. | [Update Payment Method] + [Contact Support] |
| 21 | "Your [Product] account has been paused" | Account status change. Data is safe. Easy reactivation. | [Reactivate Account] |
Email rules:
- Every email links directly to the payment update page (not the dashboard)
- No guilt, no shame. Card failures happen.
- Subject lines are specific (include product name)
- Include the amount owed and the card last 4 digits
- Offer a support channel for customers who need help
---
Win-Back Campaign Framework
Win-Back Timing
| Window | Success Rate | Approach |
|---|---|---|
| Day 7 post-cancel | 5-10% | Gentle reminder, no pressure |
| Day 30 post-cancel | 3-7% | Product update + offer |
| Day 60 post-cancel | 2-5% | Strongest offer + fresh start |
| Day 90+ post-cancel | 1-3% | Major product change only |
Win-Back Email Sequence
Day 7 Email:
- Subject: "Your [Product] account is waiting for you"
- Body: What they left behind (data, projects, team). One CTA: reactivate.
- No discount. Just value reminder.
Day 30 Email:
- Subject: "Here's what's new in [Product]"
- Body: 2-3 specific improvements since they left. One CTA: reactivate.
- Small incentive: "Come back with 1 month free"
Day 60 Email:
- Subject: "We'd love to have you back -- [offer]"
- Body: Strongest offer (50% off 3 months or extended free period). Clear deadline.
- Final significant outreach attempt.
---
Churn Health Scoring
Leading Indicators of Churn
| Signal | Weight | Detection |
|---|---|---|
| Login frequency declining (week over week) | High | Usage analytics |
| Feature usage dropping | High | Feature event tracking |
| Support ticket escalation | High | Help desk data |
| NPS response < 7 | High | Survey data |
| Invoice dispute or payment question | Medium | Billing system |
| Champion left the company | High | Contact monitoring |
| Contract renewal in < 90 days | Medium | CRM data |
| Competitor evaluation detected | High | Sales intelligence |
Risk Score Calculation
Risk Score = Sum of (Signal Weight x Signal Present)
0-20: Low risk (monitor)
21-40: Moderate risk (proactive outreach)
41-60: High risk (intervention required)
61+: Critical risk (executive escalation)---
Metrics and Benchmarks
Key Metrics
| Metric | Formula | Good | Excellent |
|---|---|---|---|
| Save rate | Customers saved / Cancel attempts | 10-15% | 20%+ |
| Voluntary churn rate | Voluntary cancels / Total customers (monthly) | < 3% | < 1.5% |
| Involuntary churn rate | Failed payment cancels / Total customers (monthly) | < 1.5% | < 0.5% |
| Payment recovery rate | Failed payments recovered / Total failed | 25-35% | 40%+ |
| Win-back rate | Reactivations / Cancellations (90-day window) | 5-10% | 10%+ |
| Exit survey completion rate | Surveys completed / Cancel attempts | > 70% | > 90% |
| Save offer acceptance rate | Offers accepted / Offers shown | 15-25% | 30%+ |
Red Flags
| Signal | Diagnosis | Action |
|---|---|---|
| Save rate < 5% | Offers not matching reasons | Rebuild offer-reason mapping |
| Exit survey completion < 60% | Survey too long or optional | Make it required, 1 question |
| Recovery rate < 20% | Retry logic or emails broken | Audit dunning sequence |
| Single reason > 40% | Systemic product/pricing issue | Escalate to product/leadership |
| Churn rate > 5% monthly | Business is likely contracting | Churn prevention alone will not fix; review ICP + product |
---
Churn Impact Calculator
Quick Estimate
Monthly MRR at risk = Total MRR x Monthly churn rate
Annual MRR saved by 1% churn reduction = Total MRR x 0.01 x 12
Annual MRR saved by 20% save rate = (Monthly MRR at risk x 0.20) x 12
Example:
MRR: $500,000
Monthly churn: 4% = $20,000/month lost
Reduce to 3% = $5,000/month saved = $60,000/year
Add 20% save rate on remaining = $3,000/month saved = $36,000/year
Total annual impact: $96,000---
Output Artifacts
| Artifact | Format | Description |
|---|---|---|
| Cancel Flow Design | 5-stage flow with copy | Complete flow from trigger to post-cancel |
| Exit Survey | Radio button options + mapping | 6-8 reasons with save offer mapping |
| Save Offer System | Decision tree | Reason-to-offer mapping with economics |
| Dunning Sequence | 5-email sequence | Subject lines, body copy, timing, retry schedule |
| Win-Back Campaign | 3-email sequence | Day 7, 30, 60 emails with subject lines and offers |
| Churn Scorecard | Metric table | Current metrics vs benchmarks with gap analysis |
| Impact Model | Revenue calculation | Dollar impact of churn reduction at various improvement levels |
---
Related Skills
- customer-success-manager -- Use for health scoring, QBRs, and expansion revenue. Not for cancel flow or dunning design.
- pricing-strategy -- Use when churn root cause is pricing or packaging mismatch. Not for save offer design.
- onboarding-cro -- Use when churn traces back to poor activation. If users never experienced value, fix onboarding first.
- referral-program -- Use for acquisition. Churn prevention handles the other end of the funnel.
---
Tool Reference
1. churn_impact_calculator.py
Purpose: Calculate the revenue impact of churn reduction at various improvement levels.
python scripts/churn_impact_calculator.py --mrr 500000 --churn-rate 4.0 --save-rate 20
python scripts/churn_impact_calculator.py --mrr 500000 --churn-rate 4.0 --save-rate 20 --json| Flag | Required | Description |
|---|---|---|
--mrr | Yes | Current monthly recurring revenue in dollars |
--churn-rate | Yes | Current monthly churn rate as percentage (e.g., 4.0 for 4%) |
--save-rate | No | Cancel flow save rate as percentage (default: 15) |
--target-churn | No | Target churn rate as percentage (default: current minus 1) |
--json | No | Output results as JSON |
2. dunning_sequence_analyzer.py
Purpose: Analyze dunning email sequence effectiveness and recommend retry timing optimizations.
python scripts/dunning_sequence_analyzer.py dunning_data.json
python scripts/dunning_sequence_analyzer.py dunning_data.json --json| Flag | Required | Description |
|---|---|---|
dunning_data.json | Yes | JSON file with failed payment and retry data |
--json | No | Output results as JSON |
Input JSON format:
{
"failed_payments": [
{
"payment_id": "PAY-001",
"amount": 99.00,
"failure_reason": "expired_card",
"retry_attempts": [
{"day": 0, "recovered": false},
{"day": 3, "recovered": false},
{"day": 7, "recovered": true}
]
}
]
}3. exit_survey_analyzer.py
Purpose: Analyze exit survey responses to identify churn patterns, save offer effectiveness, and systemic issues.
python scripts/exit_survey_analyzer.py survey_data.json
python scripts/exit_survey_analyzer.py survey_data.json --json| Flag | Required | Description |
|---|---|---|
survey_data.json | Yes | JSON file with exit survey response data |
--json | No | Output results as JSON |
--period | No | Analysis period label (default: "current") |
---
Troubleshooting
| Problem | Likely Cause | Solution |
|---|---|---|
| Save rate below 5% across all reasons | Save offers do not match exit reasons | Rebuild the exit-reason-to-offer mapping using survey data; run exit_survey_analyzer.py to identify mismatches |
| Exit survey completion under 60% | Survey is optional or too long | Make the single-question survey required before showing the save offer; remove multi-page flows |
| Payment recovery rate below 20% | Retry logic misconfigured or dunning emails not sending | Audit dunning sequence with dunning_sequence_analyzer.py; verify email deliverability and retry schedule |
| Single exit reason exceeds 40% of responses | Systemic product or pricing issue | Escalate to product or leadership; this is not solvable with cancel flow alone |
| Churn rate above 5% monthly | Likely ICP, product-market fit, or pricing problem | Churn prevention alone will not fix this; pair with pricing-strategy and product feedback loops |
| Win-back emails have zero reactivations | Emails not reaching inbox or offers are weak | Check deliverability (SPF, DKIM, DMARC); test stronger offers; verify reactivation links work |
| Involuntary churn rising while voluntary is stable | Card updater not enabled or retry timing is poor | Enable automatic card updating on your payment processor; review retry schedule in dunning_sequence_analyzer.py |
---
Success Criteria
- Monthly voluntary churn rate below 2.5% (below 1.5% is excellent)
- Monthly involuntary churn rate below 1.0% (below 0.5% is excellent)
- Cancel flow save rate of 15-25% (above 20% is excellent)
- Payment recovery rate of 30%+ on failed payments
- Exit survey completion rate above 80%
- Win-back reactivation rate of 5-10% within 90 days post-cancel
- Save offer acceptance rate above 20% with retained customers staying 6+ months post-save
---
Scope & Limitations
- In scope: Cancel flow design, exit survey architecture, save offer mapping, dunning sequences, payment recovery, win-back campaigns, churn impact modeling
- Out of scope: Product-market fit analysis, pricing restructuring, ICP redefinition, customer acquisition
- Data dependency: Scripts analyze point-in-time snapshots from JSON input; no real-time CRM integration
- Not predictive ML: All scoring is deterministic and algorithmic -- no machine learning models
- Legal note: Cancel flows must comply with FTC guidelines (US) and consumer protection laws (EU) -- do not make cancellation unreasonably difficult
- Revenue estimates: Impact calculations are projections based on input assumptions, not guarantees
---
Integration Points
- customer-success-manager -- Feed health scores into churn risk assessment; use churn data to calibrate health score thresholds
- pricing-strategy -- When exit survey data shows PRICE as the dominant reason (>30%), escalate to pricing-strategy for structural pricing review
- onboarding-cro -- When exit survey data shows LOW_USAGE or COMPLEXITY as top reasons, the root cause is often poor activation; fix onboarding first
- revenue-operations -- Pipeline and forecast models should account for churn reduction impact on net revenue retention (NRR)
- referral-program -- Retained customers from save offers are candidates for referral program enrollment after 90 days of continued usage
#!/usr/bin/env python3
"""
Churn Impact Calculator
Calculate the revenue impact of churn reduction at various improvement levels.
Models voluntary churn savings, involuntary churn recovery, and cancel flow
save rate contributions to annual revenue retention.
Usage:
python churn_impact_calculator.py --mrr 500000 --churn-rate 4.0 --save-rate 20
python churn_impact_calculator.py --mrr 500000 --churn-rate 4.0 --save-rate 20 --json
"""
import argparse
import json
import sys
def calculate_impact(mrr: float, churn_rate: float, save_rate: float,
target_churn: float) -> dict:
"""Calculate revenue impact of churn reduction."""
monthly_mrr_lost = mrr * (churn_rate / 100.0)
annual_mrr_lost = monthly_mrr_lost * 12
# Impact of reducing churn rate to target
reduction_pct = churn_rate - target_churn
monthly_savings_from_reduction = mrr * (reduction_pct / 100.0)
annual_savings_from_reduction = monthly_savings_from_reduction * 12
# Impact of save rate on remaining churn
remaining_monthly_churn = mrr * (target_churn / 100.0)
monthly_savings_from_saves = remaining_monthly_churn * (save_rate / 100.0)
annual_savings_from_saves = monthly_savings_from_saves * 12
total_annual_impact = annual_savings_from_reduction + annual_savings_from_saves
# Scenario modeling at different improvement levels
scenarios = []
for improvement in [0.5, 1.0, 1.5, 2.0]:
new_rate = max(0.1, churn_rate - improvement)
new_monthly_lost = mrr * (new_rate / 100.0)
saved = (monthly_mrr_lost - new_monthly_lost) * 12
scenarios.append({
"churn_reduction_pct": improvement,
"new_churn_rate": round(new_rate, 2),
"annual_mrr_saved": round(saved, 2),
})
# Save rate scenarios
save_scenarios = []
for sr in [10, 15, 20, 25, 30]:
monthly_saved = monthly_mrr_lost * (sr / 100.0)
save_scenarios.append({
"save_rate_pct": sr,
"monthly_mrr_saved": round(monthly_saved, 2),
"annual_mrr_saved": round(monthly_saved * 12, 2),
})
# Net Revenue Retention estimate
nrr = ((mrr - monthly_mrr_lost + monthly_savings_from_reduction +
monthly_savings_from_saves) / mrr) * 100
return {
"inputs": {
"current_mrr": mrr,
"current_churn_rate_pct": churn_rate,
"target_churn_rate_pct": target_churn,
"save_rate_pct": save_rate,
},
"current_state": {
"monthly_mrr_at_risk": round(monthly_mrr_lost, 2),
"annual_mrr_at_risk": round(annual_mrr_lost, 2),
"annual_arr": round(mrr * 12, 2),
"churn_as_pct_of_arr": round((annual_mrr_lost / (mrr * 12)) * 100, 2),
},
"projected_impact": {
"annual_savings_from_churn_reduction": round(annual_savings_from_reduction, 2),
"annual_savings_from_save_offers": round(annual_savings_from_saves, 2),
"total_annual_impact": round(total_annual_impact, 2),
"estimated_nrr_pct": round(nrr, 2),
},
"churn_reduction_scenarios": scenarios,
"save_rate_scenarios": save_scenarios,
"recommendations": _generate_recommendations(churn_rate, save_rate, monthly_mrr_lost),
}
def _generate_recommendations(churn_rate: float, save_rate: float,
monthly_lost: float) -> list:
"""Generate actionable recommendations based on inputs."""
recs = []
if churn_rate > 5.0:
recs.append({
"priority": "CRITICAL",
"area": "Overall churn",
"recommendation": "Churn rate above 5% indicates systemic issues beyond cancel flow optimization. Review ICP, product-market fit, and pricing.",
})
if churn_rate > 3.0:
recs.append({
"priority": "HIGH",
"area": "Voluntary churn",
"recommendation": "Implement or optimize cancel flow with exit survey and dynamic save offers. Target 15-25% save rate.",
})
if save_rate < 10:
recs.append({
"priority": "HIGH",
"area": "Save rate",
"recommendation": "Save rate below 10% suggests offers do not match exit reasons. Rebuild reason-to-offer mapping.",
})
if save_rate < 20:
recs.append({
"priority": "MEDIUM",
"area": "Save rate",
"recommendation": "Test stronger save offers: pause option for LOW_USAGE, 30-50% discount for PRICE, free onboarding for COMPLEXITY.",
})
recs.append({
"priority": "MEDIUM",
"area": "Involuntary churn",
"recommendation": f"Enable card updater service and optimize dunning. Recovering 30% of failed payments saves ${monthly_lost * 0.3 * 0.3 * 12:,.0f}/year (assuming 30% of churn is involuntary).",
})
return recs
def format_text(result: dict) -> str:
"""Format results as human-readable text."""
lines = []
lines.append("=" * 60)
lines.append("CHURN IMPACT CALCULATOR")
lines.append("=" * 60)
inp = result["inputs"]
lines.append(f"\nCurrent MRR: ${inp['current_mrr']:>12,.2f}")
lines.append(f"Churn Rate: {inp['current_churn_rate_pct']:>12.1f}%")
lines.append(f"Target Churn Rate: {inp['target_churn_rate_pct']:>12.1f}%")
lines.append(f"Save Rate: {inp['save_rate_pct']:>12.1f}%")
cs = result["current_state"]
lines.append(f"\n--- Current State ---")
lines.append(f"Monthly MRR at Risk: ${cs['monthly_mrr_at_risk']:>12,.2f}")
lines.append(f"Annual MRR at Risk: ${cs['annual_mrr_at_risk']:>12,.2f}")
lines.append(f"Annual ARR: ${cs['annual_arr']:>12,.2f}")
lines.append(f"Churn as % of ARR: {cs['churn_as_pct_of_arr']:>12.1f}%")
pi = result["projected_impact"]
lines.append(f"\n--- Projected Impact ---")
lines.append(f"From Churn Reduction: ${pi['annual_savings_from_churn_reduction']:>12,.2f}/year")
lines.append(f"From Save Offers: ${pi['annual_savings_from_save_offers']:>12,.2f}/year")
lines.append(f"Total Annual Impact: ${pi['total_annual_impact']:>12,.2f}/year")
lines.append(f"Estimated NRR: {pi['estimated_nrr_pct']:>12.1f}%")
lines.append(f"\n--- Churn Reduction Scenarios ---")
lines.append(f"{'Reduction':>10} {'New Rate':>10} {'Annual Saved':>14}")
for s in result["churn_reduction_scenarios"]:
lines.append(f"{s['churn_reduction_pct']:>9.1f}% {s['new_churn_rate']:>9.1f}% ${s['annual_mrr_saved']:>12,.2f}")
lines.append(f"\n--- Save Rate Scenarios ---")
lines.append(f"{'Save Rate':>10} {'Monthly Saved':>14} {'Annual Saved':>14}")
for s in result["save_rate_scenarios"]:
lines.append(f"{s['save_rate_pct']:>9d}% ${s['monthly_mrr_saved']:>12,.2f} ${s['annual_mrr_saved']:>12,.2f}")
lines.append(f"\n--- Recommendations ---")
for r in result["recommendations"]:
lines.append(f"[{r['priority']}] {r['area']}: {r['recommendation']}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Calculate revenue impact of churn reduction at various improvement levels."
)
parser.add_argument("--mrr", type=float, required=True,
help="Current monthly recurring revenue in dollars")
parser.add_argument("--churn-rate", type=float, required=True,
help="Current monthly churn rate as percentage (e.g., 4.0 for 4%%)")
parser.add_argument("--save-rate", type=float, default=15.0,
help="Cancel flow save rate as percentage (default: 15)")
parser.add_argument("--target-churn", type=float, default=None,
help="Target churn rate as percentage (default: current minus 1)")
parser.add_argument("--json", action="store_true",
help="Output results as JSON")
args = parser.parse_args()
if args.mrr <= 0:
print("Error: --mrr must be positive.", file=sys.stderr)
sys.exit(1)
if args.churn_rate <= 0 or args.churn_rate > 100:
print("Error: --churn-rate must be between 0 and 100.", file=sys.stderr)
sys.exit(1)
if args.save_rate < 0 or args.save_rate > 100:
print("Error: --save-rate must be between 0 and 100.", file=sys.stderr)
sys.exit(1)
target = args.target_churn if args.target_churn is not None else max(0.1, args.churn_rate - 1.0)
result = calculate_impact(args.mrr, args.churn_rate, args.save_rate, target)
if args.json:
print(json.dumps(result, indent=2))
else:
print(format_text(result))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Dunning Sequence Analyzer
Analyze dunning email sequence effectiveness and recommend retry timing
optimizations. Calculates recovery rates by failure reason, retry attempt,
and timing window.
Usage:
python dunning_sequence_analyzer.py dunning_data.json
python dunning_sequence_analyzer.py dunning_data.json --json
"""
import argparse
import json
import sys
from collections import defaultdict
FAILURE_REASONS = {
"expired_card": {"recoverable": True, "typical_rate": 0.60},
"insufficient_funds": {"recoverable": True, "typical_rate": 0.50},
"bank_decline": {"recoverable": True, "typical_rate": 0.30},
"account_closed": {"recoverable": False, "typical_rate": 0.05},
"network_error": {"recoverable": True, "typical_rate": 0.85},
"fraud_flag": {"recoverable": True, "typical_rate": 0.20},
"unknown": {"recoverable": True, "typical_rate": 0.35},
}
OPTIMAL_RETRY_SCHEDULE = [
{"retry": 1, "day": 3, "rationale": "Most card issues resolve within 72 hours"},
{"retry": 2, "day": 7, "rationale": "Paycheck cycle alignment"},
{"retry": 3, "day": 12, "rationale": "Second paycheck cycle"},
{"retry": 4, "day": 18, "rationale": "Final attempt before service action"},
]
def analyze_dunning(data: dict) -> dict:
"""Analyze dunning sequence data."""
payments = data.get("failed_payments", [])
if not payments:
return {"error": "No failed payment data provided."}
total = len(payments)
total_amount = sum(p.get("amount", 0) for p in payments)
recovered_count = 0
recovered_amount = 0.0
# By failure reason
by_reason = defaultdict(lambda: {"total": 0, "recovered": 0, "amount": 0.0, "recovered_amount": 0.0})
# By retry attempt
by_retry = defaultdict(lambda: {"attempts": 0, "recovered": 0})
# Recovery timing
recovery_days = []
for p in payments:
reason = p.get("failure_reason", "unknown")
amount = p.get("amount", 0)
retries = p.get("retry_attempts", [])
by_reason[reason]["total"] += 1
by_reason[reason]["amount"] += amount
payment_recovered = False
for i, retry in enumerate(retries):
by_retry[i]["attempts"] += 1
if retry.get("recovered", False) and not payment_recovered:
payment_recovered = True
recovered_count += 1
recovered_amount += amount
by_reason[reason]["recovered"] += 1
by_reason[reason]["recovered_amount"] += amount
by_retry[i]["recovered"] += 1
recovery_days.append(retry.get("day", 0))
# Build reason breakdown
reason_breakdown = []
for reason, stats in sorted(by_reason.items(), key=lambda x: x[1]["total"], reverse=True):
rate = (stats["recovered"] / stats["total"] * 100) if stats["total"] > 0 else 0
benchmark = FAILURE_REASONS.get(reason, {})
typical = benchmark.get("typical_rate", 0.35) * 100
reason_breakdown.append({
"reason": reason,
"total_failures": stats["total"],
"recovered": stats["recovered"],
"recovery_rate_pct": round(rate, 1),
"amount_at_risk": round(stats["amount"], 2),
"amount_recovered": round(stats["recovered_amount"], 2),
"benchmark_recovery_pct": round(typical, 1),
"vs_benchmark": "above" if rate > typical else "below" if rate < typical else "at",
"recoverable": benchmark.get("recoverable", True),
})
# Build retry breakdown
retry_breakdown = []
for attempt in sorted(by_retry.keys()):
stats = by_retry[attempt]
rate = (stats["recovered"] / stats["attempts"] * 100) if stats["attempts"] > 0 else 0
retry_breakdown.append({
"attempt": attempt + 1,
"total_attempts": stats["attempts"],
"recovered": stats["recovered"],
"recovery_rate_pct": round(rate, 1),
})
overall_rate = (recovered_count / total * 100) if total > 0 else 0
avg_recovery_day = (sum(recovery_days) / len(recovery_days)) if recovery_days else 0
# Recommendations
recommendations = _generate_recommendations(overall_rate, reason_breakdown, retry_breakdown)
return {
"summary": {
"total_failed_payments": total,
"total_amount_at_risk": round(total_amount, 2),
"recovered_count": recovered_count,
"recovered_amount": round(recovered_amount, 2),
"overall_recovery_rate_pct": round(overall_rate, 1),
"average_recovery_day": round(avg_recovery_day, 1),
"unrecovered_count": total - recovered_count,
"unrecovered_amount": round(total_amount - recovered_amount, 2),
},
"by_failure_reason": reason_breakdown,
"by_retry_attempt": retry_breakdown,
"optimal_retry_schedule": OPTIMAL_RETRY_SCHEDULE,
"recommendations": recommendations,
}
def _generate_recommendations(overall_rate: float, reasons: list, retries: list) -> list:
"""Generate recommendations based on analysis."""
recs = []
if overall_rate < 20:
recs.append({
"priority": "CRITICAL",
"area": "Overall recovery",
"recommendation": "Recovery rate below 20% indicates dunning system is underperforming. Audit email delivery, retry schedule, and payment update UX.",
})
elif overall_rate < 35:
recs.append({
"priority": "HIGH",
"area": "Overall recovery",
"recommendation": "Recovery rate below benchmark (35%). Review retry timing and add in-app payment update prompts.",
})
for r in reasons:
if r["reason"] == "expired_card" and r["recovery_rate_pct"] < 50:
recs.append({
"priority": "HIGH",
"area": "Card updates",
"recommendation": "Expired card recovery is low. Enable automatic card updater service on your payment processor (Stripe, Braintree, etc.).",
})
if r["reason"] == "insufficient_funds" and r["recovery_rate_pct"] < 40:
recs.append({
"priority": "MEDIUM",
"area": "Retry timing",
"recommendation": "Insufficient funds recovery is low. Ensure retries align with common paycheck dates (1st, 15th of month).",
})
if len(retries) < 4:
recs.append({
"priority": "MEDIUM",
"area": "Retry attempts",
"recommendation": f"Only {len(retries)} retry attempts configured. Best practice is 4 retries over 18 days before service action.",
})
recs.append({
"priority": "MEDIUM",
"area": "Dunning emails",
"recommendation": "Ensure every dunning email links directly to the payment update page (not the dashboard). Include amount owed and card last 4 digits.",
})
return recs
def format_text(result: dict) -> str:
"""Format results as human-readable text."""
lines = []
lines.append("=" * 60)
lines.append("DUNNING SEQUENCE ANALYZER")
lines.append("=" * 60)
s = result["summary"]
lines.append(f"\n--- Summary ---")
lines.append(f"Total Failed Payments: {s['total_failed_payments']:>8d}")
lines.append(f"Total Amount at Risk: ${s['total_amount_at_risk']:>12,.2f}")
lines.append(f"Recovered: {s['recovered_count']:>8d} (${s['recovered_amount']:>10,.2f})")
lines.append(f"Unrecovered: {s['unrecovered_count']:>8d} (${s['unrecovered_amount']:>10,.2f})")
lines.append(f"Overall Recovery Rate: {s['overall_recovery_rate_pct']:>8.1f}%")
lines.append(f"Avg Recovery Day: {s['average_recovery_day']:>8.1f}")
lines.append(f"\n--- By Failure Reason ---")
lines.append(f"{'Reason':<20} {'Total':>6} {'Recovered':>10} {'Rate':>8} {'Benchmark':>10} {'Status':>8}")
for r in result["by_failure_reason"]:
lines.append(f"{r['reason']:<20} {r['total_failures']:>6d} {r['recovered']:>10d} {r['recovery_rate_pct']:>7.1f}% {r['benchmark_recovery_pct']:>9.1f}% {r['vs_benchmark']:>8}")
lines.append(f"\n--- By Retry Attempt ---")
lines.append(f"{'Attempt':>8} {'Tried':>8} {'Recovered':>10} {'Rate':>8}")
for r in result["by_retry_attempt"]:
lines.append(f"{r['attempt']:>8d} {r['total_attempts']:>8d} {r['recovered']:>10d} {r['recovery_rate_pct']:>7.1f}%")
lines.append(f"\n--- Optimal Retry Schedule ---")
for r in result["optimal_retry_schedule"]:
lines.append(f" Retry {r['retry']}: Day {r['day']:>2d} -- {r['rationale']}")
lines.append(f"\n--- Recommendations ---")
for r in result["recommendations"]:
lines.append(f"[{r['priority']}] {r['area']}: {r['recommendation']}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze dunning email sequence effectiveness and recommend optimizations."
)
parser.add_argument("input_file", help="JSON file with failed payment and retry data")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
try:
with open(args.input_file, "r") as f:
data = json.load(f)
except FileNotFoundError:
print(f"Error: File not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {args.input_file}: {e}", file=sys.stderr)
sys.exit(1)
result = analyze_dunning(data)
if args.json:
print(json.dumps(result, indent=2))
else:
print(format_text(result))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Exit Survey Analyzer
Analyze exit survey responses to identify churn patterns, save offer
effectiveness, and systemic issues. Produces reason distribution, save
rate analysis, trend detection, and competitive intelligence.
Usage:
python exit_survey_analyzer.py survey_data.json
python exit_survey_analyzer.py survey_data.json --json
python exit_survey_analyzer.py survey_data.json --period "Q1 2026"
"""
import argparse
import json
import sys
from collections import defaultdict
EXIT_REASONS = ["PRICE", "LOW_USAGE", "MISSING_FEATURE", "COMPETITOR",
"PROJECT_END", "COMPLEXITY", "TESTING", "OTHER"]
BENCHMARKS = {
"save_rate_good": 15.0,
"save_rate_excellent": 25.0,
"survey_completion_good": 70.0,
"survey_completion_excellent": 90.0,
"reason_concentration_warning": 30.0,
"reason_concentration_critical": 40.0,
}
def analyze_surveys(data: dict, period: str) -> dict:
"""Analyze exit survey data."""
responses = data.get("responses", [])
if not responses:
return {"error": "No survey response data provided."}
total_cancel_attempts = data.get("total_cancel_attempts", len(responses))
total_responses = len(responses)
# Reason distribution
by_reason = defaultdict(lambda: {
"count": 0, "saved": 0, "mrr_at_risk": 0.0, "mrr_saved": 0.0,
"competitors_mentioned": defaultdict(int), "features_requested": defaultdict(int),
})
for r in responses:
reason = r.get("reason", "OTHER")
if reason not in EXIT_REASONS:
reason = "OTHER"
by_reason[reason]["count"] += 1
by_reason[reason]["mrr_at_risk"] += r.get("mrr", 0)
if r.get("saved", False):
by_reason[reason]["saved"] += 1
by_reason[reason]["mrr_saved"] += r.get("mrr", 0)
if reason == "COMPETITOR" and r.get("competitor_name"):
by_reason[reason]["competitors_mentioned"][r["competitor_name"]] += 1
if reason == "MISSING_FEATURE" and r.get("feature_name"):
by_reason[reason]["features_requested"][r["feature_name"]] += 1
# Build reason breakdown
reason_breakdown = []
total_saved = sum(d["saved"] for d in by_reason.values())
total_mrr_risk = sum(d["mrr_at_risk"] for d in by_reason.values())
total_mrr_saved = sum(d["mrr_saved"] for d in by_reason.values())
for reason in EXIT_REASONS:
stats = by_reason[reason]
if stats["count"] == 0:
continue
pct = (stats["count"] / total_responses * 100) if total_responses > 0 else 0
save_rate = (stats["saved"] / stats["count"] * 100) if stats["count"] > 0 else 0
reason_breakdown.append({
"reason": reason,
"count": stats["count"],
"percentage": round(pct, 1),
"saved": stats["saved"],
"save_rate_pct": round(save_rate, 1),
"mrr_at_risk": round(stats["mrr_at_risk"], 2),
"mrr_saved": round(stats["mrr_saved"], 2),
})
reason_breakdown.sort(key=lambda x: x["count"], reverse=True)
# Competitive intelligence
all_competitors = defaultdict(int)
for stats in by_reason.values():
for comp, cnt in stats["competitors_mentioned"].items():
all_competitors[comp] += cnt
top_competitors = sorted(all_competitors.items(), key=lambda x: x[1], reverse=True)[:5]
# Feature gaps
all_features = defaultdict(int)
for stats in by_reason.values():
for feat, cnt in stats["features_requested"].items():
all_features[feat] += cnt
top_features = sorted(all_features.items(), key=lambda x: x[1], reverse=True)[:5]
# Overall metrics
overall_save_rate = (total_saved / total_responses * 100) if total_responses > 0 else 0
survey_completion = (total_responses / total_cancel_attempts * 100) if total_cancel_attempts > 0 else 0
# Alerts
alerts = _generate_alerts(reason_breakdown, overall_save_rate, survey_completion)
return {
"period": period,
"summary": {
"total_cancel_attempts": total_cancel_attempts,
"survey_responses": total_responses,
"survey_completion_rate_pct": round(survey_completion, 1),
"total_saved": total_saved,
"overall_save_rate_pct": round(overall_save_rate, 1),
"total_mrr_at_risk": round(total_mrr_risk, 2),
"total_mrr_saved": round(total_mrr_saved, 2),
},
"reason_breakdown": reason_breakdown,
"competitive_intelligence": {
"top_competitors": [{"name": c, "mentions": n} for c, n in top_competitors],
},
"feature_gaps": {
"top_requested_features": [{"feature": f, "mentions": n} for f, n in top_features],
},
"alerts": alerts,
"benchmarks": BENCHMARKS,
}
def _generate_alerts(reasons: list, save_rate: float, completion: float) -> list:
"""Generate alerts based on analysis."""
alerts = []
if completion < BENCHMARKS["survey_completion_good"]:
alerts.append({
"severity": "HIGH",
"type": "low_completion",
"message": f"Survey completion rate is {completion:.1f}%. Target is {BENCHMARKS['survey_completion_good']}%+. Make the single-question survey required before showing save offers.",
})
if save_rate < 5:
alerts.append({
"severity": "CRITICAL",
"type": "low_save_rate",
"message": f"Overall save rate is {save_rate:.1f}%. Offers likely do not match exit reasons. Rebuild reason-to-offer mapping.",
})
elif save_rate < BENCHMARKS["save_rate_good"]:
alerts.append({
"severity": "HIGH",
"type": "below_benchmark_save_rate",
"message": f"Save rate {save_rate:.1f}% is below benchmark of {BENCHMARKS['save_rate_good']}%. Test stronger offers.",
})
for r in reasons:
if r["percentage"] > BENCHMARKS["reason_concentration_critical"]:
alerts.append({
"severity": "CRITICAL",
"type": "reason_concentration",
"message": f"'{r['reason']}' accounts for {r['percentage']}% of cancellations. This indicates a systemic issue requiring product or leadership escalation.",
})
elif r["percentage"] > BENCHMARKS["reason_concentration_warning"]:
alerts.append({
"severity": "HIGH",
"type": "reason_concentration",
"message": f"'{r['reason']}' accounts for {r['percentage']}% of cancellations. Monitor trend and investigate root cause.",
})
if r["save_rate_pct"] < 5 and r["reason"] != "TESTING" and r["count"] >= 5:
alerts.append({
"severity": "HIGH",
"type": "ineffective_offer",
"message": f"Save rate for '{r['reason']}' is only {r['save_rate_pct']}%. The current offer for this reason is not working.",
})
return alerts
def format_text(result: dict) -> str:
"""Format results as human-readable text."""
lines = []
lines.append("=" * 60)
lines.append(f"EXIT SURVEY ANALYZER -- Period: {result['period']}")
lines.append("=" * 60)
s = result["summary"]
lines.append(f"\n--- Summary ---")
lines.append(f"Cancel Attempts: {s['total_cancel_attempts']:>8d}")
lines.append(f"Survey Responses: {s['survey_responses']:>8d}")
lines.append(f"Survey Completion: {s['survey_completion_rate_pct']:>8.1f}%")
lines.append(f"Customers Saved: {s['total_saved']:>8d}")
lines.append(f"Overall Save Rate: {s['overall_save_rate_pct']:>8.1f}%")
lines.append(f"MRR at Risk: ${s['total_mrr_at_risk']:>10,.2f}")
lines.append(f"MRR Saved: ${s['total_mrr_saved']:>10,.2f}")
lines.append(f"\n--- Reason Breakdown ---")
lines.append(f"{'Reason':<18} {'Count':>6} {'%':>6} {'Saved':>6} {'Save%':>7} {'MRR Risk':>10}")
for r in result["reason_breakdown"]:
lines.append(
f"{r['reason']:<18} {r['count']:>6d} {r['percentage']:>5.1f}% "
f"{r['saved']:>6d} {r['save_rate_pct']:>6.1f}% ${r['mrr_at_risk']:>8,.0f}"
)
ci = result["competitive_intelligence"]
if ci["top_competitors"]:
lines.append(f"\n--- Top Competitors Mentioned ---")
for c in ci["top_competitors"]:
lines.append(f" {c['name']}: {c['mentions']} mentions")
fg = result["feature_gaps"]
if fg["top_requested_features"]:
lines.append(f"\n--- Top Requested Features ---")
for f in fg["top_requested_features"]:
lines.append(f" {f['feature']}: {f['mentions']} mentions")
if result["alerts"]:
lines.append(f"\n--- Alerts ---")
for a in result["alerts"]:
lines.append(f"[{a['severity']}] {a['message']}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze exit survey responses to identify churn patterns and save offer effectiveness."
)
parser.add_argument("input_file", help="JSON file with exit survey response data")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
parser.add_argument("--period", default="current", help="Analysis period label (default: current)")
args = parser.parse_args()
try:
with open(args.input_file, "r") as f:
data = json.load(f)
except FileNotFoundError:
print(f"Error: File not found: {args.input_file}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {args.input_file}: {e}", file=sys.stderr)
sys.exit(1)
result = analyze_surveys(data, args.period)
if args.json:
print(json.dumps(result, indent=2))
else:
print(format_text(result))
if __name__ == "__main__":
main()
Related skills
FAQ
What churn types does it address?
Both voluntary churn where customers decide to leave and involuntary churn from payment failures.
How are save offers chosen?
It maps each exit reason from the survey to exactly one save offer, such as a discount for price, a pause for low usage, or free onboarding for complexity.