157 lines
6.6 KiB
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)));
|
|
}
|
|
}
|