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:
parent
52aae3fd67
commit
2b2e4c4a87
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue