110 lines
3.8 KiB
PHP
110 lines
3.8 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.
|
|
*
|
|
* Without --archive this reports and touches nothing. With it, the orphans are
|
|
* archived at Stripe, which is harmless for the reason it always is in this
|
|
* codebase: Stripe goes on billing every subscription already on an archived
|
|
* Price. It stops being SOLD, and nothing here is sold on a Price no row knows.
|
|
*/
|
|
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';
|
|
|
|
public function handle(StripeClient $stripe): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$archive = (bool) $this->option('archive');
|
|
|
|
if (! $dryRun && ! $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;
|
|
}
|
|
|
|
$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. Run with --archive to stop selling them.",
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|