// e.g., ['export:csv', 'api:unlimited']
assets: DigitalAsset[];
limits: Record<string, number>; // e.g., { 'api:requests_per_minute': 1000 }
}
export interface UserEntitlementState {
userId: string;
currentMatrix: EntitlementMatrix;
assetMap: Map<string, DigitalAsset>;
lastSyncedAt: Date;
}
```
2. Implement the Upsell Orchestrator:
The orchestrator manages the flow, generates idempotency keys, and handles the transition.
```typescript
// services/UpsellOrchestrator.ts
import { v4 as uuidv4 } from 'uuid';
import { EventPublisher } from './EventPublisher';
import { EntitlementRepository } from './EntitlementRepository';
export class UpsellOrchestrator {
constructor(
private repo: EntitlementRepository,
private publisher: EventPublisher
) {}
async initiateUpgrade(
userId: string,
targetTierId: string,
paymentIntentId: string
): Promise<UpgradeSession> {
const idempotencyKey = uuidv4();
// 1. Fetch current state and target matrix
const currentState = await this.repo.getState(userId);
const targetMatrix = await this.repo.getMatrix(targetTierId);
// 2. Calculate delta
const delta = this.calculateDelta(currentState, targetMatrix);
// 3. Create upgrade session with PENDING status
const session = await this.repo.createSession({
userId,
targetTierId,
paymentIntentId,
idempotencyKey,
status: 'PENDING',
delta,
createdAt: new Date()
});
// 4. Publish event for client-side optimistic update
await this.publisher.publish('upsell.session_created', {
userId,
sessionId: session.id,
optimisticState: targetMatrix // Client can render premium UI immediately
});
return session;
}
private calculateDelta(current: UserEntitlementState, target: EntitlementMatrix) {
// Logic to determine assets to provision/upgrade
return {
assetsToProvision: target.assets.filter(a => !current.assetMap.has(a.type)),
limitsToAdjust: target.limits
};
}
}
```
3. Handle Payment Webhooks with Idempotency:
Webhooks must be idempotent. The system checks the idempotency key before applying changes.
```typescript
// handlers/PaymentWebhookHandler.ts
export class PaymentWebhookHandler {
async handlePaymentSuccess(event: PaymentEvent) {
const session = await this.repo.getSessionByPaymentIntent(event.paymentIntentId);
if (!session) throw new Error('Session not found');
if (session.status === 'COMPLETED') {
// Idempotency: Already processed
return { status: 200 };
}
try {
// 1. Atomic transaction: Update entitlements and provision assets
await this.db.transaction(async (tx) => {
await tx.entitlements.update(session.userId, session.targetTierId);
await tx.assets.provision(session.delta.assetsToProvision, session.userId);
await tx.sessions.markCompleted(session.id);
});
// 2. Publish confirmation event
await this.publisher.publish('upsell.completed', {
userId: session.userId,
finalState: await this.repo.getState(session.userId)
});
return { status: 200 };
} catch (err) {
// 3. Error handling: Trigger rollback or alert
await this.publisher.publish('upsell.failed', { sessionId: session.id, error: err.message });
throw err;
}
}
}
```
4. Client-Side Synchronization:
The client listens for events to update the UI state without polling.
```typescript
// hooks/useUpsellState.ts
import { useState, useEffect } from 'react';
import { useWebSocket } from './useWebSocket';
export function useUpsellState(userId: string) {
const [state, setState] = useState<UserEntitlementState | null>(null);
const [isUpgrading, setIsUpgrading] = useState(false);
const ws = useWebSocket(`/entitlements/${userId}`);
useEffect(() => {
ws.on('optimisticState', (data) => {
setIsUpgrading(true);
// Render premium features immediately
setState(data.optimisticState);
});
ws.on('upsellCompleted', (data) => {
setIsUpgrading(false);
setState(data.finalState);
});
ws.on('upsellFailed', () => {
setIsUpgrading(false);
// Revert to previous state or show error
});
}, [ws]);
return { state, isUpgrading };
}
```
Architecture Decisions
- Capability-Based vs. Tier-Based: Mapping code to capabilities (
api:unlimited) rather than tier names (Pro Plan) allows for flexible pricing changes without code refactoring. The matrix serves as the single source of truth.
- Event-Driven State Sync: Using WebSockets or SSE for state propagation eliminates polling overhead and reduces perceived latency to near-zero.
- Idempotency Keys: Every upgrade session generates a unique key. This prevents duplicate asset provisioning if the payment gateway retries the webhook, a common cause of revenue leakage.
- Optimistic UI with Rollback: The UI updates immediately upon session creation. If the webhook fails, the system reverts the state. This maximizes conversion while maintaining data integrity.
Pitfall Guide
-
Race Conditions on Concurrent Upgrades:
- Mistake: Allowing multiple upgrade requests for the same user simultaneously.
- Impact: Duplicate payment charges or conflicting entitlement states.
- Fix: Implement distributed locks on the
userId during the upgrade initiation phase. Reject concurrent requests with a 409 Conflict.
-
Webhook Latency Blocking UX:
- Mistake: Keeping the user on a "Processing" screen until the webhook confirms.
- Impact: High abandonment rate. Users assume the payment failed.
- Fix: Always use optimistic updates. Show a "Upgrade in progress" toast that resolves to "Success" upon event receipt.
-
Hardcoded Feature Flags:
- Mistake: Using
if (user.plan === 'PRO') in business logic.
- Impact: Technical debt. Adding a new feature requires database migrations and code deployments.
- Fix: Use
if (user.hasCapability('export:csv')). The entitlement matrix drives the logic.
-
Ignoring Downgrade/Cancellation Flows:
- Mistake: Implementing upsell logic but neglecting the reverse path.
- Impact: Users retain premium assets after cancellation, leading to compliance issues and support burdens.
- Fix: The entitlement matrix must support delta calculations for downgrades. Implement automated asset revocation workflows triggered by cancellation events.
-
Client-Side Entitlement Checks Only:
- Mistake: Relying on the frontend to hide premium features based on local state.
- Impact: Security vulnerability. Users can manipulate local storage to access premium APIs.
- Fix: Always verify capabilities on the server-side middleware. The client state is for UX only; the server is the source of truth.
-
Idempotency Failures on Asset Provisioning:
- Mistake: Provisioning an API key or license without checking for existing duplicates.
- Impact: Revenue leakage. User gets multiple assets for one payment.
- Fix: Use idempotency keys scoped to the asset type.
PROVISION_API_KEY:{userId}:{idempotencyKey}. Check Redis or DB for existence before provisioning.
-
Metric Blindness:
- Mistake: Tracking revenue but not the technical health of the upsell flow.
- Impact: Inability to detect silent failures where payments succeed but entitlements fail.
- Fix: Instrument metrics for
upsell_initiated, webhook_received, entitlement_granted, and time_to_grant. Alert on divergence between payment success and entitlement grant rates.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP / Low Volume | Synchronous API with Optimistic UI | Simpler implementation; sufficient for low concurrency. | Low |
| High Scale SaaS | Event-Driven Entitlement Matrix | Decouples payment from features; handles high concurrency; prevents drift. | Medium (Infra) |
| Marketplace / Multi-Tenant | Third-Party Entitlement Service (e.g., Entitle, Featurebase) | Reduces engineering overhead; handles complex billing logic out-of-the-box. | High (SaaS Cost) |
| Regulated Assets | Atomic Transaction with Audit Log | Ensures compliance; provides immutable record of asset allocation. | Medium (Dev Time) |
Configuration Template
Copy this TypeScript configuration to define your entitlement matrix structure.
// config/entitlement-matrix.config.ts
import { EntitlementMatrix } from '../types/entitlement';
export const ENTITLEMENT_MATRICES: Record<string, EntitlementMatrix> = {
'free': {
tierId: 'free',
capabilities: ['read:public', 'write:limited'],
assets: [
{ type: 'STORAGE_QUOTA', metadata: { limit: 100 }, status: 'PROVISIONED' }
],
limits: { 'api:requests_per_day': 100 }
},
'pro': {
tierId: 'pro',
capabilities: ['read:public', 'write:unlimited', 'export:csv', 'api:unlimited'],
assets: [
{ type: 'STORAGE_QUOTA', metadata: { limit: 10000 }, status: 'PROVISIONED' },
{ type: 'API_KEY', metadata: { prefix: 'pk_pro_' }, status: 'PENDING' }
],
limits: { 'api:requests_per_day': 50000 }
},
'enterprise': {
tierId: 'enterprise',
capabilities: ['read:public', 'write:unlimited', 'export:all', 'api:unlimited', 'sso:saml'],
assets: [
{ type: 'STORAGE_QUOTA', metadata: { limit: -1 }, status: 'PROVISIONED' },
{ type: 'API_KEY', metadata: { prefix: 'pk_ent_' }, status: 'PENDING' },
{ type: 'SEAT', metadata: { count: 50 }, status: 'PENDING' }
],
limits: { 'api:requests_per_day': -1 }
}
};
Quick Start Guide
- Initialize Schema: Add the
EntitlementMatrix and DigitalAsset types to your shared types package.
- Deploy Orchestrator: Integrate the
UpsellOrchestrator class into your backend API. Ensure it connects to your database and event publisher.
- Wire Webhooks: Implement the
PaymentWebhookHandler in your webhook route. Configure idempotency checks against your database.
- Enable Client Sync: Add the
useUpsellState hook to your upgrade modal. Connect it to your WebSocket endpoint.
- Verify Flow: Trigger a test upgrade. Confirm the UI updates optimistically, the webhook processes idempotently, and the entitlement state reflects the new tier. Monitor logs for
time_to_grant metrics.