├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── RELEASE.md ├── UPGRADE.md ├── composer.json ├── config └── lemon-squeezy.php ├── database ├── factories │ ├── CustomerFactory.php │ ├── LicenseKeyFactory.php │ ├── OrderFactory.php │ └── SubscriptionFactory.php └── migrations │ ├── 2023_01_16_000001_create_customers_table.php │ ├── 2023_01_16_000002_create_subscriptions_table.php │ ├── 2023_01_16_000003_create_orders_table.php │ ├── 2023_01_16_000004_create_license_keys_table.php │ └── 2023_01_16_000005_create_license_key_instances_table.php ├── phpstan.neon.dist ├── pint.json ├── resources └── views │ ├── components │ └── button.blade.php │ └── js.blade.php └── src ├── Billable.php ├── Checkout.php ├── Concerns ├── ManagesCheckouts.php ├── ManagesCustomer.php ├── ManagesLicenses.php ├── ManagesOrders.php ├── ManagesSubscriptions.php └── Prorates.php ├── Console ├── ListLicensesCommand.php ├── ListProductsCommand.php └── ListenCommand.php ├── Customer.php ├── Events ├── LicenseKeyCreated.php ├── LicenseKeyUpdated.php ├── OrderCreated.php ├── OrderRefunded.php ├── SubscriptionCancelled.php ├── SubscriptionCreated.php ├── SubscriptionExpired.php ├── SubscriptionPaused.php ├── SubscriptionPaymentFailed.php ├── SubscriptionPaymentRecovered.php ├── SubscriptionPaymentSuccess.php ├── SubscriptionResumed.php ├── SubscriptionUnpaused.php ├── SubscriptionUpdated.php ├── WebhookHandled.php └── WebhookReceived.php ├── Exceptions ├── InvalidCustomPayload.php ├── InvalidCustomer.php ├── LemonSqueezyApiError.php ├── LicenseKeyNotFound.php ├── LicenseKeyNotValidated.php ├── MalformedDataError.php ├── MissingStore.php └── ReservedCustomKeys.php ├── Http ├── Controllers │ └── WebhookController.php ├── Middleware │ └── VerifyWebhookSignature.php └── Throwable │ ├── BadRequest.php │ └── NotFound.php ├── LemonSqueezy.php ├── LemonSqueezyServiceProvider.php ├── LicenseKey.php ├── LicenseKeyInstance.php ├── Order.php ├── Subscription.php └── Webhooks ├── Enums ├── AffiliateStatus.php ├── BillingReason.php ├── CardBrand.php ├── LicenseKeyStatus.php ├── OrderStatus.php ├── PauseMode.php ├── SubscriptionInvoiceStatus.php ├── SubscriptionStatus.php └── Topic.php ├── Factory.php └── Hooks ├── Affiliate.php ├── Hook.php ├── LicenseKey.php ├── Meta.php ├── Order.php ├── OrderItem.php ├── Pause.php ├── Subscription.php ├── SubscriptionInvoice.php ├── SubscriptionItem.php └── Webhook.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | juststevemcd@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | Copyright (c) Dries Vints 5 | Copyright (c) Steve McDougall 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Readme header

2 | 3 | # Lemon Squeezy for Laravel 4 | 5 | 6 | Tests 7 | 8 | 9 | Coding Standards 10 | 11 | 12 | Latest Stable Version 13 | 14 | 15 | Total Downloads 16 | 17 | 18 | A package to easily integrate your [Laravel](https://laravel.com) application with Lemon Squeezy. It takes the pain out of setting up a checkout experience. Easily set up payments for your products or let your customers subscribe to your product plans. Handle grace periods, pause subscriptions, or offer free trials. 19 | 20 | This package drew inspiration from [Cashier](https://github.com/laravel/cashier-stripe) which was created by [Taylor Otwell](https://twitter.com/taylorotwell). 21 | 22 | We also recommend to read the Lemon Squeezy [docs](https://docs.lemonsqueezy.com/help) and [developer guide](https://docs.lemonsqueezy.com/guides/developer-guide). 23 | 24 | ## Roadmap 25 | 26 | The below features are not yet in this package but are planned to be added in the future: 27 | 28 | - Subscription invoices 29 | - [Usage Based Billing](https://github.com/lmsqueezy/laravel/issues/55) 30 | - Marketing emails check 31 | - Create discount codes 32 | - [Nova integration](https://github.com/lmsqueezy/laravel/issues/51) 33 | 34 | ## Requirements 35 | 36 | - PHP 8.1 or higher 37 | - Laravel 10.0 or higher 38 | 39 | ## Installation 40 | 41 | There are a few steps you'll need to take to install the package: 42 | 43 | 1. Requiring the package through Composer 44 | 2. Creating an API Key 45 | 3. Connecting your store 46 | 4. Configuring the Billable Model 47 | 5. Running Migrations 48 | 6. Connecting to Lemon JS 49 | 7. Setting up webhooks 50 | 51 | We'll go over each of these below. 52 | 53 | ### Composer 54 | 55 | Install the package with composer: 56 | 57 | ```bash 58 | composer require lemonsqueezy/laravel 59 | ``` 60 | 61 | ### API Key 62 | 63 | Next, configure your API key. Create a new key in testing mode in [the Lemon Squeezy dashboard](https://app.lemonsqueezy.com/settings/api) and paste them in your `.env` file as shown below: 64 | 65 | ```ini 66 | LEMON_SQUEEZY_API_KEY=your-lemon-squeezy-api-key 67 | ``` 68 | 69 | When you're deploying your app to production, you'll have to create a new key in production mode to work with live data. 70 | 71 | ### Store Identifier 72 | 73 | Your store identifier will be used when creating checkouts for your products. Go to [your Lemon Squeezy stores settings](https://app.lemonsqueezy.com/settings/stores) and copy the Store ID (the part after the `#` sign) into the env value below: 74 | 75 | ```ini 76 | LEMON_SQUEEZY_STORE=your-lemon-squeezy-store-id 77 | ``` 78 | 79 | ### Billable Model 80 | 81 | To make sure we can actually create checkouts for our customers, we'll need to configure a model to be our "billable" model. This is typical the `User` model of your app. To do this, import and use the `Billable` trait on your model: 82 | 83 | ```php 84 | use LemonSqueezy\Laravel\Billable; 85 | 86 | class User extends Authenticatable 87 | { 88 | use Billable; 89 | } 90 | ``` 91 | 92 | Now your user model will have access to methods from our package to create checkouts in Lemon Squeezy for your products. Note that you can make any model type a billable as you wish. It's not required to use one specific model class. 93 | 94 | > [!NOTE] 95 | > Every action on the library like charging, creating checkouts or subscribing will automatically create a customer in Lemon Squeezy for you and connect it to your billable. It should never be necessary to manually create a customer with the Lemon Squeezy API. 96 | 97 | ### Running Migrations 98 | 99 | The package comes with some migrations to store data received from Lemon Squeezy by webhooks. It'll add a `lemon_squeezy_customers` table which holds all info about a customer. This table is connected to a billable model of any model type you wish. It'll also add a `lemon_squeezy_subscriptions` table which holds info about subscriptions. Install these migrations by simply running `artisan migrate`: 100 | 101 | ```bash 102 | php artisan migrate 103 | ``` 104 | 105 | If you want to customize these migrations, you can [overwrite them](#overwriting-migrations). 106 | 107 | ### Lemon JS 108 | 109 | Lemon Squeezy uses its own JavaScript library to initiate its checkout widget. We can make use of it by loading it through the Blade directive in the `head` section of our app, right before the closing `` tag. 110 | 111 | ```blade 112 | 113 | ... 114 | 115 | @lemonJS 116 | 117 | ``` 118 | 119 | ### Webhooks 120 | 121 | Finally, make sure to set up incoming webhooks. This is both needed in development as in production. 122 | 123 | #### Webhooks In Development 124 | 125 | The easiest way to set this up while developing your app is with the `php artisan lmsqueezy:listen` command that ships with this package. This command will setup a webhook through the Lemon Squeezy API, start listening for any events and remove the webhook when quitting the command. 126 | 127 | ```bash 128 | php artisan lmsqueezy:listen 129 | ``` 130 | 131 | For ngrok, if your app is not running on port `8000` pass in the port you want to tunnel using: 132 | ```aiignore 133 | php artisan lmsqueezy:listen ngrok --port=80 134 | ``` 135 | 136 | Although this command should always cleanup the webhook after itself, you may wish to cleanup any lingering webhooks with the `--cleanup` flag: 137 | 138 | ```bash 139 | php artisan lmsqueezy:listen --cleanup 140 | ``` 141 | 142 | Currently, this command supports [Ngrok](https://ngrok.com/) and [Expose](https://github.com/beyondcode/expose). 143 | 144 | > [!WARNING] 145 | > The `lmsqueezy:listen` command is currently not supported in Windows due to the lack of signal handling. Instead you can take the manual approach from the [webhooks in production](#webhooks-in-production) docs below. You'll still need to use a service like Ngrok or Expose to expose a publically accessible url. 146 | 147 | #### Webhooks In Production 148 | 149 | For production, we'll need to setup things manually. The package already ships with a route so all that's left is to go to [your Lemon Squeezy's webhook settings](https://app.lemonsqueezy.com/settings/webhooks) and point the url to your app's domain. The path you should point to is `/lemon-squeezy/webhook` by default. Make sure to select all event types. 150 | 151 | > [!NOTE] 152 | > We also very much recommend to [verify webhook signatures](#verifying-webhook-signatures) in production. 153 | 154 | #### Webhooks & CSRF Protection 155 | 156 | Incoming webhooks should not be affected by [CSRF protection](https://laravel.com/docs/csrf). To prevent this, add your webhook path to the except list of your `App\Http\Middleware\VerifyCsrfToken` middleware: 157 | 158 | ```php 159 | protected $except = [ 160 | 'lemon-squeezy/*', 161 | ]; 162 | ``` 163 | 164 | Or if you're using Laravel v11 and up, you should exclude `lemon-squeezy/*` in your application's `bootstrap/app.php` file: 165 | 166 | ```php 167 | ->withMiddleware(function (Middleware $middleware) { 168 | $middleware->validateCsrfTokens(except: [ 169 | 'lemon-squeezy/*', 170 | ]); 171 | }) 172 | ``` 173 | 174 | ## Upgrading 175 | 176 | Please review [our upgrade guide](./UPGRADE.md) when upgrading to a new version. 177 | 178 | ## Configuration 179 | 180 | The package offers various way to configure your experience with integrating with Lemon Squeezy. 181 | 182 | By default, we don't recommend publishing the config file as most things can be configured with environment variables. Should you still want to adjust the config file, you can publish it with the following command: 183 | 184 | ```bash 185 | php artisan vendor:publish --tag="lemon-squeezy-config" 186 | ``` 187 | 188 | ### Verifying Webhook Signatures 189 | 190 | In order to make sure that incoming webhooks are actually from Lemon Squeezy, we can configure a signing secret for them. Go to your webhook settings in the Lemon Squeezy dashboard, click on the webhook of your app and copy the signing secret into the environment variable below: 191 | 192 | ```ini 193 | LEMON_SQUEEZY_SIGNING_SECRET=your-webhook-signing-secret 194 | ``` 195 | 196 | Any incoming webhook will now first be verified before being executed. 197 | 198 | ### Overwriting Migrations 199 | 200 | Lemon Squeezy for Laravel ships with some migrations to hold data sent over. If you're using something like a string based identifier for your billable model, like a UUID, or want to adjust something to the migrations you can overwrite them. First, publish these with the following command: 201 | 202 | ```bash 203 | php artisan vendor:publish --tag="lemon-squeezy-migrations" 204 | ``` 205 | 206 | Then, ignore the package's migrations in your `AppServiceProvider`'s `register` method: 207 | 208 | ```php 209 | use LemonSqueezy\Laravel\LemonSqueezy; 210 | 211 | public function register(): void 212 | { 213 | LemonSqueezy::ignoreMigrations(); 214 | } 215 | ``` 216 | 217 | Now you'll rely on your own migrations rather than the package one. Please note though that you're now responsible as well for keeping these in sync withe package one manually whenever you upgrade the package. 218 | 219 | ## Commands 220 | 221 | Below you'll find a list of commands you can run to retrieve info from Lemon Squeezy: 222 | 223 | Command | Description 224 | --- | --- 225 | `php artisan lmsqueezy:products` | List all available products with their variants and prices 226 | `php artisan lmsqueezy:products 12345` | List a specific product by its ID with its variants and prices 227 | `php artisan lmsqueezy:licenses 12345` | List licenses generated for a given product ID 228 | `php artisan lmsqueezy:licenses -p 3 -s 20` | List the paginated result of all generated licenses 229 | `php artisan lmsqueezy:licenses --order=1234 --status=active` | List active licenses for a given order ID 230 | 231 | 232 | ## Checkouts 233 | 234 | With this package, you can easily create checkouts for your customers. 235 | 236 | ### Single Payments 237 | 238 | For example, to create a checkout for a single-payment, use a variant ID of a product variant you want to sell and create a checkout using the snippet below: 239 | 240 | ```php 241 | use Illuminate\Http\Request; 242 | 243 | Route::get('/buy', function (Request $request) { 244 | return $request->user()->checkout('variant-id'); 245 | }); 246 | ``` 247 | 248 | This will automatically redirect your customer to a Lemon Squeezy checkout where the customer can buy your product. 249 | 250 | > [!NOTE] 251 | > When creating a checkout for your store, each time you redirect a checkout object or call `url` on the checkout object, an API call to Lemon Squeezy will be made. These calls are expensive and can be time and resource consuming for your app. If you are creating the same session over and over again you may want to cache these urls. 252 | 253 | #### Custom Priced Charges 254 | 255 | You can also overwrite the amount of a product variant by calling the `charge` method on a customer: 256 | 257 | ```php 258 | use Illuminate\Http\Request; 259 | 260 | Route::get('/buy', function (Request $request) { 261 | return $request->user()->charge(2500, 'variant-id'); 262 | }); 263 | ``` 264 | 265 | The amount should be a positive integer in cents. 266 | 267 | You'll still need to provide a variant ID but can overwrite the price as you see fit. One thing you can do is create a "generic" product with a specific currency which you can dynamically charge against. 268 | 269 | ### Overlay Widget 270 | 271 | Instead of redirecting your customer to a checkout screen, you can also create a checkout button which will render a checkout overlay on your page. To do this, pass the `$checkout` object to a view: 272 | 273 | ```php 274 | use Illuminate\Http\Request; 275 | 276 | Route::get('/buy', function (Request $request) { 277 | $checkout = $request->user()->checkout('variant-id'); 278 | 279 | return view('billing', ['checkout' => $checkout]); 280 | }); 281 | ``` 282 | 283 | Now, create the button using the shipped Laravel Blade component from the package: 284 | 285 | ```blade 286 | 287 | Buy Product 288 | 289 | ``` 290 | 291 | When a user clicks this button, it'll trigger the Lemon Squeezy checkout overlay. You can also, optionally request it to be rendered in dark mode: 292 | 293 | ```blade 294 | 295 | Buy Product 296 | 297 | ``` 298 | 299 | If you're checking out subscriptions, and you don't want to show the "You will be charged..." text, you may disable this by calling the `withoutSubscriptionPreview` method on the checkout object: 300 | 301 | ```php 302 | $request->user()->subscribe('variant-id') 303 | ->withoutSubscriptionPreview(); 304 | ``` 305 | 306 | If you want to set a different color for the checkout button you may pass a hex color code (with the leading `#` sign) through `withButtonColor`: 307 | 308 | ```php 309 | $request->user()->checkout('variant-id') 310 | ->withButtonColor('#FF2E1F'); 311 | ``` 312 | 313 | ### Prefill User Data 314 | 315 | You can easily prefill user data for checkouts by overwriting the following methods on your billable model: 316 | 317 | ```php 318 | public function lemonSqueezyName(): ?string; // name 319 | public function lemonSqueezyEmail(): ?string; // email 320 | public function lemonSqueezyCountry(): ?string; // country 321 | public function lemonSqueezyZip(): ?string; // zip 322 | public function lemonSqueezyTaxNumber(): ?string; // tax_number 323 | ``` 324 | 325 | By default, the attributes displayed in a comment on the right of the methods will be used. 326 | 327 | Additionally, you may also pass this data on the fly by using the following methods: 328 | 329 | ```php 330 | use Illuminate\Http\Request; 331 | 332 | Route::get('/buy', function (Request $request) { 333 | return $request->user()->checkout('variant-id') 334 | ->withName('John Doe') 335 | ->withEmail('john@example.com') 336 | ->withBillingAddress('US', '10038') // Country & Zip Code 337 | ->withTaxNumber('123456679') 338 | ->withDiscountCode('PROMO'); 339 | }); 340 | ``` 341 | 342 | ### Product Details 343 | 344 | You can overwrite additional data for product checkouts with the `withProductName` and `withDescription` methods: 345 | 346 | ```php 347 | $request->user()->checkout('variant-id') 348 | ->withProductName('Ebook') 349 | ->withDescription('A thrilling novel!'); 350 | ``` 351 | 352 | ### Receipt Thank You 353 | 354 | Additionally, you can customize the thank you note for the order receipt email. 355 | 356 | ```php 357 | $request->user()->checkout('variant-id') 358 | ->withThankYouNote('Thanks for your purchase!'); 359 | ``` 360 | 361 | ### Redirects After Purchase 362 | 363 | To redirect customers back to your app after purchase, you may use the `redirectTo` method: 364 | 365 | ```php 366 | $request->user()->checkout('variant-id') 367 | ->redirectTo(url('/')); 368 | ``` 369 | 370 | You may also set a default url for this by configuring the `lemon-squeezy.redirect_url` in your config file: 371 | 372 | ```php 373 | 'redirect_url' => 'https://my-app.com/dashboard', 374 | ``` 375 | 376 | In order to do this you'll need to [publish your config file](#configuration). 377 | 378 | ### Expire Checkouts 379 | 380 | You can indicate how long a checkout session should stay active by calling the `expiresAt` method on it: 381 | 382 | ```php 383 | $request->user()->checkout('variant-id') 384 | ->expiresAt(now()->addDays(3)); 385 | ``` 386 | 387 | ### Custom Data 388 | 389 | You can also [pass along custom data with your checkouts](https://docs.lemonsqueezy.com/help/checkout/passing-custom-data). To do this, send along key/value pairs with the checkout method: 390 | 391 | ```php 392 | use Illuminate\Http\Request; 393 | 394 | Route::get('/buy', function (Request $request) { 395 | return $request->user()->checkout('variant-id', custom: ['foo' => 'bar']); 396 | }); 397 | ``` 398 | 399 | These will then later be available in the related webhooks for you. 400 | 401 | #### Reserved Keywords 402 | 403 | When working with custom data there are a few reserved keywords for this library: 404 | 405 | - `billable_id` 406 | - `billable_type` 407 | - `subscription_type` 408 | 409 | Attempting to use any of these will result in an exception being thrown. 410 | 411 | ## Customers 412 | 413 | ### Customer Portal 414 | 415 | Customers may easily manage their personal data like their name, email address, etc by visiting their [customer portal](https://docs.lemonsqueezy.com/guides/developer-guide/customer-portal). Lemon Squeezy for Laravel makes it easy to redirect customers to this by calling `redirectToCustomerPortal` on the billable: 416 | 417 | ```php 418 | use Illuminate\Http\Request; 419 | 420 | Route::get('/customer-portal', function (Request $request) { 421 | return $request->user()->redirectToCustomerPortal(); 422 | }); 423 | ``` 424 | 425 | In order to call this method your billable already needs to have a subscription through Lemon Squeezy. Also, this method will perform an underlying API call so make sure to place this redirect behind a route which you can link to in your app. 426 | 427 | Optionally, you also get the signed customer portal url directly: 428 | 429 | ```php 430 | $url = $user->customerPortalUrl(); 431 | ``` 432 | 433 | ### My Orders 434 | 435 | Besides the customer portal for managing subscriptions, [Lemon Squeezy also has a "My Orders" portal](https://docs.lemonsqueezy.com/help/online-store/my-orders) to manage all of your purchases for a customer account. This does involve a mixture of purchases across multiple vendors. If this is something you wish your customers can find, you can link to [`https://app.lemonsqueezy.com/my-orders`](https://app.lemonsqueezy.com/my-orders) and tell them to login with the email address they performed the purchase with. 436 | 437 | ## Orders 438 | 439 | Lemon Squeezy allows you to retrieve a list of all orders made for your store. You can then use this list to present all orders to your customers. 440 | 441 | ### Retrieving Orders 442 | 443 | To retrieve a list of orders for a specific customer, simply call the saved models in the database: 444 | 445 | ```blade 446 | 447 | @foreach ($user->orders as $order) 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | @endforeach 456 |
{{ $order->ordered_at->toFormattedDateString() }}{{ $order->order_number }}{{ $order->subtotal() }}{{ $order->discount() }}{{ $order->tax() }}{{ $order->total() }}{{ $order->receipt_url }}
457 | ``` 458 | 459 | ### Checking Order Status 460 | 461 | To check if an individual order is paid, you may use the `paid` method: 462 | 463 | ```php 464 | if ($order->paid()) { 465 | // ... 466 | } 467 | ``` 468 | 469 | Besides that, you have three other checks you can do: `pending`, `failed` & `refunded`. If the order is `refunded`, you may also use the `refunded_at` timestamp: 470 | 471 | ```blade 472 | @if ($order->refunded()) 473 | Order {{ $order->order_number }} was refunded on {{ $order->refunded_at->toFormattedDateString() }} 474 | @endif 475 | ``` 476 | 477 | You can also check if an order was for a specific product: 478 | 479 | ```php 480 | if ($order->hasProduct('your-product-id')) { 481 | // ... 482 | } 483 | ``` 484 | 485 | Or for a specific variant: 486 | 487 | ```php 488 | if ($order->hasVariant('your-variant-id')) { 489 | // ... 490 | } 491 | ``` 492 | 493 | Additionally, you may check if a customer has purchased a specific product: 494 | 495 | ```php 496 | if ($user->hasPurchasedProduct('your-product-id')) { 497 | // ... 498 | } 499 | ``` 500 | 501 | Or a specific variant: 502 | 503 | ```php 504 | if ($user->hasPurchasedVariant('your-variant-id')) { 505 | // ... 506 | } 507 | ``` 508 | 509 | These two checks will both make sure the correct product or variant was purchased and paid for. This is useful as well if you're offering a feature in your app like lifetime access. 510 | 511 | ## Subscriptions 512 | 513 | ### Setting Up Subscription Products 514 | 515 | Setting up subscription products with different plans and intervals needs to be done in a specific way. Lemon Squeezy has [a good guide](https://docs.lemonsqueezy.com/guides/tutorials/saas-subscription-plans) on how to do this. 516 | 517 | Although you're free to choose how you set up products and plans, it's easier to go for option two and create a product for each plan type. So for example, when you have a "Basic" and "Pro" plan and both have monthly and yearly prices, it's wiser to create two separate products for these and then add two variants for each for their monthly and yearly prices. 518 | 519 | This gives you the advantage later on to make use of the `hasProduct` method on a subscription which allows you to just check if a subscription is on a specific plan type and don't worry if it's on a monthly or yearly schedule. 520 | 521 | ### Creating Subscriptions 522 | 523 | Starting subscriptions is easy. For this, we need the variant id from our product. Copy the variant id and initiate a new subscription checkout from your billable model: 524 | 525 | ```php 526 | use Illuminate\Http\Request; 527 | 528 | Route::get('/subscribe', function (Request $request) { 529 | return $request->user()->subscribe('variant-id'); 530 | }); 531 | ``` 532 | 533 | When the customer has finished their checkout, the incoming `SubscriptionCreated` webhook will couple it to your billable model in the database. You can then retrieve the subscription from your billable model: 534 | 535 | ```php 536 | $subscription = $user->subscription(); 537 | ``` 538 | 539 | ### Checking Subscription Status 540 | 541 | Once a customer is subscribed to your services, you can use a variety of methods to check for various states on the subscription. The most basic example, is to check if a customer is subscribed to a valid subscription: 542 | 543 | ```php 544 | if ($user->subscribed()) { 545 | // ... 546 | } 547 | ``` 548 | 549 | You may use this in various places in your app like middleware, policies, etc, to offer your services. To check if an individual subscription is valid, you may use the `valid` method: 550 | 551 | ```php 552 | if ($user->subscription()->valid()) { 553 | // ... 554 | } 555 | ``` 556 | 557 | This method, as well as the `subscribed` method, will return true if your subscription is active, on trial, past due, paused for free or on its cancelled grace period. 558 | 559 | You can also check if a subscription is on a specific product: 560 | 561 | ```php 562 | if ($user->subscription()->hasProduct('your-product-id')) { 563 | // ... 564 | } 565 | ``` 566 | 567 | Or on a specific variant: 568 | 569 | ```php 570 | if ($user->subscription()->hasVariant('your-variant-id')) { 571 | // ... 572 | } 573 | ``` 574 | 575 | If you want to check if a subscription is on a specific variant and at the same valid you can use: 576 | 577 | ```php 578 | if ($user->subscribedToVariant('your-variant-id')) { 579 | // ... 580 | } 581 | ``` 582 | 583 | Or if you're using [multiple subscription types](#multiple-subscriptions), you can pass a type as an extra parameter: 584 | 585 | ```php 586 | if ($user->subscribed('swimming')) { 587 | // ... 588 | } 589 | 590 | if ($user->subscribedToVariant('your-variant-id', 'swimming')) { 591 | // ... 592 | } 593 | ``` 594 | 595 | #### Cancelled Status 596 | 597 | To check if a user has cancelled their subscription you may use the `cancelled` method: 598 | 599 | ```php 600 | if ($user->subscription()->cancelled()) { 601 | // ... 602 | } 603 | ``` 604 | 605 | When they're on their grace period, you can use the `onGracePeriod` check: 606 | 607 | ```php 608 | if ($user->subscription()->onGracePeriod()) { 609 | // ... 610 | } 611 | ``` 612 | 613 | If a subscription is fully cancelled and no longer on its grace period, you may use the `expired` check: 614 | 615 | ```php 616 | if ($user->subscription()->expired()) { 617 | // ... 618 | } 619 | ``` 620 | 621 | #### Past Due Status 622 | 623 | If a recurring payment for a subscription fails, the subscription will transition in a past due state. This means it's still a valid subscription but your customer will have a 2 weeks period where their payments will be retried. 624 | 625 | ```php 626 | if ($user->subscription()->pastDue()) { 627 | // ... 628 | } 629 | ``` 630 | 631 | In this state, you should instruct your customer to [update their payment info](#updating-payment-information). Failed payments in Lemon Squeezy are retried a couple of times. For more information on that, as well as the dunning process, head over to [the Lemon Squeezy documentation](https://docs.lemonsqueezy.com/help/online-store/recovery-dunning) 632 | 633 | #### Subscription Scopes 634 | 635 | Various subscriptions scopes are available to query subscriptions in specific states: 636 | 637 | ```php 638 | // Get all active subscriptions... 639 | $subscriptions = Subscription::query()->active()->get(); 640 | 641 | // Get all of the cancelled subscriptions for a specific user... 642 | $subscriptions = $user->subscriptions()->cancelled()->get(); 643 | ``` 644 | 645 | Here's all available scopes: 646 | 647 | ```php 648 | Subscription::query()->onTrial(); 649 | Subscription::query()->active(); 650 | Subscription::query()->paused(); 651 | Subscription::query()->pastDue(); 652 | Subscription::query()->unpaid(); 653 | Subscription::query()->cancelled(); 654 | Subscription::query()->expired(); 655 | ``` 656 | 657 | ### Updating Payment Information 658 | 659 | To allow your customer to [update their payment details](https://docs.lemonsqueezy.com/guides/developer-guide/managing-subscriptions#updating-billing-details), like their credit card info, you can redirect them with the following method: 660 | 661 | ```php 662 | use Illuminate\Http\Request; 663 | 664 | Route::get('/update-payment-info', function (Request $request) { 665 | $subscription = $request->user()->subscription(); 666 | 667 | return view('billing', [ 668 | 'paymentMethodUrl' => $subscription->updatePaymentMethodUrl(), 669 | ]); 670 | }); 671 | ``` 672 | 673 | Alternatively, if you want the URL to open in a more seamless way on top of your app (similar to the checkout overlay), you may use [Lemon.js](https://docs.lemonsqueezy.com/help/lemonjs/opening-overlays#updating-payment-details-overlay) to open the URL with the `LemonSqueezy.Url.Open()` method. First, pass the url to a view: 674 | 675 | ```php 676 | use Illuminate\Http\Request; 677 | 678 | Route::get('/update-payment-info', function (Request $request) { 679 | $subscription = $request->user()->subscription(); 680 | 681 | return view('billing', [ 682 | 'paymentMethodUrl' => $subscription->updatePaymentMethodUrl(), 683 | ]); 684 | }); 685 | ``` 686 | 687 | Then trigger it through a button: 688 | 689 | ```blade 690 | 695 | 696 | 699 | ``` 700 | 701 | This requires you to have set up [Lemon.js](#lemon-js). 702 | 703 | ### Changing Plans 704 | 705 | When a customer is subscribed to a monthly plan, they might want to upgrade to a better plan, change their payments to a yearly plan or downgrade to a cheaper plan. For these situations, you can allow them to swap plans by passing a different variant id with its product id to the `swap` method: 706 | 707 | ```php 708 | use App\Models\User; 709 | 710 | $user = User::find(1); 711 | 712 | $user->subscription()->swap('product-id', 'variant-id'); 713 | ``` 714 | 715 | This will swap the customer to their new subscription plan but billing will only be done on the next billing cycle. If you'd like to immediately invoice the customer you may use the `swapAndInvoice` method instead: 716 | 717 | ```php 718 | $user = User::find(1); 719 | 720 | $user->subscription()->swapAndInvoice('product-id', 'variant-id'); 721 | ``` 722 | 723 | > [!NOTE] 724 | > You'll notice in the above methods that you both need to provide a product ID and variant ID and might wonder why that is. Can't you derive the product ID from the variant ID? Unfortuntately that's only possible when swapping to variants between the same product. When swapping to a different product alltogether you are required to also provide the product ID in the Lemon Squeezy API. Therefor, we've made the decision to make this uniform and just always require the product ID as well. 725 | 726 | #### Prorations 727 | 728 | By default, Lemon Squeezy will prorate amounts when changing plans. If you want to prevent this, you may use the `noProrate` method before executing the swap: 729 | 730 | ```php 731 | $user = User::find(1); 732 | 733 | $user->subscription()->noProrate()->swap('product-id', 'variant-id'); 734 | ``` 735 | 736 | ### Changing The Billing Date 737 | 738 | To change the date of the month on which your customer gets billed for their subscription, you may use the `anchorBillingCycleOn` method: 739 | 740 | ```php 741 | $user = User::find(1); 742 | 743 | $user->subscription()->anchorBillingCycleOn(21); 744 | ``` 745 | 746 | In the above example, the customer will now get billed on the 21st of each month going forward. For more info, see [the Lemon Squeezy docs](https://docs.lemonsqueezy.com/guides/developer-guide/managing-subscriptions#changing-the-billing-date). 747 | 748 | ### Multiple Subscriptions 749 | 750 | In some situation you may find yourself wanting to allow your customer to subscribe to multiple subscription types. For example, a gym may offer a swimming and weight lifting subscription. You can allow your customer to subscribe to either or both. 751 | 752 | To handle the different subscriptions you may provide a `type` of subscription as the second argument to `subscribe` when starting a new one: 753 | 754 | ```php 755 | $user = User::find(1); 756 | 757 | $checkout = $user->subscribe('variant-id', 'swimming'); 758 | ``` 759 | 760 | Now you may always refer this specific subscription type by providing the `type` argument when retrieving it: 761 | 762 | ```php 763 | $user = User::find(1); 764 | 765 | // Retrieve the swimming subscription type... 766 | $subscription = $user->subscription('swimming'); 767 | 768 | // Swap plans for the gym subscription type... 769 | $user->subscription('gym')->swap('product-id', 'variant-id'); 770 | 771 | // Cancel the swimming subscription... 772 | $user->subscription('swimming')->cancel(); 773 | ``` 774 | 775 | ### Pausing Subscriptions 776 | 777 | To [pause subscriptions](https://docs.lemonsqueezy.com/guides/developer-guide/managing-subscriptions#pausing-and-unpausing-subscriptions), call the `pause` method on it: 778 | 779 | ```php 780 | $user = User::find(1); 781 | 782 | $user->subscription()->pause(); 783 | ``` 784 | 785 | Optionally, provide a date when the subscription can resume: 786 | 787 | ```php 788 | $user = User::find(1); 789 | 790 | $user->subscription()->pause( 791 | now()->addDays(5) 792 | ); 793 | ``` 794 | 795 | This will fill in the `resumes_at` timestamp on your customer. To know if your subscription is within its paused period you can use the `onPausedPeriod` method: 796 | 797 | ```php 798 | if ($user->subscription()->onPausedPeriod()) { 799 | // ... 800 | } 801 | ``` 802 | 803 | To unpause, simply call that method on the subscription: 804 | 805 | ```php 806 | $user->subscription()->unpause(); 807 | ``` 808 | 809 | #### Pause State 810 | 811 | By default, pausing a subscription will void its usage for the remainder of the pause period. If you instead would like your customers to use your services for free, you may use the `pauseForFree` method: 812 | 813 | ```php 814 | $user->subscription()->pauseForFree(); 815 | ``` 816 | 817 | ### Cancelling Subscriptions 818 | 819 | To [cancel a subscription](https://docs.lemonsqueezy.com/guides/developer-guide/managing-subscriptions#cancelling-and-resuming-subscriptions), call the `cancel` method on it: 820 | 821 | ```php 822 | $user = User::find(1); 823 | 824 | $user->subscription()->cancel(); 825 | ``` 826 | 827 | This will set your subscription to be cancelled. If your subscription is cancelled mid-cycle, it'll enter a grace period and the `ends_at` column will be set. The customer will still have access to the services provided for the remainder of the period. You can check for its grace period by calling the `onGracePeriod` method: 828 | 829 | ```php 830 | if ($user->subscription()->onGracePeriod()) { 831 | // ... 832 | } 833 | ``` 834 | 835 | Immediate cancellation with Lemon Squeezy is not possible. To resume a subscription while it's still on its grace period, call the `resume` method: 836 | 837 | ```php 838 | $user->subscription()->resume(); 839 | ``` 840 | 841 | When a cancelled subscription reaches the end of its grace period it'll transition to a state of expired and won't be able to resume any longer. 842 | 843 | ### Subscription Trials 844 | 845 | For a thorough read on trialing in Lemon Squeezy, [have a look at their guide](https://docs.lemonsqueezy.com/guides/tutorials/saas-free-trials). 846 | 847 | #### No Payment Required 848 | 849 | To allow people to signup for your product without having them to fill out their payment details, you may set the `trial_ends_at` column when creating them as a customer: 850 | 851 | ```php 852 | use App\Models\User; 853 | 854 | $user = User::create([ 855 | // ... 856 | ]); 857 | 858 | $user->createAsCustomer([ 859 | 'trial_ends_at' => now()->addDays(10) 860 | ]); 861 | ``` 862 | 863 | This is what's called "a generic trial" because it's not attached to any subscription. You can use the `onTrial` method to check if a customer is currently trialing your app: 864 | 865 | ```php 866 | if ($user->onTrial()) { 867 | // User is within their trial period... 868 | } 869 | ``` 870 | 871 | Or if you specifically also want to make sure it's a generic trial, you can use the `onGenericTrial` method: 872 | 873 | ```php 874 | if ($user->onGenericTrial()) { 875 | // User is within their "generic" trial period... 876 | } 877 | ``` 878 | 879 | You can also retrieve the ending date of the trial by calling the `trialEndsAt` method: 880 | 881 | ```php 882 | if ($user->onTrial()) { 883 | $trialEndsAt = $user->trialEndsAt(); 884 | } 885 | ``` 886 | 887 | As soon as your customer is ready, or after their trial has expired, they may start their subscription: 888 | 889 | ```php 890 | use Illuminate\Http\Request; 891 | 892 | Route::get('/buy', function (Request $request) { 893 | return $request->user()->subscribe('variant-id'); 894 | }); 895 | ``` 896 | 897 | Please note that when a customer starts their subscription when they're still on their generic trial, their trial will be cancelled because they have started to pay for your product. 898 | 899 | #### Payment required 900 | 901 | Another option is to require payment details when people want to trial your products. This means that after the trial expires, they'll immediately be subscribed to your product. To get started with this, you'll need to [configure a trial period in your product's settings](https://docs.lemonsqueezy.com/guides/tutorials/saas-free-trials#1-create-subscription-products-with-trials). Then, let a customer start a subscription: 902 | 903 | ```php 904 | use Illuminate\Http\Request; 905 | 906 | Route::get('/buy', function (Request $request) { 907 | return $request->user()->subscribe('variant-id'); 908 | }); 909 | ``` 910 | 911 | After your customer is subscribed, they'll enter their trial period which you configured and won't be charged until after this date. You'll need to give them the option to cancel their subscription before this time if they want. 912 | 913 | To check if your customer is currently on their free trial, you may use the `onTrial` method on both the billable or an individual subscription: 914 | 915 | ```php 916 | if ($user->onTrial()) { 917 | // ... 918 | } 919 | 920 | if ($user->subscription()->onTrial()) { 921 | // ... 922 | } 923 | ``` 924 | 925 | To determine if a trial has expired, you may use the `hasExpiredTrial` method: 926 | 927 | ```php 928 | if ($user->hasExpiredTrial()) { 929 | // ... 930 | } 931 | 932 | if ($user->subscription()->hasExpiredTrial()) { 933 | // ... 934 | } 935 | ``` 936 | 937 | ##### Ending Trials Early 938 | 939 | To end a trial with payment upfront early you may use the `endTrial` method on a subscription: 940 | 941 | ```php 942 | $user = User::find(1); 943 | 944 | $user->subscription()->endTrial(); 945 | ``` 946 | 947 | This method will move the billing achor to the current day and thus ending any trial period the customer had. 948 | 949 | ## License Keys 950 | 951 | License keys can be activated and validated using the license key string. A license key instance will be stored in 952 | the database for each activated license. 953 | 954 | **Please note:** the billable for the license is the billable that purchased the license key, but _not necessarily the 955 | user who activated the license key_, so you should establish a relationship between a license key instance and the user 956 | creating it yourself. 957 | 958 | To activate a license key and retrieve a license key instance: 959 | 960 | ```php 961 | $user->activateLicenseKey('your-key', 'your-reference') 962 | ``` 963 | 964 | To validate a license key: 965 | ```php 966 | $user->activateLicenseKey('your-key', 'your-reference') 967 | ``` 968 | 969 | 970 | ## Handling Webhooks 971 | 972 | Lemon Squeezy can send your app webhooks which you can react on. By default, this package already does the bulk of the work for you. [If you've properly set up webhooks](#webhooks), it'll listen to any incoming events and update your database accordingly. We recommend enabling all event types so it's easy for you to upgrade in the future. 973 | 974 | To listen to incoming webhooks, we have two events that will be fired: 975 | 976 | - `LemonSqueezy\Laravel\Events\WebhookReceived` 977 | - `LemonSqueezy\Laravel\Events\WebhookHandled` 978 | 979 | The `WebhookReceived` will be fired as soon as a webhook comes in but has not been handled by the package's `WebhookController`. The `WebhookHandled` event will be fired as soon as the webhook has been processed by the package. Both events will contain the full payload of the incoming webhook. 980 | 981 | If you want to react to these events, you'll have to create listeners for them. For example, you may want to react to a subscription being updated: 982 | 983 | ```php 984 | payload['meta']['event_name'] === 'subscription_updated') { 998 | // Handle the incoming event... 999 | } 1000 | } 1001 | } 1002 | ``` 1003 | 1004 | For an example payload, [take a look at the Lemon Squeezy docs](https://docs.lemonsqueezy.com/help/webhooks/webhook-requests). 1005 | 1006 | Laravel v11 and up will detect the listener automatically. If you're on Laravel v10 or lower, you should wire it up in your app's `EventServiceProvider`: 1007 | 1008 | ```php 1009 | [ 1021 | LemonSqueezyEventListener::class, 1022 | ], 1023 | ]; 1024 | } 1025 | ``` 1026 | 1027 | ### Webhook Events 1028 | 1029 | Instead of listening to the `WebhookHandled` event, you may also subscribe to one of the following, dedicated package events that are fired after a webhook has been handled: 1030 | 1031 | - `LemonSqueezy\Laravel\Events\OrderCreated` 1032 | - `LemonSqueezy\Laravel\Events\OrderRefunded` 1033 | - `LemonSqueezy\Laravel\Events\SubscriptionCreated` 1034 | - `LemonSqueezy\Laravel\Events\SubscriptionUpdated` 1035 | - `LemonSqueezy\Laravel\Events\SubscriptionCancelled` 1036 | - `LemonSqueezy\Laravel\Events\SubscriptionResumed` 1037 | - `LemonSqueezy\Laravel\Events\SubscriptionExpired` 1038 | - `LemonSqueezy\Laravel\Events\SubscriptionPaused` 1039 | - `LemonSqueezy\Laravel\Events\SubscriptionUnpaused` 1040 | - `LemonSqueezy\Laravel\Events\SubscriptionPaymentSuccess` 1041 | - `LemonSqueezy\Laravel\Events\SubscriptionPaymentFailed` 1042 | - `LemonSqueezy\Laravel\Events\SubscriptionPaymentRecovered` 1043 | - `LemonSqueezy\Laravel\Events\LicenseKeyCreated` 1044 | - `LemonSqueezy\Laravel\Events\LicenseKeyUpdated` 1045 | 1046 | All of these events contain a billable `$model` instance and the event `$payload`. The subscription events also contain the `$subscription` object. These can be accessed through their public properties. 1047 | 1048 | ## Changelog 1049 | 1050 | Check out the [CHANGELOG](CHANGELOG.md) in this repository for all the recent changes. 1051 | 1052 | ## License 1053 | 1054 | Lemon Squeezy for Laravel is open-sourced software licensed under [the MIT license](LICENSE.md). 1055 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Instructions 2 | 3 | 1. Update the `VERSION` constant in [`LemonSqueezy.php`](./src/LemonSqueezy.php) and commit it 4 | 2. Create a new GitHub release for this version with the release notes 5 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | Future upgrade notes will be placed here. 4 | 5 | ## Upgrading To v1.3 From 1.x 6 | 7 | ### New Order Model 8 | 9 | Lemon Squeezy for Laravel v1.3 adds a new `Order` model. In order for your webhooks to start filling these out, you'll need to run the relevant migration: 10 | 11 | ```shell 12 | php artisan migrate 13 | ``` 14 | 15 | And now your webhooks will start saving newly made orders. If you're overwriting your migrations, you'll need to create [this migration](./database/migrations/2023_01_16_000003_create_orders_table.php) manually. 16 | 17 | Previously made orders unfortunately need to be stored manually but we're planning on making a sync command in the future to make this more easily. 18 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lemonsqueezy/laravel", 3 | "description": "A package to easily integrate your Laravel application with Lemon Squeezy.", 4 | "keywords": [ 5 | "laravel", 6 | "lemon squeezy", 7 | "billing" 8 | ], 9 | "license": "MIT", 10 | "support": { 11 | "issues": "https://github.com/lmsqueezy/laravel/issues", 12 | "source": "https://github.com/lmsqueezy/laravel" 13 | }, 14 | "authors": [ 15 | { 16 | "name": "Dries Vints", 17 | "homepage": "https://driesvints.com" 18 | }, 19 | { 20 | "role": "Maintainer", 21 | "name": "Steve McDougall", 22 | "email": "juststevemcd@gmail.com", 23 | "homepage": "https://juststeveking.link" 24 | } 25 | ], 26 | "funding": [ 27 | { 28 | "type": "github", 29 | "url": "https://github.com/sponsors/juststeveking" 30 | }, 31 | { 32 | "type": "paypal", 33 | "url": "https://www.paypal.com/paypalme/driesvints" 34 | } 35 | ], 36 | "require": { 37 | "php": "~8.2.0|~8.3.0|~8.4.0", 38 | "guzzlehttp/guzzle": "^7.0", 39 | "laravel/framework": "^11.0|^12.0", 40 | "laravel/prompts": "^0.2|^0.3", 41 | "moneyphp/money": "^4.0", 42 | "nesbot/carbon": "^2.0|^3.0" 43 | }, 44 | "require-dev": { 45 | "laravel/pint": "^1.20", 46 | "orchestra/testbench": "^9.0|^10.0", 47 | "pestphp/pest": "^2.0|^3.0", 48 | "phpstan/phpstan": "^2.1", 49 | "roave/security-advisories": "dev-latest" 50 | }, 51 | "suggest": { 52 | "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values." 53 | }, 54 | "autoload": { 55 | "psr-4": { 56 | "LemonSqueezy\\Laravel\\": "src/", 57 | "LemonSqueezy\\Laravel\\Database\\Factories\\": "database/factories/" 58 | } 59 | }, 60 | "autoload-dev": { 61 | "psr-4": { 62 | "Tests\\": "tests/" 63 | } 64 | }, 65 | "extra": { 66 | "branch-alias": { 67 | "dev-main": "1.x-dev" 68 | }, 69 | "laravel": { 70 | "providers": [ 71 | "LemonSqueezy\\Laravel\\LemonSqueezyServiceProvider" 72 | ] 73 | } 74 | }, 75 | "config": { 76 | "sort-packages": true, 77 | "allow-plugins": { 78 | "pestphp/pest-plugin": true 79 | } 80 | }, 81 | "minimum-stability": "dev", 82 | "prefer-stable": true, 83 | "scripts": { 84 | "stan": [ 85 | "./vendor/bin/phpstan analyse --memory-limit=-1" 86 | ], 87 | "test": [ 88 | "./vendor/bin/pest" 89 | ] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /config/lemon-squeezy.php: -------------------------------------------------------------------------------- 1 | env('LEMON_SQUEEZY_API_KEY'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Lemon Squeezy Signing Secret 21 | |-------------------------------------------------------------------------- 22 | | 23 | | The Lemon Squeezy signing secret is used to verify that the webhook 24 | | requests are coming from Lemon Squeezy. You can find your signing 25 | | secret in the Lemon Squeezy dashboard under the "Webhooks" section. 26 | | 27 | */ 28 | 29 | 'signing_secret' => env('LEMON_SQUEEZY_SIGNING_SECRET'), 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Lemon Squeezy Url Path 34 | |-------------------------------------------------------------------------- 35 | | 36 | | This is the base URI where routes from Lemon Squeezy will be served 37 | | from. The URL built into Lemon Squeezy is used by default; however, 38 | | you can modify this path as you see fit for your application. 39 | | 40 | */ 41 | 42 | 'path' => env('LEMON_SQUEEZY_PATH', 'lemon-squeezy'), 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Lemon Squeezy Store 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This is the ID of your Lemon Squeezy store. You can find your store 50 | | ID in the Lemon Squeezy dashboard. The entered value should be the 51 | | part after the # sign. 52 | | 53 | */ 54 | 55 | 'store' => env('LEMON_SQUEEZY_STORE'), 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Default Redirect URL 60 | |-------------------------------------------------------------------------- 61 | | 62 | | This is the default redirect URL that will be used when a customer 63 | | is redirected back to your application after completing a purchase 64 | | from a checkout session in your Lemon Squeezy store. 65 | | 66 | */ 67 | 68 | 'redirect_url' => null, 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Currency Locale 73 | |-------------------------------------------------------------------------- 74 | | 75 | | This is the default locale in which your money values are formatted in 76 | | for display. To utilize other locales besides the default en locale 77 | | verify you have the "intl" PHP extension installed on the system. 78 | | 79 | */ 80 | 81 | 'currency_locale' => env('LEMON_SQUEEZY_CURRENCY_LOCALE', 'en'), 82 | 83 | ]; 84 | -------------------------------------------------------------------------------- /database/factories/CustomerFactory.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function definition(): array 23 | { 24 | return [ 25 | 'billable_id' => rand(1, 1000), 26 | 'billable_type' => 'App\\Models\\User', 27 | 'lemon_squeezy_id' => rand(1, 1000), 28 | 'trial_ends_at' => null, 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /database/factories/LicenseKeyFactory.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function definition(): array 23 | { 24 | return [ 25 | 'lemon_squeezy_id' => rand(1, 1000), 26 | 'status' => LicenseKey::STATUS_ACTIVE, 27 | 'disabled' => false, 28 | 'license_key' => $this->faker->uuid(), 29 | 'product_id' => rand(1, 1000), 30 | 'order_id' => rand(1, 1000), 31 | 'activation_limit' => 0, 32 | 'instances_count' => 0, 33 | 'expires_at' => null, 34 | 'updated_at' => null 35 | ]; 36 | } 37 | 38 | /** 39 | * Mark the license key as active. 40 | */ 41 | public function active(): self 42 | { 43 | return $this->state([ 44 | 'status' => LicenseKey::STATUS_ACTIVE, 45 | ]); 46 | } 47 | 48 | /** 49 | * Disable the license key. 50 | */ 51 | public function disable(): self 52 | { 53 | return $this->state([ 54 | 'disabled' => true, 55 | 'status' => LicenseKey::STATUS_DISABLED, 56 | ]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /database/factories/OrderFactory.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function definition(): array 26 | { 27 | return [ 28 | 'billable_id' => rand(1, 1000), 29 | 'billable_type' => 'App\\Models\\User', 30 | 'lemon_squeezy_id' => rand(1, 1000), 31 | 'customer_id' => rand(1, 1000), 32 | 'product_id' => rand(1, 1000), 33 | 'variant_id' => rand(1, 1000), 34 | 'order_number' => rand(1, 1000), 35 | 'currency' => $this->faker->randomElement(['USD', 'EUR', 'GBP']), 36 | 'subtotal' => $subtotal = rand(400, 1000), 37 | 'discount_total' => $discount = rand(1, 400), 38 | 'tax' => $tax = rand(1, 50), 39 | 'total' => $subtotal - $discount + $tax, 40 | 'tax_name' => $this->faker->randomElement(['VAT', 'Sales Tax']), 41 | 'receipt_url' => null, 42 | 'ordered_at' => $orderedAt = Carbon::make($this->faker->dateTimeBetween('-1 year', 'now')), 43 | 'refunded' => $refunded = $this->faker->boolean(75), 44 | 'refunded_at' => $refunded ? $orderedAt->addWeek() : null, 45 | 'status' => $refunded ? Order::STATUS_REFUNDED : Order::STATUS_PAID, 46 | ]; 47 | } 48 | 49 | /** 50 | * Configure the model factory. 51 | */ 52 | public function configure(): self 53 | { 54 | return $this->afterCreating(function ($subscription) { 55 | Customer::factory()->create([ 56 | 'billable_id' => $subscription->billable_id, 57 | 'billable_type' => $subscription->billable_type, 58 | ]); 59 | }); 60 | } 61 | 62 | /** 63 | * Mark the order as pending. 64 | */ 65 | public function pending(): self 66 | { 67 | return $this->state([ 68 | 'status' => Order::STATUS_PENDING, 69 | 'refunded' => false, 70 | 'refunded_at' => null, 71 | ]); 72 | } 73 | 74 | /** 75 | * Mark the order as failed. 76 | */ 77 | public function failed(): self 78 | { 79 | return $this->state([ 80 | 'status' => Order::STATUS_FAILED, 81 | 'refunded' => false, 82 | 'refunded_at' => null, 83 | ]); 84 | } 85 | 86 | /** 87 | * Mark the order as paid. 88 | */ 89 | public function paid(): self 90 | { 91 | return $this->state([ 92 | 'status' => Order::STATUS_PAID, 93 | 'refunded' => false, 94 | 'refunded_at' => null, 95 | ]); 96 | } 97 | 98 | /** 99 | * Mark the order as being refunded. 100 | */ 101 | public function refunded(?DateTimeInterface $refundedAt = null): self 102 | { 103 | return $this->state([ 104 | 'status' => Order::STATUS_REFUNDED, 105 | 'refunded' => true, 106 | 'refunded_at' => $refundedAt, 107 | ]); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /database/factories/SubscriptionFactory.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function definition(): array 25 | { 26 | return [ 27 | 'billable_id' => rand(1, 1000), 28 | 'billable_type' => 'App\\Models\\User', 29 | 'type' => Subscription::DEFAULT_TYPE, 30 | 'lemon_squeezy_id' => rand(1, 1000), 31 | 'status' => Subscription::STATUS_ACTIVE, 32 | 'product_id' => rand(1, 1000), 33 | 'variant_id' => rand(1, 1000), 34 | 'card_brand' => $this->faker->randomElement(['visa', 'mastercard', 'american_express', 'discover', 'jcb', 'diners_club']), 35 | 'card_last_four' => rand(1000, 9999), 36 | 'pause_mode' => null, 37 | 'pause_resumes_at' => null, 38 | 'trial_ends_at' => null, 39 | 'renews_at' => null, 40 | 'ends_at' => null, 41 | ]; 42 | } 43 | 44 | /** 45 | * Configure the model factory. 46 | */ 47 | public function configure(): self 48 | { 49 | return $this->afterCreating(function ($subscription) { 50 | Customer::factory()->create([ 51 | 'billable_id' => $subscription->billable_id, 52 | 'billable_type' => $subscription->billable_type, 53 | ]); 54 | }); 55 | } 56 | 57 | /** 58 | * Mark the subscription as being within a trial period. 59 | */ 60 | public function trialing(?DateTimeInterface $trialEndsAt = null): self 61 | { 62 | return $this->state([ 63 | 'status' => Subscription::STATUS_ON_TRIAL, 64 | 'trial_ends_at' => $trialEndsAt, 65 | ]); 66 | } 67 | 68 | /** 69 | * Mark the subscription as active. 70 | */ 71 | public function active(): self 72 | { 73 | return $this->state([ 74 | 'status' => Subscription::STATUS_ACTIVE, 75 | ]); 76 | } 77 | 78 | /** 79 | * Mark the subscription as paused. 80 | */ 81 | public function paused(?DateTimeInterface $resumesAt = null): self 82 | { 83 | return $this->state([ 84 | 'status' => Subscription::STATUS_PAUSED, 85 | 'pause_mode' => $this->faker->randomElement(['void', 'free']), 86 | 'pause_resumes_at' => $resumesAt, 87 | ]); 88 | } 89 | 90 | /** 91 | * Mark the subscription as past due. 92 | */ 93 | public function pastDue(): self 94 | { 95 | return $this->state([ 96 | 'status' => Subscription::STATUS_PAST_DUE, 97 | ]); 98 | } 99 | 100 | /** 101 | * Mark the subscription as unpaid. 102 | */ 103 | public function unpaid(): self 104 | { 105 | return $this->state([ 106 | 'status' => Subscription::STATUS_UNPAID, 107 | ]); 108 | } 109 | 110 | /** 111 | * Mark the subscription as cancelled. 112 | */ 113 | public function cancelled(): self 114 | { 115 | return $this->state([ 116 | 'status' => Subscription::STATUS_CANCELLED, 117 | 'ends_at' => now(), 118 | ]); 119 | } 120 | 121 | /** 122 | * Mark the subscription as expired 123 | */ 124 | public function expired(): self 125 | { 126 | return $this->state([ 127 | 'status' => Subscription::STATUS_EXPIRED, 128 | ]); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /database/migrations/2023_01_16_000001_create_customers_table.php: -------------------------------------------------------------------------------- 1 | id(); 12 | $table->unsignedBigInteger('billable_id'); 13 | $table->string('billable_type'); 14 | $table->string('lemon_squeezy_id')->nullable()->unique(); 15 | $table->timestamp('trial_ends_at')->nullable(); 16 | $table->timestamps(); 17 | 18 | $table->unique(['billable_id', 'billable_type']); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::dropIfExists('lemon_squeezy_customers'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /database/migrations/2023_01_16_000002_create_subscriptions_table.php: -------------------------------------------------------------------------------- 1 | id(); 12 | $table->morphs('billable'); 13 | $table->string('type'); 14 | $table->string('lemon_squeezy_id')->unique(); 15 | $table->string('status'); 16 | $table->string('product_id'); 17 | $table->string('variant_id'); 18 | $table->string('card_brand')->nullable(); 19 | $table->string('card_last_four')->nullable(); 20 | $table->string('pause_mode')->nullable(); 21 | $table->timestamp('pause_resumes_at')->nullable(); 22 | $table->timestamp('trial_ends_at')->nullable(); 23 | $table->timestamp('renews_at')->nullable(); 24 | $table->timestamp('ends_at')->nullable(); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | public function down(): void 30 | { 31 | Schema::dropIfExists('lemon_squeezy_subscriptions'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2023_01_16_000003_create_orders_table.php: -------------------------------------------------------------------------------- 1 | id(); 12 | $table->morphs('billable'); 13 | $table->string('lemon_squeezy_id')->unique(); 14 | $table->string('customer_id'); 15 | $table->uuid('identifier')->unique(); 16 | $table->string('product_id')->index(); 17 | $table->string('variant_id')->index(); 18 | $table->integer('order_number')->unique(); 19 | $table->string('currency'); 20 | $table->integer('subtotal'); 21 | $table->integer('discount_total'); 22 | $table->integer('tax'); 23 | $table->integer('total'); 24 | $table->string('tax_name')->nullable(); 25 | $table->string('status'); 26 | $table->string('receipt_url')->nullable(); 27 | $table->boolean('refunded'); 28 | $table->timestamp('refunded_at')->nullable(); 29 | $table->timestamp('ordered_at'); 30 | $table->timestamps(); 31 | }); 32 | } 33 | 34 | public function down(): void 35 | { 36 | Schema::dropIfExists('lemon_squeezy_orders'); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /database/migrations/2023_01_16_000004_create_license_keys_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('lemon_squeezy_id')->unique(); 14 | $table->string('license_key')->unique(); 15 | $table->string('status'); 16 | $table->string('order_id')->index(); 17 | $table->string('product_id')->index(); 18 | $table->boolean('disabled'); 19 | $table->integer('activation_limit')->nullable(); 20 | $table->integer('instances_count'); 21 | $table->timestamp('expires_at')->nullable(); 22 | $table->timestamps(); 23 | 24 | $table->foreign('order_id')->references('lemon_squeezy_id')->on('lemon_squeezy_orders'); 25 | }); 26 | } 27 | 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('lemon_squeezy_license_keys'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2023_01_16_000005_create_license_key_instances_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->uuid('identifier')->unique(); 14 | $table->string('license_key_id'); 15 | $table->string('name'); 16 | $table->timestamps(); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | Schema::dropIfExists('lemon_squeezy_license_key_instances'); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - src/ 4 | 5 | level: 5 6 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "per" 3 | } 4 | -------------------------------------------------------------------------------- /resources/views/components/button.blade.php: -------------------------------------------------------------------------------- 1 | @props(['href', 'dark' => false]) 2 | 3 | @if ($href instanceof LemonSqueezy\Laravel\Checkout) 4 | @if ($dark) 5 | @php($href = $href->dark()) 6 | @endif 7 | 8 | @php($href = $href->embed()->url()) 9 | @endif 10 | 11 | merge(['class' => 'lemonsqueezy-button']) }} 14 | > 15 | {{ $slot }} 16 | 17 | -------------------------------------------------------------------------------- /resources/views/js.blade.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/Billable.php: -------------------------------------------------------------------------------- 1 | embed = true; 55 | 56 | return $this; 57 | } 58 | 59 | public function withoutLogo(): self 60 | { 61 | $this->logo = false; 62 | 63 | return $this; 64 | } 65 | 66 | public function withoutMedia(): self 67 | { 68 | $this->media = false; 69 | 70 | return $this; 71 | } 72 | 73 | public function withoutDescription(): self 74 | { 75 | $this->desc = false; 76 | 77 | return $this; 78 | } 79 | 80 | public function withoutDiscountField(): self 81 | { 82 | $this->discount = false; 83 | 84 | return $this; 85 | } 86 | 87 | public function dark(): self 88 | { 89 | $this->dark = true; 90 | 91 | return $this; 92 | } 93 | 94 | public function withoutSubscriptionPreview(): self 95 | { 96 | $this->subscriptionPreview = false; 97 | 98 | return $this; 99 | } 100 | 101 | public function withButtonColor(string $color): self 102 | { 103 | $this->buttonColor = $color; 104 | 105 | return $this; 106 | } 107 | 108 | public function withName(string $name): self 109 | { 110 | $this->checkoutData['name'] = $name; 111 | 112 | return $this; 113 | } 114 | 115 | public function withEmail(string $email): self 116 | { 117 | $this->checkoutData['email'] = $email; 118 | 119 | return $this; 120 | } 121 | 122 | public function withBillingAddress(string $country, ?string $zip = null): self 123 | { 124 | $this->checkoutData['billing_address'] = array_filter([ 125 | 'country' => $country, 126 | 'zip' => $zip, 127 | ]); 128 | 129 | return $this; 130 | } 131 | 132 | public function withTaxNumber(string $taxNumber): self 133 | { 134 | $this->checkoutData['tax_number'] = $taxNumber; 135 | 136 | return $this; 137 | } 138 | 139 | public function withDiscountCode(string $discountCode): self 140 | { 141 | $this->checkoutData['discount_code'] = $discountCode; 142 | 143 | return $this; 144 | } 145 | 146 | public function withCustomData(array $custom): self 147 | { 148 | if ( 149 | (array_key_exists('billable_id', $custom) && isset($this->custom['billable_id'])) || 150 | (array_key_exists('billable_type', $custom) && isset($this->custom['billable_type'])) || 151 | (array_key_exists('subscription_type', $custom) && isset($this->custom['subscription_type'])) 152 | ) { 153 | throw ReservedCustomKeys::overwriteAttempt(); 154 | } 155 | 156 | $this->custom = collect(array_replace_recursive($this->custom, $custom)) 157 | ->map(fn($value) => is_string($value) ? trim($value) : $value) 158 | ->filter(fn($value) => ! is_null($value)) 159 | ->toArray(); 160 | 161 | return $this; 162 | } 163 | 164 | public function withProductName(string $productName): self 165 | { 166 | $this->productName = $productName; 167 | 168 | return $this; 169 | } 170 | 171 | public function withDescription(string $description): self 172 | { 173 | $this->description = $description; 174 | 175 | return $this; 176 | } 177 | 178 | public function withThankYouNote(string $thankYouNote): self 179 | { 180 | $this->thankYouNote = $thankYouNote; 181 | 182 | return $this; 183 | } 184 | 185 | public function redirectTo(string $url): self 186 | { 187 | $this->redirectUrl = $url; 188 | 189 | return $this; 190 | } 191 | 192 | public function expiresAt(DateTimeInterface $expiresAt): self 193 | { 194 | $this->expiresAt = $expiresAt; 195 | 196 | return $this; 197 | } 198 | 199 | public function withCustomPrice(?int $customPrice): self 200 | { 201 | $this->customPrice = $customPrice; 202 | 203 | return $this; 204 | } 205 | 206 | public function url(): string 207 | { 208 | $response = LemonSqueezy::api('POST', 'checkouts', [ 209 | 'data' => [ 210 | 'type' => 'checkouts', 211 | 'attributes' => [ 212 | 'custom_price' => $this->customPrice, 213 | 'checkout_data' => array_merge( 214 | array_filter($this->checkoutData, fn($value) => $value !== ''), 215 | ['custom' => $this->custom], 216 | ), 217 | 'checkout_options' => array_filter([ 218 | 'embed' => $this->embed, 219 | 'logo' => $this->logo, 220 | 'media' => $this->media, 221 | 'desc' => $this->desc, 222 | 'discount' => $this->discount, 223 | 'dark' => $this->dark, 224 | 'subscription_preview' => $this->subscriptionPreview, 225 | 'button_color' => $this->buttonColor ?? null, 226 | ], function ($value) { 227 | return ! is_null($value); 228 | }), 229 | 'product_options' => array_filter([ 230 | 'name' => $this->productName, 231 | 'description' => $this->description, 232 | 'receipt_thank_you_note' => $this->thankYouNote, 233 | 'redirect_url' => $this->redirectUrl ?? config('lemon-squeezy.redirect_url'), 234 | ]), 235 | 'expires_at' => isset($this->expiresAt) ? $this->expiresAt->format(DateTimeInterface::ATOM) : null, 236 | ], 237 | 'relationships' => [ 238 | 'store' => [ 239 | 'data' => [ 240 | 'type' => 'stores', 241 | 'id' => $this->store, 242 | ], 243 | ], 244 | 'variant' => [ 245 | 'data' => [ 246 | 'type' => 'variants', 247 | 'id' => $this->variant, 248 | ], 249 | ], 250 | ], 251 | ], 252 | ]); 253 | 254 | return $response['data']['attributes']['url']; 255 | } 256 | 257 | public function redirect(): RedirectResponse 258 | { 259 | return Redirect::to($this->url(), 303); 260 | } 261 | 262 | public function toResponse($request): RedirectResponse 263 | { 264 | return $this->redirect(); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/Concerns/ManagesCheckouts.php: -------------------------------------------------------------------------------- 1 | (string) $this->getKey(), 21 | 'billable_type' => $this->getMorphClass(), 22 | ]); 23 | 24 | return Checkout::make($this->lemonSqueezyStore(), $variant) 25 | ->withName($options['name'] ?? (string) $this->lemonSqueezyName()) 26 | ->withEmail($options['email'] ?? (string) $this->lemonSqueezyEmail()) 27 | ->withBillingAddress( 28 | $options['country'] ?? (string) $this->lemonSqueezyCountry(), 29 | $options['zip'] ?? (string) $this->lemonSqueezyZip(), 30 | ) 31 | ->withTaxNumber($options['tax_number'] ?? (string) $this->lemonSqueezyTaxNumber()) 32 | ->withDiscountCode($options['discount_code'] ?? '') 33 | ->withCustomPrice($options['custom_price'] ?? null) 34 | ->withCustomData($custom); 35 | } 36 | 37 | /** 38 | * Create a new checkout instance to sell a product with a custom price. 39 | */ 40 | public function charge(int $amount, string $variant, array $options = [], array $custom = []) 41 | { 42 | return $this->checkout($variant, array_merge($options, [ 43 | 'custom_price' => $amount, 44 | ]), $custom); 45 | } 46 | 47 | /** 48 | * Subscribe the customer to a new plan variant. 49 | */ 50 | public function subscribe(string $variant, string $type = Subscription::DEFAULT_TYPE, array $options = [], array $custom = []): Checkout 51 | { 52 | return $this->checkout($variant, $options, array_merge($custom, [ 53 | 'subscription_type' => $type, 54 | ])); 55 | } 56 | 57 | /** 58 | * Get the configured Lemon Squeezy store subdomain from the config. 59 | * 60 | * @throws MissingStore 61 | */ 62 | protected function lemonSqueezyStore(): string 63 | { 64 | if (! $store = config('lemon-squeezy.store')) { 65 | throw MissingStore::notConfigured(); 66 | } 67 | 68 | return $store; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Concerns/ManagesCustomer.php: -------------------------------------------------------------------------------- 1 | customer()->create($attributes); 19 | } 20 | 21 | /** 22 | * Get the customer related to the billable model. 23 | */ 24 | public function customer(): MorphOne 25 | { 26 | return $this->morphOne(Customer::class, 'billable'); 27 | } 28 | 29 | /** 30 | * Get the billable's name to associate with Lemon Squeezy. 31 | */ 32 | public function lemonSqueezyName(): ?string 33 | { 34 | return $this->name ?? null; 35 | } 36 | 37 | /** 38 | * Get the billable's email address to associate with Lemon Squeezy. 39 | */ 40 | public function lemonSqueezyEmail(): ?string 41 | { 42 | return $this->email ?? null; 43 | } 44 | 45 | /** 46 | * Get the billable's country to associate with Lemon Squeezy. 47 | * 48 | * This needs to be a 2 letter code. 49 | */ 50 | public function lemonSqueezyCountry(): ?string 51 | { 52 | return $this->country ?? null; // 'US' 53 | } 54 | 55 | /** 56 | * Get the billable's zip code to associate with Lemon Squeezy. 57 | */ 58 | public function lemonSqueezyZip(): ?string 59 | { 60 | return $this->zip ?? null; // '10038' 61 | } 62 | 63 | /** 64 | * Get the billable's tax number to associate with Lemon Squeezy. 65 | */ 66 | public function lemonSqueezyTaxNumber(): ?string 67 | { 68 | return $this->tax_number ?? null; // 'GB123456789' 69 | } 70 | 71 | /** 72 | * Get the customer portal url for this billable. 73 | */ 74 | public function customerPortalUrl(): string 75 | { 76 | $this->assertCustomerExists(); 77 | 78 | $response = LemonSqueezy::api('GET', "customers/{$this->customer->lemon_squeezy_id}"); 79 | 80 | return $response['data']['attributes']['urls']['customer_portal']; 81 | } 82 | 83 | /** 84 | * Generate a redirect response to the billable's customer portal. 85 | */ 86 | public function redirectToCustomerPortal(): RedirectResponse 87 | { 88 | return new RedirectResponse($this->customerPortalUrl()); 89 | } 90 | 91 | /** 92 | * Determine if the billable is already a Lemon Squeezy customer and throw an exception if not. 93 | * 94 | * @throws InvalidCustomer 95 | */ 96 | protected function assertCustomerExists(): void 97 | { 98 | if (is_null($this->customer) || is_null($this->customer->lemon_squeezy_id)) { 99 | throw InvalidCustomer::notYetCreated($this); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Concerns/ManagesLicenses.php: -------------------------------------------------------------------------------- 1 | withKey($key)->exists()) { 28 | throw LicenseKeyNotFound::withKey($key); 29 | } 30 | 31 | $res = LemonSqueezy::api('POST', 'licenses/activate', [ 32 | 'license_key' => $key, 33 | 'instance_name' => $reference, 34 | ]); 35 | 36 | return LicenseKeyInstance::fromPayload($res->json()); 37 | } 38 | 39 | /** 40 | * @throws MalformedDataError 41 | * @throws LicenseKeyNotValidated 42 | */ 43 | public function assertValid(string $licenseKey, ?string $instanceId = null): LicenseKey { 44 | try { 45 | $res = LemonSqueezy::api('POST', 'licenses/validate', [ 46 | 'license_key' => $licenseKey, 47 | 'instance_id' => $instanceId, 48 | ]); 49 | } catch (LemonSqueezyApiError $e) { 50 | throw LicenseKeyNotValidated::withErrorMessage($e->getMessage()); 51 | } 52 | 53 | return LicenseKey::fromPayload($res['data']); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Concerns/ManagesOrders.php: -------------------------------------------------------------------------------- 1 | morphMany(LemonSqueezy::$orderModel, 'billable')->orderByDesc('created_at'); 17 | } 18 | 19 | /** 20 | * Determine if the billable has purchased a specific product. 21 | */ 22 | public function hasPurchasedProduct(string $productId): bool 23 | { 24 | return $this->orders()->where('product_id', $productId)->where('status', Order::STATUS_PAID)->exists(); 25 | } 26 | 27 | /** 28 | * Determine if the billable has purchased a specific variant of a product. 29 | */ 30 | public function hasPurchasedVariant(string $variantId): bool 31 | { 32 | return $this->orders()->where('variant_id', $variantId)->where('status', Order::STATUS_PAID)->exists(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Concerns/ManagesSubscriptions.php: -------------------------------------------------------------------------------- 1 | morphMany(LemonSqueezy::$subscriptionModel, 'billable')->orderByDesc('created_at'); 18 | } 19 | 20 | /** 21 | * Get a subscription instance by type. 22 | */ 23 | public function subscription(string $type = Subscription::DEFAULT_TYPE): ?Subscription 24 | { 25 | return $this->subscriptions->where('type', $type)->first(); 26 | } 27 | 28 | /** 29 | * Determine if the billable is on trial. 30 | */ 31 | public function onTrial(string $type = Subscription::DEFAULT_TYPE, ?string $variant = null): bool 32 | { 33 | if (func_num_args() === 0 && $this->onGenericTrial()) { 34 | return true; 35 | } 36 | 37 | $subscription = $this->subscription($type); 38 | 39 | if (! $subscription || ! $subscription->onTrial()) { 40 | return false; 41 | } 42 | 43 | return $variant ? $subscription->hasVariant($variant) : true; 44 | } 45 | 46 | /** 47 | * Determine if the billable's trial has ended. 48 | */ 49 | public function hasExpiredTrial(string $type = Subscription::DEFAULT_TYPE, ?string $variant = null): bool 50 | { 51 | if (func_num_args() === 0 && $this->hasExpiredGenericTrial()) { 52 | return true; 53 | } 54 | 55 | $subscription = $this->subscription($type); 56 | 57 | if (! $subscription || ! $subscription->hasExpiredTrial()) { 58 | return false; 59 | } 60 | 61 | return $variant ? $subscription->hasPlan($variant) : true; 62 | } 63 | 64 | /** 65 | * Determine if the billable is on a "generic" trial at the model level. 66 | */ 67 | public function onGenericTrial(): bool 68 | { 69 | if (is_null($this->customer)) { 70 | return false; 71 | } 72 | 73 | return $this->customer->onGenericTrial(); 74 | } 75 | 76 | /** 77 | * Determine if the billable's "generic" trial at the model level has expired. 78 | */ 79 | public function hasExpiredGenericTrial(): bool 80 | { 81 | if (is_null($this->customer)) { 82 | return false; 83 | } 84 | 85 | return $this->customer->hasExpiredGenericTrial(); 86 | } 87 | 88 | /** 89 | * Get the ending date of the trial. 90 | */ 91 | public function trialEndsAt(string $type = Subscription::DEFAULT_TYPE): ?CarbonInterface 92 | { 93 | if ($subscription = $this->subscription($type)) { 94 | return $subscription->trial_ends_at; 95 | } 96 | 97 | return $this->customer->trial_ends_at; 98 | } 99 | 100 | /** 101 | * Determine if the billable has a valid subscription. 102 | */ 103 | public function subscribed(string $type = Subscription::DEFAULT_TYPE, ?string $variant = null): bool 104 | { 105 | $subscription = $this->subscription($type); 106 | 107 | if (! $subscription || ! $subscription->valid()) { 108 | return false; 109 | } 110 | 111 | return $variant ? $subscription->hasVariant($variant) : true; 112 | } 113 | 114 | /** 115 | * Determine if the billable has a valid subscription for the given variant. 116 | */ 117 | public function subscribedToVariant(string $variant, string $type = Subscription::DEFAULT_TYPE): bool 118 | { 119 | $subscription = $this->subscription($type); 120 | 121 | if (! $subscription || ! $subscription->valid()) { 122 | return false; 123 | } 124 | 125 | return $subscription->hasVariant($variant); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Concerns/Prorates.php: -------------------------------------------------------------------------------- 1 | prorate = false; 18 | 19 | return $this; 20 | } 21 | 22 | /** 23 | * Indicate that the plan change should be prorated. 24 | */ 25 | public function prorate(): self 26 | { 27 | $this->prorate = true; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * Set the prorating behavior for the plan change. 34 | */ 35 | public function setProration(bool $prorate = true): self 36 | { 37 | $this->prorate = $prorate; 38 | 39 | return $this; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Console/ListLicensesCommand.php: -------------------------------------------------------------------------------- 1 | validate()) { 41 | return static::FAILURE; 42 | } 43 | 44 | $storeResponse = spin(fn() => $this->fetchStore(), '🍋 Fetching store information...'); 45 | $store = $storeResponse->json('data.attributes'); 46 | 47 | return $this->handleLicenses($store); 48 | } 49 | 50 | protected function validate(): bool 51 | { 52 | $arr = array_merge( 53 | config('lemon-squeezy'), 54 | ['page' => $this->option('page')], 55 | ['size' => $this->option('size')], 56 | ); 57 | $validator = Validator::make($arr, [ 58 | 'api_key' => [ 59 | 'required', 60 | ], 61 | 'store' => [ 62 | 'required', 63 | ], 64 | 'page' => [ 65 | 'nullable', 'numeric', 'min:1', 66 | ], 67 | 'size' => [ 68 | 'nullable', 'numeric', 'min:1', 'max:100', 69 | ], 70 | ], [ 71 | 'api_key.required' => 'Lemon Squeezy API key not set. You can add it to your .env file as LEMON_SQUEEZY_API_KEY.', 72 | 'store.required' => 'Lemon Squeezy store ID not set. You can add it to your .env file as LEMON_SQUEEZY_STORE.', 73 | ]); 74 | 75 | if ($validator->passes()) { 76 | return true; 77 | } 78 | 79 | $this->newLine(); 80 | 81 | foreach ($validator->errors()->all() as $error) { 82 | error($error); 83 | } 84 | 85 | return false; 86 | } 87 | 88 | protected function fetchStore(): Response 89 | { 90 | return LemonSqueezy::api('GET', sprintf('stores/%s', config('lemon-squeezy.store'))); 91 | } 92 | 93 | protected function handleLicenses(array $store): int 94 | { 95 | $licensesResponse = spin( 96 | fn() => LemonSqueezy::api( 97 | 'GET', 98 | sprintf('license-keys'), 99 | [ 100 | 'filter[store_id]' => config('lemon-squeezy.store'), 101 | 'page[size]' => (int) $this->option('size'), 102 | 'page[number]' => (int) $this->option('page'), 103 | 'filter[product_id]' => $this->argument('product'), 104 | 'filter[order_id]' => $this->option('order'), 105 | 'filter[status]' => $this->option('status'), 106 | ], 107 | ), 108 | '🍋 Fetching licenses...', 109 | ); 110 | 111 | $currPage = $licensesResponse->json('meta.page.currentPage'); 112 | $lastPage = $licensesResponse->json('meta.page.lastPage'); 113 | 114 | if ($lastPage > 1 && $currPage <= $lastPage) { 115 | info(sprintf('Showing page %d of %d', $currPage, $lastPage)); 116 | } 117 | 118 | $licenses = collect($licensesResponse->json('data')); 119 | $licenses->each(function ($license) { 120 | $this->displayLicense($license, $this->option('long')); 121 | 122 | $this->newLine(); 123 | }); 124 | 125 | return static::SUCCESS; 126 | } 127 | 128 | private function displayStatus(array $license): string 129 | { 130 | $status = Arr::get($license, 'attributes.status_formatted'); 131 | $limit = Arr::get($license, 'attributes.activation_limit') ?? '0'; 132 | $usage = Arr::get($license, 'attributes.activation_usage') ?? '0'; 133 | 134 | return "{$status} ({$usage}/{$limit})"; 135 | } 136 | 137 | private function displayProductInfo(array $license): void 138 | { 139 | $productId = Arr::get($license, 'attributes.product_id'); 140 | $variantId = Arr::get($license, 'attributes.variant_id') ?? 'None'; 141 | 142 | $this->components->twoColumnDetail( 143 | 'Product:Variant', 144 | "{$productId}:{$variantId}" 145 | ); 146 | } 147 | 148 | private function displayCustomer(array $license): void 149 | { 150 | $customerName = Arr::get($license, 'attributes.user_name'); 151 | $customerEmail = Arr::get($license, 'attributes.user_email'); 152 | 153 | $this->components->twoColumnDetail( 154 | 'Customer', 155 | "{$customerName} [{$customerEmail}]" 156 | ); 157 | } 158 | 159 | protected function displayLicense(array $license, bool $long): void 160 | { 161 | $key = Arr::get($license, $long ? 'attributes.key' : 'attributes.key_short'); 162 | $orderId = Arr::get($license, 'attributes.order_id'); 163 | 164 | $this->components->twoColumnDetail( 165 | sprintf('%s', $key), 166 | $this->displayStatus($license), 167 | ); 168 | $this->displayProductInfo($license); 169 | $this->displayCustomer($license); 170 | $this->components->twoColumnDetail( 171 | 'Order ID', 172 | "{$orderId}" 173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Console/ListProductsCommand.php: -------------------------------------------------------------------------------- 1 | validate()) { 35 | return static::FAILURE; 36 | } 37 | 38 | $storeResponse = spin(fn() => $this->fetchStore(), '🍋 Fetching store information...'); 39 | $store = $storeResponse->json('data.attributes'); 40 | 41 | $productId = $this->argument('product'); 42 | 43 | if ($productId) { 44 | return $this->handleProduct($store, $productId); 45 | } 46 | 47 | return $this->handleProducts($store); 48 | } 49 | 50 | protected function validate(): bool 51 | { 52 | $validator = Validator::make(config('lemon-squeezy'), [ 53 | 'api_key' => [ 54 | 'required', 55 | ], 56 | 'store' => [ 57 | 'required', 58 | ], 59 | ], [ 60 | 'api_key.required' => 'Lemon Squeezy API key not set. You can add it to your .env file as LEMON_SQUEEZY_API_KEY.', 61 | 'store.required' => 'Lemon Squeezy store ID not set. You can add it to your .env file as LEMON_SQUEEZY_STORE.', 62 | ]); 63 | 64 | if ($validator->passes()) { 65 | return true; 66 | } 67 | 68 | $this->newLine(); 69 | 70 | foreach ($validator->errors()->all() as $error) { 71 | error($error); 72 | } 73 | 74 | return false; 75 | } 76 | 77 | protected function fetchStore(): Response 78 | { 79 | return LemonSqueezy::api('GET', sprintf('stores/%s', config('lemon-squeezy.store'))); 80 | } 81 | 82 | protected function handleProduct(array $store, string $productId): int 83 | { 84 | $response = spin( 85 | fn() => LemonSqueezy::api( 86 | 'GET', 87 | sprintf('products/%s', $productId), 88 | ['include' => 'variants'], 89 | ), 90 | '🍋 Fetching product information...', 91 | ); 92 | 93 | $product = $response->json('data'); 94 | 95 | $this->newLine(); 96 | $this->displayTitle(); 97 | $this->newLine(); 98 | 99 | $this->displayProduct($product); 100 | 101 | $variants = collect($response->json('included')) 102 | ->filter(fn($item) => $item['type'] === 'variants') 103 | ->sortBy('sort'); 104 | 105 | $variants->each(fn(array $variant) => $this->displayVariant( 106 | $variant, 107 | Arr::get($store, 'currency'), 108 | $variants->count() > 1, 109 | )); 110 | 111 | $this->newLine(); 112 | 113 | return static::SUCCESS; 114 | } 115 | 116 | protected function handleProducts(array $store): int 117 | { 118 | $productsResponse = spin( 119 | fn() => LemonSqueezy::api( 120 | 'GET', 121 | 'products', 122 | [ 123 | 'include' => 'variants', 124 | 'filter[store_id]' => config('lemon-squeezy.store'), 125 | 'page[size]' => 100, 126 | ], 127 | ), 128 | '🍋 Fetching products information...', 129 | ); 130 | 131 | $products = collect($productsResponse->json('data')); 132 | 133 | $this->newLine(); 134 | $this->displayTitle(); 135 | $this->newLine(); 136 | 137 | $products->each(function ($product) use ($productsResponse, $store) { 138 | $this->displayProduct($product); 139 | 140 | $variantIds = collect(Arr::get($product, 'relationships.variants.data'))->pluck('id'); 141 | $variants = collect($productsResponse->json('included')) 142 | ->filter(fn($item) => $item['type'] === 'variants') 143 | ->filter(fn($item) => $variantIds->contains($item['id'])) 144 | ->sortBy('sort'); 145 | 146 | $variants->each(fn($variant) => $this->displayVariant( 147 | $variant, 148 | Arr::get($store, 'currency'), 149 | $variants->count() > 1, 150 | )); 151 | 152 | $this->newLine(); 153 | }); 154 | 155 | return static::SUCCESS; 156 | } 157 | 158 | protected function displayTitle(): void 159 | { 160 | $this->components->twoColumnDetail('Product/Variant', 'ID'); 161 | } 162 | 163 | protected function displayProduct(array $product): void 164 | { 165 | $this->components->twoColumnDetail( 166 | sprintf('%s', Arr::get($product, 'attributes.name')), 167 | Arr::get($product, 'id'), 168 | ); 169 | } 170 | 171 | protected function displayVariant(array $variant, string $currency, bool $hidePending = false): void 172 | { 173 | if (Arr::get($variant, 'attributes.status') === 'pending' && $hidePending) { 174 | return; 175 | } 176 | 177 | $name = Arr::get($variant, 'attributes.name'); 178 | 179 | $price = LemonSqueezy::formatAmount( 180 | Arr::get($variant, 'attributes.price'), 181 | $currency, 182 | ); 183 | 184 | $id = Arr::get($variant, 'id'); 185 | 186 | $this->components->twoColumnDetail(sprintf('%s %s', $name, $price), $id); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/Console/ListenCommand.php: -------------------------------------------------------------------------------- 1 | [ 47 | 'domain' => 'sharedwithexpose.com', 48 | ], 49 | 'ngrok' => [ 50 | 'api' => 'http://localhost:4040/api', 51 | 'domain' => 'ngrok-free.app', 52 | ], 53 | ]; 54 | 55 | /** 56 | * The currently invoked process instance. 57 | */ 58 | protected InvokedProcess $process; 59 | 60 | /** 61 | * The currently in-use Lemon Squeezy webhook ID. 62 | */ 63 | protected int $webhookId; 64 | 65 | /** 66 | * Execute the console command. 67 | */ 68 | public function handle(): int 69 | { 70 | if (windows_os()) { 71 | error('lmsqueezy:listen is not supported on Windows because it lacks support for signal handling.'); 72 | 73 | return static::FAILURE; 74 | } 75 | 76 | $this->validateArguments(); 77 | 78 | $errorCode = $this->handleEnvironment(); 79 | 80 | if ($errorCode !== null) { 81 | return $errorCode; 82 | } 83 | 84 | $errorCode = $this->handleCleanup(); 85 | 86 | if ($errorCode !== null) { 87 | return $errorCode; 88 | } 89 | 90 | return $this->handleService(); 91 | } 92 | 93 | protected function validateArguments(): void 94 | { 95 | Validator::make($this->arguments() + config('lemon-squeezy'), [ 96 | 'api_key' => [ 97 | 'required', 98 | ], 99 | 'service' => [ 100 | 'required', 101 | 'string', 102 | 'in:expose,ngrok,test', 103 | ], 104 | 'store' => [ 105 | 'required', 106 | ], 107 | ], [ 108 | 'api_key.required' => 'The LEMON_SQUEEZY_API_KEY environment variable is required.', 109 | 'store.required' => 'The LEMON_SQUEEZY_STORE environment variable is required.', 110 | ])->validate(); 111 | } 112 | 113 | protected function handleEnvironment(): ?int 114 | { 115 | if ($this->argument('service') === 'test') { 116 | info('lmsqueezy:listen is using the test service.'); 117 | 118 | return static::SUCCESS; 119 | } 120 | 121 | if (! App::environment('local')) { 122 | error('lmsqueezy:listen can only be used in local environment.'); 123 | 124 | return static::FAILURE; 125 | } 126 | 127 | return null; 128 | } 129 | 130 | protected function handleCleanup(): ?int 131 | { 132 | if ($this->option('cleanup')) { 133 | note("Cleaning up webhooks for '{$this->argument('service')}' service..."); 134 | 135 | $cleaned = $this->cleanupWebhooks(); 136 | 137 | if ($cleaned === 0) { 138 | info('No webhooks found to clean.'); 139 | } 140 | 141 | return static::SUCCESS; 142 | } 143 | 144 | return null; 145 | } 146 | 147 | protected function handleService(): int 148 | { 149 | note('Setting up webhooks domain with ' . $this->argument('service') . '...'); 150 | 151 | $this->trap([SIGINT], fn(int $signal) => $this->teardownWebhook()); 152 | 153 | return $this->{$this->argument('service')}(); 154 | } 155 | 156 | protected function promptForMissingArgumentsUsing(): array 157 | { 158 | return [ 159 | 'service' => fn() => select( 160 | label: 'Please choose a service', 161 | options: [ 162 | 'expose', 163 | 'ngrok', 164 | ], 165 | default: 'expose', 166 | validate: fn($val) => in_array($val, ['expose', 'ngrok']) 167 | ? null 168 | : 'Please choose a valid service.', 169 | ), 170 | ]; 171 | } 172 | 173 | protected function cleanOutput($output): string 174 | { 175 | if (preg_match( 176 | '/Remaining time:\s+\d{2}:\d{2}:\d{2}\\n/', 177 | $output, 178 | $matches, 179 | )) { 180 | $output = preg_replace('/Remaining time:\s+\d{2}:\d{2}:\d{2}\\n/', '', $output); 181 | } 182 | 183 | if ($output) { 184 | $lines = explode("\n", $output); 185 | $cleaned_lines = []; 186 | 187 | foreach ($lines as $line) { 188 | // Trim leading and trailing whitespace 189 | $line = trim($line); 190 | // Replace multiple spaces with a single space 191 | $line = preg_replace('/\s+/', ' ', $line); 192 | 193 | if (! empty($line)) { 194 | $cleaned_lines[] = $line; 195 | } 196 | } 197 | // Join cleaned lines back into a single string 198 | $output = implode("\n", $cleaned_lines); 199 | } 200 | 201 | return $output; 202 | } 203 | 204 | protected function process(array $commands): InvokedProcess 205 | { 206 | return $this->process = Process::timeout(120) 207 | ->start($commands, function (string $type, string $output) { 208 | if (isset($this->webhookId) || $this->option('verbose')) { 209 | $output = $this->cleanOutput($output); 210 | if ($output) { 211 | note($output); 212 | } 213 | } 214 | }); 215 | } 216 | 217 | protected function expose(): int 218 | { 219 | $tunnel = null; 220 | 221 | $this->process([ 222 | 'expose', 223 | 'share', 224 | route('lemon-squeezy.webhook'), 225 | sprintf('--subdomain=%s', sha1(time())), 226 | '--no-interaction', 227 | ]); 228 | 229 | while ($this->process->running()) { 230 | if (is_null($tunnel) && preg_match( 231 | '/Public HTTPS:\s+(http[s]?:\/\/[^\s]+)/', 232 | $this->process->latestOutput(), 233 | $matches, 234 | )) { 235 | $tunnel = $matches[1]; 236 | 237 | $errorCode = $this->setupWebhook($tunnel); 238 | 239 | if ($errorCode !== null) { 240 | return $errorCode; 241 | } 242 | } 243 | 244 | sleep(1); 245 | } 246 | 247 | return static::SUCCESS; 248 | } 249 | 250 | protected function ngrok(): int 251 | { 252 | $logs = []; 253 | $tunnel = null; 254 | 255 | $this->process([ 256 | 'ngrok', 257 | 'http', 258 | $this->option('port'), 259 | '--host-header=rewrite', 260 | ]); 261 | 262 | while ($this->process->running()) { 263 | if (is_null($tunnel)) { 264 | $result = Http::retry(5, 1000) 265 | ->get("{$this->services['ngrok']['api']}/tunnels") 266 | ->json(); 267 | 268 | $tunnel = $result['tunnels'][0]['public_url'] ?? null; 269 | 270 | if (Str::startsWith($tunnel ?? '', ['https://', 'http://'])) { 271 | $errorCode = $this->setupWebhook($tunnel); 272 | 273 | if ($errorCode !== null) { 274 | return $errorCode; 275 | } 276 | } 277 | } 278 | 279 | if ($tunnel) { 280 | $result = Http::get("{$this->services['ngrok']['api']}/requests/http?limit=50") 281 | ->json('requests'); 282 | 283 | foreach ($result as $request) { 284 | if (! in_array($request['id'], $logs, true)) { 285 | $logs[] = $request['id']; 286 | 287 | note(sprintf( 288 | '%s %s %s %s', 289 | $request['response']['status_code'], 290 | $request['request']['method'], 291 | Str::padRight(Str::limit($request['request']['uri'], 48, ''), 48, '.'), 292 | isset($request['response']['headers']['Date'][0]) 293 | ? Carbon::parse($request['response']['headers']['Date'][0])->format('H:i:s') 294 | : '[Date-Not-Set]', 295 | )); 296 | } 297 | } 298 | } 299 | 300 | sleep(1); 301 | } 302 | 303 | return static::SUCCESS; 304 | } 305 | 306 | protected function setupWebhook(string $tunnel): ?int 307 | { 308 | note("Found webhook endpoint: {$tunnel}"); 309 | note('Sending webhook to Lemon Squeezy...'); 310 | 311 | $data = [ 312 | 'data' => [ 313 | 'type' => 'webhooks', 314 | 'attributes' => [ 315 | 'url' => $tunnel . '/' . config('lemon-squeezy.path') . '/webhook', 316 | 'events' => [ 317 | 'order_created', 318 | 'order_refunded', 319 | 'subscription_created', 320 | 'subscription_updated', 321 | 'subscription_cancelled', 322 | 'subscription_resumed', 323 | 'subscription_expired', 324 | 'subscription_paused', 325 | 'subscription_unpaused', 326 | 'subscription_payment_success', 327 | 'subscription_payment_failed', 328 | 'subscription_payment_recovered', 329 | 'subscription_payment_refunded', 330 | 'subscription_plan_changed', 331 | 'license_key_created', 332 | 'license_key_updated', 333 | ], 334 | 'secret' => config('lemon-squeezy.signing_secret') ?: Str::random(32), 335 | ], 336 | 'relationships' => [ 337 | 'store' => [ 338 | 'data' => [ 339 | 'type' => 'stores', 340 | 'id' => config('lemon-squeezy.store'), 341 | ], 342 | ], 343 | ], 344 | ], 345 | ]; 346 | 347 | $result = Http::withToken(config('lemon-squeezy.api_key')) 348 | ->retry(3, 250) 349 | ->post(LemonSqueezy::API . '/webhooks', $data); 350 | 351 | if ($result->status() !== 201) { 352 | error('Failed to setup webhook.'); 353 | 354 | return static::FAILURE; 355 | } 356 | 357 | $this->webhookId = $result['data']['id']; 358 | 359 | info('✅ Webhook setup successfully.'); 360 | note('Listening for webhooks...'); 361 | 362 | return null; 363 | } 364 | 365 | protected function teardownWebhook(): void 366 | { 367 | if (! isset($this->webhookId)) { 368 | return; 369 | } 370 | 371 | note("\nCleaning up webhook on Lemon Squeezy..."); 372 | 373 | if ($this->deleteWebhook($this->webhookId)->status() !== 204) { 374 | error("Failed to remove webhook, use --cleanup to remove all {$this->argument('service')}. domains"); 375 | 376 | return; 377 | } 378 | 379 | unset($this->webhookId); 380 | 381 | info('✅ Webhook removed successfully.'); 382 | } 383 | 384 | protected function cleanupWebhooks(): int 385 | { 386 | return collect($this->fetchWebhooks()) 387 | ->filter(function ($url, $id) { 388 | collect($this->services[$this->argument('service')]['domain']) 389 | ->reduce(fn($carry, $domain) => $carry || Str::endsWith($url, $domain), false); 390 | }) 391 | ->each(function ($url, $id) { 392 | $this->deleteWebhook($id)->status() === 204 393 | ? info("✅ Webhook {$id} removed successfully.") 394 | : error("Failed to remove webhook {$id}."); 395 | }) 396 | ->count(); 397 | } 398 | 399 | protected function fetchWebhooks(): array 400 | { 401 | $fetch = true; 402 | $fetchPage = 0; 403 | $webhooks = []; 404 | 405 | while ($fetch) { 406 | $result = Http::withToken(config('lemon-squeezy.api_key')) 407 | ->retry(3, 250) 408 | ->get(sprintf( 409 | '%s/webhooks/?filter[store_id]=%s%s', 410 | LemonSqueezy::API, 411 | config('lemon-squeezy.store'), 412 | $fetchPage > 0 ? "&page[number]={$fetchPage}" : '', 413 | )) 414 | ->json(); 415 | 416 | $fetchPage++; 417 | 418 | $page = $result['meta']['page']; 419 | 420 | if ($page['currentPage'] === $page['lastPage']) { 421 | $fetch = false; 422 | } 423 | 424 | foreach (collect($result['data'])->pluck('attributes.url', 'id') as $id => $url) { 425 | $webhooks[$id] = $url; 426 | } 427 | } 428 | 429 | return $webhooks; 430 | } 431 | 432 | protected function deleteWebhook(int $webhookId): Response 433 | { 434 | return Http::withToken(config('lemon-squeezy.api_key')) 435 | ->retry(3, 250) 436 | ->delete(LemonSqueezy::API . "/webhooks/{$webhookId}"); 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/Customer.php: -------------------------------------------------------------------------------- 1 | 'datetime', 46 | ]; 47 | 48 | /** 49 | * Get the billable model related to the customer. 50 | */ 51 | public function billable(): MorphTo 52 | { 53 | return $this->morphTo(); 54 | } 55 | 56 | /** 57 | * Determine if the customer is on a "generic" trial at the model level. 58 | */ 59 | public function onGenericTrial(): bool 60 | { 61 | return $this->trial_ends_at && $this->trial_ends_at->isFuture(); 62 | } 63 | 64 | /** 65 | * Determine if the customer has an expired "generic" trial at the model level. 66 | */ 67 | public function hasExpiredGenericTrial(): bool 68 | { 69 | return $this->trial_ends_at && $this->trial_ends_at->isPast(); 70 | } 71 | 72 | /** 73 | * Create a new factory instance for the model. 74 | */ 75 | protected static function newFactory(): CustomerFactory 76 | { 77 | return CustomerFactory::new(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Events/LicenseKeyCreated.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 35 | $this->order = $order; 36 | $this->payload = $payload; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Events/OrderRefunded.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 35 | $this->order = $order; 36 | $this->payload = $payload; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Events/SubscriptionCancelled.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionCreated.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionExpired.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionPaused.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionPaymentFailed.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionPaymentRecovered.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionPaymentSuccess.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionResumed.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionUnpaused.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/SubscriptionUpdated.php: -------------------------------------------------------------------------------- 1 | billable = $billable; 33 | $this->subscription = $subscription; 34 | $this->payload = $payload; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/WebhookHandled.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/WebhookReceived.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidCustomPayload.php: -------------------------------------------------------------------------------- 1 | errors()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exceptions/MissingStore.php: -------------------------------------------------------------------------------- 1 | middleware(VerifyWebhookSignature::class); 47 | } 48 | } 49 | 50 | /** 51 | * Handle a Lemon Squeezy webhook call. 52 | */ 53 | public function __invoke(Request $request): Response 54 | { 55 | $payload = $request->all(); 56 | 57 | if (! isset($payload['meta']['event_name'])) { 58 | return new Response('Webhook received but no event name was found.'); 59 | } 60 | 61 | $method = 'handle' . Str::studly($payload['meta']['event_name']); 62 | 63 | WebhookReceived::dispatch($payload); 64 | 65 | if (method_exists($this, $method)) { 66 | try { 67 | $this->{$method}($payload); 68 | } catch (BadRequest $e) { 69 | return new Response($e->getMessage(), 400); 70 | } catch (NotFound $e) { 71 | return new Response($e->getMessage(), 404); 72 | } catch (\Exception $e) { 73 | return new Response(sprintf('Internal server error: %s', $e->getMessage()), 500); 74 | } 75 | 76 | WebhookHandled::dispatch($payload); 77 | 78 | return new Response('Webhook was handled.'); 79 | } 80 | 81 | return new Response('Webhook received but no handler found.'); 82 | } 83 | 84 | public function handleOrderCreated(array $payload): void 85 | { 86 | $billable = $this->resolveBillable($payload); 87 | 88 | // Todo v2: Remove this check 89 | if (Schema::hasTable((new LemonSqueezy::$orderModel())->getTable())) { 90 | $attributes = $payload['data']['attributes']; 91 | 92 | $order = $billable->orders()->create([ 93 | 'lemon_squeezy_id' => $payload['data']['id'], 94 | 'customer_id' => $attributes['customer_id'], 95 | 'product_id' => (string) $attributes['first_order_item']['product_id'], 96 | 'variant_id' => (string) $attributes['first_order_item']['variant_id'], 97 | 'identifier' => $attributes['identifier'], 98 | 'order_number' => $attributes['order_number'], 99 | 'currency' => $attributes['currency'], 100 | 'subtotal' => $attributes['subtotal'], 101 | 'discount_total' => $attributes['discount_total'], 102 | 'tax' => $attributes['tax'], 103 | 'total' => $attributes['total'], 104 | 'tax_name' => $attributes['tax_name'], 105 | 'status' => $attributes['status'], 106 | 'receipt_url' => $attributes['urls']['receipt'] ?? null, 107 | 'refunded' => $attributes['refunded'], 108 | 'refunded_at' => $attributes['refunded_at'] ? Carbon::make($attributes['refunded_at']) : null, 109 | 'ordered_at' => Carbon::make($attributes['created_at']), 110 | ]); 111 | } else { 112 | $order = null; 113 | } 114 | 115 | OrderCreated::dispatch($billable, $order, $payload); 116 | } 117 | 118 | public function handleOrderRefunded(array $payload): void 119 | { 120 | $billable = $this->resolveBillable($payload); 121 | 122 | // Todo v2: Remove this check 123 | if (Schema::hasTable((new LemonSqueezy::$orderModel())->getTable())) { 124 | if (! $order = $this->findOrder($payload['data']['id'])) { 125 | return; 126 | } 127 | 128 | $order = $order->sync($payload['data']['attributes']); 129 | } else { 130 | $order = null; 131 | } 132 | 133 | OrderRefunded::dispatch($billable, $order, $payload); 134 | } 135 | 136 | public function handleSubscriptionCreated(array $payload): void 137 | { 138 | $custom = $payload['meta']['custom_data'] ?? null; 139 | $attributes = $payload['data']['attributes']; 140 | 141 | $billable = $this->resolveBillable($payload); 142 | 143 | $subscription = $billable->subscriptions()->create([ 144 | 'type' => $custom['subscription_type'] ?? Subscription::DEFAULT_TYPE, 145 | 'lemon_squeezy_id' => $payload['data']['id'], 146 | 'status' => $attributes['status'], 147 | 'product_id' => (string) $attributes['product_id'], 148 | 'variant_id' => (string) $attributes['variant_id'], 149 | 'card_brand' => $attributes['card_brand'] ?? null, 150 | 'card_last_four' => $attributes['card_last_four'] ?? null, 151 | 'trial_ends_at' => $attributes['trial_ends_at'] ? Carbon::make($attributes['trial_ends_at']) : null, 152 | 'renews_at' => $attributes['renews_at'] ? Carbon::make($attributes['renews_at']) : null, 153 | 'ends_at' => $attributes['ends_at'] ? Carbon::make($attributes['ends_at']) : null, 154 | ]); 155 | 156 | // Terminate the billable's generic trial at the model level if it exists... 157 | if (! is_null($billable->customer->trial_ends_at)) { 158 | $billable->customer->update(['trial_ends_at' => null]); 159 | } 160 | 161 | // Set the billable's lemon squeezy id if it was on generic trial at the model level 162 | if (is_null($billable->customer->lemon_squeezy_id)) { 163 | $billable->customer->update(['lemon_squeezy_id' => $attributes['customer_id']]); 164 | } 165 | 166 | SubscriptionCreated::dispatch($billable, $subscription, $payload); 167 | } 168 | 169 | private function handleSubscriptionUpdated(array $payload): void 170 | { 171 | if (! $subscription = $this->findSubscription($payload['data']['id'])) { 172 | return; 173 | } 174 | 175 | $subscription = $subscription->sync($payload['data']['attributes']); 176 | 177 | if ($subscription->billable) { 178 | SubscriptionUpdated::dispatch($subscription->billable, $subscription, $payload); 179 | } 180 | } 181 | 182 | private function handleSubscriptionCancelled(array $payload): void 183 | { 184 | if (! $subscription = $this->findSubscription($payload['data']['id'])) { 185 | return; 186 | } 187 | 188 | $subscription = $subscription->sync($payload['data']['attributes']); 189 | 190 | if ($subscription->billable) { 191 | SubscriptionCancelled::dispatch($subscription->billable, $subscription, $payload); 192 | } 193 | } 194 | 195 | private function handleSubscriptionResumed(array $payload): void 196 | { 197 | if (! $subscription = $this->findSubscription($payload['data']['id'])) { 198 | return; 199 | } 200 | 201 | $subscription = $subscription->sync($payload['data']['attributes']); 202 | 203 | SubscriptionResumed::dispatch($subscription->billable, $subscription, $payload); 204 | } 205 | 206 | private function handleSubscriptionExpired(array $payload): void 207 | { 208 | if (! $subscription = $this->findSubscription($payload['data']['id'])) { 209 | return; 210 | } 211 | 212 | $subscription = $subscription->sync($payload['data']['attributes']); 213 | 214 | if ($subscription->billable) { 215 | SubscriptionExpired::dispatch($subscription->billable, $subscription, $payload); 216 | } 217 | } 218 | 219 | private function handleSubscriptionPaused(array $payload): void 220 | { 221 | if (! $subscription = $this->findSubscription($payload['data']['id'])) { 222 | return; 223 | } 224 | 225 | $subscription = $subscription->sync($payload['data']['attributes']); 226 | 227 | SubscriptionPaused::dispatch($subscription->billable, $subscription, $payload); 228 | } 229 | 230 | private function handleSubscriptionUnpaused(array $payload): void 231 | { 232 | if (! $subscription = $this->findSubscription($payload['data']['id'])) { 233 | return; 234 | } 235 | 236 | $subscription = $subscription->sync($payload['data']['attributes']); 237 | 238 | SubscriptionUnpaused::dispatch($subscription->billable, $subscription, $payload); 239 | } 240 | 241 | private function handleSubscriptionPaymentSuccess(array $payload): void 242 | { 243 | if ( 244 | ($subscription = $this->findSubscription($payload['data']['attributes']['subscription_id'])) && 245 | $subscription->billable 246 | ) { 247 | SubscriptionPaymentSuccess::dispatch($subscription->billable, $subscription, $payload); 248 | } 249 | } 250 | 251 | private function handleSubscriptionPaymentFailed(array $payload): void 252 | { 253 | if ( 254 | ($subscription = $this->findSubscription($payload['data']['attributes']['subscription_id'])) && 255 | $subscription->billable 256 | ) { 257 | SubscriptionPaymentFailed::dispatch($subscription->billable, $subscription, $payload); 258 | } 259 | } 260 | 261 | private function handleSubscriptionPaymentRecovered(array $payload): void 262 | { 263 | if ( 264 | ($subscription = $this->findSubscription($payload['data']['attributes']['subscription_id'])) && 265 | $subscription->billable 266 | ) { 267 | SubscriptionPaymentRecovered::dispatch($subscription->billable, $subscription, $payload); 268 | } 269 | } 270 | 271 | /** 272 | * @throws MalformedDataError 273 | * @throws InvalidCustomPayload 274 | */ 275 | private function handleLicenseKeyCreated(array $payload): void 276 | { 277 | $licenseKey = LicenseKey::fromPayload($payload['data']); 278 | $billable = $this->resolveBillable($payload); 279 | 280 | LicenseKeyCreated::dispatch($billable, $licenseKey); 281 | } 282 | 283 | /** 284 | * @throws LicenseKeyNotFound 285 | */ 286 | private function handleLicenseKeyUpdated(array $payload): void 287 | { 288 | $key = $payload['data']['attributes']['key'] ?? ''; 289 | $licenseKey = LicenseKey::withKey($key)->first(); 290 | 291 | if ($licenseKey === null) { 292 | throw LicenseKeyNotFound::withKey($key); 293 | } 294 | 295 | $licenseKey = $licenseKey->sync($payload['data']['attributes']); 296 | 297 | LicenseKeyUpdated::dispatch($licenseKey->billable(), $licenseKey); 298 | } 299 | 300 | /** 301 | * @return \LemonSqueezy\Laravel\Billable 302 | * 303 | * @throws InvalidCustomPayload 304 | */ 305 | private function resolveBillable(array $payload) 306 | { 307 | $custom = $payload['meta']['custom_data'] ?? null; 308 | 309 | if (! isset($custom) || ! is_array($custom) || ! isset($custom['billable_id'], $custom['billable_type'])) { 310 | throw new InvalidCustomPayload(); 311 | } 312 | 313 | return $this->findOrCreateCustomer( 314 | $custom['billable_id'], 315 | (string) $custom['billable_type'], 316 | (string) $payload['data']['attributes']['customer_id'], 317 | ); 318 | } 319 | 320 | /** 321 | * @return \LemonSqueezy\Laravel\Billable 322 | */ 323 | private function findOrCreateCustomer(int|string $billableId, string $billableType, string $customerId) 324 | { 325 | return LemonSqueezy::$customerModel::firstOrCreate([ 326 | 'billable_id' => $billableId, 327 | 'billable_type' => $billableType, 328 | ], [ 329 | 'lemon_squeezy_id' => $customerId, 330 | ])->billable; 331 | } 332 | 333 | private function findSubscription(string $subscriptionId): ?Subscription 334 | { 335 | return LemonSqueezy::$subscriptionModel::firstWhere('lemon_squeezy_id', $subscriptionId); 336 | } 337 | 338 | private function findOrder(string $orderId): ?Order 339 | { 340 | return LemonSqueezy::$orderModel::firstWhere('lemon_squeezy_id', $orderId); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerifyWebhookSignature.php: -------------------------------------------------------------------------------- 1 | isInvalidSignature($request->getContent(), $request->header('x-signature'))) { 22 | throw new AccessDeniedHttpException('Invalid webhook signature.'); 23 | } 24 | 25 | return $next($request); 26 | } 27 | 28 | /** 29 | * Validate the API signature. 30 | */ 31 | protected function isInvalidSignature(string $payload, string $signature): bool 32 | { 33 | $hash = hash_hmac('sha256', $payload, config('lemon-squeezy.signing_secret')); 34 | 35 | return ! hash_equals($hash, $signature); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Http/Throwable/BadRequest.php: -------------------------------------------------------------------------------- 1 | withUserAgent('LemonSqueezy\Laravel/' . static::VERSION) 61 | ->accept('application/vnd.api+json') 62 | ->contentType('application/vnd.api+json') 63 | ->$method(static::API . "/{$uri}", $payload); 64 | 65 | if ($response->failed()) { 66 | throw new LemonSqueezyApiError($response['errors'][0]['detail'], (int) $response['errors'][0]['status']); 67 | } 68 | 69 | return $response; 70 | } 71 | 72 | /** 73 | * Format the given amount into a displayable currency. 74 | */ 75 | public static function formatAmount(int $amount, string $currency, ?string $locale = null, array $options = []): string 76 | { 77 | $money = new Money($amount, new Currency(strtoupper($currency))); 78 | 79 | $locale = $locale ?? config('lemon-squeezy.currency_locale'); 80 | 81 | $numberFormatter = new NumberFormatter($locale, NumberFormatter::CURRENCY); 82 | 83 | if (isset($options['min_fraction_digits'])) { 84 | $numberFormatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $options['min_fraction_digits']); 85 | } 86 | 87 | $moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies()); 88 | 89 | return $moneyFormatter->format($money); 90 | } 91 | 92 | /** 93 | * Configure to not register any migrations. 94 | */ 95 | public static function ignoreMigrations(): void 96 | { 97 | static::$runsMigrations = false; 98 | } 99 | 100 | /** 101 | * Configure to not register its routes. 102 | */ 103 | public static function ignoreRoutes(): void 104 | { 105 | static::$registersRoutes = false; 106 | } 107 | 108 | /** 109 | * Set the customer model class name. 110 | */ 111 | public static function useCustomerModel(string $customerModel): void 112 | { 113 | static::$customerModel = $customerModel; 114 | } 115 | 116 | /** 117 | * Set the subscription model class name. 118 | */ 119 | public static function useSubscriptionModel(string $subscriptionModel): void 120 | { 121 | static::$subscriptionModel = $subscriptionModel; 122 | } 123 | 124 | /** 125 | * Set the order model class name. 126 | */ 127 | public static function useOrderModel(string $orderModel): void 128 | { 129 | static::$orderModel = $orderModel; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/LemonSqueezyServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 18 | __DIR__ . '/../config/lemon-squeezy.php', 19 | 'lemon-squeezy', 20 | ); 21 | } 22 | 23 | public function boot(): void 24 | { 25 | $this->bootRoutes(); 26 | $this->bootResources(); 27 | $this->bootMigrations(); 28 | $this->bootPublishing(); 29 | $this->bootDirectives(); 30 | $this->bootComponents(); 31 | $this->bootCommands(); 32 | } 33 | 34 | protected function bootRoutes(): void 35 | { 36 | if (LemonSqueezy::$registersRoutes) { 37 | Route::group([ 38 | 'prefix' => config('lemon-squeezy.path'), 39 | 'as' => 'lemon-squeezy.', 40 | ], function () { 41 | Route::post('webhook', WebhookController::class)->name('webhook'); 42 | }); 43 | } 44 | } 45 | 46 | protected function bootResources(): void 47 | { 48 | $this->loadViewsFrom(__DIR__ . '/../resources/views', 'lemon-squeezy'); 49 | } 50 | 51 | protected function bootMigrations(): void 52 | { 53 | if (LemonSqueezy::$runsMigrations && $this->app->runningInConsole()) { 54 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 55 | } 56 | } 57 | 58 | protected function bootPublishing(): void 59 | { 60 | if ($this->app->runningInConsole()) { 61 | $this->publishes([ 62 | __DIR__ . '/../config/lemon-squeezy.php' => $this->app->configPath('lemon-squeezy.php'), 63 | ], 'lemon-squeezy-config'); 64 | 65 | $this->publishes([ 66 | __DIR__ . '/../database/migrations' => $this->app->databasePath('migrations'), 67 | ], 'lemon-squeezy-migrations'); 68 | 69 | $this->publishes([ 70 | __DIR__ . '/../resources/views' => $this->app->resourcePath('views/vendor/lemon-squeezy'), 71 | ], 'lemon-squeezy-views'); 72 | } 73 | } 74 | 75 | protected function bootDirectives(): void 76 | { 77 | Blade::directive('lemonJS', function () { 78 | return ""; 79 | }); 80 | } 81 | 82 | protected function bootComponents(): void 83 | { 84 | Blade::component('lemon-squeezy::components.button', 'lemon-button'); 85 | } 86 | 87 | protected function bootCommands(): void 88 | { 89 | if ($this->app->runningInConsole()) { 90 | $this->commands([ 91 | ListenCommand::class, 92 | ListLicensesCommand::class, 93 | ListProductsCommand::class, 94 | ]); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/LicenseKey.php: -------------------------------------------------------------------------------- 1 | 'integer', 56 | 'instances_count' => 'integer', 57 | 'expired_at' => 'datetime', 58 | ]; 59 | 60 | /** 61 | * @throws MalformedDataError 62 | */ 63 | public static function fromPayload(array $payload): LicenseKey { 64 | $validator = Validator::make(array_merge([self::KEY_ID => $payload[self::KEY_ID]], $payload['attributes']), [ 65 | self::KEY_ID => ['required', 'string'], 66 | self::KEY_KEY => ['required', 'string'], 67 | self::KEY_KEY_SHORT => ['required', 'string'], 68 | self::KEY_ACTIVATION_LIMIT => ['nullable', 'numeric'], 69 | self::KEY_PRODUCT_ID => ['required'], 70 | self::KEY_ORDER_ID => ['required'], 71 | self::KEY_STATUS => ['required'], 72 | self::KEY_CREATED_AT => ['required', 'date'], 73 | ]); 74 | 75 | if (!$validator->passes()) { 76 | throw MalformedDataError::forLicenseKey($validator); 77 | } 78 | 79 | $attributes = $payload['attributes']; 80 | 81 | $licenseKey = LicenseKey::create([ 82 | 'lemon_squeezy_id' => $payload[self::KEY_ID], 83 | 'status' => $attributes[self::KEY_STATUS], 84 | 'disabled' => $attributes[self::KEY_DISABLED] ?? false, 85 | 'license_key' => $attributes[self::KEY_KEY], 86 | 'product_id' => $attributes[self::KEY_PRODUCT_ID], 87 | 'order_id' => $attributes[self::KEY_ORDER_ID], 88 | 'activation_limit' => $attributes[self::KEY_ACTIVATION_LIMIT], 89 | 'instances_count' => $attributes[self::KEY_INSTANCES_COUNT] ?? 0, 90 | 'expires_at' => isset($attributes[self::KEY_EXPIRES_AT]) 91 | ? Carbon::make($attributes[self::KEY_EXPIRES_AT]) 92 | : null, 93 | 'created_at' => $attributes[self::KEY_CREATED_AT], 94 | 'updated_at' => isset($attributes[self::KEY_UPDATED_AT]) 95 | ? Carbon::make($attributes[self::KEY_UPDATED_AT]) : null, 96 | ]); 97 | 98 | return $licenseKey; 99 | } 100 | 101 | /** 102 | * The order this license key was generated for 103 | */ 104 | public function order(): BelongsTo { 105 | return $this->belongsTo(Order::class); 106 | } 107 | 108 | /** 109 | * The billable that purchased the license 110 | */ 111 | public function billable(): Model { 112 | return $this->order()->first()->billable; 113 | } 114 | 115 | /** 116 | * Check if the license key is active. 117 | */ 118 | public function isActive(): bool 119 | { 120 | return $this->status === self::STATUS_ACTIVE; 121 | } 122 | 123 | /** 124 | * Filter query by active. 125 | */ 126 | public function scopeActive(Builder $query): void 127 | { 128 | $query->where('status', self::STATUS_ACTIVE); 129 | } 130 | 131 | /** 132 | * Filter query by enabled. 133 | */ 134 | public function scopeNotDisabled(Builder $query): void 135 | { 136 | $query->where('disabled', false); 137 | } 138 | 139 | /** 140 | * Filter query by license key. 141 | */ 142 | public function scopeWithKey(Builder $query, string $key): void 143 | { 144 | $query->where('license_key', $key); 145 | } 146 | 147 | /** 148 | * Check if the license key is inactive. 149 | */ 150 | public function inactive(): bool 151 | { 152 | return $this->status === self::STATUS_INACTIVE; 153 | } 154 | 155 | /** 156 | * Filter query by inactive. 157 | */ 158 | public function scopeInactive(Builder $query): void 159 | { 160 | $query->where('status', self::STATUS_INACTIVE); 161 | } 162 | 163 | /** 164 | * Check if the license key is disabled. 165 | */ 166 | public function disabled(): bool 167 | { 168 | return $this->status === self::STATUS_DISABLED; 169 | } 170 | 171 | /** 172 | * Filter query by disabled. 173 | */ 174 | public function scopeDisabled(Builder $query): void 175 | { 176 | $query->where('status', self::STATUS_DISABLED); 177 | } 178 | 179 | /** 180 | * Check if the license key is expired. 181 | */ 182 | public function expired(): bool 183 | { 184 | return $this->status === self::STATUS_EXPIRED; 185 | } 186 | 187 | /** 188 | * Filter query by expired. 189 | */ 190 | public function scopeExpired(Builder $query): void 191 | { 192 | $query->where('status', self::STATUS_EXPIRED); 193 | } 194 | 195 | /** 196 | * Determine if the license is for a specific product. 197 | */ 198 | public function hasProduct(string $productId): bool 199 | { 200 | return $this->product_id === $productId; 201 | } 202 | 203 | /** 204 | * Sync the license key with the given payload data. 205 | */ 206 | public function sync(array $attributes): self 207 | { 208 | $this->update([ 209 | 'status' => $attributes[self::KEY_STATUS], 210 | 'disabled' => $attributes[self::KEY_DISABLED], 211 | 'product_id' => $attributes[self::KEY_PRODUCT_ID], 212 | 'activation_limit' => $attributes[self::KEY_ACTIVATION_LIMIT], 213 | 'instances_count' => $attributes[self::KEY_INSTANCES_COUNT], 214 | 'expires_at' => isset($attributes[self::KEY_EXPIRES_AT]) 215 | ? Carbon::make($attributes[self::KEY_EXPIRES_AT]) 216 | : null, 217 | 'updated_at' => Carbon::make($attributes[self::KEY_EXPIRES_AT]), 218 | ]); 219 | 220 | return $this; 221 | } 222 | 223 | /** 224 | * Create a new factory instance for the model. 225 | */ 226 | protected static function newFactory(): LicenseKeyFactory 227 | { 228 | return LicenseKeyFactory::new(); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/LicenseKeyInstance.php: -------------------------------------------------------------------------------- 1 | ['required'], 28 | 'instance.id' => ['required', 'string'], 29 | 'instance.name' => ['required', 'string'], 30 | ]); 31 | 32 | if (!$validator->passes()) { 33 | throw MalformedDataError::forLicenseKey($validator); 34 | } 35 | 36 | $licenseKey = LicenseKey::notDisabled()->withKey($payload['license_key']['key'])->first(); 37 | 38 | return LicenseKeyInstance::create([ 39 | 'identifier' => $payload['instance']['id'], 40 | 'license_key_id' => $licenseKey->id, 41 | 'name' => $payload['instance']['name'], 42 | ]); 43 | } 44 | 45 | public function licenseKey(): BelongsTo { 46 | return $this->belongsTo(LicenseKey::class); 47 | } 48 | 49 | public function active(Builder $query): void { 50 | $query 51 | ->join('license_keys', 'license_keys.id', '=', 'license_key_instances.license_key_id') 52 | ->where('license_keys.status', LicenseKey::STATUS_ACTIVE); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Order.php: -------------------------------------------------------------------------------- 1 | 'integer', 70 | 'discount_total' => 'integer', 71 | 'tax' => 'integer', 72 | 'total' => 'integer', 73 | 'refunded' => 'boolean', 74 | 'refunded_at' => 'datetime', 75 | 'ordered_at' => 'datetime', 76 | ]; 77 | 78 | /** 79 | * Get the billable model related to the customer. 80 | */ 81 | public function billable(): MorphTo 82 | { 83 | return $this->morphTo(); 84 | } 85 | 86 | /** 87 | * Check if the order is pending. 88 | */ 89 | public function pending(): bool 90 | { 91 | return $this->status === self::STATUS_PENDING; 92 | } 93 | 94 | /** 95 | * Filter query by pending. 96 | */ 97 | public function scopePending(Builder $query): void 98 | { 99 | $query->where('status', self::STATUS_PENDING); 100 | } 101 | 102 | /** 103 | * Check if the order is failed. 104 | */ 105 | public function failed(): bool 106 | { 107 | return $this->status === self::STATUS_FAILED; 108 | } 109 | 110 | /** 111 | * Filter query by failed. 112 | */ 113 | public function scopeFailed(Builder $query): void 114 | { 115 | $query->where('status', self::STATUS_FAILED); 116 | } 117 | 118 | /** 119 | * Check if the order is paid. 120 | */ 121 | public function paid(): bool 122 | { 123 | return $this->status === self::STATUS_PAID; 124 | } 125 | 126 | /** 127 | * Filter query by paid. 128 | */ 129 | public function scopePaid(Builder $query): void 130 | { 131 | $query->where('status', self::STATUS_PAID); 132 | } 133 | 134 | /** 135 | * Check if the order is refunded. 136 | */ 137 | public function refunded(): bool 138 | { 139 | return $this->status === self::STATUS_REFUNDED; 140 | } 141 | 142 | /** 143 | * Filter query by refunded. 144 | */ 145 | public function scopeRefunded(Builder $query): void 146 | { 147 | $query->where('status', self::STATUS_REFUNDED); 148 | } 149 | 150 | /** 151 | * Determine if the order is for a specific product. 152 | */ 153 | public function hasProduct(string $productId): bool 154 | { 155 | return $this->product_id === $productId; 156 | } 157 | 158 | /** 159 | * Determine if the order is for a specific variant. 160 | */ 161 | public function hasVariant(string $variantId): bool 162 | { 163 | return $this->variant_id === $variantId; 164 | } 165 | 166 | /** 167 | * Get the order's subtotal. 168 | */ 169 | public function subtotal(): string 170 | { 171 | return LemonSqueezy::formatAmount($this->subtotal, $this->currency); 172 | } 173 | 174 | /** 175 | * Get the order's discount total. 176 | */ 177 | public function discount(): string 178 | { 179 | return LemonSqueezy::formatAmount($this->discount_total, $this->currency); 180 | } 181 | 182 | /** 183 | * Get the order's tax. 184 | */ 185 | public function tax(): string 186 | { 187 | return LemonSqueezy::formatAmount($this->tax, $this->currency); 188 | } 189 | 190 | /** 191 | * Get the order's total. 192 | */ 193 | public function total(): string 194 | { 195 | return LemonSqueezy::formatAmount($this->total, $this->currency); 196 | } 197 | 198 | /** 199 | * Sync the order with the given attributes. 200 | */ 201 | public function sync(array $attributes): self 202 | { 203 | $this->update([ 204 | 'customer_id' => $attributes['customer_id'], 205 | 'product_id' => (string) $attributes['first_order_item']['product_id'], 206 | 'variant_id' => (string) $attributes['first_order_item']['variant_id'], 207 | 'identifier' => $attributes['identifier'], 208 | 'order_number' => $attributes['order_number'], 209 | 'currency' => $attributes['currency'], 210 | 'subtotal' => $attributes['subtotal'], 211 | 'discount_total' => $attributes['discount_total'], 212 | 'tax' => $attributes['tax'], 213 | 'total' => $attributes['total'], 214 | 'tax_name' => $attributes['tax_name'], 215 | 'status' => $attributes['status'], 216 | 'receipt_url' => $attributes['urls']['receipt'] ?? null, 217 | 'refunded' => $attributes['refunded'], 218 | 'refunded_at' => isset($attributes['refunded_at']) ? Carbon::make($attributes['refunded_at']) : null, 219 | 'ordered_at' => isset($attributes['created_at']) ? Carbon::make($attributes['created_at']) : null, 220 | ]); 221 | 222 | return $this; 223 | } 224 | 225 | /** 226 | * Create a new factory instance for the model. 227 | */ 228 | protected static function newFactory(): OrderFactory 229 | { 230 | return OrderFactory::new(); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Subscription.php: -------------------------------------------------------------------------------- 1 | 'datetime', 78 | 'trial_ends_at' => 'datetime', 79 | 'renews_at' => 'datetime', 80 | 'ends_at' => 'datetime', 81 | ]; 82 | 83 | /** 84 | * Get the billable model related to the subscription. 85 | */ 86 | public function billable(): MorphTo 87 | { 88 | return $this->morphTo(); 89 | } 90 | 91 | /** 92 | * Determine if the subscription is active, on trial, past due, paused for free, or within its grace period. 93 | */ 94 | public function valid(): bool 95 | { 96 | return $this->active() || 97 | $this->onTrial() || 98 | $this->pastDue() || 99 | $this->onGracePeriod() || 100 | ($this->paused() && $this->pause_mode === 'free'); 101 | } 102 | 103 | /** 104 | * Check if the subscription is on trial. 105 | */ 106 | public function onTrial(): bool 107 | { 108 | return $this->status === self::STATUS_ON_TRIAL; 109 | } 110 | 111 | /** 112 | * Filter query by on trial. 113 | */ 114 | public function scopeOnTrial(Builder $query): void 115 | { 116 | $query->where('status', self::STATUS_ON_TRIAL); 117 | } 118 | 119 | /** 120 | * Check if the subscription is active. 121 | */ 122 | public function active(): bool 123 | { 124 | return $this->status === self::STATUS_ACTIVE; 125 | } 126 | 127 | /** 128 | * Determine if the subscription's trial has expired. 129 | */ 130 | public function hasExpiredTrial(): bool 131 | { 132 | return $this->trial_ends_at && $this->trial_ends_at->isPast(); 133 | } 134 | 135 | /** 136 | * Filter query by active. 137 | */ 138 | public function scopeActive(Builder $query): void 139 | { 140 | $query->where('status', self::STATUS_ACTIVE); 141 | } 142 | 143 | /** 144 | * Check if the subscription is paused. 145 | */ 146 | public function paused(): bool 147 | { 148 | return $this->status === self::STATUS_PAUSED; 149 | } 150 | 151 | /** 152 | * Filter query by paused. 153 | */ 154 | public function scopePaused(Builder $query): void 155 | { 156 | $query->where('status', self::STATUS_PAUSED); 157 | } 158 | 159 | /** 160 | * Check if the subscription is past due. 161 | */ 162 | public function pastDue(): bool 163 | { 164 | return $this->status === self::STATUS_PAST_DUE; 165 | } 166 | 167 | /** 168 | * Filter query by past due. 169 | */ 170 | public function scopePastDue(Builder $query): void 171 | { 172 | $query->where('status', self::STATUS_PAST_DUE); 173 | } 174 | 175 | /** 176 | * Check if the subscription is unpaid. 177 | */ 178 | public function unpaid(): bool 179 | { 180 | return $this->status === self::STATUS_UNPAID; 181 | } 182 | 183 | /** 184 | * Filter query by unpaid. 185 | */ 186 | public function scopeUnpaid(Builder $query): void 187 | { 188 | $query->where('status', self::STATUS_UNPAID); 189 | } 190 | 191 | /** 192 | * Check if the subscription is cancelled. 193 | */ 194 | public function cancelled(): bool 195 | { 196 | return $this->status === self::STATUS_CANCELLED; 197 | } 198 | 199 | /** 200 | * Filter query by cancelled. 201 | */ 202 | public function scopeCancelled(Builder $query): void 203 | { 204 | $query->where('status', self::STATUS_CANCELLED); 205 | } 206 | 207 | /** 208 | * Check if the subscription is expired. 209 | */ 210 | public function expired(): bool 211 | { 212 | return $this->status === self::STATUS_EXPIRED; 213 | } 214 | 215 | /** 216 | * Filter query by expired. 217 | */ 218 | public function scopeExpired(Builder $query): void 219 | { 220 | $query->where('status', self::STATUS_EXPIRED); 221 | } 222 | 223 | /** 224 | * Determine if the subscription is within its grace period after cancellation. 225 | */ 226 | public function onGracePeriod(): bool 227 | { 228 | return $this->cancelled() && $this->ends_at?->isFuture(); 229 | } 230 | 231 | /** 232 | * Determine if the subscription is within its paused period. 233 | */ 234 | public function onPausedPeriod(): bool 235 | { 236 | return $this->paused() && $this->pause_resumes_at?->isFuture(); 237 | } 238 | 239 | /** 240 | * Determine if the subscription is on a specific product. 241 | */ 242 | public function hasProduct(string $productId): bool 243 | { 244 | return $this->product_id === $productId; 245 | } 246 | 247 | /** 248 | * Determine if the subscription is on a specific variant. 249 | */ 250 | public function hasVariant(string $variantId): bool 251 | { 252 | return $this->variant_id === $variantId; 253 | } 254 | 255 | /** 256 | * Change the billing cycle anchor on the subscription. 257 | */ 258 | public function anchorBillingCycleOn(?int $date): self 259 | { 260 | $response = LemonSqueezy::api('PATCH', "subscriptions/{$this->lemon_squeezy_id}", [ 261 | 'data' => [ 262 | 'type' => 'subscriptions', 263 | 'id' => $this->lemon_squeezy_id, 264 | 'attributes' => [ 265 | 'billing_anchor' => $date, 266 | ], 267 | ], 268 | ]); 269 | 270 | $this->sync($response['data']['attributes']); 271 | 272 | return $this; 273 | } 274 | 275 | /** 276 | * End the current trial by resetting the billing anchor to today. 277 | */ 278 | public function endTrial(): self 279 | { 280 | return $this->anchorBillingCycleOn(0); 281 | } 282 | 283 | /** 284 | * Swap the subscription to a new product plan. 285 | */ 286 | public function swap(string $product, string $variant, array $attributes = []): self 287 | { 288 | $response = LemonSqueezy::api('PATCH', "subscriptions/{$this->lemon_squeezy_id}", [ 289 | 'data' => [ 290 | 'type' => 'subscriptions', 291 | 'id' => $this->lemon_squeezy_id, 292 | 'attributes' => array_merge([ 293 | 'product_id' => $product, 294 | 'variant_id' => $variant, 295 | 'disable_prorations' => ! $this->prorate, 296 | ], $attributes), 297 | ], 298 | ]); 299 | 300 | $this->sync($response['data']['attributes']); 301 | 302 | return $this; 303 | } 304 | 305 | /** 306 | * Swap the subscription to a new product plan and invoice immediately. 307 | */ 308 | public function swapAndInvoice(string $product, string $variant): self 309 | { 310 | return $this->swap($product, $variant, [ 311 | 'invoice_immediately' => true, 312 | ]); 313 | } 314 | 315 | /** 316 | * Cancel the subscription. 317 | */ 318 | public function cancel(): self 319 | { 320 | $response = LemonSqueezy::api('DELETE', "subscriptions/{$this->lemon_squeezy_id}"); 321 | 322 | $this->sync($response['data']['attributes']); 323 | 324 | return $this; 325 | } 326 | 327 | /** 328 | * Resume the subscription. 329 | */ 330 | public function resume(): self 331 | { 332 | if ($this->expired()) { 333 | throw new LogicException('Cannot resume an expired subscription.'); 334 | } 335 | 336 | $response = LemonSqueezy::api('PATCH', "subscriptions/{$this->lemon_squeezy_id}", [ 337 | 'data' => [ 338 | 'type' => 'subscriptions', 339 | 'id' => $this->lemon_squeezy_id, 340 | 'attributes' => [ 341 | 'cancelled' => false, 342 | ], 343 | ], 344 | ]); 345 | 346 | $this->sync($response['data']['attributes']); 347 | 348 | return $this; 349 | } 350 | 351 | /** 352 | * Pause the subscription and prevent the user from using the service. 353 | */ 354 | public function pause(?DateTimeInterface $resumesAt = null): self 355 | { 356 | $response = LemonSqueezy::api('PATCH', "subscriptions/{$this->lemon_squeezy_id}", [ 357 | 'data' => [ 358 | 'type' => 'subscriptions', 359 | 'id' => $this->lemon_squeezy_id, 360 | 'attributes' => [ 361 | 'pause' => [ 362 | 'mode' => 'void', 363 | 'resumes_at' => $resumesAt ? Carbon::instance($resumesAt)->toIso8601String() : null, 364 | ], 365 | ], 366 | ], 367 | ]); 368 | 369 | $this->sync($response['data']['attributes']); 370 | 371 | return $this; 372 | } 373 | 374 | /** 375 | * Pause the subscription but let the user continue to use the service for free. 376 | */ 377 | public function pauseForFree(?DateTimeInterface $resumesAt = null): self 378 | { 379 | $response = LemonSqueezy::api('PATCH', "subscriptions/{$this->lemon_squeezy_id}", [ 380 | 'data' => [ 381 | 'type' => 'subscriptions', 382 | 'id' => $this->lemon_squeezy_id, 383 | 'attributes' => [ 384 | 'pause' => [ 385 | 'mode' => 'free', 386 | 'resumes_at' => $resumesAt ? Carbon::instance($resumesAt)->toIso8601String() : null, 387 | ], 388 | ], 389 | ], 390 | ]); 391 | 392 | $this->sync($response['data']['attributes']); 393 | 394 | return $this; 395 | } 396 | 397 | /** 398 | * Unpause the subscription. 399 | */ 400 | public function unpause(): self 401 | { 402 | $response = LemonSqueezy::api('PATCH', "subscriptions/{$this->lemon_squeezy_id}", [ 403 | 'data' => [ 404 | 'type' => 'subscriptions', 405 | 'id' => $this->lemon_squeezy_id, 406 | 'attributes' => [ 407 | 'pause' => null, 408 | ], 409 | ], 410 | ]); 411 | 412 | $this->sync($response['data']['attributes']); 413 | 414 | return $this; 415 | } 416 | 417 | /** 418 | * Get the subscription update payment method URL. 419 | */ 420 | public function updatePaymentMethodUrl(): string 421 | { 422 | $response = LemonSqueezy::api('GET', "subscriptions/{$this->lemon_squeezy_id}"); 423 | 424 | return $response['data']['attributes']['urls']['update_payment_method']; 425 | } 426 | 427 | /** 428 | * Sync the subscription with the given attributes. 429 | */ 430 | public function sync(array $attributes): self 431 | { 432 | $this->update([ 433 | 'status' => $attributes['status'], 434 | 'product_id' => (string) $attributes['product_id'], 435 | 'variant_id' => (string) $attributes['variant_id'], 436 | 'card_brand' => $attributes['card_brand'] ?? null, 437 | 'card_last_four' => $attributes['card_last_four'] ?? null, 438 | 'pause_mode' => $attributes['pause']['mode'] ?? null, 439 | 'pause_resumes_at' => isset($attributes['pause']['resumes_at']) ? Carbon::make($attributes['pause']['resumes_at']) : null, 440 | 'trial_ends_at' => isset($attributes['trial_ends_at']) ? Carbon::make($attributes['trial_ends_at']) : null, 441 | 'renews_at' => isset($attributes['renews_at']) ? Carbon::make($attributes['renews_at']) : null, 442 | 'ends_at' => isset($attributes['ends_at']) ? Carbon::make($attributes['ends_at']) : null, 443 | ]); 444 | 445 | return $this; 446 | } 447 | 448 | /** 449 | * Create a new factory instance for the model. 450 | */ 451 | protected static function newFactory(): SubscriptionFactory 452 | { 453 | return SubscriptionFactory::new(); 454 | } 455 | } 456 | -------------------------------------------------------------------------------- /src/Webhooks/Enums/AffiliateStatus.php: -------------------------------------------------------------------------------- 1 | Order::fromArray($payload['attributes']), 23 | Topic::SubscriptionCreated, 24 | Topic::SubscriptionUpdated, 25 | Topic::SubscriptionCancelled, 26 | Topic::SubscriptionResumed, 27 | Topic::SubscriptionExpired, 28 | Topic::SubscriptionPaused, 29 | Topic::SubscriptionUnpaused, 30 | Topic::SubscriptionPaymentFailed, 31 | Topic::SubscriptionPaymentSuccess, 32 | Topic::SubscriptionPaymentRecovered, 33 | Topic::SubscriptionPaymentRefunded => Subscription::fromArray($payload['attributes']), 34 | Topic::LicenseKeyCreated, 35 | Topic::LicenseKeyUpdated => LicenseKey::fromArray($payload['attributes']), 36 | Topic::AffiliateActivated => Affiliate::fromArray($payload['attributes']), 37 | default => throw new InvalidArgumentException( 38 | message: "Unsupported topic: {$topic->value}.", 39 | ), 40 | }; 41 | 42 | return new Webhook( 43 | meta: new Meta( 44 | event_name: $topic->value, 45 | ), 46 | type: $payload['type'], 47 | id: $payload['id'], 48 | attributes: $attributes, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Webhooks/Hooks/Affiliate.php: -------------------------------------------------------------------------------- 1 |