feat: changes to sem
This commit is contained in:
parent
e467c60288
commit
80638ddf9b
119
app/Console/Commands/RepairLinkedInvoicePaymentItems.php
Normal file
119
app/Console/Commands/RepairLinkedInvoicePaymentItems.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,12 +3,15 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\BillingItemType;
|
||||||
|
use App\Models\Client;
|
||||||
use App\Models\ClientInvoice;
|
use App\Models\ClientInvoice;
|
||||||
use App\Services\ClientInvoiceApprovalService;
|
use App\Services\ClientInvoiceApprovalService;
|
||||||
use App\Services\ClientInvoicePaymentSyncService;
|
use App\Services\ClientInvoicePaymentSyncService;
|
||||||
use App\Services\ClientLookupService;
|
use App\Services\ClientLookupService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -34,21 +37,6 @@ public function pending(): JsonResponse
|
|||||||
'pending_sql_acc_code',
|
'pending_sql_acc_code',
|
||||||
'pending_client_name',
|
'pending_client_name',
|
||||||
'invoice_no',
|
'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_sem_amount',
|
||||||
'total_net_amount',
|
'total_net_amount',
|
||||||
'created_at',
|
'created_at',
|
||||||
@ -72,32 +60,69 @@ public function pending(): JsonResponse
|
|||||||
|
|
||||||
public function store(Request $request): 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([
|
$validated = $request->validate([
|
||||||
'client_id' => ['nullable', 'exists:clients,id'],
|
'client_id' => ['nullable', 'exists:clients,id'],
|
||||||
'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'],
|
'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'],
|
||||||
'client_name' => ['nullable', 'string'],
|
'client_name' => ['nullable', 'string'],
|
||||||
|
'invoice_no' => ['required', 'string'],
|
||||||
'linked_invoice_id' => ['nullable', 'integer'],
|
'linked_invoice_id' => ['nullable', 'integer'],
|
||||||
'is_credit_card' => ['nullable', 'boolean'],
|
'is_credit_card' => ['nullable', 'boolean'],
|
||||||
'payments' => ['nullable', 'array', 'min:1'],
|
'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'],
|
'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'],
|
'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'],
|
'total_spending' => ['nullable', 'numeric', 'min:0'],
|
||||||
'sem_invoice_items' => ['nullable', 'array'],
|
'sem_invoice_items' => ['nullable', 'array'],
|
||||||
'sem_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);
|
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null);
|
||||||
$client = ! empty($validated['client_id'])
|
$client = ! empty($validated['client_id'])
|
||||||
? \App\Models\Client::find($validated['client_id'])
|
? Client::find($validated['client_id'])
|
||||||
: $this->clientLookupService->findBySqlAccCode($sqlAccCode);
|
: $this->clientLookupService->findBySqlAccCode($sqlAccCode);
|
||||||
|
|
||||||
if (! empty($validated['linked_invoice_id'])) {
|
if (! empty($validated['linked_invoice_id'])) {
|
||||||
@ -113,66 +138,298 @@ public function store(Request $request): JsonResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// return response()->json([
|
$payments = $this->paymentsPayload($validated);
|
||||||
// 'message' => 'Invoice creation endpoint is under development.',
|
if ($payments === []) {
|
||||||
// 'items' => $validated['sem_items']
|
throw ValidationException::withMessages([
|
||||||
// ], 501);
|
'payments' => 'At least one payment item is required.',
|
||||||
$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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// return response()->json([
|
$invoice = DB::transaction(function () use ($validated, $client, $sqlAccCode, $payments) {
|
||||||
// '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([
|
$invoice = ClientInvoice::create([
|
||||||
'client_id' => $client?->id,
|
'client_id' => $client?->id,
|
||||||
'pending_sql_acc_code' => $client === null ? $sqlAccCode : null,
|
'pending_sql_acc_code' => $client === null ? $sqlAccCode : null,
|
||||||
'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null,
|
'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null,
|
||||||
'invoice_no' => $validated['invoice_no'],
|
'invoice_no' => $validated['invoice_no'],
|
||||||
// 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
|
'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,
|
'approved_at' => null,
|
||||||
// 'payment_no' => $validated['payment_no'] ?? null,
|
'total_sem_amount' => $validated['total_sem_amount'] ?? $this->paymentsGrossTotal($payments),
|
||||||
// 'start_date' => $validated['start_date'] ?? null,
|
'total_net_amount' => $validated['total_net_amount'] ?? $this->paymentsNetTotal($payments),
|
||||||
// 'end_date' => $validated['end_date'] ?? null,
|
|
||||||
// 'amount' => $mediaFee + $managementFee,
|
|
||||||
// 'total_spending' => $validated['total_spending'] ?? null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$invoice = $this->paymentSyncService->sync(
|
return $this->paymentSyncService->sync($invoice, $payments);
|
||||||
$invoice,
|
});
|
||||||
$validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice)
|
|
||||||
|
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([
|
private function semItemNetAmount(array $semItem, float $grossAmount, float $taxPercentage): float
|
||||||
// 'message' => 'Invoice created and marked for approval.',
|
{
|
||||||
// 'invoice' => $invoice->fresh('payments.items.billingItemType'),
|
$explicitNetAmount = data_get($semItem, 'net_amount')
|
||||||
// ], 201);
|
?? 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
|
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')
|
$sqlAccCode = data_get($paymentItem, 'item.item.sql_acc_code')
|
||||||
?? data_get($paymentItem, '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');
|
?? 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
|
private function paymentItemEstimatedTotal(array $paymentItem): float
|
||||||
|
|||||||
@ -2,37 +2,38 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use Google\Ads\GoogleAds\Lib\V22\GoogleAdsClientBuilder;
|
|
||||||
use Google\Ads\GoogleAds\Lib\OAuth2TokenBuilder;
|
use Google\Ads\GoogleAds\Lib\OAuth2TokenBuilder;
|
||||||
use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest;
|
use Google\Ads\GoogleAds\Lib\V22\GoogleAdsClientBuilder;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\CustomerStatusEnum\CustomerStatus;
|
use Google\Ads\GoogleAds\V22\Enums\AdGroupAdStatusEnum\AdGroupAdStatus;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus;
|
use Google\Ads\GoogleAds\V22\Enums\AdGroupCriterionStatusEnum\AdGroupCriterionStatus;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
|
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelSubTypeEnum\AdvertisingChannelSubType;
|
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AdGroupStatusEnum\AdGroupStatus;
|
use Google\Ads\GoogleAds\V22\Enums\AdGroupStatusEnum\AdGroupStatus;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AdGroupTypeEnum\AdGroupType;
|
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\AdTypeEnum\AdType;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\KeywordMatchTypeEnum\KeywordMatchType;
|
use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelSubTypeEnum\AdvertisingChannelSubType;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AdGroupCriterionStatusEnum\AdGroupCriterionStatus;
|
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\AssetSourceEnum\AssetSource;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType;
|
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\PolicyReviewStatusEnum\PolicyReviewStatus;
|
||||||
use Google\Ads\GoogleAds\V22\Enums\AssetLinkStatusEnum\AssetLinkStatus;
|
use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest;
|
||||||
use GPBMetadata\Google\Api\Log;
|
|
||||||
use Illuminate\Support\Facades\Log as FacadesLog;
|
use Illuminate\Support\Facades\Log as FacadesLog;
|
||||||
|
|
||||||
class GoogleAdsService
|
class GoogleAdsService
|
||||||
{
|
{
|
||||||
protected $oAuth2Credential;
|
protected $oAuth2Credential;
|
||||||
|
|
||||||
protected $developerToken;
|
protected $developerToken;
|
||||||
|
|
||||||
protected $loginCustomerId;
|
protected $loginCustomerId;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->oAuth2Credential = (new OAuth2TokenBuilder())
|
$this->oAuth2Credential = (new OAuth2TokenBuilder)
|
||||||
->withClientId(env('GOOGLE_ADS_CLIENT_ID'))
|
->withClientId(env('GOOGLE_ADS_CLIENT_ID'))
|
||||||
->withClientSecret(env('GOOGLE_ADS_CLIENT_SECRET'))
|
->withClientSecret(env('GOOGLE_ADS_CLIENT_SECRET'))
|
||||||
->withRefreshToken(env('GOOGLE_ADS_REFRESH_TOKEN'))
|
->withRefreshToken(env('GOOGLE_ADS_REFRESH_TOKEN'))
|
||||||
@ -47,7 +48,7 @@ public function __construct()
|
|||||||
*/
|
*/
|
||||||
protected function buildClient(string $customerId)
|
protected function buildClient(string $customerId)
|
||||||
{
|
{
|
||||||
return (new GoogleAdsClientBuilder())
|
return (new GoogleAdsClientBuilder)
|
||||||
->withDeveloperToken($this->developerToken)
|
->withDeveloperToken($this->developerToken)
|
||||||
->withOAuth2Credential($this->oAuth2Credential)
|
->withOAuth2Credential($this->oAuth2Credential)
|
||||||
->withLoginCustomerId($customerId)
|
->withLoginCustomerId($customerId)
|
||||||
@ -63,7 +64,7 @@ public function listAccounts(): array
|
|||||||
$client = $this->buildClient($this->loginCustomerId);
|
$client = $this->buildClient($this->loginCustomerId);
|
||||||
$service = $client->getGoogleAdsServiceClient();
|
$service = $client->getGoogleAdsServiceClient();
|
||||||
|
|
||||||
$query = <<<QUERY
|
$query = <<<'QUERY'
|
||||||
SELECT
|
SELECT
|
||||||
customer_client.id,
|
customer_client.id,
|
||||||
customer_client.client_customer,
|
customer_client.client_customer,
|
||||||
@ -110,7 +111,7 @@ public function listCampaigns(string $clientCustomerId): array
|
|||||||
$service = $client->getGoogleAdsServiceClient();
|
$service = $client->getGoogleAdsServiceClient();
|
||||||
$customerId = str_replace('-', '', $clientCustomerId);
|
$customerId = str_replace('-', '', $clientCustomerId);
|
||||||
|
|
||||||
$query = <<<QUERY
|
$query = <<<'QUERY'
|
||||||
SELECT
|
SELECT
|
||||||
campaign.id,
|
campaign.id,
|
||||||
campaign.name,
|
campaign.name,
|
||||||
@ -211,7 +212,7 @@ public function listCampaignsMetrics(string $clientCustomerId, string $campaignI
|
|||||||
// 'average_cpc' => round($row->getMetrics()->getAverageCpc() / 1000000, 2),
|
// 'average_cpc' => round($row->getMetrics()->getAverageCpc() / 1000000, 2),
|
||||||
'conversions' => $row->getMetrics()->getConversions(),
|
'conversions' => $row->getMetrics()->getConversions(),
|
||||||
'conversions_value' => $row->getMetrics()->getConversionsValue(),
|
'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),
|
// 'conversions_from_interactions_rate' => round($row->getMetrics()->getConversionsFromInteractionsRate() * 100, 2),
|
||||||
'interactions' => $row->getMetrics()->getInteractions(),
|
'interactions' => $row->getMetrics()->getInteractions(),
|
||||||
'interaction_rate' => number_format($row->getMetrics()->getInteractionRate() * 100, 2, '.', ''),
|
'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()
|
// 'view_through_conversions' => $row->getMetrics()->getViewThroughConversions()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $metrics;
|
return $metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,7 +229,6 @@ public function listCampaignsMetricsById(string $clientCustomerId, string $campa
|
|||||||
$client = $this->buildClient($this->loginCustomerId);
|
$client = $this->buildClient($this->loginCustomerId);
|
||||||
$service = $client->getGoogleAdsServiceClient();
|
$service = $client->getGoogleAdsServiceClient();
|
||||||
$customerId = str_replace('-', '', $clientCustomerId);
|
$customerId = str_replace('-', '', $clientCustomerId);
|
||||||
|
|
||||||
if (empty($startDate)) {
|
if (empty($startDate)) {
|
||||||
$startDate = date('Y-m-d');
|
$startDate = date('Y-m-d');
|
||||||
}
|
}
|
||||||
@ -235,7 +236,6 @@ public function listCampaignsMetricsById(string $clientCustomerId, string $campa
|
|||||||
if (empty($endDate)) {
|
if (empty($endDate)) {
|
||||||
$endDate = date('Y-m-d');
|
$endDate = date('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = <<<QUERY
|
$query = <<<QUERY
|
||||||
SELECT
|
SELECT
|
||||||
campaign.id,
|
campaign.id,
|
||||||
@ -281,6 +281,7 @@ public function listCampaignsMetricsById(string $clientCustomerId, string $campa
|
|||||||
'interaction_rate' => round($row->getMetrics()->getInteractionRate() * 100, 2),
|
'interaction_rate' => round($row->getMetrics()->getInteractionRate() * 100, 2),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $metrics;
|
return $metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -370,7 +371,7 @@ public function listAdsByAdGroupId(string $clientCustomerId, string $adGroupId):
|
|||||||
'status' => AdGroupAdStatus::name($adGroupAd->getStatus()),
|
'status' => AdGroupAdStatus::name($adGroupAd->getStatus()),
|
||||||
'approval_status' => PolicyApprovalStatus::name($adGroupAd->getPolicySummary()->getApprovalStatus()),
|
'approval_status' => PolicyApprovalStatus::name($adGroupAd->getPolicySummary()->getApprovalStatus()),
|
||||||
'final_urls' => iterator_to_array($ad->getFinalUrls()),
|
'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);
|
$response = $service->search($request);
|
||||||
$assets = [];
|
$assets = [];
|
||||||
|
|
||||||
|
|
||||||
foreach ($response->iterateAllElements() as $row) {
|
foreach ($response->iterateAllElements() as $row) {
|
||||||
$asset = $row->getAsset();
|
$asset = $row->getAsset();
|
||||||
$ca = $row->getCampaignAsset();
|
$ca = $row->getCampaignAsset();
|
||||||
@ -438,7 +438,6 @@ public function listAssetsByCampaignId(string $clientCustomerId, string $campaig
|
|||||||
return $assets;
|
return $assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function listKeywordsByAdGroupId(string $clientCustomerId, string $adGroupId): array
|
public function listKeywordsByAdGroupId(string $clientCustomerId, string $adGroupId): array
|
||||||
{
|
{
|
||||||
$customerId = str_replace('-', '', $clientCustomerId);
|
$customerId = str_replace('-', '', $clientCustomerId);
|
||||||
@ -477,14 +476,13 @@ public function listKeywordsByAdGroupId(string $clientCustomerId, string $adGrou
|
|||||||
'match_type' => KeywordMatchType::name($keywordInfo->getMatchType()),
|
'match_type' => KeywordMatchType::name($keywordInfo->getMatchType()),
|
||||||
'status' => AdGroupCriterionStatus::name($criterion->getStatus()),
|
'status' => AdGroupCriterionStatus::name($criterion->getStatus()),
|
||||||
'cpc_bid' => round($criterion->getCpcBidMicros() / 1000000, 2),
|
'cpc_bid' => round($criterion->getCpcBidMicros() / 1000000, 2),
|
||||||
'ad_group_id' => $row->getAdGroup()->getId()
|
'ad_group_id' => $row->getAdGroup()->getId(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $keywords;
|
return $keywords;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function listAdGroupMetrics(string $dateFrom, string $dateTo): array
|
public function listAdGroupMetrics(string $dateFrom, string $dateTo): array
|
||||||
{
|
{
|
||||||
$client = $this->buildClient($this->loginCustomerId);
|
$client = $this->buildClient($this->loginCustomerId);
|
||||||
@ -644,7 +642,7 @@ public function getAdGroupMetricsById(string $clientCustomerId, string $adGroupI
|
|||||||
'conversion_value' => $m->getConversionsValue(),
|
'conversion_value' => $m->getConversionsValue(),
|
||||||
'ctr' => $m->getImpressions() > 0
|
'ctr' => $m->getImpressions() > 0
|
||||||
? round(($m->getClicks() / $m->getImpressions()) * 100, 2)
|
? round(($m->getClicks() / $m->getImpressions()) * 100, 2)
|
||||||
: 0
|
: 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -701,7 +699,7 @@ public function getAdMetricsById(string $clientCustomerId, string $adId, string
|
|||||||
'clicks' => $m->getClicks(),
|
'clicks' => $m->getClicks(),
|
||||||
'spend' => round($m->getCostMicros() / 1000000, 2),
|
'spend' => round($m->getCostMicros() / 1000000, 2),
|
||||||
'conversions' => $m->getConversions(),
|
'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(),
|
'clicks' => $m->getClicks(),
|
||||||
'cost' => round($m->getCostMicros() / 1000000, 2),
|
'cost' => round($m->getCostMicros() / 1000000, 2),
|
||||||
'conversions' => $m->getConversions(),
|
'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();
|
$service = $client->getGoogleAdsServiceClient();
|
||||||
|
|
||||||
// Querying 'customer' gives you specific settings for that ID
|
// Querying 'customer' gives you specific settings for that ID
|
||||||
$query = "SELECT
|
$query = 'SELECT
|
||||||
customer.id,
|
customer.id,
|
||||||
customer.descriptive_name,
|
customer.descriptive_name,
|
||||||
customer.currency_code,
|
customer.currency_code,
|
||||||
@ -782,7 +780,7 @@ public function getAccountDetails(string $customerId): array
|
|||||||
customer.manager,
|
customer.manager,
|
||||||
customer.test_account,
|
customer.test_account,
|
||||||
customer.status
|
customer.status
|
||||||
FROM customer";
|
FROM customer';
|
||||||
|
|
||||||
// When querying the 'customer' resource, the customer_id in the request
|
// When querying the 'customer' resource, the customer_id in the request
|
||||||
// must match the ID you are querying.
|
// must match the ID you are querying.
|
||||||
@ -814,6 +812,4 @@ public function getAccountDetails(string $customerId): array
|
|||||||
'tracking_template' => $customer->getTrackingUrlTemplate(),
|
'tracking_template' => $customer->getTrackingUrlTemplate(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
useMantineTheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -223,12 +224,14 @@ const getInvoiceSpending = (invoice: ClientInvoice): number =>
|
|||||||
parseNumber(invoice.total_sem_amount) ||
|
parseNumber(invoice.total_sem_amount) ||
|
||||||
parseNumber(invoice.total_spend);
|
parseNumber(invoice.total_spend);
|
||||||
|
|
||||||
const getBillableInvoiceSpending = (invoice: ClientInvoice): number =>
|
const getCreditCardMediaSpending = (invoice: ClientInvoice): number =>
|
||||||
getInvoiceItems(invoice).reduce(
|
getInvoiceItems(invoice).reduce((sum, item) => {
|
||||||
(sum, item) =>
|
if (item.billing_item_types_id !== 1 || !item.is_creditcard) {
|
||||||
item.is_creditcard ? sum : sum + parseNumber(item.spending),
|
return sum;
|
||||||
0,
|
}
|
||||||
);
|
|
||||||
|
return sum + parseNumber(item.spending);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const getBillableNettAmount = (invoice: ClientInvoice): number =>
|
const getBillableNettAmount = (invoice: ClientInvoice): number =>
|
||||||
getInvoiceItems(invoice).reduce((sum, item) => {
|
getInvoiceItems(invoice).reduce((sum, item) => {
|
||||||
@ -258,6 +261,16 @@ const getInvoiceIsCreditCard = (invoice: ClientInvoice): boolean => {
|
|||||||
return items.length > 0 && items.every((item) => item.is_creditcard);
|
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 buildInvoiceTree = (invoices: ClientInvoice[]): InvoiceRow[] => {
|
||||||
const nodes: InvoiceRow[] = invoices.map((invoice) => ({
|
const nodes: InvoiceRow[] = invoices.map((invoice) => ({
|
||||||
...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 => {
|
const getInvoiceAmount = (invoice: ClientInvoice): number => {
|
||||||
return parseNumber(invoice.total_sem_amount);
|
return parseNumber(invoice.total_sem_amount);
|
||||||
};
|
};
|
||||||
@ -378,6 +377,30 @@ const getPaymentSpending = (payment: ClientInvoicePayment): number =>
|
|||||||
0,
|
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 = (
|
const minDateString = (
|
||||||
values: Array<string | null | undefined>,
|
values: Array<string | null | undefined>,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
@ -646,8 +669,7 @@ export default function TicketDetails({
|
|||||||
total_impressions: parseNumber(
|
total_impressions: parseNumber(
|
||||||
campaign.total_impressions,
|
campaign.total_impressions,
|
||||||
),
|
),
|
||||||
total_actual_spend:
|
total_actual_spend: String(
|
||||||
String(
|
|
||||||
campaign.total_actual_spend ??
|
campaign.total_actual_spend ??
|
||||||
summaryDefaults.total_actual_spend,
|
summaryDefaults.total_actual_spend,
|
||||||
),
|
),
|
||||||
@ -798,10 +820,14 @@ export default function TicketDetails({
|
|||||||
return sum + getInvoiceSpending(invoice);
|
return sum + getInvoiceSpending(invoice);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const billableInvoiceSpending = invoicesData.reduce(
|
const creditCardMediaSpending = invoicesData.reduce(
|
||||||
(sum, invoice) => sum + getBillableInvoiceSpending(invoice),
|
(sum, invoice) => sum + getCreditCardMediaSpending(invoice),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const billableInvoiceSpending = Math.max(
|
||||||
|
0,
|
||||||
|
parseNumber(lifeTimeSpending) - creditCardMediaSpending,
|
||||||
|
);
|
||||||
|
|
||||||
const nettAmountBase = invoicesData.reduce(
|
const nettAmountBase = invoicesData.reduce(
|
||||||
(sum, invoice) => sum + getBillableNettAmount(invoice),
|
(sum, invoice) => sum + getBillableNettAmount(invoice),
|
||||||
@ -809,10 +835,11 @@ export default function TicketDetails({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nettAmount = nettAmountBase + adjustmentNet;
|
const nettAmount = nettAmountBase + adjustmentNet;
|
||||||
const remainingAmount = Math.max(
|
const remainingAmount = getInvoicesMediaItemsAreAllCreditCard(
|
||||||
0,
|
invoicesData,
|
||||||
nettAmount - billableInvoiceSpending,
|
)
|
||||||
);
|
? 0
|
||||||
|
: Math.max(0, nettAmount - billableInvoiceSpending);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
managementFee: invoicesData.reduce(
|
managementFee: invoicesData.reduce(
|
||||||
@ -829,7 +856,7 @@ export default function TicketDetails({
|
|||||||
nettAmount,
|
nettAmount,
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
};
|
};
|
||||||
}, [clientInvoices, clientAdjustments]);
|
}, [clientInvoices, clientAdjustments, lifeTimeSpending]);
|
||||||
|
|
||||||
const groupedInvoices = useMemo(
|
const groupedInvoices = useMemo(
|
||||||
() => buildInvoiceTree(clientInvoices ?? []),
|
() => 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>[]>(
|
const adjustmentColumns = useMemo<MRT_ColumnDef<AdjustmentRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -1871,29 +1763,7 @@ export default function TicketDetails({
|
|||||||
enablePagination
|
enablePagination
|
||||||
enableExpanding
|
enableExpanding
|
||||||
renderDetailPanel={({ row }) => (
|
renderDetailPanel={({ row }) => (
|
||||||
<MantineReactTable
|
<InvoicePaymentDetails invoice={row.original} />
|
||||||
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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
initialState={{
|
initialState={{
|
||||||
density: 'xs',
|
density: 'xs',
|
||||||
@ -2078,7 +1948,9 @@ export default function TicketDetails({
|
|||||||
<ActivityForm
|
<ActivityForm
|
||||||
status={activityFormMode}
|
status={activityFormMode}
|
||||||
projectId={localClientId}
|
projectId={localClientId}
|
||||||
activity={(selectedActivity as any) ?? undefined}
|
activity={
|
||||||
|
(selectedActivity as any) ?? undefined
|
||||||
|
}
|
||||||
activityCategories={[]}
|
activityCategories={[]}
|
||||||
unassignedTickets={[]}
|
unassignedTickets={[]}
|
||||||
formAction={
|
formAction={
|
||||||
@ -2145,3 +2017,351 @@ function AccountStatCard({ label, value, icon }: AccountStatCardProps) {
|
|||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -15,3 +15,15 @@
|
|||||||
Schedule::command('project-activities:pending-notify')
|
Schedule::command('project-activities:pending-notify')
|
||||||
->dailyAt('16:00')
|
->dailyAt('16:00')
|
||||||
->withoutOverlapping();
|
->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();
|
||||||
|
|||||||
135
tests/Feature/RepairLinkedInvoicePaymentItemsTest.php
Normal file
135
tests/Feature/RepairLinkedInvoicePaymentItemsTest.php
Normal 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);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user