From ac1476eeffee91a67bd91443c5a10b4c46576275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 5 Mar 2026 11:41:59 +0000 Subject: [PATCH] feat(pricing): dynamic Stripe pricing with locale-aware formatting (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .env.example | 6 +- .../Commands/SyncStripePricesCommand.php | 163 ++++++++++++++++++ .../Controllers/SubscriptionController.php | 29 +++- app/Http/Middleware/HandleInertiaRequests.php | 1 + config/cashier.php | 4 +- config/subscriptions.php | 14 +- .../onboarding/step-create-account.tsx | 11 +- resources/js/pages/settings/billing.tsx | 8 +- resources/js/pages/subscription/paywall.tsx | 48 ++---- resources/js/pages/welcome.tsx | 50 ++---- resources/js/types/pricing.ts | 3 +- .../views/mail/user-lead-invitation.blade.php | 14 +- tests/Feature/SubscriptionTest.php | 3 +- tests/Feature/SyncStripePricesCommandTest.php | 151 ++++++++++++++++ 14 files changed, 402 insertions(+), 103 deletions(-) create mode 100644 app/Console/Commands/SyncStripePricesCommand.php create mode 100644 tests/Feature/SyncStripePricesCommandTest.php diff --git a/.env.example b/.env.example index 063c2384..38c980fe 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/Console/Commands/SyncStripePricesCommand.php b/app/Console/Commands/SyncStripePricesCommand.php new file mode 100644 index 00000000..642c3766 --- /dev/null +++ b/app/Console/Commands/SyncStripePricesCommand.php @@ -0,0 +1,163 @@ +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(" {$plan['name']}"); + + try { + $existing = $this->findPriceByLookupKey($lookupKey); + + if ($existing) { + if ($this->priceMatches($existing, $amountInCents, $currency, $billingPeriod)) { + $this->line(" ✓ Already in sync ({$existing->id})"); + $skipped++; + + continue; + } + + $this->line(' ↻ Price changed — creating new price and transferring lookup key...'); + + if (! $dryRun) { + $price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: true); + $this->line(" ✓ Transferred to {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})"); + } else { + $this->line(" [dry-run] Would create new price and transfer lookup key '{$lookupKey}'"); + } + + $transferred++; + } else { + $this->line(' + No existing price — creating...'); + + if (! $dryRun) { + $price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false); + $this->line(" ✓ Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})"); + } else { + $this->line(" [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); + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 5f58938f..2bd12f2f 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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'); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 47c274ff..a349ffea 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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', diff --git a/config/cashier.php b/config/cashier.php index 4a9b024b..3caff638 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -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'), /* |-------------------------------------------------------------------------- diff --git a/config/subscriptions.php b/config/subscriptions.php index 562125fa..38cec59e 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -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', diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx index fd368e3b..87d3b8c3 100644 --- a/resources/js/components/onboarding/step-create-account.tsx +++ b/resources/js/components/onboarding/step-create-account.tsx @@ -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().props; const openBankingEnabled = features['open-banking']; const [mode, setMode] = useState('select'); @@ -584,8 +585,12 @@ export function StepCreateAccount({ {subscriptionsEnabled && cheapestMonthlyPrice !== null && (

- {__('From')} $ - {cheapestMonthlyPrice.toFixed(0)} + {__('From')}{' '} + {formatCurrency( + cheapestMonthlyPrice * 100, + pricing.currency, + locale, + )} {__('/month')}

)} diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 62a82908..10d80ead 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -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().props; + const { auth, pricing, locale } = usePage().props; + const defaultPlan = pricing.plans[pricing.defaultPlan]; const benefits = [ { @@ -110,7 +112,9 @@ export default function Billing() { {__('Pro Plan Active')} - {__('\u2014 $9/month')} + {defaultPlan + ? `\u2014 ${formatCurrency(defaultPlan.price * 100, pricing.currency, locale)}/${defaultPlan.billing_period}` + : ''}

diff --git a/resources/js/pages/subscription/paywall.tsx b/resources/js/pages/subscription/paywall.tsx index f02260b3..854ca480 100644 --- a/resources/js/pages/subscription/paywall.tsx +++ b/resources/js/pages/subscription/paywall.tsx @@ -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({ )}

- ${monthlyEquivalent} + + {formatCurrency(monthlyEquivalent * 100, currency, locale)} + {getEquivalentBillingLabel(plan.billing_period, __)}
{plan.billing_period === 'year' && ( - {__('Billed annually at')} ${plan.price} + {__('Billed annually at')}{' '} + {formatCurrency(plan.price * 100, currency, locale)} )} @@ -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} /> ))} @@ -344,32 +347,6 @@ function PricingSection({ ); } -function PromoSection() { - return ( -

- {__('Your data is ready')} - - - - - - {__('Discord for 80% off')} - - - - {__("You'll receive an exclusive promo code via DM!")} - - - -

- ); -} - export default function Paywall() { const { pricing, stats, canUseFreePlan } = usePage().props; @@ -392,10 +369,9 @@ export default function Paywall() { - {pricing.promo.enabled && } - {canUseFreePlan && (
- - {pricing.promo.enabled && ( -

- {__( - '\uD83C\uDF89 Get a founder discount \u2022', - )}{' '} - - - - - {__( - 'Join our Discord', - )} - - - - {__( - "You'll receive an\n exclusive promo code via\n DM!", - )} - - - -

- )} )} diff --git a/resources/js/types/pricing.ts b/resources/js/types/pricing.ts index 8b0f8583..8c6ac258 100644 --- a/resources/js/types/pricing.ts +++ b/resources/js/types/pricing.ts @@ -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; } diff --git a/resources/views/mail/user-lead-invitation.blade.php b/resources/views/mail/user-lead-invitation.blade.php index f0f67c2e..6eac4c07 100644 --- a/resources/views/mail/user-lead-invitation.blade.php +++ b/resources/views/mail/user-lead-invitation.blade.php @@ -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: - - -Use code **FOUNDER** to get **80% off** your first period (monthly or yearly!) - - -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) -Get Started for $1 +Get Started ## Built by one person, for real people @@ -42,6 +34,4 @@ Thanks for being interested in what I'm building! Best,
Víctor
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. diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index e7bec62b..3768c0e2 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -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') ) diff --git a/tests/Feature/SyncStripePricesCommandTest.php b/tests/Feature/SyncStripePricesCommandTest.php new file mode 100644 index 00000000..70d6b3f5 --- /dev/null +++ b/tests/Feature/SyncStripePricesCommandTest.php @@ -0,0 +1,151 @@ + $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(); +});