DEV Community

Jeremy Longshore
Jeremy Longshore

Posted on Originally published at startaitools.com

Building an Idempotent Stripe Billing Enforcement Engine for Firestore

When you're building subscription billing with Stripe webhooks, you quickly discover a harsh reality: webhooks can arrive delayed, duplicated, or out of order. For a youth sports stats SaaS platform I'm building, this created a critical problem—plan and status updates were scattered across multiple handlers with no guarantee of consistency.

This is the story of building a unified plan enforcement engine that solved webhook chaos, eliminated duplicate logic, and added automatic drift correction—all while maintaining a complete audit trail.

The Problem: Webhook Drift and Duplicate Logic

What We Started With

Our billing system had plan/status update logic in four separate locations:

  1. Stripe webhook handler (/api/billing/webhook/route.ts) - 5 event handlers
  2. Event replay endpoint (/api/admin/billing/replay-events/route.ts) - 5 replay handlers
  3. Billing auditor (src/lib/stripe/auditor.ts) - drift detection
  4. Manual admin operations - future admin dashboard actions

Each location had its own version of this pattern:

// Duplicated across 10+ handlers
const plan = getPlanForPriceId(priceId);
const status = mapStripeStatusToWorkspaceStatus(subscription.status);
await updateWorkspace(workspaceId, { plan, status });
await recordBillingEvent(workspaceId, {
  type: 'subscription_updated',
  planBefore,
  planAfter: plan,
  // ... more fields
});
Enter fullscreen mode Exit fullscreen mode

The problems:

  • ~180 lines of duplicated logic across handlers
  • No guarantee of consistency if one handler was updated but not others
  • No built-in idempotency for duplicate webhooks
  • Drift detection couldn't automatically fix itself
  • Manual before/after tracking in every location

Real-World Webhook Chaos

Stripe webhooks are eventually consistent, which means:

Scenario 1: Duplicate Webhooks

Webhook 1: customer.subscription.updated (plan change)
Webhook 2: customer.subscription.updated (same event, redelivered)
Enter fullscreen mode Exit fullscreen mode

Without idempotency, you'd process the same change twice and create duplicate ledger entries.

Scenario 2: Out-of-Order Arrival

Event 1: subscription.updated (plan: starter → plus) sent at 14:00:01
Event 2: payment.succeeded (plan: plus) sent at 14:00:05

But Event 2 arrives first!
Enter fullscreen mode Exit fullscreen mode

Your workspace briefly shows the wrong plan until Event 1 catches up.

Scenario 3: Delayed Webhooks

User upgrades at 14:00
Webhook arrives at 14:05 (5 minutes late)
Meanwhile, auditor runs at 14:03 and detects drift
Enter fullscreen mode Exit fullscreen mode

Who wins? How do you prevent conflicting updates?

The Solution: Unified Plan Enforcement Engine

Design Goals

  1. Single Source of Truth: ONE function handles ALL plan/status updates
  2. Idempotent: Safe to call multiple times with same data
  3. Delta Detection: Only update what changed, track before/after state
  4. Audit Trail: Full ledger integration for compliance
  5. Passive: Never modify Stripe data, only read and apply to workspace

Core Architecture

File: src/lib/stripe/plan-enforcement.ts (264 lines)

export async function enforceWorkspacePlan(
  workspaceId: string,
  input: EnforcePlanInput
): Promise<EnforcePlanResult> {
  // 1. Validate inputs
  if (!workspaceId || typeof workspaceId !== 'string') {
    throw new Error('Invalid workspaceId: must be non-empty string');
  }

  // Validate source enum
  const validSources: LedgerEventSource[] = [
    'webhook', 'replay', 'auditor', 'manual', 'enforcement'
  ];
  if (!validSources.includes(input.source)) {
    throw new Error(`Invalid source: ${input.source}`);
  }

  // 2. Fetch current workspace state
  const workspaceDoc = await adminDb
    .collection('workspaces')
    .doc(workspaceId)
    .get();

  if (!workspaceDoc.exists) {
    throw new Error(`Workspace not found: ${workspaceId}`);
  }

  const workspace = workspaceDoc.data() as Workspace;

  // 3. Map Stripe data to workspace types
  let targetPlan: WorkspacePlan;
  let targetStatus: WorkspaceStatus;

  try {
    targetPlan = getPlanForPriceId(input.stripePriceId);
    targetStatus = mapStripeStatusToWorkspaceStatus(input.stripeStatus);
  } catch (error: any) {
    throw new Error(`Failed to map Stripe data: ${error.message}`);
  }

  // 4. Detect deltas (THIS IS KEY!)
  const planBefore = workspace.plan;
  const statusBefore = workspace.status;
  const planChanged = planBefore !== targetPlan;
  const statusChanged = statusBefore !== targetStatus;

  console.log('[Plan Enforcement]', {
    workspaceId,
    planChanged,
    statusChanged,
    planBefore,
    targetPlan,
    statusBefore,
    targetStatus,
  });

  // 5. Update workspace if mismatch detected
  if (planChanged || statusChanged) {
    const updates: Partial<Workspace> = {
      updatedAt: FieldValue.serverTimestamp() as any,
    };

    if (planChanged) updates.plan = targetPlan;
    if (statusChanged) updates.status = targetStatus;

    await adminDb
      .collection('workspaces')
      .doc(workspaceId)
      .update(updates);

    // Record delta in ledger
    const ledgerEventId = await recordBillingEvent(workspaceId, {
      type: 'plan_changed',
      stripeEventId: input.stripeEventId,
      statusBefore,
      statusAfter: statusChanged ? targetStatus : statusBefore,
      planBefore,
      planAfter: planChanged ? targetPlan : planBefore,
      source: input.source,
      note: `Plan enforcement: ${planChanged ? `${planBefore}${targetPlan}` : 'plan unchanged'}, ${statusChanged ? `${statusBefore}${targetStatus}` : 'status unchanged'}`,
    });

    return {
      workspaceId,
      planChanged,
      statusChanged,
      planBefore,
      planAfter: targetPlan,
      statusBefore,
      statusAfter: targetStatus,
      ledgerEventId,
    };
  } else {
    // No changes needed - record noop
    const ledgerEventId = await recordBillingEvent(workspaceId, {
      type: 'plan_changed',
      stripeEventId: input.stripeEventId,
      statusBefore,
      statusAfter: statusBefore,
      planBefore,
      planAfter: planBefore,
      source: input.source,
      note: 'Plan enforcement: no changes (workspace already in sync)',
    });

    return {
      workspaceId,
      planChanged: false,
      statusChanged: false,
      planBefore,
      planAfter: planBefore,
      statusBefore,
      statusAfter: statusBefore,
      ledgerEventId,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Solves Webhook Chaos

Idempotency in Action:

// Webhook arrives twice (duplicate delivery)
// Call 1:
await enforceWorkspacePlan('workspace123', {
  stripePriceId: 'price_plus',
  stripeStatus: 'active',
  source: 'webhook',
  stripeEventId: 'evt_123',
});
// Result: planChanged=true, updates workspace, records delta

// Call 2 (same data):
await enforceWorkspacePlan('workspace123', {
  stripePriceId: 'price_plus',
  stripeStatus: 'active',
  source: 'webhook',
  stripeEventId: 'evt_123', // Same event!
});
// Result: planChanged=false, NO workspace update, records noop
Enter fullscreen mode Exit fullscreen mode

Out-of-Order Handling:

// Event 2 arrives first (payment.succeeded)
await enforceWorkspacePlan('workspace123', {
  stripePriceId: 'price_starter',
  stripeStatus: 'active',
  source: 'webhook',
  stripeEventId: 'evt_payment',
});
// Sets plan=starter, status=active

// Event 1 arrives later (subscription.updated, upgrade to plus)
await enforceWorkspacePlan('workspace123', {
  stripePriceId: 'price_plus',
  stripeStatus: 'active',
  source: 'webhook',
  stripeEventId: 'evt_upgrade',
});
// Detects mismatch, corrects to plan=plus
// Workspace converges to correct state!
Enter fullscreen mode Exit fullscreen mode

Testing Strategy: 14 Comprehensive Tests

We wrote 450 lines of tests (more than the implementation!) to cover every edge case.

Test Structure

File: src/lib/stripe/plan-enforcement.test.ts

describe('Workspace Plan Enforcement', () => {
  beforeEach(() => {
    vi.clearAllMocks();

    // Default workspace state: starter plan, active status
    mockGet.mockResolvedValue({
      exists: true,
      id: 'workspace123',
      data: () => ({
        plan: 'starter',
        status: 'active',
        createdAt: { toDate: () => new Date('2025-01-01') },
        // ... other fields
      }),
    });

    mockUpdate.mockResolvedValue({});
    mockRecordBillingEvent.mockResolvedValue('ledger123');
  });

  it('should update plan AND status when both changed', async () => {
    const result = await enforceWorkspacePlan('workspace123', {
      stripePriceId: 'price_plus',
      stripeStatus: 'past_due',
      source: 'webhook',
      stripeEventId: 'evt_123',
    });

    expect(result.planChanged).toBe(true);
    expect(result.statusChanged).toBe(true);
    expect(result.planBefore).toBe('starter');
    expect(result.planAfter).toBe('plus');
    expect(result.statusBefore).toBe('active');
    expect(result.statusAfter).toBe('past_due');

    expect(mockUpdate).toHaveBeenCalledWith(
      expect.objectContaining({
        plan: 'plus',
        status: 'past_due',
        updatedAt: expect.objectContaining({
          _methodName: 'FieldValue.serverTimestamp'
        }),
      })
    );

    expect(mockRecordBillingEvent).toHaveBeenCalled();
  });

  it('should record noop ledger entry when no changes', async () => {
    const result = await enforceWorkspacePlan('workspace123', {
      stripePriceId: 'price_starter', // Same as current
      stripeStatus: 'active',         // Same as current
      source: 'webhook',
      stripeEventId: 'evt_noop',
    });

    expect(result.planChanged).toBe(false);
    expect(result.statusChanged).toBe(false);

    // Verify NO workspace update
    expect(mockUpdate).not.toHaveBeenCalled();

    // Verify ledger entry still recorded (noop)
    expect(mockRecordBillingEvent).toHaveBeenCalled();
  });

  it('should support auditor-driven enforcement (drift correction)', async () => {
    const result = await enforceWorkspacePlan('workspace123', {
      stripePriceId: 'price_pro',
      stripeStatus: 'past_due',
      source: 'auditor', // Different source
      stripeEventId: null, // No Stripe event for auditor
    });

    expect(result.planChanged).toBe(true);
    expect(result.statusChanged).toBe(true);
    expect(mockRecordBillingEvent).toHaveBeenCalled();
  });

  it('should never call Stripe API (passive enforcement)', async () => {
    await enforceWorkspacePlan('workspace123', {
      stripePriceId: 'price_plus',
      stripeStatus: 'active',
      source: 'webhook',
      stripeEventId: 'evt_123',
    });

    // Enforcement should only interact with Firestore, never Stripe
    expect(mockUpdate).toHaveBeenCalled();
    expect(mockRecordBillingEvent).toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Key Test Categories

  1. Delta Detection (4 tests):

    • Both plan and status changed
    • Only plan changed
    • Only status changed
    • No changes (noop)
  2. Event Sources (3 tests):

    • Webhook-driven enforcement
    • Replay-driven enforcement
    • Auditor-driven enforcement
  3. Input Validation (4 tests):

    • Invalid workspaceId
    • Invalid stripePriceId
    • Invalid stripeStatus
    • Invalid source enum
  4. Error Handling (3 tests):

    • Workspace not found
    • Firestore update failure
    • Unknown Stripe price ID

All 229 tests passing (14 new + 215 existing)

Integration: Four Locations, One Function

1. Stripe Webhook Handler

Before (duplicated logic):

async function handleSubscriptionUpdated(subscription, eventId) {
  const workspace = await getWorkspaceByStripeCustomerId(customerId);

  const planBefore = workspace.plan;
  const statusBefore = workspace.status;

  const priceId = subscription.items.data[0].price.id;
  const plan = getPlanForPriceId(priceId);
  const status = mapStripeStatusToWorkspaceStatus(subscription.status);

  await updateWorkspace(workspace.id, { plan, status });
  await updateWorkspaceBilling(workspace.id, {
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });

  await recordBillingEvent(workspace.id, {
    type: 'subscription_updated',
    stripeEventId: eventId,
    statusBefore,
    statusAfter: status,
    planBefore,
    planAfter: plan,
    source: 'webhook',
    note: `Subscription updated: ${planBefore}${plan}`,
  });
}
Enter fullscreen mode Exit fullscreen mode

After (unified enforcement):

async function handleSubscriptionUpdated(subscription, eventId) {
  const workspace = await getWorkspaceByStripeCustomerId(customerId);
  const priceId = subscription.items.data[0].price.id;

  // Enforce workspace plan and status (Phase 7 Task 9)
  await enforceWorkspacePlan(workspace.id, {
    stripePriceId: priceId,
    stripeStatus: subscription.status,
    source: 'webhook',
    stripeEventId: eventId,
  });

  // Update billing information
  await updateWorkspaceBilling(workspace.id, {
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });
}
Enter fullscreen mode Exit fullscreen mode

Reduction: 15 lines → 8 lines per handler × 5 handlers = ~35 lines saved

2. Event Replay Endpoint

Same pattern, but with source: 'replay':

async function replaySubscriptionDeleted(subscription, customerId, eventId) {
  const workspace = await getWorkspaceByStripeCustomerId(customerId);
  const priceId = subscription.items.data[0].price.id;

  // Enforce workspace plan and status
  // Subscription deleted means status should be 'canceled'
  await enforceWorkspacePlan(workspace.id, {
    stripePriceId: priceId,
    stripeStatus: 'canceled',
    source: 'replay',
    stripeEventId: eventId,
  });

  // Keep currentPeriodEnd for grace period access
  await updateWorkspaceBilling(workspace.id, {
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });
}
Enter fullscreen mode Exit fullscreen mode

Benefit: Event source tracking in ledger allows distinguishing webhook vs. replay events

3. Billing Auditor (Auto-Fix Drift)

This is where it gets powerful:

export async function auditWorkspaceBilling(
  workspaceId: string
): Promise<BillingAuditReport> {
  // ... fetch workspace and Stripe subscription ...

  // Detect drift
  const expectedStatus = mapStripeStatusToWorkspaceStatus(subscription.status);
  if (workspace.status !== expectedStatus) {
    report.drift = true;
    report.driftReasons.push(
      `Status mismatch: Firestore=${workspace.status}, Stripe=${subscription.status}`
    );
  }

  // Determine fix strategy
  if (report.drift) {
    const hasOnlyStatusOrPlanDrift = report.driftReasons.every(
      (reason) =>
        reason.includes('Status mismatch') || reason.includes('Plan mismatch')
    );

    if (hasOnlyStatusOrPlanDrift) {
      report.recommendedFix = 'run_event_replay';

      // 🔥 NEW: Apply automatic enforcement if drift can be fixed
      if (report.stripePriceId && report.stripeStatus) {
        await enforceWorkspacePlan(workspaceId, {
          stripePriceId: report.stripePriceId,
          stripeStatus: report.stripeStatus,
          source: 'auditor',
          stripeEventId: null,
        });
      }
    } else {
      report.recommendedFix = 'manual_stripe_review';
    }

    // Record drift detection in ledger
    await recordBillingEvent(workspaceId, {
      type: 'drift_detected',
      // ... drift details ...
      note: `Drift detected: ${report.driftReasons.join('; ')}. ${
        report.recommendedFix === 'run_event_replay'
          ? '(auto-applied via enforcement)'
          : ''
      }`,
    });
  }

  return report;
}
Enter fullscreen mode Exit fullscreen mode

Result: Auditor detects drift AND automatically fixes it!

Ledger Audit Trail (2 entries):

  1. drift_detected (from auditor) - "Drift detected... (auto-applied)"
  2. plan_changed (from enforcement) - "Plan enforcement: active→past_due"

Real-World Scenarios

Scenario 1: Payment Failure

Event: User's credit card declines

Stripe Webhook: invoice.payment_failed

// Webhook handler
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;

await enforceWorkspacePlan(workspace.id, {
  stripePriceId: priceId,
  stripeStatus: 'past_due', // Stripe sets this automatically
  source: 'webhook',
  stripeEventId: 'evt_payment_failed',
});
Enter fullscreen mode Exit fullscreen mode

Result:

  • workspace.status updated: activepast_due
  • workspace.plan unchanged: starterstarter
  • Ledger entry: "Plan enforcement: plan unchanged, active→past_due"
  • User sees grace period notice in dashboard

Scenario 2: Plan Downgrade

Event: User downgrades from Plus to Starter

Stripe Webhook: customer.subscription.updated

await enforceWorkspacePlan(workspace.id, {
  stripePriceId: 'price_starter',
  stripeStatus: 'active',
  source: 'webhook',
  stripeEventId: 'evt_sub_updated',
});
Enter fullscreen mode Exit fullscreen mode

Result:

  • workspace.plan updated: plusstarter
  • workspace.status unchanged: activeactive
  • Ledger entry: "Plan enforcement: plus→starter, status unchanged"
  • Player limits automatically enforced on next action

Scenario 3: Drift Correction

Event: Periodic audit runs at 14:00, finds drift

Audit Report:

  • workspace.status = 'active'
  • subscription.status = 'past_due' (payment failed, but webhook delayed)
  • recommendedFix = 'run_event_replay'

Auto-Enforcement:

// Auditor automatically applies enforcement
await enforceWorkspacePlan(workspace.id, {
  stripePriceId: 'price_starter',
  stripeStatus: 'past_due',
  source: 'auditor',
  stripeEventId: null,
});
Enter fullscreen mode Exit fullscreen mode

Ledger Entries (2 entries):

  1. drift_detected - "Drift detected: Status mismatch (auto-applied)"
  2. plan_changed - "Plan enforcement: active→past_due"

Result: Drift corrected before webhook even arrives!

Lessons Learned

1. Idempotency Is Not Optional

Webhooks will be duplicated. Design for it from day one.

Key pattern:

// Always compare current state vs. target state
const changed = current !== target;

if (changed) {
  // Update
} else {
  // Noop (but still record in ledger!)
}
Enter fullscreen mode Exit fullscreen mode

2. Test More Than You Implement

Our ratio: 450 lines of tests vs. 264 lines of implementation (1.7:1)

Why?

  • Caught 5 bugs before they hit production
  • Mocking Firestore requires careful setup
  • Edge cases (workspace not found, unknown price ID) easy to miss
  • Behavioral contracts ("never calls Stripe API") prevent regression

3. Audit Trails Are Critical

Every enforcement action writes to ledger with:

  • Before/after state (delta tracking)
  • Event source (webhook, replay, auditor, manual)
  • Stripe event ID (when applicable)
  • Human-readable note

Value:

  • Troubleshoot billing issues: "When did plan change?"
  • Customer support: "Show me all billing events for this workspace"
  • Compliance: "Prove we applied subscription correctly"

4. Passive Enforcement Prevents Circular Updates

Never modify Stripe data from enforcement.

Why?

  • Stripe is source of truth for billing
  • Workspace is source of truth for runtime behavior
  • Updating Stripe triggers webhook → triggers enforcement → infinite loop

The rule: Enforcement only reads Stripe, applies to workspace.

What's Next?

Optional Enhancements

  1. Batch Enforcement Script

    • Run enforcement across ALL workspaces
    • Useful for periodic drift correction
    • Source: 'enforcement'
  2. Admin Dashboard UI

    • Manual "Force Sync" button
    • Show current workspace vs. Stripe state
    • Display enforcement result with deltas
  3. Monitoring & Alerts

    • Track enforcement metrics (noop vs. delta ratio)
    • Alert on high failure rate
    • Dashboard visualization (Grafana/Cloud Monitoring)

Future Integration

The 'manual' source is reserved for admin operations:

// Admin dashboard: "Force Sync with Stripe" button
await enforceWorkspacePlan(workspaceId, {
  stripePriceId: manuallyEnteredPriceId,
  stripeStatus: manuallyEnteredStatus,
  source: 'manual',
  stripeEventId: null,
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a unified plan enforcement engine solved three critical problems:

  1. Eliminated duplicate logic - One function, four integration points
  2. Handled webhook chaos - Idempotent design survives duplicates and out-of-order events
  3. Automated drift correction - Auditor detects and fixes drift automatically

Code impact:

  • Removed: ~180 lines of duplicate logic
  • Added: 264 lines of enforcement + 450 lines of tests
  • Net: Cleaner, more maintainable codebase

Key takeaways:

  • Design for idempotency from day one
  • Test more than you implement
  • Audit trails are critical for billing
  • Never create circular update loops

If you're building subscription billing with Stripe webhooks, consider whether your plan/status updates are consolidated. It's worth the upfront investment.

Related posts:

Building Hustle, a youth sports stats platform with Firebase, Stripe, and Vertex AI. Follow along as I document the technical decisions and lessons learned.

Top comments (0)