mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
371 lines
14 KiB
PHP
371 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Jobs\GeneratePaymentPdfJob;
|
|
use App\Models\Currency;
|
|
use App\Models\Customer;
|
|
use App\Models\Invoice;
|
|
use App\Models\Payment;
|
|
use App\Models\PaymentAllocation;
|
|
use App\Models\User;
|
|
use App\Services\Document\PaymentAllocationService;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Silber\Bouncer\BouncerFacade;
|
|
|
|
beforeEach(function () {
|
|
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
|
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
|
});
|
|
|
|
function allocatableInvoice(int $amount = 10000): Invoice
|
|
{
|
|
return Invoice::factory()->create([
|
|
'type' => Invoice::TYPE_INVOICE,
|
|
'status' => Invoice::STATUS_SENT,
|
|
'sent' => true,
|
|
'viewed' => false,
|
|
'paid_status' => Invoice::STATUS_UNPAID,
|
|
'sub_total' => $amount,
|
|
'total' => $amount,
|
|
'due_amount' => $amount,
|
|
'exchange_rate' => 1,
|
|
'base_sub_total' => $amount,
|
|
'base_total' => $amount,
|
|
'base_due_amount' => $amount,
|
|
]);
|
|
}
|
|
|
|
function allocationPayment(Invoice $invoice, int $amount): Payment
|
|
{
|
|
return Payment::factory()->create([
|
|
'company_id' => $invoice->company_id,
|
|
'customer_id' => $invoice->customer_id,
|
|
'currency_id' => $invoice->currency_id,
|
|
'amount' => $amount,
|
|
'base_amount' => $amount,
|
|
'exchange_rate' => 1,
|
|
]);
|
|
}
|
|
|
|
test('a payment can be allocated across multiple invoices and retain credit', function () {
|
|
$first = allocatableInvoice(300);
|
|
$second = allocatableInvoice(400);
|
|
$third = allocatableInvoice(200);
|
|
|
|
foreach ([$second, $third] as $invoice) {
|
|
$invoice->update([
|
|
'company_id' => $first->company_id,
|
|
'customer_id' => $first->customer_id,
|
|
'currency_id' => $first->currency_id,
|
|
]);
|
|
}
|
|
|
|
$payment = allocationPayment($first, 1000);
|
|
|
|
app(PaymentAllocationService::class)->replace($payment, [
|
|
['invoice_id' => $first->id, 'amount' => 300],
|
|
['invoice_id' => $second->id, 'amount' => 400],
|
|
['invoice_id' => $third->id, 'amount' => 200],
|
|
]);
|
|
|
|
expect(PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(900)
|
|
->and($first->fresh()->due_amount)->toBe(0)
|
|
->and($second->fresh()->due_amount)->toBe(0)
|
|
->and($third->fresh()->due_amount)->toBe(0)
|
|
->and($payment->fresh()->amount - PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(100);
|
|
});
|
|
|
|
test('replacing allocations recalculates both old and new invoice balances', function () {
|
|
$first = allocatableInvoice(500);
|
|
$second = allocatableInvoice(500);
|
|
$second->update([
|
|
'company_id' => $first->company_id,
|
|
'customer_id' => $first->customer_id,
|
|
'currency_id' => $first->currency_id,
|
|
]);
|
|
$payment = allocationPayment($first, 500);
|
|
$service = app(PaymentAllocationService::class);
|
|
|
|
$service->replace($payment, [['invoice_id' => $first->id, 'amount' => 500]]);
|
|
$service->replace($payment, [['invoice_id' => $second->id, 'amount' => 500]]);
|
|
|
|
expect($first->fresh()->due_amount)->toBe(500)
|
|
->and($first->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID)
|
|
->and($second->fresh()->due_amount)->toBe(0)
|
|
->and($second->fresh()->paid_status)->toBe(Invoice::STATUS_PAID);
|
|
});
|
|
|
|
test('allocation over an invoice balance is rejected without changing allocations', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
$payment = allocationPayment($invoice, 101);
|
|
|
|
expect(fn () => app(PaymentAllocationService::class)->replace($payment, [
|
|
['invoice_id' => $invoice->id, 'amount' => 101],
|
|
]))->toThrow(ValidationException::class);
|
|
|
|
expect(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse()
|
|
->and($invoice->fresh()->due_amount)->toBe(100);
|
|
});
|
|
|
|
test('allocations reject mismatched currencies, customers, drafts, and credit notes', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
$payment = allocationPayment($invoice, 100);
|
|
$otherCustomer = Customer::factory()->create([
|
|
'company_id' => $invoice->company_id,
|
|
'currency_id' => $invoice->currency_id,
|
|
]);
|
|
$otherCurrency = Currency::query()->whereKeyNot($invoice->currency_id)->firstOrFail();
|
|
|
|
$invalidInvoices = collect([
|
|
allocatableInvoice(100)->forceFill([
|
|
'company_id' => $invoice->company_id,
|
|
'customer_id' => $otherCustomer->id,
|
|
'currency_id' => $invoice->currency_id,
|
|
]),
|
|
allocatableInvoice(100)->forceFill([
|
|
'company_id' => $invoice->company_id,
|
|
'customer_id' => $invoice->customer_id,
|
|
'currency_id' => $otherCurrency->id,
|
|
]),
|
|
allocatableInvoice(100)->forceFill([
|
|
'company_id' => $invoice->company_id,
|
|
'customer_id' => $invoice->customer_id,
|
|
'currency_id' => $invoice->currency_id,
|
|
'status' => Invoice::STATUS_DRAFT,
|
|
]),
|
|
allocatableInvoice(100)->forceFill([
|
|
'company_id' => $invoice->company_id,
|
|
'customer_id' => $invoice->customer_id,
|
|
'currency_id' => $invoice->currency_id,
|
|
'type' => Invoice::TYPE_CREDIT_NOTE,
|
|
]),
|
|
])->each->save();
|
|
|
|
foreach ($invalidInvoices as $invalidInvoice) {
|
|
expect(fn () => app(PaymentAllocationService::class)->replace($payment, [[
|
|
'invoice_id' => $invalidInvoice->id,
|
|
'amount' => 100,
|
|
]]))->toThrow(ValidationException::class);
|
|
}
|
|
|
|
expect($payment->allocations()->exists())->toBeFalse();
|
|
});
|
|
|
|
test('duplicate targets and totals above the payment amount are rejected', function () {
|
|
$first = allocatableInvoice(100);
|
|
$second = allocatableInvoice(100);
|
|
$second->update([
|
|
'company_id' => $first->company_id,
|
|
'customer_id' => $first->customer_id,
|
|
'currency_id' => $first->currency_id,
|
|
]);
|
|
$payment = allocationPayment($first, 100);
|
|
$service = app(PaymentAllocationService::class);
|
|
|
|
expect(fn () => $service->replace($payment, [
|
|
['invoice_id' => $first->id, 'amount' => 50],
|
|
['invoice_id' => $first->id, 'amount' => 50],
|
|
]))->toThrow(ValidationException::class)
|
|
->and(fn () => $service->replace($payment, [
|
|
['invoice_id' => $first->id, 'amount' => 60],
|
|
['invoice_id' => $second->id, 'amount' => 50],
|
|
]))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('payment PDF generation is deferred until a successful allocation transaction commits', function () {
|
|
Queue::fake();
|
|
$invoice = allocatableInvoice(100);
|
|
|
|
DB::transaction(function () use ($invoice): void {
|
|
$payment = allocationPayment($invoice, 100);
|
|
app(PaymentAllocationService::class)->replace($payment, [
|
|
['invoice_id' => $invoice->id, 'amount' => 100],
|
|
]);
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
Queue::assertPushed(GeneratePaymentPdfJob::class);
|
|
|
|
Queue::fake();
|
|
|
|
expect(fn () => DB::transaction(function () use ($invoice): void {
|
|
$payment = allocationPayment($invoice, 100);
|
|
|
|
app(PaymentAllocationService::class)->replace($payment, [
|
|
['invoice_id' => $invoice->id, 'amount' => 101],
|
|
]);
|
|
}))->toThrow(ValidationException::class);
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('customer credit can be applied atomically to multiple invoices', function () {
|
|
$first = allocatableInvoice(100);
|
|
$second = allocatableInvoice(200);
|
|
$second->update([
|
|
'company_id' => $first->company_id,
|
|
'customer_id' => $first->customer_id,
|
|
'currency_id' => $first->currency_id,
|
|
]);
|
|
$payment = allocationPayment($first, 300);
|
|
|
|
app(PaymentAllocationService::class)->applyCustomerCredits(
|
|
$first->company_id,
|
|
$first->customer_id,
|
|
[
|
|
['payment_id' => $payment->id, 'invoice_id' => $first->id, 'amount' => 100],
|
|
['payment_id' => $payment->id, 'invoice_id' => $second->id, 'amount' => 200],
|
|
],
|
|
);
|
|
|
|
expect(PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(300)
|
|
->and($first->fresh()->due_amount)->toBe(0)
|
|
->and($second->fresh()->due_amount)->toBe(0);
|
|
});
|
|
|
|
test('creating payments does not authorize reallocating existing customer credit', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
$payment = allocationPayment($invoice, 100);
|
|
$user = User::factory()->create();
|
|
$user->companies()->attach($invoice->company_id);
|
|
|
|
BouncerFacade::scope()->to($invoice->company_id);
|
|
BouncerFacade::allow($user)->to('view-customer', Customer::class);
|
|
BouncerFacade::allow($user)->to('create-payment', Payment::class);
|
|
Sanctum::actingAs($user, ['*']);
|
|
$this->withHeaders(['company' => $invoice->company_id]);
|
|
|
|
$this->postJson("/api/v1/customers/{$invoice->customer_id}/credit-allocations", [
|
|
'allocations' => [[
|
|
'payment_id' => $payment->id,
|
|
'invoice_id' => $invoice->id,
|
|
'amount' => 100,
|
|
]],
|
|
])->assertForbidden();
|
|
});
|
|
|
|
test('the migration restores an invalid legacy link to unapplied credit and recalculates its invoice', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
$invoice->update([
|
|
'due_amount' => 0,
|
|
'base_due_amount' => 0,
|
|
'status' => Invoice::STATUS_COMPLETED,
|
|
'paid_status' => Invoice::STATUS_PAID,
|
|
]);
|
|
$otherCustomer = Customer::factory()->create([
|
|
'company_id' => $invoice->company_id,
|
|
'currency_id' => $invoice->currency_id,
|
|
]);
|
|
$payment = Payment::factory()->create([
|
|
'company_id' => $invoice->company_id,
|
|
'customer_id' => $otherCustomer->id,
|
|
'currency_id' => $invoice->currency_id,
|
|
'amount' => 100,
|
|
'base_amount' => 100,
|
|
'exchange_rate' => 1,
|
|
]);
|
|
|
|
Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index());
|
|
Payment::query()->whereKey($payment->id)->update(['invoice_id' => $invoice->id]);
|
|
|
|
(require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up();
|
|
|
|
expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
|
|
->and(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse()
|
|
->and($invoice->fresh()->due_amount)->toBe(100)
|
|
->and($invoice->fresh()->status)->toBe(Invoice::STATUS_SENT)
|
|
->and($invoice->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID);
|
|
});
|
|
|
|
test('the migration leaves an invalid legacy credit note target unchanged', function () {
|
|
$creditNote = allocatableInvoice(100);
|
|
$creditNote->update([
|
|
'type' => Invoice::TYPE_CREDIT_NOTE,
|
|
'status' => Invoice::STATUS_SENT,
|
|
'paid_status' => Invoice::STATUS_UNPAID,
|
|
'due_amount' => 100,
|
|
'base_due_amount' => 100,
|
|
]);
|
|
$payment = allocationPayment($creditNote, 100);
|
|
|
|
Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index());
|
|
Payment::query()->whereKey($payment->id)->update(['invoice_id' => $creditNote->id]);
|
|
|
|
(require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up();
|
|
|
|
expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
|
|
->and(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse()
|
|
->and($creditNote->fresh()->status)->toBe(Invoice::STATUS_SENT)
|
|
->and($creditNote->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID)
|
|
->and($creditNote->fresh()->due_amount)->toBe(100);
|
|
});
|
|
|
|
test('the migration allocates only the payable portion of an overpaid legacy payment', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
$payment = allocationPayment($invoice, 150);
|
|
|
|
Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index());
|
|
Payment::query()->whereKey($payment->id)->update(['invoice_id' => $invoice->id]);
|
|
|
|
(require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up();
|
|
|
|
expect(PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(100)
|
|
->and($invoice->fresh()->due_amount)->toBe(0)
|
|
->and($invoice->fresh()->status)->toBe(Invoice::STATUS_COMPLETED)
|
|
->and($payment->amount - PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(50);
|
|
});
|
|
|
|
test('the migration refuses rollback when a payment retains unapplied credit', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
$payment = allocationPayment($invoice, 150);
|
|
PaymentAllocation::create([
|
|
'payment_id' => $payment->id,
|
|
'invoice_id' => $invoice->id,
|
|
'amount' => 100,
|
|
'base_amount' => 100,
|
|
]);
|
|
|
|
$migration = require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php');
|
|
|
|
expect(fn () => $migration->down())->toThrow(RuntimeException::class, 'Cannot roll back payment allocations with unapplied customer credit.');
|
|
});
|
|
|
|
test('a partial migration run with no legacy column still verifies existing allocations', function () {
|
|
$invoice = Invoice::factory()->create([
|
|
'type' => Invoice::TYPE_INVOICE,
|
|
'status' => Invoice::STATUS_DRAFT,
|
|
]);
|
|
$payment = allocationPayment($invoice, 100);
|
|
PaymentAllocation::create([
|
|
'payment_id' => $payment->id,
|
|
'invoice_id' => $invoice->id,
|
|
'amount' => 100,
|
|
'base_amount' => 100,
|
|
]);
|
|
|
|
$migration = require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php');
|
|
|
|
expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
|
|
->and(fn () => $migration->up())->toThrow(RuntimeException::class, 'allocation target is not payable');
|
|
});
|
|
|
|
test('a partial migration run rejects allocations whose payment no longer exists', function () {
|
|
$invoice = allocatableInvoice(100);
|
|
PaymentAllocation::create([
|
|
'payment_id' => 999999,
|
|
'invoice_id' => $invoice->id,
|
|
'amount' => 100,
|
|
'base_amount' => 100,
|
|
]);
|
|
|
|
$migration = require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php');
|
|
|
|
expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
|
|
->and(fn () => $migration->up())->toThrow(RuntimeException::class, 'allocation payment is missing');
|
|
});
|