where('addon_key', $addonKey) ->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. $existing->update(['archived_at' => null, 'net_cents' => $netCents]); $this->archiveSuperseded($addonKey, $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, ], // 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. idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}", ); $this->remember($addonKey, $amount, $netCents, $currency, $interval, $productId, $priceId); $this->archiveSuperseded($addonKey, $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. */ public function liveFor(string $addonKey, int $unitNetCents, string $currency, string $term): ?string { if ($unitNetCents <= 0) { return null; } return $this->find( $addonKey, self::chargedCents($unitNetCents, $term), $currency, $term === Subscription::TERM_YEARLY ? 'year' : 'month', ); } /** The live Price we already have for this charged figure, or null. */ public function find(string $addonKey, int $amountCents, string $currency, string $interval): ?string { $id = StripeAddonPrice::query() ->where('addon_key', $addonKey) ->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 — the net one this whole * change replaces. * * 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, int $netCents, string $currency, string $interval, int $keepAmountCents, ): void { $superseded = StripeAddonPrice::query() ->where('addon_key', $addonKey) ->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, int $amountCents, int $netCents, string $currency, string $interval, string $productId, string $priceId, ): void { try { StripeAddonPrice::create([ 'addon_key' => $addonKey, '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. } } }