From d9eaaeaa9256257ec9b69f5aa5f5a56c7bd2920e Mon Sep 17 00:00:00 2001 From: brian-inspiren Date: Thu, 18 Jun 2026 16:05:12 +0800 Subject: [PATCH] feat: changes to invoice and payment details --- ...culateClientInvoicePaymentItemSpending.php | 121 ++ app/Console/Commands/CreateClientInvoice.php | 382 ++++- .../Api/ClientInvoiceController.php | 115 +- .../ClientInvoiceAdjustmentController.php | 5 +- .../Controllers/ClientInvoiceController.php | 379 +++-- app/Http/Controllers/DashboardController.php | 8 +- app/Http/Controllers/GoogleAdsController.php | 186 ++- app/Models/BillingItemType.php | 32 + app/Models/Client.php | 24 +- app/Models/ClientInvoice.php | 37 +- app/Models/ClientInvoicePayment.php | 34 + app/Models/ClientInvoicePaymentItem.php | 47 + .../ClientInvoicePaymentSyncService.php | 285 ++++ ...001_create_invoice_payment_item_tables.php | 61 + ...total_columns_to_client_invoices_table.php | 38 + ...401_drop_unused_client_invoice_columns.php | 131 ++ database/seeders/BillingItemTypeSeeder.php | 14 + ...ClientInvoicePaymentItemBackfillSeeder.php | 30 + database/seeders/DatabaseSeeder.php | 6 +- resources/js/forms/account/InvoiceForm.tsx | 1074 ++++++++++-- resources/js/layouts/app-layout.tsx | 860 ++++++++-- resources/js/pages/campaigns/show.tsx | 1487 ++++++++++------- resources/js/pages/client-invoices/create.tsx | 128 +- resources/js/pages/client-invoices/edit.tsx | 332 +++- resources/js/pages/dashboard.tsx | 9 +- resources/js/types/index.d.ts | 87 +- tests/Feature/ClientInvoiceApprovalTest.php | 99 ++ 27 files changed, 4623 insertions(+), 1388 deletions(-) create mode 100644 app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php create mode 100644 app/Models/BillingItemType.php create mode 100644 app/Models/ClientInvoicePayment.php create mode 100644 app/Models/ClientInvoicePaymentItem.php create mode 100644 app/Services/ClientInvoicePaymentSyncService.php create mode 100644 database/migrations/2026_06_05_000001_create_invoice_payment_item_tables.php create mode 100644 database/migrations/2026_06_05_000002_add_new_invoice_total_columns_to_client_invoices_table.php create mode 100644 database/migrations/2026_06_18_022401_drop_unused_client_invoice_columns.php create mode 100644 database/seeders/BillingItemTypeSeeder.php create mode 100644 database/seeders/ClientInvoicePaymentItemBackfillSeeder.php diff --git a/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php b/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php new file mode 100644 index 0000000..d871582 --- /dev/null +++ b/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php @@ -0,0 +1,121 @@ +option('dry-run'); + $spendCache = []; + $updated = 0; + $skipped = 0; + $failed = 0; + + ClientInvoicePaymentItem::query() + ->with('payment.invoice.client') + ->whereNotNull('start_date') + ->whereNotNull('end_date') + ->orderBy('id') + ->chunkById(50, function ($items) use ($adsService, $dryRun, &$spendCache, &$updated, &$skipped, &$failed) { + foreach ($items as $item) { + $client = $item->payment?->invoice?->client; + + if ($client === null || empty($client->customer_id)) { + $skipped++; + $this->warn("Skipping item {$item->id}: missing client/customer ID."); + continue; + } + + $startDate = $item->start_date?->format('Y-m-d'); + $endDate = $item->end_date?->format('Y-m-d'); + + if ($startDate === null || $endDate === null) { + $skipped++; + continue; + } + + $cacheKey = implode('|', [$client->customer_id, $startDate, $endDate]); + + try { + if (! array_key_exists($cacheKey, $spendCache)) { + $spendCache[$cacheKey] = $this->spendForDateRange( + $adsService, + $client->customer_id, + $startDate, + $endDate, + ); + } + + $spending = $spendCache[$cacheKey]; + + if (! $dryRun) { + $item->forceFill([ + 'spending' => $spending, + ])->save(); + } + + $updated++; + $this->line(sprintf( + '%s item %d: RM %.2f (%s to %s)', + $dryRun ? 'Calculated' : 'Updated', + $item->id, + $spending, + $startDate, + $endDate, + )); + } catch (\Throwable $exception) { + $failed++; + Log::error('Unable to calculate invoice payment item spending.', [ + 'client_invoice_payment_item_id' => $item->id, + 'customer_id' => $client->customer_id, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'message' => $exception->getMessage(), + ]); + + $this->error("Failed item {$item->id}: {$exception->getMessage()}"); + } + } + }); + + $this->info("Done. {$updated} calculated, {$skipped} skipped, {$failed} failed."); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + private function spendForDateRange( + GoogleAdsService $adsService, + string $customerId, + string $startDate, + string $endDate, + ): float { + $campaigns = $adsService->listCampaigns($customerId); + $spending = 0.0; + + foreach ($campaigns as $campaign) { + $metrics = $adsService->listCampaignsMetricsById( + $customerId, + (string) $campaign['id'], + $startDate, + $endDate, + ); + + $spending += array_sum(array_map( + fn (array $metric): float => (float) ($metric['actual_spend'] ?? 0), + $metrics, + )); + } + + return round($spending, 6); + } +} diff --git a/app/Console/Commands/CreateClientInvoice.php b/app/Console/Commands/CreateClientInvoice.php index e9c0e20..9f2aadf 100644 --- a/app/Console/Commands/CreateClientInvoice.php +++ b/app/Console/Commands/CreateClientInvoice.php @@ -3,42 +3,73 @@ namespace App\Console\Commands; use App\Models\Client; -use App\Models\Customers; use App\Models\ClientInvoice; use App\Models\ClientUserAssignation; use App\Models\User; -use App\Services\ClientInvoiceApprovalService; +use App\Services\ClientInvoicePaymentSyncService; +use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use App\Services\GoogleAdsService; use Illuminate\Support\Facades\Log; use Rap2hpoutre\FastExcel\FastExcel; -use Carbon\Carbon; class CreateClientInvoice extends Command { protected $signature = 'customer:create-invoice'; + protected $description = 'Create client invoice'; public function handle() { - $adsService = new GoogleAdsService(); - $approvalService = new ClientInvoiceApprovalService(); + $paymentSyncService = app(ClientInvoicePaymentSyncService::class); + try { DB::beginTransaction(); $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_EJ.csv')); + // $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_HJ.csv')); + // $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_K.csv')); $array = $collection->toArray(); - foreach ($array as $row) { - $startDate = Carbon::parse($row['start_date'])->format('Y-m-d'); - $endDate = Carbon::parse($row['end_date'])->format('Y-m-d'); - $client = Client::where('customer_id', str_replace('-', '', $row['customer_id']))->first(); - if ($client) { + $linkedInvoices = []; + $linkedInvoiceTargets = $this->linkedInvoiceTargets($array); + + foreach ($this->groupRowsByInvoice($array) as $invoiceNo => $invoiceRows) { + $invoiceClient = null; + $linkedInvoiceNo = null; + $payments = []; + $totalSemAmount = 0.0; + $totalNetAmount = 0.0; + + foreach ($invoiceRows as $row) { + $customerId = str_replace('-', '', (string) $this->rowValue($row, 'customer_id', '')); + $startDate = $this->date($this->rowValue($row, 'start_date')); + $endDate = $this->date($this->rowValue($row, 'end_date')); + $client = Client::where('customer_id', $customerId)->first(); + + if (! $client) { + Log::warning('Client not found for customer_id: '.$customerId, [ + 'invoice_no' => $invoiceNo, + ]); + + continue; + } + + if ($invoiceClient !== null && $invoiceClient->isNot($client)) { + Log::warning('Invoice rows resolve to different clients; row skipped.', [ + 'invoice_no' => $invoiceNo, + 'expected_client_id' => $invoiceClient->id, + 'row_client_id' => $client->id, + ]); + + continue; + } + + $invoiceClient ??= $client; $client->update([ - 'industry' => $row['industry'], + 'industry' => $this->rowValue($row, 'industry'), ]); - $salesUser = User::where('name', $row['sales'])->first(); - $pic = User::where('name', $row['pic'])->first(); + $salesUser = User::where('name', $this->rowValue($row, 'sales'))->first(); + $pic = User::where('name', $this->rowValue($row, 'pic'))->first(); if ($pic) { ClientUserAssignation::updateOrCreate( [ @@ -61,65 +92,66 @@ public function handle() ] ); } - $row['client_id'] = $client->id; - // if ($client->status != 'CANCELED') { - // $campaigns = $adsService->listCampaigns($row['customer_id']); - // Log::info('Hydrated client data', [ - // 'campaigns' => $campaigns, - // ]); - // foreach ($campaigns as $campaign) { - // Log::info('Hydrated client data', [ - // 'campaigns' => $campaign['id'], - // ]); - // if (empty($invoice->start_date) || empty($invoice->end_date)) { - // $totalSpend = 0; - // $spend += number_format($totalSpend, 2, '.', ''); - // } else { - // $metrics = $adsService->listCampaignsMetricsById( - // $row['customer_id'], - // $campaign['id'], - // $startDate, - // $endDate - // ); - // Log::info('Hydrated client data', [ - // 'metrics' => $metrics, - // ]); - // $totalSpend = array_sum(array_column($metrics, 'actual_spend')); - // $spend += number_format($totalSpend, 2, '.', ''); - // } - // } - // } else { - $spend = 0; - // } - $managementFee = intval(str_replace(',', '', $row['management_fee'])) ?? 0; - $mediaFee = intval(str_replace(',', '', $row['media_fee'])) ?? 0; - $managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0; - $mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0; + $rowLinkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', '')); + $managementFee = $this->amount($this->rowValue($row, 'management_fee', 0)); + $mediaFee = $this->amount($this->rowValue($row, 'media_fee', 0)); + $paymentNettAmount = $mediaFee + $managementFee; + $tax = $this->tax($this->rowValue($row, 'tax', 0), $paymentNettAmount, $startDate); + $paymentTotalAmount = $paymentNettAmount + $tax['amount']; + $isCreditCard = $mediaFee == 0 && ! in_array($invoiceNo, $linkedInvoiceTargets, true); - $invoice = ClientInvoice::updateOrCreate( - ['invoice_no' => $row['invoice_no']], - [ - 'client_id' => $row['client_id'], - 'is_credit_card' => $mediaFee == 0 ? 1 : 0, - 'start_date' => $startDate, - 'end_date' => $endDate, - 'management_fee' => $managementFee, - 'management_fee_amount' => $managementFeeAmount, - 'management_fee_tax' => $managementFee - $managementFeeAmount, - 'media_fee' => $mediaFee, - 'media_fee_amount' => $mediaFeeAmount, - 'media_fee_tax' => $mediaFee - $mediaFeeAmount, - 'tax_percent' => 8, - 'nett_amount' => $mediaFeeAmount, - 'total_spending' => $spend, - ] - ); + $payments[] = [ + 'payment_no' => $this->nullableString($this->rowValue($row, 'payment_no')), + 'payment_total_amount' => $paymentTotalAmount, + 'payment_nett_amount' => $paymentNettAmount, + 'items' => $this->paymentItems( + $mediaFee, + $managementFee, + $tax['percentage'], + $startDate, + $endDate, + $isCreditCard, + ), + ]; - $approvalService->approve($invoice); - } else { - Log::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id'])); - continue; // Skip this row if client not found + $totalSemAmount += $paymentTotalAmount; + $totalNetAmount += $paymentNettAmount; + + if ($rowLinkedInvoiceNo !== null) { + $linkedInvoiceNo ??= $rowLinkedInvoiceNo; + } + } + + if ($invoiceClient === null || $payments === []) { + continue; + } + + $invoice = ClientInvoice::updateOrCreate( + ['invoice_no' => $invoiceNo], + [ + 'client_id' => $invoiceClient->id, + 'approved_at' => now(), + 'total_sem_amount' => $totalSemAmount, + 'total_net_amount' => $totalNetAmount, + ] + ); + + $paymentSyncService->sync($invoice, $payments); + + if ($linkedInvoiceNo !== null) { + $linkedInvoices[$invoiceNo] = $linkedInvoiceNo; + } + } + + foreach ($linkedInvoices as $invoiceNo => $linkedInvoiceNo) { + $invoice = ClientInvoice::where('invoice_no', $invoiceNo)->first(); + $linkedInvoice = ClientInvoice::where('invoice_no', $linkedInvoiceNo)->first(); + + if ($invoice && $linkedInvoice) { + $invoice->update([ + 'linked_invoice_id' => $linkedInvoice->id, + ]); } } @@ -129,7 +161,217 @@ public function handle() Log::error('Error project linkage : '.$e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); + return 1; } + + return 0; + } + + private function rowValue(array $row, string $key, mixed $default = null): mixed + { + $normalizedKey = $this->normalizeHeader($key); + + foreach ($row as $rowKey => $value) { + if ($this->normalizeHeader((string) $rowKey) === $normalizedKey) { + return $value; + } + } + + return $default; + } + + private function normalizeHeader(string $header): string + { + return trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($header)), '_'); + } + + private function nullableString(mixed $value): ?string + { + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function amount(mixed $value): float + { + $normalized = preg_replace('/[^0-9.\-]/', '', (string) $value); + + return is_numeric($normalized) ? (float) $normalized : 0.0; + } + + private function invoiceNo(mixed $value): string + { + $invoiceNo = trim((string) $value); + + if (preg_match('/^\s*([A-Za-z0-9]+)/', $invoiceNo, $matches)) { + return $matches[1]; + } + + return $invoiceNo; + } + + private function linkedInvoiceNo(mixed $value): ?string + { + $linkedInvoiceNo = $this->invoiceNo($value); + + if ($linkedInvoiceNo === '' || $linkedInvoiceNo === '0') { + return null; + } + + return $linkedInvoiceNo; + } + + /** + * @param array> $rows + * @return array>> + */ + private function groupRowsByInvoice(array $rows): array + { + $groupedRows = []; + + foreach ($rows as $row) { + $invoiceNo = $this->invoiceNo($this->rowValue($row, 'invoice_no')); + + if ($invoiceNo === '') { + Log::warning('Invoice row skipped because invoice_no is empty.'); + + continue; + } + + $groupedRows[$invoiceNo][] = $row; + } + + return $groupedRows; + } + + /** + * @param array> $rows + * @return array + */ + private function linkedInvoiceTargets(array $rows): array + { + $invoiceNumbers = []; + + foreach ($rows as $row) { + $linkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', '')); + + if ($linkedInvoiceNo !== null) { + $invoiceNumbers[] = $linkedInvoiceNo; + } + } + + return array_values(array_unique($invoiceNumbers)); + } + + private function date(mixed $value): ?Carbon + { + if (empty($value)) { + return null; + } + + $date = trim((string) $value); + + foreach (['d/m/y', 'd/m/Y', 'Y-m-d', 'd-m-y', 'd-m-Y'] as $format) { + try { + return Carbon::createFromFormat($format, $date)->startOfDay(); + } catch (\Throwable) { + continue; + } + } + + return Carbon::parse($date)->startOfDay(); + } + + /** + * @return array{percentage: float, amount: float} + */ + private function tax(mixed $value, float $amount, ?Carbon $startDate): array + { + $tax = $this->amount($value); + + if ($tax > 100 && $amount > 0) { + return [ + 'percentage' => ($tax / $amount) * 100, + 'amount' => $tax, + ]; + } + + if ($tax > 0) { + return [ + 'percentage' => $tax, + 'amount' => $this->taxAmount($amount, $tax), + ]; + } + + if ($startDate !== null && $startDate->greaterThanOrEqualTo(Carbon::parse('2024-03-01'))) { + return [ + 'percentage' => 8, + 'amount' => $this->taxAmount($amount, 8), + ]; + } + + return [ + 'percentage' => 6, + 'amount' => $this->taxAmount($amount, 6), + ]; + } + + private function taxAmount(float $amount, float $taxPercentage): float + { + return $amount * ($taxPercentage / 100); + } + + /** + * @return array> + */ + private function paymentItems( + float $mediaFee, + float $managementFee, + float $taxPercentage, + ?Carbon $startDate, + ?Carbon $endDate, + bool $isCreditCard, + ): array { + $items = []; + + if ($mediaFee > 0 || $isCreditCard) { + $mediaTax = $this->taxAmount($mediaFee, $taxPercentage); + $items[] = [ + 'billing_item_types_id' => 1, + 'start_date' => $startDate?->format('Y-m-d'), + 'end_date' => $endDate?->format('Y-m-d'), + 'payment_item_amount' => $mediaFee + $mediaTax, + 'tax_percentage' => $taxPercentage, + 'net_amount' => $mediaFee, + 'withholding_tax' => $taxPercentage, + 'final_net_amount' => $this->finalNetAmount($mediaFee, $taxPercentage), + 'spending' => 0, + 'is_creditcard' => $isCreditCard, + ]; + } + + if ($managementFee > 0) { + $managementTax = $this->taxAmount($managementFee, $taxPercentage); + $items[] = [ + 'billing_item_types_id' => 2, + 'start_date' => null, + 'end_date' => null, + 'payment_item_amount' => $managementFee + $managementTax, + 'tax_percentage' => $taxPercentage, + 'net_amount' => $managementFee, + 'withholding_tax' => 0, + 'final_net_amount' => $this->finalNetAmount($managementFee, 0), + 'spending' => 0, + 'is_creditcard' => false, + ]; + } + + return $items; + } + + private function finalNetAmount(float $netAmount, float $withholdingTax): float + { + return $netAmount / (1 + ($withholdingTax / 100)); } } diff --git a/app/Http/Controllers/Api/ClientInvoiceController.php b/app/Http/Controllers/Api/ClientInvoiceController.php index 9fc0914..f07766d 100644 --- a/app/Http/Controllers/Api/ClientInvoiceController.php +++ b/app/Http/Controllers/Api/ClientInvoiceController.php @@ -4,8 +4,9 @@ use App\Http\Controllers\Controller; use App\Models\ClientInvoice; -use App\Services\ClientLookupService; use App\Services\ClientInvoiceApprovalService; +use App\Services\ClientInvoicePaymentSyncService; +use App\Services\ClientLookupService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -16,6 +17,7 @@ class ClientInvoiceController extends Controller { public function __construct( private ClientInvoiceApprovalService $approvalService, + private ClientInvoicePaymentSyncService $paymentSyncService, private ClientLookupService $clientLookupService, ) { } @@ -23,7 +25,7 @@ public function __construct( public function pending(): JsonResponse { $invoices = ClientInvoice::query() - ->with('client:id,name,customer_id') + ->with('client:id,name,customer_id', 'payments.items.billingItemType') ->whereNull('approved_at') ->latest('id') ->get([ @@ -32,17 +34,23 @@ public function pending(): JsonResponse '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', ]); @@ -68,26 +76,25 @@ public function store(Request $request): JsonResponse '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'], - 'is_paid' => ['nullable', 'boolean'], - 'payment_no' => ['nullable', 'string'], - 'start_date' => ['nullable', 'date'], - 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], - 'management_fee' => ['required', 'numeric', 'min:0'], - 'management_fee_amount' => ['nullable', 'numeric', 'min:0'], - 'management_fee_tax' => ['nullable', 'numeric', 'min:0'], - 'media_fee' => ['required', 'numeric', 'min:0'], - 'media_fee_amount' => ['nullable', 'numeric', 'min:0'], - 'media_fee_tax' => ['nullable', 'numeric', 'min:0'], + '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'], ]); - $mediaFee = $validated['media_fee']; + // 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.'); - $taxPercent = (float) ($validated['tax_percent'] ?? 0); - $nettAmount = $mediaFee / (1 + ($taxPercent / 100)); + // 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']) @@ -106,35 +113,66 @@ public function store(Request $request): JsonResponse } } + // 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, + // 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, 'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false), - 'is_paid' => (bool) ($validated['is_paid'] ?? 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, - 'management_fee' => $validated['management_fee'], - 'management_fee_amount' => $validated['management_fee_amount'] ?? null, - 'management_fee_tax' => $validated['management_fee_tax'] ?? null, - 'media_fee' => $validated['media_fee'], - 'media_fee_amount' => $validated['media_fee_amount'] ?? null, - 'media_fee_tax' => $validated['media_fee_tax'] ?? null, - 'tax_percent' => null, - 'nett_amount' => $nettAmount, - 'total_spending' => $validated['total_spending'] ?? 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, ]); - $this->approvalService->requireApproval($invoice); + $invoice = $this->paymentSyncService->sync( + $invoice, + $validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice) + ); - return response()->json([ - 'message' => 'Invoice created and marked for approval.', - 'invoice' => $invoice->fresh(), - ], 201); + // $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 @@ -313,4 +351,11 @@ private function invoiceBillingTotalsFromPayments(array $payments): array 'management_fee' => $payment['invoice_management_fee'] ?? 0, ]; } + + private function netFromGross(float $grossAmount, float $taxPercent): float + { + return $taxPercent > 0 + ? $grossAmount / (1 + ($taxPercent / 100)) + : $grossAmount; + } } diff --git a/app/Http/Controllers/ClientInvoiceAdjustmentController.php b/app/Http/Controllers/ClientInvoiceAdjustmentController.php index fef09d3..faf0f02 100644 --- a/app/Http/Controllers/ClientInvoiceAdjustmentController.php +++ b/app/Http/Controllers/ClientInvoiceAdjustmentController.php @@ -36,7 +36,7 @@ public function store(Request $request, Client $client) ]); return redirect() - ->back() + ->route('google-ads.accounts.show', ['id' => $client->customer_id]) ->with('message-info', 'Adjustment added successfully.'); } @@ -44,10 +44,11 @@ public function destroy(ClientInvoiceAdjustment $adjustment) { abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403); + $client = $adjustment->client; $adjustment->delete(); return redirect() - ->back() + ->route('google-ads.accounts.show', ['id' => $client->customer_id]) ->with('message-info', 'Adjustment deleted successfully.'); } } diff --git a/app/Http/Controllers/ClientInvoiceController.php b/app/Http/Controllers/ClientInvoiceController.php index 99e9eae..699fb65 100644 --- a/app/Http/Controllers/ClientInvoiceController.php +++ b/app/Http/Controllers/ClientInvoiceController.php @@ -2,29 +2,32 @@ 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 Inertia\Inertia; -use Inertia\Response; -use Illuminate\Validation\Rule; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use Inertia\Inertia; +use Inertia\Response; 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 { @@ -47,23 +50,33 @@ public function create(Request $request): Response 'clientId' => $clientId, 'customerId' => $customerId, 'availableInvoices' => $availableInvoices, + 'billingItemTypes' => $this->billingItemTypesForForm(), ]); } public function edit(ClientInvoice $invoice): Response { - abort_if($invoice->client === null, 409, 'Create the client before editing this invoice.'); - abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); + $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 = ClientInvoice::query() - ->where('client_id', $invoice->client_id) - ->where('id', '!=', $invoice->id) - ->orderBy('invoice_no') - ->get(['id', 'invoice_no', 'linked_invoice_id']); + $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'), + 'invoice' => $invoice->load('client', 'payments.items.billingItemType'), 'availableInvoices' => $availableInvoices, + 'billingItemTypes' => $this->billingItemTypesForForm(), + 'existingClient' => $invoice->client === null ? $resolvedClient : null, + 'unlinkedClients' => $this->clientOptions(), ]); } @@ -71,7 +84,7 @@ public function store(Request $request) { $validated = $request->validate([ 'client_id' => ['required', 'exists:clients,id'], - 'customer_id' => ['required', 'string'], + 'customer_id' => ['nullable', 'string'], 'invoice_no' => ['required', 'string'], 'linked_invoice_id' => [ 'nullable', @@ -80,117 +93,274 @@ public function store(Request $request) return $query->where('client_id', $request->integer('client_id')); }), ], - 'is_credit_card' => ['nullable', 'boolean'], 'is_paid' => ['nullable', 'boolean'], - 'payment_no' => ['nullable', 'string'], - 'start_date' => ['nullable', 'date'], - 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], - 'management_fee' => ['required', 'numeric', 'min:0'], - 'management_fee_amount' => ['nullable', 'numeric', 'min:0'], - 'management_fee_tax' => ['nullable', 'numeric', 'min:0'], - 'media_fee' => ['required', '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'], - 'total_spending' => ['nullable', 'numeric', 'min:0'], + ...$this->paymentValidationRules(), ]); $client = Client::findOrFail($validated['client_id']); abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403); - $mediaFee = $validated['media_fee']; - $taxPercent = (float) ($validated['tax_percent'] ?? 0); - $nettAmount = $mediaFee / (1 + ($taxPercent / 100)); + $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, + ]); - $invoice = ClientInvoice::create([ - 'client_id' => $validated['client_id'], - '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, - 'management_fee' => $validated['management_fee'], - 'management_fee_amount' => $validated['management_fee_amount'] ?? null, - 'management_fee_tax' => $validated['management_fee_tax'] ?? null, - 'media_fee' => $validated['media_fee'], - 'media_fee_amount' => $validated['media_fee_amount'] ?? null, - 'media_fee_tax' => $validated['media_fee_tax'] ?? null, - 'tax_percent' => $taxPercent, - 'nett_amount' => $nettAmount, - 'total_spending' => $validated['total_spending'] ?? null, - ]); + return $this->paymentSyncService->sync( + $invoice, + $this->paymentsPayload($validated, $invoice) + ); + }); $this->approvalService->approve($invoice); return redirect() - ->route('google-ads.accounts.show', ['id' => $validated['customer_id']]) + ->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) { - abort_if($invoice->client === null, 409, 'Create the client before updating this invoice.'); - abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); + $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 ($invoice) { + Rule::exists('client_invoices', 'id')->where(function ($query) use ($request, $invoice) { return $query - ->where('client_id', $invoice->client_id) + ->where('client_id', $request->integer('client_id') ?: $invoice->client_id) ->where('id', '!=', $invoice->id); }), ], - 'is_credit_card' => ['nullable', 'boolean'], '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'], - 'amount' => ['required', 'numeric', 'min:0'], - 'management_fee' => ['required', 'numeric', 'min:0'], + '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', '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'], - ]); + ]; + } - $managementFee = $validated['management_fee']; - $mediaFee = $validated['media_fee']; - $taxPercent = (float) ($validated['tax_percent'] ?? 0); - $nettAmount = $validated['nett_amount'] ?? ($mediaFee / (1 + ($taxPercent / 100))); - - $invoice->update([ - '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), - 'payment_no' => $validated['payment_no'] ?? null, - 'start_date' => $validated['start_date'] ?? null, - 'end_date' => $validated['end_date'] ?? null, - 'amount' => $validated['amount'], - 'management_fee' => $managementFee, - 'media_fee' => $validated['media_fee'], - 'tax_percent' => $taxPercent, - 'nett_amount' => $nettAmount, - 'total_spending' => $validated['total_spending'] ?? null, - ]); - - if(empty($invoice->approved_at)) { - $this->approvalService->approve($invoice); + private function paymentsPayload(array $validated, ClientInvoice $invoice): array + { + if (! empty($validated['payments'])) { + return $validated['payments']; } - return redirect() - ->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id]) - ->with('message-info', 'Invoice updated successfully.'); + $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) @@ -211,7 +381,20 @@ public function destroy(ClientInvoice $invoice) abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); } - $invoice->delete(); + 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() @@ -279,28 +462,10 @@ public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\ ->with('message-info', 'Invoice '.$invoice->invoice_no.' has been linked to '.$existingClient->name.'.'); } - $unlinkedClients = Client::query() - ->where(function ($query) { - $query->whereNull('sql_acc_code') - ->orWhere('sql_acc_code', ''); - }) - ->whereDoesntHave('customers', function ($query) { - $query->whereNotNull('sql_acc_code') - ->where('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(); - return Inertia::render('client-invoices/create-client', [ 'invoice' => $invoice->load('client'), 'existingClient' => null, - 'unlinkedClients' => $unlinkedClients, + 'unlinkedClients' => $this->unlinkedClientOptions(), ]); } @@ -332,6 +497,10 @@ public function storeClient(Request $request, ClientInvoice $invoice) $selectedClientIsLinked = Client::query() ->where('id', $validated['client_id']) + ->where(function ($query) { + $query->whereNotNull('sql_acc_code') + ->where('sql_acc_code', '!=', ''); + }) ->exists(); if ($selectedClientIsLinked) { diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index dcc52bf..2e2cbde 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -74,18 +74,14 @@ public function __invoke(Request $request): Response 'client_id', 'invoice_no', 'approved_at', - 'management_fee', - 'media_fee', - 'nett_amount', + 'total_net_amount', 'created_at', ]) ->map(fn (ClientInvoice $invoice) => [ 'id' => $invoice->id, 'invoice_no' => $invoice->invoice_no, 'approved_at' => $invoice->approved_at?->toDateTimeString(), - 'management_fee' => $invoice->management_fee, - 'media_fee' => $invoice->media_fee, - 'nett_amount' => $invoice->nett_amount, + 'total_net_amount' => $invoice->total_net_amount, 'created_at' => $invoice->created_at?->toDateString(), 'client' => $invoice->client, ]) diff --git a/app/Http/Controllers/GoogleAdsController.php b/app/Http/Controllers/GoogleAdsController.php index 55b11f2..93b6430 100644 --- a/app/Http/Controllers/GoogleAdsController.php +++ b/app/Http/Controllers/GoogleAdsController.php @@ -3,35 +3,36 @@ namespace App\Http\Controllers; use App\Models\Client; +use App\Models\ClientCustomer; +use App\Models\ClientInvoice; +use App\Models\ClientInvoiceAdjustment; use App\Models\ClientProjectActivities; use App\Models\ClientUserAssignation; -use App\Models\ClientInvoiceAdjustment; -use App\Models\ClientCustomer; -use App\Models\GoogleCampaignMetric; use App\Models\User; -use App\Models\ClientInvoice; +use App\Services\ClientInvoicePaymentSyncService; use App\Services\GoogleAdsService; use App\Services\UserHierarchyService; use Carbon\Carbon; -use Inertia\Inertia; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log as FacadesLog; +use Inertia\Inertia; use Rap2hpoutre\FastExcel\FastExcel; class GoogleAdsController extends Controller { protected $adsService; + private const GOOGLE_COMPANY_SYNC_LOCK = 'google-ads:get-company-details:running'; public function __construct( GoogleAdsService $adsService, + private ClientInvoicePaymentSyncService $paymentSyncService, private UserHierarchyService $hierarchyService, - ) - { + ) { $this->adsService = $adsService; } @@ -46,7 +47,7 @@ public function accounts() // $customerMap = $accounts->keyBy('customer_id'); $localClients = $this->hierarchyService ->scopeClientsVisibleTo(Client::query(), Auth::user()) - ->with('assignations.user', 'customers', 'invoices', 'invoiceAdjustments') + ->with('assignations.user', 'customers', 'invoices.payments.items.billingItemType', 'invoiceAdjustments') ->get(); $customerMap = $localClients->map(function ($data) { $assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON); @@ -55,24 +56,11 @@ public function accounts() $data['sql_acc_code'] = implode(',', $data->customers->pluck('sql_acc_code')->toArray()); $data['assigned_person'] = $assignedPerson?->user?->name; $data['sales_person'] = $salesPerson?->user?->name; - $data['latest_remaining_amount'] = $data->latestRemainingAmount(function (ClientInvoice $invoice) use ($data) { - if (empty($invoice->start_date) || empty($invoice->end_date)) { - return 0; - } + $data['latest_remaining_amount'] = $data->latestRemainingAmount(); - return GoogleCampaignMetric::query() - ->join('google_campaigns', 'google_campaign_metrics.google_campaign_id', '=', 'google_campaigns.id') - ->where('google_campaigns.client_id', $data->id) - ->whereNull('google_campaigns.deleted_at') - ->whereNull('google_campaign_metrics.deleted_at') - ->whereBetween('google_campaign_metrics.date', [ - $invoice->start_date?->toDateString(), - $invoice->end_date?->toDateString(), - ]) - ->sum('google_campaign_metrics.actual_spend'); - }); return $data; }); + return Inertia::render('campaigns/index', [ 'clients' => $customerMap->values()->all(), 'googleCompanySyncRunning' => Cache::has(self::GOOGLE_COMPANY_SYNC_LOCK), @@ -214,6 +202,7 @@ public function updateAccount(Request $request, $id) foreach ($assignmentValues as $role => $userId) { if ($userId === null) { $localClient->assignations()->where('role', $role)->delete(); + continue; } @@ -231,12 +220,14 @@ public function updateAccount(Request $request, $id) public function campaigns($id) { $campaigns = $this->adsService->listCampaigns($id); + return response()->json($campaigns); } public function listCampaignsMetrics($id, $startDate, $endDate) { $campaigns = $this->adsService->listCampaignsMetrics($id, $startDate, $endDate); + return response()->json($campaigns); } @@ -251,7 +242,7 @@ private function hydrateClient(array $account): array ] ); // dd($localClient); - $localClient->load(['assignations.user', 'invoices']); + $localClient->load(['assignations.user', 'invoices.payments.items.billingItemType']); $assignments = $localClient->assignations ->mapWithKeys(function (ClientUserAssignation $assignation) { @@ -287,61 +278,64 @@ private function hydrateClient(array $account): array ->all(); $invoices = $localClient->invoices - ->map(function ($invoice) use ($account) { - $campaigns = $this->adsService->listCampaigns($account['id']); - $totalInvoiceSpend = 0; - FacadesLog::info('Hydrated client data', [ - 'campaigns' => $campaigns, - ]); - foreach ($campaigns as $campaign) { - FacadesLog::info('Hydrated client data', [ - 'campaigns' => $campaign['id'], - ]); - - if (empty($invoice->start_date) || empty($invoice->end_date)) { - continue; - } else { - $metrics = $this->adsService->listCampaignsMetricsById( - $account['id'], - $campaign['id'], - $invoice->start_date?->toDateString() ?? null, - $invoice->end_date?->toDateString() ?? null - ); - FacadesLog::info('Hydrated client data', [ - 'metrics' => $metrics, - ]); - $totalSpend = array_sum(array_column($metrics, 'actual_spend')); - $totalInvoiceSpend += $totalSpend; - } - } + ->map(function ($invoice) { return [ 'id' => $invoice->id, 'client_id' => $invoice->client_id, 'invoice_no' => $invoice->invoice_no, 'linked_invoice_id' => $invoice->linked_invoice_id, - 'is_credit_card' => $invoice->is_credit_card, - 'is_paid' => $invoice->is_paid, - 'start_date' => $invoice->start_date?->toDateString(), - 'end_date' => $invoice->end_date?->toDateString(), - 'payment_no' => $invoice->payment_no, - 'amount' => $invoice->amount, - 'total_spend' => number_format($totalInvoiceSpend, 2, '.', ''), - 'management_fee' => $invoice->management_fee, - 'management_fee_amount' => $invoice->management_fee_amount, - 'management_fee_tax' => $invoice->management_fee_tax, - 'media_fee' => $invoice->media_fee, - 'media_fee_amount' => $invoice->media_fee_amount, - 'media_fee_tax' => $invoice->media_fee_tax, - 'tax_percent' => $invoice->tax_percent, - 'nett_amount' => $invoice->nett_amount, - 'total_spending' => $invoice->total_spending, + 'approved_at' => $invoice->approved_at?->toDateTimeString(), + 'total_sem_amount' => $invoice->total_sem_amount, + 'total_net_amount' => $invoice->total_net_amount, + 'created_at' => $invoice->created_at?->toDateTimeString(), + 'updated_at' => $invoice->updated_at?->toDateTimeString(), + 'payments' => $invoice->payments + ->map(fn ($payment) => [ + 'id' => $payment->id, + 'client_invoice_id' => $payment->client_invoice_id, + 'payment_no' => $payment->payment_no, + 'payment_total_amount' => $payment->payment_total_amount, + 'payment_nett_amount' => $payment->payment_nett_amount, + 'items' => $payment->items + ->map(fn ($item) => [ + 'id' => $item->id, + 'client_invoice_payment_id' => $item->client_invoice_payment_id, + 'billing_item_types_id' => $item->billing_item_types_id, + 'billing_item_type' => $item->billingItemType + ? [ + 'id' => $item->billingItemType->id, + 'name' => $item->billingItemType->name, + 'sql_acc_code' => $item->billingItemType->sql_acc_code, + 'nett_contribution' => $item->billingItemType->nett_contribution, + 'fee_type' => $item->billingItemType->fee_type, + 'type' => $item->billingItemType->type, + 'campaign_type' => $item->billingItemType->campaign_type, + ] + : null, + 'start_date' => $item->start_date?->toDateString(), + 'end_date' => $item->end_date?->toDateString(), + 'payment_item_amount' => $item->payment_item_amount, + 'tax_percentage' => $item->tax_percentage, + 'net_amount' => $item->net_amount, + 'withholding_tax' => $item->withholding_tax, + 'final_net_amount' => $item->final_net_amount, + 'spending' => $item->spending, + 'is_creditcard' => $item->is_creditcard, + ]) + ->values() + ->all(), + ]) + ->values() + ->all(), ]; }) ->toArray(); // dd($invoices); - - $campaigns = $this->adsService->listCampaigns($localClient->customer_id); + $campaigns = []; + // if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){ + // $campaigns = $this->adsService->listCampaigns($localClient->customer_id); + // } $lifeTimeSpend = 0; @@ -395,6 +389,7 @@ private function hydrateClient(array $account): array 'users' => $users, 'invoices' => $invoices, ]); + return [ $localClient, $assignments, @@ -475,27 +470,54 @@ public function insertCSVDataToDB() $mediaFee = intval($row['media_fee']); $managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0; $mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0; + $billingItemTypes = $this->paymentSyncService->ensureDefaultItemTypes()->keyBy('name'); + $items = []; - ClientInvoice::updateOrCreate( + if ($mediaFee > 0) { + $items[] = [ + 'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME]->id, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'payment_item_amount' => $mediaFee, + 'tax_percentage' => 8, + 'net_amount' => $mediaFeeAmount, + 'withholding_tax' => 0, + 'final_net_amount' => $mediaFeeAmount, + 'spending' => $spend, + 'is_creditcard' => 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' => 8, + 'net_amount' => $managementFeeAmount, + 'withholding_tax' => 0, + 'final_net_amount' => $managementFeeAmount, + 'spending' => 0, + 'is_creditcard' => false, + ]; + } + + $invoice = ClientInvoice::updateOrCreate( ['invoice_no' => $row['invoice_no']], [ 'client_id' => $row['client_id'], - 'is_credit_card' => $mediaFee == 0 ? 1 : 0, - 'start_date' => $startDate, - 'end_date' => $endDate, - 'management_fee' => $managementFee, - 'management_fee_amount' => $managementFeeAmount, - 'management_fee_tax' => $managementFee - $managementFeeAmount, - 'media_fee' => $mediaFee, - 'media_fee_amount' => $mediaFeeAmount, - 'media_fee_tax' => $mediaFee - $mediaFeeAmount, - 'tax_percent' => 8, - 'nett_amount' => $mediaFeeAmount, - 'total_spending' => $spend, ] ); + $this->paymentSyncService->sync($invoice, [[ + 'payment_no' => null, + 'payment_total_amount' => $mediaFee + $managementFee, + 'payment_nett_amount' => $mediaFeeAmount + $managementFeeAmount, + 'items' => $items, + ]]); } else { FacadesLog::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id'])); + continue; // Skip this row if client not found } } diff --git a/app/Models/BillingItemType.php b/app/Models/BillingItemType.php new file mode 100644 index 0000000..ade9456 --- /dev/null +++ b/app/Models/BillingItemType.php @@ -0,0 +1,32 @@ + 'boolean', + ]; + + public function paymentItems(): HasMany + { + return $this->hasMany(ClientInvoicePaymentItem::class, 'billing_item_types_id'); + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php index 11ce02e..5141fc9 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -42,28 +42,28 @@ public function getLatestRemainingAmountAttribute(): string return $this->latestRemainingAmount(); } - public function latestRemainingAmount(?callable $invoiceSpendingResolver = null): string + public function latestRemainingAmount(): string { $invoices = $this->relationLoaded('invoices') ? $this->invoices - : $this->invoices()->get(['client_id', 'is_credit_card', 'nett_amount', 'total_spending']); + : $this->invoices()->with('payments.items.billingItemType')->get(); + + $invoices->loadMissing('payments.items.billingItemType'); $adjustments = $this->relationLoaded('invoiceAdjustments') ? $this->invoiceAdjustments : $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']); - $nettAmount = $invoices - ->sum(fn (ClientInvoice $invoice) => (float) ($invoice->nett_amount ?? 0)); + $items = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments) + ->flatMap(fn ($payment) => $payment->items); - $billableSpending = $invoices - ->reject(fn (ClientInvoice $invoice) => $invoice->is_credit_card) - ->sum(function (ClientInvoice $invoice) use ($invoiceSpendingResolver) { - if ($invoiceSpendingResolver !== null) { - return (float) $invoiceSpendingResolver($invoice); - } + $nettAmount = $items + ->filter(fn ($item) => ! $item->is_creditcard && ($item->billingItemType?->nett_contribution ?? false)) + ->sum(fn ($item) => (float) ($item->final_net_amount ?? $item->net_amount ?? 0)); - return (float) ($invoice->total_spending ?? 0); - }); + $billableSpending = $items + ->reject(fn ($item) => $item->is_creditcard) + ->sum(fn ($item) => (float) ($item->spending ?? 0)); $adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) { $amount = (float) ($adjustment->amount ?? 0); diff --git a/app/Models/ClientInvoice.php b/app/Models/ClientInvoice.php index 2f70566..c2c7096 100644 --- a/app/Models/ClientInvoice.php +++ b/app/Models/ClientInvoice.php @@ -17,40 +17,15 @@ class ClientInvoice extends Model 'pending_client_name', 'invoice_no', 'linked_invoice_id', - 'is_credit_card', - 'is_paid', 'approved_at', - 'start_date', - 'end_date', - 'payment_no', - 'amount', - 'tax_percent', - 'media_fee', - 'media_fee_amount', - 'media_fee_tax', - 'management_fee', - 'management_fee_amount', - 'management_fee_tax', - 'nett_amount', - 'total_spending', + 'total_sem_amount', + 'total_net_amount', ]; protected $casts = [ - 'start_date' => 'date', - 'end_date' => 'date', - 'is_credit_card' => 'boolean', - 'is_paid' => 'boolean', 'approved_at' => 'datetime', - 'amount' => 'decimal:2', - 'tax_percent' => 'decimal:2', - 'media_fee' => 'decimal:2', - 'media_fee_amount' => 'decimal:2', - 'media_fee_tax' => 'decimal:2', - 'management_fee' => 'decimal:2', - 'management_fee_amount' => 'decimal:2', - 'management_fee_tax' => 'decimal:2', - 'nett_amount' => 'decimal:2', - 'total_spending' => 'decimal:2', + 'total_sem_amount' => 'decimal:6', + 'total_net_amount' => 'decimal:6', ]; public function client(): BelongsTo @@ -68,4 +43,8 @@ public function linkedInvoices(): HasMany return $this->hasMany(self::class, 'linked_invoice_id'); } + public function payments(): HasMany + { + return $this->hasMany(ClientInvoicePayment::class, 'client_invoice_id'); + } } diff --git a/app/Models/ClientInvoicePayment.php b/app/Models/ClientInvoicePayment.php new file mode 100644 index 0000000..3b250a1 --- /dev/null +++ b/app/Models/ClientInvoicePayment.php @@ -0,0 +1,34 @@ + 'decimal:6', + 'payment_nett_amount' => 'decimal:6', + ]; + + public function invoice(): BelongsTo + { + return $this->belongsTo(ClientInvoice::class, 'client_invoice_id'); + } + + public function items(): HasMany + { + return $this->hasMany(ClientInvoicePaymentItem::class, 'client_invoice_payment_id'); + } +} diff --git a/app/Models/ClientInvoicePaymentItem.php b/app/Models/ClientInvoicePaymentItem.php new file mode 100644 index 0000000..fd769c8 --- /dev/null +++ b/app/Models/ClientInvoicePaymentItem.php @@ -0,0 +1,47 @@ + 'date', + 'end_date' => 'date', + 'payment_item_amount' => 'decimal:6', + 'tax_percentage' => 'decimal:2', + 'net_amount' => 'decimal:6', + 'withholding_tax' => 'decimal:2', + 'final_net_amount' => 'decimal:6', + 'spending' => 'decimal:6', + 'is_creditcard' => 'boolean', + ]; + + public function payment(): BelongsTo + { + return $this->belongsTo(ClientInvoicePayment::class, 'client_invoice_payment_id'); + } + + public function billingItemType(): BelongsTo + { + return $this->belongsTo(BillingItemType::class, 'billing_item_types_id'); + } +} diff --git a/app/Services/ClientInvoicePaymentSyncService.php b/app/Services/ClientInvoicePaymentSyncService.php new file mode 100644 index 0000000..2208f64 --- /dev/null +++ b/app/Services/ClientInvoicePaymentSyncService.php @@ -0,0 +1,285 @@ +total_sem_amount; + $manualTotalNetAmount = $invoice->total_net_amount; + + $billingItemTypes = BillingItemType::withTrashed() + ->whereIn('id', collect($payments)->flatMap(fn (array $payment) => $payment['items'] ?? []) + ->pluck('billing_item_types_id') + ->filter() + ->unique() + ->values()) + ->get() + ->keyBy('id'); + + $invoice->payments()->with('items')->get()->each(function ($payment) { + $payment->items()->delete(); + $payment->delete(); + }); + + $totals = $this->emptyTotals(); + + foreach ($payments as $paymentPayload) { + $payment = $invoice->payments()->create([ + 'payment_no' => $paymentPayload['payment_no'] ?? null, + 'payment_total_amount' => $this->amount($paymentPayload['payment_total_amount'] ?? 0), + 'payment_nett_amount' => $this->amount($paymentPayload['payment_nett_amount'] ?? 0), + ]); + + $totals['amount'] += (float) $payment->payment_total_amount; + $totals['payment_no'] ??= $payment->payment_no; + + foreach ($paymentPayload['items'] ?? [] as $itemPayload) { + $billingItemType = $billingItemTypes->get((int) ($itemPayload['billing_item_types_id'] ?? 0)); + $grossAmount = $this->amount($itemPayload['payment_item_amount'] ?? 0); + $netAmount = $this->amount($itemPayload['net_amount'] ?? 0); + $finalNetAmount = $this->amount($itemPayload['final_net_amount'] ?? $netAmount); + $spending = $this->amount($itemPayload['spending'] ?? 0); + $isCreditCard = (bool) ($itemPayload['is_creditcard'] ?? false); + + $payment->items()->create([ + 'billing_item_types_id' => $billingItemType?->id ?? $itemPayload['billing_item_types_id'], + 'start_date' => $itemPayload['start_date'] ?? null, + 'end_date' => $itemPayload['end_date'] ?? null, + 'payment_item_amount' => $grossAmount, + 'tax_percentage' => $this->amount($itemPayload['tax_percentage'] ?? 0), + 'net_amount' => $netAmount, + 'withholding_tax' => $this->amount($itemPayload['withholding_tax'] ?? 0), + 'final_net_amount' => $finalNetAmount, + 'spending' => $spending, + 'is_creditcard' => $isCreditCard, + ]); + + $this->addItemToTotals($totals, $billingItemType, [ + 'gross_amount' => $grossAmount, + 'net_amount' => $netAmount, + 'final_net_amount' => $finalNetAmount, + 'spending' => $spending, + 'is_creditcard' => $isCreditCard, + 'start_date' => $itemPayload['start_date'] ?? null, + 'end_date' => $itemPayload['end_date'] ?? null, + ]); + } + } + + $invoice->update( + $this->legacyInvoicePayload( + $totals, + $manualTotalSemAmount, + $manualTotalNetAmount, + ) + ); + + return $invoice->refresh()->load('payments.items.billingItemType'); + }); + } + + public function syncLegacyFees(ClientInvoice $invoice, bool $replaceExisting = false): ClientInvoice + { + if (! $replaceExisting && $invoice->payments()->exists()) { + return $invoice; + } + + return $this->sync($invoice, $this->legacyPaymentsFor($invoice)); + } + + public function legacyPaymentsFor(ClientInvoice $invoice): array + { + $this->ensureDefaultItemTypes(); + + $mediaType = BillingItemType::where('name', self::MEDIA_SEARCH_NAME)->firstOrFail(); + $managementType = BillingItemType::where('name', self::MANAGEMENT_SEARCH_NAME)->firstOrFail(); + $taxPercentage = (float) ($invoice->tax_percent ?? 0); + $mediaGross = (float) ($invoice->media_fee ?? 0); + $managementGross = (float) ($invoice->management_fee ?? 0); + $mediaNet = (float) ($invoice->media_fee_amount ?? $this->netFromGross($mediaGross, $taxPercentage)); + $managementNet = (float) ($invoice->management_fee_amount ?? $this->netFromGross($managementGross, $taxPercentage)); + $mediaFinalNet = (float) ($invoice->nett_amount ?? $mediaNet); + $paymentTotal = $mediaGross + $managementGross; + + return [[ + 'payment_no' => $invoice->payment_no, + 'payment_total_amount' => $paymentTotal, + 'payment_tax_percentage' => $taxPercentage, + 'payment_nett_amount' => $this->netFromGross($paymentTotal, $taxPercentage), + // 'items' => [ + // [ + // 'billing_item_types_id' => $mediaType->id, + // 'start_date' => $invoice->start_date?->toDateString(), + // 'end_date' => $invoice->end_date?->toDateString(), + // 'payment_item_amount' => $mediaGross, + // 'tax_percentage' => $taxPercentage, + // 'net_amount' => $mediaNet, + // 'withholding_tax' => 0, + // 'final_net_amount' => $mediaFinalNet, + // 'spending' => (float) ($invoice->total_spending ?? 0), + // 'is_creditcard' => (bool) $invoice->is_credit_card, + // ], + // [ + // 'billing_item_types_id' => $managementType->id, + // 'start_date' => null, + // 'end_date' => null, + // 'payment_item_amount' => $managementGross, + // 'tax_percentage' => $taxPercentage, + // 'net_amount' => $managementNet, + // 'withholding_tax' => 0, + // 'final_net_amount' => $managementNet, + // 'spending' => 0, + // 'is_creditcard' => false, + // ], + // ], + ]]; + } + + public function ensureDefaultItemTypes(): Collection + { + return collect($this->defaultItemTypes())->map(function (array $itemType) { + $billingItemType = BillingItemType::withTrashed()->updateOrCreate( + ['name' => $itemType['name']], + $itemType + ); + + if ($billingItemType->trashed()) { + $billingItemType->restore(); + } + + return $billingItemType; + }); + } + + public function defaultItemTypes(): array + { + return [ + [ + 'name' => self::MEDIA_SEARCH_NAME, + 'sql_acc_code' => 'G03', + 'nett_contribution' => true, + 'fee_type' => 'Media', + 'type' => 'Google', + 'campaign_type' => 'Search', + ], + [ + 'name' => self::MANAGEMENT_SEARCH_NAME, + 'sql_acc_code' => 'GOOGLE', + 'nett_contribution' => false, + 'fee_type' => 'Management', + 'type' => 'Google', + 'campaign_type' => 'Search', + ], + [ + 'name' => self::MANAGEMENT_DEMAND_GEN_NAME, + 'sql_acc_code' => 'M05', + 'nett_contribution' => false, + 'fee_type' => 'Management', + 'type' => 'Google', + 'campaign_type' => 'Demand Gen', + ], + [ + 'name' => self::MEDIA_DEMAND_GEN_NAME, + 'sql_acc_code' => 'G06', + 'nett_contribution' => true, + 'fee_type' => 'Media', + 'type' => 'Google', + 'campaign_type' => 'Demand Gen', + ], + ]; + } + + private function addItemToTotals(array &$totals, ?BillingItemType $billingItemType, array $item): void + { + $feeType = strtolower((string) $billingItemType?->fee_type); + + if ($feeType === 'media') { + $totals['media_fee'] += $item['gross_amount']; + $totals['media_fee_amount'] += $item['net_amount']; + $totals['media_fee_tax'] += max(0, $item['gross_amount'] - $item['net_amount']); + } + + if ($feeType === 'management') { + $totals['management_fee'] += $item['gross_amount']; + $totals['management_fee_amount'] += $item['net_amount']; + $totals['management_fee_tax'] += max(0, $item['gross_amount'] - $item['net_amount']); + } + + if ($billingItemType?->nett_contribution) { + $totals['nett_amount'] += $item['final_net_amount']; + } + + $totals['total_sem_amount'] += $item['gross_amount']; + $totals['total_spending'] += $item['spending']; + $totals['is_credit_card'] = $totals['is_credit_card'] || $item['is_creditcard']; + + if ($item['start_date'] !== null) { + $totals['start_date'] ??= $item['start_date']; + } + + if ($item['end_date'] !== null) { + $totals['end_date'] ??= $item['end_date']; + } + } + + private function legacyInvoicePayload( + array $totals, + mixed $manualTotalSemAmount = null, + mixed $manualTotalNetAmount = null, + ): array + { + return [ + 'total_sem_amount' => $manualTotalSemAmount ?? $totals['total_sem_amount'], + 'total_net_amount' => $manualTotalNetAmount ?? $totals['nett_amount'], + ]; + } + + private function emptyTotals(): array + { + return [ + 'payment_no' => null, + 'start_date' => null, + 'end_date' => null, + 'amount' => 0, + 'management_fee' => 0, + 'management_fee_amount' => 0, + 'management_fee_tax' => 0, + 'media_fee' => 0, + 'media_fee_amount' => 0, + 'media_fee_tax' => 0, + 'nett_amount' => 0, + 'total_spending' => 0, + 'total_sem_amount' => 0, + 'payment_tax_percentage' => null, + 'is_credit_card' => false, + ]; + } + + private function netFromGross(float $grossAmount, float $taxPercentage): float + { + return $taxPercentage > 0 + ? $grossAmount / (1 + ($taxPercentage / 100)) + : $grossAmount; + } + + private function amount(mixed $amount): float + { + return is_numeric($amount) ? (float) $amount : 0.0; + } +} diff --git a/database/migrations/2026_06_05_000001_create_invoice_payment_item_tables.php b/database/migrations/2026_06_05_000001_create_invoice_payment_item_tables.php new file mode 100644 index 0000000..896e72e --- /dev/null +++ b/database/migrations/2026_06_05_000001_create_invoice_payment_item_tables.php @@ -0,0 +1,61 @@ +id(); + $table->string('name'); + $table->string('sql_acc_code')->nullable(); + $table->boolean('nett_contribution')->default(false); + $table->string('fee_type')->nullable(); + $table->string('type')->nullable(); + $table->string('campaign_type')->nullable(); + $table->softDeletes(); + + $table->unique(['name', 'sql_acc_code']); + }); + + Schema::create('client_invoice_payments', function (Blueprint $table) { + $table->id(); + $table->foreignId('client_invoice_id') + ->constrained('client_invoices') + ->cascadeOnDelete(); + $table->string('payment_no')->nullable(); + $table->decimal('payment_total_amount', 15, 6)->default(0); + $table->decimal('payment_tax_percentage', 8, 2)->default(0); + $table->decimal('payment_nett_amount', 15, 6)->default(0); + }); + + Schema::create('client_invoice_payment_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('client_invoice_payment_id') + ->constrained('client_invoice_payments') + ->cascadeOnDelete(); + $table->foreignId('billing_item_types_id') + ->constrained('billing_item_types') + ->restrictOnDelete(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->decimal('payment_item_amount', 15, 6)->default(0); + $table->decimal('tax_percentage', 8, 2)->default(0); + $table->decimal('net_amount', 15, 6)->default(0); + $table->decimal('withholding_tax', 8, 2)->default(0); + $table->decimal('final_net_amount', 15, 6)->default(0); + $table->decimal('spending', 15, 6)->default(0); + $table->boolean('is_creditcard')->default(false); + }); + } + + public function down(): void + { + Schema::dropIfExists('client_invoice_payment_items'); + Schema::dropIfExists('client_invoice_payments'); + Schema::dropIfExists('billing_item_types'); + } +}; diff --git a/database/migrations/2026_06_05_000002_add_new_invoice_total_columns_to_client_invoices_table.php b/database/migrations/2026_06_05_000002_add_new_invoice_total_columns_to_client_invoices_table.php new file mode 100644 index 0000000..46cad87 --- /dev/null +++ b/database/migrations/2026_06_05_000002_add_new_invoice_total_columns_to_client_invoices_table.php @@ -0,0 +1,38 @@ +decimal('total_sem_amount', 15, 6)->nullable()->after('total_spending'); + }); + } + + if (! Schema::hasColumn('client_invoices', 'total_net_amount')) { + Schema::table('client_invoices', function (Blueprint $table) { + $table->decimal('total_net_amount', 15, 6)->nullable()->after('total_sem_amount'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('client_invoices', 'total_net_amount')) { + Schema::table('client_invoices', function (Blueprint $table) { + $table->dropColumn('total_net_amount'); + }); + } + + if (Schema::hasColumn('client_invoices', 'total_sem_amount')) { + Schema::table('client_invoices', function (Blueprint $table) { + $table->dropColumn('total_sem_amount'); + }); + } + } +}; diff --git a/database/migrations/2026_06_18_022401_drop_unused_client_invoice_columns.php b/database/migrations/2026_06_18_022401_drop_unused_client_invoice_columns.php new file mode 100644 index 0000000..bcbcb9a --- /dev/null +++ b/database/migrations/2026_06_18_022401_drop_unused_client_invoice_columns.php @@ -0,0 +1,131 @@ +dropColumns('client_invoices', [ + 'start_date', + 'end_date', + 'payment_no', + 'amount', + 'tax_percent', + 'media_fee', + 'media_fee_amount', + 'media_fee_tax', + 'management_fee', + 'management_fee_amount', + 'management_fee_tax', + 'nett_amount', + 'total_spending', + 'is_credit_card', + 'is_paid', + ]); + + $this->dropColumns('client_invoice_payments', [ + 'payment_tax_percentage', + ]); + } + + public function down(): void + { + if (Schema::hasTable('client_invoices')) { + Schema::table('client_invoices', function (Blueprint $table) { + if (! Schema::hasColumn('client_invoices', 'start_date')) { + $table->date('start_date')->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'end_date')) { + $table->date('end_date')->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'payment_no')) { + $table->string('payment_no')->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'amount')) { + $table->decimal('amount', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'tax_percent')) { + $table->decimal('tax_percent', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'media_fee')) { + $table->decimal('media_fee', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'media_fee_amount')) { + $table->decimal('media_fee_amount', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'media_fee_tax')) { + $table->decimal('media_fee_tax', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'management_fee')) { + $table->decimal('management_fee', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'management_fee_amount')) { + $table->decimal('management_fee_amount', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'management_fee_tax')) { + $table->decimal('management_fee_tax', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'nett_amount')) { + $table->decimal('nett_amount', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'total_spending')) { + $table->decimal('total_spending', 10, 2)->nullable(); + } + + if (! Schema::hasColumn('client_invoices', 'is_credit_card')) { + $table->boolean('is_credit_card')->default(false); + } + + if (! Schema::hasColumn('client_invoices', 'is_paid')) { + $table->boolean('is_paid')->default(false); + } + }); + } + + if (Schema::hasTable('client_invoice_payments')) { + Schema::table('client_invoice_payments', function (Blueprint $table) { + if (! Schema::hasColumn('client_invoice_payments', 'payment_tax_percentage')) { + $table->decimal('payment_tax_percentage', 8, 2)->default(0); + } + }); + } + } + + /** + * @param array $columns + */ + private function dropColumns(string $table, array $columns): void + { + if (! Schema::hasTable($table)) { + return; + } + + $existingColumns = array_values(array_filter( + $columns, + fn (string $column): bool => Schema::hasColumn($table, $column), + )); + + if ($existingColumns === []) { + return; + } + + Schema::table($table, function (Blueprint $blueprint) use ($existingColumns) { + $blueprint->dropColumn($existingColumns); + }); + } +}; diff --git a/database/seeders/BillingItemTypeSeeder.php b/database/seeders/BillingItemTypeSeeder.php new file mode 100644 index 0000000..d5394c1 --- /dev/null +++ b/database/seeders/BillingItemTypeSeeder.php @@ -0,0 +1,14 @@ +ensureDefaultItemTypes(); + } +} diff --git a/database/seeders/ClientInvoicePaymentItemBackfillSeeder.php b/database/seeders/ClientInvoicePaymentItemBackfillSeeder.php new file mode 100644 index 0000000..7cd109e --- /dev/null +++ b/database/seeders/ClientInvoicePaymentItemBackfillSeeder.php @@ -0,0 +1,30 @@ +ensureDefaultItemTypes(); + + ClientInvoice::query() + ->whereDoesntHave('payments') + ->where(function ($query) { + $query + ->whereNotNull('media_fee') + ->orWhereNotNull('management_fee'); + }) + ->orderBy('id') + ->chunkById(100, function ($invoices) use ($syncService) { + foreach ($invoices as $invoice) { + $syncService->syncLegacyFees($invoice); + } + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ee7eddd..d64453d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,11 @@ public function run(): void { // User::factory(10)->create(); - $this->call(RoleSeeder::class); + $this->call([ + RoleSeeder::class, + BillingItemTypeSeeder::class, + ClientInvoicePaymentItemBackfillSeeder::class, + ]); $user = User::firstOrCreate( ['email' => 'test@example.com'], diff --git a/resources/js/forms/account/InvoiceForm.tsx b/resources/js/forms/account/InvoiceForm.tsx index a4ff965..292e13e 100644 --- a/resources/js/forms/account/InvoiceForm.tsx +++ b/resources/js/forms/account/InvoiceForm.tsx @@ -1,32 +1,71 @@ -import React, { useEffect, useState } from "react"; -import { InertiaFormProps } from "@inertiajs/react"; -import { route } from "ziggy-js"; -import axios from "axios"; -import { Button, Group, Stack, TextInput, NumberInput, Loader, Select, Switch, Text } from "@mantine/core"; -import { DateInput } from "@mantine/dates"; -import { IconDeviceFloppy } from "@tabler/icons-react"; -import dayjs from "dayjs"; +import { InertiaFormProps } from '@inertiajs/react'; +import { + ActionIcon, + Badge, + Box, + Button, + Group, + Loader, + NumberInput, + Select, + SimpleGrid, + Stack, + Switch, + Text, + TextInput, + type MantineTheme, +} from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { + IconDeviceFloppy, + IconPlus, + IconTrash, + IconUserPlus, +} from '@tabler/icons-react'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { route } from 'ziggy-js'; + +export interface BillingItemTypeOption { + id: number; + name: string; + sql_acc_code: string | null; + nett_contribution: boolean; + fee_type: string | null; + type: string | null; + campaign_type: string | null; +} + +export interface InvoicePaymentItemFormValues { + billing_item_types_id: string; + start_date: string; + end_date: string; + payment_item_amount: string; + tax_percentage: string; + net_amount: string; + withholding_tax: string; + final_net_amount: string; + spending: string; + is_creditcard: boolean; +} + +export interface InvoicePaymentFormValues { + payment_no: string; + payment_total_amount: string; + payment_nett_amount: string; + items: InvoicePaymentItemFormValues[]; +} export interface InvoiceFormValues { invoice_no: string; linked_invoice_id: string; - is_credit_card: boolean; is_paid: boolean; - payment_no: string; - start_date: string; - end_date: string; - amount: string; - management_fee: string; - management_fee_amount?: string; - management_fee_tax?: string; - media_fee: string; - media_fee_amount?: string; - media_fee_tax?: string; - tax_percent: string; - total_spending: string; + total_sem_amount: string; + total_net_amount: string; + payments: InvoicePaymentFormValues[]; client_id?: string; customer_id?: string; - nett_amount: string; } interface InvoiceOption { @@ -37,52 +76,285 @@ interface InvoiceOption { interface Props { form: InertiaFormProps; onSubmit: (event: React.FormEvent) => void; + billingItemTypes: BillingItemTypeOption[]; submitLabel?: string; invoiceOptions?: InvoiceOption[]; + showClientLink?: boolean; + requiresClient?: boolean; + clientOptions?: InvoiceOption[]; + pendingClientName?: string | null; +} + +const parseAmount = (value?: string) => { + const parsed = Number.parseFloat(value ?? ''); + + return Number.isFinite(parsed) ? parsed : 0; +}; + +const formatAmount = (value: number) => value.toFixed(2); + +const finalNetAmount = (netAmount: number, withholdingTax: number) => + netAmount / (1 + withholdingTax / 100); + +const numberInputValue = (value?: string) => + value !== '' && value !== undefined ? Number(value) : undefined; + +const numberInputString = (value: number | string | null | undefined) => + value === '' || value === null || value === undefined ? '' : String(value); + +const sectionSurface = (theme: MantineTheme) => + theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.white; + +const subtleSurface = (theme: MantineTheme) => + theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0]; + +const nestedSurface = (theme: MantineTheme) => + theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0]; + +const borderColor = (theme: MantineTheme, shade = 3) => + theme.colorScheme === 'dark' + ? theme.colors.dark[Math.max(3, 7 - shade)] + : theme.colors.gray[shade]; + +export function createPaymentItem( + billingItemTypeId: string, + overrides: Partial = {}, +): InvoicePaymentItemFormValues { + return { + billing_item_types_id: billingItemTypeId, + start_date: '', + end_date: '', + payment_item_amount: '', + tax_percentage: '8', + net_amount: '', + withholding_tax: '0', + final_net_amount: '', + spending: '0', + is_creditcard: false, + ...overrides, + }; +} + +export function createPayment( + _billingItemTypes: BillingItemTypeOption[], + overrides: Partial = {}, +): InvoicePaymentFormValues { + return { + payment_no: '', + payment_total_amount: '', + payment_nett_amount: '', + items: [], + ...overrides, + }; } export default function InvoiceForm({ form, onSubmit, - submitLabel = "Save invoice", + billingItemTypes, + submitLabel = 'Save invoice', invoiceOptions = [], + showClientLink = false, + requiresClient = false, + clientOptions = [], + pendingClientName = null, }: Props) { - const formatDate = (value: string) => (value ? dayjs(value).toDate() : null); + const formatDate = (value: string) => + value ? dayjs(value).toDate() : null; const [fetchingSpend, setFetchingSpend] = useState(false); + const [manualTotals, setManualTotals] = useState({ + total_sem_amount: false, + total_net_amount: false, + }); - const parseAmount = (value?: string) => { - const parsed = Number.parseFloat(value ?? ""); + const itemTypeOptions = billingItemTypes.map((itemType) => ({ + value: String(itemType.id), + label: itemType.name, + })); - return Number.isFinite(parsed) ? parsed : 0; + const itemTypesById = useMemo( + () => + new Map( + billingItemTypes.map((itemType) => [ + String(itemType.id), + itemType, + ]), + ), + [billingItemTypes], + ); + + const fieldError = (path: string) => + (form.errors as Record)[path]; + + const calculateItem = ( + item: InvoicePaymentItemFormValues, + ): InvoicePaymentItemFormValues => { + const grossAmount = parseAmount(item.payment_item_amount); + const netAmount = + grossAmount / (1 + parseAmount(item.tax_percentage) / 100); + + return { + ...item, + net_amount: formatAmount(netAmount), + final_net_amount: formatAmount( + finalNetAmount(netAmount, parseAmount(item.withholding_tax)), + ), + }; }; - const formatAmount = (value: number) => value.toFixed(2); + const calculateFinalNetItem = ( + item: InvoicePaymentItemFormValues, + ): InvoicePaymentItemFormValues => ({ + ...item, + final_net_amount: formatAmount( + finalNetAmount( + parseAmount(item.net_amount), + parseAmount(item.withholding_tax), + ), + ), + }); - const calculateNettAmount = () => { - const mediaFeeAmount = form.data.media_fee; - const calculated = formatAmount( - parseAmount(mediaFeeAmount) / (1 + parseAmount(form.data.tax_percent) / 100) + const calculatePayment = ( + payment: InvoicePaymentFormValues, + ): InvoicePaymentFormValues => { + const paymentTotal = payment.items.reduce( + (sum, item) => sum + parseAmount(item.payment_item_amount), + 0, + ); + const paymentNett = payment.items.reduce( + (sum, item) => sum + parseAmount(item.net_amount), + 0, ); - form.setData("nett_amount", calculated); + return { + ...payment, + payment_total_amount: formatAmount(paymentTotal), + payment_nett_amount: formatAmount(paymentNett), + }; }; - useEffect(() => { - if (!form.data.is_credit_card) return; + const calculateTotals = (payments: InvoicePaymentFormValues[]) => { + const totalSemAmount = payments.reduce( + (paymentSum, payment) => + paymentSum + + payment.items.reduce( + (itemSum, item) => + itemSum + parseAmount(item.payment_item_amount), + 0, + ), + 0, + ); + const totalNetAmount = payments.reduce( + (paymentSum, payment) => + paymentSum + + payment.items.reduce( + (itemSum, item) => itemSum + parseAmount(item.net_amount), + 0, + ), + 0, + ); - // Credit-card invoices can be fee-free; normalize fields to 0 for convenience. - // if (form.data.management_fee !== "0") form.setData("management_fee", "0"); - if (form.data.media_fee !== "0") form.setData("media_fee", "0"); - // if (form.data.tax_percent !== "0") form.setData("tax_percent", "0"); - }, [form.data.is_credit_card]); + form.setData((data) => ({ + ...data, + payments, + total_sem_amount: manualTotals.total_sem_amount + ? data.total_sem_amount + : formatAmount(totalSemAmount), + total_net_amount: manualTotals.total_net_amount + ? data.total_net_amount + : formatAmount(totalNetAmount), + })); + }; + + const setPayments = (payments: InvoicePaymentFormValues[]) => { + calculateTotals(payments.map(calculatePayment)); + }; + + const updatePayment = ( + paymentIndex: number, + field: keyof Omit, + value: string, + ) => { + const payments = [...form.data.payments]; + payments[paymentIndex] = calculatePayment({ + ...payments[paymentIndex], + [field]: value, + }); + calculateTotals(payments); + }; + + const updateItem = ( + paymentIndex: number, + itemIndex: number, + field: keyof InvoicePaymentItemFormValues, + value: string | boolean, + ) => { + const payments = [...form.data.payments]; + const payment = { ...payments[paymentIndex] }; + const items = [...payment.items]; + const nextItem = { + ...items[itemIndex], + [field]: value, + }; + + items[itemIndex] = + field === 'payment_item_amount' || field === 'tax_percentage' + ? calculateItem(nextItem) + : field === 'net_amount' || field === 'withholding_tax' + ? calculateFinalNetItem(nextItem) + : nextItem; + + payments[paymentIndex] = calculatePayment({ + ...payment, + items, + }); + calculateTotals(payments); + }; + + const addPayment = () => { + setPayments([...form.data.payments, createPayment(billingItemTypes)]); + }; + + const removePayment = (paymentIndex: number) => { + setPayments( + form.data.payments.filter((_, index) => index !== paymentIndex), + ); + }; + + const addItem = (paymentIndex: number) => { + const payments = [...form.data.payments]; + const fallbackType = billingItemTypes[0]; + const payment = payments[paymentIndex]; + + payments[paymentIndex] = { + ...payment, + items: [ + ...payment.items, + createPaymentItem(String(fallbackType?.id ?? '')), + ], + }; + setPayments(payments); + }; + + const removeItem = (paymentIndex: number, itemIndex: number) => { + const payments = [...form.data.payments]; + const payment = payments[paymentIndex]; + + payments[paymentIndex] = { + ...payment, + items: payment.items.filter((_, index) => index !== itemIndex), + }; + setPayments(payments); + }; + + const firstItem = form.data.payments[0]?.items[0]; useEffect(() => { - const startDate = form.data.start_date; - const endDate = form.data.end_date; + const startDate = firstItem?.start_date; + const endDate = firstItem?.end_date; const customerId = form.data.customer_id; - if (!startDate || !endDate) { - form.setData("total_spending", "0"); + if (!startDate || !endDate || !customerId) { setFetchingSpend(false); return; } @@ -92,7 +364,7 @@ export default function InvoiceForm({ axios .post( - route("google.getCampaignsDetails"), + route('google.getCampaignsDetails'), { clientCustomerId: customerId, startDate, @@ -100,20 +372,18 @@ export default function InvoiceForm({ }, { withCredentials: true, - } + }, ) .then((response) => { if (canceled) return; - const value = parseFloat(response.data?.summary?.total_actual_spend ?? 0) || 0; - const formatted = value.toFixed(2); - form.setData("total_spending", formatted); - }) - .catch(() => { - if (!canceled) { - form.setData("total_spending", ""); - } + const value = + parseFloat( + response.data?.summary?.total_actual_spend ?? 0, + ) || 0; + updateItem(0, 0, 'spending', value.toFixed(2)); }) + .catch(() => undefined) .finally(() => { if (!canceled) { setFetchingSpend(false); @@ -123,135 +393,609 @@ export default function InvoiceForm({ return () => { canceled = true; }; - }, [form.data.start_date, form.data.end_date, form.data.customer_id]); + }, [firstItem?.start_date, firstItem?.end_date, form.data.customer_id]); return (
- - form.setData("invoice_no", event.target.value)} - error={form.errors.invoice_no} - required - /> + + ({ + border: `1px solid ${borderColor(theme)}`, + borderRadius: theme.radius.sm, + background: sectionSurface(theme), + padding: theme.spacing.lg, + })} + > + + + + Invoice details + + + {form.data.payments.length} payment + {form.data.payments.length === 1 ? '' : 's'} + + - + form.setData( + 'linked_invoice_id', + value ?? '', + ) + } + error={form.errors.linked_invoice_id} + clearable + searchable + nothingFound="No invoices available" + /> + + + - - form.setData("start_date", value ? dayjs(value).format("YYYY-MM-DD") : "") - } - error={form.errors.start_date} - clearable - /> + {showClientLink ? ( + ({ + border: `1px solid ${borderColor(theme)}`, + borderRadius: theme.radius.sm, + background: sectionSurface(theme), + padding: theme.spacing.lg, + })} + > + + + + Link client + {pendingClientName ? ( + + {pendingClientName} + + ) : null} + - - form.setData("end_date", value ? dayjs(value).format("YYYY-MM-DD") : "") - } - error={form.errors.end_date} - clearable - /> + + + updateItem( + paymentIndex, + itemIndex, + 'billing_item_types_id', + value ?? '', + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.billing_item_types_id`, + )} + searchable + required + /> + + + updateItem( + paymentIndex, + itemIndex, + 'start_date', + value + ? dayjs( + value, + ).format( + 'YYYY-MM-DD', + ) + : '', + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.start_date`, + )} + clearable + /> + + + updateItem( + paymentIndex, + itemIndex, + 'end_date', + value + ? dayjs( + value, + ).format( + 'YYYY-MM-DD', + ) + : '', + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.end_date`, + )} + clearable + /> + + + updateItem( + paymentIndex, + itemIndex, + 'payment_item_amount', + numberInputString( + value, + ), + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.payment_item_amount`, + )} + /> + + + updateItem( + paymentIndex, + itemIndex, + 'tax_percentage', + numberInputString( + value, + ), + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.tax_percentage`, + )} + /> + + + updateItem( + paymentIndex, + itemIndex, + 'net_amount', + numberInputString( + value, + ), + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.net_amount`, + )} + /> + + + updateItem( + paymentIndex, + itemIndex, + 'withholding_tax', + numberInputString( + value, + ), + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.withholding_tax`, + )} + /> + + + updateItem( + paymentIndex, + itemIndex, + 'final_net_amount', + numberInputString( + value, + ), + ) + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.final_net_amount`, + )} + /> + + + updateItem( + paymentIndex, + itemIndex, + 'spending', + numberInputString( + value, + ), + ) + } + rightSection={ + paymentIndex === 0 && + itemIndex === 0 && + fetchingSpend ? ( + + ) : null + } + error={fieldError( + `payments.${paymentIndex}.items.${itemIndex}.spending`, + )} + /> + + + + updateItem( + paymentIndex, + itemIndex, + 'is_creditcard', + event.currentTarget.checked, + ) + } + /> + + + ))} + + + ))} + + ({ + border: `1px solid ${borderColor(theme)}`, + borderRadius: theme.radius.sm, + background: sectionSurface(theme), + padding: theme.spacing.lg, + })} + > + + + + Totals + + + RM{' '} + {formatAmount( + parseAmount(form.data.total_net_amount), + )} + + + + + { + setManualTotals((current) => ({ + ...current, + total_sem_amount: true, + })); + form.setData( + 'total_sem_amount', + numberInputString(value), + ); + }} + /> + + { + setManualTotals((current) => ({ + ...current, + total_net_amount: true, + })); + form.setData( + 'total_net_amount', + numberInputString(value), + ); + }} + /> + + + + + + +
); diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 3c83542..1ed9267 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -1,40 +1,56 @@ -import React from 'react'; import { - AppShell, - Navbar, - Header, - Group, - Text, - MediaQuery, - Burger, - useMantineTheme, - Box, - Container, - Menu, - Avatar, - Button, - Stack, ActionIcon, + Anchor, + AppShell, + Avatar, + Badge, + Box, + Burger, + Button, + Container, + Group, + Header, Indicator, Loader, - Anchor, - Badge, + MediaQuery, + Menu, Modal, + Navbar, + Stack, + Text, Tooltip, + useMantineTheme, } from '@mantine/core'; +import React from 'react'; -import Sidebar from '../components/sidebar'; -import ThemeToggle from '../components/theme-toggle'; import { SidebarProvider } from '@/components/ui/sidebar'; -import { usePage, Link, router } from '@inertiajs/react'; -import { Alert } from '@mantine/core'; -import { notifications } from "@mantine/notifications"; -import { useEffect } from "react"; -import { IconInfoCircle, IconCircleX, IconAlertCircle, IconSettings, IconLogout, IconBell, IconFileDollar, IconUserPlus } from "@tabler/icons-react"; import { logout } from '@/routes'; import { edit } from '@/routes/profile'; -import { SharedData } from '@/types'; -import { MantineReactTable, type MRT_ColumnDef, type MRT_Row } from 'mantine-react-table'; +import { + ClientInvoicePayment, + ClientInvoicePaymentItem, + SharedData, +} from '@/types'; +import { Link, router, usePage } from '@inertiajs/react'; +import { notifications } from '@mantine/notifications'; +import { + IconAlertCircle, + IconBell, + IconCircleX, + IconFileDollar, + IconInfoCircle, + IconLogout, + IconSettings, + IconUserPlus, +} from '@tabler/icons-react'; +import { + MantineReactTable, + type MRT_ColumnDef, + type MRT_Row, +} from 'mantine-react-table'; +import { useEffect } from 'react'; +import Sidebar from '../components/sidebar'; +import ThemeToggle from '../components/theme-toggle'; type PendingInvoiceNotification = { id: number; @@ -43,10 +59,21 @@ type PendingInvoiceNotification = { pending_sql_acc_code?: string | null; pending_client_name?: string | null; requires_client?: boolean; + is_credit_card?: boolean; + is_paid?: boolean; payment_no: string | null; + amount: string | number | null; management_fee: string | number | null; + management_fee_amount?: string | number | null; + management_fee_tax?: string | number | null; media_fee: string | number | null; + media_fee_amount?: string | number | null; + media_fee_tax?: string | number | null; + tax_percent?: string | number | null; + nett_amount?: string | number | null; + total_net_amount?: string | number | null; created_at: string | null; + payments?: ClientInvoicePayment[]; previous_payments?: PreviousPaymentRecord[]; invoice_billing_totals?: { media_fee: string | number | null; @@ -69,6 +96,10 @@ type PreviousPaymentRecord = { invoice_number: string | null; }; +type CurrentPaymentItemRecord = ClientInvoicePaymentItem & { + payment_no: string | null; +}; + type AppNotification = { type: 'pending_invoice' | string; title: string; @@ -77,12 +108,18 @@ type AppNotification = { }; function AppNotifications() { - const [notifications, setNotifications] = React.useState([]); - const [invoices, setInvoices] = React.useState([]); + const [notifications, setNotifications] = React.useState( + [], + ); + const [invoices, setInvoices] = React.useState< + PendingInvoiceNotification[] + >([]); const [count, setCount] = React.useState(0); - const [isLoadingNotifications, setIsLoadingNotifications] = React.useState(true); + const [isLoadingNotifications, setIsLoadingNotifications] = + React.useState(true); const [isLoadingInvoices, setIsLoadingInvoices] = React.useState(false); - const [pendingInvoicesOpened, setPendingInvoicesOpened] = React.useState(false); + const [pendingInvoicesOpened, setPendingInvoicesOpened] = + React.useState(false); const dateFormatter = React.useMemo( () => @@ -103,6 +140,199 @@ function AppNotifications() { }).format(Number.isFinite(value) ? value : 0); }; + const parseAmount = (amount: string | number | null | undefined) => { + const value = Number(amount ?? 0); + + return Number.isFinite(value) ? value : 0; + }; + + const getPaymentRows = ( + invoice: PendingInvoiceNotification, + ): ClientInvoicePayment[] => { + if (invoice.payments?.length) { + return invoice.payments; + } + + const invoiceAmount = + parseAmount(invoice.amount) || + parseAmount(invoice.management_fee) + + parseAmount(invoice.media_fee); + const taxAmount = Math.max( + 0, + parseAmount(invoice.management_fee_tax) + + parseAmount(invoice.media_fee_tax), + ); + const netAmount = + parseAmount(invoice.total_net_amount) || + parseAmount(invoice.management_fee_amount) + + parseAmount(invoice.media_fee_amount) || + Math.max(0, invoiceAmount - taxAmount); + + return [ + { + payment_no: invoice.payment_no, + payment_total_amount: invoiceAmount, + payment_tax_percentage: parseAmount(invoice.tax_percent), + payment_nett_amount: netAmount, + items: [], + }, + ]; + }; + + const getPaymentTaxAmount = (payment: ClientInvoicePayment) => { + const totalAmount = parseAmount(payment.payment_total_amount); + const nettAmount = parseAmount(payment.payment_nett_amount); + + if (totalAmount > 0 || nettAmount > 0) { + return Math.max(0, totalAmount - nettAmount); + } + + return (payment.items ?? []).reduce( + (sum, item) => + sum + + Math.max( + 0, + parseAmount(item.payment_item_amount) - + parseAmount(item.net_amount), + ), + 0, + ); + }; + + const getInvoiceAmount = (invoice: PendingInvoiceNotification) => { + const directAmount = parseAmount(invoice.amount); + + if (directAmount > 0) { + return directAmount; + } + + const paymentTotal = getPaymentRows(invoice).reduce( + (sum, payment) => sum + parseAmount(payment.payment_total_amount), + 0, + ); + + return ( + paymentTotal || + parseAmount(invoice.management_fee) + parseAmount(invoice.media_fee) + ); + }; + + const getInvoiceTaxAmount = (invoice: PendingInvoiceNotification) => { + const paymentTaxTotal = getPaymentRows(invoice).reduce( + (sum, payment) => sum + getPaymentTaxAmount(payment), + 0, + ); + + return ( + paymentTaxTotal || + Math.max( + 0, + parseAmount(invoice.management_fee_tax) + + parseAmount(invoice.media_fee_tax), + ) + ); + }; + + const getInvoiceNetAmount = (invoice: PendingInvoiceNotification) => { + const directNetAmount = + parseAmount(invoice.total_net_amount) || + parseAmount(invoice.nett_amount); + + if (directNetAmount > 0) { + return directNetAmount; + } + + const paymentNetTotal = getPaymentRows(invoice).reduce( + (sum, payment) => sum + parseAmount(payment.payment_nett_amount), + 0, + ); + + return ( + paymentNetTotal || + parseAmount(invoice.management_fee_amount) + + parseAmount(invoice.media_fee_amount) + ); + }; + + const getPaidAmount = (invoice: PendingInvoiceNotification) => + getPaymentRows(invoice).reduce( + (sum, payment) => sum + parseAmount(payment.payment_total_amount), + 0, + ); + + const getOutstandingAmount = (invoice: PendingInvoiceNotification) => + Math.max(0, getInvoiceAmount(invoice) - getPaidAmount(invoice)); + + const getPaymentItemRows = ( + invoice: PendingInvoiceNotification, + ): CurrentPaymentItemRecord[] => { + const paymentItems = getPaymentRows(invoice).flatMap((payment) => + (payment.items ?? []).map((item) => ({ + ...item, + payment_no: payment.payment_no, + })), + ); + + if (paymentItems.length > 0) { + return paymentItems; + } + + return [ + { + payment_no: invoice.payment_no, + billing_item_types_id: 0, + billing_item_type: { + id: 0, + name: 'Google Ads Search (Media Fee)', + sql_acc_code: 'G03', + nett_contribution: true, + fee_type: 'Media', + type: 'Google', + campaign_type: 'Search', + }, + start_date: null, + end_date: null, + payment_item_amount: invoice.media_fee ?? 0, + tax_percentage: invoice.tax_percent ?? 0, + net_amount: + invoice.media_fee_amount ?? invoice.nett_amount ?? 0, + withholding_tax: 0, + final_net_amount: + invoice.total_net_amount ?? invoice.nett_amount ?? 0, + spending: 0, + is_creditcard: !!invoice.is_credit_card, + }, + { + payment_no: invoice.payment_no, + billing_item_types_id: 0, + billing_item_type: { + id: 0, + name: 'Management Fee (Google Search Ads)', + sql_acc_code: 'GOOGLE', + nett_contribution: false, + fee_type: 'Management', + type: 'Google', + campaign_type: 'Search', + }, + start_date: null, + end_date: null, + payment_item_amount: invoice.management_fee ?? 0, + tax_percentage: invoice.tax_percent ?? 0, + net_amount: + invoice.management_fee_amount ?? + invoice.management_fee ?? + 0, + withholding_tax: 0, + final_net_amount: + invoice.management_fee_amount ?? + invoice.management_fee ?? + 0, + spending: 0, + is_creditcard: false, + }, + ].filter((item) => parseAmount(item.payment_item_amount) > 0); + }; + const formatDate = (date: string | null) => { if (!date) { return '-'; @@ -110,10 +340,14 @@ function AppNotifications() { const value = new Date(date); - return Number.isNaN(value.getTime()) ? '-' : dateFormatter.format(value); + return Number.isNaN(value.getTime()) + ? '-' + : dateFormatter.format(value); }; - const pendingInvoiceColumns = React.useMemo[]>( + const pendingInvoiceColumns = React.useMemo< + MRT_ColumnDef[] + >( () => [ { accessorKey: 'invoice_no', @@ -122,18 +356,16 @@ function AppNotifications() { {row.original.invoice_no} - {row.original.requires_client ? 'Client setup required' : 'Ready for review'} + {row.original.requires_client + ? 'Link client before approval' + : 'Ready for review'} ), @@ -141,7 +373,8 @@ function AppNotifications() { { id: 'client', header: 'Client', - accessorFn: (invoice) => invoice.client?.name ?? invoice.pending_client_name ?? '-', + accessorFn: (invoice) => + invoice.client?.name ?? invoice.pending_client_name ?? '-', Cell: ({ cell }) => ( {cell.getValue()} @@ -152,7 +385,9 @@ function AppNotifications() { id: 'sql_acc_code', header: 'SQL Acc Code', accessorFn: (invoice) => invoice.pending_sql_acc_code ?? '-', - Cell: ({ cell }) => {cell.getValue()}, + Cell: ({ cell }) => ( + {cell.getValue()} + ), }, // { // id: 'invoice_management_fee', @@ -175,56 +410,61 @@ function AppNotifications() { // ), // }, { - accessorKey: 'management_fee', - header: 'Payment Management Fee (incl. tax)', + id: 'invoice_amount', + header: 'Invoice Amount', + accessorFn: (invoice) => getInvoiceAmount(invoice), Cell: ({ row }) => ( - {formatAmount(row.original.management_fee_amount)} + {formatAmount(getInvoiceAmount(row.original))} ), }, { - accessorKey: 'management_fee_tax', - header: 'Payment Management Tax', + id: 'tax_amount', + header: 'Tax Amount', + accessorFn: (invoice) => getInvoiceTaxAmount(invoice), Cell: ({ row }) => ( - {formatAmount(row.original.management_fee_tax)} + {formatAmount(getInvoiceTaxAmount(row.original))} ), }, { - accessorKey: 'management_fee_nett', - header: 'Payment Management Nett', + id: 'net_amount', + header: 'Net Amount', + accessorFn: (invoice) => getInvoiceNetAmount(invoice), Cell: ({ row }) => ( - {formatAmount(row.original.management_fee)} + {formatAmount(getInvoiceNetAmount(row.original))} ), }, { - accessorKey: 'media_fee', - header: 'Payment Media Fee (incl. tax)', + id: 'paid_amount', + header: 'Paid Amount', + accessorFn: (invoice) => getPaidAmount(invoice), Cell: ({ row }) => ( - {formatAmount(row.original.media_fee_amount)} + {formatAmount(getPaidAmount(row.original))} ), }, { - accessorKey: 'media_fee_tax', - header: 'Payment Media Tax', + id: 'outstanding', + header: 'Outstanding', + accessorFn: (invoice) => getOutstandingAmount(invoice), Cell: ({ row }) => ( - - {formatAmount(row.original.media_fee_tax)} - - ), - }, - { - accessorKey: 'media_fee_nett', - header: 'Payment Media Nett', - Cell: ({ row }) => ( - - {formatAmount(row.original.media_fee)} + 0 + ? 'red' + : 'green' + } + > + {formatAmount(getOutstandingAmount(row.original))} ), }, @@ -233,105 +473,376 @@ function AppNotifications() { header: 'Payment Date', accessorFn: (invoice) => invoice.created_at, Cell: ({ row }) => ( - - {formatDate(row.original.created_at)} - + {formatDate(row.original.created_at)} ), }, { accessorKey: 'created_at', header: 'Created', Cell: ({ row }) => ( - - {formatDate(row.original.created_at)} - + {formatDate(row.original.created_at)} ), }, ], [], ); - const renderPreviousPaymentsPanel = ({ row }: { row: MRT_Row }) => { + const renderPendingInvoiceDetailPanel = ({ + row, + }: { + row: MRT_Row; + }) => { const records = row.original.previous_payments ?? []; - - if (records.length === 0) { - return ( - - - No previous payment records found for this invoice. - - - ); - } + const paymentItems = getPaymentItemRows(row.original); return ( - - - - Previous Payment Records - - {records.length} - - - - - - - - - - - - - - - - - {records.map((record, index) => ( - - - - - - - - - - - ))} - -
Payment NoStatusPayment DateMedia FeeManagement FeeInvoice MediaInvoice ManagementTotal
{record.payment_number ?? '-'} - - {record.status ?? '-'} - - {formatDate(record.sql_created_at)} - {formatAmount(record.media_fee)} - - {formatAmount(record.management_fee)} - - {formatAmount(record.invoice_media_fee)} - - {formatAmount(record.invoice_management_fee)} - - {formatAmount(record.amount)} -
-
+ + + + + Payment Items + + {paymentItems.length} + + {paymentItems.length === 0 ? ( + + No payment items found for this invoice. + + ) : ( + + + + + + + + + + + + + + + {paymentItems.map((item, index) => ( + + + + + + + + + + ))} + +
+ Payment No + + Item + + SQL Code + + Fee Type + + Amount + + Tax % + + Net +
+ {item.payment_no ?? '-'} + + {item.billing_item_type + ?.name ?? '-'} + + {item.billing_item_type + ?.sql_acc_code ?? '-'} + + + {item.billing_item_type + ?.fee_type ?? '-'} + + + {formatAmount( + item.payment_item_amount, + )} + + {formatAmount( + item.tax_percentage, + )} + + {formatAmount( + item.net_amount, + )} +
+
+ )} +
+ + + + + Previous Payment Records + + {records.length} + + {records.length === 0 ? ( + + No previous payment records found for this + invoice. + + ) : ( + + + + + + + + + + + + + + + + {records.map((record, index) => ( + + + + + + + + + + + ))} + +
+ Payment No + + Status + + Payment Date + + Media Fee + + Management Fee + + Invoice Media + + Invoice Management + + Total +
+ {record.payment_number ?? + '-'} + + + {record.status ?? '-'} + + + {formatDate( + record.sql_created_at, + )} + + {formatAmount( + record.media_fee, + )} + + {formatAmount( + record.management_fee, + )} + + {formatAmount( + record.invoice_media_fee, + )} + + {formatAmount( + record.invoice_management_fee, + )} + + {formatAmount( + record.amount, + )} +
+
+ )} +
); }; - const renderPendingInvoiceActions = ({ row }: { row: MRT_Row }) => ( + const renderPendingInvoiceActions = ({ + row, + }: { + row: MRT_Row; + }) => ( {row.original.requires_client ? ( - + @@ -407,7 +918,6 @@ function AppNotifications() { const data = await response.json(); setInvoices(data.invoices ?? []); - console.log(data.invoices); } catch { setInvoices([]); } finally { @@ -448,10 +958,11 @@ function AppNotifications() { px="md" py="sm" sx={(theme) => ({ - borderBottom: `1px solid ${theme.colorScheme === 'dark' - ? theme.colors.dark[4] - : theme.colors.gray[2] - }`, + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' + ? theme.colors.dark[4] + : theme.colors.gray[2] + }`, backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] @@ -467,7 +978,10 @@ function AppNotifications() { Updates grouped by notification type
- 0 ? 'red' : 'gray'} variant="filled"> + 0 ? 'red' : 'gray'} + variant="filled" + > {count} pending @@ -495,7 +1009,9 @@ function AppNotifications() { {notifications.map((notification) => ( openNotification(notification)} + onClick={() => + openNotification(notification) + } icon={} rightSection={ @@ -547,7 +1063,7 @@ function AppNotifications() { columns={pendingInvoiceColumns} data={invoices} renderRowActions={renderPendingInvoiceActions} - renderDetailPanel={renderPreviousPaymentsPanel} + renderDetailPanel={renderPendingInvoiceDetailPanel} enableExpanding getRowCanExpand={() => true} positionExpandColumn="first" @@ -579,7 +1095,6 @@ export default function AppLayout({ children }: Props) { const page = usePage(); const { flash, auth } = page.props as any; useEffect(() => { - if (flash['message-info']) { notifications.show({ title: 'Info', @@ -606,7 +1121,6 @@ export default function AppLayout({ children }: Props) { message: flash['message-error'], }); } - }, [flash]); return ( @@ -620,8 +1134,11 @@ export default function AppLayout({ children }: Props) { hidden={!opened} p="md" style={{ - borderRight: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3] - }`, + borderRight: `1px solid ${ + isDark + ? theme.colors.dark[4] + : theme.colors.gray[3] + }`, }} > @@ -632,14 +1149,26 @@ export default function AppLayout({ children }: Props) { height={60} px="lg" style={{ - backgroundColor: isDark ? theme.colors.dark[6] : theme.white, - borderBottom: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3] - }`, + backgroundColor: isDark + ? theme.colors.dark[6] + : theme.white, + borderBottom: `1px solid ${ + isDark + ? theme.colors.dark[4] + : theme.colors.gray[3] + }`, }} > - + - + setOpened((o) => !o)} @@ -658,10 +1187,17 @@ export default function AppLayout({ children }: Props) { - + {accountSummary.map((item) => ( ))} @@ -1380,17 +1661,38 @@ export default function TicketDetails({ - + {clientAssignmentRoles.map((role) => { const assignee = lookupAssignmentLabel(role.id); return ( - + {role.label} - - {assignee !== "—" ? "Assigned" : "Unassigned"} + + {assignee !== '—' + ? 'Assigned' + : 'Unassigned'} {assignee} @@ -1401,12 +1703,15 @@ export default function TicketDetails({ - + - } value="campaigns"> + {/* } value="campaigns"> Campaigns - - } value="invoice"> + */} + } + value="invoice" + > Invoices } value="tasks"> @@ -1414,7 +1719,7 @@ export default function TicketDetails({ - + {/* @@ -1473,7 +1778,7 @@ export default function TicketDetails({ )} - + */} @@ -1489,7 +1794,10 @@ export default function TicketDetails({