CluPilotCloud/app/Models/Order.php

186 lines
6.6 KiB
PHP

<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Observers\OrderObserver;
use App\Provisioning\Contracts\ProvisioningSubject;
use App\Services\Billing\TaxTreatment;
use Database\Factories\OrderFactory;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
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;
#[ObservedBy(OrderObserver::class)]
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', 'stripe_payment_intent_id',
'stripe_invoice_id', 'status', 'immediate_start_consent_at',
];
protected function casts(): array
{
return [
'amount_cents' => 'integer',
'plan_version_id' => 'integer',
// When the consumer expressly asked for the service to begin inside
// the withdrawal window.
//
// DEAD as a gate, kept as a record. It used to decide whether a
// withdrawing consumer owed the pro-rata value of the days already
// delivered; the owner has decided they owe nothing and get the whole
// amount back, so nothing reads this to make a decision any more. It
// stays because it is evidence of what the customer was shown and
// agreed to at the checkout.
'immediate_start_consent_at' => 'datetime',
];
}
/**
* 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.
*
* Only ever asked of a PENDING cart line, whose `amount_cents` is a net
* catalogue figure. An order that has been through a Stripe checkout already
* holds what was taken; see chargedCents() below, which is what the invoice
* uses and which does not double the tax on such an order.
*/
public function grossCents(): int
{
return $this->taxTreatment()->grossCents($this->amount_cents);
}
/**
* What this order actually cost the customer, tax included.
*
* `amount_cents` means two things, and this is the one place that resolves
* which. An order created from a Stripe checkout carries the session's
* `amount_total` — the gross that was taken from the card, and the catalogue
* is pushed to Stripe gross, so it already includes the VAT. An order that
* never went through a checkout (a grant, a line in the portal cart) carries
* the catalogue's NET figure, and what a checkout would have taken for it is
* that figure at the till.
*
* Keyed on `stripe_event_id` for exactly the reason OpenSubscription keys the
* register's charged amount on it, and not on the amount being non-zero: a
* fully discounted checkout charges nothing and is still a checkout.
*/
public function chargedCents(): int
{
return $this->stripe_event_id !== null
? (int) $this->amount_cents
: TaxTreatment::chargedCents((int) $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';
}
}