From 44c368120b05116af6d0f99b7ed6521dd367bcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 18 Jul 2026 18:34:26 +0200 Subject: [PATCH] feat(subscriptions): add A/B price experiment assignment and per-variant pricing Introduce a Pennant PriceExperiment feature (control vs a higher price tier), salted independently of the trial experiment. The assigned variant's prices and Stripe lookup keys flow through ExperimentOffer into the shared pricing prop (paywall + upgrade dialogs) and into checkout server-side, so the price shown always equals the price charged. stripe:sync-prices now also creates the variant tiers. Inert until PRICE_EXPERIMENT_STARTED_AT is set. --- .../Commands/SyncStripePricesCommand.php | 29 ++- app/Features/PriceExperiment.php | 66 +++++++ .../Controllers/SubscriptionController.php | 5 +- app/Http/Middleware/HandleInertiaRequests.php | 10 +- .../Subscriptions/ExperimentOffer.php | 53 +++++- config/subscriptions.php | 37 ++++ tests/Feature/PriceExperimentTest.php | 169 ++++++++++++++++++ tests/Feature/SyncStripePricesCommandTest.php | 24 +++ 8 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 app/Features/PriceExperiment.php create mode 100644 tests/Feature/PriceExperimentTest.php diff --git a/app/Console/Commands/SyncStripePricesCommand.php b/app/Console/Commands/SyncStripePricesCommand.php index ce34f821..0d63df35 100644 --- a/app/Console/Commands/SyncStripePricesCommand.php +++ b/app/Console/Commands/SyncStripePricesCommand.php @@ -17,7 +17,7 @@ class SyncStripePricesCommand extends Command public function handle(): int { - $plans = config('subscriptions.plans', []); + $plans = $this->plansToSync(); $currency = config('cashier.currency', 'eur'); $dryRun = $this->option('dry-run'); @@ -103,6 +103,33 @@ class SyncStripePricesCommand extends Command return Command::SUCCESS; } + /** + * The base plans plus the price-experiment variant tiers, flattened into one + * list of pseudo-plans so each gets its own Stripe price + lookup key. The + * variant billing period and name are inherited from the matching base plan. + * + * @return array> + */ + private function plansToSync(): array + { + $plans = (array) config('subscriptions.plans', []); + + foreach ((array) config('subscriptions.price_experiment.variants', []) as $variantKey => $variantPlans) { + foreach ((array) $variantPlans as $planKey => $spec) { + $base = $plans[$planKey] ?? []; + + $plans["price_experiment.{$variantKey}.{$planKey}"] = [ + 'name' => ($base['name'] ?? ucfirst((string) $planKey))." (price: {$variantKey})", + 'price' => $spec['price'], + 'billing_period' => $base['billing_period'] ?? ($planKey === 'yearly' ? 'year' : 'month'), + 'stripe_lookup_key' => $spec['lookup'] ?? null, + ]; + } + } + + return $plans; + } + /** * @return Price|null */ diff --git a/app/Features/PriceExperiment.php b/app/Features/PriceExperiment.php new file mode 100644 index 00000000..94dbe935 --- /dev/null +++ b/app/Features/PriceExperiment.php @@ -0,0 +1,66 @@ +created_at?->lt(CarbonImmutable::parse($startedAt))) { + return self::LEGACY; + } + + return self::bucket((string) $user->getKey()); + } + + /** + * Deterministic, evenly-split bucket for a post-start user. The 'price:' salt + * decouples this split from every other crc32-based experiment on the same id + * (e.g. SubscriptionExperiment), so the two arms are independent. Keep this the + * single source of truth — the funnel report mirrors it to attribute users + * without reading Pennant per row. + */ + public static function bucket(string $key): string + { + return crc32('price:'.$key) % 2 === 0 ? self::CONTROL : self::HIGH; + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index fbddaac5..4806b24a 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -71,7 +71,10 @@ class SubscriptionController extends Controller abort(400, 'Invalid plan selected'); } - $priceId = $this->resolvePriceIdByLookupKey($plan['stripe_lookup_key']); + // The price tier is resolved server-side from the user's assigned variant, + // never from the request, so a user can't pick the cheaper price. + $lookupKey = $this->experimentOffer->lookupKeyFor($request->user(), $planKey); + $priceId = $this->resolvePriceIdByLookupKey($lookupKey); $subscriptionBuilder = $request->user() ->newSubscription('default', $priceId); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index d39085cd..e827dee4 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -9,6 +9,7 @@ use App\Features\Mcp; use App\Jobs\PurgeResidualEncryptionArtifactsJob; use App\Models\BankingConnection; use App\Services\CurrencyOptions; +use App\Services\Subscriptions\ExperimentOffer; use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; use Inertia\Middleware; @@ -16,7 +17,10 @@ use Laravel\Pennant\Feature; class HandleInertiaRequests extends Middleware { - public function __construct(private CurrencyOptions $currencyOptions) {} + public function __construct( + private CurrencyOptions $currencyOptions, + private ExperimentOffer $experimentOffer, + ) {} /** * The root template that's loaded on the first page visit. @@ -95,7 +99,9 @@ class HandleInertiaRequests extends Middleware 'subscriptionsEnabled' => config('subscriptions.enabled', false), 'aiCategorizationUpsellRate' => (int) config('ai_categorization.upsell_sample_rate'), 'pricing' => [ - 'plans' => config('subscriptions.plans', []), + 'plans' => $user + ? $this->experimentOffer->pricingPlansFor($user) + : config('subscriptions.plans', []), 'defaultPlan' => config('subscriptions.default_plan', 'monthly'), 'bestValuePlan' => config('subscriptions.best_value_plan', null), 'promo' => config('subscriptions.promo', []), diff --git a/app/Services/Subscriptions/ExperimentOffer.php b/app/Services/Subscriptions/ExperimentOffer.php index 1110c8d3..383305c1 100644 --- a/app/Services/Subscriptions/ExperimentOffer.php +++ b/app/Services/Subscriptions/ExperimentOffer.php @@ -2,6 +2,7 @@ namespace App\Services\Subscriptions; +use App\Features\PriceExperiment; use App\Features\SubscriptionExperiment; use App\Models\User; use Carbon\CarbonImmutable; @@ -9,10 +10,11 @@ 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. + * Translates a user's experiment variants into the concrete offer they get: the + * plan prices (price experiment), 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, so a user is always shown exactly the price and terms they are charged. */ class ExperimentOffer { @@ -21,6 +23,49 @@ class ExperimentOffer return Feature::for($user)->value(SubscriptionExperiment::class); } + public function priceVariantFor(User $user): string + { + return Feature::for($user)->value(PriceExperiment::class); + } + + /** + * The plans config with the user's price-experiment variant applied: price, + * original_price and Stripe lookup key swapped for the assigned tier. Control + * and legacy users get the unchanged config. Drives both the paywall display + * and the checkout charge, so the shown price always equals the charged one. + * + * @return array> + */ + public function pricingPlansFor(User $user): array + { + $plans = (array) config('subscriptions.plans', []); + $overrides = (array) config('subscriptions.price_experiment.variants.'.$this->priceVariantFor($user), []); + + foreach ($overrides as $planKey => $override) { + if (! isset($plans[$planKey])) { + continue; + } + + $plans[$planKey]['price'] = $override['price']; + $plans[$planKey]['original_price'] = $override['original_price'] ?? null; + $plans[$planKey]['stripe_lookup_key'] = $override['lookup']; + } + + return $plans; + } + + /** + * Stripe lookup key to charge for the given plan and the user's price variant. + * Falls back to the control plan's key for control/legacy users. + */ + public function lookupKeyFor(User $user, string $planKey): string + { + return (string) config( + "subscriptions.price_experiment.variants.{$this->priceVariantFor($user)}.{$planKey}.lookup", + config("subscriptions.plans.{$planKey}.stripe_lookup_key"), + ); + } + /** * Trial days to apply at checkout for the given plan and the user's variant. */ diff --git a/config/subscriptions.php b/config/subscriptions.php index bdaf4005..ffda6575 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -45,6 +45,43 @@ return [ 'pay_now_refund_window_days' => (int) env('SUBSCRIPTION_EXPERIMENT_REFUND_WINDOW_DAYS', 3), ], + /* + |-------------------------------------------------------------------------- + | Price Experiment + |-------------------------------------------------------------------------- + | + | A/B test on the *price* of the paid plan, independent of the trial + | experiment above. Users who register on or after `started_at` are split + | evenly into `control` (the plans.* prices) and `high` (the variant prices + | below); earlier users stay "legacy" and keep the control price. While + | `started_at` is null the experiment is off and everyone gets control, so + | this is a no-op until activated. Each variant needs its own Stripe prices — + | run `php artisan stripe:sync-prices` before setting `started_at`. The yearly + | price is the monthly × 6, matching the control plan structure. + | + */ + + 'price_experiment' => [ + 'started_at' => env('PRICE_EXPERIMENT_STARTED_AT'), + // Once a winner is chosen, set this to control / high to give every user + // that price and end the split (env-only, no deploy). + 'force_variant' => env('PRICE_EXPERIMENT_FORCE_VARIANT'), + 'variants' => [ + 'high' => [ + 'monthly' => [ + 'price' => (float) env('PRICE_EXPERIMENT_HIGH_MONTHLY', 8.99), + 'original_price' => null, + 'lookup' => env('PRICE_EXPERIMENT_HIGH_MONTHLY_LOOKUP_KEY', 'whisper_pro_monthly_v9'), + ], + 'yearly' => [ + 'price' => (float) env('PRICE_EXPERIMENT_HIGH_YEARLY', 53.94), + 'original_price' => (float) env('PRICE_EXPERIMENT_HIGH_YEARLY_ORIGINAL', 107.88), + 'lookup' => env('PRICE_EXPERIMENT_HIGH_YEARLY_LOOKUP_KEY', 'whisper_pro_yearly_v9'), + ], + ], + ], + ], + /* |-------------------------------------------------------------------------- | Stripe Product IDs diff --git a/tests/Feature/PriceExperimentTest.php b/tests/Feature/PriceExperimentTest.php new file mode 100644 index 00000000..30ffcb34 --- /dev/null +++ b/tests/Feature/PriceExperimentTest.php @@ -0,0 +1,169 @@ + true, + 'subscriptions.price_experiment.started_at' => '2026-06-01', + 'subscriptions.price_experiment.force_variant' => null, + 'subscriptions.price_experiment.variants.high' => [ + 'monthly' => ['price' => 8.99, 'original_price' => null, 'lookup' => 'whisper_pro_monthly_v9'], + 'yearly' => ['price' => 53.94, 'original_price' => 107.88, 'lookup' => 'whisper_pro_yearly_v9'], + ], + 'subscriptions.plans.monthly.price' => 3.99, + 'subscriptions.plans.monthly.stripe_lookup_key' => 'whisper_pro_monthly', + 'subscriptions.plans.yearly.price' => 23.88, + 'subscriptions.plans.yearly.stripe_lookup_key' => 'whisper_pro_yearly', + ]); +}); + +it('keeps users who registered before the experiment on the control price', function () { + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-20')]); + + expect(app(ExperimentOffer::class)->priceVariantFor($user))->toBe(PriceExperiment::LEGACY); +}); + +it('treats a null (guest) scope as legacy', function () { + expect((new PriceExperiment)->resolve(null))->toBe(PriceExperiment::LEGACY); +}); + +it('treats everyone as legacy while the experiment is off', function () { + config(['subscriptions.price_experiment.started_at' => null]); + + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + + expect(app(ExperimentOffer::class)->priceVariantFor($user))->toBe(PriceExperiment::LEGACY); +}); + +it('pins every user to the forced winner price', function () { + config(['subscriptions.price_experiment.force_variant' => PriceExperiment::HIGH]); + $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->priceVariantFor($legacy))->toBe(PriceExperiment::HIGH) + ->and($offer->priceVariantFor($fresh))->toBe(PriceExperiment::HIGH); +}); + +it('ignores an invalid forced variant', function () { + config(['subscriptions.price_experiment.force_variant' => 'bogus']); + + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-01')]); + + expect(app(ExperimentOffer::class)->priceVariantFor($user))->toBe(PriceExperiment::LEGACY); +}); + +it('splits post-start users across control and high and stays stable per user', function () { + $offer = app(ExperimentOffer::class); + $variants = []; + + for ($i = 0; $i < 40; $i++) { + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + $assigned = $offer->priceVariantFor($user); + $variants[] = $assigned; + + Feature::flushCache(); + expect($offer->priceVariantFor($user))->toBe($assigned); + } + + expect(array_unique($variants))->toEqualCanonicalizing([ + PriceExperiment::CONTROL, + PriceExperiment::HIGH, + ]); +}); + +it('assigns the price independently of the trial experiment (salted hash)', function () { + // For users the trial experiment buckets into pay_now, the price experiment + // must still produce BOTH price arms — otherwise the two are collinear. + $priceArmsWithinOneTrialArm = []; + + for ($i = 0; $i < 200 && count(array_unique($priceArmsWithinOneTrialArm)) < 2; $i++) { + $id = (string) Str::uuid(); + + if (SubscriptionExperiment::bucket($id) === SubscriptionExperiment::PAY_NOW) { + $priceArmsWithinOneTrialArm[] = PriceExperiment::bucket($id); + } + } + + expect(array_unique($priceArmsWithinOneTrialArm))->toEqualCanonicalizing([ + PriceExperiment::CONTROL, + PriceExperiment::HIGH, + ]); +}); + +it('applies the variant price and lookup key for a high-price user', function () { + $offer = app(ExperimentOffer::class); + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + Feature::for($user)->activate(PriceExperiment::class, PriceExperiment::HIGH); + + $plans = $offer->pricingPlansFor($user); + + expect($plans['monthly']['price'])->toBe(8.99) + ->and($plans['monthly']['stripe_lookup_key'])->toBe('whisper_pro_monthly_v9') + ->and($plans['yearly']['price'])->toBe(53.94) + ->and($plans['yearly']['original_price'])->toBe(107.88) + ->and($offer->lookupKeyFor($user, 'monthly'))->toBe('whisper_pro_monthly_v9') + ->and($offer->lookupKeyFor($user, 'yearly'))->toBe('whisper_pro_yearly_v9'); +}); + +it('leaves control-price users on the config prices', function () { + $offer = app(ExperimentOffer::class); + $user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + Feature::for($user)->activate(PriceExperiment::class, PriceExperiment::CONTROL); + + $plans = $offer->pricingPlansFor($user); + + expect($plans['monthly']['price'])->toBe(3.99) + ->and($plans['monthly']['stripe_lookup_key'])->toBe('whisper_pro_monthly') + ->and($offer->lookupKeyFor($user, 'monthly'))->toBe('whisper_pro_monthly'); +}); + +it('shows the assigned variant price on the paywall', function () { + $user = User::factory()->onboarded()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + Feature::for($user)->activate(PriceExperiment::class, PriceExperiment::HIGH); + + $this->actingAs($user) + ->get(route('subscribe')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('subscription/paywall') + ->where('pricing.plans.monthly.price', 8.99) + ->where('pricing.plans.yearly.price', 53.94)); +}); + +it('charges the assigned variant price id at checkout, not a client-supplied one', function () { + config(['subscriptions.price_experiment.force_variant' => PriceExperiment::HIGH]); + Cache::put('stripe_price_id:whisper_pro_monthly_v9', 'price_high_monthly', now()->addHour()); + + $checkout = Mockery::mock(Checkout::class); + $checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://stripe.test/session')); + + $builder = Mockery::mock(SubscriptionBuilder::class)->shouldIgnoreMissing(); + $builder->shouldReceive('checkout')->once()->andReturn($checkout); + + $user = Mockery::mock(User::class)->shouldIgnoreMissing(); + $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user->shouldReceive('hasProPlan')->andReturn(false); + $user->shouldReceive('newSubscription') + ->once() + ->with('default', 'price_high_monthly') + ->andReturn($builder); + + $this->withoutMiddleware(HandleInertiaRequests::class); + $this->actingAs($user); + + $this->get(route('subscribe.checkout', ['plan' => 'monthly']))->assertRedirect(); +}); diff --git a/tests/Feature/SyncStripePricesCommandTest.php b/tests/Feature/SyncStripePricesCommandTest.php index e9719792..cbd28603 100644 --- a/tests/Feature/SyncStripePricesCommandTest.php +++ b/tests/Feature/SyncStripePricesCommandTest.php @@ -68,6 +68,8 @@ beforeEach(function () { ], 'subscriptions.products.pro' => 'prod_test123', 'cashier.currency' => 'eur', + // Base-plan tests isolate the plans loop; variant syncing has its own test. + 'subscriptions.price_experiment.variants' => [], ]); }); @@ -149,3 +151,25 @@ test('returns success and warns when no plans are configured', function () { ->expectsOutputToContain('No plans found') ->assertSuccessful(); }); + +test('syncs the price-experiment variant tiers alongside the base plans', function () { + config([ + 'subscriptions.price_experiment.variants' => [ + 'high' => [ + 'monthly' => ['price' => 8.99, 'lookup' => 'whisper_pro_monthly_v9'], + 'yearly' => ['price' => 53.94, 'lookup' => 'whisper_pro_yearly_v9'], + ], + ], + ]); + + // Base plans already in sync; both high tiers are new → 2 created, 2 skipped. + bindMockStripeClientForSync([ + 'whisper_pro_monthly' => makeStripePrice('price_monthly', 399, 'eur', 'month'), + 'whisper_pro_yearly' => makeStripePrice('price_yearly', 2388, 'eur', 'year'), + ]); + + $this->artisan('stripe:sync-prices') + ->expectsOutputToContain('(price: high)') + ->expectsOutputToContain('2 created, 0 updated, 2 skipped') + ->assertSuccessful(); +});