157 lines
7.0 KiB
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();
|
|
}
|
|
}
|