3 Commits

Author SHA1 Message Date
Rihards Simanovics
aba252824c feat: add initialiser script 2026-04-13 23:05:06 +00:00
Rihards Simanovics
84aaa4baff feat: add dev database seeder 2026-04-13 22:56:34 +00:00
Rihards Simanovics
6e13185f84 chore: fix type in example config for sanctum stateful domainS 2026-04-13 22:52:05 +00:00
3 changed files with 667 additions and 1 deletions

View File

@@ -15,7 +15,7 @@ DB_USERNAME=
DB_PASSWORD=
SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAIN=
SANCTUM_STATEFUL_DOMAINS=
TRUSTED_PROXIES="*"
# Dompdf: keep false so untrusted HTML in PDF notes cannot trigger outbound requests (SSRF).

View File

@@ -0,0 +1,473 @@
<?php
namespace Database\Seeders;
use App\Facades\Hashids;
use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\Customer;
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\Payment;
use App\Models\RecurringInvoice;
use App\Models\Setting;
use App\Models\TaxType;
use App\Models\Unit;
use App\Models\User;
use App\Services\SerialNumberFormatter;
use App\Space\InstallUtils;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Silber\Bouncer\BouncerFacade;
/**
* Seeds the database with realistic fake data for development and manual testing.
*
* Usage:
* php artisan db:seed --class=DevDataSeeder
*
* This creates:
* - An admin user (admin@invoiceshelf.com / password: password)
* - Two additional staff users
* - One company with all default data (payment methods, units, roles)
* - Tax types (VAT, GST)
* - 15 catalogue items
* - 10 customers
* - 20 invoices across all statuses, each with 13 line items
* - 15 estimates across all statuses, each with 13 line items
* - 10 payments linked to paid/partially-paid invoices
* - 12 expenses across several categories
* - 3 recurring invoices
*/
class DevDataSeeder extends Seeder
{
public function run(): void
{
// ── 1. Admin user & company ───────────────────────────────────────────
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@invoiceshelf.com',
'role' => 'super admin',
'password' => 'password',
]);
$company = Company::create([
'name' => 'Acme Corp',
'owner_id' => $admin->id,
'slug' => 'acme-corp',
]);
$company->unique_hash = Hashids::connection(Company::class)->encode($company->id);
$company->save();
$company->setupDefaultData(); // roles, payment methods, units, default settings
$admin->companies()->attach($company->id);
BouncerFacade::scope()->to($company->id);
$admin->assign('super admin');
$admin->setSettings([
'language' => 'en',
]);
CompanySetting::setSettings([
'currency' => 4, // USD
'time_zone' => 'UTC',
'language' => 'en',
'fiscal_year' => '1-12',
'tax_per_item' => 'NO',
'discount_per_item' => 'NO',
], $company->id);
Setting::setSetting('profile_complete', 'COMPLETED');
InstallUtils::setCurrentVersion();
$companyId = $company->id;
// ── 2. Extra staff users ──────────────────────────────────────────────
foreach ([
['name' => 'Jane Smith', 'email' => 'jane@invoiceshelf.com'],
['name' => 'Bob Johnson', 'email' => 'bob@invoiceshelf.com'],
] as $data) {
$staffUser = User::create([
'name' => $data['name'],
'email' => $data['email'],
'role' => 'admin',
'password' => 'password',
]);
$staffUser->companies()->attach($companyId);
BouncerFacade::scope()->to($companyId);
$staffUser->assign('admin');
}
// ── 3. Tax types ──────────────────────────────────────────────────────
$vat = TaxType::create([
'name' => 'VAT',
'calculation_type' => 'percentage',
'company_id' => $companyId,
'percent' => 20,
'description' => 'Value Added Tax (20%)',
'compound_tax' => 0,
'collective_tax' => 0,
]);
$gst = TaxType::create([
'name' => 'GST',
'calculation_type' => 'percentage',
'company_id' => $companyId,
'percent' => 10,
'description' => 'Goods and Services Tax (10%)',
'compound_tax' => 0,
'collective_tax' => 0,
]);
// ── 4. Units & catalogue items ────────────────────────────────────────
$unit = Unit::where('company_id', $companyId)->first()
?? Unit::factory()->create(['company_id' => $companyId]);
$itemData = [
['name' => 'Web Design', 'price' => 150000, 'description' => 'Custom website design'],
['name' => 'Logo Design', 'price' => 50000, 'description' => 'Brand logo design'],
['name' => 'SEO Audit', 'price' => 80000, 'description' => 'Full site SEO audit'],
['name' => 'Monthly Hosting', 'price' => 2000, 'description' => 'Shared hosting plan'],
['name' => 'Content Writing', 'price' => 10000, 'description' => 'Per 1 000 words'],
['name' => 'Social Media Package', 'price' => 60000, 'description' => 'Monthly social management'],
['name' => 'Email Marketing', 'price' => 35000, 'description' => 'Campaign design & send'],
['name' => 'CRM Integration', 'price' => 120000, 'description' => 'Third-party CRM setup'],
['name' => 'Mobile App Dev', 'price' => 500000, 'description' => 'iOS/Android application'],
['name' => 'E-Commerce Setup', 'price' => 200000, 'description' => 'Full shop configuration'],
['name' => 'Domain Registration', 'price' => 1500, 'description' => 'Annual domain fee'],
['name' => 'SSL Certificate', 'price' => 5000, 'description' => 'Annual SSL certificate'],
['name' => 'Google Ads Management', 'price' => 45000, 'description' => 'PPC campaign management'],
['name' => 'Photography Session', 'price' => 30000, 'description' => 'Half-day studio shoot'],
['name' => 'Video Production', 'price' => 250000, 'description' => 'Corporate promo video'],
];
$items = collect($itemData)->map(fn ($d) => Item::create([
'name' => $d['name'],
'description' => $d['description'],
'price' => $d['price'],
'company_id' => $companyId,
'unit_id' => $unit->id,
'creator_id' => $admin->id,
'currency_id' => 1,
'tax_per_item' => false,
]));
// ── 5. Customers ──────────────────────────────────────────────────────
$customers = Customer::factory()->count(10)->create(['company_id' => $companyId]);
// ── 6. Invoices ───────────────────────────────────────────────────────
$invoiceStatuses = [
Invoice::STATUS_DRAFT,
Invoice::STATUS_DRAFT,
Invoice::STATUS_SENT,
Invoice::STATUS_SENT,
Invoice::STATUS_VIEWED,
Invoice::STATUS_VIEWED,
Invoice::STATUS_COMPLETED,
Invoice::STATUS_COMPLETED,
Invoice::STATUS_UNPAID,
Invoice::STATUS_UNPAID,
Invoice::STATUS_UNPAID,
Invoice::STATUS_PARTIALLY_PAID,
Invoice::STATUS_PARTIALLY_PAID,
Invoice::STATUS_PAID,
Invoice::STATUS_PAID,
Invoice::STATUS_PAID,
Invoice::STATUS_PAID,
Invoice::STATUS_PAID,
Invoice::STATUS_PAID,
Invoice::STATUS_PAID,
];
$invoices = collect($invoiceStatuses)->map(function (string $status, int $index) use ($companyId, $customers, $items) {
$customer = $customers->random();
$lineItems = $items->random(rand(1, 3));
$subTotal = $lineItems->sum('price');
$tax = (int) ($subTotal * 0.10);
$total = $subTotal + $tax;
$paidStatus = match ($status) {
Invoice::STATUS_PAID => Invoice::STATUS_PAID,
Invoice::STATUS_PARTIALLY_PAID => Invoice::STATUS_PARTIALLY_PAID,
default => Invoice::STATUS_UNPAID,
};
$dueAmount = match ($paidStatus) {
Invoice::STATUS_PAID => 0,
Invoice::STATUS_PARTIALLY_PAID => (int) ($total / 2),
default => $total,
};
$seq = (new SerialNumberFormatter)
->setModel(new Invoice)
->setCompany($companyId)
->setNextNumbers();
$invoice = Invoice::create([
'invoice_number' => $seq->getNextNumber(),
'sequence_number' => $seq->nextSequenceNumber,
'customer_sequence_number' => $seq->nextCustomerSequenceNumber,
'reference_number' => 'REF-'.str_pad($index + 1, 4, '0', STR_PAD_LEFT),
'invoice_date' => now()->subDays(rand(1, 120))->toDateString(),
'due_date' => now()->addDays(rand(1, 30))->toDateString(),
'status' => $status,
'paid_status' => $paidStatus,
'template_name' => 'invoice1',
'sub_total' => $subTotal,
'tax' => $tax,
'total' => $total,
'due_amount' => $dueAmount,
'discount' => 0,
'discount_val' => 0,
'discount_type' => 'fixed',
'tax_per_item' => 'NO',
'tax_included' => false,
'discount_per_item' => 'NO',
'notes' => 'Thank you for your business.',
'unique_hash' => str_random(60),
'company_id' => $companyId,
'customer_id' => $customer->id,
'currency_id' => 1,
'exchange_rate' => 1,
'base_sub_total' => $subTotal,
'base_tax' => $tax,
'base_total' => $total,
'base_discount_val' => 0,
'base_due_amount' => $dueAmount,
]);
foreach ($lineItems as $item) {
InvoiceItem::create([
'invoice_id' => $invoice->id,
'item_id' => $item->id,
'name' => $item->name,
'description' => $item->description,
'price' => $item->price,
'quantity' => 1,
'total' => $item->price,
'tax' => 0,
'discount' => 0,
'discount_val' => 0,
'discount_type' => 'fixed',
'company_id' => $companyId,
'exchange_rate' => 1,
'base_price' => $item->price,
'base_total' => $item->price,
'base_discount_val' => 0,
'base_tax' => 0,
]);
}
return $invoice;
});
// ── 7. Estimates ──────────────────────────────────────────────────────
$estimateStatuses = [
Estimate::STATUS_DRAFT,
Estimate::STATUS_DRAFT,
Estimate::STATUS_DRAFT,
Estimate::STATUS_SENT,
Estimate::STATUS_SENT,
Estimate::STATUS_VIEWED,
Estimate::STATUS_VIEWED,
Estimate::STATUS_ACCEPTED,
Estimate::STATUS_ACCEPTED,
Estimate::STATUS_ACCEPTED,
Estimate::STATUS_REJECTED,
Estimate::STATUS_REJECTED,
Estimate::STATUS_EXPIRED,
Estimate::STATUS_EXPIRED,
Estimate::STATUS_EXPIRED,
];
collect($estimateStatuses)->each(function (string $status, int $index) use ($companyId, $customers, $items) {
$customer = $customers->random();
$lineItems = $items->random(rand(1, 3));
$subTotal = $lineItems->sum('price');
$tax = (int) ($subTotal * 0.10);
$total = $subTotal + $tax;
$seq = (new SerialNumberFormatter)
->setModel(new Estimate)
->setCompany($companyId)
->setNextNumbers();
$estimate = Estimate::create([
'estimate_number' => $seq->getNextNumber(),
'sequence_number' => $seq->nextSequenceNumber,
'customer_sequence_number' => $seq->nextCustomerSequenceNumber,
'reference_number' => 'EREF-'.str_pad($index + 1, 4, '0', STR_PAD_LEFT),
'estimate_date' => now()->subDays(rand(1, 90))->toDateString(),
'expiry_date' => now()->addDays(rand(15, 60))->toDateString(),
'status' => $status,
'template_name' => 'estimate1',
'sub_total' => $subTotal,
'tax' => $tax,
'total' => $total,
'discount' => 0,
'discount_val' => 0,
'discount_type' => 'fixed',
'tax_per_item' => 'NO',
'tax_included' => false,
'discount_per_item' => 'NO',
'notes' => 'This estimate is valid for 30 days.',
'unique_hash' => str_random(60),
'company_id' => $companyId,
'customer_id' => $customer->id,
'currency_id' => 1,
'exchange_rate' => 1,
'base_sub_total' => $subTotal,
'base_tax' => $tax,
'base_total' => $total,
'base_discount_val' => 0,
]);
foreach ($lineItems as $item) {
EstimateItem::create([
'estimate_id' => $estimate->id,
'item_id' => $item->id,
'name' => $item->name,
'description' => $item->description,
'price' => $item->price,
'quantity' => 1,
'total' => $item->price,
'tax' => 0,
'discount' => 0,
'discount_val' => 0,
'discount_type' => 'fixed',
'company_id' => $companyId,
'exchange_rate' => 1,
'base_price' => $item->price,
'base_total' => $item->price,
'base_discount_val' => 0,
'base_tax' => 0,
]);
}
});
// ── 8. Payments (linked to paid/partially-paid invoices) ──────────────
$paymentMethod = DB::table('payment_methods')
->where('company_id', $companyId)
->value('id');
$paidInvoices = $invoices->filter(fn ($inv) => in_array($inv->paid_status, [
Invoice::STATUS_PAID,
Invoice::STATUS_PARTIALLY_PAID,
]));
$paidInvoices->take(10)->each(function (Invoice $invoice) use ($companyId, $paymentMethod) {
$amount = $invoice->paid_status === Invoice::STATUS_PAID
? $invoice->total
: (int) ($invoice->total / 2);
$seq = (new SerialNumberFormatter)
->setModel(new Payment)
->setCompany($companyId)
->setNextNumbers();
Payment::create([
'payment_number' => $seq->getNextNumber(),
'sequence_number' => $seq->nextSequenceNumber,
'customer_sequence_number' => $seq->nextCustomerSequenceNumber,
'payment_date' => now()->subDays(rand(1, 60))->toDateString(),
'amount' => $amount,
'base_amount' => $amount,
'notes' => 'Payment received. Thank you!',
'unique_hash' => str_random(60),
'company_id' => $companyId,
'customer_id' => $invoice->customer_id,
'invoice_id' => $invoice->id,
'payment_method_id' => $paymentMethod,
'currency_id' => 1,
'exchange_rate' => 1,
]);
});
// ── 9. Expense categories & expenses ──────────────────────────────────
$categoryNames = ['Travel', 'Office Supplies', 'Software Subscriptions', 'Marketing', 'Utilities'];
$categories = collect($categoryNames)->map(fn ($name) => ExpenseCategory::create([
'name' => $name,
'company_id' => $companyId,
'description' => "Expenses for {$name}",
]));
$expenseDescriptions = [
'Flight tickets to client meeting',
'Hotel stay for conference',
'Printer paper and ink cartridges',
'Pens, notebooks, and folders',
'Adobe Creative Cloud annual plan',
'Slack Business subscription',
'Google Workspace plan',
'Facebook ads campaign',
'LinkedIn sponsored posts',
'Electricity bill',
'Internet service bill',
'Postage and shipping fees',
];
collect($expenseDescriptions)->each(function (string $notes, int $index) use ($companyId, $customers, $categories) {
Expense::create([
'expense_date' => now()->subDays(rand(1, 90))->toDateString(),
'expense_category_id' => $categories->random()->id,
'expense_number' => 'EXP-'.str_pad($index + 1, 5, '0', STR_PAD_LEFT),
'company_id' => $companyId,
'amount' => rand(500, 100000),
'base_amount' => rand(500, 100000),
'notes' => $notes,
'attachment_receipt' => null,
'customer_id' => $customers->random()->id,
'currency_id' => 1,
'exchange_rate' => 1,
]);
});
// ── 10. Recurring invoices ────────────────────────────────────────────
$recurringStatuses = ['ACTIVE', 'ON_HOLD', 'COMPLETED'];
foreach ($recurringStatuses as $rStatus) {
$customer = $customers->random();
$lineItems = $items->random(2);
$subTotal = $lineItems->sum('price');
$total = (int) ($subTotal * 1.10);
RecurringInvoice::create([
'starts_at' => now()->subMonths(rand(1, 6))->toDateTimeString(),
'send_automatically' => false,
'status' => $rStatus,
'tax_per_item' => 'NO',
'tax_included' => false,
'discount_per_item' => 'NO',
'sub_total' => $subTotal,
'total' => $total,
'tax' => $total - $subTotal,
'due_amount' => $total,
'discount' => 0,
'discount_val' => 0,
'company_id' => $companyId,
'customer_id' => $customer->id,
'frequency' => '0 0 1 * *', // monthly, 1st of month
'limit_by' => 'NONE',
'limit_count' => null,
'limit_date' => null,
'exchange_rate' => 1,
'template_name' => 'invoice1',
]);
}
}
}

193
docker/development/init-dev.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# =============================================================================
# init-dev.sh — Reset and initialise the InvoiceShelf dev environment.
#
# Run this script INSIDE the PHP container:
#
# docker exec -it invoiceshelf-dev-php bash /var/www/html/docker/development/init-dev.sh [options]
#
# Safe to re-run at any time, including after switching git tags/branches.
# Always does a full clean slate: fresh DB, cleared caches, reinstalled deps.
#
# Usage:
# init-dev.sh [--db=sqlite|mysql|pgsql] [--seed] [--no-seed]
#
# --db=sqlite Use SQLite (default — no external DB container needed)
# --db=mysql Use MariaDB (requires docker-compose.mysql.yml)
# --db=pgsql Use PostgreSQL (requires docker-compose.pgsql.yml)
# --seed Seed the database with fake dev data (DevDataSeeder)
# --no-seed Skip seeding (default)
#
# Examples:
# init-dev.sh # sqlite, no seed
# init-dev.sh --seed # sqlite + fake data
# init-dev.sh --db=mysql --seed # MariaDB + fake data
# init-dev.sh --db=pgsql --no-seed # PostgreSQL, no seed
# =============================================================================
set -euo pipefail
ROOT="/var/www/html"
cd "$ROOT"
# ── Argument parsing ──────────────────────────────────────────────────────────
DB=sqlite
SEED=prompt
for arg in "$@"; do
case "$arg" in
--db=sqlite) DB=sqlite ;;
--db=mysql) DB=mysql ;;
--db=pgsql) DB=pgsql ;;
--seed) SEED=true ;;
--no-seed) SEED=false ;;
*)
echo "Unknown argument: $arg"
echo "Usage: $0 [--db=sqlite|mysql|pgsql] [--seed] [--no-seed]"
exit 1
;;
esac
done
step() { echo; echo "$*"; }
# ── 1. PHP dependencies ───────────────────────────────────────────────────────
# Note: JS dependencies (yarn) are NOT available in this PHP-FPM container.
# Run "yarn install && yarn build" on your host, or use the static_builder
# Docker stage which handles the frontend build automatically.
step "Installing PHP dependencies"
composer install --no-interaction --prefer-dist
# ── 2. Environment file ───────────────────────────────────────────────────────
ENV_FILE="$ROOT/.env"
# Helper: replace or append a key=value in .env
set_env() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
else
echo "${key}=${value}" >> "$ENV_FILE"
fi
}
step "Setting up .env"
cp "$ROOT/.env.example" "$ENV_FILE"
php artisan key:generate --force
set_env "APP_ENV" "local"
set_env "APP_DEBUG" "true"
set_env "APP_URL" "http://invoiceshelf.test"
# Database — values differ per backend
case "$DB" in
sqlite)
SQLITE_PATH="$ROOT/storage/app/database.sqlite"
set_env "DB_CONNECTION" "sqlite"
set_env "DB_DATABASE" "$SQLITE_PATH"
;;
mysql)
set_env "DB_CONNECTION" "mysql"
set_env "DB_HOST" "db"
set_env "DB_PORT" "3306"
set_env "DB_DATABASE" "invoiceshelf"
set_env "DB_USERNAME" "invoiceshelf"
set_env "DB_PASSWORD" "invoiceshelf"
;;
pgsql)
set_env "DB_CONNECTION" "pgsql"
set_env "DB_HOST" "db"
set_env "DB_PORT" "5432"
set_env "DB_DATABASE" "invoiceshelf"
set_env "DB_USERNAME" "invoiceshelf"
set_env "DB_PASSWORD" "invoiceshelf"
;;
esac
set_env "CACHE_STORE" "file"
set_env "SESSION_DRIVER" "file"
set_env "SESSION_LIFETIME" "120"
set_env "SESSION_DOMAIN" "invoiceshelf.test"
set_env "SANCTUM_STATEFUL_DOMAINS" "invoiceshelf.test"
# Mailpit — fake SMTP available inside the dev Docker network
set_env "MAIL_MAILER" "smtp"
set_env "MAIL_HOST" "mail"
set_env "MAIL_PORT" "1025"
set_env "MAIL_ENCRYPTION" "none"
set_env "MAIL_USERNAME" ""
set_env "MAIL_PASSWORD" ""
# ── 3. Clear ALL Laravel caches ───────────────────────────────────────────────
step "Clearing all Laravel caches"
php artisan optimize:clear
php artisan cache:clear
php artisan clear-compiled
step "Clearing runtime session and view files"
find "$ROOT/storage/framework/sessions" -type f ! -name '.gitignore' -delete 2>/dev/null || true
find "$ROOT/storage/framework/views" -name '*.php' -delete 2>/dev/null || true
find "$ROOT/storage/framework/cache/data" -type f ! -name '.gitignore' -delete 2>/dev/null || true
# ── 4. Database — full reset ──────────────────────────────────────────────────
if [ "$DB" = "sqlite" ]; then
step "Resetting SQLite database"
mkdir -p "$(dirname "$SQLITE_PATH")"
cp "$ROOT/database/stubs/sqlite.empty.db" "$SQLITE_PATH"
chown www-data:www-data "$SQLITE_PATH" 2>/dev/null || true
fi
step "Running all migrations from scratch (migrate:fresh)"
php artisan migrate:fresh --force
step "Seeding reference data (currencies, countries)"
php artisan db:seed --class=CurrenciesTableSeeder --force
php artisan db:seed --class=CountriesTableSeeder --force
# ── 5. Optional dev data seeding ─────────────────────────────────────────────
if [ "$SEED" = prompt ]; then
echo
read -r -p "Seed the database with fake dev data? [Y/n] " reply
case "${reply:-Y}" in
[Yy]*|"") SEED=true ;;
*) SEED=false ;;
esac
fi
if [ "$SEED" = true ]; then
step "Seeding fake dev data"
php artisan db:seed --class=DevDataSeeder --force
echo
echo " Credentials seeded:"
echo " Admin : admin@invoiceshelf.com / password"
echo " Staff : jane@invoiceshelf.com / password"
echo " Staff : bob@invoiceshelf.com / password"
else
echo
echo " Seeding skipped. Run again with --seed, or:"
echo " php artisan db:seed --class=DevDataSeeder"
fi
# ── 6. Storage ────────────────────────────────────────────────────────────────
step "Recreating public storage symlink"
rm -f "$ROOT/public/storage"
php artisan storage:link
# ── 7. Permissions ────────────────────────────────────────────────────────────
step "Fixing permissions"
chmod -R 775 storage/framework storage/logs bootstrap/cache
# ── Done ──────────────────────────────────────────────────────────────────────
echo
echo "✓ Dev environment ready — http://invoiceshelf.test"
echo