CluPilotCloud/app/Services/Billing/AddonPrices.php

148 lines
5.7 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\StripeAddonPrice;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;
use Illuminate\Database\UniqueConstraintViolationException;
/**
* The Stripe Price a module is billed on, found or minted.
*
* Modules had no place in Stripe at all, which is why they were charged in the
* month they were booked and never again: nothing ever became an item on the
* subscription. This is the missing half of the catalogue mirror — the module
* side of what stripe:sync-catalogue does for packages.
*
* **Why a module needs more than one Price.** A booking is frozen at the price
* it was booked at, and a Stripe Price is immutable. So a customer who took
* storage at ten euros keeps billing against the ten-euro Price while everyone
* who books it tomorrow bills against the twelve-euro one, and both stay live.
* The interval belongs to the Price as well, and every item on one subscription
* has to share it — so a module on a yearly contract is a yearly Price of twelve
* times the monthly figure, which is exactly what a yearly customer pays for it.
*
* **Minting on demand, not only from the sweep.** The command pre-creates
* today's prices so the ordinary booking finds one waiting. It cannot cover a
* discounted grant, or a customer grandfathered on a figure the catalogue left
* behind years ago — and refusing to bill those would be the same bug in a new
* place. Stripe's idempotency key makes the on-demand path safe to repeat.
*/
final class AddonPrices
{
public function __construct(private readonly StripeClient $stripe) {}
/**
* The Stripe Price for this module at this money on this term, creating it
* if Stripe has never been told about it.
*
* Null when there is nothing to bill: a module given away costs nothing, and
* a zero-amount item on a subscription is a line on every invoice that says
* the customer owes nothing for it.
*/
public function ensure(string $addonKey, int $unitNetCents, string $currency, string $term): ?string
{
if ($unitNetCents <= 0) {
return null;
}
$interval = $term === Subscription::TERM_YEARLY ? 'year' : 'month';
$currency = strtoupper($currency);
// A module is priced per month; a yearly contract bills a year of it at
// once, in step with the package beside it. Twelve months exactly, so
// the figure the customer was shown times twelve is the figure charged.
$amount = $interval === 'year' ? $unitNetCents * 12 : $unitNetCents;
$existing = $this->find($addonKey, $amount, $currency, $interval);
if ($existing !== null) {
return $existing;
}
$productId = $this->product($addonKey);
$priceId = $this->stripe->createPrice(
productId: $productId,
amountCents: $amount,
currency: $currency,
interval: $interval,
metadata: [
// Read back when a Stripe invoice line has to be turned into
// wording a customer can read — see StripeInvoiceLines.
'addon' => $addonKey,
],
// Keyed on what the Price IS, so a crash between Stripe creating it
// and us storing its id gives back the same Price on the next
// attempt rather than a second one at the same money.
idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}",
);
$this->remember($addonKey, $amount, $currency, $interval, $productId, $priceId);
return $priceId;
}
/** The Price we already have for this combination, or null. */
public function find(string $addonKey, int $amountCents, string $currency, string $interval): ?string
{
$id = StripeAddonPrice::query()
->where('addon_key', $addonKey)
->where('amount_cents', $amountCents)
->where('currency', strtoupper($currency))
->where('interval', $interval)
->value('stripe_price_id');
return is_string($id) && $id !== '' ? $id : null;
}
/**
* The Stripe Product every Price for this module hangs off.
*
* One per module, however many Prices it accumulates — that is what a
* Product is for, and it is what makes the module readable in Stripe's own
* dashboard rather than a list of anonymous amounts.
*/
private function product(string $addonKey): string
{
$existing = StripeAddonPrice::query()
->where('addon_key', $addonKey)
->value('stripe_product_id');
if (is_string($existing) && $existing !== '') {
return $existing;
}
return $this->stripe->createProduct(
app(AddonCatalogue::class)->name($addonKey),
['addon' => $addonKey],
idempotencyKey: "clupilot-addon-product-{$addonKey}",
);
}
private function remember(
string $addonKey,
int $amountCents,
string $currency,
string $interval,
string $productId,
string $priceId,
): void {
try {
StripeAddonPrice::create([
'addon_key' => $addonKey,
'amount_cents' => $amountCents,
'currency' => $currency,
'interval' => $interval,
'stripe_product_id' => $productId,
'stripe_price_id' => $priceId,
]);
} catch (UniqueConstraintViolationException) {
// Two bookings of the same module landed together. Stripe replayed
// one Price for both — the idempotency key saw to that — so there is
// nothing to correct here beyond letting the first row stand.
}
}
}