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); $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', ]; // Asked BEFORE minting, because a run that died between Stripe's create // and our insert left a Price no table of ours knows — and the key below // stops protecting it after twenty-four hours. See AdoptStripePrice for // what may be taken over and why so narrowly. $priceId = ($this->adopt)( productId: $productId, amountCents: $amount, currency: $currency, interval: $interval, metadata: $metadata, identifying: ['addon'], claimed: fn (string $id) => StripeAddonPrice::query() ->where('stripe_price_id', $id) ->exists(), ); $priceId ??= $this->stripe->createPrice( productId: $productId, amountCents: $amount, currency: $currency, interval: $interval, metadata: $metadata, // Says "I have already sent this call", nothing more. The amount is // the CHARGED one and the treatment is in there because at a rate of // nought the two Prices are the same amount — but what stops a // second Price for one figure is AdoptStripePrice above, not this: // Stripe forgets a key after twenty-four hours. The metadata is // folded in by IdempotencyKey inside the client, so changing the // format above can never again refuse the call for a day. 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; } $metadata = ['addon' => $addonKey]; // Asked before minting. A run that created this Product and died before // any row carried its id leaves an orphan — and a second Product would // make activePricesFor() ask about the wrong one, blinding the Price // recognition for this module entirely. $adopted = app(AdoptStripeProduct::class)($metadata, ['addon']); return $adopted ?? $this->stripe->createProduct( app(AddonCatalogue::class)->name($addonKey), $metadata, // A retry's guard for twenty-four hours and nothing beyond them; // what stops a second Product is the adoption step above. 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. // // Since the unique index on `stripe_price_id` below, this catch has a // SECOND meaning: another TUPLE claims this Price id, which is two // callers adopting the same orphan at once. The outcome then is no row // for this tuple at all, not "the first row stands" — the row that // exists belongs to the other tuple. That self-heals on the next // ensure(): the orphan is claimed, so adoption refuses it and // createPrice() mints this tuple its own Price. No money moves in the // meantime, because both callers matched the same `unit_amount` // before adopting; what a caller gets back here is the Price id it // asked for, and it charges what was asked. // // What keeps two ROWS off one Price id is the `claimed` callback in // ensure(), asked before minting. The unique index on // `stripe_price_id` (`stripe_addon_prices_price_unique`, since // 2026_07_31_210000_one_row_per_stripe_price, matching // `stripe_plan_prices.stripe_price_id` on the plan side) is the net // UNDER that callback, not a substitute for it — two rows sharing one // Price would be worse than the ordinary duplicate this catch // handles: archiving one — the ordinary response to a superseded // figure — would then withdraw the very Price the other row is still // selling. } } }