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:
- Stripe webhook handler (
/api/billing/webhook/route.ts) - 5 event handlers - Event replay endpoint (
/api/admin/billing/replay-events/route.ts) - 5 replay handlers - Billing auditor (
src/lib/stripe/auditor.ts) - drift detection - 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
});
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)
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!
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
Who wins? How do you prevent conflicting updates?
The Solution: Unified Plan Enforcement Engine
Design Goals
- Single Source of Truth: ONE function handles ALL plan/status updates
- Idempotent: Safe to call multiple times with same data
- Delta Detection: Only update what changed, track before/after state
- Audit Trail: Full ledger integration for compliance
- 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,
};
}
}
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
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!
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();
});
});
Key Test Categories
Delta Detection (4 tests):
- Both plan and status changed
- Only plan changed
- Only status changed
- No changes (noop)
Event Sources (3 tests):
- Webhook-driven enforcement
- Replay-driven enforcement
- Auditor-driven enforcement
Input Validation (4 tests):
- Invalid workspaceId
- Invalid stripePriceId
- Invalid stripeStatus
- Invalid source enum
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}`,
});
}
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),
});
}
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),
});
}
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;
}
Result: Auditor detects drift AND automatically fixes it!
Ledger Audit Trail (2 entries):
drift_detected(from auditor) - “Drift detected… (auto-applied)”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',
});
Result:
workspace.statusupdated:active→past_dueworkspace.planunchanged:starter→starter- 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',
});
Result:
workspace.planupdated:plus→starterworkspace.statusunchanged:active→active- 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,
});
Ledger Entries (2 entries):
drift_detected- “Drift detected: Status mismatch (auto-applied)”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!)
}
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
Batch Enforcement Script
- Run enforcement across ALL workspaces
- Useful for periodic drift correction
- Source:
'enforcement'
Admin Dashboard UI
- Manual “Force Sync” button
- Show current workspace vs. Stripe state
- Display enforcement result with deltas
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,
});
Conclusion
Building a unified plan enforcement engine solved three critical problems:
- Eliminated duplicate logic - One function, four integration points
- Handled webhook chaos - Idempotent design survives duplicates and out-of-order events
- 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:
- Architecting Production Multi-Agent AI Platform: Technical Leadership
- Building Production Multi-Agent AI with Vertex AI
Building Hustle, a youth sports stats platform with Firebase, Stripe, and Vertex AI. Follow along as I document the technical decisions and lessons learned.