diff --git a/tests/Feature/DomainSpec/AccountsDomainTest.php b/tests/Feature/DomainSpec/AccountsDomainTest.php new file mode 100644 index 00000000..5ed9f16d --- /dev/null +++ b/tests/Feature/DomainSpec/AccountsDomainTest.php @@ -0,0 +1,161 @@ + 'DatabaseSeeder', '--force' => true]); + $this->owner = User::where('role', 'super admin')->first(); + $this->companyId = $this->owner->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->owner, ['*']); +}); + +it('provisions a new company with the documented defaults', function () { + $countryId = DB::table('countries')->value('id'); + $currency = DB::table('currencies')->where('code', 'EUR')->value('id'); + + $company = postJson('/api/v1/companies', [ + 'name' => 'Fresh GmbH', 'currency' => $currency, 'address' => ['country_id' => $countryId], + ])->assertSuccessful()->json('data'); + + $settings = DB::table('company_settings')->where('company_id', $company['id']) + ->pluck('value', 'option'); + expect($settings['time_zone'])->toBe('Asia/Kolkata'); + expect($settings['language'])->toBe('en'); + expect($settings['fiscal_year'])->toBe('1-12'); + expect($settings['invoice_number_format'])->toBe('{{SERIES:INV}}{{DELIMITER:-}}{{SEQUENCE:6}}'); + expect($settings['bulk_exchange_rate_configured'])->toBe('YES'); + expect((int) $settings['currency'])->toBe((int) $currency); + + expect(DB::table('payment_methods')->where('company_id', $company['id'])->pluck('name')->sort()->values()->all()) + ->toBe(['Bank Transfer', 'Cash', 'Check', 'Credit Card']); + expect(DB::table('units')->where('company_id', $company['id'])->count())->toBe(11); + + $roleId = DB::table('roles')->where('scope', $company['id'])->where('name', 'owner')->value('id'); + expect($roleId)->not->toBeNull(); + expect(DB::table('permissions')->where('entity_id', $roleId)->count()) + ->toBe(count(config('abilities.abilities'))); + expect((int) $company['owner_id'])->toBe($this->owner->id); +}); + +it('flips owner-only authorization when ownership is transferred', function () { + postJson('/api/v1/members', ['name' => 'Heir', 'email' => 'heir@x.test', 'password' => 'secret123', + 'companies' => [['id' => $this->companyId, 'role' => 'owner']]])->assertSuccessful(); + $heir = User::where('email', 'heir@x.test')->first(); + + postJson('/api/v1/company/settings', ['settings' => ['language' => 'de']])->assertOk(); + + postJson("/api/v1/transfer/ownership/{$heir->id}")->assertOk()->assertJson(['success' => true]); + + app('auth')->forgetGuards(); + Sanctum::actingAs($this->owner->fresh(), ['*']); + postJson('/api/v1/company/settings', ['settings' => ['language' => 'fr']])->assertForbidden(); + + app('auth')->forgetGuards(); + Sanctum::actingAs($heir, ['*']); + $this->withHeaders(['company' => $this->companyId]); + postJson('/api/v1/company/settings', ['settings' => ['language' => 'mk']])->assertOk(); +}); + +it('locks the company currency once transactions exist', function () { + $eur = DB::table('currencies')->where('code', 'EUR')->value('id'); + postJson('/api/v1/company/settings', ['settings' => ['currency' => (string) $eur]]) + ->assertOk()->assertJson(['success' => true]); + + postJson('/api/v1/customers', ['name' => 'Tx Customer'])->assertSuccessful(); + + $usd = DB::table('currencies')->where('code', 'USD')->value('id'); + postJson('/api/v1/company/settings', ['settings' => ['currency' => (string) $usd]]) + ->assertOk()->assertJson(['success' => false, + 'message' => 'Cannot update company currency after transactions are created.']); +}); + +it('requires the exact company name to delete, and never deletes users', function () { + postJson('/api/v1/companies/delete', ['name' => 'wrong']) + ->assertStatus(422)->assertJson(['error' => 'company_name_must_match_with_given_name']); + + $name = DB::table('companies')->where('id', $this->companyId)->value('name'); + $usersBefore = DB::table('users')->count(); + postJson('/api/v1/companies/delete', ['name' => $name])->assertOk()->assertJson(['success' => true]); + + expect(DB::table('companies')->where('id', $this->companyId)->exists())->toBeFalse(); + expect(DB::table('users')->count())->toBe($usersBefore); +}); + +it('silently swaps a foreign company header for the user’s first company', function () { + postJson('/api/v1/customers', ['name' => 'Visible'])->assertSuccessful(); + + $this->withHeaders(['company' => 999]); + $names = collect(getJson('/api/v1/customers')->assertOk()->json('data'))->pluck('name'); + expect($names)->toContain('Visible'); +}); + +it('excludes the requester from the member list but counts them in the meta', function () { + postJson('/api/v1/members', ['name' => 'M1', 'email' => 'm1@x.test', 'password' => 'secret123', + 'companies' => [['id' => $this->companyId, 'role' => 'owner']]])->assertSuccessful(); + postJson('/api/v1/members', ['name' => 'M2', 'email' => 'm2@x.test', 'password' => 'secret123', + 'companies' => [['id' => $this->companyId, 'role' => 'owner']]])->assertSuccessful(); + + $payload = getJson('/api/v1/members')->assertOk()->json(); + expect(collect($payload['data'])->pluck('email'))->not->toContain($this->owner->email); + expect(count($payload['data']))->toBe(2); + expect($payload['meta']['user_total_count'])->toBe(3); +}); + +it('scopes the role listing to the active company through the role-scope mechanism', function () { + $countryId = DB::table('countries')->value('id'); + $eur = DB::table('currencies')->where('code', 'EUR')->value('id'); + $second = postJson('/api/v1/companies', [ + 'name' => 'Second Co', 'currency' => $eur, 'address' => ['country_id' => $countryId], + ])->assertSuccessful()->json('data'); + expect(DB::table('roles')->distinct()->count('scope'))->toBeGreaterThan(1); + + // Despite taking a company_id filter, the listing is bounded by the active + // company's role scope: the default listing shows only the active company's + // roles, and a foreign company_id can only narrow that to nothing. + $default = collect(getJson('/api/v1/roles')->assertOk()->json('data')); + expect($default->count())->toBe(DB::table('roles')->where('scope', $this->companyId)->count()); + + $foreign = collect(getJson('/api/v1/roles?company_id='.$second['id'])->json('data')); + expect($foreign->count())->toBe(0); +}); + +it('syncs role abilities as grant-listed, revoke-unlisted', function () { + $abilities = fn (array $names) => array_map(fn ($a) => ['ability' => $a], $names); + $role = postJson('/api/v1/roles', ['name' => 'shape-shifter', + 'abilities' => $abilities(['view-item', 'view-customer'])])->assertSuccessful()->json('data'); + + $granted = collect(getJson("/api/v1/roles/{$role['id']}")->json('data.abilities'))->pluck('name'); + expect($granted)->toContain('view-item', 'view-customer')->not->toContain('view-invoice'); + + putJson("/api/v1/roles/{$role['id']}", ['name' => 'shape-shifter', + 'abilities' => $abilities(['view-invoice'])])->assertSuccessful(); + + // The authorization layer caches grants per process; a real deployment + // re-reads them per request. + BouncerFacade::refresh(); + $after = collect(getJson("/api/v1/roles/{$role['id']}")->json('data.abilities'))->pluck('name'); + expect($after)->toContain('view-invoice')->not->toContain('view-item'); +}); + +it('logs in case-insensitively and issues a bearer token', function () { + app('auth')->forgetGuards(); + $this->flushHeaders(); + + postJson('/api/v1/auth/login', [ + 'username' => 'ADMIN@InvoiceShelf.com', 'password' => 'invoiceshelf@123', 'device_name' => 'suite', + ])->assertOk()->assertJson(['type' => 'Bearer'])->assertJsonStructure(['token']); + + postJson('/api/v1/auth/login', [ + 'username' => 'admin@invoiceshelf.com', 'password' => 'wrong', 'device_name' => 'suite', + ])->assertStatus(422)->assertJsonPath('errors.email.0', 'The provided credentials are incorrect.'); +}); diff --git a/tests/Feature/DomainSpec/CatalogDomainTest.php b/tests/Feature/DomainSpec/CatalogDomainTest.php new file mode 100644 index 00000000..f2dc0a8b --- /dev/null +++ b/tests/Feature/DomainSpec/CatalogDomainTest.php @@ -0,0 +1,110 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); +}); + +it('stamps the item currency from the company setting, ignoring the payload', function () { + $companyCurrency = (int) DB::table('company_settings')->where('company_id', $this->companyId) + ->where('option', 'currency')->value('value'); + $other = DB::table('currencies')->where('id', '!=', $companyCurrency)->value('id'); + + $item = postJson('/api/v1/items', ['name' => 'Widget', 'price' => 500, 'currency_id' => $other]) + ->assertSuccessful()->json('data'); + + expect((int) $item['currency_id'])->toBe($companyCurrency); +}); + +it('raises the per-item-tax flag on attach and never lowers it', function () { + $typeId = postJson('/api/v1/tax-types', ['name' => 'ItemTax', 'calculation_type' => 'percentage', 'percent' => 10]) + ->json('data.id'); + + $id = postJson('/api/v1/items', ['name' => 'Flagged', 'price' => 100])->json('data.id'); + expect((bool) DB::table('items')->where('id', $id)->value('tax_per_item'))->toBeFalse(); + + putJson("/api/v1/items/{$id}", ['name' => 'Flagged', 'price' => 100, + 'taxes' => [['tax_type_id' => $typeId, 'name' => 'ItemTax', 'percent' => 10, 'amount' => 10]], + ])->assertSuccessful(); + expect((bool) DB::table('items')->where('id', $id)->value('tax_per_item'))->toBeTrue(); + + // Replacing with an empty list removes the taxes but leaves the flag raised. + putJson("/api/v1/items/{$id}", ['name' => 'Flagged', 'price' => 100, 'taxes' => []])->assertSuccessful(); + expect(DB::table('taxes')->where('item_id', $id)->count())->toBe(0); + expect((bool) DB::table('items')->where('id', $id)->value('tax_per_item'))->toBeTrue(); +}); + +it('blocks bulk deletion of items that still carry their own taxes', function () { + $typeId = postJson('/api/v1/tax-types', ['name' => 'BlockTax', 'calculation_type' => 'percentage', 'percent' => 5]) + ->json('data.id'); + $id = postJson('/api/v1/items', ['name' => 'Undeletable', 'price' => 100, + 'taxes' => [['tax_type_id' => $typeId, 'name' => 'BlockTax', 'percent' => 5, 'amount' => 5]], + ])->json('data.id'); + + postJson('/api/v1/items/delete', ['ids' => [$id]])->assertStatus(422); + + putJson("/api/v1/items/{$id}", ['name' => 'Undeletable', 'price' => 100, 'taxes' => []])->assertSuccessful(); + postJson('/api/v1/items/delete', ['ids' => [$id]])->assertOk()->assertJson(['success' => true]); + expect(DB::table('items')->where('id', $id)->exists())->toBeFalse(); +}); + +it('resolves unit names on the listing and returns only sales tax types in meta', function () { + postJson('/api/v1/tax-types', ['name' => 'SalesT', 'calculation_type' => 'percentage', 'percent' => 1])->assertSuccessful(); + postJson('/api/v1/tax-types', ['name' => 'PurchT', 'calculation_type' => 'percentage', 'percent' => 2, + 'transaction_type' => 'purchases'])->assertSuccessful(); + + $unitId = DB::table('units')->where('company_id', $this->companyId)->value('id'); + postJson('/api/v1/items', ['name' => 'WithUnit', 'price' => 10, 'unit_id' => $unitId])->assertSuccessful(); + postJson('/api/v1/items', ['name' => 'NoUnit', 'price' => 10])->assertSuccessful(); + + $payload = getJson('/api/v1/items?limit=all')->assertOk()->json(); + $byName = collect($payload['data'])->keyBy('name'); + expect($byName['WithUnit']['unit']['name'] ?? null)->not->toBeNull(); + expect($byName['NoUnit']['unit'] ?? null)->toBeNull(); + $metaTypes = collect($payload['meta']['tax_types'])->pluck('name'); + expect($metaTypes)->toContain('SalesT')->not->toContain('PurchT'); +}); + +it('enforces unit name uniqueness per company and refuses deleting a used unit', function () { + $unitId = postJson('/api/v1/units', ['name' => 'crate'])->assertSuccessful()->json('data.id'); + postJson('/api/v1/units', ['name' => 'crate'])->assertStatus(422)->assertJsonValidationErrors(['name']); + + postJson('/api/v1/items', ['name' => 'Crated', 'price' => 10, 'unit_id' => $unitId])->assertSuccessful(); + deleteJson("/api/v1/units/{$unitId}")->assertStatus(422)->assertJson(['error' => 'items_attached']); + + $freeId = postJson('/api/v1/units', ['name' => 'pallet'])->json('data.id'); + deleteJson("/api/v1/units/{$freeId}")->assertOk() + ->assertJson(['success' => 'Unit deleted successfully']); +}); + +it('gates every unit action on the item view ability alone', function () { + $abilities = fn (array $names) => array_map(fn ($a) => ['ability' => $a], $names); + + postJson('/api/v1/roles', ['name' => 'item-viewer', 'abilities' => $abilities(['view-item'])])->assertSuccessful(); + postJson('/api/v1/roles', ['name' => 'item-editor', 'abilities' => $abilities(['edit-item', 'create-item'])])->assertSuccessful(); + + postJson('/api/v1/members', ['name' => 'Viewer', 'email' => 'viewer@x.test', 'password' => 'secret123', + 'companies' => [['id' => $this->companyId, 'role' => 'item-viewer']]])->assertSuccessful(); + postJson('/api/v1/members', ['name' => 'Editor', 'email' => 'editor@x.test', 'password' => 'secret123', + 'companies' => [['id' => $this->companyId, 'role' => 'item-editor']]])->assertSuccessful(); + + Sanctum::actingAs(User::where('email', 'viewer@x.test')->first(), ['*']); + postJson('/api/v1/units', ['name' => 'viewer-made'])->assertSuccessful(); + + Sanctum::actingAs(User::where('email', 'editor@x.test')->first(), ['*']); + getJson('/api/v1/units')->assertForbidden(); +}); diff --git a/tests/Feature/DomainSpec/ContactsDomainTest.php b/tests/Feature/DomainSpec/ContactsDomainTest.php new file mode 100644 index 00000000..40338608 --- /dev/null +++ b/tests/Feature/DomainSpec/ContactsDomainTest.php @@ -0,0 +1,133 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->companySlug = DB::table('companies')->where('id', $this->companyId)->value('slug'); + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); +}); + +it('requires every search term to match name, email or phone', function () { + postJson('/api/v1/customers', ['name' => 'Alice Wonder', 'email' => 'alice@x.test'])->assertSuccessful(); + postJson('/api/v1/customers', ['name' => 'Bob Wonder', 'email' => 'bob@x.test'])->assertSuccessful(); + + $names = collect(getJson('/api/v1/customers?search='.urlencode('Wonder alice'))->assertOk()->json('data')) + ->pluck('name'); + expect($names->all())->toBe(['Alice Wonder']); + + $both = collect(getJson('/api/v1/customers?search=Wonder')->json('data'))->pluck('name'); + expect($both)->toContain('Alice Wonder', 'Bob Wonder'); +}); + +it('locks the customer currency once any document exists', function () { + $usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $eur = DB::table('currencies')->where('code', 'EUR')->value('id'); + $id = postJson('/api/v1/customers', ['name' => 'Locked', 'currency_id' => $usd])->json('data.id'); + + putJson("/api/v1/customers/{$id}", ['name' => 'Locked', 'currency_id' => $eur])->assertSuccessful(); + + postJson('/api/v1/invoices', [ + 'invoice_date' => '2026-01-10', 'customer_id' => $id, 'invoice_number' => 'INV-LOCK-1', + 'discount' => 0, 'discount_val' => 0, 'sub_total' => 100, 'total' => 100, 'tax' => 0, + 'template_name' => 'invoice1', 'exchange_rate' => 2, 'currency_id' => $eur, + 'items' => [['name' => 'X', 'quantity' => 1, 'price' => 100, 'description' => '', + 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 100]], + ])->assertSuccessful(); + + putJson("/api/v1/customers/{$id}", ['name' => 'Locked', 'currency_id' => $usd]) + ->assertStatus(422)->assertJsonValidationErrors(['currency_id']); +}); + +it('replaces addresses wholesale on update — omitting them erases them', function () { + $id = postJson('/api/v1/customers', [ + 'name' => 'Addressed', + 'billing' => ['name' => 'Bill', 'city' => 'Skopje'], + 'shipping' => ['name' => 'Ship', 'city' => 'Ohrid'], + ])->assertSuccessful()->json('data.id'); + expect(DB::table('addresses')->where('customer_id', $id)->count())->toBe(2); + + putJson("/api/v1/customers/{$id}", ['name' => 'Addressed'])->assertSuccessful(); + expect(DB::table('addresses')->where('customer_id', $id)->count())->toBe(0); +}); + +it('purges the customer’s documents, payments and allocations on delete', function () { + $usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $id = postJson('/api/v1/customers', ['name' => 'Purged', 'currency_id' => $usd])->json('data.id'); + + $invoiceId = postJson('/api/v1/invoices', [ + 'invoice_date' => '2026-01-10', 'customer_id' => $id, 'invoice_number' => 'INV-PURGE-1', + 'discount' => 0, 'discount_val' => 0, 'sub_total' => 100, 'total' => 100, 'tax' => 0, + 'template_name' => 'invoice1', 'exchange_rate' => 2, 'currency_id' => $usd, + 'items' => [['name' => 'X', 'quantity' => 1, 'price' => 100, 'description' => '', + 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 100]], + ])->assertSuccessful()->json('data.id'); + + postJson("/api/v1/invoices/{$invoiceId}/status", ['status' => 'SENT'])->assertOk(); + + postJson('/api/v1/payments', [ + 'payment_date' => '2026-01-11', 'customer_id' => $id, 'amount' => 100, + 'payment_number' => 'PAY-PURGE-1', 'exchange_rate' => 2, + 'allocations' => [['invoice_id' => $invoiceId, 'amount' => 100]], + ])->assertSuccessful(); + + postJson('/api/v1/customers/delete', ['ids' => [$id]])->assertOk()->assertJson(['success' => true]); + + expect(DB::table('invoices')->where('customer_id', $id)->count())->toBe(0); + expect(DB::table('payments')->where('customer_id', $id)->count())->toBe(0); + expect(DB::table('payment_allocations')->count())->toBe(0); + expect(DB::table('customers')->where('id', $id)->exists())->toBeFalse(); +}); + +it('logs portal customers in case-insensitively and distinguishes the two failure modes', function () { + postJson('/api/v1/customers', [ + 'name' => 'Portal Kate', 'email' => 'kate@portal.test', 'password' => 'secret123', 'enable_portal' => true, + ])->assertSuccessful(); + + postJson("/{$this->companySlug}/customer/login", ['email' => 'KATE@Portal.TEST', 'password' => 'secret123']) + ->assertOk()->assertJson(['success' => true]); + + postJson("/{$this->companySlug}/customer/login", ['email' => 'kate@portal.test', 'password' => 'wrong']) + ->assertStatus(422)->assertJsonPath('errors.email.0', 'The provided credentials are incorrect.'); + + postJson('/api/v1/customers', [ + 'name' => 'No Portal', 'email' => 'nope@portal.test', 'password' => 'secret123', 'enable_portal' => false, + ])->assertSuccessful(); + postJson("/{$this->companySlug}/customer/login", ['email' => 'nope@portal.test', 'password' => 'secret123']) + ->assertStatus(422)->assertJsonPath('errors.email.0', 'Customer portal not available for this user.'); +}); + +it('revokes portal access immediately when the flag is switched off', function () { + $id = postJson('/api/v1/customers', [ + 'name' => 'Revoked', 'email' => 'rev@portal.test', 'password' => 'secret123', 'enable_portal' => true, + ])->json('data.id'); + + postJson("/{$this->companySlug}/customer/login", ['email' => 'rev@portal.test', 'password' => 'secret123']) + ->assertOk(); + getJson("/api/v1/{$this->companySlug}/customer/me")->assertOk(); + + putJson("/api/v1/customers/{$id}", ['name' => 'Revoked', 'email' => 'rev@portal.test', 'enable_portal' => false]) + ->assertSuccessful(); + expect((int) DB::table('customers')->where('id', $id)->value('enable_portal'))->toBe(0); + + // Force guard re-resolution: in-process tests cache the resolved customer, + // which a real per-request deployment never does. + app('auth')->forgetGuards(); + getJson("/api/v1/{$this->companySlug}/customer/me")->assertStatus(401); +}); + +it('exposes a missing avatar as the number zero', function () { + $id = postJson('/api/v1/customers', ['name' => 'Faceless'])->json('data.id'); + expect(getJson("/api/v1/customers/{$id}")->assertOk()->json('data.avatar'))->toBe(0); +}); diff --git a/tests/Feature/DomainSpec/MetadataDomainTest.php b/tests/Feature/DomainSpec/MetadataDomainTest.php new file mode 100644 index 00000000..3f3d45c3 --- /dev/null +++ b/tests/Feature/DomainSpec/MetadataDomainTest.php @@ -0,0 +1,89 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); +}); + +it('generates colliding slugs with suffixes and freezes them across renames', function () { + $a = postJson('/api/v1/custom-fields', ['name' => 'vat number', 'label' => 'VAT Number', + 'model_type' => 'Customer', 'order' => 1, 'type' => 'Input', 'is_required' => false]) + ->assertSuccessful()->json('data'); + $b = postJson('/api/v1/custom-fields', ['name' => 'vat number', 'label' => 'VAT Number Two', + 'model_type' => 'Customer', 'order' => 2, 'type' => 'Input', 'is_required' => false]) + ->assertSuccessful()->json('data'); + + expect($a['slug'])->toBe('CUSTOM_CUSTOMER_VAT_NUMBER'); + expect($b['slug'])->toBe('CUSTOM_CUSTOMER_VAT_NUMBER_1'); + + putJson("/api/v1/custom-fields/{$a['id']}", ['name' => 'renamed', 'label' => 'Totally Renamed', + 'model_type' => 'Customer', 'order' => 1, 'type' => 'Input', 'is_required' => false]) + ->assertSuccessful(); + expect(DB::table('custom_fields')->where('id', $a['id'])->value('slug')) + ->toBe('CUSTOM_CUSTOMER_VAT_NUMBER'); +}); + +it('normalises time default answers and round-trips per type', function () { + postJson('/api/v1/custom-fields', ['name' => 'opens at', 'label' => 'Opens At', + 'model_type' => 'Customer', 'order' => 1, 'type' => 'Time', 'is_required' => false, + 'default_answer' => '9:30 AM'])->assertSuccessful(); + expect(DB::table('custom_fields')->where('name', 'opens at')->value('time_answer'))->toBe('09:30:00'); +}); + +it('attaches and updates owner values in the mapped column, deleting them with the definition', function () { + $field = postJson('/api/v1/custom-fields', ['name' => 'nick', 'label' => 'Nickname', + 'model_type' => 'Customer', 'order' => 1, 'type' => 'Input', 'is_required' => false])->json('data'); + + $customerId = postJson('/api/v1/customers', ['name' => 'Fielded', + 'customFields' => [['id' => $field['id'], 'value' => 'Neo']]])->assertSuccessful()->json('data.id'); + $value = DB::table('custom_field_values')->where('custom_field_id', $field['id'])->first(); + expect($value->string_answer)->toBe('Neo'); + expect((int) $value->custom_field_valuable_id)->toBe($customerId); + + putJson("/api/v1/customers/{$customerId}", ['name' => 'Fielded', + 'customFields' => [['id' => $field['id'], 'value' => 'Morpheus']]])->assertSuccessful(); + expect(DB::table('custom_field_values')->where('custom_field_id', $field['id'])->count())->toBe(1); + expect(DB::table('custom_field_values')->where('custom_field_id', $field['id'])->value('string_answer')) + ->toBe('Morpheus'); + + deleteJson("/api/v1/custom-fields/{$field['id']}")->assertOk(); + expect(DB::table('custom_field_values')->where('custom_field_id', $field['id'])->count())->toBe(0); + expect(DB::table('custom_fields')->where('id', $field['id'])->exists())->toBeFalse(); +}); + +it('scopes note name uniqueness by company and type', function () { + postJson('/api/v1/notes', ['type' => 'Invoice', 'name' => 'Thanks', 'notes' => 'Thank you!', 'is_default' => false]) + ->assertSuccessful(); + postJson('/api/v1/notes', ['type' => 'Invoice', 'name' => 'Thanks', 'notes' => 'Again', 'is_default' => false]) + ->assertStatus(422)->assertJsonValidationErrors(['name']); + postJson('/api/v1/notes', ['type' => 'Estimate', 'name' => 'Thanks', 'notes' => 'Estimate note', 'is_default' => false]) + ->assertSuccessful(); +}); + +it('demotes other default notes of the type across companies — the known defect', function () { + $otherCompany = DB::table('companies')->insertGetId([ + 'name' => 'Other Co', 'slug' => 'other-co', 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('notes')->insert([ + 'type' => 'Invoice', 'name' => 'Their default', 'notes' => 'x', 'is_default' => true, + 'company_id' => $otherCompany, 'created_at' => now(), 'updated_at' => now(), + ]); + + postJson('/api/v1/notes', ['type' => 'Invoice', 'name' => 'Our default', 'notes' => 'y', 'is_default' => true]) + ->assertSuccessful(); + + expect((bool) DB::table('notes')->where('company_id', $otherCompany)->value('is_default'))->toBeFalse(); +}); diff --git a/tests/Feature/DomainSpec/MoneyDomainTest.php b/tests/Feature/DomainSpec/MoneyDomainTest.php new file mode 100644 index 00000000..1046e1ef --- /dev/null +++ b/tests/Feature/DomainSpec/MoneyDomainTest.php @@ -0,0 +1,126 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); +}); + +it('lists currencies common-first, then the rest by name', function () { + $codes = collect(getJson('/api/v1/currencies')->assertOk()->json('data'))->pluck('code'); + + expect($codes->take(10)->values()->all()) + ->toBe(['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'CNY', 'INR', 'BRL']); + + $restNames = collect(getJson('/api/v1/currencies')->json('data'))->skip(10)->pluck('name')->values(); + expect($restNames->all())->toBe($restNames->sort()->values()->all()); +}); + +it('creates a provider after live validation and enforces the one-active-provider-per-currency rule', function () { + Http::fake(['api.currencyfreaks.com/*' => Http::response(['rates' => ['INR' => '83.1']])]); + + postJson('/api/v1/exchange-rate-providers', [ + 'driver' => 'currency_freak', 'key' => 'k1', 'currencies' => ['USD'], 'active' => true, + ])->assertSuccessful(); + + postJson('/api/v1/exchange-rate-providers', [ + 'driver' => 'currency_freak', 'key' => 'k2', 'currencies' => ['USD'], 'active' => true, + ])->assertStatus(422)->assertJson(['error' => 'currency_used']); +}); + +it('maps an invalid provider key to the invalid-key error', function () { + Http::fake(['api.currencyfreaks.com/*' => Http::response([ + 'success' => false, 'error' => ['status' => 404, 'message' => 'bad key'], + ])]); + + postJson('/api/v1/exchange-rate-providers', [ + 'driver' => 'currency_freak', 'key' => 'bad', 'currencies' => ['USD'], 'active' => true, + ])->assertStatus(422)->assertJson(['error' => 'invalid_key']); +}); + +it('refuses to delete an active provider and deletes an inactive one', function () { + Http::fake(['api.currencyfreaks.com/*' => Http::response(['rates' => ['INR' => '83.1']])]); + $id = postJson('/api/v1/exchange-rate-providers', [ + 'driver' => 'currency_freak', 'key' => 'k', 'currencies' => ['USD'], 'active' => true, + ])->json('data.id'); + + deleteJson("/api/v1/exchange-rate-providers/{$id}") + ->assertStatus(422)->assertJson(['error' => 'provider_active']); + + DB::table('exchange_rate_providers')->where('id', $id)->update(['active' => false]); + deleteJson("/api/v1/exchange-rate-providers/{$id}")->assertOk()->assertJson(['success' => true]); +}); + +it('resolves rates live first, then from the log, then reports none', function () { + $usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $base = DB::table('company_settings')->where('company_id', $this->companyId) + ->where('option', 'currency')->value('value'); + + getJson("/api/v1/currencies/{$usd}/exchange-rate") + ->assertOk()->assertJson(['error' => 'no_exchange_rate_available']); + + DB::table('exchange_rate_logs')->insert([ + 'exchange_rate' => 82.5, 'base_currency_id' => $usd, 'currency_id' => $base, + 'company_id' => $this->companyId, 'created_at' => now(), 'updated_at' => now(), + ]); + getJson("/api/v1/currencies/{$usd}/exchange-rate") + ->assertOk()->assertJsonPath('exchangeRate.0', 82.5); + + Http::fake(['api.currencyfreaks.com/*' => Http::response(['rates' => ['INR' => '99.9']])]); + postJson('/api/v1/exchange-rate-providers', [ + 'driver' => 'currency_freak', 'key' => 'k', 'currencies' => ['USD'], 'active' => true, + ])->assertSuccessful(); + getJson("/api/v1/currencies/{$usd}/exchange-rate") + ->assertOk()->assertJsonPath('exchangeRate.0', '99.9'); +}); + +it('gates the historical backfill and reproduces its defective arithmetic', function () { + $usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $customerId = postJson('/api/v1/customers', ['name' => 'Backfill Co', 'currency_id' => $usd]) + ->assertSuccessful()->json('data.id'); + + $invoice = postJson('/api/v1/invoices', [ + 'invoice_date' => '2026-01-10', 'customer_id' => $customerId, + 'invoice_number' => 'INV-000001', 'discount' => 0, 'discount_val' => 0, + 'sub_total' => 1000, 'total' => 1000, 'tax' => 0, 'template_name' => 'invoice1', + 'exchange_rate' => 3, 'currency_id' => $usd, + 'items' => [['name' => 'Thing', 'quantity' => 1, 'price' => 1000, 'description' => '', + 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 1000]], + ])->assertSuccessful()->json('data'); + + // The gate: any value other than NO means nothing happens. + postJson('/api/v1/currencies/bulk-update-exchange-rate', [ + 'currencies' => [['id' => $usd, 'exchange_rate' => 2]], + ])->assertOk()->assertExactJson(['error' => false]); + + DB::table('company_settings')->where('company_id', $this->companyId) + ->where('option', 'bulk_exchange_rate_configured')->update(['value' => 'NO']); + DB::table('invoices')->where('id', $invoice['id'])->update(['exchange_rate' => null]); + + postJson('/api/v1/currencies/bulk-update-exchange-rate', [ + 'currencies' => [['id' => $usd, 'exchange_rate' => 2]], + ])->assertOk()->assertJson(['success' => true]); + + $row = DB::table('invoices')->where('id', $invoice['id'])->first(); + expect((float) $row->exchange_rate)->toBe(2.0); + expect((int) $row->base_sub_total)->toBe(2000); + expect((int) $row->base_total)->toBe(2000); + // Defect, kept deliberately: base discount sourced from the sub-total. + expect((int) $row->base_discount_val)->toBe(2000); + + expect(DB::table('company_settings')->where('company_id', $this->companyId) + ->where('option', 'bulk_exchange_rate_configured')->value('value'))->toBe('YES'); +}); diff --git a/tests/Feature/DomainSpec/PurchasesDomainTest.php b/tests/Feature/DomainSpec/PurchasesDomainTest.php new file mode 100644 index 00000000..4d1c0f17 --- /dev/null +++ b/tests/Feature/DomainSpec/PurchasesDomainTest.php @@ -0,0 +1,114 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); + $this->categoryId = postJson('/api/v1/categories', ['name' => 'Office'])->json('data.id'); + $this->companyCurrency = (int) DB::table('company_settings')->where('company_id', $this->companyId) + ->where('option', 'currency')->value('value'); + $this->usd = DB::table('currencies')->where('code', 'USD')->value('id'); +}); + +it('stores the submitted currency and applies the exchange-rate rules', function () { + postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-01', 'expense_category_id' => $this->categoryId, + 'amount' => 500, 'currency_id' => $this->usd, + ])->assertStatus(422)->assertJsonValidationErrors(['exchange_rate']); + + $foreign = postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-01', 'expense_category_id' => $this->categoryId, + 'amount' => 500, 'currency_id' => $this->usd, 'exchange_rate' => 3, + ])->assertSuccessful()->json('data'); + $row = DB::table('expenses')->where('id', $foreign['id'])->first(); + expect((int) $row->currency_id)->toBe((int) $this->usd); + expect((int) $row->base_amount)->toBe(1500); + + $home = postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-01', 'expense_category_id' => $this->categoryId, + 'amount' => 500, 'currency_id' => (string) $this->companyCurrency, + ])->assertSuccessful()->json('data'); + expect((int) DB::table('expenses')->where('id', $home['id'])->value('base_amount'))->toBe(500); +}); + +it('treats the tax list as absent-keeps, empty-clears, present-replaces — purchases types only', function () { + $purchType = postJson('/api/v1/tax-types', ['name' => 'PTax', 'calculation_type' => 'percentage', + 'percent' => 10, 'transaction_type' => 'purchases'])->json('data.id'); + $salesType = postJson('/api/v1/tax-types', ['name' => 'STax', 'calculation_type' => 'percentage', + 'percent' => 10])->json('data.id'); + + postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-02', 'expense_category_id' => $this->categoryId, + 'amount' => 100, 'currency_id' => (string) $this->companyCurrency, + 'taxes' => [['tax_type_id' => $salesType, 'amount' => 10]], + ])->assertStatus(422); + + $id = postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-02', 'expense_category_id' => $this->categoryId, + 'amount' => 100, 'currency_id' => (string) $this->companyCurrency, + 'taxes' => [['tax_type_id' => $purchType, 'amount' => 10]], + ])->assertSuccessful()->json('data.id'); + expect(DB::table('taxes')->where('expense_id', $id)->count())->toBe(1); + expect(DB::table('taxes')->where('expense_id', $id)->value('name'))->toBe('PTax'); + + putJson("/api/v1/expenses/{$id}", [ + 'expense_date' => '2026-02-02', 'expense_category_id' => $this->categoryId, + 'amount' => 100, 'currency_id' => (string) $this->companyCurrency, + ])->assertSuccessful(); + expect(DB::table('taxes')->where('expense_id', $id)->count())->toBe(1); + + putJson("/api/v1/expenses/{$id}", [ + 'expense_date' => '2026-02-02', 'expense_category_id' => $this->categoryId, + 'amount' => 100, 'currency_id' => (string) $this->companyCurrency, 'taxes' => [], + ])->assertSuccessful(); + expect(DB::table('taxes')->where('expense_id', $id)->count())->toBe(0); +}); + +it('attaches, replaces and serves a single receipt through the base64 endpoint', function () { + $id = postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-03', 'expense_category_id' => $this->categoryId, + 'amount' => 100, 'currency_id' => (string) $this->companyCurrency, + ])->json('data.id'); + + getJson("/api/v1/expenses/{$id}/show/receipt")->assertStatus(422) + ->assertJson(['error' => 'receipt_does_not_exist']); + + $png = 'data:image/png;base64,'.base64_encode(base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')); + postJson("/api/v1/expenses/{$id}/upload/receipts", [ + 'attachment_receipt' => json_encode(['name' => 'r1.png', 'data' => $png]), 'type' => 'create', + ])->assertOk(); + postJson("/api/v1/expenses/{$id}/upload/receipts", [ + 'attachment_receipt' => json_encode(['name' => 'r2.png', 'data' => $png]), 'type' => 'edit', + ])->assertOk(); + + expect(DB::table('media')->where('collection_name', 'receipts')->count())->toBe(1); + getJson("/api/v1/expenses/{$id}/show/receipt")->assertOk(); +}); + +it('refuses to delete a category in use and allows duplicate expense numbers', function () { + postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-04', 'expense_category_id' => $this->categoryId, + 'amount' => 10, 'currency_id' => (string) $this->companyCurrency, 'expense_number' => 'EXP-1', + ])->assertSuccessful(); + postJson('/api/v1/expenses', [ + 'expense_date' => '2026-02-05', 'expense_category_id' => $this->categoryId, + 'amount' => 20, 'currency_id' => (string) $this->companyCurrency, 'expense_number' => 'EXP-1', + ])->assertSuccessful(); + + deleteJson("/api/v1/categories/{$this->categoryId}") + ->assertStatus(422)->assertJson(['error' => 'expense_attached']); +}); diff --git a/tests/Feature/DomainSpec/ReceivablesDomainTest.php b/tests/Feature/DomainSpec/ReceivablesDomainTest.php new file mode 100644 index 00000000..efb223d5 --- /dev/null +++ b/tests/Feature/DomainSpec/ReceivablesDomainTest.php @@ -0,0 +1,195 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); + + $this->usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $this->customerId = postJson('/api/v1/customers', ['name' => 'Payer', 'currency_id' => $this->usd]) + ->json('data.id'); + + $this->makeInvoice = function (string $number, int $total, bool $sent = true, ?int $customerId = null) { + $id = postJson('/api/v1/invoices', [ + 'invoice_date' => '2026-03-01', 'customer_id' => $customerId ?? $this->customerId, + 'invoice_number' => $number, 'discount' => 0, 'discount_val' => 0, + 'sub_total' => $total, 'total' => $total, 'tax' => 0, 'template_name' => 'invoice1', + 'exchange_rate' => 3, 'currency_id' => $this->usd, + 'items' => [['name' => 'Line', 'quantity' => 1, 'price' => $total, 'description' => '', + 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => $total]], + ])->assertSuccessful()->json('data.id'); + if ($sent) { + postJson("/api/v1/invoices/{$id}/status", ['status' => 'SENT'])->assertOk(); + } + + return $id; + }; +}); + +it('walks the allocation guard ladder', function () { + $inv = ($this->makeInvoice)('INV-G-1', 100); + $draft = ($this->makeInvoice)('INV-G-2', 100, sent: false); + + $base = ['payment_date' => '2026-03-02', 'customer_id' => $this->customerId, + 'amount' => 100, 'exchange_rate' => 3]; + + // Duplicates are already refused at the request layer (per-row distinct rule). + postJson('/api/v1/payments', $base + ['payment_number' => 'PAY-G-1', + 'allocations' => [['invoice_id' => $inv, 'amount' => 50], ['invoice_id' => $inv, 'amount' => 50]], + ])->assertStatus(422)->assertJsonValidationErrors(['allocations.0.invoice_id']); + + postJson('/api/v1/payments', $base + ['payment_number' => 'PAY-G-2', + 'allocations' => [['invoice_id' => $inv, 'amount' => 150]], + ])->assertStatus(422)->assertJsonPath('errors.allocations.0', 'payment_allocation_exceeds_payment_amount'); + + postJson('/api/v1/payments', $base + ['payment_number' => 'PAY-G-3', + 'allocations' => [['invoice_id' => $draft, 'amount' => 50]], + ])->assertStatus(422)->assertJsonPath('errors.allocations.0', 'payment_allocation_invoice_not_payable'); + + postJson('/api/v1/payments', $base + ['payment_number' => 'PAY-G-4', + 'allocations' => [['invoice_id' => $inv, 'amount' => 100]], + ])->assertSuccessful(); + + postJson('/api/v1/payments', $base + ['payment_number' => 'PAY-G-5', + 'allocations' => [['invoice_id' => $inv, 'amount' => 1]], + ])->assertStatus(422)->assertJsonPath('errors.allocations.0', 'payment_allocation_exceeds_invoice_balance'); +}); + +it('settles the invoice on full allocation and restores it when the payment is deleted', function () { + $inv = ($this->makeInvoice)('INV-S-1', 100); + $payment = postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-S-1', + 'allocations' => [['invoice_id' => $inv, 'amount' => 100]], + ])->assertSuccessful()->json('data'); + + $row = DB::table('invoices')->where('id', $inv)->first(); + expect((int) $row->due_amount)->toBe(0); + expect($row->status)->toBe('COMPLETED'); + expect($row->paid_status)->toBe('PAID'); + + postJson('/api/v1/payments/delete', ['ids' => [$payment['id']]])->assertOk(); + $row = DB::table('invoices')->where('id', $inv)->first(); + expect((int) $row->due_amount)->toBe(100); + expect($row->paid_status)->toBe('UNPAID'); + expect($row->status)->toBe('SENT'); +}); + +it('prorates base amounts with the last-row remainder rule', function () { + $a = ($this->makeInvoice)('INV-R-1', 33); + $b = ($this->makeInvoice)('INV-R-2', 33); + $c = ($this->makeInvoice)('INV-R-3', 34); + + $id = postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-R-1', + 'allocations' => [ + ['invoice_id' => $a, 'amount' => 33], + ['invoice_id' => $b, 'amount' => 33], + ['invoice_id' => $c, 'amount' => 34], + ], + ])->assertSuccessful()->json('data.id'); + + $bases = DB::table('payment_allocations')->where('payment_id', $id) + ->orderBy('invoice_id')->pluck('base_amount')->map(fn ($v) => (int) $v)->all(); + expect(array_sum($bases))->toBe(300); + expect($bases)->toBe([99, 99, 102]); +}); + +it('allows reshaping a payment’s own allocations across covered invoices', function () { + $a = ($this->makeInvoice)('INV-M-1', 100); + $b = ($this->makeInvoice)('INV-M-2', 100); + $id = postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-M-1', + 'allocations' => [['invoice_id' => $a, 'amount' => 100]], + ])->json('data.id'); + + putJson("/api/v1/payments/{$id}/allocations", [ + 'allocations' => [['invoice_id' => $b, 'amount' => 100]], + ])->assertSuccessful(); + + expect((int) DB::table('invoices')->where('id', $a)->value('due_amount'))->toBe(100); + expect((int) DB::table('invoices')->where('id', $b)->value('due_amount'))->toBe(0); +}); + +it('locks the payment customer while allocated and frees it after deallocation', function () { + $inv = ($this->makeInvoice)('INV-L-1', 100); + $id = postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-L-1', + 'allocations' => [['invoice_id' => $inv, 'amount' => 100]], + ])->json('data.id'); + + $other = postJson('/api/v1/customers', ['name' => 'Other Payer', 'currency_id' => $this->usd]) + ->json('data.id'); + + putJson("/api/v1/payments/{$id}", [ + 'payment_date' => '2026-03-02', 'customer_id' => $other, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-L-1', + ])->assertStatus(422)->assertJsonValidationErrors(['customer_id']); + + putJson("/api/v1/payments/{$id}/allocations", ['allocations' => []])->assertSuccessful(); + putJson("/api/v1/payments/{$id}", [ + 'payment_date' => '2026-03-02', 'customer_id' => $other, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-L-1', + ])->assertSuccessful(); +}); + +it('applies customer credit on top of existing allocations', function () { + $a = ($this->makeInvoice)('INV-C-1', 100); + $b = ($this->makeInvoice)('INV-C-2', 100); + $paymentId = postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-C-1', + 'allocations' => [['invoice_id' => $a, 'amount' => 40]], + ])->json('data.id'); + + postJson("/api/v1/customers/{$this->customerId}/credit-allocations", [ + 'allocations' => [['payment_id' => $paymentId, 'invoice_id' => $b, 'amount' => 30]], + ])->assertOk()->assertJson(['success' => true]); + + $rows = DB::table('payment_allocations')->where('payment_id', $paymentId) + ->orderBy('invoice_id')->get(['invoice_id', 'amount']); + expect($rows->pluck('amount')->map(fn ($v) => (int) $v)->all())->toBe([40, 30]); + expect((int) DB::table('invoices')->where('id', $b)->value('due_amount'))->toBe(70); +}); + +it('rejects the legacy direct invoice field on payments', function () { + $inv = ($this->makeInvoice)('INV-P-1', 100); + postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 3, 'payment_number' => 'PAY-P-1', 'invoice_id' => $inv, + ])->assertStatus(422)->assertJsonValidationErrors(['invoice_id']); +}); + +it('refuses to delete payment methods referenced by payments or expenses', function () { + $method = postJson('/api/v1/payment-methods', ['name' => 'Wire'])->assertSuccessful()->json('data'); + postJson('/api/v1/payments', [ + 'payment_date' => '2026-03-02', 'customer_id' => $this->customerId, 'amount' => 10, + 'exchange_rate' => 3, 'payment_number' => 'PAY-W-1', 'payment_method_id' => $method['id'], + ])->assertSuccessful(); + deleteJson("/api/v1/payment-methods/{$method['id']}") + ->assertStatus(422)->assertJson(['error' => 'payments_attached']); + + $method2 = postJson('/api/v1/payment-methods', ['name' => 'Petty cash'])->json('data'); + $catId = postJson('/api/v1/categories', ['name' => 'Misc'])->json('data.id'); + $companyCurrency = DB::table('company_settings')->where('company_id', $this->companyId) + ->where('option', 'currency')->value('value'); + postJson('/api/v1/expenses', ['expense_date' => '2026-03-03', 'expense_category_id' => $catId, + 'amount' => 5, 'currency_id' => $companyCurrency, 'payment_method_id' => $method2['id']])->assertSuccessful(); + deleteJson("/api/v1/payment-methods/{$method2['id']}") + ->assertStatus(422)->assertJson(['error' => 'expenses_attached']); +}); diff --git a/tests/Feature/DomainSpec/ReportingDomainTest.php b/tests/Feature/DomainSpec/ReportingDomainTest.php new file mode 100644 index 00000000..44ce8e04 --- /dev/null +++ b/tests/Feature/DomainSpec/ReportingDomainTest.php @@ -0,0 +1,134 @@ + 'DatabaseSeeder', '--force' => true]); + $this->owner = User::where('role', 'super admin')->first(); + $this->companyId = $this->owner->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->owner, ['*']); + + $this->usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $this->customerId = postJson('/api/v1/customers', ['name' => 'Reported', 'currency_id' => $this->usd]) + ->json('data.id'); + + $this->makeInvoice = function (string $number, int $total, string $date, bool $sent = true) { + $id = postJson('/api/v1/invoices', [ + 'invoice_date' => $date, 'customer_id' => $this->customerId, + 'invoice_number' => $number, 'discount' => 0, 'discount_val' => 0, + 'sub_total' => $total, 'total' => $total, 'tax' => 0, 'template_name' => 'invoice1', + 'exchange_rate' => 2, 'currency_id' => $this->usd, + 'items' => [['name' => 'L', 'quantity' => 1, 'price' => $total, 'description' => '', + 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => $total]], + ])->assertSuccessful()->json('data.id'); + if ($sent) { + postJson("/api/v1/invoices/{$id}/status", ['status' => 'SENT'])->assertOk(); + } + + return $id; + }; +}); + +it('derives the account summary from non-draft standard invoices and unallocated payments', function () { + $sent = ($this->makeInvoice)('INV-AS-1', 1000, '2026-01-05'); + ($this->makeInvoice)('INV-AS-2', 500, '2026-01-06', sent: false); // draft — excluded + + postJson('/api/v1/payments', [ + 'payment_date' => '2026-01-07', 'customer_id' => $this->customerId, 'amount' => 300, + 'exchange_rate' => 2, 'payment_number' => 'PAY-AS-1', + 'allocations' => [['invoice_id' => $sent, 'amount' => 200]], + ])->assertSuccessful(); + + $customer = getJson("/api/v1/customers/{$this->customerId}")->assertOk()->json('data'); + expect((int) $customer['invoice_due_amount'])->toBe(800); + expect((int) $customer['available_credit'])->toBe(100); + expect((int) $customer['account_balance'])->toBe(700); + expect((int) $customer['due_amount'])->toBe(800); +}); + +it('builds activity statements with running balances, excluding draft invoices', function () { + ($this->makeInvoice)('INV-ACT-1', 100, '2026-02-01'); + ($this->makeInvoice)('INV-ACT-2', 999, '2026-02-02', sent: false); // draft — excluded + postJson('/api/v1/payments', [ + 'payment_date' => '2026-02-03', 'customer_id' => $this->customerId, 'amount' => 40, + 'exchange_rate' => 2, 'payment_number' => 'PAY-ACT-1', + ])->assertSuccessful(); + + $statement = getJson("/api/v1/customers/{$this->customerId}/statement" + .'?type=activity&from_date=2026-02-01&to_date=2026-02-28')->assertOk()->json('data'); + + $entries = $statement['entries']['data'] ?? $statement['entries']; + expect((int) $statement['opening_balance'])->toBe(0); + expect(count($entries))->toBe(2); + expect($entries[0]['entry_type'])->toBe('invoice'); + expect((int) $entries[0]['balance'])->toBe(100); + expect($entries[1]['entry_type'])->toBe('payment'); + expect((int) $entries[1]['balance'])->toBe(60); + expect((int) $statement['closing_balance'])->toBe(60); +}); + +it('cuts outstanding-statement allocations by their creation time, not the payment date', function () { + $inv = ($this->makeInvoice)('INV-OUT-1', 100, '2026-01-01'); + postJson('/api/v1/payments', [ + 'payment_date' => '2026-01-02', 'customer_id' => $this->customerId, 'amount' => 40, + 'exchange_rate' => 2, 'payment_number' => 'PAY-OUT-1', + 'allocations' => [['invoice_id' => $inv, 'amount' => 40]], + ])->assertSuccessful(); + + // The allocation row was created "now" (test time), long after the payment date. + $early = getJson("/api/v1/customers/{$this->customerId}/statement?type=outstanding&as_of=2026-06-01") + ->assertOk()->json('data'); + $invoices = collect($early['open_invoices'] ?? $early['invoices'])->keyBy('invoice_number'); + expect((int) $invoices['INV-OUT-1']['remaining_amount'])->toBe(100); + + $today = now()->toDateString(); + $late = getJson("/api/v1/customers/{$this->customerId}/statement?type=outstanding&as_of={$today}") + ->assertOk()->json('data'); + $invoices = collect($late['open_invoices'] ?? $late['invoices'])->keyBy('invoice_number'); + expect((int) $invoices['INV-OUT-1']['remaining_amount'])->toBe(60); +}); + +it('empties the dashboard recent lists by ability while always returning totals', function () { + ($this->makeInvoice)('INV-DB-1', 700, '2026-01-05'); + + $abilities = fn (array $names) => array_map(fn ($a) => ['ability' => $a], $names); + postJson('/api/v1/roles', ['name' => 'dash-only', 'abilities' => $abilities(['dashboard'])])->assertSuccessful(); + postJson('/api/v1/members', ['name' => 'Dash', 'email' => 'dash@x.test', 'password' => 'secret123', + 'companies' => [['id' => $this->companyId, 'role' => 'dash-only']]])->assertSuccessful(); + + $full = getJson('/api/v1/dashboard')->assertOk()->json(); + expect(count($full['recent_due_invoices']))->toBe(1); + expect((int) $full['total_amount_due'])->toBe(1400); + + app('auth')->forgetGuards(); + Sanctum::actingAs(User::where('email', 'dash@x.test')->first(), ['*']); + $this->withHeaders(['company' => $this->companyId]); + + $limited = getJson('/api/v1/dashboard')->assertOk()->json(); + expect($limited['recent_due_invoices'])->toBe([]); + expect((int) $limited['total_amount_due'])->toBe(1400); +}); + +it('searches users by email across the whole installation', function () { + $countryId = DB::table('countries')->value('id'); + $eur = DB::table('currencies')->where('code', 'EUR')->value('id'); + $second = postJson('/api/v1/companies', [ + 'name' => 'Elsewhere Ltd', 'currency' => $eur, 'address' => ['country_id' => $countryId], + ])->assertSuccessful()->json('data'); + + postJson('/api/v1/members', ['name' => 'Foreign Member', 'email' => 'foreign@elsewhere.test', + 'password' => 'secret123', 'companies' => [['id' => $second['id'], 'role' => 'owner']]]) + ->assertSuccessful(); + + $found = collect(getJson('/api/v1/search/user?email=elsewhere')->assertOk()->json('users.data')) + ->pluck('email'); + expect($found)->toContain('foreign@elsewhere.test'); +}); diff --git a/tests/Feature/DomainSpec/SalesDomainTest.php b/tests/Feature/DomainSpec/SalesDomainTest.php new file mode 100644 index 00000000..082939fb --- /dev/null +++ b/tests/Feature/DomainSpec/SalesDomainTest.php @@ -0,0 +1,226 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); + + $this->usd = DB::table('currencies')->where('code', 'USD')->value('id'); + $this->customerId = postJson('/api/v1/customers', ['name' => 'Buyer', 'currency_id' => $this->usd]) + ->json('data.id'); + + $this->invoicePayload = fn (string $number, array $overrides = []) => array_merge([ + 'invoice_date' => '2026-04-01', 'customer_id' => $this->customerId, + 'invoice_number' => $number, 'discount' => 0, 'discount_val' => 0, + 'sub_total' => 1, 'total' => 1, 'tax' => 0, 'template_name' => 'invoice1', + 'exchange_rate' => 2, 'currency_id' => $this->usd, + 'items' => [['name' => 'Line', 'quantity' => 1, 'price' => 1000, 'description' => '', + 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 1000]], + ], $overrides); +}); + +it('computes totals server-side, ignoring the submitted figures', function () { + $taxType = postJson('/api/v1/tax-types', ['name' => 'DocTax', 'calculation_type' => 'percentage', 'percent' => 10]) + ->json('data.id'); + + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-T-1', [ + 'discount_val' => 100, + 'sub_total' => 1, 'total' => 999999, 'tax' => 7, + 'items' => [ + ['name' => 'A', 'quantity' => 2, 'price' => 500, 'description' => '', 'discount_type' => 'fixed', + 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 1], + ['name' => 'B', 'quantity' => 1, 'price' => 250, 'description' => '', 'discount_type' => 'fixed', + 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 1], + ], + 'taxes' => [['tax_type_id' => $taxType, 'name' => 'DocTax', 'percent' => 10, 'amount' => 50]], + ]))->assertSuccessful()->json('data'); + + expect((int) $invoice['sub_total'])->toBe(1250); + expect((int) $invoice['tax'])->toBe(50); + expect((int) $invoice['total'])->toBe(1200); + expect((int) $invoice['due_amount'])->toBe(1200); + expect((int) DB::table('invoices')->where('id', $invoice['id'])->value('base_total'))->toBe(2400); +}); + +it('adds only compound taxes on top of tax-inclusive totals', function () { + $simple = postJson('/api/v1/tax-types', ['name' => 'Simple', 'calculation_type' => 'percentage', 'percent' => 5]) + ->json('data.id'); + $compound = postJson('/api/v1/tax-types', ['name' => 'Comp', 'calculation_type' => 'percentage', + 'percent' => 3, 'compound_tax' => true])->json('data.id'); + + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-T-2', [ + 'tax_included' => true, 'discount_val' => 100, + 'taxes' => [ + ['tax_type_id' => $simple, 'name' => 'Simple', 'percent' => 5, 'amount' => 50], + ['tax_type_id' => $compound, 'name' => 'Comp', 'percent' => 3, 'amount' => 30, 'compound_tax' => true], + ], + ]))->assertSuccessful()->json('data'); + + expect((int) $invoice['sub_total'])->toBe(1000); + expect((int) $invoice['tax'])->toBe(80); + expect((int) $invoice['total'])->toBe(930); +}); + +it('refuses a tax amount without a tax type but accepts zero-amount placeholders', function () { + postJson('/api/v1/invoices', ($this->invoicePayload)('INV-T-3', [ + 'items' => [['name' => 'A', 'quantity' => 1, 'price' => 100, 'description' => '', 'discount_type' => 'fixed', + 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 100, + 'taxes' => [['tax_type_id' => null, 'amount' => 10]]]], + ]))->assertStatus(422); + + $ok = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-T-4', [ + 'items' => [['name' => 'A', 'quantity' => 1, 'price' => 100, 'description' => '', 'discount_type' => 'fixed', + 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 100, + 'taxes' => [['tax_type_id' => null, 'amount' => 0]]]], + ]))->assertSuccessful()->json('data'); + expect(DB::table('taxes')->where('invoice_id', $ok['id'])->orWhere('invoice_item_id', $ok['items'][0]['id'])->count()) + ->toBe(0); +}); + +it('renders serial numbers from the format placeholders with separate credit-note sequences', function () { + postJson('/api/v1/invoices', ($this->invoicePayload)('INV-N-1'))->assertSuccessful(); + + $next = getJson('/api/v1/next-number?key=invoice&userId='.$this->customerId + .'&format='.urlencode('{{SERIES:XX}}{{DELIMITER:-}}{{SEQUENCE:4}}{{DELIMITER:/}}{{CUSTOMER_SEQUENCE:2}}')) + ->assertOk()->json(); + expect($next['nextNumber'] ?? $next['next_number'] ?? null)->toBe('XX-0002/02'); + + $cn = getJson('/api/v1/next-number?key=credit_note&userId='.$this->customerId + .'&format='.urlencode('{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:4}}'))->assertOk()->json(); + expect($cn['nextNumber'] ?? $cn['next_number'] ?? null)->toBe('CN-0001'); +}); + +it('guards invoice updates once payments exist', function () { + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-U-1'))->json('data'); + postJson("/api/v1/invoices/{$invoice['id']}/status", ['status' => 'SENT'])->assertOk(); + postJson('/api/v1/payments', [ + 'payment_date' => '2026-04-02', 'customer_id' => $this->customerId, 'amount' => 400, + 'exchange_rate' => 2, 'payment_number' => 'PAY-U-1', + 'allocations' => [['invoice_id' => $invoice['id'], 'amount' => 400]], + ])->assertSuccessful(); + + $other = postJson('/api/v1/customers', ['name' => 'Somebody Else', 'currency_id' => $this->usd])->json('data.id'); + putJson("/api/v1/invoices/{$invoice['id']}", ($this->invoicePayload)('INV-U-1', ['customer_id' => $other])) + ->assertStatus(422)->assertJsonValidationErrors(['customer_id']); + + putJson("/api/v1/invoices/{$invoice['id']}", ($this->invoicePayload)('INV-U-1', [ + 'items' => [['name' => 'Line', 'quantity' => 1, 'price' => 300, 'description' => '', 'discount_type' => 'fixed', + 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 300]], + ]))->assertStatus(422)->assertJsonValidationErrors(['total']); +}); + +it('walks the credit-note guard ladder and recalculates the balance', function () { + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-CN-1'))->json('data'); + postJson("/api/v1/invoices/{$invoice['id']}/status", ['status' => 'SENT'])->assertOk(); + $itemId = $invoice['items'][0]['id']; + + postJson("/api/v1/invoices/{$invoice['id']}/credit-note", [ + 'items' => [['id' => $itemId, 'quantity' => 2]], + ])->assertStatus(422)->assertJsonPath('errors.invoice.0', 'credit_quantity_exceeds_remaining'); + + $cn = postJson("/api/v1/invoices/{$invoice['id']}/credit-note", [ + 'reason' => 'partial return', 'items' => [['id' => $itemId, 'quantity' => 0.5]], + ])->assertSuccessful()->json('data'); + expect((int) $cn['total'])->toBe(-500); + + $row = DB::table('invoices')->where('id', $invoice['id'])->first(); + expect((int) $row->due_amount)->toBe(500); + expect($row->paid_status)->toBe('UNPAID'); + expect((bool) getJson("/api/v1/invoices/{$invoice['id']}")->json('data.allow_edit'))->toBeFalse(); + + postJson("/api/v1/invoices/{$invoice['id']}/credit-note", [ + 'items' => [['id' => $itemId, 'quantity' => 0.5]], + ])->assertSuccessful(); + postJson("/api/v1/invoices/{$invoice['id']}/credit-note", [ + 'items' => [['id' => $itemId, 'quantity' => 0.1]], + ])->assertStatus(422)->assertJsonPath('errors.invoice.0', 'invoice_already_fully_credited'); +}); + +it('deletes credit notes only together with their invoice, and blocks allocated invoices', function () { + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-D-1'))->json('data'); + postJson("/api/v1/invoices/{$invoice['id']}/status", ['status' => 'SENT'])->assertOk(); + $cnId = postJson("/api/v1/invoices/{$invoice['id']}/credit-note", [ + 'items' => [['id' => $invoice['items'][0]['id'], 'quantity' => 0.25]], + ])->assertSuccessful()->json('data.id'); + + postJson('/api/v1/invoices/delete', ['ids' => [$invoice['id']]])->assertStatus(422); + postJson('/api/v1/invoices/delete', ['ids' => [$invoice['id'], $cnId]])->assertOk(); + + $paid = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-D-2'))->json('data'); + postJson("/api/v1/invoices/{$paid['id']}/status", ['status' => 'SENT'])->assertOk(); + postJson('/api/v1/payments', [ + 'payment_date' => '2026-04-02', 'customer_id' => $this->customerId, 'amount' => 100, + 'exchange_rate' => 2, 'payment_number' => 'PAY-D-1', + 'allocations' => [['invoice_id' => $paid['id'], 'amount' => 100]], + ])->assertSuccessful(); + // The request layer's relation rule fires before the service-level guard. + postJson('/api/v1/invoices/delete', ['ids' => [$paid['id']]]) + ->assertStatus(422)->assertJsonValidationErrors(['ids.0']); +}); + +it('whitelists invoice status changes and requires settlement for completion', function () { + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-ST-1'))->json('data'); + + postJson("/api/v1/invoices/{$invoice['id']}/status", ['status' => 'VIEWED']) + ->assertStatus(422)->assertJsonValidationErrors(['status']); + postJson("/api/v1/invoices/{$invoice['id']}/status", ['status' => 'COMPLETED']) + ->assertStatus(422)->assertJsonPath('errors.status.0', 'invoice_must_be_settled_before_completion'); +}); + +it('applies any submitted estimate status without validation — the documented quirk', function () { + $estimate = postJson('/api/v1/estimates', [ + 'estimate_date' => '2026-04-01', 'expiry_date' => '2026-05-01', 'customer_id' => $this->customerId, + 'estimate_number' => 'EST-Q-1', 'discount' => 0, 'discount_val' => 0, + 'sub_total' => 100, 'total' => 100, 'tax' => 0, 'template_name' => 'estimate1', + 'exchange_rate' => 2, 'currency_id' => $this->usd, + 'items' => [['name' => 'L', 'quantity' => 1, 'price' => 100, 'description' => '', 'discount_type' => 'fixed', + 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'total' => 100]], + ])->assertSuccessful()->json('data'); + + postJson("/api/v1/estimates/{$estimate['id']}/status", ['status' => 'BANANAS']) + ->assertOk()->assertJson(['success' => true]); + expect(DB::table('estimates')->where('id', $estimate['id'])->value('status'))->toBe('BANANAS'); +}); + +it('clones invoices as fresh drafts and refuses to clone credit notes', function () { + $invoice = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-CL-1'))->json('data'); + postJson("/api/v1/invoices/{$invoice['id']}/status", ['status' => 'SENT'])->assertOk(); + + $clone = postJson("/api/v1/invoices/{$invoice['id']}/clone")->assertSuccessful()->json('data'); + expect($clone['status'])->toBe('DRAFT'); + expect($clone['invoice_number'])->not->toBe('INV-CL-1'); + expect((int) $clone['total'])->toBe(1000); + + $cnId = postJson("/api/v1/invoices/{$invoice['id']}/credit-note", [ + 'items' => [['id' => $invoice['items'][0]['id'], 'quantity' => 0.5]], + ])->json('data.id'); + postJson("/api/v1/invoices/{$cnId}/clone")->assertStatus(422); +}); + +it('marks overdue invoices daily, skipping drafts and credit notes', function () { + $due = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-O-1', [ + 'invoice_date' => '2026-01-01', 'due_date' => '2026-01-15', + ]))->json('data'); + postJson("/api/v1/invoices/{$due['id']}/status", ['status' => 'SENT'])->assertOk(); + + $draft = postJson('/api/v1/invoices', ($this->invoicePayload)('INV-O-2', [ + 'invoice_date' => '2026-01-01', 'due_date' => '2026-01-15', + ]))->json('data'); + + Artisan::call('check:invoices:status'); + + expect((bool) DB::table('invoices')->where('id', $due['id'])->value('overdue'))->toBeTrue(); + expect((bool) DB::table('invoices')->where('id', $draft['id'])->value('overdue'))->toBeFalse(); +}); diff --git a/tests/Feature/DomainSpec/TaxationDomainTest.php b/tests/Feature/DomainSpec/TaxationDomainTest.php new file mode 100644 index 00000000..eafa434b --- /dev/null +++ b/tests/Feature/DomainSpec/TaxationDomainTest.php @@ -0,0 +1,99 @@ + 'DatabaseSeeder', '--force' => true]); + $user = User::where('role', 'super admin')->first(); + $this->companyId = $user->companies()->first()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($user, ['*']); +}); + +it('enforces name uniqueness among the company general kinds', function () { + postJson('/api/v1/tax-types', ['name' => 'VAT', 'calculation_type' => 'percentage', 'percent' => 18]) + ->assertSuccessful(); + postJson('/api/v1/tax-types', ['name' => 'VAT', 'calculation_type' => 'percentage', 'percent' => 5]) + ->assertStatus(422)->assertJsonValidationErrors(['name']); +}); + +it('applies the create defaults and forces kind and company', function () { + $created = postJson('/api/v1/tax-types', [ + 'name' => 'Defaulted', 'calculation_type' => 'percentage', 'percent' => 10, + 'type' => 'MODULE', 'company_id' => 999, + ])->assertSuccessful()->json('data'); + + expect($created['transaction_type'])->toBe('sales'); + expect($created['compound_tax'])->toBeFalsy(); + expect($created['type'])->toBe('GENERAL'); + expect((int) $created['company_id'])->toBe((int) $this->companyId); +}); + +it('allows compound only for percentage sales taxes, honouring stored values on update', function () { + postJson('/api/v1/tax-types', [ + 'name' => 'FixedCompound', 'calculation_type' => 'fixed', 'fixed_amount' => 500, 'compound_tax' => true, + ])->assertStatus(422)->assertJsonValidationErrors(['compound_tax']); + + postJson('/api/v1/tax-types', [ + 'name' => 'PurchCompound', 'calculation_type' => 'percentage', 'percent' => 5, + 'transaction_type' => 'purchases', 'compound_tax' => true, + ])->assertStatus(422)->assertJsonValidationErrors(['compound_tax']); + + $id = postJson('/api/v1/tax-types', [ + 'name' => 'Compound', 'calculation_type' => 'percentage', 'percent' => 5, 'compound_tax' => true, + ])->assertSuccessful()->json('data.id'); + + // Update inheriting the stored compound flag: switching to fixed must be refused. + putJson("/api/v1/tax-types/{$id}", ['name' => 'Compound', 'calculation_type' => 'fixed', 'fixed_amount' => 100]) + ->assertStatus(422)->assertJsonValidationErrors(['compound_tax']); +}); + +it('keeps applied taxes as snapshots and blocks deletion while referenced', function () { + $typeId = postJson('/api/v1/tax-types', [ + 'name' => 'SnapTax', 'calculation_type' => 'percentage', 'percent' => 10, + ])->assertSuccessful()->json('data.id'); + + $itemId = postJson('/api/v1/items', [ + 'name' => 'Taxed item', 'price' => 1000, + 'taxes' => [['tax_type_id' => $typeId, 'name' => 'SnapTax', 'percent' => 10, 'amount' => 100]], + ])->assertSuccessful()->json('data.id'); + + deleteJson("/api/v1/tax-types/{$typeId}") + ->assertStatus(422)->assertJson(['error' => 'taxes_attached']); + + putJson("/api/v1/tax-types/{$typeId}", ['name' => 'SnapTax renamed', 'calculation_type' => 'percentage', 'percent' => 25]) + ->assertSuccessful(); + $applied = DB::table('taxes')->where('item_id', $itemId)->first(); + expect($applied->name)->toBe('SnapTax'); + expect((float) $applied->percent)->toBe(10.0); + + // Clearing the references frees the type for deletion. + putJson("/api/v1/items/{$itemId}", ['name' => 'Taxed item', 'price' => 1000, 'taxes' => []]) + ->assertSuccessful(); + deleteJson("/api/v1/tax-types/{$typeId}")->assertOk()->assertJson(['success' => true]); +}); + +it('lists only the company general kinds with search', function () { + postJson('/api/v1/tax-types', ['name' => 'Alpha VAT', 'calculation_type' => 'percentage', 'percent' => 1])->assertSuccessful(); + postJson('/api/v1/tax-types', ['name' => 'Beta GST', 'calculation_type' => 'percentage', 'percent' => 2])->assertSuccessful(); + DB::table('tax_types')->insert([ + 'name' => 'Module tax', 'calculation_type' => 'percentage', 'percent' => 3, + 'type' => 'MODULE', 'transaction_type' => 'sales', 'company_id' => $this->companyId, + 'compound_tax' => 0, 'collective_tax' => 0, 'created_at' => now(), 'updated_at' => now(), + ]); + + $names = collect(getJson('/api/v1/tax-types?limit=all')->assertOk()->json('data'))->pluck('name'); + expect($names)->toContain('Alpha VAT', 'Beta GST')->not->toContain('Module tax'); + + $found = collect(getJson('/api/v1/tax-types?limit=all&search=Beta')->json('data'))->pluck('name'); + expect($found->all())->toBe(['Beta GST']); +}); diff --git a/tests/Feature/PilotSpec/InstallationChecksTest.php b/tests/Feature/PilotSpec/InstallationChecksTest.php new file mode 100644 index 00000000..70c39aa8 --- /dev/null +++ b/tests/Feature/PilotSpec/InstallationChecksTest.php @@ -0,0 +1,42 @@ + 'DatabaseSeeder', '--force' => true]); +}); + +it('reports the PHP version block against the configured minimum', function () { + $payload = getJson('/api/v1/installation/requirements')->assertOk()->json(); + + $php = $payload['phpSupportInfo']; + expect($php['minimum'])->toBe(config('installer.core.minPhpVersion')); + expect($php['full'])->toBe(PHP_VERSION); + expect($php['current'])->toMatch('/^\d+(\.\d+)*$/'); + expect($php['supported'])->toBe(version_compare($php['current'], $php['minimum']) >= 0); +}); + +it('checks every required PHP extension', function () { + $payload = getJson('/api/v1/installation/requirements')->assertOk()->json(); + + $checked = $payload['requirements']['requirements']['php']; + foreach (config('installer.requirements')['php'] as $extension) { + expect($checked)->toHaveKey($extension); + expect($checked[$extension])->toBe(extension_loaded($extension)); + } +}); + +it('reports folder permissions with a consistent errors flag', function () { + $payload = getJson('/api/v1/installation/permissions')->assertOk()->json(); + + $entries = $payload['permissions']['permissions']; + $folders = array_column($entries, 'folder'); + foreach (array_keys(config('installer.permissions')) as $folder) { + expect($folders)->toContain($folder); + } + $anyFailed = collect($entries)->contains(fn ($entry) => $entry['isSet'] === false); + expect($payload['permissions']['errors'])->toBe($anyFailed ? true : null); +}); diff --git a/tests/Feature/PilotSpec/InstallationDatabaseConfigTest.php b/tests/Feature/PilotSpec/InstallationDatabaseConfigTest.php new file mode 100644 index 00000000..78436e23 --- /dev/null +++ b/tests/Feature/PilotSpec/InstallationDatabaseConfigTest.php @@ -0,0 +1,67 @@ + 'DatabaseSeeder', '--force' => true]); + $this->envBackup = file_get_contents(base_path('.env')); +}); + +afterEach(function () { + file_put_contents(base_path('.env'), $this->envBackup); +}); + +it('returns connection defaults per driver', function () { + getJson('/api/v1/installation/database/config?connection=pgsql') + ->assertOk() + ->assertJson(['success' => true, 'config' => ['database_connection' => 'pgsql', 'database_host' => '127.0.0.1', 'database_port' => 5432]]); + + getJson('/api/v1/installation/database/config?connection=mysql') + ->assertOk() + ->assertJson(['success' => true, 'config' => ['database_connection' => 'mysql', 'database_host' => '127.0.0.1', 'database_port' => 3306]]); + + getJson('/api/v1/installation/database/config?connection=mariadb') + ->assertOk() + ->assertJson(['success' => true, 'config' => ['database_connection' => 'mariadb', 'database_host' => '127.0.0.1', 'database_port' => 3306]]); + + $sqlite = getJson('/api/v1/installation/database/config?connection=sqlite')->assertOk()->json(); + expect($sqlite['config']['database_connection'])->toBe('sqlite'); + expect($sqlite['config']['database_name'])->toBe(config('database.connections.sqlite.database') ?: 'storage/app/database.sqlite'); +}); + +it('validates the payload per driver', function () { + postJson('/api/v1/installation/database/config', []) + ->assertStatus(422) + ->assertJsonValidationErrors(['app_url', 'database_connection']); + + postJson('/api/v1/installation/database/config', [ + 'app_url' => 'http://pilot.test', + 'database_connection' => 'sqlite', + ])->assertStatus(422)->assertJsonValidationErrors(['database_name']); + + postJson('/api/v1/installation/database/config', [ + 'app_url' => 'http://pilot.test', + 'database_connection' => 'mysql', + ])->assertStatus(422)->assertJsonValidationErrors([ + 'database_hostname', 'database_port', 'database_name', 'database_username', + ]); + + // The password is deliberately never a validation requirement. + postJson('/api/v1/installation/database/config', [ + 'app_url' => 'http://pilot.test', + 'database_connection' => 'mysql', + 'database_hostname' => '127.0.0.1', + 'database_port' => 3306, + 'database_name' => 'x', + 'database_username' => 'u', + 'database_overwrite' => 'not-a-boolean', + ])->assertStatus(422)->assertJsonValidationErrors(['database_overwrite']) + ->assertJsonMissingValidationErrors(['database_password']); +}); diff --git a/tests/Feature/PilotSpec/InstallationWizardTest.php b/tests/Feature/PilotSpec/InstallationWizardTest.php new file mode 100644 index 00000000..0dcf0a2b --- /dev/null +++ b/tests/Feature/PilotSpec/InstallationWizardTest.php @@ -0,0 +1,70 @@ + 'DatabaseSeeder', '--force' => true]); + // DatabaseSeeder leaves profile_complete at 0: database created, NOT installed. +}); + +function pilotSetSetting(string $key, $value): void +{ + DB::table('settings')->updateOrInsert(['option' => $key], ['option' => $key, 'value' => $value]); +} + +it('caches the created-database probe for the lifetime of the process', function () { + // The first probe in this PHP process ran at boot, before the schema + // existed, and the answer is cached per process — so the wizard reports + // the pre-database defaults even though the tables exist by now. This is + // deliberate current behaviour; the stored-values branch and the + // redirect-once-completed behaviour are pinned by the sandbox scenarios + // (see the suite README). + pilotSetSetting('profile_complete', 'STEP_2'); + pilotSetSetting('profile_language', 'de'); + + getJson('/api/v1/installation/wizard-step') + ->assertOk() + ->assertJson(['profile_complete' => 0, 'profile_language' => 'en']); +}); + +it('stores a wizard step and echoes the stored value', function () { + postJson('/api/v1/installation/wizard-step', ['profile_complete' => 'STEP_3']) + ->assertOk() + ->assertJson(['profile_complete' => 'STEP_3']); + + expect(DB::table('settings')->where('option', 'profile_complete')->value('value')) + ->toBe('STEP_3'); +}); + +it('refuses to overwrite a completed wizard state', function () { + pilotSetSetting('profile_complete', 'COMPLETED'); + + postJson('/api/v1/installation/wizard-step', ['profile_complete' => 'STEP_1']) + ->assertOk() + ->assertJson(['profile_complete' => 'COMPLETED']); + + expect(DB::table('settings')->where('option', 'profile_complete')->value('value')) + ->toBe('COMPLETED'); +}); + +it('stores the wizard language and echoes it', function () { + postJson('/api/v1/installation/wizard-language', ['profile_language' => 'fr']) + ->assertOk() + ->assertJson(['profile_language' => 'fr']); + + expect(DB::table('settings')->where('option', 'profile_language')->value('value')) + ->toBe('fr'); +}); + +it('lists the supported languages as code and name pairs', function () { + $response = getJson('/api/v1/installation/languages')->assertOk()->json(); + + expect($response['languages'])->toBeArray()->not->toBeEmpty(); + expect(collect($response['languages'])->firstWhere('code', 'en')['name'])->toBe('English'); +}); diff --git a/tests/Feature/PilotSpec/SetDomainTest.php b/tests/Feature/PilotSpec/SetDomainTest.php new file mode 100644 index 00000000..21d4598f --- /dev/null +++ b/tests/Feature/PilotSpec/SetDomainTest.php @@ -0,0 +1,53 @@ + 'DatabaseSeeder', '--force' => true]); + $this->envBackup = file_get_contents(base_path('.env')); +}); + +afterEach(function () { + file_put_contents(base_path('.env'), $this->envBackup); +}); + +it('requires a domain', function () { + putJson('/api/v1/installation/set-domain', [])->assertStatus(422); +}); + +it('always writes the session domain as the host portion of the submitted value', function () { + config(['app.url' => 'http://pilot.test']); + + putJson('/api/v1/installation/set-domain', ['app_domain' => 'http://other.test']) + ->assertOk() + ->assertJson(['success' => true]); + + $env = file_get_contents(base_path('.env')); + expect($env)->toContain('SESSION_DOMAIN=other.test'); +}); + +it('writes the stateful-domains entry when the submitted domain differs from the current one', function () { + config(['app.url' => 'http://pilot.test']); + + putJson('/api/v1/installation/set-domain', ['app_domain' => 'elsewhere.test'])->assertOk(); + + $env = file_get_contents(base_path('.env')); + expect($env)->toContain('SANCTUM_STATEFUL_DOMAINS=elsewhere.test'); + expect($env)->toContain('SESSION_DOMAIN=elsewhere.test'); +}); + +it('replaces an existing key in place rather than appending a duplicate', function () { + config(['app.url' => 'http://pilot.test']); + + putJson('/api/v1/installation/set-domain', ['app_domain' => 'first.test'])->assertOk(); + putJson('/api/v1/installation/set-domain', ['app_domain' => 'second.test'])->assertOk(); + + $env = file_get_contents(base_path('.env')); + expect(substr_count($env, "\nSESSION_DOMAIN=") + (str_starts_with($env, 'SESSION_DOMAIN=') ? 1 : 0)) + ->toBe(1); + expect($env)->toContain('SESSION_DOMAIN=second.test'); +}); diff --git a/tests/Feature/PilotSpec/SettingsCronDashboardTest.php b/tests/Feature/PilotSpec/SettingsCronDashboardTest.php new file mode 100644 index 00000000..1895c9a0 --- /dev/null +++ b/tests/Feature/PilotSpec/SettingsCronDashboardTest.php @@ -0,0 +1,67 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::where('role', 'super admin')->first(); + $this->withHeaders(['company' => $user->companies()->first()->id]); + Sanctum::actingAs($user, ['*']); +}); + +it('upserts settings by key and reads them back', function () { + postJson('/api/v1/settings', ['settings' => ['pilot_key_a' => 'v1', 'pilot_key_b' => 'v2']]) + ->assertOk() + ->assertJson(['success' => true]); + + postJson('/api/v1/settings', ['settings' => ['pilot_key_a' => 'v3']])->assertOk(); + + expect(DB::table('settings')->where('option', 'pilot_key_a')->count())->toBe(1); + expect(DB::table('settings')->where('option', 'pilot_key_a')->value('value'))->toBe('v3'); + + getJson('/api/v1/settings?key=pilot_key_a')->assertOk()->assertJson(['pilot_key_a' => 'v3']); +}); + +it('reads a missing setting as null', function () { + getJson('/api/v1/settings?key=pilot_absent_key') + ->assertOk() + ->assertJson(['pilot_absent_key' => null]); +}); + +it('validates the settings endpoints', function () { + postJson('/api/v1/settings', [])->assertStatus(422)->assertJsonValidationErrors(['settings']); + getJson('/api/v1/settings')->assertStatus(422); +}); + +it('guards the cron webhook with the shared token', function () { + config(['services.cron_job.auth_token' => 'pilot-cron-token']); + + getJson('/api/cron')->assertUnauthorized(); + + $this->withHeaders(['x-authorization-token' => 'wrong']) + ->getJson('/api/cron')->assertUnauthorized(); + + $this->withHeaders(['x-authorization-token' => 'pilot-cron-token']) + ->getJson('/api/cron')->assertOk()->assertJson(['success' => true]); +}); + +it('reports versions and row counts on the admin dashboard', function () { + $payload = getJson('/api/v1/super-admin/dashboard')->assertOk()->json(); + + $expected = preg_replace('~[\r\n]+~', '', file_get_contents(base_path('version.md'))); + expect($payload['app_version'])->toBe($expected); + expect($payload['php_version'])->toBe(phpversion()); + expect($payload['database']['driver'])->toBe(config('database.default')); + expect($payload['counts']['companies'])->toBe(DB::table('companies')->count()); + expect($payload['counts']['users'])->toBe(DB::table('users')->count()); +}); diff --git a/tests/Feature/PilotSpec/UpdateSystemTest.php b/tests/Feature/PilotSpec/UpdateSystemTest.php new file mode 100644 index 00000000..93f3b9df --- /dev/null +++ b/tests/Feature/PilotSpec/UpdateSystemTest.php @@ -0,0 +1,180 @@ + ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], + $pipes + ); + for ($i = 0; $i < 50; $i++) { + if (@file_get_contents('http://127.0.0.1:8873/releases/update-check/0') !== false) { + return; + } + usleep(100_000); + } +} + +afterAll(function () { + global $pilotReleaseServer; + if ($pilotReleaseServer) { + proc_terminate($pilotReleaseServer); + $pilotReleaseServer = null; + } +}); + +beforeEach(function () { + Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::where('role', 'super admin')->first(); + $this->withHeaders(['company' => $user->companies()->first()->id]); + Sanctum::actingAs($user, ['*']); +}); + +it('rejects unauthenticated update requests', function () { + $this->flushHeaders(); + app('auth')->forgetGuards(); + + getJson('/api/v1/check/update')->assertUnauthorized(); + postJson('/api/v1/update/finish', ['installed' => 'a', 'version' => 'b'])->assertUnauthorized(); +}); + +it('reads the installed version and the self-healing channel from the version endpoint', function () { + expect(DB::table('settings')->where('option', 'updater_channel')->exists())->toBeFalse(); + + $expected = preg_replace('~[\r\n]+~', '', file_get_contents(base_path('version.md'))); + + getJson('/api/v1/app/version') + ->assertOk() + ->assertJson(['version' => $expected, 'channel' => 'stable']); + + // The default channel is persisted on first read. + expect(DB::table('settings')->where('option', 'updater_channel')->value('value'))->toBe('stable'); +}); + +it('checks for updates against the release server and grades required extensions', function () { + pilotStartReleaseServer(); + config(['invoiceshelf.base_url' => 'http://127.0.0.1:8873']); + + $payload = getJson('/api/v1/check/update?channel=stable')->assertOk()->json(); + + expect($payload['success'])->toBeTrue(); + $extensions = $payload['release']['extensions']; + expect($extensions['curl'])->toBeTrue(); + expect($extensions['pilot_missing_ext'])->toBeFalse(); + expect($extensions['php(8.0)'])->toBeTrue(); +}); + +it('downloads a release archive into private storage', function () { + pilotStartReleaseServer(); + config(['invoiceshelf.base_url' => 'http://127.0.0.1:8873']); + + $payload = postJson('/api/v1/update/download', ['version' => '9.9.9-test'])->assertOk()->json(); + + expect($payload['success'])->toBeTrue(); + expect($payload['path'])->toBeString()->toEndWith('.zip'); + expect(str_starts_with($payload['path'], storage_path('app')))->toBeTrue(); + expect(file_exists($payload['path']))->toBeTrue(); + + File::deleteDirectory(dirname($payload['path'])); +}); + +it('unzips a release archive and deletes the archive file', function () { + $zipPath = storage_path('framework/testing/pilot-release.zip'); + @mkdir(dirname($zipPath), 0775, true); + $zip = new ZipArchive; + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('InvoiceShelf/pilot-marker.txt', 'ok'); + $zip->close(); + + $payload = postJson('/api/v1/update/unzip', ['path' => $zipPath])->assertOk()->json(); + + expect($payload['success'])->toBeTrue(); + expect(file_exists($payload['path'].'/InvoiceShelf/pilot-marker.txt'))->toBeTrue(); + expect(file_exists($zipPath))->toBeFalse(); + + File::deleteDirectory($payload['path']); +}); + +it('fails the unzip step with an error when the archive is missing', function () { + postJson('/api/v1/update/unzip', ['path' => storage_path('framework/testing/absent.zip')]) + ->assertStatus(500) + ->assertJson(['success' => false]); +}); + +it('deletes exactly the listed legacy files during clean-up when no manifest exists', function () { + if (file_exists(base_path('manifest.json'))) { + $this->markTestSkipped('A root manifest exists; the legacy branch cannot be exercised safely.'); + } + + $stale = base_path('storage/framework/testing/pilot-stale.txt'); + @mkdir(dirname($stale), 0775, true); + file_put_contents($stale, 'stale'); + $kept = base_path('storage/framework/testing/pilot-kept.txt'); + file_put_contents($kept, 'kept'); + + postJson('/api/v1/update/delete', [ + 'deleted_files' => json_encode(['storage/framework/testing/pilot-stale.txt']), + ])->assertOk()->assertJson(['success' => true]); + + expect(file_exists($stale))->toBeFalse(); + expect(file_exists($kept))->toBeTrue(); + + @unlink($kept); +}); + +it('treats a missing manifest as nothing to clean', function () { + if (file_exists(base_path('manifest.json'))) { + $this->markTestSkipped('A root manifest exists; skipping the no-manifest branch.'); + } + + postJson('/api/v1/update/clean') + ->assertOk() + ->assertJson(['success' => true, 'cleaned' => 0]); +}); + +it('runs pending migrations as an update step', function () { + postJson('/api/v1/update/migrate')->assertOk()->assertJson(['success' => true]); +}); + +it('stamps the new version when finishing an update', function () { + postJson('/api/v1/update/finish', ['installed' => '3.0.0', 'version' => '9.9.9-test']) + ->assertOk() + ->assertJson(['success' => true, 'error' => false]); + + expect(DB::table('settings')->where('option', 'version')->value('value'))->toBe('9.9.9-test'); +}); + +it('validates the finish payload', function () { + postJson('/api/v1/update/finish', [])->assertStatus(422) + ->assertJsonValidationErrors(['installed', 'version']); +}); + +it('refuses the console updater in containerized installs', function () { + config(['invoiceshelf.containerized' => true]); + + $this->artisan('core:update') + ->expectsOutputToContain('disabled in containerized installs') + ->assertExitCode(0); +}); diff --git a/tests/Feature/PilotSpec/ZzDatabaseRefusalTest.php b/tests/Feature/PilotSpec/ZzDatabaseRefusalTest.php new file mode 100644 index 00000000..77fb27d4 --- /dev/null +++ b/tests/Feature/PilotSpec/ZzDatabaseRefusalTest.php @@ -0,0 +1,37 @@ + 'DatabaseSeeder', '--force' => true]); + $this->envBackup = file_get_contents(base_path('.env')); +}); + +afterEach(function () { + file_put_contents(base_path('.env'), $this->envBackup); +}); + +it('refuses a database that already contains data, without touching the environment file', function () { + $path = storage_path('framework/testing/pilot-nonempty.sqlite'); + @mkdir(dirname($path), 0775, true); + @unlink($path); + $db = new SQLite3($path); + $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + $db->close(); + + $response = postJson('/api/v1/installation/database/config', [ + 'app_url' => 'http://pilot.test', + 'database_connection' => 'sqlite', + 'database_name' => $path, + ])->assertOk()->json(); + + expect($response['error'])->toBe('database_should_be_empty'); + expect(file_get_contents(base_path('.env')))->toBe($this->envBackup); + + @unlink($path); +}); diff --git a/tests/Feature/PilotSpec/fixtures/release-server-router.php b/tests/Feature/PilotSpec/fixtures/release-server-router.php new file mode 100644 index 00000000..6cf702b3 --- /dev/null +++ b/tests/Feature/PilotSpec/fixtures/release-server-router.php @@ -0,0 +1,31 @@ + true, + 'release' => [ + 'version' => '9.9.9-test', + 'min_php_version' => '8.0', + 'extensions' => ['curl', 'pilot_missing_ext'], + ], + ]); + + return true; +} +if (preg_match('#^/releases/download/9\.9\.9-test\.zip#', $uri)) { + $tmp = tempnam(sys_get_temp_dir(), 'relzip'); + $zip = new ZipArchive; + $zip->open($tmp, ZipArchive::OVERWRITE); + $zip->addFromString('InvoiceShelf/pilot-release-marker.txt', 'ok'); + $zip->close(); + header('Content-Type: application/zip'); + readfile($tmp); + unlink($tmp); + + return true; +} +http_response_code(404);