*/ 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'; } }