One time charges, are they possible with Wave?

Solved
droenfeldt

Oct 30th, 2024 10:10 AM

First off, I love the framework, you guys did a fantastic job!

Now about my use case: I'm going to use almost every feature Wave is offering, but I need to be able to charge members each time they buy a service. Would that be possible to achieve with Wave? If so, where should I look?

Thanks a lot!

tnylea

Oct 30th, 2024 04:30 PM

Best Answer

Hey Droenfeldt, Thanks so much.

Yes, you can do one-time purchases for sure. So, what you would do is make it a one-time payment in Stripe or in Paddle. For paddle you have to set the recurring period to 9999 years (that's just how they have it setup for one-time payments 🤪)

Now, when someone purchases a plan from your Wave app, upon a successful purchase it will change their role to the associated role with the Plan. So say that you have a Plan called 'Gold', you may also want to create a user role called gold. When the user purchases the Gold plan, their role then update to be gold and you can offer users with this role certain services.

If you are going to have multiple services it might need some custom work done. When a user purchases a service via Stripe or Paddle, you would create the functionality to automatically enable that feature in the application.

So, it's definitey possible, but it sounds like you might need to build some additional functionality into your app.

We don't have an event that fires yet during a Stripe/Paddle webhook, but this sounds like something that would be a good feature to get built in. Wioth that event it would make it easier for you to build custom functionality like this.

I've went ahead and added this in the Backlog to the Project Kanban here: https://github.com/orgs/thedevdojo/projects/9?pane=issue&itemId=85450576

Let me know if that makes sense 😉 Appreciate it!

Report
2
droenfeldt

Jan 28th, 2025 06:26 AM

@tnylea any chance you could help me implement the event that should fire during a Stripe webhook?

I created plans corresponding to each type of service, and implemented "one-time" payments. My Stripe sandbox is successfully accepting them, and I'm correctly redirected to the subscription/welcome success URL. However, the user is not assigned any plan, as one would otherwise expect. That's the reason as to why I assume I'd need to make sure the user gets assigned to the corrersponding plan via an event.

Please help me figure this out, it's been bothering me for over a week now. Thank you so much!

bobbyiliev

Jan 31st, 2025 12:48 AM

Hi @droenfeldt,

I think that you'll want to use webhooks for this - they're essentially a way for Stripe to notify your application when a payment is completed.

The webhook controller in Wave is located at wave/src/Http/Controllers/Billing/Webhooks/StripeWebhook.php. This is where you can add your custom logic to handle one-time payments based on the event that is coming from Stripe.

You can find all possible webhook events in Stripe's documentation here:

https://docs.stripe.com/api/events/types

The basic flow should be:

  1. Stripe processes the payment
  2. Stripe sends a webhook event to your application
  3. Your webhook handler receives it and updates the user's role

To get this working, you'll need to:

  1. Configure your webhook endpoint in your Stripe dashboard to point to your application
  2. Make sure you're passing the correct metadata when creating the checkout session
  3. Handle the webhook event to update the user's role

Could you share more details about your current setup? Specifically:

  • How are you creating the checkout session?
  • Have you configured the webhook endpoint in Stripe?
  • What metadata are you passing with the payment?

- Bobby

droenfeldt

Feb 3rd, 2025 06:23 AM

Hey Bobby,

This is how I'm creating the checkout session, along with the necessary and (hopefully) correct metadata:

public function redirectToStripeCheckout(Plan $plan)
{
    //dd($plan); // successfully getting the Plan data
    $stripe = new StripeClient(config('wave.stripe.secret_key'));

    $price_id = $plan->onetime_price_id ?? null;
    ray($price_id); // successfully geting the Plan's ID
    
    if (!$price_id) {
        ray('No price ID found for the selected plan', ['plan_id' => $plan->id]);
    }

    $checkout_session = $stripe->checkout->sessions->create([
        'line_items' => [[
            'price' => $price_id,
            'quantity' => 1
        ]],
        'metadata' => [
            'billable_type' => 'user',
            'billable_id' => auth()->user()->id,
            'plan_id' => $plan->id,
            //billing_cycle' => $this->billing_cycle_selected // configured as public $billing_cycle_selected = 'onetime';
            // setting the billing_cycle metadata is not necessary, since it's not a subscription
        ],
        'mode' => 'payment', // indicates a one-time payment
        'success_url' => url('subscription/welcome'),
        'cancel_url' => url('settings/subscription'),
    ]);

    return redirect($checkout_session->url);
}

The webhook endpoint is correctly defined in Stripe, as Stripe is successfully processing the sandbox payment and is trying to deliver the checkout.session.completed event, but it appears as pending. The webhook endpoint is set up on both the local app as well as on Stripe as:

https://etam.test/webhook/stripe

For testing purposes, I created a temporary webhook endpoint route defined with a closure within, but it doesn't receive any activity back from Stripe upon a successful payment:

Route::post('webhook/stripe', function (\Illuminate\Http\Request $request) {
    ray('Stripe webhook received', ['request' => request()->all()]);
    return response()->json(['status' => 'route accessed', 'data' => request()->all()], 200);
});

I'm completely puzzled here. Any thoughts?

Thanks a lot!

/ Daniel

droenfeldt

Feb 3rd, 2025 07:18 AM

Some extra info, for further insight:

All the event deliveries are pending, and ultimately failing, as revealed on the Stripe webhooks info section. Regardless of whether the billing_cycle metadata field is set or not, they are still failing.

Here's the latest one with the Failed status:

{
  "object": {
    "id": "cs_test_a1zNlzD[...]27OrG",
    "object": "checkout.session",
    "adaptive_pricing": {
      "enabled": false
    },
    "after_expiration": null,
    "allow_promotion_codes": null,
    "amount_subtotal": 3000,
    "amount_total": 3000,
    "automatic_tax": {
      "enabled": false,
      "liability": null,
      "status": null
    },
    "billing_address_collection": null,
    "cancel_url": "https://etam.test/settings/subscription",
    "client_reference_id": null,
    "client_secret": null,
    "consent": null,
    "consent_collection": null,
    "created": 1738590245,
    "currency": "eur",
    "currency_conversion": null,
    "custom_fields": [],
    "custom_text": {
      "after_submit": null,
      "shipping_address": null,
      "submit": null,
      "terms_of_service_acceptance": null
    },
    "customer": null,
    "customer_creation": "if_required",
    "customer_details": {
      "address": {
        "city": null,
        "country": "DK",
        "line1": null,
        "line2": null,
        "postal_code": null,
        "state": null
      },
      "email": "[email protected]",
      "name": "Full Name",
      "phone": null,
      "tax_exempt": "none",
      "tax_ids": []
    },
    "customer_email": null,
    "discounts": [],
    "expires_at": 1738676645,
    "invoice": null,
    "invoice_creation": {
      "enabled": false,
      "invoice_data": {
        "account_tax_ids": null,
        "custom_fields": null,
        "description": null,
        "footer": null,
        "issuer": null,
        "metadata": {},
        "rendering_options": null
      }
    },
    "livemode": false,
    "locale": null,
    "metadata": {
      "billable_id": "3",
      "billable_type": "user",
      "billing_cycle": "onetime",
      "plan_id": "4"
    },
    "mode": "payment",
    "payment_intent": "pi_3Qo[...]t2m1YpiLSkV",
    "payment_link": null,
    "payment_method_collection": "if_required",
    "payment_method_configuration_details": {
      "id": "pmc_1QjJz[...]vNMORwf",
      "parent": null
    },
    "payment_method_options": {
      "card": {
        "request_three_d_secure": "automatic"
      }
    },
    "payment_method_types": [
      "card",
      "klarna",
      "link"
    ],
    "payment_status": "paid",
    "phone_number_collection": {
      "enabled": false
    },
    "recovered_from": null,
    "saved_payment_method_options": null,
    "setup_intent": null,
    "shipping_address_collection": null,
    "shipping_cost": null,
    "shipping_details": null,
    "shipping_options": [],
    "status": "complete",
    "submit_type": null,
    "subscription": null,
    "success_url": "https://etam.test/subscription/welcome",
    "total_details": {
      "amount_discount": 0,
      "amount_shipping": 0,
      "amount_tax": 0
    },
    "ui_mode": "hosted",
    "url": null
  },
  "previous_attributes": null
}
bobbyiliev

Feb 3rd, 2025 09:51 AM

Hi there,

Oh wait, is your app only running locally? If this is the case, then Stripe webhook will not work as Stripe can not reach the app that is running on your local laptop out of the box.

If I understand this all correctly, it looks like the issue is that Stripe is unable to successfully deliver the checkout.session.completed webhook event to your application.

What you could do here is to make your local app publicly accessible. If you're testing locally, you can use a tool like ngrok to expose your local server and update the webhook URL in your Stripe settings:

https://ngrok.com/

Let me know how it goes!

Report
1
droenfeldt

Feb 3rd, 2025 11:47 AM

For some reason I couldn't figure out, since my local setup is powered by Laravel Herd on macOS, after configuring ngrok very carefully and setting it up to make my local app publicly accessible, Herd kept being stubborn and refusing to forward the random ngrok url to the local testing app, saying that the https://[...]-xyz.ngrok-free.app "could not be found in your parked or linked sites". And therefore, I simply used the staging, online app to test the webhook event delivery. The event gets delivered successfully (YAY!), but I'm encountering an error stating "Stripe Notice: Undefined property of Stripe\StripeObject instance: billing_cycle'". That error is most likely triggerd during Subscription::create()-ing, in fulfill_checkout() method of StripeWebhook.php, and I would higly appreciate if you helped me out refactor the method, so that the subscription is handled on Stripe's end as a one-time payment.

Is there any way of faking the webhook event receival at the local app level, so that I can keep developing the app without being forced to constantly update it on the staging server?

Thank you so much for your help, Bobby, it's highly appreciated.

Report
1
bobbyiliev

Feb 3rd, 2025 12:17 PM

Hi there,

I've not done this personally in a while but I think that you could simulate the webhook event using Stripe's CLI:

https://docs.stripe.com/stripe-cli

Another option is to deploy the app on a small DigitalOcean server so it is publicly accessibly and use the server IP to test things out:

https://devdojo.com/wave/docs/guides/deploy-on-digitalocean

And then use VS Code to remotely connect to the server directly and make the changes there:

Remote Development in Visual Studio Code

On to the second question, that error likely happens because billing_cycle is missing in some cases. You can modify fulfill_checkout() in StripeWebhook.php to check if it exists before accessing it:

$billingCycle = $payload['data']['object']['metadata']['billing_cycle'] ?? 'onetime';

For one-time payments, you should not create a Subscription::create() record in your app, since subscriptions are for recurring plans. Instead, you can handle one-time purchases separately.

Report
1