← back home

payment & capabilities system implementation guide

Payment & Capabilities System - Implementation Guide

This document provides a complete guide to implementing a team-based payment and capabilities system using Laravel Cashier and Stripe.


Table of Contents

  1. System Overview
  2. Architecture Diagram
  3. Environment Configuration
  4. Database Schema
  5. Offer & Capability Configuration
  6. Core Enums
  7. Team Model Setup
  8. Payment Methods
  9. Stripe Webhook Handling
  10. Capability Enforcement
  11. License Key System
  12. Frontend Implementation
  13. Step-by-Step Implementation

System Overview

This payment system supports three acquisition channels:

Channel Type Handled By
Recurring Subscriptions Monthly/Yearly StripeSubscriptionController via Stripe Checkout
Lifetime Payments One-time StripePaymentLinkController via Stripe Payment Links
License Keys Pre-generated codes LicenseKeyController + LicenseKey model

All channels update the Team model's offer and capabilities fields, which control feature access.

Core Concepts

  • Team: The billable entity (not User). Uses Laravel Cashier's Billable trait
  • Offer: The plan tier (STARTER, PLUS, PRO, ENTERPRISE)
  • Frequency: Billing period (MONTHLY, YEARLY, LIFETIME)
  • Capabilities: Feature limits stored as JSON on the Team model

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                         PAYMENT FLOWS                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌──────────────────┐    ┌──────────────────┐   ┌────────────────┐ │
│  │  Subscription    │    │  Payment Link    │   │  License Key   │ │
│  │  (Recurring)     │    │  (Lifetime)      │   │  (Special)     │ │
│  └────────┬─────────┘    └────────┬─────────┘   └───────┬────────┘ │
│           │                       │                      │          │
│           ▼                       ▼                      ▼          │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                    Stripe Webhooks                           │  │
│  │         customer.subscription.* | invoice.paid               │  │
│  └──────────────────────────────┬───────────────────────────────┘  │
│                                 │                                   │
│                                 ▼                                   │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │               StripeEventListener                            │  │
│  │    - updateTeamOfferFromSubscription()                       │  │
│  │    - updateTeamOfferFromPaymentLink()                        │  │
│  │    - handleAffiliation()                                     │  │
│  └──────────────────────────────┬───────────────────────────────┘  │
│                                 │                                   │
│                                 ▼                                   │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                    Team Model Updated                        │  │
│  │          offer: "PLUS" | capabilities: {...}                 │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    CAPABILITY ENFORCEMENT                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌───────────────────┐  ┌───────────────────┐  ┌────────────────┐  │
│  │ DomainPolicy      │  │ TeamInvitation    │  │ LimitLead      │  │
│  │ (NB_INTERNAL_     │  │ Policy            │  │ Pagination     │  │
│  │  DOMAINS)         │  │ (NB_TEAM_USERS)   │  │ (NB_LEADS)     │  │
│  └───────────────────┘  └───────────────────┘  └────────────────┘  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Environment Configuration

Add these environment variables to your .env:

# Stripe API Keys
STRIPE_KEY=pk_test_xxxx              # Publishable key
STRIPE_SECRET=sk_test_xxxx           # Secret key
STRIPE_WEBHOOK_SECRET=whsec_xxxx     # Webhook signing secret

# Stripe Price IDs - Monthly Subscriptions
STRIPE_PRICE_OFFER_1_MONTHLY=price_xxxx
STRIPE_PRICE_OFFER_2_MONTHLY=price_xxxx
STRIPE_PRICE_OFFER_3_MONTHLY=price_xxxx
STRIPE_PRICE_OFFER_4_MONTHLY=price_xxxx

# Stripe Price IDs - Yearly Subscriptions
STRIPE_PRICE_OFFER_1_YEARLY=price_xxxx
STRIPE_PRICE_OFFER_2_YEARLY=price_xxxx
STRIPE_PRICE_OFFER_3_YEARLY=price_xxxx
STRIPE_PRICE_OFFER_4_YEARLY=price_xxxx

# Stripe Price IDs - Lifetime (One-time)
STRIPE_PRICE_OFFER_1_LIFETIME=price_xxxx
STRIPE_PRICE_OFFER_2_LIFETIME=price_xxxx
STRIPE_PRICE_OFFER_3_LIFETIME=price_xxxx
STRIPE_PRICE_OFFER_4_LIFETIME=price_xxxx

# Cashier Configuration
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en

Database Schema

1. Teams Table

// database/migrations/xxxx_create_teams_table.php
Schema::create('teams', function (Blueprint $table) {
    $table->id();
    $table->uuid()->index();
    $table->foreignId('user_id')->index();
    $table->string('name');
    $table->boolean('personal_team');
    $table->string('offer')->default('DEFAULT');  // Offer enum value
    $table->json('capabilities');                  // Feature limits JSON
    $table->timestamps();
    $table->softDeletes();
});

Note: Laravel Cashier adds additional columns automatically:

  • stripe_id
  • pm_type
  • pm_last_four
  • trial_ends_at

2. Licenses Table (Lifetime Purchases)

// database/migrations/xxxx_create_licenses_table.php
Schema::create('licenses', function (Blueprint $table) {
    $table->id();
    $table->string('price_id')->nullable();        // Stripe price ID
    $table->foreignId('license_key_id')->nullable(); // For special license keys
    $table->foreignId('team_id');
    $table->timestamps();
});

3. License Keys Table (Pre-generated Codes)

// database/migrations/xxxx_create_license_keys_table.php
Schema::create('license_keys', function (Blueprint $table) {
    $table->id();
    $table->uuid('key')->unique();         // The actual license key
    $table->string('offer_id');            // Reference to offers config path
    $table->timestamps();
});

4. Leads Table (for capability tracking)

// Important: leads have a team_number for capability enforcement
Schema::create('leads', function (Blueprint $table) {
    // ... other columns
    $table->foreignId('team_id');
    $table->unsignedInteger('team_number');  // Sequential number per team
    $table->timestamps();
});

Offer & Capability Configuration

Config File: config/offers.php

<?php

use App\Enums\Offer;
use App\Enums\OfferFrequency;
use App\Enums\TeamCapabilities;

return [
    // MONTHLY PLANS
    OfferFrequency::MONTHLY => [
        Offer::OFFER_1 => [
            'name' => 'Starter',
            'description' => "Perfect for individuals getting started",
            'price' => 9_00,  // Price in cents
            'priceId' => env('STRIPE_PRICE_OFFER_1_MONTHLY'),
            'capabilities' => [
                TeamCapabilities::NB_LEADS => 500,
                TeamCapabilities::NB_INTERNAL_DOMAINS => 3,
                TeamCapabilities::NB_TEAM_USERS => 1,
            ],
        ],
        Offer::OFFER_2 => [
            'name' => 'Plus',
            'description' => "For growing businesses",
            'price' => 19_00,
            'priceId' => env('STRIPE_PRICE_OFFER_2_MONTHLY'),
            'capabilities' => [
                TeamCapabilities::NB_LEADS => 5000,
                TeamCapabilities::NB_INTERNAL_DOMAINS => 20,
                TeamCapabilities::NB_TEAM_USERS => 3,
            ],
        ],
        Offer::OFFER_3 => [
            'name' => 'Pro',
            'description' => "For agencies and professionals",
            'price' => 79_00,
            'priceId' => env('STRIPE_PRICE_OFFER_3_MONTHLY'),
            'capabilities' => [
                TeamCapabilities::NB_LEADS => 15000,
                TeamCapabilities::NB_INTERNAL_DOMAINS => -1,  // -1 = unlimited
                TeamCapabilities::NB_TEAM_USERS => 5,
            ],
        ],
        Offer::OFFER_4 => [
            'name' => 'Enterprise',
            'description' => "Unlimited power for large teams",
            'price' => 129_00,
            'priceId' => env('STRIPE_PRICE_OFFER_4_MONTHLY'),
            'capabilities' => [
                TeamCapabilities::NB_LEADS => -1,           // -1 = unlimited
                TeamCapabilities::NB_INTERNAL_DOMAINS => -1,
                TeamCapabilities::NB_TEAM_USERS => -1,
            ],
            'is_unlimited' => true,
        ],
    ],
    
    // YEARLY PLANS
    OfferFrequency::YEARLY => [
        // Same structure as monthly with different prices
    ],
    
    // LIFETIME PLANS (One-time purchase)
    OfferFrequency::LIFETIME => [
        // Same structure with one-time pricing
    ],
    
    // SPECIAL OFFERS (License keys, partnerships)
    'special' => [
        'superuser' => [
            'name' => 'SuperUser',
            'description' => "Unlimited access for super users",
            'price' => 0,
            'priceId' => null,
            'capabilities' => [
                TeamCapabilities::NB_LEADS => -1,
                TeamCapabilities::NB_INTERNAL_DOMAINS => -1,
                TeamCapabilities::NB_TEAM_USERS => -1,
            ],
            'is_unlimited' => true,
        ],
        'partner-1' => [
            // Partner-specific offers structure
            Offer::OFFER_1 => [
                'name' => 'Starter (Partner Offer)',
                // ... same structure
            ],
        ],
    ],
];

Core Enums

1. Offer Enum

// app/Enums/Offer.php
<?php

namespace App\Enums;

final class Offer
{
    const DEFAULT = 'DEFAULT';        // Free tier
    const OFFER_1 = 'STARTER';
    const OFFER_2 = 'PLUS';
    const OFFER_3 = 'PRO';
    const OFFER_4 = 'ENTERPRISE';

    /**
     * Reverse lookup: get offer type from Stripe price ID
     */
    static public function getOfferFromPriceId(string $priceId): string
    {
        return match ($priceId) {
            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_1]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_1]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_1]['priceId'] => self::OFFER_1,

            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_2]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_2]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_2]['priceId'] => self::OFFER_2,

            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_3]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_3]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_3]['priceId'] => self::OFFER_3,

            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_4]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_4]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_4]['priceId'] => self::OFFER_4,
            
            default => self::DEFAULT,
        };
    }
}

2. OfferFrequency Enum

// app/Enums/OfferFrequency.php
<?php

namespace App\Enums;

final class OfferFrequency
{
    const MONTHLY = 'MONTHLY';
    const YEARLY = 'YEARLY';
    const LIFETIME = 'LIFETIME';

    static public function getFrequencyFromPriceId(string $priceId): string
    {
        return match ($priceId) {
            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_1]['priceId'],
            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_2]['priceId'],
            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_3]['priceId'],
            config('offers')[OfferFrequency::MONTHLY][Offer::OFFER_4]['priceId'] => self::MONTHLY,

            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_1]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_2]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_3]['priceId'],
            config('offers')[OfferFrequency::YEARLY][Offer::OFFER_4]['priceId'] => self::YEARLY,

            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_1]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_2]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_3]['priceId'],
            config('offers')[OfferFrequency::LIFETIME][Offer::OFFER_4]['priceId'] => self::LIFETIME,
            
            default => throw new \Exception('Unknown frequency'),
        };
    }
}

3. TeamCapabilities Enum

// app/Enums/TeamCapabilities.php
<?php

namespace App\Enums;

final class TeamCapabilities
{
    const NB_LEADS = 'nb_leads';
    const NB_INTERNAL_DOMAINS = 'nb_internal_domains';
    const NB_TEAM_USERS = 'nb_team_users';

    /**
     * Default capabilities for free tier
     */
    static public function getDefaults(): array
    {
        return [
            self::NB_LEADS => 3,
            self::NB_INTERNAL_DOMAINS => 3,
            self::NB_TEAM_USERS => 1,
        ];
    }

    /**
     * Get capabilities for a specific offer + frequency
     */
    static public function getCapabilitiesFromOfferAndFrequency(string $offer, string $frequency): array
    {
        $config = config('offers')[$frequency][$offer]['capabilities'] ?? null;
        return $config ?? self::getDefaults();
    }
}

Team Model Setup

1. Configure Cashier in AppServiceProvider

// app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Team;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Use Team as the billable model (not User)
        Cashier::useCustomerModel(Team::class);
        
        // Enable automatic tax calculation (optional)
        Cashier::calculateTaxes();
    }
}

2. Team Model

// app/Models/Team.php
<?php

namespace App\Models;

use Laravel\Cashier\Billable;
use Laravel\Jetstream\Team as JetstreamTeam;

class Team extends JetstreamTeam
{
    use Billable;  // Critical: adds Stripe subscription methods

    protected $casts = [
        'personal_team' => 'boolean',
        'capabilities' => 'array',    // JSON to array
    ];

    protected $fillable = [
        'name',
        'personal_team',
        'offer',
        'capabilities',
    ];

    // Relationship to license (for lifetime purchases)
    public function license()
    {
        return $this->hasOne(License::class);
    }

    // Relationship to leads (for capability tracking)
    public function leads()
    {
        return $this->hasMany(Lead::class);
    }
}

3. Team Creation (with default capabilities)

// app/Actions/Jetstream/CreateTeam.php
public function create(User $user, array $input): Team
{
    $user->switchTeam($team = $user->ownedTeams()->create([
        'name' => $input['name'],
        'personal_team' => false,
        'offer' => Offer::DEFAULT,
        'capabilities' => TeamCapabilities::getDefaults(),
    ]));

    return $team;
}

Payment Methods

1. Recurring Subscriptions (Monthly/Yearly)

// app/Http/Controllers/StripeSubscriptionController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class StripeSubscriptionController extends Controller
{
    public function __invoke(Request $request)
    {
        // If already subscribed, redirect to billing portal
        if (team()->subscribed()) {
            return team()->redirectToBillingPortal(route('dashboard'));
        }

        // Validate the price ID
        $request->validate([
            'price_id' => [
                'nullable',
                Rule::in([
                    ...collect(config('offers.MONTHLY'))
                        ->map(fn ($offer) => $offer['priceId'])
                        ->values(),
                    ...collect(config('offers.YEARLY'))
                        ->map(fn ($offer) => $offer['priceId'])
                        ->values(),
                ]),
            ],
        ]);

        // Create subscription via Stripe Checkout
        if ($request->price_id) {
            return team()->newSubscription('default', $request->price_id)
                ->allowPromotionCodes()
                ->checkout([
                    'success_url' => route('dashboard'),
                    'cancel_url' => route('dashboard'),
                ]);
        }

        return redirect()->route('offers');
    }
}

2. Lifetime Purchases (One-time Payment)

// app/Http/Controllers/StripePaymentLinkController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Laravel\Cashier\Cashier;

class StripePaymentLinkController extends Controller
{
    public function __invoke(Request $request)
    {
        $request->validate([
            'price_id' => [
                'required',
                Rule::in([
                    ...collect(config('offers.LIFETIME'))
                        ->map(fn ($offer) => $offer['priceId'])
                        ->values(),
                ]),
            ],
        ]);

        // Create Stripe Payment Link
        $response = Cashier::stripe()->paymentLinks->create([
            'line_items' => [
                [
                    'price' => $request->price_id,
                    'quantity' => 1,
                ],
            ],
            'automatic_tax' => ['enabled' => true],
            'invoice_creation' => [
                'enabled' => true,
                'invoice_data' => [
                    // Critical: team_uuid for webhook identification
                    'metadata' => ['team_uuid' => $request->user()->currentTeam->uuid],
                ],
            ],
            'allow_promotion_codes' => true,
            'billing_address_collection' => 'required',
            'payment_method_types' => ['card'],
            'after_completion' => [
                'type' => 'redirect',
                'redirect' => ['url' => route('dashboard')],
            ],
            'tax_id_collection' => ['enabled' => true],
        ]);

        return redirect($response['url'] . '?prefilled_email=' . $request->user()->email);
    }
}

3. Routes Configuration

// routes/web.php
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('stripe/subscription', StripeSubscriptionController::class)
        ->name('stripe.subscription');
    Route::get('stripe/payment-link', StripePaymentLinkController::class)
        ->name('stripe.payment-link');
    Route::get('offers', OfferController::class)->name('offers');
    Route::get('my-subscription', UserSubscriptionController::class)
        ->name('user-subscriptions');
});

Stripe Webhook Handling

1. Register the Listener

// app/Providers/EventServiceProvider.php
use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\StripeEventListener;

protected $listen = [
    WebhookReceived::class => [
        StripeEventListener::class,
    ],
];

2. Stripe Event Listener

// app/Listeners/StripeEventListener.php
<?php

namespace App\Listeners;

use App\Enums\Offer;
use App\Enums\OfferFrequency;
use App\Enums\TeamCapabilities;
use App\Models\Team;
use Illuminate\Support\Str;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;

class StripeEventListener
{
    public function handle(WebhookReceived $event): void
    {
        $this->updateTeamOfferFromSubscription($event);
        $this->updateTeamOfferFromPaymentLink($event);
    }

    /**
     * Handle subscription events (create, update, delete)
     */
    private function updateTeamOfferFromSubscription(WebhookReceived $event): void
    {
        if (Str::startsWith($event->payload['type'], 'customer.subscription')) {
            $data = $event->payload['data']['object'];
            
            // Find team by Stripe customer ID
            $team = Cashier::findBillable($data['customer']);
            if (!$team) {
                return;
            }

            if ($event->payload['type'] === 'customer.subscription.deleted') {
                // Subscription cancelled - revert to defaults
                $team->offer = Offer::DEFAULT;
                $team->capabilities = TeamCapabilities::getDefaults();
            } else {
                // Subscription created/updated - apply new capabilities
                $offer = Offer::getOfferFromPriceId($data['plan']['id']);
                $frequency = OfferFrequency::getFrequencyFromPriceId($data['plan']['id']);
                
                $team->offer = $offer;
                $team->capabilities = TeamCapabilities::getCapabilitiesFromOfferAndFrequency(
                    $offer, 
                    $frequency
                );

                // Update default payment method if provided
                if (!empty($data['default_payment_method'])) {
                    $team->updateDefaultPaymentMethod($data['default_payment_method']);
                }
            }

            $team->save();
        }
    }

    /**
     * Handle one-time payment (lifetime) via invoice.paid
     */
    private function updateTeamOfferFromPaymentLink(WebhookReceived $event): void
    {
        if ($event->payload['type'] === 'invoice.paid') {
            $data = $event->payload['data']['object'];
            $subscription = $data['subscription'];

            // Only process if NOT a subscription payment
            if (empty($subscription)) {
                $price = $data['lines']['data'][0]['price']['id'];
                $offer = Offer::getOfferFromPriceId($price);
                
                // Find team by UUID from invoice metadata
                $team = Team::query()
                    ->where('uuid', $data['metadata']['team_uuid'])
                    ->firstOrFail();

                $team->offer = $offer;
                $team->capabilities = TeamCapabilities::getCapabilitiesFromOfferAndFrequency(
                    $offer, 
                    OfferFrequency::LIFETIME
                );
                $team->save();

                // Create license record for lifetime purchase
                $team->license()->create([
                    'price_id' => $price,
                ]);
            }
        }
    }
}

Capability Enforcement

1. Domain Creation Policy

// app/Policies/DomainPolicy.php
<?php

namespace App\Policies;

use App\Enums\TeamCapabilities;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class DomainPolicy
{
    public function create(User $user): Response
    {
        $team = $user->currentTeam;
        
        // -1 means unlimited
        if (
            $team->capabilities[TeamCapabilities::NB_INTERNAL_DOMAINS] !== -1
            && $team->domains()->count() >= $team->capabilities[TeamCapabilities::NB_INTERNAL_DOMAINS]
        ) {
            return Response::deny(__('Maximum number of domains reached'));
        }

        return Response::allow();
    }
}

2. Team Invitation Policy

// app/Policies/TeamInvitationPolicy.php
public function create(User $user): Response
{
    $team = $user->currentTeam;
    
    $usersAndInvitationsCount = $team->allUsers()->count() 
        + $team->teamInvitations()->count();
    
    if (
        $team->capabilities[TeamCapabilities::NB_TEAM_USERS] !== -1
        && $usersAndInvitationsCount >= $team->capabilities[TeamCapabilities::NB_TEAM_USERS]
    ) {
        return Response::deny(__('Maximum team member reached'));
    }

    return Response::allow();
}

3. Lead Data Masking (Soft Limit)

Leads are captured but masked when over limit (data still saved):

// app/Actions/LimitLeadPagination.php
<?php

namespace App\Actions;

use App\Enums\TeamCapabilities;
use App\Models\Team;
use Illuminate\Pagination\LengthAwarePaginator;

class LimitLeadPagination
{
    public function execute(LengthAwarePaginator $leads, Team $team)
    {
        $leads->getCollection()->transform(function ($lead) use ($team) {
            // If over capability limit, mask the data
            if (
                $team->capabilities[TeamCapabilities::NB_LEADS] !== -1
                && $lead->team_number > $team->capabilities[TeamCapabilities::NB_LEADS]
            ) {
                return [
                    'email' => 'xxxxx@xxx.com',
                    'firstname' => 'xxxxx',
                    'lastname' => 'xxxxx',
                    'phone' => 'xxxxx',
                    'has_accepted_marketing' => false,
                    'data' => [],
                    'team_id' => $team->id,
                    'created_at' => now(),
                    'blocked' => true,  // Flag for UI
                ];
            }

            return $lead;
        });

        return $leads;
    }
}

4. Automation Enforcement

Automations only trigger for leads within limit:

// app/Listeners/ProcessAutomations.php
public function shouldQueue($event)
{
    return $event->lead->team->capabilities[TeamCapabilities::NB_LEADS] === -1
        || $event->lead->team_number <= $event->lead->team->capabilities[TeamCapabilities::NB_LEADS];
}

5. Lead Export Enforcement

// app/Jobs/ExportLeadsInCSV.php
$maxCapabilities = $this->team->capabilities[TeamCapabilities::NB_LEADS];

if ($maxCapabilities !== -1) {
    $query = $query->where('team_number', '<=', $maxCapabilities);
}

License Key System

For partners or special offers, pre-generated license keys can be used.

1. LicenseKey Model

// app/Models/LicenseKey.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class LicenseKey extends Model
{
    protected $fillable = ['key', 'offer_id'];

    // Check if this is a special (partner) offer
    protected function isSpecial(): Attribute
    {
        return Attribute::make(
            get: fn () => Str::startsWith($this->offer_id, 'special')
        );
    }

    // Get the offer configuration
    protected function offer(): Attribute
    {
        return Attribute::make(
            get: fn () => data_get(config('offers'), $this->offer_id)
        );
    }

    // Check if already redeemed
    protected function alreadyUsed(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->license()->exists()
        );
    }

    public function license()
    {
        return $this->hasOne(License::class);
    }
}

2. License Redemption Controller

// app/Http/Controllers/LicenseKeyController.php
public function store(StoreLicenseKeyRequest $request)
{
    return DB::transaction(function () use ($request) {
        $validatedData = $request->validated();
        $licenseKey = LicenseKey::where('key', $validatedData['license_key'])->first();

        // Create user (includes team creation)
        $user = (new CreateNewUser())->create($request->all());

        // Create license record
        $license = $licenseKey->license()->create([
            'team_id' => $user->currentTeam->id,
        ]);
        $user->currentTeam->license()->save($license);

        // Apply capabilities from license key
        $offer = data_get(config('offers'), $licenseKey->offer_id);
        $user->currentTeam->capabilities = $offer['capabilities'];
        $user->currentTeam->save();

        event(new Registered($user));
        Auth::login($user);

        return redirect()->back();
    });
}

3. Artisan Command for Manual License Assignment

// app/Console/Commands/AssociateUserLicenseCommand.php
// Usage: php artisan license-key:associate {teamId} {licenseId}

public function handle()
{
    $team = Team::find($this->argument('teamId'));
    $licenseKey = LicenseKey::find($this->argument('licenseId'));

    DB::transaction(function () use ($team, $licenseKey) {
        $license = $licenseKey->license()->create([
            'team_id' => $team->id,
        ]);

        $team->license()->save($license);

        $offer = data_get(config('offers'), $licenseKey->offer_id);
        $team->capabilities = $offer['capabilities'];
        $team->save();
    });
}

Frontend Implementation

1. Offers Page

// resources/js/views/offers.tsx
function OfferItem(props: { type: "recurring" | "one-time", priceId: string, ... }) {
    return (
        <Button asChild>
            <a
                href={route(
                    props.type === "recurring"
                        ? "stripe.subscription"
                        : "stripe.payment-link",
                    { price_id: props.priceId }
                )}
            >
                Choose this plan
            </a>
        </Button>
    );
}

2. Capability Display Component

// resources/js/components/capability-list.tsx
export function CapabilityList(props: {
    nb_leads: number;
    nb_internal_domains: number;
    nb_team_users: number;
}) {
    const capabilities = [
        {
            name: props.nb_leads > -1 
                ? `Up to ${props.nb_leads} leads` 
                : "Unlimited leads",
        },
        {
            name: props.nb_internal_domains > -1 
                ? `Up to ${props.nb_internal_domains} domains` 
                : "Unlimited domains",
        },
        {
            name: props.nb_team_users > -1 
                ? `Up to ${props.nb_team_users} team members` 
                : "Unlimited team members",
        },
    ];
    
    return capabilities.map((cap, index) => (
        <li key={index}>{cap.name}</li>
    ));
}

3. User Subscription Page

Pass capabilities to the frontend:

// app/Http/Controllers/UserSubscriptionController.php
return Inertia::render('user-subscription', [
    'lead_count' => team()->leads()->count(),
    'offer' => team()->offer,
    'capabilities' => team()->capabilities,
    'paid_price' => $paidPrice,
]);

Step-by-Step Implementation

Phase 1: Database Setup

  1. Create migrations:

    php artisan make:migration create_teams_table
    php artisan make:migration create_licenses_table
    php artisan make:migration create_license_keys_table
    
  2. Run Cashier installation:

    php artisan vendor:publish --tag=cashier-migrations
    php artisan migrate
    

Phase 2: Configuration

  1. Create config/offers.php with all plans and capabilities
  2. Create enums: Offer, OfferFrequency, TeamCapabilities
  3. Add Stripe environment variables

Phase 3: Model Setup

  1. Add Billable trait to Team model
  2. Configure Cashier::useCustomerModel(Team::class) in AppServiceProvider
  3. Add capabilities cast and offer field to Team model

Phase 4: Payment Controllers

  1. Create StripeSubscriptionController for recurring
  2. Create StripePaymentLinkController for lifetime
  3. Add routes

Phase 5: Webhook Handling

  1. Register StripeEventListener in EventServiceProvider
  2. Implement updateTeamOfferFromSubscription()
  3. Implement updateTeamOfferFromPaymentLink()

Phase 6: Capability Enforcement

  1. Create policies for domains, team invitations
  2. Implement LimitLeadPagination action
  3. Add capability checks to relevant listeners/jobs

Phase 7: Frontend

  1. Create offers page with plan selection
  2. Create user subscription management page
  3. Implement capability display components

Phase 8: Testing

  1. Test with Stripe CLI:
    stripe listen --forward-to localhost:8000/stripe/webhook
    
  2. Test subscription creation, update, cancellation
  3. Test lifetime purchase flow
  4. Verify capability enforcement

Key Webhook Events to Handle

Event Purpose
customer.subscription.created Apply capabilities on new subscription
customer.subscription.updated Update capabilities on plan change
customer.subscription.deleted Revert to defaults on cancellation
invoice.paid Handle lifetime purchases (non-subscription)

Important Notes

  1. Team is Billable, Not User: Subscriptions are tied to teams, allowing multiple users to share a subscription
  2. Capabilities = -1 means Unlimited: Always check for -1 before comparing limits
  3. Leads are Never Deleted: Even over-limit leads are captured but masked in the UI
  4. License Keys are Single-Use: Once redeemed, they cannot be used again
  5. Webhooks are Critical: All capability updates happen via webhooks, not on redirect