289 lines
13 KiB
PHP
289 lines
13 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,
|
|
private readonly AdoptStripePrice $adopt,
|
|
) {}
|
|
|
|
/** 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;
|
|
$interval = $price->term === Subscription::TERM_YEARLY ? 'year' : 'month';
|
|
|
|
if ($priceId === null) {
|
|
$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',
|
|
];
|
|
|
|
// Asked BEFORE minting: a run that died between Stripe's create and
|
|
// our insert left a Price the register does not know, and the key
|
|
// below stops protecting it after twenty-four hours. `plan_price_id`
|
|
// is what proves such a Price is this row's — one Product carries
|
|
// every version and term of a family, so the amount alone would not.
|
|
$priceId = ($this->adopt)(
|
|
productId: (string) $productId,
|
|
amountCents: $charged,
|
|
currency: (string) $price->currency,
|
|
interval: $interval,
|
|
metadata: $metadata,
|
|
identifying: ['plan_price_id'],
|
|
claimed: fn (string $id) => StripePlanPrice::query()
|
|
->where('stripe_price_id', $id)
|
|
->exists(),
|
|
);
|
|
|
|
$priceId ??= $this->stripe->createPrice(
|
|
productId: (string) $productId,
|
|
amountCents: $charged,
|
|
currency: (string) $price->currency,
|
|
interval: $interval,
|
|
metadata: $metadata,
|
|
// Says "I have already sent this call", nothing more. 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. The metadata is folded in by IdempotencyKey
|
|
// inside the client, so changing the format above can never
|
|
// again refuse the call for a day. What stops a second Price
|
|
// for one figure is the adoption step above, not this key —
|
|
// Stripe forgets a key after twenty-four hours.
|
|
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()]);
|
|
}
|
|
}
|
|
}
|