CluPilotCloud/app/Services/Billing/PlanPrices.php

260 lines
11 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\PlanPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;
/**
* The Stripe Price a package is sold on — and there are two of them.
*
* A priced catalogue row is mirrored into Stripe twice: once at the DOMESTIC
* GROSS, which is what the website quotes and what everybody who is charged VAT
* pays, and once at the BARE NET, which is the whole of what a reverse-charge
* business in another member state owes. Both are live at the same time, both
* hang off the same Product, and which one a checkout uses is TaxTreatment's
* decision and nobody else's.
*
* Before this, one Price served everybody. A verified EU business was charged
* 214,80 € for a 179,00 € package and their document then stated the whole
* 214,80 € as net at 0 % — a flat 20 % surcharge with no VAT line anywhere for
* them to reclaim, on exactly the customers who read their invoices.
*
* **Where each of the two is written down.** `plan_prices.stripe_price_id` keeps
* the meaning it has always had — the Price an ordinary domestic customer is sold
* on — because the checkout, the plan swap and every test read it there and the
* ordinary sale must have one source. The net Price gets no column of its own:
* nothing but a reverse-charge business is ever sold on it, so it is read out of
* `stripe_plan_prices`, where `reverse_charge` says which of the two a row is.
* That register is also what answers "which of the two is this contract billed
* on?" — join it on `subscriptions.stripe_price_id`.
*
* **Minting is the sync's job and nothing else's.** A Stripe Price is immutable,
* so a changed figure means a new Price with the old one archived, and doing that
* from a web request or from a sweep over live contracts would put the catalogue
* in two hands. Everything outside stripe:sync-catalogue reads; a Price that is
* missing is reported as a catalogue that has not been synced, which is a
* sentence an operator can act on.
*/
final class PlanPrices
{
public function __construct(private readonly StripeClient $stripe) {}
/** What Stripe is asked to take for this row from a customer treated so. */
public static function chargedCents(PlanPrice $price, TaxTreatment $treatment): int
{
return $treatment->chargeCents((int) $price->amount_cents);
}
/**
* The Price a customer with this treatment is sold on, or null where the
* catalogue has never been mirrored.
*
* The domestic answer falls back to the catalogue row's own column, which is
* where it lived before this register existed and where an install that has
* not run the sync since still has it. There is deliberately NO such fallback
* for a reverse-charge business: handing them the domestic Price is the
* overcharge this whole file exists to end, and refusing the sale until the
* catalogue is synced is the one outcome that cannot take money nobody owes.
*/
public function liveFor(PlanPrice $price, TaxTreatment $treatment): ?string
{
$registered = $this->registered($price, $treatment);
if ($registered !== null) {
return (string) $registered->stripe_price_id;
}
if ($treatment->reverseCharge) {
return null;
}
return blank($price->stripe_price_id) ? null : (string) $price->stripe_price_id;
}
/**
* Is Stripe selling this row at the right figure for this treatment?
*
* Asked of the CHARGED figure and of the pointer together, which is what
* makes a second sync run free and a run that died halfway recoverable: a
* rate change leaves the register live at yesterday's amount, and a crash
* between Stripe minting the Price and us storing its id leaves the register
* right and the catalogue row empty.
*/
public function inStep(PlanPrice $price, TaxTreatment $treatment): bool
{
$registered = $this->registered($price, $treatment);
if ($registered === null) {
return false;
}
// The net Price has no pointer to be out of step with — see the class
// comment on where each of the two is written down.
return $treatment->reverseCharge
|| $price->stripe_price_id === $registered->stripe_price_id;
}
/**
* The Price this row is sold on for this treatment, created at Stripe if it
* has never been told about it.
*
* Three outcomes, decided by the charged figure and never by whether an id
* happens to be stored:
*
* - the register is live at that figure and the catalogue row points at it:
* nothing to do;
* - a Price for that figure exists but is archived, or is not the one the row
* points at — a rate moved and moved back, or a run died between Stripe
* creating the Price and us storing its id: it is brought back, at Stripe
* as well as here, rather than minted a second time;
* - nothing charges it: a new Price, the pointer moved, and whatever it was
* pointing at archived — after the replacement exists, never before, so a
* failure in between leaves the old Price selling rather than nothing.
*
* Null only where the family has no Product yet, which means the run that
* creates Products has not reached it.
*/
public function ensure(PlanPrice $price, TaxTreatment $treatment): ?string
{
$version = $price->version;
$family = $version?->family;
$productId = $family?->stripe_product_id;
if ($version === null || $family === null || blank($productId)) {
return null;
}
$charged = self::chargedCents($price, $treatment);
$existing = $this->registered($price, $treatment, includeArchived: true);
$priceId = $existing?->stripe_price_id;
if ($priceId === null) {
$priceId = $this->stripe->createPrice(
productId: (string) $productId,
amountCents: $charged,
currency: (string) $price->currency,
interval: $price->term === Subscription::TERM_YEARLY ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
// Read back by anything that has a Price id and needs to know
// what kind of sale it was — and by a person looking at
// Stripe's own dashboard, where two Prices on one Product
// would otherwise differ only by an amount.
'tax_treatment' => $treatment->reverseCharge ? 'reverse_charge' : 'domestic',
],
// The CHARGED amount is part of the key, so a run after a rate
// change cannot replay the Price minted at the old figure. So is
// the treatment, and it has to be: at a rate of nought the two
// Prices are the same amount, and one key would have Stripe hand
// back the same object for both — which the register, where a
// Price id is unique, would refuse to record twice.
idempotencyKey: "clupilot-price-{$price->id}-{$charged}"
.($treatment->reverseCharge ? '-rc' : ''),
);
} elseif ($existing?->archived_at !== null) {
// Unarchived at Stripe too. Bringing it back only in our own table
// would leave the catalogue pointing at a Price Stripe refuses to
// open a checkout for, which is the failure a customer meets and
// nobody else does.
$this->stripe->activatePrice($priceId);
}
StripePlanPrice::query()->updateOrCreate(
[
'plan_price_id' => $price->id,
'reverse_charge' => $treatment->reverseCharge,
'charged_cents' => $charged,
],
['stripe_price_id' => $priceId, 'archived_at' => null],
);
$this->archiveSuperseded($price, $treatment, $charged);
// The pointer is the domestic Price and only ever that. Written straight
// through the query builder: stripe_price_id is not part of what
// publication froze, and the model would otherwise have to be re-read.
if (! $treatment->reverseCharge) {
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
}
return $priceId;
}
/** The priced catalogue row a contract is billed through, or null. */
public function rowFor(Subscription $subscription): ?PlanPrice
{
return PlanPrice::query()
->where('plan_version_id', $subscription->plan_version_id)
->where('term', $subscription->term)
->first();
}
/**
* The Price this contract should be billed on, given who is paying for it.
*
* By plan VERSION and term, because that pair is what a Stripe Price is —
* resolving it by plan name would hand a grandfathered contract today's
* terms — and then by the customer's own treatment, because a business whose
* registration was verified after they bought belongs on the net Price from
* that moment, and one whose registration has lapsed belongs back on the
* gross one.
*/
public function liveForSubscription(Subscription $subscription): ?string
{
$row = $this->rowFor($subscription);
return $row === null
? null
: $this->liveFor($row, TaxTreatment::for($subscription->customer));
}
/** The register's row for this figure, live unless asked otherwise. */
private function registered(
PlanPrice $price,
TaxTreatment $treatment,
bool $includeArchived = false,
): ?StripePlanPrice {
return StripePlanPrice::query()
->where('plan_price_id', $price->id)
->where('reverse_charge', $treatment->reverseCharge)
->where('charged_cents', self::chargedCents($price, $treatment))
->when(! $includeArchived, fn ($query) => $query->whereNull('archived_at'))
->first();
}
/**
* Stop offering every other Price this row was sold on to customers treated
* the same way.
*
* Within ONE treatment, which is the whole reason `reverse_charge` is part of
* the register's key: a change of rate moves the gross Price and leaves the
* net one exactly where it was, and a sweep that did not know the difference
* would archive the half it had not just replaced.
*
* 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(PlanPrice $price, TaxTreatment $treatment, int $keepChargedCents): void
{
$superseded = StripePlanPrice::query()
->where('plan_price_id', $price->id)
->where('reverse_charge', $treatment->reverseCharge)
->where('charged_cents', '!=', $keepChargedCents)
->whereNull('archived_at')
->get();
foreach ($superseded as $old) {
$this->stripe->archivePrice((string) $old->stripe_price_id);
$old->update(['archived_at' => now()]);
}
}
}