67 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
mchev
83caff13cd Merge pull request #615 from InvoiceShelf/translations
New Crowdin updates
2026-04-08 09:29:39 +02:00
mchev
d2736b1c46 Merge pull request #616 from mchev/taxdecimal
Support 3-decimal tax percentages (e.g. 6.625%)
2026-04-08 09:28:44 +02:00
mchev
cdc504f518 Merge pull request #617 from mchev/duplicate_expense
Add duplicate expense action
2026-04-08 09:28:22 +02:00
Darko Gjorgjijoski
2c840b2d97 Bump version from 2.3.2 to 2.3.3 2026-04-07 21:25:20 +02:00
Darko Gjorgjijoski
34db4b7897 Sanitize PDF address fields against SSRF, not just notes
Closes the residual surface from the three published SSRF advisories (GHSA-pc5v-8xwc-v9xq, GHSA-38hf-fq8x-q49r, GHSA-q9wx-ggwq-mcgh / CVE-2026-34365 to 34367). The original fix in 07757e74 only sanitized the Notes field via Invoice/Estimate/Payment::getNotes(), but the same blade templates also render company/billing/shipping address fields with {!! !!} (Blade unescaped output). Those address strings are produced by getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() which feed into GeneratesPdfTrait::getFormattedString() — and that method does not call PdfHtmlSanitizer.

Customer-controlled fields (name, street, phone, custom field values) are substituted into address templates via getFieldsArray() without HTML-escaping. A malicious customer name like "Acme <img src='http://attacker/probe'>" therefore reaches Dompdf as raw HTML through the address path, exactly the same CWE-918 SSRF pattern the advisories describe — only blocked today by the secondary defense of dompdf's enable_remote=false. If a self-hoster sets DOMPDF_ENABLE_REMOTE=true for legitimate remote logos, the address surface immediately re-opens.

Move the PdfHtmlSanitizer::sanitize() call into the chokepoint at GeneratesPdfTrait::getFormattedString(), so all four sinks — notes plus the three address fields, on all three models — get the same treatment via a single call site. The explicit wrapper in each model's getNotes() becomes redundant and is removed (along with the now-unused App\Support\PdfHtmlSanitizer imports). Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly) so there is no risk of stripping useful HTML from a non-PDF context.

Extends tests/Unit/PdfHtmlSanitizerTest.php with three new cases covering the address-template scenario, iframe/link tag stripping, and on* event handler removal. All 8 tests pass via vendor/bin/pest tests/Unit/PdfHtmlSanitizerTest.php.
2026-04-07 20:39:19 +02:00
mchev
beb2a43ed3 Duplicate expense 2026-04-07 19:00:07 +02:00
mchev
7a25a15877 Increasing tax decimal 2026-04-07 18:35:30 +02:00
Darko Gjorgjijoski
8eee4bc4f5 New translations en.json (Serbian (Latin)) 2026-04-07 14:51:02 +02:00
Darko Gjorgjijoski
c34cfeea11 New translations en.json (Swahili) 2026-04-07 14:51:01 +02:00
Darko Gjorgjijoski
f37edbcc92 New translations en.json (Malay) 2026-04-07 14:50:59 +02:00
Darko Gjorgjijoski
ba581313df New translations en.json (Hindi) 2026-04-07 14:50:58 +02:00
Darko Gjorgjijoski
1c1a180dd5 New translations en.json (Latvian) 2026-04-07 14:50:57 +02:00
Darko Gjorgjijoski
8e815fd887 New translations en.json (Estonian) 2026-04-07 14:50:56 +02:00
Darko Gjorgjijoski
df612bc773 New translations en.json (Croatian) 2026-04-07 14:50:55 +02:00
Darko Gjorgjijoski
10b2cf5af1 New translations en.json (Thai) 2026-04-07 14:50:53 +02:00
Darko Gjorgjijoski
f140256efd New translations en.json (Bengali) 2026-04-07 14:50:52 +02:00
Darko Gjorgjijoski
1ed8e5b2d0 New translations en.json (Persian) 2026-04-07 14:50:50 +02:00
Darko Gjorgjijoski
fcb7c96bca New translations en.json (Indonesian) 2026-04-07 14:50:49 +02:00
Darko Gjorgjijoski
d587e3fd00 New translations en.json (Portuguese, Brazilian) 2026-04-07 14:50:48 +02:00
Darko Gjorgjijoski
1b26d47539 New translations en.json (Vietnamese) 2026-04-07 14:50:47 +02:00
Darko Gjorgjijoski
340522da19 New translations en.json (Urdu (Pakistan)) 2026-04-07 14:50:45 +02:00
Darko Gjorgjijoski
3ecfcede3e New translations en.json (Chinese Traditional) 2026-04-07 14:50:44 +02:00
Darko Gjorgjijoski
30f76c044a New translations en.json (Chinese Simplified) 2026-04-07 14:50:43 +02:00
Darko Gjorgjijoski
854a8bb50d New translations en.json (Ukrainian) 2026-04-07 14:50:41 +02:00
Darko Gjorgjijoski
1be3132dc4 New translations en.json (Turkish) 2026-04-07 14:50:40 +02:00
Darko Gjorgjijoski
fefd856cfb New translations en.json (Swedish) 2026-04-07 14:50:39 +02:00
Darko Gjorgjijoski
e5fe62e4de New translations en.json (Albanian) 2026-04-07 14:50:38 +02:00
Darko Gjorgjijoski
129d63c5b1 New translations en.json (Slovenian) 2026-04-07 14:50:37 +02:00
Darko Gjorgjijoski
65e9182272 New translations en.json (Slovak) 2026-04-07 14:50:35 +02:00
Darko Gjorgjijoski
3abcba2752 New translations en.json (Russian) 2026-04-07 14:50:34 +02:00
Darko Gjorgjijoski
6c1e51d126 New translations en.json (Portuguese) 2026-04-07 14:50:33 +02:00
Darko Gjorgjijoski
c1b8ba448d New translations en.json (Polish) 2026-04-07 14:50:31 +02:00
Darko Gjorgjijoski
ab5ea81424 New translations en.json (Norwegian) 2026-04-07 14:50:30 +02:00
Darko Gjorgjijoski
befd69fdb2 New translations en.json (Dutch) 2026-04-07 14:50:29 +02:00
Darko Gjorgjijoski
417b92ad9f New translations en.json (Macedonian) 2026-04-07 14:50:27 +02:00
Darko Gjorgjijoski
d6b1e102fe New translations en.json (Lithuanian) 2026-04-07 14:50:26 +02:00
Darko Gjorgjijoski
d7bf942da2 New translations en.json (Georgian) 2026-04-07 14:50:25 +02:00
Darko Gjorgjijoski
5db82db958 New translations en.json (Japanese) 2026-04-07 14:50:24 +02:00
Darko Gjorgjijoski
c63355391a New translations en.json (Italian) 2026-04-07 14:50:22 +02:00
Darko Gjorgjijoski
de3c8f89fb New translations en.json (Hungarian) 2026-04-07 14:50:21 +02:00
Darko Gjorgjijoski
cab4c62c5d New translations en.json (Hebrew) 2026-04-07 14:50:20 +02:00
Darko Gjorgjijoski
4b634f96c9 New translations en.json (Finnish) 2026-04-07 14:50:19 +02:00
Darko Gjorgjijoski
9ef0932c6e New translations en.json (Greek) 2026-04-07 14:50:17 +02:00
Darko Gjorgjijoski
d122c8c95a New translations en.json (German) 2026-04-07 14:50:16 +02:00
Darko Gjorgjijoski
820073e8e0 New translations en.json (Danish) 2026-04-07 14:50:14 +02:00
Darko Gjorgjijoski
80c14b4b7f New translations en.json (Czech) 2026-04-07 14:50:13 +02:00
Darko Gjorgjijoski
174acbf70e New translations en.json (Catalan) 2026-04-07 14:50:12 +02:00
Darko Gjorgjijoski
c93b8f0da9 New translations en.json (Bulgarian) 2026-04-07 14:50:11 +02:00
Darko Gjorgjijoski
2693b0b0f2 New translations en.json (Arabic) 2026-04-07 14:50:09 +02:00
Darko Gjorgjijoski
54d9c57925 New translations en.json (Spanish) 2026-04-07 14:50:08 +02:00
Darko Gjorgjijoski
ba3e94c4ad New translations en.json (French) 2026-04-07 14:50:07 +02:00
Darko Gjorgjijoski
a644653513 New translations en.json (Romanian) 2026-04-07 14:50:05 +02:00
Darko Gjorgjijoski
3391d104f9 Update source file en.json 2026-04-07 14:50:00 +02:00
mchev
f17c7be5f0 Merge pull request #611 from klittle81/EnhanceExpenseReport
Enhance Expense Report - Grouped itemized Expenses By Expense Category
2026-04-07 12:58:09 +02:00
mchev
0e9f18d4d1 fix: i18n for expense report PDF and correct report controller return types
Add expenses.uncategorized and pdf_expense_group_total_label; use the new key
in the grouped expense template; document View|Response instead of JsonResponse.

Made-with: Cursor
2026-04-07 10:43:02 +02:00
mchev
e22050bc71 fix: use DomPDF Pdf facade and Pint style in expense report
Replace legacy PDF facade alias with Barryvdh\DomPDF\Facade\Pdf so CI Pint passes.

Made-with: Cursor
2026-04-07 10:31:48 +02:00
mchev
af9d672574 Bump version from 2.3.1 to 2.3.2 2026-04-06 11:02:23 +02:00
mchev
7606f8ece8 Merge pull request #585 from InvoiceShelf/translations
New Crowdin updates
2026-04-06 11:00:58 +02:00
mchev
9b0498a2e5 Merge pull request #583 from sirlupusdev/fix-setup-wizard
Fix: Set Slug when creating/updating first company
2026-04-06 10:54:57 +02:00
mchev
04c7682e73 Merge pull request #584 from sirlupusdev/feat-auto-due-date
Feat: Automatically set due date when invoice date is changed
2026-04-06 10:50:05 +02:00
klittle81
834b53ea40 Enhance Expense Report - Grouped itemized expenses 2026-04-04 11:22:32 -04:00
Darko Gjorgjijoski
0f933b6217 New translations en.json (Hindi) 2026-03-26 19:47:36 +01:00
lupus0802
241ec09220 Feat: Automatically set due date when invoice date is changed 2026-03-25 13:06:39 +01:00
lupus0802
ed7af3fc3c Fix: Set Slug when creating/updating first company 2026-03-25 13:05:23 +01:00
72 changed files with 1468 additions and 62 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,65 @@
<?php
namespace App\Http\Controllers\V1\Admin\Expense;
use App\Http\Controllers\Controller;
use App\Http\Requests\DuplicateExpenseRequest;
use App\Http\Resources\ExpenseResource;
use App\Models\CompanySetting;
use App\Models\ExchangeRateLog;
use App\Models\Expense;
class DuplicateExpenseController extends Controller
{
/**
* Duplicate an expense, appending " (copy)" to the note (description).
*/
public function __invoke(DuplicateExpenseRequest $request, Expense $expense): ExpenseResource
{
$this->authorize('view', $expense);
$this->authorize('create', Expense::class);
$expense->load('fields');
$companyCurrency = CompanySetting::getSetting('currency', $request->header('company'));
$currentCurrency = $expense->currency_id;
$exchangeRate = $companyCurrency != $currentCurrency ? $expense->exchange_rate : 1;
$notes = trim((string) $expense->notes);
$duplicatedNotes = $notes === '' ? '(copy)' : $notes.' (copy)';
$newExpense = Expense::query()->create([
'expense_date' => $request->validated('expense_date'),
'expense_number' => null,
'expense_category_id' => $expense->expense_category_id,
'payment_method_id' => $expense->payment_method_id,
'amount' => $expense->amount,
'customer_id' => $expense->customer_id,
'notes' => $duplicatedNotes,
'currency_id' => $expense->currency_id,
'creator_id' => $request->user()->id,
'company_id' => $request->header('company'),
'exchange_rate' => $exchangeRate,
'base_amount' => $expense->amount * $exchangeRate,
]);
if ((string) $newExpense->currency_id !== (string) $companyCurrency) {
ExchangeRateLog::addExchangeRateLog($newExpense);
}
if ($expense->fields()->exists()) {
$customFields = [];
foreach ($expense->fields as $data) {
$customFields[] = [
'id' => $data->custom_field_id,
'value' => $data->defaultAnswer,
];
}
$newExpense->addCustomFields($customFields);
}
return new ExpenseResource($newExpense);
}
}

View File

@@ -7,11 +7,12 @@ use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Expense;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use PDF;
class ExpensesReportController extends Controller
{
@@ -19,7 +20,7 @@ class ExpensesReportController extends Controller
* Handle the incoming request.
*
* @param string $hash
* @return JsonResponse
* @return View|Response
*/
public function __invoke(Request $request, $hash)
{
@@ -31,14 +32,26 @@ class ExpensesReportController extends Controller
App::setLocale($locale);
$expenseCategories = Expense::with('category')
// Fetch individual expenses (filtered and ordered by date), then group by category
$expenses = Expense::with('category')
->whereCompanyId($company->id)
->applyFilters($request->only(['from_date', 'to_date']))
->expensesAttributes()
->applyFilters($request->only(['from_date', 'to_date', 'expense_category_id']))
->orderBy('expense_date', 'asc')
->get();
$totalAmount = 0;
foreach ($expenseCategories as $category) {
$totalAmount += $category->total_amount;
$totalAmount = $expenses->sum('base_amount');
$grouped = $expenses->groupBy(function ($item) {
return $item->category ? $item->category->name : trans('expenses.uncategorized');
});
$expenseGroups = collect();
foreach ($grouped as $categoryName => $group) {
$expenseGroups->push([
'name' => $categoryName,
'expenses' => $group,
'total' => $group->sum('base_amount'),
]);
}
$dateFormat = CompanySetting::getSetting('carbon_date_format', $company->id);
@@ -62,7 +75,7 @@ class ExpensesReportController extends Controller
->get();
view()->share([
'expenseCategories' => $expenseCategories,
'expenseGroups' => $expenseGroups,
'colorSettings' => $colorSettings,
'totalExpense' => $totalAmount,
'company' => $company,
@@ -70,7 +83,7 @@ class ExpensesReportController extends Controller
'to_date' => $to_date,
'currency' => $currency,
]);
$pdf = PDF::loadView('app.pdf.reports.expenses');
$pdf = Pdf::loadView('app.pdf.reports.expenses');
if ($request->has('preview')) {
return view('app.pdf.reports.expenses');

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DuplicateExpenseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'expense_date' => [
'required',
'date_format:Y-m-d',
],
];
}
}

View File

@@ -8,7 +8,6 @@ use App\Facades\PDF;
use App\Mail\SendEstimateMail;
use App\Services\SerialNumberFormatter;
use App\Space\PdfTemplateUtils;
use App\Support\PdfHtmlSanitizer;
use App\Traits\GeneratesPdfTrait;
use App\Traits\HasCustomFieldsTrait;
use Carbon\Carbon;
@@ -476,7 +475,7 @@ class Estimate extends Model implements HasMedia
public function getNotes()
{
return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
return $this->getFormattedString($this->notes);
}
public function getEmailAttachmentSetting()

View File

@@ -8,7 +8,6 @@ use App\Facades\PDF;
use App\Mail\SendInvoiceMail;
use App\Services\SerialNumberFormatter;
use App\Space\PdfTemplateUtils;
use App\Support\PdfHtmlSanitizer;
use App\Traits\GeneratesPdfTrait;
use App\Traits\HasCustomFieldsTrait;
use Carbon\Carbon;
@@ -657,7 +656,7 @@ class Invoice extends Model implements HasMedia
public function getNotes()
{
return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
return $this->getFormattedString($this->notes);
}
public function getEmailString($body)

View File

@@ -6,7 +6,6 @@ use App\Facades\Hashids;
use App\Jobs\GeneratePaymentPdfJob;
use App\Mail\SendPaymentMail;
use App\Services\SerialNumberFormatter;
use App\Support\PdfHtmlSanitizer;
use App\Traits\GeneratesPdfTrait;
use App\Traits\HasCustomFieldsTrait;
use Barryvdh\DomPDF\Facade\Pdf as PDF;
@@ -434,7 +433,7 @@ class Payment extends Model implements HasMedia
public function getNotes()
{
return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
return $this->getFormattedString($this->notes);
}
public function getEmailBody($body)

View File

@@ -5,6 +5,7 @@ namespace App\Traits;
use App\Models\Address;
use App\Models\CompanySetting;
use App\Models\FileDisk;
use App\Support\PdfHtmlSanitizer;
use Carbon\Carbon;
use Illuminate\Support\Facades\App;
@@ -182,6 +183,10 @@ trait GeneratesPdfTrait
$str = str_replace('</p>', '<br />', $str);
return $str;
// Sanitize the assembled HTML to strip any SSRF vectors that may have
// entered through user-supplied address fields, customer names, or
// custom field values. Notes also pass through this method, so they
// get the same treatment without needing a separate wrapper.
return PdfHtmlSanitizer::sanitize($str);
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tax_types', function (Blueprint $table) {
$table->decimal('percent', 5, 3)->nullable()->change();
});
Schema::table('taxes', function (Blueprint $table) {
$table->decimal('percent', 5, 3)->nullable()->change();
});
}
public function down(): void
{
Schema::table('tax_types', function (Blueprint $table) {
$table->decimal('percent', 5, 2)->nullable()->change();
});
Schema::table('taxes', function (Blueprint $table) {
$table->decimal('percent', 5, 2)->nullable()->change();
});
}
};

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 1–3 line items
* - 15 estimates across all statuses, each with 1–3 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

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "تواصل",
"category": "الفئة",
"uncategorized": "Uncategorized",
"from_date": "من تاريخ",
"to_date": "حتى تاريخ",
"expense_date": "التاريخ",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "اجمالي الاداءات",
"pdf_tax_types_label": "أنواع الضرائب",
"pdf_expenses_label": "النفقات",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "مطلوب من,",
"pdf_ship_to": "يشحن إلى,",
"pdf_received_from": "تم الاستلام من:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Měna",
"contact": "Kontakt",
"category": "Kategorie",
"uncategorized": "Uncategorized",
"from_date": "Od data",
"to_date": "Do data",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "DANĚ CELKEM",
"pdf_tax_types_label": "Typy daní",
"pdf_expenses_label": "Výdaje",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Odběratel",
"pdf_ship_to": "Příjemce",
"pdf_received_from": "Přijato od:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Währung",
"contact": "Kontakt",
"category": "Kategorie",
"uncategorized": "Uncategorized",
"from_date": "Von Datum",
"to_date": "bis Datum",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "Gesamte Umsatzsteuer",
"pdf_tax_types_label": "Steuersätze",
"pdf_expenses_label": "Ausgaben",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Rechnungsanschrift",
"pdf_ship_to": "Lieferanschrift",
"pdf_received_from": "Erhalten von:",

View File

@@ -652,6 +652,7 @@
"currency": "Νόμισμα",
"contact": "Επικοινωνία",
"category": "Κατηγορία",
"uncategorized": "Uncategorized",
"from_date": "Από Ημερομηνία",
"to_date": "Έως ημερομηνία",
"expense_date": "Ημερομηνία",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "ΣΥΝΟΛΟ ΦΟΡΟΥ",
"pdf_tax_types_label": "Φορολογική κλάση",
"pdf_expenses_label": "Έξοδα",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Χρέωση σε,",
"pdf_ship_to": "Αποστολή σε,",
"pdf_received_from": "Λήψη από",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -676,6 +677,10 @@
"no_expenses": "No expenses yet!",
"list_of_expenses": "This section will contain the list of expenses.",
"confirm_delete": "You will not be able to recover this Expense | You will not be able to recover these Expenses",
"duplicate_expense": "Duplicate",
"duplicate_expense_title": "Duplicate expense",
"duplicate_expense_modal_hint": "Change the date if you need to. (copy) is added to the note.",
"duplicated_message": "Expense duplicated successfully",
"created_message": "Expense created successfully",
"updated_message": "Expense updated successfully",
"deleted_message": "Expense deleted successfully | Expenses deleted successfully",
@@ -1649,6 +1654,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Divisa",
"contact": "Contacto",
"category": "Categoría",
"uncategorized": "Uncategorized",
"from_date": "Desde la fecha",
"to_date": "Hasta la fecha",
"expense_date": "Fecha",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "IMPUESTO TOTAL",
"pdf_tax_types_label": "Tipos de impuestos",
"pdf_expenses_label": "Gastos",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Cobrar a,",
"pdf_ship_to": "Enviar a,",
"pdf_received_from": "Recibido de:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Valuutta",
"contact": "Yhteyshenkilö",
"category": "Luokka",
"uncategorized": "Uncategorized",
"from_date": "Päivästä",
"to_date": "Päivään",
"expense_date": "Päivämäärä",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "ALV YHTEENSÄ",
"pdf_tax_types_label": "ALV Verokannat",
"pdf_expenses_label": "Kulut",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Laskutetaan,",
"pdf_ship_to": "Toimitetaan,",
"pdf_received_from": "Vastaanotettu:",

View File

@@ -652,6 +652,7 @@
"currency": "Devise",
"contact": "Contact",
"category": "Catégorie",
"uncategorized": "Uncategorized",
"from_date": "Du",
"to_date": "Au",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAXES",
"pdf_tax_types_label": "Taxe",
"pdf_expenses_label": "Dépenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Facturer à",
"pdf_ship_to": "Expédier à",
"pdf_received_from": "Reçu de :",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -35,7 +35,7 @@
"yes": "हां",
"no": "नहीं",
"sort_by": "इसके अनुसार क्रमबद्ध करें",
"ascending": "आरोही",
"ascending": "बढ़ते क्रम में",
"descending": "उतरते",
"subject": "विषय",
"body": "बॉडी",
@@ -652,6 +652,7 @@
"currency": "मुद्रा",
"contact": "संपर्क",
"category": "वर्ग",
"uncategorized": "Uncategorized",
"from_date": "इस तारीख से",
"to_date": "इस तारीख तक",
"expense_date": "दिनांक",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Valuta",
"contact": "Kontakt",
"category": "Kategorija",
"uncategorized": "Uncategorized",
"from_date": "Datum od",
"to_date": "Datum do",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "UKUPNO POREZ",
"pdf_tax_types_label": "Vrsta Poreza",
"pdf_expenses_label": "Rashodi",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Račun za,",
"pdf_ship_to": "Isporučiti za,",
"pdf_received_from": "Poslat od strane:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Mata Uang",
"contact": "Kontak",
"category": "Kategori",
"uncategorized": "Uncategorized",
"from_date": "Dari Tanggal",
"to_date": "Sampai Tanggal",
"expense_date": "Tanggal",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL PAJAK",
"pdf_tax_types_label": "Jenis Pajak",
"pdf_expenses_label": "Pengeluaran",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Ditagih ke,",
"pdf_ship_to": "Dikirim ke,",
"pdf_received_from": "Diterima dari:",

View File

@@ -652,6 +652,7 @@
"currency": "Valuta",
"contact": "Contatto",
"category": "Categoria",
"uncategorized": "Uncategorized",
"from_date": "Dalla Data",
"to_date": "Alla Data",
"expense_date": "Data",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTALE IMPOSTA",
"pdf_tax_types_label": "Tipi di Tasse",
"pdf_expenses_label": "Uscite",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Fattura a,",
"pdf_ship_to": "Invia a,",
"pdf_received_from": "Ricevuto da:",

View File

@@ -652,6 +652,7 @@
"currency": "通貨",
"contact": "連絡先",
"category": "カテゴリ",
"uncategorized": "Uncategorized",
"from_date": "開始日",
"to_date": "終了日",
"expense_date": "日時",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "合計税額",
"pdf_tax_types_label": "税の種類",
"pdf_expenses_label": "支出",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "請求先",
"pdf_ship_to": "配送先",
"pdf_received_from": "受信元:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Data",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Mokesčių tipai",
"pdf_expenses_label": "Išlaidos",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Siųsti į,",
"pdf_received_from": "Gauta nuo:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Kontakti",
"category": "Kategorija",
"uncategorized": "Uncategorized",
"from_date": "Datums no",
"to_date": "Datums līdz",
"expense_date": "Datums",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "NODOKĻI KOPĀ",
"pdf_tax_types_label": "Nodokļu veidi",
"pdf_expenses_label": "Izdevumi",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Saņēmējs,",
"pdf_ship_to": "Piegādes adrese,",
"pdf_received_from": "Saņemts no:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Valuta",
"contact": "Contact",
"category": "Categorie",
"uncategorized": "Uncategorized",
"from_date": "Van datum",
"to_date": "Tot datum",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTALE BELASTINGEN",
"pdf_tax_types_label": "Belastingtypen",
"pdf_expenses_label": "Uitgaven",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Aan,",
"pdf_ship_to": "Verzend naar,",
"pdf_received_from": "Ontvangen van:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Waluta",
"contact": "Kontakt",
"category": "Kategoria",
"uncategorized": "Uncategorized",
"from_date": "Od daty",
"to_date": "Do daty",
"expense_date": "Data",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "CAŁKOWITY PODATEK",
"pdf_tax_types_label": "Rodzaje podatku",
"pdf_expenses_label": "Wydatki",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Wystawiono dla",
"pdf_ship_to": "Wysyłka do",
"pdf_received_from": "Otrzymane od:",

View File

@@ -652,6 +652,7 @@
"currency": "Moeda",
"contact": "Contato",
"category": "Categoria",
"uncategorized": "Uncategorized",
"from_date": "A partir da Data",
"to_date": "Até a Data",
"expense_date": "Data",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "IMPOSTOS TOTAIS",
"pdf_tax_types_label": "Tipos de Impostos",
"pdf_expenses_label": "Despesas",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Cobrar a,",
"pdf_ship_to": "Envie a,",
"pdf_received_from": "Remetente:",

View File

@@ -652,6 +652,7 @@
"currency": "Moeda",
"contact": "Contato",
"category": "Categoria",
"uncategorized": "Uncategorized",
"from_date": "A partir da Data",
"to_date": "Até a Data",
"expense_date": "Data",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "IMPOSTOS TOTAIS",
"pdf_tax_types_label": "Tipos de Impostos",
"pdf_expenses_label": "Despesas",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Cobrar a,",
"pdf_ship_to": "Envie a,",
"pdf_received_from": "Remetente:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Валюта",
"contact": "Контакт",
"category": "Категория",
"uncategorized": "Uncategorized",
"from_date": "От даты",
"to_date": "До даты",
"expense_date": "Дата",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "ВСЕГО НАЛОГОВ",
"pdf_tax_types_label": "Типы налогов",
"pdf_expenses_label": "Расходы",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Адрес счёта,",
"pdf_ship_to": "Адрес доставки,",
"pdf_received_from": "Получено от:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Kontakt",
"category": "Kategória",
"uncategorized": "Uncategorized",
"from_date": "Od dátumu",
"to_date": "Do dátumu",
"expense_date": "Dátum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "Celkové dane",
"pdf_tax_types_label": "Typy daní",
"pdf_expenses_label": "Výdaje",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Odberateľ:",
"pdf_ship_to": "Doručiť do",
"pdf_received_from": "Prijaté od:",

View File

@@ -652,6 +652,7 @@
"currency": "Valuta",
"contact": "Pišite na",
"category": "Kategorija",
"uncategorized": "Uncategorized",
"from_date": "Od datuma",
"to_date": "Do danes",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "SKUPAJ DAVEK",
"pdf_tax_types_label": "Vrste davkov",
"pdf_expenses_label": "Odhodki",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Račun za,",
"pdf_ship_to": "Pošljite v,",
"pdf_received_from": "Prejeto od:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Kontakt",
"category": "Kategorija",
"uncategorized": "Uncategorized",
"from_date": "Datum od",
"to_date": "Datum do",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "UKUPNO POREZ",
"pdf_tax_types_label": "Tipovi Poreza",
"pdf_expenses_label": "Rashodi",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Račun za,",
"pdf_ship_to": "Isporučiti za,",
"pdf_received_from": "Poslat od strane:",

View File

@@ -652,6 +652,7 @@
"currency": "Valuta",
"contact": "Kontakt",
"category": "Kategori",
"uncategorized": "Uncategorized",
"from_date": "Från datum",
"to_date": "Till datum",
"expense_date": "Datum",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "SUMMA MOMS",
"pdf_tax_types_label": "Momssatser",
"pdf_expenses_label": "Utgifter",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Faktureras till,",
"pdf_ship_to": "Skickas till,",
"pdf_received_from": "Från:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "สกุลเงิน",
"contact": "ติดต่อเรา",
"category": "ประเภท",
"uncategorized": "Uncategorized",
"from_date": "จากวันที่",
"to_date": "ถึงวันที่",
"expense_date": "วันที่",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "ภาษีทั้งหมด",
"pdf_tax_types_label": "ประเภทภาษี",
"pdf_expenses_label": "ค่าใช้จ่าย",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "ที่อยู่เรียกเก็บเงิน,",
"pdf_ship_to": "ที่อยู่สำหรับจัดส่ง,",
"pdf_received_from": "ได้รับจาก:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "İletişim",
"category": "Kategori",
"uncategorized": "Uncategorized",
"from_date": "Başlangıç tarihi",
"to_date": "Bitiş tarihi",
"expense_date": "Tarih",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOPLAM VERGİ",
"pdf_tax_types_label": "Vergi Türleri",
"pdf_expenses_label": "Giderler",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Fatura Adresi,",
"pdf_ship_to": "Teslimat Adresi,",
"pdf_received_from": "Alındığı Kişi:",

View File

@@ -652,6 +652,7 @@
"currency": "Валюта",
"contact": "Контакт",
"category": "Категорія",
"uncategorized": "Uncategorized",
"from_date": "Від дати",
"to_date": "До дати",
"expense_date": "Дата",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "Загальний звіт податків",
"pdf_tax_types_label": "Типи податків",
"pdf_expenses_label": "Витрати",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Рахунок до,",
"pdf_ship_to": "Доставити до,",
"pdf_received_from": "Отримано від:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "Tiền tệ",
"contact": "Tiếp xúc",
"category": "Danh mục",
"uncategorized": "Uncategorized",
"from_date": "Từ ngày",
"to_date": "Đến nay",
"expense_date": "Ngày",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TỔNG THUẾ",
"pdf_tax_types_label": "Các loại thuế",
"pdf_expenses_label": "Chi phí",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Hoa đơn để,",
"pdf_ship_to": "Tàu,",
"pdf_received_from": "Nhận được tư:",

View File

@@ -652,6 +652,7 @@
"currency": "Currency",
"contact": "Contact",
"category": "Category",
"uncategorized": "Uncategorized",
"from_date": "From Date",
"to_date": "To Date",
"expense_date": "Date",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "TOTAL TAX",
"pdf_tax_types_label": "Tax Types",
"pdf_expenses_label": "Expenses",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "Bill to,",
"pdf_ship_to": "Ship to,",
"pdf_received_from": "Received from:",

View File

@@ -652,6 +652,7 @@
"currency": "主要貨幣",
"contact": "聯絡",
"category": "分類",
"uncategorized": "Uncategorized",
"from_date": "啟始日",
"to_date": "終止日",
"expense_date": "日期",
@@ -1649,6 +1650,7 @@
"pdf_total_tax_label": "稅項總額",
"pdf_tax_types_label": "稅收類型",
"pdf_expenses_label": "支出",
"pdf_expense_group_total_label": "Group total:",
"pdf_bill_to": "帳單地址,",
"pdf_ship_to": "送貨地址,",
"pdf_received_from": "接收自",

View File

@@ -21,6 +21,18 @@
</BaseDropdownItem>
</router-link>
<!-- duplicate expense -->
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.CREATE_EXPENSE)"
@click="onDuplicateExpense(row)"
>
<BaseIcon
name="DocumentDuplicateIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('expenses.duplicate_expense') }}
</BaseDropdownItem>
<!-- delete expense -->
<BaseDropdownItem
v-if="userStore.hasAbilities(abilities.DELETE_EXPENSE)"
@@ -37,10 +49,10 @@
<script setup>
import { useDialogStore } from '@/scripts/stores/dialog'
import { useNotificationStore } from '@/scripts/stores/notification'
import { useModalStore } from '@/scripts/stores/modal'
import { useI18n } from 'vue-i18n'
import { useExpenseStore } from '@/scripts/admin/stores/expense'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { inject } from 'vue'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
@@ -61,15 +73,24 @@ const props = defineProps({
})
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const modalStore = useModalStore()
const { t } = useI18n()
const expenseStore = useExpenseStore()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const $utils = inject('utils')
function onDuplicateExpense(row) {
modalStore.openModal({
title: t('expenses.duplicate_expense_title'),
componentName: 'DuplicateExpenseModal',
data: row,
size: 'sm',
refreshData: props.loadData,
})
}
function removeExpense(id) {
dialogStore
.openDialog({

View File

@@ -0,0 +1,146 @@
<template>
<BaseModal
:show="modalActive"
:initial-focus="initialFocusRef"
@close="closeModal"
@open="onModalOpen"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-gray-500 cursor-pointer"
@click="closeModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitDuplicate">
<div
ref="initialFocusRef"
class="sr-only outline-none focus:outline-none"
tabindex="-1"
aria-hidden="true"
/>
<div class="px-8 py-6 sm:p-6">
<p class="mb-6 text-sm text-gray-600">
{{ $t('expenses.duplicate_expense_modal_hint') }}
</p>
<BaseInputGroup
:label="$t('expenses.expense_date')"
variant="vertical"
required
>
<BaseDatePicker
v-model="selectedExpenseDate"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
</div>
<div
class="
z-0
flex
justify-end
px-4
py-4
border-t border-gray-200 border-solid
"
>
<BaseButton
class="mr-2"
variant="primary-outline"
type="button"
@click="closeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isDuplicating"
:disabled="isDuplicating || !selectedExpenseDate"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isDuplicating"
name="DocumentDuplicateIcon"
:class="slotProps.class"
/>
</template>
{{ $t('expenses.duplicate_expense') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<script setup>
import { ref, computed } from 'vue'
import moment from 'moment'
import { useRouter } from 'vue-router'
import { useModalStore } from '@/scripts/stores/modal'
import { useExpenseStore } from '@/scripts/admin/stores/expense'
const modalStore = useModalStore()
const expenseStore = useExpenseStore()
const router = useRouter()
const selectedExpenseDate = ref('')
const isDuplicating = ref(false)
const initialFocusRef = ref(null)
const modalActive = computed(
() => modalStore.active && modalStore.componentName === 'DuplicateExpenseModal'
)
function toYmd(value) {
if (!value) {
return moment().format('YYYY-MM-DD')
}
const str = String(value)
if (str.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(str)) {
return str.slice(0, 10)
}
return moment(value).format('YYYY-MM-DD')
}
function onModalOpen() {
selectedExpenseDate.value = toYmd(modalStore.data?.expense_date)
}
async function submitDuplicate() {
if (!modalStore.data?.id || !selectedExpenseDate.value) {
return
}
isDuplicating.value = true
try {
await expenseStore.duplicateExpense({
id: modalStore.data.id,
expense_date: selectedExpenseDate.value,
})
modalStore.refreshData && modalStore.refreshData()
closeModal()
router.push('/admin/expenses')
} finally {
isDuplicating.value = false
}
}
function closeModal() {
modalStore.closeModal()
selectedExpenseDate.value = ''
}
</script>

View File

@@ -58,15 +58,16 @@
variant="horizontal"
required
>
<BaseMoney
v-model="taxTypeStore.currentTaxType.percent"
:currency="{
decimal: '.',
thousands: ',',
symbol: '% ',
precision: 2,
masked: false,
}"
<BaseInput
:model-value="taxTypeStore.currentTaxType.percent"
type="number"
step="0.001"
min="-100"
max="100"
inline-addon="%"
:invalid="v$.currentTaxType.percent.$error"
@update:model-value="onTaxPercentInput"
@blur="onTaxPercentBlur"
/>
</BaseInputGroup>
@@ -207,7 +208,38 @@ const v$ = useVuelidate(
computed(() => taxTypeStore)
)
function onTaxPercentInput(val) {
v$.value.currentTaxType.percent.$touch()
if (val === '' || val === null) {
taxTypeStore.currentTaxType.percent = null
return
}
const n = typeof val === 'number' ? val : parseFloat(val)
taxTypeStore.currentTaxType.percent = Number.isNaN(n) ? null : n
}
function onTaxPercentBlur() {
const p = taxTypeStore.currentTaxType.percent
if (p === null || p === undefined || p === '') {
return
}
const n = typeof p === 'number' ? p : parseFloat(p)
if (Number.isNaN(n)) {
return
}
taxTypeStore.currentTaxType.percent = Math.round(n * 1000) / 1000
}
async function submitTaxTypeData() {
if (taxTypeStore.currentTaxType.calculation_type === 'percentage') {
onTaxPercentBlur()
}
v$.value.currentTaxType.$touch()
if (v$.value.currentTaxType.$invalid) {
return true

View File

@@ -91,6 +91,27 @@ export const useExpenseStore = (useWindow = false) => {
})
},
duplicateExpense({ id, expense_date }) {
return new Promise((resolve, reject) => {
http
.post(`/api/v1/expenses/${id}/duplicate`, { expense_date })
.then((response) => {
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t('expenses.duplicated_message'),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
addExpense(data) {
const formData = utils.toFormData(data)

View File

@@ -1,5 +1,7 @@
<template>
<BasePage>
<DuplicateExpenseModal />
<!-- Page Header -->
<BasePageHeader :title="$t('expenses.title')">
<BaseBreadcrumb>
@@ -231,6 +233,7 @@ import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import UFOIcon from '@/scripts/components/icons/empty/UFOIcon.vue'
import DuplicateExpenseModal from '@/scripts/admin/components/modal-components/DuplicateExpenseModal.vue'
import ExpenseDropdown from '@/scripts/admin/components/dropdowns/ExpenseIndexDropdown.vue'
const companyStore = useCompanyStore()

View File

@@ -164,6 +164,7 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { deburr } from 'lodash'
import { required, maxLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import { useGlobalStore } from '@/scripts/admin/stores/global'
@@ -180,6 +181,7 @@ let logoFileName = ref(null)
const companyForm = reactive({
name: null,
slug: null,
tax_id: null,
vat_id: null,
address: {
@@ -251,6 +253,18 @@ async function next() {
}
isSaving.value = true
// Generate slug from company name (imitate Laravel's Str::slug)
if (companyForm.name) {
companyForm.slug = deburr(companyForm.name) // Remove accents etc.
.toLowerCase()
.trim()
.replace(/_/g, '-')
.replace(/[^a-z0-9\s-]/g, '') // Remove all non-alphanumeric chars
.replace(/[\s-]+/g, '-')
.replace(/^-+|-+$/g, '') // Trim dashes
}
let res = companyStore.updateCompany(companyForm)
if (res) {
if (logoFileBlob.value) {

View File

@@ -139,6 +139,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import moment from 'moment'
import {
required,
maxLength,
@@ -179,6 +180,9 @@ let router = useRouter()
const invoiceValidationScope = 'newInvoice'
let isSaving = ref(false)
const isMarkAsDefault = ref(false)
const dueDateManuallyChanged = ref(false)
let isAutoUpdatingDueDate = false
let expectedAutoDueDate = ref(null)
const invoiceNoteFieldList = ref([
'customer',
@@ -261,6 +265,52 @@ watch(
{immediate: true}
)
// Watch for manual changes to due_date
watch(() => invoiceStore.newInvoice.due_date, (newDueDate, oldDueDate) => {
if (!isAutoUpdatingDueDate && newDueDate !== oldDueDate && oldDueDate !== undefined && newDueDate !== expectedAutoDueDate.value) {
dueDateManuallyChanged.value = true
}
});
// Watch invoice_date and automatically update due_date when it changes
watch(() => invoiceStore.newInvoice.invoice_date, (newInvoiceDate, oldInvoiceDate) => {
if (
companyStore.selectedCompanySettings?.invoice_set_due_date_automatically === 'YES' &&
newInvoiceDate &&
newInvoiceDate !== oldInvoiceDate &&
oldInvoiceDate !== undefined
) {
const dueDateDays = parseInt(companyStore.selectedCompanySettings.invoice_due_date_days || 0);
const invoiceDate = moment(newInvoiceDate)
if (invoiceDate.isValid()) {
const calculatedDueDate = invoiceDate.clone().add(dueDateDays, 'days').format('YYYY-MM-DD')
expectedAutoDueDate.value = calculatedDueDate
if (dueDateManuallyChanged.value) {
const currentDueDate = invoiceStore.newInvoice.due_date
if (currentDueDate) {
const dueDateMoment = moment(currentDueDate)
if (dueDateMoment.isValid() && dueDateMoment.isSameOrAfter(invoiceDate, 'day')) {
return // Manual due date still valid/in the future
}
}
// Manual due date is in the past/invalid
dueDateManuallyChanged.value = false
}
// Set the calculated due date
isAutoUpdatingDueDate = true
invoiceStore.newInvoice.due_date = calculatedDueDate
isAutoUpdatingDueDate = false
}
}
})
async function submitForm() {
v$.value.$touch()

View File

@@ -248,4 +248,5 @@ function openTaxModal() {
refreshData: table.value && table.value.refresh,
})
}
</script>

View File

@@ -6,6 +6,7 @@
static
class="fixed inset-0 z-20 overflow-y-auto"
:open="show"
:initial-focus="initialFocus"
@close="$emit('close')"
>
<div
@@ -106,6 +107,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
/**
* Optional ref (template ref) for the element that should receive focus when the dialog opens.
* When omitted, Headless UI focuses the first focusable control (often an input).
*/
initialFocus: {
type: Object,
default: null,
},
})
const slots = useSlots()

View File

@@ -33,7 +33,7 @@
.heading-date-range {
font-weight: normal;
font-size: 15px;
color: #A5ACC1;
color: #606060;
width: 100%;
text-align: right;
padding: 0px;
@@ -41,7 +41,7 @@
}
.sub-heading-text {
font-weight: normal;
font-weight: bold;
font-size: 16px;
color: #595959;
padding: 0px;
@@ -50,8 +50,7 @@
}
.expenses-title {
margin-top: 60px;
padding-left: 3px;
margin-top: 30px;
font-size: 16px;
line-height: 21px;
color: #040405;
@@ -133,6 +132,84 @@
line-height: 21px;
color: #5851D8;
}
/* -- Items Table -- */
.items-table {
margin-top: 35px;
padding: 0px 30px 10px 30px;
page-break-before: avoid;
page-break-after: auto;
}
.items-table hr {
height: 0.1px;
}
.item-table-heading-left {
font-size: 13.5;
text-align: left;
color: rgba(0, 0, 0, 0.85);
padding: 5px;
padding-bottom: 10px;
}
.item-table-heading-right {
font-size: 13.5;
text-align: right;
color: rgba(0, 0, 0, 0.85);
padding: 5px;
padding-bottom: 10px;
}
tr.item-table-heading-row th {
border-bottom: 0.620315px solid #E8E8E8;
font-size: 12px;
line-height: 18px;
}
.item-table-heading-row {
margin-bottom: 10px;
}
tr.item-row td {
font-size: 12px;
line-height: 18px;
}
.item-cell-left {
font-size: 13;
color: #040405;
text-align: left;
padding: 5px;
padding-top: 10px;
border-color: #d9d9d9;
}
.item-cell-right {
font-size: 13;
color: #040405;
text-align: right;
padding: 5px;
padding-top: 10px;
border-color: #d9d9d9;
}
.item-description {
color: #595959;
font-size: 9px;
line-height: 12px;
}
.item-table-group-total {
font-size: 14px;
font-weight: bold;
text-align: right;
color: rgba(0, 0, 0, 0.85);
padding: 5px;
padding-bottom: 10px;
}
</style>
@if (App::isLocale('th'))
@@ -158,33 +235,28 @@
</tr>
</table>
<p class="expenses-title">@lang('pdf_expenses_label')</p>
<div class="expenses-table-container">
<table class="expenses-table">
@foreach ($expenseCategories as $expenseCategory)
<tr>
<td>
<p class="expense-title">
{{ $expenseCategory->category->name }}
</p>
</td>
<td>
<p class="expense-amount">
{!! format_money_pdf($expenseCategory->total_amount, $currency) !!}
</p>
</td>
@foreach ($expenseGroups as $group)
<p class="expense-title">{{ $group['name'] }}</p>
<table width="100%" style="margin-bottom:18px;">
<tr class="item-table-heading-row">
<th style="width: 15%;" class="text-left item-table-heading-left">@lang('Date')</th>
<th style="width: 70%;" class="text-left item-table-heading-left">@lang('Note')</th>
<th style="width: 15%;" class="text-right item-table-heading-right">@lang('Amount')</th>
</tr>
@foreach ($group['expenses'] as $expense)
<tr class="item-row">
<td style="width: 15%;" class="text-left item-cell-left">{{ $expense->formatted_expense_date }}</td>
<td style="width: 70%;" class="text-left item-cell-left">{{ $expense->notes ? $expense->notes : '-' }}</td>
<td style="width: 15%;" class="text-right item-cell-right">{!! format_money_pdf($expense->base_amount, $currency) !!}</td>
</tr>
@endforeach
</table>
</div>
</div>
<table class="expense-total-table">
<tr>
<td class="expense-total-cell">
<p class="expense-total">{!! format_money_pdf($totalExpense, $currency) !!}</p>
</td>
</tr>
</table>
</table>
<div class="item-table-group-total">
<p>@lang('pdf_expense_group_total_label')&nbsp;&nbsp;&nbsp;<span style="color: #5851D8;">{!! format_money_pdf($group['total'], $currency) !!}</span></p>
</div>
@endforeach
</div>
<table class="report-footer">
<tr>
<td>

View File

@@ -23,6 +23,7 @@ use App\Http\Controllers\V1\Admin\ExchangeRate\GetActiveProviderController;
use App\Http\Controllers\V1\Admin\ExchangeRate\GetExchangeRateController;
use App\Http\Controllers\V1\Admin\ExchangeRate\GetSupportedCurrenciesController;
use App\Http\Controllers\V1\Admin\ExchangeRate\GetUsedCurrenciesController;
use App\Http\Controllers\V1\Admin\Expense\DuplicateExpenseController;
use App\Http\Controllers\V1\Admin\Expense\ExpenseCategoriesController;
use App\Http\Controllers\V1\Admin\Expense\ExpensesController;
use App\Http\Controllers\V1\Admin\Expense\ShowReceiptController;
@@ -312,6 +313,8 @@ Route::prefix('/v1')->group(function () {
Route::post('/expenses/delete', [ExpensesController::class, 'delete']);
Route::post('/expenses/{expense}/duplicate', DuplicateExpenseController::class);
Route::apiResource('expenses', ExpensesController::class);
Route::apiResource('categories', ExpenseCategoriesController::class);

View File

@@ -1,6 +1,8 @@
<?php
use App\Http\Controllers\V1\Admin\Expense\DuplicateExpenseController;
use App\Http\Controllers\V1\Admin\Expense\ExpensesController;
use App\Http\Requests\DuplicateExpenseRequest;
use App\Http\Requests\ExpenseRequest;
use App\Models\Expense;
use App\Models\User;
@@ -114,6 +116,79 @@ test('search expenses', function () {
$response->assertOk();
});
test('duplicate expense', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
'notes' => 'Monthly rent',
]);
$response = postJson("api/v1/expenses/{$expense->id}/duplicate", [
'expense_date' => '2019-02-05',
]);
$response->assertStatus(201);
$newId = $response->json('data.id');
expect($newId)->not->toBe($expense->id);
$this->assertDatabaseHas('expenses', [
'id' => $newId,
'expense_date' => '2019-02-05',
'notes' => 'Monthly rent (copy)',
'expense_category_id' => $expense->expense_category_id,
'amount' => $expense->amount,
]);
});
test('duplicate expense with empty note uses copy as note', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
'notes' => null,
]);
postJson("api/v1/expenses/{$expense->id}/duplicate", [
'expense_date' => '2019-02-05',
])
->assertStatus(201)
->assertJsonPath('data.notes', '(copy)');
});
test('duplicate expense uses submitted expense date', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
]);
$response = postJson("api/v1/expenses/{$expense->id}/duplicate", [
'expense_date' => '2024-03-10',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('expenses', [
'id' => $response->json('data.id'),
'expense_date' => '2024-03-10',
]);
});
test('duplicate expense requires expense date', function () {
$expense = Expense::factory()->create([
'expense_date' => '2019-02-05',
]);
postJson("api/v1/expenses/{$expense->id}/duplicate", [])
->assertUnprocessable()
->assertJsonValidationErrors(['expense_date']);
});
test('duplicate validates using a form request', function () {
$this->assertActionUsesFormRequest(
DuplicateExpenseController::class,
'__invoke',
DuplicateExpenseRequest::class
);
});
test('delete multiple expenses', function () {
$expenses = Expense::factory()->count(3)->create([
'expense_date' => '2019-02-05',

View File

@@ -5,6 +5,7 @@ use App\Http\Requests\TaxTypeRequest;
use App\Models\TaxType;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\deleteJson;
@@ -110,3 +111,22 @@ test('create fixed amount tax type', function () {
$this->assertDatabaseHas('tax_types', $taxType);
});
test('create percentage tax type with three decimals', function () {
$payload = TaxType::factory()->raw([
'calculation_type' => 'percentage',
'percent' => 6.625,
'fixed_amount' => null,
]);
$response = postJson('api/v1/tax-types', $payload)
->assertStatus(201);
$taxTypeId = $response->json('data.id');
expect($taxTypeId)->not()->toBeNull();
$rawPercent = DB::table('tax_types')->where('id', $taxTypeId)->value('percent');
expect((string) $rawPercent)->toBe('6.625');
});

View File

@@ -34,3 +34,39 @@ it('normalizes legacy closing-br markup so lines are not collapsed in PDF output
expect($out)->toContain('<br')->toContain('line1')->toContain('line2');
expect($out)->not->toBe('line1line2');
});
it('strips SSRF vectors injected via address-template placeholders', function () {
// Simulates the output of GeneratesPdfTrait::getFormattedString() after a
// malicious customer name like "Acme <img src='http://attacker/probe'>" has
// been substituted into an address template via {BILLING_ADDRESS_NAME}.
$html = "Acme <img src='http://attacker.test/probe'><br />123 Main St<br />Springfield";
$out = PdfHtmlSanitizer::sanitize($html);
expect($out)->not->toContain('<img');
expect($out)->not->toContain('src=');
expect($out)->not->toContain('attacker.test');
expect($out)->toContain('Acme');
expect($out)->toContain('123 Main St');
expect($out)->toContain('Springfield');
});
it('strips iframe and link tags that could trigger SSRF', function () {
$html = '<iframe src="http://attacker/x"></iframe><link rel="stylesheet" href="http://attacker/y.css">Hello';
$out = PdfHtmlSanitizer::sanitize($html);
expect($out)->not->toContain('<iframe');
expect($out)->not->toContain('<link');
expect($out)->not->toContain('attacker');
expect($out)->toContain('Hello');
});
it('strips on* event handler attributes from allowed tags', function () {
$html = '<p onload="alert(1)" onclick="x">click me</p>';
$out = PdfHtmlSanitizer::sanitize($html);
expect($out)->not->toContain('onload')->not->toContain('onclick')->not->toContain('alert');
expect($out)->toContain('click me');
});

View File

@@ -1 +1 @@
2.3.1
2.3.3