diff --git a/app/Features/SubscriptionExperiment.php b/app/Features/SubscriptionExperiment.php index a1410af3..029ad20b 100644 --- a/app/Features/SubscriptionExperiment.php +++ b/app/Features/SubscriptionExperiment.php @@ -13,9 +13,8 @@ use Carbon\CarbonImmutable; * registered on or after the start is split evenly into the three variants by a * stable hash of their id, so the bucket never changes for a given user. * - * The split is deterministic (crc32(id) % 3); the funnel report mirrors the same - * formula in SQL. Manual overrides via `feature:enable` are honoured at runtime - * but are not reflected in that report — keep overrides to QA accounts. + * The split is deterministic (crc32(id) % 3) and persisted by Pennant; the funnel + * report reads that persisted value so it always matches what the user was served. * * @api */ @@ -29,6 +28,21 @@ class SubscriptionExperiment public const PAY_NOW = 'pay_now'; + /** + * In-memory override that pins every user to the winning variant once the + * experiment is decided. Returning non-null skips both storage and resolve, + * so flipping SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT rolls the winner out to + * everyone without a deploy and without rewriting stored assignments. + */ + public function before(?User $user): ?string + { + $forced = config('subscriptions.experiment.force_variant'); + + return in_array($forced, [self::CONTROL, self::REDUCED_TRIAL, self::PAY_NOW], true) + ? $forced + : null; + } + public function resolve(?User $user): string { $startedAt = config('subscriptions.experiment.started_at'); diff --git a/config/subscriptions.php b/config/subscriptions.php index 19337042..bdaf4005 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -35,6 +35,9 @@ return [ 'experiment' => [ 'started_at' => env('SUBSCRIPTION_EXPERIMENT_STARTED_AT'), + // Once a winner is chosen, set this to control / reduced_trial / pay_now + // to give every user that variant and end the split (env-only, no deploy). + 'force_variant' => env('SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT'), 'reduced_trial' => [ 'monthly' => (int) env('SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY', 3), 'yearly' => (int) env('SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_YEARLY', 7), diff --git a/tests/Feature/SubscriptionExperimentTest.php b/tests/Feature/SubscriptionExperimentTest.php index cf3ebaf7..3626587c 100644 --- a/tests/Feature/SubscriptionExperimentTest.php +++ b/tests/Feature/SubscriptionExperimentTest.php @@ -36,6 +36,26 @@ it('treats everyone as legacy while the experiment is off', function () { expect(app(ExperimentOffer::class)->variantFor($user))->toBe(SubscriptionExperiment::LEGACY); }); +it('pins every user to the forced winner variant', function () { + config(['subscriptions.experiment.force_variant' => SubscriptionExperiment::PAY_NOW]); + $offer = app(ExperimentOffer::class); + + $legacy = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-01')]); + $fresh = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + + expect($offer->variantFor($legacy))->toBe(SubscriptionExperiment::PAY_NOW) + ->and($offer->variantFor($fresh))->toBe(SubscriptionExperiment::PAY_NOW) + ->and($offer->trialDaysFor($fresh, 'monthly'))->toBe(0); +}); + +it('ignores an invalid forced variant', function () { + config(['subscriptions.experiment.force_variant' => 'bogus']); + + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-01')]); + + expect(app(ExperimentOffer::class)->variantFor($user))->toBe(SubscriptionExperiment::LEGACY); +}); + it('splits post-start users across all three variants and stays stable per user', function () { $offer = app(ExperimentOffer::class); $variants = [];