From b631762cd159d987ec05781b26b4b85a8fb6c104 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:42:01 +0100 Subject: [PATCH 01/11] feat: Configure the switch product and recognise yearly subscriptions Co-Authored-By: Claude Opus 5.5 --- backend/app/Services/BillingPlans.php | 59 ++++++++++++++++++++++ backend/config/settings.php | 13 +++++ backend/tests/Feature/BillingPlansTest.php | 49 ++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 backend/app/Services/BillingPlans.php create mode 100644 backend/tests/Feature/BillingPlansTest.php diff --git a/backend/app/Services/BillingPlans.php b/backend/app/Services/BillingPlans.php new file mode 100644 index 0000000..5363f6d --- /dev/null +++ b/backend/app/Services/BillingPlans.php @@ -0,0 +1,59 @@ +variant_id, self::yearlyVariantIds(), true); + } + + /** + * Whether the product behind a switch is configured, in both directions. + */ + public static function canSwitch(): bool + { + return filled(self::switchProductId()) + && filled(self::switchMonthlyVariantId()) + && filled(self::switchYearlyVariantId()); + } + + public static function switchProductId(): string + { + return (string) config('settings.pro_switch_product_id'); + } + + public static function switchMonthlyVariantId(): string + { + return (string) config('settings.pro_switch_monthly_variant_id'); + } + + public static function switchYearlyVariantId(): string + { + return (string) config('settings.pro_switch_yearly_variant_id'); + } + + /** + * @return array + */ + private static function yearlyVariantIds(): array + { + return array_values(array_filter(array_map('strval', [ + ...(array) config('settings.pro_yearly_variant_ids', []), + self::switchYearlyVariantId(), + ]))); + } +} diff --git a/backend/config/settings.php b/backend/config/settings.php index 669cdbe..729cca9 100644 --- a/backend/config/settings.php +++ b/backend/config/settings.php @@ -21,6 +21,19 @@ 'cloud_hosted_pro_plan_id' => env('CLOUD_HOSTED_PRO_PLAN_ID'), + // Switching an existing subscription between monthly and yearly. Every switch goes + // through a separate Lemon Squeezy product without a trial (the product behind + // cloud_hosted_pro_plan_id has one and is only for a first checkout), in both + // directions. Leave any of the three empty and the switch buttons stay hidden. + 'pro_switch_product_id' => env('CLOUD_HOSTED_PRO_SWITCH_PRODUCT_ID'), + 'pro_switch_monthly_variant_id' => env('CLOUD_HOSTED_PRO_SWITCH_MONTHLY_VARIANT_ID'), + 'pro_switch_yearly_variant_id' => env('CLOUD_HOSTED_PRO_SWITCH_YEARLY_VARIANT_ID'), + + // Every other yearly variant, comma-separated: the yearly variant new customers can pick + // in the first checkout, and any older one. A subscription on one of these is billed a + // year ahead, so its quantity must not follow usage day by day. + 'pro_yearly_variant_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('CLOUD_HOSTED_PRO_YEARLY_VARIANT_IDS', ''))))), + // The standard monthly list price per billable unit (display = 1 unit, board = 2 units). // // One number with two readers, deliberately not two settings: they would be the same diff --git a/backend/tests/Feature/BillingPlansTest.php b/backend/tests/Feature/BillingPlansTest.php new file mode 100644 index 0000000..2d05d5a --- /dev/null +++ b/backend/tests/Feature/BillingPlansTest.php @@ -0,0 +1,49 @@ + '10', + 'settings.pro_switch_monthly_variant_id' => '11', + 'settings.pro_switch_yearly_variant_id' => '12', + 'settings.pro_yearly_variant_ids' => ['22'], + ]); +}); + +function subscriptionOnVariant(string $variant): Subscription +{ + return new Subscription(['variant_id' => $variant]); +} + +test('a variant from the yearly list is yearly', function () { + expect(BillingPlans::isYearly(subscriptionOnVariant('22')))->toBeTrue(); +}); + +test('the yearly variant of the switch product is yearly without being listed', function () { + expect(BillingPlans::isYearly(subscriptionOnVariant('12')))->toBeTrue(); +}); + +test('any other variant is monthly', function () { + expect(BillingPlans::isYearly(subscriptionOnVariant('11')))->toBeFalse() + ->and(BillingPlans::isYearly(subscriptionOnVariant('1')))->toBeFalse() + ->and(BillingPlans::isYearly(null))->toBeFalse(); +}); + +test('switching is available only when the switch product is fully configured', function () { + expect(BillingPlans::canSwitch())->toBeTrue(); + + config(['settings.pro_switch_monthly_variant_id' => null]); + + expect(BillingPlans::canSwitch())->toBeFalse(); +}); + +test('the switch ids are read from config', function () { + expect(BillingPlans::switchProductId())->toBe('10') + ->and(BillingPlans::switchMonthlyVariantId())->toBe('11') + ->and(BillingPlans::switchYearlyVariantId())->toBe('12'); +}); From 8dc8c3d60aac0a5c40a13de445722ae2274f784f Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:42:51 +0100 Subject: [PATCH 02/11] feat: Leave yearly quantities alone in the usage push Co-Authored-By: Claude Opus 5.5 --- .../UpdateLemonSqueezySubscriptions.php | 9 +++ .../PushWorkspaceUsageToLemonSqueezy.php | 6 +- .../app/Services/LemonSqueezyUsageService.php | 65 +++++++++++++++++++ .../Feature/LemonSqueezyQuantityTest.php | 61 +++++++++++++++++ .../Feature/UpdateLemonSqueezyUsageTest.php | 15 +++++ .../tests/Feature/WorkspaceUsagePushTest.php | 11 ++++ 6 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 backend/tests/Feature/LemonSqueezyQuantityTest.php diff --git a/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php b/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php index 0c25cc4..c9eb859 100644 --- a/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php +++ b/backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Workspace; +use App\Services\BillingPlans; use App\Services\LemonSqueezyUsageService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -78,6 +79,14 @@ public function handle(): int continue; } + // Billed a year ahead and trued up by app:process-yearly-subscriptions. + if (BillingPlans::isYearly($subscription)) { + $this->line("Skipping yearly subscription of workspace {$workspace->id} ({$units} units)"); + $skippedCount++; + + continue; + } + if ($dryRun) { $this->line(sprintf( 'workspace=%s subscription=%s units=%d', diff --git a/backend/app/Listeners/PushWorkspaceUsageToLemonSqueezy.php b/backend/app/Listeners/PushWorkspaceUsageToLemonSqueezy.php index 76d3bcd..67d96a2 100644 --- a/backend/app/Listeners/PushWorkspaceUsageToLemonSqueezy.php +++ b/backend/app/Listeners/PushWorkspaceUsageToLemonSqueezy.php @@ -3,6 +3,7 @@ namespace App\Listeners; use App\Events\WorkspaceUsageChanged; +use App\Services\BillingPlans; use App\Services\LemonSqueezyUsageService; use Illuminate\Contracts\Queue\ShouldQueue; @@ -34,7 +35,10 @@ public function handle(WorkspaceUsageChanged $event): void $subscription = $workspace->liveSubscription(); - if (! $subscription) { + // Paid a year ahead. Its quantity only moves on the monthly anniversary, with an + // immediate invoice (app:process-yearly-subscriptions): pushed here, the increase would + // wait for the renewal and a decrease would turn into a credit. + if (! $subscription || BillingPlans::isYearly($subscription)) { return; } diff --git a/backend/app/Services/LemonSqueezyUsageService.php b/backend/app/Services/LemonSqueezyUsageService.php index 441d90b..a5031ac 100644 --- a/backend/app/Services/LemonSqueezyUsageService.php +++ b/backend/app/Services/LemonSqueezyUsageService.php @@ -33,6 +33,71 @@ public function resolveSubscriptionItemId(string $subscriptionId): ?string ); } + /** + * Drop the cached item id, for when a variant swap may have replaced the item. + */ + public function forgetSubscriptionItemId(string $subscriptionId): void + { + cache()->forget("lemonsqueezy:subscription:{$subscriptionId}:item-id"); + } + + /** + * The quantity a subscription item is billed for right now, or null if it cannot be read. + */ + public function getQuantity(string $subscriptionItemId): ?int + { + $response = $this->request()->get(self::BASE."/subscription-items/{$subscriptionItemId}"); + + if (! $response->successful()) { + Log::warning('Could not read Lemon Squeezy subscription item', [ + 'subscription_item_id' => $subscriptionItemId, + 'status' => $response->status(), + 'error' => $this->errorDetail($response), + ]); + + return null; + } + + $quantity = $response->json('data.attributes.quantity'); + + return $quantity === null ? null : (int) $quantity; + } + + /** + * Set the quantity with an explicit choice about proration. + * + * For a yearly subscription the default (prorate onto the next invoice, which is the + * renewal) is never what we want: an increase is invoiced now, and the quantity the + * renewal bills for is set without any proration so a decrease does not become a credit. + */ + public function updateQuantity(string $subscriptionItemId, int $quantity, bool $invoiceImmediately = false, bool $disableProrations = false): bool + { + $attributes = array_filter([ + 'quantity' => $quantity, + 'invoice_immediately' => $invoiceImmediately ?: null, + 'disable_prorations' => $disableProrations ?: null, + ], fn ($value) => $value !== null); + + $response = $this->request()->patch(self::BASE."/subscription-items/{$subscriptionItemId}", [ + 'data' => [ + 'type' => 'subscription-items', + 'id' => $subscriptionItemId, + 'attributes' => $attributes, + ], + ]); + + if (! $response->successful()) { + Log::warning('Lemon Squeezy refused a quantity update', [ + 'subscription_item_id' => $subscriptionItemId, + 'attributes' => $attributes, + 'status' => $response->status(), + 'error' => $this->errorDetail($response), + ]); + } + + return $response->successful(); + } + /** * Quantity-based billing. */ diff --git a/backend/tests/Feature/LemonSqueezyQuantityTest.php b/backend/tests/Feature/LemonSqueezyQuantityTest.php new file mode 100644 index 0000000..9227aca --- /dev/null +++ b/backend/tests/Feature/LemonSqueezyQuantityTest.php @@ -0,0 +1,61 @@ + 'test-key']); + cache()->flush(); +}); + +test('the quantity is read from the subscription item', function () { + Http::fake([ + '*lemonsqueezy.com/v1/subscription-items/item_1' => Http::response([ + 'data' => ['id' => 'item_1', 'attributes' => ['quantity' => 7]], + ]), + ]); + + expect(app(LemonSqueezyUsageService::class)->getQuantity('item_1'))->toBe(7); +}); + +test('an unreadable item gives no quantity', function () { + Http::fake(['*' => Http::response([], 500)]); + + expect(app(LemonSqueezyUsageService::class)->getQuantity('item_1'))->toBeNull(); +}); + +test('a quantity can be invoiced immediately', function () { + Http::fake(['*' => Http::response(['data' => []])]); + + expect(app(LemonSqueezyUsageService::class)->updateQuantity('item_1', 9, invoiceImmediately: true))->toBeTrue(); + + Http::assertSent(fn ($request) => $request->method() === 'PATCH' + && str_ends_with($request->url(), 'subscription-items/item_1') + && $request['data']['attributes'] === ['quantity' => 9, 'invoice_immediately' => true]); +}); + +test('a quantity can be set without proration', function () { + Http::fake(['*' => Http::response(['data' => []])]); + + app(LemonSqueezyUsageService::class)->updateQuantity('item_1', 4, disableProrations: true); + + Http::assertSent(fn ($request) => $request['data']['attributes'] === ['quantity' => 4, 'disable_prorations' => true]); +}); + +test('a refused update reports failure', function () { + Http::fake(['*' => Http::response([], 422)]); + + expect(app(LemonSqueezyUsageService::class)->updateQuantity('item_1', 4))->toBeFalse(); +}); + +test('the cached item id can be forgotten', function () { + cache()->put('lemonsqueezy:subscription:sub_1:item-id', 'item_old', now()->addDay()); + + app(LemonSqueezyUsageService::class)->forgetSubscriptionItemId('sub_1'); + + expect(cache()->has('lemonsqueezy:subscription:sub_1:item-id'))->toBeFalse(); +}); diff --git a/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php b/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php index 044db0c..c6d6512 100644 --- a/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php +++ b/backend/tests/Feature/UpdateLemonSqueezyUsageTest.php @@ -204,3 +204,18 @@ function pushedQuantity(): ?int Http::assertNothingSent(); }); + +test('a yearly subscription is skipped by the reconciliation pass', function () { + fakeLemonSqueezy(); + config(['settings.pro_yearly_variant_ids' => ['1']]); + + $owner = User::factory()->active()->create(); + $workspace = subscribedWorkspace($owner); + Display::factory()->count(3)->create(['workspace_id' => $workspace->id]); + + $exitCode = Artisan::call('app:update-lemonsqueezy-subscriptions'); + + Http::assertNothingSent(); + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Skipping yearly subscription'); +}); diff --git a/backend/tests/Feature/WorkspaceUsagePushTest.php b/backend/tests/Feature/WorkspaceUsagePushTest.php index 20c3f8a..5b004d6 100644 --- a/backend/tests/Feature/WorkspaceUsagePushTest.php +++ b/backend/tests/Feature/WorkspaceUsagePushTest.php @@ -142,3 +142,14 @@ function lastPushedQuantity(): ?int Http::assertNothingSent(); }); + +test('a yearly subscription is not resized when usage changes', function () { + config(['settings.pro_yearly_variant_ids' => ['1']]); + $workspace = pushWorkspace(); + + // Paid a year ahead: the quantity only moves on the monthly anniversary, with an + // immediate invoice, so a push here would leave nothing to bill and credit a decrease. + Display::factory()->count(2)->create(['workspace_id' => $workspace->id]); + + Http::assertNothingSent(); +}); From 99cca8785d91ab941e8c01f89edd1ba31e4fdcb6 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:47:03 +0100 Subject: [PATCH 03/11] feat: Switch a monthly workspace to yearly through a checkout Co-Authored-By: Claude Opus 5.5 --- .../Http/Controllers/BillingController.php | 31 +++ .../CancelSupersededSubscription.php | 46 ++++ backend/app/Models/Workspace.php | 73 ++++++ backend/routes/web.php | 1 + backend/tests/Feature/SwitchToYearlyTest.php | 224 ++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 backend/app/Listeners/CancelSupersededSubscription.php create mode 100644 backend/tests/Feature/SwitchToYearlyTest.php diff --git a/backend/app/Http/Controllers/BillingController.php b/backend/app/Http/Controllers/BillingController.php index 8747fa9..e99c466 100644 --- a/backend/app/Http/Controllers/BillingController.php +++ b/backend/app/Http/Controllers/BillingController.php @@ -68,6 +68,37 @@ public function portal(Request $request): RedirectResponse } } + /** + * Move a monthly workspace to yearly through a fresh checkout. + * + * A checkout rather than a swap on the existing subscription: the customer is present to + * pay the year and to pass 3D Secure, so a card that refuses the amount is found out here + * instead of a month later on an off-session charge. The monthly subscription is + * cancelled by CancelSupersededSubscription once the yearly one exists, so an abandoned + * checkout changes nothing. + */ + public function switchToYearly(Request $request): RedirectResponse + { + $workspace = $request->user()->getSelectedWorkspace(); + + abort_unless($workspace !== null, 404, 'No workspace found'); + $this->authorize('manageBilling', $workspace); + + if (! $workspace->canSwitchToYearly()) { + return redirect()->route('workspaces.members') + ->with('error', 'This workspace cannot switch to a yearly subscription right now. Email support@spacepad.io if you think it should.'); + } + + try { + return redirect()->away($workspace->getYearlySwitchCheckoutUrl()); + } catch (Throwable $e) { + report($e); + + return redirect()->route('workspaces.members') + ->with('error', 'We could not open the checkout just now. Please try again, or email support@spacepad.io.'); + } + } + public function thanks(): RedirectResponse { GoogleTagManagerFacade::flashPush([ diff --git a/backend/app/Listeners/CancelSupersededSubscription.php b/backend/app/Listeners/CancelSupersededSubscription.php new file mode 100644 index 0000000..0473496 --- /dev/null +++ b/backend/app/Listeners/CancelSupersededSubscription.php @@ -0,0 +1,46 @@ +subscription)) { + return; + } + + $superseded = $event->billable->subscriptions() + ->whereKeyNot($event->subscription->getKey()) + ->whereNotIn('status', [Subscription::STATUS_CANCELLED, Subscription::STATUS_EXPIRED]) + ->whereNull('ends_at') + ->get(); + + foreach ($superseded as $subscription) { + try { + $subscription->cancel(); + } catch (Throwable $e) { + // The webhook must still succeed, or Lemon Squeezy retries it and the + // subscription row is created twice. The monthly one is then cancelled by + // hand; the report says which. + report($e); + } + } + } +} diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php index 843af2a..ab81f5b 100644 --- a/backend/app/Models/Workspace.php +++ b/backend/app/Models/Workspace.php @@ -4,6 +4,7 @@ use App\Enums\UsageType; use App\Enums\WorkspaceRole; +use App\Services\BillingPlans; use App\Services\InstanceService; use App\Traits\HasUlid; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,6 +15,8 @@ use Illuminate\Support\Facades\DB; use LemonSqueezy\Laravel\Billable; use LemonSqueezy\Laravel\Checkout; +use LemonSqueezy\Laravel\LemonSqueezy; +use LemonSqueezy\Laravel\Subscription; class Workspace extends Model { @@ -592,6 +595,76 @@ public function getCheckoutUrl(?string $redirectUrl = null): ?Checkout ->redirectTo($redirectUrl ?? route('dashboard')); } + /** + * Whether the owner can move this workspace from a monthly to a yearly subscription. + * + * A trial counts: the yearly checkout is paid straight away and the trial is cancelled. + */ + public function canSwitchToYearly(): bool + { + if (! $this->canSwitchInterval()) { + return false; + } + + $subscription = $this->liveSubscription(); + + return $subscription !== null + && $subscription->ends_at === null + && in_array($subscription->status, [Subscription::STATUS_ACTIVE, Subscription::STATUS_ON_TRIAL], true) + && ! BillingPlans::isYearly($subscription); + } + + /** + * A Lemon Squeezy checkout for a year of the current usage, at the current list price. + * + * Built by hand because the vendor's Checkout has no way to set a quantity. The payload + * otherwise mirrors it, including the custom data the webhook uses to find this workspace. + */ + public function getYearlySwitchCheckoutUrl(): string + { + $variant = BillingPlans::switchYearlyVariantId(); + + $response = LemonSqueezy::api('POST', 'checkouts', [ + 'data' => [ + 'type' => 'checkouts', + 'attributes' => [ + 'checkout_data' => array_filter([ + 'name' => (string) $this->lemonSqueezyName(), + 'email' => (string) $this->lemonSqueezyEmail(), + 'variant_quantities' => [ + ['variant_id' => (int) $variant, 'quantity' => max(1, $this->getTotalUsageCount())], + ], + 'custom' => [ + 'billable_id' => (string) $this->getKey(), + 'billable_type' => $this->getMorphClass(), + 'subscription_type' => Subscription::DEFAULT_TYPE, + ], + ], fn ($value) => $value !== ''), + 'product_options' => [ + 'redirect_url' => route('billing.thanks'), + ], + ], + 'relationships' => [ + 'store' => ['data' => ['type' => 'stores', 'id' => (string) config('lemon-squeezy.store')]], + 'variant' => ['data' => ['type' => 'variants', 'id' => $variant]], + ], + ], + ]); + + return $response['data']['attributes']['url']; + } + + /** + * The conditions both switch directions share. + */ + private function canSwitchInterval(): bool + { + return ! config('settings.is_self_hosted') + && ! $this->is_manually_billed + && ! $this->is_unlimited + && BillingPlans::canSwitch(); + } + /** * Whether this workspace can be sent to the Lemon Squeezy customer portal. * diff --git a/backend/routes/web.php b/backend/routes/web.php index 7e1b930..e8710ac 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -167,6 +167,7 @@ Route::post('/billing/checkout', [BillingController::class, 'checkout'])->name('billing.checkout'); Route::post('/billing/portal', [BillingController::class, 'portal'])->name('billing.portal'); + Route::post('/billing/switch-yearly', [BillingController::class, 'switchToYearly'])->name('billing.switch-yearly'); Route::get('/billing/thanks', [BillingController::class, 'thanks'])->name('billing.thanks'); Route::get('/account', [ProfileController::class, 'show'])->name('profile.show'); diff --git a/backend/tests/Feature/SwitchToYearlyTest.php b/backend/tests/Feature/SwitchToYearlyTest.php new file mode 100644 index 0000000..4b1db69 --- /dev/null +++ b/backend/tests/Feature/SwitchToYearlyTest.php @@ -0,0 +1,224 @@ + false, + 'settings.pro_switch_product_id' => '10', + 'settings.pro_switch_monthly_variant_id' => '11', + 'settings.pro_switch_yearly_variant_id' => '12', + 'settings.pro_yearly_variant_ids' => [], + 'lemon-squeezy.api_key' => 'test-key', + 'lemon-squeezy.store' => 'teststore', + ]); + + cache()->flush(); +}); + +/** + * @return array{0: Workspace, 1: User, 2: User} + */ +function switchingWorkspace(?string $variant = '1', string $status = 'active'): array +{ + $owner = User::factory()->active()->create(); + $workspace = Workspace::factory()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => WorkspaceRole::OWNER]); + + $member = User::factory()->active()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $member->id, 'role' => WorkspaceRole::MEMBER]); + + if ($variant !== null) { + addSubscription($workspace, 'sub_monthly', $variant, $status, now()->subMonth()); + } + + return [$workspace, $owner, $member]; +} + +function addSubscription(Workspace $workspace, string $id, string $variant, string $status = 'active', $createdAt = null, $endsAt = null): Subscription +{ + DB::table('lemon_squeezy_subscriptions')->insert([ + 'billable_id' => $workspace->id, + 'billable_type' => $workspace->getMorphClass(), + 'type' => 'default', + 'lemon_squeezy_id' => $id, + 'status' => $status, + 'product_id' => '1', + 'variant_id' => $variant, + 'ends_at' => $endsAt, + 'created_at' => $createdAt ?? now(), + 'updated_at' => now(), + ]); + + return Subscription::where('lemon_squeezy_id', $id)->first(); +} + +function fakeSwitchCheckout(): void +{ + Http::fake([ + '*lemonsqueezy.com/v1/checkouts*' => Http::response([ + 'data' => ['attributes' => ['url' => 'https://teststore.lemonsqueezy.com/checkout/yearly']], + ]), + ]); +} + +function cancelledAttributes(string $variant): array +{ + return ['data' => ['attributes' => [ + 'status' => 'cancelled', 'product_id' => 1, 'variant_id' => $variant, + 'renews_at' => null, 'ends_at' => now()->addWeeks(2)->toIso8601String(), 'trial_ends_at' => null, + ]]]; +} + +test('the owner is sent to a yearly checkout for the current usage', function () { + fakeSwitchCheckout(); + [$workspace, $owner] = switchingWorkspace(); + Display::factory()->count(3)->create(['workspace_id' => $workspace->id]); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-yearly')) + ->assertRedirect('https://teststore.lemonsqueezy.com/checkout/yearly'); + + // Creating the displays pushes usage on its own; only the checkout matters here. + Http::assertSent(function ($request) use ($workspace) { + if (! str_ends_with($request->url(), '/checkouts')) { + return false; + } + + $data = $request['data']; + + return $data['relationships']['variant']['data']['id'] === '12' + && $data['relationships']['store']['data']['id'] === 'teststore' + && $data['attributes']['checkout_data']['variant_quantities'] === [['variant_id' => 12, 'quantity' => 3]] + && $data['attributes']['checkout_data']['custom']['billable_id'] === (string) $workspace->id + && ! array_key_exists('discount_code', $data['attributes']['checkout_data']) + && ! isset($data['attributes']['custom_price']); + }); +}); + +test('a workspace without usage is still checked out for one unit', function () { + fakeSwitchCheckout(); + [$workspace, $owner] = switchingWorkspace(); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-yearly')); + + Http::assertSent(fn ($request) => $request['data']['attributes']['checkout_data']['variant_quantities'][0]['quantity'] === 1); +}); + +test('a member cannot switch to yearly', function () { + Http::fake(); + [$workspace, , $member] = switchingWorkspace(); + + $this->actingAs($member); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-yearly'))->assertForbidden(); + Http::assertNothingSent(); +}); + +test('a workspace that cannot switch is sent back without a checkout', function (Closure $arrange) { + Http::fake(); + [$workspace, $owner] = switchingWorkspace(); + $arrange($workspace); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-yearly')) + ->assertRedirect(route('workspaces.members')) + ->assertSessionHas('error'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/checkouts')); +})->with([ + 'already yearly' => [fn (Workspace $w) => Subscription::query()->update(['variant_id' => '12'])], + 'no subscription' => [fn (Workspace $w) => Subscription::query()->delete()], + 'cancelled' => [fn (Workspace $w) => Subscription::query()->update(['status' => 'cancelled', 'ends_at' => now()->addWeek()])], + 'manually billed' => [fn (Workspace $w) => $w->update(['is_manually_billed' => true])], + 'unlimited' => [fn (Workspace $w) => $w->update(['is_unlimited' => true])], + 'not configured' => [fn (Workspace $w) => config(['settings.pro_switch_yearly_variant_id' => null])], +]); + +test('a failing checkout leaves the owner on the page with an explanation', function () { + Http::fake(['*' => Http::response([], 500)]); + [$workspace, $owner] = switchingWorkspace(); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-yearly')) + ->assertRedirect(route('workspaces.members')) + ->assertSessionHas('error'); +}); + +test('a new yearly subscription cancels the monthly one it replaces', function () { + Http::fake(['*lemonsqueezy.com/v1/subscriptions/sub_monthly' => Http::response(cancelledAttributes('1'))]); + [$workspace] = switchingWorkspace(); + $yearly = addSubscription($workspace, 'sub_yearly', '12'); + + app(CancelSupersededSubscription::class)->handle(new SubscriptionCreated($workspace, $yearly, [])); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), 'subscriptions/sub_monthly')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'sub_yearly')); + expect(Subscription::where('lemon_squeezy_id', 'sub_monthly')->value('status'))->toBe('cancelled'); +}); + +test('subscriptions that are already cancelled or expired are left alone', function () { + Http::fake(); + [$workspace] = switchingWorkspace(status: 'cancelled'); + Subscription::where('lemon_squeezy_id', 'sub_monthly')->update(['ends_at' => now()->addWeek()]); + addSubscription($workspace, 'sub_old', '1', 'expired', now()->subYear(), now()->subMonth()); + $yearly = addSubscription($workspace, 'sub_yearly', '12'); + + app(CancelSupersededSubscription::class)->handle(new SubscriptionCreated($workspace, $yearly, [])); + + Http::assertNothingSent(); +}); + +test('a new monthly subscription cancels nothing', function () { + Http::fake(); + [$workspace] = switchingWorkspace(); + $monthly = addSubscription($workspace, 'sub_new', '1'); + + app(CancelSupersededSubscription::class)->handle(new SubscriptionCreated($workspace, $monthly, [])); + + Http::assertNothingSent(); +}); + +test('a refused cancellation is reported rather than thrown', function () { + Http::fake(['*' => Http::response([], 500)]); + [$workspace] = switchingWorkspace(); + $yearly = addSubscription($workspace, 'sub_yearly', '12'); + + app(CancelSupersededSubscription::class)->handle(new SubscriptionCreated($workspace, $yearly, [])); + + expect(Subscription::where('lemon_squeezy_id', 'sub_monthly')->value('status'))->toBe('active'); +}); + +test('the listener is registered for the webhook event', function () { + Event::fake(); + + Event::assertListening(SubscriptionCreated::class, CancelSupersededSubscription::class); +}); From a59b447fb61e3a6ee8e39a450202d6983538aec4 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:47:49 +0100 Subject: [PATCH 04/11] feat: Let a yearly workspace ask to go back to monthly Co-Authored-By: Claude Opus 5.5 --- .../Http/Controllers/BillingController.php | 37 ++++++ backend/app/Models/Workspace.php | 21 ++++ ...nthly_requested_at_to_workspaces_table.php | 24 ++++ backend/routes/web.php | 2 + backend/tests/Feature/SwitchToMonthlyTest.php | 115 ++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 backend/database/migrations/2026_11_01_000001_add_switch_to_monthly_requested_at_to_workspaces_table.php create mode 100644 backend/tests/Feature/SwitchToMonthlyTest.php diff --git a/backend/app/Http/Controllers/BillingController.php b/backend/app/Http/Controllers/BillingController.php index e99c466..54f40af 100644 --- a/backend/app/Http/Controllers/BillingController.php +++ b/backend/app/Http/Controllers/BillingController.php @@ -99,6 +99,43 @@ public function switchToYearly(Request $request): RedirectResponse } } + /** + * Ask for a yearly workspace to go back to monthly when the paid year ends. + * + * Only recorded here. app:process-yearly-subscriptions swaps the variant the day before + * renewal, so the year that is paid for runs out and the renewal bills a month. + */ + public function switchToMonthly(Request $request): RedirectResponse + { + $workspace = $request->user()->getSelectedWorkspace(); + + abort_unless($workspace !== null, 404, 'No workspace found'); + $this->authorize('manageBilling', $workspace); + + if (! $workspace->canSwitchToMonthly()) { + return redirect()->route('workspaces.members') + ->with('error', 'This workspace cannot switch to a monthly subscription right now. Email support@spacepad.io if you think it should.'); + } + + $workspace->forceFill(['switch_to_monthly_requested_at' => now()])->save(); + + return redirect()->route('workspaces.members') + ->with('success', 'Your subscription switches to monthly when your current year ends.'); + } + + public function cancelSwitchToMonthly(Request $request): RedirectResponse + { + $workspace = $request->user()->getSelectedWorkspace(); + + abort_unless($workspace !== null, 404, 'No workspace found'); + $this->authorize('manageBilling', $workspace); + + $workspace->forceFill(['switch_to_monthly_requested_at' => null])->save(); + + return redirect()->route('workspaces.members') + ->with('success', 'Your subscription stays yearly.'); + } + public function thanks(): RedirectResponse { GoogleTagManagerFacade::flashPush([ diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php index ab81f5b..d2dc545 100644 --- a/backend/app/Models/Workspace.php +++ b/backend/app/Models/Workspace.php @@ -34,6 +34,7 @@ class Workspace extends Model 'is_unlimited' => 'boolean', 'is_manually_billed' => 'boolean', 'manual_billing_unit_price' => 'decimal:2', + 'switch_to_monthly_requested_at' => 'datetime', ]; /** @@ -614,6 +615,26 @@ public function canSwitchToYearly(): bool && ! BillingPlans::isYearly($subscription); } + /** + * Whether the owner can ask to go from yearly back to monthly. + * + * The switch itself happens the day before the paid year ends; until then the request is + * only a flag, which is also what makes it possible to take it back. + */ + public function canSwitchToMonthly(): bool + { + if (! $this->canSwitchInterval()) { + return false; + } + + $subscription = $this->liveSubscription(); + + return $subscription !== null + && $subscription->ends_at === null + && in_array($subscription->status, [Subscription::STATUS_ACTIVE, Subscription::STATUS_PAST_DUE], true) + && BillingPlans::isYearly($subscription); + } + /** * A Lemon Squeezy checkout for a year of the current usage, at the current list price. * diff --git a/backend/database/migrations/2026_11_01_000001_add_switch_to_monthly_requested_at_to_workspaces_table.php b/backend/database/migrations/2026_11_01_000001_add_switch_to_monthly_requested_at_to_workspaces_table.php new file mode 100644 index 0000000..fa10a40 --- /dev/null +++ b/backend/database/migrations/2026_11_01_000001_add_switch_to_monthly_requested_at_to_workspaces_table.php @@ -0,0 +1,24 @@ +timestamp('switch_to_monthly_requested_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('workspaces', function (Blueprint $table) { + $table->dropColumn('switch_to_monthly_requested_at'); + }); + } +}; diff --git a/backend/routes/web.php b/backend/routes/web.php index e8710ac..fd3fc20 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -168,6 +168,8 @@ Route::post('/billing/checkout', [BillingController::class, 'checkout'])->name('billing.checkout'); Route::post('/billing/portal', [BillingController::class, 'portal'])->name('billing.portal'); Route::post('/billing/switch-yearly', [BillingController::class, 'switchToYearly'])->name('billing.switch-yearly'); + Route::post('/billing/switch-monthly', [BillingController::class, 'switchToMonthly'])->name('billing.switch-monthly'); + Route::delete('/billing/switch-monthly', [BillingController::class, 'cancelSwitchToMonthly'])->name('billing.switch-monthly.cancel'); Route::get('/billing/thanks', [BillingController::class, 'thanks'])->name('billing.thanks'); Route::get('/account', [ProfileController::class, 'show'])->name('profile.show'); diff --git a/backend/tests/Feature/SwitchToMonthlyTest.php b/backend/tests/Feature/SwitchToMonthlyTest.php new file mode 100644 index 0000000..50a690e --- /dev/null +++ b/backend/tests/Feature/SwitchToMonthlyTest.php @@ -0,0 +1,115 @@ + false, + 'settings.pro_switch_product_id' => '10', + 'settings.pro_switch_monthly_variant_id' => '11', + 'settings.pro_switch_yearly_variant_id' => '12', + 'settings.pro_yearly_variant_ids' => [], + 'lemon-squeezy.api_key' => 'test-key', + ]); + + Http::fake(); +}); + +/** + * @return array{0: Workspace, 1: User, 2: User} + */ +function yearlyWorkspace(string $variant = '12'): array +{ + $owner = User::factory()->active()->create(); + $workspace = Workspace::factory()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => WorkspaceRole::OWNER]); + + $member = User::factory()->active()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $member->id, 'role' => WorkspaceRole::MEMBER]); + + DB::table('lemon_squeezy_subscriptions')->insert([ + 'billable_id' => $workspace->id, + 'billable_type' => $workspace->getMorphClass(), + 'type' => 'default', + 'lemon_squeezy_id' => 'sub_yearly', + 'status' => 'active', + 'product_id' => '10', + 'variant_id' => $variant, + 'renews_at' => now()->addMonths(5), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [$workspace, $owner, $member]; +} + +test('the owner can ask to go back to monthly', function () { + [$workspace, $owner] = yearlyWorkspace(); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-monthly')) + ->assertRedirect(route('workspaces.members')) + ->assertSessionHas('success'); + + expect($workspace->fresh()->switch_to_monthly_requested_at)->not->toBeNull(); + Http::assertNothingSent(); +}); + +test('the owner can change their mind before the year ends', function () { + [$workspace, $owner] = yearlyWorkspace(); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()])->save(); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->delete(route('billing.switch-monthly.cancel')) + ->assertRedirect(route('workspaces.members')) + ->assertSessionHas('success'); + + expect($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull(); +}); + +test('a member can do neither', function () { + [$workspace, , $member] = yearlyWorkspace(); + + $this->actingAs($member); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-monthly'))->assertForbidden(); + $this->delete(route('billing.switch-monthly.cancel'))->assertForbidden(); +}); + +test('a monthly workspace cannot ask to go monthly', function () { + [$workspace, $owner] = yearlyWorkspace(variant: '11'); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-monthly')) + ->assertRedirect(route('workspaces.members')) + ->assertSessionHas('error'); + + expect($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull(); +}); + +test('a cancelled yearly subscription cannot be switched', function () { + [$workspace, $owner] = yearlyWorkspace(); + DB::table('lemon_squeezy_subscriptions')->update(['status' => 'cancelled', 'ends_at' => now()->addMonth()]); + + expect($workspace->fresh()->canSwitchToMonthly())->toBeFalse(); +}); From 3573607103073f4af2eb7c7d8e5da864d95ddc3d Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:49:11 +0100 Subject: [PATCH 05/11] feat: Bill yearly subscriptions monthly for extra usage and prepare renewals Co-Authored-By: Claude Opus 5.5 --- .../Commands/ProcessYearlySubscriptions.php | 191 +++++++++++++++ backend/routes/console.php | 9 + .../ProcessYearlySubscriptionsTest.php | 218 ++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 backend/app/Console/Commands/ProcessYearlySubscriptions.php create mode 100644 backend/tests/Feature/ProcessYearlySubscriptionsTest.php diff --git a/backend/app/Console/Commands/ProcessYearlySubscriptions.php b/backend/app/Console/Commands/ProcessYearlySubscriptions.php new file mode 100644 index 0000000..22da413 --- /dev/null +++ b/backend/app/Console/Commands/ProcessYearlySubscriptions.php @@ -0,0 +1,191 @@ +usage->hasApiKey()) { + $this->info('Skipping: self-hosted, or no Lemon Squeezy API key configured.'); + + return self::SUCCESS; + } + + $today = now()->startOfDay(); + $errors = 0; + $handled = 0; + + $this->workspaces()->chunkById(200, function ($workspaces) use ($today, &$errors, &$handled) { + foreach ($workspaces as $workspace) { + $subscription = $workspace->liveSubscription(); + + if (! $this->isProcessable($subscription)) { + continue; + } + + $renewal = $subscription->renews_at->copy()->startOfDay(); + + try { + if ($today->equalTo($renewal->copy()->subDay())) { + $this->prepareRenewal($workspace, $subscription); + $handled++; + } elseif ($this->isAnniversary($today, $renewal)) { + $this->invoiceExtraUsage($workspace, $subscription); + $handled++; + } + } catch (Throwable $e) { + $errors++; + report($e); + $this->error("Workspace {$workspace->id} (subscription {$subscription->lemon_squeezy_id}): {$e->getMessage()}"); + } + } + }); + + $this->info("Completed: {$handled} yearly subscriptions handled, {$errors} errors"); + + return $errors === 0 ? self::SUCCESS : self::FAILURE; + } + + private function isProcessable(?Subscription $subscription): bool + { + return $subscription !== null + && $subscription->renews_at !== null + && $subscription->ends_at === null + && in_array($subscription->status, [Subscription::STATUS_ACTIVE, Subscription::STATUS_PAST_DUE], true) + && BillingPlans::isYearly($subscription); + } + + /** + * The renewal's day of the month, or the last day of a month too short to have it. + * Never the renewal date itself: that day the renewal bills the whole year. + */ + private function isAnniversary(CarbonInterface $today, CarbonInterface $renewal): bool + { + return ! $today->equalTo($renewal) + && $today->day === min($renewal->day, $today->daysInMonth); + } + + private function invoiceExtraUsage(Workspace $workspace, Subscription $subscription): void + { + $itemId = $this->itemId($subscription); + $paid = $this->usage->getQuantity($itemId) ?? throw new RuntimeException('Could not read the paid quantity'); + $units = $this->units($workspace); + + if ($units <= $paid) { + $this->line("Workspace {$workspace->id}: {$units} units within the {$paid} paid for"); + + return; + } + + $this->line("Workspace {$workspace->id}: invoice {$units} units now (paid for {$paid})"); + + if ($this->option('dry-run')) { + return; + } + + if (! $this->usage->updateQuantity($itemId, $units, invoiceImmediately: true)) { + throw new RuntimeException('Lemon Squeezy refused the quantity update'); + } + } + + private function prepareRenewal(Workspace $workspace, Subscription $subscription): void + { + $units = $this->units($workspace); + + if ($workspace->switch_to_monthly_requested_at !== null) { + $this->line("Workspace {$workspace->id}: switch to monthly before renewal, {$units} units"); + + if ($this->option('dry-run')) { + return; + } + + // Throws on a refusal, which keeps the request in place for a retry. + $subscription->swap(BillingPlans::switchProductId(), BillingPlans::switchMonthlyVariantId(), [ + 'disable_prorations' => true, + ]); + + $workspace->forceFill(['switch_to_monthly_requested_at' => null])->save(); + + // The swap may have replaced the subscription item. + $this->usage->forgetSubscriptionItemId($subscription->lemon_squeezy_id); + $this->setRenewalQuantity($this->itemId($subscription), $units); + + return; + } + + $itemId = $this->itemId($subscription); + + if ($this->usage->getQuantity($itemId) === $units) { + return; + } + + $this->line("Workspace {$workspace->id}: renewal set to {$units} units"); + + if (! $this->option('dry-run')) { + $this->setRenewalQuantity($itemId, $units); + } + } + + private function setRenewalQuantity(string $itemId, int $units): void + { + if (! $this->usage->updateQuantity($itemId, $units, disableProrations: true)) { + throw new RuntimeException('Lemon Squeezy refused the renewal quantity'); + } + } + + private function itemId(Subscription $subscription): string + { + return $this->usage->resolveSubscriptionItemId($subscription->lemon_squeezy_id) + ?? throw new RuntimeException('No subscription item to bill against'); + } + + /** + * Billed for at least one unit, the same floor as the checkout. + */ + private function units(Workspace $workspace): int + { + return max(1, $workspace->getTotalUsageCount()); + } + + private function workspaces() + { + return Workspace::query() + ->where('is_unlimited', false) + ->where('is_manually_billed', false) + ->whereHas('subscriptions', fn ($query) => $query->whereNull('ends_at')) + ->with(['subscriptions' => fn ($query) => $query->orderByDesc('created_at')]); + } +} diff --git a/backend/routes/console.php b/backend/routes/console.php index 6d07e3a..8b6dbdf 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -2,6 +2,7 @@ use App\Console\Commands\CheckMarketingTriggers; use App\Console\Commands\CleanupExpiredEvents; +use App\Console\Commands\ProcessYearlySubscriptions; use App\Console\Commands\ReconcileWorkspaceUsage; use App\Console\Commands\RefreshAnalytics; use App\Console\Commands\RenewEventSubscriptions; @@ -42,6 +43,14 @@ ->appendOutputTo(storage_path('logs/lemonsqueezy-usage.log')) ->withoutOverlapping(10); // Release lock after 10 minutes +// Yearly subscriptions: extra usage invoiced on the monthly anniversary, and the quantity +// (and a requested switch to monthly) settled the day before renewal. +Schedule::command(ProcessYearlySubscriptions::class) + ->when(fn () => ! config('settings.is_self_hosted')) + ->dailyAt('04:10') + ->appendOutputTo(storage_path('logs/lemonsqueezy-usage.log')) + ->withoutOverlapping(30); + Schedule::command(CheckMarketingTriggers::class) ->when(fn () => ! config('settings.is_self_hosted')) ->hourly() diff --git a/backend/tests/Feature/ProcessYearlySubscriptionsTest.php b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php new file mode 100644 index 0000000..c9f359d --- /dev/null +++ b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php @@ -0,0 +1,218 @@ + false, + 'settings.pro_switch_product_id' => '10', + 'settings.pro_switch_monthly_variant_id' => '11', + 'settings.pro_switch_yearly_variant_id' => '12', + 'settings.pro_yearly_variant_ids' => [], + 'lemon-squeezy.api_key' => 'test-key', + ]); + + cache()->flush(); + Event::fake([WorkspaceUsageChanged::class]); +}); + +/** + * Lemon Squeezy as far as this command sees it: one item per subscription with a paid + * quantity, and a swap that answers with the subscription's new attributes. + */ +function fakeYearlyApi(int $paidQuantity = 3, int $status = 200, array $failFor = []): void +{ + Http::fake(function (Request $request) use ($paidQuantity, $status, $failFor) { + foreach ($failFor as $fragment) { + if (str_contains($request->url(), $fragment) && $request->method() !== 'GET') { + return Http::response([], 500); + } + } + + if ($request->method() === 'GET' && preg_match('#subscriptions/(sub_\w+)$#', $request->url(), $m)) { + return Http::response(['data' => ['attributes' => ['subscription_items' => [['id' => "item_{$m[1]}"]]]]]); + } + + if ($request->method() === 'GET' && str_contains($request->url(), 'subscription-items/')) { + return Http::response(['data' => ['attributes' => ['quantity' => $paidQuantity]]]); + } + + if ($request->method() === 'PATCH' && preg_match('#subscriptions/sub_\w+$#', $request->url())) { + return Http::response(['data' => ['attributes' => [ + 'status' => 'active', 'product_id' => 10, 'variant_id' => 11, + 'renews_at' => now()->addDay()->toIso8601String(), 'ends_at' => null, 'trial_ends_at' => null, + ]]], $status); + } + + return Http::response(['data' => []], $status); + }); +} + +function yearlyBillingWorkspace(string $renewsAt, int $displays = 3, string $variant = '12', string $id = 'sub_y'): Workspace +{ + $owner = User::factory()->active()->create(); + $workspace = Workspace::factory()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => WorkspaceRole::OWNER]); + Display::factory()->count($displays)->create(['workspace_id' => $workspace->id]); + + DB::table('lemon_squeezy_subscriptions')->insert([ + 'billable_id' => $workspace->id, + 'billable_type' => $workspace->getMorphClass(), + 'type' => 'default', + 'lemon_squeezy_id' => $id, + 'status' => 'active', + 'product_id' => '10', + 'variant_id' => $variant, + 'renews_at' => Carbon::parse($renewsAt), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $workspace; +} + +/** + * @return array> + */ +function quantityPatches(): array +{ + return collect(Http::recorded()) + ->map(fn ($pair) => $pair[0]) + ->filter(fn (Request $r) => $r->method() === 'PATCH' && str_contains($r->url(), 'subscription-items/')) + ->map(fn (Request $r) => $r['data']['attributes']) + ->values() + ->all(); +} + +test('extra usage is invoiced on the monthly anniversary', function () { + $this->travelTo(Carbon::parse('2026-12-21 04:10')); + fakeYearlyApi(paidQuantity: 3); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 4); + + Artisan::call('app:process-yearly-subscriptions'); + + expect(quantityPatches())->toBe([['quantity' => 4, 'invoice_immediately' => true]]); +}); + +test('lower usage on the anniversary changes nothing', function () { + $this->travelTo(Carbon::parse('2026-12-21 04:10')); + fakeYearlyApi(paidQuantity: 5); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 2); + + Artisan::call('app:process-yearly-subscriptions'); + + expect(quantityPatches())->toBe([]); +}); + +test('nothing happens on an ordinary day', function () { + $this->travelTo(Carbon::parse('2026-12-20 04:10')); + fakeYearlyApi(paidQuantity: 1); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 4); + + Artisan::call('app:process-yearly-subscriptions'); + + Http::assertNothingSent(); +}); + +test('a 31st anniversary falls on the last day of a shorter month', function () { + $this->travelTo(Carbon::parse('2026-11-30 04:10')); + fakeYearlyApi(paidQuantity: 1); + yearlyBillingWorkspace('2027-03-31 10:00', displays: 2); + + Artisan::call('app:process-yearly-subscriptions'); + + expect(quantityPatches())->toBe([['quantity' => 2, 'invoice_immediately' => true]]); +}); + +test('the day before renewal sets the quantity without proration', function () { + $this->travelTo(Carbon::parse('2027-10-20 04:10')); + fakeYearlyApi(paidQuantity: 6); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 2); + + Artisan::call('app:process-yearly-subscriptions'); + + expect(quantityPatches())->toBe([['quantity' => 2, 'disable_prorations' => true]]); +}); + +test('the renewal day itself is not an anniversary', function () { + // The renewal has just billed the year for the quantity set the day before. + $this->travelTo(Carbon::parse('2027-10-21 04:10')); + fakeYearlyApi(paidQuantity: 1); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 3); + + Artisan::call('app:process-yearly-subscriptions'); + + Http::assertNothingSent(); +}); + +test('a requested switch to monthly is made the day before renewal', function () { + $this->travelTo(Carbon::parse('2027-10-20 04:10')); + fakeYearlyApi(paidQuantity: 6); + $workspace = yearlyBillingWorkspace('2027-10-21 10:00', displays: 2); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()->subMonth()])->save(); + + Artisan::call('app:process-yearly-subscriptions'); + + Http::assertSent(fn (Request $r) => $r->method() === 'PATCH' + && str_ends_with($r->url(), 'subscriptions/sub_y') + && $r['data']['attributes']['product_id'] === '10' + && $r['data']['attributes']['variant_id'] === '11' + && $r['data']['attributes']['disable_prorations'] === true); + expect(quantityPatches())->toBe([['quantity' => 2, 'disable_prorations' => true]]) + ->and($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull() + ->and(DB::table('lemon_squeezy_subscriptions')->value('variant_id'))->toBe('11'); +}); + +test('a failed swap keeps the request and the next workspace is still processed', function () { + $this->travelTo(Carbon::parse('2027-10-20 04:10')); + fakeYearlyApi(paidQuantity: 1, failFor: ['subscriptions/sub_a']); + $failing = yearlyBillingWorkspace('2027-10-21 10:00', displays: 2, id: 'sub_a'); + $failing->forceFill(['switch_to_monthly_requested_at' => now()])->save(); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 4, id: 'sub_b'); + + $exitCode = Artisan::call('app:process-yearly-subscriptions'); + + expect($failing->fresh()->switch_to_monthly_requested_at)->not->toBeNull() + ->and(quantityPatches())->toBe([['quantity' => 4, 'disable_prorations' => true]]) + ->and($exitCode)->toBe(1); +}); + +test('monthly subscriptions are left to the usage push', function () { + $this->travelTo(Carbon::parse('2026-12-21 04:10')); + fakeYearlyApi(paidQuantity: 1); + yearlyBillingWorkspace('2026-12-22 10:00', displays: 4, variant: '11'); + + Artisan::call('app:process-yearly-subscriptions'); + + Http::assertNothingSent(); +}); + +test('a dry run calls nothing that changes billing', function () { + $this->travelTo(Carbon::parse('2026-12-21 04:10')); + fakeYearlyApi(paidQuantity: 1); + yearlyBillingWorkspace('2027-10-21 10:00', displays: 4); + + Artisan::call('app:process-yearly-subscriptions', ['--dry-run' => true]); + + Http::assertNotSent(fn (Request $r) => $r->method() !== 'GET'); + expect(Artisan::output())->toContain('invoice'); +}); From ad05529cb57e17cbf598ce5e70b2cffea7a43885 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:49:37 +0100 Subject: [PATCH 06/11] feat: Report yearly subscriptions whose quantity drifted from usage Co-Authored-By: Claude Opus 5.5 --- .../Commands/ReportYearlySubscriptions.php | 59 ++++++++++++++ .../Feature/ReportYearlySubscriptionsTest.php | 81 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 backend/app/Console/Commands/ReportYearlySubscriptions.php create mode 100644 backend/tests/Feature/ReportYearlySubscriptionsTest.php diff --git a/backend/app/Console/Commands/ReportYearlySubscriptions.php b/backend/app/Console/Commands/ReportYearlySubscriptions.php new file mode 100644 index 0000000..4ba6961 --- /dev/null +++ b/backend/app/Console/Commands/ReportYearlySubscriptions.php @@ -0,0 +1,59 @@ +whereHas('subscriptions', fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())) + ->with('subscriptions') + ->chunkById(200, function ($workspaces) use ($usage, &$rows) { + foreach ($workspaces as $workspace) { + $subscription = $workspace->liveSubscription(); + + if (! BillingPlans::isYearly($subscription)) { + continue; + } + + $itemId = $usage->resolveSubscriptionItemId($subscription->lemon_squeezy_id); + $quantity = $itemId ? $usage->getQuantity($itemId) : null; + $units = $workspace->getTotalUsageCount(); + + $rows[] = [ + $workspace->id, + $workspace->name, + $subscription->lemon_squeezy_id, + $units, + $quantity ?? '?', + $subscription->renews_at?->toDateString() ?? '-', + $quantity === $units ? '' : 'mismatch', + ]; + } + }); + + $this->table(['Workspace', 'Name', 'Subscription', 'Usage', 'LS quantity', 'Renews', ''], $rows); + $this->info(count($rows).' yearly subscriptions'); + + return self::SUCCESS; + } +} diff --git a/backend/tests/Feature/ReportYearlySubscriptionsTest.php b/backend/tests/Feature/ReportYearlySubscriptionsTest.php new file mode 100644 index 0000000..c6d3e58 --- /dev/null +++ b/backend/tests/Feature/ReportYearlySubscriptionsTest.php @@ -0,0 +1,81 @@ + false, + 'settings.pro_switch_yearly_variant_id' => '12', + 'settings.pro_yearly_variant_ids' => ['22'], + 'lemon-squeezy.api_key' => 'test-key', + ]); + + cache()->flush(); + Event::fake([WorkspaceUsageChanged::class]); + + Http::fake([ + '*lemonsqueezy.com/v1/subscriptions/*' => Http::response([ + 'data' => ['attributes' => ['subscription_items' => [['id' => 'item_1']]]], + ]), + '*lemonsqueezy.com/v1/subscription-items/*' => Http::response([ + 'data' => ['attributes' => ['quantity' => 5]], + ]), + ]); +}); + +function reportedWorkspace(string $name, string $variant, int $displays): Workspace +{ + $owner = User::factory()->active()->create(); + $workspace = Workspace::factory()->create(['name' => $name]); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => WorkspaceRole::OWNER]); + Display::factory()->count($displays)->create(['workspace_id' => $workspace->id]); + + DB::table('lemon_squeezy_subscriptions')->insert([ + 'billable_id' => $workspace->id, + 'billable_type' => $workspace->getMorphClass(), + 'type' => 'default', + 'lemon_squeezy_id' => 'sub_'.$name, + 'status' => 'active', + 'product_id' => '1', + 'variant_id' => $variant, + 'renews_at' => now()->addMonths(3), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $workspace; +} + +test('every yearly workspace is listed with usage, paid quantity and renewal', function () { + reportedWorkspace('Drifted', '22', 3); + reportedWorkspace('Matching', '12', 5); + reportedWorkspace('Monthly', '1', 2); + + Artisan::call('app:report-yearly-subscriptions'); + $output = Artisan::output(); + + expect($output)->toContain('Drifted') + ->and($output)->toContain('Matching') + ->and($output)->not->toContain('Monthly') + ->and($output)->toContain(now()->addMonths(3)->toDateString()) + ->and(substr_count($output, 'mismatch'))->toBe(1); + + Http::assertNotSent(fn (Request $r) => $r->method() !== 'GET'); +}); From 20a0781db77f5a0bd2940caa528a11a220fa16c6 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:50:45 +0100 Subject: [PATCH 07/11] feat: Switch between monthly and yearly from the workspace page Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 12 ++ .../Controllers/WorkspaceMemberController.php | 4 + .../views/pages/workspaces/members.blade.php | 46 ++++++++ .../Feature/BillingIntervalSwitchUiTest.php | 109 ++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 backend/tests/Feature/BillingIntervalSwitchUiTest.php diff --git a/FEATURES.md b/FEATURES.md index 2dff924..ca9bf64 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -234,6 +234,18 @@ a separate account with its own invisible set of displays. - One button on the workspace page opens the Lemon Squeezy billing portal in a new tab, to change the payment method, download invoices or cancel. A manually billed workspace has no portal and is pointed at support instead +- Owners and admins can switch a monthly subscription to yearly from the workspace page. That opens a + fresh checkout for a year of the current usage at the current price, paid on the spot (so the card + is checked, with 3D Secure, while the customer is there). Once it is paid the monthly subscription + is cancelled automatically; an abandoned checkout changes nothing +- A yearly subscription is not resized when usage changes. Once a month, on the day the year + started, usage above what was paid for is invoiced for the rest of the year. Less usage is never + credited. The day before renewal the subscription is set to the usage at that moment, so the new + year is billed for what is actually used +- A yearly workspace can ask to go back to monthly. The paid year runs out first; the day before + renewal it switches, so the renewal bills a month. Until then the request can be taken back +- `app:report-yearly-subscriptions` lists yearly subscriptions whose quantity at Lemon Squeezy + differs from usage, without changing anything - Besides a Lemon Squeezy subscription, Spacepad can put a workspace on one of two arrangements: **manually billed** (invoiced by us, at a per-workspace unit price) or **unlimited** (Pro for free, for a non-profit deal or a test account: never charged and no price shown). A workspace is on diff --git a/backend/app/Http/Controllers/WorkspaceMemberController.php b/backend/app/Http/Controllers/WorkspaceMemberController.php index 5a16dca..96c2296 100644 --- a/backend/app/Http/Controllers/WorkspaceMemberController.php +++ b/backend/app/Http/Controllers/WorkspaceMemberController.php @@ -54,6 +54,10 @@ public function index(): View // Resolved here so the page never asks Lemon Squeezy anything while rendering: // the portal url itself is fetched behind the POST route. 'canOpenBillingPortal' => $workspace->hasBillingPortal(), + // Monthly and yearly can be swapped from this page. Both are local checks; the + // checkout url is only fetched behind the POST route. + 'canSwitchToYearly' => $workspace->canSwitchToYearly(), + 'canSwitchToMonthly' => $workspace->canSwitchToMonthly(), ]); } diff --git a/backend/resources/views/pages/workspaces/members.blade.php b/backend/resources/views/pages/workspaces/members.blade.php index 44b9bfb..8f0da16 100644 --- a/backend/resources/views/pages/workspaces/members.blade.php +++ b/backend/resources/views/pages/workspaces/members.blade.php @@ -183,6 +183,52 @@ class="inline-flex items-center rounded-md bg-oxford px-3 py-2 text-sm font-semi @endif @endif + + {{-- Monthly or yearly. Moving to yearly is a fresh checkout, paid there and then; going + back to monthly is only recorded here and happens when the paid year ends. --}} + @can('manageBilling', $workspace) + @if($canSwitchToYearly || $canSwitchToMonthly) +
+ @if($canSwitchToYearly) +

+ Pay a year in advance for your current {{ $usageBreakdown['total'] }} units. Your monthly subscription stops immediately and switches to a yearly subscription. +

+
+ @csrf + +
+ @elseif($workspace->switch_to_monthly_requested_at) +

+ Your subscription switches to monthly on {{ $subscription?->renews_at?->format('j F Y') ?? 'the date your current year ends' }}. + Until then your yearly subscription carries on as it is. +

+
+ @csrf + @method('DELETE') + +
+ @else +

+ Your yearly subscription runs until {{ $subscription?->renews_at?->format('j F Y') ?? 'the end of the paid year' }}. + Switch to monthly and you pay per month from then on. +

+
+ @csrf + +
+ @endif +
+ @endif + @endcan {{-- Members --}} diff --git a/backend/tests/Feature/BillingIntervalSwitchUiTest.php b/backend/tests/Feature/BillingIntervalSwitchUiTest.php new file mode 100644 index 0000000..c685f5f --- /dev/null +++ b/backend/tests/Feature/BillingIntervalSwitchUiTest.php @@ -0,0 +1,109 @@ + false, + 'settings.pro_switch_product_id' => '10', + 'settings.pro_switch_monthly_variant_id' => '11', + 'settings.pro_switch_yearly_variant_id' => '12', + 'settings.pro_yearly_variant_ids' => [], + 'lemon-squeezy.api_key' => 'test-key', + ]); + + Http::fake(); + Event::fake([WorkspaceUsageChanged::class]); +}); + +/** + * @return array{0: Workspace, 1: User, 2: User} + */ +function intervalUiWorkspace(string $variant): array +{ + $owner = User::factory()->active()->create(); + $workspace = Workspace::factory()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => WorkspaceRole::OWNER]); + + $member = User::factory()->active()->create(); + WorkspaceMember::create(['workspace_id' => $workspace->id, 'user_id' => $member->id, 'role' => WorkspaceRole::MEMBER]); + + Display::factory()->count(3)->create(['workspace_id' => $workspace->id]); + + DB::table('lemon_squeezy_subscriptions')->insert([ + 'billable_id' => $workspace->id, + 'billable_type' => $workspace->getMorphClass(), + 'type' => 'default', + 'lemon_squeezy_id' => 'sub_ui', + 'status' => 'active', + 'product_id' => '10', + 'variant_id' => $variant, + 'renews_at' => '2027-10-21 10:00:00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [$workspace, $owner, $member]; +} + +function viewWorkspacePage(User $user, Workspace $workspace) +{ + test()->actingAs($user); + session()->put('selected_workspace_id', $workspace->id); + + return test()->get(route('workspaces.members')); +} + +test('the owner of a monthly workspace is offered yearly', function () { + [$workspace, $owner] = intervalUiWorkspace('11'); + + viewWorkspacePage($owner, $workspace) + ->assertOk() + ->assertSee('Switch to yearly') + ->assertSee('Pay a year in advance for your current 3 units. Your monthly subscription stops immediately and switches to a yearly subscription.') + ->assertDontSee('Switch to monthly') + ->assertDontSee('refund'); +}); + +test('the owner of a yearly workspace is offered monthly', function () { + [$workspace, $owner] = intervalUiWorkspace('12'); + + viewWorkspacePage($owner, $workspace) + ->assertOk() + ->assertSee('Switch to monthly') + ->assertDontSee('Switch to yearly'); +}); + +test('a requested switch shows its date and can be taken back', function () { + [$workspace, $owner] = intervalUiWorkspace('12'); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()])->save(); + + viewWorkspacePage($owner, $workspace) + ->assertOk() + ->assertSee('Your subscription switches to monthly on 21 October 2027.') + ->assertSee('Keep yearly') + ->assertDontSee('Switch to monthly'); +}); + +test('a member sees no switch', function () { + [$workspace, , $member] = intervalUiWorkspace('11'); + + viewWorkspacePage($member, $workspace) + ->assertOk() + ->assertDontSee('Switch to yearly') + ->assertDontSee('Switch to monthly'); +}); From b31863224873f8cfb1f959669c1e5286021e271b Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 1 Nov 2026 22:56:22 +0100 Subject: [PATCH 08/11] fix: Retry renewal prep and superseded cancellations, harden the switch checkout Co-Authored-By: Claude Opus 5.5 --- .../Commands/ProcessYearlySubscriptions.php | 33 +++++++++++-- .../Http/Controllers/BillingController.php | 7 ++- .../CancelSupersededSubscription.php | 23 +++++---- backend/app/Models/Workspace.php | 24 +++++++++- .../views/pages/workspaces/members.blade.php | 3 +- .../Feature/BillingIntervalSwitchUiTest.php | 9 ++++ .../ProcessYearlySubscriptionsTest.php | 34 +++++++++++++ backend/tests/Feature/SwitchToYearlyTest.php | 48 +++++++++++++++++++ 8 files changed, 166 insertions(+), 15 deletions(-) diff --git a/backend/app/Console/Commands/ProcessYearlySubscriptions.php b/backend/app/Console/Commands/ProcessYearlySubscriptions.php index 22da413..533e987 100644 --- a/backend/app/Console/Commands/ProcessYearlySubscriptions.php +++ b/backend/app/Console/Commands/ProcessYearlySubscriptions.php @@ -21,9 +21,10 @@ * paid quantity is invoiced straight away, prorated for the rest of the year. Lemon * Squeezy only bills on a quantity change, which is why the push must not raise it first. * Lower usage is never credited. - * - The day before renewal, the quantity is set to current usage without proration, so the - * renewal bills the new year for what is used then. A requested switch to monthly is - * made at the same moment, so the renewal bills a month instead of a year. + * - In the days before renewal, the quantity is set to current usage without proration, so + * the renewal bills the new year for what is used then. A requested switch to monthly is + * made in that window too, so the renewal bills a month instead of a year. + * - A monthly subscription the yearly one replaced but that is still running is cancelled. */ class ProcessYearlySubscriptions extends Command { @@ -31,6 +32,13 @@ class ProcessYearlySubscriptions extends Command protected $description = 'Invoice extra usage on yearly subscriptions and prepare their renewal'; + /** + * Days before renewal in which the renewal is prepared, every day. One day would leave a + * single chance: a refused swap or a day the scheduler did not run, and someone who asked + * for monthly is billed a full year. Setting the quantity again is harmless. + */ + private const RENEWAL_WINDOW_DAYS = 3; + public function __construct(private readonly LemonSqueezyUsageService $usage) { parent::__construct(); @@ -59,7 +67,9 @@ public function handle(): int $renewal = $subscription->renews_at->copy()->startOfDay(); try { - if ($today->equalTo($renewal->copy()->subDay())) { + $this->cancelSuperseded($workspace, $subscription); + + if ($today->gte($renewal->copy()->subDays(self::RENEWAL_WINDOW_DAYS)) && $today->lt($renewal)) { $this->prepareRenewal($workspace, $subscription); $handled++; } elseif ($this->isAnniversary($today, $renewal)) { @@ -79,6 +89,21 @@ public function handle(): int return $errors === 0 ? self::SUCCESS : self::FAILURE; } + /** + * Retry what CancelSupersededSubscription could not do when the yearly subscription + * arrived, so a monthly subscription never keeps renewing on top of a paid year. + */ + private function cancelSuperseded(Workspace $workspace, Subscription $yearly): void + { + foreach ($workspace->subscriptionsSupersededBy($yearly) as $superseded) { + $this->line("Workspace {$workspace->id}: cancel superseded subscription {$superseded->lemon_squeezy_id}"); + + if (! $this->option('dry-run')) { + $superseded->cancel(); + } + } + } + private function isProcessable(?Subscription $subscription): bool { return $subscription !== null diff --git a/backend/app/Http/Controllers/BillingController.php b/backend/app/Http/Controllers/BillingController.php index 54f40af..a33d3b4 100644 --- a/backend/app/Http/Controllers/BillingController.php +++ b/backend/app/Http/Controllers/BillingController.php @@ -136,8 +136,13 @@ public function cancelSwitchToMonthly(Request $request): RedirectResponse ->with('success', 'Your subscription stays yearly.'); } - public function thanks(): RedirectResponse + public function thanks(Request $request): RedirectResponse { + // An existing customer switching to yearly is not a new purchase for ad reporting. + if ($request->boolean('switched')) { + return redirect()->route('dashboard'); + } + GoogleTagManagerFacade::flashPush([ 'event' => 'purchase', ]); diff --git a/backend/app/Listeners/CancelSupersededSubscription.php b/backend/app/Listeners/CancelSupersededSubscription.php index 0473496..1a8c717 100644 --- a/backend/app/Listeners/CancelSupersededSubscription.php +++ b/backend/app/Listeners/CancelSupersededSubscription.php @@ -2,6 +2,7 @@ namespace App\Listeners; +use App\Models\Workspace; use App\Services\BillingPlans; use LemonSqueezy\Laravel\Events\SubscriptionCreated; use LemonSqueezy\Laravel\Subscription; @@ -26,19 +27,25 @@ public function handle(SubscriptionCreated $event): void return; } - $superseded = $event->billable->subscriptions() - ->whereKeyNot($event->subscription->getKey()) - ->whereNotIn('status', [Subscription::STATUS_CANCELLED, Subscription::STATUS_EXPIRED]) - ->whereNull('ends_at') - ->get(); + $workspace = $event->billable; + + if (! $workspace instanceof Workspace) { + return; + } + + // A request to go monthly belongs to the yearly subscription it was made on; a new + // one starts without it. + $workspace->forceFill(['switch_to_monthly_requested_at' => null])->save(); + + $superseded = $workspace->subscriptionsSupersededBy($event->subscription); foreach ($superseded as $subscription) { try { $subscription->cancel(); } catch (Throwable $e) { - // The webhook must still succeed, or Lemon Squeezy retries it and the - // subscription row is created twice. The monthly one is then cancelled by - // hand; the report says which. + // The webhook must still succeed: Lemon Squeezy would retry it and the + // subscription row cannot be created twice. app:process-yearly-subscriptions + // retries the cancellation every day until it goes through. report($e); } } diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php index d2dc545..c5072f4 100644 --- a/backend/app/Models/Workspace.php +++ b/backend/app/Models/Workspace.php @@ -7,6 +7,7 @@ use App\Services\BillingPlans; use App\Services\InstanceService; use App\Traits\HasUlid; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -661,9 +662,16 @@ public function getYearlySwitchCheckoutUrl(): string 'subscription_type' => Subscription::DEFAULT_TYPE, ], ], fn ($value) => $value !== ''), + // No discount field: a switch is always at the current list price. + 'checkout_options' => ['discount' => false], 'product_options' => [ - 'redirect_url' => route('billing.thanks'), + // Marked, so the thank-you page does not count an existing customer + // as a new conversion. + 'redirect_url' => route('billing.thanks', ['switched' => 1]), ], + // A link paid much later would add a second yearly subscription, and the + // year paid first would then be cancelled as superseded. + 'expires_at' => now()->addHour()->toIso8601String(), ], 'relationships' => [ 'store' => ['data' => ['type' => 'stores', 'id' => (string) config('lemon-squeezy.store')]], @@ -675,6 +683,20 @@ public function getYearlySwitchCheckoutUrl(): string return $response['data']['attributes']['url']; } + /** + * Subscriptions still running next to the given (yearly) one, which it replaces. + * + * @return Collection + */ + public function subscriptionsSupersededBy(Subscription $current) + { + return $this->subscriptions() + ->whereKeyNot($current->getKey()) + ->whereNotIn('status', [Subscription::STATUS_CANCELLED, Subscription::STATUS_EXPIRED]) + ->whereNull('ends_at') + ->get(); + } + /** * The conditions both switch directions share. */ diff --git a/backend/resources/views/pages/workspaces/members.blade.php b/backend/resources/views/pages/workspaces/members.blade.php index 8f0da16..52a2649 100644 --- a/backend/resources/views/pages/workspaces/members.blade.php +++ b/backend/resources/views/pages/workspaces/members.blade.php @@ -191,7 +191,8 @@ class="inline-flex items-center rounded-md bg-oxford px-3 py-2 text-sm font-semi
@if($canSwitchToYearly)

- Pay a year in advance for your current {{ $usageBreakdown['total'] }} units. Your monthly subscription stops immediately and switches to a yearly subscription. + @php $yearlyUnits = max(1, $usageBreakdown['total']) @endphp + Pay a year in advance for your current {{ $yearlyUnits }} {{ Str::plural('unit', $yearlyUnits) }}. Your monthly subscription stops immediately and switches to a yearly subscription.

@csrf diff --git a/backend/tests/Feature/BillingIntervalSwitchUiTest.php b/backend/tests/Feature/BillingIntervalSwitchUiTest.php index c685f5f..c702181 100644 --- a/backend/tests/Feature/BillingIntervalSwitchUiTest.php +++ b/backend/tests/Feature/BillingIntervalSwitchUiTest.php @@ -107,3 +107,12 @@ function viewWorkspacePage(User $user, Workspace $workspace) ->assertDontSee('Switch to yearly') ->assertDontSee('Switch to monthly'); }); + +test('the unit count in the copy matches what the checkout charges', function () { + [$workspace, $owner] = intervalUiWorkspace('11'); + DB::table('displays')->where('workspace_id', $workspace->id)->delete(); + $workspace->forceFill(['displays_count' => 0, 'boards_count' => 0])->save(); + + viewWorkspacePage($owner, $workspace) + ->assertSee('Pay a year in advance for your current 1 unit.'); +}); diff --git a/backend/tests/Feature/ProcessYearlySubscriptionsTest.php b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php index c9f359d..088a442 100644 --- a/backend/tests/Feature/ProcessYearlySubscriptionsTest.php +++ b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php @@ -216,3 +216,37 @@ function quantityPatches(): array Http::assertNotSent(fn (Request $r) => $r->method() !== 'GET'); expect(Artisan::output())->toContain('invoice'); }); + +test('a switch that failed the day before is retried while the renewal is still ahead', function () { + // Yesterday the swap was refused, or the scheduler did not run. One chance would mean a + // full year billed to someone who asked for monthly. + $this->travelTo(Carbon::parse('2027-10-19 04:10')); + fakeYearlyApi(paidQuantity: 6); + $workspace = yearlyBillingWorkspace('2027-10-21 10:00', displays: 2); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()->subMonth()])->save(); + + Artisan::call('app:process-yearly-subscriptions'); + + expect($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull(); +}); + +test('a monthly subscription that was not cancelled after the switch is cancelled now', function () { + $this->travelTo(Carbon::parse('2026-12-20 04:10')); + fakeYearlyApi(paidQuantity: 3); + $workspace = yearlyBillingWorkspace('2027-10-21 10:00', displays: 3); + DB::table('lemon_squeezy_subscriptions')->insert([ + 'billable_id' => $workspace->id, + 'billable_type' => $workspace->getMorphClass(), + 'type' => 'default', + 'lemon_squeezy_id' => 'sub_m', + 'status' => 'active', + 'product_id' => '1', + 'variant_id' => '1', + 'created_at' => now()->subYear(), + 'updated_at' => now(), + ]); + + Artisan::call('app:process-yearly-subscriptions'); + + Http::assertSent(fn (Request $r) => $r->method() === 'DELETE' && str_ends_with($r->url(), 'subscriptions/sub_m')); +}); diff --git a/backend/tests/Feature/SwitchToYearlyTest.php b/backend/tests/Feature/SwitchToYearlyTest.php index 4b1db69..6ba953d 100644 --- a/backend/tests/Feature/SwitchToYearlyTest.php +++ b/backend/tests/Feature/SwitchToYearlyTest.php @@ -222,3 +222,51 @@ function cancelledAttributes(string $variant): array Event::assertListening(SubscriptionCreated::class, CancelSupersededSubscription::class); }); + +test('the switch checkout expires, hides the discount field and is not counted as a new conversion', function () { + fakeSwitchCheckout(); + [$workspace, $owner] = switchingWorkspace(); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->post(route('billing.switch-yearly')); + + Http::assertSent(function ($request) { + if (! str_ends_with($request->url(), '/checkouts')) { + return false; + } + + $attributes = $request['data']['attributes']; + + // An old checkout link paid later would create a second yearly subscription, and + // the listener would then cancel the year already paid for. + return isset($attributes['expires_at']) + && now()->addHours(2)->greaterThan($attributes['expires_at']) + && $attributes['checkout_options']['discount'] === false + && $attributes['product_options']['redirect_url'] === route('billing.thanks', ['switched' => 1]); + }); +}); + +test('the thank-you page does not count a switch as a purchase', function () { + [$workspace, $owner] = switchingWorkspace(); + config(['services.google_conversion.send_to' => 'AW-1/x']); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->get(route('billing.thanks', ['switched' => 1]))->assertRedirect(route('dashboard')); + + expect(json_encode(session()->all()))->not->toContain('purchase'); +}); + +test('a new yearly subscription clears an old request to go monthly', function () { + Http::fake(['*' => Http::response(cancelledAttributes('1'))]); + [$workspace] = switchingWorkspace(); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()->subYear()])->save(); + $yearly = addSubscription($workspace, 'sub_yearly', '12'); + + app(CancelSupersededSubscription::class)->handle(new SubscriptionCreated($workspace, $yearly, [])); + + expect($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull(); +}); From cb32f1fe4862a0adbf740d04d05ce9396290114d Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Wed, 11 Nov 2026 15:55:01 +0100 Subject: [PATCH 09/11] feat: Show whether a workspace is billed monthly or yearly Co-Authored-By: Claude Opus 5.5 --- .../Controllers/WorkspaceMemberController.php | 25 +++++++++++++++++++ .../views/pages/workspaces/members.blade.php | 7 +++++- .../Feature/BillingIntervalSwitchUiTest.php | 22 ++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/backend/app/Http/Controllers/WorkspaceMemberController.php b/backend/app/Http/Controllers/WorkspaceMemberController.php index 96c2296..b3ccca5 100644 --- a/backend/app/Http/Controllers/WorkspaceMemberController.php +++ b/backend/app/Http/Controllers/WorkspaceMemberController.php @@ -4,7 +4,9 @@ use App\Enums\WorkspaceRole; use App\Models\User; +use App\Models\Workspace; use App\Models\WorkspaceMember; +use App\Services\BillingPlans; use App\Services\WorkspaceService; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; @@ -56,6 +58,7 @@ public function index(): View 'canOpenBillingPortal' => $workspace->hasBillingPortal(), // Monthly and yearly can be swapped from this page. Both are local checks; the // checkout url is only fetched behind the POST route. + 'billingIntervalLabel' => $this->billingIntervalLabel($workspace), 'canSwitchToYearly' => $workspace->canSwitchToYearly(), 'canSwitchToMonthly' => $workspace->canSwitchToMonthly(), ]); @@ -167,4 +170,26 @@ public function leave(Request $request): RedirectResponse return redirect()->route('dashboard')->with('success', "You have left {$workspace->name}."); } + + /** + * How the live Lemon Squeezy subscription is billed, in a few words for the card heading. + * Null when there is no such subscription: manual and unlimited workspaces explain + * themselves in the action bar already. + */ + private function billingIntervalLabel(Workspace $workspace): ?string + { + if (config('settings.is_self_hosted') || $workspace->is_manually_billed || $workspace->is_unlimited) { + return null; + } + + $subscription = $workspace->liveSubscription(); + + if ($subscription === null) { + return null; + } + + $interval = BillingPlans::isYearly($subscription) ? 'yearly' : 'monthly'; + + return $subscription->onTrial() ? "Trial, then billed {$interval}" : 'Billed '.$interval; + } } diff --git a/backend/resources/views/pages/workspaces/members.blade.php b/backend/resources/views/pages/workspaces/members.blade.php index 52a2649..2a838b7 100644 --- a/backend/resources/views/pages/workspaces/members.blade.php +++ b/backend/resources/views/pages/workspaces/members.blade.php @@ -55,7 +55,12 @@ class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow- }; @endphp -

Subscription

+
+

Subscription

+ @if($billingIntervalLabel) + {{ $billingIntervalLabel }} + @endif +

Usage for this workspace. Displays count once, boards count double. There is no charge per team member. diff --git a/backend/tests/Feature/BillingIntervalSwitchUiTest.php b/backend/tests/Feature/BillingIntervalSwitchUiTest.php index c702181..e04bc65 100644 --- a/backend/tests/Feature/BillingIntervalSwitchUiTest.php +++ b/backend/tests/Feature/BillingIntervalSwitchUiTest.php @@ -116,3 +116,25 @@ function viewWorkspacePage(User $user, Workspace $workspace) viewWorkspacePage($owner, $workspace) ->assertSee('Pay a year in advance for your current 1 unit.'); }); + +test('the subscription card says whether the workspace pays monthly or yearly', function (string $variant, string $status, string $label) { + [$workspace, $owner, $member] = intervalUiWorkspace($variant); + DB::table('lemon_squeezy_subscriptions')->update(['status' => $status, 'trial_ends_at' => $status === 'on_trial' ? now()->addWeek() : null]); + + viewWorkspacePage($owner, $workspace)->assertSee($label); + // Everyone can see how the workspace is billed, not only those who can change it. + viewWorkspacePage($member, $workspace)->assertSee($label); +})->with([ + 'monthly' => ['11', 'active', 'Billed monthly'], + 'yearly' => ['12', 'active', 'Billed yearly'], + 'trial on a monthly plan' => ['11', 'on_trial', 'Trial, then billed monthly'], +]); + +test('a workspace without a subscription shows no billing interval', function () { + [$workspace, $owner] = intervalUiWorkspace('11'); + DB::table('lemon_squeezy_subscriptions')->delete(); + + viewWorkspacePage($owner, $workspace) + ->assertDontSee('Billed monthly') + ->assertDontSee('Billed yearly'); +}); From 614435b8aff7921275f0d62af8bc65a87a9d8234 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Wed, 11 Nov 2026 15:59:53 +0100 Subject: [PATCH 10/11] feat: Confirm the switch to yearly on the dashboard Co-Authored-By: Claude Opus 5.5 --- backend/app/Http/Controllers/BillingController.php | 5 ++++- backend/tests/Feature/SwitchToYearlyTest.php | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/app/Http/Controllers/BillingController.php b/backend/app/Http/Controllers/BillingController.php index a33d3b4..4013f9d 100644 --- a/backend/app/Http/Controllers/BillingController.php +++ b/backend/app/Http/Controllers/BillingController.php @@ -139,8 +139,11 @@ public function cancelSwitchToMonthly(Request $request): RedirectResponse public function thanks(Request $request): RedirectResponse { // An existing customer switching to yearly is not a new purchase for ad reporting. + // Lemon Squeezy only sends the customer back after a successful payment. The webhook + // that cancels the monthly subscription arrives within seconds of it. if ($request->boolean('switched')) { - return redirect()->route('dashboard'); + return redirect()->route('dashboard') + ->with('success', 'Your yearly subscription is active. Your monthly subscription has been stopped and will not be charged again.'); } GoogleTagManagerFacade::flashPush([ diff --git a/backend/tests/Feature/SwitchToYearlyTest.php b/backend/tests/Feature/SwitchToYearlyTest.php index 6ba953d..910d647 100644 --- a/backend/tests/Feature/SwitchToYearlyTest.php +++ b/backend/tests/Feature/SwitchToYearlyTest.php @@ -270,3 +270,14 @@ function cancelledAttributes(string $variant): array expect($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull(); }); + +test('coming back from the switch checkout says yearly is active and monthly is stopped', function () { + [$workspace, $owner] = switchingWorkspace(); + + $this->actingAs($owner); + session()->put('selected_workspace_id', $workspace->id); + + $this->get(route('billing.thanks', ['switched' => 1])) + ->assertRedirect(route('dashboard')) + ->assertSessionHas('success', 'Your yearly subscription is active. Your monthly subscription has been stopped and will not be charged again.'); +}); From a9cac2ccf0f1c188fc6f2daff8015281c0f66290 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Wed, 11 Nov 2026 20:33:28 +0100 Subject: [PATCH 11/11] fix: Set the quantity before switching to monthly, only on the last paid day Co-Authored-By: Claude Opus 5.5 --- .../Commands/ProcessYearlySubscriptions.php | 60 ++++++++++--------- .../Http/Controllers/BillingController.php | 6 +- .../views/pages/workspaces/members.blade.php | 4 +- .../Feature/BillingIntervalSwitchUiTest.php | 3 +- .../ProcessYearlySubscriptionsTest.php | 22 +++++-- 5 files changed, 56 insertions(+), 39 deletions(-) diff --git a/backend/app/Console/Commands/ProcessYearlySubscriptions.php b/backend/app/Console/Commands/ProcessYearlySubscriptions.php index 533e987..138eb93 100644 --- a/backend/app/Console/Commands/ProcessYearlySubscriptions.php +++ b/backend/app/Console/Commands/ProcessYearlySubscriptions.php @@ -23,7 +23,7 @@ * Lower usage is never credited. * - In the days before renewal, the quantity is set to current usage without proration, so * the renewal bills the new year for what is used then. A requested switch to monthly is - * made in that window too, so the renewal bills a month instead of a year. + * made on the last day of the paid year: Lemon Squeezy starts the monthly cycle then. * - A monthly subscription the yearly one replaced but that is still running is cancelled. */ class ProcessYearlySubscriptions extends Command @@ -33,9 +33,9 @@ class ProcessYearlySubscriptions extends Command protected $description = 'Invoice extra usage on yearly subscriptions and prepare their renewal'; /** - * Days before renewal in which the renewal is prepared, every day. One day would leave a - * single chance: a refused swap or a day the scheduler did not run, and someone who asked - * for monthly is billed a full year. Setting the quantity again is harmless. + * Days before renewal in which the renewal quantity is set, every day, so a refused call + * or a day the scheduler did not run is made good the next day. Setting it again is + * harmless. A switch to monthly is only made on the last of these days. */ private const RENEWAL_WINDOW_DAYS = 3; @@ -70,7 +70,7 @@ public function handle(): int $this->cancelSuperseded($workspace, $subscription); if ($today->gte($renewal->copy()->subDays(self::RENEWAL_WINDOW_DAYS)) && $today->lt($renewal)) { - $this->prepareRenewal($workspace, $subscription); + $this->prepareRenewal($workspace, $subscription, $today->equalTo($renewal->copy()->subDay())); $handled++; } elseif ($this->isAnniversary($today, $renewal)) { $this->invoiceExtraUsage($workspace, $subscription); @@ -146,42 +146,48 @@ private function invoiceExtraUsage(Workspace $workspace, Subscription $subscript } } - private function prepareRenewal(Workspace $workspace, Subscription $subscription): void + /** + * Set the quantity the renewal bills for and, on the last day of the paid year, make a + * requested switch to monthly. + * + * Verified in test mode: changing the variant to monthly starts a monthly cycle on the + * spot and invoices it straight away, for the quantity the item has at that moment, even + * with disable_prorations. Hence the order (quantity first) and the timing (only the last + * day, so the customer pays at most one day twice). A refused swap is reported and left + * for us to finish by hand; the request stays in place. + */ + private function prepareRenewal(Workspace $workspace, Subscription $subscription, bool $isLastDay): void { $units = $this->units($workspace); + $itemId = $this->itemId($subscription); - if ($workspace->switch_to_monthly_requested_at !== null) { - $this->line("Workspace {$workspace->id}: switch to monthly before renewal, {$units} units"); + if ($this->usage->getQuantity($itemId) !== $units) { + $this->line("Workspace {$workspace->id}: renewal set to {$units} units"); - if ($this->option('dry-run')) { - return; + if (! $this->option('dry-run')) { + $this->setRenewalQuantity($itemId, $units); } + } - // Throws on a refusal, which keeps the request in place for a retry. - $subscription->swap(BillingPlans::switchProductId(), BillingPlans::switchMonthlyVariantId(), [ - 'disable_prorations' => true, - ]); - - $workspace->forceFill(['switch_to_monthly_requested_at' => null])->save(); - - // The swap may have replaced the subscription item. - $this->usage->forgetSubscriptionItemId($subscription->lemon_squeezy_id); - $this->setRenewalQuantity($this->itemId($subscription), $units); - + if ($workspace->switch_to_monthly_requested_at === null || ! $isLastDay) { return; } - $itemId = $this->itemId($subscription); + $this->line("Workspace {$workspace->id}: switch to monthly, {$units} units"); - if ($this->usage->getQuantity($itemId) === $units) { + if ($this->option('dry-run')) { return; } - $this->line("Workspace {$workspace->id}: renewal set to {$units} units"); + // Throws on a refusal, which keeps the request in place. + $subscription->swap(BillingPlans::switchProductId(), BillingPlans::switchMonthlyVariantId(), [ + 'disable_prorations' => true, + ]); - if (! $this->option('dry-run')) { - $this->setRenewalQuantity($itemId, $units); - } + $workspace->forceFill(['switch_to_monthly_requested_at' => null])->save(); + + // The swap may have replaced the subscription item. + $this->usage->forgetSubscriptionItemId($subscription->lemon_squeezy_id); } private function setRenewalQuantity(string $itemId, int $units): void diff --git a/backend/app/Http/Controllers/BillingController.php b/backend/app/Http/Controllers/BillingController.php index 4013f9d..82725b3 100644 --- a/backend/app/Http/Controllers/BillingController.php +++ b/backend/app/Http/Controllers/BillingController.php @@ -102,8 +102,8 @@ public function switchToYearly(Request $request): RedirectResponse /** * Ask for a yearly workspace to go back to monthly when the paid year ends. * - * Only recorded here. app:process-yearly-subscriptions swaps the variant the day before - * renewal, so the year that is paid for runs out and the renewal bills a month. + * Only recorded here. app:process-yearly-subscriptions swaps the variant on the last day + * of the paid year, which is when Lemon Squeezy starts and invoices the monthly cycle. */ public function switchToMonthly(Request $request): RedirectResponse { @@ -120,7 +120,7 @@ public function switchToMonthly(Request $request): RedirectResponse $workspace->forceFill(['switch_to_monthly_requested_at' => now()])->save(); return redirect()->route('workspaces.members') - ->with('success', 'Your subscription switches to monthly when your current year ends.'); + ->with('success', 'Your subscription switches to monthly on the last day of your paid year.'); } public function cancelSwitchToMonthly(Request $request): RedirectResponse diff --git a/backend/resources/views/pages/workspaces/members.blade.php b/backend/resources/views/pages/workspaces/members.blade.php index 2a838b7..30a62cd 100644 --- a/backend/resources/views/pages/workspaces/members.blade.php +++ b/backend/resources/views/pages/workspaces/members.blade.php @@ -208,7 +208,7 @@ class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow- @elseif($workspace->switch_to_monthly_requested_at)

- Your subscription switches to monthly on {{ $subscription?->renews_at?->format('j F Y') ?? 'the date your current year ends' }}. + Your subscription switches to monthly on {{ $subscription?->renews_at ? $subscription->renews_at->copy()->subDay()->format('j F Y').',' : '' }} the last day of your paid year. Until then your yearly subscription carries on as it is.

@@ -222,7 +222,7 @@ class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow- @else

Your yearly subscription runs until {{ $subscription?->renews_at?->format('j F Y') ?? 'the end of the paid year' }}. - Switch to monthly and you pay per month from then on. + Monthly billing starts on {{ $subscription?->renews_at ? $subscription->renews_at->copy()->subDay()->format('j F Y').',' : '' }} the last day of your paid year.

@csrf diff --git a/backend/tests/Feature/BillingIntervalSwitchUiTest.php b/backend/tests/Feature/BillingIntervalSwitchUiTest.php index e04bc65..a8948e0 100644 --- a/backend/tests/Feature/BillingIntervalSwitchUiTest.php +++ b/backend/tests/Feature/BillingIntervalSwitchUiTest.php @@ -85,6 +85,7 @@ function viewWorkspacePage(User $user, Workspace $workspace) viewWorkspacePage($owner, $workspace) ->assertOk() ->assertSee('Switch to monthly') + ->assertSee('Monthly billing starts on 20 October 2027, the last day of your paid year.') ->assertDontSee('Switch to yearly'); }); @@ -94,7 +95,7 @@ function viewWorkspacePage(User $user, Workspace $workspace) viewWorkspacePage($owner, $workspace) ->assertOk() - ->assertSee('Your subscription switches to monthly on 21 October 2027.') + ->assertSee('Your subscription switches to monthly on 20 October 2027, the last day of your paid year.') ->assertSee('Keep yearly') ->assertDontSee('Switch to monthly'); }); diff --git a/backend/tests/Feature/ProcessYearlySubscriptionsTest.php b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php index 088a442..acbe615 100644 --- a/backend/tests/Feature/ProcessYearlySubscriptionsTest.php +++ b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php @@ -164,7 +164,7 @@ function quantityPatches(): array Http::assertNothingSent(); }); -test('a requested switch to monthly is made the day before renewal', function () { +test('a requested switch to monthly is made the day before renewal, with the quantity set first', function () { $this->travelTo(Carbon::parse('2027-10-20 04:10')); fakeYearlyApi(paidQuantity: 6); $workspace = yearlyBillingWorkspace('2027-10-21 10:00', displays: 2); @@ -172,6 +172,14 @@ function quantityPatches(): array Artisan::call('app:process-yearly-subscriptions'); + // Lemon Squeezy invoices the first month the moment the variant changes, for the + // quantity it has then, so the quantity must already be the current usage. + $writes = collect(Http::recorded())->map(fn ($pair) => $pair[0]) + ->filter(fn (Request $r) => $r->method() === 'PATCH') + ->map(fn (Request $r) => str_contains($r->url(), 'subscription-items/') ? 'quantity' : 'swap') + ->values()->all(); + + expect($writes)->toBe(['quantity', 'swap']); Http::assertSent(fn (Request $r) => $r->method() === 'PATCH' && str_ends_with($r->url(), 'subscriptions/sub_y') && $r['data']['attributes']['product_id'] === '10' @@ -192,7 +200,7 @@ function quantityPatches(): array $exitCode = Artisan::call('app:process-yearly-subscriptions'); expect($failing->fresh()->switch_to_monthly_requested_at)->not->toBeNull() - ->and(quantityPatches())->toBe([['quantity' => 4, 'disable_prorations' => true]]) + ->and(quantityPatches())->toContain(['quantity' => 4, 'disable_prorations' => true]) ->and($exitCode)->toBe(1); }); @@ -217,9 +225,9 @@ function quantityPatches(): array expect(Artisan::output())->toContain('invoice'); }); -test('a switch that failed the day before is retried while the renewal is still ahead', function () { - // Yesterday the swap was refused, or the scheduler did not run. One chance would mean a - // full year billed to someone who asked for monthly. +test('the switch to monthly waits for the last day of the paid year', function () { + // The swap starts a monthly cycle and invoices it straight away, so made two days early + // the customer pays two days twice. Only the renewal quantity is set before then. $this->travelTo(Carbon::parse('2027-10-19 04:10')); fakeYearlyApi(paidQuantity: 6); $workspace = yearlyBillingWorkspace('2027-10-21 10:00', displays: 2); @@ -227,7 +235,9 @@ function quantityPatches(): array Artisan::call('app:process-yearly-subscriptions'); - expect($workspace->fresh()->switch_to_monthly_requested_at)->toBeNull(); + Http::assertNotSent(fn (Request $r) => $r->method() === 'PATCH' && str_contains($r->url(), 'subscriptions/sub_y')); + expect(quantityPatches())->toBe([['quantity' => 2, 'disable_prorations' => true]]) + ->and($workspace->fresh()->switch_to_monthly_requested_at)->not->toBeNull(); }); test('a monthly subscription that was not cancelled after the switch is cancelled now', function () {