# Dealer Registration & Authentication System Design

**Date:** November 3, 2025
**Author:** Development Team
**Status:** Approved for Implementation
**ClickUp Task:** [86acycgqm](https://app.clickup.com/t/86acycgqm)

> **Superseded constraint (PR #813, April 2026):** The "global subdomain uniqueness" rule
> described in this document (line 19 - `bobsoil.myamsoil.com` blocks `bobsoil.myamsoil.ca`)
> was intentionally relaxed. `Dealer.subdomain @unique` was replaced with a composite index
> `@@unique([subdomain, domainPrefix, domain])`. Two dealers can now share the same subdomain
> if they are on different parent domains. The `/api/check-subdomain` endpoint validates the
> exact `(subdomain, domainPrefix, domain)` tuple rather than global uniqueness.

## Overview

This document outlines the architecture for the AMSOIL Dealer Login/Registration system, integrating Stripe payment processing, webhook-driven account creation, and OAuth-based authentication.

## Business Requirements

### AMSOIL Domain Rules (Hard Constraints)

1. **US dealers**: `.myamsoil.com` subdomains only
2. **Canadian dealers**: `.myamsoil.ca` subdomains only
3. **No cross-country assignments**: Dealer's billing country determines domain
4. **Global subdomain uniqueness**: `bobsoil.myamsoil.com` blocks `bobsoil.myamsoil.ca`

### User Flow Requirements

1. Dealer visits landing page and selects pricing tier
2. Completes Stripe checkout with payment information
3. Redirected to registration completion page
4. Chooses subdomain and confirms pre-filled profile information
5. Logged in automatically; future logins via OAuth (Google/Apple) tied to email

### Success Criteria

- Zero lost registrations due to browser closures during redirect
- Subdomain uniqueness enforced globally across both domains
- OAuth providers (Google/Apple) automatically link to existing accounts by email
- Subscription lifecycle (upgrades, downgrades, cancellations) handled automatically
- Production-ready from day one with proper error handling and idempotent operations

## Architecture Overview

### Core Components

1. **Stripe Integration**: Payment processing via Buy Links + Webhooks
2. **PostgreSQL + Prisma**: Relational database for dealers, users, subscriptions
3. **NextAuth.js**: Session management + OAuth provider integration
4. **Webhook Handler**: Processes Stripe events for account lifecycle
5. **Registration API**: Completes dealer profile and activates accounts
6. **Subdomain Validation API**: Real-time uniqueness checks with country-based domain assignment

### Technology Stack

- **Framework**: Next.js 14 (App Router)
- **Database**: PostgreSQL with Prisma ORM
- **Authentication**: NextAuth.js v4
- **Payment Processing**: Stripe Checkout + Webhooks
- **Deployment**: Vercel (or compatible platform with wildcard domain support)

## Database Schema

### Prisma Schema

```prisma
model User {
  id            String    @id @default(cuid())
  email         String    @unique
  emailVerified DateTime?
  name          String?
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts      Account[]
  dealer        Dealer?
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String  // "oauth" or "credentials"
  provider          String  // "google", "apple"
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@index([userId])
}

model Dealer {
  id                  String   @id @default(cuid())
  userId              String   @unique
  subdomain           String   @unique // "bobsoil"
  domain              String   // "com" or "ca"
  subscriptionTier    String   // "starter", "growth", "enhanced", "professional"
  stripeCustomerId    String   @unique
  stripeSubscriptionId String? @unique

  // Contact Info (pre-filled from Stripe)
  businessName        String?
  phone               String
  address             String
  city                String
  state               String
  zip                 String
  country             String   // "US" or "CA"

  // CMS Configuration (for future dealer page customization)
  contactData         Json?    // Business hours, alternate phones, secondary emails
  socialLinks         Json?    // Facebook, Instagram, Twitter, LinkedIn, YouTube
  pageContent         Json?    // Custom hero text, about section, testimonials
  mediaAssets         Json?    // Logos, images, videos
  settings            Json?    // Feature flags, display preferences

  // Migration Support (for 3500 existing dealer sites)
  migrationData       Json?    // Scraped raw data, source URL
  migrationStatus     String   @default("pending") // "pending", "registration_pending", "completed", "needs_review"

  // Account Status
  status              String   @default("pending") // "pending", "active", "suspended", "cancelled", "payment_failed"
  createdAt           DateTime @default(now())
  updatedAt           DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([subdomain, domain]) // Enforce global subdomain uniqueness
  @@index([subdomain, domain])  // Fast subdomain lookups
  @@index([stripeCustomerId])
  @@index([migrationStatus])
}
```

### Schema Design Decisions

- **User/Dealer Separation**: Supports future admin users or non-dealer accounts
- **subdomain + domain fields**: Enables AMSOIL's cross-domain uniqueness rule
- **JSON fields**: Flexible CMS configuration without schema migrations
- **Migration fields**: Supports bulk import of 3500 existing dealer sites
- **Status tracking**: Enables subscription lifecycle management
- **NextAuth compatibility**: Uses standard User/Account models for OAuth

## Registration Flow

### High-Level Flow

```
Dealer clicks Subscribe
    ↓
Stripe Checkout (payment + billing info)
    ↓
Payment Succeeds
    ├─→ Webhook fires → Creates skeleton User + Dealer account
    └─→ Browser redirects → /register?session_id=xxx
            ↓
    Page loads → Finds existing account (created by webhook)
            ↓
    Dealer picks subdomain + confirms pre-filled details
            ↓
    POST /api/register/complete → Updates dealer with subdomain
            ↓
    Account activated → Auto-login → Redirect to /dashboard
```

### Detailed Step-by-Step

#### Step 1: Landing Page → Stripe Checkout

**User Action**: Dealer clicks "Subscribe" button on pricing tier

**Implementation**:

- Existing Stripe Buy Links already configured in `app/page.tsx`
- Each tier has unique Stripe checkout URL
- Stripe collects: email, name, payment method, billing address

**Stripe Configuration** (in Stripe Dashboard):

```
success_url: https://amsoil.aimclear.com/register?session_id={CHECKOUT_SESSION_ID}
cancel_url: https://amsoil.aimclear.com

metadata:
  tier: "starter" | "growth" | "enhanced" | "professional"
  source: "landing-page"
```

#### Step 2: Stripe Webhook Creates Skeleton Account

**Event**: `checkout.session.completed`

**Handler**: `POST /api/webhooks/stripe`

**Logic**:

1. Verify webhook signature (security)
2. Check if dealer already exists by `stripeCustomerId` (idempotent)
3. If not exists, create User + Dealer with:
   - Email, name from Stripe customer data
   - Phone, address, city, state, zip from billing address
   - Country determines domain assignment (US → .com, CA → .ca)
   - `status: "pending"` (not active until subdomain chosen)
   - `migrationStatus: "registration_pending"`
   - `subdomain: ""` (empty, will be set in Step 4)

**Why webhooks?**

- Guarantees account creation even if dealer closes browser during redirect
- Idempotent (safe to replay if webhook retries)
- Enables subscription lifecycle management (updates, cancellations)
- Recommended best practice by Stripe

#### Step 3: Registration Completion Page

**Route**: `GET /register?session_id={CHECKOUT_SESSION_ID}`

**Server-Side Logic** (`app/register/page.tsx`):

1. Verify Stripe session is valid and paid
2. Look up dealer by `stripeCustomerId`
3. If dealer not found → Show loading state (webhook delayed)
4. If dealer found and has subdomain → Auto-login and redirect to dashboard
5. If dealer found without subdomain → Render registration form

**Registration Form** (`components/RegistrationForm.tsx`):

- Pre-filled fields (editable): Name, Email, Phone, Address, City, State, Zip
- **Subdomain picker**:
  - Input field with live validation
  - Shows full URL: `{subdomain}.myamsoil.{domain}`
  - Domain auto-assigned based on billing country
  - Real-time API check: `GET /api/check-subdomain?name={subdomain}`
  - Displays availability status and validation errors

**Subdomain Validation Rules**:

- 3-63 characters
- Alphanumeric + hyphens only
- Cannot start/end with hyphen
- Reserved names blocked: `www`, `admin`, `api`, `mail`, `support`, etc.
- **Global uniqueness check**: Queries both .com and .ca domains

#### Step 4: Complete Registration

**API**: `POST /api/register/complete`

**Request Body**:

```json
{
  "dealerId": "clxxxx",
  "subdomain": "bobsoil",
  "name": "Bob's Oil Service",
  "phone": "(555) 123-4567",
  "address": "123 Main St",
  "city": "Minneapolis",
  "state": "MN",
  "zip": "55401"
}
```

**Logic**:

1. Validate subdomain uniqueness (across both domains)
2. Update Dealer record:
   - Set subdomain
   - Update contact info (allow edits from Stripe pre-fill)
   - Set `status: "active"`
   - Set `migrationStatus: "completed"`
3. Create NextAuth session for dealer
4. Return success

**Response**:

```json
{
  "success": true,
  "dealer": { ... },
  "redirectUrl": "/dashboard"
}
```

#### Step 5: Auto-Login and Dashboard Redirect

**Client-Side**:

- After successful registration, call `signIn('credentials')` with dealer email
- NextAuth creates session
- Redirect to `/dashboard`

**Dashboard**:

- Shows dealer's live site URL: `{subdomain}.myamsoil.{domain}`
- Links to future CMS features (tier-dependent)

### Future Login Flow (OAuth)

**User Action**: Dealer returns and clicks "Sign in with Google"

**NextAuth Callback** (`lib/auth.ts`):

```typescript
callbacks: {
  async signIn({ user, account, profile }) {
    // Find existing dealer by email
    const existingUser = await prisma.user.findUnique({
      where: { email: user.email },
      include: { dealer: true, accounts: true }
    });

    if (!existingUser) {
      // No account with this email - reject
      return false;
    }

    // Check if this OAuth provider already linked
    const linkedAccount = existingUser.accounts.find(
      acc => acc.provider === account.provider
    );

    if (!linkedAccount) {
      // Link OAuth account to existing user
      await prisma.account.create({
        data: {
          userId: existingUser.id,
          type: 'oauth',
          provider: account.provider,
          providerAccountId: account.providerAccountId,
          access_token: account.access_token,
          refresh_token: account.refresh_token,
          expires_at: account.expires_at,
          token_type: account.token_type,
          scope: account.scope,
          id_token: account.id_token
        }
      });
    }

    return true; // Allow sign in
  }
}
```

**Key Points**:

- Email is the account identifier
- OAuth automatically links by matching email address
- Works with multiple providers (Google, Apple)
- No separate "link account" flow required
- Stripe email = account email (ensures access)

## API Endpoints

### New Endpoints to Create

#### 1. `POST /api/webhooks/stripe`

**Purpose**: Handle Stripe webhook events

**Events Handled**:

- `checkout.session.completed` → Create skeleton dealer account
- `customer.subscription.updated` → Update subscription tier
- `customer.subscription.deleted` → Cancel dealer account
- `invoice.payment_failed` → Flag payment issues

**Security**:

- Verify webhook signature using `STRIPE_WEBHOOK_SECRET`
- Return 400 for invalid signatures
- Idempotent operation (safe to replay)

**Implementation Details**:

```typescript
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('stripe-signature')!;

  // Verify webhook signature
  const event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);

  // Handle event types...
}
```

#### 2. `GET /api/check-subdomain`

**Purpose**: Validate subdomain availability in real-time

**Query Parameters**:

- `name`: Subdomain to check (e.g., "bobsoil")

**Response**:

```json
{
  "available": true,
  "subdomain": "bobsoil",
  "fullUrl": "bobsoil.myamsoil.com"
}
```

**Or if taken**:

```json
{
  "available": false,
  "reason": "Subdomain already registered"
}
```

**Implementation**:

```typescript
// app/api/check-subdomain/route.ts
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const subdomain = searchParams.get('name');

  // Validate format
  if (!isValidSubdomainFormat(subdomain)) {
    return Response.json({
      available: false,
      reason: 'Invalid subdomain format',
    });
  }

  // Check reserved names
  if (RESERVED_SUBDOMAINS.includes(subdomain)) {
    return Response.json({
      available: false,
      reason: 'Reserved subdomain',
    });
  }

  // Check global uniqueness (both .com and .ca)
  const existsOnCom = await prisma.dealer.findFirst({
    where: { subdomain, domain: 'com' },
  });
  const existsOnCa = await prisma.dealer.findFirst({
    where: { subdomain, domain: 'ca' },
  });

  if (existsOnCom || existsOnCa) {
    return Response.json({
      available: false,
      reason: 'Subdomain already registered',
    });
  }

  return Response.json({ available: true, subdomain });
}
```

**Rate Limiting**: 10 requests per minute per IP (prevent enumeration)

#### 3. `POST /api/register/complete`

**Purpose**: Complete dealer registration with subdomain

**Request Body**:

```json
{
  "dealerId": "clxxxx",
  "subdomain": "bobsoil",
  "name": "Bob's Oil Service",
  "phone": "(555) 123-4567",
  "address": "123 Main St",
  "city": "Minneapolis",
  "state": "MN",
  "zip": "55401"
}
```

**Validation**:

1. Verify dealer exists and is in "pending" status
2. Validate subdomain uniqueness
3. Validate all required fields

**Response**:

```json
{
  "success": true,
  "dealer": { ... },
  "redirectUrl": "/dashboard"
}
```

#### 4. `GET /register`

**Purpose**: Registration completion page (Server Component)

**Query Parameters**:

- `session_id`: Stripe checkout session ID

**Logic**:

1. Verify Stripe session
2. Look up dealer by `stripeCustomerId`
3. Render appropriate UI based on dealer state

### Updates to Existing Endpoints

#### `GET /api/auth/[...nextauth]/route.ts`

**Update**: Add OAuth email linking logic to `signIn` callback

**Changes**:

- Check for existing user by email
- Auto-link OAuth account if email matches
- Reject sign-in if no matching email found

## Security Considerations

### Subdomain Validation

**Format Rules**:

- Length: 3-63 characters
- Characters: `a-z`, `0-9`, hyphens (not at start/end)
- Regex: `/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/`

**Reserved Subdomains**:

```typescript
const RESERVED_SUBDOMAINS = [
  'www',
  'admin',
  'api',
  'mail',
  'smtp',
  'imap',
  'pop',
  'ftp',
  'webmail',
  'support',
  'help',
  'billing',
  'portal',
  'dashboard',
  'app',
  'staging',
  'dev',
  'test',
  'demo',
  'sandbox',
];
```

**Global Uniqueness**:

- Query both `domain: "com"` and `domain: "ca"`
- If exists on either → reject

### Webhook Security

**Signature Verification**:

- REQUIRED on all webhook handlers
- Use `stripe.webhooks.constructEvent()` with `STRIPE_WEBHOOK_SECRET`
- Return 400 for invalid signatures
- Never process unverified webhooks

**Idempotency**:

- Check if dealer exists before creating (by `stripeCustomerId`)
- Use database transactions for multi-step operations
- Safe to replay webhook events

### Registration Security

**Stripe Session Verification**:

- Verify session is paid (`payment_status === 'paid'`)
- Check session hasn't expired (24-hour window)
- Prevent session_id reuse (webhook already created account)

**CSRF Protection**:

- Next.js CSRF protection enabled by default
- Use form actions or protected API routes

**Rate Limiting**:

- Subdomain checks: 10/min per IP
- Registration attempts: 5/min per IP

### OAuth Security

**Email Verification**:

- Stripe verifies email during checkout
- OAuth providers verify email ownership
- No additional email verification needed

**Account Linking**:

- Only link if email matches existing user
- Prevent account takeover via OAuth

**Session Management**:

- JWT sessions (secure, httpOnly cookies)
- 30-day expiration
- Refresh tokens for OAuth providers

## Environment Variables

### Required Variables

```bash
# Existing (already configured)
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx
NEXTAUTH_SECRET=xxx
NEXTAUTH_URL=https://amsoil.aimclear.com
NEXT_PUBLIC_SITE_URL=https://amsoil.aimclear.com

# New (to be added)
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
DATABASE_URL=postgresql://user:pass@host:5432/amsoil_dlp

# Optional (for Apple OAuth)
APPLE_ID=xxx
APPLE_TEAM_ID=xxx
APPLE_PRIVATE_KEY=xxx
APPLE_KEY_ID=xxx
```

## Deployment Checklist

### Stripe Configuration

- [ ] Create production Stripe account
- [ ] Set up 4 products (Starter, Growth, Enhanced, Professional)
- [ ] Generate Buy Links for each product
- [ ] Configure success/cancel URLs
- [ ] Add metadata to checkout sessions (`tier`, `source`)
- [ ] Set up webhook endpoint: `POST /api/webhooks/stripe`
- [ ] Configure webhook events:
  - `checkout.session.completed`
  - `customer.subscription.updated`
  - `customer.subscription.deleted`
  - `invoice.payment_failed`
- [ ] Copy webhook signing secret to `STRIPE_WEBHOOK_SECRET`

### Database Setup

- [ ] Provision PostgreSQL database
- [ ] Add `DATABASE_URL` to environment variables
- [ ] Run Prisma migrations: `npx prisma migrate deploy`
- [ ] Seed reserved subdomains (optional)

### DNS Configuration

- [ ] Configure wildcard DNS for `*.myamsoil.com` → app
- [ ] Configure wildcard DNS for `*.myamsoil.ca` → app
- [ ] Verify SSL certificates for wildcard domains

### NextAuth Configuration

- [ ] Update `NEXTAUTH_URL` to production domain
- [ ] Configure Google OAuth authorized redirect URIs
- [ ] (Optional) Set up Apple Sign In credentials
- [ ] Test OAuth flows in production

### Testing Checklist

- [ ] Test successful registration flow end-to-end
- [ ] Test webhook delivery and retry logic
- [ ] Test subdomain uniqueness across domains
- [ ] Test country-based domain assignment (US → .com, CA → .ca)
- [ ] Test OAuth linking with existing accounts
- [ ] Test subscription updates via Stripe
- [ ] Test payment failure handling
- [ ] Test subdomain validation edge cases

## Future Enhancements

### Phase 2: Dealer Migration (3500 sites)

- Bulk import scraped dealer data
- Auto-claim subdomains for existing dealers
- Email existing dealers with registration links
- Handle conflicts (duplicate subdomains, missing data)

### Phase 3: CMS Features (Tier-Dependent)

- Dealer information editor (all tiers)
- Featured content slider (Enhanced+)
- Content section editor (Professional)
- Social profile linking (Growth+)
- Custom domain mapping (Growth+)

### Phase 4: Analytics Dashboard

- Performance metrics (Enhanced+)
- Lead generation tracking (Growth+)
- SEO insights (Enhanced+)

### Phase 5: Admin Portal

- Internal dashboard for AIMCLEAR team
- Dealer account management
- Subscription management
- Migration tools and bulk operations

## Technical Debt & Risks

### Known Limitations

1. **No email verification**: Relies on Stripe email verification
   - **Mitigation**: Stripe already verifies email ownership

2. **Webhook delays**: Account creation may lag behind redirect
   - **Mitigation**: Show loading state, poll for account creation

3. **No multi-factor authentication**: Only OAuth security
   - **Future**: Add 2FA for high-value accounts

4. **Subdomain enumeration**: API allows checking availability
   - **Mitigation**: Rate limiting on subdomain checks

### Performance Considerations

- **Database indexes**: Added for subdomain lookups, stripe IDs
- **Webhook processing**: Runs async, doesn't block user flow
- **Prisma query optimization**: Use `select` to fetch only needed fields
- **Connection pooling**: Configure for production load (3500+ dealers)

## Appendix

### Subdomain Validation Helper

```typescript
export function isValidSubdomainFormat(subdomain: string): boolean {
  // Length check
  if (subdomain.length < 3 || subdomain.length > 63) {
    return false;
  }

  // Format check: alphanumeric + hyphens, not at start/end
  const regex = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
  return regex.test(subdomain);
}

export async function validateSubdomain(
  subdomain: string
): Promise<{ available: boolean; reason?: string }> {
  // Format validation
  if (!isValidSubdomainFormat(subdomain)) {
    return { available: false, reason: 'Invalid format' };
  }

  // Reserved check
  if (RESERVED_SUBDOMAINS.includes(subdomain)) {
    return { available: false, reason: 'Reserved subdomain' };
  }

  // Global uniqueness check
  const [onCom, onCa] = await Promise.all([
    prisma.dealer.findFirst({ where: { subdomain, domain: 'com' } }),
    prisma.dealer.findFirst({ where: { subdomain, domain: 'ca' } }),
  ]);

  if (onCom || onCa) {
    return { available: false, reason: 'Already registered' };
  }

  return { available: true };
}
```

### Domain Assignment Helper

```typescript
export function assignDomain(billingCountry: string): 'com' | 'ca' {
  if (billingCountry === 'CA') return 'ca';
  if (billingCountry === 'US') return 'com';
  throw new Error('Only US and Canadian dealers supported');
}
```

### Tier Mapping Helper

```typescript
const STRIPE_PRICE_TO_TIER: Record<string, string> = {
  price_starter_annual: 'starter',
  price_growth_monthly: 'growth',
  price_enhanced_monthly: 'enhanced',
  price_professional_monthly: 'professional',
};

export function getTierFromPriceId(priceId: string): string {
  return STRIPE_PRICE_TO_TIER[priceId] || 'starter';
}
```

---

**End of Design Document**
