Apr 09, 2021 · 6 minutes read

Selling products to guests with Paddle and Cashier

When selling digital products on your website, you may would like that your customers don't have to create an account before they can create a purchase. This behaviour isn't supported by Laravel Cashier by default, but easy to add manually.

When selling digital products on your website, you may would like that your customers don't have to create an account before they can create a purchase. This behaviour isn't supported by Laravel Cashier by default, but easy to add manually.

A few days ago I released Timey - the time tracking tool straight out of your dreams. It's an electron app and can get purchased via this website. And I'm not sure about this, but I can imagine that many people don't want to create an account on a random website in the internet only to purchase a product. So I wanted to add the option that users can buy my product, even without having the need of creating an account therefore.

As mentioned in the title, I'm using Laravel Cashier with Paddle and as you know, you would usually create a payment link like this:

$payLink = auth()->user()->chargeProduct($productId);

But there comes our first problem: We don't have a user where we can call this method on.

So let's take a look on how Cashier creates a payment link for our user.

The chargeProduct function uses generatePayLink under the hood to send a POST request to Paddle's api:

protected function generatePayLink(array $payload)
{
    $payload['customer_email'] = $payload['customer_email'] ?? (string) $this->paddleEmail();
    $payload['customer_country'] = $payload['customer_country'] ?? (string) $this->paddleCountry();
    $payload['customer_postcode'] = $payload['customer_postcode'] ?? (string) $this->paddlePostcode();

    // We'll need a way to identify the user in any webhook we're catching so before
    // we make the API request we'll attach the authentication identifier to this
    // payload so we can match it back to a user when handling Paddle webhooks.
    if (! isset($payload['passthrough'])) {
        $payload['passthrough'] = [];
    }

    if (! is_array($payload['passthrough'])) {
        throw new LogicException('The value for "passthrough" always needs to be an array.');
    }

    $payload['passthrough']['billable_id'] = $this->getKey();
    $payload['passthrough']['billable_type'] = $this->getMorphClass();

    $payload['passthrough'] = json_encode($payload['passthrough']);

    return Cashier::post('/product/generate_pay_link', $payload)['response']['url'];
}

So we see, that it first passes some customer data and creates a passthrough object in our payload containing the user's class and id as well as the product id which get's passed from the chargeProduct method.

Since we don't need all these customer data, we can just use Cashier's post helper to create a payment link for our guest. I've created a method called generatePayLinkForGuest() which looks like the following:

protected function generatePayLinkForGuest(): string
{
    return Cashier::post('/product/generate_pay_link', array_merge([
        'passthrough' => json_encode([]),
        'product_id' => $productId,
    ], Cashier::paddleOptions()))['response']['url'];
}

Beside of an empty array as passthrough, I'm sending along my product id and Cashier's static accessible options.

So now that we have our payment link created, we still need to somehow tell Cashier which user to associate the invoice with, etc.

Intercepting Cashier's webhook logic

When you already have worked with Cashier it might be familiar to you to create a custom Webhook Controller and tell Cashier to use this one instead of the default one.

I'm going to assume that you've already set up webhooks and created a custom webhook controller. To learn more about this, please read the official documentation: https://laravel.com/docs/master/cashier-paddle#handling-paddle-webhooks

So let's take a look on our WebhookController class. You might have something there that's looking like the following

public function handlePaymentSucceeded($payload)
{
    parent::handlePaymentSucceeded($payload);

    // Handle the successful payment
}

Cashier expects this $payload to contain passthrough which should be a valid JSON string containing the user data. So I've added the following code:

public function handlePaymentSucceeded($payload)
{
    $passthrough = json_decode($payload['passthrough'], true);
    if (! is_array($passthrough) || ! isset($passthrough['billable_id'], $passthrough['billable_type'])) {
        $payload['passthrough'] = json_encode($this->getPassthroughForPaymentWithoutBillable($payload));
    }

    parent::handlePaymentSucceeded($payload);

    // Handle the successful payment
}

Let's take a look at that. First I'm fetching the passthrough from the webhook and decoding it to an array. I then check if it contains the data which get's send when using Cashiers method for authenticated users, and if not I'm setting the passthrough property of my payload array to the json encoded string of my getPassthroughForPaymentWithoutBillable() method. Let's take a look what happens there:

When there is no email in the payload, something went wrong. So let's throw an error in this case

if (! isset($payload['email'])) {
    throw new InvalidPassthroughPayload;
}

When there is an email I'ld like to either create a new user or associate the purchase with the user when he already have created an account but wasn't logged in at the time of making the purchase.

if (! ($user = User::firstWhere('email', $payload['email']))) {
    $user = User::create([
        'name' => Str::of($payload['email'])->before('@'),
        'email' => $payload['email'],
        'password' => bcrypt(Str::random(64)),
    ]);

    Mail::to($payload['email'])
        ->send(new PurchaseAccountCreated($user));
}

Here we first check if a user with that given email exists, and if not create a new user with a random password and the email passed from paddle. I also send an email to the user containing a link to my reset password site, so that they can easily set a new password.

And not to forget, lastly I return the exact passthrough array which would get sent from Cashier when charging an existing user:

return [
    'billable_id' => $user->id,
    'billable_type' => User::class,
];

Since I already have set the passthrough property of my payload array to the json encoded string of this new method, I can just pass my new payload to Cashier, and let it do the magic!

In closing

In my opinion this is a great way to not force your customers to manually create an account, but also not overcomplicating your code. In the background we create an account for your customer so you can do all your operations on your default User model and your customer has all the benefits of having an account for your application, without the need to manually registering.

Hit me up on Twitter, or take a look at my newest product Timey, which is a minimal and elegant time tracking software like you've always dreamed of.

Did you enjoy what you’ve read?

Once in a while I send an email with some project updates, article drafts or other cool stuff. I won’t share your email and I won’t let your inbox overflow. Promised.