From e5350ff1a6061ee3bdb9596e3b6e925618e39e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 27 Jun 2026 18:00:15 +0200 Subject: [PATCH] feat(subscriptions): trial/pricing A/B/C experiment (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- app/Actions/Subscription/RefundSelfServe.php | 54 +++++ .../SendExperimentFunnelReportCommand.php | 123 ++++++++++ .../Commands/VerifyRefundFlowCommand.php | 105 ++++++++ app/Features/SubscriptionExperiment.php | 70 ++++++ .../Controllers/SubscriptionController.php | 73 +++++- .../Stats/ExperimentFunnelCollector.php | 228 ++++++++++++++++++ .../Subscriptions/ExperimentOffer.php | 93 +++++++ config/subscriptions.php | 30 +++ ...add_refunded_at_to_subscriptions_table.php | 28 +++ lang/es.json | 12 + lang/fr.json | 12 + resources/js/pages/settings/billing.tsx | 92 ++++++- resources/js/pages/subscription/paywall.tsx | 77 +++++- routes/console.php | 1 + routes/settings.php | 3 + tests/Browser/SubscriptionRefundTest.php | 111 +++++++++ tests/Feature/SelfServeRefundTest.php | 200 +++++++++++++++ .../SendExperimentFunnelReportCommandTest.php | 170 +++++++++++++ tests/Feature/SubscriptionExperimentTest.php | 122 ++++++++++ 19 files changed, 1596 insertions(+), 8 deletions(-) create mode 100644 app/Actions/Subscription/RefundSelfServe.php create mode 100644 app/Console/Commands/SendExperimentFunnelReportCommand.php create mode 100644 app/Console/Commands/VerifyRefundFlowCommand.php create mode 100644 app/Features/SubscriptionExperiment.php create mode 100644 app/Services/Stats/ExperimentFunnelCollector.php create mode 100644 app/Services/Subscriptions/ExperimentOffer.php create mode 100644 database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php create mode 100644 tests/Browser/SubscriptionRefundTest.php create mode 100644 tests/Feature/SelfServeRefundTest.php create mode 100644 tests/Feature/SendExperimentFunnelReportCommandTest.php create mode 100644 tests/Feature/SubscriptionExperimentTest.php diff --git a/app/Actions/Subscription/RefundSelfServe.php b/app/Actions/Subscription/RefundSelfServe.php new file mode 100644 index 00000000..ce53a237 --- /dev/null +++ b/app/Actions/Subscription/RefundSelfServe.php @@ -0,0 +1,54 @@ +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(), + ]); + } + } +} diff --git a/app/Console/Commands/SendExperimentFunnelReportCommand.php b/app/Console/Commands/SendExperimentFunnelReportCommand.php new file mode 100644 index 00000000..92e60e7a --- /dev/null +++ b/app/Console/Commands/SendExperimentFunnelReportCommand.php @@ -0,0 +1,123 @@ + '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>} $report + * @return list + */ + 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>} $report + * @return array + */ + 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, + ], + ], + ]; + } +} diff --git a/app/Console/Commands/VerifyRefundFlowCommand.php b/app/Console/Commands/VerifyRefundFlowCommand.php new file mode 100644 index 00000000..3e20e58a --- /dev/null +++ b/app/Console/Commands/VerifyRefundFlowCommand.php @@ -0,0 +1,105 @@ +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 ? 'PASS' : '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; + } +} diff --git a/app/Features/SubscriptionExperiment.php b/app/Features/SubscriptionExperiment.php new file mode 100644 index 00000000..029ad20b --- /dev/null +++ b/app/Features/SubscriptionExperiment.php @@ -0,0 +1,70 @@ +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, + }; + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index fec16c43..39e6db5f 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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 + */ + 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()) { diff --git a/app/Services/Stats/ExperimentFunnelCollector.php b/app/Services/Stats/ExperimentFunnelCollector.php new file mode 100644 index 00000000..f7973886 --- /dev/null +++ b/app/Services/Stats/ExperimentFunnelCollector.php @@ -0,0 +1,228 @@ + + * } + */ + 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 + */ + 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 + */ + 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), + ]; + } +} diff --git a/app/Services/Subscriptions/ExperimentOffer.php b/app/Services/Subscriptions/ExperimentOffer.php new file mode 100644 index 00000000..1110c8d3 --- /dev/null +++ b/app/Services/Subscriptions/ExperimentOffer.php @@ -0,0 +1,93 @@ +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} + */ + 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()); + } +} diff --git a/config/subscriptions.php b/config/subscriptions.php index 8b375b1a..bdaf4005 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -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 diff --git a/database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php b/database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php new file mode 100644 index 00000000..ecde038d --- /dev/null +++ b/database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 783d7ade..b8a9e52a 100644 --- a/lang/es.json +++ b/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", diff --git a/lang/fr.json b/lang/fr.json index 2e5b60c9..257cc47f 100644 --- a/lang/fr.json +++ b/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", diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 7f51c236..0a1ff167 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -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 ( +
+

+ {__('Money-back guarantee')} +

+

+ {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.', + )} +

+ + {confirming ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} + function SubscribedSection({ isDemoAccount, defaultPlan, currency, locale, + refund, }: { isDemoAccount: boolean; defaultPlan: Plan | undefined; currency: string; locale: string; + refund?: RefundInfo; }) { return (
@@ -302,6 +381,10 @@ function SubscribedSection({ )}
+ + {refund?.canSelfRefund && ( + + )} ); } @@ -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} /> ) : ( ; } +interface ExperimentOffer { + variant: string; + payNow: boolean; + refundWindowDays: number; + trialDays: Record; +} + 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 ( +
+

+ {__("You'll be charged :amount today", { amount })} +

+

+ {__( + 'Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.', + { days: offer.refundWindowDays }, + )} +

+
+ ); + } + + const days = offer.trialDays[planKey] ?? 0; + + if (days <= 0) { + return null; + } + + return ( +
+

+ {__(':days-day free trial', { days })} +

+

+ {__( + "You won't be charged until your :days-day trial ends. Cancel anytime before then.", + { days }, + )} +

+
+ ); } 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({ ))} + +