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)); } }