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:
parent
e4be39be12
commit
e5350ff1a6
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
12
lang/es.json
12
lang/es.json
|
|
@ -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",
|
||||
|
|
|
|||
12
lang/fr.json
12
lang/fr.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue