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:
parent
3f6c67631b
commit
ac1476eeff
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue