feat(discord): enrich Stripe event messages and dedupe deliveries (#460)

## Why

The Stripe subscription events posted to the Discord admin feed weren't
useful (only status + raw `cus_123` id) and sometimes arrived duplicated
— including two cancellation messages for the same subscription.

## What changed

**Richer messages** (`PostStripeEventToDiscord`)
- Customer name + email (resolved via the Stripe API), plan + interval
(`€19.99 / month`), status, subscription id.
- A `Changed` field built from Stripe's `previous_attributes` diff (e.g.
`Status: trialing → active`).
- Cancellation reason, trial-end and period-end dates where relevant.
- Invoices now show invoice number + subscription id.

**Deduplication (two causes fixed)**
- *Webhook/queue retries*: guard on the Stripe event id via
`Cache::add(..., 24h)` — first delivery wins, retries skipped.
- *Double cancellation*: Stripe fires `subscription.updated` (cancel
scheduled) **then** `subscription.deleted`. We now label the scheduled
one `🗓️ Cancellation scheduled` distinctly from `👋 Subscription
cancelled`, and only post `subscription.updated` when something
meaningful changed (status, plan, or cancellation), dropping trivial
churn.

**New** `StripeCustomerResolver` service — resolves `Name (email)` from
a customer id, falls back to the id on any error. Injectable so it's
stubbed in tests (no network).

## Tests

9 cases covering rich fields, dedup, scheduled-vs-deleted cancellation,
status-change diff, trivial-update skip, deletion reason, and invoice
amounts. All passing.

Note: this adds one Stripe API call per subscription/invoice event, made
inside the queued listener (no webhook latency).
This commit is contained in:
Víctor Falcón 2026-05-31 12:50:37 +02:00 committed by GitHub
parent 85ea3cc714
commit f9bf0ea5ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 412 additions and 59 deletions

View File

@ -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.');

View File

@ -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<string, array{title: string, color: int}>
*/
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<string, mixed> $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<string, mixed> $payload
* @return array<string, mixed>|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<string, mixed> $object
* @param array<string, mixed> $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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $object
* @return array<int, array<string, mixed>>
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed>
*/
private function field(string $name, string $value, bool $inline): array
{
return ['name' => $name, 'value' => $value, 'inline' => $inline];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Services\Stripe;
use Laravel\Cashier\Cashier;
use Throwable;
class StripeCustomerResolver
{
public function label(?string $customerId): string
{
if (blank($customerId)) {
return 'unknown';
}
try {
$customer = Cashier::stripe()->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;
}
}
}

View File

@ -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,
]);

View File

@ -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<int, array<string, mixed>>
*/
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'));
});

View File

@ -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' => []]);