304 lines
12 KiB
PHP
304 lines
12 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 depends on who is billed.** `amount_cents` on the row
|
|
* is what Stripe takes: the catalogue's net figure with the domestic rate on it
|
|
* for everybody who is charged VAT, because the figure on the price sheet is the
|
|
* figure charged — and the bare net for a business in another member state whose
|
|
* VAT id is verified, because reverse charge means no VAT is owed to us at all
|
|
* and the net is the whole of it. So a module has TWO live Prices per interval,
|
|
* not one, and `reverse_charge` on the row says which is which.
|
|
*
|
|
* `net_cents` beside it is the catalogue figure the Price 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 from a customer
|
|
* treated so.
|
|
*
|
|
* The treatment is a parameter rather than the domestic one assumed, because
|
|
* a reverse-charge business owes the bare net and there is a Price of their
|
|
* own for it. Grossed ONCE, after the term is formed, for the reason above.
|
|
*/
|
|
public static function chargedCents(int $monthlyNetCents, string $term, TaxTreatment $treatment): int
|
|
{
|
|
return $treatment->chargeCents(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,
|
|
TaxTreatment $treatment,
|
|
): ?string {
|
|
if ($unitNetCents <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$interval = $term === Subscription::TERM_YEARLY ? 'year' : 'month';
|
|
$currency = strtoupper($currency);
|
|
$reverseCharge = $treatment->reverseCharge;
|
|
|
|
$netCents = self::termNetCents($unitNetCents, $term);
|
|
$amount = $treatment->chargeCents($netCents);
|
|
|
|
$existing = StripeAddonPrice::query()
|
|
->where('addon_key', $addonKey)
|
|
->where('reverse_charge', $reverseCharge)
|
|
->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. Brought back at Stripe
|
|
// too where it had been archived there: a Price that is only live in
|
|
// our own table is one a checkout is refused for.
|
|
if ($existing->archived_at !== null) {
|
|
$this->stripe->activatePrice((string) $existing->stripe_price_id);
|
|
}
|
|
|
|
$existing->update(['archived_at' => null, 'net_cents' => $netCents]);
|
|
$this->archiveSuperseded($addonKey, $reverseCharge, $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,
|
|
// Which of the module's two Prices this is, for anyone reading
|
|
// Stripe's own dashboard, where they would otherwise differ only
|
|
// by an amount.
|
|
'tax_treatment' => $reverseCharge ? 'reverse_charge' : 'domestic',
|
|
],
|
|
// 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 — and the treatment is in
|
|
// there because at a rate of nought the two Prices are the same
|
|
// amount: one key would have Stripe hand the same object back for
|
|
// both, and the two rows would then share a Price, so archiving the
|
|
// gross one at the next rate change would withdraw the very Price the
|
|
// net side is still selling.
|
|
idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}"
|
|
.($reverseCharge ? '-rc' : ''),
|
|
);
|
|
|
|
$this->remember($addonKey, $reverseCharge, $amount, $netCents, $currency, $interval, $productId, $priceId);
|
|
$this->archiveSuperseded($addonKey, $reverseCharge, $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 — and from the treatment, because a reverse-charge
|
|
* business is billed on a Price of their own at the bare net.
|
|
*/
|
|
public function liveFor(
|
|
string $addonKey,
|
|
int $unitNetCents,
|
|
string $currency,
|
|
string $term,
|
|
TaxTreatment $treatment,
|
|
): ?string {
|
|
if ($unitNetCents <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return $this->find(
|
|
$addonKey,
|
|
self::chargedCents($unitNetCents, $term, $treatment),
|
|
$currency,
|
|
$term === Subscription::TERM_YEARLY ? 'year' : 'month',
|
|
$treatment->reverseCharge,
|
|
);
|
|
}
|
|
|
|
/** The live Price we already have for this charged figure, or null. */
|
|
public function find(
|
|
string $addonKey,
|
|
int $amountCents,
|
|
string $currency,
|
|
string $interval,
|
|
bool $reverseCharge,
|
|
): ?string {
|
|
$id = StripeAddonPrice::query()
|
|
->where('addon_key', $addonKey)
|
|
->where('reverse_charge', $reverseCharge)
|
|
->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.
|
|
*
|
|
* And only ones for the same TREATMENT. A change of rate moves the gross
|
|
* Price and leaves the net one exactly where it was, so a sweep that did not
|
|
* know the difference would archive the half it had not just replaced — and
|
|
* every reverse-charge business would find their module Price withdrawn the
|
|
* next time the VAT rate moved.
|
|
*
|
|
* 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,
|
|
bool $reverseCharge,
|
|
int $netCents,
|
|
string $currency,
|
|
string $interval,
|
|
int $keepAmountCents,
|
|
): void {
|
|
$superseded = StripeAddonPrice::query()
|
|
->where('addon_key', $addonKey)
|
|
->where('reverse_charge', $reverseCharge)
|
|
->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,
|
|
bool $reverseCharge,
|
|
int $amountCents,
|
|
int $netCents,
|
|
string $currency,
|
|
string $interval,
|
|
string $productId,
|
|
string $priceId,
|
|
): void {
|
|
try {
|
|
StripeAddonPrice::create([
|
|
'addon_key' => $addonKey,
|
|
'reverse_charge' => $reverseCharge,
|
|
'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.
|
|
}
|
|
}
|
|
}
|