diff --git a/app/Features/SubscriptionExperiment.php b/app/Features/SubscriptionExperiment.php new file mode 100644 index 00000000..cc496db2 --- /dev/null +++ b/app/Features/SubscriptionExperiment.php @@ -0,0 +1,46 @@ +created_at?->lt(CarbonImmutable::parse($startedAt))) { + return self::LEGACY; + } + + return match (crc32((string) $user->getKey()) % 3) { + 0 => self::CONTROL, + 1 => self::REDUCED_TRIAL, + default => self::PAY_NOW, + }; + } +} diff --git a/app/Services/Subscriptions/ExperimentOffer.php b/app/Services/Subscriptions/ExperimentOffer.php new file mode 100644 index 00000000..14c3efa0 --- /dev/null +++ b/app/Services/Subscriptions/ExperimentOffer.php @@ -0,0 +1,88 @@ +value(SubscriptionExperiment::class); + } + + /** + * Trial days to apply at checkout for the given plan and the user's variant. + */ + public function trialDaysFor(User $user, string $planKey): int + { + return match ($this->variantFor($user)) { + SubscriptionExperiment::PAY_NOW => 0, + SubscriptionExperiment::REDUCED_TRIAL => (int) config( + "subscriptions.experiment.reduced_trial.{$planKey}", + config("subscriptions.plans.{$planKey}.trial_days", 0), + ), + default => (int) config("subscriptions.plans.{$planKey}.trial_days", 0), + }; + } + + public function refundWindowDays(): int + { + return (int) config('subscriptions.experiment.pay_now_refund_window_days', 3); + } + + /** + * The offer descriptor handed to the frontend so it can render the trial / + * money-back copy without re-deriving any experiment logic. + * + * @return array{variant: string, payNow: bool, refundWindowDays: int, trialDays: array} + */ + public function offerFor(User $user): array + { + $variant = $this->variantFor($user); + + return [ + 'variant' => $variant, + 'payNow' => $variant === SubscriptionExperiment::PAY_NOW, + 'refundWindowDays' => $this->refundWindowDays(), + 'trialDays' => [ + 'monthly' => $this->trialDaysFor($user, 'monthly'), + 'yearly' => $this->trialDaysFor($user, 'yearly'), + ], + ]; + } + + /** + * Whether the user can still self-refund: pay_now variant, an active + * subscription, inside the refund window, and not already refunded. + */ + public function canSelfRefund(User $user): bool + { + if ($this->variantFor($user) !== SubscriptionExperiment::PAY_NOW) { + return false; + } + + $subscription = $user->subscription('default'); + + if ($subscription === null || $subscription->refunded_at !== null || ! $subscription->active()) { + return false; + } + + return $this->refundDeadlineFor($subscription)->isFuture(); + } + + public function refundDeadlineFor(Subscription $subscription): CarbonImmutable + { + return CarbonImmutable::parse($subscription->created_at)->addDays($this->refundWindowDays()); + } +} diff --git a/config/subscriptions.php b/config/subscriptions.php index 8b375b1a..19337042 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -15,6 +15,33 @@ return [ 'enabled' => env('SUBSCRIPTIONS_ENABLED', false), + /* + |-------------------------------------------------------------------------- + | Trial / Pricing Experiment + |-------------------------------------------------------------------------- + | + | A/B/C test on how the paid plan is offered. Users who register on or after + | `started_at` are split evenly into three variants (control / reduced_trial + | / pay_now); everyone who registered earlier stays "legacy" and keeps the + | current trial. While `started_at` is null the experiment is off and every + | user behaves like the control group, so this is a no-op until activated. + | + | - control: the current trial (plans.*.trial_days, 15 days). + | - reduced_trial: a shorter trial (reduced_trial.* below). + | - pay_now: no trial, charged immediately, with a self-service refund + | window of `pay_now_refund_window_days` days. + | + */ + + 'experiment' => [ + 'started_at' => env('SUBSCRIPTION_EXPERIMENT_STARTED_AT'), + 'reduced_trial' => [ + 'monthly' => (int) env('SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY', 3), + 'yearly' => (int) env('SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_YEARLY', 7), + ], + 'pay_now_refund_window_days' => (int) env('SUBSCRIPTION_EXPERIMENT_REFUND_WINDOW_DAYS', 3), + ], + /* |-------------------------------------------------------------------------- | Stripe Product IDs diff --git a/tests/Feature/SubscriptionExperimentTest.php b/tests/Feature/SubscriptionExperimentTest.php new file mode 100644 index 00000000..deba5eab --- /dev/null +++ b/tests/Feature/SubscriptionExperimentTest.php @@ -0,0 +1,82 @@ + '2026-06-01', + 'subscriptions.experiment.reduced_trial.monthly' => 3, + 'subscriptions.experiment.reduced_trial.yearly' => 7, + 'subscriptions.experiment.pay_now_refund_window_days' => 3, + 'subscriptions.plans.monthly.trial_days' => 15, + 'subscriptions.plans.yearly.trial_days' => 15, + ]); +}); + +it('keeps users who registered before the experiment as legacy', function () { + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-20')]); + + expect(app(ExperimentOffer::class)->variantFor($user))->toBe(SubscriptionExperiment::LEGACY); +}); + +it('treats everyone as legacy while the experiment is off', function () { + config(['subscriptions.experiment.started_at' => null]); + + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + + 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 = []; + + for ($i = 0; $i < 60; $i++) { + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + $assigned = $offer->variantFor($user); + $variants[] = $assigned; + + Feature::flushCache(); + expect($offer->variantFor($user))->toBe($assigned); + } + + expect(array_unique($variants))->toEqualCanonicalizing([ + SubscriptionExperiment::CONTROL, + SubscriptionExperiment::REDUCED_TRIAL, + SubscriptionExperiment::PAY_NOW, + ]); +}); + +it('applies the trial days that match each variant', function () { + $offer = app(ExperimentOffer::class); + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::CONTROL); + expect($offer->trialDaysFor($user, 'monthly'))->toBe(15) + ->and($offer->trialDaysFor($user, 'yearly'))->toBe(15); + + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::REDUCED_TRIAL); + expect($offer->trialDaysFor($user, 'monthly'))->toBe(3) + ->and($offer->trialDaysFor($user, 'yearly'))->toBe(7); + + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW); + expect($offer->trialDaysFor($user, 'monthly'))->toBe(0) + ->and($offer->trialDaysFor($user, 'yearly'))->toBe(0); +}); + +it('describes a pay-now offer for the frontend', function () { + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW); + + $offer = app(ExperimentOffer::class)->offerFor($user); + + expect($offer['variant'])->toBe(SubscriptionExperiment::PAY_NOW) + ->and($offer['payNow'])->toBeTrue() + ->and($offer['refundWindowDays'])->toBe(3) + ->and($offer['trialDays']['monthly'])->toBe(0) + ->and($offer['trialDays']['yearly'])->toBe(0); +});