refactor: extract duplicated money formatter into App\Support\Money

The same currency-symbol formatter (match on the currency code +
number_format of cents/100) was copy-pasted across five files: the
experiment funnel, subscription stats, daily stats and Stripe price
sync commands, plus the Discord Stripe listener.

Centralize it in a single App\Support\Money::format(int $cents, string
$currency) helper. The helper is the union of the existing symbol maps
(eur/gbp/usd/jpy/brl, uppercased code + trailing space otherwise), which
reproduces every call site's output for the currencies they actually
receive:

- Cents-based sites (experiment funnel, Discord listener, price sync)
  call the helper directly. The price sync command only ever formats the
  Cashier currency (eur), so adding usd/brl symbols is a no-op there.
- The two stats commands work in major units (floats), so their private
  method now just converts to cents and delegates; call sites are
  unchanged.

Pure refactor: no rendered output changes. Covered by a new unit test
(eur, gbp, usd, jpy, brl, unknown currency, case-insensitivity,
thousands separator) and the existing command feature tests.
This commit is contained in:
Víctor Falcón 2026-07-15 09:21:09 +02:00
parent 3db03de86d
commit 5a14d1e606
7 changed files with 71 additions and 69 deletions

View File

@ -6,6 +6,7 @@ use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Models\User;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Stripe\Exception\ApiErrorException;
@ -115,17 +116,12 @@ class SendDailyStatsReportCommand extends Command
];
}
/**
* MRR figures are held in major units (e.g. euros), so convert to cents for
* the shared formatter.
*/
private function money(float $amount, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount, 2);
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -7,6 +7,7 @@ use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\BinomialProportion;
use App\Services\Stats\ExperimentFunnelCollector;
use App\Services\Stats\ProportionSignificance;
use App\Support\Money;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
@ -93,11 +94,11 @@ class SendExperimentFunnelReportCommand extends Command
$row['assignedMature'],
$row['convertedMature'],
$mature ? ((int) round($row['conversionRate'] * 100)).'%' : 'pend',
$showMoney && $row['arpuCents'] !== null ? $this->money($row['arpuCents'], $currency) : '—',
$showMoney ? $this->money($row['mrrCents'], $currency) : '—',
$mature ? $this->money($row['costCents'], $currency) : '—',
$mature ? $this->money($row['wastedCostCents'], $currency) : '—',
$showMoney ? $this->money($row['contributionMarginCents'], $currency) : '—',
$showMoney && $row['arpuCents'] !== null ? Money::format($row['arpuCents'], $currency) : '—',
$showMoney ? Money::format($row['mrrCents'], $currency) : '—',
$mature ? Money::format($row['costCents'], $currency) : '—',
$mature ? Money::format($row['wastedCostCents'], $currency) : '—',
$showMoney ? Money::format($row['contributionMarginCents'], $currency) : '—',
);
}
@ -171,18 +172,6 @@ class SendExperimentFunnelReportCommand extends Command
return number_format($rate * 100, 1).'%';
}
private function money(int $cents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
default => $currency.' ',
};
return $symbol.number_format($cents / 100, 2);
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return array<string, mixed>
@ -208,7 +197,7 @@ class SendExperimentFunnelReportCommand extends Command
'name' => 'Legend',
'value' => sprintf(
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Conv = matured users who ever converted (were charged, net of refund) — time-invariant, so it does not shrink as an older cohort has longer to churn · Conv%% = Conv ÷ MatU (always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of *currently* paying subs (yearly ÷ 12); Conv above MRR is churn · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR Cost · `pend`/`—` = no matured data yet.',
$this->money($report['costPerConnectionCents'], $report['currency']),
Money::format($report['costPerConnectionCents'], $report['currency']),
),
'inline' => false,
],

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Stripe\Exception\ApiErrorException;
@ -77,17 +78,12 @@ class StripeSubscriptionStatsCommand extends Command
$this->newLine();
}
/**
* MRR figures are held in major units (e.g. euros), so convert to cents for
* the shared formatter.
*/
private function format(float $amount, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount, 2);
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Support\Money;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Stripe\Exception\ApiErrorException;
@ -50,6 +51,7 @@ class SyncStripePricesCommand extends Command
$amountInCents = (int) round($plan['price'] * 100);
$billingPeriod = $plan['billing_period'] ?? null;
$productId = config('subscriptions.products.pro');
$formattedAmount = Money::format($amountInCents, $currency);
$this->line(" <options=bold>{$plan['name']}</>");
@ -68,7 +70,7 @@ class SyncStripePricesCommand extends Command
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})");
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$formattedAmount}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create new price and transfer lookup key '{$lookupKey}'");
}
@ -79,9 +81,9 @@ class SyncStripePricesCommand extends Command
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false);
$this->line(" <fg=green>✓</> Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
$this->line(" <fg=green>✓</> Created {$price->id} ({$formattedAmount}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$this->formatAmount($amountInCents, $currency)}/{$billingPeriod}");
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$formattedAmount}/{$billingPeriod}");
}
$created++;
@ -148,16 +150,4 @@ class SyncStripePricesCommand extends Command
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

@ -4,6 +4,7 @@ namespace App\Listeners;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stripe\StripeCustomerResolver;
use App\Support\Money;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
@ -171,7 +172,7 @@ class PostStripeEventToDiscord implements ShouldQueue
$email = $this->stringOrNull($object['customer_email'] ?? null);
$fields = [
$this->field('Amount', $this->money((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true),
$this->field('Amount', Money::format((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true),
$this->field('Customer', $email ?? $this->customers->label($this->stringOrNull($object['customer'] ?? null)), true),
];
@ -237,7 +238,7 @@ class PostStripeEventToDiscord implements ShouldQueue
return null;
}
$label = $this->money((int) $amount, (string) ($price['currency'] ?? 'usd'));
$label = Money::format((int) $amount, (string) ($price['currency'] ?? 'usd'));
$recurring = is_array($price['recurring'] ?? null) ? $price['recurring'] : $price;
if (! isset($recurring['interval'])) {
@ -260,20 +261,6 @@ class PostStripeEventToDiscord implements ShouldQueue
return is_string($reason) ? str_replace('_', ' ', $reason) : null;
}
private function money(int $amount, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount / 100, 2);
}
private function timestamp(mixed $value): ?string
{
if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) {

27
app/Support/Money.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Support;
/**
* Formats a minor-unit amount (cents) with its currency symbol, e.g. "€3.99".
*
* Centralizes the money formatting that was duplicated across the stats report
* commands and the Discord Stripe listener. Currencies without a known symbol
* fall back to the uppercased currency code plus a trailing space ("CHF 3.99").
*/
final class Money
{
public static function format(int $cents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'usd' => '$',
'jpy' => '¥',
'brl' => 'R$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($cents / 100, 2);
}
}

View File

@ -0,0 +1,17 @@
<?php
use App\Support\Money;
it('formats cents with the currency symbol', function (int $cents, string $currency, string $expected) {
expect(Money::format($cents, $currency))->toBe($expected);
})->with([
'eur' => [399, 'eur', '€3.99'],
'gbp' => [399, 'gbp', '£3.99'],
'usd' => [399, 'usd', '$3.99'],
'jpy' => [100000, 'jpy', '¥1,000.00'],
'brl' => [399, 'brl', 'R$3.99'],
'unknown currency falls back to uppercased code' => [399, 'chf', 'CHF 3.99'],
'currency match is case-insensitive' => [399, 'EUR', '€3.99'],
'thousands separator' => [123456, 'eur', '€1,234.56'],
'zero' => [0, 'eur', '€0.00'],
]);