feat(subscriptions): post self-service refunds to Discord in real time

Announce every pay_now self-service refund to the Stripe/ops Discord channel —
green on success, red on failure (with the error, before re-throwing so it still
500s + hits Sentry). Gives the riskiest, least-tested path a real-time signal
the first time a real user hits it. Emitted from the controller so the sandbox
verifier stays silent.
This commit is contained in:
Víctor Falcón 2026-06-27 17:43:04 +02:00
parent 54b9de3683
commit 7cc312fb7c
2 changed files with 76 additions and 2 deletions

View File

@ -7,6 +7,7 @@ use App\Features\SubscriptionExperiment;
use App\Models\AccountBalance;
use App\Models\User;
use App\Models\UserLead;
use App\Services\Discord\DiscordWebhook;
use App\Services\Subscriptions\ExperimentOffer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -18,7 +19,10 @@ use Laravel\Cashier\Checkout;
class SubscriptionController extends Controller
{
public function __construct(private ExperimentOffer $experimentOffer) {}
public function __construct(
private ExperimentOffer $experimentOffer,
private DiscordWebhook $discord,
) {}
public function index(Request $request): Response|RedirectResponse
{
@ -205,12 +209,47 @@ class SubscriptionController extends Controller
->withErrors(['refund' => __('This subscription is no longer eligible for a self-service refund.')]);
}
app(RefundSelfServe::class)->handle($user);
try {
app(RefundSelfServe::class)->handle($user);
} catch (\Throwable $exception) {
$this->discord->send('', [$this->refundEmbed($user, success: false, detail: $exception->getMessage())]);
throw $exception;
}
$this->discord->send('', [$this->refundEmbed($user, success: true)]);
return redirect()->route('settings.billing')
->with('status', __('Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.'));
}
/**
* @return array<string, mixed>
*/
private function refundEmbed(User $user, bool $success, ?string $detail = null): array
{
if (! $success) {
return [
'title' => '🔴 Self-service refund FAILED',
'description' => 'A pay_now refund threw — the user may have been charged without a refund. Check Stripe and Sentry now.',
'color' => 0xED4245,
'fields' => [
['name' => 'User', 'value' => $user->email, 'inline' => false],
['name' => 'Error', 'value' => substr((string) $detail, 0, 1000), 'inline' => false],
],
];
}
return [
'title' => '💸 Self-service refund processed',
'description' => 'A pay_now user refunded within the money-back window — subscription canceled and bank connections disconnected.',
'color' => 0xFAA61A,
'fields' => [
['name' => 'User', 'value' => $user->email, 'inline' => false],
],
];
}
public function billingPortal(Request $request): RedirectResponse
{
if ($request->user()->isDemoAccount()) {

View File

@ -9,6 +9,7 @@ use App\Services\Subscriptions\ExperimentOffer;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Laravel\Cashier\Payment;
use Laravel\Cashier\Subscription;
use Laravel\Pennant\Feature;
@ -90,6 +91,40 @@ it('rejects the refund request when not eligible', function () {
->assertSessionHasErrors(['refund']);
});
it('announces a successful refund to discord', function () {
config(['services.discord.webhook_url' => 'https://discord.test/hook']);
Http::fake(['discord.test/*' => Http::response('', 204)]);
$user = payNowSubscriber();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldReceive('handle')->once();
app()->instance(RefundSelfServe::class, $action);
$this->actingAs($user)
->post(route('settings.billing.refund'))
->assertRedirect(route('settings.billing'));
Http::assertSent(fn ($request) => $request->url() === 'https://discord.test/hook'
&& str_contains(strtolower($request['embeds'][0]['title']), 'refund processed'));
});
it('announces a failed refund to discord and surfaces the error', function () {
config(['services.discord.webhook_url' => 'https://discord.test/hook']);
Http::fake(['discord.test/*' => Http::response('', 204)]);
$user = payNowSubscriber();
$action = Mockery::mock(RefundSelfServe::class);
$action->shouldReceive('handle')->once()->andThrow(new RuntimeException('stripe down'));
app()->instance(RefundSelfServe::class, $action);
$this->actingAs($user)
->post(route('settings.billing.refund'))
->assertStatus(500);
Http::assertSent(fn ($request) => isset($request['embeds'][0]['title'])
&& str_contains(strtolower($request['embeds'][0]['title']), 'failed'));
});
it('refunds the charge, cancels the subscription and disconnects connections', function () {
$payment = new Payment(new PaymentIntent('pi_test_123'));