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/Console/Commands/ProcessYearlySubscriptions.php b/backend/app/Console/Commands/ProcessYearlySubscriptions.php new file mode 100644 index 0000000..138eb93 --- /dev/null +++ b/backend/app/Console/Commands/ProcessYearlySubscriptions.php @@ -0,0 +1,222 @@ +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 { + $this->cancelSuperseded($workspace, $subscription); + + if ($today->gte($renewal->copy()->subDays(self::RENEWAL_WINDOW_DAYS)) && $today->lt($renewal)) { + $this->prepareRenewal($workspace, $subscription, $today->equalTo($renewal->copy()->subDay())); + $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; + } + + /** + * 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 + && $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'); + } + } + + /** + * 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 ($this->usage->getQuantity($itemId) !== $units) { + $this->line("Workspace {$workspace->id}: renewal set to {$units} units"); + + if (! $this->option('dry-run')) { + $this->setRenewalQuantity($itemId, $units); + } + } + + if ($workspace->switch_to_monthly_requested_at === null || ! $isLastDay) { + return; + } + + $this->line("Workspace {$workspace->id}: switch to monthly, {$units} units"); + + if ($this->option('dry-run')) { + return; + } + + // Throws on a refusal, which keeps the request in place. + $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); + } + + 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/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/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/Http/Controllers/BillingController.php b/backend/app/Http/Controllers/BillingController.php index 8747fa9..82725b3 100644 --- a/backend/app/Http/Controllers/BillingController.php +++ b/backend/app/Http/Controllers/BillingController.php @@ -68,8 +68,84 @@ public function portal(Request $request): RedirectResponse } } - public function thanks(): 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.'); + } + } + + /** + * 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 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 { + $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 on the last day of your paid year.'); + } + + 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(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') + ->with('success', 'Your yearly subscription is active. Your monthly subscription has been stopped and will not be charged again.'); + } + GoogleTagManagerFacade::flashPush([ 'event' => 'purchase', ]); diff --git a/backend/app/Http/Controllers/WorkspaceMemberController.php b/backend/app/Http/Controllers/WorkspaceMemberController.php index 5a16dca..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; @@ -54,6 +56,11 @@ 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. + 'billingIntervalLabel' => $this->billingIntervalLabel($workspace), + 'canSwitchToYearly' => $workspace->canSwitchToYearly(), + 'canSwitchToMonthly' => $workspace->canSwitchToMonthly(), ]); } @@ -163,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/app/Listeners/CancelSupersededSubscription.php b/backend/app/Listeners/CancelSupersededSubscription.php new file mode 100644 index 0000000..1a8c717 --- /dev/null +++ b/backend/app/Listeners/CancelSupersededSubscription.php @@ -0,0 +1,53 @@ +subscription)) { + return; + } + + $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: 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/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/Models/Workspace.php b/backend/app/Models/Workspace.php index 843af2a..c5072f4 100644 --- a/backend/app/Models/Workspace.php +++ b/backend/app/Models/Workspace.php @@ -4,8 +4,10 @@ use App\Enums\UsageType; use App\Enums\WorkspaceRole; +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; @@ -14,6 +16,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 { @@ -31,6 +35,7 @@ class Workspace extends Model 'is_unlimited' => 'boolean', 'is_manually_billed' => 'boolean', 'manual_billing_unit_price' => 'decimal:2', + 'switch_to_monthly_requested_at' => 'datetime', ]; /** @@ -592,6 +597,117 @@ 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); + } + + /** + * 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. + * + * 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 !== ''), + // No discount field: a switch is always at the current list price. + 'checkout_options' => ['discount' => false], + 'product_options' => [ + // 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')]], + 'variant' => ['data' => ['type' => 'variants', 'id' => $variant]], + ], + ], + ]); + + 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. + */ + 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/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/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/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/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/resources/views/pages/workspaces/members.blade.php b/backend/resources/views/pages/workspaces/members.blade.php index 44b9bfb..30a62cd 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. @@ -183,6 +188,53 @@ 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) +

+ @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 + +
+ @elseif($workspace->switch_to_monthly_requested_at) +

+ 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. +

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

+ Your yearly subscription runs until {{ $subscription?->renews_at?->format('j F Y') ?? 'the end of the paid year' }}. + Monthly billing starts on {{ $subscription?->renews_at ? $subscription->renews_at->copy()->subDay()->format('j F Y').',' : '' }} the last day of your paid year. +

+
+ @csrf + +
+ @endif +
+ @endif + @endcan {{-- Members --}} 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/routes/web.php b/backend/routes/web.php index 7e1b930..fd3fc20 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -167,6 +167,9 @@ 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/BillingIntervalSwitchUiTest.php b/backend/tests/Feature/BillingIntervalSwitchUiTest.php new file mode 100644 index 0000000..a8948e0 --- /dev/null +++ b/backend/tests/Feature/BillingIntervalSwitchUiTest.php @@ -0,0 +1,141 @@ + 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') + ->assertSee('Monthly billing starts on 20 October 2027, the last day of your paid year.') + ->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 20 October 2027, the last day of your paid year.') + ->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'); +}); + +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.'); +}); + +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'); +}); 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'); +}); 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/ProcessYearlySubscriptionsTest.php b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php new file mode 100644 index 0000000..acbe615 --- /dev/null +++ b/backend/tests/Feature/ProcessYearlySubscriptionsTest.php @@ -0,0 +1,262 @@ + 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, 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); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()->subMonth()])->save(); + + 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' + && $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())->toContain(['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'); +}); + +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); + $workspace->forceFill(['switch_to_monthly_requested_at' => now()->subMonth()])->save(); + + Artisan::call('app:process-yearly-subscriptions'); + + 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 () { + $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/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'); +}); 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(); +}); diff --git a/backend/tests/Feature/SwitchToYearlyTest.php b/backend/tests/Feature/SwitchToYearlyTest.php new file mode 100644 index 0000000..910d647 --- /dev/null +++ b/backend/tests/Feature/SwitchToYearlyTest.php @@ -0,0 +1,283 @@ + 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); +}); + +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(); +}); + +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.'); +}); 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(); +});