diff --git a/app/Http/Controllers/GoogleAdsController.php b/app/Http/Controllers/GoogleAdsController.php
index a587bfb..dbc24b1 100644
--- a/app/Http/Controllers/GoogleAdsController.php
+++ b/app/Http/Controllers/GoogleAdsController.php
@@ -47,7 +47,13 @@ public function accounts()
// $customerMap = $accounts->keyBy('customer_id');
$localClients = $this->hierarchyService
->scopeClientsVisibleTo(Client::query(), Auth::user())
- ->with('assignations.user', 'customers', 'invoices.payments.items.billingItemType', 'invoiceAdjustments')
+ ->with(
+ 'assignations.user',
+ 'customers',
+ 'invoices.payments.items.billingItemType',
+ 'invoices.linkedInvoice.payments.items.billingItemType',
+ 'invoiceAdjustments',
+ )
->get();
$customerMap = $localClients->map(function ($data) {
$assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON);
diff --git a/app/Models/Client.php b/app/Models/Client.php
index 8c79383..85a51de 100644
--- a/app/Models/Client.php
+++ b/app/Models/Client.php
@@ -3,18 +3,26 @@
namespace App\Models;
use App\Services\ClientInvoicePaymentSyncService;
+use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Collection;
class Client extends Model
{
use HasFactory;
+ private ?array $latestPaymentDateRange = null;
+
protected $fillable = ['name', 'customer_id', 'status', 'time_zone', 'industry', 'sql_acc_code'];
protected $appends = [
'latest_remaining_amount',
+ 'latest_start_date',
+ 'latest_end_date',
+ 'latest_remaining_days',
];
public function campaigns()
@@ -42,13 +50,28 @@ public function getLatestRemainingAmountAttribute(): string
return $this->latestRemainingAmount();
}
+ public function getLatestStartDateAttribute(): ?string
+ {
+ return $this->latestPaymentDateRange()['start_date'];
+ }
+
+ public function getLatestEndDateAttribute(): ?string
+ {
+ return $this->latestPaymentDateRange()['end_date'];
+ }
+
+ public function getLatestRemainingDaysAttribute(): ?int
+ {
+ return $this->latestRemainingDays();
+ }
+
public function latestRemainingAmount(): string
{
$invoices = $this->relationLoaded('invoices')
? $this->invoices
: $this->invoices()->with('payments.items.billingItemType')->get();
- $invoices->loadMissing('payments.items.billingItemType');
+ $invoices->loadMissing('payments.items.billingItemType', 'linkedInvoice.payments.items.billingItemType');
$adjustments = $this->relationLoaded('invoiceAdjustments')
? $this->invoiceAdjustments
@@ -77,7 +100,66 @@ public function latestRemainingAmount(): string
: -$amount;
});
- return number_format(max(0, $nettAmount + $adjustmentNet - $billableSpending), 2, '.', '');
+ return number_format($nettAmount + $adjustmentNet - $billableSpending, 2, '.', '');
+ }
+
+ public function latestPaymentDateRange(): array
+ {
+ if ($this->latestPaymentDateRange !== null) {
+ return $this->latestPaymentDateRange;
+ }
+
+ $row = $this->effectiveInvoiceItems()
+ ->filter(fn (array $row) => $row['item']->start_date !== null || $row['item']->end_date !== null)
+ ->sortByDesc(function (array $row) {
+ $item = $row['item'];
+
+ return sprintf(
+ '%s|%s|%010d',
+ $this->dateSortValue($item->start_date),
+ $this->dateSortValue($item->end_date),
+ $item->id ?? 0,
+ );
+ })
+ ->first();
+
+ if ($row === null) {
+ return $this->latestPaymentDateRange = [
+ 'start_date' => null,
+ 'end_date' => null,
+ ];
+ }
+
+ return $this->latestPaymentDateRange = [
+ 'start_date' => $row['item']->start_date?->toDateString(),
+ 'end_date' => $row['item']->end_date?->toDateString(),
+ ];
+ }
+
+ public function latestRemainingDays(): ?int
+ {
+ $range = $this->latestPaymentDateRange();
+
+ if ($range['start_date'] === null || $range['end_date'] === null) {
+ return null;
+ }
+
+ $startDate = Carbon::parse($range['start_date'])->startOfDay();
+ $endDate = Carbon::parse($range['end_date'])->startOfDay();
+
+ if ($endDate->lessThan($startDate)) {
+ return null;
+ }
+
+ $today = today()->startOfDay();
+
+ if ($today->greaterThan($endDate)) {
+ return 0;
+ }
+
+ $fromDate = $today->lessThan($startDate) ? $startDate : $today;
+
+ return (int) $fromDate->diffInDays($endDate);
}
public function activitiesList(): HasMany
@@ -96,6 +178,31 @@ private function isCreditCardSpend(ClientInvoice $invoice, ClientInvoicePaymentI
&& (float) ($item->spending ?? 0) > 0;
}
+ private function effectiveInvoiceItems(): Collection
+ {
+ $invoices = $this->relationLoaded('invoices')
+ ? $this->invoices
+ : $this->invoices()->with('payments.items.billingItemType', 'linkedInvoice.payments.items.billingItemType')->get();
+
+ $invoices->loadMissing('payments.items.billingItemType', 'linkedInvoice.payments.items.billingItemType');
+
+ return $invoices->flatMap(function (ClientInvoice $invoice) {
+ return collect([$invoice, $invoice->linkedInvoice])
+ ->filter()
+ ->flatMap(fn (ClientInvoice $effectiveInvoice) => $effectiveInvoice->payments
+ ->flatMap(fn ($payment) => $payment->items
+ ->map(fn (ClientInvoicePaymentItem $item) => [
+ 'invoice' => $effectiveInvoice,
+ 'item' => $item,
+ ])));
+ });
+ }
+
+ private function dateSortValue(?CarbonInterface $date): string
+ {
+ return $date?->toDateString() ?? '0000-00-00';
+ }
+
public function customers()
{
return $this->hasMany(ClientCustomer::class);
diff --git a/resources/js/pages/campaigns/index.tsx b/resources/js/pages/campaigns/index.tsx
index 1d68ecc..ad30b6b 100644
--- a/resources/js/pages/campaigns/index.tsx
+++ b/resources/js/pages/campaigns/index.tsx
@@ -1,4 +1,5 @@
import { Client } from '@/types';
+import { dateDisplay } from '@/utils/datetime';
import { Link, router } from '@inertiajs/react';
import {
ActionIcon,
@@ -39,6 +40,9 @@ const parseAmount = (value?: number | string | null): number => {
return Number.isNaN(normalized) ? 0 : normalized;
};
+const formatDate = (value?: string | null): string =>
+ value ? dateDisplay(value) : '—';
+
const statusOrder = ['ENABLED', 'PAUSED', 'REMOVED', 'ENDED', 'CANCELED'];
const getStatusColor = (status: string) => {
@@ -128,6 +132,38 @@ export default function TicketDetails({
return {value};
},
},
+ {
+ accessorKey: 'latest_remaining_amount',
+ header: 'Remaining Amount',
+ size: 180,
+ Cell: ({ cell }: any) => {
+ const amount = parseAmount(cell.getValue());
+
+ return (
+
+ {currencyFormatter.format(amount)}
+
+ );
+ },
+ },
+ {
+ accessorKey: 'latest_start_date',
+ header: 'Latest Start Date',
+ size: 180,
+ Cell: ({ cell }: any) => formatDate(cell.getValue()),
+ },
+ {
+ accessorKey: 'latest_end_date',
+ header: 'Latest End Date',
+ size: 180,
+ Cell: ({ cell }: any) => formatDate(cell.getValue()),
+ },
+ {
+ accessorKey: 'latest_remaining_days',
+ header: 'Remaining Days',
+ size: 160,
+ Cell: ({ cell }: any) => cell.getValue() ?? '—',
+ },
{
accessorKey: 'industry',
header: 'Industry',
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
index 6515a3f..932a54c 100644
--- a/resources/js/types/index.d.ts
+++ b/resources/js/types/index.d.ts
@@ -59,6 +59,9 @@ export interface Client {
updated_at: string;
invoices?: ClientInvoice[];
latest_remaining_amount?: string;
+ latest_start_date?: string | null;
+ latest_end_date?: string | null;
+ latest_remaining_days?: number | null;
assigned_person?: string;
sales_person?: string;
}
diff --git a/tests/Feature/ClientLatestRemainingAmountTest.php b/tests/Feature/ClientLatestRemainingAmountTest.php
index 2d2a68f..c8e3dd7 100644
--- a/tests/Feature/ClientLatestRemainingAmountTest.php
+++ b/tests/Feature/ClientLatestRemainingAmountTest.php
@@ -6,6 +6,7 @@
use App\Models\ClientInvoicePayment;
use App\Models\ClientInvoicePaymentItem;
use App\Services\ClientInvoicePaymentSyncService;
+use Illuminate\Support\Carbon;
function createRemainingAmountPaymentItem(
ClientInvoice $invoice,
@@ -39,6 +40,10 @@ function createRemainingAmountPaymentItem(
]);
});
+afterEach(function () {
+ Carbon::setTestNow();
+});
+
test('it treats google search management spending on credit card invoices as credit card media spend', function () {
$client = Client::factory()->create();
@@ -87,3 +92,77 @@ function createRemainingAmountPaymentItem(
expect($client->latestRemainingAmount())->toBe('75.00');
});
+
+test('it allows negative remaining amount when billable spending exceeds invoice net amount', function () {
+ $client = Client::factory()->create();
+
+ $invoice = ClientInvoice::query()->forceCreate([
+ 'client_id' => $client->id,
+ 'invoice_no' => 'INV-OVERSPEND',
+ 'is_credit_card' => false,
+ ]);
+
+ createRemainingAmountPaymentItem($invoice, 1, [
+ 'final_net_amount' => 100,
+ 'spending' => 175,
+ 'is_creditcard' => false,
+ ]);
+
+ expect($client->latestRemainingAmount())->toBe('-75.00');
+});
+
+test('it uses linked invoice payment dates when they are the latest range', function () {
+ Carbon::setTestNow('2026-06-25 10:00:00');
+
+ $client = Client::factory()->create();
+
+ $olderInvoice = ClientInvoice::query()->forceCreate([
+ 'client_id' => $client->id,
+ 'invoice_no' => 'INV-OLDER',
+ ]);
+ createRemainingAmountPaymentItem($olderInvoice, 1, [
+ 'start_date' => '2026-06-01',
+ 'end_date' => '2026-06-30',
+ ]);
+
+ $linkedInvoice = ClientInvoice::query()->forceCreate([
+ 'client_id' => $client->id,
+ 'invoice_no' => 'INV-LINKED-SOURCE',
+ ]);
+ createRemainingAmountPaymentItem($linkedInvoice, 2, [
+ 'start_date' => '2026-08-01',
+ 'end_date' => '2026-08-31',
+ ]);
+
+ $linkedPaymentInvoice = ClientInvoice::query()->forceCreate([
+ 'client_id' => $client->id,
+ 'invoice_no' => 'INV-LINKED-PAYMENT',
+ 'linked_invoice_id' => $linkedInvoice->id,
+ ]);
+ createRemainingAmountPaymentItem($linkedPaymentInvoice, 2, [
+ 'start_date' => '2026-09-01',
+ 'end_date' => '2026-09-30',
+ ]);
+
+ expect($client->latestPaymentDateRange())->toBe([
+ 'start_date' => '2026-09-01',
+ 'end_date' => '2026-09-30',
+ ])->and($client->latestRemainingDays())->toBe(29);
+});
+
+test('it counts remaining days from today within the latest range', function () {
+ Carbon::setTestNow('2026-06-25 10:00:00');
+
+ $client = Client::factory()->create();
+ $invoice = ClientInvoice::query()->forceCreate([
+ 'client_id' => $client->id,
+ 'invoice_no' => 'INV-CURRENT',
+ ]);
+
+ createRemainingAmountPaymentItem($invoice, 1, [
+ 'start_date' => '2026-06-01',
+ 'end_date' => '2026-06-30',
+ ]);
+
+ expect($client->latestRemainingDays())->toBe(5);
+});