diff --git a/app/Console/Commands/SendDailyStatsReportCommand.php b/app/Console/Commands/SendDailyStatsReportCommand.php new file mode 100644 index 00000000..ec249b76 --- /dev/null +++ b/app/Console/Commands/SendDailyStatsReportCommand.php @@ -0,0 +1,117 @@ +collector->collect(); + } catch (ApiErrorException $exception) { + $this->error("Stripe API error: {$exception->getMessage()}"); + + return self::FAILURE; + } + + $yesterdayStart = Carbon::now(self::TIMEZONE)->subDay()->startOfDay(); + $todayStart = Carbon::now(self::TIMEZONE)->startOfDay(); + + $newUsers = User::query() + ->whereBetween('created_at', [$yesterdayStart->copy()->utc(), $todayStart->copy()->utc()]) + ->count(); + + $this->discord->send('', [ + $this->buildEmbed($stats, $newUsers, User::query()->count(), $yesterdayStart), + ]); + + $this->info('Daily stats report sent to Discord.'); + + return self::SUCCESS; + } + + /** + * @param array{active: array, trialing: array} $stats + * @return array + */ + private function buildEmbed(array $stats, int $newUsers, int $totalUsers, Carbon $day): array + { + $fields = [ + [ + 'name' => '👥 Users', + 'value' => "New yesterday: **{$newUsers}**\nTotal: **{$totalUsers}**", + 'inline' => false, + ], + ]; + + $currencies = array_unique(array_merge(array_keys($stats['active']), array_keys($stats['trialing']))); + sort($currencies); + + foreach ($currencies as $currency) { + $active = $stats['active'][$currency] ?? ['count' => 0, 'mrr' => 0.0]; + $trialing = $stats['trialing'][$currency] ?? ['count' => 0, 'mrr' => 0.0]; + + $currentMrr = $active['mrr']; + $projectedMrr = $active['mrr'] + $trialing['mrr']; + + $fields[] = [ + 'name' => '💳 '.strtoupper($currency), + 'value' => implode("\n", [ + "Active: **{$active['count']}** ({$this->money($currentMrr, $currency)} MRR)", + "Trialing: **{$trialing['count']}** ({$this->money($trialing['mrr'], $currency)} MRR)", + "Current MRR/ARR: **{$this->money($currentMrr, $currency)}** / **{$this->money($currentMrr * 12, $currency)}**", + "Projected MRR/ARR: **{$this->money($projectedMrr, $currency)}** / **{$this->money($projectedMrr * 12, $currency)}**", + ]), + 'inline' => false, + ]; + } + + if ($currencies === []) { + $fields[] = [ + 'name' => '💳 Subscriptions', + 'value' => 'No active or trialing subscriptions.', + 'inline' => false, + ]; + } + + return [ + 'title' => '📊 Daily Stats — '.$day->format('D, d M Y'), + 'color' => 0x5865F2, + 'fields' => $fields, + ]; + } + + 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); + } +} diff --git a/app/Console/Commands/StripeSubscriptionStatsCommand.php b/app/Console/Commands/StripeSubscriptionStatsCommand.php index 16d3c3bc..aa840f14 100644 --- a/app/Console/Commands/StripeSubscriptionStatsCommand.php +++ b/app/Console/Commands/StripeSubscriptionStatsCommand.php @@ -2,10 +2,9 @@ namespace App\Console\Commands; +use App\Services\Stripe\SubscriptionStatsCollector; use Illuminate\Console\Command; -use Laravel\Cashier\Cashier; use Stripe\Exception\ApiErrorException; -use Stripe\Subscription; class StripeSubscriptionStatsCommand extends Command { @@ -23,74 +22,29 @@ class StripeSubscriptionStatsCommand extends Command */ private array $trialing = []; + public function __construct(private SubscriptionStatsCollector $collector) + { + parent::__construct(); + } + public function handle(): int { try { - $this->collect('active', $this->active); - $this->collect('trialing', $this->trialing); + $stats = $this->collector->collect(); } catch (ApiErrorException $exception) { $this->error("Stripe API error: {$exception->getMessage()}"); return self::FAILURE; } + $this->active = $stats['active']; + $this->trialing = $stats['trialing']; + $this->render(); return self::SUCCESS; } - /** - * @param array $bucket - * - * @throws ApiErrorException - */ - private function collect(string $status, array &$bucket): void - { - $subscriptions = Cashier::stripe()->subscriptions->all([ - 'status' => $status, - 'limit' => 100, - 'expand' => ['data.items.data.price'], - ]); - - /** @var Subscription $subscription */ - foreach ($subscriptions->autoPagingIterator() as $subscription) { - $currency = strtolower((string) $subscription->currency); - - $bucket[$currency] ??= ['count' => 0, 'mrr' => 0.0]; - $bucket[$currency]['count']++; - $bucket[$currency]['mrr'] += $this->monthlyValue($subscription); - } - } - - private function monthlyValue(Subscription $subscription): float - { - $monthly = 0.0; - - foreach ($subscription->items->data as $item) { - $price = $item->price; - - if ($price->recurring === null) { - continue; - } - - $amount = ($price->unit_amount ?? 0) / 100; - $quantity = $item->quantity ?? 1; - $intervalCount = $price->recurring->interval_count ?: 1; - - $perMonth = match ($price->recurring->interval) { - 'day' => $amount * 365 / 12, - 'week' => $amount * 52 / 12, - 'month' => $amount, - 'year' => $amount / 12, - default => 0.0, - }; - - $monthly += ($perMonth / $intervalCount) * $quantity; - } - - return $monthly; - } - private function render(): void { $currencies = array_unique(array_merge(array_keys($this->active), array_keys($this->trialing))); diff --git a/app/Listeners/PostStripeEventToDiscord.php b/app/Listeners/PostStripeEventToDiscord.php new file mode 100644 index 00000000..46e36d43 --- /dev/null +++ b/app/Listeners/PostStripeEventToDiscord.php @@ -0,0 +1,105 @@ + + */ + private const EVENTS = [ + 'customer.subscription.created' => ['title' => '🎉 New subscription', 'color' => 0x57F287], + 'customer.subscription.updated' => ['title' => '🔄 Subscription updated', 'color' => 0x5865F2], + 'customer.subscription.deleted' => ['title' => '👋 Subscription cancelled', 'color' => 0xED4245], + 'invoice.payment_succeeded' => ['title' => '💰 Payment succeeded', 'color' => 0x57F287], + 'invoice.payment_failed' => ['title' => '⚠️ Payment failed', 'color' => 0xED4245], + ]; + + public function __construct(private DiscordWebhook $discord) {} + + public function handle(WebhookReceived $event): void + { + $type = $event->payload['type'] ?? null; + + if (! is_string($type) || ! array_key_exists($type, self::EVENTS)) { + return; + } + + $this->discord->send('', [$this->buildEmbed($type, $event->payload)]); + } + + /** + * @param array $payload + * @return array + */ + private function buildEmbed(string $type, array $payload): array + { + $object = $payload['data']['object'] ?? []; + + return [ + 'title' => self::EVENTS[$type]['title'], + 'color' => self::EVENTS[$type]['color'], + 'fields' => $this->fields($type, $object), + ]; + } + + /** + * @param array $object + * @return array> + */ + private function fields(string $type, array $object): array + { + $fields = []; + + if (str_starts_with($type, 'invoice.')) { + $amount = match ($type) { + 'invoice.payment_failed' => $object['amount_due'] ?? 0, + default => $object['amount_paid'] ?? 0, + }; + + $fields[] = [ + 'name' => 'Amount', + 'value' => $this->money((int) $amount, (string) ($object['currency'] ?? 'usd')), + 'inline' => true, + ]; + $fields[] = [ + 'name' => 'Customer', + 'value' => (string) ($object['customer_email'] ?? $object['customer'] ?? 'unknown'), + 'inline' => true, + ]; + + return $fields; + } + + $fields[] = [ + 'name' => 'Status', + 'value' => (string) ($object['status'] ?? 'unknown'), + 'inline' => true, + ]; + $fields[] = [ + 'name' => 'Customer', + 'value' => (string) ($object['customer'] ?? 'unknown'), + 'inline' => true, + ]; + + return $fields; + } + + 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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bda58a86..e50792b1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,13 +9,16 @@ use App\Events\TransactionUpdated; use App\Http\Responses\RegisterResponse; use App\Listeners\ApplyAutomationRules; use App\Listeners\AssignTransactionToBudget; +use App\Listeners\PostStripeEventToDiscord; use App\Listeners\UnassignTransactionFromBudget; use App\Services\Banking\EnableBankingProvider; +use App\Services\Discord\DiscordWebhook; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; +use Laravel\Cashier\Events\WebhookReceived; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; class AppServiceProvider extends ServiceProvider @@ -35,6 +38,10 @@ class AppServiceProvider extends ServiceProvider base_path(config('services.enablebanking.private_key_path')), ); }); + + $this->app->bind(DiscordWebhook::class, function () { + return new DiscordWebhook(config('services.discord.webhook_url')); + }); } /** @@ -46,6 +53,7 @@ class AppServiceProvider extends ServiceProvider Event::listen(TransactionCreated::class, AssignTransactionToBudget::class); Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class); Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class); + Event::listen(WebhookReceived::class, PostStripeEventToDiscord::class); RateLimiter::for('emails', function (object $job): Limit { return Limit::perSecond(30); diff --git a/app/Services/Discord/DiscordWebhook.php b/app/Services/Discord/DiscordWebhook.php new file mode 100644 index 00000000..03e56088 --- /dev/null +++ b/app/Services/Discord/DiscordWebhook.php @@ -0,0 +1,37 @@ +> $embeds + */ + public function send(string $content = '', array $embeds = []): void + { + if (blank($this->webhookUrl)) { + Log::warning('Discord webhook URL not configured, skipping message.'); + + return; + } + + $payload = array_filter([ + 'content' => $content !== '' ? $content : null, + 'embeds' => $embeds !== [] ? $embeds : null, + ]); + + $response = Http::asJson()->post($this->webhookUrl, $payload); + + if ($response->failed()) { + Log::warning('Discord webhook request failed.', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } +} diff --git a/app/Services/Stripe/SubscriptionStatsCollector.php b/app/Services/Stripe/SubscriptionStatsCollector.php new file mode 100644 index 00000000..2da75d4d --- /dev/null +++ b/app/Services/Stripe/SubscriptionStatsCollector.php @@ -0,0 +1,79 @@ +, trialing: array} + * + * @throws ApiErrorException + */ + public function collect(): array + { + return [ + 'active' => $this->collectStatus('active'), + 'trialing' => $this->collectStatus('trialing'), + ]; + } + + /** + * @return array + * + * @throws ApiErrorException + */ + private function collectStatus(string $status): array + { + $bucket = []; + + $subscriptions = Cashier::stripe()->subscriptions->all([ + 'status' => $status, + 'limit' => 100, + 'expand' => ['data.items.data.price'], + ]); + + /** @var Subscription $subscription */ + foreach ($subscriptions->autoPagingIterator() as $subscription) { + $currency = strtolower((string) $subscription->currency); + + $bucket[$currency] ??= ['count' => 0, 'mrr' => 0.0]; + $bucket[$currency]['count']++; + $bucket[$currency]['mrr'] += $this->monthlyValue($subscription); + } + + return $bucket; + } + + private function monthlyValue(Subscription $subscription): float + { + $monthly = 0.0; + + foreach ($subscription->items->data as $item) { + $price = $item->price; + + if ($price->recurring === null) { + continue; + } + + $amount = ($price->unit_amount ?? 0) / 100; + $quantity = $item->quantity ?? 1; + $intervalCount = $price->recurring->interval_count ?: 1; + + $perMonth = match ($price->recurring->interval) { + 'day' => $amount * 365 / 12, + 'week' => $amount * 52 / 12, + 'month' => $amount, + 'year' => $amount / 12, + default => 0.0, + }; + + $monthly += ($perMonth / $intervalCount) * $quantity; + } + + return $monthly; + } +} diff --git a/config/services.php b/config/services.php index 920397cf..a9d48fe8 100644 --- a/config/services.php +++ b/config/services.php @@ -43,4 +43,8 @@ return [ 'redirect_url' => env('ENABLEBANKING_REDIRECT_URL'), ], + 'discord' => [ + 'webhook_url' => env('DISCORD_WEBHOOK_URL'), + ], + ]; diff --git a/routes/console.php b/routes/console.php index b5c04f12..970c55af 100644 --- a/routes/console.php +++ b/routes/console.php @@ -11,3 +11,4 @@ Schedule::command('loans:generate-balances')->monthlyOn(1, '00:00'); Schedule::command('resend:sync-leads')->dailyAt('03:00'); Schedule::command('leads:send-invitations --force --limit=150')->dailyAt('09:00'); Schedule::command('leads:send-re-invitations --force')->dailyAt('09:00'); +Schedule::command('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madrid'); diff --git a/tests/Feature/DiscordWebhookTest.php b/tests/Feature/DiscordWebhookTest.php new file mode 100644 index 00000000..e99ee9ee --- /dev/null +++ b/tests/Feature/DiscordWebhookTest.php @@ -0,0 +1,36 @@ +send('hello', [['title' => 'Test embed']]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://discord.test/webhook' + && $request['content'] === 'hello' + && $request['embeds'][0]['title'] === 'Test embed'; + }); +}); + +test('omits empty content and embeds from the payload', function () { + Http::fake(); + + (new DiscordWebhook('https://discord.test/webhook'))->send('only content'); + + Http::assertSent(function ($request) { + return $request['content'] === 'only content' + && ! isset($request['embeds']); + }); +}); + +test('does not post when the webhook url is missing', function () { + Http::fake(); + + (new DiscordWebhook(null))->send('hello'); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/PostStripeEventToDiscordTest.php b/tests/Feature/PostStripeEventToDiscordTest.php new file mode 100644 index 00000000..bfe500a7 --- /dev/null +++ b/tests/Feature/PostStripeEventToDiscordTest.php @@ -0,0 +1,59 @@ +set('services.discord.webhook_url', 'https://discord.test/webhook'); +}); + +function handleStripeWebhook(array $payload): void +{ + (new PostStripeEventToDiscord(app(DiscordWebhook::class))) + ->handle(new WebhookReceived($payload)); +} + +test('posts a message for a created subscription', function () { + Http::fake(); + + handleStripeWebhook([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['status' => 'active', 'customer' => 'cus_123']], + ]); + + Http::assertSent(fn ($request) => str_contains($request['embeds'][0]['title'], 'New subscription')); +}); + +test('posts the formatted amount for a succeeded payment', function () { + Http::fake(); + + handleStripeWebhook([ + 'type' => 'invoice.payment_succeeded', + 'data' => ['object' => ['amount_paid' => 1999, 'currency' => 'eur', 'customer_email' => 'a@b.com']], + ]); + + Http::assertSent(fn ($request) => collect($request['embeds'][0]['fields']) + ->contains(fn ($field) => $field['value'] === '€19.99')); +}); + +test('uses amount_due for a failed payment', function () { + Http::fake(); + + handleStripeWebhook([ + 'type' => 'invoice.payment_failed', + 'data' => ['object' => ['amount_due' => 500, 'currency' => 'usd']], + ]); + + Http::assertSent(fn ($request) => str_contains($request['embeds'][0]['title'], 'Payment failed') + && collect($request['embeds'][0]['fields'])->contains(fn ($field) => $field['value'] === '$5.00')); +}); + +test('ignores events outside the handled list', function () { + Http::fake(); + + handleStripeWebhook(['type' => 'charge.refunded', 'data' => ['object' => []]]); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/SendDailyStatsReportCommandTest.php b/tests/Feature/SendDailyStatsReportCommandTest.php new file mode 100644 index 00000000..6f7f3fc6 --- /dev/null +++ b/tests/Feature/SendDailyStatsReportCommandTest.php @@ -0,0 +1,49 @@ +set('services.discord.webhook_url', 'https://discord.test/webhook'); +}); + +test('posts yesterday user counts and stripe stats to discord', function () { + Http::fake(); + bindMockStripeClientForStats([ + 'active' => [makeStripeSubscription('eur', 1000, 'month')], + 'trialing' => [makeStripeSubscription('eur', 1000, 'month')], + ]); + + $tz = 'Europe/Madrid'; + User::factory()->create(['created_at' => Carbon::now($tz)->subDay()->setTime(12, 0)->utc()]); + User::factory()->create(['created_at' => Carbon::now($tz)->subDays(10)->utc()]); + + $this->artisan('stats:daily-report')->assertSuccessful(); + + Http::assertSent(function ($request) { + $embed = $request['embeds'][0]; + $users = collect($embed['fields'])->firstWhere('name', '👥 Users'); + + return $request->url() === 'https://discord.test/webhook' + && str_contains($users['value'], 'New yesterday: **1**') + && str_contains($users['value'], 'Total: **2**') + && collect($embed['fields'])->contains(fn ($f) => str_contains($f['value'], '€10.00')); + }); +}); + +test('reports zero new users when none were created yesterday', function () { + Http::fake(); + bindMockStripeClientForStats(['active' => [], 'trialing' => []]); + + User::factory()->create(['created_at' => Carbon::now('Europe/Madrid')->subDays(5)->utc()]); + + $this->artisan('stats:daily-report')->assertSuccessful(); + + Http::assertSent(function ($request) { + $users = collect($request['embeds'][0]['fields'])->firstWhere('name', '👥 Users'); + + return str_contains($users['value'], 'New yesterday: **0**') + && str_contains($users['value'], 'Total: **1**'); + }); +}); diff --git a/tests/Feature/StripeSubscriptionStatsCommandTest.php b/tests/Feature/StripeSubscriptionStatsCommandTest.php index 740980f9..2c0f2692 100644 --- a/tests/Feature/StripeSubscriptionStatsCommandTest.php +++ b/tests/Feature/StripeSubscriptionStatsCommandTest.php @@ -1,64 +1,5 @@ 'subscription', - 'currency' => $currency, - 'items' => [ - 'object' => 'list', - 'data' => [ - [ - 'object' => 'subscription_item', - 'quantity' => $quantity, - 'price' => [ - 'object' => 'price', - 'unit_amount' => $unitAmount, - 'recurring' => [ - 'interval' => $interval, - 'interval_count' => $intervalCount, - ], - ], - ], - ], - ], - ]); -} - -function makeSubscriptionCollection(array $subscriptions): Collection -{ - return Collection::constructFrom([ - 'object' => 'list', - 'has_more' => false, - 'data' => array_map(fn (Subscription $s) => $s->toArray(), $subscriptions), - ]); -} - -/** - * @param array> $byStatus - */ -function bindMockStripeClientForStats(array $byStatus): void -{ - $subscriptionService = Mockery::mock(SubscriptionService::class); - - $subscriptionService->shouldReceive('all') - ->andReturnUsing(function (array $params) use ($byStatus): Collection { - $status = $params['status'] ?? 'active'; - - return makeSubscriptionCollection($byStatus[$status] ?? []); - }); - - $stripeClient = Mockery::mock(StripeClient::class); - $stripeClient->subscriptions = $subscriptionService; - - app()->bind(StripeClient::class, fn () => $stripeClient); -} - test('warns when there are no subscriptions', function () { bindMockStripeClientForStats(['active' => [], 'trialing' => []]); diff --git a/tests/Pest.php b/tests/Pest.php index e892483d..cff5d4c2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -10,6 +10,10 @@ use App\Models\Transaction; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Stripe\Collection as StripeCollection; +use Stripe\Service\SubscriptionService; +use Stripe\StripeClient; +use Stripe\Subscription; use Tests\TestCase; /* @@ -152,6 +156,68 @@ function assertMaxQueries(int $max, Closure $callback, string $context = ''): vo expect($result['count'])->toBeLessThanOrEqual($max); } +/** + * Build a fake Stripe subscription object for stats tests. + */ +function makeStripeSubscription(string $currency, int $unitAmount, string $interval, int $quantity = 1, int $intervalCount = 1): Subscription +{ + return Subscription::constructFrom([ + 'object' => 'subscription', + 'currency' => $currency, + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'object' => 'subscription_item', + 'quantity' => $quantity, + 'price' => [ + 'object' => 'price', + 'unit_amount' => $unitAmount, + 'recurring' => [ + 'interval' => $interval, + 'interval_count' => $intervalCount, + ], + ], + ], + ], + ], + ]); +} + +/** + * @param list $subscriptions + */ +function makeSubscriptionCollection(array $subscriptions): StripeCollection +{ + return StripeCollection::constructFrom([ + 'object' => 'list', + 'has_more' => false, + 'data' => array_map(fn (Subscription $s) => $s->toArray(), $subscriptions), + ]); +} + +/** + * Bind a mocked Stripe client that returns subscriptions per status. + * + * @param array> $byStatus + */ +function bindMockStripeClientForStats(array $byStatus): void +{ + $subscriptionService = Mockery::mock(SubscriptionService::class); + + $subscriptionService->shouldReceive('all') + ->andReturnUsing(function (array $params) use ($byStatus): StripeCollection { + $status = $params['status'] ?? 'active'; + + return makeSubscriptionCollection($byStatus[$status] ?? []); + }); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->subscriptions = $subscriptionService; + + app()->bind(StripeClient::class, fn () => $stripeClient); +} + function createCategoryViaUI($page, string $name, string $color = 'green', string $type = 'Expense'): void { $page->click('Create Category')