247 lines
9.8 KiB
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.
|
|
}
|
|
}
|
|
}
|