diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b90f31e..c680973 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, soap + extensions: dom, curl, libxml, mbstring, zip, soap, intl coverage: none - name: Install dependencies diff --git a/README.md b/README.md index b853424..19fe81f 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,80 @@ $url = $user->customerPortalUrl(); 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. +## Orders + +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. + +### Retrieving Orders + +To retrieve a list of orders for a specific customer, simply call the saved models in the database: + +```blade + + @foreach ($billable->orders as $order) + + + + + + + + @endforeach +
{{ $order->ordered_at->toFormattedDateString() }}{{ $order->order_number }}{{ $order->subtotal() }}{{ $order->discount() }}{{ $order->tax() }}{{ $order->total() }}{{ $order->receipt_url }}
+``` + +### Checking Order Status + +To check if an individual order is paid, you may use the `paid` method: + +```php +if ($order->paid()) { + // ... +} +``` + +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: + +```blade +@if ($order->refunded()) + Order {{ $order->order_number }} was rufunded on {{ $order->refunded_at->toFormattedDateString() }} +@endif +``` + +You can also check if an order was for a specific product: + +```php +if ($order->hasProduct('your-product-id')) { + // ... +} +``` + +Or for a specific variant: + +```php +if ($order->hasVariant('your-variant-id')) { + // ... +} +``` + +Additionally, you may check if a customer has purchased a specific product: + +```php +if ($billable->hasPurchasedProduct('your-product-id')) { + // ... +} +``` + +Or a specific variant: + +```php +if ($billable->hasPurchasedVariant('your-variant-id')) { + // ... +} +``` + +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. + ## Subscriptions ### Setting Up Subscription Products diff --git a/UPGRADE.md b/UPGRADE.md index a2ed7d5..4a2aa20 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,17 @@ # Upgrade Guide Future upgrade notes will be placed here. + +## Upgrading To 1.3 From 1.x + +### New Order Model + +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: + +```shell +php artisan migrate +``` + +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. + +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. diff --git a/config/lemon-squeezy.php b/config/lemon-squeezy.php index cd95ca2..051ff2c 100644 --- a/config/lemon-squeezy.php +++ b/config/lemon-squeezy.php @@ -67,4 +67,17 @@ 'redirect_url' => null, + /* + |-------------------------------------------------------------------------- + | Currency Locale + |-------------------------------------------------------------------------- + | + | This is the default locale in which your money values are formatted in + | for display. To utilize other locales besides the default en locale + | verify you have the "intl" PHP extension installed on the system. + | + */ + + 'currency_locale' => env('LEMON_SQUEEZY_CURRENCY_LOCALE', 'en'), + ]; diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 0000000..b76a62a --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,109 @@ + + */ + public function definition(): array + { + return [ + 'billable_id' => rand(1, 1000), + 'billable_type' => 'App\\Models\\User', + 'lemon_squeezy_id' => rand(1, 1000), + 'customer_id' => rand(1, 1000), + 'product_id' => rand(1, 1000), + 'variant_id' => rand(1, 1000), + 'order_number' => rand(1, 1000), + 'currency' => $this->faker->randomElement(['USD', 'EUR', 'GBP']), + 'subtotal' => $subtotal = rand(400, 1000), + 'discount_total' => $discount = rand(1, 400), + 'tax' => $tax = rand(1, 50), + 'total' => $subtotal - $discount + $tax, + 'tax_name' => $this->faker->randomElement(['VAT', 'Sales Tax']), + 'receipt_url' => null, + 'ordered_at' => $orderedAt = Carbon::make($this->faker->dateTimeBetween('-1 year', 'now')), + 'refunded' => $refunded = $this->faker->boolean(75), + 'refunded_at' => $refunded ? $orderedAt->addWeek() : null, + 'status' => $refunded ? Order::STATUS_REFUNDED : Order::STATUS_PAID, + ]; + } + + /** + * Configure the model factory. + */ + public function configure(): self + { + return $this->afterCreating(function ($subscription) { + Customer::factory()->create([ + 'billable_id' => $subscription->billable_id, + 'billable_type' => $subscription->billable_type, + ]); + }); + } + + /** + * Mark the order as pending. + */ + public function pending(): self + { + return $this->state([ + 'status' => Order::STATUS_PENDING, + 'refunded' => false, + 'refunded_at' => null, + ]); + } + + /** + * Mark the order as failed. + */ + public function failed(): self + { + return $this->state([ + 'status' => Order::STATUS_FAILED, + 'refunded' => false, + 'refunded_at' => null, + ]); + } + + /** + * Mark the order as paid. + */ + public function paid(): self + { + return $this->state([ + 'status' => Order::STATUS_PAID, + 'refunded' => false, + 'refunded_at' => null, + ]); + } + + /** + * Mark the order as being refunded. + */ + public function refunded(DateTimeInterface $refundedAt = null): self + { + return $this->state([ + 'status' => Order::STATUS_REFUNDED, + 'refunded' => true, + 'refunded_at' => $refundedAt, + ]); + } +} diff --git a/database/migrations/2023_01_16_000003_create_orders_table.php b/database/migrations/2023_01_16_000003_create_orders_table.php new file mode 100644 index 0000000..ee75d52 --- /dev/null +++ b/database/migrations/2023_01_16_000003_create_orders_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('billable_id'); + $table->string('billable_type'); + $table->string('lemon_squeezy_id')->unique(); + $table->string('customer_id'); + $table->uuid('identifier')->unique(); + $table->string('product_id'); + $table->string('variant_id'); + $table->integer('order_number')->unique(); + $table->string('currency'); + $table->integer('subtotal'); + $table->integer('discount_total'); + $table->integer('tax'); + $table->integer('total'); + $table->string('tax_name'); + $table->string('status'); + $table->string('receipt_url')->nullable(); + $table->boolean('refunded'); + $table->timestamp('refunded_at')->nullable(); + $table->timestamp('ordered_at'); + $table->timestamps(); + + $table->index(['billable_id', 'billable_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('lemon_squeezy_orders'); + } +}; diff --git a/src/Billable.php b/src/Billable.php index 0bf35aa..8e36166 100644 --- a/src/Billable.php +++ b/src/Billable.php @@ -4,11 +4,13 @@ use LemonSqueezy\Laravel\Concerns\ManagesCheckouts; use LemonSqueezy\Laravel\Concerns\ManagesCustomer; +use LemonSqueezy\Laravel\Concerns\ManagesOrders; use LemonSqueezy\Laravel\Concerns\ManagesSubscriptions; trait Billable { use ManagesCheckouts; use ManagesCustomer; + use ManagesOrders; use ManagesSubscriptions; } diff --git a/src/Concerns/ManagesOrders.php b/src/Concerns/ManagesOrders.php new file mode 100644 index 0000000..daa7485 --- /dev/null +++ b/src/Concerns/ManagesOrders.php @@ -0,0 +1,33 @@ +morphMany(LemonSqueezy::$orderModel, 'billable')->orderByDesc('created_at'); + } + + /** + * Determine if the billable has purchased a specific product. + */ + public function hasPurchasedProduct(string $productId): bool + { + return $this->orders()->where('product_id', $productId)->where('status', static::STATUS_PAID)->exists(); + } + + /** + * Determine if the billable has purchased a specific variant of a product. + */ + public function hasPurchasedVariant(string $variantId): bool + { + return $this->orders()->where('variant_id', $variantId)->where('status', static::STATUS_PAID)->exists(); + } +} diff --git a/src/Events/OrderCreated.php b/src/Events/OrderCreated.php index ac3da9b..1ac203f 100644 --- a/src/Events/OrderCreated.php +++ b/src/Events/OrderCreated.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use LemonSqueezy\Laravel\Order; class OrderCreated { @@ -15,14 +16,22 @@ class OrderCreated */ public Model $billable; + /** + * The order entity. + * + * @todo v2: Remove the nullable type hint. + */ + public ?Order $order; + /** * The payload array. */ public array $payload; - public function __construct(Model $billable, array $payload) + public function __construct(Model $billable, ?Order $order, array $payload) { $this->billable = $billable; + $this->order = $order; $this->payload = $payload; } } diff --git a/src/Events/OrderRefunded.php b/src/Events/OrderRefunded.php index 8afc900..164f0de 100644 --- a/src/Events/OrderRefunded.php +++ b/src/Events/OrderRefunded.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use LemonSqueezy\Laravel\Order; class OrderRefunded { @@ -15,14 +16,22 @@ class OrderRefunded */ public Model $billable; + /** + * The order entity. + * + * @todo v2: Remove the nullable type hint. + */ + public ?Order $order; + /** * The payload array. */ public array $payload; - public function __construct(Model $billable, array $payload) + public function __construct(Model $billable, ?Order $order, array $payload) { $this->billable = $billable; + $this->order = $order; $this->payload = $payload; } } diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php index 1cc5173..ac4badc 100644 --- a/src/Http/Controllers/WebhookController.php +++ b/src/Http/Controllers/WebhookController.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use LemonSqueezy\Laravel\Events\LicenseKeyCreated; use LemonSqueezy\Laravel\Events\LicenseKeyUpdated; @@ -25,6 +26,7 @@ use LemonSqueezy\Laravel\Exceptions\InvalidCustomPayload; use LemonSqueezy\Laravel\Http\Middleware\VerifyWebhookSignature; use LemonSqueezy\Laravel\LemonSqueezy; +use LemonSqueezy\Laravel\Order; use LemonSqueezy\Laravel\Subscription; use Symfony\Component\HttpFoundation\Response; @@ -74,14 +76,51 @@ public function handleOrderCreated(array $payload): void { $billable = $this->resolveBillable($payload); - OrderCreated::dispatch($billable, $payload); + // Todo v2: Remove this check + if (Schema::hasTable((new LemonSqueezy::$orderModel)->getTable())) { + $attributes = $payload['data']['attributes']; + + $order = $billable->orders()->create([ + 'lemon_squeezy_id' => $payload['data']['id'], + 'customer_id' => $attributes['customer_id'], + 'product_id' => $attributes['product_id'], + 'variant_id' => $attributes['variant_id'], + 'order_number' => $attributes['order_number'], + 'currency' => $attributes['currency'], + 'subtotal' => $attributes['subtotal'], + 'discount_total' => $attributes['discount_total'], + 'tax' => $attributes['tax'], + 'total' => $attributes['total'], + 'tax_name' => $attributes['tax_name'], + 'status' => $attributes['status'], + 'receipt_url' => $attributes['urls']['receipt'] ?? null, + 'refunded' => $attributes['refunded'], + 'refunded_at' => $attributes['refunded_at'] ? Carbon::make($attributes['refunded_at']) : null, + 'ordered_at' => Carbon::make($attributes['created_at']), + ]); + } else { + $order = null; + } + + OrderCreated::dispatch($billable, $order, $payload); } public function handleOrderRefunded(array $payload): void { $billable = $this->resolveBillable($payload); - OrderRefunded::dispatch($billable, $payload); + // Todo v2: Remove this check + if (Schema::hasTable((new LemonSqueezy::$orderModel)->getTable())) { + if (! $order = $this->findOrder($payload['data']['id'])) { + return; + } + + $order = $order->sync($payload['data']['attributes']); + } else { + $order = null; + } + + OrderRefunded::dispatch($billable, $order, $payload); } public function handleSubscriptionCreated(array $payload): void @@ -255,4 +294,9 @@ private function findSubscription(string $subscriptionId): ?Subscription { return LemonSqueezy::$subscriptionModel::firstWhere('lemon_squeezy_id', $subscriptionId); } + + private function findOrder(string $orderId): ?Order + { + return LemonSqueezy::$orderModel::firstWhere('lemon_squeezy_id', $orderId); + } } diff --git a/src/LemonSqueezy.php b/src/LemonSqueezy.php index ecb593a..9960c4b 100644 --- a/src/LemonSqueezy.php +++ b/src/LemonSqueezy.php @@ -6,6 +6,11 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use LemonSqueezy\Laravel\Exceptions\LemonSqueezyApiError; +use Money\Currencies\ISOCurrencies; +use Money\Currency; +use Money\Formatter\IntlMoneyFormatter; +use Money\Money; +use NumberFormatter; class LemonSqueezy { @@ -31,6 +36,11 @@ class LemonSqueezy */ public static string $subscriptionModel = Subscription::class; + /** + * The order model class name. + */ + public static string $orderModel = Order::class; + /** * Perform a Lemon Squeezy API call. * @@ -57,6 +67,26 @@ public static function api(string $method, string $uri, array $payload = []): Re return $response; } + /** + * Format the given amount into a displayable currency. + */ + public static function formatAmount(int $amount, string $currency, string $locale = null, array $options = []): string + { + $money = new Money($amount, new Currency(strtoupper($currency))); + + $locale = $locale ?? config('lemon-squeezy.currency_locale'); + + $numberFormatter = new NumberFormatter($locale, NumberFormatter::CURRENCY); + + if (isset($options['min_fraction_digits'])) { + $numberFormatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $options['min_fraction_digits']); + } + + $moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies()); + + return $moneyFormatter->format($money); + } + /** * Configure to not register any migrations. */ @@ -88,4 +118,12 @@ public static function useSubscriptionModel(string $subscriptionModel): void { static::$subscriptionModel = $subscriptionModel; } + + /** + * Set the order model class name. + */ + public static function useOrderModel(string $orderModel): void + { + static::$orderModel = $orderModel; + } } diff --git a/src/Order.php b/src/Order.php new file mode 100644 index 0000000..d2c5395 --- /dev/null +++ b/src/Order.php @@ -0,0 +1,208 @@ + 'integer', + 'discount_total' => 'integer', + 'tax' => 'integer', + 'total' => 'integer', + 'refunded' => 'boolean', + 'refunded_at' => 'datetime', + 'ordered_at' => 'datetime', + ]; + + /** + * Get the billable model related to the customer. + */ + public function billable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Check if the order is pending. + */ + public function pending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * Filter query by pending. + */ + public function scopePending(Builder $query): void + { + $query->where('status', self::STATUS_PENDING); + } + + /** + * Check if the order is failed. + */ + public function failed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + /** + * Filter query by failed. + */ + public function scopeFailed(Builder $query): void + { + $query->where('status', self::STATUS_FAILED); + } + + /** + * Check if the order is paid. + */ + public function paid(): bool + { + return $this->status === self::STATUS_PAID; + } + + /** + * Filter query by paid. + */ + public function scopePaid(Builder $query): void + { + $query->where('status', self::STATUS_PAID); + } + + /** + * Check if the order is refunded. + */ + public function refunded(): bool + { + return $this->status === self::STATUS_REFUNDED; + } + + /** + * Filter query by refunded. + */ + public function scopeRefunded(Builder $query): void + { + $query->where('status', self::STATUS_REFUNDED); + } + + /** + * Determine if the order is for a specific product. + */ + public function hasProduct(string $productId): bool + { + return $this->product_id === $productId; + } + + /** + * Determine if the order is for a specific variant. + */ + public function hasVariant(string $variantId): bool + { + return $this->variant_id === $variantId; + } + + /** + * Get the order's subtotal. + */ + public function subtotal(): string + { + return LemonSqueezy::formatAmount($this->subtotal, $this->currency); + } + + /** + * Get the order's discount total. + */ + public function discount(): string + { + return LemonSqueezy::formatAmount($this->discount_total, $this->currency); + } + + /** + * Get the order's tax. + */ + public function tax(): string + { + return LemonSqueezy::formatAmount($this->tax, $this->currency); + } + + /** + * Get the order's total. + */ + public function total(): string + { + return LemonSqueezy::formatAmount($this->total, $this->currency); + } + + /** + * Sync the order with the given attributes. + */ + public function sync(array $attributes): self + { + $this->update([ + 'customer_id' => $attributes['customer_id'], + 'product_id' => $attributes['product_id'], + 'variant_id' => $attributes['variant_id'], + 'order_number' => $attributes['order_number'], + 'currency' => $attributes['currency'], + 'subtotal' => $attributes['subtotal'], + 'discount_total' => $attributes['discount_total'], + 'tax' => $attributes['tax'], + 'total' => $attributes['total'], + 'tax_name' => $attributes['tax_name'], + 'status' => $attributes['status'], + 'receipt_url' => $attributes['urls']['receipt'] ?? null, + 'refunded' => $attributes['refunded'], + 'refunded_at' => isset($attributes['refunded_at']) ? Carbon::make($attributes['refunded_at']) : null, + 'ordered_at' => isset($attributes['created_at']) ? Carbon::make($attributes['created_at']) : null, + ]); + + return $this; + } + + /** + * Create a new factory instance for the model. + */ + protected static function newFactory(): OrderFactory + { + return OrderFactory::new(); + } +} diff --git a/tests/Feature/OrderTest.php b/tests/Feature/OrderTest.php new file mode 100644 index 0000000..761d081 --- /dev/null +++ b/tests/Feature/OrderTest.php @@ -0,0 +1,18 @@ + 1000, + 'discount_total' => 10, + 'tax' => 100, + 'total' => 1090, + 'currency' => 'USD', + ]); + + expect($order->subtotal())->toBe('$10.00'); + expect($order->discount())->toBe('$0.10'); + expect($order->tax())->toBe('$1.00'); + expect($order->total())->toBe('$10.90'); +}); diff --git a/tests/Unit/OrderTest.php b/tests/Unit/OrderTest.php new file mode 100644 index 0000000..8450964 --- /dev/null +++ b/tests/Unit/OrderTest.php @@ -0,0 +1,59 @@ + Order::STATUS_PENDING]); + + expect($order->pending())->toBeTrue(); + expect($order->paid())->toBeFalse(); +}); + +it('can determine if the order is failed', function () { + $order = new Order(['status' => Order::STATUS_FAILED]); + + expect($order->failed())->toBeTrue(); + expect($order->paid())->toBeFalse(); +}); + +it('can determine if the order is paid', function () { + $order = new Order(['status' => Order::STATUS_PAID]); + + expect($order->paid())->toBeTrue(); + expect($order->failed())->toBeFalse(); +}); + +it('can determine if the order is refunded', function () { + $order = new Order([ + 'status' => Order::STATUS_REFUNDED, + 'refunded' => true, + 'refunded_at' => now()->subDay(), + ]); + + expect($order->refunded())->toBeTrue(); + expect($order->paid())->toBeFalse(); +}); + +it('can determine if the order is for a specific product', function () { + $order = new Order(['product_id' => '45067']); + + expect($order->hasProduct('45067'))->toBeTrue(); + expect($order->hasProduct('93048'))->toBeFalse(); +}); + +it('can determine if the order is for a specific variant', function () { + $order = new Order(['variant_id' => '45067']); + + expect($order->hasVariant('45067'))->toBeTrue(); + expect($order->hasVariant('93048'))->toBeFalse(); +}); + +class Order extends LemonSqueezyOrder +{ + /** + * The storage format of the model's date columns. + * + * @var string + */ + protected $dateFormat = 'Y-m-d H:i:s'; +}