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