CluPilotCloud/app/Services/Billing/WithdrawalRight.php

188 lines
8.0 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
/**
* The fourteen-day right of withdrawal, and what it costs when it is used.
*
* EU distance selling. In Austria the FAGG, in Germany §312g BGB, and the same
* substance in every member state: a CONSUMER who concludes a contract at a
* distance may withdraw from it within fourteen days without giving a reason,
* and gets their money back. A business customer has no such right at all —
* which is why nothing here will answer a question about a contract whose
* customer is a business, and why `customers.customer_type` had to exist before
* any of this could be written.
*
* ## Why it is not simply "give it all back"
*
* A cloud is running from the moment it is paid for. FAGG §16 is the rule for
* exactly that case: where the consumer EXPRESSLY asked for the service to
* begin during the withdrawal period, and was told they would owe a
* proportionate amount if they withdrew, they owe the pro-rata value of what
* was actually performed up to the moment of withdrawal. Not the whole period —
* they withdrew — and not nothing either, because the machine really did run.
*
* Both halves of that condition are required, and both are on record: the
* consent is a timestamp taken at the checkout, and its absence means the
* consumer owes NOTHING and the refund is the whole amount. That is the
* lawful reading and it is also the safe one: a seller who cannot prove the
* consent was given cannot charge for the days.
*
* ## How the share is measured
*
* By whole days, over the term the customer actually paid for, and a started day
* counts as delivered. Days rather than seconds because the figure ends up on a
* document a person reads and has to be able to check with a calendar; a started
* day counts because the service was genuinely available on it. The share is
* taken of the NET price, and the VAT is put back on top by the document — never
* the other way round, for the reason InvoiceMath sets out.
*/
final readonly class WithdrawalRight
{
/** Fourteen days, from the conclusion of the contract. The law's number. */
public const WINDOW_DAYS = 14;
private function __construct(
/** Does this contract have a withdrawal right at all — is the customer a consumer? */
public bool $applies,
/** When the window opened: the moment the contract was concluded. */
public ?Carbon $opensAt,
/** When it closes. Stamped on the contract, not recomputed here. */
public ?Carbon $endsAt,
/** May it be exercised right now? */
public bool $open,
/** Why not, in the sentence the customer is shown. Null while it is open. */
public ?string $refusal,
/** Did the consumer expressly ask for the service to start at once? */
public bool $immediateStartConsented,
) {}
public static function for(?Subscription $subscription): self
{
if ($subscription === null) {
return new self(false, null, null, false, __('withdrawal.refusal_no_contract'), false);
}
$opensAt = $subscription->withdrawalOpenedAt();
$endsAt = $subscription->withdrawal_ends_at
// A contract opened before the column existed still had a window;
// it is derived here rather than left null so an old contract reads
// as "expired" instead of "no window was ever counted".
?? $opensAt?->copy()->addDays(self::WINDOW_DAYS);
$consented = $subscription->immediate_start_consent_at !== null;
// Asked of the CUSTOMER, never of the VAT number. A customer nobody has
// asked is a consumer here — see Customer::isConsumer() for why the
// unknown case leans this way and not the other.
if ($subscription->customer?->isBusiness() === true) {
return new self(false, $opensAt, $endsAt, false, __('withdrawal.refusal_business'), $consented);
}
if ($subscription->isWithdrawn()) {
return new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_already'), $consented);
}
if ($endsAt === null) {
// A contract with no beginning on record. Refused rather than
// guessed: inventing the moment a statutory deadline started is the
// one thing this class must never do.
return new self(true, $opensAt, null, false, __('withdrawal.refusal_no_contract'), $consented);
}
return $endsAt->isFuture()
? new self(true, $opensAt, $endsAt, true, null, $consented)
: new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_expired'), $consented);
}
/**
* How many whole days of the window are left, for the sentence that says so.
*
* Rounded UP, because a customer told "1 Tag" on the last afternoon still
* has that afternoon, and rounding down would tell them the window had shut
* while it was open.
*/
public function daysLeft(?Carbon $at = null): int
{
if (! $this->open || $this->endsAt === null) {
return 0;
}
return max(1, (int) ceil(($at ?? now())->diffInSeconds($this->endsAt, absolute: false) / 86400));
}
/**
* The whole days the term the customer paid for is made of.
*
* From the contract's own boundaries, so a month is 28, 30 or 31 days and a
* year is 365 or 366 — a fixed 30 would over- or under-charge every second
* customer by a day's worth of service.
*/
public static function termDays(Subscription $subscription): int
{
$from = $subscription->current_period_start ?? $subscription->started_at;
$to = $subscription->current_period_end;
if ($from === null || $to === null || ! $to->greaterThan($from)) {
// Nothing sensible to divide by. One day means the first day of
// service is the whole term, which charges the consumer the full
// amount — so this branch is only ever reached by a contract that is
// already broken, and it fails towards "we keep what we were paid"
// rather than towards a division by zero.
return 1;
}
return max(1, (int) ceil($from->diffInSeconds($to, absolute: true) / 86400));
}
/**
* The days of service actually delivered by the moment of withdrawal.
*
* A started day counts, so this is never zero: a consumer who withdraws
* three hours after buying still had a running cloud for those three hours.
* Capped at the term, because a withdrawal after the term has run out cannot
* owe more than the term was sold for.
*/
public static function deliveredDays(Subscription $subscription, ?Carbon $at = null): int
{
$from = $subscription->current_period_start ?? $subscription->started_at;
$at ??= now();
if ($from === null || ! $at->greaterThan($from)) {
return 1;
}
$days = (int) ceil($from->diffInSeconds($at, absolute: true) / 86400);
return max(1, min($days, self::termDays($subscription)));
}
/**
* What the consumer owes for the service they actually had, net.
*
* Zero without the express request to begin at once: the pro-rata liability
* is the price of that request, and a consumer who never made it — or who
* was never told what it would cost them — owes nothing and gets everything
* back.
*/
public static function owedNetCents(Subscription $subscription, ?Carbon $at = null): int
{
if ($subscription->immediate_start_consent_at === null) {
return 0;
}
// The contract's own frozen NET price for the term. Never
// Order::amount_cents, which is Stripe's GROSS: mixing the two is how a
// pro-rata share ends up with VAT inside it and VAT added on top again.
$termNet = (int) $subscription->price_cents;
if ($termNet <= 0) {
return 0;
}
return (int) round($termNet * self::deliveredDays($subscription, $at) / self::termDays($subscription));
}
}