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:
parent
54b9de3683
commit
7cc312fb7c
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue