Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,45 @@ public function handle(): int
// for a while the only sign of trouble was the exit code, with nothing
// to say which workspace caused it.
if (! $this->usage->pushUnits($subscription->lemon_squeezy_id, $units, ['workspace_id' => $workspace->id])) {
// A refusal is often Lemon Squeezy's way of saying the subscription
// is not what we think it is: a missed webhook left one expired
// subscription looking live for months. Pull the truth before
// deciding whether this is an error at all.
$remote = $this->usage->refreshSubscription($subscription);

if ($remote !== null && ! $subscription->valid()) {
$this->line(sprintf(
'Skipping workspace %s: subscription %s is %s at Lemon Squeezy, local row synced',
$workspace->id,
$subscription->lemon_squeezy_id,
$subscription->status,
));
$skippedCount++;

continue;
}

// Nothing we can do from here: Lemon Squeezy only lets the customer
// change a PayPal subscription, through the portal. Say so with the
// numbers, since the quantity at Lemon Squeezy will drift from the
// real usage until someone acts on it.
if (($remote['payment_processor'] ?? null) === 'paypal') {
$billed = $remote['first_subscription_item']['quantity'] ?? null;
$message = sprintf(
'Workspace %s pays through PayPal, which Lemon Squeezy cannot update via the API: billed for %s, uses %d units',
$workspace->id,
$billed ?? '?',
$units,
);
$this->warn($message);
if ($billed !== $units) {
Log::warning($message, ['workspace_id' => $workspace->id, 'subscription_id' => $subscription->lemon_squeezy_id]);
}
$skippedCount++;

continue;
}

$errorCount++;
$this->error(sprintf(
'Lemon Squeezy refused workspace %s (subscription %s, %d units)',
Expand Down
37 changes: 37 additions & 0 deletions backend/app/Services/LemonSqueezyUsageService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use LemonSqueezy\Laravel\Subscription;

/**
* Pushing billable usage to Lemon Squeezy.
Expand Down Expand Up @@ -205,6 +206,42 @@ public function pushUnits(string $subscriptionId, int $units, array $context = [
return true;
}

/**
* Overwrite the local subscription row with what Lemon Squeezy currently says.
*
* Webhooks are the normal way status reaches us, but one can be missed: a subscription
* sat locally as past_due for eight months after it had expired at Lemon Squeezy, and
* was pushed, and refused, every hour of it. Called when a push is refused, so a
* subscription that is no longer live stops being treated as one.
*
* Returns the remote attributes, because the caller also needs what the local row
* does not keep: the payment processor. Lemon Squeezy cannot change a PayPal
* subscription through the API at all (the customer has to, in the portal), and
* answers a quantity update on one with a bare 500.
*
* @return array<string, mixed>|null
*/
public function refreshSubscription(Subscription $subscription): ?array
{
$response = $this->request()->get(self::BASE.'/subscriptions/'.$subscription->lemon_squeezy_id);

$attributes = $response->json('data.attributes');

if (! $response->successful() || ! is_array($attributes) || ! isset($attributes['status'])) {
Log::warning('Could not refresh Lemon Squeezy subscription', [
'subscription_id' => $subscription->lemon_squeezy_id,
'status' => $response->status(),
'error' => $this->errorDetail($response),
]);

return null;
}

$subscription->sync($attributes);

return $attributes;
}

public function hasApiKey(): bool
{
return (bool) config('lemon-squeezy.api_key');
Expand Down
89 changes: 89 additions & 0 deletions backend/tests/Feature/UpdateLemonSqueezyUsageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,92 @@ function pushedQuantity(): ?int
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Skipping yearly subscription');
});

test('a subscription that expired at Lemon Squeezy without a webhook is synced locally and skipped', function () {
// Production case: the subscription_expired webhook never arrived, so the local row
// stayed past_due with no ends_at and was pushed, and refused, every hour for months.
Http::fake([
'*lemonsqueezy.com/v1/subscriptions/*' => Http::response([
'data' => ['attributes' => [
'status' => 'expired',
'product_id' => 1,
'variant_id' => 1,
'renews_at' => null,
'ends_at' => '2026-03-17T15:13:39.000000Z',
'subscription_items' => [['id' => 'item_1']],
]],
]),
'*lemonsqueezy.com/v1/subscription-items/*' => Http::response(['message' => 'An unexpected error occurred.'], 500),
'*lemonsqueezy.com/v1/usage-records*' => Http::response(['errors' => [['detail' => 'not metered']]], 422),
]);

$owner = User::factory()->active()->create();
$workspace = subscribedWorkspace($owner);
DB::table('lemon_squeezy_subscriptions')->where('billable_id', $workspace->id)->update(['status' => 'past_due']);
Display::factory()->create(['workspace_id' => $workspace->id]);

$exitCode = Artisan::call('app:update-lemonsqueezy-subscriptions');

expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('expired');

$row = DB::table('lemon_squeezy_subscriptions')->where('billable_id', $workspace->id)->first();
expect($row->status)->toBe('expired')
->and($row->ends_at)->not->toBeNull();
});

test('a refused push on a subscription that is still live at Lemon Squeezy stays an error', function () {
Http::fake([
'*lemonsqueezy.com/v1/subscriptions/*' => Http::response([
'data' => ['attributes' => [
'status' => 'active',
'product_id' => 1,
'variant_id' => 1,
'renews_at' => now()->addMonth()->toIso8601String(),
'ends_at' => null,
'subscription_items' => [['id' => 'item_1']],
]],
]),
'*lemonsqueezy.com/v1/subscription-items/*' => Http::response(['message' => 'An unexpected error occurred.'], 500),
'*lemonsqueezy.com/v1/usage-records*' => Http::response(['errors' => [['detail' => 'not metered']]], 422),
]);

$owner = User::factory()->active()->create();
$workspace = subscribedWorkspace($owner);
Display::factory()->create(['workspace_id' => $workspace->id]);

expect(Artisan::call('app:update-lemonsqueezy-subscriptions'))->toBe(1);
});

test('a PayPal subscription is reported as unsyncable and skipped rather than failing every hour', function () {
// Lemon Squeezy cannot change PayPal subscriptions through the API (the customer has
// to do it in the portal); the subscription-items endpoint answers with a 500. Two
// production subscriptions failed the hourly run for months because of this.
Http::fake([
'*lemonsqueezy.com/v1/subscriptions/*' => Http::response([
'data' => ['attributes' => [
'status' => 'active',
'product_id' => 1,
'variant_id' => 1,
'payment_processor' => 'paypal',
'renews_at' => now()->addMonth()->toIso8601String(),
'ends_at' => null,
'first_subscription_item' => ['id' => 'item_1', 'quantity' => 1],
'subscription_items' => [['id' => 'item_1']],
]],
]),
'*lemonsqueezy.com/v1/subscription-items/*' => Http::response(['message' => 'An unexpected error occurred.'], 500),
'*lemonsqueezy.com/v1/usage-records*' => Http::response(['errors' => [['detail' => 'not metered']]], 422),
]);

$owner = User::factory()->active()->create();
$workspace = subscribedWorkspace($owner);
Display::factory()->count(3)->create(['workspace_id' => $workspace->id]);

$exitCode = Artisan::call('app:update-lemonsqueezy-subscriptions');
$output = Artisan::output();

expect($exitCode)->toBe(0)
->and($output)->toContain('PayPal')
->and($output)->toContain('billed for 1, uses 3');
});