Fix round: match the actual selection IssueInvoice and sync use
Two of the five billing checks measured something close to, but not
the same as, what actually gates the flow they report on:
- billing.invoice_series counted distinct kinds without filtering on
`active`, but IssueInvoice draws its series with
where('kind', ...)->where('active', true) (IssueInvoice.php:373,
:444) — a deactivated row is invisible to it exactly like a missing
one. An installation with a switched-off 'invoice' series was
reported ready while no invoice could draw a number: false-green.
- billing.catalogue_synced counted every PlanPrice with a null
stripe_price_id, but stripe:sync-catalogue only ever mirrors
PUBLISHED versions (SyncStripeCatalogue's own header comment). A
draft's unsynced price is an ordinary state, not a gap; the old
query kept this check permanently unsatisfied on any installation
that had ever drafted a next version — an alarm always on and
therefore never read.
Both queries now mirror the condition the real gate uses instead of a
similar-looking one of their own. Two tests added that were red before
the fix: an inactive required series that must not read as ready, and
an unpublished draft's unsynced price that must not raise an alarm.
Also strengthens the weakest breaks sentence: invoice_series_breaks
named the mechanism ("a document cannot draw a number") rather than
the consequence, unlike its four siblings. It now says what the
customer and operator actually experience — a running cloud with no
invoice, the failure only logged — matching company_details_breaks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
parent
d9c0ad0e4a
commit
b1e5bf8023
|
|
@ -65,8 +65,16 @@ final class BillingChecks
|
|||
label: __('readiness.billing.invoice_series'),
|
||||
breaks: __('readiness.billing.invoice_series_breaks'),
|
||||
tab: 'company',
|
||||
// IssueInvoice draws a series with `where('kind', $kind)
|
||||
// ->where('active', true)` (IssueInvoice.php:373, :444) — a
|
||||
// deactivated row is invisible to it exactly like a missing
|
||||
// one, so the same `active` filter belongs here too. Without
|
||||
// it, an installation with a switched-off 'invoice' series
|
||||
// still counted as ready, the false-green this page exists to
|
||||
// rule out.
|
||||
satisfied: InvoiceSeries::query()
|
||||
->whereIn('kind', ['invoice', 'credit_note', 'cancellation'])
|
||||
->where('active', true)
|
||||
->distinct()
|
||||
->count('kind') === 3,
|
||||
),
|
||||
|
|
@ -77,7 +85,18 @@ final class BillingChecks
|
|||
label: __('readiness.billing.catalogue_synced'),
|
||||
breaks: __('readiness.billing.catalogue_synced_breaks'),
|
||||
tab: 'services',
|
||||
satisfied: PlanPrice::query()->whereNull('stripe_price_id')->doesntExist(),
|
||||
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only
|
||||
// ever touches PUBLISHED versions — "a draft has promised
|
||||
// nothing, and a Product for it would be a price list entry
|
||||
// for something that may never exist" (its own header
|
||||
// comment). A draft's own unsynced price is therefore not a
|
||||
// gap; counting it anyway turns this check into an alarm that
|
||||
// never clears on any installation that has ever drafted a
|
||||
// next version, which stops it being read at all.
|
||||
satisfied: PlanPrice::query()
|
||||
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'))
|
||||
->whereNull('stripe_price_id')
|
||||
->doesntExist(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ return [
|
|||
'company_details' => 'Firmendaten vollständig',
|
||||
'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.',
|
||||
'invoice_series' => 'Rechnungsserie je Belegart',
|
||||
'invoice_series_breaks' => 'Ein Beleg kann keine Nummer ziehen.',
|
||||
'invoice_series_breaks' => 'IssueInvoice bricht beim Ausstellen ab, sobald eine Serie fehlt oder deaktiviert ist. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg, und der Fehler landet nur im Log.',
|
||||
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
|
||||
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ return [
|
|||
'company_details' => 'Company details complete',
|
||||
'company_details_breaks' => 'IssueInvoice refuses to issue the invoice. Provisioning still goes through — the customer gets a running cloud with no invoice.',
|
||||
'invoice_series' => 'Invoice series for every document kind',
|
||||
'invoice_series_breaks' => 'A document cannot draw a number.',
|
||||
'invoice_series_breaks' => 'IssueInvoice aborts while issuing whenever a series is missing or switched off. Provisioning still goes through — the customer gets a running cloud with no invoice, and the failure is only written to the log.',
|
||||
'catalogue_synced' => 'Stripe catalogue in sync',
|
||||
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\Operator;
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanPrice;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\Readiness;
|
||||
|
|
@ -54,3 +57,36 @@ it('says the installation is not ready while something blocking is open', functi
|
|||
expect(Readiness::isReady())->toBeFalse();
|
||||
expect(Readiness::blocking())->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not report the invoice series ready when the required series is switched off', function () {
|
||||
// IssueInvoice draws its series with `where('kind', ...)->where('active', true)`
|
||||
// (IssueInvoice.php:373 and :444) — a deactivated 'invoice' row is as
|
||||
// invisible to it as a missing one. Counting distinct kinds without the
|
||||
// same `active` filter would report readiness for an installation where
|
||||
// not a single invoice can draw a number.
|
||||
InvoiceSeries::query()->where('kind', 'invoice')->update(['active' => false]);
|
||||
|
||||
expect(checkFor('billing.invoice_series')->satisfied)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not raise a permanent alarm for a draft version stripe never touches', function () {
|
||||
// stripe:sync-catalogue only mirrors PUBLISHED versions (its own header
|
||||
// comment: "Only PUBLISHED versions are synced. A draft has promised
|
||||
// nothing..."). A draft is an entirely ordinary state — there is a
|
||||
// dedicated modal to create one — so a query that does not also filter on
|
||||
// `published_at` would keep this check permanently unsatisfied on any
|
||||
// installation that has ever drafted a next version: an alarm that is
|
||||
// always on and therefore never read.
|
||||
PlanPrice::query()->update(['stripe_price_id' => 'price_already_synced']);
|
||||
|
||||
$family = PlanFamily::query()->create(['key' => 'draftonly', 'name' => 'Draft Only', 'tier' => 9]);
|
||||
$draft = $family->versions()->create([
|
||||
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
|
||||
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
|
||||
'available_from' => now(),
|
||||
// published_at left null: a draft, exactly what stripe:sync-catalogue skips.
|
||||
]);
|
||||
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 1000, 'currency' => 'EUR']);
|
||||
|
||||
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue