feat: changes to sem

This commit is contained in:
brian-inspiren 2026-07-13 06:07:56 +08:00
parent e467c60288
commit 80638ddf9b
8 changed files with 1288 additions and 304 deletions

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Models\ClientInvoicePayment;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class RepairLinkedInvoicePaymentItems extends Command
{
protected $signature = 'customer:repair-linked-invoice-payment-items
{--invoice-id=* : Only repair these referenced linked invoice IDs}
{--dry-run : Show changes without saving them}';
protected $description = 'Move media item date ranges and spending to management items on invoices referenced by linked_invoice_id, then remove media items.';
public function handle(): int
{
$dryRun = (bool) $this->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;
}
}

View File

@ -0,0 +1,124 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\ClientInvoicePaymentItem;
use App\Services\GoogleAdsSpendService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class UpdateCurrentClientInvoicePaymentItemSpending extends Command
{
protected $signature = 'customer:update-current-invoice-item-spending
{--dry-run : Calculate without saving changes}';
protected $description = 'Update spending on billing type 1 invoice payment items ending today or later.';
public function handle(GoogleAdsSpendService $spendService): int
{
$dryRun = (bool) $this->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);
}
}

View File

@ -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.',
]);
}
}
// 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 = 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,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
// 'is_paid' => (bool) ($validated['is_paid'] ?? false),
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'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,
'total_sem_amount' => $validated['total_sem_amount'] ?? $this->paymentsGrossTotal($payments),
'total_net_amount' => $validated['total_net_amount'] ?? $this->paymentsNetTotal($payments),
]);
$invoice = $this->paymentSyncService->sync(
$invoice,
$validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice)
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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 $payments;
}
/**
* @return array<int, string>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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

View File

@ -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 = <<<QUERY
$query = <<<'QUERY'
SELECT
customer_client.id,
customer_client.client_customer,
@ -110,7 +111,7 @@ public function listCampaigns(string $clientCustomerId): array
$service = $client->getGoogleAdsServiceClient();
$customerId = str_replace('-', '', $clientCustomerId);
$query = <<<QUERY
$query = <<<'QUERY'
SELECT
campaign.id,
campaign.name,
@ -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 = <<<QUERY
SELECT
campaign.id,
@ -281,6 +281,7 @@ public function listCampaignsMetricsById(string $clientCustomerId, string $campa
'interaction_rate' => 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(),
];
}
}

View File

@ -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 | undefined>,
): string | null => {
@ -646,8 +669,7 @@ export default function TicketDetails({
total_impressions: parseNumber(
campaign.total_impressions,
),
total_actual_spend:
String(
total_actual_spend: String(
campaign.total_actual_spend ??
summaryDefaults.total_actual_spend,
),
@ -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<MRT_ColumnDef<ClientInvoicePayment>[]>(
() => [
{
accessorKey: 'payment_no',
header: 'Payment No',
Cell: ({ cell }) => cell.getValue<string | null>() ?? '—',
},
{
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<ClientInvoicePaymentItem>[]
>(
() => [
{
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 }) => (
<Badge
color={
cell.getValue<string>() === 'Credit Card'
? 'teal'
: 'green'
}
variant="light"
>
{cell.getValue<string>()}
</Badge>
),
},
{
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<string | null>() ?? '—',
},
{
accessorKey: 'end_date',
header: 'End Date',
Cell: ({ cell }) => cell.getValue<string | null>() ?? '—',
},
{
id: 'spending',
header: 'Spending',
accessorFn: (row) => parseNumber(row.spending),
Cell: ({ cell }) =>
`${currencyFormatter.format(parseNumber(cell.getValue() as number))}`,
},
],
[],
);
const adjustmentColumns = useMemo<MRT_ColumnDef<AdjustmentRow>[]>(
() => [
{
@ -1871,29 +1763,7 @@ export default function TicketDetails({
enablePagination
enableExpanding
renderDetailPanel={({ row }) => (
<MantineReactTable
columns={paymentDetailColumns}
data={getInvoicePayments(row.original)}
enableExpanding
enablePagination={false}
enableTopToolbar={false}
enableBottomToolbar={false}
renderDetailPanel={({ row: paymentRow }) => (
<MantineReactTable
columns={paymentItemColumns}
data={paymentRow.original.items ?? []}
enablePagination={false}
enableTopToolbar={false}
enableBottomToolbar={false}
initialState={{
density: 'xs',
}}
/>
)}
initialState={{
density: 'xs',
}}
/>
<InvoicePaymentDetails invoice={row.original} />
)}
initialState={{
density: 'xs',
@ -2078,7 +1948,9 @@ export default function TicketDetails({
<ActivityForm
status={activityFormMode}
projectId={localClientId}
activity={(selectedActivity as any) ?? undefined}
activity={
(selectedActivity as any) ?? undefined
}
activityCategories={[]}
unassignedTickets={[]}
formAction={
@ -2145,3 +2017,351 @@ function AccountStatCard({ label, value, icon }: AccountStatCardProps) {
</Card>
);
}
function InvoicePaymentDetails({ invoice }: { invoice: InvoiceRow }) {
const payments = getInvoicePayments(invoice);
if (payments.length === 0) {
return (
<Paper
withBorder
radius="sm"
p="md"
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
})}
>
<Text size="sm" c="dimmed">
No payments recorded for this invoice.
</Text>
</Paper>
);
}
return (
<Stack spacing="sm">
<SimpleGrid
cols={4}
spacing="sm"
breakpoints={[
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'xs', cols: 1 },
]}
>
<InvoiceDetailMetric
label="Payments"
value={payments.length.toLocaleString('en-MY')}
/>
<InvoiceDetailMetric
label="Payment amount"
value={`RM ${formatCurrency(getInvoicePaidAmount(invoice))}`}
/>
<InvoiceDetailMetric
label="Outstanding"
value={`RM ${formatCurrency(getInvoiceOutstandingAmount(invoice))}`}
tone={
getInvoiceOutstandingAmount(invoice) > 0
? 'red'
: 'green'
}
/>
<InvoiceDetailMetric
label="Tracked spending"
value={`RM ${formatCurrency(getInvoiceSpending(invoice))}`}
/>
</SimpleGrid>
{payments.map((payment, index) => (
<PaymentPanel
key={payment.id ?? index}
payment={payment}
index={index}
/>
))}
</Stack>
);
}
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 (
<Paper
withBorder
radius="sm"
p="sm"
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
})}
>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={700} c={color}>
{value}
</Text>
</Paper>
);
}
function PaymentPanel({
payment,
index,
}: {
payment: ClientInvoicePayment;
index: number;
}) {
const items = payment.items ?? [];
const startDate = getPaymentStartDate(payment);
const endDate = getPaymentEndDate(payment);
return (
<Paper
withBorder
radius="sm"
p="md"
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
})}
>
<Group position="apart" align="flex-start" mb="sm">
<Stack spacing={2}>
<Group spacing="xs">
<ThemeIcon variant="light" color="blue" size="sm">
<IconWallet size={14} />
</ThemeIcon>
<Text fw={700}>
{payment.payment_no || `Payment ${index + 1}`}
</Text>
</Group>
<Text size="xs" c="dimmed">
{formatShortDate(startDate)} to{' '}
{formatShortDate(endDate)}
</Text>
</Stack>
<Group spacing="lg">
<PaymentAmount
label="Amount"
value={getPaymentAmount(payment)}
/>
<PaymentAmount
label="Tax"
value={getPaymentTaxAmount(payment)}
/>
<PaymentAmount
label="Net"
value={getPaymentNetAmount(payment)}
/>
<PaymentAmount
label="Spend"
value={getPaymentSpending(payment)}
/>
</Group>
</Group>
{items.length === 0 ? (
<Text size="sm" c="dimmed">
No payment items recorded.
</Text>
) : (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
minWidth: 920,
borderCollapse: 'collapse',
tableLayout: 'fixed',
}}
>
<thead>
<tr>
<PaymentItemHeader width="20%">
Item
</PaymentItemHeader>
<PaymentItemHeader width="12%">
Billing
</PaymentItemHeader>
<PaymentItemHeader width="8%">
Dates
</PaymentItemHeader>
<PaymentItemHeader width="10%">
Spend
</PaymentItemHeader>
<PaymentItemHeader width="10%">
Amount
</PaymentItemHeader>
<PaymentItemHeader width="8%">
Tax
</PaymentItemHeader>
<PaymentItemHeader width="10%">
Net
</PaymentItemHeader>
<PaymentItemHeader width="10%">
WHT
</PaymentItemHeader>
<PaymentItemHeader width="12%">
Final Net
</PaymentItemHeader>
</tr>
</thead>
<tbody>
{items.map((item, itemIndex) => (
<PaymentItemRow
key={item.id ?? itemIndex}
item={item}
/>
))}
</tbody>
</table>
</div>
)}
</Paper>
);
}
function PaymentAmount({ label, value }: { label: string; value: number }) {
return (
<Stack spacing={0} align="flex-end">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={700}>
RM {formatCurrency(value)}
</Text>
</Stack>
);
}
function PaymentItemHeader({
children,
width,
}: {
children: React.ReactNode;
width: string;
}) {
const theme = useMantineTheme();
const isDark = theme.colorScheme === 'dark';
return (
<th
style={{
width,
padding: '8px 10px',
textAlign: 'left',
borderBottom: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3]
}`,
backgroundColor: isDark
? theme.colors.dark[7]
: theme.colors.gray[0],
color: isDark ? theme.colors.dark[1] : theme.colors.gray[6],
fontSize: 12,
fontWeight: 600,
}}
>
{children}
</th>
);
}
function PaymentItemCell({
children,
align = 'left',
}: {
children: React.ReactNode;
align?: 'left' | 'right';
}) {
const theme = useMantineTheme();
const isDark = theme.colorScheme === 'dark';
return (
<td
style={{
padding: '10px',
textAlign: align,
borderBottom: `1px solid ${isDark ? theme.colors.dark[5] : theme.colors.gray[1]
}`,
verticalAlign: 'top',
}}
>
{children}
</td>
);
}
function PaymentItemRow({ item }: { item: ClientInvoicePaymentItem }) {
const billingMode = item.is_creditcard ? 'Credit Card' : 'Pay to Us';
return (
<tr>
<PaymentItemCell>
<Stack spacing={2}>
<Text size="sm" fw={600}>
{item.billing_item_type?.name ?? 'Billing item'}
</Text>
<Text size="xs" c="dimmed">
{item.billing_item_type?.fee_type ?? '-'}
</Text>
</Stack>
</PaymentItemCell>
<PaymentItemCell>
<Badge
color={item.is_creditcard ? 'teal' : 'green'}
variant="light"
size="sm"
>
{billingMode}
</Badge>
</PaymentItemCell>
<PaymentItemCell>
<Text size="xs">
{formatShortDate(item.start_date)}
<br />
{formatShortDate(item.end_date)}
</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm" fw={600}>
RM {formatCurrency(item.spending)}
</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">
RM {formatCurrency(item.payment_item_amount)}
</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">{formatPercent(item.tax_percentage)}</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">RM {formatCurrency(item.net_amount)}</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm">{formatPercent(item.withholding_tax)}</Text>
</PaymentItemCell>
<PaymentItemCell align="right">
<Text size="sm" fw={600}>
RM {formatCurrency(item.final_net_amount)}
</Text>
</PaymentItemCell>
</tr>
);
}

View File

@ -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();

View File

@ -0,0 +1,135 @@
<?php
use App\Models\BillingItemType;
use App\Models\Client;
use App\Models\ClientInvoice;
use App\Models\ClientInvoicePayment;
use App\Models\ClientInvoicePaymentItem;
beforeEach(function () {
BillingItemType::query()->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);
});

View File

@ -0,0 +1,112 @@
<?php
use App\Models\BillingItemType;
use App\Models\Client;
use App\Models\ClientInvoice;
use App\Models\ClientInvoicePayment;
use App\Models\ClientInvoicePaymentItem;
use App\Services\GoogleAdsService;
use Illuminate\Support\Carbon;
function createCurrentSpendingInvoiceItem(
Client $client,
int $billingItemTypeId,
?string $startDate,
?string $endDate,
float $spending = 0,
): ClientInvoicePaymentItem {
$invoice = ClientInvoice::query()->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);
});