feat: reuse the upgrade modal at more upsell points and attribute revenue

Extract the AI-categorization upgrade dialog into a shared UpgradeDialog
(plan picker -> Stripe checkout) and use it at two more Pro-feature entry
points that previously showed a plain "go to billing" dialog:

- Connections: "Connect Bank"
- Accounts: the "Connected" account option

Each passes its own copy and an upsell "source". To measure how much revenue
each point drives, the source is captured two ways:

- PostHog "upgrade_checkout_started" event at click (intent), and
- carried into Stripe as subscription metadata (SubscriptionController), then
  persisted onto the local "subscriptions.upsell_source" column when the
  subscription webhook lands (PersistUpsellSourceFromStripe) so revenue can be
  grouped by point with the existing local tooling.

Delete the now-unused UpgradeConnectionDialog.
This commit is contained in:
Víctor Falcón 2026-07-18 13:23:54 +02:00
parent 52aae3fd67
commit 041b2147de
15 changed files with 522 additions and 216 deletions

View File

@ -0,0 +1,16 @@
<?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.
*/
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,164 @@
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,
}: {
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>
);
}
/**
* 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}
planKey={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,
@ -397,75 +327,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 +450,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,