diff --git a/app/Console/Commands/RepairLinkedInvoicePaymentItems.php b/app/Console/Commands/RepairLinkedInvoicePaymentItems.php new file mode 100644 index 0000000..6ef8dac --- /dev/null +++ b/app/Console/Commands/RepairLinkedInvoicePaymentItems.php @@ -0,0 +1,119 @@ +option('dry-run'); + $invoiceIds = collect($this->option('invoice-id')) + ->filter(fn ($id) => is_numeric($id)) + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + + $merged = 0; + $removed = 0; + $skipped = 0; + + ClientInvoicePayment::query() + ->with([ + 'invoice:id,invoice_no,linked_invoice_id', + 'items' => fn ($query) => $query->orderBy('id'), + ]) + ->whereIn('client_invoice_id', function ($query) use ($invoiceIds) { + $query + ->select('linked_invoice_id') + ->distinct() + ->from('client_invoices') + ->whereNotNull('linked_invoice_id'); + + if ($invoiceIds !== []) { + $query->whereIn('linked_invoice_id', $invoiceIds); + } + }) + ->whereHas('items', fn (Builder $query) => $query->where('billing_item_types_id', 1)) + ->orderBy('id') + ->chunkById(100, function ($payments) use ($dryRun, &$merged, &$removed, &$skipped) { + foreach ($payments as $payment) { + $mediaItems = $payment->items->where('billing_item_types_id', 1)->values(); + $managementItems = $payment->items->where('billing_item_types_id', 2)->values(); + + if ($managementItems->count() !== 1) { + $skipped += $mediaItems->count(); + $this->warn(sprintf( + 'Skipping payment %d on invoice %s: expected 1 management item, found %d.', + $payment->id, + $payment->invoice?->invoice_no ?? $payment->client_invoice_id, + $managementItems->count(), + )); + + continue; + } + + $managementItem = $managementItems->first(); + + $startDate = $mediaItems + ->map(fn ($item) => $item->start_date?->toDateString()) + ->filter() + ->min(); + $endDate = $mediaItems + ->map(fn ($item) => $item->end_date?->toDateString()) + ->filter() + ->max(); + $spending = (float) $managementItem->spending + + $mediaItems->sum(fn ($item) => (float) $item->spending); + + $this->line(sprintf( + '%s payment %d: %d media item%s -> management item %d (%s to %s, spending RM %.2f).', + $dryRun ? 'Would merge' : 'Merging', + $payment->id, + $mediaItems->count(), + $mediaItems->count() === 1 ? '' : 's', + $managementItem->id, + $startDate ?? 'null', + $endDate ?? 'null', + $spending, + )); + + if (! $dryRun) { + DB::transaction(function () use ($managementItem, $mediaItems, $startDate, $endDate, $spending) { + $managementItem->forceFill([ + 'start_date' => $startDate, + 'end_date' => $endDate, + 'spending' => $spending, + ])->save(); + + $mediaItems->each->delete(); + }); + } + + $merged += $mediaItems->count(); + $removed += $mediaItems->count(); + } + }); + + $this->info(sprintf( + 'Done. %d media item%s merged into management, %d media item%s removed, %d skipped.', + $merged, + $merged === 1 ? '' : 's', + $removed, + $removed === 1 ? '' : 's', + $skipped, + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/UpdateCurrentClientInvoicePaymentItemSpending.php b/app/Console/Commands/UpdateCurrentClientInvoicePaymentItemSpending.php new file mode 100644 index 0000000..259fc8d --- /dev/null +++ b/app/Console/Commands/UpdateCurrentClientInvoicePaymentItemSpending.php @@ -0,0 +1,124 @@ +option('dry-run'); + $today = today()->toDateString(); + $updated = 0; + $skipped = 0; + $failed = 0; + + Client::query() + ->whereHas('invoices.payments.items', fn (Builder $query) => $this->eligibleItems($query, $today)) + ->orderBy('id') + ->chunkById(50, function ($clients) use ( + $spendService, + $dryRun, + $today, + &$updated, + &$skipped, + &$failed, + ) { + foreach ($clients as $client) { + if (empty($client->customer_id)) { + $skipped++; + $this->warn("Skipping client {$client->id}: missing customer ID."); + + continue; + } + + ClientInvoicePaymentItem::query() + ->whereHas( + 'payment.invoice', + fn (Builder $query) => $query->where('client_id', $client->id), + ) + ->where(fn (Builder $query) => $this->eligibleItems($query, $today)) + ->orderBy('id') + ->chunkById(50, function ($items) use ( + $client, + $spendService, + $dryRun, + $today, + &$updated, + &$skipped, + &$failed, + ) { + foreach ($items as $item) { + $startDate = $item->start_date->format('Y-m-d'); + $endDate = $today; + + if ($endDate < $startDate) { + $skipped++; + $this->warn("Skipping item {$item->id}: today is before start date."); + + continue; + } + + try { + $spending = $spendService->forDateRange( + $client->customer_id, + $startDate, + $endDate, + ); + + if (! $dryRun) { + $item->forceFill(['spending' => $spending])->save(); + } + + $updated++; + $this->line(sprintf( + '%s client %d item %d: RM %.2f (%s to %s)', + $dryRun ? 'Calculated' : 'Updated', + $client->id, + $item->id, + $spending, + $startDate, + $endDate, + )); + } catch (\Throwable $exception) { + $failed++; + Log::error('Unable to update current invoice payment item spending.', [ + 'client_id' => $client->id, + '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 eligibleItems(Builder $query, string $today): Builder + { + return $query + ->where('billing_item_types_id', 1) + ->whereNotNull('start_date') + ->whereDate('end_date', '>=', $today); + } +} diff --git a/app/Http/Controllers/Api/ClientInvoiceController.php b/app/Http/Controllers/Api/ClientInvoiceController.php index f07766d..af14813 100644 --- a/app/Http/Controllers/Api/ClientInvoiceController.php +++ b/app/Http/Controllers/Api/ClientInvoiceController.php @@ -3,12 +3,15 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\BillingItemType; +use App\Models\Client; use App\Models\ClientInvoice; use App\Services\ClientInvoiceApprovalService; use App\Services\ClientInvoicePaymentSyncService; use App\Services\ClientLookupService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; @@ -34,21 +37,6 @@ 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', @@ -72,32 +60,69 @@ public function pending(): JsonResponse public function store(Request $request): JsonResponse { + $request->merge([ + 'invoice_no' => $request->input('invoice_no') + ?? $request->input('invoice.invoice_no') + ?? $request->input('invoice.invoice_number'), + 'client_name' => $request->input('client_name') + ?? $request->input('invoice.company_name') + ?? $request->input('invoice.client_name'), + ]); + $validated = $request->validate([ 'client_id' => ['nullable', 'exists:clients,id'], 'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'], 'client_name' => ['nullable', 'string'], + 'invoice_no' => ['required', 'string'], 'linked_invoice_id' => ['nullable', 'integer'], 'is_credit_card' => ['nullable', 'boolean'], 'payments' => ['nullable', 'array', 'min:1'], + 'payments.*.payment_no' => ['nullable', 'string'], + 'payments.*.payment_total_amount' => ['required_with:payments', 'numeric', 'min:0'], + 'payments.*.payment_nett_amount' => ['required_with:payments', 'numeric', 'min:0'], + 'payments.*.items' => ['required_with:payments', 'array', 'min:1'], + 'payments.*.items.*.billing_item_types_id' => ['nullable', 'integer'], + 'payments.*.items.*.sql_acc_code' => ['nullable', 'string'], + 'payments.*.items.*.billing_item_type' => ['nullable', 'array'], + 'payments.*.items.*.billing_item_type.sql_acc_code' => ['nullable', 'string'], + 'payments.*.items.*.billingItemType' => ['nullable', 'array'], + 'payments.*.items.*.billingItemType.sql_acc_code' => ['nullable', 'string'], + 'payments.*.items.*.billing_item' => ['nullable', 'array'], + 'payments.*.items.*.billing_item.sql_acc_code' => ['nullable', 'string'], + 'payments.*.items.*.item' => ['nullable', 'array'], + 'payments.*.items.*.item.sql_acc_code' => ['nullable', 'string'], + 'payments.*.items.*.item.item' => ['nullable', 'array'], + 'payments.*.items.*.item.item.sql_acc_code' => ['nullable', 'string'], + 'payments.*.items.*.start_date' => ['nullable', 'date'], + 'payments.*.items.*.end_date' => ['nullable', 'date'], + 'payments.*.items.*.payment_item_amount' => ['required_with:payments', 'numeric', 'min:0'], + 'payments.*.items.*.tax_percentage' => ['required_with:payments', 'numeric', 'min:0', 'max:100'], + 'payments.*.items.*.net_amount' => ['required_with:payments', 'numeric', 'min:0'], + 'payments.*.items.*.withholding_tax' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'payments.*.items.*.final_net_amount' => ['required_with:payments', 'numeric', 'min:0'], + 'payments.*.items.*.spending' => ['nullable', 'numeric', 'min:0'], + 'payments.*.items.*.is_creditcard' => ['nullable', 'boolean'], 'invoice' => ['nullable', 'array'], + 'payment_no' => ['nullable', 'string'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'amount' => ['nullable', 'numeric', 'min:0'], + 'media_fee' => ['nullable', 'numeric', 'min:0'], + 'media_fee_amount' => ['nullable', 'numeric', 'min:0'], + 'management_fee' => ['nullable', 'numeric', 'min:0'], + 'management_fee_amount' => ['nullable', 'numeric', 'min:0'], + 'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], 'nett_amount' => ['nullable', 'numeric', 'min:0'], + 'total_sem_amount' => ['nullable', 'numeric', 'min:0'], + 'total_net_amount' => ['nullable', 'numeric', 'min:0'], 'total_spending' => ['nullable', 'numeric', 'min:0'], 'sem_invoice_items' => ['nullable', 'array'], 'sem_items' => ['nullable', 'array'], ]); - // return response()->json($validated['sem_invoice_items']); - // Log::debug('Validated request data for creating client invoice.', [ - // 'validated' => $validated, - // ]); - // Log::info('Received request to create client invoice.'); - - // return response()->json([ - // 'message' => 'Invoice creation endpoint is under development.', - // ], 501); $sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null); $client = ! empty($validated['client_id']) - ? \App\Models\Client::find($validated['client_id']) + ? Client::find($validated['client_id']) : $this->clientLookupService->findBySqlAccCode($sqlAccCode); if (! empty($validated['linked_invoice_id'])) { @@ -113,66 +138,298 @@ 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, - ]; + $payments = $this->paymentsPayload($validated); + if ($payments === []) { + throw ValidationException::withMessages([ + 'payments' => 'At least one payment item is required.', + ]); + } + + $invoice = DB::transaction(function () use ($validated, $client, $sqlAccCode, $payments) { + $invoice = ClientInvoice::create([ + 'client_id' => $client?->id, + 'pending_sql_acc_code' => $client === null ? $sqlAccCode : null, + 'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null, + 'invoice_no' => $validated['invoice_no'], + 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, + 'approved_at' => null, + 'total_sem_amount' => $validated['total_sem_amount'] ?? $this->paymentsGrossTotal($payments), + 'total_net_amount' => $validated['total_net_amount'] ?? $this->paymentsNetTotal($payments), + ]); + + return $this->paymentSyncService->sync($invoice, $payments); + }); + + return response()->json([ + 'message' => 'Invoice created and marked for approval.', + 'invoice' => $invoice->fresh('client', 'payments.items.billingItemType'), + ], 201); + } + + /** + * @return array> + */ + private function paymentsPayload(array $validated): array + { + if (! empty($validated['payments'])) { + return $this->paymentsPayloadFromExplicitPayments($validated); + } + + if (! empty($validated['sem_items']) || ! empty($validated['sem_invoice_items'])) { + return $this->paymentsPayloadFromSemItems($validated); + } + + return $this->paymentsPayloadFromLegacyFees($validated); + } + + /** + * @return array> + */ + private function paymentsPayloadFromExplicitPayments(array $validated): array + { + $payments = $validated['payments'] ?? []; + $this->paymentSyncService->ensureDefaultItemTypes(); + + $billingItemTypesBySqlCode = BillingItemType::withTrashed() + ->get() + ->keyBy(fn (BillingItemType $itemType) => strtoupper((string) $itemType->sql_acc_code)); + $billingItemTypeIds = BillingItemType::withTrashed() + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + $externalItemSqlCodes = $this->externalItemSqlCodes($validated); + + foreach ($payments as $paymentIndex => $payment) { + foreach (($payment['items'] ?? []) as $itemIndex => $item) { + $sqlAccCode = $this->paymentItemSqlAccCode($item); + $billingItemTypeId = (int) ($item['billing_item_types_id'] ?? 0); + + if ($sqlAccCode === null && $billingItemTypeId > 0) { + $sqlAccCode = $externalItemSqlCodes[$billingItemTypeId] ?? null; + } + + if ($sqlAccCode !== null) { + $billingItemType = $billingItemTypesBySqlCode->get($sqlAccCode); + + if ($billingItemType === null) { + throw ValidationException::withMessages([ + "payments.{$paymentIndex}.items.{$itemIndex}.sql_acc_code" => 'The item SQL account code must match a billing item type.', + ]); + } + + $payments[$paymentIndex]['items'][$itemIndex]['billing_item_types_id'] = $billingItemType->id; + + continue; + } + + if (! in_array($billingItemTypeId, $billingItemTypeIds, true)) { + throw ValidationException::withMessages([ + "payments.{$paymentIndex}.items.{$itemIndex}.billing_item_types_id" => 'The selected billing item type is invalid, and no matching item SQL account code was provided.', + ]); + } } } - // return response()->json([ - // 'message' => 'Invoice creation endpoint is under development.', - // ], 501); - // if (! empty($validated['sem_invoice_items'])) { - // foreach ($validated['sem_invoice_items'] as $item) { - // Log::debug('Validating SEM invoice item.', [ - // 'item' => $item, - // ]); - // // if (! isset($item['sql_acc_code']) || ! isset($item['amount'])) { - // // throw ValidationException::withMessages([ - // // 'sem_invoice_items' => 'Each SEM invoice item must include sql_acc_code and amount.', - // // ]); - // // } - // } - // } - return response()->json([ - 'message' => 'Invoice creation endpoint is under development.', - 'items' => $semItems - ], 501); - $invoice = ClientInvoice::create([ - 'client_id' => $client?->id, - 'pending_sql_acc_code' => $client === null ? $sqlAccCode : null, - 'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null, - 'invoice_no' => $validated['invoice_no'], - // 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, - 'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false), - // 'is_paid' => (bool) ($validated['is_paid'] ?? false), - 'approved_at' => null, - // 'payment_no' => $validated['payment_no'] ?? null, - // 'start_date' => $validated['start_date'] ?? null, - // 'end_date' => $validated['end_date'] ?? null, - // 'amount' => $mediaFee + $managementFee, - // 'total_spending' => $validated['total_spending'] ?? null, - ]); - $invoice = $this->paymentSyncService->sync( - $invoice, - $validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice) + return $payments; + } + + /** + * @return array + */ + private function externalItemSqlCodes(array $validated): array + { + $records = collect($validated['sem_invoice_items'] ?? []) + ->merge(data_get($validated, 'invoice.items', [])) + ->merge(collect($validated['sem_items'] ?? [])->pluck('item')->filter()); + $sqlCodes = []; + + foreach ($records as $record) { + if (! is_array($record)) { + continue; + } + + $sqlAccCode = $this->paymentItemSqlAccCode($record); + + if ($sqlAccCode === null) { + continue; + } + + foreach (['item_id', 'id', 'item.id'] as $key) { + $externalId = data_get($record, $key); + + if (is_numeric($externalId)) { + $sqlCodes[(int) $externalId] = $sqlAccCode; + } + } + } + + return $sqlCodes; + } + + /** + * @return array> + */ + private function paymentsPayloadFromSemItems(array $validated): array + { + $billingItemTypes = $this->paymentSyncService + ->ensureDefaultItemTypes() + ->keyBy(fn (BillingItemType $itemType) => strtoupper((string) $itemType->sql_acc_code)); + $items = []; + + foreach (($validated['sem_items'] ?? $validated['sem_invoice_items'] ?? []) as $semItem) { + if (! is_array($semItem)) { + continue; + } + + $sqlAccCode = $this->paymentItemSqlAccCode($semItem); + $billingItemType = $sqlAccCode === null ? null : $billingItemTypes->get($sqlAccCode); + + if ($billingItemType === null) { + Log::warning('Skipping invoice API SEM item with unknown SQL account code.', [ + 'invoice_no' => $validated['invoice_no'] ?? null, + 'sql_acc_code' => $sqlAccCode, + ]); + + continue; + } + + $grossAmount = $this->semItemAmount($semItem); + if ($grossAmount <= 0) { + continue; + } + + $taxPercentage = $this->semItemTaxPercentage($semItem, $validated); + $netAmount = $this->semItemNetAmount($semItem, $grossAmount, $taxPercentage); + $isMediaItem = strtolower((string) $billingItemType->fee_type) === 'media'; + + $items[] = [ + 'billing_item_types_id' => $billingItemType->id, + 'start_date' => $isMediaItem ? ($semItem['start_date'] ?? $validated['start_date'] ?? null) : null, + 'end_date' => $isMediaItem ? ($semItem['end_date'] ?? $validated['end_date'] ?? null) : null, + 'payment_item_amount' => $grossAmount, + 'tax_percentage' => $taxPercentage, + 'net_amount' => $netAmount, + 'withholding_tax' => (float) ($semItem['withholding_tax'] ?? 0), + 'final_net_amount' => (float) ($semItem['final_net_amount'] ?? $semItem['nett_amount'] ?? $netAmount), + 'spending' => $isMediaItem ? (float) ($semItem['spending'] ?? 0) : 0, + 'is_creditcard' => $isMediaItem && (bool) ($validated['is_credit_card'] ?? false), + ]; + } + + if ($items === []) { + return []; + } + + return [[ + 'payment_no' => $validated['payment_no'] ?? null, + 'payment_total_amount' => array_sum(array_map(fn (array $item) => (float) $item['payment_item_amount'], $items)), + 'payment_nett_amount' => array_sum(array_map(fn (array $item) => (float) $item['final_net_amount'], $items)), + 'items' => $items, + ]]; + } + + /** + * @return array> + */ + private function paymentsPayloadFromLegacyFees(array $validated): array + { + $billingItemTypes = $this->paymentSyncService->ensureDefaultItemTypes()->keyBy('name'); + $taxPercent = (float) ($validated['tax_percent'] ?? 0); + $mediaFee = (float) ($validated['media_fee'] ?? 0); + $mediaFeeAmount = (float) ($validated['media_fee_amount'] ?? $this->netFromGross($mediaFee, $taxPercent)); + $managementFee = (float) ($validated['management_fee'] ?? 0); + $managementFeeAmount = (float) ($validated['management_fee_amount'] ?? $this->netFromGross($managementFee, $taxPercent)); + $items = []; + + if ($mediaFee > 0) { + $items[] = [ + 'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME]->id, + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + 'payment_item_amount' => $mediaFee, + 'tax_percentage' => $taxPercent, + 'net_amount' => $mediaFeeAmount, + 'withholding_tax' => 0, + 'final_net_amount' => $validated['nett_amount'] ?? $mediaFeeAmount, + 'spending' => $validated['total_spending'] ?? 0, + 'is_creditcard' => (bool) ($validated['is_credit_card'] ?? false), + ]; + } + + if ($managementFee > 0) { + $items[] = [ + 'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME]->id, + 'start_date' => null, + 'end_date' => null, + 'payment_item_amount' => $managementFee, + 'tax_percentage' => $taxPercent, + 'net_amount' => $managementFeeAmount, + 'withholding_tax' => 0, + 'final_net_amount' => $managementFeeAmount, + 'spending' => 0, + 'is_creditcard' => false, + ]; + } + + if ($items === []) { + return []; + } + + return [[ + 'payment_no' => $validated['payment_no'] ?? null, + 'payment_total_amount' => $mediaFee + $managementFee, + 'payment_nett_amount' => $mediaFeeAmount + $managementFeeAmount, + 'items' => $items, + ]]; + } + + private function paymentsGrossTotal(array $payments): float + { + return array_sum(array_map(fn (array $payment) => (float) ($payment['payment_total_amount'] ?? 0), $payments)); + } + + private function paymentsNetTotal(array $payments): float + { + return array_sum(array_map(fn (array $payment) => (float) ($payment['payment_nett_amount'] ?? 0), $payments)); + } + + private function semItemAmount(array $semItem): float + { + return (float) ( + data_get($semItem, 'exact_price') + ?? data_get($semItem, 'amount') + ?? data_get($semItem, 'item.estimated_total') + ?? data_get($semItem, 'estimated_total') + ?? 0 ); + } - // $this->approvalService->requireApproval($invoice); + private function semItemTaxPercentage(array $semItem, array $validated): float + { + return (float) ( + data_get($semItem, 'item.sql_acc_tax_percent') + ?? data_get($semItem, 'tax_percent') + ?? $validated['tax_percent'] + ?? 0 + ); + } - // return response()->json([ - // 'message' => 'Invoice created and marked for approval.', - // 'invoice' => $invoice->fresh('payments.items.billingItemType'), - // ], 201); + private function semItemNetAmount(array $semItem, float $grossAmount, float $taxPercentage): float + { + $explicitNetAmount = data_get($semItem, 'net_amount') + ?? data_get($semItem, 'nett_amount'); + + if (is_numeric($explicitNetAmount)) { + return (float) $explicitNetAmount; + } + + $taxAmount = data_get($semItem, 'exact_tax'); + + if (is_numeric($taxAmount)) { + return max(0, $grossAmount - (float) $taxAmount); + } + + return $this->netFromGross($grossAmount, $taxPercentage); } public function approve(ClientInvoice $invoice): JsonResponse @@ -312,9 +569,18 @@ private function paymentItemSqlAccCode(array $paymentItem): ?string { $sqlAccCode = data_get($paymentItem, 'item.item.sql_acc_code') ?? data_get($paymentItem, 'item.sql_acc_code') + ?? data_get($paymentItem, 'billing_item_type.sql_acc_code') + ?? data_get($paymentItem, 'billingItemType.sql_acc_code') + ?? data_get($paymentItem, 'billing_item.sql_acc_code') ?? data_get($paymentItem, 'sql_acc_code'); - return is_string($sqlAccCode) ? strtoupper(trim($sqlAccCode)) : null; + if (! is_string($sqlAccCode)) { + return null; + } + + $sqlAccCode = strtoupper(trim($sqlAccCode)); + + return $sqlAccCode === '' ? null : $sqlAccCode; } private function paymentItemEstimatedTotal(array $paymentItem): float diff --git a/app/Services/GoogleAdsService.php b/app/Services/GoogleAdsService.php index cbe83e4..eeaabd3 100644 --- a/app/Services/GoogleAdsService.php +++ b/app/Services/GoogleAdsService.php @@ -2,37 +2,38 @@ namespace App\Services; -use Google\Ads\GoogleAds\Lib\V22\GoogleAdsClientBuilder; use Google\Ads\GoogleAds\Lib\OAuth2TokenBuilder; -use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest; -use Google\Ads\GoogleAds\V22\Enums\CustomerStatusEnum\CustomerStatus; -use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus; -use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; -use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelSubTypeEnum\AdvertisingChannelSubType; +use Google\Ads\GoogleAds\Lib\V22\GoogleAdsClientBuilder; +use Google\Ads\GoogleAds\V22\Enums\AdGroupAdStatusEnum\AdGroupAdStatus; +use Google\Ads\GoogleAds\V22\Enums\AdGroupCriterionStatusEnum\AdGroupCriterionStatus; use Google\Ads\GoogleAds\V22\Enums\AdGroupStatusEnum\AdGroupStatus; use Google\Ads\GoogleAds\V22\Enums\AdGroupTypeEnum\AdGroupType; -use Google\Ads\GoogleAds\V22\Enums\PolicyApprovalStatusEnum\PolicyApprovalStatus; -use Google\Ads\GoogleAds\V22\Enums\AdGroupAdStatusEnum\AdGroupAdStatus; use Google\Ads\GoogleAds\V22\Enums\AdTypeEnum\AdType; -use Google\Ads\GoogleAds\V22\Enums\KeywordMatchTypeEnum\KeywordMatchType; -use Google\Ads\GoogleAds\V22\Enums\AdGroupCriterionStatusEnum\AdGroupCriterionStatus; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelSubTypeEnum\AdvertisingChannelSubType; +use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; +use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType; +use Google\Ads\GoogleAds\V22\Enums\AssetLinkStatusEnum\AssetLinkStatus; use Google\Ads\GoogleAds\V22\Enums\AssetSourceEnum\AssetSource; use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType; -use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType; +use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus; +use Google\Ads\GoogleAds\V22\Enums\CustomerStatusEnum\CustomerStatus; +use Google\Ads\GoogleAds\V22\Enums\KeywordMatchTypeEnum\KeywordMatchType; +use Google\Ads\GoogleAds\V22\Enums\PolicyApprovalStatusEnum\PolicyApprovalStatus; use Google\Ads\GoogleAds\V22\Enums\PolicyReviewStatusEnum\PolicyReviewStatus; -use Google\Ads\GoogleAds\V22\Enums\AssetLinkStatusEnum\AssetLinkStatus; -use GPBMetadata\Google\Api\Log; +use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest; use Illuminate\Support\Facades\Log as FacadesLog; class GoogleAdsService { protected $oAuth2Credential; + protected $developerToken; + protected $loginCustomerId; public function __construct() { - $this->oAuth2Credential = (new OAuth2TokenBuilder()) + $this->oAuth2Credential = (new OAuth2TokenBuilder) ->withClientId(env('GOOGLE_ADS_CLIENT_ID')) ->withClientSecret(env('GOOGLE_ADS_CLIENT_SECRET')) ->withRefreshToken(env('GOOGLE_ADS_REFRESH_TOKEN')) @@ -47,7 +48,7 @@ public function __construct() */ protected function buildClient(string $customerId) { - return (new GoogleAdsClientBuilder()) + return (new GoogleAdsClientBuilder) ->withDeveloperToken($this->developerToken) ->withOAuth2Credential($this->oAuth2Credential) ->withLoginCustomerId($customerId) @@ -63,7 +64,7 @@ public function listAccounts(): array $client = $this->buildClient($this->loginCustomerId); $service = $client->getGoogleAdsServiceClient(); - $query = <<getGoogleAdsServiceClient(); $customerId = str_replace('-', '', $clientCustomerId); - $query = << $query, ]); $response = $service->search($request); - FacadesLog::info('Google Ads Query', [ + FacadesLog::info('Google Ads Query', [ 'query' => $query, 'customer_id' => $customerId, ]); @@ -211,7 +212,7 @@ public function listCampaignsMetrics(string $clientCustomerId, string $campaignI // 'average_cpc' => round($row->getMetrics()->getAverageCpc() / 1000000, 2), 'conversions' => $row->getMetrics()->getConversions(), 'conversions_value' => $row->getMetrics()->getConversionsValue(), - 'cost_per_conversion' =>number_format(( $row->getMetrics()->getCostPerConversion() / 1000000), 2, '.', ''), + 'cost_per_conversion' => number_format(($row->getMetrics()->getCostPerConversion() / 1000000), 2, '.', ''), // 'conversions_from_interactions_rate' => round($row->getMetrics()->getConversionsFromInteractionsRate() * 100, 2), 'interactions' => $row->getMetrics()->getInteractions(), 'interaction_rate' => number_format($row->getMetrics()->getInteractionRate() * 100, 2, '.', ''), @@ -219,6 +220,7 @@ public function listCampaignsMetrics(string $clientCustomerId, string $campaignI // 'view_through_conversions' => $row->getMetrics()->getViewThroughConversions() ]; } + return $metrics; } @@ -227,7 +229,6 @@ public function listCampaignsMetricsById(string $clientCustomerId, string $campa $client = $this->buildClient($this->loginCustomerId); $service = $client->getGoogleAdsServiceClient(); $customerId = str_replace('-', '', $clientCustomerId); - if (empty($startDate)) { $startDate = date('Y-m-d'); } @@ -235,7 +236,6 @@ public function listCampaignsMetricsById(string $clientCustomerId, string $campa if (empty($endDate)) { $endDate = date('Y-m-d'); } - $query = << round($row->getMetrics()->getInteractionRate() * 100, 2), ]; } + return $metrics; } @@ -370,7 +371,7 @@ public function listAdsByAdGroupId(string $clientCustomerId, string $adGroupId): 'status' => AdGroupAdStatus::name($adGroupAd->getStatus()), 'approval_status' => PolicyApprovalStatus::name($adGroupAd->getPolicySummary()->getApprovalStatus()), 'final_urls' => iterator_to_array($ad->getFinalUrls()), - 'ad_group_id' => $row->getAdGroup()->getId() + 'ad_group_id' => $row->getAdGroup()->getId(), ]; } @@ -406,7 +407,6 @@ public function listAssetsByCampaignId(string $clientCustomerId, string $campaig $response = $service->search($request); $assets = []; - foreach ($response->iterateAllElements() as $row) { $asset = $row->getAsset(); $ca = $row->getCampaignAsset(); @@ -438,7 +438,6 @@ public function listAssetsByCampaignId(string $clientCustomerId, string $campaig return $assets; } - public function listKeywordsByAdGroupId(string $clientCustomerId, string $adGroupId): array { $customerId = str_replace('-', '', $clientCustomerId); @@ -477,14 +476,13 @@ public function listKeywordsByAdGroupId(string $clientCustomerId, string $adGrou 'match_type' => KeywordMatchType::name($keywordInfo->getMatchType()), 'status' => AdGroupCriterionStatus::name($criterion->getStatus()), 'cpc_bid' => round($criterion->getCpcBidMicros() / 1000000, 2), - 'ad_group_id' => $row->getAdGroup()->getId() + 'ad_group_id' => $row->getAdGroup()->getId(), ]; } return $keywords; } - public function listAdGroupMetrics(string $dateFrom, string $dateTo): array { $client = $this->buildClient($this->loginCustomerId); @@ -644,7 +642,7 @@ public function getAdGroupMetricsById(string $clientCustomerId, string $adGroupI 'conversion_value' => $m->getConversionsValue(), 'ctr' => $m->getImpressions() > 0 ? round(($m->getClicks() / $m->getImpressions()) * 100, 2) - : 0 + : 0, ]; } @@ -701,7 +699,7 @@ public function getAdMetricsById(string $clientCustomerId, string $adId, string 'clicks' => $m->getClicks(), 'spend' => round($m->getCostMicros() / 1000000, 2), 'conversions' => $m->getConversions(), - 'ad_group_id' => $row->getAdGroup()->getId() + 'ad_group_id' => $row->getAdGroup()->getId(), ]; } @@ -759,7 +757,7 @@ public function getAssetMetricsById(string $clientCustomerId, string $assetId, s 'clicks' => $m->getClicks(), 'cost' => round($m->getCostMicros() / 1000000, 2), 'conversions' => $m->getConversions(), - 'campaign_id' => $row->getCampaign()->getId() + 'campaign_id' => $row->getCampaign()->getId(), ]; } @@ -772,7 +770,7 @@ public function getAccountDetails(string $customerId): array $service = $client->getGoogleAdsServiceClient(); // Querying 'customer' gives you specific settings for that ID - $query = "SELECT + $query = 'SELECT customer.id, customer.descriptive_name, customer.currency_code, @@ -782,7 +780,7 @@ public function getAccountDetails(string $customerId): array customer.manager, customer.test_account, customer.status - FROM customer"; + FROM customer'; // When querying the 'customer' resource, the customer_id in the request // must match the ID you are querying. @@ -814,6 +812,4 @@ public function getAccountDetails(string $customerId): array 'tracking_template' => $customer->getTrackingUrlTemplate(), ]; } - - } diff --git a/resources/js/pages/campaigns/show.tsx b/resources/js/pages/campaigns/show.tsx index cad36d5..6ab94e7 100644 --- a/resources/js/pages/campaigns/show.tsx +++ b/resources/js/pages/campaigns/show.tsx @@ -40,6 +40,7 @@ import { ThemeIcon, Title, Tooltip, + useMantineTheme, } from '@mantine/core'; import { @@ -223,12 +224,14 @@ const getInvoiceSpending = (invoice: ClientInvoice): number => parseNumber(invoice.total_sem_amount) || parseNumber(invoice.total_spend); -const getBillableInvoiceSpending = (invoice: ClientInvoice): number => - getInvoiceItems(invoice).reduce( - (sum, item) => - item.is_creditcard ? sum : sum + parseNumber(item.spending), - 0, - ); +const getCreditCardMediaSpending = (invoice: ClientInvoice): number => + getInvoiceItems(invoice).reduce((sum, item) => { + if (item.billing_item_types_id !== 1 || !item.is_creditcard) { + return sum; + } + + return sum + parseNumber(item.spending); + }, 0); const getBillableNettAmount = (invoice: ClientInvoice): number => getInvoiceItems(invoice).reduce((sum, item) => { @@ -258,6 +261,16 @@ const getInvoiceIsCreditCard = (invoice: ClientInvoice): boolean => { return items.length > 0 && items.every((item) => item.is_creditcard); }; +const getInvoicesMediaItemsAreAllCreditCard = ( + invoices: ClientInvoice[], +): boolean => { + const items = invoices + .flatMap(getInvoiceItems) + .filter((item) => item.billing_item_types_id === 1); + + return items.length > 0 && items.every((item) => item.is_creditcard); +}; + const buildInvoiceTree = (invoices: ClientInvoice[]): InvoiceRow[] => { const nodes: InvoiceRow[] = invoices.map((invoice) => ({ ...invoice, @@ -334,20 +347,6 @@ const getPaymentTaxAmount = (payment: ClientInvoicePayment): number => { ); }; -const getPaymentTaxPercentage = (payment: ClientInvoicePayment): number => { - const items = payment.items ?? []; - const taxableGross = items.reduce( - (sum, item) => sum + parseNumber(item.payment_item_amount), - 0, - ); - - if (taxableGross > 0) { - return (getPaymentTaxAmount(payment) / taxableGross) * 100; - } - - return 0; -}; - const getInvoiceAmount = (invoice: ClientInvoice): number => { return parseNumber(invoice.total_sem_amount); }; @@ -378,6 +377,30 @@ const getPaymentSpending = (payment: ClientInvoicePayment): number => 0, ); +const getPaymentAmount = (payment: ClientInvoicePayment): number => + (payment.items ?? []).reduce( + (sum, item) => sum + parseNumber(item.payment_item_amount), + 0, + ) || parseNumber(payment.payment_total_amount); + +const getPaymentNetAmount = (payment: ClientInvoicePayment): number => + (payment.items ?? []).reduce( + (sum, item) => + sum + + (parseNumber(item.final_net_amount) || + parseNumber(item.net_amount)), + 0, + ) || parseNumber(payment.payment_nett_amount); + +const formatCurrency = (value?: number | string | null): string => + currencyFormatter.format(parseNumber(value)); + +const formatPercent = (value?: number | string | null): string => + `${currencyFormatter.format(parseNumber(value))}%`; + +const formatShortDate = (value?: string | null): string => + value ? dayjs(value).format('DD MMM YYYY') : '-'; + const minDateString = ( values: Array, ): string | null => { @@ -646,11 +669,10 @@ export default function TicketDetails({ total_impressions: parseNumber( campaign.total_impressions, ), - total_actual_spend: - String( - campaign.total_actual_spend ?? - summaryDefaults.total_actual_spend, - ), + total_actual_spend: String( + campaign.total_actual_spend ?? + summaryDefaults.total_actual_spend, + ), metrics: normalizeCampaignMetrics(campaign.metrics), }), ); @@ -798,10 +820,14 @@ export default function TicketDetails({ return sum + getInvoiceSpending(invoice); }, 0); - const billableInvoiceSpending = invoicesData.reduce( - (sum, invoice) => sum + getBillableInvoiceSpending(invoice), + const creditCardMediaSpending = invoicesData.reduce( + (sum, invoice) => sum + getCreditCardMediaSpending(invoice), 0, ); + const billableInvoiceSpending = Math.max( + 0, + parseNumber(lifeTimeSpending) - creditCardMediaSpending, + ); const nettAmountBase = invoicesData.reduce( (sum, invoice) => sum + getBillableNettAmount(invoice), @@ -809,10 +835,11 @@ export default function TicketDetails({ ); const nettAmount = nettAmountBase + adjustmentNet; - const remainingAmount = Math.max( - 0, - nettAmount - billableInvoiceSpending, - ); + const remainingAmount = getInvoicesMediaItemsAreAllCreditCard( + invoicesData, + ) + ? 0 + : Math.max(0, nettAmount - billableInvoiceSpending); return { managementFee: invoicesData.reduce( @@ -829,7 +856,7 @@ export default function TicketDetails({ nettAmount, remainingAmount, }; - }, [clientInvoices, clientAdjustments]); + }, [clientInvoices, clientAdjustments, lifeTimeSpending]); const groupedInvoices = useMemo( () => buildInvoiceTree(clientInvoices ?? []), @@ -1013,141 +1040,6 @@ export default function TicketDetails({ [], ); - const paymentDetailColumns = useMemo[]>( - () => [ - { - accessorKey: 'payment_no', - header: 'Payment No', - Cell: ({ cell }) => cell.getValue() ?? '—', - }, - { - id: 'payment_amount', - header: 'Payment Amount', - accessorFn: (row) => - (row.items ?? []).reduce( - (sum, item) => - sum + parseNumber(item.payment_item_amount), - 0, - ) || parseNumber(row.payment_total_amount), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - { - id: 'tax_amount', - header: 'Tax Amount', - accessorFn: (row) => getPaymentTaxAmount(row), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - { - id: 'net_amount', - header: 'Net Amount', - accessorFn: (row) => - (row.items ?? []).reduce( - (sum, item) => - sum + - (parseNumber(item.final_net_amount) || - parseNumber(item.net_amount)), - 0, - ) || parseNumber(row.payment_nett_amount), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - { - id: 'spending', - header: 'Spending', - accessorFn: (row) => getPaymentSpending(row), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - ], - [], - ); - - const paymentItemColumns = useMemo< - MRT_ColumnDef[] - >( - () => [ - { - id: 'billing_item', - header: 'Billing Item', - accessorFn: (row) => - row.billing_item_type?.name ?? 'Billing item', - }, - { - id: 'billing_mode', - header: 'Billing', - accessorFn: (row) => - row.is_creditcard ? 'Credit Card' : 'Pay to Us', - Cell: ({ cell }) => ( - () === 'Credit Card' - ? 'teal' - : 'green' - } - variant="light" - > - {cell.getValue()} - - ), - }, - { - id: 'payment_item_amount', - header: 'Amount', - accessorFn: (row) => parseNumber(row.payment_item_amount), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - { - id: 'tax_percentage', - header: 'Tax %', - accessorFn: (row) => parseNumber(row.tax_percentage), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}%`, - }, - { - id: 'net_amount', - header: 'Net Amount', - accessorFn: (row) => parseNumber(row.net_amount), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - { - id: 'withholding_tax', - header: 'Withholding Tax', - accessorFn: (row) => parseNumber(row.withholding_tax), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}%`, - }, - { - id: 'final_net_amount', - header: 'Final Net Amount', - accessorFn: (row) => parseNumber(row.final_net_amount), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - { - accessorKey: 'start_date', - header: 'Start Date', - Cell: ({ cell }) => cell.getValue() ?? '—', - }, - { - accessorKey: 'end_date', - header: 'End Date', - Cell: ({ cell }) => cell.getValue() ?? '—', - }, - { - id: 'spending', - header: 'Spending', - accessorFn: (row) => parseNumber(row.spending), - Cell: ({ cell }) => - `${currencyFormatter.format(parseNumber(cell.getValue() as number))}`, - }, - ], - [], - ); - const adjustmentColumns = useMemo[]>( () => [ { @@ -1871,29 +1763,7 @@ export default function TicketDetails({ enablePagination enableExpanding renderDetailPanel={({ row }) => ( - ( - - )} - initialState={{ - density: 'xs', - }} - /> + )} initialState={{ density: 'xs', @@ -2078,7 +1948,9 @@ export default function TicketDetails({ ); } + +function InvoicePaymentDetails({ invoice }: { invoice: InvoiceRow }) { + const payments = getInvoicePayments(invoice); + + if (payments.length === 0) { + return ( + ({ + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[7] + : theme.colors.gray[0], + })} + > + + No payments recorded for this invoice. + + + ); + } + + return ( + + + + + 0 + ? 'red' + : 'green' + } + /> + + + + {payments.map((payment, index) => ( + + ))} + + ); +} + +function InvoiceDetailMetric({ + label, + value, + tone = 'gray', +}: { + label: string; + value: string; + tone?: 'gray' | 'green' | 'red'; +}) { + const color = + tone === 'green' ? 'green.7' : tone === 'red' ? 'red.7' : undefined; + + return ( + ({ + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[6] + : theme.white, + })} + > + + {label} + + + {value} + + + ); +} + +function PaymentPanel({ + payment, + index, +}: { + payment: ClientInvoicePayment; + index: number; +}) { + const items = payment.items ?? []; + const startDate = getPaymentStartDate(payment); + const endDate = getPaymentEndDate(payment); + + return ( + ({ + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[6] + : theme.white, + })} + > + + + + + + + + {payment.payment_no || `Payment ${index + 1}`} + + + + {formatShortDate(startDate)} to{' '} + {formatShortDate(endDate)} + + + + + + + + + + + + {items.length === 0 ? ( + + No payment items recorded. + + ) : ( +
+ + + + + Item + + + Billing + + + Dates + + + Spend + + + Amount + + + Tax + + + Net + + + WHT + + + Final Net + + + + + {items.map((item, itemIndex) => ( + + ))} + +
+
+ )} +
+ ); +} + +function PaymentAmount({ label, value }: { label: string; value: number }) { + return ( + + + {label} + + + RM {formatCurrency(value)} + + + ); +} + +function PaymentItemHeader({ + children, + width, +}: { + children: React.ReactNode; + width: string; +}) { + const theme = useMantineTheme(); + const isDark = theme.colorScheme === 'dark'; + + return ( + + {children} + + ); +} + +function PaymentItemCell({ + children, + align = 'left', +}: { + children: React.ReactNode; + align?: 'left' | 'right'; +}) { + const theme = useMantineTheme(); + const isDark = theme.colorScheme === 'dark'; + + return ( + + {children} + + ); +} + +function PaymentItemRow({ item }: { item: ClientInvoicePaymentItem }) { + const billingMode = item.is_creditcard ? 'Credit Card' : 'Pay to Us'; + + return ( + + + + + {item.billing_item_type?.name ?? 'Billing item'} + + + {item.billing_item_type?.fee_type ?? '-'} + + + + + + {billingMode} + + + + + {formatShortDate(item.start_date)} +
+ {formatShortDate(item.end_date)} +
+
+ + + RM {formatCurrency(item.spending)} + + + + + RM {formatCurrency(item.payment_item_amount)} + + + + {formatPercent(item.tax_percentage)} + + + RM {formatCurrency(item.net_amount)} + + + {formatPercent(item.withholding_tax)} + + + + RM {formatCurrency(item.final_net_amount)} + + + + ); +} diff --git a/routes/console.php b/routes/console.php index e29345f..474036e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,3 +15,15 @@ Schedule::command('project-activities:pending-notify') ->dailyAt('16:00') ->withoutOverlapping(); + +Schedule::command('customer:update-current-invoice-item-spending') + ->dailyAt('09:00') + ->withoutOverlapping(); + +Schedule::command('customer:update-current-invoice-item-spending') + ->dailyAt('12:00') + ->withoutOverlapping(); + +Schedule::command('customer:update-current-invoice-item-spending') + ->dailyAt('16:00') + ->withoutOverlapping(); diff --git a/tests/Feature/RepairLinkedInvoicePaymentItemsTest.php b/tests/Feature/RepairLinkedInvoicePaymentItemsTest.php new file mode 100644 index 0000000..435ddc3 --- /dev/null +++ b/tests/Feature/RepairLinkedInvoicePaymentItemsTest.php @@ -0,0 +1,135 @@ +insert([ + ['id' => 1, 'name' => 'Media'], + ['id' => 2, 'name' => 'Management'], + ]); +}); + +test('it moves media date range and spending to management items on distinct referenced invoices', function () { + $client = Client::factory()->create(); + $parentInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-PARENT', + ]); + ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-LINKED-1', + 'linked_invoice_id' => $parentInvoice->id, + ]); + $secondLinkedInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-LINKED-2', + 'linked_invoice_id' => $parentInvoice->id, + ]); + $unlinkedInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-UNLINKED', + ]); + + $parentPayment = ClientInvoicePayment::query()->create([ + 'client_invoice_id' => $parentInvoice->id, + 'payment_no' => 'P1', + ]); + $childPayment = ClientInvoicePayment::query()->create([ + 'client_invoice_id' => $secondLinkedInvoice->id, + 'payment_no' => 'P2', + ]); + $unlinkedPayment = ClientInvoicePayment::query()->create([ + 'client_invoice_id' => $unlinkedInvoice->id, + 'payment_no' => 'P3', + ]); + + $parentMediaItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $parentPayment->id, + 'billing_item_types_id' => 1, + 'start_date' => '2026-06-01', + 'end_date' => '2026-06-30', + 'spending' => 125.50, + ]); + $parentManagementItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $parentPayment->id, + 'billing_item_types_id' => 2, + 'spending' => 10, + ]); + $childMediaItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $childPayment->id, + 'billing_item_types_id' => 1, + 'start_date' => '2026-06-15', + 'end_date' => '2026-06-20', + 'spending' => 50, + ]); + $childManagementItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $childPayment->id, + 'billing_item_types_id' => 2, + ]); + $unlinkedMediaItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $unlinkedPayment->id, + 'billing_item_types_id' => 1, + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-31', + 'spending' => 99, + ]); + $unlinkedManagementItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $unlinkedPayment->id, + 'billing_item_types_id' => 2, + ]); + + $this->artisan('customer:repair-linked-invoice-payment-items') + ->expectsOutputToContain('Done. 1 media item merged into management, 1 media item removed, 0 skipped.') + ->assertSuccessful(); + + expect(ClientInvoicePaymentItem::query()->whereKey($parentMediaItem->id)->exists())->toBeFalse() + ->and($parentManagementItem->fresh()->start_date->toDateString())->toBe('2026-06-01') + ->and($parentManagementItem->fresh()->end_date->toDateString())->toBe('2026-06-30') + ->and((float) $parentManagementItem->fresh()->spending)->toBe(135.50) + ->and($childMediaItem->fresh())->not->toBeNull() + ->and($childManagementItem->fresh()->start_date)->toBeNull() + ->and($unlinkedMediaItem->fresh())->not->toBeNull() + ->and($unlinkedManagementItem->fresh()->start_date)->toBeNull() + ->and((float) $unlinkedManagementItem->fresh()->spending)->toBe(0.0); +}); + +test('dry run does not update or delete items', function () { + $client = Client::factory()->create(); + $parentInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-PARENT', + ]); + ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-LINKED', + 'linked_invoice_id' => $parentInvoice->id, + ]); + $payment = ClientInvoicePayment::query()->create([ + 'client_invoice_id' => $parentInvoice->id, + ]); + + $mediaItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $payment->id, + 'billing_item_types_id' => 1, + 'start_date' => '2026-06-01', + 'end_date' => '2026-06-30', + 'spending' => 125.50, + ]); + $managementItem = ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $payment->id, + 'billing_item_types_id' => 2, + ]); + + $this->artisan('customer:repair-linked-invoice-payment-items', ['--dry-run' => true]) + ->expectsOutputToContain('Done. 1 media item merged into management, 1 media item removed, 0 skipped.') + ->assertSuccessful(); + + expect($mediaItem->fresh())->not->toBeNull() + ->and($managementItem->fresh()->start_date)->toBeNull() + ->and($managementItem->fresh()->end_date)->toBeNull() + ->and((float) $managementItem->fresh()->spending)->toBe(0.0); +}); diff --git a/tests/Feature/UpdateCurrentClientInvoicePaymentItemSpendingTest.php b/tests/Feature/UpdateCurrentClientInvoicePaymentItemSpendingTest.php new file mode 100644 index 0000000..2936cac --- /dev/null +++ b/tests/Feature/UpdateCurrentClientInvoicePaymentItemSpendingTest.php @@ -0,0 +1,112 @@ +forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => fake()->unique()->numerify('INV-####'), + ]); + + $payment = ClientInvoicePayment::query()->create([ + 'client_invoice_id' => $invoice->id, + ]); + + return ClientInvoicePaymentItem::query()->create([ + 'client_invoice_payment_id' => $payment->id, + 'billing_item_types_id' => $billingItemTypeId, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'spending' => $spending, + ]); +} + +test('it updates eligible invoice payment items ending today or later through today', function () { + Carbon::setTestNow('2026-06-25 10:00:00'); + + BillingItemType::query()->insert([ + ['id' => 1, 'name' => 'Media'], + ['id' => 2, 'name' => 'Management'], + ]); + + $client = Client::factory()->create([ + 'customer_id' => '1111111111', + 'status' => 'ENABLED', + 'time_zone' => 'Asia/Kuala_Lumpur', + ]); + + $expiredItem = createCurrentSpendingInvoiceItem($client, 1, '2026-06-01', '2026-06-24', 10); + $todayItem = createCurrentSpendingInvoiceItem($client, 1, '2026-06-01', '2026-06-25'); + $futureEndItem = createCurrentSpendingInvoiceItem($client, 1, '2026-06-10', '2026-06-30'); + $futureStartItem = createCurrentSpendingInvoiceItem($client, 1, '2026-06-26', '2026-06-30', 30); + $openEndedItem = createCurrentSpendingInvoiceItem($client, 1, '2026-06-01', null, 40); + $otherTypeItem = createCurrentSpendingInvoiceItem($client, 2, '2026-06-01', '2026-06-30', 50); + + $adsService = Mockery::mock(GoogleAdsService::class); + $adsService->shouldReceive('listCampaigns') + ->twice() + ->with('1111111111') + ->andReturn([['id' => 101]]); + $adsService->shouldReceive('listCampaignsMetricsById') + ->once() + ->with('1111111111', '101', '2026-06-01', '2026-06-25') + ->andReturn([['actual_spend' => 12.25]]); + $adsService->shouldReceive('listCampaignsMetricsById') + ->once() + ->with('1111111111', '101', '2026-06-10', '2026-06-25') + ->andReturn([['actual_spend' => 7.75]]); + + app()->instance(GoogleAdsService::class, $adsService); + + $this->artisan('customer:update-current-invoice-item-spending') + ->expectsOutputToContain('Done. 2 calculated, 1 skipped, 0 failed.') + ->assertSuccessful(); + + expect((float) $expiredItem->fresh()->spending)->toBe(10.0) + ->and((float) $todayItem->fresh()->spending)->toBe(12.25) + ->and((float) $futureEndItem->fresh()->spending)->toBe(7.75) + ->and((float) $futureStartItem->fresh()->spending)->toBe(30.0) + ->and((float) $openEndedItem->fresh()->spending)->toBe(40.0) + ->and((float) $otherTypeItem->fresh()->spending)->toBe(50.0); +}); + +test('dry run calculates current spending without updating the item', function () { + Carbon::setTestNow('2026-06-25 10:00:00'); + + BillingItemType::query()->insert([ + 'id' => 1, + 'name' => 'Media', + ]); + + $client = Client::factory()->create([ + 'customer_id' => '3333333333', + 'status' => 'ENABLED', + 'time_zone' => 'Asia/Kuala_Lumpur', + ]); + $item = createCurrentSpendingInvoiceItem($client, 1, '2026-06-01', '2026-06-30', 8); + + $adsService = Mockery::mock(GoogleAdsService::class); + $adsService->shouldReceive('listCampaigns') + ->once() + ->with('3333333333') + ->andReturn([]); + + app()->instance(GoogleAdsService::class, $adsService); + + $this->artisan('customer:update-current-invoice-item-spending', ['--dry-run' => true]) + ->assertSuccessful(); + + expect((float) $item->fresh()->spending)->toBe(8.0); +});