148 lines
5.7 KiB
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.
|
|
}
|
|
}
|
|
}
|