feat: reuse the upgrade modal at more upsell points and attribute revenue (#699)

> **Stacked on #696.** That PR introduced the AI-categorization upgrade
dialog this one generalizes. It targets `main` so CI runs, so until #696
merges this PR's diff also contains #696's commit — review/merge #696
first, then this diff resolves to just its own three commits.

## What & why

The contextual "this is a paid feature → pick a plan → checkout" modal
built for AI categorization is now a **shared component** reused at two
more Pro-feature entry points, and every checkout it starts is
**attributed to the upsell point** so we can measure revenue per point.

### 1. Reusable upgrade modal

Extracted `AiUpgradeDialog` (+ `PlanCard`) out of `settings/billing.tsx`
into a shared `UpgradeDialog`
(`resources/js/components/subscription/upgrade-dialog.tsx`). It reads
`pricing`/`locale` from `usePage`, takes `title` / `description` /
`source`, renders the plan picker, and links to Stripe checkout. Used
at:

| Point | Trigger | Copy |
|---|---|---|
| AI categorization | Manage Plan toggle (unchanged) | "AI
categorization is a paid feature" |
| **Connections** | "Connect Bank" | "Bank connections are a paid
feature" |
| **Connected accounts** | Create Account → "Connected" | "Connected
accounts are a paid feature" |

Both new points already gated on `isFreePlan`, so the modal only shows
to free users. The old plain `UpgradeConnectionDialog` (which just
routed to billing) is deleted.

### 2. Revenue attribution

The upsell `source` is captured two ways:

- **Intent** — a PostHog `upgrade_checkout_started` event (`{ source,
plan }`) fires on click, matching the repo's existing `{ source }` event
convention.
- **Revenue** — `source` rides the checkout URL (`?plan=&source=`), and
`SubscriptionController::checkout` validates it against the new
`App\Enums\UpsellSource` enum and attaches it as Stripe **subscription
metadata** (`withMetadata`). When the subscription webhook lands,
`PersistUpsellSourceFromStripe` (on Cashier's `WebhookHandled`, so the
row already exists) copies it onto a new `subscriptions.upsell_source`
column — **write-once** (`whereNull`), so a later `subscription.updated`
never overwrites the original attribution.

Measuring revenue per point is then a group-by on
`subscriptions.upsell_source` joined to invoices, using existing local
tooling.

### Scoping notes (from review)
- **Paywall & the billing on-page upgrade button are intentionally left
untagged** (`upsell_source = NULL`). "Upsell source" means a
*feature-gate nudge*; the paywall/billing pages are the baseline upgrade
surface, not a nudge — so they form the null/baseline bucket rather than
getting their own enum value.
- The listener is **synchronous** (not `ShouldQueue` like the sibling
Discord listener) on purpose: a single indexed, idempotent `UPDATE`
doesn't warrant a queue-worker dependency for attribution.
- The PostHog event fires just before a full-page navigation; posthog-js
flushes via beacon but the event (analytics only, not the revenue source
of truth) could occasionally be lost. Cheap to harden later if the
funnel proves lossy.

## Tests
- `upgrade-dialog.test.tsx` — renders per-feature copy, checkout link
carries `plan` + `source`, click fires the PostHog event.
- `SubscriptionTest` — checkout tags valid sources as Stripe metadata
and ignores unknown ones.
- `PersistUpsellSourceFromStripeTest` — persists from webhook metadata,
doesn't overwrite an existing attribution, ignores unknown/absent
sources.

## Demo

Free user hitting both new upsell points (Connections → "Connect Bank",
Accounts → "Connected"):

<!-- 📎 PLACEHOLDER: drag in
~/Downloads/upsell-points-connections-accounts.mp4 -->


https://github.com/user-attachments/assets/a27435d9-2296-4dc9-ae7f-753b6f7950f0



> Note: the clip ends at the modal (doesn't cross into Stripe) because
this local dev env has a pre-existing Stripe tax-rate 500 on
`/subscribe/checkout`, unrelated to this change. The `source` reaching
the checkout URL is verified in the browser and by the tests.
This commit is contained in:
Víctor Falcón 2026-07-18 14:53:20 +02:00 committed by GitHub
parent 52aae3fd67
commit 2b2e4c4a87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 524 additions and 217 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Enums;
/**
* The upsell entry point a subscription checkout was started from, used to
* attribute revenue to each upgrade prompt. The value is carried into Stripe as
* subscription metadata and persisted onto the local subscription so revenue
* can be measured per upsell point.
*
* Mirrored on the frontend by the UpsellSource union in
* resources/js/components/subscription/upgrade-dialog.tsx keep both in sync
* when adding a point (an unknown value is silently dropped by tryFrom()).
*/
enum UpsellSource: string
{
case AiCategorization = 'ai_categorization';
case Connections = 'connections';
case Accounts = 'accounts';
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Actions\Subscription\RefundSelfServe;
use App\Enums\UpsellSource;
use App\Features\SubscriptionExperiment;
use App\Models\User;
use App\Models\UserLead;
@ -87,6 +88,14 @@ class SubscriptionController extends Controller
$subscriptionBuilder->trialDays($trialDays);
}
// Attribute revenue to the upsell point the checkout started from. The
// value rides along as Stripe subscription metadata and is persisted
// locally when the subscription webhook lands (see
// PersistUpsellSourceFromStripe).
if ($source = UpsellSource::tryFrom((string) $request->query('source', ''))) {
$subscriptionBuilder->withMetadata(['upsell_source' => $source->value]);
}
return $subscriptionBuilder->checkout([
'success_url' => route('subscribe.success'),
'cancel_url' => route('subscribe.cancel'),

View File

@ -0,0 +1,40 @@
<?php
namespace App\Listeners;
use App\Enums\UpsellSource;
use Laravel\Cashier\Events\WebhookHandled;
use Laravel\Cashier\Subscription;
/**
* Copies the upsell attribution set at checkout (Stripe subscription metadata)
* onto the local subscription row so revenue can be measured per upsell point.
*
* Listens to WebhookHandled (fired after Cashier has already upserted the local
* subscription) and only fills the column while it's empty, so a later
* subscription.updated event never overwrites the original attribution.
*/
class PersistUpsellSourceFromStripe
{
public function handle(WebhookHandled $event): void
{
$type = $event->payload['type'] ?? null;
if (! is_string($type) || ! str_starts_with($type, 'customer.subscription.')) {
return;
}
$object = $event->payload['data']['object'] ?? [];
$stripeId = $object['id'] ?? null;
$source = UpsellSource::tryFrom((string) ($object['metadata']['upsell_source'] ?? ''));
if (! is_string($stripeId) || $stripeId === '' || $source === null) {
return;
}
Subscription::query()
->where('stripe_id', $stripeId)
->whereNull('upsell_source')
->update(['upsell_source' => $source->value]);
}
}

View File

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

View File

@ -20,6 +20,10 @@
"Allow AI categorization": "Permitir categorización con IA",
"AI categorization is a paid feature": "La categorización con IA es una función de pago",
"Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.": "Suscríbete a un plan para que la IA categorice tus transacciones automáticamente. Podrás activar la IA justo después de suscribirte.",
"Bank connections are a paid feature": "Las conexiones bancarias son una función de pago",
"Subscribe to a plan to automatically sync your transactions and balances straight from your bank.": "Suscríbete a un plan para sincronizar automáticamente tus transacciones y saldos directamente desde tu banco.",
"Connected accounts are a paid feature": "Las cuentas conectadas son una función de pago",
"Subscribe to a plan to link a bank account and keep it synced automatically.": "Suscríbete a un plan para vincular una cuenta bancaria y mantenerla sincronizada automáticamente.",
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Con tu permiso, enviamos los nombres de los comercios de tus transacciones a nuestro proveedor de IA para que pueda sugerir categorías. Puedes revocarlo en cualquier momento.",
"AI categorization enabled": "Categorización con IA activada",
"AI categorization disabled": "Categorización con IA desactivada",
@ -400,7 +404,6 @@
"Bank Name": "Nombre del Banco",
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
"Bank accounts": "Cuentas bancarias",
"Bank connections automatically sync your transactions directly from your bank. This feature requires the Standard Plan.": "Las conexiones bancarias sincronizan automáticamente tus transacciones desde tu banco. Esta función requiere el Plan Standard.",
"Bank logo preview": "Vista previa del logo del banco",
"Bank name": "Nombre del banco",
"Be Smart About Spending & Investing": "Sé Inteligente con tus Gastos e Inversiones",
@ -1507,7 +1510,6 @@
"Spot savings opportunities": "Identifica oportunidades de ahorro",
"Square images only (max": "Solo imágenes cuadradas (máx",
"Standard": "Estandar",
"Standard Plan required": "Plan Standard requerido",
"Start Date": "Fecha de Inicio",
"Start Day": "Día de inicio",
"Start Day of Month": "Día de inicio del mes",

View File

@ -17,6 +17,10 @@
"Allow AI categorization": "Autoriser la catégorisation par IA",
"AI categorization is a paid feature": "La catégorisation par IA est une fonctionnalité payante",
"Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.": "Abonnez-vous à un forfait pour laisser l'IA catégoriser automatiquement vos transactions. Vous pourrez activer l'IA juste après votre abonnement.",
"Bank connections are a paid feature": "Les connexions bancaires sont une fonctionnalité payante",
"Subscribe to a plan to automatically sync your transactions and balances straight from your bank.": "Abonnez-vous à un forfait pour synchroniser automatiquement vos transactions et vos soldes directement depuis votre banque.",
"Connected accounts are a paid feature": "Les comptes connectés sont une fonctionnalité payante",
"Subscribe to a plan to link a bank account and keep it synced automatically.": "Abonnez-vous à un forfait pour associer un compte bancaire et le garder synchronisé automatiquement.",
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Avec votre permission, nous envoyons les noms des commerçants de vos transactions à notre fournisseur d'IA afin qu'il puisse suggérer des catégories. Vous pouvez révoquer cela à tout moment.",
"AI categorization enabled": "Catégorisation par IA activée",
"AI categorization disabled": "Catégorisation par IA désactivée",
@ -378,7 +382,6 @@
"Bank Name": "Nom de la banque",
"Bank account reconnected successfully.": "Compte bancaire reconnecté avec succès.",
"Bank accounts": "comptes bancaires",
"Bank connections automatically sync your transactions directly from your bank. This feature requires the Standard Plan.": "Les connexions bancaires synchronisent automatiquement vos transactions depuis votre banque. Cette fonctionnalité nécessite le forfait standard.",
"Bank logo preview": "Aperçu du logo de la banque",
"Bank name": "Nom de la banque",
"Be Smart About Spending & Investing": "Soyez intelligent avec vos dépenses et vos investissements",
@ -1460,7 +1463,6 @@
"Spot savings opportunities": "Identifier les opportunités d'économies",
"Square images only (max": "Images carrées uniquement (max.",
"Standard": "Standard",
"Standard Plan required": "Plan standard requis",
"Start Date": "Date de début",
"Start Day": "Jour de début",
"Start Day of Month": "Jour de début du mois",

View File

@ -2,7 +2,7 @@ import { store } from '@/actions/App/Http/Controllers/Settings/AccountController
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
import { UpgradeConnectionDialog } from '@/components/open-banking/upgrade-connection-dialog';
import { UpgradeDialog } from '@/components/subscription/upgrade-dialog';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
@ -342,9 +342,14 @@ export function CreateAccountDialog({
connections={connections}
/>
<UpgradeConnectionDialog
<UpgradeDialog
open={upgradeDialogOpen}
onOpenChange={setUpgradeDialogOpen}
title={__('Connected accounts are a paid feature')}
description={__(
'Subscribe to a plan to link a bank account and keep it synced automatically.',
)}
source="accounts"
/>
<IntegrationRequestsDrawer

View File

@ -1,58 +0,0 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { billing } from '@/routes/settings';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { Zap } from 'lucide-react';
interface UpgradeConnectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UpgradeConnectionDialog({
open,
onOpenChange,
}: UpgradeConnectionDialogProps) {
function handleUpgrade() {
router.visit(billing.url());
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/40">
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<DialogTitle>{__('Standard Plan required')}</DialogTitle>
<DialogDescription>
{__(
'Bank connections automatically sync your transactions directly from your bank. This feature requires the Standard Plan.',
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Maybe later')}
</Button>
<Button type="button" onClick={handleUpgrade}>
{__('Upgrade to Standard Plan')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,95 @@
import { PricingConfig } from '@/types/pricing';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { UpgradeDialog } from './upgrade-dialog';
const mocks = vi.hoisted(() => ({ captureEvent: vi.fn() }));
vi.mock('@/lib/posthog', () => ({ captureEvent: mocks.captureEvent }));
const pricing: PricingConfig = {
plans: {
monthly: {
name: 'Monthly',
price: 3.99,
original_price: null,
stripe_lookup_key: 'monthly',
billing_period: 'month',
features: [],
},
yearly: {
name: 'Annual',
price: 23.88,
original_price: 47.88,
stripe_lookup_key: 'yearly',
billing_period: 'year',
features: [],
},
},
defaultPlan: 'yearly',
bestValuePlan: 'yearly',
promo: { enabled: false, code: '', description: '', badge: '' },
currency: 'EUR',
};
vi.mock('@inertiajs/react', () => ({
usePage: () => ({ props: { pricing, locale: 'en' } }),
}));
function renderDialog() {
return render(
<UpgradeDialog
open
onOpenChange={vi.fn()}
title="Bank connections are a paid feature"
description="Subscribe to sync your bank."
source="connections"
/>,
);
}
describe('UpgradeDialog', () => {
beforeEach(() => vi.clearAllMocks());
it('renders the per-feature title and description', () => {
renderDialog();
expect(
screen.getByText('Bank connections are a paid feature'),
).toBeInTheDocument();
expect(
screen.getByText('Subscribe to sync your bank.'),
).toBeInTheDocument();
});
it('links checkout with the selected plan and the upsell source', () => {
renderDialog();
const link = screen
.getByRole('button', { name: /Upgrade to Standard Plan/ })
.closest('a');
// Default plan is the configured default (yearly), and the source rides
// along so the subscription can be attributed to this upsell point.
expect(link).toHaveAttribute(
'href',
expect.stringContaining('plan=yearly'),
);
expect(link).toHaveAttribute(
'href',
expect.stringContaining('source=connections'),
);
});
it('captures a checkout-started event tagged with the source', () => {
renderDialog();
fireEvent.click(
screen.getByRole('button', { name: /Upgrade to Standard Plan/ }),
);
expect(mocks.captureEvent).toHaveBeenCalledWith(
'upgrade_checkout_started',
{ source: 'connections', plan: 'yearly' },
);
});
});

View File

@ -0,0 +1,162 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { captureEvent } from '@/lib/posthog';
import { cn } from '@/lib/utils';
import { checkout } from '@/routes/subscribe';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { ZapIcon } from 'lucide-react';
import { useState } from 'react';
/**
* The upsell entry point a checkout starts from. Mirrors the PHP
* App\Enums\UpsellSource so revenue can be attributed per point.
*/
export type UpsellSource = 'ai_categorization' | 'connections' | 'accounts';
export function PlanCard({
plan,
isSelected,
onSelect,
currency,
locale,
}: {
plan: Plan;
isSelected: boolean;
onSelect: () => void;
currency: string;
locale: string;
}) {
const savingsPercent =
plan.original_price && plan.billing_period === 'year'
? Math.round(
((plan.original_price - plan.price) / plan.original_price) *
100,
)
: null;
const monthlyEquivalent =
plan.billing_period === 'year' ? plan.price / 12 : plan.price;
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex flex-1 flex-col rounded-lg border p-3 text-left transition-all',
isSelected
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
: 'border-border bg-card hover:border-muted-foreground/50',
)}
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{plan.billing_period === 'year'
? __('Annual')
: __('Monthly')}
</span>
{savingsPercent && savingsPercent > 0 && (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{__('Saving')} {savingsPercent}%
</span>
)}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-bold">
{formatCurrency(monthlyEquivalent * 100, currency, locale)}
</span>
<span className="text-sm text-muted-foreground">
{__('/month')}
</span>
</div>
{plan.billing_period === 'year' && (
<span className="mt-2 text-xs text-muted-foreground">
{__('Billed annually at')}{' '}
{formatCurrency(plan.price * 100, currency, locale)}
</span>
)}
</button>
);
}
/**
* A contextual "this is a paid feature" dialog with a plan picker that starts
* Stripe checkout. Reused across upsell points (AI categorization, bank
* connections, connected accounts); each passes its own copy and `source`.
*/
export function UpgradeDialog({
open,
onOpenChange,
title,
description,
source,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
source: UpsellSource;
}) {
const { pricing, locale } = usePage<SharedData>().props;
const planEntries = Object.entries(pricing.plans);
const [selectedPlan, setSelectedPlan] = useState(pricing.defaultPlan);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex gap-3">
{planEntries.map(([key, plan]) => (
<PlanCard
key={key}
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
currency={pricing.currency}
locale={locale}
/>
))}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Maybe later')}
</Button>
<a
href={checkout.url({
query: { plan: selectedPlan, source },
})}
onClick={() =>
captureEvent('upgrade_checkout_started', {
source,
plan: selectedPlan,
})
}
>
<Button className="w-full bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700">
<ZapIcon className="size-4" />
{__('Upgrade to Standard Plan')}
</Button>
</a>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,19 +1,14 @@
import HeadingSmall from '@/components/heading-small';
import { ProBadge } from '@/components/pro-badge';
import {
PlanCard,
UpgradeDialog,
} from '@/components/subscription/upgrade-dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { cn } from '@/lib/utils';
import {
destroy as revokeConsent,
store as storeConsent,
@ -108,71 +103,6 @@ function BenefitsGrid() {
);
}
function PlanCard({
plan,
isSelected,
onSelect,
currency,
locale,
}: {
planKey: string;
plan: Plan;
isSelected: boolean;
onSelect: () => void;
currency: string;
locale: string;
}) {
const savingsPercent =
plan.original_price && plan.billing_period === 'year'
? Math.round(
((plan.original_price - plan.price) / plan.original_price) *
100,
)
: null;
const monthlyEquivalent =
plan.billing_period === 'year' ? plan.price / 12 : plan.price;
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex flex-1 flex-col rounded-lg border p-3 text-left transition-all',
isSelected
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
: 'border-border bg-card hover:border-muted-foreground/50',
)}
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{plan.billing_period === 'year'
? __('Annual')
: __('Monthly')}
</span>
{savingsPercent && savingsPercent > 0 && (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{__('Saving')} {savingsPercent}%
</span>
)}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-bold">
{formatCurrency(monthlyEquivalent * 100, currency, locale)}
</span>
<span className="text-sm text-muted-foreground">
{__('/month')}
</span>
</div>
{plan.billing_period === 'year' && (
<span className="mt-2 text-xs text-muted-foreground">
{__('Billed annually at')}{' '}
{formatCurrency(plan.price * 100, currency, locale)}
</span>
)}
</button>
);
}
function UpgradeSection({
planEntries,
defaultPlan,
@ -209,7 +139,6 @@ function UpgradeSection({
{planEntries.map(([key, plan]) => (
<PlanCard
key={key}
planKey={key}
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
@ -397,75 +326,6 @@ function SubscribedSection({
);
}
function AiUpgradeDialog({
open,
onOpenChange,
planEntries,
defaultPlan,
currency,
locale,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
locale: string;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{__('AI categorization is a paid feature')}
</DialogTitle>
<DialogDescription>
{__(
'Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.',
)}
</DialogDescription>
</DialogHeader>
{/* ponytail: plan cards + checkout button mirror UpgradeSection;
PlanCard is the shared unit not worth a further abstraction
for two call sites. Extract a PlanPicker only if paywall.tsx's
third copy is unified too. */}
<div className="flex gap-3">
{planEntries.map(([key, plan]) => (
<PlanCard
key={key}
planKey={key}
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
currency={currency}
locale={locale}
/>
))}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Maybe later')}
</Button>
<a href={checkout.url({ query: { plan: selectedPlan } })}>
<Button className="w-full bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700">
<ZapIcon className="size-4" />
{__('Upgrade to Standard Plan')}
</Button>
</a>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AiConsentSection({
initialConsent,
hasProPlan,
@ -589,13 +449,14 @@ export default function Billing() {
/>
{!hasProPlan && (
<AiUpgradeDialog
<UpgradeDialog
open={showAiUpgrade}
onOpenChange={setShowAiUpgrade}
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
locale={locale}
title={__('AI categorization is a paid feature')}
description={__(
'Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.',
)}
source="ai_categorization"
/>
)}
</div>

View File

@ -57,8 +57,8 @@ vi.mock('@/components/open-banking/update-credentials-dialog', () => ({
UpdateCredentialsDialog: () => null,
}));
vi.mock('@/components/open-banking/upgrade-connection-dialog', () => ({
UpgradeConnectionDialog: () => null,
vi.mock('@/components/subscription/upgrade-dialog', () => ({
UpgradeDialog: () => null,
}));
vi.mock('@/components/open-banking/connection-status-badge', () => ({

View File

@ -2,7 +2,7 @@ import { ConnectAccountDialog } from '@/components/open-banking/connect-account-
import { ConnectionStatusBadge } from '@/components/open-banking/connection-status-badge';
import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog';
import { UpdateCredentialsDialog } from '@/components/open-banking/update-credentials-dialog';
import { UpgradeConnectionDialog } from '@/components/open-banking/upgrade-connection-dialog';
import { UpgradeDialog } from '@/components/subscription/upgrade-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
@ -518,9 +518,14 @@ export default function ConnectionsPage({ connections }: Props) {
connections={connections}
/>
<UpgradeConnectionDialog
<UpgradeDialog
open={upgradeDialogOpen}
onOpenChange={setUpgradeDialogOpen}
title={__('Bank connections are a paid feature')}
description={__(
'Subscribe to a plan to automatically sync your transactions and balances straight from your bank.',
)}
source="connections"
/>
{disconnectConnection && (

View File

@ -0,0 +1,78 @@
<?php
use App\Models\User;
use Laravel\Cashier\Events\WebhookHandled;
/**
* @param array<string, mixed> $metadata
* @return array<string, mixed>
*/
function subscriptionWebhookPayload(string $stripeId, array $metadata, string $type = 'customer.subscription.created'): array
{
return [
'type' => $type,
'data' => [
'object' => [
'id' => $stripeId,
'metadata' => $metadata,
],
],
];
}
test('persists the upsell source from stripe metadata onto the local subscription', function () {
$user = User::factory()->create();
$subscription = $user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_upsell_123',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
event(new WebhookHandled(subscriptionWebhookPayload('sub_upsell_123', ['upsell_source' => 'connections'])));
expect($subscription->fresh()->upsell_source)->toBe('connections');
});
test('does not overwrite an already-attributed subscription', function () {
$user = User::factory()->create();
$subscription = $user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_upsell_456',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
'upsell_source' => 'accounts',
]);
event(new WebhookHandled(subscriptionWebhookPayload('sub_upsell_456', ['upsell_source' => 'connections'], 'customer.subscription.updated')));
expect($subscription->fresh()->upsell_source)->toBe('accounts');
});
test('ignores an unknown upsell source value', function () {
$user = User::factory()->create();
$subscription = $user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_upsell_789',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
event(new WebhookHandled(subscriptionWebhookPayload('sub_upsell_789', ['upsell_source' => 'bogus'])));
expect($subscription->fresh()->upsell_source)->toBeNull();
});
test('does nothing for subscriptions without upsell metadata', function () {
$user = User::factory()->create();
$subscription = $user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_upsell_000',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
event(new WebhookHandled(subscriptionWebhookPayload('sub_upsell_000', [])));
expect($subscription->fresh()->upsell_source)->toBeNull();
});

View File

@ -525,6 +525,64 @@ test('checkout applies configured trial days to the subscription builder', funct
$this->get(route('subscribe.checkout', ['plan' => 'monthly']))->assertRedirect();
});
test('checkout tags the subscription with a valid upsell source', function () {
config([
'subscriptions.plans.monthly.trial_days' => 0,
'subscriptions.plans.monthly.stripe_lookup_key' => 'test_monthly_lookup',
]);
Cache::put('stripe_price_id:test_monthly_lookup', 'price_test_monthly', now()->addHour());
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://stripe.test/session'));
$builder = Mockery::mock(SubscriptionBuilder::class);
$builder->shouldReceive('allowPromotionCodes')->once()->andReturnSelf();
$builder->shouldReceive('withMetadata')->once()->with(['upsell_source' => 'connections'])->andReturnSelf();
$builder->shouldReceive('checkout')->once()->andReturn($checkout);
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('hasVerifiedEmail')->andReturn(true);
$user->shouldReceive('hasProPlan')->andReturn(false);
$user->shouldReceive('newSubscription')
->once()
->with('default', 'price_test_monthly')
->andReturn($builder);
$this->withoutMiddleware(HandleInertiaRequests::class);
$this->actingAs($user);
$this->get(route('subscribe.checkout', ['plan' => 'monthly', 'source' => 'connections']))->assertRedirect();
});
test('checkout ignores an unknown upsell source', function () {
config([
'subscriptions.plans.monthly.trial_days' => 0,
'subscriptions.plans.monthly.stripe_lookup_key' => 'test_monthly_lookup',
]);
Cache::put('stripe_price_id:test_monthly_lookup', 'price_test_monthly', now()->addHour());
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://stripe.test/session'));
$builder = Mockery::mock(SubscriptionBuilder::class);
$builder->shouldReceive('allowPromotionCodes')->once()->andReturnSelf();
$builder->shouldNotReceive('withMetadata');
$builder->shouldReceive('checkout')->once()->andReturn($checkout);
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('hasVerifiedEmail')->andReturn(true);
$user->shouldReceive('hasProPlan')->andReturn(false);
$user->shouldReceive('newSubscription')
->once()
->with('default', 'price_test_monthly')
->andReturn($builder);
$this->withoutMiddleware(HandleInertiaRequests::class);
$this->actingAs($user);
$this->get(route('subscribe.checkout', ['plan' => 'monthly', 'source' => 'bogus']))->assertRedirect();
});
test('checkout applies lead promotion code without allowing manual promotion codes', function () {
config([
'subscriptions.plans.monthly.trial_days' => 0,