Stripe-Client: offene Rechnungen listen und einziehen

main
nexxo 2026-07-31 20:04:09 +02:00
parent 0ae752d2ae
commit 82c6d62057
14 changed files with 212 additions and 39 deletions

View File

@ -610,4 +610,32 @@ class FakeStripeClient implements StripeClient
'exp_year' => 2030,
];
}
/** @var array<int, array<string, mixed>> Was openInvoices() zurückgibt. */
public array $openInvoiceRows = [];
public bool $payDeclines = false;
/** @var array<int, string> */
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];
}
}

View File

@ -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', ''),
];
}
}

View File

@ -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<int, array{id: string, number: ?string, amount_due_cents: int, currency: string, created_at: string}>
*/
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;
}

View File

@ -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();

View File

@ -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 () {

View File

@ -1,5 +1,8 @@
<?php
use App\Livewire\Admin\EditExportTarget;
use App\Models\ExportTarget;
use App\Services\Secrets\SecretCipher;
use Livewire\Livewire;
it('shows host, port and user only once SFTP is chosen', function () {
@ -8,13 +11,13 @@ it('shows host, port and user only once SFTP is chosen', function () {
// A mounted directory has no host: the mount is made on the server with
// mount(8), and the application only names the directory it writes into.
$c->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');
});

View File

@ -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);
});

View File

@ -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;

View File

@ -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]);

View File

@ -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 todays 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();
});

View File

@ -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();

View File

@ -0,0 +1,64 @@
<?php
use App\Services\Stripe\HttpStripeClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config()->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();
});

View File

@ -1,6 +1,8 @@
<?php
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanChange;
use Illuminate\Support\Carbon;
@ -11,12 +13,12 @@ it('freezes what the customer bought against later price rises', function () {
// The owner doubles what Team costs, which means publishing a new version:
// the price of a published one is fixed, because someone may be standing at
// the checkout looking at it.
$catalogue = app(App\Services\Billing\PlanCatalogue::class);
$catalogue = app(PlanCatalogue::class);
$current = $catalogue->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)

View File

@ -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');