option('dry-run'); if (! $dryRun && ! $stripe->isConfigured()) { $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.'); return self::FAILURE; } $created = 0; foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) { $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) { $productId = $stripe->createProduct( $family->name, ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id], // Keyed on our row, so a crash between Stripe creating // the product and us storing its id gives back the same // product on the next run rather than a second one. idempotencyKey: "clupilot-product-{$family->id}", ); $family->update(['stripe_product_id' => $productId]); } } foreach ($published as $version) { foreach ($version->prices as $price) { $created += $this->syncPrice($stripe, $family, $version, $price, $productId, $dryRun); } } } $created += $this->syncModules($dryRun); $this->newLine(); if ($created === 0) { $this->info('Stripe is already in step with the catalogue.'); return self::SUCCESS; } $this->info($dryRun ? "{$created} object(s) would be created. Run without --dry-run to create them." : "{$created} object(s) created in Stripe."); return self::SUCCESS; } /** * The Stripe Price one priced catalogue row is sold on, brought into step. * * Three outcomes, and which one happens is decided by the CHARGED figure — * the row's net at today's rate — and never by whether an id happens to be * stored: * * - the live Price already charges it, and the row points at that Price: * nothing to do, which is what makes a second run free; * - 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 rather than * minted a second time; * - nothing charges it: a new Price is created, the row is repointed, and * whatever it was pointing at is archived in Stripe and here. * * 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( StripeClient $stripe, PlanFamily $family, PlanVersion $version, PlanPrice $price, ?string $productId, bool $dryRun, ): int { $charged = TaxTreatment::chargedCents((int) $price->amount_cents); $existing = StripePlanPrice::query() ->where('plan_price_id', $price->id) ->where('charged_cents', $charged) ->first(); if ($existing !== null && $existing->isLive() && $price->stripe_price_id === $existing->stripe_price_id) { return 0; } $this->line(sprintf( ' price %s v%d %s %d %s net → %d %s charged%s', $family->key, $version->version, $price->term, $price->amount_cents, $price->currency, $charged, $price->currency, $existing === null ? '' : ' (Price already exists)', )); if ($dryRun || $productId === null) { return 1; } $priceId = $existing?->stripe_price_id ?? $stripe->createPrice( productId: $productId, amountCents: $charged, currency: $price->currency, interval: $price->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, ], // The CHARGED amount is part of the key. Without it a run after a // rate change would replay the Price minted at the old figure, and // Stripe would hand back the very object this is replacing. idempotencyKey: "clupilot-price-{$price->id}-{$charged}", ); StripePlanPrice::query()->updateOrCreate( ['plan_price_id' => $price->id, 'charged_cents' => $charged], ['stripe_price_id' => $priceId, 'archived_at' => null], ); // Everything else this row was ever sold on stops being offered — after // the replacement exists, never before, so a failure in between leaves // the old Price selling rather than nothing at all. $superseded = StripePlanPrice::query() ->where('plan_price_id', $price->id) ->where('charged_cents', '!=', $charged) ->whereNull('archived_at') ->get(); foreach ($superseded as $old) { $stripe->archivePrice((string) $old->stripe_price_id); $old->update(['archived_at' => now()]); } // 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 first. PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]); return 1; } /** * A Product and two Prices — monthly and yearly — for every module on sale. * * Both terms, 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) { // 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) !== null) { continue; } $this->line(sprintf( ' module %s %s %d %s net → %d %s charged', $key, $term, AddonPrices::termNetCents($monthly, $term), $currency, AddonPrices::chargedCents($monthly, $term), $currency, )); $created++; if ($dryRun) { continue; } $prices->ensure($key, $monthly, $currency, $term); } } return $created; } /** 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(); } }