option('dry-run'); if (! $dryRun && ! $stripe->isConfigured()) { $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.'); return self::FAILURE; } if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) { $this->error($this->staleCatalogueMessage()); // Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was // FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt // schon eine ID, nur eine aus dem falschen Konto. „0 object(s) // would be created" wäre die beruhigende Antwort auf die // gefährliche Lage. return self::FAILURE; } // VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass // entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört — // der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von // handle() trug er sie NICHT: createProduct() und ensure() werfen // ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt // stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für // ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren, // an dem laufende Verträge abgerechnet werden — statt schlicht // wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind. if (! $dryRun) { if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) { $this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.'); } StripeCatalogueMode::record(); } // Beide Seiten setzen hier an, und beide werden gebraucht. // Die Modus-Sperre oben steht zuerst, weil sie VERWEIGERT: sie darf // nicht hinter etwas laufen, das schon Zustand aufgebaut hat. Die // Zähler darunter müssen nur vor der Anlegeschleife stehen, und das // tun sie hier auch. // Resolved once, as singletons: each counts across the WHOLE run, and a // count taken before ensure()/the adoption call runs can never itself // distinguish "created" from "adopted" — see AdoptStripePrice. $adoptPrices = app(AdoptStripePrice::class); $adoptProducts = app(AdoptStripeProduct::class); $adoptedPricesBefore = $adoptPrices->adoptions; // Duplicates accumulate on the SAME singleton across the whole process, // not only this run — without this, a second handle() in one process // would print a duplicate a previous run already reported. $duplicatesBefore = count($adoptProducts->duplicates); $created = 0; // Counted here, locally, rather than on AdoptStripeProduct itself: the // very same class also adopts a MODULE's Product, from // AddonPrices::product() inside syncModules() below, and a module's // Product is never counted as an intent at all — unlike a family's, // which gets its own "product …" line and $created++ right where this // is incremented. A single counter on the class could not tell the two // apart; counting only at the one call site that also counts the // intent avoids the question entirely. $adoptedFamilyProducts = 0; foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) { // An internal family is never billed through Stripe — handed out by // hand (the internal test package, Enterprise once a machine is // built for it) and never bought. A Stripe Price cannot be deleted, // only archived, so whatever a live run mints here stays in the // operator's account for good — and a package nobody is ever // charged for is not worth that. Skipped here, ahead of the // published-version check below and everything after it, so a // family that already carries Stripe ids from when it WAS sellable // (Enterprise, precisely) is left untouched: nothing is cleared, // nothing is replaced, and nothing new is ever added for it. if ($family->internal) { continue; } $published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished()); // A family whose versions are all drafts has promised nothing, so // it has no business appearing in Stripe's price list yet. if ($published->isEmpty()) { continue; } $productId = $family->stripe_product_id; if ($productId === null) { $this->line(" product {$family->key} — {$family->name}"); $created++; if (! $dryRun) { $metadata = ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id]; // Asked BEFORE minting, for the same reason the Price side // asks: a run that died between Stripe's create and our // update left a Product this row does not know, and the key // below stops protecting it after twenty-four hours. // plan_family_id is what proves such a Product is this // family's. $productId = $adoptProducts($metadata, ['plan_family_id']); if ($productId !== null) { // The intent was counted three lines up, so — unlike a // module's Product — an adoption here may honestly be // subtracted back out of it below. $adoptedFamilyProducts++; } $productId ??= $stripe->createProduct( $family->name, $metadata, // Covers a RETRY, for twenty-four hours, and nothing // beyond them. What stops a second Product for one // family is the adoption step above. idempotencyKey: "clupilot-product-{$family->id}", ); $family->update(['stripe_product_id' => $productId]); } } foreach ($published as $version) { foreach ($version->prices as $price) { $created += $this->syncPrice($family, $version, $price, $productId, $dryRun); } } } $created += $this->syncModules($dryRun); $this->newLine(); // $adopted may only subtract what $created actually counted as an // intent. Every price is one, family or module — syncPrice() and // syncModules() both increment $created before knowing whether ensure() // will mint or adopt, so the full price-adoption delta is honest. A // family's Product is one too, counted above, so $adoptedFamilyProducts // is honest for the same reason. A module's Product is NOT one — it // prints no line of its own and increments nothing in syncModules(), // however AdoptStripeProduct answers it — so its adoption stays out of // both figures entirely. Subtracting it would report fewer objects // created than Stripe actually gained. $adopted = ($adoptPrices->adoptions - $adoptedPricesBefore) + $adoptedFamilyProducts; $minted = max(0, $created - $adopted); // Sliced to what THIS run added — the list lives on the same // process-wide singleton as the count above, so without the same // before/after care a second handle() in one process would print a // duplicate a previous run already reported. foreach (array_slice($adoptProducts->duplicates, $duplicatesBefore) as $duplicate) { // Named here as well as in the log: an operator running the sweep // reads this, and a second Product for one family is something only // a person can resolve — we deliberately do not deactivate it. $this->warn(" duplicate product {$duplicate} — left active, a product's prices become unsellable if it is deactivated"); } if ($created === 0) { $this->info('Stripe is already in step with the catalogue.'); return self::SUCCESS; } // "or adopted" on the dry run, and only there. A dry run counts intents // without ever calling ensure(), so it cannot know which of them Stripe // already holds a Price for — and a dry run is the first thing an // operator runs after an interrupted one, which is exactly when some of // these will be adopted rather than minted. The live line below has // both figures because by then the adoption step has answered. $this->info($dryRun ? "{$created} object(s) would be created or adopted. Run without --dry-run to create them." : "{$minted} object(s) created, {$adopted} adopted in Stripe."); return self::SUCCESS; } /** * Why a run is refused outright rather than left to skip its way through. * * A Stripe Product and a Stripe Price belong to the ACCOUNT that issued * them, and a test account and a live account are two accounts. This * command skips every row that already carries an id (PlanPrices::inStep() * asks the register, not Stripe), so a run after the switch would report * "already in step" and change nothing — while recording that the * catalogue now belongs to the account it never touched. * * The registers have to go too, not only the two columns: inStep() takes * a registered row as proof on its own for the reverse-charge half, so a * cleared pointer with the register left behind would leave exactly that * half pointing at the old account, and nothing would say so. * * Only reached where the OTHER account is established fact. A catalogue * whose account was never recorded is a different state with a different * cure — see StripeCatalogueMode::belongsToAnotherMode(). Telling that * operator to clear anything would be telling them to delete a catalogue * their live contracts bill on. */ private function staleCatalogueMessage(): string { return sprintf( 'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. ' .'Stripe objects belong to the account that issued them, and this command skips every row that ' .'already carries an id — so it cannot repair this by running again. Clear ' .'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / ' .'stripe_addon_prices registers first, then run it. Nothing was created.', StripeCatalogueMode::recorded()?->value, OperatingMode::current()->value, ); } /** * The Stripe Prices one priced catalogue row is sold on, brought into step. * * Both treatments, always: the domestic gross AND the bare net a * reverse-charge business is charged. Unconditionally, rather than only when * such a customer exists — the Price has to be there BEFORE the checkout that * needs it, and a verified business meeting a refused checkout is worse than * an unused Price sitting in Stripe. * * What "in step" means, and the find-or-mint-or-replace that follows from it, * belongs to PlanPrices: it is the same question the checkout and the plan * swap ask, and asking it in two places is how they come to disagree. All * this adds is the reporting, because an operator running a sync has to be * able to see which half of which pair moved. * * Archiving stops a Price being offered and leaves every subscription on it * untouched, which is exactly what is wanted: those are moved deliberately, * by stripe:reprice-subscriptions. * * @return int how many objects this row changed, for the run's own count */ private function syncPrice( PlanFamily $family, PlanVersion $version, PlanPrice $price, ?string $productId, bool $dryRun, ): int { $prices = app(PlanPrices::class); $changed = 0; foreach ($this->treatments() as $label => $treatment) { if ($prices->inStep($price, $treatment)) { continue; } $this->line(sprintf( ' price %s v%d %s %s %d %s net → %d %s charged', $family->key, $version->version, $price->term, $label, $price->amount_cents, $price->currency, PlanPrices::chargedCents($price, $treatment), $price->currency, )); $changed++; // Nothing to create against: the Product for this family is minted // by the caller, and on a dry run it does not exist yet either. if ($dryRun || $productId === null) { continue; } $prices->ensure($price, $treatment); } return $changed; } /** * A Product and four Prices for every module on sale: monthly and yearly, * each at the domestic gross and at the bare net. * * Both terms and both treatments, unconditionally, rather than only the ones * somebody has bought a contract on: the Price has to exist BEFORE the booking * that needs it, and a customer on a yearly package booking their first module * would otherwise discover the gap at the moment their money was due. * * @return int how many objects were created, for the run's own count */ private function syncModules(bool $dryRun): int { $catalogue = app(AddonCatalogue::class); $prices = app(AddonPrices::class); $currency = Subscription::catalogueCurrency(); $created = 0; $keys = array_merge(array_keys((array) config('provisioning.addons')), [AddonCatalogue::STORAGE]); foreach ($keys as $key) { $monthly = $catalogue->priceCents($key); // Nothing to bill and nothing to mirror. A module priced at zero is // either a placeholder or included, and an item at no money would // put a line saying so on every invoice a customer ever gets. if ($monthly === null || $monthly <= 0) { continue; } foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) { foreach ($this->treatments() as $label => $treatment) { // Asked of the CHARGED figure, so a rate change is seen as a // Price that has to be replaced rather than as one that is // already there. if ($prices->liveFor($key, $monthly, $currency, $term, $treatment) !== null) { continue; } $this->line(sprintf( ' module %s %s %s %d %s net → %d %s charged', $key, $term, $label, AddonPrices::termNetCents($monthly, $term), $currency, AddonPrices::chargedCents($monthly, $term, $treatment), $currency, )); $created++; if ($dryRun) { continue; } $prices->ensure($key, $monthly, $currency, $term, $treatment); } } } return $created; } /** * The two customers everything on sale is mirrored for, labelled for the * output so an operator reading a run can see which of a pair moved. * * Built from TaxTreatment rather than from a rate written down here: it is the * single tax authority, and a second list of treatments is how the catalogue * would come to sell a figure no invoice agrees with. * * @return array */ private function treatments(): array { return [ 'domestic' => TaxTreatment::domestic(), 'reverse-charge' => TaxTreatment::reverseCharge(), ]; } /** Versions whose prices are live in Stripe, for the status line. */ public static function syncedVersions(): int { return PlanVersion::query() ->whereNotNull('published_at') ->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id')) ->count(); } }