CluPilotCloud/app/Services/Billing/AddonPrices.php

247 lines
9.8 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.
*
* **What the Price charges is GROSS.** `amount_cents` on the row is what Stripe
* takes, and it is the catalogue's net figure with the domestic rate on it —
* because the figure on the price sheet is the figure charged, for everybody.
* `net_cents` beside it is the catalogue figure it was formed from, which is what
* a booking is frozen at and what identifies the Price when the rate moves. A
* Price at the wrong figure is archived rather than edited: Stripe does not allow
* editing one, and a contract may still be billing on it.
*/
final class AddonPrices
{
public function __construct(private readonly StripeClient $stripe) {}
/**
* The net a whole term of this module costs.
*
* 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, and
* grossed ONCE afterwards rather than twelve times — twelve rounded gross
* months can differ from a rounded gross year by a cent, and the year is the
* figure the customer actually pays.
*/
public static function termNetCents(int $monthlyNetCents, string $term): int
{
return $term === Subscription::TERM_YEARLY ? $monthlyNetCents * 12 : $monthlyNetCents;
}
/** What Stripe is asked to take for a term of this module. */
public static function chargedCents(int $monthlyNetCents, string $term): int
{
return TaxTreatment::chargedCents(self::termNetCents($monthlyNetCents, $term));
}
/**
* 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);
$netCents = self::termNetCents($unitNetCents, $term);
$amount = TaxTreatment::chargedCents($netCents);
$existing = StripeAddonPrice::query()
->where('addon_key', $addonKey)
->where('amount_cents', $amount)
->where('currency', $currency)
->where('interval', $interval)
->first();
if ($existing !== null) {
// Brought back rather than minted again. A rate that moves and then
// moves back — or a run interrupted between Stripe and us — must not
// leave a second Stripe Price for one figure.
$existing->update(['archived_at' => null, 'net_cents' => $netCents]);
$this->archiveSuperseded($addonKey, $netCents, $currency, $interval, $amount);
return (string) $existing->stripe_price_id;
}
$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. The amount is
// the CHARGED one, so the move from net to gross does not replay the
// net Price the old key was minted under.
idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}",
);
$this->remember($addonKey, $amount, $netCents, $currency, $interval, $productId, $priceId);
$this->archiveSuperseded($addonKey, $netCents, $currency, $interval, $amount);
return $priceId;
}
/**
* The live Price for a module at a catalogue figure, or null.
*
* Asked with the MONTHLY net the booking is frozen at, because that is what
* a caller has: the charged figure is worked out here, from the one place
* that knows the rate.
*/
public function liveFor(string $addonKey, int $unitNetCents, string $currency, string $term): ?string
{
if ($unitNetCents <= 0) {
return null;
}
return $this->find(
$addonKey,
self::chargedCents($unitNetCents, $term),
$currency,
$term === Subscription::TERM_YEARLY ? 'year' : 'month',
);
}
/** The live Price we already have for this charged figure, 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)
->whereNull('archived_at')
->value('stripe_price_id');
return is_string($id) && $id !== '' ? $id : null;
}
/**
* Stop offering every other Price for this module at this catalogue figure.
*
* Only ones formed from the SAME net: a customer grandfathered at ten euros
* keeps their own Price, and archiving that would leave their subscription
* billing on something Stripe no longer sells. What is archived here is the
* Price for today's figure at yesterday's rate — the net one this whole
* change replaces.
*
* Archived in Stripe as well as here. The Price object stays, as Stripe
* requires, because a contract may still be billing on it until
* stripe:reprice-subscriptions has moved it.
*/
private function archiveSuperseded(
string $addonKey,
int $netCents,
string $currency,
string $interval,
int $keepAmountCents,
): void {
$superseded = StripeAddonPrice::query()
->where('addon_key', $addonKey)
->where('net_cents', $netCents)
->where('currency', $currency)
->where('interval', $interval)
->where('amount_cents', '!=', $keepAmountCents)
->whereNull('archived_at')
->get();
foreach ($superseded as $price) {
$this->stripe->archivePrice((string) $price->stripe_price_id);
$price->update(['archived_at' => now()]);
}
}
/**
* 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,
int $netCents,
string $currency,
string $interval,
string $productId,
string $priceId,
): void {
try {
StripeAddonPrice::create([
'addon_key' => $addonKey,
'amount_cents' => $amountCents,
'net_cents' => $netCents,
'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.
}
}
}