Currently Available: Need a skilled Software Developer for your next project?
Categories
Laravel

Testing Billing in Laravel Spark (Paddle) Applications

I'll show you how to test Spark billing components without making actual API calls to Paddle.

Challenges

When testing Laravel Spark with Paddle, you'll likely encounter these challenges:

  1. External API dependencies: Spark makes calls to Paddle's API during normal operation
  2. Class aliasing issues: Mocking the Cashier class can be problematic
  3. Subscription lifecycle testing: Testing grace periods and cancellations
  4. Route testing: Especially in localized applications
  5. Method availability: Differences between Stripe and Paddle implementations

Setting Up a Test Environment

Factory for Billable Teams

First, create a factory that handles subscription creation for testing:

// TeamFactory.php
public function withSubscription(string $planType = 'basic'): static
{
    return $this->afterCreating(function (Team $team) use ($planType) {
        // Create a local Paddle Customer record associated with the team
        $customer = PaddleCustomer::create([
            'billable_id' => $team->id,
            'billable_type' => Team::class,
            'paddle_id' => 'cus_' . fake()->unique()->lexify('????????????'),
            'name' => $team->paddleName(),
            'email' => $team->paddleEmail(),
            'trial_ends_at' => null,
        ]);

        // Set the customer relationship
        $team->setRelation('customer', $customer);

        // Create a Subscription record if needed
        if (!$team->subscribed('default')) {
            $subscription = $team->subscriptions()->create([
                'type' => 'default',
                'paddle_id' => 'sub_' . fake()->unique()->lexify('????????????'),
                'status' => 'active',
                'trial_ends_at' => null,
                'paused_at' => null,
                'ends_at' => null,
            ]);

            // Generate mock Paddle Price ID
            $paddlePriceId = 'pri_' . fake()->unique()->lexify('??????????????????????????');

            // Create the SubscriptionItem
            $subscription->items()->create([
                'subscription_id' => $subscription->id,
                'product_id' => 'prod_' . fake()->unique()->lexify('????????????'),
                'price_id' => $paddlePriceId,
                'status' => 'active',
                'quantity' => 1,
            ]);
        }

        // Update team credits based on the plan
        $credits = match ($planType) {
            'basic' => config('plans.defaults.basic.credits_per_month', 1000),
            'pro'   => config('plans.defaults.pro.credits_per_month', 5000),
            default => 0,
        };
        $team->update(['credits' => $credits]);

        // Refresh the team instance
        $team->refresh();
    });
}

Handling Paddle API Calls in Tests

Modify your Team model to skip actual API calls during testing:

// Team.php
public function createOrFetchExistingPaddleCustomer()
{
    // If this team already has a local Paddle customer record, just return it
    if ($this->customer) {
        return $this->customer;
    }

    // If running in test environment, create a mock customer without API calls
    if (app()->environment('testing')) {
        $customer = $this->customer()->make([
            'paddle_id' => 'cus_' . fake()->unique()->lexify('????????????'),
            'name' => $this->paddleName(),
            'email' => $this->paddleEmail(),
        ]);
        $customer->save();

        return $customer;
    }

    // Normal production code follows...
}

/**
 * Override this method for tests to skip actual billing portal access
 */
public function redirectToBillingPortal(array $options = [])
{
    if (app()->environment('testing')) {
        return redirect('/billing');
    }

    return parent::redirectToBillingPortal($options);
}

/**
 * Get the owner of the team - alias for the user relationship
 * expected by some billing logic.
 */
public function user()
{
    return $this->owner();
}

Mock HTTP Requests

Use Laravel's HTTP facade to mock all requests to Paddle:

// In test setup
Http::fake([
    'paddle.com/*' => Http::response(['data' => [
        ['id' => 'test-customer-id', 'email' => 'test@example.com']
    ]], 200),
    'api.paddle.com/*' => Http::response(['data' => [
        ['id' => 'test-customer-id', 'email' => 'test@example.com']
    ]], 200),
    '*/prices-preview' => Http::response(['data' => []], 200),
    '*' => Http::response(['data' => []], 200),
]);

Effective Subscription Test Patterns

Testing Subscription Status

public function team_can_subscribe_to_basic_plan()
{
    // Create a user with a team and a basic subscription
    $user = User::factory()->create();
    $team = Team::factory()
        ->for($user)
        ->withSubscription('basic')
        ->create(['personal_team' => true]);

    $user->current_team_id = $team->id;
    $user->save();

    // Assert - Team should have an active subscription
    $this->assertTrue($team->subscribed('default'));

    // Check for a valid Paddle price ID format
    $subscription = $team->subscription('default');
    $priceId = $subscription->items->first()->price_id;
    $this->assertStringStartsWith('pri_', $priceId);
}

Testing Subscription Cancellation

public function team_can_cancel_subscription()
{
    $user = User::factory()->create();
    $team = Team::factory()
        ->for($user)
        ->withSubscription('basic')
        ->create(['personal_team' => true]);

    // Get the subscription and cancel it
    $subscription = $team->subscription('default');
    $subscription->update([
        'ends_at' => now()->addDays(7)
    ]);

    // Assert it's on grace period but still active
    $this->assertTrue($team->subscription('default')->onGracePeriod());
    $this->assertFalse($team->subscription('default')->canceled());
    $this->assertTrue($team->subscription('default')->active());
}

Testing Grace Period Resumption

public function team_can_resume_subscription_during_grace_period()
{
    $user = User::factory()->create();
    $team = Team::factory()
        ->for($user)
        ->withSubscription('basic')
        ->create(['personal_team' => true]);

    // Put it in grace period
    $subscription = $team->subscription('default');
    $subscription->update([
        'ends_at' => now()->addDays(7)
    ]);

    // Should be in grace period
    $this->assertTrue($team->subscription('default')->onGracePeriod());

    // Act - Resume subscription
    $subscription->update([
        'ends_at' => null
    ]);

    // No longer in grace period
    $team->refresh();
    $this->assertFalse($team->subscription('default')->onGracePeriod());
    $this->assertTrue($team->subscription('default')->active());
}

Testing Billing Notifications

For testing grace period notifications, use a Carbon instance rather than a Subscription object:

public function grace_period_notification_is_sent_at_appropriate_intervals()
{
    Notification::fake();

    $user = User::factory()->create();
    $team = Team::factory()
        ->for($user)
        ->withSubscription('basic')
        ->create(['personal_team' => true]);

    // Set up a grace period
    $subscription = $team->subscription('default');
    $endsAt = now()->addDays(7);

    $subscription->update([
        'ends_at' => $endsAt
    ]);

    // Fire the notification with a Carbon instance
    $user->notify(new SubscriptionGracePeriodWarning($endsAt));

    // Verify notification was sent
    Notification::assertSentTo(
        User::find($user->id),
        SubscriptionGracePeriodWarning::class
    );
}

Testing Route Existence

For testing billing routes without actually loading them:

public function localized_routes_are_registered_for_billing()
{
    $routes = app('router')->getRoutes();

    // Check that the main portal route exists
    $this->assertTrue($routes->hasNamedRoute('spark.portal'));

    // Test that we can generate a URL
    $this->assertNotEmpty(route('spark.portal'));
}

Key Lessons Learned

  1. Don't use class aliasing for mocking: Rather than trying to mock Cashier::previewPrices() with Mockery::mock('alias:Laravel\Paddle\Cashier'), implement test-specific overrides in your models.

  2. Handle missing method issues: Some methods like invoices() or paymentMethods() that are available in Stripe may not exist in Paddle's implementation. Skip these tests or verify alternative functionality.

  3. Locale-aware testing: If your app has multiple locales, ensure your User model's preferredLocale() method handles null values:

public function preferredLocale(): string
{
    return $this->locale ?? config('app.locale', 'en');
}
  1. Test real behavior: Focus on testing the actual behavior of your application with subscriptions rather than implementation details of Spark/Paddle.

  2. Avoid testing the billing portal directly: Testing the actual billing portal page is challenging due to external API dependencies. Instead, test that the routes exist and focus on the subscription lifecycle logic.

What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Subscribe to my newsletter

Get new posts when I publish them.

I respect your privacy. Unsubscribe at any time.

Leave a Reply

Your email address will not be published. Required fields are marked *