diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php index d0e4bbf..8e61435 100644 --- a/app/Services/Stripe/FakeStripeClient.php +++ b/app/Services/Stripe/FakeStripeClient.php @@ -610,4 +610,32 @@ class FakeStripeClient implements StripeClient 'exp_year' => 2030, ]; } + + /** @var array> Was openInvoices() zurückgibt. */ + public array $openInvoiceRows = []; + + public bool $payDeclines = false; + + /** @var array */ + public array $paidInvoices = []; + + public function openInvoices(string $customerId): array + { + return $this->openInvoiceRows; + } + + public function payInvoice(string $invoiceId): array + { + if ($this->payDeclines) { + return ['paid' => false, 'status' => 'open', 'failure' => 'Your card was declined.']; + } + + $this->paidInvoices[] = $invoiceId; + $this->openInvoiceRows = array_values(array_filter( + $this->openInvoiceRows, + fn (array $invoice) => $invoice['id'] !== $invoiceId, + )); + + return ['paid' => true, 'status' => 'paid', 'failure' => null]; + } } diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index 1fe32da..f7c287f 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -583,4 +583,55 @@ class HttpStripeClient implements StripeClient 'exp_year' => (int) $method['card']['exp_year'], ]; } + + public function openInvoices(string $customerId): array + { + $invoices = $this->request() + ->get($this->url('invoices'), [ + 'customer' => $customerId, + // Finalisiert und unbezahlt. Ohne den Filter käme die ganze + // Historie zurück, und neben längst beglichenen Rechnungen + // stünde ein Bezahlknopf. + 'status' => 'open', + 'limit' => 100, + ]) + ->throw() + ->json('data', []); + + return array_map(fn (array $invoice) => [ + 'id' => (string) $invoice['id'], + 'number' => isset($invoice['number']) ? (string) $invoice['number'] : null, + 'amount_due_cents' => (int) ($invoice['amount_due'] ?? 0), + 'currency' => strtoupper((string) ($invoice['currency'] ?? 'eur')), + 'created_at' => (string) ($invoice['created'] ?? ''), + ], $invoices); + } + + public function payInvoice(string $invoiceId): array + { + $response = $this->request()->asForm()->post($this->url('invoices/'.$invoiceId.'/pay')); + + if ($response->successful()) { + return [ + 'paid' => (bool) $response->json('paid', false), + 'status' => (string) $response->json('status', 'unknown'), + 'failure' => null, + ]; + } + + // 402 ist Stripes Antwort auf eine abgelehnte Karte — eine Aussage + // über das Zahlungsmittel. Alles andere (401, 5xx) sagt etwas über + // UNS und bleibt ein Wurf: es als „Ihre Karte wurde abgelehnt" + // auszugeben wäre eine Behauptung über das Konto des Kunden, + // hergeleitet aus unserem eigenen Fehler. + if ($response->status() !== 402) { + $response->throw(); + } + + return [ + 'paid' => false, + 'status' => 'open', + 'failure' => (string) $response->json('error.message', ''), + ]; + } } diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index c63c6a2..9d8509e 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -363,4 +363,21 @@ interface StripeClient * @return array{id: string, brand: string, last4: string, exp_month: int, exp_year: int}|null */ public function defaultPaymentMethod(string $customerId): ?array; + + /** + * Die finalisierten, unbezahlten Rechnungen dieses Kunden. + * + * @return array + */ + public function openInvoices(string $customerId): array; + + /** + * Eine offene Rechnung mit dem hinterlegten Zahlungsmittel einziehen. + * + * Wirft NICHT bei einer abgelehnten Karte: das ist die häufigste Antwort + * überhaupt und gehört auf die Seite, nicht in einen 500er. + * + * @return array{paid: bool, status: string, failure: ?string} + */ + public function payInvoice(string $invoiceId): array; } diff --git a/tests/Feature/Admin/CodexFixRoundTest.php b/tests/Feature/Admin/CodexFixRoundTest.php index 7c7b6c2..2211198 100644 --- a/tests/Feature/Admin/CodexFixRoundTest.php +++ b/tests/Feature/Admin/CodexFixRoundTest.php @@ -82,7 +82,10 @@ it('does not make a host unpurgeable because its record id predates the cloud AP Http::preventStrayRequests(); Http::fake(); - expect(fn () => (new HttpHetznerDnsClient)->deleteRecord('rec-123'))->not->toThrow(Throwable::class); + // Kein `toThrow(Throwable::class)` — Pest prüft bei einer Schnittstelle + // die MELDUNG, nicht den Typ, und eine Verneinung davon ist immer grün. + // Hier zählt schlicht, dass der Aufruf zurückkommt. + (new HttpHetznerDnsClient)->deleteRecord('rec-123'); // Und zwar OHNE einen Aufruf zu bauen, der irgendetwas anderes trifft. Http::assertNothingSent(); diff --git a/tests/Feature/Billing/ArchivePullAccessTest.php b/tests/Feature/Billing/ArchivePullAccessTest.php index 8f68fba..115a18a 100644 --- a/tests/Feature/Billing/ArchivePullAccessTest.php +++ b/tests/Feature/Billing/ArchivePullAccessTest.php @@ -2,7 +2,6 @@ use App\Livewire\Admin\Finance; use App\Models\ExportTarget; -use App\Services\Deployment\UpdateChannel; use Illuminate\Support\Facades\File; use Livewire\Livewire; @@ -94,7 +93,7 @@ it('never writes the key into the database', function () { expect(json_encode($target->getAttributes()))->not->toContain('GEHEIM-XYZ'); } - expect(json_encode(App\Models\ExportTarget::query()->get()->toArray()))->not->toContain('GEHEIM-XYZ'); + expect(json_encode(ExportTarget::query()->get()->toArray()))->not->toContain('GEHEIM-XYZ'); }); it('locks the key to one directory, read-only, with no shell', function () { diff --git a/tests/Feature/Billing/ExportTargetFormTest.php b/tests/Feature/Billing/ExportTargetFormTest.php index 10adc91..7e9f7f1 100644 --- a/tests/Feature/Billing/ExportTargetFormTest.php +++ b/tests/Feature/Billing/ExportTargetFormTest.php @@ -1,5 +1,8 @@ assertDontSee(__('finance.target.host')) - ->assertSee(__('finance.target.path')); + ->assertSee(__('finance.target.path')); $c->set('driver', 'sftp') - ->assertSee(__('finance.target.host')) - ->assertSee(__('finance.target.port')) - ->assertSee(__('finance.target.username')) - ->assertSee(__('finance.target.password')); + ->assertSee(__('finance.target.host')) + ->assertSee(__('finance.target.port')) + ->assertSee(__('finance.target.username')) + ->assertSee(__('finance.target.password')); }); it('stores an SFTP destination with its host and keeps the password out of the row', function () { @@ -30,7 +33,7 @@ it('stores an SFTP destination with its host and keeps the password out of the r ->call('save') ->assertHasNoErrors(); - $target = App\Models\ExportTarget::query()->where('name', 'Storage Box')->firstOrFail(); + $target = ExportTarget::query()->where('name', 'Storage Box')->firstOrFail(); expect($target->host)->toBe('u123456.your-storagebox.de') ->and($target->port)->toBe(23) @@ -39,5 +42,5 @@ it('stores an SFTP destination with its host and keeps the password out of the r // table in plain text is a credential in every database dump. ->and($target->secret_key)->not->toBe('geheim') ->and($target->secret_key)->not->toBeEmpty() - ->and(app(App\Services\Secrets\SecretCipher::class)->decrypt($target->secret_key))->toBe('geheim'); + ->and(app(SecretCipher::class)->decrypt($target->secret_key))->toBe('geheim'); }); diff --git a/tests/Feature/Billing/FinanceSettingsTest.php b/tests/Feature/Billing/FinanceSettingsTest.php index fb5c274..4642f0f 100644 --- a/tests/Feature/Billing/FinanceSettingsTest.php +++ b/tests/Feature/Billing/FinanceSettingsTest.php @@ -4,7 +4,9 @@ use App\Livewire\Admin\EditInvoiceSeries; use App\Livewire\Admin\Finance; use App\Models\Invoice; use App\Models\InvoiceSeries; +use App\Services\Billing\TaxTreatment; use App\Support\CompanyProfile; +use App\Support\Settings; use Livewire\Livewire; /** @@ -51,7 +53,7 @@ it('drops a field that is not part of the profile', function () { // arbitrary setting into the same table the site-visibility switch uses. CompanyProfile::put(['site.public' => false, 'name' => 'Echt']); - expect(App\Support\Settings::get('site.public', true))->toBeTrue() + expect(Settings::get('site.public', true))->toBeTrue() ->and(CompanyProfile::get('name'))->toBe('Echt'); }); @@ -139,11 +141,11 @@ it('decides VAT in one place, so the invoice cannot disagree with the checkout', // fallback until a rate has been saved. config(['provisioning.tax.rate_percent' => 20]); - expect(App\Support\CompanyProfile::taxRate())->toBe(20.0) - ->and(App\Services\Billing\TaxTreatment::for(null)->rate)->toBe(0.2); + expect(CompanyProfile::taxRate())->toBe(20.0) + ->and(TaxTreatment::for(null)->rate)->toBe(0.2); - App\Support\Settings::set('company.tax_rate', 10.0); + Settings::set('company.tax_rate', 10.0); - expect(App\Support\CompanyProfile::taxRate())->toBe(10.0) - ->and(App\Services\Billing\TaxTreatment::for(null)->rate)->toBe(0.1); + expect(CompanyProfile::taxRate())->toBe(10.0) + ->and(TaxTreatment::for(null)->rate)->toBe(0.1); }); diff --git a/tests/Feature/Billing/InvoiceArchiveTest.php b/tests/Feature/Billing/InvoiceArchiveTest.php index 8a7675e..8a35e57 100644 --- a/tests/Feature/Billing/InvoiceArchiveTest.php +++ b/tests/Feature/Billing/InvoiceArchiveTest.php @@ -2,10 +2,10 @@ use App\Jobs\ArchiveInvoice; use App\Models\Customer; -use App\Models\Invoice; -use App\Models\Order; use App\Models\ExportTarget; +use App\Models\Invoice; use App\Models\InvoiceExport; +use App\Models\Order; use App\Services\Billing\InvoiceArchive; use App\Services\Billing\IssueInvoice; use App\Support\CompanyProfile; diff --git a/tests/Feature/Billing/InvoiceListTest.php b/tests/Feature/Billing/InvoiceListTest.php index 477c036..c062a45 100644 --- a/tests/Feature/Billing/InvoiceListTest.php +++ b/tests/Feature/Billing/InvoiceListTest.php @@ -2,6 +2,7 @@ use App\Livewire\Admin\Invoices; use App\Models\Customer; +use App\Models\Invoice; use App\Models\Order; use App\Models\User; use App\Services\Billing\IssueInvoice; @@ -15,7 +16,7 @@ beforeEach(function () { ]); }); -function invoiceFor(string $customerName): App\Models\Invoice +function invoiceFor(string $customerName): Invoice { $customer = Customer::factory()->create(['name' => $customerName]); diff --git a/tests/Feature/Billing/InvoiceMailTest.php b/tests/Feature/Billing/InvoiceMailTest.php index e354e3e..fee7baa 100644 --- a/tests/Feature/Billing/InvoiceMailTest.php +++ b/tests/Feature/Billing/InvoiceMailTest.php @@ -2,7 +2,9 @@ use App\Mail\InvoiceMail; use App\Models\Customer; +use App\Models\Invoice; use App\Models\Order; +use App\Services\Billing\InvoiceRenderer; use App\Services\Billing\IssueInvoice; use App\Support\CompanyProfile; @@ -16,7 +18,7 @@ beforeEach(function () { ]); }); -function issuedInvoice(): App\Models\Invoice +function issuedInvoice(): Invoice { $customer = Customer::factory()->create(['name' => 'Muster GmbH']); $orders = collect([Order::factory()->create([ @@ -44,7 +46,7 @@ it('renders a real PDF for the attachment to carry', function () { // The attachment is a closure, and a closure that returns nothing still // makes a perfectly valid mail with a nought-byte file on it. This is the // half that would be empty. - $bytes = app(App\Services\Billing\InvoiceRenderer::class)->forInvoice(issuedInvoice()); + $bytes = app(InvoiceRenderer::class)->forInvoice(issuedInvoice()); expect(substr($bytes, 0, 5))->toBe('%PDF-') ->and(strlen($bytes))->toBeGreaterThan(10000); @@ -58,7 +60,7 @@ it('renders it from the frozen document, not from today’s settings', function // The snapshot is what the renderer reads. The settings moved underneath // it and the document did not. expect($invoice->refresh()->snapshot['issuer']['name'])->toBe('CluPilot Cloud e.U.') - ->and(app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)) + ->and(app(InvoiceRenderer::class)->forInvoice($invoice)) ->toBeString()->not->toBeEmpty(); }); diff --git a/tests/Feature/Billing/IssueInvoiceTest.php b/tests/Feature/Billing/IssueInvoiceTest.php index d510978..c30b37c 100644 --- a/tests/Feature/Billing/IssueInvoiceTest.php +++ b/tests/Feature/Billing/IssueInvoiceTest.php @@ -4,9 +4,10 @@ use App\Models\Customer; use App\Models\Invoice; use App\Models\InvoiceSeries; use App\Models\Order; -use App\Services\Billing\IssueInvoice; use App\Services\Billing\InvoiceRenderer; +use App\Services\Billing\IssueInvoice; use App\Support\CompanyProfile; +use App\Support\Settings; /** * Issuing a document that cannot be changed afterwards. @@ -50,7 +51,7 @@ it('refuses to issue before the company details are complete', function () { // An invoice without a registered name, an address or a VAT number is not // a valid invoice here — and issuing one consumes a number that can never // be handed out again. - App\Support\Settings::forget('company.vat_id'); + Settings::forget('company.vat_id'); $customer = Customer::factory()->create(); diff --git a/tests/Feature/Billing/OpenInvoiceClientTest.php b/tests/Feature/Billing/OpenInvoiceClientTest.php new file mode 100644 index 0000000..020b0ad --- /dev/null +++ b/tests/Feature/Billing/OpenInvoiceClientTest.php @@ -0,0 +1,64 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + withStripeSecret(); + Http::preventStrayRequests(); +}); + +it('lists only the invoices the customer still owes', function () { + Http::fake(['api.stripe.com/v1/invoices*' => Http::response(['data' => [ + ['id' => 'in_1', 'number' => 'R-1', 'amount_due' => 21480, 'currency' => 'eur', 'created' => 1750000000], + ]])]); + + $open = app(HttpStripeClient::class)->openInvoices('cus_42'); + + expect($open)->toHaveCount(1) + ->and($open[0]['amount_due_cents'])->toBe(21480) + ->and($open[0]['currency'])->toBe('EUR'); + + // `open` ist Stripes Status für finalisiert und unbezahlt. Ohne den Filter + // käme die ganze Rechnungshistorie zurück, und der Kunde bekäme einen + // Bezahlknopf neben Rechnungen, die er längst beglichen hat. + Http::assertSent(fn ($request) => $request['customer'] === 'cus_42' + && $request['status'] === 'open'); +}); + +it('reports a refused charge instead of throwing', function () { + // Der häufigste Fall überhaupt: die Karte lehnt ab. Das ist kein + // Serverfehler, das ist die Antwort — und die Seite muss sie zeigen + // können, statt in eine 500 zu laufen. + Http::fake(['api.stripe.com/v1/invoices/in_1/pay' => Http::response([ + 'error' => ['code' => 'card_declined', 'message' => 'Your card was declined.'], + ], 402)]); + + $result = app(HttpStripeClient::class)->payInvoice('in_1'); + + expect($result['paid'])->toBeFalse() + ->and($result['failure'])->toContain('declined'); +}); + +it('still throws on an answer that says nothing about the card', function () { + // 401 oder 500 sind Aussagen über UNS, nicht über das Zahlungsmittel des + // Kunden. Sie als „Ihre Karte wurde abgelehnt" auszugeben wäre eine + // Behauptung über sein Konto, hergeleitet aus unserem Fehler. + Http::fake(['api.stripe.com/v1/invoices/in_1/pay' => Http::response([], 500)]); + + // Konkrete Klasse, nicht `Throwable::class`: Pest behandelt eine + // Schnittstelle als zu enthaltende MELDUNG, nicht als Typ — der Test wäre + // sonst grün, ohne irgendetwas zu prüfen. + expect(fn () => app(HttpStripeClient::class)->payInvoice('in_1')) + ->toThrow(RequestException::class); +}); + +it('reports a successful charge', function () { + Http::fake(['api.stripe.com/v1/invoices/in_1/pay' => Http::response([ + 'id' => 'in_1', 'status' => 'paid', 'paid' => true, + ])]); + + expect(app(HttpStripeClient::class)->payInvoice('in_1')['paid'])->toBeTrue(); +}); diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index 868fe0b..f7a0e68 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -1,6 +1,8 @@ currentVersion('team'); $currency = Subscription::catalogueCurrency(); $catalogue->schedule($current, $current->available_from, now()); - $dearer = App\Models\PlanVersion::query()->create([ + $dearer = PlanVersion::query()->create([ ...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), 'version' => 2, 'features' => $current->features, 'available_from' => now(), ]); @@ -139,7 +141,7 @@ it('prices a yearly term as twelve months of the same plan', function () { }); it('reads the direction from the plan rank, not from a grandfathered price', function () { - $start = Illuminate\Support\Carbon::parse('2026-08-01'); + $start = Carbon::parse('2026-08-01'); // A business customer from back when business cost less than today's team. $subscription = Subscription::factory()->plan('business')->create([ @@ -156,7 +158,7 @@ it('reads the direction from the plan rank, not from a grandfathered price', fun // and the test would prove nothing. expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(9900); - $change = PlanChange::evaluate($subscription->fresh(), 'team', Illuminate\Support\Carbon::parse('2026-08-15')); + $change = PlanChange::evaluate($subscription->fresh(), 'team', Carbon::parse('2026-08-15')); // Moving to team takes resources away — that is a downgrade whatever the // prices say, and it waits for the end of the term. @@ -166,26 +168,26 @@ it('reads the direction from the plan rank, not from a grandfathered price', fun }); it('lets a scheduled downgrade actually happen once the term is over', function () { - $start = Illuminate\Support\Carbon::parse('2026-08-01'); + $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // Mid-term: not yet. - expect(PlanChange::evaluate($subscription, 'team', Illuminate\Support\Carbon::parse('2026-08-15'))->allowedNow) + expect(PlanChange::evaluate($subscription, 'team', Carbon::parse('2026-08-15'))->allowedNow) ->toBeFalse(); // On the boundary and after it: the job carrying it out has to be able to. foreach (['2026-09-01', '2026-09-03'] as $when) { - $change = PlanChange::evaluate($subscription, 'team', Illuminate\Support\Carbon::parse($when)); + $change = PlanChange::evaluate($subscription, 'team', Carbon::parse($when)); expect($change->allowedNow)->toBeTrue() ->and($change->effectiveAt->toDateString())->toBe($when); } }); it('never invoices a customer for downgrading', function () { - $start = Illuminate\Support\Carbon::parse('2026-08-01'); + $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), @@ -195,27 +197,27 @@ it('never invoices a customer for downgrading', function () { $subscription->forceFill(['price_cents' => 9900])->saveQuietly(); expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(9900); - $credit = PlanChange::goodwillCredit($subscription->fresh(), 'team', Illuminate\Support\Carbon::parse('2026-08-15')); + $credit = PlanChange::goodwillCredit($subscription->fresh(), 'team', Carbon::parse('2026-08-15')); expect($credit)->toBe(0); }); it('does not give away an upgrade in the last hours of a term', function () { - $start = Illuminate\Support\Carbon::parse('2026-08-01'); + $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('start')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // Six hours left: still service, so still a charge. - $change = PlanChange::evaluate($subscription, 'business', Illuminate\Support\Carbon::parse('2026-08-31 18:00')); + $change = PlanChange::evaluate($subscription, 'business', Carbon::parse('2026-08-31 18:00')); expect($change->remainingDays)->toBe(1) ->and($change->chargeCents)->toBeGreaterThan(0); }); it('sends an upgrade requested after the term into the next one', function () { - $start = Illuminate\Support\Carbon::parse('2026-08-01'); + $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('start')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), @@ -223,7 +225,7 @@ it('sends an upgrade requested after the term into the next one', function () { // Nothing left to prorate — charging zero and switching immediately would // hand out the bigger plan for free. - $change = PlanChange::evaluate($subscription, 'business', Illuminate\Support\Carbon::parse('2026-09-05')); + $change = PlanChange::evaluate($subscription, 'business', Carbon::parse('2026-09-05')); expect($change->allowedNow)->toBeFalse() ->and($change->chargeCents)->toBe(0) diff --git a/tests/Feature/Billing/YearlyTermTest.php b/tests/Feature/Billing/YearlyTermTest.php index ee4eebb..565f393 100644 --- a/tests/Feature/Billing/YearlyTermTest.php +++ b/tests/Feature/Billing/YearlyTermTest.php @@ -100,9 +100,9 @@ it('links to the checkout with one query string, not two question marks', functi it('keeps the saving above the switch, where it cannot move the buttons', function () { // It hung underneath and was hidden on the yearly view, so choosing a term // moved the very buttons that choose it. - $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php')); + $page = File::get(resource_path('views/livewire/order.blade.php')); - expect(strpos($page, "order.free_months_hint"))->toBeLessThan(strpos($page, "order.term_'.\$option")) + expect(strpos($page, 'order.free_months_hint'))->toBeLessThan(strpos($page, "order.term_'.\$option")) // And it is always there: one line, two wordings, no appearing and // disappearing. ->and($page)->toContain('order.free_months_taken');