CluPilotCloud/app/Services/Billing/WithdrawalRight.php

157 lines
6.6 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.
*
* ## It IS simply "give it all back"
*
* FAGG §16 would let us keep the pro-rata value of the days the cloud actually
* ran, where the consumer expressly asked for the service to begin inside the
* window and was told what that would cost them. The owner has decided not to:
* a consumer who withdraws gets the WHOLE amount back, and a cancellation
* invoice is issued for the whole of it.
*
* That is a business decision and not a legal one, and it is more generous than
* the statute requires — which is why nothing turns on the express-start consent
* any more. It used to be the hinge the refund swung on; a full refund makes it
* irrelevant, and `immediate_start_consent_at` on the order and on the contract
* is now a record of what the customer ticked rather than a gate anything reads.
*
* ## Days are still counted
*
* By whole days, over the term the customer actually paid for, and a started day
* counts as delivered. Nothing is charged for them, but how long the service ran
* before a withdrawal is a fact about the contract, it goes into the proof
* register, and an operator recording a withdrawal by telephone is shown it.
*/
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,
) {}
public static function for(?Subscription $subscription): self
{
if ($subscription === null) {
return new self(false, null, null, false, __('withdrawal.refusal_no_contract'));
}
$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);
// 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'));
}
if ($subscription->isWithdrawn()) {
return new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_already'));
}
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'));
}
return $endsAt->isFuture()
? new self(true, $opensAt, $endsAt, true, null)
: new self(true, $opensAt, $endsAt, false, __('withdrawal.refusal_expired'));
}
/**
* 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. Nothing is charged against it any more — it is what
* `deliveredDays()` is a share OF, in the register and on the operator's
* screen.
*/
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 no more of a term can be delivered than it
* holds.
*/
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)));
}
}