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)
+ {{ $order->ordered_at->toFormattedDateString() }} |
+ {{ $order->order_number }} |
+ {{ $order->subtotal() }} |
+ {{ $order->discount() }} |
+ {{ $order->tax() }} |
+ {{ $order->total() }} |
+ {{ $order->receipt_url }} |
+ @endforeach
+
+```
+
+### 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';
+}