354 lines
16 KiB
PHP
354 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanPrice;
|
|
use App\Models\PlanVersion;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\AddonCatalogue;
|
|
use App\Services\Billing\AddonPrices;
|
|
use App\Services\Billing\PlanPrices;
|
|
use App\Services\Billing\TaxTreatment;
|
|
use App\Services\Stripe\StripeClient;
|
|
use App\Support\OperatingMode;
|
|
use App\Support\StripeCatalogueMode;
|
|
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.
|
|
*
|
|
* **It pushes TWO Prices for everything it sells.** The catalogue's `amount_cents`
|
|
* is net and stays net — it is frozen onto every contract and PlanChange prorates
|
|
* against it — and what Stripe is asked to take from it depends on who is buying:
|
|
*
|
|
* - the DOMESTIC GROSS, which is the figure on the website and what everybody
|
|
* who is charged VAT pays, private and business alike. Pushing the net was the
|
|
* original defect: a customer quoted 214,80 € paid 179,00 € and was then
|
|
* invoiced 179,00 € plus 35,80 € VAT that nobody had collected;
|
|
* - the BARE NET, for a business in another member state whose VAT id is
|
|
* verified. Reverse charge means no VAT is owed to us at all, so the net is
|
|
* the whole of what they owe. Charging them the gross was a flat 20 % surcharge
|
|
* with no VAT line on the document for them to reclaim.
|
|
*
|
|
* Which of the two a checkout uses is TaxTreatment's decision — see
|
|
* App\Services\Billing\PlanPrices, which owns the plan half of the mirror, and
|
|
* App\Services\Billing\AddonPrices, which owns the module half.
|
|
*
|
|
* 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. A rate change moves the
|
|
* gross Price of each pair and leaves the net one untouched, because the net does
|
|
* not depend on the rate. 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 at one figure 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.
|
|
*
|
|
* **A run belongs to ONE Stripe account, and it says which.** Products and
|
|
* Prices are account-bound, and the operating mode picks the key — so the mode
|
|
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
|
|
* a catalogue that belongs to the other account is refused rather than allowed
|
|
* to skip its way to "already in step". Without that, switching to live left
|
|
* every stored id pointing at test objects while the readiness page went on
|
|
* reporting a synced catalogue, and the first real order met "No such price".
|
|
*
|
|
* **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;
|
|
}
|
|
|
|
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
|
|
$this->error($this->staleCatalogueMessage());
|
|
|
|
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
|
|
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
|
|
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
|
|
// would be created" wäre die beruhigende Antwort auf die
|
|
// gefährliche Lage.
|
|
return self::FAILURE;
|
|
}
|
|
|
|
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
|
|
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
|
|
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
|
|
// handle() trug er sie NICHT: createProduct() und ensure() werfen
|
|
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
|
|
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
|
|
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
|
|
// an dem laufende Verträge abgerechnet werden — statt schlicht
|
|
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
|
|
if (! $dryRun) {
|
|
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
|
|
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
|
|
}
|
|
|
|
StripeCatalogueMode::record();
|
|
}
|
|
|
|
$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($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;
|
|
}
|
|
|
|
/**
|
|
* Why a run is refused outright rather than left to skip its way through.
|
|
*
|
|
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
|
|
* them, and a test account and a live account are two accounts. This
|
|
* command skips every row that already carries an id (PlanPrices::inStep()
|
|
* asks the register, not Stripe), so a run after the switch would report
|
|
* "already in step" and change nothing — while recording that the
|
|
* catalogue now belongs to the account it never touched.
|
|
*
|
|
* The registers have to go too, not only the two columns: inStep() takes
|
|
* a registered row as proof on its own for the reverse-charge half, so a
|
|
* cleared pointer with the register left behind would leave exactly that
|
|
* half pointing at the old account, and nothing would say so.
|
|
*
|
|
* Only reached where the OTHER account is established fact. A catalogue
|
|
* whose account was never recorded is a different state with a different
|
|
* cure — see StripeCatalogueMode::belongsToAnotherMode(). Telling that
|
|
* operator to clear anything would be telling them to delete a catalogue
|
|
* their live contracts bill on.
|
|
*/
|
|
private function staleCatalogueMessage(): string
|
|
{
|
|
return sprintf(
|
|
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
|
|
.'Stripe objects belong to the account that issued them, and this command skips every row that '
|
|
.'already carries an id — so it cannot repair this by running again. Clear '
|
|
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
|
|
.'stripe_addon_prices registers first, then run it. Nothing was created.',
|
|
StripeCatalogueMode::recorded()?->value,
|
|
OperatingMode::current()->value,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The Stripe Prices one priced catalogue row is sold on, brought into step.
|
|
*
|
|
* Both treatments, always: the domestic gross AND the bare net a
|
|
* reverse-charge business is charged. Unconditionally, rather than only when
|
|
* such a customer exists — the Price has to be there BEFORE the checkout that
|
|
* needs it, and a verified business meeting a refused checkout is worse than
|
|
* an unused Price sitting in Stripe.
|
|
*
|
|
* What "in step" means, and the find-or-mint-or-replace that follows from it,
|
|
* belongs to PlanPrices: it is the same question the checkout and the plan
|
|
* swap ask, and asking it in two places is how they come to disagree. All
|
|
* this adds is the reporting, because an operator running a sync has to be
|
|
* able to see which half of which pair moved.
|
|
*
|
|
* 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(
|
|
PlanFamily $family,
|
|
PlanVersion $version,
|
|
PlanPrice $price,
|
|
?string $productId,
|
|
bool $dryRun,
|
|
): int {
|
|
$prices = app(PlanPrices::class);
|
|
$changed = 0;
|
|
|
|
foreach ($this->treatments() as $label => $treatment) {
|
|
if ($prices->inStep($price, $treatment)) {
|
|
continue;
|
|
}
|
|
|
|
$this->line(sprintf(
|
|
' price %s v%d %s %s %d %s net → %d %s charged',
|
|
$family->key, $version->version, $price->term, $label,
|
|
$price->amount_cents, $price->currency,
|
|
PlanPrices::chargedCents($price, $treatment), $price->currency,
|
|
));
|
|
$changed++;
|
|
|
|
// Nothing to create against: the Product for this family is minted
|
|
// by the caller, and on a dry run it does not exist yet either.
|
|
if ($dryRun || $productId === null) {
|
|
continue;
|
|
}
|
|
|
|
$prices->ensure($price, $treatment);
|
|
}
|
|
|
|
return $changed;
|
|
}
|
|
|
|
/**
|
|
* A Product and four Prices for every module on sale: monthly and yearly,
|
|
* each at the domestic gross and at the bare net.
|
|
*
|
|
* Both terms and both treatments, 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) {
|
|
foreach ($this->treatments() as $label => $treatment) {
|
|
// 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, $treatment) !== null) {
|
|
continue;
|
|
}
|
|
|
|
$this->line(sprintf(
|
|
' module %s %s %s %d %s net → %d %s charged',
|
|
$key, $term, $label,
|
|
AddonPrices::termNetCents($monthly, $term), $currency,
|
|
AddonPrices::chargedCents($monthly, $term, $treatment), $currency,
|
|
));
|
|
$created++;
|
|
|
|
if ($dryRun) {
|
|
continue;
|
|
}
|
|
|
|
$prices->ensure($key, $monthly, $currency, $term, $treatment);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $created;
|
|
}
|
|
|
|
/**
|
|
* The two customers everything on sale is mirrored for, labelled for the
|
|
* output so an operator reading a run can see which of a pair moved.
|
|
*
|
|
* Built from TaxTreatment rather than from a rate written down here: it is the
|
|
* single tax authority, and a second list of treatments is how the catalogue
|
|
* would come to sell a figure no invoice agrees with.
|
|
*
|
|
* @return array<string, TaxTreatment>
|
|
*/
|
|
private function treatments(): array
|
|
{
|
|
return [
|
|
'domestic' => TaxTreatment::domestic(),
|
|
'reverse-charge' => TaxTreatment::reverseCharge(),
|
|
];
|
|
}
|
|
|
|
/** 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();
|
|
}
|
|
}
|