diff --git a/app/Models/Client.php b/app/Models/Client.php index 5141fc9..8c79383 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -2,16 +2,16 @@ namespace App\Models; +use App\Services\ClientInvoicePaymentSyncService; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use App\Models\ClientProjectActivities; use Illuminate\Database\Eloquent\Relations\HasMany; class Client extends Model { use HasFactory; - protected $fillable = ['name','customer_id','status','time_zone','industry','sql_acc_code']; + protected $fillable = ['name', 'customer_id', 'status', 'time_zone', 'industry', 'sql_acc_code']; protected $appends = [ 'latest_remaining_amount', @@ -54,16 +54,20 @@ public function latestRemainingAmount(): string ? $this->invoiceAdjustments : $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']); - $items = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments) - ->flatMap(fn ($payment) => $payment->items); + $invoiceItems = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments + ->flatMap(fn ($payment) => $payment->items + ->map(fn (ClientInvoicePaymentItem $item) => [ + 'invoice' => $invoice, + 'item' => $item, + ]))); - $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)); + $nettAmount = $invoiceItems + ->filter(fn (array $row) => ! $row['item']->is_creditcard && ($row['item']->billingItemType?->nett_contribution ?? false)) + ->sum(fn (array $row) => (float) ($row['item']->final_net_amount ?? $row['item']->net_amount ?? 0)); - $billableSpending = $items - ->reject(fn ($item) => $item->is_creditcard) - ->sum(fn ($item) => (float) ($item->spending ?? 0)); + $billableSpending = $invoiceItems + ->reject(fn (array $row) => $this->isCreditCardSpend($row['invoice'], $row['item'])) + ->sum(fn (array $row) => (float) ($row['item']->spending ?? 0)); $adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) { $amount = (float) ($adjustment->amount ?? 0); @@ -81,6 +85,17 @@ public function activitiesList(): HasMany return $this->hasMany(ClientProjectActivities::class); } + private function isCreditCardSpend(ClientInvoice $invoice, ClientInvoicePaymentItem $item): bool + { + if ($item->is_creditcard) { + return true; + } + + return $invoice->is_credit_card + && $item->billingItemType?->name === ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME + && (float) ($item->spending ?? 0) > 0; + } + 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 1db7db6..1d68ecc 100644 --- a/resources/js/pages/campaigns/index.tsx +++ b/resources/js/pages/campaigns/index.tsx @@ -1,21 +1,21 @@ -import React, { useMemo } from 'react'; -import AppLayout from '../../layouts/app-layout'; +import { Client } from '@/types'; +import { Link, router } from '@inertiajs/react'; import { - Container, - Title, - Group, - Button, - Badge, ActionIcon, + Badge, + Button, + Container, + Group, Stack, - Text, Tabs, + Text, + Title, } from '@mantine/core'; import { IconEye, IconRefresh } from '@tabler/icons-react'; -import { MantineReactTable } from 'mantine-react-table'; import type { MRT_Row } from 'mantine-react-table'; -import { Link, router } from '@inertiajs/react'; -import { Client } from "@/types"; +import { MantineReactTable } from 'mantine-react-table'; +import React, { useMemo } from 'react'; +import AppLayout from '../../layouts/app-layout'; interface Props { clients: Client[]; @@ -32,7 +32,10 @@ const parseAmount = (value?: number | string | null): number => { return 0; } - const normalized = typeof value === 'string' ? Number(value.replace(/,/g, '')) : Number(value); + const normalized = + typeof value === 'string' + ? Number(value.replace(/,/g, '')) + : Number(value); return Number.isNaN(normalized) ? 0 : normalized; }; @@ -59,23 +62,30 @@ export default function TicketDetails({ googleCompanySyncRunning, }: Props) { const [syncing, setSyncing] = React.useState(false); - const [activeStatus, setActiveStatus] = React.useState('all'); + const [activeStatus, setActiveStatus] = React.useState( + 'all', + ); const campaignsData = clients ?? []; const statusTabs = useMemo(() => { - const counts = campaignsData.reduce>((acc, client) => { - const status = client.status || 'UNKNOWN'; - acc[status] = (acc[status] ?? 0) + 1; - return acc; - }, {}); + const counts = campaignsData.reduce>( + (acc, client) => { + const status = client.status || 'UNKNOWN'; + acc[status] = (acc[status] ?? 0) + 1; + return acc; + }, + {}, + ); return Object.entries(counts).sort(([a], [b]) => { const aIndex = statusOrder.indexOf(a); const bIndex = statusOrder.indexOf(b); if (aIndex !== -1 || bIndex !== -1) { - return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex); + return ( + (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - + (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex) + ); } return a.localeCompare(b); @@ -87,7 +97,9 @@ export default function TicketDetails({ return campaignsData; } - return campaignsData.filter((client) => (client.status || 'UNKNOWN') === activeStatus); + return campaignsData.filter( + (client) => (client.status || 'UNKNOWN') === activeStatus, + ); }, [activeStatus, campaignsData]); const columns = useMemo( @@ -95,18 +107,22 @@ export default function TicketDetails({ { accessorKey: 'customer_id', header: 'Google ID', + size: 150, }, { accessorKey: 'sql_acc_code', header: 'Customer Code', + size: 170, }, { accessorKey: 'name', header: 'Google Account Name', + size: 320, }, { accessorKey: 'status', header: 'Status', + size: 130, Cell: ({ cell }: any) => { const value = cell.getValue() as string; return {value}; @@ -115,19 +131,22 @@ export default function TicketDetails({ { accessorKey: 'industry', header: 'Industry', + size: 180, }, { accessorKey: 'assigned_person', header: 'Assigned Person', + size: 180, Cell: ({ cell }: any) => cell.getValue() ?? '—', }, { accessorKey: 'sales_person', header: 'Sales Person', + size: 180, Cell: ({ cell }: any) => cell.getValue() ?? '—', }, ], - [] + [], ); const renderRowActions = ({ row }: { row: MRT_Row }) => { @@ -152,7 +171,7 @@ export default function TicketDetails({ setSyncing(true); router.post( - route("google-ads.accounts.sync-google-company-details"), + route('google-ads.accounts.sync-google-company-details'), {}, { preserveScroll: true, @@ -163,12 +182,13 @@ export default function TicketDetails({ return ( - + Clients - Sync Google account records before linking pending invoices to new clients. + Sync Google account records before linking pending + invoices to new clients. ( - + - All ({campaignsData.length}) + + All ({campaignsData.length}) + {statusTabs.map(([status, count]) => ( - + {status} diff --git a/resources/js/pages/campaigns/show.tsx b/resources/js/pages/campaigns/show.tsx index 0ca8059..c192487 100644 --- a/resources/js/pages/campaigns/show.tsx +++ b/resources/js/pages/campaigns/show.tsx @@ -213,12 +213,26 @@ const getInvoicePayments = (invoice: ClientInvoice): ClientInvoicePayment[] => const getInvoiceItems = (invoice: ClientInvoice): ClientInvoicePaymentItem[] => getInvoicePayments(invoice).flatMap((payment) => payment.items ?? []); +const GOOGLE_SEARCH_MANAGEMENT_FEE_NAME = 'Management Fee (Google Search Ads)'; + const isMediaItem = (item: ClientInvoicePaymentItem): boolean => item.billing_item_type?.fee_type?.toLowerCase() === 'media'; const isManagementItem = (item: ClientInvoicePaymentItem): boolean => item.billing_item_type?.fee_type?.toLowerCase() === 'management'; +const carriesSearchMediaSpending = (item: ClientInvoicePaymentItem): boolean => + item.billing_item_type?.name === GOOGLE_SEARCH_MANAGEMENT_FEE_NAME && + parseNumber(item.spending) > 0; + +const isCreditCardMediaSpendingItem = ( + invoice: ClientInvoice, + item: ClientInvoicePaymentItem, +): boolean => + (isMediaItem(item) && item.is_creditcard) || + (carriesSearchMediaSpending(item) && + (item.is_creditcard || Boolean(invoice.is_credit_card))); + const contributesToNet = (item: ClientInvoicePaymentItem): boolean => item.billing_item_type?.nett_contribution ?? false; @@ -232,7 +246,7 @@ const getInvoiceSpending = (invoice: ClientInvoice): number => const getCreditCardMediaSpending = (invoice: ClientInvoice): number => getInvoiceItems(invoice).reduce((sum, item) => { - if (item.billing_item_types_id !== 1 || !item.is_creditcard) { + if (!isCreditCardMediaSpendingItem(invoice, item)) { return sum; } @@ -270,11 +284,20 @@ const getInvoiceIsCreditCard = (invoice: ClientInvoice): boolean => { const getInvoicesMediaItemsAreAllCreditCard = ( invoices: ClientInvoice[], ): boolean => { - const items = invoices - .flatMap(getInvoiceItems) - .filter((item) => item.billing_item_types_id === 1); + const items = invoices.flatMap((invoice) => + getInvoiceItems(invoice) + .filter( + (item) => isMediaItem(item) || carriesSearchMediaSpending(item), + ) + .map((item) => ({ invoice, item })), + ); - return items.length > 0 && items.every((item) => item.is_creditcard); + return ( + items.length > 0 && + items.every(({ invoice, item }) => + isCreditCardMediaSpendingItem(invoice, item), + ) + ); }; const buildInvoiceTree = (invoices: ClientInvoice[]): InvoiceRow[] => { diff --git a/tests/Feature/ClientLatestRemainingAmountTest.php b/tests/Feature/ClientLatestRemainingAmountTest.php new file mode 100644 index 0000000..2d2a68f --- /dev/null +++ b/tests/Feature/ClientLatestRemainingAmountTest.php @@ -0,0 +1,89 @@ +create([ + 'client_invoice_id' => $invoice->id, + ]); + + return ClientInvoicePaymentItem::query()->create(array_merge([ + 'client_invoice_payment_id' => $payment->id, + 'billing_item_types_id' => $billingItemTypeId, + ], $attributes)); +} + +beforeEach(function () { + BillingItemType::query()->insert([ + [ + 'id' => 1, + 'name' => ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME, + 'nett_contribution' => true, + 'fee_type' => 'Media', + ], + [ + 'id' => 2, + 'name' => ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME, + 'nett_contribution' => false, + 'fee_type' => 'Management', + ], + ]); +}); + +test('it treats google search management spending on credit card invoices as credit card media spend', function () { + $client = Client::factory()->create(); + + $payToUsInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-PAY-TO-US', + 'is_credit_card' => false, + ]); + createRemainingAmountPaymentItem($payToUsInvoice, 1, [ + 'final_net_amount' => 200, + 'spending' => 0, + 'is_creditcard' => false, + ]); + + $creditCardInvoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-CREDIT-CARD', + 'is_credit_card' => true, + ]); + createRemainingAmountPaymentItem($creditCardInvoice, 2, [ + 'spending' => 125, + 'is_creditcard' => false, + ]); + + expect($client->latestRemainingAmount())->toBe('200.00'); +}); + +test('it keeps google search management spending billable on non credit card invoices', function () { + $client = Client::factory()->create(); + + $invoice = ClientInvoice::query()->forceCreate([ + 'client_id' => $client->id, + 'invoice_no' => 'INV-PAY-TO-US', + 'is_credit_card' => false, + ]); + + createRemainingAmountPaymentItem($invoice, 1, [ + 'final_net_amount' => 200, + 'spending' => 0, + 'is_creditcard' => false, + ]); + createRemainingAmountPaymentItem($invoice, 2, [ + 'spending' => 125, + 'is_creditcard' => false, + ]); + + expect($client->latestRemainingAmount())->toBe('75.00'); +});