diff --git a/app/Support/Readiness/BillingChecks.php b/app/Support/Readiness/BillingChecks.php index 10dc061..461ae36 100644 --- a/app/Support/Readiness/BillingChecks.php +++ b/app/Support/Readiness/BillingChecks.php @@ -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(), ), ]; } diff --git a/lang/de/readiness.php b/lang/de/readiness.php index d3d56ac..7a025b1 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -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.', ], diff --git a/lang/en/readiness.php b/lang/en/readiness.php index 74b3d06..fb338a8 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -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.', ], diff --git a/tests/Feature/Readiness/BillingChecksTest.php b/tests/Feature/Readiness/BillingChecksTest.php index 04fbb2a..2d80adc 100644 --- a/tests/Feature/Readiness/BillingChecksTest.php +++ b/tests/Feature/Readiness/BillingChecksTest.php @@ -1,6 +1,9 @@ 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(); +});