whisper-money/app/Services/Subscriptions/PriceExperiment.php

124 lines
4.6 KiB
PHP

<?php
namespace App\Services\Subscriptions;
use App\Models\User;
/**
* A/B split on the price of the paid plan: `control` keeps the plans.* prices,
* `high` swaps in the variant tier.
*
* The arm is drawn for an **anonymous visitor** on their first page view and kept
* in a cookie, then copied onto the user row at registration. It has to work that
* way round: the landing quotes a price before anyone has an account, so assigning
* at registration would advertise the control price to everyone and then switch
* half of them to the high one — measuring the annoyance of a price that moved
* rather than the price itself, and hiding the visitors who would never have
* signed up at the higher price at all.
*
* Anyone without an arm — registered before the experiment, cookies blocked,
* arrived straight at a deep link — is `legacy` and pays the control price.
*
* @api The arm names are the vocabulary the experiment is configured and read
* with: the accepted values of PRICE_EXPERIMENT_FORCE_VARIANT, the contents
* of users.price_arm, and what a funnel report groups by.
*/
class PriceExperiment
{
public const COOKIE = 'price_arm';
public const LEGACY = 'legacy';
public const CONTROL = 'control';
public const HIGH = 'high';
/**
* Whether new visitors should still be drawn into the split. A forced variant
* means a winner is being rolled out to everyone, so the split is over even
* though the start date is still set.
*/
public static function isRunning(): bool
{
// blank(), not === null: an empty PRICE_EXPERIMENT_STARTED_AT in the env
// reads back as '', and treating that as a start date would launch the
// experiment — charging the high price — on an ops typo.
return filled(config('subscriptions.price_experiment.started_at'))
&& self::forcedArm() === null;
}
/**
* A fresh 50/50 draw for a visitor we have not seen before. Random rather than
* a hash: there is no stable identifier to hash before the user exists.
*/
public static function draw(): string
{
return random_int(0, 1) === 0 ? self::CONTROL : self::HIGH;
}
/**
* A stored or cookie value narrowed to a real arm, or null. Guards the column
* and the cookie alike, both of which a user can put anything into.
*/
public static function sanitize(?string $arm): ?string
{
return in_array($arm, [self::CONTROL, self::HIGH], true) ? $arm : null;
}
/**
* The arm to price a request with: a signed-in user keeps the arm stored at
* registration, a guest gets the one drawn into their cookie.
*/
public static function armFor(?User $user, ?string $cookieArm = null): string
{
return self::forcedArm()
?? self::sanitize($user !== null ? $user->price_arm : $cookieArm)
?? self::LEGACY;
}
/**
* The plans config with the request's arm applied: price, original_price and
* Stripe lookup key. Feeds both the shared pricing prop and checkout, so what
* is shown is always what is charged.
*
* @return array<string, array<string, mixed>>
*/
public static function plansFor(?User $user, ?string $cookieArm = null): array
{
$plans = (array) config('subscriptions.plans', []);
$arm = self::armFor($user, $cookieArm);
$overrides = (array) config("subscriptions.price_experiment.variants.{$arm}", []);
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 a plan, resolved from the user's stored arm
* server-side and never from the request, so nobody can pick the cheap price
* by editing their cookie after signing up.
*/
public static function lookupKeyFor(User $user, string $planKey): string
{
return (string) (self::plansFor($user)[$planKey]['stripe_lookup_key'] ?? '');
}
/**
* The winner pinned via PRICE_EXPERIMENT_FORCE_VARIANT, applied to everyone —
* visitors and existing users — so a rollout needs no deploy and no backfill.
*/
private static function forcedArm(): ?string
{
return self::sanitize(config('subscriptions.price_experiment.force_variant'));
}
}