diff --git a/app/Enums/UpsellSource.php b/app/Enums/UpsellSource.php new file mode 100644 index 00000000..af7c35ba --- /dev/null +++ b/app/Enums/UpsellSource.php @@ -0,0 +1,20 @@ +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'), diff --git a/app/Listeners/PersistUpsellSourceFromStripe.php b/app/Listeners/PersistUpsellSourceFromStripe.php new file mode 100644 index 00000000..4a3aecee --- /dev/null +++ b/app/Listeners/PersistUpsellSourceFromStripe.php @@ -0,0 +1,40 @@ +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]); + } +} diff --git a/database/migrations/2026_07_18_111320_add_upsell_source_to_subscriptions_table.php b/database/migrations/2026_07_18_111320_add_upsell_source_to_subscriptions_table.php new file mode 100644 index 00000000..c05365de --- /dev/null +++ b/database/migrations/2026_07_18_111320_add_upsell_source_to_subscriptions_table.php @@ -0,0 +1,28 @@ +string('upsell_source')->nullable()->after('stripe_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('upsell_source'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index d2e7d5b6..b965008a 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/lang/fr.json b/lang/fr.json index fed787bc..4b997c50 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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", diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index 92d245d3..c99ff928 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -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} /> - void; -} - -export function UpgradeConnectionDialog({ - open, - onOpenChange, -}: UpgradeConnectionDialogProps) { - function handleUpgrade() { - router.visit(billing.url()); - } - - return ( - - - -
- -
- {__('Standard Plan required')} - - {__( - 'Bank connections automatically sync your transactions directly from your bank. This feature requires the Standard Plan.', - )} - -
- - - - - -
-
- ); -} diff --git a/resources/js/components/subscription/upgrade-dialog.test.tsx b/resources/js/components/subscription/upgrade-dialog.test.tsx new file mode 100644 index 00000000..c2ad7353 --- /dev/null +++ b/resources/js/components/subscription/upgrade-dialog.test.tsx @@ -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( + , + ); +} + +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' }, + ); + }); +}); diff --git a/resources/js/components/subscription/upgrade-dialog.tsx b/resources/js/components/subscription/upgrade-dialog.tsx new file mode 100644 index 00000000..7bc70fd5 --- /dev/null +++ b/resources/js/components/subscription/upgrade-dialog.tsx @@ -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 ( + + ); +} + +/** + * 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().props; + const planEntries = Object.entries(pricing.plans); + const [selectedPlan, setSelectedPlan] = useState(pricing.defaultPlan); + + return ( + + + + {title} + {description} + + +
+ {planEntries.map(([key, plan]) => ( + setSelectedPlan(key)} + currency={pricing.currency} + locale={locale} + /> + ))} +
+ + + + + captureEvent('upgrade_checkout_started', { + source, + plan: selectedPlan, + }) + } + > + + + +
+
+ ); +} diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 278abcb8..92041649 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -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 ( - - ); -} - function UpgradeSection({ planEntries, defaultPlan, @@ -209,7 +139,6 @@ function UpgradeSection({ {planEntries.map(([key, plan]) => ( 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 ( - - - - - {__('AI categorization is a paid feature')} - - - {__( - 'Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.', - )} - - - - {/* 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. */} -
- {planEntries.map(([key, plan]) => ( - setSelectedPlan(key)} - currency={currency} - locale={locale} - /> - ))} -
- - - - - - - -
-
- ); -} - function AiConsentSection({ initialConsent, hasProPlan, @@ -589,13 +449,14 @@ export default function Billing() { /> {!hasProPlan && ( - )} diff --git a/resources/js/pages/settings/connections.test.tsx b/resources/js/pages/settings/connections.test.tsx index 3c78af99..a7f89614 100644 --- a/resources/js/pages/settings/connections.test.tsx +++ b/resources/js/pages/settings/connections.test.tsx @@ -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', () => ({ diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx index 48853bc4..7b48a6d7 100644 --- a/resources/js/pages/settings/connections.tsx +++ b/resources/js/pages/settings/connections.tsx @@ -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} /> - {disconnectConnection && ( diff --git a/tests/Feature/PersistUpsellSourceFromStripeTest.php b/tests/Feature/PersistUpsellSourceFromStripeTest.php new file mode 100644 index 00000000..a0f991c5 --- /dev/null +++ b/tests/Feature/PersistUpsellSourceFromStripeTest.php @@ -0,0 +1,78 @@ + $metadata + * @return array + */ +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(); +}); diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index 898bb6ae..cb6b9aee 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -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,