diff --git a/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php b/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php index c9eb859..2e4ccb5 100644 --- a/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php +++ b/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php @@ -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)', diff --git a/backend/app/Services/LemonSqueezyUsageService.php b/backend/app/Services/LemonSqueezyUsageService.php index a5031ac..a3a5b94 100644 --- a/backend/app/Services/LemonSqueezyUsageService.php +++ b/backend/app/Services/LemonSqueezyUsageService.php @@ -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. @@ -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|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'); diff --git a/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php b/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php index c6d6512..89ba175 100644 --- a/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php +++ b/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php @@ -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'); +});