From 24fcdef09816664cb7c8de2cbb7978403d05dee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Mej=C3=ADas=20Leiva?= Date: Fri, 24 Jul 2026 13:30:33 +0200 Subject: [PATCH] feat(docker): run the Laravel scheduler in the production image (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The production image runs the queue workers via supervisor but **nothing runs the Laravel scheduler** (there is no `schedule:work` program and no cron running `schedule:run`). As a result, none of the commands in `routes/console.php` ever fire automatically — most notably `banking:sync` (every 6h), so self-hosted users have to trigger bank synchronization by hand. The manual "sync" button works because it dispatches the same job to the queue, which the existing workers pick up; only the scheduled trigger was missing. ## Changes - **`docker/supervisor/supervisord.conf`**: add a `[program:scheduler]` running `php artisan schedule:work`, mirroring the existing worker programs (autostart/autorestart, dedicated log). This is what makes every scheduled command fire on its own cadence. - **Gate the operator-only stats reports** (`stats:daily-report`, `stats:subscription-funnel`, `stats:ai-cohort-report`, `stats:experiment-funnel`) behind `config('subscriptions.enabled')` with an early return. These are subscription-conversion telemetry destined for the admin Discord; on a self-hosted instance (`subscriptions.enabled=false`) `stats:daily-report` would otherwise make an unnecessary live Stripe API call, and the others would build reports for an unconfigured Discord channel. On the SaaS (subscriptions enabled) behavior is unchanged. The guard lives **inside each command** (not in `routes/console.php`) so it stays unit-testable. `banking:cancel-free-enablebanking` was left alone (already a safe no-op via `hasProPlan()` when subscriptions are off), as were the drip-email commands (already gated by `mail.drip_emails_enabled`). ## Testing - `vendor/bin/pint --test` passes. - New Pest test per gated command asserting it skips and makes no external call when subscriptions are disabled; existing tests updated to enable subscriptions so they still exercise the real path. - Full suite green except 5 unrelated pre-existing environment failures (timezone alias, dashboard 409, Passport OAuth key) that fail identically on `main`. - Verified end-to-end with a real supervisor run: the `scheduler` program reaches `RUNNING`, `schedule:work` boots, and autorestart resurrects it; `php artisan schedule:test --name=banking:sync` dispatches successfully. > Takes effect after rebuilding the production image. --- app/Console/Commands/SendAiCohortReportCommand.php | 6 ++++++ .../Commands/SendDailyStatsReportCommand.php | 6 ++++++ .../Commands/SendExperimentFunnelReportCommand.php | 6 ++++++ .../SendSubscriptionFunnelReportCommand.php | 6 ++++++ docker/supervisor/supervisord.conf | 11 +++++++++++ tests/Feature/SendAiCohortReportCommandTest.php | 13 +++++++++++++ tests/Feature/SendDailyStatsReportCommandTest.php | 13 +++++++++++++ .../SendExperimentFunnelReportCommandTest.php | 12 ++++++++++++ .../SendSubscriptionFunnelReportCommandTest.php | 13 +++++++++++++ 9 files changed, 86 insertions(+) diff --git a/app/Console/Commands/SendAiCohortReportCommand.php b/app/Console/Commands/SendAiCohortReportCommand.php index dda26f0a..2d28911f 100644 --- a/app/Console/Commands/SendAiCohortReportCommand.php +++ b/app/Console/Commands/SendAiCohortReportCommand.php @@ -23,6 +23,12 @@ class SendAiCohortReportCommand extends Command public function handle(): int { + if (! config('subscriptions.enabled')) { + $this->info('Subscriptions are disabled; skipping the AI cohort report.'); + + return self::SUCCESS; + } + $weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null; $report = $this->collector->collect($weeks); diff --git a/app/Console/Commands/SendDailyStatsReportCommand.php b/app/Console/Commands/SendDailyStatsReportCommand.php index 64b2e545..ad284618 100644 --- a/app/Console/Commands/SendDailyStatsReportCommand.php +++ b/app/Console/Commands/SendDailyStatsReportCommand.php @@ -30,6 +30,12 @@ class SendDailyStatsReportCommand extends Command public function handle(): int { + if (! config('subscriptions.enabled')) { + $this->info('Subscriptions are disabled; skipping the daily stats report.'); + + return self::SUCCESS; + } + try { $stats = $this->collector->collect(); } catch (ApiErrorException $exception) { diff --git a/app/Console/Commands/SendExperimentFunnelReportCommand.php b/app/Console/Commands/SendExperimentFunnelReportCommand.php index 56f56b2f..37859c29 100644 --- a/app/Console/Commands/SendExperimentFunnelReportCommand.php +++ b/app/Console/Commands/SendExperimentFunnelReportCommand.php @@ -34,6 +34,12 @@ class SendExperimentFunnelReportCommand extends Command public function handle(): int { + if (! config('subscriptions.enabled')) { + $this->info('Subscriptions are disabled; skipping the experiment funnel report.'); + + return self::SUCCESS; + } + $costPerConnectionCents = (int) round(((float) $this->option('cost-per-connection')) * 100); $report = $this->collector->collect($costPerConnectionCents); diff --git a/app/Console/Commands/SendSubscriptionFunnelReportCommand.php b/app/Console/Commands/SendSubscriptionFunnelReportCommand.php index e8541d9b..bf91525a 100644 --- a/app/Console/Commands/SendSubscriptionFunnelReportCommand.php +++ b/app/Console/Commands/SendSubscriptionFunnelReportCommand.php @@ -19,6 +19,12 @@ class SendSubscriptionFunnelReportCommand extends Command public function handle(): int { + if (! config('subscriptions.enabled')) { + $this->info('Subscriptions are disabled; skipping the subscription funnel report.'); + + return self::SUCCESS; + } + $weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null; $report = $this->collector->collect($weeks); diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index 9f7bd766..7ff9b7e1 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -87,3 +87,14 @@ redirect_stderr=true stdout_logfile=/var/log/queue-worker-ai.log stopwaitsecs=3600 numprocs=2 + +[program:scheduler] +process_name=%(program_name)s +command=php /app/artisan schedule:work +user=www-data +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/scheduler.log +stopwaitsecs=60 +numprocs=1 diff --git a/tests/Feature/SendAiCohortReportCommandTest.php b/tests/Feature/SendAiCohortReportCommandTest.php index 4f97cde4..73b0baf3 100644 --- a/tests/Feature/SendAiCohortReportCommandTest.php +++ b/tests/Feature/SendAiCohortReportCommandTest.php @@ -76,10 +76,23 @@ function rowForWeek(array $report, CarbonImmutable $signup): array beforeEach(function () { Carbon::setTestNow(referenceNow()); + config(['subscriptions.enabled' => true]); config(['ai_suggestions.eligibility_min_transactions' => 3]); config(['ai_suggestions.report.excluded_emails' => []]); }); +it('skips the report and hits no external service when subscriptions are disabled', function () { + config(['subscriptions.enabled' => false]); + + Http::fake(); + + artisan('stats:ai-cohort-report') + ->expectsOutputToContain('Subscriptions are disabled; skipping the AI cohort report.') + ->assertSuccessful(); + + Http::assertNothingSent(); +}); + it('only counts users who imported enough transactions within their first week', function () { $signup = referenceNow()->subWeeks(6); diff --git a/tests/Feature/SendDailyStatsReportCommandTest.php b/tests/Feature/SendDailyStatsReportCommandTest.php index 953720c6..37f13280 100644 --- a/tests/Feature/SendDailyStatsReportCommandTest.php +++ b/tests/Feature/SendDailyStatsReportCommandTest.php @@ -5,9 +5,22 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; beforeEach(function () { + config()->set('subscriptions.enabled', true); config()->set('services.discord.webhook_url', 'https://discord.test/webhook'); }); +test('skips the report and hits no external service when subscriptions are disabled', function () { + config()->set('subscriptions.enabled', false); + + Http::fake(); + + $this->artisan('stats:daily-report') + ->expectsOutputToContain('Subscriptions are disabled; skipping the daily stats report.') + ->assertSuccessful(); + + Http::assertNothingSent(); +}); + test('posts yesterday user counts and stripe stats to discord', function () { Http::fake(); bindMockStripeClientForStats([ diff --git a/tests/Feature/SendExperimentFunnelReportCommandTest.php b/tests/Feature/SendExperimentFunnelReportCommandTest.php index b5ea26c9..58dd1e00 100644 --- a/tests/Feature/SendExperimentFunnelReportCommandTest.php +++ b/tests/Feature/SendExperimentFunnelReportCommandTest.php @@ -31,6 +31,18 @@ beforeEach(function () { Cache::put('experiment_funnel_monthly_equiv', ['price_test' => 399], now()->addHour()); }); +it('skips the report and hits no external service when subscriptions are disabled', function () { + config(['subscriptions.enabled' => false]); + + Http::fake(); + + artisan('stats:experiment-funnel') + ->expectsOutputToContain('Subscriptions are disabled; skipping the experiment funnel report.') + ->assertSuccessful(); + + Http::assertNothingSent(); +}); + /** * Create a user whose id buckets into the wanted variant, anchored to a signup, * with an optional default subscription and any bank connections / AI consent diff --git a/tests/Feature/SendSubscriptionFunnelReportCommandTest.php b/tests/Feature/SendSubscriptionFunnelReportCommandTest.php index 4e001cd1..a11f8654 100644 --- a/tests/Feature/SendSubscriptionFunnelReportCommandTest.php +++ b/tests/Feature/SendSubscriptionFunnelReportCommandTest.php @@ -59,9 +59,22 @@ function funnelRow(array $report, CarbonImmutable $signup): array beforeEach(function () { Carbon::setTestNow(funnelNow()); + config(['subscriptions.enabled' => true]); config(['ai_suggestions.report.excluded_emails' => []]); }); +it('skips the report and hits no external service when subscriptions are disabled', function () { + config(['subscriptions.enabled' => false]); + + Http::fake(); + + artisan('stats:subscription-funnel') + ->expectsOutputToContain('Subscriptions are disabled; skipping the subscription funnel report.') + ->assertSuccessful(); + + Http::assertNothingSent(); +}); + it('counts registrations, subscriptions and paid conversions per signup week', function () { $signup = funnelNow()->subWeeks(10); // old enough to be paid-mature