From 38fe43414f23fcf949a2c5be57467c153badd310 Mon Sep 17 00:00:00 2001 From: brian-inspiren Date: Thu, 25 Jun 2026 16:48:39 +0800 Subject: [PATCH] feat: added new changes towards the show --- ...culateClientInvoicePaymentItemSpending.php | 37 +----- ...LatestClientInvoicePaymentItemSpending.php | 113 +++++++++++++++++ app/Http/Controllers/GoogleAdsController.php | 7 +- app/Services/GoogleAdsSpendService.php | 31 +++++ resources/js/pages/campaigns/index.tsx | 2 +- resources/js/pages/campaigns/show.tsx | 24 ++-- ...stClientInvoicePaymentItemSpendingTest.php | 119 ++++++++++++++++++ 7 files changed, 290 insertions(+), 43 deletions(-) create mode 100644 app/Console/Commands/UpdateLatestClientInvoicePaymentItemSpending.php create mode 100644 app/Services/GoogleAdsSpendService.php create mode 100644 tests/Feature/UpdateLatestClientInvoicePaymentItemSpendingTest.php diff --git a/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php b/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php index d871582..dc045b1 100644 --- a/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php +++ b/app/Console/Commands/CalculateClientInvoicePaymentItemSpending.php @@ -3,7 +3,7 @@ namespace App\Console\Commands; use App\Models\ClientInvoicePaymentItem; -use App\Services\GoogleAdsService; +use App\Services\GoogleAdsSpendService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -13,7 +13,7 @@ class CalculateClientInvoicePaymentItemSpending extends Command protected $description = 'Calculate client invoice payment item spending from Google Ads spend by item date range.'; - public function handle(GoogleAdsService $adsService): int + public function handle(GoogleAdsSpendService $spendService): int { $dryRun = (bool) $this->option('dry-run'); $spendCache = []; @@ -26,13 +26,14 @@ public function handle(GoogleAdsService $adsService): int ->whereNotNull('start_date') ->whereNotNull('end_date') ->orderBy('id') - ->chunkById(50, function ($items) use ($adsService, $dryRun, &$spendCache, &$updated, &$skipped, &$failed) { + ->chunkById(50, function ($items) use ($spendService, $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; } @@ -41,6 +42,7 @@ public function handle(GoogleAdsService $adsService): int if ($startDate === null || $endDate === null) { $skipped++; + continue; } @@ -48,8 +50,7 @@ public function handle(GoogleAdsService $adsService): int try { if (! array_key_exists($cacheKey, $spendCache)) { - $spendCache[$cacheKey] = $this->spendForDateRange( - $adsService, + $spendCache[$cacheKey] = $spendService->forDateRange( $client->customer_id, $startDate, $endDate, @@ -92,30 +93,4 @@ public function handle(GoogleAdsService $adsService): int 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/UpdateLatestClientInvoicePaymentItemSpending.php b/app/Console/Commands/UpdateLatestClientInvoicePaymentItemSpending.php new file mode 100644 index 0000000..c05236b --- /dev/null +++ b/app/Console/Commands/UpdateLatestClientInvoicePaymentItemSpending.php @@ -0,0 +1,113 @@ +option('dry-run'); + $today = today()->toDateString(); + $updated = 0; + $skipped = 0; + $failed = 0; + + Client::query() + ->whereHas('invoices.payments.items', fn (Builder $query) => $this->eligibleItems($query)) + ->orderBy('id') + ->chunkById(50, function ($clients) use ( + $spendService, + $dryRun, + $today, + &$updated, + &$skipped, + &$failed, + ) { + foreach ($clients as $client) { + $item = ClientInvoicePaymentItem::query() + ->whereHas( + 'payment.invoice', + fn (Builder $query) => $query->where('client_id', $client->id), + ) + ->where(fn (Builder $query) => $this->eligibleItems($query)) + ->latest('id') + ->first(); + + if ($item === null || empty($client->customer_id)) { + $skipped++; + $this->warn("Skipping client {$client->id}: missing eligible item/customer ID."); + + continue; + } + + $startDate = $item->start_date->format('Y-m-d'); + $endDate = $item->end_date?->format('Y-m-d') ?? $today; + + if ($endDate < $startDate) { + $skipped++; + $this->warn("Skipping item {$item->id}: end date 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 latest 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): Builder + { + return $query + ->where('billing_item_types_id', 1) + ->whereNotNull('start_date'); + } +} diff --git a/app/Http/Controllers/GoogleAdsController.php b/app/Http/Controllers/GoogleAdsController.php index 93b6430..ab5e99f 100644 --- a/app/Http/Controllers/GoogleAdsController.php +++ b/app/Http/Controllers/GoogleAdsController.php @@ -333,9 +333,9 @@ private function hydrateClient(array $account): array // dd($invoices); $campaigns = []; - // if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){ - // $campaigns = $this->adsService->listCampaigns($localClient->customer_id); - // } + if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){ + $campaigns = $this->adsService->listCampaigns($localClient->customer_id); + } $lifeTimeSpend = 0; @@ -351,7 +351,6 @@ private function hydrateClient(array $account): array $lifeTimeSpend += number_format($totalSpend, 2, '.', ''); } } - $activities = $localClient->activitiesList() ->orderByDesc('created_at') ->get() diff --git a/app/Services/GoogleAdsSpendService.php b/app/Services/GoogleAdsSpendService.php new file mode 100644 index 0000000..1d821fe --- /dev/null +++ b/app/Services/GoogleAdsSpendService.php @@ -0,0 +1,31 @@ +adsService->listCampaigns($customerId) as $campaign) { + $metrics = $this->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/resources/js/pages/campaigns/index.tsx b/resources/js/pages/campaigns/index.tsx index 061a5d1..1db7db6 100644 --- a/resources/js/pages/campaigns/index.tsx +++ b/resources/js/pages/campaigns/index.tsx @@ -184,7 +184,7 @@ export default function TicketDetails({ columns={columns} data={filteredClients} enableRowActions // ✅ REQUIRED - positionActionsColumn="last" // optional but recommended + positionActionsColumn="first" // optional but recommended renderRowActions={renderRowActions} renderTopToolbarCustomActions={() => ( diff --git a/resources/js/pages/campaigns/show.tsx b/resources/js/pages/campaigns/show.tsx index 8987a15..cad36d5 100644 --- a/resources/js/pages/campaigns/show.tsx +++ b/resources/js/pages/campaigns/show.tsx @@ -55,6 +55,7 @@ import { IconEdit, IconEye, IconFileDollar, + IconLink, IconNotebook, IconPlus, IconReportAnalytics, @@ -855,43 +856,43 @@ export default function TicketDetails({ const summaryItems = [ { - label: 'Total Media Fees (RM)', + label: 'Total Media Fees (Invoice)', value: invoiceTotals.mediaFee, icon: IconDeviceTv, color: 'grape', }, { - label: 'Net Amount (RM)', + label: 'Net Amount (Invoice)', value: invoiceTotals.nettAmount, icon: IconWallet, color: 'green', }, { - label: 'Billable Spend (Invoice)', + label: 'Spending Media Fee (Google Live)', value: invoiceTotals.billableInvoiceSpending, icon: IconCurrencyDollar, color: 'indigo', }, { - label: 'Remaining Amount (RM)', + label: 'Remaining Amount', value: invoiceTotals.remainingAmount, icon: IconChartBar, color: 'teal', }, { - label: 'Adjustments (RM)', + label: 'Adjustments', value: invoiceTotals.adjustmentNet, icon: IconChartBar, color: 'cyan', }, { - label: 'Lifetime Spend (RM)', + label: 'Lifetime Spend (All)', value: lifeTimeSpending, icon: IconCash, color: 'orange', }, { - label: 'Total Management Fees (RM)', + label: 'Total Management Fees (Invoice)', value: invoiceTotals.managementFee, icon: IconBriefcase, color: 'blue', @@ -938,6 +939,15 @@ export default function TicketDetails({ noWrap > {cell.getValue()} + {row.original.linked_invoice_id && ( + + + + )} {isCreditCard && ( 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 only the latest eligible invoice payment item for each client', function () { + Carbon::setTestNow('2026-06-25 10:00:00'); + + BillingItemType::query()->insert([ + ['id' => 1, 'name' => 'Media'], + ['id' => 2, 'name' => 'Management'], + ]); + + $firstClient = Client::factory()->create([ + 'customer_id' => '1111111111', + 'status' => 'ENABLED', + 'time_zone' => 'Asia/Kuala_Lumpur', + ]); + $secondClient = Client::factory()->create([ + 'customer_id' => '2222222222', + 'status' => 'ENABLED', + 'time_zone' => 'Asia/Kuala_Lumpur', + ]); + + $olderItem = createInvoiceItem($firstClient, 1, '2026-05-01', '2026-05-31', 10); + $latestItem = createInvoiceItem($firstClient, 1, '2026-06-01', null); + $otherTypeItem = createInvoiceItem($firstClient, 2, '2026-06-01', null, 20); + $secondClientItem = createInvoiceItem($secondClient, 1, '2026-06-10', '2026-06-20'); + + $adsService = Mockery::mock(GoogleAdsService::class); + $adsService->shouldReceive('listCampaigns') + ->once() + ->with('1111111111') + ->andReturn([['id' => 101], ['id' => 102]]); + $adsService->shouldReceive('listCampaignsMetricsById') + ->once() + ->with('1111111111', '101', '2026-06-01', '2026-06-25') + ->andReturn([['actual_spend' => 12.25]]); + $adsService->shouldReceive('listCampaignsMetricsById') + ->once() + ->with('1111111111', '102', '2026-06-01', '2026-06-25') + ->andReturn([['actual_spend' => 7.75]]); + $adsService->shouldReceive('listCampaigns') + ->once() + ->with('2222222222') + ->andReturn([['id' => 201]]); + $adsService->shouldReceive('listCampaignsMetricsById') + ->once() + ->with('2222222222', '201', '2026-06-10', '2026-06-20') + ->andReturn([['actual_spend' => 5.5]]); + + app()->instance(GoogleAdsService::class, $adsService); + + $this->artisan('customer:update-latest-invoice-item-spending') + ->expectsOutputToContain('Done. 2 calculated, 0 skipped, 0 failed.') + ->assertSuccessful(); + + expect((float) $olderItem->fresh()->spending)->toBe(10.0) + ->and((float) $latestItem->fresh()->spending)->toBe(20.0) + ->and((float) $otherTypeItem->fresh()->spending)->toBe(20.0) + ->and((float) $secondClientItem->fresh()->spending)->toBe(5.5); +}); + +test('dry run calculates spending without updating the item', function () { + BillingItemType::query()->insert([ + 'id' => 1, + 'name' => 'Media', + ]); + + $client = Client::factory()->create([ + 'customer_id' => '3333333333', + 'status' => 'ENABLED', + 'time_zone' => 'Asia/Kuala_Lumpur', + ]); + $item = createInvoiceItem($client, 1, '2026-06-01', '2026-06-15', 8); + + $adsService = Mockery::mock(GoogleAdsService::class); + $adsService->shouldReceive('listCampaigns') + ->once() + ->with('3333333333') + ->andReturn([]); + + app()->instance(GoogleAdsService::class, $adsService); + + $this->artisan('customer:update-latest-invoice-item-spending', ['--dry-run' => true]) + ->assertSuccessful(); + + expect((float) $item->fresh()->spending)->toBe(8.0); +});