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;
|
||||
|
||||
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.',
|
||||
]);
|
||||
}
|
||||
|
||||
$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,
|
||||
'approved_at' => null,
|
||||
'total_sem_amount' => $validated['total_sem_amount'] ?? $this->paymentsGrossTotal($payments),
|
||||
'total_net_amount' => $validated['total_net_amount'] ?? $this->paymentsNetTotal($payments),
|
||||
]);
|
||||
|
||||
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 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 = 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),
|
||||
'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,
|
||||
]);
|
||||
|
||||
$invoice = $this->paymentSyncService->sync(
|
||||
$invoice,
|
||||
$validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice)
|
||||
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
|
||||
|
||||
@ -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,
|
||||
@ -191,7 +192,7 @@ public function listCampaignsMetrics(string $clientCustomerId, string $campaignI
|
||||
'query' => $query,
|
||||
]);
|
||||
$response = $service->search($request);
|
||||
FacadesLog::info('Google Ads Query', [
|
||||
FacadesLog::info('Google Ads Query', [
|
||||
'query' => $query,
|
||||
'customer_id' => $customerId,
|
||||
]);
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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,11 +669,10 @@ export default function TicketDetails({
|
||||
total_impressions: parseNumber(
|
||||
campaign.total_impressions,
|
||||
),
|
||||
total_actual_spend:
|
||||
String(
|
||||
campaign.total_actual_spend ??
|
||||
summaryDefaults.total_actual_spend,
|
||||
),
|
||||
total_actual_spend: String(
|
||||
campaign.total_actual_spend ??
|
||||
summaryDefaults.total_actual_spend,
|
||||
),
|
||||
metrics: normalizeCampaignMetrics(campaign.metrics),
|
||||
}),
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
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