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', 'is_credit_card', 'is_paid', 'start_date', 'end_date', 'payment_no', 'amount', 'management_fee', 'management_fee_amount', 'management_fee_tax', 'media_fee', 'media_fee_amount', 'media_fee_tax', 'tax_percent', 'nett_amount', 'total_spending', '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 { $validated = $request->validate([ 'client_id' => ['nullable', 'exists:clients,id'], 'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'], 'client_name' => ['nullable', 'string'], 'linked_invoice_id' => ['nullable', 'integer'], 'is_credit_card' => ['nullable', 'boolean'], 'payments' => ['nullable', 'array', 'min:1'], 'invoice' => ['nullable', 'array'], 'nett_amount' => ['nullable', 'numeric', 'min:0'], 'total_spending' => ['nullable', 'numeric', 'min:0'], 'sem_invoice_items' => ['nullable', 'array'], 'sem_items' => ['nullable', 'array'], ]); // return response()->json($validated['sem_invoice_items']); // Log::debug('Validated request data for creating client invoice.', [ // 'validated' => $validated, // ]); // Log::info('Received request to create client invoice.'); // return response()->json([ // 'message' => 'Invoice creation endpoint is under development.', // ], 501); $sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null); $client = ! empty($validated['client_id']) ? \App\Models\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.', ]); } } // return response()->json([ // 'message' => 'Invoice creation endpoint is under development.', // 'items' => $validated['sem_items'] // ], 501); $semItems = []; if (! empty($validated['sem_items'])) { foreach ($validated['sem_items'] as $item) { $semItems[] = [ 'sql_acc_code' => $item['sql_acc_code'] ?? null, 'amount' => $item['exact_price'] ?? null, 'tax_percent' => $item['item']['sql_acc_tax_percent'] ?? 0, ]; } } // return response()->json([ // 'message' => 'Invoice creation endpoint is under development.', // ], 501); // if (! empty($validated['sem_invoice_items'])) { // foreach ($validated['sem_invoice_items'] as $item) { // Log::debug('Validating SEM invoice item.', [ // 'item' => $item, // ]); // // if (! isset($item['sql_acc_code']) || ! isset($item['amount'])) { // // throw ValidationException::withMessages([ // // 'sem_invoice_items' => 'Each SEM invoice item must include sql_acc_code and amount.', // // ]); // // } // } // } return response()->json([ 'message' => 'Invoice creation endpoint is under development.', 'items' => $semItems ], 501); $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, 'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false), // 'is_paid' => (bool) ($validated['is_paid'] ?? false), 'approved_at' => null, // 'payment_no' => $validated['payment_no'] ?? null, // 'start_date' => $validated['start_date'] ?? null, // 'end_date' => $validated['end_date'] ?? null, // 'amount' => $mediaFee + $managementFee, // 'total_spending' => $validated['total_spending'] ?? null, ]); $invoice = $this->paymentSyncService->sync( $invoice, $validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice) ); // $this->approvalService->requireApproval($invoice); // return response()->json([ // 'message' => 'Invoice created and marked for approval.', // 'invoice' => $invoice->fresh('payments.items.billingItemType'), // ], 201); } 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, 'sql_acc_code'); return is_string($sqlAccCode) ? strtoupper(trim($sqlAccCode)) : null; } 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; } }