mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 15:12:12 +00:00
* fix(pdf): stop an unresolvable template taking the PDF route down RealisticDemoSeeder::seedEstimate() never set template_name, while seedInvoice() has always set invoice1. Every demo estimate therefore had '', so findFormattedTemplate() returned null and EstimateService did $template['custom'] on it -- a 500 on the estimate PDF route, on either driver, since the exception is thrown before a driver is reached. That is the "Unable to load document preview" people were seeing. The seeder now sets estimate1, but seeding was only how this surfaced. The stored name is validated when a document is saved through the UI and nowhere else: seeders, imports, recurring-invoice copies and rows predating that validation all bypass it, and a template can also be deleted from disk after the fact. A name that cannot be resolved should fall back to the default design, not take the route down. PdfTemplateUtils::resolveView() -- already the resolver for payment receipts and reports -- gains an optional fallback and tries each candidate as custom then built-in. Both document services collapse to a single call and can no longer index null. The fallback logs a warning, so a bad name stays visible rather than being silently swapped. Also casts two nulls in GeneratesPdfTrait: an address line or custom field that was never filled in reaches htmlspecialchars() and strtr() as null, which every PDF render was emitting a deprecation for on PHP 8.4 and would be an error on 9. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * feat(pdf): a command that measures the two drivers against each other "The PDF looks different" has been diagnosed by eye every time, because nothing compares the renderers. Asserting on PDF bytes is useless and rendering through Gotenberg needs a live service, so the suite has never covered it. pdf:compare renders each stock template through both drivers and reports the page box, page count and the bounding box of the text on page one, then flags any template whose ink lands more than --tolerance points apart. It goes through the real document services, so it exercises the same shared view data and template resolution a request would. Two things it has to get right to be honest: Comparing designs means persisting the template choice -- InvoiceService reads it back with Invoice::find($id)->template_name, so assigning in memory silently compares the same design every row. The run happens inside a transaction that is always rolled back. Page numbers are turned off for the duration. They are a Chromium capability with no dompdf equivalent, so leaving them on puts ink at the foot of every Gotenberg page and drowns out every difference worth seeing -- which is exactly what the first run of this command did. Word positions come from poppler's pdftotext, which is on most dev machines but not in the app container; without it the command still compares page geometry and says what it could not check. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): let the two renderers agree on the items table, and drop the shim Two parts: the stock templates stop doing their own page margins, and the items table stops relying on a property that does not apply to it. Page margins. The templates carried their own via `html { margin-top: 50px }`, which predates page setup owning them. dompdf largely collapses that margin; Chromium honours it and adds it to the page box, so the same template came out 38px from the top on one renderer and 77px on the other. The html rule is gone and body is reset instead, which is what makes the page box agree. Headers that were positioned absolutely at a negative offset -- only possible because of that margin -- are back in flow. The items table. Every stock template sets `table { border-collapse: collapse }`, and CSS says padding does not apply to a table in that mode. dompdf applies it anyway; Chromium follows the spec and drops it, so the table's `padding: 0 30px` inset the content on one renderer and not the other. Measured in isolation: with border-collapse, content starts at x=24.0 on dompdf and x=1.5 on Chromium -- the full 30px. All of the table's spacing moves to .items-table-wrapper, a plain block both engines treat the same, using padding so nothing collapses through it either. Measured across the seven document templates, that closes the horizontal gap outright: xMin was 57 on dompdf against 37 on Chromium for five of them, and is now within 3pt on all seven. GotenbergStockTemplateCompatibility is removed. Its premise was that dompdf inflates declared line heights by 1.5x, and that does not hold: rendering the same text at 12px, 18px, 36px and unitless 1.0/1.5 through both engines gives line spacing within 0.5pt every time. It also applied its multiplier to the reports, where line-height 21px pairs with font sizes of 14, 16 and 20px -- so .report-footer-value at a 1.05 ratio was being blown out to 31.5px, half again taller than dompdf renders it. A residual vertical difference remains and is localised, not guessed at: it accumulates only in the address blocks, which are <br>-joined text emitted by getFormattedString() with an <h3> in front. Reduced to that construct alone, Chromium steps 11.25pt per line -- exactly the declared line-height: 15px -- while dompdf steps 14.4pt. That needs deciding on its own terms rather than a global multiplier, so it is left visible and measurable via pdf:compare. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): restore the stock template design Two regressions from the page-setup work, both visible on the page. The coloured header band stopped bleeding to the paper edges. invoice2 and estimate2 are built around a full-width band, and it now sits in normal flow at the top of body, so it only reaches the edge when the page margin is nothing. #728 defaulted margins to 1.2cm on the reasoning that it matched dompdf's built-in default and so kept existing output unchanged. That was the wrong reference: the templates are drawn for a zero margin and carry their own 30px insets, and Gotenberg rendered them at zero before #728, which is the intended look. Margins now default to nothing. Setting one still works and is honoured by both drivers, at the cost of the band no longer reaching the edge. A bare `0` is valid CSS and the only length needing no unit, so CssLength and PdfPageSetup accept it -- without that the new default would have thrown on every render. The totals block was pushed in from the items table's right edge. Fixing the border-collapse padding problem moved the table's 30px inset onto a wrapper that contains the whole partial, so it stacked on the insets the hr (25px) and the totals container (25px) already had. Those two were always honoured by both renderers; only the table's own padding was not. The inset now lives on a div wrapping just the table, and the wrapper keeps vertical spacing only, which restores the original 30px/25px relationship rather than inventing a new one. Also drops the negative margin-bottom that pulled the addresses up into the band and hid "Bill to,", and removes a stray `bottom: 0px` on invoice1's .header-bottom-divider that combined with `top: 90px` to stretch the rule down the page. Checked by rendering, not only by measurement: invoice2 and invoice1 on both drivers now match the intended design. pdf:compare puts the two renderers within a few points horizontally on all seven documents, xMin 21-22 and xMax 564-575. The remaining vertical difference is the address-block line spacing documented earlier and is unchanged by this. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * feat(demo): make the demo data actually demo the product The demo company had no address row, and Invoice::getCompanyAddress() returns false outright in that case, so every seeded document rendered with an empty company block -- the name only appeared because the header falls back to it when there is no logo. Several headline features had no demo data at all: zero tax types, zero notes, zero recurring invoices, zero custom fields. DemoSeeder, which the test suite and reset:app both run, now creates Acme Inc with a postal address, tax ids and a country -- the fields the default address format actually renders. The address is created through the relation, as CompaniesController does, so company_id is set and type/user_id/customer_id stay null: Company::address() is an unscoped hasOne, so anything else carrying that company_id would be picked up as the company's own. It also stops trusting currency id 1. Migration 2025_08_18 inserts Algerian Dinar via firstOrCreate() before any seeder runs, so on a fresh migrate+seed the demo priced everything in "DA". RealisticDemoSeeder already worked around this for itself; resolving USD by code fixes it at source for reset:app and the tests too. RealisticDemoSeeder gains a logo, two tax types, a notes library, custom fields and an active recurring invoice. Notes are seeded twice over on purpose: the library and a document's notes column are unrelated in this application -- there is no foreign key, and is_default only drives a badge in the settings list, so nothing pre-fills a document with one. Tax is applied at document level to most but not all documents, so the demo has a zero-rated example in it. The arithmetic is the caller's: the service layer trusts whatever amount it is handed rather than recomputing it, so tax is rounded once off the subtotal and carried through total, due_amount and every base_* twin -- miss due_amount and a paid invoice renders as part-paid. Custom fields are on Customer, the only model_type with a create/edit UI end to end. The PDF renders only model_type 'Item', which would add a column to the items table and disturb a layout that was just squared up across both drivers. The logo is a generated Acme mark rather than one of InvoiceShelf's own, which would read as InvoiceShelf billing the customer. Also documents both seeders in AGENTS.md. RealisticDemoSeeder was referenced nowhere outside database/seeders/, which is a poor place to keep the thing that makes the app look real. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): tighten the spacing the old absolute header left behind invoice2 and estimate2 carried three stacked top offsets -- content-wrapper's 60px margin plus address-container's 18px margin and 20px padding -- 98px of dead white between the coloured band and the first line of content. They existed because the band used to be position: absolute and out of flow, so everything below had to be pushed clear of where it visually sat. The band takes its own height now, so the compensation is just a gap. Collapsed to a single 32px. Only those two templates had it, which is the tell: they are exactly the two whose headers were absolutely positioned. Also pins the margins on the <h3> the address formats emit. Left to the user-agent default it pushed the company column out of line with the Bill to / Ship to columns beside it, so the three column headings started at three different heights. They line up now. That h3 is also where the two renderers were measured drifting apart, and pinning it narrows invoice2 from 85.8pt to 72.0pt and estimate2 from 84.4 to 76.8. The templates without a coloured band barely move, which places the rest of the difference in the per-line spacing of the <br>-joined address lines rather than in the heading -- consistent with the isolated measurement earlier (dompdf 14.4pt per line against Chromium's 11.25pt) and still open. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
945 lines
40 KiB
PHP
945 lines
40 KiB
PHP
<?php
|
|
|
|
namespace Database\Seeders;
|
|
|
|
use App\Facades\Hashids;
|
|
use App\Models\Address;
|
|
use App\Models\AiConversation;
|
|
use App\Models\Company;
|
|
use App\Models\CompanySetting;
|
|
use App\Models\Country;
|
|
use App\Models\Currency;
|
|
use App\Models\Customer;
|
|
use App\Models\CustomField;
|
|
use App\Models\Estimate;
|
|
use App\Models\EstimateItem;
|
|
use App\Models\Expense;
|
|
use App\Models\ExpenseCategory;
|
|
use App\Models\Invoice;
|
|
use App\Models\InvoiceItem;
|
|
use App\Models\Item;
|
|
use App\Models\Note;
|
|
use App\Models\Payment;
|
|
use App\Models\PaymentMethod;
|
|
use App\Models\RecurringInvoice;
|
|
use App\Models\Tax;
|
|
use App\Models\TaxType;
|
|
use App\Models\Unit;
|
|
use App\Models\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Seeder;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* ⚠️ DEVELOPMENT ONLY — DO NOT RUN IN PRODUCTION
|
|
*
|
|
* Populates the demo company with ~100 realistic records (8 customers, 12
|
|
* catalog items, 6 expense categories, 35 invoices, ~20 payments, 8 estimates,
|
|
* 15 expenses, 2 tax types, a notes library and a recurring invoice) so the app
|
|
* looks like a real install during local development, and so the AI chat
|
|
* assistant has meaningful data to query.
|
|
*
|
|
* This seeder is intentionally NOT wired into DatabaseSeeder and is NOT used
|
|
* by the test suite (the minimal DemoSeeder remains in the test path to keep
|
|
* test runs fast). To use:
|
|
*
|
|
* php artisan db:seed --class=RealisticDemoSeeder --force
|
|
*
|
|
* Prerequisite: the standard DemoSeeder must have run first (creates the demo
|
|
* user, company, company settings, and default payment methods / units via
|
|
* CompanyService::setupDefaults()). If the demo user is missing, this seeder
|
|
* will invoke DemoSeeder automatically before proceeding.
|
|
*
|
|
* Design notes:
|
|
*
|
|
* - Records are created via Model::create() rather than factories. The
|
|
* existing InvoiceFactory / InvoiceItemFactory / ItemFactory have bugs
|
|
* (unconditional RecurringInvoice cascades, creator_id pointing at a
|
|
* nonexistent User->company_id field, hardcoded User::find(1) lookups)
|
|
* that make them unsuitable for seeding realistic data.
|
|
*
|
|
* - Item prices and all monetary columns are stored in **cents**. A $250
|
|
* item has `price = 25000`. The frontend divides by 100 for display.
|
|
*
|
|
* - Dates are deliberately distributed over the last 6 months so that
|
|
* AI tool queries like `get_company_stats(period=this_month)` vs
|
|
* `get_company_stats(period=last_month)` return different numbers.
|
|
*
|
|
* - Invoice totals are computed from line items, not random. Tax is applied
|
|
* at document level (tax_per_item = 'NO') to most but not all documents,
|
|
* computed once off the subtotal and carried through total, due_amount and
|
|
* every base_* twin — the service layer trusts the amount it is given
|
|
* rather than recomputing it, so the arithmetic is the caller's to get
|
|
* right. No per-item tax or discount.
|
|
*/
|
|
class RealisticDemoSeeder extends Seeder
|
|
{
|
|
private User $user;
|
|
|
|
private int $companyId;
|
|
|
|
/** @var array<int, Customer> */
|
|
private array $customers = [];
|
|
|
|
/** @var array<int, Item> */
|
|
private array $items = [];
|
|
|
|
/** @var array<int, ExpenseCategory> */
|
|
private array $expenseCategories = [];
|
|
|
|
private int $paymentMethodId;
|
|
|
|
private int $unitId;
|
|
|
|
private int $currencyId;
|
|
|
|
private ?int $countryId = null;
|
|
|
|
private int $invoiceSequence = 1;
|
|
|
|
private int $estimateSequence = 1;
|
|
|
|
private int $paymentSequence = 1;
|
|
|
|
/** @var array<int, TaxType> */
|
|
private array $taxTypes = [];
|
|
|
|
/** @var array<int, string> */
|
|
private array $invoiceNotes = [];
|
|
|
|
public function run(): void
|
|
{
|
|
$this->ensureReferenceData();
|
|
$this->resolveDemoContext();
|
|
$this->cleanupExistingDemoData();
|
|
|
|
$this->seedCompanyLogo();
|
|
$this->seedTaxTypes();
|
|
$this->seedNotes();
|
|
$this->seedCustomFields();
|
|
$this->seedCustomers();
|
|
$this->seedCatalogItems();
|
|
$this->seedExpenseCategories();
|
|
$this->seedInvoicesWithPayments();
|
|
$this->seedEstimates();
|
|
$this->seedRecurringInvoice();
|
|
$this->seedExpenses();
|
|
|
|
$this->info(sprintf(
|
|
'RealisticDemoSeeder done: %d customers, %d items, %d invoices (%d overdue, %d paid, %d partially_paid), %d payments, %d estimates, %d expenses, %d tax types, %d notes, %d recurring.',
|
|
Customer::where('company_id', $this->companyId)->count(),
|
|
Item::where('company_id', $this->companyId)->count(),
|
|
Invoice::where('company_id', $this->companyId)->count(),
|
|
Invoice::where('company_id', $this->companyId)->where('overdue', true)->count(),
|
|
Invoice::where('company_id', $this->companyId)->where('paid_status', Invoice::STATUS_PAID)->count(),
|
|
Invoice::where('company_id', $this->companyId)->where('paid_status', Invoice::STATUS_PARTIALLY_PAID)->count(),
|
|
Payment::where('company_id', $this->companyId)->count(),
|
|
Estimate::where('company_id', $this->companyId)->count(),
|
|
Expense::where('company_id', $this->companyId)->count(),
|
|
TaxType::where('company_id', $this->companyId)->count(),
|
|
Note::where('company_id', $this->companyId)->count(),
|
|
RecurringInvoice::where('company_id', $this->companyId)->count(),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Find the demo user + company. If missing, run the base DemoSeeder first.
|
|
*/
|
|
/**
|
|
* Wrap command output so the seeder can also run programmatically
|
|
* (e.g. from a test) without a Command instance attached.
|
|
*/
|
|
private function info(string $message): void
|
|
{
|
|
if ($this->command !== null) {
|
|
$this->command->info($message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify the base reference tables (currencies, countries) have data.
|
|
*
|
|
* Both are seeded by `php artisan db:seed` / the installer via DatabaseSeeder
|
|
* ahead of DemoSeeder. If they're empty, every downstream insert that
|
|
* references them (customers.currency_id, addresses.country_id, items.currency_id…)
|
|
* will fail with an opaque SQLite "FOREIGN KEY constraint failed" — so we bail
|
|
* early with an actionable error.
|
|
*/
|
|
private function ensureReferenceData(): void
|
|
{
|
|
if (Currency::count() === 0) {
|
|
throw new RuntimeException(
|
|
'Currencies table is empty. Run `php artisan db:seed --class=CurrenciesTableSeeder --force` first.'
|
|
);
|
|
}
|
|
|
|
if (Country::count() === 0) {
|
|
throw new RuntimeException(
|
|
'Countries table is empty. Run `php artisan db:seed --class=CountriesTableSeeder --force` first.'
|
|
);
|
|
}
|
|
|
|
// Resolve the USD currency (or fall back to whatever is at id=1 / first).
|
|
$this->currencyId = Currency::where('code', 'USD')->first()?->id
|
|
?? Currency::find(1)?->id
|
|
?? Currency::first()->id;
|
|
|
|
// Resolve country for demo addresses — prefer US if present, else first.
|
|
$this->countryId = Country::where('code', 'US')->first()?->id
|
|
?? Country::find(1)?->id
|
|
?? Country::first()?->id;
|
|
}
|
|
|
|
private function resolveDemoContext(): void
|
|
{
|
|
$user = User::where('email', 'demo@invoiceshelf.com')->first();
|
|
|
|
if ($user === null) {
|
|
$this->info('Demo user missing; running DemoSeeder first…');
|
|
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
|
$user = User::where('email', 'demo@invoiceshelf.com')->firstOrFail();
|
|
}
|
|
|
|
$this->user = $user;
|
|
$this->companyId = $user->companies()->firstOrFail()->id;
|
|
|
|
// CompanyService::setupDefaults() seeds default payment methods + units;
|
|
// use whichever rows exist for this company. Fall back to creating one if
|
|
// setupDefaults wasn't called (edge case — the standard DemoSeeder invokes it).
|
|
$paymentMethod = PaymentMethod::where('company_id', $this->companyId)->first()
|
|
?? PaymentMethod::create(['name' => 'Bank Transfer', 'company_id' => $this->companyId]);
|
|
$this->paymentMethodId = $paymentMethod->id;
|
|
|
|
$unit = Unit::where('company_id', $this->companyId)->first()
|
|
?? Unit::create(['name' => 'pc', 'company_id' => $this->companyId]);
|
|
$this->unitId = $unit->id;
|
|
|
|
// Override the company's default currency to match the USD id we just
|
|
// resolved. DemoSeeder hardcodes `'currency' => 1`, but since migration
|
|
// 2025_08_18 inserts Algerian Dinar (DZD, symbol "DA") via firstOrCreate()
|
|
// BEFORE CurrenciesTableSeeder runs, currency id 1 in a fresh install is
|
|
// Algerian Dinar, not USD. Without this override the company's display
|
|
// currency shows "DA" even though all our seeded records are priced in USD.
|
|
CompanySetting::setSettings(
|
|
['currency' => (string) $this->currencyId],
|
|
$this->companyId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wipe any previously seeded demo data so the seeder is idempotent — you
|
|
* can re-run it after code changes without duplicating records.
|
|
*
|
|
* Deletes in child-first order to respect foreign keys. We deliberately
|
|
* do NOT touch company_settings, the demo user, or the demo company.
|
|
*/
|
|
private function cleanupExistingDemoData(): void
|
|
{
|
|
AiConversation::where('company_id', $this->companyId)->delete(); // cascades to ai_messages
|
|
Payment::where('company_id', $this->companyId)->delete();
|
|
InvoiceItem::where('company_id', $this->companyId)->delete();
|
|
Invoice::where('company_id', $this->companyId)->delete();
|
|
EstimateItem::where('company_id', $this->companyId)->delete();
|
|
Estimate::where('company_id', $this->companyId)->delete();
|
|
Expense::where('company_id', $this->companyId)->delete();
|
|
ExpenseCategory::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();
|
|
Customer::whereIn('id', $customerIds)->delete();
|
|
|
|
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();
|
|
TaxType::where('company_id', $this->companyId)->delete();
|
|
Note::where('company_id', $this->companyId)->delete();
|
|
CustomField::where('company_id', $this->companyId)->delete();
|
|
}
|
|
|
|
private function seedCustomers(): void
|
|
{
|
|
$customers = [
|
|
['Acme Corp', 'Sarah Chen', 'ap@acmecorp.example', '+1-617-555-0142', 'https://acmecorp.example', '100 Industrial Way', 'Boston', 'MA', '02108'],
|
|
['Widget Industries LLC', 'Marcus Johnson', 'billing@widget.example', '+1-415-555-0173', 'https://widget.example', '2200 Market Street', 'San Francisco', 'CA', '94114'],
|
|
['Global Tech Solutions', 'Priya Patel', 'accounts@globaltech.example', '+1-512-555-0188', 'https://globaltech.example', '800 Congress Avenue', 'Austin', 'TX', '78701'],
|
|
['Blue Ridge Consulting', 'James Walker', 'finance@blueridge.example', '+1-303-555-0129', 'https://blueridge.example', '1700 Broadway', 'Denver', 'CO', '80202'],
|
|
['Apex Design Studio', 'Elena Rossi', 'hello@apexdesign.example', '+1-212-555-0164', 'https://apexdesign.example', '350 West 42nd Street', 'New York', 'NY', '10036'],
|
|
['Evergreen Media Group', 'Thomas Anderson', 'ap@evergreen.example', '+1-206-555-0115', 'https://evergreen.example', '1201 Third Avenue', 'Seattle', 'WA', '98101'],
|
|
['Summit Software', 'Maya Sharma', 'billing@summitsw.example', '+1-503-555-0197', 'https://summitsw.example', '621 SW Morrison Street', 'Portland', 'OR', '97205'],
|
|
['Pacific Logistics', 'Robert Kim', 'ap@pacificlog.example', '+1-213-555-0108', 'https://pacificlog.example', '400 South Hope Street', 'Los Angeles', 'CA', '90071'],
|
|
];
|
|
|
|
foreach ($customers as [$companyName, $contactName, $email, $phone, $website, $street, $city, $state, $zip]) {
|
|
$customer = Customer::create([
|
|
'name' => $companyName,
|
|
'company_name' => $companyName,
|
|
'contact_name' => $contactName,
|
|
'email' => $email,
|
|
'phone' => $phone,
|
|
'website' => $website,
|
|
'enable_portal' => true,
|
|
'currency_id' => $this->currencyId,
|
|
'company_id' => $this->companyId,
|
|
'creator_id' => $this->user->id,
|
|
]);
|
|
|
|
// Billing + shipping addresses — created via the Customer relation so
|
|
// customer_id is set automatically. We deliberately DO NOT set user_id
|
|
// or company_id: customer addresses leave both null (the real app's
|
|
// CustomerService follows the same pattern). If we did set company_id,
|
|
// Company::address() — a bare hasOne with no scoping — would pick up
|
|
// customer addresses and CompanyResource would try to serialize them,
|
|
// triggering a Company → Address → User → Companies → Company circular
|
|
// reference that crashes json_encode in BootstrapController.
|
|
foreach ([Address::BILLING_TYPE, Address::SHIPPING_TYPE] as $type) {
|
|
$customer->addresses()->create([
|
|
'name' => $companyName,
|
|
'address_street_1' => $street,
|
|
'city' => $city,
|
|
'state' => $state,
|
|
'country_id' => $this->countryId,
|
|
'zip' => $zip,
|
|
'phone' => $phone,
|
|
'type' => $type,
|
|
]);
|
|
}
|
|
|
|
$this->customers[] = $customer;
|
|
}
|
|
}
|
|
|
|
private function seedCatalogItems(): void
|
|
{
|
|
// Format: [name, description, unit_price_in_cents]
|
|
$items = [
|
|
['Senior Consulting', 'Strategic advisory work from a principal consultant (per hour)', 25000],
|
|
['Junior Consulting', 'Implementation and support from an associate consultant (per hour)', 12500],
|
|
['Web Development', 'Full-stack web development billed hourly', 18000],
|
|
['UX Design', 'User experience research and interface design (per hour)', 20000],
|
|
['Technical Writing', 'Documentation and knowledge base articles (per hour)', 15000],
|
|
['SEO Audit', 'Comprehensive site audit with prioritized recommendations', 150000],
|
|
['Brand Strategy Session', 'Half-day workshop with deliverables', 300000],
|
|
['Code Review Pack', 'Up to 20 hours of senior code review across a repo', 120000],
|
|
['On-site Training Day', 'Full-day training at the client site', 250000],
|
|
['Custom Integration Setup', 'Bespoke third-party integration, one-time setup', 500000],
|
|
['Starter License', 'Annual software license, Starter tier', 240000],
|
|
['Enterprise License', 'Annual software license, Enterprise tier', 1200000],
|
|
];
|
|
|
|
foreach ($items as [$name, $description, $priceCents]) {
|
|
$this->items[] = Item::create([
|
|
'name' => $name,
|
|
'description' => $description,
|
|
'price' => $priceCents,
|
|
'unit_id' => $this->unitId,
|
|
'currency_id' => $this->currencyId,
|
|
'tax_per_item' => false,
|
|
'company_id' => $this->companyId,
|
|
'creator_id' => $this->user->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function seedExpenseCategories(): void
|
|
{
|
|
$names = [
|
|
['Software', 'SaaS subscriptions, licenses, developer tools'],
|
|
['Travel', 'Client visits, conferences, transportation'],
|
|
['Marketing', 'Advertising, content, campaigns'],
|
|
['Office Supplies', 'Stationery, furniture, office equipment'],
|
|
['Utilities', 'Internet, phone, electricity, coworking fees'],
|
|
['Contractors', 'Freelancers and independent contractors'],
|
|
];
|
|
|
|
foreach ($names as [$name, $description]) {
|
|
$this->expenseCategories[] = ExpenseCategory::create([
|
|
'name' => $name,
|
|
'description' => $description,
|
|
'company_id' => $this->companyId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create 35 invoices with a deliberate status + time distribution, then
|
|
* back-fill payments for the PAID and PARTIALLY_PAID ones.
|
|
*/
|
|
private function seedInvoicesWithPayments(): void
|
|
{
|
|
// Distribution plan: [count, status, paid_status, overdue, age_weeks_min, age_weeks_max]
|
|
//
|
|
// Split by time bucket so get_company_stats(period=this_month/last_month/this_quarter) differ.
|
|
$plan = [
|
|
// This month (0-4 weeks ago) — fresh activity
|
|
[3, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, false, 0, 3],
|
|
[2, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 0, 4],
|
|
[2, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 0, 2],
|
|
[3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 0, 4],
|
|
[1, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 0, 4],
|
|
// Last month (4-8 weeks ago)
|
|
[2, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 4, 8],
|
|
[1, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 4, 8],
|
|
[2, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, true, 5, 8], // overdue
|
|
[3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 4, 8],
|
|
[2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 4, 8],
|
|
// 2-3 months ago
|
|
[2, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, true, 10, 13], // overdue
|
|
[3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 8, 13],
|
|
[2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 9, 13],
|
|
[1, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 10, 13],
|
|
// 4-6 months ago (older)
|
|
[2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 16, 24],
|
|
[1, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 16, 22],
|
|
[1, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 18, 24],
|
|
[1, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, false, 18, 24],
|
|
[1, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 20, 26],
|
|
];
|
|
|
|
foreach ($plan as [$count, $status, $paidStatus, $overdue, $minWeeks, $maxWeeks]) {
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$weeksAgo = random_int($minWeeks, $maxWeeks);
|
|
$invoiceDate = Carbon::now()->subWeeks($weeksAgo)->subDays(random_int(0, 6))->startOfDay();
|
|
$dueDate = $overdue
|
|
? Carbon::now()->subDays(random_int(3, 45))->startOfDay()
|
|
: $invoiceDate->copy()->addDays(30);
|
|
|
|
$itemCount = random_int(1, 4);
|
|
$this->createInvoice($invoiceDate, $dueDate, $status, $paidStatus, $itemCount, $overdue);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function createInvoice(
|
|
Carbon $invoiceDate,
|
|
Carbon $dueDate,
|
|
string $status,
|
|
string $paidStatus,
|
|
int $itemCount,
|
|
bool $overdue,
|
|
): void {
|
|
$customer = $this->customers[array_rand($this->customers)];
|
|
$selectedItems = collect($this->items)->random($itemCount)->all();
|
|
|
|
// Compute totals from the selected line items
|
|
$lines = [];
|
|
$subTotal = 0;
|
|
foreach ($selectedItems as $item) {
|
|
$quantity = random_int(1, 8);
|
|
$lineTotal = $item->price * $quantity;
|
|
$subTotal += $lineTotal;
|
|
$lines[] = [
|
|
'item' => $item,
|
|
'quantity' => $quantity,
|
|
'line_total' => $lineTotal,
|
|
];
|
|
}
|
|
|
|
// Tax is computed once off the subtotal, not per line, and then has to be
|
|
// carried through total, due_amount and every base_* twin. Miss due_amount
|
|
// and a fully paid invoice renders as part-paid.
|
|
$taxType = $this->taxTypeForDocument($this->invoiceSequence);
|
|
$taxAmount = $taxType ? (int) round($subTotal * $taxType->percent / 100) : 0;
|
|
|
|
$total = $subTotal + $taxAmount;
|
|
$dueAmount = match ($paidStatus) {
|
|
Invoice::STATUS_PAID => 0,
|
|
Invoice::STATUS_PARTIALLY_PAID => (int) round($total * 0.6), // 40% paid, 60% still due
|
|
default => $total,
|
|
};
|
|
|
|
$invoiceNumber = 'INV-'.str_pad((string) $this->invoiceSequence, 6, '0', STR_PAD_LEFT);
|
|
$this->invoiceSequence++;
|
|
|
|
$invoice = Invoice::create([
|
|
'invoice_date' => $invoiceDate->toDateString(),
|
|
'due_date' => $dueDate->toDateString(),
|
|
'invoice_number' => $invoiceNumber,
|
|
'reference_number' => null,
|
|
'template_name' => 'invoice1',
|
|
'status' => $status,
|
|
'paid_status' => $paidStatus,
|
|
'overdue' => $overdue,
|
|
'tax_per_item' => 'NO',
|
|
'tax_included' => false,
|
|
'discount_per_item' => 'NO',
|
|
'discount_type' => 'fixed',
|
|
'discount' => 0,
|
|
'discount_val' => 0,
|
|
'sub_total' => $subTotal,
|
|
'total' => $total,
|
|
'tax' => $taxAmount,
|
|
'due_amount' => $dueAmount,
|
|
'exchange_rate' => 1,
|
|
'base_discount_val' => 0,
|
|
'base_sub_total' => $subTotal,
|
|
'base_total' => $total,
|
|
'base_tax' => $taxAmount,
|
|
'base_due_amount' => $dueAmount,
|
|
'currency_id' => $this->currencyId,
|
|
'customer_id' => $customer->id,
|
|
'company_id' => $this->companyId,
|
|
'user_id' => $this->user->id,
|
|
'creator_id' => $this->user->id,
|
|
'sent' => $status !== Invoice::STATUS_DRAFT,
|
|
'viewed' => in_array($status, [Invoice::STATUS_VIEWED, Invoice::STATUS_COMPLETED], true),
|
|
'notes' => $this->invoiceNotes === [] ? null : $this->invoiceNotes[$this->invoiceSequence % count($this->invoiceNotes)],
|
|
]);
|
|
|
|
// Touch timestamps to match the invoice_date so tool queries like
|
|
// `latest('invoice_date')` match `latest('created_at')` plausibly.
|
|
// 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.
|
|
$invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id);
|
|
$invoice->created_at = $invoiceDate;
|
|
$invoice->updated_at = $invoiceDate;
|
|
$invoice->save();
|
|
|
|
foreach ($lines as $line) {
|
|
InvoiceItem::create([
|
|
'item_id' => $line['item']->id,
|
|
'name' => $line['item']->name,
|
|
'description' => $line['item']->description,
|
|
'price' => $line['item']->price,
|
|
'quantity' => $line['quantity'],
|
|
'total' => $line['line_total'],
|
|
'discount_type' => 'fixed',
|
|
'discount' => 0,
|
|
'discount_val' => 0,
|
|
'tax' => 0,
|
|
'invoice_id' => $invoice->id,
|
|
'company_id' => $this->companyId,
|
|
'exchange_rate' => 1,
|
|
'base_price' => $line['item']->price,
|
|
'base_discount_val' => 0,
|
|
'base_tax' => 0,
|
|
'base_total' => $line['line_total'],
|
|
]);
|
|
}
|
|
|
|
if ($taxType !== null) {
|
|
$this->applyDocumentTax($invoice, $taxType, $taxAmount, 'invoice_id');
|
|
}
|
|
|
|
// Back-fill payments for PAID and PARTIALLY_PAID invoices.
|
|
if ($paidStatus === Invoice::STATUS_PAID) {
|
|
$this->createPayment($invoice, $total, $invoiceDate->copy()->addDays(random_int(3, 25)));
|
|
} elseif ($paidStatus === Invoice::STATUS_PARTIALLY_PAID) {
|
|
// 40% of the total, in one payment
|
|
$partialAmount = $total - $dueAmount;
|
|
$this->createPayment($invoice, $partialAmount, $invoiceDate->copy()->addDays(random_int(5, 20)));
|
|
}
|
|
}
|
|
|
|
private function createPayment(Invoice $invoice, int $amount, Carbon $paymentDate): void
|
|
{
|
|
$paymentNumber = 'PAY-'.str_pad((string) $this->paymentSequence, 6, '0', STR_PAD_LEFT);
|
|
$this->paymentSequence++;
|
|
|
|
// Cap payment_date at today so `list_recent_payments(days=N)` doesn't
|
|
// return future-dated rows for tests done close to the invoice_date.
|
|
if ($paymentDate->isFuture()) {
|
|
$paymentDate = Carbon::now()->subDays(random_int(1, 7));
|
|
}
|
|
|
|
$payment = Payment::create([
|
|
'payment_number' => $paymentNumber,
|
|
'payment_date' => $paymentDate->toDateString(),
|
|
'amount' => $amount,
|
|
'base_amount' => $amount,
|
|
'exchange_rate' => 1,
|
|
'user_id' => $this->user->id,
|
|
'creator_id' => $this->user->id,
|
|
'invoice_id' => $invoice->id,
|
|
'customer_id' => $invoice->customer_id,
|
|
'payment_method_id' => $this->paymentMethodId,
|
|
'currency_id' => $this->currencyId,
|
|
'company_id' => $this->companyId,
|
|
'notes' => null,
|
|
]);
|
|
|
|
// See seedInvoice(): the PDF routes bind on unique_hash.
|
|
$payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id);
|
|
$payment->created_at = $paymentDate;
|
|
$payment->updated_at = $paymentDate;
|
|
$payment->save();
|
|
}
|
|
|
|
private function seedEstimates(): void
|
|
{
|
|
// Status mix inferred from Estimate model constants — use strings directly.
|
|
$plan = [
|
|
[2, 'DRAFT', 0, 3],
|
|
[3, 'SENT', 2, 6],
|
|
[2, 'ACCEPTED', 4, 8],
|
|
[1, 'REJECTED', 8, 12],
|
|
];
|
|
|
|
foreach ($plan as [$count, $status, $minWeeks, $maxWeeks]) {
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$weeksAgo = random_int($minWeeks, $maxWeeks);
|
|
$estimateDate = Carbon::now()->subWeeks($weeksAgo)->subDays(random_int(0, 6))->startOfDay();
|
|
$expiryDate = $estimateDate->copy()->addDays(30);
|
|
|
|
$this->createEstimate($estimateDate, $expiryDate, $status, random_int(1, 3));
|
|
}
|
|
}
|
|
}
|
|
|
|
private function createEstimate(Carbon $estimateDate, Carbon $expiryDate, string $status, int $itemCount): void
|
|
{
|
|
$customer = $this->customers[array_rand($this->customers)];
|
|
$selectedItems = collect($this->items)->random($itemCount)->all();
|
|
|
|
$lines = [];
|
|
$subTotal = 0;
|
|
foreach ($selectedItems as $item) {
|
|
$quantity = random_int(1, 6);
|
|
$lineTotal = $item->price * $quantity;
|
|
$subTotal += $lineTotal;
|
|
$lines[] = ['item' => $item, 'quantity' => $quantity, 'line_total' => $lineTotal];
|
|
}
|
|
|
|
// Same arithmetic as createInvoice(): one rounding off the subtotal,
|
|
// then carried through total and the base_* twins.
|
|
$taxType = $this->taxTypeForDocument($this->estimateSequence);
|
|
$taxAmount = $taxType ? (int) round($subTotal * $taxType->percent / 100) : 0;
|
|
$total = $subTotal + $taxAmount;
|
|
|
|
$estimateNumber = 'EST-'.str_pad((string) $this->estimateSequence, 6, '0', STR_PAD_LEFT);
|
|
$this->estimateSequence++;
|
|
|
|
$estimate = Estimate::create([
|
|
'estimate_date' => $estimateDate->toDateString(),
|
|
'expiry_date' => $expiryDate->toDateString(),
|
|
'estimate_number' => $estimateNumber,
|
|
// Without this the estimate has no template and its PDF route 500s.
|
|
// seedInvoice() has always set it; the estimate side never did.
|
|
'template_name' => 'estimate1',
|
|
'status' => $status,
|
|
'tax_per_item' => 'NO',
|
|
'tax_included' => false,
|
|
'discount_per_item' => 'NO',
|
|
'discount_type' => 'fixed',
|
|
'discount' => 0,
|
|
'discount_val' => 0,
|
|
'sub_total' => $subTotal,
|
|
'total' => $total,
|
|
'tax' => $taxAmount,
|
|
'exchange_rate' => 1,
|
|
'base_discount_val' => 0,
|
|
'base_sub_total' => $subTotal,
|
|
'base_total' => $total,
|
|
'base_tax' => $taxAmount,
|
|
'currency_id' => $this->currencyId,
|
|
'customer_id' => $customer->id,
|
|
'company_id' => $this->companyId,
|
|
'user_id' => $this->user->id,
|
|
'creator_id' => $this->user->id,
|
|
'notes' => null,
|
|
]);
|
|
|
|
if ($taxType !== null) {
|
|
$this->applyDocumentTax($estimate, $taxType, $taxAmount, 'estimate_id');
|
|
}
|
|
|
|
// See seedInvoice(): the PDF routes bind on unique_hash.
|
|
$estimate->unique_hash = Hashids::connection(Estimate::class)->encode($estimate->id);
|
|
$estimate->created_at = $estimateDate;
|
|
$estimate->updated_at = $estimateDate;
|
|
$estimate->save();
|
|
|
|
foreach ($lines as $line) {
|
|
EstimateItem::create([
|
|
'item_id' => $line['item']->id,
|
|
'name' => $line['item']->name,
|
|
'description' => $line['item']->description,
|
|
'price' => $line['item']->price,
|
|
'quantity' => $line['quantity'],
|
|
'total' => $line['line_total'],
|
|
'discount_type' => 'fixed',
|
|
'discount' => 0,
|
|
'discount_val' => 0,
|
|
'tax' => 0,
|
|
'estimate_id' => $estimate->id,
|
|
'company_id' => $this->companyId,
|
|
'exchange_rate' => 1,
|
|
'base_price' => $line['item']->price,
|
|
'base_discount_val' => 0,
|
|
'base_tax' => 0,
|
|
'base_total' => $line['line_total'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach the demo company's logo.
|
|
*
|
|
* Without one the templates fall back to plain company-name text, which is
|
|
* fine but reads as an unfinished install. The asset is a generated Acme Inc
|
|
* mark rather than one of InvoiceShelf's own logos, which would make the
|
|
* demo look as though InvoiceShelf were billing the customer.
|
|
*/
|
|
private function seedCompanyLogo(): void
|
|
{
|
|
$company = Company::find($this->companyId);
|
|
$logo = database_path('seeders/assets/acme-inc-logo.png');
|
|
|
|
if (! file_exists($logo)) {
|
|
return;
|
|
}
|
|
|
|
$company->clearMediaCollection('logo');
|
|
$company->addMedia($logo)->preservingOriginal()->toMediaCollection('logo');
|
|
}
|
|
|
|
/**
|
|
* Reusable tax definitions.
|
|
*
|
|
* type must be GENERAL: TaxTypesController::index() filters on it, so a
|
|
* MODULE row would be invisible in the admin UI.
|
|
*/
|
|
private function seedTaxTypes(): void
|
|
{
|
|
$definitions = [
|
|
['name' => 'Sales Tax', 'percent' => 8.5, 'description' => 'State and local sales tax'],
|
|
['name' => 'Zero Rated', 'percent' => 0, 'description' => 'Exempt supplies'],
|
|
];
|
|
|
|
foreach ($definitions as $definition) {
|
|
$this->taxTypes[] = TaxType::create([
|
|
'name' => $definition['name'],
|
|
'percent' => $definition['percent'],
|
|
'calculation_type' => 'percentage',
|
|
'compound_tax' => false,
|
|
'collective_tax' => false,
|
|
'description' => $definition['description'],
|
|
'type' => TaxType::TYPE_GENERAL,
|
|
'company_id' => $this->companyId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The reusable notes library, plus the text those notes put on documents.
|
|
*
|
|
* These are two unrelated things in this application: a Note row is a
|
|
* snippet an operator inserts by hand, and a document's `notes` column is a
|
|
* plain string copied at that moment. There is no foreign key between them,
|
|
* and is_default only controls a badge in the settings list -- nothing
|
|
* pre-fills a new document with it. So the library is seeded for the
|
|
* settings screen, and the same text is put on documents separately.
|
|
*/
|
|
private function seedNotes(): void
|
|
{
|
|
$notes = [
|
|
['type' => 'Invoice', 'name' => 'Payment terms', 'notes' => 'Payment is due within 14 days. Late payments may incur a 1.5% monthly charge.', 'is_default' => true],
|
|
['type' => 'Invoice', 'name' => 'Thank you', 'notes' => 'Thank you for your business. We appreciate the opportunity to work with you.', 'is_default' => false],
|
|
['type' => 'Estimate', 'name' => 'Estimate validity', 'notes' => 'This estimate is valid for 30 days from the date of issue.', 'is_default' => true],
|
|
['type' => 'Payment', 'name' => 'Receipt confirmation', 'notes' => 'Payment received with thanks. This receipt confirms the amount applied to your account.', 'is_default' => true],
|
|
];
|
|
|
|
foreach ($notes as $note) {
|
|
Note::create($note + ['company_id' => $this->companyId]);
|
|
}
|
|
|
|
$this->invoiceNotes = array_column(
|
|
array_filter($notes, fn ($note) => $note['type'] === 'Invoice'),
|
|
'notes'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom fields on customers.
|
|
*
|
|
* Customer is the only model_type with a create/edit UI end to end, so a
|
|
* seeded field is both visible and editable. The PDF renders only
|
|
* model_type 'Item' fields, which would add a column to the items table and
|
|
* change a layout that was just squared up across both drivers -- left
|
|
* alone deliberately.
|
|
*/
|
|
private function seedCustomFields(): void
|
|
{
|
|
$fields = [
|
|
['name' => 'Account Manager', 'type' => 'Input', 'string_answer' => 'Dana Whitfield'],
|
|
['name' => 'Contract Renewal', 'type' => 'Date', 'date_answer' => Carbon::now()->addMonths(8)->toDateString()],
|
|
];
|
|
|
|
foreach ($fields as $order => $field) {
|
|
CustomField::create($field + [
|
|
'label' => $field['name'],
|
|
'model_type' => 'Customer',
|
|
'slug' => clean_slug('Customer', $field['name']),
|
|
'is_required' => false,
|
|
'order' => $order + 1,
|
|
'company_id' => $this->companyId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One active recurring invoice, so the feature is not an empty screen.
|
|
*
|
|
* frequency is a five-field cron expression, not a keyword, and
|
|
* next_invoice_at has to be derived from it -- nothing computes that at read
|
|
* time. Line items hang off recurring_invoice_id, leaving invoice_id null
|
|
* until an invoice is actually generated.
|
|
*/
|
|
private function seedRecurringInvoice(): void
|
|
{
|
|
$customer = $this->customers[0];
|
|
$items = collect($this->items)->random(2)->all();
|
|
|
|
$subTotal = 0;
|
|
foreach ($items as $item) {
|
|
$subTotal += $item->price;
|
|
}
|
|
|
|
$taxType = $this->taxTypes[0];
|
|
$tax = (int) round($subTotal * $taxType->percent / 100);
|
|
$total = $subTotal + $tax;
|
|
|
|
$startsAt = Carbon::now()->startOfMonth()->addMonth();
|
|
$frequency = '0 0 1 * *'; // monthly, on the first
|
|
|
|
$recurring = RecurringInvoice::create([
|
|
'starts_at' => $startsAt,
|
|
'send_automatically' => false,
|
|
'customer_id' => $customer->id,
|
|
'company_id' => $this->companyId,
|
|
'creator_id' => $this->user->id,
|
|
'status' => RecurringInvoice::ACTIVE,
|
|
'next_invoice_at' => RecurringInvoice::getNextInvoiceDate($frequency, $startsAt),
|
|
'frequency' => $frequency,
|
|
'limit_by' => RecurringInvoice::NONE,
|
|
'currency_id' => $this->currencyId,
|
|
'exchange_rate' => 1,
|
|
'tax_per_item' => 'NO',
|
|
'discount_per_item' => 'NO',
|
|
'tax_included' => false,
|
|
'discount_type' => 'fixed',
|
|
'discount' => 0,
|
|
'discount_val' => 0,
|
|
'sub_total' => $subTotal,
|
|
'tax' => $tax,
|
|
'total' => $total,
|
|
'due_amount' => $total,
|
|
'template_name' => 'invoice1',
|
|
'notes' => $this->invoiceNotes[0] ?? null,
|
|
]);
|
|
|
|
foreach ($items as $item) {
|
|
InvoiceItem::create([
|
|
'item_id' => $item->id,
|
|
'name' => $item->name,
|
|
'description' => $item->description,
|
|
'price' => $item->price,
|
|
'quantity' => 1,
|
|
'total' => $item->price,
|
|
'discount_type' => 'fixed',
|
|
'discount' => 0,
|
|
'discount_val' => 0,
|
|
'tax' => 0,
|
|
'recurring_invoice_id' => $recurring->id,
|
|
'company_id' => $this->companyId,
|
|
'exchange_rate' => 1,
|
|
'base_price' => $item->price,
|
|
'base_discount_val' => 0,
|
|
'base_tax' => 0,
|
|
'base_total' => $item->price,
|
|
]);
|
|
}
|
|
|
|
$this->applyDocumentTax($recurring, $taxType, $tax, 'recurring_invoice_id');
|
|
}
|
|
|
|
/**
|
|
* Attach a document-level tax row.
|
|
*
|
|
* The service layer trusts whatever `amount` it is given rather than
|
|
* recomputing it, so the caller owns the arithmetic and has to keep the
|
|
* document's own tax/total columns in step. Every row must point at a real
|
|
* TaxType: TaxResource dereferences it without a null check.
|
|
*/
|
|
private function applyDocumentTax(object $document, TaxType $taxType, int $amount, string $foreignKey): void
|
|
{
|
|
Tax::create([
|
|
'tax_type_id' => $taxType->id,
|
|
$foreignKey => $document->id,
|
|
'company_id' => $this->companyId,
|
|
'name' => $taxType->name,
|
|
'calculation_type' => 'percentage',
|
|
'percent' => $taxType->percent,
|
|
'amount' => $amount,
|
|
'compound_tax' => false,
|
|
'exchange_rate' => 1,
|
|
'base_amount' => $amount,
|
|
'currency_id' => $this->currencyId,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The tax type to apply to a document, or null for an untaxed one.
|
|
*
|
|
* Most documents are taxed, but not all: a demo where every row looks the
|
|
* same shows less than one with a zero-rated example in it.
|
|
*/
|
|
private function taxTypeForDocument(int $sequence): ?TaxType
|
|
{
|
|
if ($this->taxTypes === [] || $sequence % 5 === 0) {
|
|
return null;
|
|
}
|
|
|
|
return $this->taxTypes[0];
|
|
}
|
|
|
|
private function seedExpenses(): void
|
|
{
|
|
// 15 expenses spread across categories and months
|
|
// Format: [category_index, amount_cents, age_weeks_min, age_weeks_max, notes]
|
|
$expenses = [
|
|
[0, 12900, 0, 2, 'Figma — team plan'], // Software
|
|
[0, 49900, 2, 4, 'Notion — business plan'],
|
|
[0, 89500, 6, 8, 'GitHub — team'],
|
|
[1, 124000, 1, 3, 'Client site visit — flight'], // Travel
|
|
[1, 34500, 2, 4, 'Uber rides — SF'],
|
|
[1, 258000, 8, 12, 'Conference ticket + hotel'],
|
|
[2, 50000, 0, 2, 'LinkedIn Ads'], // Marketing
|
|
[2, 120000, 4, 6, 'Trade show booth'],
|
|
[3, 8750, 1, 3, 'Printer paper + toner'], // Office Supplies
|
|
[3, 79900, 6, 8, 'Ergonomic chair'],
|
|
[4, 19900, 0, 1, 'Office internet'], // Utilities
|
|
[4, 8500, 0, 1, 'Mobile phone plan'],
|
|
[4, 45000, 4, 6, 'Coworking space'],
|
|
[5, 450000, 2, 4, 'Freelance designer — website redesign'], // Contractors
|
|
[5, 280000, 6, 10, 'Contract developer — API integration'],
|
|
];
|
|
|
|
foreach ($expenses as [$catIndex, $amount, $minWeeks, $maxWeeks, $notes]) {
|
|
$weeksAgo = random_int($minWeeks, $maxWeeks);
|
|
$expenseDate = Carbon::now()->subWeeks($weeksAgo)->subDays(random_int(0, 6))->startOfDay();
|
|
|
|
$expense = Expense::create([
|
|
'expense_date' => $expenseDate->toDateString(),
|
|
'amount' => $amount,
|
|
'base_amount' => $amount,
|
|
'exchange_rate' => 1,
|
|
'notes' => $notes,
|
|
'expense_category_id' => $this->expenseCategories[$catIndex]->id,
|
|
'currency_id' => $this->currencyId,
|
|
'company_id' => $this->companyId,
|
|
'creator_id' => $this->user->id,
|
|
]);
|
|
|
|
$expense->created_at = $expenseDate;
|
|
$expense->updated_at = $expenseDate;
|
|
$expense->save();
|
|
}
|
|
}
|
|
}
|