209 lines
7.7 KiB
PHP
209 lines
7.7 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', 'setup_fee_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',
|
|
'setup_fee_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, whatever tax was in it.
|
|
*
|
|
* `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` — what was actually taken from the card, whichever of a
|
|
* package's two Prices this customer was sold on, so it needs nothing added to
|
|
* it. 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 through this customer's own treatment: the
|
|
* domestic gross where VAT is charged, the bare net for a reverse-charge
|
|
* business.
|
|
*
|
|
* 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
|
|
: $this->taxTreatment()->chargeCents((int) $this->amount_cents);
|
|
}
|
|
|
|
/**
|
|
* What was charged for the PACKAGE alone — the whole charge, less the one-off
|
|
* setup fee that rode along on the same checkout.
|
|
*
|
|
* The two have to be separable because they answer to different things. A
|
|
* withdrawal sends back everything that was taken and the confirmation mail
|
|
* states everything that was taken, so both read `amount_cents` whole. The
|
|
* proof register holds the charge against the contract's agreed price, and the
|
|
* invoice prints the fee as a line of its own — neither of which works if the
|
|
* fee is folded into the package's figure.
|
|
*
|
|
* Zero for every order that carries no fee, which is every order placed before
|
|
* the fee was charged at all and every order that is not a first purchase: a
|
|
* setup fee is charged once, when the cloud is set up.
|
|
*/
|
|
public function packageChargedCents(): int
|
|
{
|
|
return $this->chargedCents() - (int) $this->setup_fee_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';
|
|
}
|
|
}
|