From 1f6df54d8c641b50e83538b50fdd5afad0c42fc3 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 30 Jun 2026 01:07:21 +0000 Subject: [PATCH] Add Frontdesk Pro wallet renewal scheduling. Monthly auto-renew charges due organizations from the Ladill wallet and downgrades after grace when payment fails. Co-authored-by: Cursor --- .../Commands/RenewProSubscriptionsCommand.php | 22 +++++ .../Controllers/Frontdesk/ProController.php | 6 +- app/Services/Frontdesk/ProRenewalService.php | 89 +++++++++++++++++++ config/frontdesk.php | 5 ++ routes/console.php | 1 + tests/Feature/FrontdeskProTest.php | 22 +++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/RenewProSubscriptionsCommand.php create mode 100644 app/Services/Frontdesk/ProRenewalService.php diff --git a/app/Console/Commands/RenewProSubscriptionsCommand.php b/app/Console/Commands/RenewProSubscriptionsCommand.php new file mode 100644 index 0000000..e87d0f3 --- /dev/null +++ b/app/Console/Commands/RenewProSubscriptionsCommand.php @@ -0,0 +1,22 @@ +dueOrganizations(); + $this->info("Renewing {$due->count()} due organization(s)."); + $due->each(fn ($organization) => $renewals->renewIfDue($organization)); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Frontdesk/ProController.php b/app/Http/Controllers/Frontdesk/ProController.php index 4d2d8cb..9eedf72 100644 --- a/app/Http/Controllers/Frontdesk/ProController.php +++ b/app/Http/Controllers/Frontdesk/ProController.php @@ -80,7 +80,11 @@ class ProController extends Controller $settings = $organization->settings ?? []; $settings['plan'] = 'pro'; - $settings['plan_expires_at'] = now()->addMonth()->toIso8601String(); + $settings['auto_renew'] = true; + $settings['plan_expires_at'] = now() + ->addDays((int) config('frontdesk.pro.period_days', 30)) + ->toIso8601String(); + unset($settings['plan_renewal_error']); $organization->update(['settings' => $settings]); return redirect()->route('frontdesk.pro.index') diff --git a/app/Services/Frontdesk/ProRenewalService.php b/app/Services/Frontdesk/ProRenewalService.php new file mode 100644 index 0000000..ce326fe --- /dev/null +++ b/app/Services/Frontdesk/ProRenewalService.php @@ -0,0 +1,89 @@ + */ + public function dueOrganizations(): Collection + { + return Organization::query() + ->where('settings->plan', 'pro') + ->whereNotNull('settings->plan_expires_at') + ->get() + ->filter(fn (Organization $organization) => $this->isDue($organization)); + } + + public function isDue(Organization $organization): bool + { + $settings = $organization->settings ?? []; + if (($settings['plan'] ?? '') !== 'pro') { + return false; + } + if (array_key_exists('auto_renew', $settings) && $settings['auto_renew'] === false) { + return false; + } + if (empty($settings['plan_expires_at'])) { + return false; + } + + return Carbon::parse($settings['plan_expires_at'])->lte(now()); + } + + public function renewIfDue(Organization $organization): void + { + if (! $this->isDue($organization)) { + return; + } + + $settings = $organization->settings ?? []; + $price = $this->plans->proPriceMinor(); + $reference = 'frontdesk-pro-'.$organization->id.'-'.now()->format('YmdHis'); + + try { + $charged = $this->billing->debit( + $organization->owner_ref, + $price, + 'frontdesk_pro_renewal', + $reference, + 'Ladill Frontdesk Pro — monthly renewal', + ); + } catch (\Throwable) { + $charged = false; + } + + if ($charged) { + $settings['plan'] = 'pro'; + $settings['auto_renew'] = true; + $settings['plan_expires_at'] = now() + ->addDays((int) config('frontdesk.pro.period_days', 30)) + ->toIso8601String(); + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); + + return; + } + + $expires = Carbon::parse($settings['plan_expires_at']); + $graceEnd = $expires->copy()->addDays((int) config('frontdesk.pro.grace_days', 3)); + if ($graceEnd->isPast()) { + $settings['plan'] = 'free'; + unset($settings['plan_expires_at']); + $settings['plan_renewal_error'] = 'Suspended after failed renewal.'; + } else { + $settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.'; + } + + $organization->update(['settings' => $settings]); + } +} diff --git a/config/frontdesk.php b/config/frontdesk.php index 0eedd69..55b55dc 100644 --- a/config/frontdesk.php +++ b/config/frontdesk.php @@ -120,6 +120,11 @@ return [ 'sms_segment_length' => (int) env('FRONTDESK_SMS_SEGMENT_LENGTH', 160), ], + 'pro' => [ + 'grace_days' => (int) env('FRONTDESK_PRO_GRACE_DAYS', 3), + 'period_days' => (int) env('FRONTDESK_PRO_PERIOD_DAYS', 30), + ], + 'plans' => [ 'free' => [ 'label' => 'Free', diff --git a/routes/console.php b/routes/console.php index 732ce81..2475c56 100644 --- a/routes/console.php +++ b/routes/console.php @@ -13,3 +13,4 @@ Schedule::command('frontdesk:mark-expired-badges')->everyFifteenMinutes(); Schedule::command('frontdesk:mark-devices-offline')->everyFiveMinutes(); Schedule::command('frontdesk:employee-return-alerts')->everyFiveMinutes(); Schedule::command('frontdesk:send-daily-reports')->dailyAt('07:00'); +Schedule::command('frontdesk:pro-renew')->dailyAt('02:15')->withoutOverlapping(); diff --git a/tests/Feature/FrontdeskProTest.php b/tests/Feature/FrontdeskProTest.php index 46295f4..67a5cbb 100644 --- a/tests/Feature/FrontdeskProTest.php +++ b/tests/Feature/FrontdeskProTest.php @@ -86,6 +86,28 @@ class FrontdeskProTest extends TestCase $this->assertSame('pro', $this->organization->fresh()->settings['plan']); } + public function test_pro_renew_extends_subscription_when_wallet_charges(): void + { + $this->organization->update([ + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'auto_renew' => true, + 'plan_expires_at' => now()->subDay()->toIso8601String(), + ], + ]); + + Http::fake([ + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->artisan('frontdesk:pro-renew')->assertSuccessful(); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('pro', $settings['plan']); + $this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture()); + } + public function test_pro_sidebar_shows_active_label_after_upgrade(): void { $this->organization->update([