64 Commits

Author SHA1 Message Date
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
69 changed files with 801 additions and 61 deletions

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

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