inspiren-sem-tool/app/Http/Controllers/ClientInvoiceController.php

648 lines
24 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\BillingItemType;
use App\Models\Client;
use App\Models\ClientCustomer;
use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation;
use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientInvoicePaymentSyncService;
use App\Services\ClientLookupService;
use App\Services\UserHierarchyService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Throwable;
class ClientInvoiceController extends Controller
{
public function __construct(
private ClientInvoiceApprovalService $approvalService,
private ClientInvoicePaymentSyncService $paymentSyncService,
private UserHierarchyService $hierarchyService,
private ClientLookupService $clientLookupService,
) {}
public function create(Request $request): Response
{
$clientId = $request->query('client');
$customerId = $request->query('customer_id');
if (! $clientId || ! $customerId) {
abort(404);
}
$client = Client::findOrFail($clientId);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$availableInvoices = ClientInvoice::query()
->where('client_id', $clientId)
->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']);
return Inertia::render('client-invoices/create', [
'clientId' => $clientId,
'customerId' => $customerId,
'availableInvoices' => $availableInvoices,
'billingItemTypes' => $this->billingItemTypesForForm(),
]);
}
public function edit(ClientInvoice $invoice): Response
{
$invoice->load('client');
$resolvedClient = $invoice->client
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
if ($resolvedClient !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $resolvedClient), 403);
}
$availableInvoices = $resolvedClient === null
? collect()
: ClientInvoice::query()
->where('client_id', $resolvedClient->id)
->where('id', '!=', $invoice->id)
->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']);
return Inertia::render('client-invoices/edit', [
'invoice' => $invoice->load('client', 'payments.items.billingItemType'),
'availableInvoices' => $availableInvoices,
'billingItemTypes' => $this->billingItemTypesForForm(),
'existingClient' => $invoice->client === null ? $resolvedClient : null,
'unlinkedClients' => $this->clientOptions(),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'client_id' => ['required', 'exists:clients,id'],
'customer_id' => ['nullable', 'string'],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [
'nullable',
'integer',
Rule::exists('client_invoices', 'id')->where(function ($query) use ($request) {
return $query->where('client_id', $request->integer('client_id'));
}),
],
'is_paid' => ['nullable', 'boolean'],
...$this->paymentValidationRules(),
]);
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$invoice = DB::transaction(function () use ($validated) {
$invoice = ClientInvoice::create([
'client_id' => $validated['client_id'],
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'approved_at' => null,
'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
'total_net_amount' => $validated['total_net_amount'] ?? 0,
]);
return $this->paymentSyncService->sync(
$invoice,
$this->paymentsPayload($validated, $invoice)
);
});
$this->approvalService->approve($invoice);
return redirect()
->route('google-ads.accounts.show', ['id' => $validated['customer_id'] ?? $client->customer_id])
->with('message-info', 'Invoice created successfully.');
}
public function update(Request $request, ClientInvoice $invoice)
{
$invoice->load('client');
$resolvedClient = $invoice->client
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
if ($resolvedClient !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $resolvedClient), 403);
}
$validated = $request->validate([
'client_id' => [
'required',
'integer',
'exists:clients,id',
],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [
'nullable',
'integer',
Rule::exists('client_invoices', 'id')->where(function ($query) use ($request, $invoice) {
return $query
->where('client_id', $request->integer('client_id') ?: $invoice->client_id)
->where('id', '!=', $invoice->id);
}),
],
'is_paid' => ['nullable', 'boolean'],
...$this->paymentValidationRules(),
]);
$invoice = DB::transaction(function () use ($invoice, $validated) {
if ($invoice->client_id === null) {
$this->linkInvoiceClient($invoice, $validated);
$invoice->refresh();
} elseif ($invoice->client_id !== (int) $validated['client_id']) {
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$invoice->update([
'client_id' => $client->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
'linked_invoice_id' => null,
]);
$invoice->refresh();
}
$invoice->update([
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
'total_net_amount' => $validated['total_net_amount'] ?? 0,
]);
return $this->paymentSyncService->sync(
$invoice,
$this->paymentsPayload($validated, $invoice)
);
});
if (empty($invoice->approved_at)) {
$this->approvalService->approve($invoice);
}
$invoice->load('client');
return redirect()
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
->with('message-info', 'Invoice updated successfully.');
}
private function paymentValidationRules(): array
{
return [
'payments' => ['nullable', 'array', 'min:1'],
'payments.*.payment_no' => ['nullable', 'string'],
'payments.*.payment_total_amount' => ['required_with:payments', 'numeric', 'min:0'],
'payments.*.payment_nett_amount' => ['required_with:payments', 'numeric', 'min:0'],
'payments.*.items' => ['required_with:payments', 'array', 'min:1'],
'payments.*.items.*.billing_item_types_id' => ['required_with:payments', 'integer', 'exists:billing_item_types,id'],
'payments.*.items.*.start_date' => ['nullable', 'date'],
'payments.*.items.*.end_date' => ['nullable', 'date'],
'payments.*.items.*.payment_item_amount' => ['required_with:payments', 'numeric', 'min:0'],
'payments.*.items.*.tax_percentage' => ['required_with:payments', 'numeric', 'min:0', 'max:100'],
'payments.*.items.*.net_amount' => ['required_with:payments', 'numeric', 'min:0'],
'payments.*.items.*.withholding_tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
'payments.*.items.*.final_net_amount' => ['required_with:payments', 'numeric', 'min:0'],
'payments.*.items.*.spending' => ['nullable', 'numeric', 'min:0'],
'payments.*.items.*.is_creditcard' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'is_credit_card' => ['nullable', 'boolean'],
'total_sem_amount' => ['nullable', 'numeric', 'min:0'],
'total_net_amount' => ['nullable', 'numeric', 'min:0'],
'amount' => ['nullable', 'numeric', 'min:0'],
'management_fee' => ['required_without:payments', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required_without:payments', 'numeric', 'min:0'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'nett_amount' => ['nullable', 'numeric', 'min:0'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
];
}
private function paymentsPayload(array $validated, ClientInvoice $invoice): array
{
if (! empty($validated['payments'])) {
return $validated['payments'];
}
$billingItemTypes = $this->paymentSyncService->ensureDefaultItemTypes()->keyBy('name');
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$mediaFee = (float) ($validated['media_fee'] ?? 0);
$mediaFeeAmount = (float) ($validated['media_fee_amount'] ?? $this->netFromGross($mediaFee, $taxPercent));
$managementFee = (float) ($validated['management_fee'] ?? 0);
$managementFeeAmount = (float) ($validated['management_fee_amount'] ?? $this->netFromGross($managementFee, $taxPercent));
$items = [];
if ($mediaFee > 0) {
$items[] = [
'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME]->id,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'payment_item_amount' => $mediaFee,
'tax_percentage' => $taxPercent,
'net_amount' => $mediaFeeAmount,
'withholding_tax' => 0,
'final_net_amount' => $validated['nett_amount'] ?? $mediaFeeAmount,
'spending' => $validated['total_spending'] ?? 0,
'is_creditcard' => (bool) ($validated['is_credit_card'] ?? false),
];
}
if ($managementFee > 0) {
$items[] = [
'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME]->id,
'start_date' => null,
'end_date' => null,
'payment_item_amount' => $managementFee,
'tax_percentage' => $taxPercent,
'net_amount' => $managementFeeAmount,
'withholding_tax' => 0,
'final_net_amount' => $managementFeeAmount,
'spending' => 0,
'is_creditcard' => false,
];
}
return [[
'payment_no' => $validated['payment_no'] ?? null,
'payment_total_amount' => $mediaFee + $managementFee,
'payment_nett_amount' => $this->netFromGross($mediaFee + $managementFee, $taxPercent),
'items' => $items,
]];
}
private function billingItemTypesForForm(): array
{
$this->paymentSyncService->ensureDefaultItemTypes();
return BillingItemType::query()
->orderBy('id')
->get(['id', 'name', 'sql_acc_code', 'nett_contribution', 'fee_type', 'type', 'campaign_type'])
->map(fn (BillingItemType $itemType) => [
'id' => $itemType->id,
'name' => $itemType->name,
'sql_acc_code' => $itemType->sql_acc_code,
'nett_contribution' => $itemType->nett_contribution,
'fee_type' => $itemType->fee_type,
'type' => $itemType->type,
'campaign_type' => $itemType->campaign_type,
])
->all();
}
private function netFromGross(float $grossAmount, float $taxPercent): float
{
return $taxPercent > 0
? $grossAmount / (1 + ($taxPercent / 100))
: $grossAmount;
}
private function unlinkedClientOptions(): array
{
return Client::query()
->where(function ($query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
})
->orderBy('name')
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
->map(fn (Client $client) => [
'value' => (string) $client->id,
'label' => trim($client->name.' ('.$client->customer_id.')'),
])
->values()
->all();
}
private function clientOptions(): array
{
return Client::query()
->orderBy('name')
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
->map(fn (Client $client) => [
'value' => (string) $client->id,
'label' => trim($client->name.' ('.$client->customer_id.')'),
])
->values()
->all();
}
private function linkInvoiceClient(ClientInvoice $invoice, array $validated): Client
{
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_ASSIGNED_PERSON,
],
[
'user_id' => Auth::id(),
]
);
$invoice->update([
'client_id' => $client->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
]);
return $client;
}
public function approve(ClientInvoice $invoice)
{
abort_if($invoice->client === null, 409, 'Create the client before approving this invoice.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$this->approvalService->approve($invoice);
return redirect()
->back()
->with('message-info', 'Invoice approved successfully.');
}
public function destroy(ClientInvoice $invoice)
{
if ($invoice->client !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
}
DB::transaction(function () use ($invoice) {
$invoice->loadMissing('payments.items', 'linkedInvoices');
$invoice->linkedInvoices()->update([
'linked_invoice_id' => null,
]);
$invoice->payments->each(function ($payment) {
$payment->items()->delete();
$payment->delete();
});
$invoice->delete();
});
return redirect()
->back()
->with('message-info', 'Invoice deleted successfully.');
}
public function getPdfInvoice($id)
{
$invoice = ClientInvoice::find($id);
abort_if($invoice === null, 404, 'Invoice not found.');
$this->authorizeInvoicePdfAccess($invoice);
if ($this->storedInvoicePdfExists($invoice)) {
return response()->file(
Storage::disk('local')->path($invoice->pdf_path),
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="invoice-'.$invoice->id.'.pdf"',
],
);
}
$pdfUrl = $this->externalInvoicePdfUrl($invoice);
abort_if($pdfUrl === null, 404, 'Invoice PDF not found.');
$response = Http::withHeaders([
'X-Secret' => config('app.billing_key'),
'Accept' => 'application/json',
])->get($pdfUrl);
if ($response->status() === 404) {
abort(404, 'Invoice PDF not found.');
}
abort_unless($response->successful(), 502, 'Unable to fetch invoice PDF.');
return response()->stream(
function () use ($response) {
echo $response->body();
},
200,
[
'Content-Type' => 'application/pdf',
],
);
}
public function pdfInvoiceStatus(ClientInvoice $invoice)
{
$this->authorizeInvoicePdfAccess($invoice);
if ($this->storedInvoicePdfExists($invoice)) {
return response()->json([
'available' => true,
'source' => 'uploaded',
'pdf_url' => route('client-invoices.getPdfInvoice', ['id' => $invoice->id]),
]);
}
if ($this->externalInvoicePdfUrl($invoice) !== null) {
return response()->json([
'available' => true,
'source' => 'external',
'pdf_url' => route('client-invoices.getPdfInvoice', ['id' => $invoice->id]),
]);
}
return response()->json([
'available' => false,
'message' => 'Invoice PDF not found.',
], 404);
}
public function uploadPdfInvoice(Request $request, ClientInvoice $invoice)
{
$this->authorizeInvoicePdfAccess($invoice);
$validated = $request->validate([
'invoice_pdf' => ['required', 'file', 'mimes:pdf', 'max:20480'],
]);
if ($this->storedInvoicePdfExists($invoice)) {
Storage::disk('local')->delete($invoice->pdf_path);
}
$path = $validated['invoice_pdf']->store('client-invoices/'.$invoice->id, 'local');
$invoice->update([
'pdf_path' => $path,
]);
return response()->json([
'message' => 'Invoice uploaded successfully.',
'pdf_url' => route('client-invoices.getPdfInvoice', ['id' => $invoice->id]),
]);
}
private function authorizeInvoicePdfAccess(ClientInvoice $invoice): void
{
$invoice->loadMissing('client');
if ($invoice->client !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
}
}
private function storedInvoicePdfExists(ClientInvoice $invoice): bool
{
return ! empty($invoice->pdf_path)
&& Storage::disk('local')->exists($invoice->pdf_path);
}
private function externalInvoicePdfUrl(ClientInvoice $invoice): ?string
{
if (empty($invoice->invoice_no)) {
return null;
}
try {
$invoiceResponse = Http::acceptJson()
->withHeaders([
'X-Secret' => config('app.billing_key'),
'Accept' => 'application/json',
])
->get(config('app.billing_url').'/customer/invoices/', [
'audience' => 'SEM',
'invoice_numbers' => [$invoice->invoice_no],
]);
} catch (Throwable) {
return null;
}
if (! $invoiceResponse->successful()) {
return null;
}
$invoiceDetails = $invoiceResponse->json();
$pdfUrl = data_get($invoiceDetails, 'data.0.pdf_url');
return is_string($pdfUrl) && $pdfUrl !== '' ? $pdfUrl : null;
}
public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\RedirectResponse
{
$existingClient = $invoice->client
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
if ($existingClient !== null) {
$invoice->update([
'client_id' => $existingClient->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
]);
return redirect()
->route('client-invoices.edit', $invoice)
->with('message-info', 'Invoice '.$invoice->invoice_no.' has been linked to '.$existingClient->name.'.');
}
return Inertia::render('client-invoices/create-client', [
'invoice' => $invoice->load('client'),
'existingClient' => null,
'unlinkedClients' => $this->unlinkedClientOptions(),
]);
}
public function storeClient(Request $request, ClientInvoice $invoice)
{
$validated = $request->validate([
'client_id' => [
'required',
Rule::exists('clients', 'id')->where(function ($query) {
$query
->where(function ($query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
});
}),
],
'sql_acc_code' => ['required', 'string'],
]);
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code']);
$existingClient = $this->clientLookupService->findBySqlAccCode($sqlAccCode);
if ($existingClient !== null && $existingClient->id !== (int) $validated['client_id']) {
return redirect()
->back()
->withInput()
->withErrors(['sql_acc_code' => 'This SQL account code is already linked to another client.']);
}
$selectedClientIsLinked = Client::query()
->where('id', $validated['client_id'])
->where(function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->exists();
if ($selectedClientIsLinked) {
return redirect()
->back()
->withInput()
->withErrors(['client_id' => 'Select a client that does not already have an SQL account code.']);
}
$client = DB::transaction(function () use ($validated, $sqlAccCode, $invoice) {
$client = Client::findOrFail($validated['client_id']);
$client->update([
'sql_acc_code' => $sqlAccCode,
]);
ClientCustomer::updateOrCreate(
[
'client_id' => $client->id,
'sql_acc_code' => $sqlAccCode,
],
[]
);
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_ASSIGNED_PERSON,
],
[
'user_id' => Auth::id(),
]
);
$invoice->update([
'client_id' => $client->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
]);
return $client;
});
return redirect()
->route('client-invoices.edit', $invoice)
->with('message-info', 'Client '.$client->name.' has been linked to invoice '.$invoice->invoice_no.'.');
}
}