feat(pricing): dynamic Stripe pricing with locale-aware formatting (#204)

## Summary

- **Dynamic Stripe price resolution**: Replaces hardcoded
`stripe_price_id` env vars with lookup-key-based resolution
(`stripe_lookup_key`). A new `php artisan stripe:sync-prices` command
creates/updates Stripe prices from `config/subscriptions.php`
automatically.
- **Locale-aware currency formatting**: Replaces all
`getCurrencySymbol() + toFixed(2)` patterns with `formatCurrency()`
(backed by `Intl.NumberFormat`) across `welcome.tsx`, `paywall.tsx`,
`billing.tsx`, and `step-create-account.tsx`, so symbol position and
separators are correct for the user's locale (e.g. `3,90 €` in Spanish).
- **EUR defaults and updated plan prices**: Cashier currency defaulted
to EUR, plan prices updated to €7.80/month and €46.80/year, and
`pricing.currency` is now shared as an Inertia prop.
- **Promo/discount cleanup**: Removed all FOUNDER discount mentions and
Discord community links from the paywall, landing pricing section, and
invitation email.
This commit is contained in:
Víctor Falcón 2026-03-05 11:41:59 +00:00 committed by GitHub
parent 3f6c67631b
commit ac1476eeff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 402 additions and 103 deletions

View File

@ -86,8 +86,10 @@ STRIPE_WEBHOOK_SECRET=
# Subscriptions
SUBSCRIPTIONS_ENABLED=false
STRIPE_PRO_MONTHLY_PRICE_ID=
STRIPE_PRO_YEARLY_PRICE_ID=
# Run `php artisan stripe:sync-prices` to create/update Stripe prices from config.
# Override lookup keys only if you need to use different keys per environment.
STRIPE_PRO_MONTHLY_LOOKUP_KEY=whisper_pro_monthly
STRIPE_PRO_YEARLY_LOOKUP_KEY=whisper_pro_yearly
# Sentry / Bugsink Error Tracking
SENTRY_LARAVEL_DSN=

View File

@ -0,0 +1,163 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Stripe\Exception\ApiErrorException;
use Stripe\Price;
class SyncStripePricesCommand extends Command
{
protected $signature = 'stripe:sync-prices
{--dry-run : Preview changes without creating anything in Stripe}';
protected $description = 'Create or update Stripe prices from config/subscriptions.php using lookup keys';
public function handle(): int
{
$plans = config('subscriptions.plans', []);
$currency = config('cashier.currency', 'eur');
$dryRun = $this->option('dry-run');
if (empty($plans)) {
$this->warn('No plans found in config/subscriptions.php.');
return Command::SUCCESS;
}
if ($dryRun) {
$this->info('Running in dry-run mode — no changes will be made to Stripe.');
}
$this->info('Syncing Stripe prices from config...');
$this->newLine();
$created = 0;
$transferred = 0;
$skipped = 0;
foreach ($plans as $planKey => $plan) {
$lookupKey = $plan['stripe_lookup_key'] ?? null;
if (! $lookupKey) {
$this->warn(" [{$planKey}] No stripe_lookup_key defined — skipping.");
$skipped++;
continue;
}
$amountInCents = (int) round($plan['price'] * 100);
$billingPeriod = $plan['billing_period'] ?? null;
$productId = config('subscriptions.products.pro');
$this->line(" <options=bold>{$plan['name']}</>");
try {
$existing = $this->findPriceByLookupKey($lookupKey);
if ($existing) {
if ($this->priceMatches($existing, $amountInCents, $currency, $billingPeriod)) {
$this->line(" <fg=green>✓</> Already in sync ({$existing->id})");
$skipped++;
continue;
}
$this->line(' <fg=yellow>↻</> Price changed — creating new price and transferring lookup key...');
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: true);
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create new price and transfer lookup key '{$lookupKey}'");
}
$transferred++;
} else {
$this->line(' <fg=yellow>+</> No existing price — creating...');
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false);
$this->line(" <fg=green>✓</> Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$this->formatAmount($amountInCents, $currency)}/{$billingPeriod}");
}
$created++;
}
} catch (ApiErrorException $e) {
$this->error(" ✗ Stripe API error: {$e->getMessage()}");
return Command::FAILURE;
}
}
$this->newLine();
$suffix = $dryRun ? ' (dry-run)' : '';
$this->info("{$created} created, {$transferred} updated, {$skipped} skipped{$suffix}.");
return Command::SUCCESS;
}
/**
* @return \Stripe\Price|null
*/
private function findPriceByLookupKey(string $lookupKey): mixed
{
$response = Cashier::stripe()->prices->all(['lookup_keys' => [$lookupKey], 'limit' => 1]);
return $response->data[0] ?? null;
}
/**
* @throws ApiErrorException
*/
private function createPrice(
string $productId,
int $amountInCents,
string $currency,
?string $billingPeriod,
string $lookupKey,
bool $transferLookupKey,
): Price {
$params = [
'product' => $productId,
'unit_amount' => $amountInCents,
'currency' => $currency,
'lookup_key' => $lookupKey,
'transfer_lookup_key' => $transferLookupKey,
];
if ($billingPeriod) {
$params['recurring'] = ['interval' => $billingPeriod];
}
return Cashier::stripe()->prices->create($params);
}
/**
* @param \Stripe\Price $price
*/
private function priceMatches(mixed $price, int $amountInCents, string $currency, ?string $billingPeriod): bool
{
$currencyMatches = strtolower((string) $price->currency) === strtolower($currency);
$amountMatches = $price->unit_amount === $amountInCents;
$intervalMatches = $price->recurring?->interval === $billingPeriod;
return $currencyMatches && $amountMatches && $intervalMatches;
}
private function formatAmount(int $amountInCents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'jpy' => '¥',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amountInCents / 100, 2);
}
}

View File

@ -6,8 +6,10 @@ use App\Models\AccountBalance;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Checkout;
use Laravel\Pennant\Feature;
@ -70,11 +72,11 @@ class SubscriptionController extends Controller
$planKey = $request->query('plan', config('subscriptions.default_plan'));
$plan = config("subscriptions.plans.{$planKey}");
if (! $plan || ! $plan['stripe_price_id']) {
if (! $plan || ! ($plan['stripe_lookup_key'] ?? null)) {
abort(400, 'Invalid plan selected');
}
$priceId = $plan['stripe_price_id'];
$priceId = $this->resolvePriceIdByLookupKey($plan['stripe_lookup_key']);
return $request->user()
->newSubscription('default', $priceId)
@ -85,6 +87,29 @@ class SubscriptionController extends Controller
]);
}
/**
* Resolve a Stripe price ID from a lookup key, with a 1-hour cache.
*/
private function resolvePriceIdByLookupKey(string $lookupKey): string
{
return Cache::remember(
"stripe_price_id:{$lookupKey}",
now()->addHour(),
function () use ($lookupKey): string {
$prices = Cashier::stripe()->prices->all([
'lookup_keys' => [$lookupKey],
'limit' => 1,
]);
if (empty($prices->data)) {
abort(500, "Stripe price not found for lookup key '{$lookupKey}'. Run `php artisan stripe:sync-prices`.");
}
return $prices->data[0]->id;
}
);
}
public function success(): Response
{
return Inertia::render('subscription/success');

View File

@ -82,6 +82,7 @@ class HandleInertiaRequests extends Middleware
'defaultPlan' => config('subscriptions.default_plan', 'monthly'),
'bestValuePlan' => config('subscriptions.best_value_plan', null),
'promo' => config('subscriptions.promo', []),
'currency' => strtoupper(config('cashier.currency', 'eur')),
],
'chartColorScheme' => $user?->setting?->chart_color_scheme->value ?? 'colorful',
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',

View File

@ -61,7 +61,7 @@ return [
|
*/
'currency' => env('CASHIER_CURRENCY', 'usd'),
'currency' => env('CASHIER_CURRENCY', 'eur'),
/*
|--------------------------------------------------------------------------
@ -74,7 +74,7 @@ return [
|
*/
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'),
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'es'),
/*
|--------------------------------------------------------------------------

View File

@ -37,6 +37,10 @@ return [
| information (name, price, features) and Stripe configuration. The key
| is used as the plan identifier.
|
| Prices are in the configured Cashier currency (see config/cashier.php).
| Run `php artisan stripe:sync-prices` to create or update Stripe prices
| automatically from this config. Prices are referenced by lookup key.
|
| Supported billing_period values: 'month', 'year', null (for lifetime)
|
*/
@ -44,9 +48,9 @@ return [
'plans' => [
'monthly' => [
'name' => 'Pro Monthly',
'price' => 9,
'price' => 7.80,
'original_price' => null,
'stripe_price_id' => env('STRIPE_PRO_MONTHLY_PRICE_ID', 'price_1SbJYkLNIsVExnyvAJhUoSeB'),
'stripe_lookup_key' => env('STRIPE_PRO_MONTHLY_LOOKUP_KEY', 'whisper_pro_monthly'),
'billing_period' => 'month',
'features' => [
'Unlimited accounts',
@ -60,9 +64,9 @@ return [
],
'yearly' => [
'name' => 'Pro Yearly',
'price' => 48,
'original_price' => 144,
'stripe_price_id' => env('STRIPE_PRO_YEARLY_PRICE_ID'),
'price' => 46.80,
'original_price' => 93.60,
'stripe_lookup_key' => env('STRIPE_PRO_YEARLY_LOOKUP_KEY', 'whisper_pro_yearly'),
'billing_period' => 'year',
'features' => [
'Unlimited accounts',

View File

@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { cn } from '@/lib/utils';
import { type SharedData } from '@/types';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import {
@ -70,7 +71,7 @@ export function StepCreateAccount({
onAccountCreated,
onContinue,
}: StepCreateAccountProps) {
const { pricing, subscriptionsEnabled, features } =
const { pricing, subscriptionsEnabled, features, locale } =
usePage<SharedData>().props;
const openBankingEnabled = features['open-banking'];
const [mode, setMode] = useState<AccountMode>('select');
@ -584,8 +585,12 @@ export function StepCreateAccount({
{subscriptionsEnabled &&
cheapestMonthlyPrice !== null && (
<p className="mt-2 text-xs font-medium text-blue-600 dark:text-blue-400">
{__('From')} $
{cheapestMonthlyPrice.toFixed(0)}
{__('From')}{' '}
{formatCurrency(
cheapestMonthlyPrice * 100,
pricing.currency,
locale,
)}
{__('/month')}
</p>
)}

View File

@ -5,6 +5,7 @@ import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { billing } from '@/routes/settings';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import {
@ -24,7 +25,8 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function Billing() {
const { auth } = usePage<SharedData>().props;
const { auth, pricing, locale } = usePage<SharedData>().props;
const defaultPlan = pricing.plans[pricing.defaultPlan];
const benefits = [
{
@ -110,7 +112,9 @@ export default function Billing() {
{__('Pro Plan Active')}
</span>
<span className="text-muted-foreground">
{__('\u2014 $9/month')}
{defaultPlan
? `\u2014 ${formatCurrency(defaultPlan.price * 100, pricing.currency, locale)}/${defaultPlan.billing_period}`
: ''}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">

View File

@ -1,11 +1,5 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCountUp } from '@/hooks/use-count-up';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
@ -242,11 +236,14 @@ function CompactPlanCard({
plan,
isSelected,
onSelect,
currency,
}: {
plan: Plan;
isSelected: boolean;
onSelect: () => void;
currency: string;
}) {
const locale = useLocale();
const savingsPercent =
plan.original_price && plan.billing_period === 'year'
? Math.round(
@ -280,14 +277,17 @@ function CompactPlanCard({
)}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-bold">${monthlyEquivalent}</span>
<span className="text-xl font-bold">
{formatCurrency(monthlyEquivalent * 100, currency, locale)}
</span>
<span className="text-sm text-muted-foreground">
{getEquivalentBillingLabel(plan.billing_period, __)}
</span>
</div>
{plan.billing_period === 'year' && (
<span className="mt-2 text-xs text-muted-foreground">
{__('Billed annually at')} ${plan.price}
{__('Billed annually at')}{' '}
{formatCurrency(plan.price * 100, currency, locale)}
</span>
)}
</button>
@ -297,9 +297,11 @@ function CompactPlanCard({
function PricingSection({
planEntries,
defaultPlan,
currency,
}: {
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const selectedPlanData = planEntries.find(
@ -315,6 +317,7 @@ function PricingSection({
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
currency={currency}
/>
))}
</div>
@ -344,32 +347,6 @@ function PricingSection({
);
}
function PromoSection() {
return (
<p className="flex items-center justify-center gap-2 text-center text-xs text-muted-foreground">
<span>{__('Your data is ready')}</span>
<span></span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://discord.gg/2WZmDW9QZ8"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-[#5865F2] underline-offset-2 hover:underline"
>
{__('Discord for 80% off')}
</a>
</TooltipTrigger>
<TooltipContent>
{__("You'll receive an exclusive promo code via DM!")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
);
}
export default function Paywall() {
const { pricing, stats, canUseFreePlan } =
usePage<PaywallPageProps>().props;
@ -392,10 +369,9 @@ export default function Paywall() {
<PricingSection
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
/>
{pricing.promo.enabled && <PromoSection />}
{canUseFreePlan && (
<div className="text-center">
<button

View File

@ -4,17 +4,12 @@ import Header from '@/components/partials/header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { usePwaInstall } from '@/hooks/use-pwa-install';
import { cn } from '@/lib/utils';
import { store as storeUserLead } from '@/routes/user-leads';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Form, Head, Link, router, usePage } from '@inertiajs/react';
import { CheckIcon, ChevronDownIcon, LockIcon } from 'lucide-react';
@ -108,12 +103,16 @@ function LandingPlanCard({
plan,
isDefault,
isBestValue,
currency,
locale,
}: {
plan: Plan;
isDefault: boolean;
isBestValue: boolean;
promoEnabled: boolean;
promoBadge: string;
currency: string;
locale: string;
}) {
return (
<div
@ -142,11 +141,15 @@ function LandingPlanCard({
<div className="mt-3 flex items-baseline gap-2">
{plan.original_price && (
<span className="text-lg font-medium text-[#706f6c] line-through dark:text-[#A1A09A]">
${plan.original_price}
{formatCurrency(
plan.original_price * 100,
currency,
locale,
)}
</span>
)}
<span className="text-4xl font-bold tracking-tight">
${plan.price}
{formatCurrency(plan.price * 100, currency, locale)}
</span>
<span className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{getBillingLabel(plan.billing_period)}
@ -920,38 +923,11 @@ export default function Welcome({
pricing.promo.enabled
}
promoBadge={pricing.promo.badge}
currency={pricing.currency}
locale={locale}
/>
))}
</div>
{pricing.promo.enabled && (
<p className="text-center text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'\uD83C\uDF89 Get a founder discount \u2022',
)}{' '}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://discord.gg/2WZmDW9QZ8"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-[#5865F2] underline-offset-2 hover:underline"
>
{__(
'Join our Discord',
)}
</a>
</TooltipTrigger>
<TooltipContent>
{__(
"You'll receive an\n exclusive promo code via\n DM!",
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
)}
</div>
</section>
)}

View File

@ -2,7 +2,7 @@ export interface Plan {
name: string;
price: number;
original_price: number | null;
stripe_price_id: string | null;
stripe_lookup_key: string | null;
billing_period: 'month' | 'year' | null;
features: string[];
}
@ -19,4 +19,5 @@ export interface PricingConfig {
defaultPlan: string;
bestValuePlan: string | null;
promo: PromoConfig;
currency: string;
}

View File

@ -11,15 +11,7 @@ I built Whisper Money because I was tired of giving my financial data to big com
It's personal finance, but actually private.
## Special Founder's Offer - Just for You
As someone who believed in us early, I want to offer you something special:
<x-mail::panel>
Use code **FOUNDER** to get **80% off** your first period (monthly or yearly!)
</x-mail::panel>
This gets you full access to everything:
This is your exclusive invitation to get full access to everything:
- Unlimited transaction imports
- Automated categorization rules
@ -28,7 +20,7 @@ This gets you full access to everything:
- Mobile app (iOS & Android)
<x-mail::button :url="config('app.url') . '/register'">
Get Started for $1
Get Started
</x-mail::button>
## Built by one person, for real people
@ -42,6 +34,4 @@ Thanks for being interested in what I'm building!
Best,<br>
Víctor<br>
Founder, Whisper Money
P.S. The FOUNDER code gives you 80% off your first billing period, whether you choose monthly ($1.80 first month, then $9/month) or yearly ($9.60 first year, then $48/year). Cancel anytime, no questions asked.
</x-mail::message>

View File

@ -162,6 +162,7 @@ test('landing page passes subscriptions enabled prop when enabled', function ()
->has('pricing.plans')
->has('pricing.defaultPlan')
->has('pricing.promo')
->has('pricing.currency')
);
});
@ -189,7 +190,7 @@ test('pricing config includes all plan details', function () {
->has('name')
->has('price')
->has('original_price')
->has('stripe_price_id')
->has('stripe_lookup_key')
->has('billing_period')
->has('features')
)

View File

@ -0,0 +1,151 @@
<?php
use Stripe\Collection;
use Stripe\Price;
use Stripe\Service\PriceService;
use Stripe\StripeClient;
function makeStripePrice(string $id, int $unitAmount, string $currency, string $interval): Price
{
return Price::constructFrom([
'id' => $id,
'unit_amount' => $unitAmount,
'currency' => $currency,
'recurring' => ['interval' => $interval],
]);
}
function makeEmptyPriceCollection(): Collection
{
return Collection::constructFrom(['data' => [], 'object' => 'list']);
}
function makePriceCollection(Price $price): Collection
{
return Collection::constructFrom(['data' => [$price->toArray()], 'object' => 'list']);
}
function bindMockStripeClientForSync(array $pricesByLookupKey = [], ?string $createdPriceId = 'price_new123'): void
{
$priceService = Mockery::mock(PriceService::class);
$priceService->shouldReceive('all')
->andReturnUsing(function (array $params) use ($pricesByLookupKey): Collection {
$lookupKey = $params['lookup_keys'][0] ?? null;
$price = $pricesByLookupKey[$lookupKey] ?? null;
return $price ? makePriceCollection($price) : makeEmptyPriceCollection();
});
$priceService->shouldReceive('create')
->andReturnUsing(function () use ($createdPriceId): Price {
return Price::constructFrom(['id' => $createdPriceId]);
});
$stripeClient = Mockery::mock(StripeClient::class);
$stripeClient->prices = $priceService;
app()->bind(StripeClient::class, fn () => $stripeClient);
}
beforeEach(function () {
config([
'subscriptions.plans' => [
'monthly' => [
'name' => 'Monthly',
'price' => 7.80,
'billing_period' => 'month',
'stripe_lookup_key' => 'whisper_pro_monthly',
'features' => [],
],
'yearly' => [
'name' => 'Yearly',
'price' => 46.80,
'billing_period' => 'year',
'stripe_lookup_key' => 'whisper_pro_yearly',
'features' => [],
],
],
'subscriptions.products.pro' => 'prod_test123',
'cashier.currency' => 'eur',
]);
});
test('skips plans without stripe_lookup_key', function () {
config([
'subscriptions.plans' => [
'monthly' => [
'name' => 'Monthly',
'price' => 7.80,
'billing_period' => 'month',
'features' => [],
// no stripe_lookup_key
],
],
]);
$priceService = Mockery::mock(PriceService::class);
$priceService->shouldNotReceive('all');
$priceService->shouldNotReceive('create');
$stripeClient = Mockery::mock(StripeClient::class);
$stripeClient->prices = $priceService;
app()->bind(StripeClient::class, fn () => $stripeClient);
$this->artisan('stripe:sync-prices')
->expectsOutputToContain('No stripe_lookup_key defined')
->assertSuccessful();
});
test('reports correct counts when all prices are already in sync', function () {
bindMockStripeClientForSync([
'whisper_pro_monthly' => makeStripePrice('price_monthly', 780, 'eur', 'month'),
'whisper_pro_yearly' => makeStripePrice('price_yearly', 4680, 'eur', 'year'),
]);
$this->artisan('stripe:sync-prices')
->expectsOutputToContain('0 created, 0 updated, 2 skipped')
->assertSuccessful();
});
test('creates prices that do not yet exist in stripe', function () {
bindMockStripeClientForSync(pricesByLookupKey: [], createdPriceId: 'price_new456');
$this->artisan('stripe:sync-prices')
->expectsOutputToContain('2 created, 0 updated, 0 skipped')
->assertSuccessful();
});
test('transfers lookup key when price amount has changed', function () {
bindMockStripeClientForSync([
'whisper_pro_monthly' => makeStripePrice('price_monthly_old', 999, 'eur', 'month'),
'whisper_pro_yearly' => makeStripePrice('price_yearly_old', 9900, 'eur', 'year'),
]);
$this->artisan('stripe:sync-prices')
->expectsOutputToContain('0 created, 2 updated, 0 skipped')
->assertSuccessful();
});
test('dry run outputs preview without creating prices', function () {
$priceService = Mockery::mock(PriceService::class);
$priceService->shouldReceive('all')->andReturn(makeEmptyPriceCollection());
$priceService->shouldNotReceive('create');
$stripeClient = Mockery::mock(StripeClient::class);
$stripeClient->prices = $priceService;
app()->bind(StripeClient::class, fn () => $stripeClient);
$this->artisan('stripe:sync-prices', ['--dry-run' => true])
->expectsOutputToContain('[dry-run]')
->expectsOutputToContain('2 created, 0 updated, 0 skipped (dry-run)')
->assertSuccessful();
});
test('returns success and warns when no plans are configured', function () {
config(['subscriptions.plans' => []]);
$this->artisan('stripe:sync-prices')
->expectsOutputToContain('No plans found')
->assertSuccessful();
});