feat(subscriptions): assign trial/pricing experiment variants
Add the A/B/C experiment foundation: a Pennant feature that buckets users who register on or after subscriptions.experiment.started_at evenly into control / reduced_trial / pay_now via a stable hash, leaving earlier users "legacy". The ExperimentOffer service turns a variant into the concrete offer (trial days per plan, pay-now flag, refund window, self-refund eligibility) for the checkout, paywall and billing screens to share. While started_at is null the experiment is off and everyone behaves like control, so this is inert until activated via env.
This commit is contained in:
parent
756b4816a6
commit
f6d032c0ec
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* A/B/C assignment for the trial/pricing experiment.
|
||||
*
|
||||
* Users who registered before `subscriptions.experiment.started_at` (or any user
|
||||
* while it is null) are "legacy" and behave like the control group. Everyone who
|
||||
* 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.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class SubscriptionExperiment
|
||||
{
|
||||
public const LEGACY = 'legacy';
|
||||
|
||||
public const CONTROL = 'control';
|
||||
|
||||
public const REDUCED_TRIAL = 'reduced_trial';
|
||||
|
||||
public const PAY_NOW = 'pay_now';
|
||||
|
||||
public function resolve(User $user): string
|
||||
{
|
||||
$startedAt = config('subscriptions.experiment.started_at');
|
||||
|
||||
if ($startedAt === null || $user->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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Subscriptions;
|
||||
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Laravel\Cashier\Subscription;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
/**
|
||||
* Translates a user's experiment variant into the concrete offer they get:
|
||||
* how many trial days per plan, whether they pay immediately, and whether they
|
||||
* can still trigger the self-service refund. Single source of truth shared by
|
||||
* the checkout, the paywall and the billing settings screen.
|
||||
*/
|
||||
class ExperimentOffer
|
||||
{
|
||||
public function variantFor(User $user): string
|
||||
{
|
||||
return Feature::for($user)->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<string, int>}
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Models\User;
|
||||
use App\Services\Subscriptions\ExperimentOffer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'subscriptions.experiment.started_at' => '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);
|
||||
});
|
||||
Loading…
Reference in New Issue