Billing & Subscription System - Feature Documentation
Owner: Jordan Lee Version: 1.2.3
| Owner | Last verified | Next verification due | Last CI run ID | Coverage % | Open risks |
|---|---|---|---|---|---|
| Jordan Lee | 2025-12-28 | 2026-01-27 | feat/stripe-connect-tests | 100% (Stripe Connect webhooks) | Auto-reload flows lack E2E coverage; wallet reconciliation not load-tested |
Verification
- CI jobs:
ci.ymlAPI + Cypress stages (last run: ci.yml#2187) - Commands (seeded CI DB + Stripe mocks; do not point to production tenants):
pnpm test:api --config jest.config.api.cjs --runTestsByPath tests/app/api/payments/create-checkout.test.ts tests/app/api/payments/cancel-subscription.test.ts tests/app/api/payments/customer-portal.test.ts tests/app/api/payments/subscription-status.test.ts tests/lib/middleware/billing/billing-guard.test.tspnpm cypress:run --spec "cypress/e2e/flows/01-signup-onboarding-payment.cy.ts","cypress/e2e/platform/trials-dashboard.cy.ts"pnpm db:health
- Test suites + environment:
tests/app/api/payments/create-checkout.test.ts,.../cancel-subscription.test.ts,.../customer-portal.test.ts,.../subscription-status.test.ts(Stripe mocked; CI seeded demo portal)tests/lib/middleware/billing/billing-guard.test.ts,tests/lib/utils/billingUtils.test.ts(unit guard coverage)tests/app/api/platform/trials/route.test.ts,.../abuse/route.test.ts,.../expiring/route.test.ts(trial lifecycle)cypress/e2e/flows/01-signup-onboarding-payment.cy.ts(staging sandbox payments; demo tenant only)cypress/e2e/platform/trials-dashboard.cy.ts(admin view for trials; staging demo data)
- Next verification due: 2026-01-04 (refresh CI run ID + coverage)
Summary
Petunia's billing & subscription stack combines a prepaid wallet, trust-based lead billing, automated warnings/dunning, configurable 7-day trials with smart value milestones (5 leads, 3 appointments, or $200 revenue), and in-app tier upgrades with Stripe proration. The system includes:
- Customer Portal: Stripe-hosted billing management (payment methods, invoices)
- Subscription Management: Full lifecycle (status, cancel, renewal handling)
- Invoice System: Commission-based invoicing with Stripe PDF and email delivery
- Stripe Connect: OAuth-based payment attribution for closure commissions
- Budget Enforcement:
billingGuardat all billable endpoints - Webhook Integration: Wallet top-ups, payment failures, Connect events
Every billable endpoint runs through billingGuard, wallet top-ups are credited via Stripe webhooks, admins can tune trial limits from the platform console, and upgrade nudges surface the moment a customer outgrows their tier.
Table of Contents
- Problem / Job-to-Be-Done
- Solution Overview
- Architecture
- Core Components
- Lead Billing Trust Ladder
- Wallet & Usage Billing
- Proactive Warnings
- Dunning & Grace Periods
- Webhook Handlers
- Cron Jobs
- Trial System
- Tier Upgrades
- Customer Portal
- Subscription Management
- Invoice System
- Stripe Connect (Payment Attribution)
- API Reference
- Environment Variables
- Operational Runbooks
- Testing & Verification
- Troubleshooting
- File Reference
- Version History
Problem / Job-to-Be-Done
User Pain Points
| Pain Point | Impact | Solution |
|---|---|---|
| Unexpected charges | Bill shock, churn | Prepaid wallet with real-time balance visibility |
| Service interruption | Lost leads, missed opportunities | Proactive warnings at 100%/50%/20%/0% thresholds |
| Complex billing | Confusion, support tickets | Single wallet for all usage types |
| Payment failures | Service degradation | Auto-reload with dunning sequence |
| Trust issues (new users) | Revenue risk | Trust ladder from immediate → monthly billing |
Business Impact
| Metric | Risk if Unsolved | Target After Implementation |
|---|---|---|
| Revenue leakage | Unbilled usage | 0% unbilled leads/usage |
| Payment failures | Lost revenue | <1% unrecovered after dunning |
| Support tickets | High operational cost | <2% billing-related tickets |
| Customer churn | Bill shock | Proactive warnings prevent surprises |
Job Stories
When I'm using Petunia's lead generation services, I want predictable, prepaid billing, So that I never receive surprise charges at month-end.
When my wallet balance is running low, I want proactive notifications scaled to my budget, So that I can add funds before service interruption.
When I've been a reliable customer for months, I want less frequent billing settlements, So that I can manage cash flow more efficiently.
Solution Overview
Billing Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ BILLING SYSTEM FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Wallet │───▶│ Billing │───▶│ Usage │───▶│ Warning │ │
│ │ Top-Up │ │ Guard │ │ Charged │ │ System │ │
│ └──────────┘ └─────────────┘ └────────────┘ └──────────────┘ │
│ │ │ │ │ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Stripe │ │ Block if │ │ Transaction│ │ Email/SMS/ │ │
│ │ Checkout │ │ Budget │ │ Logged │ │ Call │ │
│ │ │ │ Exceeded │ │ │ │ │ │
│ └──────────┘ └─────────────┘ └────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ AUTO-RELOAD LOOP │ │
│ │ Balance < Threshold → Stripe Charge → Wallet Credit → Continue │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Capabilities
| Capability | Implementation | Status |
|---|---|---|
| Prepaid Wallet | CreditWallet model with cents precision | ✅ Complete |
| Lead Billing | Trust-based schedule (immediate→monthly) | ✅ Complete |
| Usage Billing | Real-time wallet charges (SMS, voice, AI) | ✅ Complete |
| Auto-Reload | Stripe PaymentMethod with configurable threshold | ✅ Complete |
| Budget Enforcement | BillingGuard blocks at 0 balance | ✅ Complete |
| Proactive Warnings | Proportional thresholds (email/SMS/call) | ✅ Complete |
| Dunning Sequence | Day 0/3/7/14 escalation | ✅ Complete |
| Grace Period | 1-hour buffer at $0 balance | ✅ Complete |
| Audit Trail | WalletTransaction for all movements | ✅ Complete |
| Webhook Integration | Stripe payment_intent.succeeded | ✅ Complete |
Architecture
System Diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser/App) │
│ Wallet Dashboard • Top-Up Flow • Auto-Reload Settings • Usage History │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ API ROUTES (Next.js) │
│ /api/payments/wallet • /api/payments/wallet/auto-reload │
│ /api/payments/create-checkout • /api/payments/stripe/webhook │
│ /api/leads (billing check) • /api/cron/usage-warnings │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ CreditWallet │ │ BillingGuard │
│ Service │ │ Middleware │
│ • getBalance │ │ • checkBudget │
│ • charge │ │ • blockIfExceed │
│ • topUp │ │ • getAlertLevel │
│ • autoReload │ │ │
└───────────────────┘ └───────────────────┘
│ │
▼ ▼
┌───────────────────────────────────────────┐
│ Prisma (PostgreSQL) │
│ CreditWallet • WalletTransaction │
│ UsageWarning • Client │
└───────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ Stripe │
│ PaymentIntents • PaymentMethods │
│ Webhooks • Customer Portal │
└───────────────────────────────────────────┘
Data Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ CreditWallet │
├─────────────────────────────────────────────────────────────────────────────┤
│ id │ cuid │
│ clientId │ FK → Client.id (unique) │
├────────────────────────┼────────────────────────────────────────────────────┤
│ CASH BALANCE │
│ cashBalanceCents │ Current balance (cents) │
│ lifetimeTopUpCents │ Total ever added │
│ lifetimeSpentCents │ Total ever spent │
├────────────────────────┼────────────────────────────────────────────────────┤
│ AUTO-RELOAD │
│ autoReloadEnabled │ Boolean │
│ autoReloadThreshold │ Trigger when below (default: $10) │
│ autoReloadAmount │ Amount to add (default: $5, min: $5) │
│ autoReloadFailCount │ Consecutive failures (pause after 3) │
├────────────────────────┼────────────────────────────────────────────────────┤
│ LEAD BILLING │
│ leadCredits │ Prepaid lead credits │
│ leadCreditCostCents │ $27 per lead │
│ leadBillingSchedule │ immediate | daily | weekly | monthly │
│ lifetimeLeadsUsed │ Total leads (determines trust level) │
│ maxUnpaidLeadsCents │ $135 (5 leads) before blocking │
│ unpaidLeadBalanceCents│ Current unpaid balance │
├────────────────────────┼────────────────────────────────────────────────────┤
│ GRACE PERIOD │
│ gracePeriodStartedAt │ When $0 threshold hit │
│ gracePeriodEndsAt │ 1 hour window │
│ negativeBalanceCents │ Charges during grace period │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ WalletTransaction │
├─────────────────────────────────────────────────────────────────────────────┤
│ id │ cuid │
│ walletId │ FK → CreditWallet.id │
│ type │ top_up | usage_charge | lead_charge | auto_reload │
│ amountCents │ Positive (credit) or negative (debit) │
│ balanceAfterCents │ Running balance │
│ usageType │ sms | voice | ai | lead │
│ stripePaymentId │ For top-ups (idempotency key) │
│ description │ Human-readable │
│ createdAt │ Timestamp │
└─────────────────────────────────────────────────────────────────────────────┘
Core Components
CreditWalletService
Location: lib/services/billing/credit-wallet-service.ts
| Method | Purpose |
|---|---|
getOrCreateWallet(clientId) | Get or create wallet for client |
getBalance(clientId) | Get current balance and status |
getWalletStatus(clientId) | Full status including auto-reload settings |
addFunds(clientId, amountCents, stripePaymentId, description) | Credit wallet (idempotent) |
chargeUsage(clientId, usageType, amountCents, quantity) | Debit for usage |
chargeLead(clientId) | Process lead billing based on trust level |
autoReload(clientId) | Attempt auto-reload via Stripe |
startGracePeriod(clientId) | Begin 1-hour grace period |
BillingGuard Middleware
Location: lib/middleware/billing/billing-guard.ts
| Function | Purpose |
|---|---|
checkBillingGuard(clientId, usageType) | Check if operation should proceed |
chargeWalletForLead(clientId) | Lead-specific billing check |
getAlertLevel(percent) | Calculate warning level (safe→blocked) |
Key Interfaces
interface WalletBalance {
cashBalanceCents: number;
cashBalanceDollars: number;
leadCredits: number;
unpaidLeadBalanceCents: number;
negativeBalanceCents: number;
isInGracePeriod: boolean;
gracePeriodEndsAt: Date | null;
autoReloadEnabled: boolean;
leadBillingSchedule: string;
lifetimeLeadsUsed: number;
eligibleSchedules: string[];
}
interface LeadChargeResult {
success: boolean;
charged: boolean;
leadCostCents: number;
billingSchedule: string;
unpaidBalanceCents: number;
shouldBlockNewLeads: boolean; // CRITICAL: If true, block lead creation
error?: string;
}
Lead Billing Trust Ladder
The system implements a trust-based billing schedule that reduces billing frequency as customers prove reliability:
| Trust Level | Minimum Leads | Schedule | Unpaid Limit |
|---|---|---|---|
| New User | 0 | Immediate (per-lead) | $27 (1 lead) |
| Trusted | 10 | Daily settlement | $135 (5 leads) |
| Established | 100 | Weekly settlement | $270 (10 leads) |
| Premium | 1,000 | Monthly (by request) | $540 (20 leads) |
Trust Level Thresholds
const LEAD_TRUST_LEVELS = {
IMMEDIATE: { minLeads: 0, schedule: 'immediate' },
DAILY: { minLeads: 10, schedule: 'daily' },
WEEKLY: { minLeads: 100, schedule: 'weekly' },
MONTHLY: { minLeads: 1000, schedule: 'monthly' },
};
Blocking Logic
Lead creation is blocked when:
shouldBlockNewLeads: truereturned fromchargeWalletForLead()- Unpaid lead balance exceeds
maxUnpaidLeadsCents - Wallet balance is $0 AND grace period expired AND no auto-reload
Wallet & Usage Billing
Usage Types
| Type | Unit Cost | Charged When |
|---|---|---|
| SMS | ~$0.01/segment | Immediately after send |
| Voice | ~$0.02/minute | After call completion |
| AI | ~$0.005/token | After generation |
| Lead | $27/lead | Per schedule (immediate/daily/weekly) |
Billing Enforcement Points
-
Lead API (
/app/api/leads/route.ts:356-376)- Calls
chargeWalletForLead(clientId)BEFORE creating lead - Returns HTTP 402 if
shouldBlockNewLeads: true
- Calls
-
Lead Service (
lib/services/lead/leadService.prisma.ts:319-345)- Looks up
business.clientIdfirst (wallets keyed to Client.id) - Throws
BILLING_LIMIT_EXCEEDEDerror if blocked
- Looks up
-
Voice/SMS Routes
- Call
BillingGuard.checkBillingGuard()before processing - Return 402 if budget exceeded
- Call
Proactive Warnings
Warning Thresholds
Thresholds are proportional to the user's auto-reload setting:
| Percentage | Default ($10 threshold) | High-Value ($500 threshold) |
|---|---|---|
| 100% | $10 | $500 |
| 50% | $5 | $250 |
| 20% | $2 | $100 |
| 0% | $0 | $0 |
Notification Channels
| Threshold | SMS | Voice Call | |
|---|---|---|---|
| 100% (first warning) | ✅ | - | - |
| 50% | ✅ | - | - |
| 20% | ✅ | ✅ | - |
| 0% (critical) | ✅ | ✅ | ✅ |
Implementation
// Get proportional thresholds based on user's auto-reload setting
function getWalletBalanceThresholds(autoReloadThresholdCents: number | null): number[] {
const userThreshold = autoReloadThresholdCents || 1000; // $10 default
const effectiveThreshold = Math.max(userThreshold, 100); // min $1
return [100, 50, 20, 0].map((pct) =>
Math.round((effectiveThreshold * pct) / 100)
);
}
// SMS triggers at 20% threshold
function getSmsThreshold(autoReloadThresholdCents: number | null): number {
const userThreshold = autoReloadThresholdCents || 1000;
return Math.round((Math.max(userThreshold, 100) * 20) / 100);
}
Dunning & Grace Periods
Dunning Sequence
| Day | Action | Channel |
|---|---|---|
| Day 0 | Payment failed | Webhook triggers immediate email |
| Day 3 | First reminder | |
| Day 7 | Escalation warning | Email + SMS |
| Day 14 | Final notice + suspension | Email + SMS + Service paused |
Grace Period
When balance hits $0:
- Grace period starts (1 hour by default)
- Usage continues but logged to
negativeBalanceCents - Urgent notifications sent
- After grace period expires: all billable operations blocked
const GRACE_PERIOD_HOURS = 1;
// Starting grace period
await prisma.creditWallet.update({
where: { id: wallet.id },
data: {
gracePeriodStartedAt: now,
gracePeriodEndsAt: new Date(now.getTime() + GRACE_PERIOD_HOURS * 60 * 60 * 1000),
},
});
Webhook Handlers
Stripe Webhook (/api/payments/stripe/webhook)
| Event | Handler |
|---|---|
payment_intent.succeeded | Credit wallet for top-ups (metadata.type === 'wallet_top_up') |
invoice.payment_failed | Start dunning sequence |
customer.subscription.updated | Sync subscription status |
Wallet Top-Up Webhook (Idempotent)
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
if (paymentIntent.metadata?.type === 'wallet_top_up') {
const clientId = paymentIntent.metadata.clientId;
// Idempotency check - prevent double-crediting
const existingTransaction = await prisma.walletTransaction.findFirst({
where: { stripePaymentId: paymentIntent.id },
});
if (existingTransaction) return;
await CreditWalletService.addFunds(
clientId,
paymentIntent.amount,
paymentIntent.id,
'Wallet top-up via checkout'
);
}
break;
}
Cron Jobs
| Job | Schedule | Purpose |
|---|---|---|
| usage-warnings | Every 15 min | Send proactive balance/usage warnings |
| lead-settlement | Daily 6 AM | Settle daily/weekly lead billing |
| dunning | Daily 8 AM | Process dunning sequence (Day 3/7/14) |
Cron Configuration (vercel.json)
{
"crons": [
{ "path": "/api/cron/usage-warnings", "schedule": "*/15 * * * *" },
{ "path": "/api/cron/lead-settlement", "schedule": "0 6 * * *" },
{ "path": "/api/cron/dunning", "schedule": "0 8 * * *" }
]
}
Trial System
Overview
New customers get a 7-day trial with configurable usage limits and value-based milestones. The trial system tracks real business value (leads, appointments, revenue) to determine conversion readiness.
Configuration Model (PlatformTrialConfig)
┌─────────────────────────────────────────────────────────────────────────────┐
│ PlatformTrialConfig │
├─────────────────────────────────────────────────────────────────────────────┤
│ id │ 'default' (singleton) │
│ defaultTrialDays │ 7 (standard trial period) │
│ extensionDays │ 7 (days added when value threshold met) │
│ maxTrialDays │ 21 (maximum possible trial duration) │
├────────────────────────────────────────────────────────────────────────────┤
│ USAGE LIMITS (Trial Period) │
│ smsLimit │ 100 messages │
│ voiceMinutesLimit │ 30 minutes │
│ aiRequestsLimit │ 500 requests │
├────────────────────────────────────────────────────────────────────────────┤
│ VALUE THRESHOLDS (Conversion Triggers) │
│ leadThreshold │ 5 leads → proves lead generation value │
│ appointmentThreshold │ 3 appointments → proves booking value │
│ revenueThreshold │ 20000 cents ($200) → proves revenue value │
├────────────────────────────────────────────────────────────────────────────┤
│ milestoneMessages │ JSON with celebration messages per milestone │
│ updatedAt │ Last config update timestamp │
│ updatedBy │ Admin who last modified config │
└─────────────────────────────────────────────────────────────────────────────┘
Trial Milestones
| Milestone | Trigger | Message |
|---|---|---|
first_lead | 1 lead created | "Your first lead! Petunia is working." |
three_leads | 3 leads created | "3 leads! You're on track." |
five_leads | 5 leads created | "5 leads generated! You've proven the value." |
first_appointment | 1 appointment booked | "First booking! Real value." |
three_appointments | 3 appointments booked | "3 appointments booked! You're seeing results." |
revenue_threshold | $200+ tracked | "Revenue tracked! Your business is growing." |
value_threshold | Any threshold met | "You've proven the value. Lock in your growth." |
trial_extended | Extension granted | "Great progress! Your trial has been extended." |
Hybrid Extension Logic
When a customer hits ANY value threshold during trial:
value_thresholdmilestone recorded- If
totalTrialDays < maxTrialDays, trial automatically extends byextensionDays - Maximum 3 extensions (7 → 14 → 21 days)
- Extension creates
trial_extendedmilestone with celebration notification
Trial Value Tracking
Lead tracking (lib/services/lead/leadService.prisma.ts):
// After lead creation, track trial milestone
if (business?.clientId) {
trackTrialLead(business.clientId).then((result) => {
if (result.achieved && result.milestone) {
logger.info('Trial milestone achieved on lead creation', {
clientId: business.clientId,
milestone: result.milestone.type,
});
}
});
}
Appointment tracking (lib/connections/appointments/appointmentService.ts):
// Called after appointment creation
await trackTrialAppointment(clientId);
Revenue tracking (lib/services/commission/valueDetectionService.ts):
// Called when commission detected
await trackTrialRevenue(clientId, amountCents);
Admin APIs
| Endpoint | Method | Purpose |
|---|---|---|
/api/platform/trial-config | GET | Get current trial configuration |
/api/platform/trial-config | PUT | Update trial configuration (admin only) |
/api/platform/trial-spend | GET | Get platform-wide trial spend statistics |
Trial Config Update Example
PUT /api/platform/trial-config
{
"smsLimit": 150,
"leadThreshold": 7,
"milestoneMessages": {
"firstLead": "Welcome to Petunia! Your first lead is here."
}
}
Tier Upgrades
Overview
In-app tier upgrades with Stripe proration provide a seamless upgrade experience without redirecting to Stripe's customer portal.
Pricing Tiers
| Tier | Monthly | Limits | Commission Rates |
|---|---|---|---|
| Starter | $49 | 500 SMS, 60 voice, 1000 AI | 15% booking, 10% closure |
| Growth | $149 | 2000 SMS, 200 voice, 5000 AI | 12% booking, 8% closure |
| Scale | $349 | 5000 SMS, 500 voice, 15000 AI | 10% booking, 6% closure |
| Enterprise | $799 | Unlimited | 8% booking, 4% closure |
UpgradeService
Location: lib/services/upgrade-service.ts
| Method | Purpose |
|---|---|
getUpgradeOptions(clientId, currentTier) | Get available upgrade tiers with benefits comparison |
calculateUpgradeROI(clientId, currentTier, targetTier) | Calculate break-even analysis |
shouldPromptUpgrade(clientId) | Check if upgrade nudge should show |
getProrationPreview(subscriptionId, targetTier, billing) | Get Stripe proration calculation |
executeUpgrade(clientId, targetTier, billing) | Execute the upgrade with Stripe |
Upgrade API
GET /api/payments/upgrade - Get upgrade options
{
"success": true,
"currentTier": "starter",
"currentPlan": "Starter",
"atHighestTier": false,
"options": [
{
"tier": "growth",
"name": "Growth",
"monthlyPrice": 149,
"benefits": [
"1500 more SMS messages/month",
"3.0% lower booking commission"
]
}
],
"shouldPromptUpgrade": true,
"upgradeReason": "You're at 85% of your SMS limit",
"potentialSavings": 45
}
POST /api/payments/upgrade - Execute upgrade
// Request
{
"targetTier": "growth",
"billing": "monthly",
"confirm": true
}
// Response
{
"success": true,
"message": "Successfully upgraded to Growth plan!",
"previousTier": "starter",
"newTier": "growth",
"effectiveDate": "2025-12-04T10:30:00Z"
}
ROI Projection
The system calculates break-even analysis for upgrade decisions:
{
currentTier: "starter",
targetTier: "growth",
priceDifference: 100, // $100/mo more
additionalLimits: {
smsMessages: 1500, // 1500 more SMS
voiceMinutes: 140 // 140 more minutes
},
potentialOverageSavings: 45, // Overage fees saved
commissionSavings: {
bookingRateDrop: 0.03, // 3% lower booking commission
closureRateDrop: 0.02 // 2% lower closure commission
},
breakEvenAppointments: 8, // 8 appointments to justify upgrade
recommendation: "Just 8 appointments/month cover the upgrade cost through commission savings."
}
Upgrade Prompts
The system automatically prompts upgrades when:
- Usage exceeds 80% of tier limits
- Overage charges would exceed tier price difference
- Commission savings justify the upgrade
Customer Portal
Overview
Petunia integrates with Stripe's hosted billing portal, allowing customers to manage their payment methods, view invoices, and update billing information without building custom UI.
Portal Session API
Location: app/api/payments/customer-portal/route.ts
POST /api/payments/customer-portal - Create portal session
// Response
{
"url": "https://billing.stripe.com/session/xxx"
}
Portal Capabilities
| Feature | Description |
|---|---|
| Payment Methods | Add, update, remove cards |
| Invoice History | View and download all invoices |
| Billing Info | Update billing address, tax ID |
| Subscription View | See current plan details |
Implementation
// Create portal session for authenticated user
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env['NEXT_PUBLIC_APP_URL']}/billing`,
});
Customer ID Resolution
The portal uses two-stage lookup for stripeCustomerId:
- Check session context (
session.user.stripeCustomerId) - Fallback to database lookup (
prisma.user.findUnique)
Subscription Management
Overview
Full subscription lifecycle management including status retrieval, cancellation, and renewal handling.
Subscription Status API
Location: app/api/payments/subscription-status/route.ts
GET /api/payments/subscription-status - Get subscription details
{
"id": "sub_xxx",
"stripeSubscriptionId": "sub_stripe_xxx",
"status": "active",
"planId": "growth",
"currentPeriodStart": "2025-12-01T00:00:00Z",
"currentPeriodEnd": "2026-01-01T00:00:00Z",
"cancelAtPeriodEnd": false,
"canceledAt": null,
"trialEnd": null
}
Subscription States
| Status | Meaning | User Access |
|---|---|---|
active | Payment current, full access | ✅ Full |
trialing | In trial period | ✅ Full (with limits) |
past_due | Payment failed, in dunning | ⚠️ Limited |
canceled | Subscription ended | ❌ None |
incomplete | Setup not finished | ⚠️ Limited |
Cancel Subscription API
Location: app/api/payments/cancel-subscription/route.ts
POST /api/payments/cancel-subscription - Cancel subscription
// Request
{
"cancelImmediately": false,
"reason": "Switching to competitor"
}
// Response
{
"success": true,
"canceledAt": null,
"cancelAtPeriodEnd": true,
"currentPeriodEnd": "2026-01-01T00:00:00Z"
}
Cancellation Options
| Option | Effect |
|---|---|
cancelImmediately: false | Access until period end, then cancel |
cancelImmediately: true | Immediate cancellation, no refund |
Database Sync
On cancellation:
- Update
Subscription.statusandcancelAtPeriodEnd - Update
User.subscriptionStatus - Log cancellation reason for churn analysis
Invoice System
Overview
Commission-based invoicing for clients with automatic generation, Stripe PDF creation, and email delivery.
InvoiceService
Location: lib/services/commission/invoiceService.ts
| Method | Purpose |
|---|---|
generateMonthlyInvoice(options) | Generate invoice for a client/month |
createStripeInvoice(...) | Create invoice in Stripe with PDF |
getClientInvoices(clientId) | List invoices for a client |
getInvoice(invoiceId) | Get single invoice details |
markAsPaid(invoiceId) | Mark invoice as paid |
voidInvoice(invoiceId) | Void invoice (Stripe + DB) |
generateAllMonthlyInvoices(month) | Batch generate for all clients |
Invoice Data Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ Invoice │
├─────────────────────────────────────────────────────────────────────────────┤
│ id │ Unique identifier │
│ invoiceNumber │ INV-{PREFIX}-{YEAR}-{SEQ} │
│ clientId │ FK → Client │
│ billingPeriodStart │ First day of month │
│ billingPeriodEnd │ Last day of month │
├────────────────────────┼───────────────────────────────────────────────────┤
│ AMOUNTS │
│ amount │ Subtotal (pre-tax) │
│ taxRate │ Tax percentage │
│ taxAmount │ Calculated tax │
│ totalAmount │ Final amount due │
├────────────────────────┼───────────────────────────────────────────────────┤
│ STATUS │
│ status │ draft | sent | paid | cancelled │
│ dueDate │ Payment due date │
│ paidDate │ When payment received │
├────────────────────────┼───────────────────────────────────────────────────┤
│ STRIPE INTEGRATION │
│ stripeInvoiceId │ Stripe invoice ID │
│ invoiceUrl │ Hosted invoice URL │
│ invoicePdf │ PDF download URL │
└─────────────────────────────────────────────────────────────────────────────┘
Invoice Line Items
Invoices include detailed commission breakdown:
| Item Type | Description | Calculation |
|---|---|---|
| Lead Fees | Per-lead commission | leadCount × $5 |
| Booking Fees | Appointment commission | bookingCount × rate |
| Closure Fees | Revenue-based commission | closureCount × % |
Stripe Invoice Integration
// Create invoice items
for (const item of items) {
await stripe.invoiceItems.create({
customer: stripeCustomerId,
amount: Math.round(item.amount * 100), // cents
currency: 'usd',
description: item.description,
});
}
// Create and finalize invoice
const stripeInvoice = await stripe.invoices.create({
customer: stripeCustomerId,
collection_method: autoCharge ? 'charge_automatically' : 'send_invoice',
days_until_due: 15,
});
await stripe.invoices.finalizeInvoice(stripeInvoice.id);
await stripe.invoices.sendInvoice(stripeInvoice.id);
Batch Invoice Generation
Monthly cron job generates invoices for all clients with activity:
// Find clients with commission events in target month
const clientsWithActivity = await prisma.commissionEvent.findMany({
where: {
eventTimestamp: { gte: startDate, lt: endDate },
status: { not: 'cancelled' },
},
select: { clientId: true },
distinct: ['clientId'],
});
// Generate invoice for each
for (const { clientId } of clientsWithActivity) {
await invoiceService.generateMonthlyInvoice({
clientId,
month: targetMonth,
sendEmail: true,
});
}
Stripe Connect (Payment Attribution)
Overview
Stripe Connect enables Petunia to receive payment webhooks from clients' own Stripe accounts. This powers the closure commission system by attributing payments to conversations/quotes that Petunia helped generate.
How It Works
┌─────────────────────────────────────────────────────────────────────────────┐
│ STRIPE CONNECT FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Client │───▶│ OAuth │───▶│ Webhook │───▶│ Commission │ │
│ │ Stripe │ │ Connect │ │ Received │ │ Attributed │ │
│ └──────────┘ └─────────────┘ └────────────┘ └──────────────┘ │
│ │ │ │ │ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Customer Client links Payment from Level 2/3 │
│ pays via their Stripe customer is closure │
│ client's to Petunia detected commission │
│ checkout recorded │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
OAuth Flow
Step 1: Initiate Connection
POST /api/integrations/stripe-connect
// Response
{
"success": true,
"data": {
"oauthUrl": "https://connect.stripe.com/oauth/authorize?..."
}
}
Step 2: User Authorizes in Stripe
User is redirected to Stripe, authorizes Petunia to receive webhooks.
Step 3: OAuth Callback
GET /api/integrations/stripe-connect/callback?code=xxx&state=xxx
- Exchanges authorization code for access token
- Stores
stripeConnectAccountIdon Client record - Creates
CommissionIntegrationrecord for tracking
Connection Status API
GET /api/integrations/stripe-connect?clientId=xxx
{
"success": true,
"data": {
"clientId": "cln_xxx",
"status": "connected",
"account": {
"id": "acct_xxx",
"businessName": "Client Business",
"chargesEnabled": true,
"payoutsEnabled": true
},
"benefits": [
"Automatic payment detection from your Stripe account",
"Higher commission rates attributed to Petunia-assisted sales",
"Detailed ROI reporting on Petunia performance"
]
}
}
Webhook Handler
Location: app/api/integrations/stripe-connect/webhook/route.ts
Handles events from connected accounts:
| Event | Handler |
|---|---|
payment_intent.succeeded | Primary payment detection |
charge.succeeded | Fallback for direct charges |
invoice.paid | Subscription payments |
checkout.session.completed | Checkout flow payments |
Payment Attribution (valueDetectionService)
When a payment is received, the webhook calls valueDetectionService.detectValueFromPayment():
const result = await valueDetectionService.detectValueFromPayment(clientId, {
paymentId: paymentIntent.id,
amount: paymentIntent.amount / 100,
currency: paymentIntent.currency.toUpperCase(),
customerEmail: paymentIntent.receipt_email,
customerPhone: paymentIntent.metadata?.customer_phone,
paidAt: new Date(paymentIntent.created * 1000),
});
Attribution Levels
| Level | Match Type | How Matched |
|---|---|---|
| Level 1 | Lead only | Lead exists, no quote/payment |
| Level 2 | Quote match | Quote amount matches payment |
| Level 3 | Conversation match | Customer email/phone matches conversation |
Commission Calculation
When a payment is attributed:
CommissionEventcreated withtype: 'closure'- Commission calculated based on tier rates (4-10%)
- Added to monthly invoice for client
Environment Variables
| Variable | Purpose |
|---|---|
STRIPE_CONNECT_CLIENT_ID | OAuth client ID for Stripe Connect |
STRIPE_CONNECT_WEBHOOK_SECRET | Webhook signature verification |
API Reference
Billing APIs
| Endpoint | Method | Purpose |
|---|---|---|
/api/billing/subscription | GET | Get subscription details for billing page and feature gates |
/api/billing/invoices | GET | Get invoice history for billing page |
Wallet APIs
| Endpoint | Method | Purpose |
|---|---|---|
/api/payments/wallet | GET | Get wallet status |
/api/payments/wallet | POST | Add funds (creates PaymentIntent) |
/api/payments/wallet/auto-reload | GET | Get auto-reload settings |
/api/payments/wallet/auto-reload | PUT | Update auto-reload settings |
Response Examples
GET /api/billing/subscription
{
"plan": "growth",
"tier": "GROWTH",
"status": "active",
"isTrialing": false,
"trialDaysRemaining": 0,
"paymentMethod": {
"brand": "visa",
"last4": "4242",
"expMonth": 12,
"expYear": 2025
},
"currentPeriodStart": "2025-12-01T00:00:00Z",
"currentPeriodEnd": "2026-01-01T00:00:00Z",
"cancelAtPeriodEnd": false,
"amount": 149,
"currency": "USD"
}
GET /api/billing/invoices
[
{
"id": "inv_001",
"invoiceNumber": "INV-2024-001",
"date": "2025-12-01T00:00:00Z",
"amount": 9900,
"currency": "USD",
"status": "paid",
"dueDate": "2025-12-15T00:00:00Z",
"paidDate": "2025-12-10T00:00:00Z",
"downloadUrl": "https://stripe.com/invoice/123/pdf",
"viewUrl": "https://stripe.com/invoice/123",
"billingPeriod": {
"start": "2025-11-01T00:00:00Z",
"end": "2025-11-30T00:00:00Z"
}
}
]
GET /api/payments/wallet
{
"success": true,
"wallet": {
"cashBalanceCents": 5000,
"cashBalanceDollars": 50.00,
"leadCredits": 0,
"autoReloadEnabled": true,
"autoReloadThresholdCents": 1000,
"autoReloadAmountCents": 2500,
"leadBillingSchedule": "daily",
"lifetimeLeadsUsed": 47,
"isInGracePeriod": false
}
}
POST /api/payments/wallet (Add Funds)
// Request
{ "amountCents": 5000 }
// Response
{
"success": true,
"clientSecret": "pi_xxx_secret_xxx",
"paymentIntentId": "pi_xxx"
}
Environment Variables
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | Yes | Stripe API key |
STRIPE_WEBHOOK_SECRET | Yes | Webhook signature verification |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Yes | Client-side Stripe |
CRON_SECRET | Yes | Cron job authentication |
Operational Runbooks
Manual Wallet Credit
# Via Prisma Studio
pnpm prisma studio
# Navigate to CreditWallet, update cashBalanceCents
# Via API (admin only)
curl -X POST /api/admin/wallet/credit \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"clientId": "xxx", "amountCents": 5000, "reason": "Manual adjustment"}'
Test Webhook Locally
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhooks
stripe listen --forward-to localhost:3000/api/payments/stripe/webhook
# Trigger test event
stripe trigger payment_intent.succeeded
Dry-Run Cron Jobs
# Usage warnings
curl -X POST http://localhost:3000/api/cron/usage-warnings \
-H "Authorization: Bearer $CRON_SECRET"
# Lead settlement
curl -X POST http://localhost:3000/api/cron/lead-settlement \
-H "Authorization: Bearer $CRON_SECRET"
Testing & Verification
Test Results (December 4, 2025)
| Test Suite | Command | Status | Tests |
|---|---|---|---|
| TypeScript Strict | pnpm typecheck:strict | ✅ PASS | 0 errors |
| Lead API | jest tests/app/api/leads/route.test.ts | ✅ PASS | 2/2 |
| Lead Service | jest tests/lib/services/lead/ | ✅ PASS | 14/14 |
| Payment APIs | jest tests/app/api/payments/ | ✅ PASS | 105/105 |
| Commission/Billing | jest tests/app/api/commission/ | ✅ PASS | 87/87 |
Total Billing-Related Tests: 208 passing
Pre-Existing Failures (NOT related to billing)
| Test File | Failures | Root Cause | Status |
|---|---|---|---|
googleConnectionService.test.ts | 2/23 | encrypt() signature changed for multi-tenant support | Quarantined |
Justification: The Google connection test failures are due to the encryption system being updated to require tenantId context (e.g., encrypt('token', { tenantId })). The tests were written before this change and expect encrypt('token'). This is unrelated to billing functionality.
Coverage Notes
lib/services/lead/leadService.prisma.ts: trackTrialLead wired at line 383lib/services/trial/trialService.ts: Full milestone tracking implementedlib/services/billing/credit-wallet-service.ts: All wallet operations coveredlib/middleware/billing/billing-guard.ts: Budget enforcement tested via lead/payment APIs
Webhook Test Commands
# Test wallet top-up webhook
stripe trigger payment_intent.succeeded \
--add payment_intent:metadata.type=wallet_top_up \
--add payment_intent:metadata.clientId=test_client_id
Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Lead creation always fails | businessId passed instead of clientId | Fixed in b19a4d933 - now looks up business.clientId |
| Wallet not credited after checkout | Webhook not handling wallet_top_up | Fixed in 14e00bc35 - added PaymentIntent handler |
| High-value users not getting SMS warnings | Hardcoded $2 threshold | Fixed in b19a4d933 - now proportional to auto-reload |
| Double wallet credit | Missing idempotency check | Fixed - checks stripePaymentId before crediting |
Debug Queries
-- Check wallet status
SELECT * FROM credit_wallet WHERE client_id = 'xxx';
-- Check recent transactions
SELECT * FROM wallet_transaction
WHERE wallet_id = 'xxx'
ORDER BY created_at DESC
LIMIT 20;
-- Check warning history
SELECT * FROM usage_warning
WHERE client_id = 'xxx'
ORDER BY created_at DESC;
File Reference
Core Services
| File | Purpose |
|---|---|
lib/services/billing/credit-wallet-service.ts | Wallet operations |
lib/middleware/billing/billing-guard.ts | Budget enforcement |
lib/middleware/billing/types.ts | Type definitions |
lib/middleware/billing/index.ts | Public exports |
API Routes
| File | Purpose |
|---|---|
app/api/billing/subscription/route.ts | Subscription details for billing page and feature gates |
app/api/billing/invoices/route.ts | Invoice history for billing page |
app/api/payments/wallet/route.ts | Wallet status & top-up |
app/api/payments/wallet/auto-reload/route.ts | Auto-reload settings |
app/api/payments/stripe/webhook/route.ts | Stripe webhooks |
app/api/leads/route.ts | Lead creation (billing check) |
Cron Jobs
| File | Purpose |
|---|---|
app/api/cron/usage-warnings/route.ts | Proactive warnings |
app/api/cron/lead-settlement/route.ts | Daily/weekly settlement |
app/api/cron/dunning/route.ts | Payment failure sequence |
Trial System
| File | Purpose |
|---|---|
lib/services/trial/trialService.ts | Trial management & milestones |
lib/services/trial/index.ts | Public exports |
lib/types/trial.ts | Trial type definitions |
app/api/platform/trial-config/route.ts | Admin trial configuration |
app/api/platform/trial-spend/route.ts | Platform trial spend stats |
Tier Upgrades
| File | Purpose |
|---|---|
lib/services/upgrade-service.ts | Upgrade logic & ROI calculations |
app/api/payments/upgrade/route.ts | Upgrade preview & execution |
Customer Portal & Subscriptions
| File | Purpose |
|---|---|
app/api/payments/customer-portal/route.ts | Stripe billing portal sessions |
app/api/payments/subscription-status/route.ts | Get subscription details |
app/api/payments/cancel-subscription/route.ts | Cancel subscription |
Invoice System
| File | Purpose |
|---|---|
lib/services/commission/invoiceService.ts | Full invoice lifecycle |
lib/services/commission/roiService.ts | ROI & commission calculations |
Stripe Connect
| File | Purpose |
|---|---|
app/api/integrations/stripe-connect/route.ts | OAuth initiation & status |
app/api/integrations/stripe-connect/callback/route.ts | OAuth callback handler |
app/api/integrations/stripe-connect/webhook/route.ts | Payment attribution webhooks |
lib/services/commission/valueDetectionService.ts | Payment→Commission attribution |
Database
| File | Purpose |
|---|---|
prisma/schema.prisma | CreditWallet, WalletTransaction, PlatformTrialConfig models |
Version History
| Version | Date | Changes |
|---|---|---|
| 1.2.3 | 2025-12-28 | Stripe Connect multi-party payout coverage complete |
| - Added payout.created webhook handler and tests | ||
| - Added transfer.reversed webhook handler and tests | ||
| - Added charge.refunded handler for refund tracking | ||
| - Added charge.dispute.created handler for dispute alerts | ||
| - Added charge.dispute.closed handler for dispute resolution | ||
| - All 13 Stripe Connect webhook events now covered | ||
| - MVP Contract Section 7.14 gap closed | ||
| 1.2.2 | 2025-12-23 | Added missing billing API endpoints |
- /api/billing/subscription for billing page and feature gate tier detection | ||
- /api/billing/invoices for invoice history | ||
| - Fixed webhook subscription.create to include all required Prisma fields | ||
| 1.2.1 | 2025-12-05 | Added verification snapshot, owner map reference, and explicit test/CI links |
| 1.2.0 | 2025-12-04 | Complete billing documentation |
| - Customer Portal (Stripe billing portal integration) | ||
| - Subscription Management (status, cancel, renewals) | ||
| - Invoice System (generation, PDF, Stripe integration) | ||
| - Stripe Connect (OAuth, payment attribution) | ||
| - Full file reference for all billing components | ||
| 1.1.0 | 2025-12-04 | Trial system & tier upgrades |
| - Trial system with PlatformTrialConfig model | ||
| - Value-based milestones (5 leads/3 apts/$200) | ||
| - Hybrid trial extension logic (7→14→21 days) | ||
| - trackTrialLead wired into lead creation | ||
| - Admin APIs for trial configuration | ||
| - In-app tier upgrades with Stripe proration | ||
| - ROI projection for upgrade decisions | ||
| - Upgrade prompts at 80% tier limit | ||
| 1.0.0 | 2025-12-04 | Initial production release |
| - Prepaid wallet with cents precision | ||
| - Trust-based lead billing (immediate→monthly) | ||
| - Proportional warning thresholds | ||
| - Dunning sequence (Day 0/3/7/14) | ||
| - Stripe webhook for wallet top-ups | ||
| - Grace period at $0 balance | ||
| - Budget enforcement at all billable touchpoints |
Completion Checklist
Core Billing
- CreditWallet model with all fields
- WalletTransaction audit trail
- CreditWalletService with all methods
- BillingGuard enforcement middleware
- Lead billing with clientId lookup (not businessId)
- Proportional warning thresholds
- SMS threshold proportional to user setting
- Stripe webhook for wallet_top_up
- Webhook idempotency check
- Usage warnings cron
- Lead settlement cron
- Dunning sequence cron
- Grace period handling
- Auto-reload functionality
- All API routes implemented
Trial System
- PlatformTrialConfig model with usage limits
- Value thresholds (5 leads / 3 appointments / $200)
- Milestone tracking (first_lead, three_leads, five_leads, etc.)
- Hybrid extension logic (7→14→21 days max)
- trackTrialLead wired in leadService.prisma.ts
- trackTrialAppointment in appointment service
- trackTrialRevenue in commission service
- Admin APIs for trial config (GET/PUT)
- Platform trial spend statistics API
Tier Upgrades
- UpgradeService with ROI calculations
- Stripe proration support
- Upgrade preview API (GET /api/payments/upgrade)
- Upgrade execution API (POST /api/payments/upgrade)
- Automatic upgrade prompts at 80% tier limit
- Commission savings calculations
Customer Portal & Subscriptions
- Customer Portal API (POST /api/payments/customer-portal)
- Subscription Status API (GET /api/payments/subscription-status)
- Cancel Subscription API (POST /api/payments/cancel-subscription)
- Immediate vs end-of-period cancellation
- Cancellation reason logging
Invoice System
- InvoiceService with full lifecycle
- Monthly invoice generation
- Stripe invoice creation with PDF
- Invoice email delivery
- Batch invoice generation cron
- Invoice voiding (Stripe + DB sync)
Stripe Connect (Payment Attribution)
- OAuth flow (initiate, callback)
- Connection status API
- Webhook handler for payment events
- valueDetectionService integration
- Level 2/3 closure commission attribution
- CommissionIntegration tracking
Verification
- TypeScript strict mode passing
- Production deployed