CluPilotCloud/app/Console/Commands/SyncStripeCatalogue.php

291 lines
12 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row — and the same for every module we sell.
*
* 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.
*
* **What it pushes is the GROSS figure.** The catalogue's `amount_cents` is net
* and stays net — it is frozen onto every contract and PlanChange prorates
* against it — but the amount Stripe takes is that figure at the till, so the
* charge equals the price on the website and equals the total on the document.
* Pushing the net was the defect: a customer quoted 214,80 € paid 179,00 € and
* was then invoiced 179,00 € plus 35,80 € VAT that nobody had collected.
*
* Stripe's `automatic_tax` is deliberately NOT used. TaxTreatment is the single
* tax authority here; a second rate computed by Stripe would charge a German
* consumer 19 % while our document said 20 %.
*
* Idempotent by construction, and safe to re-run after a rate change: a row
* whose live Price already charges the right figure is skipped, and one that
* does not gets a NEW Price with the old one archived. That matters more here
* than usual, because a Stripe Price cannot be edited, so a run that minted
* duplicates would leave two live prices for one plan and no way to tell which a
* customer is on.
*
* It does not move anybody. Archiving a Price stops it being SOLD and leaves
* every subscription already on it exactly where it was — moving those is
* stripe:reprice-subscriptions, which is a separate decision because it touches
* live contracts.
*
* 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.
*
* **Modules were missing from this entirely**, which is why a booked module was
* charged in the month it was booked and never again: nothing in Stripe existed
* to put on the subscription. They are pushed at today's catalogue price on both
* terms — a Stripe Price carries its own interval, so a module on a yearly
* contract needs a yearly one. What this run cannot cover is a customer
* grandfathered on an older figure or holding a discounted grant; those mint
* their own Price at the moment they are billed. See App\Services\Billing\AddonPrices.
*/
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) {
$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();
}
}