136 lines
5.1 KiB
PHP
136 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanPrice;
|
|
use App\Models\PlanVersion;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Console\Command;
|
|
|
|
/**
|
|
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
|
|
* priced row.
|
|
*
|
|
* Stripe owns the recurring billing — retries, dunning, off-session SCA,
|
|
* invoice numbering — so it needs to know what it is billing for. It does not
|
|
* need to know how big the VM is, and it is not asked.
|
|
*
|
|
* Idempotent by construction: a row that already carries an id is skipped. That
|
|
* matters more here than usual, because a Stripe Price cannot be edited, so a
|
|
* second run that minted duplicates would leave two live prices for one plan
|
|
* and no way to tell which a customer is on.
|
|
*
|
|
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
|
|
* Product for it would be a price list entry for something that may never
|
|
* exist.
|
|
*/
|
|
class SyncStripeCatalogue extends Command
|
|
{
|
|
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
|
|
|
|
protected $description = 'Create the Stripe products and prices for the plan catalogue';
|
|
|
|
public function handle(StripeClient $stripe): int
|
|
{
|
|
$dryRun = (bool) $this->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) {
|
|
if ($price->stripe_price_id !== null) {
|
|
continue;
|
|
}
|
|
|
|
$this->line(sprintf(
|
|
' price %s v%d %s %d %s',
|
|
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
|
|
));
|
|
$created++;
|
|
|
|
if ($dryRun || $productId === null) {
|
|
continue;
|
|
}
|
|
|
|
$priceId = $stripe->createPrice(
|
|
productId: $productId,
|
|
amountCents: $price->amount_cents,
|
|
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,
|
|
],
|
|
idempotencyKey: "clupilot-price-{$price->id}",
|
|
);
|
|
|
|
// 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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
/** 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();
|
|
}
|
|
}
|