142 lines
4.4 KiB
PHP
142 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Models\Concerns\HasUuid;
|
|
use App\Provisioning\Contracts\ProvisioningSubject;
|
|
use App\Services\Billing\TaxTreatment;
|
|
use Database\Factories\OrderFactory;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
|
|
class Order extends Model implements ProvisioningSubject
|
|
{
|
|
/** @use HasFactory<OrderFactory> */
|
|
use HasFactory, HasUuid;
|
|
|
|
protected $fillable = [
|
|
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
|
|
'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'status',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return ['amount_cents' => 'integer', 'plan_version_id' => 'integer'];
|
|
}
|
|
|
|
/**
|
|
* What this order actually buys, in words.
|
|
*
|
|
* Lives on the model rather than in the view: the cart, the invoice list and
|
|
* any future confirmation mail must not each invent their own wording for
|
|
* the same row.
|
|
*/
|
|
public function label(): string
|
|
{
|
|
return match ($this->type) {
|
|
'upgrade' => __('billing.cart.upgrade', ['plan' => ucfirst($this->plan)]),
|
|
'storage' => __('billing.cart.storage', ['gb' => (int) config('provisioning.storage_addon.gb', 100)]),
|
|
'traffic' => __('billing.cart.traffic', ['gb' => (int) config('provisioning.traffic.addon.gb', 1000)]),
|
|
'addon' => __('billing.addon.'.$this->addon_key.'.name'),
|
|
default => __('billing.cart.plan', ['plan' => ucfirst($this->plan)]),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Monthly or one-off.
|
|
*
|
|
* Traffic is bought for the month that is running out and does not renew;
|
|
* everything else changes the recurring bill.
|
|
*/
|
|
public function isRecurring(): bool
|
|
{
|
|
return $this->type !== 'traffic';
|
|
}
|
|
|
|
/**
|
|
* Net is what is stored; gross is what gets charged — and how much VAT that
|
|
* is depends on the customer, not on a global setting.
|
|
*/
|
|
public function grossCents(): int
|
|
{
|
|
return $this->taxTreatment()->grossCents($this->amount_cents);
|
|
}
|
|
|
|
public function taxTreatment(): TaxTreatment
|
|
{
|
|
return TaxTreatment::for($this->customer);
|
|
}
|
|
|
|
/** Only a pending order is still the customer's to change. */
|
|
public function isRemovable(): bool
|
|
{
|
|
return $this->status === 'pending';
|
|
}
|
|
|
|
public function customer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Customer::class);
|
|
}
|
|
|
|
public function instance(): HasOne
|
|
{
|
|
return $this->hasOne(Instance::class);
|
|
}
|
|
|
|
/** The contract this order opened, if it was the purchase that opened one. */
|
|
public function subscription(): HasOne
|
|
{
|
|
return $this->hasOne(Subscription::class);
|
|
}
|
|
|
|
/** The module this order booked, if it was an add-on order. */
|
|
public function subscriptionAddon(): HasOne
|
|
{
|
|
return $this->hasOne(SubscriptionAddon::class);
|
|
}
|
|
|
|
/**
|
|
* A grant that costs nothing — the one case IssueInvoice must skip.
|
|
*
|
|
* Keyed on the linked subscription/add-on's OWN provenance, not merely on
|
|
* `amount_cents === 0`: a fully discounted Stripe checkout also charges
|
|
* zero and that invoice is still owed (OpenSubscription's docblock says
|
|
* the same about the register). Only a grant with nothing charged, and
|
|
* chosen as a full gift rather than a discount, produces no invoice.
|
|
*/
|
|
public function isFreeGrant(): bool
|
|
{
|
|
if ((int) $this->amount_cents !== 0) {
|
|
return false;
|
|
}
|
|
|
|
return $this->subscription?->isFreeGrant()
|
|
?? $this->subscriptionAddon?->isGranted()
|
|
?? false;
|
|
}
|
|
|
|
public function runs(): MorphMany
|
|
{
|
|
return $this->morphMany(ProvisioningRun::class, 'subject');
|
|
}
|
|
|
|
/**
|
|
* Runner hook: a failed provisioning run marks the order failed (no
|
|
* auto-refund) AND releases the reserved instance, so its quota stops
|
|
* counting against host capacity.
|
|
*/
|
|
public function onProvisioningFailed(): void
|
|
{
|
|
$this->update(['status' => 'failed']);
|
|
$this->instance()->update(['status' => 'failed']);
|
|
}
|
|
|
|
public function provisioningPipeline(): string
|
|
{
|
|
return 'customer';
|
|
}
|
|
}
|