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::firstOrCreate( [ '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::firstOrCreate( [ '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.'.'); } }