diff --git a/app/Http/Controllers/Company/Customer/CustomerStatementController.php b/app/Http/Controllers/Company/Customer/CustomerStatementController.php new file mode 100644 index 00000000..6dd011d2 --- /dev/null +++ b/app/Http/Controllers/Company/Customer/CustomerStatementController.php @@ -0,0 +1,36 @@ +authorize('view', $customer); + $this->authorize('view report', $customer->company); + + $type = $request->validated('type'); + + $statement = $this->customerStatementService->statement( + $customer, + $type, + Carbon::createFromFormat('Y-m-d', $request->validated('from_date')), + Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementService::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), + (int) $request->validated('per_page', 50), + (int) $request->validated('page', 1), + ); + + return new CustomerStatementResource($statement); + } +} diff --git a/app/Http/Controllers/Company/Customer/CustomerStatsController.php b/app/Http/Controllers/Company/Customer/CustomerStatsController.php index 10b8e2f6..c646ee20 100644 --- a/app/Http/Controllers/Company/Customer/CustomerStatsController.php +++ b/app/Http/Controllers/Company/Customer/CustomerStatsController.php @@ -6,12 +6,14 @@ use App\Http\Controllers\Controller; use App\Http\Resources\CustomerResource; use App\Models\Customer; use App\Services\CustomerService; +use App\Services\CustomerStatementService; use Illuminate\Http\Request; class CustomerStatsController extends Controller { public function __construct( private readonly CustomerService $customerService, + private readonly CustomerStatementService $customerStatementService, ) {} public function __invoke(Request $request, Customer $customer) @@ -25,6 +27,7 @@ class CustomerStatsController extends Controller ); $customer = Customer::find($customer->id); + $this->customerStatementService->hydrateAccountSummaries([$customer]); return (new CustomerResource($customer)) ->additional(['meta' => [ diff --git a/app/Http/Controllers/Company/Customer/CustomersController.php b/app/Http/Controllers/Company/Customer/CustomersController.php index 9b010d3b..3caa4240 100644 --- a/app/Http/Controllers/Company/Customer/CustomersController.php +++ b/app/Http/Controllers/Company/Customer/CustomersController.php @@ -8,13 +8,16 @@ use App\Http\Requests\DeleteCustomersRequest; use App\Http\Resources\CustomerResource; use App\Models\Customer; use App\Services\CustomerService; +use App\Services\CustomerStatementService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; class CustomersController extends Controller { public function __construct( private readonly CustomerService $customerService, + private readonly CustomerStatementService $customerStatementService, ) {} /** @@ -31,10 +34,12 @@ class CustomersController extends Controller $customers = Customer::with('creator') ->whereCompany() ->applyFilters($request->all()) - ->withSum('invoices as base_due_amount', 'base_due_amount') - ->withSum('invoices as due_amount', 'due_amount') ->paginateData($limit); + $this->customerStatementService->hydrateAccountSummaries( + $customers instanceof LengthAwarePaginator ? $customers->getCollection() : $customers + ); + return CustomerResource::collection($customers) ->additional(['meta' => [ 'customer_total_count' => Customer::whereCompany()->count(), @@ -52,6 +57,7 @@ class CustomersController extends Controller $this->authorize('create', Customer::class); $customer = $this->customerService->create($request); + $this->customerStatementService->hydrateAccountSummaries([$customer]); return new CustomerResource($customer); } @@ -65,6 +71,8 @@ class CustomersController extends Controller { $this->authorize('view', $customer); + $this->customerStatementService->hydrateAccountSummaries([$customer]); + return new CustomerResource($customer); } @@ -79,6 +87,7 @@ class CustomersController extends Controller $this->authorize('update', $customer); $customer = $this->customerService->update($request, $customer); + $this->customerStatementService->hydrateAccountSummaries([$customer]); return new CustomerResource($customer); } diff --git a/app/Http/Controllers/Company/Customer/SendCustomerStatementController.php b/app/Http/Controllers/Company/Customer/SendCustomerStatementController.php new file mode 100644 index 00000000..4bf7b434 --- /dev/null +++ b/app/Http/Controllers/Company/Customer/SendCustomerStatementController.php @@ -0,0 +1,60 @@ +authorize('view', $customer); + $this->authorize('view report', $customer->company); + + $type = $request->validated('type'); + + $statement = $this->customerStatementService->statement( + $customer, + $type, + Carbon::createFromFormat('Y-m-d', $request->validated('from_date')), + Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementService::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), + PHP_INT_MAX, + ); + $pdf = $this->customerStatementPdfService->render($statement); + + CompanyMailConfigService::apply($customer->company_id); + + $mail = Mail::to($request->validated('to')); + if ($request->filled('cc')) { + $mail->cc($request->validated('cc')); + } + if ($request->filled('bcc')) { + $mail->bcc($request->validated('bcc')); + } + + $mail->send(new SendCustomerStatementMail([ + ...$request->safe()->only(['to', 'cc', 'bcc', 'subject', 'body']), + 'from' => config('mail.from.address'), + 'from_name' => config('mail.from.name'), + 'customer' => $customer, + 'pdf' => $pdf, + 'filename' => __('Customer Statement').' '.$customer->name.'.pdf', + ])); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/Company/Invoice/InvoicesController.php b/app/Http/Controllers/Company/Invoice/InvoicesController.php index ee0d1f9d..92a95c66 100644 --- a/app/Http/Controllers/Company/Invoice/InvoicesController.php +++ b/app/Http/Controllers/Company/Invoice/InvoicesController.php @@ -93,6 +93,7 @@ class InvoicesController extends Controller return new InvoiceResource($invoice->load([ 'creditNotes:id,related_invoice_id,invoice_number,total', 'creditNotes.items:id,invoice_id,source_invoice_item_id,quantity', + 'allocations.payment', ])); } diff --git a/app/Http/Controllers/Company/Payment/CreditAllocationsController.php b/app/Http/Controllers/Company/Payment/CreditAllocationsController.php new file mode 100644 index 00000000..f717741b --- /dev/null +++ b/app/Http/Controllers/Company/Payment/CreditAllocationsController.php @@ -0,0 +1,41 @@ +authorize('view', $customer); + + abort_unless((int) $customer->company_id === (int) $request->header('company'), 404); + + $payments = Payment::query() + ->where('company_id', $customer->company_id) + ->where('customer_id', $customer->id) + ->whereIn('id', collect($request->validated('allocations'))->pluck('payment_id')->unique()) + ->get(); + + foreach ($payments as $payment) { + $this->authorize('update', $payment); + } + + $this->paymentAllocationService->applyCustomerCredits( + (int) $request->header('company'), + $customer->id, + $request->validated('allocations'), + ); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/Company/Payment/PaymentsController.php b/app/Http/Controllers/Company/Payment/PaymentsController.php index 473ef6ca..2a1eb325 100644 --- a/app/Http/Controllers/Company/Payment/PaymentsController.php +++ b/app/Http/Controllers/Company/Payment/PaymentsController.php @@ -5,9 +5,11 @@ namespace App\Http\Controllers\Company\Payment; use App\Http\Controllers\Controller; use App\Http\Requests\DeletePaymentsRequest; use App\Http\Requests\PaymentRequest; +use App\Http\Requests\ReplacePaymentAllocationsRequest; use App\Http\Requests\SendPaymentRequest; use App\Http\Resources\PaymentResource; use App\Models\Payment; +use App\Services\Document\PaymentAllocationService; use App\Services\Document\PaymentService; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -16,6 +18,7 @@ use Illuminate\Mail\Markdown; class PaymentsController extends Controller { public function __construct( + private readonly PaymentAllocationService $paymentAllocationService, private readonly PaymentService $paymentService, ) {} @@ -30,12 +33,12 @@ class PaymentsController extends Controller $limit = $request->has('limit') ? $request->limit : 10; - $payments = Payment::whereCompany() + $payments = Payment::with(['allocations.invoice']) + ->whereCompany() ->join('customers', 'customers.id', '=', 'payments.customer_id') - ->leftJoin('invoices', 'invoices.id', '=', 'payments.invoice_id') ->leftJoin('payment_methods', 'payment_methods.id', '=', 'payments.payment_method_id') ->applyFilters($request->all()) - ->select('payments.*', 'customers.name', 'invoices.invoice_number', 'payment_methods.name as payment_mode') + ->select('payments.*', 'customers.name', 'payment_methods.name as payment_mode') ->latest() ->paginateData($limit); @@ -64,7 +67,7 @@ class PaymentsController extends Controller { $this->authorize('view', $payment); - return new PaymentResource($payment); + return new PaymentResource($payment->load(['allocations.invoice'])); } public function update(PaymentRequest $request, Payment $payment) @@ -76,6 +79,17 @@ class PaymentsController extends Controller return new PaymentResource($payment); } + public function replaceAllocations(ReplacePaymentAllocationsRequest $request, Payment $payment) + { + $this->authorize('update', $payment); + + abort_unless((int) $payment->company_id === (int) $request->header('company'), 404); + + $payment = $this->paymentAllocationService->replace($payment, $request->validated('allocations')); + + return new PaymentResource($payment->load(['allocations.invoice'])); + } + public function delete(DeletePaymentsRequest $request) { $this->authorize('delete multiple payments'); diff --git a/app/Http/Controllers/Company/Report/CustomerStatementReportController.php b/app/Http/Controllers/Company/Report/CustomerStatementReportController.php new file mode 100644 index 00000000..1d45dc52 --- /dev/null +++ b/app/Http/Controllers/Company/Report/CustomerStatementReportController.php @@ -0,0 +1,54 @@ +to($customer->company_id); + + $this->authorize('view', $customer); + $this->authorize('view report', $customer->company); + + $type = $request->validated('type'); + + $statement = $this->customerStatementService->statement( + $customer, + $type, + Carbon::createFromFormat('Y-m-d', $request->validated('from_date')), + Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementService::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), + PHP_INT_MAX, + ); + $pdf = $this->customerStatementPdfService->render($statement); + + if ($request->boolean('preview')) { + return view('app.pdf.reports.customer-statement', [ + 'statement' => $statement, + 'customer' => $customer, + 'company' => $customer->company, + 'currency' => $statement['currency'], + 'logo' => $customer->company->logo_path, + ]); + } + + if ($request->boolean('download')) { + return $pdf->download(__('Customer Statement').' '.$customer->name.'.pdf'); + } + + return $pdf->stream(); + } +} diff --git a/app/Http/Controllers/CustomerPortal/Payment/PaymentsController.php b/app/Http/Controllers/CustomerPortal/Payment/PaymentsController.php index 4ba36cc3..e9e19ae7 100644 --- a/app/Http/Controllers/CustomerPortal/Payment/PaymentsController.php +++ b/app/Http/Controllers/CustomerPortal/Payment/PaymentsController.php @@ -21,16 +21,15 @@ class PaymentsController extends Controller { $limit = $request->has('limit') ? $request->limit : 10; - $payments = Payment::with(['customer', 'invoice', 'paymentMethod', 'creator']) + $payments = Payment::with(['customer', 'allocations.invoice', 'paymentMethod', 'creator']) ->whereCustomer(Auth::guard('customer')->id()) - ->leftJoin('invoices', 'invoices.id', '=', 'payments.invoice_id') ->applyFilters($request->only([ 'payment_number', 'payment_method_id', 'orderByField', 'orderBy', ])) - ->select('payments.*', 'invoices.invoice_number') + ->select('payments.*') ->latest() ->paginateData($limit); @@ -57,6 +56,6 @@ class PaymentsController extends Controller return response()->json(['error' => 'payment_not_found'], 404); } - return new PaymentResource($payment); + return new PaymentResource($payment->load(['allocations.invoice'])); } } diff --git a/app/Http/Requests/CreditAllocationRequest.php b/app/Http/Requests/CreditAllocationRequest.php new file mode 100644 index 00000000..a1a20aa8 --- /dev/null +++ b/app/Http/Requests/CreditAllocationRequest.php @@ -0,0 +1,31 @@ +> + */ + public function rules(): array + { + return [ + 'allocations' => ['required', 'array', 'min:1'], + 'allocations.*.payment_id' => ['required', 'integer'], + 'allocations.*.invoice_id' => ['required', 'integer'], + 'allocations.*.amount' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/CustomerStatementRequest.php b/app/Http/Requests/CustomerStatementRequest.php new file mode 100644 index 00000000..2c009a6a --- /dev/null +++ b/app/Http/Requests/CustomerStatementRequest.php @@ -0,0 +1,49 @@ + + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::in([CustomerStatementService::TYPE_ACTIVITY, CustomerStatementService::TYPE_OUTSTANDING])], + 'from_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity'], + 'to_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity', 'after_or_equal:from_date'], + 'as_of' => ['nullable', 'date_format:Y-m-d', 'required_if:type,outstanding'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'page' => ['nullable', 'integer', 'min:1'], + ]; + } + + protected function prepareForValidation(): void + { + $type = $this->input('type', CustomerStatementService::TYPE_ACTIVITY); + $today = Carbon::today(); + + $this->merge([ + 'type' => $type, + 'from_date' => $this->input('from_date', $today->copy()->startOfMonth()->toDateString()), + 'to_date' => $this->input('to_date', $today->toDateString()), + 'as_of' => $this->input('as_of', $today->toDateString()), + ]); + } +} diff --git a/app/Http/Requests/PaymentRequest.php b/app/Http/Requests/PaymentRequest.php index 78692859..3080d504 100644 --- a/app/Http/Requests/PaymentRequest.php +++ b/app/Http/Requests/PaymentRequest.php @@ -4,10 +4,9 @@ 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; +use Illuminate\Validation\Validator; class PaymentRequest extends FormRequest { @@ -30,20 +29,21 @@ class PaymentRequest extends FormRequest ], 'customer_id' => [ 'required', + Rule::exists('customers', 'id')->where('company_id', $this->header('company')), ], 'exchange_rate' => [ 'nullable', + 'numeric', + 'gt:0', ], - 'amount' => [ - 'required', - ], + 'amount' => ['required', 'integer', 'min:1'], 'payment_number' => [ 'required', Rule::unique('payments')->where('company_id', $this->header('company')), ], - 'invoice_id' => [ - 'nullable', - ], + 'allocations' => ['sometimes', 'array'], + 'allocations.*.invoice_id' => ['required', 'integer', 'distinct'], + 'allocations.*.amount' => ['required', 'integer', 'min:1'], 'payment_method_id' => [ 'nullable', ], @@ -61,16 +61,6 @@ 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); @@ -79,6 +69,8 @@ class PaymentRequest extends FormRequest if ((string) $customer->currency_id !== $companyCurrency) { $rules['exchange_rate'] = [ 'required', + 'numeric', + 'gt:0', ]; } } @@ -87,68 +79,36 @@ class PaymentRequest extends FormRequest } /** - * The message string IS the translation key here, as everywhere else in the - * app: the front end maps it to a localized string. + * Reject the retired field without advertising it in the generated API + * schema. Payment-to-invoice links now exist only inside allocations. */ - public function messages(): array + public function withValidator(Validator $validator): void { - 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; + $validator->after(function (Validator $validator): void { + if ($this->exists('invoice_id')) { + $validator->errors()->add( + 'invoice_id', + __('validation.prohibited', ['attribute' => 'invoice id']) + ); + } + }); } public function getPaymentPayload() { $company_currency = CompanySetting::getSetting('currency', $this->header('company')); - $current_currency = $this->currency_id; - $exchange_rate = $company_currency != $current_currency ? $this->exchange_rate : 1; $currency = Customer::find($this->customer_id)->currency_id; + $exchange_rate = (string) $company_currency !== (string) $currency + ? (float) $this->exchange_rate + : 1; return collect($this->validated()) + ->except('allocations') ->merge([ 'creator_id' => $this->user()->id, 'company_id' => $this->header('company'), 'exchange_rate' => $exchange_rate, - 'base_amount' => $this->amount * $exchange_rate, + 'base_amount' => (int) round($this->amount * $exchange_rate), 'currency_id' => $currency, ]) ->toArray(); diff --git a/app/Http/Requests/ReplacePaymentAllocationsRequest.php b/app/Http/Requests/ReplacePaymentAllocationsRequest.php new file mode 100644 index 00000000..33fdd137 --- /dev/null +++ b/app/Http/Requests/ReplacePaymentAllocationsRequest.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return [ + 'allocations' => ['present', 'array'], + 'allocations.*.invoice_id' => ['required', 'integer', 'distinct'], + 'allocations.*.amount' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/SendCustomerStatementRequest.php b/app/Http/Requests/SendCustomerStatementRequest.php new file mode 100644 index 00000000..42fc82fa --- /dev/null +++ b/app/Http/Requests/SendCustomerStatementRequest.php @@ -0,0 +1,52 @@ + + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::in([CustomerStatementService::TYPE_ACTIVITY, CustomerStatementService::TYPE_OUTSTANDING])], + 'from_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity'], + 'to_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity', 'after_or_equal:from_date'], + 'as_of' => ['nullable', 'date_format:Y-m-d', 'required_if:type,outstanding'], + 'subject' => ['required', 'string'], + 'body' => ['required', 'string'], + 'to' => ['required', 'email'], + 'cc' => ['nullable', 'email'], + 'bcc' => ['nullable', 'email'], + ]; + } + + protected function prepareForValidation(): void + { + $type = $this->input('type', CustomerStatementService::TYPE_ACTIVITY); + $today = Carbon::today(); + + $this->merge([ + 'type' => $type, + 'from_date' => $this->input('from_date', $today->copy()->startOfMonth()->toDateString()), + 'to_date' => $this->input('to_date', $today->toDateString()), + 'as_of' => $this->input('as_of', $today->toDateString()), + ]); + } +} diff --git a/app/Http/Resources/Customer/PaymentResource.php b/app/Http/Resources/Customer/PaymentResource.php index 3cf3b688..b1ffbddc 100644 --- a/app/Http/Resources/Customer/PaymentResource.php +++ b/app/Http/Resources/Customer/PaymentResource.php @@ -14,6 +14,15 @@ class PaymentResource extends JsonResource */ public function toArray($request): array { + $allocations = $this->relationLoaded('allocations') + ? $this->allocations + : $this->allocations()->with('invoice')->get(); + $allocatedAmount = (int) $allocations->sum('amount'); + $baseAllocatedAmount = (int) $allocations->sum('base_amount'); + $baseAmount = $this->base_amount === null + ? (int) round($this->amount * ($this->exchange_rate ?: 1)) + : (int) $this->base_amount; + return [ 'id' => $this->id, 'payment_number' => $this->payment_number, @@ -21,12 +30,22 @@ class PaymentResource extends JsonResource 'notes' => $this->notes, 'amount' => $this->amount, 'unique_hash' => $this->unique_hash, - 'invoice_id' => $this->invoice_id, 'company_id' => $this->company_id, 'payment_method_id' => $this->payment_method_id, 'customer_id' => $this->customer_id, 'exchange_rate' => $this->exchange_rate, - 'base_amount' => $this->base_amount, + 'base_amount' => $baseAmount, + 'allocations' => $allocations->map(fn ($allocation) => [ + 'id' => $allocation->id, + 'invoice_id' => $allocation->invoice_id, + 'amount' => $allocation->amount, + 'base_amount' => $allocation->base_amount, + 'invoice' => $allocation->invoice ? new InvoiceResource($allocation->invoice) : null, + ]), + 'allocated_amount' => $allocatedAmount, + 'unallocated_amount' => (int) ((int) $this->amount - $allocatedAmount), + 'base_allocated_amount' => $baseAllocatedAmount, + 'base_unallocated_amount' => (int) ($baseAmount - $baseAllocatedAmount), 'currency_id' => $this->currency_id, 'transaction_id' => $this->transaction_id, 'formatted_created_at' => $this->formattedCreatedAt, @@ -35,9 +54,6 @@ class PaymentResource extends JsonResource 'customer' => $this->when($this->customer()->exists(), function () { return new CustomerResource($this->customer); }), - 'invoice' => $this->when($this->invoice()->exists(), function () { - return new InvoiceResource($this->invoice); - }), 'payment_method' => $this->when($this->paymentMethod()->exists(), function () { return new PaymentMethodResource($this->paymentMethod); }), diff --git a/app/Http/Resources/CustomerResource.php b/app/Http/Resources/CustomerResource.php index 32e35948..8c65faf1 100644 --- a/app/Http/Resources/CustomerResource.php +++ b/app/Http/Resources/CustomerResource.php @@ -35,6 +35,12 @@ class CustomerResource extends JsonResource 'avatar' => $this->avatar, 'due_amount' => $this->due_amount, 'base_due_amount' => $this->base_due_amount, + 'invoice_due_amount' => $this->invoice_due_amount, + 'base_invoice_due_amount' => $this->base_invoice_due_amount, + 'available_credit' => $this->available_credit, + 'base_available_credit' => $this->base_available_credit, + 'account_balance' => $this->account_balance, + 'base_account_balance' => $this->base_account_balance, 'prefix' => $this->prefix, 'tax_id' => $this->tax_id, 'billing' => $this->when($this->billingAddress()->exists(), function () { diff --git a/app/Http/Resources/CustomerStatementResource.php b/app/Http/Resources/CustomerStatementResource.php new file mode 100644 index 00000000..f4dc6850 --- /dev/null +++ b/app/Http/Resources/CustomerStatementResource.php @@ -0,0 +1,100 @@ + + */ + public function toArray(Request $request): array + { + $statement = $this->resource; + + $data = [ + 'type' => $statement['type'], + 'customer' => new CustomerResource($statement['customer']), + 'currency' => new CurrencyResource($statement['currency']), + ]; + + if ($statement['type'] === 'activity') { + /** @var LengthAwarePaginator $entries */ + $entries = $statement['entries']; + $items = array_values(collect($entries->items())->map(fn (array $entry): array => [ + 'id' => (int) $entry['id'], + 'date' => (string) $entry['date'], + 'entry_type' => (string) $entry['entry_type'], + 'reference' => (string) $entry['reference'], + 'description' => (string) $entry['description'], + 'debit_amount' => (int) $entry['debit_amount'], + 'credit_amount' => (int) $entry['credit_amount'], + 'base_debit_amount' => (int) $entry['base_debit_amount'], + 'base_credit_amount' => (int) $entry['base_credit_amount'], + 'balance' => (int) $entry['balance'], + 'base_balance' => (int) $entry['base_balance'], + ])->all()); + + return array_merge($data, [ + 'from_date' => (string) $statement['from_date'], + 'to_date' => (string) $statement['to_date'], + 'opening_balance' => (int) $statement['opening_balance'], + 'base_opening_balance' => (int) $statement['base_opening_balance'], + 'closing_balance' => (int) $statement['closing_balance'], + 'base_closing_balance' => (int) $statement['base_closing_balance'], + 'entries' => $items, + 'meta' => [ + 'current_page' => (int) $entries->currentPage(), + 'last_page' => (int) $entries->lastPage(), + 'per_page' => (int) $entries->perPage(), + 'total' => (int) $entries->total(), + ], + ]); + } + + $invoices = array_values(collect($statement['invoices'])->map(fn (array $invoice): array => [ + 'id' => (int) $invoice['id'], + 'invoice_number' => (string) $invoice['invoice_number'], + 'invoice_date' => (string) $invoice['invoice_date'], + 'due_date' => $invoice['due_date'] === null ? null : (string) $invoice['due_date'], + 'original_amount' => (int) $invoice['original_amount'], + 'allocated_amount' => (int) $invoice['allocated_amount'], + 'credit_amount' => (int) $invoice['credit_amount'], + 'applied_amount' => (int) $invoice['applied_amount'], + 'remaining_amount' => (int) $invoice['remaining_amount'], + 'base_original_amount' => (int) $invoice['base_original_amount'], + 'base_allocated_amount' => (int) $invoice['base_allocated_amount'], + 'base_credit_amount' => (int) $invoice['base_credit_amount'], + 'base_applied_amount' => (int) $invoice['base_applied_amount'], + 'base_remaining_amount' => (int) $invoice['base_remaining_amount'], + ])->all()); + $credits = array_values(collect($statement['credits'])->map(fn (array $credit): array => [ + 'id' => (int) $credit['id'], + 'payment_number' => (string) $credit['payment_number'], + 'payment_date' => (string) $credit['payment_date'], + 'amount' => (int) $credit['amount'], + 'allocated_amount' => (int) $credit['allocated_amount'], + 'available_amount' => (int) $credit['available_amount'], + 'base_amount' => (int) $credit['base_amount'], + 'base_allocated_amount' => (int) $credit['base_allocated_amount'], + 'base_available_amount' => (int) $credit['base_available_amount'], + ])->all()); + + return array_merge($data, [ + 'as_of' => (string) $statement['as_of'], + 'invoices' => $invoices, + 'credits' => $credits, + 'invoice_due_amount' => (int) $statement['invoice_due_amount'], + 'base_invoice_due_amount' => (int) $statement['base_invoice_due_amount'], + 'available_credit' => (int) $statement['available_credit'], + 'base_available_credit' => (int) $statement['base_available_credit'], + 'account_balance' => (int) $statement['account_balance'], + 'base_account_balance' => (int) $statement['base_account_balance'], + ]); + } +} diff --git a/app/Http/Resources/InvoiceResource.php b/app/Http/Resources/InvoiceResource.php index 64aff4b3..b250a7da 100644 --- a/app/Http/Resources/InvoiceResource.php +++ b/app/Http/Resources/InvoiceResource.php @@ -120,6 +120,24 @@ class InvoiceResource extends JsonResource return (object) $quantities; } ), + // Allocation rows explain how this invoice was settled without + // reintroducing the removed singular payment.invoice relation. + // They are loaded for the detail response only, so index listings + // remain free of per-row payment queries. + 'payment_allocations' => $this->when( + $this->relationLoaded('allocations'), + fn () => $this->allocations->map(fn ($allocation) => [ + 'id' => $allocation->id, + 'payment_id' => $allocation->payment_id, + 'amount' => $allocation->amount, + 'base_amount' => $allocation->base_amount, + 'payment' => $allocation->relationLoaded('payment') && $allocation->payment ? [ + 'id' => $allocation->payment->id, + 'payment_number' => $allocation->payment->payment_number, + 'formatted_payment_date' => $allocation->payment->formattedPaymentDate, + ] : null, + ])->values() + ), 'items' => $this->when($this->items()->exists(), function () { return InvoiceItemResource::collection($this->items); }), diff --git a/app/Http/Resources/PaymentResource.php b/app/Http/Resources/PaymentResource.php index 2c69709e..abf433d7 100644 --- a/app/Http/Resources/PaymentResource.php +++ b/app/Http/Resources/PaymentResource.php @@ -14,6 +14,15 @@ class PaymentResource extends JsonResource */ public function toArray($request): array { + $allocations = $this->relationLoaded('allocations') + ? $this->allocations + : $this->allocations()->with('invoice')->get(); + $allocatedAmount = (int) $allocations->sum('amount'); + $baseAllocatedAmount = (int) $allocations->sum('base_amount'); + $baseAmount = $this->base_amount === null + ? (int) round($this->amount * ($this->exchange_rate ?: 1)) + : (int) $this->base_amount; + return [ 'id' => $this->id, 'payment_number' => $this->payment_number, @@ -21,13 +30,23 @@ class PaymentResource extends JsonResource 'notes' => $this->getNotes(), 'amount' => $this->amount, 'unique_hash' => $this->unique_hash, - 'invoice_id' => $this->invoice_id, 'company_id' => $this->company_id, 'payment_method_id' => $this->payment_method_id, 'creator_id' => $this->creator_id, 'customer_id' => $this->customer_id, 'exchange_rate' => $this->exchange_rate, - 'base_amount' => $this->base_amount, + 'base_amount' => $baseAmount, + 'allocations' => $allocations->map(fn ($allocation) => [ + 'id' => $allocation->id, + 'invoice_id' => $allocation->invoice_id, + 'amount' => $allocation->amount, + 'base_amount' => $allocation->base_amount, + 'invoice' => $allocation->invoice ? new InvoiceResource($allocation->invoice) : null, + ]), + 'allocated_amount' => $allocatedAmount, + 'unallocated_amount' => (int) ((int) $this->amount - $allocatedAmount), + 'base_allocated_amount' => $baseAllocatedAmount, + 'base_unallocated_amount' => (int) ($baseAmount - $baseAllocatedAmount), 'currency_id' => $this->currency_id, 'transaction_id' => $this->transaction_id, 'sequence_number' => $this->sequence_number, @@ -37,9 +56,6 @@ class PaymentResource extends JsonResource 'customer' => $this->when($this->customer()->exists(), function () { return new CustomerResource($this->customer); }), - 'invoice' => $this->when($this->invoice()->exists(), function () { - return new InvoiceResource($this->invoice); - }), 'payment_method' => $this->when($this->paymentMethod()->exists(), function () { return new PaymentMethodResource($this->paymentMethod); }), diff --git a/app/Mail/SendCustomerStatementMail.php b/app/Mail/SendCustomerStatementMail.php new file mode 100644 index 00000000..73d797f6 --- /dev/null +++ b/app/Mail/SendCustomerStatementMail.php @@ -0,0 +1,39 @@ +data = $data; + } + + public function build() + { + 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' => $this->data['customer']::class, + 'mailable_id' => $this->data['customer']->id, + ]); + + return $this->from($this->data['from'], $this->data['from_name']) + ->subject($this->data['subject']) + ->markdown('emails.send.customer-statement', ['data' => $this->data]) + ->attachData($this->data['pdf']->output(), $this->data['filename']); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index bcd2283d..b5e2454e 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -85,6 +86,11 @@ class Customer extends Authenticatable implements HasMedia return $this->hasMany(Payment::class); } + public function emailLogs(): MorphMany + { + return $this->morphMany(EmailLog::class, 'mailable'); + } + public function addresses(): HasMany { return $this->hasMany(Address::class); diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 0d0f07e8..a4728ab1 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -12,6 +12,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; @@ -96,9 +97,16 @@ class Invoice extends Model implements HasMedia return $this->hasMany(Tax::class); } - public function payments(): HasMany + public function allocations(): HasMany { - return $this->hasMany(Payment::class); + return $this->hasMany(PaymentAllocation::class); + } + + public function payments(): BelongsToMany + { + return $this->belongsToMany(Payment::class, 'payment_allocations') + ->withPivot(['amount', 'base_amount']) + ->withTimestamps(); } public function currency(): BelongsTo diff --git a/app/Models/Payment.php b/app/Models/Payment.php index add37c5e..5e367718 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -12,7 +12,10 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Support\Facades\DB; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; @@ -44,11 +47,11 @@ class Payment extends Model implements HasMedia protected static function booted() { static::created(function ($payment) { - GeneratePaymentPdfJob::dispatch($payment); + DB::afterCommit(fn () => GeneratePaymentPdfJob::dispatch($payment)->afterCommit()); }); static::updated(function ($payment) { - GeneratePaymentPdfJob::dispatch($payment, true); + DB::afterCommit(fn () => GeneratePaymentPdfJob::dispatch($payment, true)->afterCommit()); }); } @@ -98,9 +101,16 @@ class Payment extends Model implements HasMedia return $this->belongsTo(Company::class); } - public function invoice(): BelongsTo + public function allocations(): HasMany { - return $this->belongsTo(Invoice::class); + return $this->hasMany(PaymentAllocation::class); + } + + public function invoices(): BelongsToMany + { + return $this->belongsToMany(Invoice::class, 'payment_allocations') + ->withPivot(['amount', 'base_amount']) + ->withTimestamps(); } public function creator(): BelongsTo diff --git a/app/Models/PaymentAllocation.php b/app/Models/PaymentAllocation.php new file mode 100644 index 00000000..b4d7d7f6 --- /dev/null +++ b/app/Models/PaymentAllocation.php @@ -0,0 +1,32 @@ + 'integer', + 'base_amount' => 'integer', + ]; + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } +} diff --git a/app/Services/Ai/Tools/ListRecentPaymentsTool.php b/app/Services/Ai/Tools/ListRecentPaymentsTool.php index 4ddde204..d5d1caa8 100644 --- a/app/Services/Ai/Tools/ListRecentPaymentsTool.php +++ b/app/Services/Ai/Tools/ListRecentPaymentsTool.php @@ -22,7 +22,7 @@ class ListRecentPaymentsTool extends AiTool public function description(): string { - return 'List payments received in the last N days for the current company, sorted most recent first. Returns payment number, customer, amount, payment date, and payment method.'; + return 'List payments received in the last N days for the current company, sorted most recent first. Returns payment number, customer, amount, allocation breakdown, unapplied credit, payment date, and payment method.'; } public function parameterSchema(): array @@ -62,7 +62,7 @@ class ListRecentPaymentsTool extends AiTool $payments = Payment::query() ->where('company_id', $companyId) ->where('payment_date', '>=', $since) - ->with(['customer:id,name', 'paymentMethod:id,name']) + ->with(['allocations:id,payment_id,invoice_id,amount', 'customer:id,name', 'paymentMethod:id,name']) ->latest('payment_date') ->limit($limit) ->get(); @@ -76,7 +76,12 @@ class ListRecentPaymentsTool extends AiTool 'amount' => $p->amount, 'customer_id' => $p->customer_id, 'customer_name' => $p->customer?->name, - 'invoice_id' => $p->invoice_id, + 'allocations' => $p->allocations->map(fn ($allocation) => [ + 'invoice_id' => $allocation->invoice_id, + 'amount' => $allocation->amount, + ])->all(), + 'allocated_amount' => (int) $p->allocations->sum('amount'), + 'unallocated_amount' => (int) $p->amount - (int) $p->allocations->sum('amount'), 'payment_method' => $p->paymentMethod?->name, ])->all(), ]; diff --git a/app/Services/Company/CompanyService.php b/app/Services/Company/CompanyService.php index 5afb98dd..b2c3e1d4 100644 --- a/app/Services/Company/CompanyService.php +++ b/app/Services/Company/CompanyService.php @@ -4,6 +4,7 @@ namespace App\Services\Company; use App\Models\Company; use App\Models\CompanySetting; +use App\Models\PaymentAllocation; use App\Models\PaymentMethod; use App\Models\Unit; use App\Models\User; @@ -56,6 +57,9 @@ class CompanyService } if ($company->payments()->exists()) { + PaymentAllocation::query() + ->whereIn('payment_id', $company->payments()->select('id')) + ->delete(); $company->payments()->delete(); } diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php index aa3e0405..c36366d6 100644 --- a/app/Services/CustomerService.php +++ b/app/Services/CustomerService.php @@ -7,6 +7,7 @@ use App\Models\Customer; use App\Models\Expense; use App\Models\Invoice; use App\Models\Payment; +use App\Models\PaymentAllocation; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -86,6 +87,16 @@ class CustomerService $customer->estimates()->delete(); } + // There are no database foreign keys for payment allocations. Clear + // them before the bulk payment delete, then remove invoices so an + // allocation cannot outlive either side of the relationship. + if ($customer->payments()->exists()) { + PaymentAllocation::query() + ->whereIn('payment_id', $customer->payments()->select('id')) + ->delete(); + $customer->payments()->delete(); + } + if ($customer->invoices()->exists()) { $customer->invoices->map(function ($invoice) { if ($invoice->transactions()->exists()) { @@ -95,10 +106,6 @@ class CustomerService }); } - if ($customer->payments()->exists()) { - $customer->payments()->delete(); - } - if ($customer->addresses()->exists()) { $customer->addresses()->delete(); } diff --git a/app/Services/CustomerStatementPdfService.php b/app/Services/CustomerStatementPdfService.php new file mode 100644 index 00000000..b1dadad5 --- /dev/null +++ b/app/Services/CustomerStatementPdfService.php @@ -0,0 +1,29 @@ +company; + + App::setLocale(CompanySetting::getSetting('language', $company->id)); + + view()->share([ + 'statement' => $statement, + 'customer' => $customer, + 'company' => $company, + 'currency' => $statement['currency'], + 'logo' => $company->logo_path, + ]); + + return Pdf::loadView('app.pdf.reports.customer-statement', [], PdfPageSetup::forReports()); + } +} diff --git a/app/Services/CustomerStatementService.php b/app/Services/CustomerStatementService.php new file mode 100644 index 00000000..2fa0074d --- /dev/null +++ b/app/Services/CustomerStatementService.php @@ -0,0 +1,363 @@ +loadMissing(['company', 'currency']); + + return $type === self::TYPE_OUTSTANDING + ? $this->outstandingStatement($customer, $to) + : $this->activityStatement($customer, $from, $to, $perPage, $page); + } + + /** + * Add the account-summary fields expected by the customer API without + * persisting derived balances on customers. + */ + public function hydrateAccountSummaries(iterable $customers): void + { + $customers = collect($customers)->values(); + + if ($customers->isEmpty()) { + return; + } + + $customerIds = $customers->pluck('id')->all(); + $invoiceTotals = Invoice::query() + ->whereIn('customer_id', $customerIds) + ->where('type', Invoice::TYPE_INVOICE) + ->where('status', '!=', Invoice::STATUS_DRAFT) + ->select('customer_id') + ->selectRaw('COALESCE(SUM(due_amount), 0) as invoice_due_amount') + ->selectRaw('COALESCE(SUM(base_due_amount), 0) as base_invoice_due_amount') + ->groupBy('customer_id') + ->get() + ->keyBy('customer_id'); + + $allocationTotals = PaymentAllocation::query() + ->select('payment_id') + ->selectRaw('COALESCE(SUM(amount), 0) as allocated_amount') + ->selectRaw('COALESCE(SUM(base_amount), 0) as base_allocated_amount') + ->groupBy('payment_id'); + + $paymentTotals = Payment::query() + ->whereIn('customer_id', $customerIds) + ->leftJoinSub($allocationTotals, 'allocation_totals', function ($join) { + $join->on('payments.id', '=', 'allocation_totals.payment_id'); + }) + ->select('payments.customer_id') + ->selectRaw('COALESCE(SUM(payments.amount), 0) as payment_amount') + ->selectRaw('COALESCE(SUM(payments.base_amount), 0) as base_payment_amount') + ->selectRaw('COALESCE(SUM(COALESCE(allocation_totals.allocated_amount, 0)), 0) as allocated_amount') + ->selectRaw('COALESCE(SUM(COALESCE(allocation_totals.base_allocated_amount, 0)), 0) as base_allocated_amount') + ->groupBy('payments.customer_id') + ->get() + ->keyBy('customer_id'); + + foreach ($customers as $customer) { + $invoice = $invoiceTotals->get($customer->id); + $payment = $paymentTotals->get($customer->id); + + $invoiceDue = (int) ($invoice->invoice_due_amount ?? 0); + $baseInvoiceDue = (int) ($invoice->base_invoice_due_amount ?? 0); + $paymentTotal = (int) ($payment->payment_amount ?? 0); + $basePaymentTotal = (int) ($payment->base_payment_amount ?? 0); + $allocated = (int) ($payment->allocated_amount ?? 0); + $baseAllocated = (int) ($payment->base_allocated_amount ?? 0); + + $credit = max(0, $paymentTotal - $allocated); + $baseCredit = max(0, $basePaymentTotal - $baseAllocated); + + $customer->setAttribute('invoice_due_amount', $invoiceDue); + $customer->setAttribute('base_invoice_due_amount', $baseInvoiceDue); + $customer->setAttribute('available_credit', $credit); + $customer->setAttribute('base_available_credit', $baseCredit); + $customer->setAttribute('account_balance', $invoiceDue - $credit); + $customer->setAttribute('base_account_balance', $baseInvoiceDue - $baseCredit); + + // Keep this long-standing response field meaningful for clients + // which have not yet adopted the richer account summary. + $customer->setAttribute('due_amount', $invoiceDue); + $customer->setAttribute('base_due_amount', $baseInvoiceDue); + } + } + + public function accountSummary(Customer $customer): array + { + $this->hydrateAccountSummaries([$customer]); + + return [ + 'invoice_due_amount' => (int) $customer->invoice_due_amount, + 'base_invoice_due_amount' => (int) $customer->base_invoice_due_amount, + 'available_credit' => (int) $customer->available_credit, + 'base_available_credit' => (int) $customer->base_available_credit, + 'account_balance' => (int) $customer->account_balance, + 'base_account_balance' => (int) $customer->base_account_balance, + ]; + } + + private function activityStatement(Customer $customer, Carbon $from, Carbon $to, int $perPage, int $page): array + { + $openingBalance = $this->activityBalanceBefore($customer, $from); + $entries = collect(); + + $documents = $this->statementDocuments($customer) + ->whereBetween('invoice_date', [$from->toDateString(), $to->toDateString()]) + ->get(['id', 'invoice_date', 'invoice_number', 'type', 'total', 'base_total']); + + foreach ($documents as $document) { + $isCreditNote = $document->type === Invoice::TYPE_CREDIT_NOTE; + $entries->push([ + 'id' => $document->id, + 'date' => Carbon::parse($document->invoice_date)->toDateString(), + 'entry_type' => $isCreditNote ? 'credit_note' : 'invoice', + 'reference' => $document->invoice_number, + 'description' => $isCreditNote ? __('Credit note') : __('Invoice'), + 'debit_amount' => $isCreditNote ? 0 : (int) $document->total, + 'credit_amount' => $isCreditNote ? abs((int) $document->total) : 0, + 'base_debit_amount' => $isCreditNote ? 0 : (int) $document->base_total, + 'base_credit_amount' => $isCreditNote ? abs((int) $document->base_total) : 0, + 'sort_order' => $isCreditNote ? 1 : 0, + ]); + } + + $payments = Payment::query() + ->where('company_id', $customer->company_id) + ->where('customer_id', $customer->id) + ->whereBetween('payment_date', [$from->toDateString(), $to->toDateString()]) + ->get(['id', 'payment_date', 'payment_number', 'amount', 'base_amount']); + + foreach ($payments as $payment) { + $entries->push([ + 'id' => $payment->id, + 'date' => Carbon::parse($payment->payment_date)->toDateString(), + 'entry_type' => 'payment', + 'reference' => $payment->payment_number, + 'description' => __('Payment'), + 'debit_amount' => 0, + 'credit_amount' => (int) $payment->amount, + 'base_debit_amount' => 0, + 'base_credit_amount' => (int) $payment->base_amount, + 'sort_order' => 2, + ]); + } + + $entries = $entries + ->sort(fn (array $left, array $right) => [$left['date'], $left['sort_order'], $left['id']] <=> [$right['date'], $right['sort_order'], $right['id']]) + ->values(); + + $runningBalance = $openingBalance['amount']; + $baseRunningBalance = $openingBalance['base_amount']; + $entries = $entries->map(function (array $entry) use (&$runningBalance, &$baseRunningBalance) { + $runningBalance += $entry['debit_amount'] - $entry['credit_amount']; + $baseRunningBalance += $entry['base_debit_amount'] - $entry['base_credit_amount']; + $entry['balance'] = $runningBalance; + $entry['base_balance'] = $baseRunningBalance; + unset($entry['sort_order']); + + return $entry; + }); + + $paginator = new LengthAwarePaginator( + $entries->forPage($page, $perPage)->values(), + $entries->count(), + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->query()] + ); + + return [ + 'type' => self::TYPE_ACTIVITY, + 'customer' => $customer, + 'currency' => $customer->currency, + 'from_date' => $from->toDateString(), + 'to_date' => $to->toDateString(), + 'opening_balance' => $openingBalance['amount'], + 'base_opening_balance' => $openingBalance['base_amount'], + 'closing_balance' => $runningBalance, + 'base_closing_balance' => $baseRunningBalance, + 'entries' => $paginator, + ]; + } + + private function outstandingStatement(Customer $customer, Carbon $asOf): array + { + $invoices = Invoice::query() + ->where('company_id', $customer->company_id) + ->where('customer_id', $customer->id) + ->where('type', Invoice::TYPE_INVOICE) + ->where('status', '!=', Invoice::STATUS_DRAFT) + ->where('invoice_date', '<=', $asOf->toDateString()) + ->withSum([ + 'creditNotes as credited_amount' => fn ($query) => $query->where('invoice_date', '<=', $asOf->toDateString()), + ], 'total') + ->withSum([ + 'creditNotes as base_credited_amount' => fn ($query) => $query->where('invoice_date', '<=', $asOf->toDateString()), + ], 'base_total') + ->orderBy('due_date') + ->orderBy('id') + ->get(['id', 'invoice_date', 'due_date', 'invoice_number', 'total', 'base_total']); + + $invoiceAllocations = $this->allocationTotalsForInvoices($invoices->pluck('id'), $asOf); + $openInvoices = $invoices->map(function (Invoice $invoice) use ($invoiceAllocations) { + $allocation = $invoiceAllocations->get($invoice->id, ['amount' => 0, 'base_amount' => 0]); + $credit = max(0, -(int) ($invoice->credited_amount ?? 0)); + $baseCredit = max(0, -(int) ($invoice->base_credited_amount ?? 0)); + $remaining = max(0, (int) $invoice->total - $credit - $allocation['amount']); + $baseRemaining = max(0, (int) $invoice->base_total - $baseCredit - $allocation['base_amount']); + + return [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'invoice_date' => Carbon::parse($invoice->invoice_date)->toDateString(), + 'due_date' => $invoice->due_date ? Carbon::parse($invoice->due_date)->toDateString() : null, + 'original_amount' => (int) $invoice->total, + 'allocated_amount' => $allocation['amount'], + 'credit_amount' => $credit, + 'applied_amount' => $allocation['amount'] + $credit, + 'remaining_amount' => $remaining, + 'base_original_amount' => (int) $invoice->base_total, + 'base_allocated_amount' => $allocation['base_amount'], + 'base_credit_amount' => $baseCredit, + 'base_applied_amount' => $allocation['base_amount'] + $baseCredit, + 'base_remaining_amount' => $baseRemaining, + ]; + })->filter(fn (array $invoice) => $invoice['remaining_amount'] > 0)->values(); + + $payments = Payment::query() + ->where('company_id', $customer->company_id) + ->where('customer_id', $customer->id) + ->where('payment_date', '<=', $asOf->toDateString()) + ->orderBy('payment_date') + ->orderBy('id') + ->get(['id', 'payment_date', 'payment_number', 'amount', 'base_amount']); + $paymentAllocations = $this->allocationTotalsForPayments($payments->pluck('id'), $asOf); + + $credits = $payments->map(function (Payment $payment) use ($paymentAllocations) { + $allocation = $paymentAllocations->get($payment->id, ['amount' => 0, 'base_amount' => 0]); + $available = max(0, (int) $payment->amount - $allocation['amount']); + + return [ + 'id' => $payment->id, + 'payment_number' => $payment->payment_number, + 'payment_date' => Carbon::parse($payment->payment_date)->toDateString(), + 'amount' => (int) $payment->amount, + 'allocated_amount' => $allocation['amount'], + 'available_amount' => $available, + 'base_amount' => (int) $payment->base_amount, + 'base_allocated_amount' => $allocation['base_amount'], + 'base_available_amount' => max(0, (int) $payment->base_amount - $allocation['base_amount']), + ]; + })->filter(fn (array $payment) => $payment['available_amount'] > 0)->values(); + + $invoiceDue = (int) $openInvoices->sum('remaining_amount'); + $baseInvoiceDue = (int) $openInvoices->sum('base_remaining_amount'); + $availableCredit = (int) $credits->sum('available_amount'); + $baseAvailableCredit = (int) $credits->sum('base_available_amount'); + + return [ + 'type' => self::TYPE_OUTSTANDING, + 'customer' => $customer, + 'currency' => $customer->currency, + 'as_of' => $asOf->toDateString(), + 'invoices' => $openInvoices, + 'credits' => $credits, + 'invoice_due_amount' => $invoiceDue, + 'base_invoice_due_amount' => $baseInvoiceDue, + 'available_credit' => $availableCredit, + 'base_available_credit' => $baseAvailableCredit, + 'account_balance' => $invoiceDue - $availableCredit, + 'base_account_balance' => $baseInvoiceDue - $baseAvailableCredit, + ]; + } + + private function activityBalanceBefore(Customer $customer, Carbon $from): array + { + $documents = $this->statementDocuments($customer) + ->where('invoice_date', '<', $from->toDateString()); + $payments = Payment::query() + ->where('company_id', $customer->company_id) + ->where('customer_id', $customer->id) + ->where('payment_date', '<', $from->toDateString()); + + return [ + 'amount' => (int) $documents->sum('total') - (int) $payments->sum('amount'), + 'base_amount' => (int) $documents->sum('base_total') - (int) $payments->sum('base_amount'), + ]; + } + + private function statementDocuments(Customer $customer) + { + return Invoice::query() + ->where('company_id', $customer->company_id) + ->where('customer_id', $customer->id) + ->where(function ($query) { + $query->where('type', Invoice::TYPE_CREDIT_NOTE) + ->orWhere(function ($query) { + $query->where('type', Invoice::TYPE_INVOICE) + ->where('status', '!=', Invoice::STATUS_DRAFT); + }); + }); + } + + private function allocationTotalsForInvoices(Collection $invoiceIds, Carbon $asOf): Collection + { + if ($invoiceIds->isEmpty()) { + return collect(); + } + + return PaymentAllocation::query() + ->join('payments', 'payments.id', '=', 'payment_allocations.payment_id') + ->whereIn('payment_allocations.invoice_id', $invoiceIds) + ->where('payments.payment_date', '<=', $asOf->toDateString()) + ->where('payment_allocations.created_at', '<=', $asOf->copy()->endOfDay()) + ->selectRaw('payment_allocations.invoice_id, SUM(payment_allocations.amount) as amount, SUM(payment_allocations.base_amount) as base_amount') + ->groupBy('payment_allocations.invoice_id') + ->get() + ->mapWithKeys(fn (PaymentAllocation $allocation) => [$allocation->invoice_id => [ + 'amount' => (int) $allocation->amount, + 'base_amount' => (int) $allocation->base_amount, + ]]); + } + + private function allocationTotalsForPayments(Collection $paymentIds, ?Carbon $asOf = null): Collection + { + if ($paymentIds->isEmpty()) { + return collect(); + } + + $query = PaymentAllocation::query() + ->whereIn('payment_id', $paymentIds) + ->selectRaw('payment_id, SUM(amount) as amount, SUM(base_amount) as base_amount') + ->groupBy('payment_id'); + + if ($asOf) { + $query->where('created_at', '<=', $asOf->copy()->endOfDay()); + } + + return $query + ->get() + ->mapWithKeys(fn (PaymentAllocation $allocation) => [$allocation->payment_id => [ + 'amount' => (int) $allocation->amount, + 'base_amount' => (int) $allocation->base_amount, + ]]); + } +} diff --git a/app/Services/Document/CreditNoteService.php b/app/Services/Document/CreditNoteService.php index 28129a59..80cbdce2 100644 --- a/app/Services/Document/CreditNoteService.php +++ b/app/Services/Document/CreditNoteService.php @@ -33,6 +33,7 @@ class CreditNoteService { public function __construct( private readonly DocumentItemService $documentItemService, + private readonly InvoiceBalanceService $invoiceBalanceService, ) {} /** @@ -63,7 +64,7 @@ class CreditNoteService $before = $this->creditedQuantities($original); $after = $this->targetQuantities($invoiced, $before, $items); - $paid = (int) $original->payments()->sum('amount'); + $paid = (int) $original->allocations()->sum('amount'); $creditedBefore = $this->creditedTotal($original); $this->guard($original, $invoiced, $before, $after, $paid, $creditedBefore); @@ -118,43 +119,9 @@ class CreditNoteService 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(); + $this->invoiceBalanceService->recalculate($invoice); } /** diff --git a/app/Services/Document/InvoiceBalanceService.php b/app/Services/Document/InvoiceBalanceService.php new file mode 100644 index 00000000..edb46918 --- /dev/null +++ b/app/Services/Document/InvoiceBalanceService.php @@ -0,0 +1,45 @@ +allocations()->sum('amount'); + } + + public function creditedTotal(Invoice $invoice): int + { + return -(int) $invoice->creditNotes()->sum('total'); + } + + /** + * Recalculate an invoice exclusively from durable credits and payment + * allocations. Callers that change allocations must hold the invoice lock. + */ + public function recalculate(Invoice $invoice): void + { + $allocated = $this->allocatedTotal($invoice); + $credited = $this->creditedTotal($invoice); + $due = max(0, (int) $invoice->total - $allocated - $credited); + + $invoice->due_amount = $due; + $invoice->base_due_amount = (int) round($due * $invoice->exchange_rate); + + if ($due === 0) { + $invoice->status = Invoice::STATUS_COMPLETED; + $invoice->paid_status = Invoice::STATUS_PAID; + $invoice->overdue = false; + } else { + $invoice->status = $invoice->getPreviousStatus(); + $invoice->paid_status = $allocated > 0 + ? Invoice::STATUS_PARTIALLY_PAID + : Invoice::STATUS_UNPAID; + } + + $invoice->save(); + } +} diff --git a/app/Services/Document/InvoiceService.php b/app/Services/Document/InvoiceService.php index f140166e..925ce9e0 100644 --- a/app/Services/Document/InvoiceService.php +++ b/app/Services/Document/InvoiceService.php @@ -171,6 +171,12 @@ class InvoiceService foreach ($ids as $id) { $invoice = Invoice::find($id); + if ($invoice->allocations()->exists()) { + throw ValidationException::withMessages([ + 'invoice' => ['invoice_has_payment_allocations'], + ]); + } + if ($invoice->transactions()->exists()) { $invoice->transactions()->delete(); } @@ -477,7 +483,7 @@ class InvoiceService $invoice->sent = true; $invoice->save(); } elseif ($status == Invoice::STATUS_COMPLETED) { - $paid = (int) $invoice->payments()->sum('amount'); + $paid = (int) $invoice->allocations()->sum('amount'); $credited = $this->creditNoteService->creditedTotal($invoice); $outstanding = max(0, (int) $invoice->total - $paid - $credited); diff --git a/app/Services/Document/PaymentAllocationService.php b/app/Services/Document/PaymentAllocationService.php new file mode 100644 index 00000000..d879a98d --- /dev/null +++ b/app/Services/Document/PaymentAllocationService.php @@ -0,0 +1,328 @@ +whereKey($payment->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $this->replaceLocked($lockedPayment, $allocations); + + return $lockedPayment; + }); + } + + /** + * Atomically apply existing unapplied credit to one or more invoices. The + * supplied rows are additive: existing allocations remain in place. + */ + public function applyCustomerCredits(int $companyId, int $customerId, array $allocations): void + { + DB::transaction(function () use ($companyId, $customerId, $allocations): void { + $this->assertCreditRows($allocations); + + $paymentIds = collect($allocations)->pluck('payment_id')->map(fn ($id) => (int) $id)->unique()->sort()->values(); + $invoiceIds = collect($allocations)->pluck('invoice_id')->map(fn ($id) => (int) $id)->unique()->sort()->values(); + + $payments = Payment::query() + ->whereIn('id', $paymentIds) + ->where('company_id', $companyId) + ->where('customer_id', $customerId) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + + if ($payments->count() !== $paymentIds->count()) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_payment_not_found'], + ]); + } + + $existingInvoiceIds = PaymentAllocation::query() + ->whereIn('payment_id', $paymentIds) + ->pluck('invoice_id'); + $allInvoiceIds = $existingInvoiceIds->concat($invoiceIds)->unique()->sort()->values(); + + // Lock every invoice before changing any allocation. This is the + // same lock order used by replaceLocked(), avoiding overspending + // the balance when two credits target the same invoice. + Invoice::query() + ->whereIn('id', $allInvoiceIds) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($payments as $payment) { + $existing = $payment->allocations() + ->get(['invoice_id', 'amount']) + ->map(fn (PaymentAllocation $allocation) => [ + 'invoice_id' => (int) $allocation->invoice_id, + 'amount' => (int) $allocation->amount, + ]); + $extra = collect($allocations) + ->where('payment_id', $payment->id) + ->map(fn (array $allocation) => [ + 'invoice_id' => (int) $allocation['invoice_id'], + 'amount' => (int) $allocation['amount'], + ]); + + $target = $existing->concat($extra) + ->groupBy('invoice_id') + ->map(fn (Collection $rows, $invoiceId) => [ + 'invoice_id' => (int) $invoiceId, + 'amount' => $rows->sum('amount'), + ]) + ->values() + ->all(); + + $this->replaceLocked($payment, $target, true); + } + }); + } + + private function replaceLocked(Payment $payment, array $allocations, bool $invoicesAlreadyLocked = false): void + { + $allocations = $this->normaliseAllocations($allocations); + $this->assertAllocationTotal($payment, $allocations); + + $oldInvoiceIds = $payment->allocations()->pluck('invoice_id'); + $invoiceIds = collect($allocations)->pluck('invoice_id')->unique()->sort()->values(); + $affectedIds = $oldInvoiceIds->concat($invoiceIds)->unique()->sort()->values(); + + // Old and new invoice rows share one sorted lock set. Clearing or + // moving an allocation changes an old invoice's available balance just + // as much as allocating to a new invoice does. + $invoices = $affectedIds->isEmpty() + ? collect() + : Invoice::query() + ->whereIn('id', $affectedIds) + ->orderBy('id') + ->when(! $invoicesAlreadyLocked, fn ($query) => $query->lockForUpdate()) + ->get() + ->keyBy('id'); + + if ($invoices->whereIn('id', $invoiceIds)->count() !== $invoiceIds->count()) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invoice_not_found'], + ]); + } + + foreach ($allocations as $allocation) { + $invoice = $invoices->get($allocation['invoice_id']); + $this->assertInvoiceCanReceiveAllocation($payment, $invoice, $allocation['amount']); + } + + $payment->allocations()->delete(); + + foreach ($this->baseAmounts($payment, $allocations) as $allocation) { + $payment->allocations()->create($allocation); + } + + foreach ($invoices as $invoice) { + $this->invoiceBalanceService->recalculate($invoice); + } + } + + private function normaliseAllocations(array $allocations): array + { + $invoiceIds = collect($allocations)->pluck('invoice_id'); + + if ($invoiceIds->count() !== $invoiceIds->unique()->count()) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_duplicate_invoice'], + ]); + } + + $normalised = collect($allocations) + ->map(function (array $allocation): array { + if (! array_key_exists('invoice_id', $allocation) || ! array_key_exists('amount', $allocation)) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invalid'], + ]); + } + + if (! $this->isInteger($allocation['invoice_id']) || ! $this->isInteger($allocation['amount'])) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invalid'], + ]); + } + + return [ + 'invoice_id' => (int) $allocation['invoice_id'], + 'amount' => (int) $allocation['amount'], + ]; + }) + ->sortBy('invoice_id') + ->values() + ->all(); + + foreach ($normalised as $allocation) { + if ($allocation['invoice_id'] < 1 || $allocation['amount'] < 1) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invalid'], + ]); + } + } + + return $normalised; + } + + private function assertAllocationTotal(Payment $payment, array $allocations): void + { + if ((int) $payment->amount < 1) { + throw ValidationException::withMessages([ + 'amount' => ['payment_amount_must_be_positive'], + ]); + } + + if (collect($allocations)->sum('amount') > (int) $payment->amount) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_exceeds_payment_amount'], + ]); + } + } + + private function assertInvoiceCanReceiveAllocation(Payment $payment, Invoice $invoice, int $amount): void + { + if ( + (int) $invoice->company_id !== (int) $payment->company_id + || (int) $invoice->customer_id !== (int) $payment->customer_id + || (int) $invoice->currency_id !== (int) $payment->currency_id + ) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invoice_mismatch'], + ]); + } + + if ($invoice->type !== Invoice::TYPE_INVOICE || $invoice->status === Invoice::STATUS_DRAFT) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invoice_not_payable'], + ]); + } + + $allocatedByOtherPayments = (int) PaymentAllocation::query() + ->where('invoice_id', $invoice->id) + ->where('payment_id', '!=', $payment->id) + ->sum('amount'); + $available = max(0, (int) $invoice->total - $this->invoiceBalanceService->creditedTotal($invoice) - $allocatedByOtherPayments); + + if ($amount > $available) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_exceeds_invoice_balance'], + ]); + } + } + + private function baseAmounts(Payment $payment, array $allocations): array + { + $paymentAmount = (int) $payment->amount; + $paymentBaseAmount = $payment->base_amount === null + ? (int) round($paymentAmount * ((float) $payment->exchange_rate ?: 1)) + : (int) $payment->base_amount; + $allocatedAmount = (int) collect($allocations)->sum('amount'); + $allocatedBaseAmount = 0; + + return collect($allocations)->values()->map(function (array $allocation, int $index) use ($paymentAmount, $paymentBaseAmount, $allocatedAmount, &$allocatedBaseAmount, $allocations): array { + $isLastFullyAllocatedRow = $allocatedAmount === $paymentAmount && $index === count($allocations) - 1; + $baseAmount = $isLastFullyAllocatedRow + ? $paymentBaseAmount - $allocatedBaseAmount + : $this->proportionalAmount($paymentBaseAmount, $allocation['amount'], $paymentAmount); + + $allocatedBaseAmount += $baseAmount; + + return [ + 'invoice_id' => $allocation['invoice_id'], + 'amount' => $allocation['amount'], + 'base_amount' => $baseAmount, + ]; + })->all(); + } + + private function assertCreditRows(array $allocations): void + { + if ($allocations === []) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_required'], + ]); + } + + foreach ($allocations as $allocation) { + if ( + ! isset($allocation['payment_id'], $allocation['invoice_id'], $allocation['amount']) + || ! $this->isInteger($allocation['payment_id']) + || ! $this->isInteger($allocation['invoice_id']) + || ! $this->isInteger($allocation['amount']) + || (int) $allocation['payment_id'] < 1 + || (int) $allocation['invoice_id'] < 1 + || (int) $allocation['amount'] < 1 + ) { + throw ValidationException::withMessages([ + 'allocations' => ['payment_allocation_invalid'], + ]); + } + } + } + + private function isInteger(mixed $value): bool + { + return filter_var($value, FILTER_VALIDATE_INT) !== false; + } + + /** + * Calculate floor(baseAmount * allocationAmount / paymentAmount) without + * overflowing an intermediate product. Every intermediate stays below the + * payment amount except the bounded final result. + */ + private function proportionalAmount(int $baseAmount, int $allocationAmount, int $paymentAmount): int + { + $whole = intdiv($baseAmount, $paymentAmount) * $allocationAmount; + $remainder = $baseAmount % $paymentAmount; + $quotient = 0; + $modulo = 0; + $factor = $remainder; + $multiplier = $allocationAmount; + + while ($multiplier > 0) { + if ($multiplier % 2 === 1) { + if ($modulo >= $paymentAmount - $factor) { + $quotient++; + $modulo -= $paymentAmount - $factor; + } else { + $modulo += $factor; + } + } + + if ($factor >= $paymentAmount - $factor) { + $factor -= $paymentAmount - $factor; + } else { + $factor += $factor; + } + + $multiplier = intdiv($multiplier, 2); + } + + return $whole + $quotient; + } +} diff --git a/app/Services/Document/PaymentService.php b/app/Services/Document/PaymentService.php index c32fdb01..6feff4e8 100644 --- a/app/Services/Document/PaymentService.php +++ b/app/Services/Document/PaymentService.php @@ -16,129 +16,118 @@ use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class PaymentService { + public function __construct( + private readonly PaymentAllocationService $paymentAllocationService, + ) {} + public function create(Request $request): Payment { $data = $request->getPaymentPayload(); + $allocations = $request->validated('allocations') ?? []; - if ($request->invoice_id) { - $invoice = Invoice::find($request->invoice_id); - $invoice->subtractInvoicePayment($request->amount); - } + $payment = DB::transaction(function () use ($data, $allocations, $request): Payment { + $payment = Payment::create($data); + $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); - $payment = Payment::create($data); - $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); + $serial = (new SerialNumberService) + ->setModel($payment) + ->setCompany($payment->company_id) + ->setCustomer($payment->customer_id) + ->setNextNumbers(); - $serial = (new SerialNumberService) - ->setModel($payment) - ->setCompany($payment->company_id) - ->setCustomer($payment->customer_id) - ->setNextNumbers(); + $payment->sequence_number = $serial->nextSequenceNumber; + $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; + $payment->save(); - $payment->sequence_number = $serial->nextSequenceNumber; - $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $payment->save(); + $this->paymentAllocationService->replace($payment, $allocations); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - if ((string) $payment['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($payment); - } + if ((string) $payment->currency_id !== $companyCurrency) { + ExchangeRateLog::addExchangeRateLog($payment); + } - $customFields = $request->customFields; + if ($request->customFields) { + $payment->addCustomFields($request->customFields); + } - if ($customFields) { - $payment->addCustomFields($customFields); - } + return $payment; + }); - return Payment::with([ - 'customer', - 'invoice', - 'paymentMethod', - 'fields', - ])->find($payment->id); + return $this->loadPayment($payment); } public function update(Payment $payment, Request $request): Payment { $data = $request->getPaymentPayload(); + $replaceAllocations = $request->exists('allocations'); + $requestedAllocations = $request->validated('allocations') ?? []; - if ($request->invoice_id && (! $payment->invoice_id || $payment->invoice_id !== $request->invoice_id)) { - $invoice = Invoice::find($request->invoice_id); - $invoice->subtractInvoicePayment($request->amount); - } + $payment = DB::transaction(function () use ($payment, $data, $replaceAllocations, $requestedAllocations, $request): Payment { + $lockedPayment = Payment::query()->whereKey($payment->id)->lockForUpdate()->firstOrFail(); + $allocations = $replaceAllocations + ? $requestedAllocations + : $lockedPayment->allocations() + ->get(['invoice_id', 'amount']) + ->map(fn ($allocation) => [ + 'invoice_id' => (int) $allocation->invoice_id, + 'amount' => (int) $allocation->amount, + ]) + ->all(); + $customerChanged = (int) $lockedPayment->customer_id !== (int) $data['customer_id']; - if ($payment->invoice_id && (! $request->invoice_id || $payment->invoice_id !== $request->invoice_id)) { - $invoice = Invoice::find($payment->invoice_id); - $invoice->addInvoicePayment($payment->amount); - } + if ($customerChanged && $allocations !== []) { + throw ValidationException::withMessages([ + 'customer_id' => ['payment_customer_change_requires_unallocated_credit'], + ]); + } - if ($payment->invoice_id && $payment->invoice_id === $request->invoice_id && $request->amount !== $payment->amount) { - $invoice = Invoice::find($payment->invoice_id); - $invoice->addInvoicePayment($payment->amount); - $invoice->subtractInvoicePayment($request->amount); - } + $serial = (new SerialNumberService) + ->setModel($lockedPayment) + ->setCompany($lockedPayment->company_id) + ->setCustomer($data['customer_id']) + ->setModelObject($lockedPayment->id) + ->setNextNumbers(); - $serial = (new SerialNumberService) - ->setModel($payment) - ->setCompany($payment->company_id) - ->setCustomer($request->customer_id) - ->setModelObject($payment->id) - ->setNextNumbers(); + $data['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; + $lockedPayment->update($data); + $this->paymentAllocationService->replace($lockedPayment, $allocations); - $data['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; - $payment->update($data); + $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + if ((string) $lockedPayment->currency_id !== $companyCurrency) { + ExchangeRateLog::addExchangeRateLog($lockedPayment); + } - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($payment); - } + if ($request->customFields) { + $lockedPayment->updateCustomFields($request->customFields); + } - $customFields = $request->customFields; + return $lockedPayment; + }); - if ($customFields) { - $payment->updateCustomFields($customFields); - } - - return Payment::with([ - 'customer', - 'invoice', - 'paymentMethod', - ])->find($payment->id); + return $this->loadPayment($payment); } public function delete(Collection $ids): bool { - foreach ($ids as $id) { - $payment = Payment::find($id); + DB::transaction(function () use ($ids): void { + foreach ($ids->sort() as $id) { + $payment = Payment::query()->whereKey($id)->lockForUpdate()->first(); - if ($payment->invoice_id != null) { - $invoice = Invoice::find($payment->invoice_id); - $invoice->due_amount = ((int) $invoice->due_amount + (int) $payment->amount); + if (! $payment) { + continue; + } - // 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(); + $this->paymentAllocationService->replace($payment, []); + $payment->delete(); } - - $payment->delete(); - } + }); return true; } @@ -176,6 +165,8 @@ class PaymentService public function getPdfData(Payment $payment) { + $payment->loadMissing('allocations.invoice.currency'); + $company = Company::find($payment->company_id); $locale = CompanySetting::getSetting('language', $company->id); @@ -216,24 +207,38 @@ class PaymentService $data['payment_number'] = $serial->getNextNumber(); $data['payment_date'] = Carbon::now(); - $data['amount'] = $invoice->total; - $data['invoice_id'] = $invoice->id; + $data['amount'] = $invoice->due_amount; $data['payment_method_id'] = request()->payment_method_id; $data['customer_id'] = $invoice->customer_id; $data['exchange_rate'] = $invoice->exchange_rate; - $data['base_amount'] = $data['amount'] * $data['exchange_rate']; + $data['base_amount'] = (int) round($data['amount'] * $data['exchange_rate']); $data['currency_id'] = $invoice->currency_id; $data['company_id'] = $invoice->company_id; $data['transaction_id'] = $transaction->id; - $payment = Payment::create($data); - $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); - $payment->sequence_number = $serial->nextSequenceNumber; - $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $payment->save(); + return DB::transaction(function () use ($data, $serial, $invoice): Payment { + $payment = Payment::create($data); + $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); + $payment->sequence_number = $serial->nextSequenceNumber; + $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; + $payment->save(); - $invoice->subtractInvoicePayment($invoice->total); + $this->paymentAllocationService->replace($payment, [[ + 'invoice_id' => $invoice->id, + 'amount' => (int) $data['amount'], + ]]); - return $payment; + return $payment; + }); + } + + private function loadPayment(Payment $payment): Payment + { + return Payment::with([ + 'customer', + 'allocations.invoice', + 'paymentMethod', + 'fields', + ])->findOrFail($payment->id); } } diff --git a/database/factories/PaymentAllocationFactory.php b/database/factories/PaymentAllocationFactory.php new file mode 100644 index 00000000..dfe258c2 --- /dev/null +++ b/database/factories/PaymentAllocationFactory.php @@ -0,0 +1,29 @@ + + */ +class PaymentAllocationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'payment_id' => Payment::factory(), + 'invoice_id' => Invoice::factory(), + 'amount' => $this->faker->numberBetween(1, 10000), + 'base_amount' => $this->faker->numberBetween(1, 10000), + ]; + } +} diff --git a/database/migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php b/database/migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php new file mode 100644 index 00000000..c14d9c61 --- /dev/null +++ b/database/migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php @@ -0,0 +1,340 @@ +bigIncrements('id'); + $table->unsignedBigInteger('payment_id')->index(); + $table->unsignedInteger('invoice_id')->index(); + $table->unsignedBigInteger('amount'); + $table->unsignedBigInteger('base_amount'); + $table->timestamps(); + + $table->unique(['payment_id', 'invoice_id']); + }); + } + + // A fresh install replays the historical payments migration before + // reaching this migration. Existing v2/v3 databases arrive here with + // the same nullable legacy column; a retry after a partial run does + // not, and must not attempt the conversion again. + if (! Schema::hasColumn('payments', 'invoice_id')) { + $this->verifyLegacyAllocations(); + + return; + } + + DB::transaction(function (): void { + $this->backfillLegacyAllocations(); + $this->verifyLegacyAllocations(); + $this->recalculateAffectedInvoices(); + }); + + try { + Schema::table('payments', function (Blueprint $table) { + $table->dropForeign(['invoice_id']); + }); + } catch (Throwable) { + // SQLite and manually upgraded databases may not carry the old + // constraint. The column can still be removed below. + } + + try { + Schema::table('payments', function (Blueprint $table) { + $table->dropIndex(['invoice_id']); + }); + } catch (Throwable) { + // SQLite keeps the legacy index and requires explicit removal; + // MySQL can remove it together with its foreign key. + } + + Schema::table('payments', function (Blueprint $table) { + $table->dropColumn('invoice_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('payment_allocations')) { + return; + } + + if (DB::table('payment_allocations') + ->select('payment_id') + ->groupBy('payment_id') + ->havingRaw('COUNT(*) > 1') + ->exists()) { + throw new RuntimeException('Cannot roll back payment allocations after a payment was allocated to multiple invoices.'); + } + + $hasPartialAllocation = DB::table('payment_allocations') + ->join('payments', 'payments.id', '=', 'payment_allocations.payment_id') + ->whereColumn('payment_allocations.amount', '!=', 'payments.amount') + ->exists(); + + if ($hasPartialAllocation) { + throw new RuntimeException('Cannot roll back payment allocations with unapplied customer credit.'); + } + + if (! Schema::hasColumn('payments', 'invoice_id')) { + Schema::table('payments', function (Blueprint $table) { + $table->unsignedInteger('invoice_id')->nullable()->index(); + }); + } + + DB::table('payment_allocations')->orderBy('id')->each(function (object $allocation): void { + DB::table('payments') + ->where('id', $allocation->payment_id) + ->update(['invoice_id' => $allocation->invoice_id]); + }); + + Schema::dropIfExists('payment_allocations'); + } + + private function backfillLegacyAllocations(): void + { + DB::table('payments') + ->whereNotNull('invoice_id') + ->orderBy('invoice_id') + ->orderBy('payment_date') + ->orderBy('id') + ->each(function (object $payment): void { + // A rerun after the allocation insert but before the legacy + // column disappears leaves the already migrated payment alone. + if (DB::table('payment_allocations')->where('payment_id', $payment->id)->exists()) { + return; + } + + $invoice = DB::table('invoices')->where('id', $payment->invoice_id)->first(); + + if (! $this->isValidLegacyLink($payment, $invoice)) { + Log::warning('Payment legacy invoice link was retained as unapplied customer credit.', [ + 'payment_id' => $payment->id, + 'invoice_id' => $payment->invoice_id, + ]); + + return; + } + + $allocated = (int) DB::table('payment_allocations') + ->where('invoice_id', $invoice->id) + ->sum('amount'); + $credited = $this->creditedTotal($invoice->id); + $remaining = max(0, (int) $invoice->total - $credited - $allocated); + $amount = min((int) $payment->amount, $remaining); + + if ($amount === 0) { + return; + } + + DB::table('payment_allocations')->insert([ + 'payment_id' => $payment->id, + 'invoice_id' => $invoice->id, + 'amount' => $amount, + 'base_amount' => $this->allocatedBaseAmount($payment, $amount), + // The legacy relationship existed at payment time; using + // its date preserves historical as-of statement balances + // instead of making every old allocation appear today. + 'created_at' => $payment->payment_date, + 'updated_at' => $payment->payment_date, + ]); + }); + } + + private function isValidLegacyLink(object $payment, ?object $invoice): bool + { + return $invoice + && (int) $payment->company_id === (int) $invoice->company_id + && (int) $payment->customer_id === (int) $invoice->customer_id + && (int) $payment->currency_id === (int) $invoice->currency_id + && ($invoice->type ?? 'INVOICE') === 'INVOICE' + && $invoice->status !== 'DRAFT'; + } + + private function allocatedBaseAmount(object $payment, int $amount): int + { + $paymentAmount = (int) $payment->amount; + + if ($paymentAmount === 0) { + return 0; + } + + $baseAmount = $payment->base_amount === null + ? (int) round($paymentAmount * ((float) $payment->exchange_rate ?: 1)) + : (int) $payment->base_amount; + + return $this->proportionalAmount($baseAmount, $amount, $paymentAmount); + } + + private function verifyLegacyAllocations(): void + { + $orphanedPayment = DB::table('payment_allocations') + ->leftJoin('payments', 'payments.id', '=', 'payment_allocations.payment_id') + ->whereNull('payments.id') + ->first(); + + if ($orphanedPayment) { + throw new RuntimeException('Payment allocation migration verification failed: allocation payment is missing.'); + } + + $overAllocatedPayment = DB::table('payment_allocations') + ->join('payments', 'payments.id', '=', 'payment_allocations.payment_id') + ->select('payments.id') + ->groupBy('payments.id', 'payments.amount') + ->havingRaw('SUM(payment_allocations.amount) > payments.amount') + ->first(); + + if ($overAllocatedPayment) { + throw new RuntimeException('Payment allocation migration verification failed: an allocation exceeds its payment.'); + } + + $overAllocatedBasePayment = DB::table('payment_allocations') + ->join('payments', 'payments.id', '=', 'payment_allocations.payment_id') + ->select('payments.id', 'payments.amount', 'payments.base_amount', 'payments.exchange_rate') + ->groupBy('payments.id', 'payments.amount', 'payments.base_amount', 'payments.exchange_rate') + ->selectRaw('SUM(payment_allocations.base_amount) as allocated_base_amount') + ->get() + ->first(function (object $payment): bool { + $baseAmount = $payment->base_amount === null + ? (int) round((int) $payment->amount * ((float) $payment->exchange_rate ?: 1)) + : (int) $payment->base_amount; + + return (int) $payment->allocated_base_amount > $baseAmount; + }); + + if ($overAllocatedBasePayment) { + throw new RuntimeException('Payment allocation migration verification failed: allocation base amount exceeds its payment.'); + } + + $invalidOwnership = DB::table('payment_allocations') + ->join('payments', 'payments.id', '=', 'payment_allocations.payment_id') + ->join('invoices', 'invoices.id', '=', 'payment_allocations.invoice_id') + ->where(function ($query): void { + $query->whereColumn('payments.company_id', '!=', 'invoices.company_id') + ->orWhereColumn('payments.customer_id', '!=', 'invoices.customer_id') + ->orWhereColumn('payments.currency_id', '!=', 'invoices.currency_id'); + }) + ->first(); + + if ($invalidOwnership) { + throw new RuntimeException('Payment allocation migration verification failed: allocation ownership mismatch.'); + } + + $invalidTarget = DB::table('payment_allocations') + ->leftJoin('invoices', 'invoices.id', '=', 'payment_allocations.invoice_id') + ->where(function ($query): void { + $query->whereNull('invoices.id') + ->orWhere('payment_allocations.amount', '<=', 0) + ->orWhere('payment_allocations.base_amount', '<', 0) + ->orWhere('invoices.type', '!=', 'INVOICE') + ->orWhere('invoices.status', 'DRAFT'); + }) + ->first(); + + if ($invalidTarget) { + throw new RuntimeException('Payment allocation migration verification failed: allocation target is not payable.'); + } + + DB::table('payment_allocations') + ->select('invoice_id') + ->groupBy('invoice_id') + ->orderBy('invoice_id') + ->each(function (object $allocation): void { + $invoice = DB::table('invoices')->where('id', $allocation->invoice_id)->first(); + $allocated = (int) DB::table('payment_allocations')->where('invoice_id', $invoice->id)->sum('amount'); + + if ($allocated > max(0, (int) $invoice->total - $this->creditedTotal($invoice->id))) { + throw new RuntimeException('Payment allocation migration verification failed: allocation exceeds invoice balance.'); + } + }); + } + + private function recalculateAffectedInvoices(): void + { + // Include all legacy targets, not only allocations that survived the + // conversion. An invalid link or excess legacy payment may previously + // have driven a target invoice's stored balance below its true balance. + DB::table('invoices') + ->where('type', 'INVOICE') + ->whereIn('id', function ($query): void { + $query->select('invoice_id')->from('payments')->whereNotNull('invoice_id'); + }) + ->orderBy('id') + ->each(function (object $invoice): void { + $paid = (int) DB::table('payment_allocations')->where('invoice_id', $invoice->id)->sum('amount'); + $due = max(0, (int) $invoice->total - $this->creditedTotal($invoice->id) - $paid); + + DB::table('invoices')->where('id', $invoice->id)->update([ + 'due_amount' => $due, + 'base_due_amount' => (int) round($due * (float) $invoice->exchange_rate), + 'status' => $due === 0 + ? 'COMPLETED' + : ((bool) $invoice->viewed ? 'VIEWED' : ((bool) $invoice->sent ? 'SENT' : 'DRAFT')), + 'paid_status' => $due === 0 ? 'PAID' : ($paid > 0 ? 'PARTIALLY_PAID' : 'UNPAID'), + ]); + }); + } + + private function creditedTotal(int $invoiceId): int + { + if (! Schema::hasColumn('invoices', 'related_invoice_id')) { + return 0; + } + + return -(int) DB::table('invoices') + ->where('related_invoice_id', $invoiceId) + ->where('type', 'CREDIT_NOTE') + ->sum('total'); + } + + /** + * Calculate floor(baseAmount * allocationAmount / paymentAmount) without + * multiplying two arbitrary BIGINT values. The long-division remainder + * loop keeps every intermediate value below paymentAmount. + */ + private function proportionalAmount(int $baseAmount, int $allocationAmount, int $paymentAmount): int + { + $whole = intdiv($baseAmount, $paymentAmount) * $allocationAmount; + $remainder = $baseAmount % $paymentAmount; + $quotient = 0; + $modulo = 0; + $factor = $remainder; + $multiplier = $allocationAmount; + + while ($multiplier > 0) { + if ($multiplier % 2 === 1) { + if ($modulo >= $paymentAmount - $factor) { + $quotient++; + $modulo -= $paymentAmount - $factor; + } else { + $modulo += $factor; + } + } + + if ($factor >= $paymentAmount - $factor) { + $factor -= $paymentAmount - $factor; + } else { + $factor += $factor; + } + + $multiplier = intdiv($multiplier, 2); + } + + return $whole + $quotient; + } +}; diff --git a/database/seeders/RealisticDemoSeeder.php b/database/seeders/RealisticDemoSeeder.php index 1c0d8064..fc793663 100644 --- a/database/seeders/RealisticDemoSeeder.php +++ b/database/seeders/RealisticDemoSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Facades\Hashids; +use App\Jobs\GeneratePaymentPdfJob; use App\Models\Address; use App\Models\AiConversation; use App\Models\Company; @@ -20,12 +21,14 @@ use App\Models\InvoiceItem; use App\Models\Item; use App\Models\Note; use App\Models\Payment; +use App\Models\PaymentAllocation; use App\Models\PaymentMethod; use App\Models\RecurringInvoice; use App\Models\Tax; use App\Models\TaxType; use App\Models\Unit; use App\Models\User; +use App\Services\Document\PaymentAllocationService; use App\Services\Document\SerialNumberService; use Carbon\Carbon; use Illuminate\Database\Seeder; @@ -238,7 +241,9 @@ class RealisticDemoSeeder extends Seeder private function cleanupExistingDemoData(): void { AiConversation::where('company_id', $this->companyId)->delete(); // cascades to ai_messages - Payment::where('company_id', $this->companyId)->delete(); + $paymentIds = Payment::where('company_id', $this->companyId)->pluck('id'); + PaymentAllocation::whereIn('payment_id', $paymentIds)->delete(); + Payment::whereIn('id', $paymentIds)->delete(); InvoiceItem::where('company_id', $this->companyId)->delete(); Invoice::where('company_id', $this->companyId)->delete(); EstimateItem::where('company_id', $this->companyId)->delete(); @@ -376,45 +381,53 @@ class RealisticDemoSeeder extends Seeder */ private function seedInvoicesWithPayments(): void { - // Distribution plan: [count, status, paid_status, overdue, age_weeks_min, age_weeks_max] + // Distribution plan: [count, status, paid_status, overdue, age_weeks_min, age_weeks_max, current_month] // // Split by time bucket so get_company_stats(period=this_month/last_month/this_quarter) differ. $plan = [ - // This month (0-4 weeks ago) — fresh activity - [3, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, false, 0, 3], - [2, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 0, 4], - [2, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 0, 2], - [3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 0, 4], - [1, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 0, 4], + // Current calendar month — each customer receives posted activity + // so its default Account Activity statement is useful immediately. + [3, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, false, 0, 3, true], + [2, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 0, 4, true], + [2, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 0, 2, true], + [3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 0, 4, true], + [1, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 0, 4, true], // Last month (4-8 weeks ago) - [2, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 4, 8], - [1, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 4, 8], - [2, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, true, 5, 8], // overdue - [3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 4, 8], - [2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 4, 8], + [2, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 4, 8, false], + [1, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 4, 8, false], + [2, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, true, 5, 8, false], // overdue + [3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 4, 8, false], + [2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 4, 8, false], // 2-3 months ago - [2, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, true, 10, 13], // overdue - [3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 8, 13], - [2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 9, 13], - [1, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 10, 13], + [2, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, true, 10, 13, false], // overdue + [3, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 8, 13, false], + [2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 9, 13, false], + [1, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 10, 13, false], // 4-6 months ago (older) - [2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 16, 24], - [1, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 16, 22], - [1, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 18, 24], - [1, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, false, 18, 24], - [1, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 20, 26], + [2, Invoice::STATUS_COMPLETED, Invoice::STATUS_PAID, false, 16, 24, false], + [1, Invoice::STATUS_COMPLETED, Invoice::STATUS_PARTIALLY_PAID, false, 16, 22, false], + [1, Invoice::STATUS_VIEWED, Invoice::STATUS_UNPAID, false, 18, 24, false], + [1, Invoice::STATUS_SENT, Invoice::STATUS_UNPAID, false, 18, 24, false], + [1, Invoice::STATUS_DRAFT, Invoice::STATUS_UNPAID, false, 20, 26, false], ]; - foreach ($plan as [$count, $status, $paidStatus, $overdue, $minWeeks, $maxWeeks]) { + $activityCustomerIndex = 0; + + foreach ($plan as [$count, $status, $paidStatus, $overdue, $minWeeks, $maxWeeks, $currentMonth]) { for ($i = 0; $i < $count; $i++) { - $weeksAgo = random_int($minWeeks, $maxWeeks); - $invoiceDate = Carbon::now()->subWeeks($weeksAgo)->subDays(random_int(0, 6))->startOfDay(); + $invoiceDate = $currentMonth + ? Carbon::now()->startOfMonth()->addDays(random_int(0, Carbon::now()->day - 1))->startOfDay() + : Carbon::now()->subWeeks(random_int($minWeeks, $maxWeeks))->subDays(random_int(0, 6))->startOfDay(); $dueDate = $overdue ? Carbon::now()->subDays(random_int(3, 45))->startOfDay() : $invoiceDate->copy()->addDays(30); $itemCount = random_int(1, 4); - $this->createInvoice($invoiceDate, $dueDate, $status, $paidStatus, $itemCount, $overdue); + $customer = $currentMonth && $status !== Invoice::STATUS_DRAFT + ? $this->customers[$activityCustomerIndex++ % count($this->customers)] + : null; + + $this->createInvoice($invoiceDate, $dueDate, $status, $paidStatus, $itemCount, $overdue, $customer); } } } @@ -426,8 +439,9 @@ class RealisticDemoSeeder extends Seeder string $paidStatus, int $itemCount, bool $overdue, + ?Customer $customer = null, ): void { - $customer = $this->customers[array_rand($this->customers)]; + $customer ??= $this->customers[array_rand($this->customers)]; $selectedItems = collect($this->items)->random($itemCount)->all(); // Compute totals from the selected line items @@ -445,17 +459,15 @@ class RealisticDemoSeeder extends Seeder } // Tax is computed once off the subtotal, not per line, and then has to be - // carried through total, due_amount and every base_* twin. Miss due_amount - // and a fully paid invoice renders as part-paid. + // carried through total and every base_* twin. $taxType = $this->taxTypeForDocument($this->invoiceSequence); $taxAmount = $taxType ? (int) round($subTotal * $taxType->percent / 100) : 0; $total = $subTotal + $taxAmount; - $dueAmount = match ($paidStatus) { - Invoice::STATUS_PAID => 0, - Invoice::STATUS_PARTIALLY_PAID => (int) round($total * 0.6), // 40% paid, 60% still due - default => $total, - }; + // PaymentAllocationService is the source of truth for paid balances. + // Start with the full balance, then let it reduce the amount after each + // seeded payment is allocated. + $dueAmount = $total; $invoiceNumber = 'INV-'.str_pad((string) $this->invoiceSequence, 6, '0', STR_PAD_LEFT); $this->invoiceSequence++; @@ -541,12 +553,13 @@ class RealisticDemoSeeder extends Seeder $this->applyDocumentTax($invoice, $taxType, $taxAmount, 'invoice_id'); } - // Back-fill payments for PAID and PARTIALLY_PAID invoices. + // Back-fill payments for PAID and PARTIALLY_PAID invoices. Allocation + // recalculation updates their stored balance and paid status. if ($paidStatus === Invoice::STATUS_PAID) { $this->createPayment($invoice, $total, $invoiceDate->copy()->addDays(random_int(3, 25))); } elseif ($paidStatus === Invoice::STATUS_PARTIALLY_PAID) { // 40% of the total, in one payment - $partialAmount = $total - $dueAmount; + $partialAmount = (int) round($total * 0.4); $this->createPayment($invoice, $partialAmount, $invoiceDate->copy()->addDays(random_int(5, 20))); } } @@ -562,7 +575,7 @@ class RealisticDemoSeeder extends Seeder $paymentDate = Carbon::now()->subDays(random_int(1, 7)); } - $payment = Payment::create([ + $payment = Payment::withoutEvents(fn () => Payment::create([ 'payment_number' => $paymentNumber, 'payment_date' => $paymentDate->toDateString(), 'amount' => $amount, @@ -570,13 +583,12 @@ class RealisticDemoSeeder extends Seeder 'exchange_rate' => 1, 'user_id' => $this->user->id, 'creator_id' => $this->user->id, - 'invoice_id' => $invoice->id, 'customer_id' => $invoice->customer_id, 'payment_method_id' => $this->paymentMethodId, 'currency_id' => $this->currencyId, 'company_id' => $this->companyId, 'notes' => null, - ]); + ])); // See seedInvoice(): the PDF routes bind on unique_hash. $serial = (new SerialNumberService) @@ -590,7 +602,14 @@ class RealisticDemoSeeder extends Seeder $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); $payment->created_at = $paymentDate; $payment->updated_at = $paymentDate; - $payment->save(); + Payment::withoutEvents(fn () => $payment->save()); + + app(PaymentAllocationService::class)->replace($payment, [[ + 'invoice_id' => $invoice->id, + 'amount' => $amount, + ]]); + + GeneratePaymentPdfJob::dispatch($payment); } private function seedEstimates(): void diff --git a/lang/en.json b/lang/en.json index e457173e..63e67baa 100644 --- a/lang/en.json +++ b/lang/en.json @@ -39,6 +39,9 @@ "update": "Update", "deselect": "Deselect", "download": "Download", + "refresh": "Refresh", + "loading": "Loading…", + "date": "Date", "from_date": "From Date", "to_date": "To Date", "from": "From", @@ -276,7 +279,33 @@ "updated_message": "Customer updated successfully", "address_updated_message": "Address Information Updated succesfully", "deleted_message": "Customer deleted successfully | Customers deleted successfully", - "edit_currency_not_allowed": "Cannot change currency once transactions created." + "edit_currency_not_allowed": "Cannot change currency once transactions created.", + "statement_type": "Statement Type", + "from_date": "From Date", + "to_date": "To Date", + "as_of": "As Of", + "download_statement": "Download Statement", + "send_statement": "Send Statement", + "invoice_due": "Invoice Due", + "available_credit": "Available Credit", + "net_account_balance": "Net Account Balance", + "credit": "Credit", + "account_activity": "Account Activity", + "outstanding_items": "Outstanding Items", + "opening_balance": "Opening Balance", + "activity": "Activity", + "debit": "Debit", + "balance": "Balance", + "original_amount": "Original Amount", + "applied": "Applied", + "remaining": "Remaining", + "no_statement_activity": "No account activity in this period.", + "no_outstanding_invoices": "No outstanding invoices.", + "apply_credit": "Apply Credit", + "apply_credit_description": "Choose the available payment credit and invoices to settle. Amounts are only applied when you confirm.", + "statement_sent": "Statement sent successfully", + "credit_applied": "Customer credit applied successfully", + "statement_email_body": "Please find your account statement attached." }, "items": { "title": "Items", @@ -346,6 +375,7 @@ "send_estimate": "Send Estimate", "resend_estimate": "Resend Estimate", "record_payment": "Record Payment", + "allocated_payments": "Allocated Payments", "add_estimate": "Add Estimate", "save_estimate": "Save Estimate", "cloned_successfully": "Estimate cloned successfully", @@ -692,7 +722,17 @@ "updated_message": "Payment updated successfully", "deleted_message": "Payment deleted successfully | Payments deleted successfully", "invalid_amount_message": "Payment amount is invalid", - "amount_due": "Due Amount" + "amount_due": "Due Amount", + "allocations": "Invoice Allocations", + "allocations_description": "Apply this payment to one or more open invoices. Any amount left over remains customer credit.", + "allocate_oldest_first": "Allocate Oldest First", + "add_allocation": "Add Allocation", + "select_customer_to_allocate": "Select a customer before allocating this payment.", + "no_allocations": "No invoices selected. This payment will remain available as customer credit.", + "allocated": "Allocated", + "unapplied_credit": "Unapplied Credit", + "remove_allocation": "Remove allocation", + "allocation_exceeds_amount": "Invoice allocations cannot exceed the payment amount." }, "expenses": { "title": "Expenses", @@ -1856,6 +1896,16 @@ "credit_note_cannot_be_converted_to_estimate": "A credit note cannot be converted to an estimate.", "invoice_must_be_settled_before_completion": "Record a payment or create a credit note before completing this invoice.", "payment_amount_exceeds_invoice_due_amount": "The payment is more than the invoice's outstanding balance.", + "payment_allocation_required": "Add at least one invoice allocation.", + "payment_allocation_invalid": "Enter a valid invoice and amount for every allocation.", + "payment_allocation_duplicate_invoice": "Each invoice can only appear once in a payment.", + "payment_allocation_exceeds_payment_amount": "Invoice allocations cannot exceed the payment amount.", + "payment_allocation_payment_not_found": "One of the selected payments is no longer available.", + "payment_allocation_invoice_not_found": "One of the selected invoices is no longer available.", + "payment_allocation_invoice_mismatch": "Payments can only be applied to invoices for the same customer and currency.", + "payment_allocation_invoice_not_payable": "Payments can only be applied to sent, unpaid invoices.", + "payment_allocation_exceeds_invoice_balance": "An allocation is more than the invoice's outstanding balance.", + "payment_customer_change_requires_unallocated_credit": "Remove all invoice allocations before changing this payment's customer.", "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.", diff --git a/public/openapi.json b/public/openapi.json index 1450a7b3..3ca9ee5d 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3822,6 +3822,76 @@ } } }, + "/customers/{customer}/credit-allocations": { + "post": { + "operationId": "creditAllocations.store", + "tags": [ + "CreditAllocations" + ], + "parameters": [ + { + "name": "customer", + "in": "path", + "required": true, + "description": "The customer ID", + "schema": { + "type": "integer" + } + }, + { + "name": "company", + "in": "header", + "required": true, + "description": "ID of the company the request operates on (multi-tenancy).", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreditAllocationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, "/currencies": { "get": { "operationId": "admin.currencies", @@ -4156,6 +4226,134 @@ } } }, + "/customers/{customer}/statement": { + "get": { + "operationId": "customer.customerStatement", + "tags": [ + "CustomerStatement" + ], + "parameters": [ + { + "name": "customer", + "in": "path", + "required": true, + "description": "The customer ID", + "schema": { + "type": "integer" + } + }, + { + "name": "type", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "activity", + "outstanding" + ] + } + }, + { + "name": "from_date", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date" + } + }, + { + "name": "to_date", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date" + } + }, + { + "name": "as_of", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + } + }, + { + "name": "company", + "in": "header", + "required": true, + "description": "ID of the company the request operates on (multi-tenancy).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`CustomerStatementResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/CustomerStatementResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, "/customers/{customer}/stats": { "get": { "operationId": "customer.customerStats", @@ -4858,8 +5056,7 @@ "type": "object", "properties": { "database_connection": { - "type": "string", - "const": "mysql" + "type": "string" }, "database_host": { "type": "string", @@ -16504,6 +16701,75 @@ } } }, + "/invoices/{invoice}/credit-note": { + "post": { + "operationId": "invoices.createCreditNote", + "tags": [ + "Invoices" + ], + "parameters": [ + { + "name": "invoice", + "in": "path", + "required": true, + "description": "The invoice ID", + "schema": { + "type": "integer" + } + }, + { + "name": "company", + "in": "header", + "required": true, + "description": "ID of the company the request operates on (multi-tenancy).", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCreditNoteRequest" + } + } + } + }, + "responses": { + "201": { + "description": "`CreditNoteResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/CreditNoteResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, "/invoices/{invoice}/status": { "post": { "operationId": "invoices.changeStatus", @@ -16530,6 +16796,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeInvoiceStatusRequest" + } + } + } + }, "responses": { "200": { "description": "", @@ -16557,6 +16833,9 @@ }, "401": { "$ref": "#/components/responses/AuthenticationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" } } } @@ -16776,19 +17055,11 @@ ], "responses": { "200": { - "description": "`InvoiceResource`", + "description": "", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/InvoiceResource" - } - }, - "required": [ - "data" - ] + "type": "object" } } } @@ -16900,6 +17171,7 @@ "properties": { "invoiceTotalCount": { "type": "integer", + "description": "Issued invoices only: a credit note is a reversal document,\nnot another invoice the customer received.", "minimum": 0 } }, @@ -19432,27 +19704,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "pdf_driver": { - "type": "string" - }, - "gotenberg_host": { - "type": "string" - }, - "gotenberg_margins": { - "type": "string" - }, - "gotenberg_papersize": { - "type": "string" - } - }, - "required": [ - "pdf_driver", - "gotenberg_host", - "gotenberg_margins", - "gotenberg_papersize" - ] + "type": "string" } } } @@ -19976,6 +20228,76 @@ } } }, + "/payments/{payment}/allocations": { + "put": { + "operationId": "payments.replaceAllocations", + "tags": [ + "Payments" + ], + "parameters": [ + { + "name": "payment", + "in": "path", + "required": true, + "description": "The payment ID", + "schema": { + "type": "integer" + } + }, + { + "name": "company", + "in": "header", + "required": true, + "description": "ID of the company the request operates on (multi-tenancy).", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePaymentAllocationsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`PaymentResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PaymentResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, "/payments/delete": { "post": { "operationId": "payments.delete", @@ -21701,6 +22023,76 @@ } } }, + "/customers/{customer}/statement/send": { + "post": { + "operationId": "customer.sendCustomerStatement", + "tags": [ + "SendCustomerStatement" + ], + "parameters": [ + { + "name": "customer", + "in": "path", + "required": true, + "description": "The customer ID", + "schema": { + "type": "integer" + } + }, + { + "name": "company", + "in": "header", + "required": true, + "description": "ID of the company the request operates on (multi-tenancy).", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendCustomerStatementRequest" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, "/next-number": { "get": { "operationId": "serialNumber.nextNumber", @@ -25518,12 +25910,6 @@ "null" ] }, - "invoice_id": { - "type": [ - "integer", - "null" - ] - }, "company_id": { "type": [ "integer", @@ -25549,10 +25935,56 @@ ] }, "base_amount": { - "type": [ - "integer", - "null" - ] + "type": "integer" + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "invoice_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "base_amount": { + "type": "integer" + }, + "invoice": { + "anyOf": [ + { + "$ref": "#/components/schemas/App.Http.Resources.Customer.InvoiceResource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "invoice_id", + "amount", + "base_amount", + "invoice" + ] + } + }, + "allocated_amount": { + "type": "integer" + }, + "unallocated_amount": { + "type": "integer" + }, + "base_allocated_amount": { + "type": "integer" + }, + "base_unallocated_amount": { + "type": "integer" }, "currency_id": { "type": [ @@ -25578,9 +26010,6 @@ "customer": { "$ref": "#/components/schemas/App.Http.Resources.Customer.CustomerResource" }, - "invoice": { - "$ref": "#/components/schemas/App.Http.Resources.Customer.InvoiceResource" - }, "payment_method": { "$ref": "#/components/schemas/App.Http.Resources.Customer.PaymentMethodResource" }, @@ -25607,12 +26036,16 @@ "notes", "amount", "unique_hash", - "invoice_id", "company_id", "payment_method_id", "customer_id", "exchange_rate", "base_amount", + "allocations", + "allocated_amount", + "unallocated_amount", + "base_allocated_amount", + "base_unallocated_amount", "currency_id", "transaction_id", "formatted_created_at", @@ -25679,7 +26112,7 @@ ] }, "compound_tax": { - "type": "integer" + "type": "boolean" }, "base_amount": { "type": [ @@ -25740,6 +26173,9 @@ "null" ] }, + "transaction_type": { + "type": "string" + }, "compound_tax": { "type": "boolean" }, @@ -25766,6 +26202,7 @@ "id", "name", "percent", + "transaction_type", "compound_tax", "collective_tax", "description", @@ -25865,6 +26302,22 @@ ], "title": "BulkExchangeRateRequest" }, + "ChangeInvoiceStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "SENT", + "COMPLETED" + ] + } + }, + "required": [ + "status" + ], + "title": "ChangeInvoiceStatusRequest" + }, "CompaniesRequest": { "type": "object", "properties": { @@ -26260,6 +26713,475 @@ ], "title": "CountryResource" }, + "CreateCreditNoteRequest": { + "type": "object", + "properties": { + "reason": { + "type": [ + "string", + "null" + ], + "maxLength": 1000 + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "quantity": { + "type": "number" + } + }, + "required": [ + "id", + "quantity" + ] + } + } + }, + "title": "CreateCreditNoteRequest" + }, + "CreditAllocationRequest": { + "type": "object", + "properties": { + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "payment_id": { + "type": "integer" + }, + "invoice_id": { + "type": "integer" + }, + "amount": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "payment_id", + "invoice_id", + "amount" + ] + }, + "minItems": 1 + } + }, + "required": [ + "allocations" + ], + "title": "CreditAllocationRequest" + }, + "CreditNoteResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "invoice_date": { + "type": "string" + }, + "due_date": { + "type": [ + "string", + "null" + ] + }, + "invoice_number": { + "type": "string" + }, + "reference_number": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "related_invoice_id": { + "type": [ + "integer", + "null" + ] + }, + "status": { + "type": "string" + }, + "paid_status": { + "type": "string" + }, + "tax_per_item": { + "type": "string" + }, + "tax_included": { + "type": "integer" + }, + "discount_per_item": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "discount_type": { + "type": [ + "string", + "null" + ] + }, + "discount": { + "type": [ + "number", + "null" + ] + }, + "discount_val": { + "type": [ + "integer", + "null" + ] + }, + "sub_total": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "due_amount": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "viewed": { + "type": "integer" + }, + "unique_hash": { + "type": [ + "string", + "null" + ] + }, + "template_name": { + "type": [ + "string", + "null" + ] + }, + "customer_id": { + "type": [ + "integer", + "null" + ] + }, + "recurring_invoice_id": { + "type": [ + "integer", + "null" + ] + }, + "sequence_number": { + "type": [ + "integer", + "null" + ] + }, + "exchange_rate": { + "type": [ + "number", + "null" + ] + }, + "base_discount_val": { + "type": [ + "integer", + "null" + ] + }, + "base_sub_total": { + "type": [ + "integer", + "null" + ] + }, + "base_total": { + "type": [ + "integer", + "null" + ] + }, + "creator_id": { + "type": [ + "integer", + "null" + ] + }, + "base_tax": { + "type": [ + "integer", + "null" + ] + }, + "base_due_amount": { + "type": [ + "integer", + "null" + ] + }, + "currency_id": { + "type": [ + "integer", + "null" + ] + }, + "formatted_created_at": { + "type": "string" + }, + "invoice_pdf_url": { + "type": "string" + }, + "formatted_invoice_date": { + "type": "string" + }, + "formatted_due_date": { + "type": "string" + }, + "allow_edit": { + "type": "string" + }, + "payment_module_enabled": { + "type": "string" + }, + "sales_tax_type": { + "type": [ + "string", + "null" + ] + }, + "sales_tax_address_type": { + "type": [ + "string", + "null" + ] + }, + "overdue": { + "type": "integer" + }, + "credit_notes": { + "type": "array", + "description": "Credit notes reversing this invoice (minimal reference so the\nUI can flag the invoice as cancelled and link to the storno\ndocument, mirroring the related_invoice back-link). Emitted only\nwhere the relation was eager-loaded: probing it per row costs two\nqueries each, and this resource is serialized in paginated lists.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "invoice_number": { + "type": "string" + } + }, + "required": [ + "id", + "invoice_number" + ] + } + }, + "credit_reason": { + "type": [ + "string", + "null" + ], + "description": "Why this invoice was credited, if it was. Set only by the\ncredit-note flow, never by the invoice form." + }, + "credited_total": { + "type": "integer", + "description": "How much of the invoice has been credited off it, as a positive\nnumber of cents (credit notes store negative totals), and whether\nthat covers the whole document. Both are read off the same loaded\nrelation the banner uses, so they cost no extra query." + }, + "credited_status": { + "type": "string", + "enum": [ + "FULL", + "PARTIAL", + "NONE" + ] + }, + "credited_quantities": { + "type": "string", + "description": "Credited quantity per ORIGINAL line, which is what a partial\ncredit form needs to offer the remaining quantities. Emitted only\nwhen the credit notes' items came along." + }, + "payment_allocations": { + "type": "array", + "description": "Allocation rows explain how this invoice was settled without\nreintroducing the removed singular payment.invoice relation.\nThey are loaded for the detail response only, so index listings\nremain free of per-row payment queries.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "payment_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "base_amount": { + "type": "integer" + }, + "payment": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "integer" + }, + "payment_number": { + "type": "string" + }, + "formatted_payment_date": { + "type": "string" + } + }, + "required": [ + "id", + "payment_number", + "formatted_payment_date" + ] + } + }, + "required": [ + "id", + "payment_id", + "amount", + "base_amount", + "payment" + ] + } + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoiceItemResource" + } + }, + "customer": { + "$ref": "#/components/schemas/CustomerResource" + }, + "creator": { + "$ref": "#/components/schemas/UserResource" + }, + "taxes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaxResource" + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomFieldValueResource" + } + }, + "company": { + "$ref": "#/components/schemas/CompanyResource" + }, + "currency": { + "$ref": "#/components/schemas/CurrencyResource" + }, + "related_invoice": { + "type": "object", + "description": "type + related_invoice_id come from InvoiceResource; this adds the\nexpanded reference to the original invoice being reversed.\nRead off the loaded relation rather than probing it: the caller\neager-loads relatedInvoice, so an exists() query here would be\npure overhead.", + "properties": { + "id": { + "type": "string" + }, + "invoice_number": { + "type": "string" + }, + "invoice_date": { + "type": "string" + }, + "formatted_invoice_date": { + "type": "string" + }, + "total": { + "type": "string" + }, + "unique_hash": { + "type": "string" + } + }, + "required": [ + "id", + "invoice_number", + "invoice_date", + "formatted_invoice_date", + "total", + "unique_hash" + ] + } + }, + "required": [ + "id", + "invoice_date", + "due_date", + "invoice_number", + "reference_number", + "type", + "related_invoice_id", + "status", + "paid_status", + "tax_per_item", + "tax_included", + "discount_per_item", + "notes", + "discount_type", + "discount", + "discount_val", + "sub_total", + "total", + "tax", + "due_amount", + "sent", + "viewed", + "unique_hash", + "template_name", + "customer_id", + "recurring_invoice_id", + "sequence_number", + "exchange_rate", + "base_discount_val", + "base_sub_total", + "base_total", + "creator_id", + "base_tax", + "base_due_amount", + "currency_id", + "formatted_created_at", + "invoice_pdf_url", + "formatted_invoice_date", + "formatted_due_date", + "allow_edit", + "payment_module_enabled", + "sales_tax_type", + "sales_tax_address_type", + "overdue", + "credit_reason" + ], + "title": "CreditNoteResource" + }, "Currency": { "type": "object", "properties": { @@ -27186,6 +28108,24 @@ "base_due_amount": { "type": "string" }, + "invoice_due_amount": { + "type": "string" + }, + "base_invoice_due_amount": { + "type": "string" + }, + "available_credit": { + "type": "string" + }, + "base_available_credit": { + "type": "string" + }, + "account_balance": { + "type": "string" + }, + "base_account_balance": { + "type": "string" + }, "prefix": { "type": [ "string", @@ -27238,11 +28178,151 @@ "avatar", "due_amount", "base_due_amount", + "invoice_due_amount", + "base_invoice_due_amount", + "available_credit", + "base_available_credit", + "account_balance", + "base_account_balance", "prefix", "tax_id" ], "title": "CustomerResource" }, + "CustomerStatementResource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "customer": { + "$ref": "#/components/schemas/CustomerResource" + }, + "currency": { + "$ref": "#/components/schemas/CurrencyResource" + }, + "as_of": { + "type": "string" + }, + "invoices": { + "type": "array", + "items": {} + }, + "credits": { + "type": "array", + "items": {} + }, + "invoice_due_amount": { + "type": "integer" + }, + "base_invoice_due_amount": { + "type": "integer" + }, + "available_credit": { + "type": "integer" + }, + "base_available_credit": { + "type": "integer" + }, + "account_balance": { + "type": "integer" + }, + "base_account_balance": { + "type": "integer" + } + }, + "required": [ + "type", + "customer", + "currency", + "as_of", + "invoices", + "credits", + "invoice_due_amount", + "base_invoice_due_amount", + "available_credit", + "base_available_credit", + "account_balance", + "base_account_balance" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "customer": { + "$ref": "#/components/schemas/CustomerResource" + }, + "currency": { + "$ref": "#/components/schemas/CurrencyResource" + }, + "from_date": { + "type": "string" + }, + "to_date": { + "type": "string" + }, + "opening_balance": { + "type": "integer" + }, + "base_opening_balance": { + "type": "integer" + }, + "closing_balance": { + "type": "integer" + }, + "base_closing_balance": { + "type": "integer" + }, + "entries": { + "type": "array", + "items": {} + }, + "meta": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "last_page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "current_page", + "last_page", + "per_page", + "total" + ] + } + }, + "required": [ + "type", + "customer", + "currency", + "from_date", + "to_date", + "opening_balance", + "base_opening_balance", + "closing_balance", + "base_closing_balance", + "entries", + "meta" + ] + } + ], + "title": "CustomerStatementResource" + }, "DatabaseEnvironmentRequest": { "type": "object", "properties": { @@ -28255,7 +29335,8 @@ ] }, "amount": { - "type": "string" + "type": "integer", + "minimum": 0 }, "customer_id": { "type": [ @@ -28280,6 +29361,25 @@ "format": "binary", "contentMediaType": "application/octet-stream", "maxLength": 20000 + }, + "taxes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tax_type_id": { + "type": "integer" + }, + "amount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "tax_type_id", + "amount" + ] + } } }, "required": [ @@ -28374,6 +29474,12 @@ "null" ] }, + "taxes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaxResource" + } + }, "customer": { "$ref": "#/components/schemas/CustomerResource" }, @@ -28662,6 +29768,21 @@ }, "tax_included": { "type": "integer" + }, + "type": { + "type": "string" + }, + "related_invoice_id": { + "type": [ + "integer", + "null" + ] + }, + "credit_reason": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -28705,7 +29826,10 @@ "sales_tax_type", "sales_tax_address_type", "overdue", - "tax_included" + "tax_included", + "type", + "related_invoice_id", + "credit_reason" ], "title": "Invoice" }, @@ -28869,6 +29993,15 @@ "null" ] }, + "type": { + "type": "string" + }, + "related_invoice_id": { + "type": [ + "integer", + "null" + ] + }, "status": { "type": "string" }, @@ -29037,6 +30170,98 @@ "overdue": { "type": "integer" }, + "credit_notes": { + "type": "array", + "description": "Credit notes reversing this invoice (minimal reference so the\nUI can flag the invoice as cancelled and link to the storno\ndocument, mirroring the related_invoice back-link). Emitted only\nwhere the relation was eager-loaded: probing it per row costs two\nqueries each, and this resource is serialized in paginated lists.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "invoice_number": { + "type": "string" + } + }, + "required": [ + "id", + "invoice_number" + ] + } + }, + "credit_reason": { + "type": [ + "string", + "null" + ], + "description": "Why this invoice was credited, if it was. Set only by the\ncredit-note flow, never by the invoice form." + }, + "credited_total": { + "type": "integer", + "description": "How much of the invoice has been credited off it, as a positive\nnumber of cents (credit notes store negative totals), and whether\nthat covers the whole document. Both are read off the same loaded\nrelation the banner uses, so they cost no extra query." + }, + "credited_status": { + "type": "string", + "enum": [ + "FULL", + "PARTIAL", + "NONE" + ] + }, + "credited_quantities": { + "type": "string", + "description": "Credited quantity per ORIGINAL line, which is what a partial\ncredit form needs to offer the remaining quantities. Emitted only\nwhen the credit notes' items came along." + }, + "payment_allocations": { + "type": "array", + "description": "Allocation rows explain how this invoice was settled without\nreintroducing the removed singular payment.invoice relation.\nThey are loaded for the detail response only, so index listings\nremain free of per-row payment queries.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "payment_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "base_amount": { + "type": "integer" + }, + "payment": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "integer" + }, + "payment_number": { + "type": "string" + }, + "formatted_payment_date": { + "type": "string" + } + }, + "required": [ + "id", + "payment_number", + "formatted_payment_date" + ] + } + }, + "required": [ + "id", + "payment_id", + "amount", + "base_amount", + "payment" + ] + } + }, "items": { "type": "array", "items": { @@ -29074,6 +30299,8 @@ "due_date", "invoice_number", "reference_number", + "type", + "related_invoice_id", "status", "paid_status", "tax_per_item", @@ -29110,7 +30337,8 @@ "payment_module_enabled", "sales_tax_type", "sales_tax_address_type", - "overdue" + "overdue", + "credit_reason" ], "title": "InvoiceResource" }, @@ -29666,10 +30894,53 @@ "properties": { "pdf_driver": { "type": "string" + }, + "pdf_paper_width": { + "type": "string" + }, + "pdf_paper_height": { + "type": "string" + }, + "pdf_orientation": { + "type": "string", + "enum": [ + "portrait", + "landscape" + ] + }, + "pdf_margin_top": { + "type": [ + "string", + "null" + ] + }, + "pdf_margin_right": { + "type": [ + "string", + "null" + ] + }, + "pdf_margin_bottom": { + "type": [ + "string", + "null" + ] + }, + "pdf_margin_left": { + "type": [ + "string", + "null" + ] + }, + "pdf_page_numbers": { + "type": "boolean" } }, "required": [ - "pdf_driver" + "pdf_driver", + "pdf_paper_width", + "pdf_paper_height", + "pdf_orientation" ], "title": "PDFConfigurationRequest" }, @@ -29722,7 +30993,7 @@ "type": "string" }, "customer_id": { - "type": "string" + "type": "integer" }, "exchange_rate": { "type": [ @@ -29731,17 +31002,12 @@ ] }, "amount": { - "type": "string" + "type": "integer", + "minimum": 1 }, "payment_number": { "type": "string" }, - "invoice_id": { - "type": [ - "string", - "null" - ] - }, "payment_method_id": { "type": [ "string", @@ -29753,6 +31019,28 @@ "string", "null" ] + }, + "allocations": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "invoice_id": { + "type": "integer" + }, + "amount": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "invoice_id", + "amount" + ] + } } }, "required": [ @@ -29787,12 +31075,6 @@ "null" ] }, - "invoice_id": { - "type": [ - "integer", - "null" - ] - }, "company_id": { "type": [ "integer", @@ -29824,10 +31106,56 @@ ] }, "base_amount": { - "type": [ - "integer", - "null" - ] + "type": "integer" + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "invoice_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "base_amount": { + "type": "integer" + }, + "invoice": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvoiceResource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "invoice_id", + "amount", + "base_amount", + "invoice" + ] + } + }, + "allocated_amount": { + "type": "integer" + }, + "unallocated_amount": { + "type": "integer" + }, + "base_allocated_amount": { + "type": "integer" + }, + "base_unallocated_amount": { + "type": "integer" }, "currency_id": { "type": [ @@ -29859,9 +31187,6 @@ "customer": { "$ref": "#/components/schemas/CustomerResource" }, - "invoice": { - "$ref": "#/components/schemas/InvoiceResource" - }, "payment_method": { "$ref": "#/components/schemas/PaymentMethodResource" }, @@ -29888,13 +31213,17 @@ "notes", "amount", "unique_hash", - "invoice_id", "company_id", "payment_method_id", "creator_id", "customer_id", "exchange_rate", "base_amount", + "allocations", + "allocated_amount", + "unallocated_amount", + "base_allocated_amount", + "base_unallocated_amount", "currency_id", "transaction_id", "sequence_number", @@ -30217,6 +31546,34 @@ ], "title": "RecurringInvoiceResource" }, + "ReplacePaymentAllocationsRequest": { + "type": "object", + "properties": { + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "invoice_id": { + "type": "integer" + }, + "amount": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "invoice_id", + "amount" + ] + } + } + }, + "required": [ + "allocations" + ], + "title": "ReplacePaymentAllocationsRequest" + }, "Role": { "type": "object", "properties": { @@ -30321,6 +31678,70 @@ ], "title": "RoleResource" }, + "SendCustomerStatementRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "activity", + "outstanding" + ] + }, + "from_date": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "to_date": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "as_of": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "subject": { + "type": "string" + }, + "body": { + "type": "string" + }, + "to": { + "type": "string", + "format": "email" + }, + "cc": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "bcc": { + "type": [ + "string", + "null" + ], + "format": "email" + } + }, + "required": [ + "type", + "subject", + "body", + "to" + ], + "title": "SendCustomerStatementRequest" + }, "SendEstimatesRequest": { "type": "object", "properties": { @@ -30474,6 +31895,12 @@ "null" ] }, + "expense_id": { + "type": [ + "integer", + "null" + ] + }, "item_id": { "type": [ "integer", @@ -30508,7 +31935,7 @@ ] }, "compound_tax": { - "type": "integer" + "type": "boolean" }, "base_amount": { "type": [ @@ -30545,6 +31972,7 @@ "estimate_id", "invoice_item_id", "estimate_item_id", + "expense_id", "item_id", "company_id", "name", @@ -30618,6 +32046,9 @@ "integer", "null" ] + }, + "transaction_type": { + "type": "string" } }, "required": [ @@ -30632,7 +32063,8 @@ "updated_at", "type", "calculation_type", - "fixed_amount" + "fixed_amount", + "transaction_type" ], "title": "TaxType" }, @@ -30678,6 +32110,13 @@ "string", "null" ] + }, + "transaction_type": { + "type": "string", + "enum": [ + "sales", + "purchases" + ] } }, "required": [ @@ -30713,6 +32152,9 @@ "type": { "type": "string" }, + "transaction_type": { + "type": "string" + }, "compound_tax": { "type": "boolean" }, @@ -30742,6 +32184,7 @@ "fixed_amount", "calculation_type", "type", + "transaction_type", "compound_tax", "collective_tax", "description", diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index a9826053..0da8c044 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -35,6 +35,7 @@ export const API = { CUSTOMERS: '/api/v1/customers', CUSTOMERS_DELETE: '/api/v1/customers/delete', CUSTOMER_STATS: '/api/v1/customers', // append /{id}/stats + CUSTOMER_STATEMENT: '/api/v1/customers', // append /{id}/statement // Items & Units ITEMS: '/api/v1/items', diff --git a/resources/scripts/api/services/customer.service.ts b/resources/scripts/api/services/customer.service.ts index fb083497..8718c5ed 100644 --- a/resources/scripts/api/services/customer.service.ts +++ b/resources/scripts/api/services/customer.service.ts @@ -6,6 +6,79 @@ import type { ListParams, DeletePayload, } from '@/scripts/types/api' +import type { Currency } from '@/scripts/types/domain/currency' + +export type CustomerStatementType = 'activity' | 'outstanding' + +export interface CustomerStatementEntry { + id: string | number + date: string + entry_type: 'invoice' | 'credit_note' | 'payment' + reference: string + description?: string | null + debit_amount: number + credit_amount: number + balance?: number +} + +export interface CustomerStatementOutstandingInvoice { + id: number + invoice_number: string + invoice_date: string + due_date: string | null + original_amount: number + applied_amount: number + remaining_amount: number +} + +export interface CustomerStatementCredit { + id: number + payment_number: string + payment_date: string + amount: number + allocated_amount: number + available_amount: number +} + +export interface CustomerStatement { + type: CustomerStatementType + customer: Customer + currency?: Currency + opening_balance?: number + closing_balance?: number + invoice_due_amount?: number + available_credit?: number + account_balance?: number + entries?: CustomerStatementEntry[] + meta?: { + current_page: number + last_page: number + per_page: number + total: number + } + invoices?: CustomerStatementOutstandingInvoice[] + credits?: CustomerStatementCredit[] +} + +export interface CustomerStatementParams { + type: CustomerStatementType + from_date?: string + to_date?: string + as_of?: string + page?: number +} + +export interface SendCustomerStatementPayload { + type: CustomerStatementType + from_date?: string + to_date?: string + as_of?: string + to?: string + cc?: string + bcc?: string + subject?: string + body?: string +} export interface CustomerListParams extends ListParams { display_name?: string @@ -81,4 +154,40 @@ export const customerService = { const { data } = await client.get(`${API.CUSTOMER_STATS}/${id}/stats`, { params }) return data }, + + async getStatement( + id: number, + params: CustomerStatementParams, + ): Promise> { + const { data } = await client.get(`${API.CUSTOMER_STATEMENT}/${id}/statement`, { params }) + return data + }, + + statementPdfUrl(id: number, params: CustomerStatementParams): string { + const query = new URLSearchParams( + Object.entries(params).reduce>((result, [key, value]) => { + if (value !== undefined && value !== null) result[key] = String(value) + return result + }, {}), + ) + query.set('download', '1') + + return `/reports/customers/${id}/statement?${query.toString()}` + }, + + async sendStatement( + id: number, + payload: SendCustomerStatementPayload, + ): Promise<{ success: boolean }> { + const { data } = await client.post(`${API.CUSTOMER_STATEMENT}/${id}/statement/send`, payload) + return data + }, + + async allocateCredit( + id: number, + allocations: Array<{ payment_id: number; invoice_id: number; amount: number }>, + ): Promise<{ success: boolean }> { + const { data } = await client.post(`${API.CUSTOMER_STATEMENT}/${id}/credit-allocations`, { allocations }) + return data + }, } diff --git a/resources/scripts/features/company/customers/components/CustomerBalanceCard.vue b/resources/scripts/features/company/customers/components/CustomerBalanceCard.vue new file mode 100644 index 00000000..da5fee42 --- /dev/null +++ b/resources/scripts/features/company/customers/components/CustomerBalanceCard.vue @@ -0,0 +1,27 @@ + + + diff --git a/resources/scripts/features/company/customers/components/CustomerStatement.vue b/resources/scripts/features/company/customers/components/CustomerStatement.vue new file mode 100644 index 00000000..1bc1464a --- /dev/null +++ b/resources/scripts/features/company/customers/components/CustomerStatement.vue @@ -0,0 +1,334 @@ + + + diff --git a/resources/scripts/features/company/customers/components/CustomerViewSidebar.vue b/resources/scripts/features/company/customers/components/CustomerViewSidebar.vue index 94c6ca5d..c8da3a30 100644 --- a/resources/scripts/features/company/customers/components/CustomerViewSidebar.vue +++ b/resources/scripts/features/company/customers/components/CustomerViewSidebar.vue @@ -5,6 +5,7 @@ import { useRoute } from 'vue-router' import { useCustomerStore } from '../store' import { useDebounceFn } from '@vueuse/core' import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue' +import type { Currency } from '@/scripts/types/domain/currency' interface SearchData { orderBy: string | null @@ -17,7 +18,8 @@ interface CustomerListItem { name: string contact_name: string | null due_amount: number | null - currency: Record | null + account_balance?: number | null + currency: Currency | null } const customerStore = useCustomerStore() @@ -76,13 +78,23 @@ async function loadCustomers( }) isFetching.value = false - if (!customerList.value) customerList.value = [] - customerList.value = [...customerList.value, ...response.data] + const nextCustomers: CustomerListItem[] = [ + ...(customerList.value ?? []), + ...response.data.map((customer) => ({ + id: customer.id, + name: customer.name, + contact_name: customer.contact_name, + due_amount: customer.due_amount, + account_balance: customer.account_balance, + currency: customer.currency ?? null, + })), + ] + customerList.value = nextCustomers currentPageNumber.value = pageNumber ?? 1 lastPageNumber.value = response.meta.last_page - const customerFound = customerList.value.find( + const customerFound = nextCustomers.find( (cust) => cust.id === Number(route.params.id) ) @@ -252,9 +264,10 @@ loadCustomers()
+ {{ $t('customers.credit') }}
diff --git a/resources/scripts/features/company/customers/views/CustomerDetailView.vue b/resources/scripts/features/company/customers/views/CustomerDetailView.vue index 104a48d1..911619f4 100644 --- a/resources/scripts/features/company/customers/views/CustomerDetailView.vue +++ b/resources/scripts/features/company/customers/views/CustomerDetailView.vue @@ -1,11 +1,14 @@