mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 07:02:13 +00:00
fix(documents): correct demo sequences and save errors (#740)
This commit is contained in:
committed by
GitHub
parent
1e72d9d449
commit
8ae82ae91e
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user