diff --git a/app/Console/Commands/CheckInvoiceStatus.php b/app/Console/Commands/CheckInvoiceStatus.php index 564607b8..be6e1a75 100644 --- a/app/Console/Commands/CheckInvoiceStatus.php +++ b/app/Console/Commands/CheckInvoiceStatus.php @@ -40,7 +40,10 @@ class CheckInvoiceStatus extends Command public function handle(): void { $date = Carbon::now(); - $invoices = Invoice::whereNotIn('status', [Invoice::STATUS_COMPLETED, Invoice::STATUS_DRAFT]) + // Only real invoices can fall overdue: a credit note is never owed, so + // it must never be flagged no matter what date it carries. + $invoices = Invoice::where('type', Invoice::TYPE_INVOICE) + ->whereNotIn('status', [Invoice::STATUS_COMPLETED, Invoice::STATUS_DRAFT]) ->where('overdue', false) ->whereDate('due_date', '<', $date) ->get(); diff --git a/app/Http/Controllers/Company/Dashboard/DashboardController.php b/app/Http/Controllers/Company/Dashboard/DashboardController.php index 805f1ba6..8e7ad2f5 100644 --- a/app/Http/Controllers/Company/Dashboard/DashboardController.php +++ b/app/Http/Controllers/Company/Dashboard/DashboardController.php @@ -121,12 +121,22 @@ class DashboardController extends Controller ]; $total_customer_count = Customer::whereCompany()->count(); + // "How many invoices did we issue" counts issued documents, so the + // reversals are excluded. The sums above deliberately keep them: a + // credit note's negated total is exactly what nets sales back out. $total_invoice_count = Invoice::whereCompany() + ->where('type', Invoice::TYPE_INVOICE) ->count(); $total_estimate_count = Estimate::whereCompany()->count(); $total_amount_due = Invoice::whereCompany() ->sum('base_due_amount'); + // Raw models, not InvoiceResource: every loaded relation is serialized + // with the full $appends set, so a column-limited creditNotes load blew + // up in the date accessors (no company_id on the children) and a full + // load would run the appends per credit note for nothing. The rows do + // not need the relation: credited_status is a resource-level field, and + // a fully credited invoice has no due amount so it never appears here. $recent_due_invoices = Invoice::with('customer') ->whereCompany() ->where('base_due_amount', '>', 0) diff --git a/app/Http/Controllers/Company/General/SerialNumberController.php b/app/Http/Controllers/Company/General/SerialNumberController.php index 61dc98b0..0c746886 100644 --- a/app/Http/Controllers/Company/General/SerialNumberController.php +++ b/app/Http/Controllers/Company/General/SerialNumberController.php @@ -23,7 +23,19 @@ class SerialNumberController extends Controller try { switch ($key) { case 'invoice': + // Scoped exactly like every invoice create path, so the + // settings preview can never count credit-note rows. $nextNumber = $serial->setModel($invoice) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setModelObject($request->model_id) + ->getNextNumber(); + + break; + + case 'credit_note': + $nextNumber = $serial->setModel($invoice) + ->setSettingKey('credit_note_number_format') + ->setSequenceScope(['type' => Invoice::TYPE_CREDIT_NOTE]) ->setModelObject($request->model_id) ->getNextNumber(); diff --git a/app/Http/Controllers/Company/Invoice/InvoicesController.php b/app/Http/Controllers/Company/Invoice/InvoicesController.php index 980c7ec2..8ef66ff6 100644 --- a/app/Http/Controllers/Company/Invoice/InvoicesController.php +++ b/app/Http/Controllers/Company/Invoice/InvoicesController.php @@ -4,22 +4,27 @@ namespace App\Http\Controllers\Company\Invoice; use App\Http\Controllers\Controller; use App\Http\Requests; +use App\Http\Requests\CreateCreditNoteRequest; use App\Http\Requests\DeleteInvoiceRequest; use App\Http\Requests\SendInvoiceRequest; +use App\Http\Resources\CreditNoteResource; use App\Http\Resources\EstimateResource; use App\Http\Resources\InvoiceResource; use App\Jobs\GenerateInvoicePdfJob; use App\Models\Estimate; use App\Models\Invoice; +use App\Services\Document\CreditNoteService; use App\Services\Document\InvoiceService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Mail\Markdown; +use Illuminate\Validation\ValidationException; class InvoicesController extends Controller { public function __construct( private readonly InvoiceService $invoiceService, + private readonly CreditNoteService $creditNoteService, ) {} /** @@ -33,9 +38,11 @@ class InvoicesController extends Controller $limit = $request->input('limit', 10); + // creditNotes drives the "cancelled" badge on every row, so it is + // eager-loaded (two columns) rather than probed per row. $invoices = Invoice::whereCompany() ->applyFilters($request->all()) - ->with('customer') + ->with(['customer', 'creditNotes:id,related_invoice_id,invoice_number,total']) ->latest() ->paginateData($limit); @@ -75,7 +82,17 @@ class InvoicesController extends Controller { $this->authorize('view', $invoice); - return new InvoiceResource($invoice); + if ($invoice->isCreditNote()) { + return new CreditNoteResource($invoice->load('relatedInvoice')); + } + + // Feeds the credit-note banner on the detail page: how much of the + // invoice has been credited, and how much of each line, so the partial + // credit form can offer the remaining quantities. + return new InvoiceResource($invoice->load([ + 'creditNotes:id,related_invoice_id,invoice_number,total', + 'creditNotes.items:id,invoice_id,source_invoice_item_id,quantity', + ])); } /** @@ -136,7 +153,11 @@ class InvoicesController extends Controller $data = $this->invoiceService->sendInvoiceData($invoice, $request->all()); $data['url'] = $invoice->invoicePdfUrl; - return $markdown->render('emails.send.invoice', ['data' => $data]); + // Preview the template that will actually be sent: a credit note goes + // out through SendCreditNoteMail, so it must preview as one. + $view = $invoice->isCreditNote() ? 'emails.send.credit-note' : 'emails.send.invoice'; + + return $markdown->render($view, ['data' => $data]); } public function clone(Request $request, Invoice $invoice) @@ -144,6 +165,14 @@ class InvoicesController extends Controller $this->authorize('view', $invoice); $this->authorize('create', Invoice::class); + // Cloning a credit note would mint a positive invoice out of a reversal + // document. Domain rule violation (422), not an authorization failure. + if ($invoice->isCreditNote()) { + throw ValidationException::withMessages([ + 'invoice' => ['a_credit_note_cannot_be_cloned'], + ]); + } + $newInvoice = $this->invoiceService->clone($invoice); return new InvoiceResource($newInvoice); @@ -156,11 +185,59 @@ class InvoicesController extends Controller $this->authorize('view', $invoice); $this->authorize('create', Estimate::class); + // Same reason as clone(): the conversion copies the amounts unnegated, + // so a credit note would become a positive estimate. + if ($invoice->isCreditNote()) { + throw ValidationException::withMessages([ + 'invoice' => ['a_credit_note_cannot_be_converted_to_an_estimate'], + ]); + } + $estimate = $this->invoiceService->convertToEstimate($invoice); return new EstimateResource($estimate); } + public function createCreditNote(CreateCreditNoteRequest $request, Invoice $invoice) + { + $this->authorize('create credit note', $invoice); + + // A credit note can only reverse a real invoice, never another credit + // note. This is a domain rule (422), not an authorization failure (403). + if ($invoice->isCreditNote()) { + throw ValidationException::withMessages([ + 'invoice' => ['a_credit_note_cannot_be_created_from_a_credit_note'], + ]); + } + + // A draft was never issued, so there is nothing to reverse: edit or + // delete it instead. + if ($invoice->status === Invoice::STATUS_DRAFT) { + throw ValidationException::withMessages([ + 'invoice' => ['a_draft_invoice_cannot_be_credited'], + ]); + } + + // How much of the invoice is still creditable, and whether the credit + // fits inside its unpaid balance, is decided by the service under a row + // lock. Guarding it here would race. + $creditNote = $this->creditNoteService->create( + $invoice, + $request->input('items', []), + $request->input('reason') + ); + + GenerateInvoicePdfJob::dispatch($creditNote); + + // The original's own PDF changed too: its balance moved and it now + // carries the cancellation banner, so the stored file is replaced. + GenerateInvoicePdfJob::dispatch($invoice->fresh(), true); + + return (new CreditNoteResource($creditNote)) + ->response() + ->setStatusCode(201); + } + public function changeStatus(Request $request, Invoice $invoice) { $this->authorize('send invoice', $invoice); diff --git a/app/Http/Controllers/CustomerPortal/General/DashboardController.php b/app/Http/Controllers/CustomerPortal/General/DashboardController.php index 40f558cf..6a900d4e 100644 --- a/app/Http/Controllers/CustomerPortal/General/DashboardController.php +++ b/app/Http/Controllers/CustomerPortal/General/DashboardController.php @@ -24,7 +24,10 @@ class DashboardController extends Controller $amountDue = Invoice::whereCustomer($user->id) ->where('status', '<>', 'DRAFT') ->sum('due_amount'); + // Counts issued invoices only; a credit note is a reversal document, + // not another invoice the customer received. $invoiceCount = Invoice::whereCustomer($user->id) + ->where('type', Invoice::TYPE_INVOICE) ->where('status', '<>', 'DRAFT') ->count(); $estimatesCount = Estimate::whereCustomer($user->id) diff --git a/app/Http/Controllers/CustomerPortal/Invoice/InvoicesController.php b/app/Http/Controllers/CustomerPortal/Invoice/InvoicesController.php index 8e8d4335..5d157056 100644 --- a/app/Http/Controllers/CustomerPortal/Invoice/InvoicesController.php +++ b/app/Http/Controllers/CustomerPortal/Invoice/InvoicesController.php @@ -30,7 +30,9 @@ class InvoicesController extends Controller return InvoiceResource::collection($invoices) ->additional(['meta' => [ - 'invoiceTotalCount' => Invoice::where('status', '<>', 'DRAFT')->whereCustomer(Auth::guard('customer')->id())->count(), + // Issued invoices only: a credit note is a reversal document, + // not another invoice the customer received. + 'invoiceTotalCount' => Invoice::where('type', Invoice::TYPE_INVOICE)->where('status', '<>', 'DRAFT')->whereCustomer(Auth::guard('customer')->id())->count(), ]]); } diff --git a/app/Http/Requests/CreateCreditNoteRequest.php b/app/Http/Requests/CreateCreditNoteRequest.php new file mode 100644 index 00000000..e2066b49 --- /dev/null +++ b/app/Http/Requests/CreateCreditNoteRequest.php @@ -0,0 +1,71 @@ + [ + 'nullable', + 'string', + 'max:1000', + ], + 'items' => [ + 'sometimes', + 'array', + ], + 'items.*.id' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('invoice_items', 'id')->where('invoice_id', $this->route('invoice')->id), + ], + 'items.*.quantity' => [ + 'required', + 'numeric', + 'gt:0', + ], + ]; + } + + /** + * The message string IS the translation key here, as everywhere else in the + * app: the front end maps it to a localized string. + */ + public function messages(): array + { + return [ + 'items.*.id.required' => 'credit_item_not_on_invoice', + 'items.*.id.integer' => 'credit_item_not_on_invoice', + 'items.*.id.distinct' => 'credit_item_not_on_invoice', + 'items.*.id.exists' => 'credit_item_not_on_invoice', + 'items.*.quantity.required' => 'credit_quantity_invalid', + 'items.*.quantity.numeric' => 'credit_quantity_invalid', + 'items.*.quantity.gt' => 'credit_quantity_invalid', + ]; + } +} diff --git a/app/Http/Requests/DeleteInvoiceRequest.php b/app/Http/Requests/DeleteInvoiceRequest.php index b5e87509..0ddf09ff 100644 --- a/app/Http/Requests/DeleteInvoiceRequest.php +++ b/app/Http/Requests/DeleteInvoiceRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Models\Invoice; +use App\Rules\CreditNoteDeletedTogether; use App\Rules\RelationNotExist; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -30,6 +31,7 @@ class DeleteInvoiceRequest extends FormRequest 'required', Rule::exists('invoices', 'id'), new RelationNotExist(Invoice::class, 'payments'), + new CreditNoteDeletedTogether((array) $this->input('ids', [])), ], ]; } diff --git a/app/Http/Requests/InvoicesRequest.php b/app/Http/Requests/InvoicesRequest.php index d1a73524..2a085cb3 100644 --- a/app/Http/Requests/InvoicesRequest.php +++ b/app/Http/Requests/InvoicesRequest.php @@ -139,6 +139,13 @@ class InvoicesRequest extends FormRequest return collect($this->except('items', 'taxes')) ->merge([ 'creator_id' => $this->user()->id ?? null, + // Credit notes are minted only by CreditNoteService::create(); + // this payload feeds Invoice::create/update, so a client must never + // be able to declare a document a reversal, re-point its origin, + // or write the reason a reversal was issued for. + 'type' => Invoice::TYPE_INVOICE, + 'related_invoice_id' => null, + 'credit_reason' => null, 'status' => $this->has('invoiceSend') ? Invoice::STATUS_SENT : Invoice::STATUS_DRAFT, 'paid_status' => Invoice::STATUS_UNPAID, 'company_id' => $this->header('company'), diff --git a/app/Http/Requests/PaymentRequest.php b/app/Http/Requests/PaymentRequest.php index 772d2a80..78692859 100644 --- a/app/Http/Requests/PaymentRequest.php +++ b/app/Http/Requests/PaymentRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests; use App\Models\CompanySetting; use App\Models\Customer; +use App\Models\Invoice; +use App\Models\Payment; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -59,6 +61,16 @@ class PaymentRequest extends FormRequest ]; } + $maxAmount = $this->maxPayableAmount(); + + if ($maxAmount !== null) { + $rules['amount'] = [ + 'required', + 'numeric', + 'max:'.$maxAmount, + ]; + } + $companyCurrency = CompanySetting::getSetting('currency', $this->header('company')); $customer = Customer::find($this->customer_id); @@ -74,6 +86,56 @@ class PaymentRequest extends FormRequest return $rules; } + /** + * The message string IS the translation key here, as everywhere else in the + * app: the front end maps it to a localized string. + */ + public function messages(): array + { + return [ + 'amount.max' => 'payment_amount_exceeds_invoice_due_amount', + ]; + } + + /** + * The most that may be paid against the invoice this request names, or null + * when the payment is not attached to an invoice and so is uncapped. + * + * An overpayment used to be accepted and then silently swallowed: + * PaymentService hands the amount to Invoice::subtractInvoicePayment(), + * which drives the balance negative, and Invoice::getInvoiceStatusByAmount() + * returns an empty array for a negative amount, so the status change is + * never applied and the invoice keeps a stale balance. Partial credit notes + * shrink the balance and make that easy to hit, so the cap is enforced here, + * before any of it runs. + * + * On an edit of a payment that already belongs to this same invoice its own + * amount returns to the pool, because PaymentService::update() adds the old + * amount back before subtracting the new one. + */ + protected function maxPayableAmount(): ?int + { + if (! $this->invoice_id) { + return null; + } + + $invoice = Invoice::find($this->invoice_id); + + if (! $invoice) { + return null; + } + + $max = (int) $invoice->due_amount; + + $payment = $this->route('payment'); + + if ($payment instanceof Payment && (int) $payment->invoice_id === (int) $this->invoice_id) { + $max += (int) $payment->amount; + } + + return $max; + } + public function getPaymentPayload() { $company_currency = CompanySetting::getSetting('currency', $this->header('company')); diff --git a/app/Http/Resources/CreditNoteResource.php b/app/Http/Resources/CreditNoteResource.php new file mode 100644 index 00000000..43fce473 --- /dev/null +++ b/app/Http/Resources/CreditNoteResource.php @@ -0,0 +1,44 @@ + $this->when( + $this->relationLoaded('relatedInvoice') && $this->relatedInvoice !== null, + function () { + $related = $this->relatedInvoice; + + return [ + 'id' => $related->id, + 'invoice_number' => $related->invoice_number, + 'invoice_date' => $related->invoice_date, + 'formatted_invoice_date' => $related->formattedInvoiceDate, + 'total' => $related->total, + 'unique_hash' => $related->unique_hash, + ]; + } + ), + ]); + } +} diff --git a/app/Http/Resources/InvoiceResource.php b/app/Http/Resources/InvoiceResource.php index 87a37c02..64aff4b3 100644 --- a/app/Http/Resources/InvoiceResource.php +++ b/app/Http/Resources/InvoiceResource.php @@ -20,6 +20,8 @@ class InvoiceResource extends JsonResource 'due_date' => $this->due_date, 'invoice_number' => $this->invoice_number, 'reference_number' => $this->reference_number, + 'type' => $this->type, + 'related_invoice_id' => $this->related_invoice_id, 'status' => $this->status, 'paid_status' => $this->paid_status, 'tax_per_item' => $this->tax_per_item, @@ -57,6 +59,67 @@ class InvoiceResource extends JsonResource 'sales_tax_type' => $this->sales_tax_type, 'sales_tax_address_type' => $this->sales_tax_address_type, 'overdue' => $this->overdue, + // Credit notes reversing this invoice (minimal reference so the + // UI can flag the invoice as cancelled and link to the storno + // document, mirroring the related_invoice back-link). Emitted only + // where the relation was eager-loaded: probing it per row costs two + // queries each, and this resource is serialized in paginated lists. + 'credit_notes' => $this->when( + $this->relationLoaded('creditNotes') && $this->creditNotes->isNotEmpty(), + fn () => $this->creditNotes->map(fn ($creditNote) => [ + 'id' => $creditNote->id, + 'invoice_number' => $creditNote->invoice_number, + ])->values() + ), + // Why this invoice was credited, if it was. Set only by the + // credit-note flow, never by the invoice form. + 'credit_reason' => $this->credit_reason, + // How much of the invoice has been credited off it, as a positive + // number of cents (credit notes store negative totals), and whether + // that covers the whole document. Both are read off the same loaded + // relation the banner uses, so they cost no extra query. + 'credited_total' => $this->when( + $this->relationLoaded('creditNotes'), + fn () => $this->creditedTotal() + ), + 'credited_status' => $this->when( + $this->relationLoaded('creditNotes'), + function () { + $credited = $this->creditedTotal(); + + if ($credited === 0) { + return 'NONE'; + } + + return $credited === (int) $this->total ? 'FULL' : 'PARTIAL'; + } + ), + // Credited quantity per ORIGINAL line, which is what a partial + // credit form needs to offer the remaining quantities. Emitted only + // when the credit notes' items came along. + 'credited_quantities' => $this->when( + $this->relationLoaded('creditNotes') + && $this->creditNotes->every(fn ($creditNote) => $creditNote->relationLoaded('items')), + function () { + $quantities = []; + + foreach ($this->creditNotes as $creditNote) { + foreach ($creditNote->items as $item) { + if (! $item->source_invoice_item_id) { + continue; + } + + $quantities[$item->source_invoice_item_id] = + ($quantities[$item->source_invoice_item_id] ?? 0) + (float) $item->quantity; + } + } + + // Cast to an object because the item ids are the keys: a + // nested array whose keys are all numeric is re-indexed to a + // list by the resource filter, which would throw the ids away. + return (object) $quantities; + } + ), 'items' => $this->when($this->items()->exists(), function () { return InvoiceItemResource::collection($this->items); }), @@ -80,4 +143,12 @@ class InvoiceResource extends JsonResource }), ]; } + + /** + * Sum of the loaded credit notes as a positive number of cents. + */ + protected function creditedTotal(): int + { + return -(int) $this->creditNotes->sum('total'); + } } diff --git a/app/Mail/SendCreditNoteMail.php b/app/Mail/SendCreditNoteMail.php new file mode 100644 index 00000000..1ef7ec60 --- /dev/null +++ b/app/Mail/SendCreditNoteMail.php @@ -0,0 +1,62 @@ +data = $data; + } + + public function build() + { + $log = EmailLog::create([ + 'from' => $this->data['from'], + 'to' => $this->data['to'], + 'cc' => $this->data['cc'] ?? null, + 'bcc' => $this->data['bcc'] ?? null, + 'subject' => $this->data['subject'], + 'body' => $this->data['body'], + 'mailable_type' => Invoice::class, + 'mailable_id' => $this->data['invoice']['id'], + ]); + + $log->token = Hashids::connection(EmailLog::class)->encode($log->id); + $log->save(); + + $this->data['url'] = route('invoice', ['email_log' => $log->token]); + + $mailContent = $this->from($this->data['from'], config('mail.from.name')) + ->subject($this->data['subject']) + ->markdown('emails.send.credit-note', ['data' => $this->data]); + + if ($this->data['attach']['data']) { + $mailContent->attachData( + $this->data['attach']['data']->output(), + $this->data['invoice']['invoice_number'].'.pdf' + ); + } + + return $mailContent; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index d4481951..0d0f07e8 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -40,6 +40,10 @@ class Invoice extends Model implements HasMedia public const STATUS_PAID = 'PAID'; + public const TYPE_INVOICE = 'INVOICE'; + + public const TYPE_CREDIT_NOTE = 'CREDIT_NOTE'; + protected $dates = [ 'created_at', 'updated_at', @@ -122,6 +126,28 @@ class Invoice extends Model implements HasMedia return $this->belongsTo(User::class, 'creator_id'); } + /** + * The original invoice this credit note reverses (null for normal invoices). + */ + public function relatedInvoice(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'related_invoice_id'); + } + + /** + * Credit notes that reverse this invoice. + */ + public function creditNotes(): HasMany + { + return $this->hasMany(Invoice::class, 'related_invoice_id') + ->where('type', self::TYPE_CREDIT_NOTE); + } + + public function isCreditNote(): bool + { + return $this->type === self::TYPE_CREDIT_NOTE; + } + public function getInvoicePdfUrlAttribute() { return url('/invoices/pdf/'.$this->unique_hash); @@ -138,6 +164,16 @@ class Invoice extends Model implements HasMedia public function getAllowEditAttribute() { + // A credited invoice is immutable: its line item ids anchor the lines of + // every credit note that reverses it. + $hasCreditNotes = $this->relationLoaded('creditNotes') + ? $this->creditNotes->isNotEmpty() + : $this->creditNotes()->exists(); + + if ($hasCreditNotes) { + return false; + } + $retrospective_edit = CompanySetting::getSetting('retrospective_edits', $this->company_id); $allowed = true; diff --git a/app/Policies/CreditNotePolicy.php b/app/Policies/CreditNotePolicy.php new file mode 100644 index 00000000..77d01a78 --- /dev/null +++ b/app/Policies/CreditNotePolicy.php @@ -0,0 +1,31 @@ +hasCompany($invoice->company_id); + } +} diff --git a/app/Policies/InvoicePolicy.php b/app/Policies/InvoicePolicy.php index 72dfa428..2615e05f 100644 --- a/app/Policies/InvoicePolicy.php +++ b/app/Policies/InvoicePolicy.php @@ -61,6 +61,13 @@ class InvoicePolicy */ public function update(User $user, Invoice $invoice): bool { + // A credit note is a reversal document: it is immutable once minted, + // because saving it back through the invoice form would recompute its + // totals positive. + if ($invoice->isCreditNote()) { + return false; + } + if (BouncerFacade::can('edit-invoice', $invoice) && $user->hasCompany($invoice->company_id)) { return $invoice->allow_edit; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cf87366c..fe09eb69 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Models\AiConversation; use App\Policies\AiConversationPolicy; use App\Policies\CompanyPolicy; +use App\Policies\CreditNotePolicy; use App\Policies\CustomerPolicy; use App\Policies\DashboardPolicy; use App\Policies\EstimatePolicy; @@ -153,6 +154,7 @@ class AppServiceProvider extends ServiceProvider Gate::define('view notes', [NotePolicy::class, 'viewNotes']); Gate::define('send invoice', [InvoicePolicy::class, 'send']); + Gate::define('create credit note', [CreditNotePolicy::class, 'create']); Gate::define('send estimate', [EstimatePolicy::class, 'send']); Gate::define('send payment', [PaymentPolicy::class, 'send']); diff --git a/app/Rules/CreditNoteDeletedTogether.php b/app/Rules/CreditNoteDeletedTogether.php new file mode 100644 index 00000000..84d0c998 --- /dev/null +++ b/app/Rules/CreditNoteDeletedTogether.php @@ -0,0 +1,39 @@ + $ids every id in the delete request + */ + public function __construct(private readonly array $ids) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $batch = array_map('intval', array_filter($this->ids, 'is_numeric')); + + $orphaned = Invoice::where('related_invoice_id', (int) $value) + ->where('type', Invoice::TYPE_CREDIT_NOTE) + ->whereNotIn('id', $batch) + ->exists(); + + if ($orphaned) { + $fail('Credit note exists.'); + } + } +} diff --git a/app/Services/Ai/Tools/GetCompanyStatsTool.php b/app/Services/Ai/Tools/GetCompanyStatsTool.php index 69ea6e0e..0de161bf 100644 --- a/app/Services/Ai/Tools/GetCompanyStatsTool.php +++ b/app/Services/Ai/Tools/GetCompanyStatsTool.php @@ -75,8 +75,11 @@ class GetCompanyStatsTool extends AiTool // so rangeFor() is guaranteed to return a non-null pair here. [$start, $end] = $this->rangeFor($period); + // Counts issued invoices only; the total below keeps the credit notes, + // whose negated amounts are what net the sales figure back out. $invoiceCount = Invoice::query() ->where('company_id', $companyId) + ->where('type', Invoice::TYPE_INVOICE) ->whereBetween('invoice_date', [$start, $end]) ->count(); diff --git a/app/Services/Ai/Tools/GetCustomerTool.php b/app/Services/Ai/Tools/GetCustomerTool.php index 803e9397..61c09db3 100644 --- a/app/Services/Ai/Tools/GetCustomerTool.php +++ b/app/Services/Ai/Tools/GetCustomerTool.php @@ -49,9 +49,11 @@ class GetCustomerTool extends AiTool } // Aggregate totals — done with lightweight queries rather than loading every invoice. + // Issued invoices only: a credit note reverses one, it is not another. $invoiceCount = Invoice::query() ->where('company_id', $companyId) ->where('customer_id', $customer->id) + ->where('type', Invoice::TYPE_INVOICE) ->count(); $outstanding = (float) Invoice::query() diff --git a/app/Services/Company/CompanyService.php b/app/Services/Company/CompanyService.php index c96b6e4b..86ecf75d 100644 --- a/app/Services/Company/CompanyService.php +++ b/app/Services/Company/CompanyService.php @@ -204,6 +204,7 @@ class CompanyService 'payment_email_attachment' => 'NO', 'retrospective_edits' => 'allow', 'invoice_number_format' => '{{SERIES:INV}}{{DELIMITER:-}}{{SEQUENCE:6}}', + 'credit_note_number_format' => '{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}', 'estimate_number_format' => '{{SERIES:EST}}{{DELIMITER:-}}{{SEQUENCE:6}}', 'payment_number_format' => '{{SERIES:PAY}}{{DELIMITER:-}}{{SEQUENCE:6}}', 'estimate_set_expiry_date_automatically' => 'YES', diff --git a/app/Services/Document/CreditNoteService.php b/app/Services/Document/CreditNoteService.php new file mode 100644 index 00000000..28129a59 --- /dev/null +++ b/app/Services/Document/CreditNoteService.php @@ -0,0 +1,490 @@ + invoiceItemId, 'quantity' => float], ...]. + * An empty array credits every remaining quantity (a full reversal). + * @param string|null $reason free-text reason stored on the credit note + * + * @throws ValidationException + */ + public function create(Invoice $invoice, array $items = [], ?string $reason = null): Invoice + { + return DB::transaction(function () use ($invoice, $items, $reason) { + // The invoice is re-read under a row lock because every guard below + // is a read-then-write on it: two concurrent credit notes checking + // the same remaining quantity would each be allowed and together + // overdraw the invoice. + $original = Invoice::query() + ->whereKey($invoice->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $original->load(['items.taxes', 'taxes', 'fields', 'creditNotes.items']); + + $snapshot = $this->snapshot($original); + $invoiced = $this->invoicedQuantities($original); + $before = $this->creditedQuantities($original); + $after = $this->targetQuantities($invoiced, $before, $items); + + $paid = (int) $original->payments()->sum('amount'); + $creditedBefore = $this->creditedTotal($original); + + $this->guard($original, $invoiced, $before, $after, $paid, $creditedBefore); + + $amounts = CreditNoteAmounts::forCredit($snapshot, $before, $after); + + if ($creditedBefore + $amounts['total'] > (int) $original->total - $paid) { + throw ValidationException::withMessages([ + 'invoice' => ['credit_amount_exceeds_invoice_balance'], + ]); + } + + $creditNote = $this->persist($original, $amounts, $reason); + + $this->recalculateBalance($original); + + return Invoice::with([ + 'items', + 'items.fields', + 'items.fields.customField', + 'customer', + 'taxes', + 'relatedInvoice', + ])->find($creditNote->id); + }); + } + + /** + * How much of every line of the invoice is still creditable, in hundredths, + * keyed by the original invoice_items.id. + */ + public function remainingQuantities(Invoice $invoice): array + { + $invoice->loadMissing(['items', 'creditNotes.items']); + + $credited = $this->creditedQuantities($invoice); + $remaining = []; + + foreach ($this->invoicedQuantities($invoice) as $itemId => $hundredths) { + $remaining[$itemId] = max(0, $hundredths - ($credited[$itemId] ?? 0)); + } + + return $remaining; + } + + /** + * The amount already credited off this invoice, as a positive number of + * cents (credit notes store negative totals). + */ + public function creditedTotal(Invoice $invoice): int + { + return -(int) $invoice->creditNotes()->sum('total'); + } + + /** + * Recompute the invoice's balance and status from what it was paid and what + * has been credited off it. + * + * This deliberately does not live in {@see Invoice::getInvoiceStatusByAmount()}: + * that method is called from the payment flow, and PaymentService::create() + * adjusts the invoice BEFORE the Payment row is written, so a rule derived + * from payments()->sum() would read a stale total there and settle the + * invoice one payment short. This method only runs when a credit note is + * created or deleted, where every payment and every credit note involved is + * already persisted. + */ + public function recalculateBalance(Invoice $invoice): void + { + $paid = (int) $invoice->payments()->sum('amount'); + $credited = $this->creditedTotal($invoice); + $due = max(0, (int) $invoice->total - $paid - $credited); + + $invoice->due_amount = $due; + $invoice->base_due_amount = (int) round($due * $invoice->exchange_rate); + + if ($due === 0) { + // Nothing is owed any more, whether that came from money or from a + // reversal, so the invoice must drop out of every "awaiting + // payment" view. Which of the two settled it is carried by the + // creditNotes relation, not by the status. + $invoice->status = Invoice::STATUS_COMPLETED; + $invoice->paid_status = Invoice::STATUS_PAID; + $invoice->overdue = false; + } else { + $invoice->status = $invoice->getPreviousStatus(); + $invoice->paid_status = $paid > 0 + ? Invoice::STATUS_PARTIALLY_PAID + : Invoice::STATUS_UNPAID; + } + + $invoice->save(); + } + + /** + * Enforce the credit-note invariants, in the order that produces the most + * specific message for each situation. + * + * @throws ValidationException + */ + protected function guard(Invoice $invoice, array $invoiced, array $before, array $after, int $paid, int $creditedBefore): void + { + $remaining = 0; + + foreach ($invoiced as $itemId => $hundredths) { + $remaining += max(0, $hundredths - ($before[$itemId] ?? 0)); + } + + if ($remaining === 0 || (int) $invoice->total - $paid - $creditedBefore <= 0) { + throw ValidationException::withMessages([ + 'invoice' => ['invoice_already_fully_credited'], + ]); + } + + foreach ($after as $itemId => $hundredths) { + if ($hundredths > ($invoiced[$itemId] ?? 0)) { + throw ValidationException::withMessages([ + 'invoice' => ['credit_quantity_exceeds_remaining'], + ]); + } + } + + foreach ($after as $itemId => $hundredths) { + if ($hundredths > ($before[$itemId] ?? 0)) { + return; + } + } + + throw ValidationException::withMessages([ + 'invoice' => ['credit_note_must_credit_something'], + ]); + } + + /** + * Write the credit-note document, its lines and its taxes. + */ + protected function persist(Invoice $invoice, array $amounts, ?string $reason): Invoice + { + // A fresh SerialNumberService per document, as everywhere else in the + // app: it is a stateful builder that keeps the number it computed, so a + // shared instance would hand the same number to the next credit note. + $serial = (new SerialNumberService) + ->setModel(new Invoice) + ->setCompany($invoice->company_id) + ->setCustomer($invoice->customer_id) + ->setSettingKey('credit_note_number_format') + ->setSequenceScope(['type' => Invoice::TYPE_CREDIT_NOTE]) + ->setNextNumbers(); + + // exchange_rate is a float multiplier, not a currency amount. The base_* + // fields are pro-rated from the original's stored base_* integers by the + // calculator, so they are negated as-is rather than recomputed through + // the rate, which would re-round a decision already made. + $creditNote = Invoice::create([ + 'creator_id' => auth()->id(), + 'type' => Invoice::TYPE_CREDIT_NOTE, + 'related_invoice_id' => $invoice->id, + 'credit_reason' => $reason, + 'invoice_date' => Carbon::now()->format('Y-m-d'), + // A reversal is never owed, so it has no due date at all. Leaving it + // null also keeps the credit note out of every due/aging query. + 'due_date' => null, + 'invoice_number' => $serial->getNextNumber(), + 'sequence_number' => $serial->nextSequenceNumber, + 'customer_sequence_number' => $serial->nextCustomerSequenceNumber, + 'reference_number' => $invoice->invoice_number, + 'customer_id' => $invoice->customer_id, + 'company_id' => $invoice->company_id, + 'template_name' => $invoice->template_name, + // A credit note gets the ordinary create-review-send lifecycle: born + // DRAFT so the Send affordances appear, promoted to SENT by send(). + // Nothing is ever owed on it, so paid_status/due_amount below keep + // it out of the payment flows regardless of status. + 'status' => Invoice::STATUS_DRAFT, + // The credit note is born settled: it exists to pair with the + // original invoice, nothing is ever owed on it, so it must never + // surface as an open (negative) balance in any due/aging view. + 'paid_status' => Invoice::STATUS_PAID, + 'sub_total' => -$amounts['sub_total'], + 'discount' => $invoice->discount, + 'discount_type' => $invoice->discount_type, + 'discount_val' => -$amounts['discount_val'], + 'total' => -$amounts['total'], + 'due_amount' => 0, + 'tax_per_item' => $invoice->tax_per_item, + 'discount_per_item' => $invoice->discount_per_item, + 'tax' => -$amounts['tax'], + 'tax_included' => $invoice->tax_included, + 'notes' => $invoice->notes, + 'exchange_rate' => $invoice->exchange_rate, + 'base_discount_val' => -$amounts['base_discount_val'], + 'base_sub_total' => -$amounts['base_sub_total'], + 'base_total' => -$amounts['base_total'], + 'base_tax' => -$amounts['base_tax'], + 'base_due_amount' => 0, + 'currency_id' => $invoice->currency_id, + 'sales_tax_type' => $invoice->sales_tax_type, + 'sales_tax_address_type' => $invoice->sales_tax_address_type, + ]); + + $creditNote->unique_hash = Hashids::connection(Invoice::class)->encode($creditNote->id); + $creditNote->save(); + + // recompute: false throughout. The calculator has already decided every + // cent of this document, and re-deriving the line totals or the base_* + // columns from price * quantity * rate would round a second time and + // break the telescoping invariant by a cent. + $this->documentItemService->createItems( + $creditNote, + $this->creditItems($invoice, $amounts['items']), + recompute: false + ); + + if ($invoice->tax_per_item !== 'YES' && ! empty($amounts['taxes'])) { + $this->documentItemService->createTaxes( + $creditNote, + $this->creditTaxes($invoice->taxes, $amounts['taxes']), + recompute: false + ); + } + + if ($invoice->fields()->exists()) { + $customFields = []; + + foreach ($invoice->fields as $field) { + $customFields[] = [ + 'id' => $field->custom_field_id, + 'value' => $field->defaultAnswer, + ]; + } + + $creditNote->addCustomFields($customFields); + } + + return $creditNote; + } + + /** + * Build the credit-note line payloads: negated amounts from the calculator, + * descriptive fields copied from the line each one credits. + */ + protected function creditItems(Invoice $invoice, array $lines): array + { + $sourceItems = $invoice->items->keyBy('id'); + $items = []; + + foreach ($lines as $sourceId => $line) { + /** @var InvoiceItem $source */ + $source = $sourceItems->get($sourceId); + + if (! $source) { + continue; + } + + $items[] = [ + 'source_invoice_item_id' => $line['source_invoice_item_id'], + 'item_id' => $source->item_id, + 'name' => $source->name, + 'description' => $source->description, + 'unit_name' => $source->unit_name, + 'discount_type' => $source->discount_type, + 'discount' => $source->discount, + // The quantity stays positive: what makes the line a credit is + // the negative price and total, exactly as a full reversal does. + 'quantity' => $line['quantity'], + 'price' => -$line['price'], + 'base_price' => -$line['base_price'], + 'discount_val' => -$line['discount_val'], + 'tax' => -$line['tax'], + 'total' => -$line['total'], + 'base_discount_val' => -$line['base_discount_val'], + 'base_tax' => -$line['base_tax'], + 'base_total' => -$line['base_total'], + 'taxes' => $this->creditTaxes($source->taxes, $line['taxes']), + ]; + } + + return $items; + } + + /** + * Build tax-row payloads: negated amounts from the calculator, descriptive + * fields copied from the tax row each one reverses. + */ + protected function creditTaxes(Collection $sourceTaxes, array $amounts): array + { + $byId = $sourceTaxes->keyBy('id'); + $taxes = []; + + foreach ($amounts as $taxId => $amount) { + $source = $byId->get($taxId); + + if (! $source) { + continue; + } + + $taxes[] = [ + 'tax_type_id' => $source->tax_type_id, + 'item_id' => $source->item_id, + 'name' => $source->name, + 'percent' => $source->percent, + 'compound_tax' => $source->compound_tax, + 'calculation_type' => $source->calculation_type, + 'fixed_amount' => $source->fixed_amount, + 'amount' => -$amount['amount'], + 'base_amount' => -$amount['base_amount'], + ]; + } + + return $taxes; + } + + /** + * The original invoice's stored figures, in the shape the calculator reads. + */ + protected function snapshot(Invoice $invoice): array + { + $items = []; + + foreach ($invoice->items as $item) { + $items[$item->id] = [ + 'price' => (int) $item->price, + 'quantity' => (float) $item->quantity, + 'discount_val' => (int) $item->discount_val, + 'tax' => (int) $item->tax, + 'total' => (int) $item->total, + 'base_price' => (int) $item->base_price, + 'base_discount_val' => (int) $item->base_discount_val, + 'base_tax' => (int) $item->base_tax, + 'base_total' => (int) $item->base_total, + 'taxes' => $this->snapshotTaxes($item->taxes), + ]; + } + + return [ + 'sub_total' => (int) $invoice->sub_total, + 'discount_val' => (int) $invoice->discount_val, + 'tax' => (int) $invoice->tax, + 'total' => (int) $invoice->total, + 'base_sub_total' => (int) $invoice->base_sub_total, + 'base_discount_val' => (int) $invoice->base_discount_val, + 'base_tax' => (int) $invoice->base_tax, + 'base_total' => (int) $invoice->base_total, + 'discount_per_item' => $invoice->discount_per_item, + 'tax_per_item' => $invoice->tax_per_item, + 'tax_included' => (bool) $invoice->tax_included, + 'items' => $items, + 'taxes' => $this->snapshotTaxes($invoice->taxes), + ]; + } + + protected function snapshotTaxes(Collection $taxes): array + { + $snapshot = []; + + foreach ($taxes as $tax) { + $snapshot[$tax->id] = [ + 'amount' => (int) $tax->amount, + 'base_amount' => (int) $tax->base_amount, + ]; + } + + return $snapshot; + } + + /** + * The invoiced quantity of every line, in hundredths. + */ + protected function invoicedQuantities(Invoice $invoice): array + { + $quantities = []; + + foreach ($invoice->items as $item) { + $quantities[$item->id] = CreditNoteAmounts::toHundredths($item->quantity); + } + + return $quantities; + } + + /** + * The already-credited quantity of every line, in hundredths, read off the + * credit notes that still exist. Deleting a credit note therefore gives its + * quantities back without any separate bookkeeping. + */ + protected function creditedQuantities(Invoice $invoice): array + { + $quantities = []; + + foreach ($invoice->creditNotes as $creditNote) { + foreach ($creditNote->items as $item) { + if (! $item->source_invoice_item_id) { + continue; + } + + $quantities[$item->source_invoice_item_id] = + ($quantities[$item->source_invoice_item_id] ?? 0) + + CreditNoteAmounts::toHundredths($item->quantity); + } + } + + return $quantities; + } + + /** + * The cumulative credited quantities this credit note leaves behind: the + * requested quantities on top of what was credited before, or every + * invoiced quantity when nothing specific was requested (full reversal). + */ + protected function targetQuantities(array $invoiced, array $before, array $items): array + { + if (empty($items)) { + return $invoiced; + } + + $after = $before; + + foreach ($items as $line) { + $itemId = (int) $line['id']; + + $after[$itemId] = ($after[$itemId] ?? 0) + CreditNoteAmounts::toHundredths($line['quantity']); + } + + return $after; + } +} diff --git a/app/Services/Document/DocumentItemService.php b/app/Services/Document/DocumentItemService.php index 8ec3593e..1c9bdd32 100644 --- a/app/Services/Document/DocumentItemService.php +++ b/app/Services/Document/DocumentItemService.php @@ -7,20 +7,50 @@ use Illuminate\Database\Eloquent\Model; class DocumentItemService { - public function createItems(Model $document, array $items): void + /** + * Company-currency columns and the column each one is derived from. + */ + private const BASE_FIELDS = [ + 'base_price' => 'price', + 'base_discount_val' => 'discount_val', + 'base_tax' => 'tax', + 'base_total' => 'total', + ]; + + /** + * Persist the line items of a document. + * + * $recompute = false is for callers that have already decided every cent of + * the document and must not have it decided again: partial credit notes + * carry pro-rated integers from CreditNoteAmounts, and re-deriving the line + * total from price * quantity or the base_* columns from the exchange rate + * rounds a second time, drifts a cent, and breaks the telescoping invariant + * that makes a chain of partial credits add back up to the invoice. Such a + * caller still gets a base_* value derived here for any it did not supply. + */ + public function createItems(Model $document, array $items, bool $recompute = true): void { $exchangeRate = $document->exchange_rate; foreach ($items as $item) { $item['company_id'] = $document->company_id; $item['exchange_rate'] = $exchangeRate; - // Recompute the item total from price/quantity so a tampered item - // total can't desync from the recomputed document totals (GHSA-8c69). - $item['total'] = DocumentTotals::itemTotal($item, $document->discount_per_item === 'YES'); - $item['base_price'] = $item['price'] * $exchangeRate; - $item['base_discount_val'] = $item['discount_val'] * $exchangeRate; - $item['base_tax'] = $item['tax'] * $exchangeRate; - $item['base_total'] = $item['total'] * $exchangeRate; + + if ($recompute) { + // Recompute the item total from price/quantity so a tampered item + // total can't desync from the recomputed document totals (GHSA-8c69). + $item['total'] = DocumentTotals::itemTotal($item, $document->discount_per_item === 'YES'); + $item['base_price'] = $item['price'] * $exchangeRate; + $item['base_discount_val'] = $item['discount_val'] * $exchangeRate; + $item['base_tax'] = $item['tax'] * $exchangeRate; + $item['base_total'] = $item['total'] * $exchangeRate; + } else { + foreach (self::BASE_FIELDS as $baseField => $field) { + if (! array_key_exists($baseField, $item)) { + $item[$baseField] = ($item[$field] ?? 0) * $exchangeRate; + } + } + } if (array_key_exists('recurring_invoice_id', $item)) { unset($item['recurring_invoice_id']); @@ -32,9 +62,12 @@ class DocumentItemService foreach ($item['taxes'] as $tax) { $tax['company_id'] = $document->company_id; $tax['exchange_rate'] = $document->exchange_rate; - $tax['base_amount'] = $tax['amount'] * $exchangeRate; $tax['currency_id'] = $document->currency_id; + if ($recompute || ! array_key_exists('base_amount', $tax)) { + $tax['base_amount'] = $tax['amount'] * $exchangeRate; + } + if (gettype($tax['amount']) !== 'NULL') { if (array_key_exists('recurring_invoice_id', $tax)) { unset($tax['recurring_invoice_id']); @@ -51,16 +84,25 @@ class DocumentItemService } } - public function createTaxes(Model $document, array $taxes): void + /** + * Persist the document-level tax rows. + * + * $recompute = false has the same meaning as in {@see createItems()}: the + * supplied base_amount is the caller's pro-rated integer and is kept as-is. + */ + public function createTaxes(Model $document, array $taxes, bool $recompute = true): void { $exchangeRate = $document->exchange_rate; foreach ($taxes as $tax) { $tax['company_id'] = $document->company_id; $tax['exchange_rate'] = $document->exchange_rate; - $tax['base_amount'] = $tax['amount'] * $exchangeRate; $tax['currency_id'] = $document->currency_id; + if ($recompute || ! array_key_exists('base_amount', $tax)) { + $tax['base_amount'] = $tax['amount'] * $exchangeRate; + } + if (gettype($tax['amount']) !== 'NULL') { if (array_key_exists('recurring_invoice_id', $tax)) { unset($tax['recurring_invoice_id']); diff --git a/app/Services/Document/EstimateService.php b/app/Services/Document/EstimateService.php index 879f9069..057a2b10 100644 --- a/app/Services/Document/EstimateService.php +++ b/app/Services/Document/EstimateService.php @@ -316,6 +316,7 @@ class EstimateService ->setModel(new Invoice) ->setCompany($estimate->company_id) ->setCustomer($estimate->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setNextNumbers(); $templateName = $estimate->getInvoiceTemplateName(); diff --git a/app/Services/Document/InvoiceService.php b/app/Services/Document/InvoiceService.php index d9d6051a..46f9a882 100644 --- a/app/Services/Document/InvoiceService.php +++ b/app/Services/Document/InvoiceService.php @@ -5,6 +5,7 @@ namespace App\Services\Document; use App; use App\Facades\Hashids; use App\Facades\Pdf; +use App\Mail\SendCreditNoteMail; use App\Mail\SendInvoiceMail; use App\Models\Company; use App\Models\CompanySetting; @@ -24,6 +25,7 @@ class InvoiceService { public function __construct( private readonly DocumentItemService $documentItemService, + private readonly CreditNoteService $creditNoteService, ) {} public function create(Request $request): Invoice @@ -40,6 +42,7 @@ class InvoiceService ->setModel($invoice) ->setCompany($invoice->company_id) ->setCustomer($invoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setNextNumbers(); $invoice->sequence_number = $serial->nextSequenceNumber; @@ -69,6 +72,7 @@ class InvoiceService 'items.fields.customField', 'customer', 'taxes', + 'creditNotes', ])->find($invoice->id); } @@ -81,6 +85,7 @@ class InvoiceService ->setModel($invoice) ->setCompany($invoice->company_id) ->setCustomer($request->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setModelObject($invoice->id) ->setNextNumbers(); @@ -151,11 +156,18 @@ class InvoiceService 'items.fields.customField', 'customer', 'taxes', + 'creditNotes', ])->find($invoice->id); } public function delete(Collection $ids): bool { + // Invoices that lose a credit note in this batch and survive it. Their + // balances are recomputed once, after every deletion has landed, so a + // batch deleting several credit notes of the same invoice settles on + // the right figure instead of one per deleted document. + $creditedInvoiceIds = []; + foreach ($ids as $id) { $invoice = Invoice::find($id); @@ -163,9 +175,32 @@ class InvoiceService $invoice->transactions()->delete(); } + if ($invoice->isCreditNote() && $invoice->related_invoice_id && ! $ids->contains($invoice->related_invoice_id)) { + $creditedInvoiceIds[$invoice->related_invoice_id] = $invoice->related_invoice_id; + } + $invoice->delete(); } + // There is no DB-level foreign key on related_invoice_id by convention, + // so the cascade lives here: nothing that survives the batch may keep + // pointing at a row that just went away. + Invoice::whereIn('related_invoice_id', $ids)->update(['related_invoice_id' => null]); + + // Deleting a credit note gives back the amount it had credited off its + // original invoice (mirror of the create-side adjustment; same symmetry + // PR #536 implemented). The balance is recomputed from the payments and + // the credit notes that remain rather than restored from a snapshot, so + // it is exact whether the invoice was partly paid, partly credited, or + // both. + foreach ($creditedInvoiceIds as $creditedInvoiceId) { + $original = Invoice::find($creditedInvoiceId); + + if ($original) { + $this->creditNoteService->recalculateBalance($original); + } + } + return true; } @@ -204,7 +239,11 @@ class InvoiceService if (! empty($data['bcc'])) { $mail->bcc($data['bcc']); } - $mail->send(new SendInvoiceMail($data)); + // A credit note travels through the same send channel as the invoice it + // reverses; only the template (and its EmailLog entry) differs. + $mail->send($invoice->isCreditNote() + ? new SendCreditNoteMail($data) + : new SendInvoiceMail($data)); if ($invoice->status == Invoice::STATUS_DRAFT) { $invoice->status = Invoice::STATUS_SENT; @@ -240,6 +279,11 @@ class InvoiceService $invoiceTemplate = Invoice::find($invoice->id)->template_name; + // Cheap either way: relatedInvoice is null for regular invoices and + // creditNotes is empty for credit notes. Eager-loaded here so the + // invoice templates can reference the paired document. + $invoice->loadMissing(['relatedInvoice', 'creditNotes']); + $company = Company::find($invoice->company_id); $locale = CompanySetting::getSetting('language', $company->id); $customFields = CustomField::where('model_type', 'Item')->get(); @@ -266,7 +310,7 @@ class InvoiceService } return Pdf::loadView($templatePath, PdfMetadata::forDocument( - __('pdf_invoice_label'), + __($invoice->isCreditNote() ? 'pdf_credit_note_label' : 'pdf_invoice_label'), $invoice->invoice_number, $company, )); @@ -280,6 +324,7 @@ class InvoiceService ->setModel($invoice) ->setCompany($invoice->company_id) ->setCustomer($invoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setNextNumbers(); $dueDate = null; diff --git a/app/Services/Document/PaymentService.php b/app/Services/Document/PaymentService.php index 543f6be2..c32fdb01 100644 --- a/app/Services/Document/PaymentService.php +++ b/app/Services/Document/PaymentService.php @@ -119,11 +119,19 @@ class PaymentService $invoice = Invoice::find($payment->invoice_id); $invoice->due_amount = ((int) $invoice->due_amount + (int) $payment->amount); - if ($invoice->due_amount == $invoice->total) { - $invoice->paid_status = Invoice::STATUS_UNPAID; - } else { - $invoice->paid_status = Invoice::STATUS_PARTIALLY_PAID; - } + // The paid status follows the payments that remain, not the + // balance. On an uncredited invoice the two rules agree exactly + // (the restored due equals the total precisely when no payment + // is left), but on a credited one the due amount is already net + // of its credit notes, so comparing it with the total would call + // an invoice unpaid while money is still recorded against it. + $remainingPaid = (int) $invoice->payments() + ->whereKeyNot($payment->getKey()) + ->sum('amount'); + + $invoice->paid_status = $remainingPaid > 0 + ? Invoice::STATUS_PARTIALLY_PAID + : Invoice::STATUS_UNPAID; $invoice->status = $invoice->getPreviousStatus(); $invoice->save(); diff --git a/app/Services/Document/RecurringInvoiceService.php b/app/Services/Document/RecurringInvoiceService.php index 14cff702..661523aa 100644 --- a/app/Services/Document/RecurringInvoiceService.php +++ b/app/Services/Document/RecurringInvoiceService.php @@ -130,6 +130,7 @@ class RecurringInvoiceService ->setModel(new Invoice) ->setCompany($recurringInvoice->company_id) ->setCustomer($recurringInvoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setNextNumbers(); $days = intval(CompanySetting::getSetting('invoice_due_date_days', $recurringInvoice->company_id)); diff --git a/app/Services/Document/SerialNumberService.php b/app/Services/Document/SerialNumberService.php index 226b45ca..ab9519e7 100644 --- a/app/Services/Document/SerialNumberService.php +++ b/app/Services/Document/SerialNumberService.php @@ -17,6 +17,10 @@ class SerialNumberService private $company; + private $settingKey; + + private $sequenceScope = []; + /** * @var string */ @@ -72,13 +76,45 @@ class SerialNumberService return $this; } + /** + * Override the company setting the number format is read from. + * + * Without this the key is derived from the model class name, which is not + * enough for documents that share a table (credit notes are Invoice rows + * but carry their own format). + * + * @return $this + */ + public function setSettingKey(string $key) + { + $this->settingKey = $key; + + return $this; + } + + /** + * Restrict the sequence lookups to a subset of the model's rows. + * + * Takes column => value constraints that are applied on top of the company + * (and customer) filters, so documents sharing a table can each keep an + * independent, gapless sequence. + * + * @return $this + */ + public function setSequenceScope(array $constraints) + { + $this->sequenceScope = $constraints; + + return $this; + } + /** * @return string */ public function getNextNumber($data = null) { $modelName = strtolower(class_basename($this->model)); - $settingKey = $modelName.'_number_format'; + $settingKey = $this->settingKey ?: $modelName.'_number_format'; $companyId = $this->company; if (request()->has('format')) { @@ -116,11 +152,15 @@ class SerialNumberService { $companyId = $this->company; - $last = $this->model::orderBy('sequence_number', 'desc') + $query = $this->model::orderBy('sequence_number', 'desc') ->where('company_id', $companyId) - ->where('sequence_number', '<>', null) - ->take(1) - ->first(); + ->where('sequence_number', '<>', null); + + foreach ($this->sequenceScope as $column => $value) { + $query->where($column, $value); + } + + $last = $query->take(1)->first(); $this->nextSequenceNumber = ($last) ? $last->sequence_number + 1 : 1; @@ -134,12 +174,16 @@ class SerialNumberService { $customer_id = ($this->customer) ? $this->customer->id : 1; - $last = $this->model::orderBy('customer_sequence_number', 'desc') + $query = $this->model::orderBy('customer_sequence_number', 'desc') ->where('company_id', $this->company) ->where('customer_id', $customer_id) - ->where('customer_sequence_number', '<>', null) - ->take(1) - ->first(); + ->where('customer_sequence_number', '<>', null); + + foreach ($this->sequenceScope as $column => $value) { + $query->where($column, $value); + } + + $last = $query->take(1)->first(); $this->nextCustomerSequenceNumber = ($last) ? $last->customer_sequence_number + 1 : 1; diff --git a/app/Support/CreditNoteAmounts.php b/app/Support/CreditNoteAmounts.php new file mode 100644 index 00000000..9af3859a --- /dev/null +++ b/app/Support/CreditNoteAmounts.php @@ -0,0 +1,312 @@ + after) = cumulative(after) - cumulative(before) + * + * where `cumulative()` answers a single question ("what would the credit be if + * exactly these quantities had been credited, in one go?") and always derives + * that answer from the ORIGINAL invoice's stored figures, never from previous + * credit notes. Summing a chain of credits telescopes: + * + * (c1 - c0) + (c2 - c1) + (c3 - c2) = c3 - c0 = c3 + * + * so crediting a 3-unit line as 1+1+1 lands on exactly the same cents as + * crediting it 3-at-once, in every field, and a fully credited invoice nets to + * zero to the cent. The order the chunks are credited in does not matter + * either, because only the endpoint quantities enter the arithmetic. + * + * Conventions: + * + * - **Integer minor units.** Every money field is an integer number of cents, + * as stored on the invoice, and every value returned here is an integer. + * - **Quantities in hundredths.** `invoice_items.quantity` is `decimal(15,2)`, + * so quantities are carried as integer hundredths internally (2.50 -> 250) + * and only converted back to a decimal at the boundary. Float quantities + * never take part in a comparison or an accumulation. + * - **Rounding.** `iround()` is `(int) round((float) $x)`, half away from zero, + * character for character the rule used by {@see DocumentTotals}, so a full + * credit lands on the same cents the invoice was written with. + * + * Two deliberate decisions in the tax handling: + * + * - **Fixed-amount tax rows are pro-rated exactly like percentage ones.** It is + * tempting to carry a flat 1.50 handling tax across in full on the first + * credit note, since it is not a function of the amount. But then crediting a + * 3-unit line in three chunks charges back 1.50 three times, and the credited + * tax exceeds the tax the invoice ever collected. Pro-rating is what keeps + * the telescoping identity true for every tax row, which is the property the + * books balance on. + * - **Percentage tax rows are pro-rated from the STORED amount, not + * re-derived.** The stored `taxes.amount` is what the customer was invoiced + * and what was reported; re-running `percent * base` on the credited slice + * re-does a rounding decision that was already made and can land a cent away + * from it, leaving a residue on a fully credited invoice. The stored integers + * are the ground truth. + * + * Base (company-currency) figures follow the same rule for the same reason: + * they are pro-rated from the original's stored `base_*` integers rather than + * recomputed through the exchange rate, so a full credit reproduces the stored + * base amounts exactly instead of re-rounding the multiplication. + * + * This class is deliberately Eloquent-free and static: it takes a plain-array + * snapshot of the original invoice and returns plain arrays. Negating the + * magnitudes and persisting them is the calling service's job. + */ +class CreditNoteAmounts +{ + /** + * Convert a decimal quantity to integer hundredths (2.50 -> 250). + */ + public static function toHundredths($quantity): int + { + return (int) round((float) $quantity * 100); + } + + /** + * Convert integer hundredths back to a decimal quantity (250 -> 2.5). + */ + public static function fromHundredths(int $hundredths): float + { + return $hundredths / 100; + } + + /** + * Compute the cumulative credit at the given credited quantities. + * + * The result is snapshot-shaped: it carries exactly the keys of the + * snapshot minus the three configuration flags, so + * `cumulative($snapshot, )` reproduces the snapshot field + * for field, and `cumulative($snapshot, )` is all zeros. + * + * @param array $snapshot the ORIGINAL invoice's stored figures: + * sub_total, discount_val, tax, total, base_sub_total, + * base_discount_val, base_tax, base_total, + * discount_per_item ('YES'|'NO'), tax_per_item ('YES'|'NO'), + * tax_included (bool), + * items keyed by invoice_items.id, each with + * price, quantity (decimal), discount_val, tax, total, + * base_price, base_discount_val, base_tax, base_total and + * taxes keyed by taxes.id, each with amount + base_amount, + * taxes: document-level tax rows keyed by taxes.id + * @param array $creditedHundredths credited quantity in hundredths, keyed by + * invoice_items.id; missing items count as zero + * @return array{sub_total:int, discount_val:int, tax:int, total:int, base_sub_total:int, + * base_discount_val:int, base_tax:int, base_total:int, items:array, taxes:array} + */ + public static function cumulative(array $snapshot, array $creditedHundredths): array + { + $perItemDiscount = self::isYes($snapshot['discount_per_item'] ?? 'NO'); + $perItemTax = self::isYes($snapshot['tax_per_item'] ?? 'NO'); + $taxIncluded = (bool) ($snapshot['tax_included'] ?? false); + + $items = []; + $subTotal = 0; + $itemTaxTotal = 0; + + foreach ($snapshot['items'] ?? [] as $itemId => $item) { + $originalHundredths = self::toHundredths($item['quantity'] ?? 0); + $credited = (int) ($creditedHundredths[$itemId] ?? 0); + $ratio = $originalHundredths === 0 ? 0.0 : $credited / $originalHundredths; + + $amount = self::iround((float) ($item['price'] ?? 0) * $credited / 100); + $discount = $perItemDiscount + ? self::iround((float) ($item['discount_val'] ?? 0) * $ratio) + : 0; + + $taxes = []; + $itemTax = 0; + + foreach ($item['taxes'] ?? [] as $taxId => $tax) { + $taxAmount = self::iround((float) ($tax['amount'] ?? 0) * $ratio); + + $taxes[$taxId] = [ + 'amount' => $taxAmount, + 'base_amount' => self::scale($tax['base_amount'] ?? 0, $taxAmount, $tax['amount'] ?? 0), + ]; + + $itemTax += $taxAmount; + } + + $total = $amount - $discount; + + $items[$itemId] = [ + 'price' => (int) ($item['price'] ?? 0), + 'quantity' => self::fromHundredths($credited), + 'discount_val' => $discount, + 'tax' => $itemTax, + 'total' => $total, + 'base_price' => (int) ($item['base_price'] ?? 0), + 'base_discount_val' => self::scale($item['base_discount_val'] ?? 0, $discount, $item['discount_val'] ?? 0), + 'base_tax' => self::scale($item['base_tax'] ?? 0, $itemTax, $item['tax'] ?? 0), + 'base_total' => self::scale($item['base_total'] ?? 0, $total, $item['total'] ?? 0), + 'taxes' => $taxes, + ]; + + $subTotal += $total; + $itemTaxTotal += $itemTax; + } + + $originalSubTotal = (int) ($snapshot['sub_total'] ?? 0); + $ratio = $originalSubTotal === 0 ? 0.0 : $subTotal / $originalSubTotal; + + $discountVal = self::iround((float) ($snapshot['discount_val'] ?? 0) * $ratio); + + $documentTaxes = []; + $documentTaxTotal = 0; + + foreach ($snapshot['taxes'] ?? [] as $taxId => $tax) { + $taxAmount = self::iround((float) ($tax['amount'] ?? 0) * $ratio); + + $documentTaxes[$taxId] = [ + 'amount' => $taxAmount, + 'base_amount' => self::scale($tax['base_amount'] ?? 0, $taxAmount, $tax['amount'] ?? 0), + ]; + + $documentTaxTotal += $taxAmount; + } + + $tax = $perItemTax ? $itemTaxTotal : $documentTaxTotal; + $total = $taxIncluded ? $subTotal - $discountVal : $subTotal - $discountVal + $tax; + + return [ + 'sub_total' => $subTotal, + 'discount_val' => $discountVal, + 'tax' => $tax, + 'total' => $total, + 'base_sub_total' => self::scale($snapshot['base_sub_total'] ?? 0, $subTotal, $originalSubTotal), + 'base_discount_val' => self::scale($snapshot['base_discount_val'] ?? 0, $discountVal, $snapshot['discount_val'] ?? 0), + 'base_tax' => self::scale($snapshot['base_tax'] ?? 0, $tax, $snapshot['tax'] ?? 0), + 'base_total' => self::scale($snapshot['base_total'] ?? 0, $total, $snapshot['total'] ?? 0), + 'items' => $items, + 'taxes' => $documentTaxes, + ]; + } + + /** + * Compute one credit note: the field-wise difference between the cumulative + * credit at $after and the cumulative credit at $before. + * + * Everything comes back as a POSITIVE magnitude. The caller negates what it + * persists (a credit note stores negative prices, totals and tax amounts) + * and copies the descriptive fields (item name, description, unit, tax + * names, percentages, tax_type_id) from the original rows itself; only + * amounts and quantities are decided here. + * + * `items` is keyed by the ORIGINAL invoice item id and only contains lines + * whose credited quantity actually moved; a line credited by zero produces + * no credit-note row. + * + * @param array $snapshot the ORIGINAL invoice's stored figures, see {@see cumulative()} + * @param array $before already-credited quantities in hundredths, keyed by invoice_items.id + * @param array $after total credited quantities after this credit note, same keying + * @return array{sub_total:int, discount_val:int, tax:int, total:int, base_sub_total:int, + * base_discount_val:int, base_tax:int, base_total:int, + * items:array}>, + * taxes:array} + */ + public static function forCredit(array $snapshot, array $before, array $after): array + { + $from = self::cumulative($snapshot, $before); + $to = self::cumulative($snapshot, $after); + + $items = []; + + foreach ($to['items'] as $itemId => $item) { + $previous = $from['items'][$itemId]; + + $hundredths = self::toHundredths($item['quantity']) - self::toHundredths($previous['quantity']); + + if ($hundredths === 0) { + continue; + } + + $taxes = []; + + foreach ($item['taxes'] as $taxId => $tax) { + $taxes[$taxId] = [ + 'amount' => $tax['amount'] - $previous['taxes'][$taxId]['amount'], + 'base_amount' => $tax['base_amount'] - $previous['taxes'][$taxId]['base_amount'], + ]; + } + + $items[$itemId] = [ + 'source_invoice_item_id' => (int) $itemId, + 'price' => $item['price'], + 'base_price' => $item['base_price'], + 'quantity' => self::fromHundredths($hundredths), + 'discount_val' => $item['discount_val'] - $previous['discount_val'], + 'tax' => $item['tax'] - $previous['tax'], + 'total' => $item['total'] - $previous['total'], + 'base_discount_val' => $item['base_discount_val'] - $previous['base_discount_val'], + 'base_tax' => $item['base_tax'] - $previous['base_tax'], + 'base_total' => $item['base_total'] - $previous['base_total'], + 'taxes' => $taxes, + ]; + } + + $taxes = []; + + foreach ($to['taxes'] as $taxId => $tax) { + $taxes[$taxId] = [ + 'amount' => $tax['amount'] - $from['taxes'][$taxId]['amount'], + 'base_amount' => $tax['base_amount'] - $from['taxes'][$taxId]['base_amount'], + ]; + } + + return [ + 'sub_total' => $to['sub_total'] - $from['sub_total'], + 'discount_val' => $to['discount_val'] - $from['discount_val'], + 'tax' => $to['tax'] - $from['tax'], + 'total' => $to['total'] - $from['total'], + 'base_sub_total' => $to['base_sub_total'] - $from['base_sub_total'], + 'base_discount_val' => $to['base_discount_val'] - $from['base_discount_val'], + 'base_tax' => $to['base_tax'] - $from['base_tax'], + 'base_total' => $to['base_total'] - $from['base_total'], + 'items' => $items, + 'taxes' => $taxes, + ]; + } + + /** + * Pro-rate a stored base amount by the share its non-base counterpart got. + * + * A zero denominator means the original never carried the amount, so the + * credited share of it is zero. + */ + protected static function scale($value, $part, $whole): int + { + $whole = (float) $whole; + + if ($whole == 0.0) { + return 0; + } + + return self::iround((float) $value * (float) $part / $whole); + } + + /** + * Round to an integer, half away from zero, exactly as DocumentTotals does. + */ + protected static function iround($value): int + { + return (int) round((float) $value); + } + + protected static function isYes($flag): bool + { + return is_string($flag) && strtoupper(trim($flag)) === 'YES'; + } +} diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php index 97e7f26e..02e9ccbd 100644 --- a/database/factories/InvoiceFactory.php +++ b/database/factories/InvoiceFactory.php @@ -81,6 +81,7 @@ class InvoiceFactory extends Factory $sequenceNumber = (new SerialNumberService) ->setModel(new Invoice) ->setCompany(User::find(1)->companies()->first()->id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setNextNumbers(); return [ diff --git a/database/migrations/2026_08_02_120000_add_credit_note_support.php b/database/migrations/2026_08_02_120000_add_credit_note_support.php new file mode 100644 index 00000000..1b4bf481 --- /dev/null +++ b/database/migrations/2026_08_02_120000_add_credit_note_support.php @@ -0,0 +1,148 @@ +string('type')->default('INVOICE')->after('status'); + } + + if (! Schema::hasColumn('invoices', 'related_invoice_id')) { + $table->unsignedInteger('related_invoice_id')->nullable()->after('type')->index(); + } + + if (! Schema::hasColumn('invoices', 'credit_reason')) { + $table->text('credit_reason')->nullable()->after('related_invoice_id'); + } + }); + + Schema::table('invoice_items', function (Blueprint $table) { + $table->bigInteger('price')->change(); + $table->bigInteger('base_price')->nullable()->change(); + + if (! Schema::hasColumn('invoice_items', 'source_invoice_item_id')) { + $table->unsignedInteger('source_invoice_item_id')->nullable()->after('invoice_id')->index(); + } + }); + + Schema::table('taxes', function (Blueprint $table) { + $table->bigInteger('base_amount')->nullable()->change(); + }); + + Company::all()->each(function ($company) { + $format = CompanySetting::getSetting('credit_note_number_format', $company->id); + + if ($format) { + return; + } + + CompanySetting::setSettings([ + 'credit_note_number_format' => '{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}', + ], $company->id); + }); + } + + /** + * Reverse the migrations. + * + * The indexes on related_invoice_id and source_invoice_item_id have to go + * before their columns do. MySQL/MariaDB and PostgreSQL would drop them + * along with the columns, but SQLite does not: its "alter table drop + * column" leaves the index behind and then fails with "no such column". + */ + public function down(): void + { + CompanySetting::where('option', 'credit_note_number_format')->delete(); + + Schema::table('taxes', function (Blueprint $table) { + $table->unsignedBigInteger('base_amount')->nullable()->change(); + }); + + Schema::table('invoice_items', function (Blueprint $table) { + if (Schema::hasColumn('invoice_items', 'source_invoice_item_id')) { + $table->dropIndex(['source_invoice_item_id']); + $table->dropColumn('source_invoice_item_id'); + } + }); + + Schema::table('invoice_items', function (Blueprint $table) { + $table->unsignedBigInteger('price')->change(); + $table->unsignedBigInteger('base_price')->nullable()->change(); + }); + + Schema::table('invoices', function (Blueprint $table) { + if (Schema::hasColumn('invoices', 'credit_reason')) { + $table->dropColumn('credit_reason'); + } + + if (Schema::hasColumn('invoices', 'related_invoice_id')) { + $table->dropIndex(['related_invoice_id']); + $table->dropColumn('related_invoice_id'); + } + + if (Schema::hasColumn('invoices', 'type')) { + $table->dropColumn('type'); + } + }); + } +}; diff --git a/lang/de.json b/lang/de.json index 9d052c66..9fb747bc 100644 --- a/lang/de.json +++ b/lang/de.json @@ -454,6 +454,28 @@ "cloned_successfully": "Rechnung erfolgreich kopiert", "clone_invoice": "Rechnung kopieren", "confirm_clone": "Diese Rechnung wird kopiert", + "create_credit_note": "Stornorechnung erstellen", + "credit_note_created": "Stornorechnung erfolgreich erstellt", + "credit_note": "Stornorechnung", + "credit_note_items": "Zu stornierende Positionen", + "credit_note_quantity_to_credit": "Storno-Menge", + "credit_note_original_quantity": "Berechnet", + "credit_note_already_credited": "Storniert", + "credit_note_remaining_quantity": "Verbleibend", + "credit_note_amount": "Betrag", + "credit_note_credited_subtotal": "Stornierte Zwischensumme", + "credit_note_reason": "Grund", + "credit_note_reason_placeholder": "Optional: warum diese Rechnung storniert wird", + "credit_note_proportional_note": "Rabatte und Steuern werden anteilig zu den oben gewählten Positionen und Mengen storniert.", + "credit_note_select_at_least_one_item": "Bitte mindestens eine zu stornierende Position auswählen.", + "credit_note_quantity_exceeds_remaining": "Die Menge übersteigt die verbleibende Menge dieser Position.", + "credit_note_fully_credited_line": "Vollständig storniert", + "credited_amount": "Stornierter Betrag", + "partially_credited": "Teilweise storniert", + "partially_credited_via_credit_notes": "Teilweise storniert durch Stornorechnung", + "original_invoice": "Originalrechnung", + "cancelled": "Storniert", + "cancelled_via_credit_note": "Storniert durch Stornorechnung", "item": { "title": "Titel des Artikels", "description": "Beschreibung", @@ -1049,6 +1071,13 @@ "disable_on_invoice_sent": "Deaktivieren, nachdem Rechnung gesendet wurde", "retrospective_edits_description": " Basierend auf den Gesetzen Ihres Landes oder Ihrer Präferenz, können Sie Benutzer daran hindern, fertige Rechnungen zu bearbeiten." }, + "credit_notes": { + "title": "Stornorechnungen", + "credit_note_number_format": "Stornorechnungsnummernformat", + "credit_note_number_format_description": "Passen Sie an, wie Ihre Stornorechnungsnummer automatisch generiert wird, wenn Sie eine neue Stornorechnung erstellen. Stornorechnungen werden unabhängig von Rechnungen nummeriert.", + "preview_credit_note_number": "Vorschau Stornorechnungsnummer", + "credit_note_settings_updated": "Stornorechnungseinstellungen erfolgreich aktualisiert" + }, "estimates": { "title": "Angebote", "estimate_number_format": "Angebotsnummernformat", @@ -1591,6 +1620,18 @@ "estimate_number_used": "Die Angebotsnummer ist bereits vergeben.", "invoice_number_used": "Die Rechnungsnummer ist bereits vergeben.", "payment_attached": "Dieser Rechnung ist bereits eine Zahlung zugewiesen. Bitte zuerst die zugewiesenen Zahlungen löschen, um mit der Entfernung fortzufahren.", + "credit_note_attached": "Diese Rechnung wurde durch eine Stornorechnung storniert. Bitte auch die Stornorechnung auswählen, um beide Belege gemeinsam zu löschen.", + "credit_note_cannot_be_created_from_credit_note": "Zu einer Stornorechnung kann keine weitere Stornorechnung erstellt werden.", + "draft_invoice_cannot_be_credited": "Ein Rechnungsentwurf kann nicht storniert werden. Bitte den Entwurf stattdessen bearbeiten oder löschen.", + "invoice_already_fully_credited": "Bei dieser Rechnung gibt es nichts mehr zu stornieren.", + "credit_quantity_exceeds_remaining": "Eine der Mengen übersteigt das, was bei dieser Position noch zu stornieren ist.", + "credit_amount_exceeds_invoice_balance": "Der Stornobetrag übersteigt den offenen Betrag der Rechnung.", + "credit_note_must_credit_something": "Bitte mindestens eine Position mit einer zu stornierenden Menge auswählen.", + "credit_item_not_on_invoice": "Eine der gewählten Positionen gehört nicht zu dieser Rechnung.", + "credit_quantity_invalid": "Bitte für jede gewählte Position eine gültige Menge größer als null angeben.", + "credit_note_cannot_be_cloned": "Eine Stornorechnung kann nicht kopiert werden.", + "credit_note_cannot_be_converted_to_estimate": "Eine Stornorechnung kann nicht in ein Angebot umgewandelt werden.", + "payment_amount_exceeds_invoice_due_amount": "Der Zahlungsbetrag übersteigt den offenen Betrag der Rechnung.", "payment_number_used": "Die Zahlungsnummer ist bereits vergeben.", "name_already_taken": "Der Name ist bereits vergeben.", "receipt_does_not_exist": "Beleg existiert nicht.", @@ -1619,6 +1660,15 @@ "pdf_invoice_number": "Rechnungsnummer", "pdf_invoice_date": "Rechnungsdatum", "pdf_invoice_due_date": "Fälligkeitsdatum", + "pdf_credit_note_label": "Stornorechnung", + "pdf_credit_note_number": "Stornorechnungsnummer", + "pdf_credit_note_date": "Stornodatum", + "pdf_credit_note_reference": "Bezug auf Rechnung :number vom :date", + "pdf_credit_note_reason": "Grund: :reason", + "pdf_cancelled_label": "Storniert", + "pdf_cancelled_via_credit_note": "Storniert durch Stornorechnung :number", + "pdf_partially_credited_label": "Teilweise storniert", + "pdf_partially_credited_via_credit_notes": ":amount storniert durch Stornorechnung :numbers", "pdf_notes": "Hinweise", "pdf_items_label": "Artikel", "pdf_quantity_label": "Menge", @@ -1655,6 +1705,7 @@ "pdf_tax_label": "Steuer", "pdf_tax_id": "Steuer-Nr.", "pdf_vat_id": "USt.-ID", + "pdf_amount_credited": "Stornierter Betrag", "pdf_amount_paid": "Bezahlter Betrag", "pdf_amount_due": "Offener Betrag", "mail_thanks": "Danke", diff --git a/lang/en.json b/lang/en.json index 07e390fd..55e191a0 100644 --- a/lang/en.json +++ b/lang/en.json @@ -472,6 +472,28 @@ "confirm_clone": "This invoice will be cloned into a new Invoice", "convert_to_estimate": "Convert to Estimate", "confirm_convert_to_estimate": "This invoice will be converted into a new Estimate", + "create_credit_note": "Create Credit Note", + "credit_note_created": "Credit note created successfully", + "credit_note": "Credit Note", + "credit_note_items": "Lines to credit", + "credit_note_quantity_to_credit": "Credit qty", + "credit_note_original_quantity": "Invoiced", + "credit_note_already_credited": "Credited", + "credit_note_remaining_quantity": "Remaining", + "credit_note_amount": "Amount", + "credit_note_credited_subtotal": "Credited subtotal", + "credit_note_reason": "Reason", + "credit_note_reason_placeholder": "Optional: why this invoice is being credited", + "credit_note_proportional_note": "Discounts and taxes are credited in proportion to the lines and quantities selected above.", + "credit_note_select_at_least_one_item": "Select at least one line to credit.", + "credit_note_quantity_exceeds_remaining": "Quantity is more than the remaining quantity of this line.", + "credit_note_fully_credited_line": "Fully credited", + "credited_amount": "Credited amount", + "partially_credited": "Partially credited", + "partially_credited_via_credit_notes": "Partially credited via credit note", + "original_invoice": "Original invoice", + "cancelled": "Cancelled", + "cancelled_via_credit_note": "Cancelled via credit note", "item": { "title": "Item Title", "description": "Description", @@ -1199,6 +1221,13 @@ "disable_on_invoice_sent": "Disable after invoice is sent", "retrospective_edits_description": " Based on your country's laws or your preference, you can restrict users from editing finalised invoices." }, + "credit_notes": { + "title": "Credit Notes", + "credit_note_number_format": "Credit Note Number Format", + "credit_note_number_format_description": "Customize how your credit note number gets generated automatically when you create a new credit note. Credit notes are numbered independently of invoices.", + "preview_credit_note_number": "Preview Credit Note Number", + "credit_note_settings_updated": "Credit Note Settings updated successfully" + }, "estimates": { "title": "Estimates", "estimate_number_format": "Estimate Number Format", @@ -1800,6 +1829,18 @@ "estimate_number_used": "The estimate number has already been taken.", "invoice_number_used": "The invoice number has already been taken.", "payment_attached": "This invoice already has a payment attached to it. Make sure to delete the attached payments first in order to go ahead with the removal.", + "credit_note_attached": "This invoice has been reversed by a credit note. Select the credit note as well to delete both documents together.", + "credit_note_cannot_be_created_from_credit_note": "A credit note cannot be created from another credit note.", + "draft_invoice_cannot_be_credited": "A draft invoice cannot be credited. Edit or delete the draft instead.", + "invoice_already_fully_credited": "This invoice has nothing left to credit.", + "credit_quantity_exceeds_remaining": "One of the quantities is more than what is left to credit on that line.", + "credit_amount_exceeds_invoice_balance": "The credit is more than the invoice's outstanding balance.", + "credit_note_must_credit_something": "Select at least one line with a quantity to credit.", + "credit_item_not_on_invoice": "One of the selected lines does not belong to this invoice.", + "credit_quantity_invalid": "Enter a valid quantity greater than zero for every selected line.", + "credit_note_cannot_be_cloned": "A credit note cannot be cloned.", + "credit_note_cannot_be_converted_to_estimate": "A credit note cannot be converted to an estimate.", + "payment_amount_exceeds_invoice_due_amount": "The payment is more than the invoice's outstanding balance.", "payment_number_used": "The payment number has already been taken.", "name_already_taken": "The name has already been taken.", "receipt_does_not_exist": "Receipt does not exist.", @@ -1828,6 +1869,15 @@ "pdf_invoice_number": "Invoice Number", "pdf_invoice_date": "Invoice Date", "pdf_invoice_due_date": "Due Date", + "pdf_credit_note_label": "Credit Note", + "pdf_credit_note_number": "Credit Note Number", + "pdf_credit_note_date": "Credit Note Date", + "pdf_credit_note_reference": "With reference to invoice :number dated :date", + "pdf_credit_note_reason": "Reason: :reason", + "pdf_cancelled_label": "Cancelled", + "pdf_cancelled_via_credit_note": "Cancelled via credit note :number", + "pdf_partially_credited_label": "Partially Credited", + "pdf_partially_credited_via_credit_notes": "Credited :amount via credit note :numbers", "pdf_notes": "Notes", "pdf_items_label": "Items", "pdf_quantity_label": "Quantity", @@ -1865,6 +1915,7 @@ "pdf_tax_label": "Tax", "pdf_tax_id": "Tax-ID", "pdf_vat_id": "VAT-ID", + "pdf_amount_credited": "Amount Credited", "pdf_amount_paid": "Amount Paid", "pdf_amount_due": "Amount Due", "mail_thanks": "Thanks", diff --git a/lang/fr.json b/lang/fr.json index c22525b1..e3b6bc50 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -454,6 +454,28 @@ "cloned_successfully": "Facture clonée", "clone_invoice": "Dupliquer", "confirm_clone": "Cette facture sera dupliquée dans une nouvelle facture", + "create_credit_note": "Créer un avoir", + "credit_note_created": "Avoir créé", + "credit_note": "Avoir", + "credit_note_items": "Lignes à créditer", + "credit_note_quantity_to_credit": "Qté à créditer", + "credit_note_original_quantity": "Facturé", + "credit_note_already_credited": "Crédité", + "credit_note_remaining_quantity": "Restant", + "credit_note_amount": "Montant", + "credit_note_credited_subtotal": "Sous-total crédité", + "credit_note_reason": "Motif", + "credit_note_reason_placeholder": "Facultatif : pourquoi cette facture est créditée", + "credit_note_proportional_note": "Les remises et les taxes sont créditées au prorata des lignes et des quantités sélectionnées ci-dessus.", + "credit_note_select_at_least_one_item": "Sélectionnez au moins une ligne à créditer.", + "credit_note_quantity_exceeds_remaining": "La quantité dépasse la quantité restante de cette ligne.", + "credit_note_fully_credited_line": "Entièrement crédité", + "credited_amount": "Montant crédité", + "partially_credited": "Partiellement créditée", + "partially_credited_via_credit_notes": "Partiellement créditée par l'avoir", + "original_invoice": "Facture d'origine", + "cancelled": "Annulée", + "cancelled_via_credit_note": "Annulée par un avoir", "item": { "title": "Titre de l'article", "description": "Description", @@ -1049,6 +1071,13 @@ "disable_on_invoice_sent": "Désactiver après l'envoi de la facture", "retrospective_edits_description": "Vous pouvez empêcher la modification de factures lorsque un paiement est effectué, pour être en conformité avec la loi de certains pays." }, + "credit_notes": { + "title": "Avoirs", + "credit_note_number_format": "Format de numéro", + "credit_note_number_format_description": "Personnalisez la structure de vos numéros d'avoir. Les avoirs sont numérotés indépendamment des factures.", + "preview_credit_note_number": "Aperçu", + "credit_note_settings_updated": "Paramètres des avoirs mis à jour" + }, "estimates": { "title": "Devis", "estimate_number_format": "Format de numéro", @@ -1591,6 +1620,18 @@ "estimate_number_used": "Ce numéro de devis est déjà utilisé.", "invoice_number_used": "Ce numéro de facture est déjà utilisé.", "payment_attached": "Cette facture est liée à un reçu de paiement. Veuillez d'abord le supprimer avant de poursuivre.", + "credit_note_attached": "Cette facture a été annulée par un avoir. Sélectionnez également l'avoir afin de supprimer les deux documents ensemble.", + "credit_note_cannot_be_created_from_credit_note": "Un avoir ne peut pas être créé à partir d'un autre avoir.", + "draft_invoice_cannot_be_credited": "Une facture au brouillon ne peut pas faire l'objet d'un avoir. Modifiez ou supprimez plutôt le brouillon.", + "invoice_already_fully_credited": "Il ne reste plus rien à créditer sur cette facture.", + "credit_quantity_exceeds_remaining": "L'une des quantités dépasse ce qu'il reste à créditer sur cette ligne.", + "credit_amount_exceeds_invoice_balance": "Le crédit dépasse le solde restant dû de la facture.", + "credit_note_must_credit_something": "Sélectionnez au moins une ligne avec une quantité à créditer.", + "credit_item_not_on_invoice": "L'une des lignes sélectionnées n'appartient pas à cette facture.", + "credit_quantity_invalid": "Saisissez une quantité valide supérieure à zéro pour chaque ligne sélectionnée.", + "credit_note_cannot_be_cloned": "Un avoir ne peut pas être dupliqué.", + "credit_note_cannot_be_converted_to_estimate": "Un avoir ne peut pas être converti en devis.", + "payment_amount_exceeds_invoice_due_amount": "Le paiement dépasse le solde restant dû de la facture.", "payment_number_used": "Ce numéro de paiement est déjà utilisé.", "name_already_taken": "Ce nom est déjà pris.", "receipt_does_not_exist": "Le reçu n'existe pas.", @@ -1619,6 +1660,15 @@ "pdf_invoice_number": "Numéro", "pdf_invoice_date": "Date", "pdf_invoice_due_date": "Date d'échéance", + "pdf_credit_note_label": "Avoir", + "pdf_credit_note_number": "Numéro", + "pdf_credit_note_date": "Date", + "pdf_credit_note_reference": "En référence à la facture :number du :date", + "pdf_credit_note_reason": "Motif : :reason", + "pdf_cancelled_label": "Annulée", + "pdf_cancelled_via_credit_note": "Annulée par l'avoir :number", + "pdf_partially_credited_label": "Partiellement créditée", + "pdf_partially_credited_via_credit_notes": ":amount crédités par l'avoir :numbers", "pdf_notes": "Notes de bas de page", "pdf_items_label": "Articles", "pdf_quantity_label": "Quantité", @@ -1655,6 +1705,7 @@ "pdf_tax_label": "Taxe", "pdf_tax_id": "N° fiscal", "pdf_vat_id": "N° de TVA", + "pdf_amount_credited": "Montant crédité", "pdf_amount_paid": "Montant acquitté", "pdf_amount_due": "Montant dû", "mail_thanks": "Merci", diff --git a/lang/it.json b/lang/it.json index 2f2ab5d7..76764e63 100644 --- a/lang/it.json +++ b/lang/it.json @@ -454,6 +454,28 @@ "cloned_successfully": "Fattura copiata con successo", "clone_invoice": "Clona Fattura", "confirm_clone": "Questa fattura verrà clonata in una nuova fattura", + "create_credit_note": "Crea Nota di Credito", + "credit_note_created": "Nota di credito creata con successo", + "credit_note": "Nota di Credito", + "credit_note_items": "Righe da accreditare", + "credit_note_quantity_to_credit": "Qtà da accreditare", + "credit_note_original_quantity": "Fatturato", + "credit_note_already_credited": "Accreditato", + "credit_note_remaining_quantity": "Rimanente", + "credit_note_amount": "Importo", + "credit_note_credited_subtotal": "Subtotale accreditato", + "credit_note_reason": "Motivo", + "credit_note_reason_placeholder": "Facoltativo: perché questa fattura viene accreditata", + "credit_note_proportional_note": "Sconti e imposte vengono accreditati in proporzione alle righe e alle quantità selezionate sopra.", + "credit_note_select_at_least_one_item": "Seleziona almeno una riga da accreditare.", + "credit_note_quantity_exceeds_remaining": "La quantità supera la quantità rimanente di questa riga.", + "credit_note_fully_credited_line": "Completamente accreditata", + "credited_amount": "Importo accreditato", + "partially_credited": "Parzialmente accreditata", + "partially_credited_via_credit_notes": "Parzialmente accreditata tramite nota di credito", + "original_invoice": "Fattura originale", + "cancelled": "Annullata", + "cancelled_via_credit_note": "Annullata tramite nota di credito", "item": { "title": "Titolo Commessa", "description": "Descrizione", @@ -1049,6 +1071,13 @@ "disable_on_invoice_sent": "Disabilita dopo l'invio della fattura", "retrospective_edits_description": " In base alle leggi del tuo paese o alle tue preferenze, puoi limitare gli utenti dalla modifica delle fatture finalizzate." }, + "credit_notes": { + "title": "Note di Credito", + "credit_note_number_format": "Formato Numero Nota di Credito", + "credit_note_number_format_description": "Personalizza il modo in cui il numero della nota di credito viene generato automaticamente quando crei una nuova nota di credito. Le note di credito sono numerate indipendentemente dalle fatture.", + "preview_credit_note_number": "Anteprima Numero Nota di Credito", + "credit_note_settings_updated": "Impostazioni note di credito aggiornate con successo" + }, "estimates": { "title": "Preventivi", "estimate_number_format": "Formato del Numero di Serie", @@ -1591,6 +1620,18 @@ "estimate_number_used": "Il numero stimato è già stato preso.", "invoice_number_used": "Il numero della fattura è già stato utilizzato.", "payment_attached": "Una delle fatture selezionate ha già associato un pagamento. Assicurati di eliminare il pagamento associato prima di procedere con la rimozione.", + "credit_note_attached": "Questa fattura è stata stornata da una nota di credito. Seleziona anche la nota di credito per eliminare entrambi i documenti insieme.", + "credit_note_cannot_be_created_from_credit_note": "Non è possibile creare una nota di credito a partire da un'altra nota di credito.", + "draft_invoice_cannot_be_credited": "Una fattura in bozza non può essere stornata. Modifica o elimina la bozza.", + "invoice_already_fully_credited": "Su questa fattura non resta nulla da accreditare.", + "credit_quantity_exceeds_remaining": "Una delle quantità supera quanto resta da accreditare su quella riga.", + "credit_amount_exceeds_invoice_balance": "L'accredito supera il saldo residuo della fattura.", + "credit_note_must_credit_something": "Seleziona almeno una riga con una quantità da accreditare.", + "credit_item_not_on_invoice": "Una delle righe selezionate non appartiene a questa fattura.", + "credit_quantity_invalid": "Inserisci una quantità valida maggiore di zero per ogni riga selezionata.", + "credit_note_cannot_be_cloned": "Una nota di credito non può essere clonata.", + "credit_note_cannot_be_converted_to_estimate": "Una nota di credito non può essere convertita in un preventivo.", + "payment_amount_exceeds_invoice_due_amount": "Il pagamento supera il saldo residuo della fattura.", "payment_number_used": "Questa modalità di pagamento è già stata inserita.", "name_already_taken": "Questo Nome esiste giá.", "receipt_does_not_exist": "La ricevuta non esiste.", @@ -1619,6 +1660,15 @@ "pdf_invoice_number": "Numero Fattura", "pdf_invoice_date": "Data fattura", "pdf_invoice_due_date": "Data di scadenza", + "pdf_credit_note_label": "Nota di Credito", + "pdf_credit_note_number": "Numero Nota di Credito", + "pdf_credit_note_date": "Data nota di credito", + "pdf_credit_note_reference": "In riferimento alla fattura :number del :date", + "pdf_credit_note_reason": "Motivo: :reason", + "pdf_cancelled_label": "Annullata", + "pdf_cancelled_via_credit_note": "Annullata tramite nota di credito :number", + "pdf_partially_credited_label": "Parzialmente accreditata", + "pdf_partially_credited_via_credit_notes": ":amount accreditati tramite nota di credito :numbers", "pdf_notes": "Note", "pdf_items_label": "Commesse", "pdf_quantity_label": "Quantità", @@ -1655,6 +1705,7 @@ "pdf_tax_label": "Tassa", "pdf_tax_id": "Codice Fiscale", "pdf_vat_id": "P. IVA", + "pdf_amount_credited": "Importo accreditato", "pdf_amount_paid": "Importo pagato", "pdf_amount_due": "Importo Dovuto", "mail_thanks": "Grazie", diff --git a/lang/mk.json b/lang/mk.json index ab30c790..d89b5528 100644 --- a/lang/mk.json +++ b/lang/mk.json @@ -469,6 +469,28 @@ "confirm_clone": "Оваа фактура ќе биде клонирана во нова фактура", "convert_to_estimate": "Конвертирај во понуда", "confirm_convert_to_estimate": "Оваа фактура ќе биде конвертирана во нова понуда", + "create_credit_note": "Креирај книжно одобрение", + "credit_note_created": "Книжното одобрение е успешно креирано", + "credit_note": "Книжно одобрение", + "credit_note_items": "Ставки за сторнирање", + "credit_note_quantity_to_credit": "Количина за сторнирање", + "credit_note_original_quantity": "Фактурирано", + "credit_note_already_credited": "Сторнирано", + "credit_note_remaining_quantity": "Преостанато", + "credit_note_amount": "Износ", + "credit_note_credited_subtotal": "Сторниран меѓузбир", + "credit_note_reason": "Причина", + "credit_note_reason_placeholder": "Опционално: зошто оваа фактура се сторнира", + "credit_note_proportional_note": "Попустите и даноците се сторнираат пропорционално на избраните ставки и количини погоре.", + "credit_note_select_at_least_one_item": "Изберете барем една ставка за сторнирање.", + "credit_note_quantity_exceeds_remaining": "Количината е поголема од преостанатата количина на оваа ставка.", + "credit_note_fully_credited_line": "Целосно сторнирана", + "credited_amount": "Сторниран износ", + "partially_credited": "Делумно сторнирана", + "partially_credited_via_credit_notes": "Делумно сторнирана со книжно одобрение", + "original_invoice": "Оригинална фактура", + "cancelled": "Сторнирана", + "cancelled_via_credit_note": "Сторнирана со книжно одобрение", "item": { "title": "Наслов на ставка", "description": "Опис", @@ -1090,6 +1112,13 @@ "disable_on_invoice_sent": "Оневозможи откако фактурата е испратена", "retrospective_edits_description": " Врз основа на законите на вашата држава или вашите преференци, можете да им забраните на корисниците да менуваат финализирани фактури." }, + "credit_notes": { + "title": "Книжни одобренија", + "credit_note_number_format": "Формат на број на книжно одобрение", + "credit_note_number_format_description": "Прилагодете како автоматски се генерира бројот на книжното одобрение кога ќе креирате ново книжно одобрение. Книжните одобренија се нумерираат независно од фактурите.", + "preview_credit_note_number": "Преглед на број на книжно одобрение", + "credit_note_settings_updated": "Поставките за книжно одобрение се ажурирани успешно" + }, "estimates": { "title": "Понуди", "estimate_number_format": "Формат на број на понуда", @@ -1664,6 +1693,18 @@ "estimate_number_used": "Бројот на понудата е веќе зафатен.", "invoice_number_used": "Бројот на фактурата е веќе зафатен.", "payment_attached": "Оваа фактура веќе има прикачено плаќање. Прво избришете ги прикачените плаќања за да продолжите со отстранувањето.", + "credit_note_attached": "Оваа фактура е сторнирана со книжно одобрение. Изберете го и книжното одобрение за да ги избришете двата документа заедно.", + "credit_note_cannot_be_created_from_credit_note": "Книжно одобрение не може да се креира од друго книжно одобрение.", + "draft_invoice_cannot_be_credited": "Фактура во нацрт не може да се сторнира. Наместо тоа изменете го или избришете го нацртот.", + "invoice_already_fully_credited": "На оваа фактура не останало ништо за сторнирање.", + "credit_quantity_exceeds_remaining": "Една од количините е поголема од преостанатото за сторнирање на таа ставка.", + "credit_amount_exceeds_invoice_balance": "Сторното е поголемо од преостанатото салдо на фактурата.", + "credit_note_must_credit_something": "Изберете барем една ставка со количина за сторнирање.", + "credit_item_not_on_invoice": "Една од избраните ставки не припаѓа на оваа фактура.", + "credit_quantity_invalid": "Внесете важечка количина поголема од нула за секоја избрана ставка.", + "credit_note_cannot_be_cloned": "Книжно одобрение не може да се клонира.", + "credit_note_cannot_be_converted_to_estimate": "Книжно одобрение не може да се конвертира во понуда.", + "payment_amount_exceeds_invoice_due_amount": "Плаќањето е поголемо од преостанатото салдо на фактурата.", "payment_number_used": "Бројот на плаќањето е веќе зафатен.", "name_already_taken": "Името е веќе зафатено.", "receipt_does_not_exist": "Сметката не постои.", @@ -1692,6 +1733,15 @@ "pdf_invoice_number": "Број на фактура", "pdf_invoice_date": "Датум на фактура", "pdf_invoice_due_date": "Датум на доспевање", + "pdf_credit_note_label": "Книжно одобрение", + "pdf_credit_note_number": "Број на книжно одобрение", + "pdf_credit_note_date": "Датум на книжно одобрение", + "pdf_credit_note_reference": "Во однос на фактура :number од :date", + "pdf_credit_note_reason": "Причина: :reason", + "pdf_cancelled_label": "Сторнирана", + "pdf_cancelled_via_credit_note": "Сторнирана со книжно одобрение :number", + "pdf_partially_credited_label": "Делумно сторнирана", + "pdf_partially_credited_via_credit_notes": "Сторнирани :amount со книжно одобрение :numbers", "pdf_notes": "Белешки", "pdf_items_label": "Ставки", "pdf_quantity_label": "Количина", @@ -1728,6 +1778,7 @@ "pdf_tax_label": "Данок", "pdf_tax_id": "Даночен број", "pdf_vat_id": "ДДВ број", + "pdf_amount_credited": "Сторниран износ", "pdf_amount_paid": "Платен износ", "pdf_amount_due": "Доспеан износ", "mail_thanks": "Благодарам", diff --git a/resources/scripts/api/services/invoice.service.ts b/resources/scripts/api/services/invoice.service.ts index 14e70597..cf260de5 100644 --- a/resources/scripts/api/services/invoice.service.ts +++ b/resources/scripts/api/services/invoice.service.ts @@ -1,6 +1,10 @@ import { client } from '../client' import { API } from '../endpoints' -import type { Invoice, CreateInvoicePayload } from '@/scripts/types/domain/invoice' +import type { + Invoice, + CreateInvoicePayload, + CreateCreditNotePayload, +} from '@/scripts/types/domain/invoice' import type { ApiResponse, PaginatedResponse, @@ -108,6 +112,18 @@ export const invoiceService = { return data }, + /** + * Credit an invoice. Omitting `payload.items` reverses every remaining + * quantity; supplying them credits only those lines. + */ + async createCreditNote( + id: number, + payload?: CreateCreditNotePayload, + ): Promise> { + const { data } = await client.post(`${API.INVOICES}/${id}/credit-note`, payload ?? {}) + return data + }, + async changeStatus(payload: InvoiceStatusPayload): Promise> { const { data } = await client.post(`${API.INVOICES}/${payload.id}/status`, payload) return data diff --git a/resources/scripts/features/company/dashboard/views/DashboardView.vue b/resources/scripts/features/company/dashboard/views/DashboardView.vue index 23c004b6..fc9dfe3d 100644 --- a/resources/scripts/features/company/dashboard/views/DashboardView.vue +++ b/resources/scripts/features/company/dashboard/views/DashboardView.vue @@ -6,6 +6,7 @@ import DashboardStats from '../components/DashboardStats.vue' import DashboardChart from '../components/DashboardChart.vue' import DashboardTable from '../components/DashboardTable.vue' import SendInvoiceModal from '@/scripts/features/company/invoices/components/SendInvoiceModal.vue' +import CreditNoteModal from '@/scripts/features/company/invoices/components/CreditNoteModal.vue' import SendEstimateModal from '@/scripts/features/company/estimates/components/SendEstimateModal.vue' const route = useRoute() @@ -31,5 +32,6 @@ onMounted(() => { + diff --git a/resources/scripts/features/company/invoices/components/CreditNoteModal.vue b/resources/scripts/features/company/invoices/components/CreditNoteModal.vue new file mode 100644 index 00000000..d1b443d5 --- /dev/null +++ b/resources/scripts/features/company/invoices/components/CreditNoteModal.vue @@ -0,0 +1,544 @@ + + + diff --git a/resources/scripts/features/company/invoices/components/InvoiceDropdown.vue b/resources/scripts/features/company/invoices/components/InvoiceDropdown.vue index 6a835f03..67048aae 100644 --- a/resources/scripts/features/company/invoices/components/InvoiceDropdown.vue +++ b/resources/scripts/features/company/invoices/components/InvoiceDropdown.vue @@ -65,7 +65,12 @@ + + + + {{ $t('invoices.create_credit_note') }} + + (() => { ) }) +// A credit note can only be created from a real invoice (never from another +// credit note), only while something is left to credit, never from a draft +// (nothing was issued yet), and only by users allowed to create invoices. +const canCreateCreditNote = computed(() => { + return ( + props.canCreate && + props.row.type !== 'CREDIT_NOTE' && + props.row.credited_status !== 'FULL' && + props.row.status !== 'DRAFT' + ) +}) + +/** + * Turn an API failure into a toast, translating the server's message key when + * it is one we know about so the user never sees a raw snake_case key. + */ +function showApiErrorNotification(err: unknown): void { + const normalized = handleApiError(err) + const translationKey = getErrorTranslationKey(normalized.message) + notificationStore.showNotification({ + type: 'error', + message: translationKey ? t(translationKey) : normalized.message, + }) +} + function removeInvoice(): void { dialogStore.openDialog({ title: t('general.are_you_sure'), @@ -212,8 +255,14 @@ function cloneInvoiceData(): void { size: 'lg', }).then(async (res: boolean) => { if (res) { - const response = await invoiceStore.cloneInvoice({ id: props.row.id }) - router.push(`/admin/invoices/${response.data.data.id}/edit`) + // Cloning a credit note is refused by the server (422), so the reason + // has to reach the user instead of failing silently. + try { + const response = await invoiceStore.cloneInvoice({ id: props.row.id }) + router.push(`/admin/invoices/${response.data.data.id}/edit`) + } catch (err: unknown) { + showApiErrorNotification(err) + } } }) } @@ -229,12 +278,32 @@ function convertToEstimate(): void { size: 'lg', }).then(async (res: boolean) => { if (res) { - const response = await invoiceStore.convertToEstimate({ id: props.row.id }) - router.push(`/admin/estimates/${response.data.data.id}/edit`) + // Same as clone(): converting a credit note is refused by the server. + try { + const response = await invoiceStore.convertToEstimate({ id: props.row.id }) + router.push(`/admin/estimates/${response.data.data.id}/edit`) + } catch (err: unknown) { + showApiErrorNotification(err) + } } }) } +// Crediting is a form, not a confirmation: which lines and how much of each +// has to be chosen, so the modal owns the whole flow including its errors. +function createCreditNote(): void { + modalStore.openModal({ + title: t('invoices.create_credit_note'), + componentName: 'CreditNoteModal', + id: props.row.id, + size: 'lg', + refreshData: () => { + props.loadData?.() + props.table?.refresh() + }, + }) +} + function onMarkAsSent(): void { dialogStore.openDialog({ title: t('general.are_you_sure'), diff --git a/resources/scripts/features/company/invoices/store.ts b/resources/scripts/features/company/invoices/store.ts index 2d1508e6..4e841b30 100644 --- a/resources/scripts/features/company/invoices/store.ts +++ b/resources/scripts/features/company/invoices/store.ts @@ -10,7 +10,12 @@ import type { InvoiceStatusPayload, InvoiceTemplate, } from '../../../api/services/invoice.service' -import type { Invoice, InvoiceItem, DiscountType } from '../../../types/domain/invoice' +import type { + Invoice, + InvoiceItem, + DiscountType, + CreateCreditNotePayload, +} from '../../../types/domain/invoice' import type { Tax, TaxType } from '../../../types/domain/tax' import type { Currency } from '../../../types/domain/currency' import type { Customer } from '../../../types/domain/customer' @@ -367,6 +372,14 @@ export const useInvoiceStore = defineStore('invoice', { return { data: response } }, + async createCreditNote( + data: { id: number } & CreateCreditNotePayload, + ): Promise<{ data: { data: Invoice } }> { + const { id, ...payload } = data + const response = await invoiceService.createCreditNote(id, payload) + return { data: response } + }, + async markAsSent(data: InvoiceStatusPayload): Promise { const response = await invoiceService.changeStatus(data) const pos = this.invoices.findIndex((inv) => inv.id === data.id) diff --git a/resources/scripts/features/company/invoices/views/InvoiceDetailView.vue b/resources/scripts/features/company/invoices/views/InvoiceDetailView.vue index 613dc0cd..6da51dd3 100644 --- a/resources/scripts/features/company/invoices/views/InvoiceDetailView.vue +++ b/resources/scripts/features/company/invoices/views/InvoiceDetailView.vue @@ -28,7 +28,11 @@ :to="`/admin/payments/${$route.params.id}/create`" > {{ $t('invoices.record_payment') }} @@ -39,7 +43,7 @@ + +
+
+ + {{ $t('invoices.credit_note') }} + + + {{ $t('invoices.original_invoice') }}: + + {{ invoiceData.related_invoice.invoice_number }} + + +
+

+ {{ $t('invoices.credit_note_reason') }}: {{ invoiceData.credit_reason }} +

+
+ + +
+ + {{ isFullyCredited ? $t('invoices.cancelled') : $t('invoices.partially_credited') }} + + + {{ + isFullyCredited + ? $t('invoices.cancelled_via_credit_note') + : $t('invoices.partially_credited_via_credit_notes') + }}: + + {{ creditNote.invoice_number }} + + + + {{ $t('invoices.credited_amount') }}: + + +
+
@@ -202,6 +282,7 @@ + @@ -212,6 +293,7 @@ import { useI18n } from 'vue-i18n' import { useInvoiceStore } from '../store' import InvoiceDropdown from '../components/InvoiceDropdown.vue' import SendInvoiceModal from '../components/SendInvoiceModal.vue' +import CreditNoteModal from '../components/CreditNoteModal.vue' import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue' import { useUserStore } from '../../../../stores/user.store' import { useDialogStore } from '../../../../stores/dialog.store' @@ -305,6 +387,22 @@ const searchData = reactive({ const pageTitle = computed(() => invoiceData.value?.invoice_number ?? '') +// credited_status is only emitted where the creditNotes relation was loaded, +// so fall back to the relation itself rather than hiding the banner outright. +const isCredited = computed(() => { + const status = invoiceData.value?.credited_status + + if (status) { + return status !== 'NONE' + } + + return !!invoiceData.value?.credit_notes?.length +}) + +const isFullyCredited = computed(() => { + return invoiceData.value?.credited_status !== 'PARTIAL' +}) + const getOrderBy = computed(() => { return searchData.orderBy === 'asc' || searchData.orderBy === null }) @@ -445,6 +543,15 @@ function onSearched(): void { }, 500) } +// Reset-and-refetch the sidebar list from page 1. Used after actions that +// change which invoices exist or their status (e.g. creating a credit +// note), since `loadInvoices()` alone only appends (it's built for +// infinite-scroll pagination) and would duplicate already-loaded rows. +function refreshInvoiceList(): void { + invoiceList.value = [] + loadInvoices() +} + function sortData(): void { if (searchData.orderBy === 'asc') { searchData.orderBy = 'desc' diff --git a/resources/scripts/features/company/invoices/views/InvoiceIndexView.vue b/resources/scripts/features/company/invoices/views/InvoiceIndexView.vue index 585c704f..a8fd4caf 100644 --- a/resources/scripts/features/company/invoices/views/InvoiceIndexView.vue +++ b/resources/scripts/features/company/invoices/views/InvoiceIndexView.vue @@ -276,6 +276,12 @@ > {{ row.data.invoice_number }} + + {{ $t('invoices.credit_note') }} + @@ -472,6 +499,7 @@ +