CluPilotCloud/app/Console/Commands/SweepOrphanStripePrices.php

157 lines
7.0 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* The Prices at our Products that no row of ours knows.
*
* App\Services\Billing\AdoptStripePrice takes over an orphan it can prove is
* ours. What it will not take over — a Price carrying none of our metadata, or
* one that charges in a way we never mint — it names in the log and leaves,
* because adopting a stranger's Price is worse than minting a second one. That
* was the right call and it left an operator with a log line and no way to act
* on it.
*
* **What this lists is wider than that log line.** Every active RECURRING Price
* at one of our Products that no row of ours knows — adoptable or not. The only
* question asked below is whether a register row holds the id; nothing here
* re-asks AdoptStripePrice whether it would have taken the Price over.
*
* Recurring, because activePricesFor() sends `type: recurring` as well as
* `active: true`. That filter can hide somebody else's one-time Price from the
* list; it can never hide one of ours. createPrice() always sends
* `recurring[interval]`, and it is the only call that mints a Price at one of
* our Products at all — the setup fee is priced inline at the checkout, under an
* ad-hoc Product of its own. So the filter errs toward listing too little, never
* toward missing an orphan of ours.
*
* **So stripe:sync-catalogue runs first.** Until it has, an orphan listed here
* may be one the next sync would simply have adopted, and --archive on that one
* is worse than it looks. Archiving it makes it invisible to the very step that
* would have rescued it: activePricesFor() asks Stripe for ACTIVE prices only,
* so adoption finds nothing and the sync falls through to createPrice() — under
* the same idempotency key the crashed run used, because the key is built from
* what is being sold and everything the call puts on the wire (see
* App\Services\Stripe\IdempotencyKey), and nothing about either has changed. For
* twenty-four hours Stripe answers a repeated key by replaying the response it
* stored rather than doing anything: the archived Price's id, in the body it had
* while it was still live. The register writes that id down as the live Price
* for the row, and a checkout on it is refused.
*
* Without --archive this reports and touches nothing. With it, the orphans are
* archived at Stripe, which is harmless for everything THIS codebase sells, for
* the reason it always is here: Stripe goes on billing every subscription
* already on an archived Price, it only stops being SOLD — and nothing this
* codebase sells is sold on a Price no row knows. That reasoning reaches no
* further than this codebase. A Price an operator wired up by hand — a
* SUBSCRIPTION payment link built in Stripe's own dashboard against one of our
* Products — is exactly the Price carrying none of our metadata described above,
* and --archive withdraws it too. A one-time link is the other side of the
* recurring filter: neither listed nor archived, and so neither protected nor
* disturbed by this command.
*/
class SweepOrphanStripePrices extends Command
{
protected $signature = 'stripe:sweep-orphan-prices
{--archive : Stop selling the orphans instead of only listing them}
{--dry-run : Show what would be archived without touching Stripe}';
protected $description = 'List the Stripe prices at our products that no row knows, and optionally archive them — run stripe:sync-catalogue first';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
$archive = (bool) $this->option('archive');
// Checked on EVERY path, unlike stripe:sync-catalogue's --dry-run,
// which touches nothing because everything it compares is already in
// our own tables. This command has no local-only mode: the report —
// the whole point of it, dry-run or not — is activePricesFor(), a
// live call. Skipping this check under --dry-run would not skip the
// call, only the graceful error in front of it.
if (! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was read.');
return self::FAILURE;
}
$known = StripeAddonPrice::query()->pluck('stripe_price_id')
->merge(StripePlanPrice::query()->pluck('stripe_price_id'))
->filter()
->flip();
$found = 0;
foreach ($this->products() as $productId) {
foreach ($stripe->activePricesFor($productId) as $price) {
if ($known->has($price['id'])) {
continue;
}
$found++;
$this->line(sprintf(
' orphan %s %d %s %s product %s %s',
$price['id'],
$price['unit_amount'],
$price['currency'],
$price['interval'],
$productId,
$price['metadata'] === [] ? 'no metadata' : json_encode($price['metadata']),
));
if ($archive && ! $dryRun) {
$stripe->archivePrice($price['id']);
}
}
}
$this->newLine();
if ($found === 0) {
$this->info('Every active price at our products is accounted for.');
return self::SUCCESS;
}
// Said on every path that found something, including after --archive
// has already run: the only question asked above is whether a row holds
// the id, so an orphan in this list may be one stripe:sync-catalogue
// would have adopted. Archiving that one is the failure this class's
// docblock describes, and an operator who has just done it needs to
// know as much as one who is about to.
$this->warn(' Run stripe:sync-catalogue first — an orphan listed here may be one the next sync would adopt.');
$this->info(match (true) {
$dryRun && $archive => "{$found} orphan(s) would be archived. Run without --dry-run to archive them.",
$archive => "{$found} orphan(s) archived. Existing subscriptions on them keep billing.",
default => "{$found} orphan(s) found. Once the sync has run, --archive stops selling what is still listed.",
});
return self::SUCCESS;
}
/**
* Every Stripe Product this platform owns: one per plan family, one per
* module. Read from our own rows rather than from Stripe, because a Product
* we do not know is not one whose Prices we can judge.
*
* @return array<int, string>
*/
private function products(): array
{
return PlanFamily::query()->whereNotNull('stripe_product_id')->pluck('stripe_product_id')
->merge(StripeAddonPrice::query()->pluck('stripe_product_id'))
->filter()
->unique()
->values()
->all();
}
}