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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Subscription\RefundSelfServe;
|
use App\Actions\Subscription\RefundSelfServe;
|
||||||
|
use App\Enums\UpsellSource;
|
||||||
use App\Features\SubscriptionExperiment;
|
use App\Features\SubscriptionExperiment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserLead;
|
use App\Models\UserLead;
|
||||||
|
|
@ -87,6 +88,14 @@ class SubscriptionController extends Controller
|
||||||
$subscriptionBuilder->trialDays($trialDays);
|
$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([
|
return $subscriptionBuilder->checkout([
|
||||||
'success_url' => route('subscribe.success'),
|
'success_url' => route('subscribe.success'),
|
||||||
'cancel_url' => route('subscribe.cancel'),
|
'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",
|
"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",
|
"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.",
|
"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.",
|
"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 enabled": "Categorización con IA activada",
|
||||||
"AI categorization disabled": "Categorización con IA desactivada",
|
"AI categorization disabled": "Categorización con IA desactivada",
|
||||||
|
|
@ -400,7 +404,6 @@
|
||||||
"Bank Name": "Nombre del Banco",
|
"Bank Name": "Nombre del Banco",
|
||||||
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
|
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
|
||||||
"Bank accounts": "Cuentas bancarias",
|
"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 logo preview": "Vista previa del logo del banco",
|
||||||
"Bank name": "Nombre del banco",
|
"Bank name": "Nombre del banco",
|
||||||
"Be Smart About Spending & Investing": "Sé Inteligente con tus Gastos e Inversiones",
|
"Be Smart About Spending & Investing": "Sé Inteligente con tus Gastos e Inversiones",
|
||||||
|
|
@ -1507,7 +1510,6 @@
|
||||||
"Spot savings opportunities": "Identifica oportunidades de ahorro",
|
"Spot savings opportunities": "Identifica oportunidades de ahorro",
|
||||||
"Square images only (max": "Solo imágenes cuadradas (máx",
|
"Square images only (max": "Solo imágenes cuadradas (máx",
|
||||||
"Standard": "Estandar",
|
"Standard": "Estandar",
|
||||||
"Standard Plan required": "Plan Standard requerido",
|
|
||||||
"Start Date": "Fecha de Inicio",
|
"Start Date": "Fecha de Inicio",
|
||||||
"Start Day": "Día de inicio",
|
"Start Day": "Día de inicio",
|
||||||
"Start Day of Month": "Día de inicio del mes",
|
"Start Day of Month": "Día de inicio del mes",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@
|
||||||
"Allow AI categorization": "Autoriser la catégorisation par IA",
|
"Allow AI categorization": "Autoriser la catégorisation par IA",
|
||||||
"AI categorization is a paid feature": "La catégorisation par IA est une fonctionnalité payante",
|
"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.",
|
"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.",
|
"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 enabled": "Catégorisation par IA activée",
|
||||||
"AI categorization disabled": "Catégorisation par IA désactivée",
|
"AI categorization disabled": "Catégorisation par IA désactivée",
|
||||||
|
|
@ -378,7 +382,6 @@
|
||||||
"Bank Name": "Nom de la banque",
|
"Bank Name": "Nom de la banque",
|
||||||
"Bank account reconnected successfully.": "Compte bancaire reconnecté avec succès.",
|
"Bank account reconnected successfully.": "Compte bancaire reconnecté avec succès.",
|
||||||
"Bank accounts": "comptes bancaires",
|
"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 logo preview": "Aperçu du logo de la banque",
|
||||||
"Bank name": "Nom de la banque",
|
"Bank name": "Nom de la banque",
|
||||||
"Be Smart About Spending & Investing": "Soyez intelligent avec vos dépenses et vos investissements",
|
"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",
|
"Spot savings opportunities": "Identifier les opportunités d'économies",
|
||||||
"Square images only (max": "Images carrées uniquement (max.",
|
"Square images only (max": "Images carrées uniquement (max.",
|
||||||
"Standard": "Standard",
|
"Standard": "Standard",
|
||||||
"Standard Plan required": "Plan standard requis",
|
|
||||||
"Start Date": "Date de début",
|
"Start Date": "Date de début",
|
||||||
"Start Day": "Jour de début",
|
"Start Day": "Jour de début",
|
||||||
"Start Day of Month": "Jour de début du mois",
|
"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 { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
|
||||||
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
|
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
|
||||||
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { CreateButton } from '@/components/ui/create-button';
|
import { CreateButton } from '@/components/ui/create-button';
|
||||||
import {
|
import {
|
||||||
|
|
@ -342,9 +342,14 @@ export function CreateAccountDialog({
|
||||||
connections={connections}
|
connections={connections}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpgradeConnectionDialog
|
<UpgradeDialog
|
||||||
open={upgradeDialogOpen}
|
open={upgradeDialogOpen}
|
||||||
onOpenChange={setUpgradeDialogOpen}
|
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
|
<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 HeadingSmall from '@/components/heading-small';
|
||||||
import { ProBadge } from '@/components/pro-badge';
|
import { ProBadge } from '@/components/pro-badge';
|
||||||
|
import {
|
||||||
|
PlanCard,
|
||||||
|
UpgradeDialog,
|
||||||
|
} from '@/components/subscription/upgrade-dialog';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import SettingsLayout from '@/layouts/settings/layout';
|
import SettingsLayout from '@/layouts/settings/layout';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
destroy as revokeConsent,
|
destroy as revokeConsent,
|
||||||
store as storeConsent,
|
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({
|
function UpgradeSection({
|
||||||
planEntries,
|
planEntries,
|
||||||
defaultPlan,
|
defaultPlan,
|
||||||
|
|
@ -209,7 +139,6 @@ function UpgradeSection({
|
||||||
{planEntries.map(([key, plan]) => (
|
{planEntries.map(([key, plan]) => (
|
||||||
<PlanCard
|
<PlanCard
|
||||||
key={key}
|
key={key}
|
||||||
planKey={key}
|
|
||||||
plan={plan}
|
plan={plan}
|
||||||
isSelected={key === selectedPlan}
|
isSelected={key === selectedPlan}
|
||||||
onSelect={() => setSelectedPlan(key)}
|
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({
|
function AiConsentSection({
|
||||||
initialConsent,
|
initialConsent,
|
||||||
hasProPlan,
|
hasProPlan,
|
||||||
|
|
@ -589,13 +449,14 @@ export default function Billing() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!hasProPlan && (
|
{!hasProPlan && (
|
||||||
<AiUpgradeDialog
|
<UpgradeDialog
|
||||||
open={showAiUpgrade}
|
open={showAiUpgrade}
|
||||||
onOpenChange={setShowAiUpgrade}
|
onOpenChange={setShowAiUpgrade}
|
||||||
planEntries={planEntries}
|
title={__('AI categorization is a paid feature')}
|
||||||
defaultPlan={pricing.defaultPlan}
|
description={__(
|
||||||
currency={pricing.currency}
|
'Subscribe to a plan to let AI categorize your transactions automatically. You can enable AI right after subscribing.',
|
||||||
locale={locale}
|
)}
|
||||||
|
source="ai_categorization"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ vi.mock('@/components/open-banking/update-credentials-dialog', () => ({
|
||||||
UpdateCredentialsDialog: () => null,
|
UpdateCredentialsDialog: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/open-banking/upgrade-connection-dialog', () => ({
|
vi.mock('@/components/subscription/upgrade-dialog', () => ({
|
||||||
UpgradeConnectionDialog: () => null,
|
UpgradeDialog: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/open-banking/connection-status-badge', () => ({
|
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 { ConnectionStatusBadge } from '@/components/open-banking/connection-status-badge';
|
||||||
import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog';
|
import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog';
|
||||||
import { UpdateCredentialsDialog } from '@/components/open-banking/update-credentials-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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -518,9 +518,14 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
connections={connections}
|
connections={connections}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpgradeConnectionDialog
|
<UpgradeDialog
|
||||||
open={upgradeDialogOpen}
|
open={upgradeDialogOpen}
|
||||||
onOpenChange={setUpgradeDialogOpen}
|
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 && (
|
{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();
|
$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 () {
|
test('checkout applies lead promotion code without allowing manual promotion codes', function () {
|
||||||
config([
|
config([
|
||||||
'subscriptions.plans.monthly.trial_days' => 0,
|
'subscriptions.plans.monthly.trial_days' => 0,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue