From 95984bd39c0a2049b242b3ee3d0e76a98efe5938 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 21 Aug 2026 09:38:43 +0200 Subject: [PATCH] feat(receivables): fresh receivables implementation --- .../Company/PaymentMethodsController.php | 105 ++++ .../Company/PaymentsController.php | 193 +++++++ .../PaymentMethodController.php | 32 ++ .../CustomerPortal/PaymentsController.php | 77 +++ .../Controllers/PublicPaymentController.php | 59 ++ .../Http/Requests/DeletePaymentsRequest.php | 35 ++ .../Http/Requests/PaymentMethodRequest.php | 60 ++ .../Http/Requests/PaymentRequest.php | 128 +++++ .../Http/Requests/SendPaymentRequest.php | 35 ++ .../CustomerPortal/PaymentMethodResource.php | 40 ++ .../CustomerPortal/PaymentResource.php | 136 +++++ .../CustomerPortal/TransactionResource.php | 51 ++ .../Http/Resources/PaymentMethodResource.php | 43 ++ .../Http/Resources/PaymentResource.php | 143 +++++ .../Http/Resources/TransactionResource.php | 50 ++ .../Jobs/GeneratePaymentPdfJob.php | 55 ++ .../Receivables/Mail/SendPaymentMail.php | 94 ++++ app/Domains/Receivables/Models/Payment.php | 530 ++++++++++++++++++ .../Receivables/Models/PaymentMethod.php | 194 +++++++ .../Receivables/Models/Transaction.php | 105 ++++ .../Policies/PaymentMethodPolicy.php | 89 +++ .../Receivables/Policies/PaymentPolicy.php | 97 ++++ 22 files changed, 2351 insertions(+) create mode 100644 app/Domains/Receivables/Http/Controllers/Company/PaymentMethodsController.php create mode 100644 app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php create mode 100644 app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php create mode 100644 app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentsController.php create mode 100644 app/Domains/Receivables/Http/Controllers/PublicPaymentController.php create mode 100644 app/Domains/Receivables/Http/Requests/DeletePaymentsRequest.php create mode 100644 app/Domains/Receivables/Http/Requests/PaymentMethodRequest.php create mode 100644 app/Domains/Receivables/Http/Requests/PaymentRequest.php create mode 100644 app/Domains/Receivables/Http/Requests/SendPaymentRequest.php create mode 100644 app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentMethodResource.php create mode 100644 app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentResource.php create mode 100644 app/Domains/Receivables/Http/Resources/CustomerPortal/TransactionResource.php create mode 100644 app/Domains/Receivables/Http/Resources/PaymentMethodResource.php create mode 100644 app/Domains/Receivables/Http/Resources/PaymentResource.php create mode 100644 app/Domains/Receivables/Http/Resources/TransactionResource.php create mode 100644 app/Domains/Receivables/Jobs/GeneratePaymentPdfJob.php create mode 100644 app/Domains/Receivables/Mail/SendPaymentMail.php create mode 100644 app/Domains/Receivables/Models/Payment.php create mode 100644 app/Domains/Receivables/Models/PaymentMethod.php create mode 100644 app/Domains/Receivables/Models/Transaction.php create mode 100644 app/Domains/Receivables/Policies/PaymentMethodPolicy.php create mode 100644 app/Domains/Receivables/Policies/PaymentPolicy.php diff --git a/app/Domains/Receivables/Http/Controllers/Company/PaymentMethodsController.php b/app/Domains/Receivables/Http/Controllers/Company/PaymentMethodsController.php new file mode 100644 index 00000000..858820df --- /dev/null +++ b/app/Domains/Receivables/Http/Controllers/Company/PaymentMethodsController.php @@ -0,0 +1,105 @@ +authorize('viewAny', PaymentMethod::class); + + // Filters come first: the method_id filter widens the query with an OR, + // so the type and company conditions have to close over it. + $paymentMethods = PaymentMethod::applyFilters($request->all()) + ->where('type', PaymentMethod::TYPE_GENERAL) + ->whereCompany() + ->latest() + ->paginateData($request->input('limit', 5)); + + return PaymentMethodResource::collection($paymentMethods); + } + + /** + * Add a manual method. + * + * @param Request $request + * @return JsonResponse + */ + public function store(PaymentMethodRequest $request) + { + $this->authorize('create', PaymentMethod::class); + + $paymentMethod = PaymentMethod::create($request->getPaymentMethodPayload()); + + return new PaymentMethodResource($paymentMethod); + } + + /** + * One method. + * + * @return JsonResponse + */ + public function show(PaymentMethod $paymentMethod) + { + $this->authorize('view', $paymentMethod); + + return new PaymentMethodResource($paymentMethod); + } + + /** + * Rename a method. + * + * @param Request $request + * @return JsonResponse + */ + public function update(PaymentMethodRequest $request, PaymentMethod $paymentMethod) + { + $this->authorize('update', $paymentMethod); + + $paymentMethod->update($request->getPaymentMethodPayload()); + + return new PaymentMethodResource($paymentMethod); + } + + /** + * Drop a method, unless money already points at it. Both refusals are + * domain conflicts (422), each carrying the key the SPA switches on. + * + * @return JsonResponse + */ + public function destroy(PaymentMethod $paymentMethod) + { + $this->authorize('delete', $paymentMethod); + + if ($paymentMethod->payments()->exists()) { + return respondJson('payments_attached', 'Payments Attached.'); + } + + if ($paymentMethod->expenses()->exists()) { + return respondJson('expenses_attached', 'Expenses Attached.'); + } + + $paymentMethod->delete(); + + return response()->json([ + 'success' => 'Payment method deleted successfully', + ]); + } +} diff --git a/app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php b/app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php new file mode 100644 index 00000000..5a4aefcc --- /dev/null +++ b/app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php @@ -0,0 +1,193 @@ +authorize('viewAny', Payment::class); + + // Customer and method are joined rather than eager loaded: rows are + // searched and ordered by the customer name, and each row carries the + // method label as payment_mode. The company scope has to precede the + // filters, because the payment_id filter widens the query with an OR. + $payments = Payment::with(['allocations.invoice']) + ->whereCompany() + ->join('customers', 'customers.id', '=', 'payments.customer_id') + ->leftJoin('payment_methods', 'payment_methods.id', '=', 'payments.payment_method_id') + ->applyFilters($request->all()) + ->select('payments.*', 'customers.name', 'payment_methods.name as payment_mode') + ->latest() + ->paginateData($request->input('limit', 10)); + + return PaymentResource::collection($payments) + ->additional([ + 'meta' => [ + 'payment_total_count' => Payment::whereCompany()->count(), + ], + ]); + } + + /** + * Record a received amount, with the invoices it settles, if any. + * + * @param Request $request + * @return JsonResponse + */ + public function store(PaymentRequest $request) + { + $this->authorize('create', Payment::class); + + $payment = $this->paymentService->create( + attributes: $request->getPaymentPayload(), + allocations: $request->validated('allocations') ?? [], + customFields: $this->customFields($request), + ); + + return new PaymentResource($payment); + } + + /** + * One payment with the invoices its rows point at. + * + * @return JsonResponse + */ + public function show(Request $request, Payment $payment) + { + $this->authorize('view', $payment); + + return new PaymentResource($payment->load(['allocations.invoice'])); + } + + /** + * Overwrite a payment. Allocations are only re-cut when the payload carries + * an allocations key; leaving it out keeps the rows already on record. + * + * @param Request $request + * @return JsonResponse + */ + public function update(PaymentRequest $request, Payment $payment) + { + $this->authorize('update', $payment); + + $payment = $this->paymentService->update( + payment: $payment, + attributes: $request->getPaymentPayload(), + replaceAllocations: $request->exists('allocations'), + allocations: $request->validated('allocations') ?? [], + customFields: $this->customFields($request), + ); + + return new PaymentResource($payment); + } + + /** + * Re-cut the whole allocation set of one payment; an empty list releases + * every invoice it was covering. + * + * @return JsonResponse + */ + 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'])); + } + + /** + * Drop several payments at once. Ids outside the active company are quietly + * dropped from the set before the service deallocates and deletes them. + * + * @return JsonResponse + */ + public function delete(DeletePaymentsRequest $request) + { + $this->authorize('delete multiple payments'); + + $ids = Payment::whereCompany() + ->whereIn('id', $request->ids) + ->pluck('id'); + + $this->paymentService->delete($ids); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * Mail the receipt, using the company's own mail configuration. + * + * @return JsonResponse + */ + public function send(SendPaymentRequest $request, Payment $payment) + { + $this->authorize('send payment', $payment); + + $response = $this->paymentService->send($payment, $request->all()); + + return response()->json($response); + } + + /** + * Render the receipt mail body the composer is currently holding, so the + * SPA can show it before anything is sent. + */ + public function sendPreview(Request $request, Payment $payment) + { + $this->authorize('send payment', $payment); + + $markdown = new Markdown(view(), config('mail.markdown')); + + $data = $this->paymentService->sendPaymentData($payment, $request->all()); + $data['url'] = $payment->paymentPdfUrl; + + return $markdown->render('emails.send.payment', ['data' => $data]); + } + + /** + * Custom field values are optional and arrive untyped, so anything that is + * not a list of rows is treated as "none supplied". + */ + private function customFields(PaymentRequest $request): ?iterable + { + $customFields = $request->input('customFields'); + + return is_iterable($customFields) ? $customFields : null; + } +} diff --git a/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php b/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php new file mode 100644 index 00000000..307932e5 --- /dev/null +++ b/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php @@ -0,0 +1,32 @@ +where('company_id', $company->id) + ->get(); + + return PaymentMethodResource::collection($methods); + } +} diff --git a/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentsController.php b/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentsController.php new file mode 100644 index 00000000..d0d9feb6 --- /dev/null +++ b/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentsController.php @@ -0,0 +1,77 @@ +has('limit')) { + $perPage = $request->limit; + } + + $contact = Auth::guard('customer')->id(); + + $narrowing = $request->only([ + 'payment_number', + 'payment_method_id', + 'orderByField', + 'orderBy', + ]); + + $page = Payment::with(['customer', 'allocations.invoice', 'paymentMethod', 'creator']) + ->whereCustomer($contact) + ->applyFilters($narrowing) + ->select('payments.*') + ->orderByDesc('created_at') + ->paginateData($perPage); + + // Counted afresh instead of taken off the page: the tally covers + // everything on file for the contact, filters and paging aside. + $recorded = Payment::whereCustomer($contact)->count(); + + return PaymentResource::collection($page)->additional([ + 'meta' => ['paymentTotalCount' => $recorded], + ]); + } + + /** + * Hand back a single receipt, looked up inside the portal's company and + * narrowed to the signed-in contact. + * + * @param string $id + * @return Response + */ + public function show(Company $company, $id) + { + $contact = Auth::guard('customer')->id(); + + $payment = $company->payments()->whereCustomer($contact)->where('id', $id)->first(); + + if ($payment === null) { + return response()->json(['error' => 'payment_not_found'], Response::HTTP_NOT_FOUND); + } + + return PaymentResource::make($payment->load(['allocations.invoice'])); + } +} diff --git a/app/Domains/Receivables/Http/Controllers/PublicPaymentController.php b/app/Domains/Receivables/Http/Controllers/PublicPaymentController.php new file mode 100644 index 00000000..4c750ee2 --- /dev/null +++ b/app/Domains/Receivables/Http/Controllers/PublicPaymentController.php @@ -0,0 +1,59 @@ +receiptBehind($emailLog)->getGeneratedPDFOrStream('payment'); + } + + /** + * Serve the same receipt as JSON. + */ + public function getPayment(EmailLog $emailLog) + { + return PaymentResource::make($this->receiptBehind($emailLog)); + } + + /** + * Trade an email-log token for the receipt it was minted for. + * + * Two things stand between the token and the disclosure, in this order: a + * log row minted for some other kind of mail is a miss rather than a + * forbidden read, however the ids happen to line up; and the link must + * still fall inside the issuing company's expiry window. + */ + private function receiptBehind(EmailLog $emailLog): Payment + { + $receipt = $emailLog->mailable; + + if (! $receipt instanceof Payment) { + abort(404); + } + + if ($emailLog->isExpired()) { + abort(403, 'Link Expired.'); + } + + return $receipt; + } +} diff --git a/app/Domains/Receivables/Http/Requests/DeletePaymentsRequest.php b/app/Domains/Receivables/Http/Requests/DeletePaymentsRequest.php new file mode 100644 index 00000000..76aea79f --- /dev/null +++ b/app/Domains/Receivables/Http/Requests/DeletePaymentsRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'ids' => ['required'], + 'ids.*' => ['required', Rule::exists('payments', 'id')], + ]; + } +} diff --git a/app/Domains/Receivables/Http/Requests/PaymentMethodRequest.php b/app/Domains/Receivables/Http/Requests/PaymentMethodRequest.php new file mode 100644 index 00000000..48e92644 --- /dev/null +++ b/app/Domains/Receivables/Http/Requests/PaymentMethodRequest.php @@ -0,0 +1,60 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', $this->uniqueName()], + ]; + } + + /** + * Anything these endpoints write is a manual label of the active company; + * module-owned methods are registered elsewhere. + */ + public function getPaymentMethodPayload() + { + return collect($this->validated()) + ->merge([ + 'company_id' => $this->header('company'), + 'type' => PaymentMethod::TYPE_GENERAL, + ]) + ->toArray(); + } + + /** + * Names are unique inside a company. A replace exempts the method being + * written from its own name. Note that only PUT counts as a replace, + * so a PATCH of an unchanged name collides with itself. + */ + private function uniqueName(): Unique + { + $rule = Rule::unique('payment_methods')->where('company_id', $this->header('company')); + + return $this->isMethod('PUT') + ? $rule->ignore($this->route('payment_method'), 'id') + : $rule; + } +} diff --git a/app/Domains/Receivables/Http/Requests/PaymentRequest.php b/app/Domains/Receivables/Http/Requests/PaymentRequest.php new file mode 100644 index 00000000..bd75320e --- /dev/null +++ b/app/Domains/Receivables/Http/Requests/PaymentRequest.php @@ -0,0 +1,128 @@ + + */ + public function rules(): array + { + return [ + 'payment_date' => ['required'], + 'customer_id' => [ + 'required', + Rule::exists('customers', 'id')->where('company_id', $this->header('company')), + ], + // A rate is only demanded when the payer settles in a currency of + // their own; it is still checked when volunteered. + 'exchange_rate' => $this->foreignCurrency() + ? ['required', 'numeric', 'gt:0'] + : ['nullable', 'numeric', 'gt:0'], + 'amount' => ['required', 'integer', 'min:1'], + 'payment_number' => ['required', $this->uniqueNumber()], + // Row-level shape only. What the rows are allowed to add up to, and + // which invoices they may name, is the allocation engine's call. + 'allocations' => ['sometimes', 'array'], + 'allocations.*.invoice_id' => ['required', 'integer', 'distinct'], + 'allocations.*.amount' => ['required', 'integer', 'min:1'], + 'payment_method_id' => ['nullable'], + 'notes' => ['nullable'], + ]; + } + + /** + * The singular invoice_id link is retired: invoices are reached through + * allocations. It is refused from here instead of through a rule so the + * dead field never shows up in the generated API schema. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if (! $this->exists('invoice_id')) { + return; + } + + $validator->errors()->add( + 'invoice_id', + __('validation.prohibited', ['attribute' => 'invoice id']) + ); + }); + } + + /** + * The stored attributes. + * + * A payment is always denominated in the customer's currency: at home the + * rate is 1, abroad it is the submitted one, and the base amount is the + * converted total rounded to whole minor units. + */ + public function getPaymentPayload() + { + $currencyId = Customer::find($this->customer_id)->currency_id; + $homeCurrency = CompanySetting::getSetting('currency', $this->header('company')); + $rate = (string) $homeCurrency !== (string) $currencyId ? (float) $this->exchange_rate : 1; + + return collect($this->validated()) + ->except('allocations') + ->merge([ + 'creator_id' => $this->user()->id, + 'company_id' => $this->header('company'), + 'exchange_rate' => $rate, + 'base_amount' => (int) round($this->amount * $rate), + 'currency_id' => $currencyId, + ]) + ->toArray(); + } + + /** + * Numbers are unique inside a company; a replace exempts the payment being + * written from its own number. + */ + private function uniqueNumber(): Unique + { + $rule = Rule::unique('payments')->where('company_id', $this->header('company')); + + return $this->isMethod('PUT') + ? $rule->ignore($this->route('payment')->id) + : $rule; + } + + /** + * True when the payer's currency is not the company's own, which is what + * makes a rate mandatory. An unknown customer or an unset company currency + * leaves the rate optional; the customer rule reports that instead. + */ + private function foreignCurrency(): bool + { + $homeCurrency = CompanySetting::getSetting('currency', $this->header('company')); + $payer = Customer::find($this->customer_id); + + if (! $payer || ! $homeCurrency) { + return false; + } + + return (string) $payer->currency_id !== $homeCurrency; + } +} diff --git a/app/Domains/Receivables/Http/Requests/SendPaymentRequest.php b/app/Domains/Receivables/Http/Requests/SendPaymentRequest.php new file mode 100644 index 00000000..f77885ba --- /dev/null +++ b/app/Domains/Receivables/Http/Requests/SendPaymentRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'subject' => ['required'], + 'body' => ['required'], + 'from' => ['required'], + 'to' => ['required'], + 'cc' => ['nullable'], + 'bcc' => ['nullable'], + ]; + } +} diff --git a/app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentMethodResource.php b/app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentMethodResource.php new file mode 100644 index 00000000..a60440b6 --- /dev/null +++ b/app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentMethodResource.php @@ -0,0 +1,40 @@ +resource; + + return [ + 'id' => $method->id, + 'name' => $method->name, + 'company_id' => $method->company_id, + 'company' => $this->when( + $method->company()->exists(), + fn () => new CompanyResource($method->company) + ), + ]; + } +} diff --git a/app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentResource.php b/app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentResource.php new file mode 100644 index 00000000..6904743f --- /dev/null +++ b/app/Domains/Receivables/Http/Resources/CustomerPortal/PaymentResource.php @@ -0,0 +1,136 @@ +resource; + + $allocations = $payment->relationLoaded('allocations') + ? $payment->allocations + : $payment->allocations()->with('invoice')->get(); + + $allocated = (int) $allocations->sum('amount'); + $baseAllocated = (int) $allocations->sum('base_amount'); + $baseAmount = $this->baseAmount(); + + return [ + 'id' => $payment->id, + 'payment_number' => $payment->payment_number, + 'payment_date' => $payment->payment_date, + 'notes' => $payment->notes, + 'amount' => $payment->amount, + 'unique_hash' => $payment->unique_hash, + 'company_id' => $payment->company_id, + 'payment_method_id' => $payment->payment_method_id, + 'customer_id' => $payment->customer_id, + 'exchange_rate' => $payment->exchange_rate, + 'base_amount' => $baseAmount, + 'allocations' => $this->allocationRows($allocations), + 'allocated_amount' => $allocated, + 'unallocated_amount' => (int) $payment->amount - $allocated, + 'base_allocated_amount' => $baseAllocated, + 'base_unallocated_amount' => $baseAmount - $baseAllocated, + 'currency_id' => $payment->currency_id, + 'transaction_id' => $payment->transaction_id, + 'formatted_created_at' => $payment->formattedCreatedAt, + 'formatted_payment_date' => $payment->formattedPaymentDate, + 'payment_pdf_url' => $payment->paymentPdfUrl, + 'customer' => $this->when( + $payment->customer()->exists(), + fn () => new CustomerResource($payment->customer) + ), + 'payment_method' => $this->when( + $payment->paymentMethod()->exists(), + fn () => new PaymentMethodResource($payment->paymentMethod) + ), + 'fields' => $this->when( + $payment->fields()->exists(), + fn () => CustomFieldValueResource::collection($payment->fields) + ), + 'company' => $this->when( + $payment->company()->exists(), + fn () => new CompanyResource($payment->company) + ), + 'currency' => $this->when( + $payment->currency()->exists(), + fn () => new CurrencyResource($payment->currency) + ), + 'transaction' => $this->when( + $payment->transaction()->exists(), + fn () => new TransactionResource($payment->transaction) + ), + ]; + } + + /** + * The payment's value in the company's currency. + * + * Recomputed from the amount and the rate when the column was never + * written, with a falsy rate read as one so the payment keeps its face + * value instead of reporting as worth nothing. + */ + private function baseAmount(): int + { + $payment = $this->resource; + + if ($payment->base_amount === null) { + return (int) round($payment->amount * ($payment->exchange_rate ?: 1)); + } + + return (int) $payment->base_amount; + } + + /** + * One row per invoice this payment has been allocated against. + * + * The invoice is nested in full where there is one; an allocation whose + * invoice cannot be resolved still reports its amounts. + */ + private function allocationRows(Collection $allocations): Collection + { + return $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, + ]); + } +} diff --git a/app/Domains/Receivables/Http/Resources/CustomerPortal/TransactionResource.php b/app/Domains/Receivables/Http/Resources/CustomerPortal/TransactionResource.php new file mode 100644 index 00000000..cdee3aac --- /dev/null +++ b/app/Domains/Receivables/Http/Resources/CustomerPortal/TransactionResource.php @@ -0,0 +1,51 @@ +resource; + + return [ + 'id' => $transaction->id, + 'transaction_id' => $transaction->transaction_id, + 'type' => $transaction->type, + 'status' => $transaction->status, + 'transaction_date' => $transaction->transaction_date, + 'invoice_id' => $transaction->invoice_id, + 'invoice' => $this->when( + $transaction->invoice()->exists(), + fn () => new InvoiceResource($transaction->invoice) + ), + 'company' => $this->when( + $transaction->company()->exists(), + fn () => new CompanyResource($transaction->company) + ), + ]; + } +} diff --git a/app/Domains/Receivables/Http/Resources/PaymentMethodResource.php b/app/Domains/Receivables/Http/Resources/PaymentMethodResource.php new file mode 100644 index 00000000..eaf5b2da --- /dev/null +++ b/app/Domains/Receivables/Http/Resources/PaymentMethodResource.php @@ -0,0 +1,43 @@ +resource; + + return [ + 'id' => $method->id, + 'name' => $method->name, + 'company_id' => $method->company_id, + 'type' => $method->type, + 'company' => $this->when( + $method->company()->exists(), + fn () => new CompanyResource($method->company) + ), + ]; + } +} diff --git a/app/Domains/Receivables/Http/Resources/PaymentResource.php b/app/Domains/Receivables/Http/Resources/PaymentResource.php new file mode 100644 index 00000000..ab5bcfb8 --- /dev/null +++ b/app/Domains/Receivables/Http/Resources/PaymentResource.php @@ -0,0 +1,143 @@ +resource; + + $allocations = $payment->relationLoaded('allocations') + ? $payment->allocations + : $payment->allocations()->with('invoice')->get(); + + $allocated = (int) $allocations->sum('amount'); + $baseAllocated = (int) $allocations->sum('base_amount'); + $baseAmount = $this->baseAmount(); + + return [ + 'id' => $payment->id, + 'payment_number' => $payment->payment_number, + 'payment_date' => $payment->payment_date, + 'notes' => $payment->getNotes(), + 'amount' => $payment->amount, + 'unique_hash' => $payment->unique_hash, + 'company_id' => $payment->company_id, + 'payment_method_id' => $payment->payment_method_id, + 'creator_id' => $payment->creator_id, + 'customer_id' => $payment->customer_id, + 'exchange_rate' => $payment->exchange_rate, + 'base_amount' => $baseAmount, + 'allocations' => $this->allocationRows($allocations), + 'allocated_amount' => $allocated, + 'unallocated_amount' => (int) $payment->amount - $allocated, + 'base_allocated_amount' => $baseAllocated, + 'base_unallocated_amount' => $baseAmount - $baseAllocated, + 'currency_id' => $payment->currency_id, + 'transaction_id' => $payment->transaction_id, + 'sequence_number' => $payment->sequence_number, + 'formatted_created_at' => $payment->formattedCreatedAt, + 'formatted_payment_date' => $payment->formattedPaymentDate, + 'payment_pdf_url' => $payment->paymentPdfUrl, + 'customer' => $this->when( + $payment->customer()->exists(), + fn () => new CustomerResource($payment->customer) + ), + 'payment_method' => $this->when( + $payment->paymentMethod()->exists(), + fn () => new PaymentMethodResource($payment->paymentMethod) + ), + 'fields' => $this->when( + $payment->fields()->exists(), + fn () => CustomFieldValueResource::collection($payment->fields) + ), + 'company' => $this->when( + $payment->company()->exists(), + fn () => new CompanyResource($payment->company) + ), + 'currency' => $this->when( + $payment->currency()->exists(), + fn () => new CurrencyResource($payment->currency) + ), + 'transaction' => $this->when( + $payment->transaction()->exists(), + fn () => new TransactionResource($payment->transaction) + ), + ]; + } + + /** + * The payment's value in the company's currency. + * + * Older rows predate the stored column, so when it is absent the figure is + * recomputed from the amount and the rate. A rate of zero -- or any other + * falsy value -- stands in as one, which keeps such a payment at its face + * value instead of reporting it as worth nothing. + */ + private function baseAmount(): int + { + $payment = $this->resource; + + if ($payment->base_amount === null) { + return (int) round($payment->amount * ($payment->exchange_rate ?: 1)); + } + + return (int) $payment->base_amount; + } + + /** + * One row per invoice this payment has been allocated against. + * + * The invoice is nested in full where there is one; an allocation whose + * invoice cannot be resolved still reports its amounts. + */ + private function allocationRows(Collection $allocations): Collection + { + return $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, + ]); + } +} diff --git a/app/Domains/Receivables/Http/Resources/TransactionResource.php b/app/Domains/Receivables/Http/Resources/TransactionResource.php new file mode 100644 index 00000000..bcd724b0 --- /dev/null +++ b/app/Domains/Receivables/Http/Resources/TransactionResource.php @@ -0,0 +1,50 @@ +resource; + + return [ + 'id' => $transaction->id, + 'transaction_id' => $transaction->transaction_id, + 'type' => $transaction->type, + 'status' => $transaction->status, + 'transaction_date' => $transaction->transaction_date, + 'invoice_id' => $transaction->invoice_id, + 'invoice' => $this->when( + $transaction->invoice()->exists(), + fn () => new InvoiceResource($transaction->invoice) + ), + 'company' => $this->when( + $transaction->company()->exists(), + fn () => new CompanyResource($transaction->company) + ), + ]; + } +} diff --git a/app/Domains/Receivables/Jobs/GeneratePaymentPdfJob.php b/app/Domains/Receivables/Jobs/GeneratePaymentPdfJob.php new file mode 100644 index 00000000..6a61d937 --- /dev/null +++ b/app/Domains/Receivables/Jobs/GeneratePaymentPdfJob.php @@ -0,0 +1,55 @@ +payment = $payment; + $this->deleteExistingFile = $deleteExistingFile; + } + + /** + * Hands the rendering to the receipt itself and always reports success. + * The number handed back is a leftover of an older queue contract — + * nothing downstream reads it. + */ + public function handle(): int + { + $receipt = $this->payment; + + $receipt->generatePDF('payment', $receipt->payment_number, $this->deleteExistingFile); + + return 0; + } +} diff --git a/app/Domains/Receivables/Mail/SendPaymentMail.php b/app/Domains/Receivables/Mail/SendPaymentMail.php new file mode 100644 index 00000000..9590cdc9 --- /dev/null +++ b/app/Domains/Receivables/Mail/SendPaymentMail.php @@ -0,0 +1,94 @@ +data = $data; + } + + /** + * @return $this + */ + public function build() + { + $this->data['url'] = route('payment', [ + 'email_log' => $this->logDelivery(), + ]); + + $payload = $this->data; + + $message = $this->from($payload['from'], config('mail.from.name')) + ->subject($payload['subject']) + ->markdown('emails.send.payment', [ + // Passed as a list, not as a keyed array. The numeric keys + // that produces are inert: the view reads $data, which + // Laravel already supplies from the public property above. + 'data', + $this->data, + ]); + + $renderer = $payload['attach']['data']; + + if ($renderer) { + $message->attachData( + $renderer->output(), + $payload['payment']['payment_number'].'.pdf' + ); + } + + return $message; + } + + /** + * Write the outgoing message to the email log and give back the token + * that stands in for it in a public link. + */ + private function logDelivery(): string + { + $payload = $this->data; + $receipt = Payment::findOrFail($payload['payment']['id']); + + return app(EmailLogWriter::class)->record($receipt, [ + 'from' => $payload['from'], + 'to' => $payload['to'], + 'cc' => $payload['cc'] ?? null, + 'bcc' => $payload['bcc'] ?? null, + 'subject' => $payload['subject'], + 'body' => $payload['body'], + ]); + } +} diff --git a/app/Domains/Receivables/Models/Payment.php b/app/Domains/Receivables/Models/Payment.php new file mode 100644 index 00000000..5860ceb8 --- /dev/null +++ b/app/Domains/Receivables/Models/Payment.php @@ -0,0 +1,530 @@ + 'string', + 'exchange_rate' => 'float', + ]; + } + + /** + * Keep the stored receipt PDF in step with the row. + * + * A brand new receipt only has to be rendered; a saved one has to replace + * the file already on disk, because the number the file is named for may + * have moved. + */ + protected static function booted() + { + static::created(function ($receipt) { + self::queueRender($receipt, false); + }); + + static::updated(function ($receipt) { + self::queueRender($receipt, true); + }); + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * The online-payment attempt that produced this receipt, when a gateway + * was involved. + */ + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'transaction_id'); + } + + /** + * Mail sent about this receipt. + */ + public function emailLogs(): MorphMany + { + return $this->morphMany(EmailLog::class, 'mailable'); + } + + /** + * Contact the money came from. + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + /** + * Company the receipt was booked under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * The slices this receipt has been split into across documents. + */ + public function allocations(): HasMany + { + return $this->hasMany(PaymentAllocation::class, 'payment_id'); + } + + /** + * Documents this receipt settles, with the allocated amounts carried on + * the pivot. + */ + public function invoices(): BelongsToMany + { + return $this->belongsToMany(Invoice::class, 'payment_allocations', 'payment_id', 'invoice_id') + ->withPivot(['amount', 'base_amount']) + ->withTimestamps(); + } + + /** + * Staff account that recorded the receipt. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creator_id'); + } + + /** + * Currency the money arrived in, always the contact's own. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class, 'currency_id'); + } + + /** + * How the money was taken, when it was recorded. + */ + public function paymentMethod(): BelongsTo + { + return $this->belongsTo(PaymentMethod::class, 'payment_method_id'); + } + + /* + |-------------------------------------------------------------------------- + | Accessors and mutators + |-------------------------------------------------------------------------- + */ + + /** + * Store gateway settings as JSON. + * + * An empty value is skipped rather than written, so saving a receipt that + * carries none leaves whatever was already on file untouched. + * + * @param mixed $value + */ + public function setSettingsAttribute($value) + { + if (! $value) { + return; + } + + $encoded = json_encode($value); + + $this->attributes['settings'] = $encoded; + } + + /** + * Creation timestamp in the company's configured date format, written in + * the language the application is running in. + * + * @param mixed $value + */ + public function getFormattedCreatedAtAttribute($value) + { + $moment = Carbon::parse($this->created_at); + + return $moment->translatedFormat($this->receiptDateFormat()); + } + + /** + * Date the money arrived, in the company's configured date format and in + * the language the application is running in. + * + * @param mixed $value + */ + public function getFormattedPaymentDateAttribute($value) + { + $moment = Carbon::parse($this->payment_date); + + return $moment->translatedFormat($this->receiptDateFormat()); + } + + /** + * Shareable link to the rendered receipt. Possession of the hash is the + * only credential the link needs. + */ + public function getPaymentPdfUrlAttribute() + { + $hash = $this->unique_hash; + + return url('/payments/pdf/'.$hash); + } + + /* + |-------------------------------------------------------------------------- + | Query scopes + |-------------------------------------------------------------------------- + */ + + /** + * Run every listed filter that carries a value. + * + * Order is load-bearing: the clauses land in the query in the order + * written here, and the receipt-id filter contributes an OR, which makes + * everything queued before it part of that alternative. A filter sent as + * an empty string, a zero or a null counts as not sent at all. + */ + public function scopeApplyFilters($query, array $filters) + { + $clauses = [ + 'search' => fn ($wanted) => $query->whereSearch($wanted), + 'payment_number' => fn ($wanted) => $query->paymentNumber($wanted), + 'payment_id' => fn ($wanted) => $query->wherePayment($wanted), + 'payment_method_id' => fn ($wanted) => $query->paymentMethod($wanted), + 'customer_id' => fn ($wanted) => $query->whereCustomer($wanted), + ]; + + foreach ($clauses as $filter => $clause) { + $wanted = $filters[$filter] ?? null; + + if ($wanted) { + $clause($wanted); + } + } + + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if ($from && $to) { + $query->paymentsBetween( + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to) + ); + } + + $sortField = $filters['orderByField'] ?? null; + $sortDirection = $filters['orderBy'] ?? null; + + if ($sortField || $sortDirection) { + $query->whereOrder($sortField ?: 'sequence_number', $sortDirection ?: 'desc'); + } + } + + /** + * Keep only receipts whose contact matches every whitespace-separated + * term, a term counting as matched when it turns up in the display name, + * the contact person or the company name. + */ + public function scopeWhereSearch($query, $search) + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $needle = '%'.$term.'%'; + + $query->whereHas('customer', function ($payer) use ($needle) { + $payer->where('name', 'LIKE', $needle) + ->orWhere('contact_name', 'LIKE', $needle) + ->orWhere('company_name', 'LIKE', $needle); + }); + } + } + + /** + * Partial match on the receipt number. + */ + public function scopePaymentNumber($query, $paymentNumber) + { + return $query->where($this->qualifyColumn('payment_number'), 'LIKE', '%'.$paymentNumber.'%'); + } + + /** + * Narrow to receipts taken one particular way. + */ + public function scopePaymentMethod($query, $paymentMethodId) + { + return $query->where($this->qualifyColumn('payment_method_id'), $paymentMethodId); + } + + /** + * Restrict to receipts dated inside the inclusive range. + */ + public function scopePaymentsBetween($query, $start, $end) + { + return $query->whereBetween($this->qualifyColumn('payment_date'), [ + $start->format('Y-m-d'), + $end->format('Y-m-d'), + ]); + } + + /** + * Sort by a caller-supplied column, sanitised before it reaches SQL and + * falling back to the creation timestamp. + */ + public function scopeWhereOrder($query, $orderByField, $orderBy) + { + SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at'); + } + + /** + * Widen a listing to also take in one specific receipt. + * + * The column is deliberately left unqualified: the listing query joins the + * contacts table, so this is the clause that decides whether an id filter + * is answerable at all. + */ + public function scopeWherePayment($query, $payment_id) + { + $query->orWhere('id', $payment_id); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany($query) + { + $company = request()->header('company'); + + $query->where($this->qualifyColumn('company_id'), $company); + } + + /** + * Narrow to one contact. + */ + public function scopeWhereCustomer($query, $customer_id) + { + $query->where($this->qualifyColumn('customer_id'), $customer_id); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + */ + public function scopePaginateData($query, $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } + + /* + |-------------------------------------------------------------------------- + | Rendering and correspondence + |-------------------------------------------------------------------------- + */ + + /** + * View data for the PDF renderer. + */ + public function getPDFData(): mixed + { + $provider = app(PaymentPdfDataProvider::class); + + return $provider->getPdfData($this); + } + + /** + * The company's address block for print, or false when the company has no + * address on file. + */ + public function getCompanyAddress(): string|false + { + return $this->addressBlock( + $this->company && (! $this->company->address()->exists()), + 'payment_company_address_format' + ); + } + + /** + * The contact's billing address block for print, or false when the contact + * has no billing address on file. + */ + public function getCustomerBillingAddress(): string|false + { + return $this->addressBlock( + $this->customer && (! $this->customer->billingAddress()->exists()), + 'payment_from_customer_address_format' + ); + } + + /** + * Whether outgoing mail should carry the receipt PDF. Anything other than + * an explicit refusal counts as consent. + */ + public function getEmailAttachmentSetting(): bool + { + return CompanySetting::getSetting('payment_email_attachment', $this->company_id) != 'NO'; + } + + /** + * The note field with its placeholders resolved and its markup sanitised. + */ + public function getNotes(): string + { + $resolved = $this->getFormattedString($this->notes); + + return PdfHtmlSanitizer::sanitize($resolved); + } + + /** + * Resolve the placeholders in a mail body, dropping any that named + * something this receipt cannot supply. + */ + public function getEmailBody(string $body): string + { + $placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields()); + + return preg_replace('/{(.*?)}/', '', strtr($body, $placeholders)); + } + + /** + * The placeholders this receipt contributes on top of the shared contact + * and company set. + */ + public function getExtraFields(): array + { + $method = $this->paymentMethod; + $money = format_money_pdf($this->amount, $this->customer->currency); + + return [ + '{PAYMENT_DATE}' => $this->formattedPaymentDate, + '{PAYMENT_MODE}' => $method ? $method->name : null, + '{PAYMENT_NUMBER}' => $this->payment_number, + '{PAYMENT_AMOUNT}' => $money, + ]; + } + + /* + |-------------------------------------------------------------------------- + | Internals + |-------------------------------------------------------------------------- + */ + + /** + * Hand a receipt's PDF rendering to the queue once the surrounding + * transaction has actually landed, and keep the job itself from starting + * any earlier than that. + */ + private static function queueRender(self $payment, bool $replaceExisting): void + { + DB::afterCommit(function () use ($payment, $replaceExisting) { + GeneratePaymentPdfJob::dispatch($payment, $replaceExisting)->afterCommit(); + }); + } + + /** + * One of the printable address blocks: the company's or the contact's. + * + * The named format is only looked up once the address is known to be + * there, so a missing one costs nothing. + */ + private function addressBlock(bool $missing, string $setting): string|false + { + if ($missing) { + return false; + } + + return $this->getFormattedString(CompanySetting::getSetting($setting, $this->company_id)); + } + + /** + * The date format this company writes its receipts in. + */ + private function receiptDateFormat() + { + return CompanySetting::getSetting('carbon_date_format', $this->company_id); + } +} diff --git a/app/Domains/Receivables/Models/PaymentMethod.php b/app/Domains/Receivables/Models/PaymentMethod.php new file mode 100644 index 00000000..e4d8897b --- /dev/null +++ b/app/Domains/Receivables/Models/PaymentMethod.php @@ -0,0 +1,194 @@ + 'array', + 'use_test_env' => 'boolean', + ]; + } + + /** + * Store the gateway settings as JSON. + * + * Writing the column by hand takes precedence over the array cast, so an + * empty value is encoded and stored rather than skipped. + * + * @param mixed $value + */ + public function setSettingsAttribute($value) + { + $encoded = json_encode($value); + + $this->attributes['settings'] = $encoded; + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * Receipts taken this way. + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class, 'payment_method_id'); + } + + /** + * Expenses settled this way. + */ + public function expenses(): HasMany + { + return $this->hasMany(Expense::class, 'payment_method_id'); + } + + /** + * Company the method was created under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /* + |-------------------------------------------------------------------------- + | Query scopes + |-------------------------------------------------------------------------- + */ + + /** + * Run every listed filter that carries a value. + * + * Order is load-bearing: the method-id filter contributes an OR, which + * makes anything queued before it part of that alternative. Note that the + * company filter ignores the id it is handed and narrows to the company on + * the current request instead. + */ + public function scopeApplyFilters($query, array $filters) + { + $clauses = [ + 'method_id' => fn ($wanted) => $query->wherePaymentMethod($wanted), + 'company_id' => fn ($wanted) => $query->whereCompany($wanted), + 'search' => fn ($wanted) => $query->whereSearch($wanted), + ]; + + foreach ($clauses as $filter => $clause) { + $wanted = $filters[$filter] ?? null; + + if ($wanted) { + $clause($wanted); + } + } + } + + /** + * Narrow to one company. + */ + public function scopeWhereCompanyId($query, $id) + { + $query->where('company_id', '=', $id); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany($query) + { + $company = request()->header('company'); + + $query->where('company_id', '=', $company); + } + + /** + * Widen a listing to also take in one specific method. + */ + public function scopeWherePaymentMethod($query, $payment_id) + { + $query->orWhere('id', $payment_id); + } + + /** + * Partial match on the method's name. + */ + public function scopeWhereSearch($query, $search) + { + $needle = '%'.$search.'%'; + + $query->where('name', 'LIKE', $needle); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + */ + public function scopePaginateData($query, $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } + + /** + * Retrieve the settings array for a payment method by its ID. + */ + public static function getSettings(int $id): mixed + { + $method = self::find($id); + + return $method->settings; + } +} diff --git a/app/Domains/Receivables/Models/Transaction.php b/app/Domains/Receivables/Models/Transaction.php new file mode 100644 index 00000000..5cbeca4a --- /dev/null +++ b/app/Domains/Receivables/Models/Transaction.php @@ -0,0 +1,105 @@ +hasMany(Payment::class, 'transaction_id'); + } + + /** + * Document the payer was settling. + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'invoice_id'); + } + + /** + * Company the attempt was made against. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * Whether the payer's link has gone stale. + * + * Three things have to line up: the company has to have asked for public + * links to expire at all, the attempt has to have succeeded, and more days + * than the configured window have to have gone by. Two of those are worth + * spelling out, because both are deliberate and neither is what the name + * suggests. The clock runs from the row's last update rather than from + * when it was opened, so anything that touches the row pushes the deadline + * out; and only a successful attempt ever expires, leaving a refused one + * reachable for good. Both dates are compared as plain calendar days. + */ + public function isExpired(): bool + { + $window = (int) CompanySetting::getSetting('link_expiry_days', $this->company_id); + $expiryEnabled = CompanySetting::getSetting('automatically_expire_public_links', $this->company_id); + + $deadline = $this->updated_at->addDays($window); + + if ($expiryEnabled != 'YES' || $this->status != self::SUCCESS) { + return false; + } + + return Carbon::now()->format('Y-m-d') > $deadline->format('Y-m-d'); + } +} diff --git a/app/Domains/Receivables/Policies/PaymentMethodPolicy.php b/app/Domains/Receivables/Policies/PaymentMethodPolicy.php new file mode 100644 index 00000000..d3ccfa30 --- /dev/null +++ b/app/Domains/Receivables/Policies/PaymentMethodPolicy.php @@ -0,0 +1,89 @@ +mayReadPayments(); + } + + public function view(User $user, PaymentMethod $paymentMethod): bool + { + return $this->mayReadPayments() && $this->sameCompany($user, $paymentMethod); + } + + public function create(User $user): bool + { + return $this->mayReadPayments(); + } + + public function update(User $user, PaymentMethod $paymentMethod): bool + { + return $this->mayReadPayments() && $this->sameCompany($user, $paymentMethod); + } + + public function delete(User $user, PaymentMethod $paymentMethod): bool + { + return $this->mayReadPayments() && $this->sameCompany($user, $paymentMethod); + } + + /** + * Restoring and erasing are governed the same way; payment methods are not + * soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, PaymentMethod $paymentMethod): bool + { + return $this->mayReadPayments() && $this->sameCompany($user, $paymentMethod); + } + + public function forceDelete(User $user, PaymentMethod $paymentMethod): bool + { + return $this->mayReadPayments() && $this->sameCompany($user, $paymentMethod); + } + + /** + * The single ability behind every decision on this table. + */ + private function mayReadPayments(): bool + { + return BouncerFacade::can('view-payment', Payment::class); + } + + private function sameCompany(User $user, PaymentMethod $paymentMethod): bool + { + return $user->hasCompany($paymentMethod->company_id); + } +} diff --git a/app/Domains/Receivables/Policies/PaymentPolicy.php b/app/Domains/Receivables/Policies/PaymentPolicy.php new file mode 100644 index 00000000..179d4a65 --- /dev/null +++ b/app/Domains/Receivables/Policies/PaymentPolicy.php @@ -0,0 +1,97 @@ +sameCompany($user, $payment); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-payment', Payment::class); + } + + public function update(User $user, Payment $payment): bool + { + return BouncerFacade::can('edit-payment', $payment) && $this->sameCompany($user, $payment); + } + + public function delete(User $user, Payment $payment): bool + { + return $this->mayRemove($user, $payment); + } + + /** + * Restoring and erasing are governed by the delete ability as well; + * payments are not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, Payment $payment): bool + { + return $this->mayRemove($user, $payment); + } + + public function forceDelete(User $user, Payment $payment): bool + { + return $this->mayRemove($user, $payment); + } + + /** + * Mailing the receipt to the customer. + */ + public function send(User $user, Payment $payment) + { + return BouncerFacade::can('send-payment', $payment) && $this->sameCompany($user, $payment); + } + + /** + * Deleting a batch of payments in one request. + * + * Only the ability is asked for here: the rows arrive as a list of ids in + * the request body, so there is nothing yet to check company membership + * against. + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-payment', Payment::class); + } + + private function mayRemove(User $user, Payment $payment): bool + { + return BouncerFacade::can('delete-payment', $payment) && $this->sameCompany($user, $payment); + } + + private function sameCompany(User $user, Payment $payment): bool + { + return $user->hasCompany($payment->company_id); + } +}