# Starter Custom Domain Add-On Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Allow Starter tier dealers to purchase a custom domain add-on for $36/year as a separate Stripe subscription.

**Architecture:** The add-on is a separate Stripe subscription tracked with three new fields on the Dealer model. Access control is extended via `hasCustomDomainAccess()`. Webhook handlers process add-on lifecycle events.

**Tech Stack:** Next.js 16 App Router, Prisma 7, Stripe Checkout, TypeScript

---

## Task 1: Database Schema Changes

**Files:**

- Modify: `prisma/schema.prisma:82-147` (Dealer model)

**Step 1: Add the three new fields to the Dealer model**

Add after line 121 (`hasGrandfatheredCustomDomain Boolean @default(false)`):

```prisma
  hasCustomDomainAddon            Boolean   @default(false)
  customDomainAddonSubscriptionId String?   @unique
  customDomainAddonStatus         String    @default("none") // none|active|cancelled
```

**Step 2: Sync the database schema**

Run: `npm run sync-db`
Expected: Schema synced successfully with new fields added

**Step 3: Verify the fields exist**

Run: `npx prisma studio` (open in browser, check Dealer model has new fields)
Expected: Three new fields visible in Dealer model

**Step 4: Commit**

```bash
git add prisma/schema.prisma
git commit -m "$(cat <<'EOF'
Add custom domain add-on fields to Dealer model (#436)

- hasCustomDomainAddon: Boolean flag for add-on status
- customDomainAddonSubscriptionId: Stripe subscription ID
- customDomainAddonStatus: none|active|cancelled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Update Access Control Function

**Files:**

- Modify: `lib/cms/types.ts:304-314`
- Test: `lib/cms/__tests__/types.test.ts` (create if doesn't exist)

**Step 1: Write the failing test**

Create/update `lib/cms/__tests__/types.test.ts`:

```typescript
import { hasCustomDomainAccess } from '../types';

describe('hasCustomDomainAccess', () => {
  it('returns true for Growth tier', () => {
    expect(hasCustomDomainAccess('growth')).toBe(true);
  });

  it('returns true for Enhanced tier', () => {
    expect(hasCustomDomainAccess('enhanced')).toBe(true);
  });

  it('returns true for Professional tier', () => {
    expect(hasCustomDomainAccess('professional')).toBe(true);
  });

  it('returns false for Starter tier without add-on', () => {
    expect(hasCustomDomainAccess('starter')).toBe(false);
  });

  it('returns true for Starter tier with grandfathered access', () => {
    expect(hasCustomDomainAccess('starter', true)).toBe(true);
  });

  it('returns true for Starter tier with custom domain add-on', () => {
    expect(hasCustomDomainAccess('starter', false, true)).toBe(true);
  });

  it('returns true when both grandfathered and add-on are true', () => {
    expect(hasCustomDomainAccess('starter', true, true)).toBe(true);
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- lib/cms/__tests__/types.test.ts`
Expected: FAIL - test for add-on parameter should fail (function doesn't accept 3rd param yet)

**Step 3: Update the hasCustomDomainAccess function**

Modify `lib/cms/types.ts` lines 294-314:

```typescript
/**
 * Checks if a dealer has custom domain access.
 *
 * Access is granted if:
 * - Dealer is on Growth tier or above, OR
 * - Dealer has grandfathered custom domain access from migration, OR
 * - Dealer has purchased the custom domain add-on (Starter tier)
 *
 * @param subscriptionTier - Dealer's current subscription tier
 * @param hasGrandfatheredAccess - Optional flag indicating grandfathered access from old system
 * @param hasCustomDomainAddon - Optional flag indicating add-on was purchased
 */
export function hasCustomDomainAccess(
  subscriptionTier: string,
  hasGrandfatheredAccess: boolean = false,
  hasCustomDomainAddon: boolean = false
): boolean {
  // Grandfathered dealers always have access regardless of tier
  if (hasGrandfatheredAccess) {
    return true;
  }
  // Dealers with custom domain add-on have access
  if (hasCustomDomainAddon) {
    return true;
  }
  // Otherwise check tier (Growth+) - case insensitive comparison
  return (CUSTOM_DOMAIN_TIERS as readonly string[]).includes(subscriptionTier.toLowerCase());
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- lib/cms/__tests__/types.test.ts`
Expected: PASS - all tests should pass

**Step 5: Commit**

```bash
git add lib/cms/types.ts lib/cms/__tests__/types.test.ts
git commit -m "$(cat <<'EOF'
Add hasCustomDomainAddon parameter to access control (#436)

Extends hasCustomDomainAccess() to grant access when Starter dealers
have purchased the custom domain add-on subscription.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Update Access Control Call Sites

**Files:**

- Modify: `app/api/dealer/custom-domain/route.ts`
- Modify: `components/settings/DomainSettingsContent.tsx` (API response already includes hasCustomDomainAccess)

**Step 1: Find all call sites**

Run: `grep -rn "hasCustomDomainAccess" --include="*.ts" --include="*.tsx"`
Expected: List of files calling the function

**Step 2: Update the custom domain API route**

The `/api/dealer/custom-domain` GET route builds the `hasCustomDomainAccess` response. Find where it calls the function and add the new parameter:

```typescript
hasCustomDomainAccess: hasCustomDomainAccess(
  dealer.subscriptionTier,
  dealer.hasGrandfatheredCustomDomain,
  dealer.hasCustomDomainAddon
),
```

**Step 3: Run the build to verify no type errors**

Run: `npm run build`
Expected: Build succeeds with no type errors

**Step 4: Commit**

```bash
git add -A
git commit -m "$(cat <<'EOF'
Update hasCustomDomainAccess call sites for add-on (#436)

Passes hasCustomDomainAddon to access control function in API routes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Add Custom Domain Add-On Price to Webhook Handlers

**Files:**

- Modify: `lib/stripe-webhook-handlers.ts:12-24`

**Step 1: Add the custom domain add-on price ID to PRICE_ENV_VARS**

Add after line 23:

```typescript
  // Custom domain add-on ($36/year)
  { envVar: 'STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL', tier: 'custom_domain_addon' },
```

**Step 2: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 3: Commit**

```bash
git add lib/stripe-webhook-handlers.ts
git commit -m "$(cat <<'EOF'
Add custom domain add-on price ID to webhook handlers (#436)

Registers STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL for tier detection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Create Add-On Checkout API Route

**Files:**

- Create: `app/api/checkout/custom-domain-addon/route.ts`
- Test: `app/api/checkout/custom-domain-addon/__tests__/route.test.ts`

**Step 1: Write the failing test**

Create `app/api/checkout/custom-domain-addon/__tests__/route.test.ts`:

```typescript
/**
 * @jest-environment node
 */
import { POST } from '../route';
import { NextRequest } from 'next/server';

// Mock dependencies
jest.mock('next-auth/next', () => ({
  getServerSession: jest.fn(),
}));

jest.mock('@/lib/prisma', () => ({
  prisma: {
    dealer: {
      findFirst: jest.fn(),
    },
  },
}));

jest.mock('@/lib/stripe', () => ({
  getStripeClient: jest.fn(() => ({
    checkout: {
      sessions: {
        create: jest.fn(),
      },
    },
  })),
}));

import { getServerSession } from 'next-auth/next';
import { prisma } from '@/lib/prisma';
import { getStripeClient } from '@/lib/stripe';

describe('POST /api/checkout/custom-domain-addon', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    process.env.STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL = 'price_test_custom_domain';
  });

  it('returns 401 if not authenticated', async () => {
    (getServerSession as jest.Mock).mockResolvedValue(null);

    const request = new NextRequest('http://localhost/api/checkout/custom-domain-addon', {
      method: 'POST',
    });

    const response = await POST(request);
    expect(response.status).toBe(401);
  });

  it('returns 400 if dealer is not on Starter tier', async () => {
    (getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } });
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue({
      id: 'dealer-1',
      subscriptionTier: 'growth',
      hasCustomDomainAddon: false,
      stripeCustomerId: 'cus_123',
    });

    const request = new NextRequest('http://localhost/api/checkout/custom-domain-addon', {
      method: 'POST',
    });

    const response = await POST(request);
    expect(response.status).toBe(400);
    const data = await response.json();
    expect(data.error).toContain('Starter');
  });

  it('returns 400 if dealer already has add-on', async () => {
    (getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } });
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue({
      id: 'dealer-1',
      subscriptionTier: 'starter',
      hasCustomDomainAddon: true,
      stripeCustomerId: 'cus_123',
    });

    const request = new NextRequest('http://localhost/api/checkout/custom-domain-addon', {
      method: 'POST',
    });

    const response = await POST(request);
    expect(response.status).toBe(400);
    const data = await response.json();
    expect(data.error).toContain('already');
  });

  it('creates checkout session for valid Starter dealer', async () => {
    (getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } });
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue({
      id: 'dealer-1',
      subscriptionTier: 'starter',
      hasCustomDomainAddon: false,
      stripeCustomerId: 'cus_123',
    });

    const mockCreate = jest.fn().mockResolvedValue({
      url: 'https://checkout.stripe.com/session123',
    });
    (getStripeClient as jest.Mock).mockReturnValue({
      checkout: { sessions: { create: mockCreate } },
    });

    const request = new NextRequest('http://localhost/api/checkout/custom-domain-addon', {
      method: 'POST',
    });

    const response = await POST(request);
    expect(response.status).toBe(200);
    const data = await response.json();
    expect(data.url).toBe('https://checkout.stripe.com/session123');
    expect(mockCreate).toHaveBeenCalledWith(
      expect.objectContaining({
        mode: 'subscription',
        customer: 'cus_123',
        metadata: expect.objectContaining({
          type: 'custom_domain_addon',
          dealerId: 'dealer-1',
        }),
      })
    );
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- app/api/checkout/custom-domain-addon/__tests__/route.test.ts`
Expected: FAIL - module not found (route doesn't exist yet)

**Step 3: Create the API route**

Create `app/api/checkout/custom-domain-addon/route.ts`:

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';
import { getStripeClient } from '@/lib/stripe';
import { getSiteUrl } from '@/lib/site-url';
import { withCsrfProtection } from '@/lib/csrf';

export const dynamic = 'force-dynamic';

/**
 * POST /api/checkout/custom-domain-addon
 *
 * Creates a Stripe Checkout Session for the custom domain add-on.
 * Only available to Starter tier dealers who don't already have the add-on.
 *
 * Returns: { url: string } - Stripe Checkout URL to redirect to
 */
async function postHandler(request: NextRequest) {
  try {
    // Step 1: Verify authentication
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    // Step 2: Get dealer account
    const dealer = await prisma.dealer.findFirst({
      where: { userId: session.user.id },
      select: {
        id: true,
        subscriptionTier: true,
        hasCustomDomainAddon: true,
        hasGrandfatheredCustomDomain: true,
        stripeCustomerId: true,
        status: true,
      },
    });

    if (!dealer) {
      return NextResponse.json({ error: 'Dealer account not found' }, { status: 404 });
    }

    // Step 3: Validate eligibility
    if (dealer.subscriptionTier.toLowerCase() !== 'starter') {
      return NextResponse.json(
        {
          error:
            'Custom domain add-on is only available for Starter tier. Your tier already includes custom domains.',
        },
        { status: 400 }
      );
    }

    if (dealer.hasCustomDomainAddon) {
      return NextResponse.json(
        { error: 'You already have the custom domain add-on.' },
        { status: 400 }
      );
    }

    if (dealer.hasGrandfatheredCustomDomain) {
      return NextResponse.json(
        { error: 'You already have custom domain access (grandfathered).' },
        { status: 400 }
      );
    }

    if (dealer.status !== 'active') {
      return NextResponse.json(
        { error: 'Your account must be active to purchase add-ons.' },
        { status: 400 }
      );
    }

    // Step 4: Get price ID
    const priceId = process.env.STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL;
    if (!priceId) {
      logger.error('Missing STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL env var');
      return NextResponse.json(
        { error: 'Add-on configuration error. Please contact support.' },
        { status: 500 }
      );
    }

    // Step 5: Create Stripe Checkout Session
    const stripe = getStripeClient();
    const checkoutSession = await stripe.checkout.sessions.create({
      mode: 'subscription',
      payment_method_types: ['card'],
      customer: dealer.stripeCustomerId,
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      metadata: {
        type: 'custom_domain_addon',
        dealerId: dealer.id,
        webhook_environment:
          process.env.WEBHOOK_ENVIRONMENT || process.env.NODE_ENV || 'development',
      },
      success_url: `${getSiteUrl().origin}/dashboard/settings/custom-domain?addon_purchased=true`,
      cancel_url: `${getSiteUrl().origin}/dashboard/settings/custom-domain?addon_cancelled=true`,
    });

    if (!checkoutSession.url) {
      throw new Error('Failed to create checkout session URL');
    }

    logger.info(
      { dealerId: dealer.id, sessionId: checkoutSession.id },
      'Custom domain add-on checkout session created'
    );

    return NextResponse.json({ url: checkoutSession.url });
  } catch (error) {
    logger.error({ err: error }, 'Error creating custom domain add-on checkout session');
    return NextResponse.json(
      { error: 'Failed to create checkout session. Please try again.' },
      { status: 500 }
    );
  }
}

export const POST = withCsrfProtection(postHandler);
```

**Step 4: Run test to verify it passes**

Run: `npm test -- app/api/checkout/custom-domain-addon/__tests__/route.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add app/api/checkout/custom-domain-addon/
git commit -m "$(cat <<'EOF'
Add custom domain add-on checkout API route (#436)

POST /api/checkout/custom-domain-addon creates a Stripe Checkout
session for Starter tier dealers to purchase the add-on.

Validates:
- User is authenticated
- Dealer is on Starter tier
- Dealer doesn't already have the add-on
- Dealer doesn't have grandfathered access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Add Webhook Handling for Add-On Events

**Files:**

- Modify: `app/api/webhooks/stripe/route.ts`

**Step 1: Update checkout.session.completed handler**

In the `checkout.session.completed` case (around line 117), add handling for add-on purchases after the existing `createDealerFromCheckout` call:

```typescript
case 'checkout.session.completed': {
  const session = event.data.object as Stripe.Checkout.Session;

  // Only process if payment succeeded
  if (session.payment_status !== 'paid') {
    webhookLogger.info({ sessionId: session.id }, 'Checkout session not paid yet, skipping');
    return successResponse({ received: true, event: event.type });
  }

  // Check if this is a custom domain add-on purchase
  if (session.metadata?.type === 'custom_domain_addon') {
    const dealerId = session.metadata.dealerId;
    if (!dealerId) {
      webhookLogger.error({ sessionId: session.id }, 'Add-on checkout missing dealerId');
      return successResponse({ received: true, event: event.type });
    }

    // Update dealer with add-on status
    await prisma.dealer.update({
      where: { id: dealerId },
      data: {
        hasCustomDomainAddon: true,
        customDomainAddonStatus: 'active',
        customDomainAddonSubscriptionId: session.subscription as string,
      },
    });

    webhookLogger.info(
      { dealerId, subscriptionId: session.subscription },
      'Custom domain add-on activated'
    );

    return successResponse({ received: true, event: event.type });
  }

  // Existing dealer creation logic...
  // Retrieve full session with line items to get price ID
  const stripe = getStripeClient();
  // ... rest of existing code
```

**Step 2: Update customer.subscription.deleted handler**

In the `customer.subscription.deleted` case (around line 309), add handling for add-on subscription deletion:

```typescript
case 'customer.subscription.deleted': {
  const subscription = event.data.object as Stripe.Subscription;
  const customerId = subscription.customer as string;

  // Find dealer by Stripe customer ID
  const dealer = await prisma.dealer.findFirst({
    where: { stripeCustomerId: customerId },
  });

  if (!dealer) {
    webhookLogger.warn({ customerId }, 'Dealer not found for subscription deletion');
    return successResponse({ received: true, event: event.type });
  }

  // Check if this is the add-on subscription being deleted
  if (dealer.customDomainAddonSubscriptionId === subscription.id) {
    webhookLogger.info(
      { dealerId: dealer.id, subscriptionId: subscription.id },
      'Custom domain add-on subscription deleted'
    );

    await prisma.dealer.update({
      where: { id: dealer.id },
      data: {
        hasCustomDomainAddon: false,
        // Keep customDomainAddonStatus as 'cancelled' to preserve history
        // Domain config is preserved but inactive
      },
    });

    return successResponse({ received: true, event: event.type });
  }

  // Existing main subscription deletion logic...
```

**Step 3: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 4: Commit**

```bash
git add app/api/webhooks/stripe/route.ts
git commit -m "$(cat <<'EOF'
Add webhook handling for custom domain add-on lifecycle (#436)

Handles:
- checkout.session.completed with type=custom_domain_addon
- customer.subscription.deleted for add-on subscription

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Update Domain Settings UI for Add-On Purchase

**Files:**

- Modify: `components/settings/DomainSettingsContent.tsx`

**Step 1: Update the "upgrade required" UI to show add-on option**

Find the section around line 324-348 that shows "Growth Tier Required" and update it:

```typescript
{!config?.hasCustomDomainAccess ? (
  <Card variant="surface">
    <CardBody>
      <div className="text-center py-4">
        <h4 className="text-lg font-semibold mb-2">Custom Domain Add-On</h4>
        <p className="text-gray-400 mb-4">
          Use your own domain name (e.g., www.yourdomain.com) instead of a subdomain.
        </p>

        {/* Add-on purchase option for Starter tier */}
        <div className="mb-6 p-4 bg-slate-600/50 rounded-lg">
          <div className="flex items-center justify-between mb-3">
            <span className="font-medium">Custom Domain Add-On</span>
            <span className="text-cyan-400 font-semibold">$36/year</span>
          </div>
          <ul className="text-left text-sm text-slate-300/90 leading-relaxed list-disc pl-6 mb-4">
            <li>Use your own domain (e.g., www.yourdomain.com)</li>
            <li>Simple A record setup</li>
            <li>Automatic SSL certificate provisioning</li>
            <li>Professional branding for your dealership</li>
          </ul>
          <Button
            variant="primary"
            size="md"
            onClick={handlePurchaseAddon}
            disabled={purchasingAddon}
          >
            {purchasingAddon ? 'Loading...' : 'Add Custom Domain - $36/year'}
          </Button>
        </div>

        <div className="text-sm text-gray-500">
          Or{' '}
          <button
            onClick={() => router.push('/dashboard/subscription')}
            className="text-cyan-400 hover:underline"
          >
            upgrade to Growth tier
          </button>
          {' '}for custom domains plus lead forms, media library, and more.
        </div>
      </div>
    </CardBody>
  </Card>
) : config?.customDomain ? (
```

**Step 2: Add the purchase handler function**

Add state and handler near the top of the component:

```typescript
const [purchasingAddon, setPurchasingAddon] = useState(false);

async function handlePurchaseAddon() {
  setPurchasingAddon(true);
  try {
    const response = await fetch('/api/checkout/custom-domain-addon', {
      method: 'POST',
    });
    const data = await response.json();

    if (data.url) {
      window.location.href = data.url;
    } else {
      setError(data.error || 'Failed to start checkout');
      setPurchasingAddon(false);
    }
  } catch {
    setError('Failed to start checkout');
    setPurchasingAddon(false);
  }
}
```

**Step 3: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 4: Commit**

```bash
git add components/settings/DomainSettingsContent.tsx
git commit -m "$(cat <<'EOF'
Add custom domain add-on purchase UI to settings page (#436)

Starter tier dealers without custom domain access now see:
- Add-on pricing and features
- Purchase button that initiates Stripe Checkout
- Alternative link to upgrade to Growth tier

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Add Add-Ons Section to Subscription Page

**Files:**

- Modify: `app/dashboard/subscription/page.tsx`
- Modify: `app/dashboard/subscription/subscription.module.css`

**Step 1: Add DealerInfo interface field**

Update the DealerInfo interface to include add-on fields:

```typescript
interface DealerInfo {
  id: string;
  subscriptionTier: string;
  status: string;
  currentBillingInterval: BillingInterval | null;
  hasCustomDomainAddon: boolean;
  customDomainAddonStatus: string;
  hasGrandfatheredCustomDomain: boolean;
}
```

**Step 2: Add Add-ons section after Current Subscription card**

Insert after the Current Subscription card (around line 236):

```typescript
{/* Add-ons Section (Starter tier only) */}
{dealer.subscriptionTier.toLowerCase() === 'starter' && (
  <div className={styles.card}>
    <h2 className={styles.cardTitle}>Add-ons</h2>

    {dealer.hasGrandfatheredCustomDomain ? (
      <div className={styles.addonItem}>
        <div className={styles.addonInfo}>
          <h3 className={styles.addonName}>Custom Domain</h3>
          <p className={styles.addonDescription}>Included (grandfathered)</p>
        </div>
        <span className={styles.addonStatusActive}>Active</span>
      </div>
    ) : dealer.hasCustomDomainAddon ? (
      <div className={styles.addonItem}>
        <div className={styles.addonInfo}>
          <h3 className={styles.addonName}>Custom Domain</h3>
          <p className={styles.addonDescription}>$36/year</p>
        </div>
        <div className={styles.addonActions}>
          <span className={styles.addonStatusActive}>Active</span>
          <button
            onClick={handleCancelAddon}
            disabled={cancellingAddon}
            className={styles.addonCancelButton}
          >
            {cancellingAddon ? 'Cancelling...' : 'Cancel'}
          </button>
        </div>
      </div>
    ) : dealer.customDomainAddonStatus === 'cancelled' ? (
      <div className={styles.addonItem}>
        <div className={styles.addonInfo}>
          <h3 className={styles.addonName}>Custom Domain</h3>
          <p className={styles.addonDescription}>$36/year - Cancelled</p>
        </div>
        <button
          onClick={handlePurchaseAddon}
          disabled={purchasingAddon}
          className={styles.addonPurchaseButton}
        >
          {purchasingAddon ? 'Loading...' : 'Reactivate'}
        </button>
      </div>
    ) : (
      <div className={styles.addonItem}>
        <div className={styles.addonInfo}>
          <h3 className={styles.addonName}>Custom Domain</h3>
          <p className={styles.addonDescription}>
            Use your own domain instead of a subdomain
          </p>
        </div>
        <button
          onClick={handlePurchaseAddon}
          disabled={purchasingAddon}
          className={styles.addonPurchaseButton}
        >
          {purchasingAddon ? 'Loading...' : 'Add - $36/year'}
        </button>
      </div>
    )}
  </div>
)}
```

**Step 3: Add state and handlers**

```typescript
const [purchasingAddon, setPurchasingAddon] = useState(false);
const [cancellingAddon, setCancellingAddon] = useState(false);

const handlePurchaseAddon = async () => {
  setPurchasingAddon(true);
  try {
    const response = await fetch('/api/checkout/custom-domain-addon', {
      method: 'POST',
    });
    const data = await response.json();
    if (data.url) {
      window.location.href = data.url;
    } else {
      setError(data.error || 'Failed to start checkout');
    }
  } catch {
    setError('Failed to start checkout');
  } finally {
    setPurchasingAddon(false);
  }
};

const handleCancelAddon = async () => {
  if (
    !confirm(
      'Are you sure you want to cancel the custom domain add-on? Your domain will remain active until the end of the billing period.'
    )
  ) {
    return;
  }
  setCancellingAddon(true);
  try {
    const response = await fetch('/api/subscription/cancel-addon', {
      method: 'POST',
    });
    const data = await response.json();
    if (response.ok) {
      window.location.reload();
    } else {
      setError(data.error || 'Failed to cancel add-on');
    }
  } catch {
    setError('Failed to cancel add-on');
  } finally {
    setCancellingAddon(false);
  }
};
```

**Step 4: Add CSS styles**

Add to `subscription.module.css`:

```css
.addonItem {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--spacing-md);
  background: var(--color-surface);
  border-radius: var(--radius-md);
}

.addonInfo {
  flex: 1;
}

.addonName {
  font-weight: 600;
  margin-bottom: var(--spacing-xs);
}

.addonDescription {
  font-size: 0.875rem;
  color: var(--color-text-muted);
}

.addonActions {
  display: flex;
  align-items: center;
  gap: var(--spacing-md);
}

.addonStatusActive {
  padding: var(--spacing-xs) var(--spacing-sm);
  background: var(--color-success-bg);
  color: var(--color-success);
  border-radius: var(--radius-sm);
  font-size: 0.875rem;
  font-weight: 500;
}

.addonPurchaseButton {
  padding: var(--spacing-sm) var(--spacing-md);
  background: var(--color-primary);
  color: white;
  border: none;
  border-radius: var(--radius-md);
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}

.addonPurchaseButton:hover {
  background: var(--color-primary-hover);
}

.addonPurchaseButton:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.addonCancelButton {
  padding: var(--spacing-xs) var(--spacing-sm);
  background: transparent;
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  font-size: 0.875rem;
  cursor: pointer;
  transition: all 0.2s;
}

.addonCancelButton:hover {
  color: var(--color-error);
  border-color: var(--color-error);
}

.addonCancelButton:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
```

**Step 5: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 6: Commit**

```bash
git add app/dashboard/subscription/
git commit -m "$(cat <<'EOF'
Add add-ons section to subscription management page (#436)

Starter tier dealers can now:
- View custom domain add-on status
- Purchase the add-on ($36/year)
- Cancel an active add-on
- Reactivate a cancelled add-on

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: Create Add-On Cancellation API Route

**Files:**

- Create: `app/api/subscription/cancel-addon/route.ts`

**Step 1: Create the API route**

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';
import { getStripeClient } from '@/lib/stripe';
import { withCsrfProtection } from '@/lib/csrf';

export const dynamic = 'force-dynamic';

/**
 * POST /api/subscription/cancel-addon
 *
 * Cancels the custom domain add-on subscription at end of billing period.
 * Domain remains active until then.
 */
async function postHandler(request: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const dealer = await prisma.dealer.findFirst({
      where: { userId: session.user.id },
      select: {
        id: true,
        hasCustomDomainAddon: true,
        customDomainAddonSubscriptionId: true,
      },
    });

    if (!dealer) {
      return NextResponse.json({ error: 'Dealer account not found' }, { status: 404 });
    }

    if (!dealer.hasCustomDomainAddon || !dealer.customDomainAddonSubscriptionId) {
      return NextResponse.json(
        { error: 'No active custom domain add-on to cancel' },
        { status: 400 }
      );
    }

    // Cancel at end of billing period
    const stripe = getStripeClient();
    await stripe.subscriptions.update(dealer.customDomainAddonSubscriptionId, {
      cancel_at_period_end: true,
    });

    // Update status in database
    await prisma.dealer.update({
      where: { id: dealer.id },
      data: {
        customDomainAddonStatus: 'cancelled',
      },
    });

    logger.info(
      { dealerId: dealer.id, subscriptionId: dealer.customDomainAddonSubscriptionId },
      'Custom domain add-on scheduled for cancellation'
    );

    return NextResponse.json({
      success: true,
      message: 'Add-on will be cancelled at the end of your billing period',
    });
  } catch (error) {
    logger.error({ err: error }, 'Error cancelling custom domain add-on');
    return NextResponse.json(
      { error: 'Failed to cancel add-on. Please try again.' },
      { status: 500 }
    );
  }
}

export const POST = withCsrfProtection(postHandler);
```

**Step 2: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 3: Commit**

```bash
git add app/api/subscription/cancel-addon/
git commit -m "$(cat <<'EOF'
Add cancel-addon API route (#436)

POST /api/subscription/cancel-addon schedules the custom domain
add-on for cancellation at the end of the billing period.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: Auto-Cancel Add-On on Tier Upgrade

**Files:**

- Modify: `app/api/checkout/upgrade/route.ts`

**Step 1: Add add-on cancellation logic**

After the successful tier upgrade (around line 300, after `handleTierUpgrade`), add:

```typescript
// Step 10: Auto-cancel custom domain add-on if upgrading to Growth+
// Growth and above include custom domains, so the add-on is redundant
if (dealer.hasCustomDomainAddon && dealer.customDomainAddonSubscriptionId) {
  try {
    // Cancel immediately with proration (refund remaining time)
    await stripe.subscriptions.cancel(dealer.customDomainAddonSubscriptionId, {
      prorate: true,
    });

    // Clear add-on fields
    await prisma.dealer.update({
      where: { id: dealer.id },
      data: {
        hasCustomDomainAddon: false,
        customDomainAddonStatus: 'none',
        customDomainAddonSubscriptionId: null,
      },
    });

    logger.info(
      { dealerId: dealer.id, subscriptionId: dealer.customDomainAddonSubscriptionId },
      'Auto-cancelled custom domain add-on due to tier upgrade'
    );
  } catch (addonError) {
    // Log but don't fail the upgrade - add-on cancellation is secondary
    logger.error(
      { dealerId: dealer.id, err: addonError },
      'Failed to auto-cancel custom domain add-on after tier upgrade'
    );
  }
}
```

**Step 2: Update the dealer select to include add-on fields**

Update the select in `prisma.dealer.findFirst` (around line 60):

```typescript
select: {
  id: true,
  stripeCustomerId: true,
  stripeSubscriptionId: true,
  subscriptionTier: true,
  status: true,
  updatedAt: true,
  hasCustomDomainAddon: true,
  customDomainAddonSubscriptionId: true,
},
```

**Step 3: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 4: Commit**

```bash
git add app/api/checkout/upgrade/route.ts
git commit -m "$(cat <<'EOF'
Auto-cancel custom domain add-on on tier upgrade (#436)

When a Starter dealer with the custom domain add-on upgrades to
Growth or higher, the add-on is automatically cancelled with a
prorated refund. Custom domain access continues via the new tier.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 11: Update NoDealerSetup Page with Bundle Option

**Files:**

- Modify: `app/dashboard/components/NoDealerSetup.tsx`

**Step 1: Add bundle checkbox state and UI**

Update the component to include a custom domain add-on toggle for Starter tier:

```typescript
const [includeCustomDomain, setIncludeCustomDomain] = useState(false);

// In the PlanCard component, update the href for Starter when bundle is selected:
const checkoutUrl =
  plan === 'starter' && includeCustomDomain
    ? `/api/checkout/starter-bundle?interval=${interval}`
    : `/api/checkout/create-session?plan=${plan}&interval=${interval}`;
```

Add a checkbox after the Starter plan card:

```typescript
{planKey === 'starter' && (
  <label className={styles.addonCheckbox}>
    <input
      type="checkbox"
      checked={includeCustomDomain}
      onChange={(e) => setIncludeCustomDomain(e.target.checked)}
    />
    <span>+ Custom Domain ($36/year)</span>
  </label>
)}
```

**Step 2: Add CSS for the addon checkbox**

Add to `dashboard.module.css`:

```css
.addonCheckbox {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.75rem;
  padding: 0.75rem;
  background: rgba(6, 182, 212, 0.1);
  border: 1px solid rgba(6, 182, 212, 0.3);
  border-radius: 0.5rem;
  cursor: pointer;
  font-size: 0.875rem;
  color: var(--color-cyan-400);
}

.addonCheckbox:hover {
  background: rgba(6, 182, 212, 0.15);
}

.addonCheckbox input {
  width: 1rem;
  height: 1rem;
  accent-color: var(--color-cyan-400);
}
```

**Step 3: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 4: Commit**

```bash
git add app/dashboard/components/NoDealerSetup.tsx app/dashboard/dashboard.module.css
git commit -m "$(cat <<'EOF'
Add custom domain bundle option to NoDealerSetup page (#436)

New users can now select Starter + Custom Domain bundle during
initial setup. Checkbox appears below Starter plan card.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 12: Create Bundle Checkout API Route

**Files:**

- Create: `app/api/checkout/starter-bundle/route.ts`

**Step 1: Create the API route**

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { logger } from '@/lib/logger';
import { getStripeClient } from '@/lib/stripe';
import { getSiteUrl } from '@/lib/site-url';

export const dynamic = 'force-dynamic';

/**
 * GET /api/checkout/starter-bundle
 *
 * Creates a Stripe Checkout Session for Starter tier + Custom Domain add-on bundle.
 * Creates two subscriptions in a single checkout.
 */
export async function GET(request: NextRequest) {
  try {
    // Step 1: Verify authentication
    const session = await getServerSession(authOptions);

    if (!session?.user?.id || !session?.user?.email) {
      // Redirect to sign-in with callback
      const callbackUrl = `/api/checkout/starter-bundle`;
      const signInUrl = `/api/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`;
      return NextResponse.redirect(new URL(signInUrl, getSiteUrl().origin));
    }

    // Step 2: Check if user already has a dealer account
    const { prisma } = await import('@/lib/prisma');
    const existingDealer = await prisma.dealer.findFirst({
      where: { userId: session.user.id },
    });

    if (existingDealer) {
      return NextResponse.redirect(new URL('/dashboard', getSiteUrl().origin));
    }

    // Step 3: Get price IDs
    const starterPriceId = process.env.STRIPE_PRICE_STARTER_ANNUAL;
    const addonPriceId = process.env.STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL;

    if (!starterPriceId || !addonPriceId) {
      logger.error(
        { starterPriceId: !!starterPriceId, addonPriceId: !!addonPriceId },
        'Missing price ID env vars for starter bundle'
      );
      return NextResponse.json(
        { error: 'Bundle configuration error. Please contact support.' },
        { status: 500 }
      );
    }

    // Step 4: Create Stripe Checkout Session with two subscriptions
    const stripe = getStripeClient();
    const checkoutSession = await stripe.checkout.sessions.create({
      mode: 'subscription',
      payment_method_types: ['card'],
      client_reference_id: session.user.id,
      customer_email: session.user.email,
      line_items: [
        {
          price: starterPriceId,
          quantity: 1,
        },
        {
          price: addonPriceId,
          quantity: 1,
        },
      ],
      metadata: {
        userId: session.user.id,
        type: 'starter_bundle',
        tier: 'starter',
        billing_interval: 'annual',
        webhook_environment:
          process.env.WEBHOOK_ENVIRONMENT || process.env.NODE_ENV || 'development',
      },
      success_url: `${getSiteUrl().origin}/registration/welcome?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${getSiteUrl().origin}/`,
      billing_address_collection: 'required',
      phone_number_collection: {
        enabled: true,
      },
    });

    if (!checkoutSession.url) {
      throw new Error('Failed to create checkout session URL');
    }

    logger.info(
      { userId: session.user.id, sessionId: checkoutSession.id },
      'Starter bundle checkout session created'
    );

    return NextResponse.redirect(checkoutSession.url);
  } catch (error) {
    logger.error({ err: error }, 'Error creating starter bundle checkout session');
    return NextResponse.json(
      { error: 'Failed to create checkout session. Please try again.' },
      { status: 500 }
    );
  }
}
```

**Step 2: Update webhook handler for bundle purchases**

In `app/api/webhooks/stripe/route.ts`, update the checkout.session.completed handler to detect bundles:

```typescript
// Check if this is a starter bundle purchase
if (session.metadata?.type === 'starter_bundle') {
  // The createDealerFromCheckout will create the dealer
  // We need to also set the add-on fields
  const stripe = getStripeClient();
  const fullSession = await stripe.checkout.sessions.retrieve(session.id, {
    expand: ['line_items', 'subscription'],
  });

  // Create dealer first
  const result = await createDealerFromCheckout(fullSession);

  // Find the add-on subscription (second line item creates separate subscription)
  // Note: Stripe may create one subscription with multiple items or two subscriptions
  // depending on configuration. Check the subscription object structure.
  if (result.created && fullSession.subscription) {
    const subscription = await stripe.subscriptions.retrieve(fullSession.subscription as string);

    // Find the add-on item in the subscription
    const addonItem = subscription.items.data.find(
      (item) => item.price.id === process.env.STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL
    );

    if (addonItem) {
      await prisma.dealer.update({
        where: { id: result.dealer.id },
        data: {
          hasCustomDomainAddon: true,
          customDomainAddonStatus: 'active',
          customDomainAddonSubscriptionId: subscription.id,
        },
      });

      webhookLogger.info(
        { dealerId: result.dealer.id },
        'Starter bundle: activated custom domain add-on'
      );
    }
  }

  return successResponse({ received: true, event: event.type });
}
```

**Step 3: Run build to verify**

Run: `npm run build`
Expected: Build succeeds

**Step 4: Commit**

```bash
git add app/api/checkout/starter-bundle/ app/api/webhooks/stripe/route.ts
git commit -m "$(cat <<'EOF'
Add starter bundle checkout API route (#436)

GET /api/checkout/starter-bundle creates a Stripe Checkout session
with both Starter tier and Custom Domain add-on as line items.

Webhook handler updated to detect bundle purchases and set both
the tier and add-on fields on dealer creation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 13: Update Homepage with Bundle Option

**Files:**

- Modify: `public/index.html`

**Step 1: Add bundle option to Starter tier pricing card**

Find the Starter tier pricing section and add a checkbox/toggle for the custom domain add-on. This requires adding JavaScript to handle the toggle and update the checkout URL.

Add after the Starter price display:

```html
<div class="addon-option" id="starter-addon">
  <label class="addon-checkbox">
    <input type="checkbox" id="include-custom-domain" onchange="updateStarterCheckout()" />
    <span>+ Custom Domain ($36/year)</span>
  </label>
</div>

<script>
  function updateStarterCheckout() {
    const checkbox = document.getElementById('include-custom-domain');
    const starterButton = document.querySelector('[data-plan="starter"]');
    if (checkbox.checked) {
      starterButton.href = '/api/checkout/starter-bundle';
    } else {
      starterButton.href = '/api/checkout/create-session?plan=starter&interval=annual';
    }
  }
</script>
```

**Step 2: Add CSS for the addon option**

```css
.addon-option {
  margin-top: 1rem;
  padding: 0.75rem;
  background: rgba(6, 182, 212, 0.1);
  border: 1px solid rgba(6, 182, 212, 0.3);
  border-radius: 0.5rem;
}

.addon-checkbox {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  font-size: 0.875rem;
  color: #22d3ee;
}

.addon-checkbox input {
  width: 1rem;
  height: 1rem;
  accent-color: #22d3ee;
}
```

**Step 3: Verify in browser**

Open `http://localhost:3000` and verify the bundle option appears under Starter tier.

**Step 4: Commit**

```bash
git add public/index.html
git commit -m "$(cat <<'EOF'
Add custom domain bundle option to homepage (#436)

Visitors can now select Starter + Custom Domain bundle when
signing up from the homepage pricing section.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 14: Final Integration Testing

**Step 1: Start the development server**

Run: `npm run dev`

**Step 2: Test add-on purchase flow for existing Starter dealer**

1. Log in as a Starter tier test dealer
2. Navigate to `/dashboard/settings/custom-domain`
3. Click "Add Custom Domain - $36/year"
4. Complete Stripe Checkout (use test card 4242 4242 4242 4242)
5. Verify redirect back to settings page
6. Verify custom domain configuration is now available

**Step 3: Test bundle purchase flow for new user**

1. Log out
2. Visit homepage
3. Check the "Custom Domain" box under Starter tier
4. Click "Get Started"
5. Complete authentication and checkout
6. Verify dealer is created with `hasCustomDomainAddon: true`

**Step 4: Test add-on cancellation**

1. Log in as dealer with add-on
2. Navigate to `/dashboard/subscription`
3. Click "Cancel" on the add-on
4. Verify cancellation confirmation
5. Verify status changes to "cancelled"

**Step 5: Test auto-cancel on upgrade**

1. Log in as Starter dealer with add-on
2. Upgrade to Growth tier
3. Verify add-on is automatically cancelled
4. Verify custom domain access continues (via Growth tier)

**Step 6: Run full test suite**

Run: `npm test`
Expected: All tests pass

**Step 7: Run production build**

Run: `npm run build`
Expected: Build succeeds without errors

**Step 8: Final commit**

```bash
git add -A
git commit -m "$(cat <<'EOF'
Complete custom domain add-on implementation (#436)

Implementation includes:
- Database schema with add-on fields
- Access control updates
- Add-on checkout API route
- Bundle checkout API route
- Webhook handling for add-on lifecycle
- Domain settings UI with purchase option
- Subscription page add-ons section
- Add-on cancellation route
- Auto-cancel on tier upgrade
- Homepage and NoDealerSetup bundle options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Summary

This plan implements the Starter Package Custom Domain Add-On feature in 14 tasks:

1. **Database schema** - Add 3 fields to Dealer model
2. **Access control** - Update `hasCustomDomainAccess()` function
3. **Call sites** - Update API routes to pass new parameter
4. **Price mapping** - Register add-on price ID in webhook handlers
5. **Add-on checkout** - Create `/api/checkout/custom-domain-addon`
6. **Webhook handling** - Process add-on checkout and deletion events
7. **Domain settings UI** - Add purchase option for Starter dealers
8. **Subscription page** - Add add-ons section with manage/purchase UI
9. **Cancel route** - Create `/api/subscription/cancel-addon`
10. **Auto-cancel** - Cancel add-on when upgrading to Growth+
11. **NoDealerSetup** - Add bundle checkbox for new users
12. **Bundle checkout** - Create `/api/checkout/starter-bundle`
13. **Homepage** - Add bundle option to public pricing
14. **Integration testing** - Verify all flows work end-to-end
