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:
parent
85ea3cc714
commit
f9bf0ea5ff
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' => []]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue