mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-03-03 02:57:01 +00:00
Fixes for client balance
This commit is contained in:
@@ -20,36 +20,36 @@ use Illuminate\Validation\Validator;
|
||||
class AddTaxIdentifierRequest extends FormRequest
|
||||
{
|
||||
public static array $vat_regex_patterns = [
|
||||
'GB' => '/^GB(\d{9}|\d{12})$/', // Great Britain
|
||||
'DE' => '/^DE\d{9}$/', // Germany
|
||||
'AT' => '/^ATU\d{8}$/', // Austria
|
||||
'BE' => '/^BE[0-1]\d{9}$/', // Belgium
|
||||
'BG' => '/^BG\d{9,10}$/', // Bulgaria
|
||||
'CY' => '/^CY\d{8}[A-Z]$/', // Cyprus
|
||||
'HR' => '/^HR\d{11}$/', // Croatia
|
||||
'DK' => '/^DK\d{8}$/', // Denmark
|
||||
'ES' => '/^ES[A-Z0-9]\d{7}[A-Z0-9]$/', // Spain
|
||||
'EE' => '/^EE\d{9}$/', // Estonia
|
||||
'FI' => '/^FI\d{8}$/', // Finland
|
||||
'FR' => '/^FR[A-Z0-9]{2}\d{9}$/', // France
|
||||
'EL' => '/^EL\d{9}$/', // Greece
|
||||
'AT' => '/^ATU\d{8}$/', // Austria
|
||||
'BE' => '/^BE[01]\d{9}$/', // Belgium
|
||||
'BG' => '/^BG\d{9,10}$/', // Bulgaria
|
||||
'CY' => '/^CY\d{8}[A-Z]$/', // Cyprus
|
||||
'CZ' => '/^CZ\d{8,10}$/', // Czech Republic
|
||||
'DE' => '/^DE\d{9}$/', // Germany
|
||||
'DK' => '/^DK\d{8}$/', // Denmark
|
||||
'EE' => '/^EE\d{9}$/', // Estonia
|
||||
'EL' => '/^EL\d{9}$/', // Greece
|
||||
'ES' => '/^ES[A-Z0-9]\d{7}[A-Z0-9]$/', // Spain
|
||||
'FI' => '/^FI\d{8}$/', // Finland
|
||||
'FR' => '/^FR[A-HJ-NP-Z0-9]{2}\d{9}$/', // France
|
||||
'GB' => '/^GB(\d{9}|\d{12})$/', // Great Britain
|
||||
'HR' => '/^HR\d{11}$/', // Croatia
|
||||
'HU' => '/^HU\d{8}$/', // Hungary
|
||||
'IE' => '/^IE\d{7}[A-WYZ][A-Z]?$/', // Ireland
|
||||
'IT' => '/^IT\d{11}$/', // Italy
|
||||
'IS' => '/^IS\d{10}|IS[\dA-Z]{6}$/', // Iceland
|
||||
'LV' => '/^LV\d{11}$/', // Latvia
|
||||
'LT' => '/^LT(\d{9}|\d{12})$/', // Lithuania
|
||||
'IE' => '/^IE\d[A-Z0-9+*]\d{5}[A-Z]{1,2}$/', // Ireland
|
||||
'IS' => '/^IS(\d{10}|[\dA-Z]{6})$/', // Iceland
|
||||
'IT' => '/^IT\d{11}$/', // Italy
|
||||
'LT' => '/^LT(\d{9}|\d{12})$/', // Lithuania
|
||||
'LU' => '/^LU\d{8}$/', // Luxembourg
|
||||
'LV' => '/^LV\d{11}$/', // Latvia
|
||||
'MT' => '/^MT\d{8}$/', // Malta
|
||||
'NL' => '/^NL\d{9}B\d{2}$/', // Netherlands
|
||||
'NO' => '/^NO\d{9}MVA$/', // Norway
|
||||
'NL' => '/^NL\d{9}B\d{2}$/', // Netherlands
|
||||
'NO' => '/^NO\d{9}MVA$/', // Norway
|
||||
'PL' => '/^PL\d{10}$/', // Poland
|
||||
'PT' => '/^PT\d{9}$/', // Portugal
|
||||
'CZ' => '/^CZ\d{8,10}$/', // Czech Republic
|
||||
'RO' => '/^RO\d{2,10}$/', // Romania
|
||||
'SK' => '/^SK\d{10}$/', // Slovakia
|
||||
'RO' => '/^RO\d{2,10}$/', // Romania
|
||||
'SE' => '/^SE\d{12}$/', // Sweden
|
||||
'SI' => '/^SI\d{8}$/', // Slovenia
|
||||
'SE' => '/^SE\d{12}$/', // Sweden
|
||||
'SK' => '/^SK\d{10}$/', // Slovakia
|
||||
];
|
||||
|
||||
public function authorize(): bool
|
||||
|
||||
@@ -84,8 +84,8 @@ class EntityLevel implements EntityLevelInterface
|
||||
* Patterns allow optional country prefix (e.g., "AT" or "ATU12345678").
|
||||
*/
|
||||
private array $vat_number_regex = [
|
||||
'AT' => '/^(AT)?U\d{9}$/i', // Austria: U + 9 digits
|
||||
'BE' => '/^(BE)?0\d{9}$/i', // Belgium: 0 + 9 digits
|
||||
'AT' => '/^(AT)?U\d{8}$/i', // Austria: U + 8 digits
|
||||
'BE' => '/^(BE)?[01]\d{9}$/i', // Belgium: 0 or 1 + 9 digits
|
||||
'BG' => '/^(BG)?\d{9,10}$/i', // Bulgaria: 9-10 digits
|
||||
'CY' => '/^(CY)?\d{8}[A-Z]$/i', // Cyprus: 8 digits + 1 letter
|
||||
'CZ' => '/^(CZ)?\d{8,10}$/i', // Czech Republic: 8-10 digits
|
||||
|
||||
297
tests/Feature/InvoiceBalanceTest.php
Normal file
297
tests/Feature/InvoiceBalanceTest.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Client;
|
||||
use App\Models\Invoice;
|
||||
use Tests\MockAccountData;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use App\Factory\InvoiceItemFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
class InvoiceBalanceTest extends TestCase
|
||||
{
|
||||
use MakesHash;
|
||||
use DatabaseTransactions;
|
||||
use MockAccountData;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Session::start();
|
||||
Model::reguard();
|
||||
|
||||
$this->makeTestData();
|
||||
}
|
||||
|
||||
private function makeLineItems(float $cost, int $quantity = 1): array
|
||||
{
|
||||
$item = InvoiceItemFactory::create();
|
||||
$item->quantity = $quantity;
|
||||
$item->cost = $cost;
|
||||
|
||||
return [(array) $item];
|
||||
}
|
||||
|
||||
private function createInvoiceViaApi(string $clientHashId, array $lineItems): array
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->postJson('/api/v1/invoices', [
|
||||
'status_id' => 1,
|
||||
'number' => '',
|
||||
'discount' => 0,
|
||||
'is_amount_discount' => 1,
|
||||
'client_id' => $clientHashId,
|
||||
'line_items' => $lineItems,
|
||||
])->assertStatus(200);
|
||||
|
||||
return $response->json()['data'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that editing a SENT invoice's amount correctly updates the client balance.
|
||||
* This is the scenario: invoice at $756, updated to $805.
|
||||
*/
|
||||
public function test_client_balance_updates_when_sent_invoice_amount_changes()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
$client->balance = 0;
|
||||
$client->saveQuietly();
|
||||
|
||||
// Create invoice at $756 and mark sent
|
||||
$line_items = $this->makeLineItems(756);
|
||||
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
|
||||
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
|
||||
$invoice = $invoice->service()->markSent()->save();
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(756, $client->balance, 'Client balance should be 756 after marking sent');
|
||||
|
||||
// Now update the invoice amount to $805 via PUT (the alternativeSave path)
|
||||
$new_line_items = $this->makeLineItems(805);
|
||||
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->putJson('/api/v1/invoices/' . $data['id'], [
|
||||
'client_id' => $client->hashed_id,
|
||||
'line_items' => $new_line_items,
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$invoice->refresh();
|
||||
|
||||
$this->assertEquals(805, $invoice->amount, 'Invoice amount should be 805');
|
||||
$this->assertEquals(805, $invoice->balance, 'Invoice balance should be 805');
|
||||
$this->assertEquals(805, $client->balance, 'Client balance should be 805 after editing sent invoice');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that editing a SENT invoice then paying in full results in zero balance.
|
||||
* Reproduces the exact $756 -> $805 -> pay $805 -> balance should be $0 scenario.
|
||||
*/
|
||||
public function test_edit_sent_invoice_then_full_payment_results_in_zero_balance()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
$client->balance = 0;
|
||||
$client->saveQuietly();
|
||||
|
||||
// Create invoice at $756 and mark sent
|
||||
$line_items = $this->makeLineItems(756);
|
||||
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
|
||||
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
|
||||
$invoice = $invoice->service()->markSent()->save();
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(756, $client->balance);
|
||||
|
||||
// Update to $805
|
||||
$new_line_items = $this->makeLineItems(805);
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->putJson('/api/v1/invoices/' . $data['id'], [
|
||||
'client_id' => $client->hashed_id,
|
||||
'line_items' => $new_line_items,
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(805, $client->balance, 'Client balance should be 805 after edit');
|
||||
|
||||
// Pay in full - $805
|
||||
$invoice->refresh();
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->postJson('/api/v1/payments', [
|
||||
'amount' => 805,
|
||||
'client_id' => $client->hashed_id,
|
||||
'invoices' => [
|
||||
[
|
||||
'invoice_id' => $invoice->hashed_id,
|
||||
'amount' => 805,
|
||||
],
|
||||
],
|
||||
'date' => '2024/01/01',
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$invoice->refresh();
|
||||
|
||||
$this->assertEquals(0, $invoice->balance, 'Invoice balance should be 0 after full payment');
|
||||
$this->assertEquals(0, $client->balance, 'Client balance should be 0 after full payment');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that marking a draft invoice as sent only increments client balance once.
|
||||
*/
|
||||
public function test_mark_sent_increments_client_balance_once()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
$client->balance = 0;
|
||||
$client->saveQuietly();
|
||||
|
||||
$line_items = $this->makeLineItems(500);
|
||||
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(0, $client->balance, 'Client balance should be 0 for draft invoice');
|
||||
|
||||
// Mark sent via triggered actions (PUT with mark_sent=true)
|
||||
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->putJson('/api/v1/invoices/' . $data['id'] . '?mark_sent=true', [
|
||||
'client_id' => $client->hashed_id,
|
||||
'line_items' => $this->makeLineItems(500),
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(500, $client->balance, 'Client balance should be 500 after mark_sent (not double)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that decreasing a sent invoice amount correctly reduces client balance.
|
||||
*/
|
||||
public function test_decreasing_sent_invoice_amount_reduces_client_balance()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
$client->balance = 0;
|
||||
$client->saveQuietly();
|
||||
|
||||
// Create and mark sent at $1000
|
||||
$line_items = $this->makeLineItems(1000);
|
||||
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
|
||||
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
|
||||
$invoice = $invoice->service()->markSent()->save();
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(1000, $client->balance);
|
||||
|
||||
// Reduce to $750
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->putJson('/api/v1/invoices/' . $data['id'], [
|
||||
'client_id' => $client->hashed_id,
|
||||
'line_items' => $this->makeLineItems(750),
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(750, $client->balance, 'Client balance should decrease to 750');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that editing a sent invoice with no amount change does not affect client balance.
|
||||
*/
|
||||
public function test_editing_sent_invoice_notes_does_not_change_balance()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
$client->balance = 0;
|
||||
$client->saveQuietly();
|
||||
|
||||
$line_items = $this->makeLineItems(300);
|
||||
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
|
||||
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
|
||||
$invoice = $invoice->service()->markSent()->save();
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(300, $client->balance);
|
||||
|
||||
// Update only notes, not line items
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->putJson('/api/v1/invoices/' . $data['id'], [
|
||||
'client_id' => $client->hashed_id,
|
||||
'public_notes' => 'Updated notes only',
|
||||
'line_items' => $this->makeLineItems(300),
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(300, $client->balance, 'Client balance should remain 300 when amount unchanged');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that multiple sent invoices for same client have correct cumulative balance.
|
||||
*/
|
||||
public function test_multiple_sent_invoices_correct_cumulative_balance()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
$client->balance = 0;
|
||||
$client->saveQuietly();
|
||||
|
||||
// Invoice 1: $200
|
||||
$data1 = $this->createInvoiceViaApi($client->hashed_id, $this->makeLineItems(200));
|
||||
$inv1 = Invoice::find($this->decodePrimaryKey($data1['id']));
|
||||
$inv1->service()->markSent()->save();
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(200, $client->balance);
|
||||
|
||||
// Invoice 2: $300
|
||||
$data2 = $this->createInvoiceViaApi($client->hashed_id, $this->makeLineItems(300));
|
||||
$inv2 = Invoice::find($this->decodePrimaryKey($data2['id']));
|
||||
$inv2->service()->markSent()->save();
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(500, $client->balance);
|
||||
|
||||
// Edit invoice 1 from $200 to $350
|
||||
$this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->putJson('/api/v1/invoices/' . $data1['id'], [
|
||||
'client_id' => $client->hashed_id,
|
||||
'line_items' => $this->makeLineItems(350),
|
||||
])->assertStatus(200);
|
||||
|
||||
$client->refresh();
|
||||
$this->assertEquals(650, $client->balance, 'Client balance should be 350 + 300 = 650');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user