Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

robertkabat's avatar

Stripe, Cashier and prorated subscription refunds - unsure of the logic.

Hello,

I'm working on implementing prorated subscription refunds via API in Laravel and Laravel Cashier, and I'm having trouble understanding some details not covered in the documentation. I'm using Stripe's hosted pages for subscription management.

ULTIMATE GOAL: I want to automatically refund users upon prorated subscription cancellation. I've decided to split the process into two parts: 1) extending Stripe's webhook controller to dispatch a job that calculates and issues the refund using Cashier's refund method, and 2) separately listening for the charge.refundedevent from Stripe to adjust the user's balance on Stripe only if the refund was successful.

I've identified three scenarios I need to cover:

A. User paid for subscription via card. B. User paid via their credit balance in full. C. User paid via card but the amount was reduced by their credit balance.

I want to ensure there won't be any refund problems or missing money. For these scenarios, I assume I need to refund the respective amounts to the original payment methods (card or credit balance).

Regarding scenario C, where the user paid via card and the amount was reduced by their credit balance, I am not sure whether I should refund everything to the card or refund part of it to the balance. Can anyone confirm the correct approach for this situation?

Here is my work-in-progress job code that handles issuing refunds upon subscription cancellation:

<?php

namespace App\Jobs;

use App\Mail\AmountProblemRefund;
use App\Mail\ExceptionProcessingRefund;
use App\Mail\InvoiceProblemRefund;
use App\Mail\NoSubscriptionForRefund;
use App\Mail\NotEnoughCreditBalanceForRefund;
use App\Mail\NoUserForRefund;
use App\Mail\RefundFailed;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Laravel\Cashier\Subscription;
use Stripe\Refund;

class ProcessStripeRefundJob extends Job
{
    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(public array $payload)
    {
    }

    public function run(): void
    {
        try {
            // Find the subscription in the application using the Stripe subscription ID
            $subscription = Subscription::where('stripe_id', $this->payload['data']['object']['id'])->first();

            // Check if the subscription doesn't exist
            if (!$subscription) {
                Mail::to('*****@gmail.com')->send(new NoSubscriptionForRefund($this->payload));
                Log::error('Subscription not found: ' . $this->payload['data']['object']['id']);
                return;
            }

            $user = $subscription->user;

            // Check if the user doesn't exist
            if (!$user) {
                Mail::to('*****@gmail.com')->send(new NoUserForRefund($this->payload));
                Log::error('User not found for subscription: ' . $subscription->id);
                return;
            }

            // Check if the user is currently on trial
            if ($user->onTrial()) {
                // The user is on trial, so no need to refund, just return a success response
                return;
            }

            // Retrieve the proration cancellation invoice, which should be the latest invoice, and it should be
            // available straight after the subscription is canceled, so at this point we should already have it here
            $latestInvoice = $user->findInvoice($this->payload['data']['object']['latest_invoice']);

            // Retrieve the previous invoice with the original subscription amount
            $previousInvoice = $user->findInvoice($latestInvoice->lines->data[0]->proration_details->credited_items->invoice);

            // Check if the invoices exist
            if (!$latestInvoice || !$previousInvoice) {
                Mail::to('*****@gmail.com')->send(new InvoiceProblemRefund(
                    $this->payload, // stripe event payload
                    $this->payload['data']['object']['latest_invoice'], // latest invoice ID
                ));
                Log::error('Latest or previous invoice not found');
                return;
            }

            // Refund amount comes from the first line item of the proration invoice, in my system currently this is the
            // only line item as I offer only one subscription plan and user can only have one subscription at a time
            // that value is in pennies
            $refundAmount = abs($latestInvoice->lines->data[0]['amount']);

            // Check if the refund amount is valid
            if ($refundAmount <= 0) {
                Mail::to('*****@gmail.com')->send(new AmountProblemRefund(
                    $this->payload, // stripe event payload
                    $refundAmount
                ));
                Log::error('Invalid refund amount: ' . $refundAmount);
                return;
            }

            // Get the user's balance - if this is a negative number, it means the user has a credit balance, meaning they
            // have money that they can use to pay for their subscription next month, but we will use that credit balance
            // to refund them prorated amount for the current billing cycle, that value is already in pennies
            $balanceInPennies = abs($user->rawBalance());

            // Determine if the payment was taken from the user's credit balance
            $paymentFromCreditBalance = false; //TODO - implement this

            $paymentFromOtherMethod = true; //TODO - implement this

            // Handle the refund paid via the user's credit balance
            if ($paymentFromCreditBalance) {
                // TODO: Implement a refund process for cases when the payment was taken from the user's credit balance
            }

            // Handle the refund paid via other payment methods
            if ($paymentFromOtherMethod) { // this means the payment was taken from the user's credit card
                // Check if the user has enough credit to cover the refund
                if ($balanceInPennies >= $refundAmount) {
                    // Create a refund in Stripe
                    $refund = $user->refund($previousInvoice->payment_intent, [
                        'amount' => $refundAmount,
                        'reason' => Refund::REASON_REQUESTED_BY_CUSTOMER,
                        'metadata' => [
                            'subscription_id' => $subscription->id,
                            'user_id' => $user->id,
                            'message' => 'Prorated refund for canceled subscription - adjust credit balance after it is processed'
                        ],
                    ]);

                    // Check if the refund was successful
                    if (!$refund) {
                        Log::error('Refund failed for user: ' . $user->id);
                        Mail::to('*****@gmail.com')->send(new RefundFailed(
                            $this->payload,
                            json_encode($refund)
                        ));
                    }
                } else {
                    // email myself about the failed refund
                    Mail::to('*****@gmail.com')->send(new NotEnoughCreditBalanceForRefund($this->payload));
                }
            }

        } catch (Exception $exception) {
            // log the error so I have some backup in case the email fails
            Log::error('Error processing refund', [
                'payload' => $this->payload,
                'line' => $exception->getLine(),
                'file' => $exception->getFile(),
                'stack' => $exception->getTraceAsString(),
                'exception' => $exception->getMessage(),
            ]);
            // email myself about the failed refund
            Mail::to('*****@gmail.com')->send(new ExceptionProcessingRefund(
                $this->payload,
                $exception->getLine(),
                $exception->getFile(),
                $exception->getMessage()
            ));
        }

    }
}
0 likes
0 replies

Please or to participate in this conversation.