laravel / cashier-paddle

Cashier Paddle provides an expressive, fluent interface to Paddle's subscription billing services.

Home Page:https://laravel.com/docs/cashier-paddle

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Problem with billing API - Transaction not created in the db

Benoit1980 opened this issue · comments

Cashier Paddle Version

2.4.0

Laravel Version

10.48.4

PHP Version

8.1

Database Driver & Version

10.6.17-MariaDB

Description

I'm encountering discrepancies in the behavior of my application when using the Paddle Billing API between the sandbox and live environments. While the hooks from Paddle return OK responses in the live environment, records are not created in the database but are created in the sandbox environment.

Steps To Reproduce

Hello,

I've encountered a strange issue that I'm currently investigating. I've reached out to Paddle for assistance, and they are also actively looking into it.

I've implemented a PaddleEventListener. This listener is essential because it allows me to credit users' accounts promptly upon the completion of transactions. I've opted for the WebhookHandled event to ensure immediate crediting as soon as transactions are finalized.

Here is the code:
eventServiceProvider.php:

<?php

namespace App\Providers;

use App\Listeners\PaddleEventListener;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Laravel\Paddle\Events\WebhookHandled;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
        WebhookHandled::class => [
            PaddleEventListener::class,
        ],
    ];

My paddle listener:

<?php

namespace App\Listeners;

//use Illuminate\Contracts\Queue\ShouldQueue;
//use Illuminate\Queue\InteractsWithQueue;
use App\Mail\ExceptionOccurred;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Laravel\Paddle\Cashier;
use Laravel\Paddle\Customer;
use Laravel\Paddle\Events\WebhookHandled;

class PaddleEventListener
{
    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle transaction completed.
     *
     * @param WebhookHandled $payload
     * @return void
     */
    public function handle(WebhookHandled $payload): void
    {

        $payloadData = $payload->payload;
        $eventType = $payloadData['event_type'];


        if ($eventType === 'transaction.completed') {

            logger($payloadData); //All good

            $customer_subscription_id = $payloadData['data']['subscription_id'];

            logger($customer_subscription_id); //All good

            $billableId = $this->findCustomer($customer_subscription_id); <--Fails on live but not sandbox

            $userId = $billableId['billable_id'];

        }
    }


    /**
     * Find the first customer matching a Paddle customer ID.
     *
     * @param string $customerId
     * @return Customer|null
     */
    protected function findCustomer(string $customerId)
    {
        return Cashier::$customerModel::firstWhere('paddle_id', $customerId);
    }

    /**
     * Determine if a transaction with a given ID already exists.
     *
     * @param string $transactionId
     * @return bool
     */
    protected function transactionExists(string $transactionId)
    {
        return Cashier::$transactionModel::where('paddle_id', $transactionId)->count() > 0;
    }
}

After digging further, I have noticed that the query $this->findCustomer($customer_subscription_id); fails because no records are created for the transaction in the database, therefore, it is impossible to find the billable_id of the record.

The transaction.completed call back works from Paddle:

array (
  'data' => 
  array (
    'id' => 'txn_01htzkcxxxxxxxxycbf',
    'items' => 
    array (
      0 => 
      array (
        'price' => 
        array (
          'id' => 'pri_01hmxxxxxxxxcjh7x',
          'name' => 'xxxxxxx 1',
          'type' => 'standard',
          'status' => 'active',
          'quantity' => 
          array (
            'maximum' => 999999,
            'minimum' => 1,
          ),
          'tax_mode' => 'external',
          'created_at' => '2024-01-15T18:47:57.154106Z',
          'product_id' => 'pro_01hm771n4xxxxxxxxwfr810',
          'unit_price' => 
          array (
            'amount' => '100',
            'currency_code' => 'USD',
          ),
          'updated_at' => '2024-04-07T23:05:27.852012Z',
          'custom_data' => 
          array (
            'credits' => '100000',
          ),
          'description' => 'xxxxxxxx 1',
          'trial_period' => NULL,
          'billing_cycle' => 
          array (
            'interval' => 'month',
            'frequency' => 1,
          ),
          'unit_price_overrides' => 
          array (
          ),
        ),
        'price_id' => 'pri_01hm7xxxxxxey8ecjh7x',
        'quantity' => 1,
        'proration' => NULL,
      ),
    ),
    'origin' => 'web',
    'status' => 'completed',
    'details' => 
    array (
      'totals' => 
      array (
        'fee' => '51',
        'tax' => '17',
        'total' => '109',
        'credit' => '0',
        'balance' => '0',
        'discount' => '0',
        'earnings' => '41',
        'subtotal' => '92',
        'grand_total' => '109',
        'currency_code' => 'EUR',
        'credit_to_balance' => '0',
      ),
      'line_items' => 
      array (
        0 => 
        array (
          'id' => 'txnitm_01htzkxxxxxxnf1z08npj',
          'totals' => 
          array (
            'tax' => '17',
            'total' => '109',
            'discount' => '0',
            'subtotal' => '92',
          ),
          'item_id' => NULL,
          'product' => 
          array (
            'id' => 'pro_01hm77xxxxxxyakxwfr810',
            'name' => 'xxxxx 1',
            'type' => 'standard',
            'status' => 'active',
            'image_url' => 'https://xxxxxxx.com/images/logo/logo-teal.png',
            'created_at' => '2024-01-15T18:44:51.974Z',
            'updated_at' => '2024-04-07T22:11:01.843Z',
            'custom_data' => NULL,
            'description' => 'xxxxxxe 1',
            'tax_category' => 'saas',
          ),
          'price_id' => 'pri_01hm77xxxxxecjh7x',
          'quantity' => 1,
          'tax_rate' => '0.18',
          'unit_totals' => 
          array (
            'tax' => '17',
            'total' => '109',
            'discount' => '0',
            'subtotal' => '92',
          ),
        ),
      ),
      'payout_totals' => 
      array (
        'fee' => '51',
        'tax' => '17',
        'total' => '109',
        'credit' => '0',
        'balance' => '0',
        'discount' => '0',
        'earnings' => '41',
        'fee_rate' => '0.05',
        'subtotal' => '92',
        'grand_total' => '109',
        'currency_code' => 'EUR',
        'exchange_rate' => '1',
        'credit_to_balance' => '0',
      ),
      'tax_rates_used' => 
      array (
        0 => 
        array (
          'totals' => 
          array (
            'tax' => '17',
            'total' => '109',
            'discount' => '0',
            'subtotal' => '92',
          ),
          'tax_rate' => '0.18',
        ),
      ),
      'adjusted_totals' => 
      array (
        'fee' => '51',
        'tax' => '17',
        'total' => '109',
        'earnings' => '41',
        'subtotal' => '92',
        'grand_total' => '109',
        'currency_code' => 'EUR',
      ),
    ),
    'checkout' => 
    array (
      'url' => 'https://xxxxxxxxxxxxxx.com?_ptxn=txn_01xxxxxxxxxxxxycbf',
    ),
    'payments' => 
    array (
      0 => 
      array (
        'amount' => '109',
        'status' => 'captured',
        'created_at' => '2024-04-08T19:37:02.650576Z',
        'error_code' => NULL,
        'captured_at' => '2024-04-08T19:37:51.910369Z',
        'method_details' => 
        array (
          'card' => 
          array (
            'type' => 'visa',
            'last4' => '0000',
            'expiry_year' => xxxx,
            'expiry_month' => 1,
            'cardholder_name' => 'ben',
          ),
          'type' => 'card',
        ),
        'payment_method_id' => 'paymtd_01xxxxxx',
        'payment_attempt_id' => '054dxxxxx',
        'stored_payment_method_id' => '0bxxxxxxx',
      ),
    ),
    'billed_at' => '2024-04-08T19:37:52.210126Z',
    'address_id' => 'add_01htxxxxxxx8y2a6q',
    'created_at' => '2024-04-08T19:36:21.922856Z',
    'invoice_id' => 'inv_01htzkf3xxxxxxmk6jpbb3',
    'updated_at' => '2024-04-08T19:39:25.2807348Z',
    'business_id' => NULL,
    'custom_data' => 
    array (
      'subscription_type' => 'default',
    ),
    'customer_id' => 'ctm_01htxa5c4xxxxxxyqqwg4fq',
    'discount_id' => NULL,
    'receipt_data' => NULL,
    'currency_code' => 'EUR',
    'billing_period' => 
    array (
      'ends_at' => '2024-05-08T19:37:51.910369Z',
      'starts_at' => '2024-04-08T19:37:51.910369Z',
    ),
    'invoice_number' => '2793-xxxxxx',
    'billing_details' => NULL,
    'collection_mode' => 'automatic',
    'subscription_id' => 'sub_01htzkf3xxxxxxxj1xva3j',
  ),
  'event_id' => 'evt_01htzkhxyzs7xxxxxxxaxqyfw',
  'event_type' => 'transaction.completed',
  'occurred_at' => '2024-04-08T19:39:25.535799Z',
  'notification_id' => 'ntf_01htzkhy0tf5r43az2dk7v30qh',
)  

I added xxxxxx at different places as I was not sure if I could share all the records from a live card.

The strange part is that none of the hook fail on the live paddle side apart from the last transaction.completed because it cannot find the record:
image

No records in the table transactions, subscriptions or subscription_items on live purchase, but all filled on sandbox.

We have checked the 2 Paddle accounts, all the records match, the webhook urls too. We are testing this on a remote server(.com domain).

I was expecting numerous hooks to fail when the records are not inserted in the DB.

Thank you,

Regards,

Ben

Hi @Benoit1980.

$customer_subscription_id = $payloadData['data']['subscription_id'];

This seems incorrect? You're trying to lookup a customer record by a subscription ID? Shouldn't it be:

$customer_subscription_id = $payloadData['data']['customer_id'];

Thanks I will check on this tonight and post back.

Hi @driesvints,

I have done a lot of testing.

And added these loggers here:
\vendor\laravel\cashier-paddle\src\Http\Controllers\WebhookController.php

The issue lies in the fact that the query "$billable->transactions()->create" never executes when a customer subscribes and pays via Paddle billing in the live environment, although it functions as expected in the billing sandbox.

The log entries for Logger('1') and Logger('2') are successfully recorded. However, Logger('3') is never triggered during live Paddle transactions. Consequently, the transaction records are not created, leading to subsequent request failures.

protected function handleTransactionCompleted(array $payload)
    {
        $data = $payload['data'];
        logger('1');
        if ($this->transactionExists($data['id'])) {
            return;
        }
        logger('2');
        if (! $billable = $this->findBillable($data['customer_id'])) {
            return;
        }

        logger('3');

        $transaction = $billable->transactions()->create([
            'paddle_id' => $data['id'],
            'paddle_subscription_id' => $data['subscription_id'],
            'invoice_number' => $data['invoice_number'],
            'status' => $data['status'],
            'total' => $data['details']['totals']['total'],
            'tax' => $data['details']['totals']['tax'],
            'currency' => $data['currency_code'],
            'billed_at' => Carbon::parse($data['billed_at'], 'UTC'),
        ]);
        logger('4');
        TransactionCompleted::dispatch($billable, $transaction, $payload);

        logger('5');
    }

I checked the payload the customer_id is there:
image

This is the part I am not understanding:

        if (! $billable = $this->findBillable($data['customer_id'])) {
            return;
        }

What could be causing this code to fail?

Users are registering for my app without undergoing any trials or making payments initially. Subsequently, they input their email addresses in the Paddle popup to pay for their first subscription. Is it possible that this workflow might be causing compatibility issues with your package?

This is how they are being subscribed at the checkout:

$user = Auth::user();
$user->subscribe('pri_01xxxxxxxxxxxxxxxxxxxxxxjh7x', 'default')
                    ->returnTo(route('dashboard.thankyou'));

This issue has been difficult, it's preventing the launch of my app. I am surprised for the live environment to encounter failures after successful testing in the sandbox.

Thanks again

Ben

This shouldn't be possible. The subscribe will either fetch the customer from paddle or create them in paddle before starting the subscription. The record is definitely in the customers table after that. So it should be fine for the webhook to find the record in the database.

Which email is being attempted to register? Can you compare the id of the customer you find in production with the ID of the customer in the payload from above? Is it the same or not?

Hi @driesvints ,

I wanted to test it tonight but the Hsbc AIS is down tonight. I will retest in the morning.

Sorry about that!

Best,

Ben

Hi @driesvints ,

Let me detail all the steps clearly so we know exactly what is happening:

SANBOX TEST
When clicking on the Paddle "BUY NOW" subscription button, a POST request is generated to:

The payload contains:
customer['id'] = **ctm_01hm6tjcbgbq0k4r6rhcmn8nvw**

The paddle response from the POST request is "ctm_01hm6tjcbgbq0k4r6rhcmn8nvw"
email: kaylexxxxxxxxx@gmail.com

Upon paying with the fake bank card and clicking on the "SUBSCRIBE" button, 3 tables are filled with records:
-transactions table
-subscriptions table
-subscription_items table

The paddle transaction.completed event returns:
"customer_id": "**ctm_01hm6tjcbgbq0k4r6rhcmn8nvw**",

So for the sandbox, everything works as it should

#####################################################################################
Now let's see with the live bank card:

LIVE TEST
When clicking on the Paddle "BUY NOW" subscription button, a POST request is generated to:

The payload contains:
customer['id'] = **ctm_01hm6tjcbgbq0k4r6rhcmn8nvw**

No paddle response like with the sandbox showing the customer ctm_number, but I see this in the messages array of the data object(but the user exist in the Paddle billing back end, in the customers area "https://vendors.paddle.com/customers-v2"):

"messages": [
            {
                "status": 404,
                "code": "not-found",
                "details": "Customer cannot be found by id.",
                "source": {
                    "pointer": "\/data\/customer\/id"
                }
            },
            {
                "status": 400,
                "code": "validation",
                "details": "The selected theme is invalid.",
                "source": {
                    "pointer": "\/data\/settings\/theme"
                }
            }
        ]

Upon paying with the real bank card and clicking on the "SUBSCRIBE" button, the 3 tables are NOT filled with records:

Now here is the weird part:
The paddle transaction.completed event returns:
"customer_id": "ctm_01htxa5c4cp1ph75rxyqqwg4fq", Which is different from the original customer['id']

I'm quite surprised to see that the customer's ctm_number before paying is identical for both live and sandbox Paddle requests. Although I used the same email for both tests, I anticipated that the ctm_numbers would be different.

I did an extra test, and went in Paddle billing >> Customers >> Click on "kaylexxxxxxxxx@gmail.com"
image

And can see:
ctm_01htxa5c4cp1ph75rxyqqwg4fq

Which matches the customer_id from the transaction.completed but does not match the originator customer['id'].

Thanks,

Ben

When clicking on the Paddle "BUY NOW" subscription button, a POST request is generated to:

The payload contains:
customer['id'] = ctm_01hm6tjcbgbq0k4r6rhcmn8nvw

I'm gonna just note that this is impossible. A sandbox and live environment can't produce the same ID's. There's something very odd going on with your live installation that you're getting sandbox ID's for customers.

I suggest you contact Paddle because this is not something we can help with sorry.

Ok @driesvints , I will speak to them now and see what is going on.

Thanks again for your help.

Alright, I've understood the issue now. Upon Paddle's notification about the wrong continued use of the SandBox ctm_id, I found that the records were indeed in the database. Initially, I presumed that the ctm_id was fetched by your packages via a single API call using the user's email address. However, it appears that it's saved in the database just before the initial order.

Please check the "customers" table:
image

When testing the website with an email such as emailxxxx@gmail.com, and subsequently attempting a real purchase with the same email/user, the ctm_id fails to update with the live ctm_number. This limitation is why I could get the sandbox to function but not the live mode.

I believe a possible solution to address this issue is to introduce an additional column named "paddle_sandbox_id." When the ENV PADDLE_SANDBOX is set to true, this ID would be utilized during the following sandbox process:

$user->checkout('pri_01hm7xxxxxxxxxxxxxxxxxxx')
                    ->returnTo(route('dashboard.thankyou'));

This feature would allow users of the package to seamlessly transition from a sandbox to a live environment.

What I also tried:
Removing the initial "ctm" entry in the database seems to be problematic for Paddle.
Additionally, attempts to delete the customer from the customers table also resulted in another Paddle issue. This arises when attempting to recreate a new user, as the "name" field sometimes lacks records. When inserting a record for an existing user in the customers table, a name is required(as shown below).

Integrity constraint violation: 1048 Column 'name' cannot be null (Connection: mysql, SQL: insert into customers (billable_id, billable_type, paddle_id, name, email, trial_ends_at, updated_at, created_at) values (429, App\Models\User, ctm_01htxa5c4cp1ph75rxyqqwg4fq, ?, emailxxxx@gmail.com, ?, 2024-04-11 16:12:32, 2024-04-11 16:12:32))

I hope this will help other users who may encounter the same issue.

Thanks,

Ben

Hello,
I think you need to test this package in a local environment and not in production because you may run into problems.
Don't you think so?)
The word "sandbox" speaks for itself

Hi Movicat,

Oftentimes, when encountering significant issues, it becomes necessary to place your site under maintenance and conduct a fake purchase as an admin on your actual live account. This scenario has occurred frequently in my experience with gateways, working with numerous clients. Particularly in urgent situations where time is of the essence, it's not always feasible to undergo the entire testing sandbox process on a secondary account. Additionally, not all accounts on a server are identical. While one account may have the necessary web socket permissions (e.g., Account A), another account (e.g., Account B) might have firewall restrictions or encounter issues with services like Cloudflare or mod_security. Due to these variations, I never assume that a sandbox account will only be utilized for local environments only. I've encountered such situations on multiple occasions where problems had to be fixed in less than 1 hour and lucky we had a paypal sandbox that could work on the spot.

Thank you,

Ben

@Benoit1980 you're using sandbox data in your production database. That can never work. You need to use a clean database in production.

I'm used to working with various technologies like PrestaShop and WordPress, where transitioning from a sandbox to a live account is seamless. However, I understand that this package isn't tailored for such transitions.
No worries, thanks again and enjoy your week end!