with('client:id,name,customer_id', 'payments.items.billingItemType') ->whereNull('approved_at') ->latest('id') ->get([ 'id', 'client_id', 'pending_sql_acc_code', 'pending_client_name', 'invoice_no', 'total_sem_amount', 'total_net_amount', 'created_at', ]); return response()->json([ 'count' => $invoices->count(), 'invoices' => $invoices->map(function (ClientInvoice $invoice) { $previousPayments = $this->previousPaymentsForInvoice($invoice); $invoiceBillingTotals = $this->invoiceBillingTotalsFromPayments($previousPayments); return [ ...$invoice->toArray(), 'requires_client' => $invoice->client_id === null, 'previous_payments' => $previousPayments, 'invoice_billing_totals' => $invoiceBillingTotals, ]; }), ]); } public function store(Request $request): JsonResponse { $request->merge([ 'invoice_no' => $request->input('invoice_no') ?? $request->input('invoice.invoice_no') ?? $request->input('invoice.invoice_number'), 'client_name' => $request->input('client_name') ?? $request->input('invoice.company_name') ?? $request->input('invoice.client_name'), ]); $validated = $request->validate([ 'client_id' => ['nullable', 'exists:clients,id'], 'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'], 'client_name' => ['nullable', 'string'], 'invoice_no' => ['required', 'string'], 'linked_invoice_id' => ['nullable', 'integer'], 'is_credit_card' => ['nullable', 'boolean'], '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' => ['nullable', 'integer'], 'payments.*.items.*.sql_acc_code' => ['nullable', 'string'], 'payments.*.items.*.billing_item_type' => ['nullable', 'array'], 'payments.*.items.*.billing_item_type.sql_acc_code' => ['nullable', 'string'], 'payments.*.items.*.billingItemType' => ['nullable', 'array'], 'payments.*.items.*.billingItemType.sql_acc_code' => ['nullable', 'string'], 'payments.*.items.*.billing_item' => ['nullable', 'array'], 'payments.*.items.*.billing_item.sql_acc_code' => ['nullable', 'string'], 'payments.*.items.*.item' => ['nullable', 'array'], 'payments.*.items.*.item.sql_acc_code' => ['nullable', 'string'], 'payments.*.items.*.item.item' => ['nullable', 'array'], 'payments.*.items.*.item.item.sql_acc_code' => ['nullable', 'string'], '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'], 'invoice' => ['nullable', 'array'], 'payment_no' => ['nullable', 'string'], 'start_date' => ['nullable', 'date'], 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], 'amount' => ['nullable', 'numeric', 'min:0'], 'media_fee' => ['nullable', 'numeric', 'min:0'], 'media_fee_amount' => ['nullable', 'numeric', 'min:0'], 'management_fee' => ['nullable', 'numeric', 'min:0'], 'management_fee_amount' => ['nullable', 'numeric', 'min:0'], 'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], 'nett_amount' => ['nullable', 'numeric', 'min:0'], 'total_sem_amount' => ['nullable', 'numeric', 'min:0'], 'total_net_amount' => ['nullable', 'numeric', 'min:0'], 'total_spending' => ['nullable', 'numeric', 'min:0'], 'sem_invoice_items' => ['nullable', 'array'], 'sem_items' => ['nullable', 'array'], ]); $sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null); $client = ! empty($validated['client_id']) ? Client::find($validated['client_id']) : $this->clientLookupService->findBySqlAccCode($sqlAccCode); if (! empty($validated['linked_invoice_id'])) { $linkedInvoiceExists = $client !== null && ClientInvoice::query() ->where('id', $validated['linked_invoice_id']) ->where('client_id', $client->id) ->exists(); if (! $linkedInvoiceExists) { throw ValidationException::withMessages([ 'linked_invoice_id' => 'The linked invoice must belong to the resolved client.', ]); } } $payments = $this->paymentsPayload($validated); if ($payments === []) { throw ValidationException::withMessages([ 'payments' => 'At least one payment item is required.', ]); } $invoice = DB::transaction(function () use ($validated, $client, $sqlAccCode, $payments) { $invoice = ClientInvoice::create([ 'client_id' => $client?->id, 'pending_sql_acc_code' => $client === null ? $sqlAccCode : null, 'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null, 'invoice_no' => $validated['invoice_no'], 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, 'approved_at' => null, 'total_sem_amount' => $validated['total_sem_amount'] ?? $this->paymentsGrossTotal($payments), 'total_net_amount' => $validated['total_net_amount'] ?? $this->paymentsNetTotal($payments), ]); return $this->paymentSyncService->sync($invoice, $payments); }); return response()->json([ 'message' => 'Invoice created and marked for approval.', 'invoice' => $invoice->fresh('client', 'payments.items.billingItemType'), ], 201); } /** * @return array> */ private function paymentsPayload(array $validated): array { if (! empty($validated['payments'])) { return $this->paymentsPayloadFromExplicitPayments($validated); } if (! empty($validated['sem_items']) || ! empty($validated['sem_invoice_items'])) { return $this->paymentsPayloadFromSemItems($validated); } return $this->paymentsPayloadFromLegacyFees($validated); } /** * @return array> */ private function paymentsPayloadFromExplicitPayments(array $validated): array { $payments = $validated['payments'] ?? []; $this->paymentSyncService->ensureDefaultItemTypes(); $billingItemTypesBySqlCode = BillingItemType::withTrashed() ->get() ->keyBy(fn (BillingItemType $itemType) => strtoupper((string) $itemType->sql_acc_code)); $billingItemTypeIds = BillingItemType::withTrashed() ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); $externalItemSqlCodes = $this->externalItemSqlCodes($validated); foreach ($payments as $paymentIndex => $payment) { foreach (($payment['items'] ?? []) as $itemIndex => $item) { $sqlAccCode = $this->paymentItemSqlAccCode($item); $billingItemTypeId = (int) ($item['billing_item_types_id'] ?? 0); if ($sqlAccCode === null && $billingItemTypeId > 0) { $sqlAccCode = $externalItemSqlCodes[$billingItemTypeId] ?? null; } if ($sqlAccCode !== null) { $billingItemType = $billingItemTypesBySqlCode->get($sqlAccCode); if ($billingItemType === null) { throw ValidationException::withMessages([ "payments.{$paymentIndex}.items.{$itemIndex}.sql_acc_code" => 'The item SQL account code must match a billing item type.', ]); } $payments[$paymentIndex]['items'][$itemIndex]['billing_item_types_id'] = $billingItemType->id; continue; } if (! in_array($billingItemTypeId, $billingItemTypeIds, true)) { throw ValidationException::withMessages([ "payments.{$paymentIndex}.items.{$itemIndex}.billing_item_types_id" => 'The selected billing item type is invalid, and no matching item SQL account code was provided.', ]); } } } return $payments; } /** * @return array */ private function externalItemSqlCodes(array $validated): array { $records = collect($validated['sem_invoice_items'] ?? []) ->merge(data_get($validated, 'invoice.items', [])) ->merge(collect($validated['sem_items'] ?? [])->pluck('item')->filter()); $sqlCodes = []; foreach ($records as $record) { if (! is_array($record)) { continue; } $sqlAccCode = $this->paymentItemSqlAccCode($record); if ($sqlAccCode === null) { continue; } foreach (['item_id', 'id', 'item.id'] as $key) { $externalId = data_get($record, $key); if (is_numeric($externalId)) { $sqlCodes[(int) $externalId] = $sqlAccCode; } } } return $sqlCodes; } /** * @return array> */ private function paymentsPayloadFromSemItems(array $validated): array { $billingItemTypes = $this->paymentSyncService ->ensureDefaultItemTypes() ->keyBy(fn (BillingItemType $itemType) => strtoupper((string) $itemType->sql_acc_code)); $items = []; foreach (($validated['sem_items'] ?? $validated['sem_invoice_items'] ?? []) as $semItem) { if (! is_array($semItem)) { continue; } $sqlAccCode = $this->paymentItemSqlAccCode($semItem); $billingItemType = $sqlAccCode === null ? null : $billingItemTypes->get($sqlAccCode); if ($billingItemType === null) { Log::warning('Skipping invoice API SEM item with unknown SQL account code.', [ 'invoice_no' => $validated['invoice_no'] ?? null, 'sql_acc_code' => $sqlAccCode, ]); continue; } $grossAmount = $this->semItemAmount($semItem); if ($grossAmount <= 0) { continue; } $taxPercentage = $this->semItemTaxPercentage($semItem, $validated); $netAmount = $this->semItemNetAmount($semItem, $grossAmount, $taxPercentage); $isMediaItem = strtolower((string) $billingItemType->fee_type) === 'media'; $items[] = [ 'billing_item_types_id' => $billingItemType->id, 'start_date' => $isMediaItem ? ($semItem['start_date'] ?? $validated['start_date'] ?? null) : null, 'end_date' => $isMediaItem ? ($semItem['end_date'] ?? $validated['end_date'] ?? null) : null, 'payment_item_amount' => $grossAmount, 'tax_percentage' => $taxPercentage, 'net_amount' => $netAmount, 'withholding_tax' => (float) ($semItem['withholding_tax'] ?? 0), 'final_net_amount' => (float) ($semItem['final_net_amount'] ?? $semItem['nett_amount'] ?? $netAmount), 'spending' => $isMediaItem ? (float) ($semItem['spending'] ?? 0) : 0, 'is_creditcard' => $isMediaItem && (bool) ($validated['is_credit_card'] ?? false), ]; } if ($items === []) { return []; } return [[ 'payment_no' => $validated['payment_no'] ?? null, 'payment_total_amount' => array_sum(array_map(fn (array $item) => (float) $item['payment_item_amount'], $items)), 'payment_nett_amount' => array_sum(array_map(fn (array $item) => (float) $item['final_net_amount'], $items)), 'items' => $items, ]]; } /** * @return array> */ private function paymentsPayloadFromLegacyFees(array $validated): array { $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, ]; } if ($items === []) { return []; } return [[ 'payment_no' => $validated['payment_no'] ?? null, 'payment_total_amount' => $mediaFee + $managementFee, 'payment_nett_amount' => $mediaFeeAmount + $managementFeeAmount, 'items' => $items, ]]; } private function paymentsGrossTotal(array $payments): float { return array_sum(array_map(fn (array $payment) => (float) ($payment['payment_total_amount'] ?? 0), $payments)); } private function paymentsNetTotal(array $payments): float { return array_sum(array_map(fn (array $payment) => (float) ($payment['payment_nett_amount'] ?? 0), $payments)); } private function semItemAmount(array $semItem): float { return (float) ( data_get($semItem, 'exact_price') ?? data_get($semItem, 'amount') ?? data_get($semItem, 'item.estimated_total') ?? data_get($semItem, 'estimated_total') ?? 0 ); } private function semItemTaxPercentage(array $semItem, array $validated): float { return (float) ( data_get($semItem, 'item.sql_acc_tax_percent') ?? data_get($semItem, 'tax_percent') ?? $validated['tax_percent'] ?? 0 ); } private function semItemNetAmount(array $semItem, float $grossAmount, float $taxPercentage): float { $explicitNetAmount = data_get($semItem, 'net_amount') ?? data_get($semItem, 'nett_amount'); if (is_numeric($explicitNetAmount)) { return (float) $explicitNetAmount; } $taxAmount = data_get($semItem, 'exact_tax'); if (is_numeric($taxAmount)) { return max(0, $grossAmount - (float) $taxAmount); } return $this->netFromGross($grossAmount, $taxPercentage); } public function approve(ClientInvoice $invoice): JsonResponse { if ($invoice->client_id === null) { return response()->json([ 'message' => 'Create and link the client before approving this invoice.', ], 409); } $invoice = $this->approvalService->approve($invoice); return response()->json([ 'message' => 'Invoice approved successfully.', 'invoice' => $invoice, ]); } private function previousPaymentsForInvoice(ClientInvoice $invoice): array { if (empty($invoice->invoice_no)) { return []; } try { $response = Http::acceptJson() ->withHeaders([ 'X-Secret' => config('app.billing_key'), 'Accept' => 'application/json', ]) ->get(config('app.billing_url').'/customer/invoices/getInvoicePaymentDetailsByInvoiceGoogle', [ 'invoice_number' => $invoice->invoice_no, ]); if (! $response->successful()) { Log::warning('Unable to fetch invoice payment details.', [ 'invoice_no' => $invoice->invoice_no, 'status' => $response->status(), ]); return []; } $records = $this->normalizePaymentRecords($response->json('data')); return collect($records) ->filter(fn (array $payment) => ($payment['payment_number'] ?? null) !== $invoice->payment_no) ->map(fn (array $payment) => $this->formatPreviousPayment($payment)) ->values() ->all(); } catch (\Throwable $e) { Log::warning('Unable to fetch invoice payment details.', [ 'invoice_no' => $invoice->invoice_no, 'message' => $e->getMessage(), ]); return []; } } private function normalizePaymentRecords(mixed $data): array { if (! is_array($data)) { return []; } if (array_is_list($data)) { return $data; } return [$data]; } private function formatPreviousPayment(array $payment): array { $items = collect($payment['items'] ?? []) ->filter(fn (mixed $paymentItem) => is_array($paymentItem)) ->values(); $paymentAmount = (float) ($payment['amount'] ?? 0); $estimatedItemsTotal = $items->sum( fn (array $paymentItem) => $this->paymentItemEstimatedTotal($paymentItem) ); $itemAmounts = $items ->map(fn (array $paymentItem) => (float) ($paymentItem['amount'] ?? 0)) ->filter(fn (float $amount) => $amount > 0) ->unique() ->values(); $usesRepeatedPaymentAmount = $items->count() > 1 && $paymentAmount > 0 && $estimatedItemsTotal > 0 && $itemAmounts->count() === 1 && abs($itemAmounts->first() - $paymentAmount) < 0.01; $totals = $items->reduce(function (array $totals, array $paymentItem) use ($usesRepeatedPaymentAmount, $paymentAmount, $estimatedItemsTotal) { $sqlAccCode = $this->paymentItemSqlAccCode($paymentItem); $estimatedTotal = $this->paymentItemEstimatedTotal($paymentItem); $exact_tax = $this->paymentItemTax($paymentItem); $taxPercent = $this->paymentTaxPercent($paymentItem) / 100 + 1; $amount = $usesRepeatedPaymentAmount ? $paymentAmount * ($estimatedTotal / $estimatedItemsTotal) : (float) ($paymentItem['amount'] ?? 0); if ($sqlAccCode === 'G03') { $totals['media_fee'] += $amount; $totals['invoice_media_fee'] += $estimatedTotal; } if ($sqlAccCode === 'GOOGLE') { $totals['management_fee'] += $amount; $totals['invoice_management_fee'] += $estimatedTotal; } return $totals; }, [ 'media_fee' => 0.0, 'management_fee' => 0.0, 'invoice_media_fee' => 0.0, 'invoice_management_fee' => 0.0, ]); return [ 'payment_number' => $payment['payment_number'] ?? null, 'pending_client_name' => $payment['company_name'] ?? null, 'status' => $payment['status'] ?? null, 'sql_created_at' => $payment['sql_created_at'] ?? null, 'amount' => $payment['amount'] ?? null, 'media_fee' => $totals['media_fee'] / 1.08, 'management_fee' => $totals['management_fee'] / 1.08, 'invoice_media_fee' => $totals['invoice_media_fee'] / 1.08, 'invoice_management_fee' => $totals['invoice_management_fee'] / 1.08, 'invoice_number' => data_get($payment, 'invoice.invoice_number'), ]; } private function paymentItemSqlAccCode(array $paymentItem): ?string { $sqlAccCode = data_get($paymentItem, 'item.item.sql_acc_code') ?? data_get($paymentItem, 'item.sql_acc_code') ?? data_get($paymentItem, 'billing_item_type.sql_acc_code') ?? data_get($paymentItem, 'billingItemType.sql_acc_code') ?? data_get($paymentItem, 'billing_item.sql_acc_code') ?? data_get($paymentItem, 'sql_acc_code'); if (! is_string($sqlAccCode)) { return null; } $sqlAccCode = strtoupper(trim($sqlAccCode)); return $sqlAccCode === '' ? null : $sqlAccCode; } private function paymentItemEstimatedTotal(array $paymentItem): float { return (float) ( data_get($paymentItem, 'item.estimated_total') ?? data_get($paymentItem, 'estimated_total') ?? 0 ); } private function paymentItemTax(array $paymentItem): float { return (float) ( data_get($paymentItem, 'exact_tax') ?? 0 ); } private function paymentTaxPercent(array $paymentItem): float { return (float) ( data_get($paymentItem, 'item.sql_acc_tax_percent') ?? 0 ); } private function invoiceBillingTotalsFromPayments(array $payments): array { $payment = $payments[0] ?? null; return [ 'media_fee' => $payment['invoice_media_fee'] ?? 0, 'management_fee' => $payment['invoice_management_fee'] ?? 0, ]; } private function netFromGross(float $grossAmount, float $taxPercent): float { return $taxPercent > 0 ? $grossAmount / (1 + ($taxPercent / 100)) : $grossAmount; } }