From 8ae82ae91e093788cc02b5377e4db77ff4a255bb Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:31:03 +0200 Subject: [PATCH] fix(documents): correct demo sequences and save errors (#740) --- database/seeders/RealisticDemoSeeder.php | 39 ++++++++-- .../estimates/views/EstimateCreateView.vue | 70 ++++++++++-------- .../invoices/views/InvoiceCreateView.vue | 20 ++++-- resources/scripts/utils/error-handling.ts | 2 +- .../Feature/Admin/RealisticDemoSeederTest.php | 71 +++++++++++++++++++ 5 files changed, 164 insertions(+), 38 deletions(-) diff --git a/database/seeders/RealisticDemoSeeder.php b/database/seeders/RealisticDemoSeeder.php index 1aeea2b1..1c0d8064 100644 --- a/database/seeders/RealisticDemoSeeder.php +++ b/database/seeders/RealisticDemoSeeder.php @@ -26,6 +26,7 @@ use App\Models\Tax; use App\Models\TaxType; use App\Models\Unit; use App\Models\User; +use App\Services\Document\SerialNumberService; use Carbon\Carbon; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Artisan; @@ -245,6 +246,12 @@ class RealisticDemoSeeder extends Seeder Expense::where('company_id', $this->companyId)->delete(); ExpenseCategory::where('company_id', $this->companyId)->delete(); + // Taxes and recurring invoices still point at their customer. Remove + // them before deleting customers so a second seeder run is idempotent + // on databases that enforce foreign keys. + Tax::where('company_id', $this->companyId)->delete(); + RecurringInvoice::where('company_id', $this->companyId)->delete(); + // Customers: delete along with their addresses (addresses keyed by customer_id) $customerIds = Customer::where('company_id', $this->companyId)->pluck('id'); Address::whereIn('customer_id', $customerIds)->delete(); @@ -252,10 +259,8 @@ class RealisticDemoSeeder extends Seeder Item::where('company_id', $this->companyId)->delete(); - // Taxes cascade from their documents, but the reusable definitions and - // the standalone rows do not. - Tax::where('company_id', $this->companyId)->delete(); - RecurringInvoice::where('company_id', $this->companyId)->delete(); + // The reusable definitions and standalone rows do not cascade from + // their documents. TaxType::where('company_id', $this->companyId)->delete(); Note::where('company_id', $this->companyId)->delete(); CustomField::where('company_id', $this->companyId)->delete(); @@ -461,6 +466,7 @@ class RealisticDemoSeeder extends Seeder 'invoice_number' => $invoiceNumber, 'reference_number' => null, 'template_name' => 'invoice1', + 'type' => Invoice::TYPE_INVOICE, 'status' => $status, 'paid_status' => $paidStatus, 'overdue' => $overdue, @@ -495,6 +501,15 @@ class RealisticDemoSeeder extends Seeder // The PDF routes bind on unique_hash. Creating through the model rather // than InvoiceService skips the one place that normally assigns it, so // set it here or every seeded document 404s on preview and download. + $serial = (new SerialNumberService) + ->setModel($invoice) + ->setCompany($invoice->company_id) + ->setCustomer($invoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setNextNumbers(); + + $invoice->sequence_number = $serial->nextSequenceNumber; + $invoice->customer_sequence_number = $serial->nextCustomerSequenceNumber; $invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id); $invoice->created_at = $invoiceDate; $invoice->updated_at = $invoiceDate; @@ -564,6 +579,14 @@ class RealisticDemoSeeder extends Seeder ]); // See seedInvoice(): the PDF routes bind on unique_hash. + $serial = (new SerialNumberService) + ->setModel($payment) + ->setCompany($payment->company_id) + ->setCustomer($payment->customer_id) + ->setNextNumbers(); + + $payment->sequence_number = $serial->nextSequenceNumber; + $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); $payment->created_at = $paymentDate; $payment->updated_at = $paymentDate; @@ -649,6 +672,14 @@ class RealisticDemoSeeder extends Seeder } // See seedInvoice(): the PDF routes bind on unique_hash. + $serial = (new SerialNumberService) + ->setModel($estimate) + ->setCompany($estimate->company_id) + ->setCustomer($estimate->customer_id) + ->setNextNumbers(); + + $estimate->sequence_number = $serial->nextSequenceNumber; + $estimate->customer_sequence_number = $serial->nextCustomerSequenceNumber; $estimate->unique_hash = Hashids::connection(Estimate::class)->encode($estimate->id); $estimate->created_at = $estimateDate; $estimate->updated_at = $estimateDate; diff --git a/resources/scripts/features/company/estimates/views/EstimateCreateView.vue b/resources/scripts/features/company/estimates/views/EstimateCreateView.vue index d32a43de..69cf6d24 100644 --- a/resources/scripts/features/company/estimates/views/EstimateCreateView.vue +++ b/resources/scripts/features/company/estimates/views/EstimateCreateView.vue @@ -115,6 +115,11 @@ import { } from '@vuelidate/validators' import useVuelidate from '@vuelidate/core' import { useEstimateStore } from '../store' +import { useNotificationStore } from '@/scripts/stores/notification.store' +import { + handleApiError, + getErrorTranslationKey, +} from '@/scripts/utils/error-handling' import EstimateBasicFields from '../components/EstimateBasicFields.vue' import { DocumentItemsTable, @@ -125,6 +130,7 @@ import { } from '../../../shared/document-form' const estimateStore = useEstimateStore() +const notificationStore = useNotificationStore() const { t } = useI18n() const route = useRoute() const router = useRouter() @@ -200,32 +206,32 @@ async function submitForm(): Promise { isSaving.value = true - const data: Record = { - ...cloneDeep(estimateStore.newEstimate), - sub_total: Math.round(estimateStore.getSubTotal), - total: Math.round(estimateStore.getTotal), - tax: Math.round(estimateStore.getTotalTax), - } - - const items = data.items as Array> - if (data.discount_per_item === 'YES') { - items.forEach((item, index) => { - if (item.discount_type === 'fixed') { - items[index].discount = Math.round((item.discount as number) * 100) - } - }) - } else { - if (data.discount_type === 'fixed') { - data.discount = Math.round((data.discount as number) * 100) - } - } - - const taxes = data.taxes as Array> - if (data.tax_per_item !== 'YES' && taxes.length) { - data.tax_type_ids = taxes.map((tax) => tax.tax_type_id) - } - try { + const data: Record = { + ...cloneDeep(estimateStore.newEstimate), + sub_total: Math.round(estimateStore.getSubTotal), + total: Math.round(estimateStore.getTotal), + tax: Math.round(estimateStore.getTotalTax), + } + + const items = data.items as Array> + if (data.discount_per_item === 'YES') { + items.forEach((item, index) => { + if (item.discount_type === 'fixed') { + items[index].discount = Math.round((item.discount as number) * 100) + } + }) + } else { + if (data.discount_type === 'fixed') { + data.discount = Math.round((data.discount as number) * 100) + } + } + + const taxes = data.taxes as Array> + if (data.tax_per_item !== 'YES' && taxes.length) { + data.tax_type_ids = taxes.map((tax) => tax.tax_type_id) + } + const action = isEdit.value ? estimateStore.updateEstimate : estimateStore.addEstimate @@ -234,10 +240,16 @@ async function submitForm(): Promise { if (res.data.data) { router.push(`/admin/estimates/${res.data.data.id}/view`) } - } catch (err) { - console.error(err) - } + } catch (err: unknown) { + const normalized = handleApiError(err) + const translationKey = getErrorTranslationKey(normalized.message) - isSaving.value = false + notificationStore.showNotification({ + type: 'error', + message: translationKey ? t(translationKey) : normalized.message, + }) + } finally { + isSaving.value = false + } } diff --git a/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue b/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue index 591a1914..db30c89b 100644 --- a/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue +++ b/resources/scripts/features/company/invoices/views/InvoiceCreateView.vue @@ -121,6 +121,11 @@ import useVuelidate from '@vuelidate/core' import { useInvoiceStore } from '../store' import { useRecurringInvoiceStore } from '@/scripts/features/company/recurring-invoices/store' import { useCompanyStore } from '@/scripts/stores/company.store' +import { useNotificationStore } from '@/scripts/stores/notification.store' +import { + handleApiError, + getErrorTranslationKey, +} from '@/scripts/utils/error-handling' import InvoiceBasicFields from '../components/InvoiceBasicFields.vue' import { DocumentItemsTable, @@ -133,6 +138,7 @@ import { const invoiceStore = useInvoiceStore() const recurringInvoiceStore = useRecurringInvoiceStore() const companyStore = useCompanyStore() +const notificationStore = useNotificationStore() const { t } = useI18n() const route = useRoute() const router = useRouter() @@ -426,10 +432,16 @@ async function submitForm(): Promise { const response = await action(data) router.push(`/admin/invoices/${response.data.data.id}/view`) } - } catch (err) { - console.error(err) - } + } catch (err: unknown) { + const normalized = handleApiError(err) + const translationKey = getErrorTranslationKey(normalized.message) - isSaving.value = false + notificationStore.showNotification({ + type: 'error', + message: translationKey ? t(translationKey) : normalized.message, + }) + } finally { + isSaving.value = false + } } diff --git a/resources/scripts/utils/error-handling.ts b/resources/scripts/utils/error-handling.ts index 09f07c28..c2c8814f 100644 --- a/resources/scripts/utils/error-handling.ts +++ b/resources/scripts/utils/error-handling.ts @@ -66,7 +66,7 @@ const ERROR_TRANSLATION_MAP: Record = { 'invoice_must_be_settled_before_completion': 'errors.invoice_must_be_settled_before_completion', 'The estimate number has already been taken.': 'errors.estimate_number_used', - 'The payment number has already been taken.': 'errors.estimate_number_used', + 'The payment number has already been taken.': 'errors.payment_number_used', 'The invoice number has already been taken.': 'errors.invoice_number_used', 'The name has already been taken.': 'errors.name_already_taken', 'total_invoice_amount_must_be_more_than_paid_amount': 'invoices.invalid_due_amount_message', diff --git a/tests/Feature/Admin/RealisticDemoSeederTest.php b/tests/Feature/Admin/RealisticDemoSeederTest.php index 583145ad..901dbdd1 100644 --- a/tests/Feature/Admin/RealisticDemoSeederTest.php +++ b/tests/Feature/Admin/RealisticDemoSeederTest.php @@ -3,7 +3,11 @@ use App\Models\Estimate; use App\Models\Invoice; use App\Models\Payment; +use App\Models\User; use Illuminate\Support\Facades\Artisan; +use Laravel\Sanctum\Sanctum; + +use function Pest\Laravel\getJson; beforeEach(function () { Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]); @@ -29,3 +33,70 @@ test('every seeded document has the unique hash its pdf route binds on', functio Estimate::class, Payment::class, ]); + +test('seeded documents maintain serial number sequences and next numbers', function () { + $companyId = User::where('email', 'demo@invoiceshelf.com') + ->firstOrFail() + ->companies() + ->firstOrFail() + ->id; + + $documents = [ + [Invoice::class, ['type' => Invoice::TYPE_INVOICE], 35], + [Estimate::class, [], 8], + [Payment::class, [], 17], + ]; + + foreach ($documents as [$model, $scope, $expectedCount]) { + $query = $model::query()->where('company_id', $companyId); + + foreach ($scope as $column => $value) { + $query->where($column, $value); + } + + $seededDocuments = $query->get(); + + expect($seededDocuments)->toHaveCount($expectedCount) + ->and($seededDocuments->whereNull('sequence_number'))->toBeEmpty() + ->and($seededDocuments->whereNull('customer_sequence_number'))->toBeEmpty() + ->and($seededDocuments->pluck('sequence_number')->sort()->values()->all()) + ->toBe(range(1, $expectedCount)); + + $seededDocuments + ->groupBy('customer_id') + ->each(function ($customerDocuments): void { + expect($customerDocuments->pluck('customer_sequence_number')->sort()->values()->all()) + ->toBe(range(1, $customerDocuments->count())); + }); + } + + $user = User::where('email', 'demo@invoiceshelf.com')->firstOrFail(); + Sanctum::actingAs($user, ['*']); + $this->withHeaders(['company' => $companyId]); + + getJson('api/v1/next-number?key=invoice') + ->assertOk() + ->assertJson(['nextNumber' => 'INV-000036']); + + getJson('api/v1/next-number?key=estimate') + ->assertOk() + ->assertJson(['nextNumber' => 'EST-000009']); + + getJson('api/v1/next-number?key=payment') + ->assertOk() + ->assertJson(['nextNumber' => 'PAY-000018']); +}); + +test('rerunning the realistic demo seeder remains idempotent', function () { + $companyId = User::where('email', 'demo@invoiceshelf.com') + ->firstOrFail() + ->companies() + ->firstOrFail() + ->id; + + Artisan::call('db:seed', ['--class' => 'RealisticDemoSeeder', '--force' => true]); + + expect(Invoice::where('company_id', $companyId)->where('type', Invoice::TYPE_INVOICE)->count())->toBe(35) + ->and(Estimate::where('company_id', $companyId)->count())->toBe(8) + ->and(Payment::where('company_id', $companyId)->count())->toBe(17); +});