## What
The same currency-symbol money formatter — a `match` on
`strtolower($currency)` returning a symbol, concatenated with
`number_format($cents / 100, 2)` — was copy-pasted across **five**
files:
- `app/Console/Commands/SendExperimentFunnelReportCommand.php`
(`money(int $cents, …)`)
- `app/Listeners/PostStripeEventToDiscord.php` (`money(int $amount, …)`)
- `app/Console/Commands/SyncStripePricesCommand.php` (`formatAmount(int
$amountInCents, …)`)
- `app/Console/Commands/StripeSubscriptionStatsCommand.php`
(`format(float $amount, …)`)
- `app/Console/Commands/SendDailyStatsReportCommand.php` (`money(float
$amount, …)`)
This extracts it into a single `App\Support\Money::format(int $cents,
string $currency): string` helper and removes the duplicated logic from
every site.
## Zero behavior change
The five copies were **not** verbatim — they diverged on three axes:
unit (cents vs. major units), symbol coverage, and default casing. The
shared helper is the **union** of the existing maps
(`eur`/`gbp`/`usd`/`jpy`/`brl`, otherwise uppercased code + trailing
space), and reproduces every call site's output for the currencies each
actually receives:
- **Cents-based sites** (experiment funnel, Discord listener, price
sync) call the helper directly.
- The experiment funnel only ever passes the Cashier currency
(uppercased `EUR` from the collector), so its narrower map / raw-case
default was a dead branch.
- The price sync command only formats `config('cashier.currency')`
(`eur`), so it never hit `usd`/`brl` — adding those symbols is a no-op
here.
- **Major-unit sites** (`StripeSubscriptionStatsCommand`,
`SendDailyStatsReportCommand`) hold MRR as floats, so their private
method now converts to cents (`(int) round($amount * 100)`) and
delegates; call sites are untouched. `number_format` of the
round-tripped value is identical to the original.
## Tests
- New `tests/Unit/Support/MoneyTest.php` covering `eur`, `gbp`, `usd`,
`jpy`, `brl`, an unknown currency (`chf` → `CHF 3.99`),
case-insensitivity, thousands separator, and zero.
- The existing command feature tests (which assert exact rendered
strings like `€10.00`, `€19.99`, `€9.99 / month → €3.99 / month`) still
pass unchanged.
`pint`, `phpstan`, and the affected feature + unit tests are green
locally (42 passing).
## What
Adds a private Discord admin feed with two flows, both posting through
one webhook (`DISCORD_WEBHOOK_URL`):
### 1. Daily stats — `stats:daily-report`
Scheduled **09:00 Europe/Madrid**. Posts an embed with:
- New users created **yesterday** (Madrid calendar day, converted to UTC
for the query)
- Total users
- Active/trialing counts + current & projected **MRR / ARR** per
currency
Reuses the #457 Stripe stats logic, extracted into
`SubscriptionStatsCollector` so the existing `stripe:subscription-stats`
command and this report share one source of truth.
### 2. Stripe events — `PostStripeEventToDiscord`
Queued listener on Cashier's `WebhookReceived`. Posts on:
- `customer.subscription.created` / `updated` / `deleted`
- `invoice.payment_succeeded` / `payment_failed`
## Setup
- `DISCORD_WEBHOOK_URL` — added to prod ✅ (set locally to test)
- Stripe dashboard must send the 5 event types above to the existing
Cashier `/stripe/webhook` endpoint
- A queue worker must be running (listener is `ShouldQueue`)
## Tests
13 passing: Discord client (3), daily report (2), Stripe event listener
(4), plus the existing stats command (4) still green after the refactor.
## What
Adds a `stripe:subscription-stats` Artisan command that prints
subscription stats to the CLI.
Shows, per currency:
- **Active subs** count + their MRR
- **Trialing subs** count + their MRR
- **Current MRR & ARR** (active subscriptions only)
- **Projected MRR & ARR** (if trialing subs convert to active)
## How
- Queries Stripe directly via `Cashier::stripe()`, auto-paginating
`active` and `trialing` subscriptions.
- MRR per subscription sums all items, normalizing each price to a
monthly value (handles day/week/month/year intervals, `interval_count`,
and quantity).
- Groups results per currency (EUR, BRL, …) since aggregating across
currencies would be incorrect.
```bash
php artisan stripe:subscription-stats
```
## Tests
4 Pest feature tests covering empty state, MRR/ARR math, per-currency
grouping, and quantity handling. Mocks the Stripe client.