Stripe-Client: offene Rechnungen listen und einziehen
parent
0ae752d2ae
commit
82c6d62057
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue