diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 5e4ff06c..39e6db5f 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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 + */ + 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()) { diff --git a/tests/Feature/SelfServeRefundTest.php b/tests/Feature/SelfServeRefundTest.php index a0c4dc9b..9550e588 100644 --- a/tests/Feature/SelfServeRefundTest.php +++ b/tests/Feature/SelfServeRefundTest.php @@ -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'));