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 */ 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}> */ 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; } }