fix(documents): correct demo sequences and save errors (#740)

This commit is contained in:
Darko Gjorgjijoski
2026-08-02 17:31:03 +02:00
committed by GitHub
parent 1e72d9d449
commit 8ae82ae91e
5 changed files with 164 additions and 38 deletions

View File

@@ -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;

View File

@@ -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<void> {
isSaving.value = true
const data: Record<string, unknown> = {
...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<Record<string, unknown>>
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<Record<string, unknown>>
if (data.tax_per_item !== 'YES' && taxes.length) {
data.tax_type_ids = taxes.map((tax) => tax.tax_type_id)
}
try {
const data: Record<string, unknown> = {
...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<Record<string, unknown>>
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<Record<string, unknown>>
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<void> {
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
}
}
</script>

View File

@@ -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<void> {
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
}
}
</script>

View File

@@ -66,7 +66,7 @@ const ERROR_TRANSLATION_MAP: Record<string, string> = {
'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',

View File

@@ -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);
});