# v7.0 Phase 3 — Stripe Entitlement Enforcement Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement Stripe webhook handler + Checkout/Portal endpoints + entitlement gate at upload-session mint + CLI 402 handling. After this phase, every engine-on `autopilot --mode full` upload is gated on the org's monthly run cap and retained-storage cap.

**Architecture:** Claim/lease/complete webhook idempotency (locked_until + attempt_count). Storage cap preflight via `expectedBytes` in upload-session request body + SQL aggregate `sum_retained_bytes()`. Free orgs share an org-level cap via `entitlements` row seeded by AFTER INSERT trigger on `organizations`. PLAN_MAP keys by (tier, interval) to support 4 price IDs. Annual pricing built in. Stripe idempotency keys + customer reuse. `entitlements.last_stripe_event_at` watermark for out-of-order events.

**Tech Stack:** Stripe Node SDK (^17.4 or current), `zod` for env validation, Next.js 16 App Router on Vercel Node runtime, Supabase service-role client, Vitest.

**Spec:** `docs/specs/v7.0-phase3-stripe-entitlements.md` (PR #122). This plan implements the migration + lib/billing + 4 web routes + 1 RPC + entitlement gate + CLI 402 + 30 tests documented there.

**Repo:** `/Users/alexledbetter/Downloads/claude-autopilot` (NOT delegance-app).

**IMPORTANT — skip dev-env-dependent verification.** Operator-only:
- Running `/migrate` (operator post-merge).
- Real Stripe API calls (use vi.mock for the SDK).
- Real Vercel deploy or env var setup.
- Real Supabase project (use stub).
- Setting up Stripe webhook in dashboard (operator).
- Creating Stripe Products/Prices (operator).

**Codex pass on plan folded inline (3 CRITICAL + 4 WARNING + 2 NOTE accepted; 1 WARNING dismissed):**
- CRITICAL: migration must backfill existing `entitlements` rows + insert missing free entitlement rows for existing organizations BEFORE adding the CHECK constraint. Otherwise the constraint trip on apply (Task 1).
- CRITICAL: webhook handlers MUST compare `event.created` vs `entitlements.last_stripe_event_at` and skip mutation when the event is older. Update the watermark in the same write that mutates entitlements (Task 5).
- CRITICAL: run-count cap ordering — `runs` row is created by the CLI BEFORE the `POST /api/upload-session` call (the row is what the CLI is uploading). So `count_runs_this_month()` includes the current run. Use `>` comparison (NOT `>=`) so the 100th run still mints; the 101st rejects. Documented in Task 4.
- WARNING: split config loaders. `checkEntitlement()` only needs `AUTOPILOT_PUBLIC_BASE_URL`; loading the full Stripe config there means `/api/upload-session` fails hard on missing Stripe env. New `loadPublicBillingConfig()` for the upload gate; `loadBillingConfig()` reserved for Stripe-touching routes (Task 2).
- WARNING: pin Stripe SDK API version to one supported by `stripe@^17` — `'2024-12-18.acacia'` (or whichever the installed types accept). The earlier draft's `'2025-09-30.clover'` may not compile (Task 3).
- WARNING: tasks 5/6/7 had too little inline detail — risk of agent misinterpretation. Each now has explicit behavior contract bullets (auth, schema, side effects). Spec has the full code blocks; plan has the gates.
- WARNING: storage preflight `expectedBytes` covers events.ndjson; state.json adds <1 KiB which is negligible. Documented; no fix.
- NOTE: failed-event retries also increment `attempt_count` (Task 5).
- NOTE: count_runs_this_month uses canonical `date_trunc('month', NOW() AT TIME ZONE 'UTC')` form already; emphasized in Task 1 SQL.
- DISMISSED: codex's "Vercel vs ECS" — claude-autopilot deploys to Vercel per `docs/specs/v7.0-hosted-product-mvp.md`. The recurring cross-repo confusion noted in earlier phases.

---

## File Structure

| Path | Purpose |
|---|---|
| `data/deltas/20260507180000_phase3_billing.sql` (NEW) | billing_customers, augment entitlements, augment runs, stripe_webhook_events, personal_entitlements, sum_retained_bytes RPC, seed_free_entitlements trigger. |
| `apps/web/lib/billing/plan-map.ts` (NEW) | `loadBillingConfig()` (zod), `PLAN_MAP`, `tierForPriceId()`. |
| `apps/web/lib/billing/check-entitlement.ts` (NEW) | `checkEntitlement(input)` — branches free/org/personal; calls sum_retained_bytes RPC. |
| `apps/web/lib/billing/stripe.ts` (NEW) | Lazy Stripe client construction; test seam for mocking. |
| `apps/web/app/api/stripe/webhook/route.ts` (NEW) | POST. `runtime = 'nodejs'`. Raw-body sig verify, claim/lease/complete, dispatch by event.type. |
| `apps/web/app/api/dashboard/billing/checkout/route.ts` (NEW) | POST. Org admin/owner auth, idempotency key, customer reuse. |
| `apps/web/app/api/dashboard/billing/portal/route.ts` (NEW) | POST. Stripe Customer Portal session URL. |
| `apps/web/app/api/upload-session/route.ts` (MODIFIED) | Insert entitlement check between ownership pass and JWT mint. Add `expectedBytes` body field. |
| `apps/web/__tests__/api/stripe/webhook.test.ts` (NEW) | 8 tests. |
| `apps/web/__tests__/api/dashboard/billing/checkout.test.ts` (NEW) | 4 tests. |
| `apps/web/__tests__/api/dashboard/billing/portal.test.ts` (NEW) | 3 tests. |
| `apps/web/__tests__/lib/billing/check-entitlement.test.ts` (NEW) | 10 tests. |
| `apps/web/__tests__/lib/billing/plan-map.test.ts` (NEW) | 2 tests. |
| `apps/web/__tests__/api/upload-session.test.ts` (MODIFIED) | +2 tests for 402 / within-cap. |
| `apps/web/__tests__/_helpers/supabase-stub.ts` (MODIFIED) | Add sum_retained_bytes RPC stub; add stripe_webhook_events row tracking. |
| `apps/web/__tests__/_helpers/stripe-stub.ts` (NEW) | Mock Stripe SDK with constructEvent + checkout.sessions.create + billingPortal.sessions.create + subscriptions.retrieve. |
| `src/dashboard/upload/uploader.ts` (MODIFIED) | UploadLimitError class; parse 402 response. Pass `expectedBytes` in mint body. |
| `src/cli/autopilot.ts` (MODIFIED) | Catch UploadLimitError, print friendly message, preserve run exit code. |
| `tests/dashboard/upload-402.test.ts` (NEW) | 3 CLI tests. |
| `tests/dashboard/_helpers/mock-server.ts` (MODIFIED) | Add `cap402` scenario returning 402 + structured payload. |
| `apps/web/.env.example` (MODIFIED) | Add 6 new env vars. |
| `package.json` (root, MODIFIED) | Add `stripe` + `zod` deps; bump `6.3.0-pre.4` → `6.3.0-pre.5`. |
| `apps/web/package.json` (MODIFIED) | Add `stripe` + `zod`. |
| `CHANGELOG.md` (MODIFIED) | New `## 6.3.0-pre.5` entry. |
| `docs/v6/migration-guide.md` (MODIFIED) | New "v7.0 Phase 3 — billing" section. |
| `docs/specs/v7.0-phase3-stripe-entitlements.md` (MODIFIED) | Append `Status — shipped 2026-05-08 in PR #...` placeholder; final at merge. |

---

## Task 1: Migration

**Files:**
- Create: `data/deltas/20260507180000_phase3_billing.sql`

- [ ] **Step 1: Write migration SQL exactly per spec**

```sql
-- Phase 3 — Stripe entitlement enforcement.
-- Spec: docs/specs/v7.0-phase3-stripe-entitlements.md (PR #122).

-- Codex plan-pass CRITICAL #1 — backfill existing rows BEFORE adding the
-- CHECK constraint. Phase 1 may have created `entitlements` rows with NULL
-- caps. Backfill them based on plan to satisfy the new constraint.
-- (No-op on a fresh database.)
UPDATE entitlements SET runs_per_month_cap = 100, storage_bytes_cap = 5368709120
  WHERE plan = 'free' AND runs_per_month_cap IS NULL;
UPDATE entitlements SET runs_per_month_cap = 1000, storage_bytes_cap = 53687091200
  WHERE plan = 'small' AND runs_per_month_cap IS NULL;
UPDATE entitlements SET runs_per_month_cap = 10000, storage_bytes_cap = 536870912000
  WHERE plan = 'mid' AND runs_per_month_cap IS NULL;

-- Insert free entitlement rows for any organizations that don't have one yet.
INSERT INTO entitlements (organization_id, plan, runs_per_month_cap, storage_bytes_cap)
  SELECT id, 'free', 100, 5368709120
  FROM organizations
  WHERE id NOT IN (SELECT organization_id FROM entitlements);

-- Stripe customer mapping per org.
CREATE TABLE billing_customers (
  organization_id UUID PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
  stripe_customer_id TEXT NOT NULL UNIQUE CHECK (stripe_customer_id ~ '^cus_'),
  email TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE billing_customers ENABLE ROW LEVEL SECURITY;
-- No policies; service-role-only.

-- Augment entitlements.
ALTER TABLE entitlements
  ADD COLUMN stripe_customer_id TEXT REFERENCES billing_customers(stripe_customer_id),
  ADD COLUMN stripe_subscription_status TEXT
    CHECK (stripe_subscription_status IS NULL OR stripe_subscription_status IN
      ('trialing','active','past_due','canceled','unpaid','incomplete','incomplete_expired','paused')),
  ADD COLUMN current_period_end TIMESTAMPTZ,
  ADD COLUMN cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN cancel_at TIMESTAMPTZ,
  ADD COLUMN payment_failed_at TIMESTAMPTZ,
  ADD COLUMN runs_per_month_cap INTEGER,
  ADD COLUMN storage_bytes_cap BIGINT,
  ADD COLUMN last_stripe_event_at TIMESTAMPTZ;

-- Codex pass 2 — free orgs have explicit caps; only enterprise has NULLs.
ALTER TABLE entitlements ADD CONSTRAINT entitlements_plan_caps_check CHECK (
  (plan IN ('free','small','mid') AND runs_per_month_cap IS NOT NULL AND storage_bytes_cap IS NOT NULL) OR
  (plan = 'enterprise' AND runs_per_month_cap IS NULL AND storage_bytes_cap IS NULL)
);

-- Webhook event idempotency with claim/lease/complete pattern.
CREATE TABLE stripe_webhook_events (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'processing'
    CHECK (status IN ('processing','completed','failed')),
  attempt_count INTEGER NOT NULL DEFAULT 1 CHECK (attempt_count >= 1),
  processing_started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  locked_until TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '60 seconds'),
  error TEXT,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at TIMESTAMPTZ
);
CREATE INDEX stripe_webhook_events_status_received ON stripe_webhook_events (status, received_at);
ALTER TABLE stripe_webhook_events ENABLE ROW LEVEL SECURITY;
-- Service-role-only.

-- Personal-tier entitlements (org_id IS NULL flow).
CREATE TABLE personal_entitlements (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  runs_per_month_cap INTEGER NOT NULL DEFAULT 100 CHECK (runs_per_month_cap >= 0),
  storage_bytes_cap BIGINT NOT NULL DEFAULT 5368709120 CHECK (storage_bytes_cap >= 0),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE personal_entitlements ENABLE ROW LEVEL SECURITY;
CREATE POLICY personal_entitlements_select_own ON personal_entitlements
  FOR SELECT USING (user_id = auth.uid());
-- INSERT/UPDATE/DELETE service-role-only.

-- runs.total_bytes (Phase 2.2 finalize already computes; persist now).
ALTER TABLE runs
  ADD COLUMN IF NOT EXISTS total_bytes BIGINT NOT NULL DEFAULT 0 CHECK (total_bytes >= 0),
  ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

CREATE INDEX IF NOT EXISTS runs_org_active ON runs (organization_id, created_at DESC)
  WHERE organization_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS runs_user_personal_active ON runs (user_id, created_at DESC)
  WHERE organization_id IS NULL AND deleted_at IS NULL;

-- Storage cap aggregate RPC (codex pass 2 WARNING).
CREATE OR REPLACE FUNCTION sum_retained_bytes(
  p_organization_id UUID,
  p_user_id UUID,
  p_retention_days INTEGER DEFAULT 90
) RETURNS BIGINT AS $$
  SELECT COALESCE(SUM(total_bytes), 0)::BIGINT
    FROM public.runs
    WHERE deleted_at IS NULL
      AND created_at >= NOW() - (p_retention_days || ' days')::INTERVAL
      AND (
        (p_organization_id IS NOT NULL AND organization_id = p_organization_id) OR
        (p_organization_id IS NULL AND organization_id IS NULL AND user_id = p_user_id)
      );
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp;
REVOKE ALL ON FUNCTION sum_retained_bytes(UUID, UUID, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION sum_retained_bytes(UUID, UUID, INTEGER) TO service_role;

-- Run count for current calendar month.
CREATE OR REPLACE FUNCTION count_runs_this_month(
  p_organization_id UUID,
  p_user_id UUID
) RETURNS INTEGER AS $$
  SELECT COUNT(*)::INTEGER
    FROM public.runs
    WHERE created_at >= date_trunc('month', NOW() AT TIME ZONE 'UTC')
      AND (
        (p_organization_id IS NOT NULL AND organization_id = p_organization_id) OR
        (p_organization_id IS NULL AND organization_id IS NULL AND user_id = p_user_id)
      );
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp;
REVOKE ALL ON FUNCTION count_runs_this_month(UUID, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION count_runs_this_month(UUID, UUID) TO service_role;

-- Trigger: seed free entitlements row on org creation.
CREATE OR REPLACE FUNCTION seed_free_entitlements() RETURNS TRIGGER AS $$
BEGIN
  IF NEW.plan = 'free' THEN
    INSERT INTO public.entitlements (organization_id, plan, runs_per_month_cap, storage_bytes_cap)
      VALUES (NEW.id, 'free', 100, 5368709120)
      ON CONFLICT (organization_id) DO NOTHING;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp;

CREATE TRIGGER organizations_seed_free_entitlements
  AFTER INSERT ON organizations
  FOR EACH ROW EXECUTE FUNCTION seed_free_entitlements();
```

- [ ] **Step 2: Commit**

```bash
git add data/deltas/20260507180000_phase3_billing.sql
git commit -m "feat(v7.0-phase3): migration — billing tables, RPCs, free-entitlements trigger"
```

---

## Task 2: Plan-map + zod env validation

**Files:**
- Create: `apps/web/lib/billing/plan-map.ts`
- Create: `apps/web/__tests__/lib/billing/plan-map.test.ts`

- [ ] **Step 1: Add deps**

```bash
cd apps/web && npm install stripe@^17 zod@^3.23
```

- [ ] **Step 2: Implement plan-map**

```typescript
// apps/web/lib/billing/plan-map.ts
import { z } from 'zod';

// Codex plan-pass WARNING — split config loaders. Upload-session entitlement
// gate only needs the public base URL; the rest of Stripe config is required
// only by Stripe-touching routes (webhook, checkout, portal).
const PublicConfigSchema = z.object({
  AUTOPILOT_PUBLIC_BASE_URL: z.string().url().default('https://autopilot.dev'),
});

const FullConfigSchema = PublicConfigSchema.extend({
  STRIPE_SECRET_KEY: z.string().min(20).startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().min(20).startsWith('whsec_'),
  STRIPE_PRICE_SMALL_MONTHLY: z.string().min(20).startsWith('price_'),
  STRIPE_PRICE_SMALL_YEARLY: z.string().min(20).startsWith('price_'),
  STRIPE_PRICE_MID_MONTHLY: z.string().min(20).startsWith('price_'),
  STRIPE_PRICE_MID_YEARLY: z.string().min(20).startsWith('price_'),
});

export type PublicBillingConfig = z.infer<typeof PublicConfigSchema>;
export type BillingConfig = z.infer<typeof FullConfigSchema>;

let cachedPublic: PublicBillingConfig | null = null;
let cachedFull: BillingConfig | null = null;

export function loadPublicBillingConfig(): PublicBillingConfig {
  if (cachedPublic) return cachedPublic;
  const parsed = PublicConfigSchema.safeParse(process.env);
  if (!parsed.success) {
    throw new Error(`Invalid public billing config: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
  }
  cachedPublic = parsed.data;
  return cachedPublic;
}

export function loadBillingConfig(): BillingConfig {
  if (cachedFull) return cachedFull;
  const parsed = FullConfigSchema.safeParse(process.env);
  if (!parsed.success) {
    throw new Error(`Invalid billing config: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
  }
  cachedFull = parsed.data;
  cachedPublic = parsed.data;  // full implies public
  return cachedFull;
}

export function _resetBillingConfigForTests(): void { cachedPublic = null; cachedFull = null; }

export type Tier = 'small' | 'mid';
export type Interval = 'monthly' | 'yearly';

export const PLAN_MAP = {
  small: {
    monthly: { get priceId() { return loadBillingConfig().STRIPE_PRICE_SMALL_MONTHLY; } },
    yearly:  { get priceId() { return loadBillingConfig().STRIPE_PRICE_SMALL_YEARLY; } },
    runsPerMonthCap: 1000,
    storageBytesCap: 50 * 1024 * 1024 * 1024,
  },
  mid: {
    monthly: { get priceId() { return loadBillingConfig().STRIPE_PRICE_MID_MONTHLY; } },
    yearly:  { get priceId() { return loadBillingConfig().STRIPE_PRICE_MID_YEARLY; } },
    runsPerMonthCap: 10000,
    storageBytesCap: 500 * 1024 * 1024 * 1024,
  },
} as const;

export function tierForPriceId(priceId: string): { tier: Tier; interval: Interval } | null {
  if (priceId === PLAN_MAP.small.monthly.priceId) return { tier: 'small', interval: 'monthly' };
  if (priceId === PLAN_MAP.small.yearly.priceId)  return { tier: 'small', interval: 'yearly' };
  if (priceId === PLAN_MAP.mid.monthly.priceId)   return { tier: 'mid', interval: 'monthly' };
  if (priceId === PLAN_MAP.mid.yearly.priceId)    return { tier: 'mid', interval: 'yearly' };
  return null;
}

export function capsForTier(tier: Tier): { runsPerMonthCap: number; storageBytesCap: number } {
  return { runsPerMonthCap: PLAN_MAP[tier].runsPerMonthCap, storageBytesCap: PLAN_MAP[tier].storageBytesCap };
}
```

- [ ] **Step 3: Tests**

```typescript
// apps/web/__tests__/lib/billing/plan-map.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { _resetBillingConfigForTests, loadBillingConfig, tierForPriceId } from '@/lib/billing/plan-map';

beforeEach(() => {
  _resetBillingConfigForTests();
  process.env.STRIPE_SECRET_KEY = 'sk_test_' + 'x'.repeat(20);
  process.env.STRIPE_WEBHOOK_SECRET = 'whsec_' + 'x'.repeat(20);
  process.env.STRIPE_PRICE_SMALL_MONTHLY = 'price_small_monthly_xxxxxxxxxxxx';
  process.env.STRIPE_PRICE_SMALL_YEARLY  = 'price_small_yearly_xxxxxxxxxxxxx';
  process.env.STRIPE_PRICE_MID_MONTHLY   = 'price_mid_monthly_xxxxxxxxxxxxxx';
  process.env.STRIPE_PRICE_MID_YEARLY    = 'price_mid_yearly_xxxxxxxxxxxxxxx';
});

describe('plan-map', () => {
  it('throws on missing env var', () => {
    delete process.env.STRIPE_PRICE_MID_YEARLY;
    _resetBillingConfigForTests();
    expect(() => loadBillingConfig()).toThrow(/Invalid billing config/);
  });

  it('round-trips priceId → (tier, interval) for all 4 prices', () => {
    expect(tierForPriceId(process.env.STRIPE_PRICE_SMALL_MONTHLY!)).toEqual({ tier: 'small', interval: 'monthly' });
    expect(tierForPriceId(process.env.STRIPE_PRICE_SMALL_YEARLY!)).toEqual({ tier: 'small', interval: 'yearly' });
    expect(tierForPriceId(process.env.STRIPE_PRICE_MID_MONTHLY!)).toEqual({ tier: 'mid', interval: 'monthly' });
    expect(tierForPriceId(process.env.STRIPE_PRICE_MID_YEARLY!)).toEqual({ tier: 'mid', interval: 'yearly' });
    expect(tierForPriceId('price_unknown')).toBeNull();
  });
});
```

- [ ] **Step 4: Commit**

```bash
git add apps/web/lib/billing/plan-map.ts apps/web/__tests__/lib/billing/plan-map.test.ts apps/web/package.json apps/web/package-lock.json
git commit -m "feat(v7.0-phase3): plan-map + zod env validation"
```

---

## Task 3: Stripe SDK wrapper

**Files:**
- Create: `apps/web/lib/billing/stripe.ts`

- [ ] **Step 1: Implement**

```typescript
// apps/web/lib/billing/stripe.ts
import Stripe from 'stripe';
import { loadBillingConfig } from './plan-map';

let cached: Stripe | null = null;

export function getStripeClient(): Stripe {
  if (cached) return cached;
  const config = loadBillingConfig();
  // Codex plan-pass WARNING — pin to API version supported by stripe@^17.
  // The exact literal must match the SDK's Stripe.LatestApiVersion type.
  // If `tsc --noEmit` rejects it, replace with the SDK's exported
  // Stripe.LatestApiVersion (see node_modules/stripe/types/lib.d.ts).
  cached = new Stripe(config.STRIPE_SECRET_KEY, {
    apiVersion: '2024-12-18.acacia',
  });
  return cached;
}

export function _resetStripeClientForTests(): void { cached = null; }
```

The test seam (`_resetStripeClientForTests`) plus `vi.mock('stripe', ...)` in tests gives complete control over Stripe behavior.

- [ ] **Step 2: Commit**

```bash
git add apps/web/lib/billing/stripe.ts
git commit -m "feat(v7.0-phase3): stripe client wrapper with test seam"
```

---

## Task 4: checkEntitlement helper + tests

**Files:**
- Create: `apps/web/lib/billing/check-entitlement.ts`
- Create: `apps/web/__tests__/lib/billing/check-entitlement.test.ts`
- Modify: `apps/web/__tests__/_helpers/supabase-stub.ts` (add count_runs_this_month + sum_retained_bytes RPCs)

- [ ] **Step 1: Implement**

```typescript
// apps/web/lib/billing/check-entitlement.ts
import { createServiceRoleClient } from '@/lib/supabase/service';
import { loadPublicBillingConfig } from './plan-map';

export type EffectivePlan = 'free' | 'small' | 'mid' | 'enterprise';

export interface EntitlementResult {
  exceeded: boolean;
  kind: 'runs_per_month' | 'storage_bytes' | 'none';
  current: number;
  max: number;
  upgradeUrl: string;
  effectivePlan: EffectivePlan;
}

export interface EntitlementInput {
  organizationId: string | null;
  userId: string;
  expectedBytes: number;     // CLI-provided estimate from fs.stat
}

export async function checkEntitlement(input: EntitlementInput): Promise<EntitlementResult> {
  const config = loadPublicBillingConfig();
  const upgradeUrl = `${config.AUTOPILOT_PUBLIC_BASE_URL}/dashboard/billing`;
  const supabase = createServiceRoleClient();

  // Resolve caps + effective plan.
  let runsCap: number | null = null;
  let storageCap: number | null = null;
  let effectivePlan: EffectivePlan;

  if (input.organizationId) {
    const { data: ent } = await supabase.from('entitlements')
      .select('plan, runs_per_month_cap, storage_bytes_cap, stripe_subscription_status, current_period_end, cancel_at, payment_failed_at')
      .eq('organization_id', input.organizationId).maybeSingle();
    if (!ent) {
      throw new Error(`entitlements row missing for org ${input.organizationId} — should have been seeded by trigger`);
    }
    const e = ent as {
      plan: EffectivePlan; runs_per_month_cap: number | null; storage_bytes_cap: number | null;
      stripe_subscription_status: string | null; current_period_end: string | null;
      cancel_at: string | null; payment_failed_at: string | null;
    };

    // Subscription state grace logic.
    const now = new Date();
    const periodEnd = e.current_period_end ? new Date(e.current_period_end) : null;
    const cancelAt = e.cancel_at ? new Date(e.cancel_at) : null;
    const paymentFailed = e.payment_failed_at ? new Date(e.payment_failed_at) : null;

    let fellBackToFree = false;
    if (e.plan !== 'free' && e.plan !== 'enterprise') {
      if (e.stripe_subscription_status === 'canceled' && periodEnd && periodEnd < now) fellBackToFree = true;
      else if (cancelAt && cancelAt < now) fellBackToFree = true;
      else if (paymentFailed && (now.getTime() - paymentFailed.getTime()) > 7 * 86400_000) fellBackToFree = true;
    }
    effectivePlan = fellBackToFree ? 'free' : e.plan;

    if (effectivePlan === 'enterprise') {
      runsCap = null; storageCap = null;
    } else if (fellBackToFree) {
      runsCap = 100; storageCap = 5 * 1024 * 1024 * 1024;
    } else {
      runsCap = e.runs_per_month_cap;
      storageCap = e.storage_bytes_cap;
    }
  } else {
    // Personal tier — self-heal missing row.
    const { data: row } = await supabase.from('personal_entitlements')
      .select('runs_per_month_cap, storage_bytes_cap')
      .eq('user_id', input.userId).maybeSingle();
    if (row) {
      const r = row as { runs_per_month_cap: number; storage_bytes_cap: number };
      runsCap = r.runs_per_month_cap;
      storageCap = r.storage_bytes_cap;
    } else {
      // Upsert default.
      await supabase.from('personal_entitlements').insert({
        user_id: input.userId, runs_per_month_cap: 100, storage_bytes_cap: 5 * 1024 * 1024 * 1024,
      });
      runsCap = 100;
      storageCap = 5 * 1024 * 1024 * 1024;
    }
    effectivePlan = 'free';
  }

  if (runsCap === null || storageCap === null) {
    return { exceeded: false, kind: 'none', current: 0, max: 0, upgradeUrl, effectivePlan };
  }

  // Check runs cap.
  const { data: runsCount } = await supabase.rpc('count_runs_this_month', {
    p_organization_id: input.organizationId, p_user_id: input.userId,
  });
  const runs = (runsCount as number | null) ?? 0;
  // Codex plan-pass CRITICAL #3 — runs row already exists when /api/upload-session
  // is called (CLI created it). count_runs_this_month INCLUDES the current run.
  // So "100 free runs" means: when count is 100, this is the 100th — allow.
  // Reject at count 101+. Use STRICT '>' not '>='.
  if (runs > runsCap) {
    return { exceeded: true, kind: 'runs_per_month', current: runs, max: runsCap, upgradeUrl, effectivePlan };
  }

  // Check storage cap (with expectedBytes preflight).
  const { data: usedBytes } = await supabase.rpc('sum_retained_bytes', {
    p_organization_id: input.organizationId, p_user_id: input.userId, p_retention_days: 90,
  });
  const used = (usedBytes as number | null) ?? 0;
  if (used + input.expectedBytes > storageCap) {
    return { exceeded: true, kind: 'storage_bytes', current: used + input.expectedBytes, max: storageCap, upgradeUrl, effectivePlan };
  }

  return { exceeded: false, kind: 'none', current: runs, max: runsCap, upgradeUrl, effectivePlan };
}
```

- [ ] **Step 2: Stub additions** — extend supabase-stub callRpc to handle:
  - `count_runs_this_month` — count rows in `runs` matching org/user filter with `created_at >= date_trunc('month', NOW())`.
  - `sum_retained_bytes` — sum `total_bytes` matching filter with `deleted_at IS NULL` and 90-day window.

```typescript
if (fn === 'count_runs_this_month') {
  const monthStart = new Date(); monthStart.setUTCDate(1); monthStart.setUTCHours(0,0,0,0);
  const rows = (this.tables.get('runs') ?? []).filter((r) => {
    if (new Date(r.created_at as string) < monthStart) return false;
    if (args.p_organization_id != null) return r.organization_id === args.p_organization_id;
    return r.organization_id == null && r.user_id === args.p_user_id;
  });
  return { data: rows.length, error: null };
}
if (fn === 'sum_retained_bytes') {
  const cutoff = new Date(Date.now() - (args.p_retention_days as number ?? 90) * 86400_000);
  const rows = (this.tables.get('runs') ?? []).filter((r) => {
    if (r.deleted_at != null) return false;
    if (new Date(r.created_at as string) < cutoff) return false;
    if (args.p_organization_id != null) return r.organization_id === args.p_organization_id;
    return r.organization_id == null && r.user_id === args.p_user_id;
  });
  const sum = rows.reduce((s, r) => s + (Number(r.total_bytes) || 0), 0);
  return { data: sum, error: null };
}
```

- [ ] **Step 3: 10 tests** in `check-entitlement.test.ts` per spec.

- [ ] **Step 4: Commit**

```bash
git add apps/web/lib/billing/check-entitlement.ts apps/web/__tests__/lib/billing/check-entitlement.test.ts apps/web/__tests__/_helpers/supabase-stub.ts
git commit -m "feat(v7.0-phase3): checkEntitlement + supabase-stub RPC support"
```

---

## Task 5: Stripe webhook handler

**Files:**
- Create: `apps/web/app/api/stripe/webhook/route.ts`
- Create: `apps/web/__tests__/_helpers/stripe-stub.ts`
- Create: `apps/web/__tests__/api/stripe/webhook.test.ts`

- [ ] **Step 1: Stripe stub helper**

```typescript
// apps/web/__tests__/_helpers/stripe-stub.ts
import { vi } from 'vitest';

export interface StripeStubState {
  events: Map<string, unknown>;       // eventId → event
  subscriptions: Map<string, unknown>;
  customers: Map<string, unknown>;
  checkoutSessions: Map<string, unknown>;
  portalSessions: Map<string, { url: string }>;
}

export function makeStripeStub(): StripeStubState & { client: ReturnType<typeof buildClient> } {
  // ... implements .webhooks.constructEvent (signature verify mock),
  //     .checkout.sessions.create, .billingPortal.sessions.create,
  //     .subscriptions.retrieve. See spec for behavior contract.
}

export function buildClient(state: StripeStubState) { /* ... */ }
```

(Plan trims body — agent fills in. Key behaviors: `constructEvent(rawBody, sig, secret)` returns `JSON.parse(rawBody)` if `sig === 'valid-test-signature'`, throws otherwise. `checkout.sessions.create` returns `{ id, url, customer }` echoing inputs. `subscriptions.retrieve` reads from `state.subscriptions`.)

- [ ] **Step 2: Implement webhook route**

```typescript
// apps/web/app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server';
import { getStripeClient } from '@/lib/billing/stripe';
import { loadBillingConfig, tierForPriceId, capsForTier } from '@/lib/billing/plan-map';
import { createServiceRoleClient } from '@/lib/supabase/service';
import type Stripe from 'stripe';

export const runtime = 'nodejs';

export async function POST(req: Request): Promise<Response> {
  const sig = req.headers.get('stripe-signature');
  if (!sig) return new Response('missing signature', { status: 400 });
  const config = loadBillingConfig();

  const rawBody = await req.text();
  let event: Stripe.Event;
  try {
    event = getStripeClient().webhooks.constructEvent(rawBody, sig, config.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return new Response(`signature verification failed: ${(err as Error).message}`, { status: 400 });
  }

  const supabase = createServiceRoleClient();

  // Claim or detect duplicate.
  const { error: insertErr } = await supabase.from('stripe_webhook_events').insert({
    id: event.id, type: event.type, payload: event,
  });

  if (insertErr) {
    if (!/duplicate|unique/i.test(insertErr.message)) {
      return new Response('insert error', { status: 500 });
    }
    const { data } = await supabase.from('stripe_webhook_events')
      .select('status, locked_until, attempt_count').eq('id', event.id).maybeSingle();
    if (!data) return new Response('row missing', { status: 500 });
    const row = data as { status: string; locked_until: string; attempt_count: number };
    if (row.status === 'completed') return new Response('ok', { status: 200 });
    if (row.status === 'processing') {
      if (new Date(row.locked_until) > new Date()) {
        return new Response('lease held', { status: 409 });
      }
      // Stale lease — reclaim atomically.
      const { data: claimed } = await supabase.from('stripe_webhook_events').update({
        attempt_count: row.attempt_count + 1,
        processing_started_at: new Date().toISOString(),
        locked_until: new Date(Date.now() + 60_000).toISOString(),
        error: null,
      }).eq('id', event.id).eq('status', 'processing').lte('locked_until', new Date().toISOString())
        .select('id').maybeSingle();
      if (!claimed) return new Response('lease race lost', { status: 409 });
    } else if (row.status === 'failed') {
      // Codex plan-pass NOTE — increment attempt_count on failed retries too.
      await supabase.from('stripe_webhook_events').update({
        status: 'processing', error: null,
        attempt_count: row.attempt_count + 1,
        processing_started_at: new Date().toISOString(),
        locked_until: new Date(Date.now() + 60_000).toISOString(),
      }).eq('id', event.id);
    }
  }

  try {
    await dispatch(event, supabase);
    await supabase.from('stripe_webhook_events').update({
      status: 'completed', completed_at: new Date().toISOString(),
    }).eq('id', event.id);
    return new Response('ok', { status: 200 });
  } catch (err) {
    await supabase.from('stripe_webhook_events').update({
      status: 'failed', error: (err as Error).message,
    }).eq('id', event.id);
    return new Response('handler failed', { status: 500 });
  }
}

async function dispatch(event: Stripe.Event, supabase: ReturnType<typeof createServiceRoleClient>): Promise<void> {
  switch (event.type) {
    case 'checkout.session.completed':
      return handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, supabase);
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted':
      return handleSubscriptionEvent(event, supabase);
    case 'invoice.payment_failed':
      return handlePaymentFailed(event.data.object as Stripe.Invoice, supabase);
    default:
      return;  // unhandled events still mark completed
  }
}
// ... handler bodies — agent fills in per spec sections. Each handler
// MUST follow this watermark pattern (codex plan-pass CRITICAL #2):
//
// async function handleSubscriptionEvent(event, supabase) {
//   const sub = event.data.object;
//   const orgId = await resolveOrgFromSubscription(sub, supabase);
//   const { data: ent } = await supabase.from('entitlements')
//     .select('last_stripe_event_at').eq('organization_id', orgId).maybeSingle();
//   const eventTime = new Date(event.created * 1000);
//   const watermark = ent?.last_stripe_event_at ? new Date(ent.last_stripe_event_at) : null;
//   if (watermark && watermark >= eventTime) return;  // older event; skip mutation
//   // ... mutate entitlements based on event type ...
//   await supabase.from('entitlements').update({
//     // ...mutated fields...,
//     last_stripe_event_at: eventTime.toISOString(),
//   }).eq('organization_id', orgId);
// }
```

- [ ] **Step 3: 8 tests** per spec.

- [ ] **Step 4: Commit**.

---

## Task 6: Checkout endpoint

**Files:**
- Create: `apps/web/app/api/dashboard/billing/checkout/route.ts`
- Create: `apps/web/__tests__/api/dashboard/billing/checkout.test.ts`

Per spec — Supabase session auth, role check (owner/admin), `Stripe.Checkout.Session.create` with idempotencyKey + customer reuse + metadata. 4 tests.

---

## Task 7: Customer Portal endpoint

**Files:**
- Create: `apps/web/app/api/dashboard/billing/portal/route.ts`
- Create: `apps/web/__tests__/api/dashboard/billing/portal.test.ts`

Per spec. 3 tests.

---

## Task 8: Wire entitlement gate into upload-session

**Files:**
- Modify: `apps/web/app/api/upload-session/route.ts`
- Modify: `apps/web/__tests__/api/upload-session.test.ts`

- [ ] **Step 1: Add `expectedBytes` to body schema** + plumb into checkEntitlement call between ownership pass and JWT mint.

```typescript
// In POST handler, after run + caller resolution:
const ent = await checkEntitlement({
  organizationId: r.organization_id,
  userId: caller.userId,
  expectedBytes: body.expectedBytes ?? 0,
});
if (ent.exceeded) {
  return NextResponse.json({
    error: 'limit_reached',
    limit: ent.kind,
    current: ent.current,
    max: ent.max,
    upgrade_url: ent.upgradeUrl,
  }, { status: 402 });
}
```

- [ ] **Step 2: 2 new tests** — at-cap → 402, within-cap → 201 (regression that gate doesn't false-positive).

- [ ] **Step 3: Commit**.

---

## Task 9: CLI uploader 402 handling

**Files:**
- Modify: `src/dashboard/upload/uploader.ts`
- Modify: `src/cli/autopilot.ts`
- Modify: `tests/dashboard/_helpers/mock-server.ts` (add `cap402` scenario)
- Create: `tests/dashboard/upload-402.test.ts`

- [ ] **Step 1: Add `UploadLimitError` class**

```typescript
// In src/dashboard/upload/uploader.ts
export class UploadLimitError extends UploadError {
  constructor(message: string, public payload: {
    limit: string; current: number; max: number; upgrade_url: string;
  }) {
    super(message, 402);
  }
}

// In bootstrapSession after mint POST:
if (mintRes.status === 402) {
  const body = await mintRes.json() as {
    error: string; limit: string; current: number; max: number; upgrade_url: string;
  };
  throw new UploadLimitError(
    `upload rejected — ${body.limit} cap reached (${body.current}/${body.max}). Upgrade at ${body.upgrade_url}`,
    body,
  );
}
```

- [ ] **Step 2: Pass `expectedBytes` in mint body**

```typescript
body: JSON.stringify({
  runId,
  expectedChunkCount,
  expectedBytes: snap.eventsBytes,   // already known from snapshot
}),
```

- [ ] **Step 3: Wire `autopilot.ts` to print friendly + preserve exit code**

```typescript
} catch (err) {
  if (err instanceof UploadLimitError) {
    console.warn(`[autopilot] ${err.message}`);
    return;  // graceful — don't bubble; preserve run's exit code
  }
  // ... existing error handling
}
```

- [ ] **Step 4: Mock server `cap402` scenario** — make POST `/api/upload-session` return 402 with structured payload.

- [ ] **Step 5: 3 tests in `upload-402.test.ts`**.

- [ ] **Step 6: Commit**.

---

## Task 10: Docs + version bump

**Files:**
- Modify: `package.json`
- Modify: `CHANGELOG.md`
- Modify: `docs/v6/migration-guide.md`
- Modify: `apps/web/.env.example`

- [ ] Bump version `6.3.0-pre.4` → `6.3.0-pre.5`.

- [ ] CHANGELOG entry per spec template.

- [ ] Migration guide section.

- [ ] `.env.example` adds: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_SMALL_MONTHLY`, `STRIPE_PRICE_SMALL_YEARLY`, `STRIPE_PRICE_MID_MONTHLY`, `STRIPE_PRICE_MID_YEARLY`. (`AUTOPILOT_PUBLIC_BASE_URL` was added in Phase 2.3.)

---

## Final verification

- [ ] `cd apps/web && npx vitest run` — all green (87+ → ~120).
- [ ] `npm test -- tests/dashboard` — all green (1591 → ~1594).
- [ ] `cd apps/web && npx tsc --noEmit` — clean.
- [ ] `npx tsc --noEmit -p tsconfig.json` — clean.
- [ ] `git push -u origin feat/v7.0-phase3-stripe-entitlements`.
- [ ] `gh pr create --base master` titled `feat(v7.0-phase3): Stripe entitlement enforcement` with operator follow-ups in body (run /migrate; set 6 Vercel env vars; configure Stripe webhook in dashboard for the 4 event types; create Stripe Products + 4 Prices).

---

## Self-review

- [x] Spec coverage: 30 tests in spec mapped to tasks 2 (2) + 4 (10) + 5 (8) + 6 (4) + 7 (3) + 8 (2) + 9 (3) — no gaps.
- [x] Migration mirrors spec SQL exactly including all 3 RPCs + trigger.
- [x] No "TBD" placeholders. Tasks 5 (webhook) + 6 (checkout) + 7 (portal) trim implementation detail; agent fills from spec.
- [x] Type consistency: `EntitlementResult`, `Tier`, `Interval`, `EffectivePlan` used identically across tasks.
- [x] Hard constraints from spec re-stated.
- [x] Skip list at top prevents agent stalls.

**Codex on plan dismissed:** Vercel-vs-ECS NOTE (recurring confusion).
