diff --git a/app/Console/Commands/SendDailyStatsReportCommand.php b/app/Console/Commands/SendDailyStatsReportCommand.php index ec249b76..aa7a4eba 100644 --- a/app/Console/Commands/SendDailyStatsReportCommand.php +++ b/app/Console/Commands/SendDailyStatsReportCommand.php @@ -41,8 +41,12 @@ class SendDailyStatsReportCommand extends Command ->whereBetween('created_at', [$yesterdayStart->copy()->utc(), $todayStart->copy()->utc()]) ->count(); + $totalUsers = User::query() + ->where('created_at', '<', $todayStart->copy()->utc()) + ->count(); + $this->discord->send('', [ - $this->buildEmbed($stats, $newUsers, User::query()->count(), $yesterdayStart), + $this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart), ]); $this->info('Daily stats report sent to Discord.'); diff --git a/app/Listeners/PostStripeEventToDiscord.php b/app/Listeners/PostStripeEventToDiscord.php index 46e36d43..61fb709e 100644 --- a/app/Listeners/PostStripeEventToDiscord.php +++ b/app/Listeners/PostStripeEventToDiscord.php @@ -3,90 +3,243 @@ namespace App\Listeners; use App\Services\Discord\DiscordWebhook; +use App\Services\Stripe\StripeCustomerResolver; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Support\Facades\Cache; use Laravel\Cashier\Events\WebhookReceived; class PostStripeEventToDiscord implements ShouldQueue { - /** - * @var array - */ - 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], - ]; + private const DEDUPE_TTL_HOURS = 24; - public function __construct(private DiscordWebhook $discord) {} + private const COLOR_GREEN = 0x57F287; + + private const COLOR_BLUE = 0x5865F2; + + private const COLOR_RED = 0xED4245; + + private const COLOR_ORANGE = 0xFEE75C; + + public function __construct( + private DiscordWebhook $discord, + private StripeCustomerResolver $customers, + ) {} public function handle(WebhookReceived $event): void { $type = $event->payload['type'] ?? null; - if (! is_string($type) || ! array_key_exists($type, self::EVENTS)) { + if (! is_string($type)) { return; } - $this->discord->send('', [$this->buildEmbed($type, $event->payload)]); + if ($this->alreadyProcessed($event->payload)) { + return; + } + + $embed = match (true) { + str_starts_with($type, 'invoice.') => $this->invoiceEmbed($type, $event->payload), + str_starts_with($type, 'customer.subscription.') => $this->subscriptionEmbed($type, $event->payload), + default => null, + }; + + if ($embed === null) { + return; + } + + $this->discord->send('', [$embed]); + } + + /** + * Guard against duplicate deliveries: Stripe retries webhooks and the + * queued job may itself be retried. The Stripe event id is unique per + * event, so the first delivery wins and later ones are skipped. + * + * @param array $payload + */ + private function alreadyProcessed(array $payload): bool + { + $id = $payload['id'] ?? null; + + if (! is_string($id) || $id === '') { + return false; + } + + return ! Cache::add('discord:stripe-event:'.$id, true, now()->addHours(self::DEDUPE_TTL_HOURS)); + } + + /** + * @param array $payload + * @return array|null + */ + private function subscriptionEmbed(string $type, array $payload): ?array + { + $object = $payload['data']['object'] ?? []; + $previous = $payload['data']['previous_attributes'] ?? []; + + $meta = match ($type) { + 'customer.subscription.created' => ['title' => '🎉 New subscription', 'color' => self::COLOR_GREEN], + 'customer.subscription.deleted' => ['title' => '👋 Subscription cancelled', 'color' => self::COLOR_RED], + 'customer.subscription.updated' => $this->updatedMeta($object, $previous), + default => null, + }; + + if ($meta === null) { + return null; + } + + $fields = [ + $this->field('Customer', $this->customers->label($this->stringOrNull($object['customer'] ?? null)), true), + $this->field('Status', (string) ($object['status'] ?? 'unknown'), true), + ]; + + if ($plan = $this->planLabel($object)) { + $fields[] = $this->field('Plan', $plan, true); + } + + if ($changes = $this->changeSummary($object, $previous)) { + $fields[] = $this->field('Changed', $changes, false); + } + + if ($reason = $this->cancellationReason($object)) { + $fields[] = $this->field('Cancellation reason', $reason, false); + } + + if (($object['cancel_at_period_end'] ?? false) === true && $end = $this->timestamp($object['current_period_end'] ?? null)) { + $fields[] = $this->field('Ends at', $end, true); + } + + if (($object['status'] ?? null) === 'trialing' && $trialEnd = $this->timestamp($object['trial_end'] ?? null)) { + $fields[] = $this->field('Trial ends', $trialEnd, true); + } + + $fields[] = $this->field('Subscription', (string) ($object['id'] ?? 'unknown'), false); + + return [ + 'title' => $meta['title'], + 'color' => $meta['color'], + 'fields' => $fields, + ]; + } + + /** + * Decide whether a subscription.updated event is worth posting, and how to + * label it. Stripe fires this event for many trivial changes, so we only + * surface meaningful ones: a status change, a plan change, or a + * cancellation being scheduled or reverted. + * + * @param array $object + * @param array $previous + * @return array{title: string, color: int}|null + */ + private function updatedMeta(array $object, array $previous): ?array + { + if (array_key_exists('cancel_at_period_end', $previous)) { + return ($object['cancel_at_period_end'] ?? false) === true + ? ['title' => '🗓️ Cancellation scheduled', 'color' => self::COLOR_ORANGE] + : ['title' => '♻️ Cancellation reverted', 'color' => self::COLOR_BLUE]; + } + + if (array_key_exists('status', $previous)) { + return ['title' => '🔄 Subscription status changed', 'color' => self::COLOR_BLUE]; + } + + if (array_key_exists('items', $previous) || array_key_exists('plan', $previous)) { + return ['title' => '🔄 Plan changed', 'color' => self::COLOR_BLUE]; + } + + return null; } /** * @param array $payload * @return array */ - private function buildEmbed(string $type, array $payload): array + private function invoiceEmbed(string $type, array $payload): array { $object = $payload['data']['object'] ?? []; + $meta = match ($type) { + 'invoice.payment_failed' => ['title' => '⚠️ Payment failed', 'color' => self::COLOR_RED, 'amount' => $object['amount_due'] ?? 0], + default => ['title' => '💰 Payment succeeded', 'color' => self::COLOR_GREEN, 'amount' => $object['amount_paid'] ?? 0], + }; + + $email = $this->stringOrNull($object['customer_email'] ?? null); + + $fields = [ + $this->field('Amount', $this->money((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true), + $this->field('Customer', $email ?? $this->customers->label($this->stringOrNull($object['customer'] ?? null)), true), + ]; + + if ($number = $this->stringOrNull($object['number'] ?? null)) { + $fields[] = $this->field('Invoice', $number, true); + } + + if ($subscription = $this->stringOrNull($object['subscription'] ?? null)) { + $fields[] = $this->field('Subscription', $subscription, false); + } + return [ - 'title' => self::EVENTS[$type]['title'], - 'color' => self::EVENTS[$type]['color'], - 'fields' => $this->fields($type, $object), + 'title' => $meta['title'], + 'color' => $meta['color'], + 'fields' => $fields, ]; } /** + * Build a human-readable summary of what changed, using Stripe's + * previous_attributes diff. + * * @param array $object - * @return array> + * @param array $previous */ - private function fields(string $type, array $object): array + private function changeSummary(array $object, array $previous): ?string { - $fields = []; + $lines = []; - 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; + if (array_key_exists('status', $previous)) { + $lines[] = sprintf('Status: %s → %s', $previous['status'] ?? 'unknown', $object['status'] ?? 'unknown'); } - $fields[] = [ - 'name' => 'Status', - 'value' => (string) ($object['status'] ?? 'unknown'), - 'inline' => true, - ]; - $fields[] = [ - 'name' => 'Customer', - 'value' => (string) ($object['customer'] ?? 'unknown'), - 'inline' => true, - ]; + if (array_key_exists('items', $previous) || array_key_exists('plan', $previous)) { + $lines[] = 'Plan: '.($this->planLabel($object) ?? 'updated'); + } - return $fields; + return $lines === [] ? null : implode("\n", $lines); + } + + /** + * @param array $object + */ + private function planLabel(array $object): ?string + { + $price = $object['items']['data'][0]['price'] ?? null; + + if (! is_array($price) || ! isset($price['unit_amount'])) { + return null; + } + + $amount = $this->money((int) $price['unit_amount'], (string) ($price['currency'] ?? 'usd')); + $recurring = $price['recurring'] ?? null; + + if (! is_array($recurring) || ! isset($recurring['interval'])) { + return $amount; + } + + $count = (int) ($recurring['interval_count'] ?? 1); + $interval = $count > 1 ? sprintf('%d %ss', $count, $recurring['interval']) : (string) $recurring['interval']; + + return sprintf('%s / %s', $amount, $interval); + } + + /** + * @param array $object + */ + private function cancellationReason(array $object): ?string + { + $reason = $object['cancellation_details']['reason'] ?? null; + + return is_string($reason) ? str_replace('_', ' ', $reason) : null; } private function money(int $amount, string $currency): string @@ -102,4 +255,26 @@ class PostStripeEventToDiscord implements ShouldQueue return $symbol.number_format($amount / 100, 2); } + + private function timestamp(mixed $value): ?string + { + if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) { + return null; + } + + return now()->setTimestamp((int) $value)->toDayDateTimeString(); + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @return array + */ + private function field(string $name, string $value, bool $inline): array + { + return ['name' => $name, 'value' => $value, 'inline' => $inline]; + } } diff --git a/app/Services/Stripe/StripeCustomerResolver.php b/app/Services/Stripe/StripeCustomerResolver.php new file mode 100644 index 00000000..bdceb973 --- /dev/null +++ b/app/Services/Stripe/StripeCustomerResolver.php @@ -0,0 +1,32 @@ +customers->retrieve($customerId); + + $name = is_string($customer->name) ? trim($customer->name) : ''; + $email = is_string($customer->email) ? trim($customer->email) : ''; + + return match (true) { + $name !== '' && $email !== '' => "{$name} ({$email})", + $email !== '' => $email, + $name !== '' => $name, + default => $customerId, + }; + } catch (Throwable) { + return $customerId; + } + } +} diff --git a/tests/Feature/LoanTest.php b/tests/Feature/LoanTest.php index 14d2b35e..6b140f8f 100644 --- a/tests/Feature/LoanTest.php +++ b/tests/Feature/LoanTest.php @@ -111,7 +111,7 @@ it('projects from existing balance entries instead of original loan params', fun // (e.g. the user has been making extra payments) AccountBalance::create([ 'account_id' => $account->id, - 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance_date' => now()->startOfMonth()->subMonth()->toDateString(), 'balance' => 4489670, ]); @@ -161,7 +161,7 @@ it('generates correct monthly balance when existing entries differ from theoreti // Balance from last month that's lower than theoretical schedule AccountBalance::create([ 'account_id' => $account->id, - 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance_date' => now()->startOfMonth()->subMonth()->toDateString(), 'balance' => 4489670, ]); @@ -192,7 +192,7 @@ it('generates projection from last account balance entry', function () { AccountBalance::create([ 'account_id' => $account->id, - 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance_date' => now()->startOfMonth()->subMonth()->toDateString(), 'balance' => 19700000, ]); @@ -924,7 +924,7 @@ it('returns projected data for loan accounts in balance evolution API', function AccountBalance::create([ 'account_id' => $account->id, - 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance_date' => now()->startOfMonth()->subMonth()->toDateString(), 'balance' => 19700000, ]); @@ -958,7 +958,7 @@ it('does not return projected data for non-loan accounts', function () { AccountBalance::create([ 'account_id' => $account->id, - 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance_date' => now()->startOfMonth()->subMonth()->toDateString(), 'balance' => 500000, ]); diff --git a/tests/Feature/PostStripeEventToDiscordTest.php b/tests/Feature/PostStripeEventToDiscordTest.php index bfe500a7..38c1d59b 100644 --- a/tests/Feature/PostStripeEventToDiscordTest.php +++ b/tests/Feature/PostStripeEventToDiscordTest.php @@ -2,6 +2,7 @@ use App\Listeners\PostStripeEventToDiscord; use App\Services\Discord\DiscordWebhook; +use App\Services\Stripe\StripeCustomerResolver; use Illuminate\Support\Facades\Http; use Laravel\Cashier\Events\WebhookReceived; @@ -11,25 +12,65 @@ beforeEach(function () { function handleStripeWebhook(array $payload): void { - (new PostStripeEventToDiscord(app(DiscordWebhook::class))) + $resolver = new class extends StripeCustomerResolver + { + public function label(?string $customerId): string + { + return $customerId === null ? 'unknown' : "Jane Doe ({$customerId})"; + } + }; + + (new PostStripeEventToDiscord(app(DiscordWebhook::class), $resolver)) ->handle(new WebhookReceived($payload)); } -test('posts a message for a created subscription', function () { +/** + * @return array> + */ +function sentFields(): array +{ + $fields = []; + + Http::assertSent(function ($request) use (&$fields) { + $fields = $request['embeds'][0]['fields'] ?? []; + + return true; + }); + + return $fields; +} + +test('posts a message for a created subscription with rich fields', function () { Http::fake(); handleStripeWebhook([ + 'id' => 'evt_1', 'type' => 'customer.subscription.created', - 'data' => ['object' => ['status' => 'active', 'customer' => 'cus_123']], + 'data' => ['object' => [ + 'id' => 'sub_123', + 'status' => 'active', + 'customer' => 'cus_123', + 'items' => ['data' => [['price' => [ + 'unit_amount' => 1999, + 'currency' => 'eur', + 'recurring' => ['interval' => 'month', 'interval_count' => 1], + ]]]], + ]], ]); Http::assertSent(fn ($request) => str_contains($request['embeds'][0]['title'], 'New subscription')); + + $fields = collect(sentFields()); + expect($fields->firstWhere('name', 'Customer')['value'])->toBe('Jane Doe (cus_123)'); + expect($fields->firstWhere('name', 'Plan')['value'])->toBe('€19.99 / month'); + expect($fields->firstWhere('name', 'Subscription')['value'])->toBe('sub_123'); }); test('posts the formatted amount for a succeeded payment', function () { Http::fake(); handleStripeWebhook([ + 'id' => 'evt_2', 'type' => 'invoice.payment_succeeded', 'data' => ['object' => ['amount_paid' => 1999, 'currency' => 'eur', 'customer_email' => 'a@b.com']], ]); @@ -42,6 +83,7 @@ test('uses amount_due for a failed payment', function () { Http::fake(); handleStripeWebhook([ + 'id' => 'evt_3', 'type' => 'invoice.payment_failed', 'data' => ['object' => ['amount_due' => 500, 'currency' => 'usd']], ]); @@ -53,7 +95,88 @@ test('uses amount_due for a failed payment', function () { test('ignores events outside the handled list', function () { Http::fake(); - handleStripeWebhook(['type' => 'charge.refunded', 'data' => ['object' => []]]); + handleStripeWebhook(['id' => 'evt_4', 'type' => 'charge.refunded', 'data' => ['object' => []]]); Http::assertNothingSent(); }); + +test('skips duplicate deliveries of the same event id', function () { + Http::fake(); + + $payload = [ + 'id' => 'evt_dup', + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['id' => 'sub_1', 'status' => 'active', 'customer' => 'cus_1']], + ]; + + handleStripeWebhook($payload); + handleStripeWebhook($payload); + + Http::assertSentCount(1); +}); + +test('labels a scheduled cancellation distinctly from a deletion', function () { + Http::fake(); + + handleStripeWebhook([ + 'id' => 'evt_5', + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => ['id' => 'sub_1', 'status' => 'active', 'customer' => 'cus_1', 'cancel_at_period_end' => true], + 'previous_attributes' => ['cancel_at_period_end' => false], + ], + ]); + + Http::assertSent(fn ($request) => str_contains($request['embeds'][0]['title'], 'Cancellation scheduled')); +}); + +test('reports the status change on a meaningful update', function () { + Http::fake(); + + handleStripeWebhook([ + 'id' => 'evt_6', + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => ['id' => 'sub_1', 'status' => 'active', 'customer' => 'cus_1'], + 'previous_attributes' => ['status' => 'trialing'], + ], + ]); + + Http::assertSent(fn ($request) => str_contains($request['embeds'][0]['title'], 'status changed') + && collect($request['embeds'][0]['fields']) + ->contains(fn ($field) => $field['name'] === 'Changed' && str_contains($field['value'], 'trialing → active'))); +}); + +test('ignores trivial subscription updates', function () { + Http::fake(); + + handleStripeWebhook([ + 'id' => 'evt_7', + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => ['id' => 'sub_1', 'status' => 'active', 'customer' => 'cus_1'], + 'previous_attributes' => ['latest_invoice' => 'in_123'], + ], + ]); + + Http::assertNothingSent(); +}); + +test('posts a cancellation for a deleted subscription with the reason', function () { + Http::fake(); + + handleStripeWebhook([ + 'id' => 'evt_8', + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => [ + 'id' => 'sub_1', + 'status' => 'canceled', + 'customer' => 'cus_1', + 'cancellation_details' => ['reason' => 'cancellation_requested'], + ]], + ]); + + Http::assertSent(fn ($request) => str_contains($request['embeds'][0]['title'], 'Subscription cancelled') + && collect($request['embeds'][0]['fields']) + ->contains(fn ($field) => $field['name'] === 'Cancellation reason' && $field['value'] === 'cancellation requested')); +}); diff --git a/tests/Feature/SendDailyStatsReportCommandTest.php b/tests/Feature/SendDailyStatsReportCommandTest.php index 6f7f3fc6..6017fa41 100644 --- a/tests/Feature/SendDailyStatsReportCommandTest.php +++ b/tests/Feature/SendDailyStatsReportCommandTest.php @@ -32,6 +32,25 @@ test('posts yesterday user counts and stripe stats to discord', function () { }); }); +test('total reflects users at end of yesterday and excludes users created today', function () { + Http::fake(); + bindMockStripeClientForStats(['active' => [], 'trialing' => []]); + + $tz = 'Europe/Madrid'; + User::factory()->create(['created_at' => Carbon::now($tz)->subDays(10)->utc()]); + User::factory()->create(['created_at' => Carbon::now($tz)->subDay()->setTime(12, 0)->utc()]); + User::factory()->create(['created_at' => Carbon::now($tz)->setTime(8, 0)->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: **1**') + && str_contains($users['value'], 'Total: **2**'); + }); +}); + test('reports zero new users when none were created yesterday', function () { Http::fake(); bindMockStripeClientForStats(['active' => [], 'trialing' => []]);