188 lines
8.0 KiB
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));
|
|
}
|
|
}
|