205 lines
8.8 KiB
PHP
205 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionAddon;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* What the modules cost today, and what THIS customer pays for the ones they
|
|
* have already booked.
|
|
*
|
|
* Two different questions, and the whole point of Phase 4 is that they have
|
|
* different answers. A booked module is a price the customer agreed to; an
|
|
* unbooked one is a sale we have not made yet, at whatever it costs now.
|
|
*
|
|
* Storage and the recurring modules live in config/provisioning.php. Unlike the
|
|
* plans, they were never versioned or scheduled, so moving them into tables
|
|
* would buy nothing — the freezing that matters happens on
|
|
* `subscription_addons`, which is where a customer's own price lives.
|
|
*
|
|
* It is also where the third question about a module is answered: what a SECOND
|
|
* booking of it means. Storage is sold in packs and two of them are twice as
|
|
* much storage; the rest are things a contract either has or has not, and a
|
|
* second one of those is a second charge for the first. That is declared per
|
|
* module beside its price (`sold_as`) and read from here by everything that
|
|
* books, offers or grants one — see soldAs() and duplicateRefusal().
|
|
*/
|
|
final class AddonCatalogue
|
|
{
|
|
/** The extra-storage pack is priced separately from the module list. */
|
|
public const STORAGE = 'storage';
|
|
|
|
/** Sold in packs: booking another one gives the customer more of it. */
|
|
public const QUANTITY = 'quantity';
|
|
|
|
/** Had or not had: booking another one gives the customer nothing. */
|
|
public const ENTITLEMENT = 'entitlement';
|
|
|
|
/** Today's monthly net price for one unit, or null if we do not sell it. */
|
|
public function priceCents(string $key): ?int
|
|
{
|
|
$price = $this->definition($key)['price_cents'] ?? null;
|
|
|
|
return $price === null ? null : (int) $price;
|
|
}
|
|
|
|
public function knows(string $key): bool
|
|
{
|
|
return $this->priceCents($key) !== null;
|
|
}
|
|
|
|
/**
|
|
* How this module is sold — see the `sold_as` block in
|
|
* config/provisioning.php for what each answer means commercially.
|
|
*
|
|
* A module that declares nothing counts as an entitlement, which is the
|
|
* cheaper of the two mistakes: a lost sale can be made again tomorrow, a
|
|
* customer charged twice for one thing has to be found and refunded. The
|
|
* declaration is not optional all the same — AddonEntitlementTest fails on
|
|
* a module that ships without one, so the default is a safety net rather
|
|
* than a way of leaving the decision unmade.
|
|
*/
|
|
public function soldAs(string $key): string
|
|
{
|
|
return ($this->definition($key)['sold_as'] ?? null) === self::QUANTITY
|
|
? self::QUANTITY
|
|
: self::ENTITLEMENT;
|
|
}
|
|
|
|
/** May a contract hold only one of these at a time? */
|
|
public function isEntitlement(string $key): bool
|
|
{
|
|
return $this->soldAs($key) === self::ENTITLEMENT;
|
|
}
|
|
|
|
/** Is this module booked and still running on this contract? */
|
|
public function hasBooked(?Subscription $subscription, string $key): bool
|
|
{
|
|
return $subscription !== null
|
|
&& $subscription->addons()->active()->where('addon_key', $key)->exists();
|
|
}
|
|
|
|
/**
|
|
* Why this module may not be booked onto this contract AGAIN. Null when it
|
|
* may.
|
|
*
|
|
* The one place the rule is written down, in the sentence the customer is
|
|
* shown: the booking action refuses with it, and the portal asks it before
|
|
* offering the button, so nobody is invited to buy something we would then
|
|
* turn down. A quantity module never refuses — a second storage pack is a
|
|
* second hundred gigabytes, and that is the whole point of selling it in
|
|
* packs.
|
|
*/
|
|
public function duplicateRefusal(?Subscription $subscription, string $key): ?string
|
|
{
|
|
if (! $this->isEntitlement($key) || ! $this->hasBooked($subscription, $key)) {
|
|
return null;
|
|
}
|
|
|
|
return __('billing.addon_already_booked', ['module' => $this->name($key)]);
|
|
}
|
|
|
|
/** The module's name as the customer reads it, never its key. */
|
|
public function name(string $key): string
|
|
{
|
|
return $key === self::STORAGE
|
|
? __('billing.storage_title')
|
|
: __('billing.addon.'.$key.'.name');
|
|
}
|
|
|
|
/**
|
|
* One module's entry, whichever side of the catalogue it lives on.
|
|
*
|
|
* Storage is priced apart from the module list — it is the pack everything
|
|
* else is not — but every question asked of a module has to be answerable
|
|
* about it too, so the two shapes are resolved here once instead of at each
|
|
* caller.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function definition(string $key): array
|
|
{
|
|
if ($key === self::STORAGE) {
|
|
return (array) config('provisioning.storage_addon');
|
|
}
|
|
|
|
$addon = (array) config("provisioning.addons.{$key}");
|
|
|
|
return $addon === [] ? [] : $addon + ['price_cents' => 0];
|
|
}
|
|
|
|
/**
|
|
* Every module we sell, each answered for this customer: booked ones at
|
|
* the price they were booked at, the rest at today's.
|
|
*
|
|
* A module can be booked more than once — storage is sold in packs, and two
|
|
* orders can each carry one — and each booking keeps its own price. So the
|
|
* bookings are summed rather than reduced to one row: showing a single
|
|
* arbitrary price while charging for all of them would let the page and the
|
|
* bill say different things.
|
|
*
|
|
* @return array<string, array{key: string, price_cents: ?int, monthly_cents: int, booked: bool, entitlement: bool, quantity: int, cancelling: bool, cancels_at: ?Carbon, bookings: array<int, array{uuid: string, price_cents: int, quantity: int, cancels_at: ?Carbon}>}>
|
|
*/
|
|
public function forSubscription(?Subscription $subscription): array
|
|
{
|
|
$booked = $subscription?->addons()->active()->orderBy('id')->get()->groupBy('addon_key')
|
|
?? collect();
|
|
|
|
$keys = array_merge(array_keys((array) config('provisioning.addons')), [self::STORAGE]);
|
|
|
|
$rows = [];
|
|
|
|
foreach ($keys as $key) {
|
|
$own = $booked->get($key) ?? collect();
|
|
$prices = $own->pluck('price_cents')->unique();
|
|
|
|
$rows[$key] = [
|
|
'key' => $key,
|
|
// Their price if they have exactly one, today's if they have
|
|
// none — and null when their bookings disagree, because there
|
|
// is no single honest figure to print.
|
|
'price_cents' => match (true) {
|
|
$own->isEmpty() => $this->priceCents($key),
|
|
$prices->count() === 1 => (int) $prices->first(),
|
|
default => null,
|
|
},
|
|
'monthly_cents' => (int) $own->sum(fn ($addon) => $addon->monthlyCents()),
|
|
'booked' => $own->isNotEmpty(),
|
|
// Whether booking it again would be a second helping or a second
|
|
// charge for the first one. The card reads this to decide
|
|
// whether it may still offer the module, so the page and
|
|
// BookAddon answer from the same declaration.
|
|
'entitlement' => $this->isEntitlement($key),
|
|
// A granted module shows without a price too, the same rule as
|
|
// a granted plan — "ein Plugin schenken" should not read as
|
|
// "kostenlos" on the very page that sells it to everyone else.
|
|
'granted' => $own->isNotEmpty() && $own->every(fn ($addon) => $addon->isGranted()),
|
|
'quantity' => (int) $own->sum('quantity'),
|
|
// A cancellation that has been booked and not yet kept. The
|
|
// module is still running and still on the bill, which is why
|
|
// `booked` above stays true — what changes is that the card has
|
|
// to say until when, and offer the way back rather than the way
|
|
// out. Only when EVERY running booking is on its way out: a
|
|
// customer holding two packs who has cancelled one has not
|
|
// cancelled the module.
|
|
'cancelling' => $own->isNotEmpty() && $own->every(
|
|
fn (SubscriptionAddon $addon) => $addon->endsAtPeriodEnd()
|
|
),
|
|
// The earliest of them, because that is the first day something
|
|
// the customer can see actually changes.
|
|
'cancels_at' => $own->pluck('cancels_at')->filter()->min(),
|
|
'bookings' => $own->map(fn ($addon) => [
|
|
'uuid' => $addon->uuid,
|
|
'price_cents' => $addon->price_cents,
|
|
'quantity' => $addon->quantity,
|
|
'cancels_at' => $addon->cancels_at,
|
|
])->values()->all(),
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
}
|