feat(docker): run the Laravel scheduler in the production image (#732)
## 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.
This commit is contained in:
parent
0351dbfb38
commit
24fcdef098
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue