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()]); } } }