• Skip to main content
  • Skip to navigation
  • Skip to search
    Petunia™
    FeaturesPricingIntegrationsAboutContact
    Log inStart free trialSign up
    Loading
    Petunia™

    Reimagining customer communication for the modern business.

    Product

    • Features
    • Pricing
    • Integrations
    • Roadmap
    • What's New

    Resources

    • Help Center
    • Documentation
    • Guides
    • API Reference
    • Community
    • Support

    Company

    • About Us
    • Careers
    • Blog
    • Press
    • Contact

    © 2026 Gray Group International LLC. All rights reserved.·
    Made by gardenpatch 🌱

    Privacy PolicyTerms of ServiceCookie Policy

    Petunia™ is a trademark of Gray Group International LLC. The Petunia name, brand, product design, and content are proprietary. Unauthorized use, imitation, or copying is prohibited.

    Documentation

    BILLING_SYSTEM

    docs/features/BILLING_SYSTEM.md
    Docs homeGuidesSupport
    Quick links
    Start here
    How the docs are organized.
    Environment setup
    Configure env + run locally.
    Unified Inbox
    Inbox concepts & behavior.
    Voice AI setup
    Providers, Twilio, testing.
    Pricing model
    Source-of-truth pricing.
    Operations runbook
    How to operate safely.

    Billing & Subscription System - Feature Documentation

    Owner: Jordan Lee Version: 1.2.3

    OwnerLast verifiedNext verification dueLast CI run IDCoverage %Open risks
    Jordan Lee2025-12-282026-01-27feat/stripe-connect-tests100% (Stripe Connect webhooks)Auto-reload flows lack E2E coverage; wallet reconciliation not load-tested

    Verification

    • CI jobs: ci.yml API + 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.ts
      • pnpm 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: billingGuard at 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

    1. Problem / Job-to-Be-Done
    2. Solution Overview
    3. Architecture
    4. Core Components
    5. Lead Billing Trust Ladder
    6. Wallet & Usage Billing
    7. Proactive Warnings
    8. Dunning & Grace Periods
    9. Webhook Handlers
    10. Cron Jobs
    11. Trial System
    12. Tier Upgrades
    13. Customer Portal
    14. Subscription Management
    15. Invoice System
    16. Stripe Connect (Payment Attribution)
    17. API Reference
    18. Environment Variables
    19. Operational Runbooks
    20. Testing & Verification
    21. Troubleshooting
    22. File Reference
    23. Version History

    Problem / Job-to-Be-Done

    User Pain Points

    Pain PointImpactSolution
    Unexpected chargesBill shock, churnPrepaid wallet with real-time balance visibility
    Service interruptionLost leads, missed opportunitiesProactive warnings at 100%/50%/20%/0% thresholds
    Complex billingConfusion, support ticketsSingle wallet for all usage types
    Payment failuresService degradationAuto-reload with dunning sequence
    Trust issues (new users)Revenue riskTrust ladder from immediate → monthly billing

    Business Impact

    MetricRisk if UnsolvedTarget After Implementation
    Revenue leakageUnbilled usage0% unbilled leads/usage
    Payment failuresLost revenue<1% unrecovered after dunning
    Support ticketsHigh operational cost<2% billing-related tickets
    Customer churnBill shockProactive 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

    CapabilityImplementationStatus
    Prepaid WalletCreditWallet model with cents precision✅ Complete
    Lead BillingTrust-based schedule (immediate→monthly)✅ Complete
    Usage BillingReal-time wallet charges (SMS, voice, AI)✅ Complete
    Auto-ReloadStripe PaymentMethod with configurable threshold✅ Complete
    Budget EnforcementBillingGuard blocks at 0 balance✅ Complete
    Proactive WarningsProportional thresholds (email/SMS/call)✅ Complete
    Dunning SequenceDay 0/3/7/14 escalation✅ Complete
    Grace Period1-hour buffer at $0 balance✅ Complete
    Audit TrailWalletTransaction for all movements✅ Complete
    Webhook IntegrationStripe 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

    MethodPurpose
    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

    FunctionPurpose
    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 LevelMinimum LeadsScheduleUnpaid Limit
    New User0Immediate (per-lead)$27 (1 lead)
    Trusted10Daily settlement$135 (5 leads)
    Established100Weekly settlement$270 (10 leads)
    Premium1,000Monthly (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: true returned from chargeWalletForLead()
    • Unpaid lead balance exceeds maxUnpaidLeadsCents
    • Wallet balance is $0 AND grace period expired AND no auto-reload

    Wallet & Usage Billing

    Usage Types

    TypeUnit CostCharged When
    SMS~$0.01/segmentImmediately after send
    Voice~$0.02/minuteAfter call completion
    AI~$0.005/tokenAfter generation
    Lead$27/leadPer schedule (immediate/daily/weekly)

    Billing Enforcement Points

    1. Lead API (/app/api/leads/route.ts:356-376)

      • Calls chargeWalletForLead(clientId) BEFORE creating lead
      • Returns HTTP 402 if shouldBlockNewLeads: true
    2. Lead Service (lib/services/lead/leadService.prisma.ts:319-345)

      • Looks up business.clientId first (wallets keyed to Client.id)
      • Throws BILLING_LIMIT_EXCEEDED error if blocked
    3. Voice/SMS Routes

      • Call BillingGuard.checkBillingGuard() before processing
      • Return 402 if budget exceeded

    Proactive Warnings

    Warning Thresholds

    Thresholds are proportional to the user's auto-reload setting:

    PercentageDefault ($10 threshold)High-Value ($500 threshold)
    100%$10$500
    50%$5$250
    20%$2$100
    0%$0$0

    Notification Channels

    ThresholdEmailSMSVoice 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

    DayActionChannel
    Day 0Payment failedWebhook triggers immediate email
    Day 3First reminderEmail
    Day 7Escalation warningEmail + SMS
    Day 14Final notice + suspensionEmail + SMS + Service paused

    Grace Period

    When balance hits $0:

    1. Grace period starts (1 hour by default)
    2. Usage continues but logged to negativeBalanceCents
    3. Urgent notifications sent
    4. 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)

    EventHandler
    payment_intent.succeededCredit wallet for top-ups (metadata.type === 'wallet_top_up')
    invoice.payment_failedStart dunning sequence
    customer.subscription.updatedSync 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

    JobSchedulePurpose
    usage-warningsEvery 15 minSend proactive balance/usage warnings
    lead-settlementDaily 6 AMSettle daily/weekly lead billing
    dunningDaily 8 AMProcess 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

    MilestoneTriggerMessage
    first_lead1 lead created"Your first lead! Petunia is working."
    three_leads3 leads created"3 leads! You're on track."
    five_leads5 leads created"5 leads generated! You've proven the value."
    first_appointment1 appointment booked"First booking! Real value."
    three_appointments3 appointments booked"3 appointments booked! You're seeing results."
    revenue_threshold$200+ tracked"Revenue tracked! Your business is growing."
    value_thresholdAny threshold met"You've proven the value. Lock in your growth."
    trial_extendedExtension granted"Great progress! Your trial has been extended."

    Hybrid Extension Logic

    When a customer hits ANY value threshold during trial:

    1. value_threshold milestone recorded
    2. If totalTrialDays < maxTrialDays, trial automatically extends by extensionDays
    3. Maximum 3 extensions (7 → 14 → 21 days)
    4. Extension creates trial_extended milestone 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

    EndpointMethodPurpose
    /api/platform/trial-configGETGet current trial configuration
    /api/platform/trial-configPUTUpdate trial configuration (admin only)
    /api/platform/trial-spendGETGet 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

    TierMonthlyLimitsCommission Rates
    Starter$49500 SMS, 60 voice, 1000 AI15% booking, 10% closure
    Growth$1492000 SMS, 200 voice, 5000 AI12% booking, 8% closure
    Scale$3495000 SMS, 500 voice, 15000 AI10% booking, 6% closure
    Enterprise$799Unlimited8% booking, 4% closure

    UpgradeService

    Location: lib/services/upgrade-service.ts

    MethodPurpose
    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

    FeatureDescription
    Payment MethodsAdd, update, remove cards
    Invoice HistoryView and download all invoices
    Billing InfoUpdate billing address, tax ID
    Subscription ViewSee 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:

    1. Check session context (session.user.stripeCustomerId)
    2. 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

    StatusMeaningUser Access
    activePayment current, full access✅ Full
    trialingIn trial period✅ Full (with limits)
    past_duePayment failed, in dunning⚠️ Limited
    canceledSubscription ended❌ None
    incompleteSetup 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

    OptionEffect
    cancelImmediately: falseAccess until period end, then cancel
    cancelImmediately: trueImmediate cancellation, no refund

    Database Sync

    On cancellation:

    1. Update Subscription.status and cancelAtPeriodEnd
    2. Update User.subscriptionStatus
    3. 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

    MethodPurpose
    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 TypeDescriptionCalculation
    Lead FeesPer-lead commissionleadCount × $5
    Booking FeesAppointment commissionbookingCount × rate
    Closure FeesRevenue-based commissionclosureCount × %

    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 stripeConnectAccountId on Client record
    • Creates CommissionIntegration record 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:

    EventHandler
    payment_intent.succeededPrimary payment detection
    charge.succeededFallback for direct charges
    invoice.paidSubscription payments
    checkout.session.completedCheckout 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

    LevelMatch TypeHow Matched
    Level 1Lead onlyLead exists, no quote/payment
    Level 2Quote matchQuote amount matches payment
    Level 3Conversation matchCustomer email/phone matches conversation

    Commission Calculation

    When a payment is attributed:

    1. CommissionEvent created with type: 'closure'
    2. Commission calculated based on tier rates (4-10%)
    3. Added to monthly invoice for client

    Environment Variables

    VariablePurpose
    STRIPE_CONNECT_CLIENT_IDOAuth client ID for Stripe Connect
    STRIPE_CONNECT_WEBHOOK_SECRETWebhook signature verification

    API Reference

    Billing APIs

    EndpointMethodPurpose
    /api/billing/subscriptionGETGet subscription details for billing page and feature gates
    /api/billing/invoicesGETGet invoice history for billing page

    Wallet APIs

    EndpointMethodPurpose
    /api/payments/walletGETGet wallet status
    /api/payments/walletPOSTAdd funds (creates PaymentIntent)
    /api/payments/wallet/auto-reloadGETGet auto-reload settings
    /api/payments/wallet/auto-reloadPUTUpdate 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

    VariableRequiredDescription
    STRIPE_SECRET_KEYYesStripe API key
    STRIPE_WEBHOOK_SECRETYesWebhook signature verification
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYYesClient-side Stripe
    CRON_SECRETYesCron 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 SuiteCommandStatusTests
    TypeScript Strictpnpm typecheck:strict✅ PASS0 errors
    Lead APIjest tests/app/api/leads/route.test.ts✅ PASS2/2
    Lead Servicejest tests/lib/services/lead/✅ PASS14/14
    Payment APIsjest tests/app/api/payments/✅ PASS105/105
    Commission/Billingjest tests/app/api/commission/✅ PASS87/87

    Total Billing-Related Tests: 208 passing

    Pre-Existing Failures (NOT related to billing)

    Test FileFailuresRoot CauseStatus
    googleConnectionService.test.ts2/23encrypt() signature changed for multi-tenant supportQuarantined

    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 383
    • lib/services/trial/trialService.ts: Full milestone tracking implemented
    • lib/services/billing/credit-wallet-service.ts: All wallet operations covered
    • lib/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

    IssueCauseSolution
    Lead creation always failsbusinessId passed instead of clientIdFixed in b19a4d933 - now looks up business.clientId
    Wallet not credited after checkoutWebhook not handling wallet_top_upFixed in 14e00bc35 - added PaymentIntent handler
    High-value users not getting SMS warningsHardcoded $2 thresholdFixed in b19a4d933 - now proportional to auto-reload
    Double wallet creditMissing idempotency checkFixed - 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

    FilePurpose
    lib/services/billing/credit-wallet-service.tsWallet operations
    lib/middleware/billing/billing-guard.tsBudget enforcement
    lib/middleware/billing/types.tsType definitions
    lib/middleware/billing/index.tsPublic exports

    API Routes

    FilePurpose
    app/api/billing/subscription/route.tsSubscription details for billing page and feature gates
    app/api/billing/invoices/route.tsInvoice history for billing page
    app/api/payments/wallet/route.tsWallet status & top-up
    app/api/payments/wallet/auto-reload/route.tsAuto-reload settings
    app/api/payments/stripe/webhook/route.tsStripe webhooks
    app/api/leads/route.tsLead creation (billing check)

    Cron Jobs

    FilePurpose
    app/api/cron/usage-warnings/route.tsProactive warnings
    app/api/cron/lead-settlement/route.tsDaily/weekly settlement
    app/api/cron/dunning/route.tsPayment failure sequence

    Trial System

    FilePurpose
    lib/services/trial/trialService.tsTrial management & milestones
    lib/services/trial/index.tsPublic exports
    lib/types/trial.tsTrial type definitions
    app/api/platform/trial-config/route.tsAdmin trial configuration
    app/api/platform/trial-spend/route.tsPlatform trial spend stats

    Tier Upgrades

    FilePurpose
    lib/services/upgrade-service.tsUpgrade logic & ROI calculations
    app/api/payments/upgrade/route.tsUpgrade preview & execution

    Customer Portal & Subscriptions

    FilePurpose
    app/api/payments/customer-portal/route.tsStripe billing portal sessions
    app/api/payments/subscription-status/route.tsGet subscription details
    app/api/payments/cancel-subscription/route.tsCancel subscription

    Invoice System

    FilePurpose
    lib/services/commission/invoiceService.tsFull invoice lifecycle
    lib/services/commission/roiService.tsROI & commission calculations

    Stripe Connect

    FilePurpose
    app/api/integrations/stripe-connect/route.tsOAuth initiation & status
    app/api/integrations/stripe-connect/callback/route.tsOAuth callback handler
    app/api/integrations/stripe-connect/webhook/route.tsPayment attribution webhooks
    lib/services/commission/valueDetectionService.tsPayment→Commission attribution

    Database

    FilePurpose
    prisma/schema.prismaCreditWallet, WalletTransaction, PlatformTrialConfig models

    Version History

    VersionDateChanges
    1.2.32025-12-28Stripe 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.22025-12-23Added 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.12025-12-05Added verification snapshot, owner map reference, and explicit test/CI links
    1.2.02025-12-04Complete 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.02025-12-04Trial 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.02025-12-04Initial 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
    On this page
    VerificationSummaryTable of ContentsProblem / Job-to-Be-DoneUser Pain PointsBusiness ImpactJob StoriesSolution OverviewBilling FlowKey CapabilitiesArchitectureSystem DiagramData ModelCore ComponentsCreditWalletServiceBillingGuard MiddlewareKey InterfacesLead Billing Trust LadderTrust Level ThresholdsBlocking LogicWallet & Usage BillingUsage TypesBilling Enforcement PointsProactive WarningsWarning ThresholdsNotification ChannelsImplementationDunning & Grace PeriodsDunning SequenceGrace PeriodWebhook HandlersStripe Webhook (`/api/payments/stripe/webhook`)