feat(subscriptions): trial/pricing A/B/C experiment (#600)

## What

A 3-way experiment on how the paid plan is offered, plus per-variant
measurement. New signups (on/after `SUBSCRIPTION_EXPERIMENT_STARTED_AT`)
are split evenly into:

- **control** — current 15-day trial.
- **reduced_trial** — shorter trial: 3 days monthly, 7 days yearly.
- **pay_now** — charged immediately (no trial), with a self-service
money-back guarantee for the first 3 days.

Earlier users stay **legacy** and keep the 15-day trial. **While
`started_at` is null the experiment is off and everyone behaves like
control — inert until activated via env.**

## How it works

- **Assignment** — `App\Features\SubscriptionExperiment` (Pennant),
deterministic even split by a stable hash of the user id. QA can force a
variant with `feature:enable`.
- **Offer policy** — `ExperimentOffer` is the single source of truth for
trial days per plan, the pay-now flag, the refund window and refund
eligibility; shared by checkout, paywall and billing.
- **Checkout** — trial length comes from the variant (`trialDays(0)` for
pay_now → immediate charge).
- **Onboarding clarity** — the paywall states the exact terms above the
CTA: trial length for the selected plan, or "charged €X today + 3-day
money-back guarantee" for pay_now.
- **Self-service refund (pay_now)** — Settings → Billing, within the
window: refunds the upfront charge, `cancelNow`, revokes bank
connections keeping imported data. `refunded_at` records it and blocks a
second refund. Crash-safe ordering: the refund is stamped before
cancel/disconnect, which run best-effort in a try/catch.

## Measurement

`stats:experiment-funnel` (weekly → Discord): per-variant funnel
(assigned, subscribed, status breakdown, refunds) with a **net-active
rate** gated by each variant's decision window (control 15d / reduced 7d
/ pay_now 3d) so cohorts are read at equal age. Attribution reads the
variant Pennant actually served each user, so the report can't drift
from what users experienced. It also reports **MRR** (monthly run-rate
of mature net-active subs, yearly normalised ÷12) and **ARPU** (MRR ÷
assigned) per variant — ARPU is the revenue metric for the winner
decision. Plus a winner can be pinned org-wide with
`SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT` (env, no deploy).

## Config (env)

- `SUBSCRIPTION_EXPERIMENT_STARTED_AT` — activates the experiment
(launch date). Null = off.
- `SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY` (3), `..._YEARLY` (7),
`..._REFUND_WINDOW_DAYS` (3)

## Tests

- **Feature/unit:** assignment, offer policy, checkout wiring, refund
eligibility, the refund action incl. idempotency + crash-safe ordering
(Stripe mocked), and the funnel collector/command. ES + FR translations.
- **Browser** (`tests/Browser/SubscriptionRefundTest.php`): the
self-service refund UX end to end — card visibility + deadline, two-step
confirm, back-out, the refund control disappearing after confirming, and
gating (window passed / non-pay_now hidden). The `RefundSelfServe`
action is doubled so it never hits Stripe but applies the same DB
effect. Screenshots: `refund-card-visible`, `refund-confirm-step`,
`refund-completed`.
- Full non-browser suite green (the one failing `DashboardTest` is
pre-existing on `main` — Inertia 409 from the unbuilt local manifest).
Pint + ESLint + tsc (changed files) clean.

## Two independent reviews — acted on

**Fixed:** refund atomicity/idempotency (major) · funnel attribution now
reads Pennant's served value instead of recomputing, killing
report-vs-runtime drift (major) · pay_now copy shows the exact amount
charged · throttle + block-demo on the refund route · `resolve(?User)`
nullable · French translations.

**Reviewer notes (deferred, low value):**
- `refunded_at` is not cast to Carbon on Cashier's `Subscription` (safe
today — only null-compared; would need a custom Cashier model).
- `ExperimentFunnelCollector` walks users in PHP via `chunkById`; fine
at current volume, can move to grouped SQL if it grows.

## Confidence: 85 / 100

The critical money path is now **verified live against the Stripe
sandbox** (see below), which removes the earlier cap. All gates are
green and the acceptance criteria are met. Held at 85 (not higher)
because the browser UI test runs in CI rather than locally, and the
pay_now *hosted-checkout + webhook* leg reuses the standard Cashier
checkout already proven by the control flow (only `trialDays(0)`
differs) but wasn't re-driven through the hosted page. Given it moves
money + disconnects accounts, a human glance is still warranted before
enabling.

## Sandbox verification (live Stripe test mode)

`php artisan stripe:verify-refund` creates a real immediately-charged
subscription with a test card, runs the actual `RefundSelfServe`, and
checks the Stripe API. Result:

```
PASS  subscription active after immediate charge   (pay_now, no trial)
PASS  canSelfRefund is true before refund
PASS  latestPayment() resolves a payment intent
PASS  refunded_at is stamped
PASS  subscription is canceled
PASS  canSelfRefund is false after refund
PASS  Stripe charge shows a full refund   (refunded=true)
```

The command is committed and guarded to Stripe test keys /
non-production, so it can be re-run before each launch toggle.


## Launch checklist

1. Stripe-sandbox smoke: `php artisan stripe:verify-refund` (done —
passing). Optionally also drive the hosted pay_now checkout once for
monthly + yearly to confirm the webhook leg.
2. Set `SUBSCRIPTION_EXPERIMENT_STARTED_AT` to the launch date (set
once; don't backdate).
3. Watch `stats:experiment-funnel`; a clean cohort baseline lands once
each variant's window matures.
This commit is contained in:
Víctor Falcón 2026-06-27 18:00:15 +02:00 committed by GitHub
parent e4be39be12
commit e5350ff1a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1596 additions and 8 deletions

View File

@ -0,0 +1,54 @@
<?php
namespace App\Actions\Subscription;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Log;
/**
* Self-service "money-back guarantee" for the pay_now experiment variant:
* refund the upfront charge, cancel the subscription immediately, and revoke
* the user's bank connections (keeping the data they already imported).
*
* Eligibility is enforced by the caller via ExperimentOffer::canSelfRefund().
* The refund is stamped before the cancel/disconnect steps run so that a
* failure in those steps can never leave a refunded-but-active subscription
* that could be refunded a second time; the cleanup is best-effort and logged.
*/
class RefundSelfServe
{
public function __construct(private DisconnectBankingConnection $disconnect) {}
public function handle(User $user): void
{
$subscription = $user->subscription('default');
if ($subscription === null || $subscription->refunded_at !== null) {
return;
}
$payment = $subscription->latestPayment();
if ($payment !== null) {
$user->refund($payment->asStripePaymentIntent()->id);
}
$subscription->forceFill(['refunded_at' => now()])->save();
try {
$subscription->cancelNow();
$user->bankingConnections()->get()->each(function (BankingConnection $connection): void {
$this->disconnect->handle($connection, deleteAccounts: false);
});
} catch (\Throwable $exception) {
Log::error('Self-serve refund issued but post-refund cleanup failed', [
'user_id' => $user->getKey(),
'subscription_id' => $subscription->getKey(),
'error' => $exception->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Console\Commands;
use App\Features\SubscriptionExperiment;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\ExperimentFunnelCollector;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
class SendExperimentFunnelReportCommand extends Command
{
protected $signature = 'stats:experiment-funnel';
protected $description = 'Post the trial/pricing experiment funnel (per variant) to Discord';
private const LABELS = [
SubscriptionExperiment::CONTROL => 'control',
SubscriptionExperiment::REDUCED_TRIAL => 'reduced',
SubscriptionExperiment::PAY_NOW => 'pay_now',
];
public function __construct(private ExperimentFunnelCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$report = $this->collector->collect();
if ($report['startedAt'] === null) {
$this->warn('Experiment not started — set SUBSCRIPTION_EXPERIMENT_STARTED_AT to begin.');
return self::SUCCESS;
}
foreach ($this->tableLines($report) as $line) {
$this->line($line);
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Experiment funnel report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$revenue = $report['revenueAvailable'];
$lines = [sprintf('%-8s %5s %4s %5s %5s %5s %5s %8s %8s', 'Variant', 'Assg', 'Sub', 'Actv', 'Cncl', 'Rfnd', 'Net%', 'MRR', 'ARPU')];
foreach (self::LABELS as $key => $label) {
$row = $report['variants'][$key];
$mature = $row['assignedMature'] > 0;
$lines[] = sprintf(
'%-8s %5d %4d %5d %5d %5d %5s %8s %8s',
$label,
$row['assigned'],
$row['subscribed'],
$row['active'],
$row['canceled'],
$row['refunded'],
$mature ? ((int) round($row['netActiveRate'] * 100)).'%' : 'pend',
$revenue ? $this->money($row['mrrCents'], $report['currency']) : '—',
$revenue && $mature ? $this->money((int) $row['arpuCents'], $report['currency']) : '—',
);
}
return $lines;
}
private function money(int $cents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
default => $currency.' ',
};
return $symbol.number_format($cents / 100, 2);
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
return [
'title' => '🧪 Trial/Pricing Experiment — Funnel by Variant',
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
'color' => 0xFEE75C,
'fields' => [
[
'name' => 'Started',
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
'inline' => false,
],
[
'name' => 'Legend',
'value' => 'Assg = assigned · Sub = started a plan · Actv/Cncl = current status · Rfnd = self-service refunds (pay_now) · Net% = live, non-refunded subs ÷ assigned (mature users only) · MRR = monthly run-rate of those subs (yearly ÷ 12) · ARPU = MRR ÷ assigned · `pend`/`—` = no mature data yet.',
'inline' => false,
],
[
'name' => '⚠️ How to read it',
'value' => 'Each variant is gated by its own decision window (control 15d, reduced 7d, pay_now 3d), so pay_now matures first — compare only once all three have mature volume, and check significance before calling a winner. **ARPU is the revenue metric to compare.** MRR is run-rate, so it does not credit pay_now\'s yearly upfront cash; true LTV also needs a churn rate the experiment is too young to have.',
'inline' => false,
],
],
];
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\User;
use App\Services\Subscriptions\ExperimentOffer;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Laravel\Pennant\Feature;
/**
* Live sandbox check for the pay_now self-service refund the one path that
* Pest tests can only mock. It creates a real, immediately-charged subscription
* against the Stripe test environment, runs the actual RefundSelfServe action,
* and confirms via the Stripe API that the charge was refunded and the
* subscription canceled. Run before flipping SUBSCRIPTION_EXPERIMENT_STARTED_AT.
*
* Refuses to run against anything but Stripe test keys.
*/
class VerifyRefundFlowCommand extends Command
{
protected $signature = 'stripe:verify-refund';
protected $description = 'Verify the pay_now self-service refund end-to-end against the Stripe sandbox';
public function __construct(private ExperimentOffer $offer)
{
parent::__construct();
}
public function handle(): int
{
if (app()->isProduction() || ! str_starts_with((string) config('cashier.secret'), 'sk_test')) {
$this->error('Refusing to run: this command requires Stripe test keys and a non-production environment.');
return self::FAILURE;
}
config(['subscriptions.tax_rates' => []]);
$passed = true;
$check = function (string $label, bool $ok) use (&$passed): void {
$this->line(($ok ? '<fg=green>PASS</>' : '<fg=red>FAIL</>').' '.$label);
$passed = $passed && $ok;
};
$lookup = config('subscriptions.plans.monthly.stripe_lookup_key');
$prices = Cashier::stripe()->prices->all(['lookup_keys' => [$lookup], 'limit' => 1]);
$priceId = $prices->data[0]->id ?? null;
if ($priceId === null) {
$this->error("No Stripe price found for lookup key '{$lookup}'.");
return self::FAILURE;
}
$user = User::factory()->create([
'email' => 'refund-sandbox-'.uniqid().'@whisper.test',
'created_at' => now(),
]);
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW);
try {
$user->newSubscription('default', $priceId)->create('pm_card_visa');
$subscription = $user->subscription('default');
$check('subscription active after immediate charge', $subscription->active() && $subscription->stripe_status === 'active');
$check('canSelfRefund is true before refund', $this->offer->canSelfRefund($user));
$paymentIntentId = $subscription->latestPayment()?->asStripePaymentIntent()->id;
$check('latestPayment() resolves a payment intent', $paymentIntentId !== null);
app(RefundSelfServe::class)->handle($user->fresh());
$subscription = $user->subscription('default')->fresh();
$check('refunded_at is stamped', $subscription->refunded_at !== null);
$check('subscription is canceled', $subscription->canceled());
$check('canSelfRefund is false after refund', ! $this->offer->canSelfRefund($user->fresh()));
$intent = Cashier::stripe()->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]);
$charge = $intent->latest_charge;
$check('Stripe charge shows a full refund', is_object($charge) && $charge->refunded === true);
$this->line(' amount_refunded='.($charge->amount_refunded ?? 'n/a'));
} catch (\Throwable $exception) {
$this->error($exception::class.': '.$exception->getMessage());
$passed = false;
} finally {
try {
if ($user->hasStripeId()) {
Cashier::stripe()->customers->delete($user->stripe_id);
}
} catch (\Throwable) {
// best-effort sandbox cleanup
}
$user->forceDelete();
}
$this->newLine();
$this->{$passed ? 'info' : 'error'}($passed ? 'Refund flow verified.' : 'Refund flow verification FAILED.');
return $passed ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -0,0 +1,70 @@
<?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) and persisted by Pennant; the funnel
* report reads that persisted value so it always matches what the user was served.
*
* @api
*/
class SubscriptionExperiment
{
public const LEGACY = 'legacy';
public const CONTROL = 'control';
public const REDUCED_TRIAL = 'reduced_trial';
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');
if ($user === null || $startedAt === null || $user->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 funnel report
* mirrors this in PHP to attribute users without reading Pennant per row, so
* keep the formula here as the single source of truth.
*/
public static function bucket(string $key): string
{
return match (crc32($key) % 3) {
0 => self::CONTROL,
1 => self::REDUCED_TRIAL,
default => self::PAY_NOW,
};
}
}

View File

@ -2,9 +2,13 @@
namespace App\Http\Controllers;
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\AccountBalance;
use App\Models\User;
use App\Models\UserLead;
use App\Services\Discord\DiscordWebhook;
use App\Services\Subscriptions\ExperimentOffer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@ -15,6 +19,11 @@ use Laravel\Cashier\Checkout;
class SubscriptionController extends Controller
{
public function __construct(
private ExperimentOffer $experimentOffer,
private DiscordWebhook $discord,
) {}
public function index(Request $request): Response|RedirectResponse
{
/** @var User $user */
@ -38,6 +47,7 @@ class SubscriptionController extends Controller
'canManageConnectionsForFreePlan' => $user->isOnboarded()
&& $hasBankConnections
&& $user->hasCanceledSubscription(),
'offer' => $this->experimentOffer->offerFor($user),
]);
}
@ -91,7 +101,7 @@ class SubscriptionController extends Controller
$subscriptionBuilder->allowPromotionCodes();
}
$trialDays = (int) ($plan['trial_days'] ?? 0);
$trialDays = $this->experimentOffer->trialDaysFor($request->user(), $planKey);
if ($trialDays > 0) {
$subscriptionBuilder->trialDays($trialDays);
}
@ -176,11 +186,70 @@ class SubscriptionController extends Controller
return redirect()->route('dashboard');
}
$user = $request->user();
$subscription = $user->subscription('default');
return Inertia::render('settings/billing', [
'hasAiConsent' => $request->user()->hasActiveAiConsent(),
'hasAiConsent' => $user->hasActiveAiConsent(),
'refund' => [
'canSelfRefund' => $this->experimentOffer->canSelfRefund($user),
'deadline' => $subscription !== null && $this->experimentOffer->variantFor($user) === SubscriptionExperiment::PAY_NOW
? $this->experimentOffer->refundDeadlineFor($subscription)->toIso8601String()
: null,
],
]);
}
public function refund(Request $request): RedirectResponse
{
$user = $request->user();
if (! $this->experimentOffer->canSelfRefund($user)) {
return redirect()->route('settings.billing')
->withErrors(['refund' => __('This subscription is no longer eligible for a self-service refund.')]);
}
try {
app(RefundSelfServe::class)->handle($user);
} catch (\Throwable $exception) {
$this->discord->send('', [$this->refundEmbed($user, success: false, detail: $exception->getMessage())]);
throw $exception;
}
$this->discord->send('', [$this->refundEmbed($user, success: true)]);
return redirect()->route('settings.billing')
->with('status', __('Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.'));
}
/**
* @return array<string, mixed>
*/
private function refundEmbed(User $user, bool $success, ?string $detail = null): array
{
if (! $success) {
return [
'title' => '🔴 Self-service refund FAILED',
'description' => 'A pay_now refund threw — the user may have been charged without a refund. Check Stripe and Sentry now.',
'color' => 0xED4245,
'fields' => [
['name' => 'User', 'value' => $user->email, 'inline' => false],
['name' => 'Error', 'value' => substr((string) $detail, 0, 1000), 'inline' => false],
],
];
}
return [
'title' => '💸 Self-service refund processed',
'description' => 'A pay_now user refunded within the money-back window — subscription canceled and bank connections disconnected.',
'color' => 0xFAA61A,
'fields' => [
['name' => 'User', 'value' => $user->email, 'inline' => false],
],
];
}
public function billingPortal(Request $request): RedirectResponse
{
if ($request->user()->isDemoAccount()) {

View File

@ -0,0 +1,228 @@
<?php
namespace App\Services\Stats;
use App\Features\SubscriptionExperiment;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Subscription;
use Laravel\Pennant\Feature;
class ExperimentFunnelCollector
{
/**
* Days after a variant's decision point (trial end / refund deadline) before
* a user's outcome is settled enough to score, to let the charge clear.
*/
private const SETTLE_BUFFER_DAYS = 3;
/**
* Per-variant funnel for the trial/pricing experiment. Users are attributed
* by the variant Pennant resolved for them the same value the runtime
* served at checkout/paywall so the report can't drift from what users
* actually experienced (including any QA override or a legacy bucket that
* predates the experiment). "Net active" is a live, non-refunded
* subscription an exact, heuristic-free metric that is comparable across
* variants once each cohort clears its own decision window.
*
* Revenue is the monthly-recurring run-rate (MRR) of the mature, net-active
* subscriptions, with yearly plans normalised to a monthly equivalent, plus
* ARPU = MRR ÷ assigned (mature) the per-signup revenue each variant earns.
* This is run-rate, not realised cash, so it does not credit pay_now's yearly
* upfront payment any differently from a monthly one. Full LTV needs a churn
* rate the experiment is too young to have; ARPU is the proxy until then.
*
* @return array{
* startedAt: ?CarbonImmutable,
* currency: string,
* revenueAvailable: bool,
* variants: array<string, array{
* assigned: int,
* subscribed: int,
* trialing: int,
* active: int,
* canceled: int,
* pastDue: int,
* refunded: int,
* assignedMature: int,
* activeMature: int,
* netActiveRate: ?float,
* mrrCents: int,
* arpuCents: ?int,
* }>
* }
*/
public function collect(): array
{
$startedValue = config('subscriptions.experiment.started_at');
$startedAt = $startedValue !== null ? CarbonImmutable::parse($startedValue) : null;
$currency = strtoupper((string) config('cashier.currency', 'eur'));
$variants = [
SubscriptionExperiment::CONTROL => $this->emptyRow(),
SubscriptionExperiment::REDUCED_TRIAL => $this->emptyRow(),
SubscriptionExperiment::PAY_NOW => $this->emptyRow(),
];
if ($startedAt === null) {
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'variants' => $variants];
}
$now = CarbonImmutable::now('UTC');
$excluded = (array) config('ai_suggestions.report.excluded_emails', []);
$windows = $this->decisionWindows();
$monthlyEquiv = $this->monthlyEquivByPriceId();
User::query()
->where('users.created_at', '>=', $startedAt)
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
->with(['subscriptions' => fn ($query) => $query->where('type', 'default')])
->select(['id', 'created_at'])
->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv): void {
Feature::for($users)->load([SubscriptionExperiment::class]);
foreach ($users as $user) {
$variant = Feature::for($user)->value(SubscriptionExperiment::class);
if (! isset($variants[$variant])) {
continue;
}
$row = &$variants[$variant];
$row['assigned']++;
/** @var Subscription|null $subscription */
$subscription = $user->subscriptions->sortByDesc('created_at')->first();
$status = $subscription?->stripe_status;
$netActive = $status === 'active' && $subscription->refunded_at === null;
if ($subscription !== null) {
$row['subscribed']++;
$row['trialing'] += $status === 'trialing' ? 1 : 0;
$row['active'] += $status === 'active' ? 1 : 0;
$row['canceled'] += $status === 'canceled' ? 1 : 0;
$row['pastDue'] += $status === 'past_due' ? 1 : 0;
$row['refunded'] += $subscription->refunded_at !== null ? 1 : 0;
}
$mature = CarbonImmutable::parse($user->created_at)
->addDays($windows[$variant] + self::SETTLE_BUFFER_DAYS)
->lessThanOrEqualTo($now);
if ($mature) {
$row['assignedMature']++;
if ($netActive) {
$row['activeMature']++;
$row['mrrCents'] += (int) ($monthlyEquiv[$subscription->stripe_price] ?? 0);
}
}
unset($row);
}
});
foreach ($variants as $key => $row) {
$variants[$key]['netActiveRate'] = $row['assignedMature'] > 0
? $row['activeMature'] / $row['assignedMature']
: null;
$variants[$key]['arpuCents'] = $row['assignedMature'] > 0
? (int) round($row['mrrCents'] / $row['assignedMature'])
: null;
}
return [
'startedAt' => $startedAt,
'currency' => $currency,
'revenueAvailable' => $monthlyEquiv !== [],
'variants' => $variants,
];
}
/**
* @return array{assigned: int, subscribed: int, trialing: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activeMature: int, netActiveRate: ?float, mrrCents: int, arpuCents: ?int}
*/
private function emptyRow(): array
{
return [
'assigned' => 0,
'subscribed' => 0,
'trialing' => 0,
'active' => 0,
'canceled' => 0,
'pastDue' => 0,
'refunded' => 0,
'assignedMature' => 0,
'activeMature' => 0,
'netActiveRate' => null,
'mrrCents' => 0,
'arpuCents' => null,
];
}
/**
* Monthly-equivalent amount (in cents) for each plan price id, from Stripe.
* Yearly prices are divided by 12. Cached for an hour; returns [] (revenue
* unavailable) if Stripe can't be reached, without caching the failure.
*
* @return array<string, int>
*/
private function monthlyEquivByPriceId(): array
{
$key = 'experiment_funnel_monthly_equiv';
if (Cache::has($key)) {
return Cache::get($key);
}
$lookups = array_values(array_filter([
config('subscriptions.plans.monthly.stripe_lookup_key'),
config('subscriptions.plans.yearly.stripe_lookup_key'),
]));
if ($lookups === []) {
return [];
}
try {
$prices = Cashier::stripe()->prices->all(['lookup_keys' => $lookups, 'limit' => 10]);
} catch (\Throwable) {
return [];
}
$map = [];
foreach ($prices->data as $price) {
$amount = (int) ($price->unit_amount ?? 0);
$map[$price->id] = ($price->recurring->interval ?? 'month') === 'year'
? intdiv($amount, 12)
: $amount;
}
if ($map !== []) {
Cache::put($key, $map, now()->addHour());
}
return $map;
}
/**
* Days from signup until each variant's outcome can be scored: the trial
* length (the longer of the two reduced trials) or the refund window.
*
* @return array<string, int>
*/
private function decisionWindows(): array
{
return [
SubscriptionExperiment::CONTROL => (int) config('subscriptions.plans.monthly.trial_days', 15),
SubscriptionExperiment::REDUCED_TRIAL => max(
(int) config('subscriptions.experiment.reduced_trial.monthly', 3),
(int) config('subscriptions.experiment.reduced_trial.yearly', 7),
),
SubscriptionExperiment::PAY_NOW => (int) config('subscriptions.experiment.pay_now_refund_window_days', 3),
];
}
}

View File

@ -0,0 +1,93 @@
<?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 $this->trialDaysForVariant($this->variantFor($user), $planKey);
}
private function trialDaysForVariant(string $variant, string $planKey): int
{
return match ($variant) {
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->trialDaysForVariant($variant, 'monthly'),
'yearly' => $this->trialDaysForVariant($variant, '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());
}
}

View File

@ -15,6 +15,36 @@ 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'),
// 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),
],
'pay_now_refund_window_days' => (int) env('SUBSCRIPTION_EXPERIMENT_REFUND_WINDOW_DAYS', 3),
],
/*
|--------------------------------------------------------------------------
| Stripe Product IDs

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->timestamp('refunded_at')->nullable()->after('ends_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->dropColumn('refunded_at');
});
}
};

View File

@ -1,4 +1,16 @@
{
"This subscription is no longer eligible for a self-service refund.": "Esta suscripción ya no es elegible para una devolución automática.",
"Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.": "Te hemos devuelto el pago, cancelado la suscripción y desconectado tus cuentas bancarias.",
"You'll be charged :amount today": "Se te cobrará :amount hoy",
"Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.": "¿Cambias de idea? Solicita tú mismo la devolución completa desde Ajustes durante los primeros :days días, sin preguntas.",
":days-day free trial": "Prueba gratis de :days días",
"You won't be charged until your :days-day trial ends. Cancel anytime before then.": "No se te cobrará hasta que termine tu prueba de :days días. Cancela cuando quieras antes de eso.",
"Money-back guarantee": "Garantía de devolución",
"Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "¿Cambias de idea? Solicita la devolución completa hasta el :date. Esto cancela tu suscripción y desconecta tus cuentas bancarias; los datos que ya importaste se conservan.",
"Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "¿Cambias de idea? Solicita la devolución completa. Esto cancela tu suscripción y desconecta tus cuentas bancarias; los datos que ya importaste se conservan.",
"Confirm refund": "Confirmar devolución",
"Keep my plan": "Mantener mi plan",
"Request a refund": "Solicitar devolución",
"AI Categorization": "Categorización con IA",
"Let AI suggest categories for your transactions automatically.": "Deja que la IA sugiera categorías para tus transacciones automáticamente.",
"Allow AI categorization": "Permitir categorización con IA",

View File

@ -1,4 +1,16 @@
{
"This subscription is no longer eligible for a self-service refund.": "Cet abonnement n'est plus éligible à un remboursement automatique.",
"Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.": "Votre paiement a été remboursé, votre abonnement annulé et vos comptes bancaires déconnectés.",
"You'll be charged :amount today": "Vous serez débité de :amount aujourd'hui",
"Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.": "Vous avez changé d'avis ? Demandez vous-même un remboursement complet depuis les Paramètres durant les :days premiers jours, sans condition.",
":days-day free trial": "Essai gratuit de :days jours",
"You won't be charged until your :days-day trial ends. Cancel anytime before then.": "Vous ne serez débité qu'à la fin de votre essai de :days jours. Annulez à tout moment avant.",
"Money-back guarantee": "Garantie de remboursement",
"Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "Vous avez changé d'avis ? Demandez un remboursement complet jusqu'au :date. Cela annule votre abonnement et déconnecte vos comptes bancaires ; les données déjà importées sont conservées.",
"Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "Vous avez changé d'avis ? Demandez un remboursement complet. Cela annule votre abonnement et déconnecte vos comptes bancaires ; les données déjà importées sont conservées.",
"Confirm refund": "Confirmer le remboursement",
"Keep my plan": "Conserver mon abonnement",
"Request a refund": "Demander un remboursement",
"AI Categorization": "Catégorisation par IA",
"Let AI suggest categories for your transactions automatically.": "Laissez l'IA suggérer automatiquement des catégories pour vos transactions.",
"Allow AI categorization": "Autoriser la catégorisation par IA",

View File

@ -11,13 +11,13 @@ import {
store as storeConsent,
} from '@/routes/ai/consent';
import { billing } from '@/routes/settings';
import { portal } from '@/routes/settings/billing';
import { portal, refund as refundRoute } from '@/routes/settings/billing';
import { checkout } from '@/routes/subscribe';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import { Head, router, usePage } from '@inertiajs/react';
import axios from 'axios';
import {
CheckIcon,
@ -245,16 +245,95 @@ function UpgradeSection({
);
}
interface RefundInfo {
canSelfRefund: boolean;
deadline: string | null;
}
function RefundCard({
deadline,
locale,
}: {
deadline: string | null;
locale: string;
}) {
const [confirming, setConfirming] = useState(false);
const [processing, setProcessing] = useState(false);
const deadlineLabel = deadline
? new Date(deadline).toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
})
: null;
const submit = () => {
setProcessing(true);
router.post(
refundRoute.url(),
{},
{ onFinish: () => setProcessing(false) },
);
};
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-5 dark:border-amber-900 dark:bg-amber-950/30">
<h4 className="font-medium text-amber-900 dark:text-amber-200">
{__('Money-back guarantee')}
</h4>
<p className="mt-1 text-sm text-amber-800/80 dark:text-amber-300/80">
{deadlineLabel
? __(
'Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.',
{ date: deadlineLabel },
)
: __(
'Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.',
)}
</p>
{confirming ? (
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="destructive"
disabled={processing}
onClick={submit}
>
{__('Confirm refund')}
</Button>
<Button
variant="ghost"
disabled={processing}
onClick={() => setConfirming(false)}
>
{__('Keep my plan')}
</Button>
</div>
) : (
<Button
variant="outline"
className="mt-4"
onClick={() => setConfirming(true)}
>
{__('Request a refund')}
</Button>
)}
</div>
);
}
function SubscribedSection({
isDemoAccount,
defaultPlan,
currency,
locale,
refund,
}: {
isDemoAccount: boolean;
defaultPlan: Plan | undefined;
currency: string;
locale: string;
refund?: RefundInfo;
}) {
return (
<div className="space-y-6">
@ -302,6 +381,10 @@ function SubscribedSection({
</a>
)}
</div>
{refund?.canSelfRefund && (
<RefundCard deadline={refund.deadline} locale={locale} />
)}
</div>
);
}
@ -393,8 +476,8 @@ function AiConsentSection({
}
export default function Billing() {
const { auth, pricing, locale, features, hasAiConsent } = usePage<
SharedData & { hasAiConsent: boolean }
const { auth, pricing, locale, features, hasAiConsent, refund } = usePage<
SharedData & { hasAiConsent: boolean; refund?: RefundInfo }
>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const hasProPlan = auth?.hasProPlan ?? false;
@ -413,6 +496,7 @@ export default function Billing() {
defaultPlan={defaultPlan}
currency={pricing.currency}
locale={locale}
refund={refund}
/>
) : (
<UpgradeSection

View File

@ -32,10 +32,64 @@ interface PaywallStats {
balancesByCurrency: Record<string, number>;
}
interface ExperimentOffer {
variant: string;
payNow: boolean;
refundWindowDays: number;
trialDays: Record<string, number>;
}
interface PaywallPageProps extends SharedData {
stats: PaywallStats;
canUseFreePlan: boolean;
canManageConnectionsForFreePlan: boolean;
offer: ExperimentOffer;
}
function TrialTerms({
offer,
planKey,
amount,
}: {
offer: ExperimentOffer;
planKey: string;
amount: string;
}) {
if (offer.payNow) {
return (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-center text-sm dark:border-emerald-900 dark:bg-emerald-950/40">
<p className="font-medium text-emerald-900 dark:text-emerald-200">
{__("You'll be charged :amount today", { amount })}
</p>
<p className="mt-1 text-emerald-800/80 dark:text-emerald-300/80">
{__(
'Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.',
{ days: offer.refundWindowDays },
)}
</p>
</div>
);
}
const days = offer.trialDays[planKey] ?? 0;
if (days <= 0) {
return null;
}
return (
<div className="rounded-lg border bg-muted/30 p-3 text-center text-sm">
<p className="font-medium">
{__(':days-day free trial', { days })}
</p>
<p className="mt-1 text-muted-foreground">
{__(
"You won't be charged until your :days-day trial ends. Cancel anytime before then.",
{ days },
)}
</p>
</div>
);
}
function getEquivalentBillingLabel(
@ -335,20 +389,27 @@ function PricingSection({
currency,
canUseFreePlan,
canManageConnectionsForFreePlan,
offer,
}: {
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
canUseFreePlan: boolean;
canManageConnectionsForFreePlan: boolean;
offer: ExperimentOffer;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
const locale = useLocale();
const selectedPlanData = planEntries.find(
([key]) => key === selectedPlan,
)?.[1];
const selectedAmount = selectedPlanData
? formatCurrency(selectedPlanData.price * 100, currency, locale)
: '';
useEffect(() => {
if (!canUseFreePlan) {
return;
@ -379,6 +440,12 @@ function PricingSection({
))}
</div>
<TrialTerms
offer={offer}
planKey={selectedPlan}
amount={selectedAmount}
/>
<a href={checkout.url({ query: { plan: selectedPlan } })}>
<Button
className="w-full bg-emerald-600 py-6 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700"
@ -428,8 +495,13 @@ function PricingSection({
}
export default function Paywall() {
const { pricing, stats, canUseFreePlan, canManageConnectionsForFreePlan } =
usePage<PaywallPageProps>().props;
const {
pricing,
stats,
canUseFreePlan,
canManageConnectionsForFreePlan,
offer,
} = usePage<PaywallPageProps>().props;
const planEntries = Object.entries(pricing.plans);
if (planEntries.length === 0) {
@ -454,6 +526,7 @@ export default function Paywall() {
canManageConnectionsForFreePlan={
canManageConnectionsForFreePlan
}
offer={offer}
/>
</div>
</div>

View File

@ -17,3 +17,4 @@ Schedule::command('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madr
Schedule::command('stats:ai-cohort-report')->monthlyOn(1, '09:00')->timezone('Europe/Madrid');
Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid');
Schedule::command('stats:subscription-funnel')->weekly()->mondays()->at('09:15')->timezone('Europe/Madrid');
Schedule::command('stats:experiment-funnel')->weekly()->mondays()->at('09:30')->timezone('Europe/Madrid');

View File

@ -87,6 +87,9 @@ Route::middleware('auth')->group(function () {
Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');
Route::post('settings/billing/refund', [SubscriptionController::class, 'refund'])
->middleware(['throttle:6,1', 'block-demo'])
->name('settings.billing.refund');
Route::get('settings/delete-account', function (Request $request) {
return Inertia::render('settings/delete-account', [

View File

@ -0,0 +1,111 @@
<?php
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\User;
use Illuminate\Support\Str;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
/**
* Browser coverage of the pay_now self-service refund the most delicate path,
* since it moves money and disconnects accounts. The RefundSelfServe action is
* swapped for a double so the test never hits Stripe; the double applies the same
* DB effect (stamping refunded_at) so the refund control genuinely disappears
* after confirming, proving the full click -> POST -> re-render loop. The real
* Stripe refund/cancel shapes are covered by tests/Feature/SelfServeRefundTest
* and must still be smoke-checked against the Stripe sandbox before launch.
*/
beforeEach(function () {
config([
'subscriptions.enabled' => true,
'subscriptions.experiment.started_at' => '2026-06-01',
'subscriptions.experiment.pay_now_refund_window_days' => 3,
]);
});
function browserPayNowUser(array $subscriptionOverrides = []): User
{
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW);
$user->subscriptions()->create(array_merge([
'type' => 'default',
'stripe_id' => 'sub_'.Str::random(12),
'stripe_status' => 'active',
'stripe_price' => 'price_test',
'created_at' => now(),
], $subscriptionOverrides));
return $user;
}
it('lets a pay-now subscriber self-refund from billing settings', function () {
$user = browserPayNowUser();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldReceive('handle')->once()->andReturnUsing(function () use ($user): void {
$user->subscription('default')->forceFill(['refunded_at' => now()])->save();
});
app()->instance(RefundSelfServe::class, $action);
actingAs($user);
visit('/settings/billing')
->assertSee('Money-back guarantee')
->assertSee('Request a refund')
->screenshot(filename: 'refund-card-visible')
->click('Request a refund')
->waitForText('Confirm refund', 5)
->assertSee('Keep my plan')
->screenshot(filename: 'refund-confirm-step')
->click('Confirm refund')
->wait(3)
->assertDontSee('Money-back guarantee')
->screenshot(filename: 'refund-completed')
->assertNoJavascriptErrors();
});
it('lets the user back out of the refund without charging anything', function () {
$user = browserPayNowUser();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldNotReceive('handle');
app()->instance(RefundSelfServe::class, $action);
actingAs($user);
visit('/settings/billing')
->assertSee('Request a refund')
->click('Request a refund')
->waitForText('Keep my plan', 5)
->click('Keep my plan')
->wait(1)
->assertSee('Request a refund')
->assertDontSee('Confirm refund')
->assertNoJavascriptErrors();
});
it('hides the refund control once the window has passed', function () {
$user = browserPayNowUser(['created_at' => now()->subDays(5)]);
actingAs($user);
visit('/settings/billing')
->assertSee('Pro Plan Active')
->assertDontSee('Money-back guarantee')
->assertNoJavascriptErrors();
});
it('does not offer a refund to control-group subscribers', function () {
$user = browserPayNowUser();
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::CONTROL);
actingAs($user);
visit('/settings/billing')
->assertSee('Pro Plan Active')
->assertDontSee('Money-back guarantee')
->assertNoJavascriptErrors();
});

View File

@ -0,0 +1,200 @@
<?php
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Subscriptions\ExperimentOffer;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Laravel\Cashier\Payment;
use Laravel\Cashier\Subscription;
use Laravel\Pennant\Feature;
use Stripe\PaymentIntent;
beforeEach(function () {
config([
'subscriptions.enabled' => true,
'subscriptions.experiment.started_at' => '2026-06-01',
'subscriptions.experiment.pay_now_refund_window_days' => 3,
]);
Carbon::setTestNow(CarbonImmutable::parse('2026-06-15 12:00:00'));
});
function payNowSubscriber(array $overrides = []): User
{
$user = User::factory()->onboarded()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]);
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW);
$user->subscriptions()->create(array_merge([
'type' => 'default',
'stripe_id' => 'sub_paynow_'.fake()->unique()->numerify('######'),
'stripe_status' => 'active',
'stripe_price' => 'price_test',
'created_at' => now(),
], $overrides));
return $user;
}
it('allows a self-refund inside the window for a pay-now subscriber', function () {
$user = payNowSubscriber();
expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeTrue();
});
it('blocks a self-refund once the window has passed', function () {
$user = payNowSubscriber(['created_at' => now()->subDays(5)]);
expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse();
});
it('blocks a self-refund once already refunded', function () {
$user = payNowSubscriber(['refunded_at' => now()]);
expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse();
});
it('blocks a self-refund for non pay-now variants', function () {
$user = payNowSubscriber();
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::CONTROL);
expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse();
});
it('runs the refund action when eligible and reports it on the billing screen', function () {
$user = payNowSubscriber();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldReceive('handle')->once();
app()->instance(RefundSelfServe::class, $action);
$this->actingAs($user)
->post(route('settings.billing.refund'))
->assertRedirect(route('settings.billing'))
->assertSessionHas('status');
});
it('rejects the refund request when not eligible', function () {
$user = payNowSubscriber(['created_at' => now()->subDays(5)]);
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldNotReceive('handle');
app()->instance(RefundSelfServe::class, $action);
$this->actingAs($user)
->post(route('settings.billing.refund'))
->assertRedirect(route('settings.billing'))
->assertSessionHasErrors(['refund']);
});
it('announces a successful refund to discord', function () {
config(['services.discord.webhook_url' => 'https://discord.test/hook']);
Http::fake(['discord.test/*' => Http::response('', 204)]);
$user = payNowSubscriber();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldReceive('handle')->once();
app()->instance(RefundSelfServe::class, $action);
$this->actingAs($user)
->post(route('settings.billing.refund'))
->assertRedirect(route('settings.billing'));
Http::assertSent(fn ($request) => $request->url() === 'https://discord.test/hook'
&& str_contains(strtolower($request['embeds'][0]['title']), 'refund processed'));
});
it('announces a failed refund to discord and surfaces the error', function () {
config(['services.discord.webhook_url' => 'https://discord.test/hook']);
Http::fake(['discord.test/*' => Http::response('', 204)]);
$user = payNowSubscriber();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldReceive('handle')->once()->andThrow(new RuntimeException('stripe down'));
app()->instance(RefundSelfServe::class, $action);
$this->actingAs($user)
->post(route('settings.billing.refund'))
->assertStatus(500);
Http::assertSent(fn ($request) => isset($request['embeds'][0]['title'])
&& str_contains(strtolower($request['embeds'][0]['title']), 'failed'));
});
it('refunds the charge, cancels the subscription and disconnects connections', function () {
$payment = new Payment(new PaymentIntent('pi_test_123'));
$subscription = Mockery::mock(Subscription::class);
$subscription->shouldReceive('getAttribute')->with('refunded_at')->andReturn(null);
$subscription->shouldReceive('latestPayment')->once()->andReturn($payment);
$subscription->shouldReceive('cancelNow')->once();
$subscription->shouldReceive('forceFill')->once()
->with(Mockery::on(fn ($attrs) => array_key_exists('refunded_at', $attrs)))
->andReturnSelf();
$subscription->shouldReceive('save')->once();
$relation = Mockery::mock(HasMany::class);
$relation->shouldReceive('get')->once()->andReturn(collect([
Mockery::mock(BankingConnection::class),
Mockery::mock(BankingConnection::class),
]));
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('subscription')->with('default')->andReturn($subscription);
$user->shouldReceive('refund')->once()->with('pi_test_123');
$user->shouldReceive('bankingConnections')->andReturn($relation);
$disconnect = Mockery::mock(DisconnectBankingConnection::class);
$disconnect->shouldReceive('handle')->twice();
(new RefundSelfServe($disconnect))->handle($user);
});
it('records the refund before cleanup so a cleanup failure cannot double-refund', function () {
$payment = new Payment(new PaymentIntent('pi_test_123'));
$subscription = Mockery::mock(Subscription::class);
$subscription->shouldReceive('getAttribute')->with('refunded_at')->andReturn(null);
$subscription->shouldReceive('latestPayment')->andReturn($payment);
$subscription->shouldReceive('forceFill')
->with(Mockery::on(fn ($attrs) => array_key_exists('refunded_at', $attrs)))
->once()->andReturnSelf();
$subscription->shouldReceive('save')->once();
$subscription->shouldReceive('cancelNow')->once()->andThrow(new RuntimeException('stripe down'));
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('subscription')->with('default')->andReturn($subscription);
$user->shouldReceive('refund')->once()->with('pi_test_123');
$disconnect = Mockery::mock(DisconnectBankingConnection::class);
$disconnect->shouldNotReceive('handle');
// refunded_at is saved before cancelNow throws, and the failure is swallowed.
expect(fn () => (new RefundSelfServe($disconnect))->handle($user))->not->toThrow(RuntimeException::class);
});
it('skips a subscription that was already refunded', function () {
$user = payNowSubscriber(['refunded_at' => now()]);
$disconnect = Mockery::mock(DisconnectBankingConnection::class);
$disconnect->shouldNotReceive('handle');
expect(fn () => (new RefundSelfServe($disconnect))->handle($user))->not->toThrow(Exception::class);
expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse();
});
it('does nothing when there is no subscription to refund', function () {
$disconnect = Mockery::mock(DisconnectBankingConnection::class);
$disconnect->shouldNotReceive('handle');
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('subscription')->with('default')->andReturn(null);
$user->shouldNotReceive('refund');
expect(fn () => (new RefundSelfServe($disconnect))->handle($user))->not->toThrow(Exception::class);
});

View File

@ -0,0 +1,170 @@
<?php
use App\Features\SubscriptionExperiment;
use App\Models\User;
use App\Services\Stats\ExperimentFunnelCollector;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use function Pest\Laravel\artisan;
beforeEach(function () {
config([
'subscriptions.enabled' => true,
'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,
'ai_suggestions.report.excluded_emails' => [],
]);
Carbon::setTestNow(CarbonImmutable::parse('2026-06-30 12:00:00'));
// Seed the price→monthly-equivalent map so revenue is computed without Stripe.
Cache::put('experiment_funnel_monthly_equiv', ['price_test' => 399], now()->addHour());
});
/**
* Create a user whose id buckets into the wanted variant, anchored to a signup,
* with an optional default subscription.
*
* @param array{status: string, at: CarbonImmutable, endsAt?: CarbonImmutable, refundedAt?: CarbonImmutable}|null $subscription
*/
function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscription = null): User
{
do {
$id = (string) Str::uuid();
} while (SubscriptionExperiment::bucket($id) !== $variant);
$user = User::factory()->create(['id' => $id, 'created_at' => $signup]);
if ($subscription !== null) {
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_'.Str::random(12),
'stripe_status' => $subscription['status'],
'stripe_price' => 'price_test',
'created_at' => $subscription['at'],
'ends_at' => $subscription['endsAt'] ?? null,
'refunded_at' => $subscription['refundedAt'] ?? null,
]);
}
return $user;
}
it('returns empty variants when the experiment has not started', function () {
config(['subscriptions.experiment.started_at' => null]);
$report = app(ExperimentFunnelCollector::class)->collect();
expect($report['startedAt'])->toBeNull()
->and($report['variants'][SubscriptionExperiment::CONTROL]['assigned'])->toBe(0);
});
it('attributes users and their subscription status to the right variant', function () {
$signup = CarbonImmutable::parse('2026-06-05'); // paid-mature for every variant by the test clock
experimentUser(SubscriptionExperiment::CONTROL, $signup); // assigned, no sub
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
experimentUser(SubscriptionExperiment::PAY_NOW, $signup, ['status' => 'active', 'at' => $signup]);
// pay_now refund: active charge that was refunded -> canceled and not counted as net active.
experimentUser(SubscriptionExperiment::PAY_NOW, $signup, [
'status' => 'canceled',
'at' => $signup,
'endsAt' => $signup->addDay(),
'refundedAt' => $signup->addDay(),
]);
$variants = app(ExperimentFunnelCollector::class)->collect()['variants'];
expect($variants[SubscriptionExperiment::CONTROL]['assigned'])->toBe(2)
->and($variants[SubscriptionExperiment::CONTROL]['subscribed'])->toBe(1)
->and($variants[SubscriptionExperiment::CONTROL]['active'])->toBe(1)
->and($variants[SubscriptionExperiment::CONTROL]['netActiveRate'])->toBe(0.5)
->and($variants[SubscriptionExperiment::REDUCED_TRIAL]['active'])->toBe(1)
->and($variants[SubscriptionExperiment::PAY_NOW]['assigned'])->toBe(2)
->and($variants[SubscriptionExperiment::PAY_NOW]['active'])->toBe(1)
->and($variants[SubscriptionExperiment::PAY_NOW]['refunded'])->toBe(1)
->and($variants[SubscriptionExperiment::PAY_NOW]['activeMature'])->toBe(1);
});
it('computes MRR and ARPU from the net-active subscriptions', function () {
$signup = CarbonImmutable::parse('2026-06-05');
// One control assigned with no plan, one converted (€3.99/mo net-active).
experimentUser(SubscriptionExperiment::CONTROL, $signup);
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
// A refunded pay_now contributes no revenue.
experimentUser(SubscriptionExperiment::PAY_NOW, $signup, [
'status' => 'canceled', 'at' => $signup, 'endsAt' => $signup->addDay(), 'refundedAt' => $signup->addDay(),
]);
$report = app(ExperimentFunnelCollector::class)->collect();
$control = $report['variants'][SubscriptionExperiment::CONTROL];
$payNow = $report['variants'][SubscriptionExperiment::PAY_NOW];
expect($report['revenueAvailable'])->toBeTrue()
->and($report['currency'])->toBe('EUR')
->and($control['mrrCents'])->toBe(399) // one €3.99 net-active sub
->and($control['arpuCents'])->toBe(200) // 399 / 2 assigned, rounded
->and($payNow['mrrCents'])->toBe(0) // refunded → no revenue
->and($payNow['arpuCents'])->toBe(0);
});
it('marks revenue unavailable when Stripe prices cannot be loaded', function () {
Cache::forget('experiment_funnel_monthly_equiv');
Cache::put('experiment_funnel_monthly_equiv', [], now()->addHour()); // empty = unavailable
experimentUser(SubscriptionExperiment::CONTROL, CarbonImmutable::parse('2026-06-05'), [
'status' => 'active', 'at' => CarbonImmutable::parse('2026-06-05'),
]);
$report = app(ExperimentFunnelCollector::class)->collect();
expect($report['revenueAvailable'])->toBeFalse()
->and($report['variants'][SubscriptionExperiment::CONTROL]['mrrCents'])->toBe(0);
});
it('leaves young cohorts out of the mature net-active rate', function () {
// pay_now decides in 3d (+3 buffer); a 2-day-old signup is not mature yet.
experimentUser(SubscriptionExperiment::PAY_NOW, CarbonImmutable::now()->subDays(2), [
'status' => 'active',
'at' => CarbonImmutable::now()->subDays(2),
]);
$payNow = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::PAY_NOW];
expect($payNow['assigned'])->toBe(1)
->and($payNow['active'])->toBe(1)
->and($payNow['assignedMature'])->toBe(0)
->and($payNow['netActiveRate'])->toBeNull();
});
it('posts the experiment funnel embed to discord', function () {
config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']);
Http::fake(['discord.test/*' => Http::response('', 204)]);
experimentUser(SubscriptionExperiment::CONTROL, CarbonImmutable::parse('2026-06-05'), [
'status' => 'active',
'at' => CarbonImmutable::parse('2026-06-05'),
]);
artisan('stats:experiment-funnel')->assertSuccessful();
Http::assertSent(fn ($request) => $request->url() === 'https://discord.test/hook'
&& str_contains($request['embeds'][0]['title'], 'Experiment'));
});
it('does not post when the experiment has not started', function () {
config(['subscriptions.experiment.started_at' => null]);
Http::fake();
artisan('stats:experiment-funnel')->assertSuccessful();
Http::assertNothingSent();
});

View File

@ -0,0 +1,122 @@
<?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.enabled' => true,
'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 a null (guest) scope as legacy', function () {
expect((new SubscriptionExperiment)->resolve(null))->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('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 = [];
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('exposes the experiment offer on the paywall', function () {
$user = User::factory()->onboarded()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]);
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::REDUCED_TRIAL);
$this->actingAs($user)
->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('offer.variant', SubscriptionExperiment::REDUCED_TRIAL)
->where('offer.payNow', false)
->where('offer.trialDays.monthly', 3)
->where('offer.trialDays.yearly', 7));
});
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);
});