Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Version 3: Nova Stripe Package Complete Rewrite #70

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

nicodevs
Copy link

@nicodevs nicodevs commented Jan 31, 2025

This PR is a complete rewrite of the package. Instead of pulling data from Stripe on the fly, the new version introduces a sync process that allows the user to select resources to sync: Products, Charges, Customers, and Subscriptions. The sync process uses the Stripe SDK to import all records, enabling us to use classic Nova resources and leverage all of Nova's built-in features to address the current issues and enhancement requests.

New Features in Version 3

Welcome Dialog: When the user visits the tool's sections for the first time, a welcome dialog explaining how to use the "Sync With Stripe" action appears. Once closed, a key is saved in localStorage to prevent it from being displayed again.

Sync with Stripe: Users can sync data from Stripe by selecting one or more resources (Products, Customers, Charges, and Subscriptions). The sync process retrieves all records from Stripe in chunks of 100 until complete. The user can choose whether to run the process in the background or immediately.

Enhanced Pagination: By syncing all records, we now have the data needed to support any pagination mode and respect the user’s Nova configuration. Additionally, users can choose records per page: 25, 50, or 100.

Search: No more clicking "Next" until finding a record! Each resource includes search functionality for relevant fields, such as "ID," "Name," and "Email" in Customers.

Sorting: Sort Charges by amount, Customers by email, and more.

Filters: Filter Products by "active" or "inactive," Charges by date, and more.

Improved List and Detail Views: I focused on displaying the most relevant information in a way that closely resembles Stripe's Dashboard, ensuring the data is presented clearly (no printed out JSON strings!).

Relationships: Resource details are interconnected for easy navigation. For example, view a Customer's Charges and Subscriptions directly on their detail page, or navigate from a Charge to the associated Customer.

White Label: We avoid mentions of Nova (the menu reads "Stripe" instead of "Nova Stripe"), as some developers do not disclose the name of the dashboard to their clients by changing the footer, logo, URL, etc.

The previous version only supported Charges and Customers. This version introduces two new resources:

  • Products: View a list and detailed information about the products you're selling, whether they're one-time purchases or subscriptions.
  • Subscriptions: Access subscription details, including the associated Customer, Product, start and end dates, and more.

Screenshots

Click here to expand

Welcome Dialog
image

Sync With Stripe
image

Enhanced Pagination
image

Search
image

Sorting
image

Filters and "Items Per Page" selector
image

Improved List and Details Views
image

Relationships
rels

Motivation and Technical Details

Nova Stripe is a package for Laravel Nova that lets you view basic data from your Stripe account, such as charges and customers. It is a Nova tool. In Laravel Nova, tools are like plugins that integrate with Nova’s core and can add custom routes, views, components, and more. In terms of user interface, a tool provides a blank canvas where you can implement any user interface you need using Laravel, Inertia, and Vue.

The current version of Nova Stripe, version 2, uses Vue components to send requests to its API, which fetches data from Stripe via the Stripe PHP SDK and returns it to the front end. The data is then presented in a table or detail view.

This approach comes with limitations, reflected in the open issues.

Issues of the current version

Click here to read more

Most GitHub issues for Nova Stripe are enhancement requests, but a few are bug reports. One, in particular, is crucial to understanding the rest:

This issue highlights that Nova Stripe always uses "Previous" and "Next" buttons for pagination, regardless of the settings configured in Nova.

Laravel Nova offers three pagination modes for resource listings:

"simple": Default, with "Previous" and "Next" buttons.
"load-more": A "Load More" button at the bottom of the table.
"links": A list of numbered page links.

You can set the pagination style globally in the config/nova.php file:

'pagination' => 'links',

This configuration applies to all resource listings—but not to Nova Stripe, which always defaults to "simple" pagination with "Previous" and "Next" buttons.

Looking at the package code, the reason becomes clear: Nova Stripe doesn’t use the standard Vue components utilized by the rest of Laravel Nova. Instead, it relies on its own table component and pagination component. The latter has hardcoded "Previous" and "Next" links. In essence, it only supports the "simple" mode.

This reliance on custom components also accounts for another issue:

  • Add a per page dropdown: Nova Stripe lacks an "items per page" selector. In standard Nova resource listings, users can choose the number of rows per page (typically the options are 25, 50, and 100). However, Nova Stripe always displays 10 items per page, which is impractical for users with many charges or customers.

It also explains the absence of features requested in other GitHub issues marked as "enhancements":

  • Add ability to search charges: Nova Stripe does not include any search functionality in its charges or customers listings. To locate a record, you must click "Next" in the pagination links repeatedly until you find it.
  • Add ability to sort fields: Standard Nova resources allow users to sort by predefined columns. Nova Stripe, however, offers no sorting options.
  • Add filter by status: Another standard Nova feature missing in Nova Stripe is filter support. For instance, users cannot filter charges by status, such as showing only "failed" charges.

And on the topic of issues, I’ve found two more myself:

  • The pagination in the Customers listing doesn’t work. The table always displays the first 10 records, no matter how many times you click the "Next" button.

bug

  • The charges listing allows you to select which columns to display. However, if you select a column containing JSON data, the JSON string is rendered as-is. While not a bug per se, this isn’t ideal in my opinion.
bug2

Now, the key question is: why doesn’t Nova Stripe use the standard Nova components? Nova provides Vue components to support features like search, pagination modes, per-page limits, filters, sorting, and more. So, why not leverage them?

The answer is: those components are tightly coupled to Nova resources, and Nova resources are tightly coupled to Eloquent models. In other words:

  • Nova renders listings through a complex chain of nested Vue components that rely on numerous mixins. These components and mixins are designed to work with standard Nova resources, and their dependency hierarchy makes them difficult to repurpose in other contexts.
  • Each Nova resource class defines all the data the front-end requires, such as fields, their types, pagination options, filters, and more.
  • The central piece of a Nova resource class is its $model. The resource accesses the model’s fields, field types, relationships, etc., to perform queries and pass the data as props to the Vue page and child components.

With this in mind, it’s clear why Nova Stripe doesn’t use the built-in Nova components: it can’t, because it doesn’t use Eloquent models. Instead, it’s simply a layer on top of API requests to Stripe.

Stripe's API caveats

On top of that, Stripe’s API has its caveats: for instance, when you request customers, it doesn’t return the total record count. It only returns the current page and a boolean indicating whether there are more records, but it doesn’t specify how many. The only way to know the total number of customers is to make sequential API calls until you've retrieved them all. If we had access to the total count, along with the number of records per page, we could implement a custom paginator. However, since this information is unavailable through Stripe’s API, the only choice is to use "Previous" and "Next" pagination.

Potential Solution 1: Expanding the custom components

Click here to read more

Now that we have a clear understanding of the scenario, let’s explore potential solutions.

One way to address these issues and limitations is to expand Nova Stripe’s custom Vue components to cover the missing features. This would involve creating new components to handle search, filters, sorting, etc. These components could be based on Nova’s core components, adapted to work with Stripe’s API. However, this approach would require a significant time investment, both for development and ongoing maintenance. Moreover, it doesn’t solve the pagination issue, given the limitations of Stripe’s API regarding total counts.

Initially, this seems like the only solution. If we were working with standard Nova resources and Eloquent models, all of our problems would be solved—we could take full advantage of the features that Nova provides out of the box. But Nova Stripe doesn’t use standard Eloquent models.

What if it did?

Potential Solution 2: Sync with Stripe (The approach chosen for this PR)

Click here to read more

Let's imagine, for a moment, that we have an Eloquent model for each of the objects we want to manage. For example, a Customer model would look something like this:

class Customer extends Model
{
    protected $casts = [
        'default_source' => 'json',
        'address' => 'json',
        // ... and more
    ];

    public function charges()
    {
        return $this->hasMany(Charge::class);
    }

    // ... and more
}

We could then define the corresponding Nova resource and instantly gain access to all the features we need. Take this Customer resource class as an example:

use Laravel\Nova\Resource;

class Customer extends Resource
{
    // We indicate the model for this resource
    public static $model = \Tighten\NovaStripe\Models\Customer::class;

    // Search out of the box, choosing the searcheable columns
    public static $search = ['id', 'name', 'email'];

    // Relationships with other models, to show related information
    public static $with = ['charges', 'subscriptions'];

    // Access to Nova's default fields
    public function fields(NovaRequest $request)
    {
        return [
            // Sort feature out of the box with `sortable`
            Email::make('Email')->sortable(),

            // And more!
        ]
    }
}

We mentioned that tools can register resources. They can also define routes and sidebar menus with links to those routes, like this:

class NovaStripe extends Tool
{
    public function boot(): void
    {
        Nova::resources([
            Customer::class,
            Charge::class,
        ]);
    }

    public function menu(Request $request): MenuSection
    {
        return MenuSection::make('Stripe', [
            MenuItem::make('Customers', '/resources/customers'),
            MenuItem::make('Charges', '/resources/charges'),
        ], 'credit-card');
    }
}

A simple configuration like that can generate fully functional customer and charge resource pages, each complete with list and detail views, just like any other standard Nova resource—no need for custom components.

The question then becomes: how can we leverage Eloquent models if the data isn't stored in a database but is instead accessed via Stripe?

My solution is to sync the data from Stripe into a database.

  • Each listing we want to sync — such as charges, customers, or products — will have its own table.
  • An import process will pull data from Stripe and populate these tables with the relevant records.
  • Instead of reading from Stripe on the fly, we query the data synced in the database.

Let's address some questions that might come up:

  • Wouldn't the sync process take too long? It depends on the number of records, but on a test account with 1,000 customers and 1,000 charges, it takes only seconds. This process can even run in the background, using Nova's queued actions, with the user receiving a notification when it's done. Or in regular intervals, with a scheduled task.
  • Could this sync process hit Stripe's API rate limit? Fortunately, Stripe's rate limit is quite generous, with 100 read operations and 100 write operations per second in live mode. Even if the sync hits the rate limit, Stripe's PHP SDK will handle it gracefully.
  • Will the synced data become stale? Yes, think of it as a cache that requires regular refreshing. The frequency depends on the project’s needs—for example, a subscription app with low sales volume might sync every few hours (or only on demand when an admin checks stats), while a large online store might sync charges every minute.

If we plan to save Stripe data into a database, we’ll need to decide on the database approach. Here are two options:

  • Provide migrations: This involves including database migrations with the package and instructing users to run them during installation. While straightforward, this approach requires effort to manage future schema updates (for example, to adapt to Stripe’s object changes).
  • Use Sushi: Sushi dynamically generates an in-memory SQLite database per model, meaning no migrations are required. It allows us to handle Stripe data as if it were stored in traditional tables, with full support for Eloquent and even relationships between models. This keeps the client’s application database cleaner, and eliminates the need for additional migrations in future versions.

Both options are valid, but I chose sushi. With this in mind, this PR replicates the functionality of the Nova Stripe package in a completely new, rewritten-from-scratch version.

How to Test

composer require tightenco/nova-stripe:dev-nd/version-3
STRIPE_KEY=
STRIPE_SECRET=
  • Add a stripe element to your config/services.php configuration file:
'stripe' => [
    'key' => env('STRIPE_KEY'),
    'secret' => env('STRIPE_SECRET'),
],
  • Register the NovaStripe tool in app/Providers/NovaServiceProvider:
public function tools()
{
    return [
        new \Tighten\NovaStripe\NovaStripe,
    ];
}
  • Migrate and seed your database
  • Open your Laravel Nova in your browser

Expected: You should see the "Stripe" menu in Nova's the sidebar, with four links: Products, Charges, Customers, and Subscriptions. You should see a welcome modal, explaining how to use the tool.

  • Click the dropdown menu in the top right corner.
  • Select "Sync with Stripe."
  • Select the resources you'd like to sync.
  • Check "Run in the background" if you'd like to queue the sync job. Please note that queuing requires processing the jobs from the default queue, so you'll need to run php artisan queue:work.

Expected:

  • If you chose to sync in the background, a success toast message should appear saying "Sync started!" and the modal will close. Once a resource completes the sync, you will receive a Nova notification.
  • If you chose to sync immediately, the sync will start right away. Once completed, the modal will close and the toast message will read "Sync completed!". Please note that syncing large datasets (+1000 records) is not recommended in this mode.
  • Click on the sidebar's resources links
  • Tests the different features (sorting, search, etc)

Unit Testing and GitHub Actions

  • This PR includes a brand new test suite using Pest.
  • The previous GitHub actions have been disabled (I kept the code but changed their extension to .txt), in favor of a new action that installs PHP, checks out the code, runs the linter, and runs the tests.

Please let me know if the changes to the actions are correct. Thank you!

Issues Fixed

Fixes #69
Fixes #67
Fixes #29
Fixes #22
Fixes #17
Fixes #12
Fixes #11
Fixes #10
Fixes #9


Note

All feedback welcome!

@nicodevs nicodevs added enhancement New feature or request and removed enhancement New feature or request labels Jan 31, 2025
@nicodevs nicodevs self-assigned this Jan 31, 2025
@nicodevs nicodevs changed the title Version 3 Version 3: Nova Stripe Package Complete Rewrite Jan 31, 2025
@nicodevs nicodevs force-pushed the nd/version-3 branch 3 times, most recently from aa526a1 to 9ea1477 Compare January 31, 2025 17:38
@nicodevs nicodevs marked this pull request as ready for review January 31, 2025 17:42
Copy link

@gcavanunez gcavanunez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested it locally on a nova 4 app, and other than the Product Resources page having some minor issue with the presentation of the product fields, everything else (Subs, Charges and Customers) worked excellent!

src/Resources/Product.php Outdated Show resolved Hide resolved
src/Resources/Product.php Outdated Show resolved Hide resolved
Copy link

@gcavanunez gcavanunez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the setting up a cron that dispatches this syncing process workflow

protected function stripeLink(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes): string => 'https://dashboard.stripe.com/' . $this->service . '/' . $attributes['id'],
Copy link

@gcavanunez gcavanunez Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we rely a bunch on linking back to the stripe dashboard and the stripe links for tests, what do you think of adding a conditional prefix of something like

$isTest ? 'https://dashboard.stripe.com/test/' : 'https://dashboard.stripe.com/'

Perhaps by adding a private function like

public function isTestMode(): bool
{
     return ! str($this->getService()->config->apiKey)->startsWith('sk_test');
}

While digging through the Stripe SDK wasn't able to find a nice method like Stripe::livemode() other than by retriveing the account Stripe\Account::retrieve()->livemode but it seemed like potentially yet another value to ensure we kept in sync.

Comment on lines +57 to +65
$address = json_decode((string) $attributes['address'], true);

return trim(
$address['line1'] . ' ' .
($address['line2'] ? $address['line2'] . ' ' : '') .
$address['city'] . ', ' .
$address['country'] . ' ' .
$address['postal_code']
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the trim on each line is a bit excessive, but ran into an offset issue here, what do you think of this change?

Suggested change
$address = json_decode((string) $attributes['address'], true);
return trim(
$address['line1'] . ' ' .
($address['line2'] ? $address['line2'] . ' ' : '') .
$address['city'] . ', ' .
$address['country'] . ' ' .
$address['postal_code']
);
$addressData = json_decode((string) $attributes['address'], true) ?: [];
$line1 = trim($addressData['line1'] ?? '');
$line2 = trim($addressData['line2'] ?? '');
$city = trim($addressData['city'] ?? '');
$country = trim($addressData['country'] ?? '');
$postalCode = trim($addressData['postal_code'] ?? '');
$streetAddress = implode(' ', array_filter([$line1, $line2]));
$locality = implode(' ', array_filter([$city, $country, $postalCode]));
$formattedAddress = implode(', ', array_filter([$streetAddress, $locality]));
return trim($formattedAddress);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants