feat: changes to invoice and payment details

This commit is contained in:
brian-inspiren 2026-06-18 16:05:12 +08:00
parent 688ac63c1c
commit d9eaaeaa92
27 changed files with 4623 additions and 1388 deletions

View File

@ -0,0 +1,121 @@
<?php
namespace App\Console\Commands;
use App\Models\ClientInvoicePaymentItem;
use App\Services\GoogleAdsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CalculateClientInvoicePaymentItemSpending extends Command
{
protected $signature = 'customer:calculate-invoice-item-spending {--dry-run : Calculate without saving changes}';
protected $description = 'Calculate client invoice payment item spending from Google Ads spend by item date range.';
public function handle(GoogleAdsService $adsService): int
{
$dryRun = (bool) $this->option('dry-run');
$spendCache = [];
$updated = 0;
$skipped = 0;
$failed = 0;
ClientInvoicePaymentItem::query()
->with('payment.invoice.client')
->whereNotNull('start_date')
->whereNotNull('end_date')
->orderBy('id')
->chunkById(50, function ($items) use ($adsService, $dryRun, &$spendCache, &$updated, &$skipped, &$failed) {
foreach ($items as $item) {
$client = $item->payment?->invoice?->client;
if ($client === null || empty($client->customer_id)) {
$skipped++;
$this->warn("Skipping item {$item->id}: missing client/customer ID.");
continue;
}
$startDate = $item->start_date?->format('Y-m-d');
$endDate = $item->end_date?->format('Y-m-d');
if ($startDate === null || $endDate === null) {
$skipped++;
continue;
}
$cacheKey = implode('|', [$client->customer_id, $startDate, $endDate]);
try {
if (! array_key_exists($cacheKey, $spendCache)) {
$spendCache[$cacheKey] = $this->spendForDateRange(
$adsService,
$client->customer_id,
$startDate,
$endDate,
);
}
$spending = $spendCache[$cacheKey];
if (! $dryRun) {
$item->forceFill([
'spending' => $spending,
])->save();
}
$updated++;
$this->line(sprintf(
'%s item %d: RM %.2f (%s to %s)',
$dryRun ? 'Calculated' : 'Updated',
$item->id,
$spending,
$startDate,
$endDate,
));
} catch (\Throwable $exception) {
$failed++;
Log::error('Unable to calculate invoice payment item spending.', [
'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 spendForDateRange(
GoogleAdsService $adsService,
string $customerId,
string $startDate,
string $endDate,
): float {
$campaigns = $adsService->listCampaigns($customerId);
$spending = 0.0;
foreach ($campaigns as $campaign) {
$metrics = $adsService->listCampaignsMetricsById(
$customerId,
(string) $campaign['id'],
$startDate,
$endDate,
);
$spending += array_sum(array_map(
fn (array $metric): float => (float) ($metric['actual_spend'] ?? 0),
$metrics,
));
}
return round($spending, 6);
}
}

View File

@ -3,42 +3,73 @@
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\Customers;
use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation;
use App\Models\User;
use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientInvoicePaymentSyncService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
use Rap2hpoutre\FastExcel\FastExcel;
use Carbon\Carbon;
class CreateClientInvoice extends Command
{
protected $signature = 'customer:create-invoice';
protected $description = 'Create client invoice';
public function handle()
{
$adsService = new GoogleAdsService();
$approvalService = new ClientInvoiceApprovalService();
$paymentSyncService = app(ClientInvoicePaymentSyncService::class);
try {
DB::beginTransaction();
$collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_EJ.csv'));
// $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_HJ.csv'));
// $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_K.csv'));
$array = $collection->toArray();
foreach ($array as $row) {
$startDate = Carbon::parse($row['start_date'])->format('Y-m-d');
$endDate = Carbon::parse($row['end_date'])->format('Y-m-d');
$client = Client::where('customer_id', str_replace('-', '', $row['customer_id']))->first();
if ($client) {
$linkedInvoices = [];
$linkedInvoiceTargets = $this->linkedInvoiceTargets($array);
foreach ($this->groupRowsByInvoice($array) as $invoiceNo => $invoiceRows) {
$invoiceClient = null;
$linkedInvoiceNo = null;
$payments = [];
$totalSemAmount = 0.0;
$totalNetAmount = 0.0;
foreach ($invoiceRows as $row) {
$customerId = str_replace('-', '', (string) $this->rowValue($row, 'customer_id', ''));
$startDate = $this->date($this->rowValue($row, 'start_date'));
$endDate = $this->date($this->rowValue($row, 'end_date'));
$client = Client::where('customer_id', $customerId)->first();
if (! $client) {
Log::warning('Client not found for customer_id: '.$customerId, [
'invoice_no' => $invoiceNo,
]);
continue;
}
if ($invoiceClient !== null && $invoiceClient->isNot($client)) {
Log::warning('Invoice rows resolve to different clients; row skipped.', [
'invoice_no' => $invoiceNo,
'expected_client_id' => $invoiceClient->id,
'row_client_id' => $client->id,
]);
continue;
}
$invoiceClient ??= $client;
$client->update([
'industry' => $row['industry'],
'industry' => $this->rowValue($row, 'industry'),
]);
$salesUser = User::where('name', $row['sales'])->first();
$pic = User::where('name', $row['pic'])->first();
$salesUser = User::where('name', $this->rowValue($row, 'sales'))->first();
$pic = User::where('name', $this->rowValue($row, 'pic'))->first();
if ($pic) {
ClientUserAssignation::updateOrCreate(
[
@ -61,65 +92,66 @@ public function handle()
]
);
}
$row['client_id'] = $client->id;
// if ($client->status != 'CANCELED') {
// $campaigns = $adsService->listCampaigns($row['customer_id']);
// Log::info('Hydrated client data', [
// 'campaigns' => $campaigns,
// ]);
// foreach ($campaigns as $campaign) {
// Log::info('Hydrated client data', [
// 'campaigns' => $campaign['id'],
// ]);
// if (empty($invoice->start_date) || empty($invoice->end_date)) {
// $totalSpend = 0;
// $spend += number_format($totalSpend, 2, '.', '');
// } else {
// $metrics = $adsService->listCampaignsMetricsById(
// $row['customer_id'],
// $campaign['id'],
// $startDate,
// $endDate
// );
// Log::info('Hydrated client data', [
// 'metrics' => $metrics,
// ]);
// $totalSpend = array_sum(array_column($metrics, 'actual_spend'));
// $spend += number_format($totalSpend, 2, '.', '');
// }
// }
// } else {
$spend = 0;
// }
$managementFee = intval(str_replace(',', '', $row['management_fee'])) ?? 0;
$mediaFee = intval(str_replace(',', '', $row['media_fee'])) ?? 0;
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0;
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0;
$rowLinkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', ''));
$managementFee = $this->amount($this->rowValue($row, 'management_fee', 0));
$mediaFee = $this->amount($this->rowValue($row, 'media_fee', 0));
$paymentNettAmount = $mediaFee + $managementFee;
$tax = $this->tax($this->rowValue($row, 'tax', 0), $paymentNettAmount, $startDate);
$paymentTotalAmount = $paymentNettAmount + $tax['amount'];
$isCreditCard = $mediaFee == 0 && ! in_array($invoiceNo, $linkedInvoiceTargets, true);
$invoice = ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']],
[
'client_id' => $row['client_id'],
'is_credit_card' => $mediaFee == 0 ? 1 : 0,
'start_date' => $startDate,
'end_date' => $endDate,
'management_fee' => $managementFee,
'management_fee_amount' => $managementFeeAmount,
'management_fee_tax' => $managementFee - $managementFeeAmount,
'media_fee' => $mediaFee,
'media_fee_amount' => $mediaFeeAmount,
'media_fee_tax' => $mediaFee - $mediaFeeAmount,
'tax_percent' => 8,
'nett_amount' => $mediaFeeAmount,
'total_spending' => $spend,
]
);
$payments[] = [
'payment_no' => $this->nullableString($this->rowValue($row, 'payment_no')),
'payment_total_amount' => $paymentTotalAmount,
'payment_nett_amount' => $paymentNettAmount,
'items' => $this->paymentItems(
$mediaFee,
$managementFee,
$tax['percentage'],
$startDate,
$endDate,
$isCreditCard,
),
];
$approvalService->approve($invoice);
} else {
Log::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id']));
continue; // Skip this row if client not found
$totalSemAmount += $paymentTotalAmount;
$totalNetAmount += $paymentNettAmount;
if ($rowLinkedInvoiceNo !== null) {
$linkedInvoiceNo ??= $rowLinkedInvoiceNo;
}
}
if ($invoiceClient === null || $payments === []) {
continue;
}
$invoice = ClientInvoice::updateOrCreate(
['invoice_no' => $invoiceNo],
[
'client_id' => $invoiceClient->id,
'approved_at' => now(),
'total_sem_amount' => $totalSemAmount,
'total_net_amount' => $totalNetAmount,
]
);
$paymentSyncService->sync($invoice, $payments);
if ($linkedInvoiceNo !== null) {
$linkedInvoices[$invoiceNo] = $linkedInvoiceNo;
}
}
foreach ($linkedInvoices as $invoiceNo => $linkedInvoiceNo) {
$invoice = ClientInvoice::where('invoice_no', $invoiceNo)->first();
$linkedInvoice = ClientInvoice::where('invoice_no', $linkedInvoiceNo)->first();
if ($invoice && $linkedInvoice) {
$invoice->update([
'linked_invoice_id' => $linkedInvoice->id,
]);
}
}
@ -129,7 +161,217 @@ public function handle()
Log::error('Error project linkage : '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
return 0;
}
private function rowValue(array $row, string $key, mixed $default = null): mixed
{
$normalizedKey = $this->normalizeHeader($key);
foreach ($row as $rowKey => $value) {
if ($this->normalizeHeader((string) $rowKey) === $normalizedKey) {
return $value;
}
}
return $default;
}
private function normalizeHeader(string $header): string
{
return trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($header)), '_');
}
private function nullableString(mixed $value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function amount(mixed $value): float
{
$normalized = preg_replace('/[^0-9.\-]/', '', (string) $value);
return is_numeric($normalized) ? (float) $normalized : 0.0;
}
private function invoiceNo(mixed $value): string
{
$invoiceNo = trim((string) $value);
if (preg_match('/^\s*([A-Za-z0-9]+)/', $invoiceNo, $matches)) {
return $matches[1];
}
return $invoiceNo;
}
private function linkedInvoiceNo(mixed $value): ?string
{
$linkedInvoiceNo = $this->invoiceNo($value);
if ($linkedInvoiceNo === '' || $linkedInvoiceNo === '0') {
return null;
}
return $linkedInvoiceNo;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, array<int, array<string, mixed>>>
*/
private function groupRowsByInvoice(array $rows): array
{
$groupedRows = [];
foreach ($rows as $row) {
$invoiceNo = $this->invoiceNo($this->rowValue($row, 'invoice_no'));
if ($invoiceNo === '') {
Log::warning('Invoice row skipped because invoice_no is empty.');
continue;
}
$groupedRows[$invoiceNo][] = $row;
}
return $groupedRows;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, string>
*/
private function linkedInvoiceTargets(array $rows): array
{
$invoiceNumbers = [];
foreach ($rows as $row) {
$linkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', ''));
if ($linkedInvoiceNo !== null) {
$invoiceNumbers[] = $linkedInvoiceNo;
}
}
return array_values(array_unique($invoiceNumbers));
}
private function date(mixed $value): ?Carbon
{
if (empty($value)) {
return null;
}
$date = trim((string) $value);
foreach (['d/m/y', 'd/m/Y', 'Y-m-d', 'd-m-y', 'd-m-Y'] as $format) {
try {
return Carbon::createFromFormat($format, $date)->startOfDay();
} catch (\Throwable) {
continue;
}
}
return Carbon::parse($date)->startOfDay();
}
/**
* @return array{percentage: float, amount: float}
*/
private function tax(mixed $value, float $amount, ?Carbon $startDate): array
{
$tax = $this->amount($value);
if ($tax > 100 && $amount > 0) {
return [
'percentage' => ($tax / $amount) * 100,
'amount' => $tax,
];
}
if ($tax > 0) {
return [
'percentage' => $tax,
'amount' => $this->taxAmount($amount, $tax),
];
}
if ($startDate !== null && $startDate->greaterThanOrEqualTo(Carbon::parse('2024-03-01'))) {
return [
'percentage' => 8,
'amount' => $this->taxAmount($amount, 8),
];
}
return [
'percentage' => 6,
'amount' => $this->taxAmount($amount, 6),
];
}
private function taxAmount(float $amount, float $taxPercentage): float
{
return $amount * ($taxPercentage / 100);
}
/**
* @return array<int, array<string, mixed>>
*/
private function paymentItems(
float $mediaFee,
float $managementFee,
float $taxPercentage,
?Carbon $startDate,
?Carbon $endDate,
bool $isCreditCard,
): array {
$items = [];
if ($mediaFee > 0 || $isCreditCard) {
$mediaTax = $this->taxAmount($mediaFee, $taxPercentage);
$items[] = [
'billing_item_types_id' => 1,
'start_date' => $startDate?->format('Y-m-d'),
'end_date' => $endDate?->format('Y-m-d'),
'payment_item_amount' => $mediaFee + $mediaTax,
'tax_percentage' => $taxPercentage,
'net_amount' => $mediaFee,
'withholding_tax' => $taxPercentage,
'final_net_amount' => $this->finalNetAmount($mediaFee, $taxPercentage),
'spending' => 0,
'is_creditcard' => $isCreditCard,
];
}
if ($managementFee > 0) {
$managementTax = $this->taxAmount($managementFee, $taxPercentage);
$items[] = [
'billing_item_types_id' => 2,
'start_date' => null,
'end_date' => null,
'payment_item_amount' => $managementFee + $managementTax,
'tax_percentage' => $taxPercentage,
'net_amount' => $managementFee,
'withholding_tax' => 0,
'final_net_amount' => $this->finalNetAmount($managementFee, 0),
'spending' => 0,
'is_creditcard' => false,
];
}
return $items;
}
private function finalNetAmount(float $netAmount, float $withholdingTax): float
{
return $netAmount / (1 + ($withholdingTax / 100));
}
}

View File

@ -4,8 +4,9 @@
use App\Http\Controllers\Controller;
use App\Models\ClientInvoice;
use App\Services\ClientLookupService;
use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientInvoicePaymentSyncService;
use App\Services\ClientLookupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
@ -16,6 +17,7 @@ class ClientInvoiceController extends Controller
{
public function __construct(
private ClientInvoiceApprovalService $approvalService,
private ClientInvoicePaymentSyncService $paymentSyncService,
private ClientLookupService $clientLookupService,
) {
}
@ -23,7 +25,7 @@ public function __construct(
public function pending(): JsonResponse
{
$invoices = ClientInvoice::query()
->with('client:id,name,customer_id')
->with('client:id,name,customer_id', 'payments.items.billingItemType')
->whereNull('approved_at')
->latest('id')
->get([
@ -32,17 +34,23 @@ 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',
]);
@ -68,26 +76,25 @@ public function store(Request $request): JsonResponse
'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'],
'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'management_fee' => ['required', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'payments' => ['nullable', 'array', 'min:1'],
'invoice' => ['nullable', 'array'],
'nett_amount' => ['nullable', 'numeric', 'min:0'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
'sem_invoice_items' => ['nullable', 'array'],
'sem_items' => ['nullable', 'array'],
]);
$mediaFee = $validated['media_fee'];
// 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.');
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
// 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'])
@ -106,35 +113,66 @@ 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,
];
}
}
// 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,
// 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
'is_paid' => (bool) ($validated['is_paid'] ?? 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,
'management_fee' => $validated['management_fee'],
'management_fee_amount' => $validated['management_fee_amount'] ?? null,
'management_fee_tax' => $validated['management_fee_tax'] ?? null,
'media_fee' => $validated['media_fee'],
'media_fee_amount' => $validated['media_fee_amount'] ?? null,
'media_fee_tax' => $validated['media_fee_tax'] ?? null,
'tax_percent' => null,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? 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,
]);
$this->approvalService->requireApproval($invoice);
$invoice = $this->paymentSyncService->sync(
$invoice,
$validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice)
);
return response()->json([
'message' => 'Invoice created and marked for approval.',
'invoice' => $invoice->fresh(),
], 201);
// $this->approvalService->requireApproval($invoice);
// return response()->json([
// 'message' => 'Invoice created and marked for approval.',
// 'invoice' => $invoice->fresh('payments.items.billingItemType'),
// ], 201);
}
public function approve(ClientInvoice $invoice): JsonResponse
@ -313,4 +351,11 @@ private function invoiceBillingTotalsFromPayments(array $payments): array
'management_fee' => $payment['invoice_management_fee'] ?? 0,
];
}
private function netFromGross(float $grossAmount, float $taxPercent): float
{
return $taxPercent > 0
? $grossAmount / (1 + ($taxPercent / 100))
: $grossAmount;
}
}

View File

@ -36,7 +36,7 @@ public function store(Request $request, Client $client)
]);
return redirect()
->back()
->route('google-ads.accounts.show', ['id' => $client->customer_id])
->with('message-info', 'Adjustment added successfully.');
}
@ -44,10 +44,11 @@ public function destroy(ClientInvoiceAdjustment $adjustment)
{
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403);
$client = $adjustment->client;
$adjustment->delete();
return redirect()
->back()
->route('google-ads.accounts.show', ['id' => $client->customer_id])
->with('message-info', 'Adjustment deleted successfully.');
}
}

View File

@ -2,29 +2,32 @@
namespace App\Http\Controllers;
use App\Models\BillingItemType;
use App\Models\Client;
use App\Models\ClientCustomer;
use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation;
use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientInvoicePaymentSyncService;
use App\Services\ClientLookupService;
use App\Services\UserHierarchyService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ClientInvoiceController extends Controller
{
public function __construct(
private ClientInvoiceApprovalService $approvalService,
private ClientInvoicePaymentSyncService $paymentSyncService,
private UserHierarchyService $hierarchyService,
private ClientLookupService $clientLookupService,
) {
}
) {}
public function create(Request $request): Response
{
@ -47,23 +50,33 @@ public function create(Request $request): Response
'clientId' => $clientId,
'customerId' => $customerId,
'availableInvoices' => $availableInvoices,
'billingItemTypes' => $this->billingItemTypesForForm(),
]);
}
public function edit(ClientInvoice $invoice): Response
{
abort_if($invoice->client === null, 409, 'Create the client before editing this invoice.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$invoice->load('client');
$resolvedClient = $invoice->client
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
if ($resolvedClient !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $resolvedClient), 403);
}
$availableInvoices = ClientInvoice::query()
->where('client_id', $invoice->client_id)
->where('id', '!=', $invoice->id)
->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']);
$availableInvoices = $resolvedClient === null
? collect()
: ClientInvoice::query()
->where('client_id', $resolvedClient->id)
->where('id', '!=', $invoice->id)
->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']);
return Inertia::render('client-invoices/edit', [
'invoice' => $invoice->load('client'),
'invoice' => $invoice->load('client', 'payments.items.billingItemType'),
'availableInvoices' => $availableInvoices,
'billingItemTypes' => $this->billingItemTypesForForm(),
'existingClient' => $invoice->client === null ? $resolvedClient : null,
'unlinkedClients' => $this->clientOptions(),
]);
}
@ -71,7 +84,7 @@ public function store(Request $request)
{
$validated = $request->validate([
'client_id' => ['required', 'exists:clients,id'],
'customer_id' => ['required', 'string'],
'customer_id' => ['nullable', 'string'],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [
'nullable',
@ -80,117 +93,274 @@ public function store(Request $request)
return $query->where('client_id', $request->integer('client_id'));
}),
],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'management_fee' => ['required', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
...$this->paymentValidationRules(),
]);
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$mediaFee = $validated['media_fee'];
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
$invoice = DB::transaction(function () use ($validated) {
$invoice = ClientInvoice::create([
'client_id' => $validated['client_id'],
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'approved_at' => null,
'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
'total_net_amount' => $validated['total_net_amount'] ?? 0,
]);
$invoice = ClientInvoice::create([
'client_id' => $validated['client_id'],
'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,
'management_fee' => $validated['management_fee'],
'management_fee_amount' => $validated['management_fee_amount'] ?? null,
'management_fee_tax' => $validated['management_fee_tax'] ?? null,
'media_fee' => $validated['media_fee'],
'media_fee_amount' => $validated['media_fee_amount'] ?? null,
'media_fee_tax' => $validated['media_fee_tax'] ?? null,
'tax_percent' => $taxPercent,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
]);
return $this->paymentSyncService->sync(
$invoice,
$this->paymentsPayload($validated, $invoice)
);
});
$this->approvalService->approve($invoice);
return redirect()
->route('google-ads.accounts.show', ['id' => $validated['customer_id']])
->route('google-ads.accounts.show', ['id' => $validated['customer_id'] ?? $client->customer_id])
->with('message-info', 'Invoice created successfully.');
}
public function update(Request $request, ClientInvoice $invoice)
{
abort_if($invoice->client === null, 409, 'Create the client before updating this invoice.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$invoice->load('client');
$resolvedClient = $invoice->client
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
if ($resolvedClient !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $resolvedClient), 403);
}
$validated = $request->validate([
'client_id' => [
'required',
'integer',
'exists:clients,id',
],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [
'nullable',
'integer',
Rule::exists('client_invoices', 'id')->where(function ($query) use ($invoice) {
Rule::exists('client_invoices', 'id')->where(function ($query) use ($request, $invoice) {
return $query
->where('client_id', $invoice->client_id)
->where('client_id', $request->integer('client_id') ?: $invoice->client_id)
->where('id', '!=', $invoice->id);
}),
],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'],
...$this->paymentValidationRules(),
]);
$invoice = DB::transaction(function () use ($invoice, $validated) {
if ($invoice->client_id === null) {
$this->linkInvoiceClient($invoice, $validated);
$invoice->refresh();
} elseif ($invoice->client_id !== (int) $validated['client_id']) {
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$invoice->update([
'client_id' => $client->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
'linked_invoice_id' => null,
]);
$invoice->refresh();
}
$invoice->update([
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
'total_net_amount' => $validated['total_net_amount'] ?? 0,
]);
return $this->paymentSyncService->sync(
$invoice,
$this->paymentsPayload($validated, $invoice)
);
});
if (empty($invoice->approved_at)) {
$this->approvalService->approve($invoice);
}
$invoice->load('client');
return redirect()
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
->with('message-info', 'Invoice updated successfully.');
}
private function paymentValidationRules(): array
{
return [
'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' => ['required_with:payments', 'integer', 'exists:billing_item_types,id'],
'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'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'amount' => ['required', 'numeric', 'min:0'],
'management_fee' => ['required', 'numeric', 'min:0'],
'is_credit_card' => ['nullable', 'boolean'],
'total_sem_amount' => ['nullable', 'numeric', 'min:0'],
'total_net_amount' => ['nullable', 'numeric', 'min:0'],
'amount' => ['nullable', 'numeric', 'min:0'],
'management_fee' => ['required_without:payments', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'media_fee' => ['required_without:payments', 'numeric', 'min:0'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'nett_amount' => ['nullable', 'numeric', 'min:0'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
];
}
$managementFee = $validated['management_fee'];
$mediaFee = $validated['media_fee'];
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $validated['nett_amount'] ?? ($mediaFee / (1 + ($taxPercent / 100)));
$invoice->update([
'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),
'payment_no' => $validated['payment_no'] ?? null,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'amount' => $validated['amount'],
'management_fee' => $managementFee,
'media_fee' => $validated['media_fee'],
'tax_percent' => $taxPercent,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
]);
if(empty($invoice->approved_at)) {
$this->approvalService->approve($invoice);
private function paymentsPayload(array $validated, ClientInvoice $invoice): array
{
if (! empty($validated['payments'])) {
return $validated['payments'];
}
return redirect()
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
->with('message-info', 'Invoice updated successfully.');
$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,
];
}
return [[
'payment_no' => $validated['payment_no'] ?? null,
'payment_total_amount' => $mediaFee + $managementFee,
'payment_nett_amount' => $this->netFromGross($mediaFee + $managementFee, $taxPercent),
'items' => $items,
]];
}
private function billingItemTypesForForm(): array
{
$this->paymentSyncService->ensureDefaultItemTypes();
return BillingItemType::query()
->orderBy('id')
->get(['id', 'name', 'sql_acc_code', 'nett_contribution', 'fee_type', 'type', 'campaign_type'])
->map(fn (BillingItemType $itemType) => [
'id' => $itemType->id,
'name' => $itemType->name,
'sql_acc_code' => $itemType->sql_acc_code,
'nett_contribution' => $itemType->nett_contribution,
'fee_type' => $itemType->fee_type,
'type' => $itemType->type,
'campaign_type' => $itemType->campaign_type,
])
->all();
}
private function netFromGross(float $grossAmount, float $taxPercent): float
{
return $taxPercent > 0
? $grossAmount / (1 + ($taxPercent / 100))
: $grossAmount;
}
private function unlinkedClientOptions(): array
{
return Client::query()
->where(function ($query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
})
->orderBy('name')
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
->map(fn (Client $client) => [
'value' => (string) $client->id,
'label' => trim($client->name.' ('.$client->customer_id.')'),
])
->values()
->all();
}
private function clientOptions(): array
{
return Client::query()
->orderBy('name')
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
->map(fn (Client $client) => [
'value' => (string) $client->id,
'label' => trim($client->name.' ('.$client->customer_id.')'),
])
->values()
->all();
}
private function linkInvoiceClient(ClientInvoice $invoice, array $validated): Client
{
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_ASSIGNED_PERSON,
],
[
'user_id' => Auth::id(),
]
);
$invoice->update([
'client_id' => $client->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
]);
return $client;
}
public function approve(ClientInvoice $invoice)
@ -211,7 +381,20 @@ public function destroy(ClientInvoice $invoice)
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
}
$invoice->delete();
DB::transaction(function () use ($invoice) {
$invoice->loadMissing('payments.items', 'linkedInvoices');
$invoice->linkedInvoices()->update([
'linked_invoice_id' => null,
]);
$invoice->payments->each(function ($payment) {
$payment->items()->delete();
$payment->delete();
});
$invoice->delete();
});
return redirect()
->back()
@ -279,28 +462,10 @@ public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\
->with('message-info', 'Invoice '.$invoice->invoice_no.' has been linked to '.$existingClient->name.'.');
}
$unlinkedClients = Client::query()
->where(function ($query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
})
->whereDoesntHave('customers', function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->orderBy('name')
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
->map(fn (Client $client) => [
'value' => (string) $client->id,
'label' => trim($client->name.' ('.$client->customer_id.')'),
])
->values()
->all();
return Inertia::render('client-invoices/create-client', [
'invoice' => $invoice->load('client'),
'existingClient' => null,
'unlinkedClients' => $unlinkedClients,
'unlinkedClients' => $this->unlinkedClientOptions(),
]);
}
@ -332,6 +497,10 @@ public function storeClient(Request $request, ClientInvoice $invoice)
$selectedClientIsLinked = Client::query()
->where('id', $validated['client_id'])
->where(function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->exists();
if ($selectedClientIsLinked) {

View File

@ -74,18 +74,14 @@ public function __invoke(Request $request): Response
'client_id',
'invoice_no',
'approved_at',
'management_fee',
'media_fee',
'nett_amount',
'total_net_amount',
'created_at',
])
->map(fn (ClientInvoice $invoice) => [
'id' => $invoice->id,
'invoice_no' => $invoice->invoice_no,
'approved_at' => $invoice->approved_at?->toDateTimeString(),
'management_fee' => $invoice->management_fee,
'media_fee' => $invoice->media_fee,
'nett_amount' => $invoice->nett_amount,
'total_net_amount' => $invoice->total_net_amount,
'created_at' => $invoice->created_at?->toDateString(),
'client' => $invoice->client,
])

View File

@ -3,35 +3,36 @@
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientCustomer;
use App\Models\ClientInvoice;
use App\Models\ClientInvoiceAdjustment;
use App\Models\ClientProjectActivities;
use App\Models\ClientUserAssignation;
use App\Models\ClientInvoiceAdjustment;
use App\Models\ClientCustomer;
use App\Models\GoogleCampaignMetric;
use App\Models\User;
use App\Models\ClientInvoice;
use App\Services\ClientInvoicePaymentSyncService;
use App\Services\GoogleAdsService;
use App\Services\UserHierarchyService;
use Carbon\Carbon;
use Inertia\Inertia;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log as FacadesLog;
use Inertia\Inertia;
use Rap2hpoutre\FastExcel\FastExcel;
class GoogleAdsController extends Controller
{
protected $adsService;
private const GOOGLE_COMPANY_SYNC_LOCK = 'google-ads:get-company-details:running';
public function __construct(
GoogleAdsService $adsService,
private ClientInvoicePaymentSyncService $paymentSyncService,
private UserHierarchyService $hierarchyService,
)
{
) {
$this->adsService = $adsService;
}
@ -46,7 +47,7 @@ public function accounts()
// $customerMap = $accounts->keyBy('customer_id');
$localClients = $this->hierarchyService
->scopeClientsVisibleTo(Client::query(), Auth::user())
->with('assignations.user', 'customers', 'invoices', 'invoiceAdjustments')
->with('assignations.user', 'customers', 'invoices.payments.items.billingItemType', 'invoiceAdjustments')
->get();
$customerMap = $localClients->map(function ($data) {
$assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON);
@ -55,24 +56,11 @@ public function accounts()
$data['sql_acc_code'] = implode(',', $data->customers->pluck('sql_acc_code')->toArray());
$data['assigned_person'] = $assignedPerson?->user?->name;
$data['sales_person'] = $salesPerson?->user?->name;
$data['latest_remaining_amount'] = $data->latestRemainingAmount(function (ClientInvoice $invoice) use ($data) {
if (empty($invoice->start_date) || empty($invoice->end_date)) {
return 0;
}
$data['latest_remaining_amount'] = $data->latestRemainingAmount();
return GoogleCampaignMetric::query()
->join('google_campaigns', 'google_campaign_metrics.google_campaign_id', '=', 'google_campaigns.id')
->where('google_campaigns.client_id', $data->id)
->whereNull('google_campaigns.deleted_at')
->whereNull('google_campaign_metrics.deleted_at')
->whereBetween('google_campaign_metrics.date', [
$invoice->start_date?->toDateString(),
$invoice->end_date?->toDateString(),
])
->sum('google_campaign_metrics.actual_spend');
});
return $data;
});
return Inertia::render('campaigns/index', [
'clients' => $customerMap->values()->all(),
'googleCompanySyncRunning' => Cache::has(self::GOOGLE_COMPANY_SYNC_LOCK),
@ -214,6 +202,7 @@ public function updateAccount(Request $request, $id)
foreach ($assignmentValues as $role => $userId) {
if ($userId === null) {
$localClient->assignations()->where('role', $role)->delete();
continue;
}
@ -231,12 +220,14 @@ public function updateAccount(Request $request, $id)
public function campaigns($id)
{
$campaigns = $this->adsService->listCampaigns($id);
return response()->json($campaigns);
}
public function listCampaignsMetrics($id, $startDate, $endDate)
{
$campaigns = $this->adsService->listCampaignsMetrics($id, $startDate, $endDate);
return response()->json($campaigns);
}
@ -251,7 +242,7 @@ private function hydrateClient(array $account): array
]
);
// dd($localClient);
$localClient->load(['assignations.user', 'invoices']);
$localClient->load(['assignations.user', 'invoices.payments.items.billingItemType']);
$assignments = $localClient->assignations
->mapWithKeys(function (ClientUserAssignation $assignation) {
@ -287,61 +278,64 @@ private function hydrateClient(array $account): array
->all();
$invoices = $localClient->invoices
->map(function ($invoice) use ($account) {
$campaigns = $this->adsService->listCampaigns($account['id']);
$totalInvoiceSpend = 0;
FacadesLog::info('Hydrated client data', [
'campaigns' => $campaigns,
]);
foreach ($campaigns as $campaign) {
FacadesLog::info('Hydrated client data', [
'campaigns' => $campaign['id'],
]);
if (empty($invoice->start_date) || empty($invoice->end_date)) {
continue;
} else {
$metrics = $this->adsService->listCampaignsMetricsById(
$account['id'],
$campaign['id'],
$invoice->start_date?->toDateString() ?? null,
$invoice->end_date?->toDateString() ?? null
);
FacadesLog::info('Hydrated client data', [
'metrics' => $metrics,
]);
$totalSpend = array_sum(array_column($metrics, 'actual_spend'));
$totalInvoiceSpend += $totalSpend;
}
}
->map(function ($invoice) {
return [
'id' => $invoice->id,
'client_id' => $invoice->client_id,
'invoice_no' => $invoice->invoice_no,
'linked_invoice_id' => $invoice->linked_invoice_id,
'is_credit_card' => $invoice->is_credit_card,
'is_paid' => $invoice->is_paid,
'start_date' => $invoice->start_date?->toDateString(),
'end_date' => $invoice->end_date?->toDateString(),
'payment_no' => $invoice->payment_no,
'amount' => $invoice->amount,
'total_spend' => number_format($totalInvoiceSpend, 2, '.', ''),
'management_fee' => $invoice->management_fee,
'management_fee_amount' => $invoice->management_fee_amount,
'management_fee_tax' => $invoice->management_fee_tax,
'media_fee' => $invoice->media_fee,
'media_fee_amount' => $invoice->media_fee_amount,
'media_fee_tax' => $invoice->media_fee_tax,
'tax_percent' => $invoice->tax_percent,
'nett_amount' => $invoice->nett_amount,
'total_spending' => $invoice->total_spending,
'approved_at' => $invoice->approved_at?->toDateTimeString(),
'total_sem_amount' => $invoice->total_sem_amount,
'total_net_amount' => $invoice->total_net_amount,
'created_at' => $invoice->created_at?->toDateTimeString(),
'updated_at' => $invoice->updated_at?->toDateTimeString(),
'payments' => $invoice->payments
->map(fn ($payment) => [
'id' => $payment->id,
'client_invoice_id' => $payment->client_invoice_id,
'payment_no' => $payment->payment_no,
'payment_total_amount' => $payment->payment_total_amount,
'payment_nett_amount' => $payment->payment_nett_amount,
'items' => $payment->items
->map(fn ($item) => [
'id' => $item->id,
'client_invoice_payment_id' => $item->client_invoice_payment_id,
'billing_item_types_id' => $item->billing_item_types_id,
'billing_item_type' => $item->billingItemType
? [
'id' => $item->billingItemType->id,
'name' => $item->billingItemType->name,
'sql_acc_code' => $item->billingItemType->sql_acc_code,
'nett_contribution' => $item->billingItemType->nett_contribution,
'fee_type' => $item->billingItemType->fee_type,
'type' => $item->billingItemType->type,
'campaign_type' => $item->billingItemType->campaign_type,
]
: null,
'start_date' => $item->start_date?->toDateString(),
'end_date' => $item->end_date?->toDateString(),
'payment_item_amount' => $item->payment_item_amount,
'tax_percentage' => $item->tax_percentage,
'net_amount' => $item->net_amount,
'withholding_tax' => $item->withholding_tax,
'final_net_amount' => $item->final_net_amount,
'spending' => $item->spending,
'is_creditcard' => $item->is_creditcard,
])
->values()
->all(),
])
->values()
->all(),
];
})
->toArray();
// dd($invoices);
$campaigns = $this->adsService->listCampaigns($localClient->customer_id);
$campaigns = [];
// if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){
// $campaigns = $this->adsService->listCampaigns($localClient->customer_id);
// }
$lifeTimeSpend = 0;
@ -395,6 +389,7 @@ private function hydrateClient(array $account): array
'users' => $users,
'invoices' => $invoices,
]);
return [
$localClient,
$assignments,
@ -475,27 +470,54 @@ public function insertCSVDataToDB()
$mediaFee = intval($row['media_fee']);
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0;
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0;
$billingItemTypes = $this->paymentSyncService->ensureDefaultItemTypes()->keyBy('name');
$items = [];
ClientInvoice::updateOrCreate(
if ($mediaFee > 0) {
$items[] = [
'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME]->id,
'start_date' => $startDate,
'end_date' => $endDate,
'payment_item_amount' => $mediaFee,
'tax_percentage' => 8,
'net_amount' => $mediaFeeAmount,
'withholding_tax' => 0,
'final_net_amount' => $mediaFeeAmount,
'spending' => $spend,
'is_creditcard' => 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' => 8,
'net_amount' => $managementFeeAmount,
'withholding_tax' => 0,
'final_net_amount' => $managementFeeAmount,
'spending' => 0,
'is_creditcard' => false,
];
}
$invoice = ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']],
[
'client_id' => $row['client_id'],
'is_credit_card' => $mediaFee == 0 ? 1 : 0,
'start_date' => $startDate,
'end_date' => $endDate,
'management_fee' => $managementFee,
'management_fee_amount' => $managementFeeAmount,
'management_fee_tax' => $managementFee - $managementFeeAmount,
'media_fee' => $mediaFee,
'media_fee_amount' => $mediaFeeAmount,
'media_fee_tax' => $mediaFee - $mediaFeeAmount,
'tax_percent' => 8,
'nett_amount' => $mediaFeeAmount,
'total_spending' => $spend,
]
);
$this->paymentSyncService->sync($invoice, [[
'payment_no' => null,
'payment_total_amount' => $mediaFee + $managementFee,
'payment_nett_amount' => $mediaFeeAmount + $managementFeeAmount,
'items' => $items,
]]);
} else {
FacadesLog::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id']));
continue; // Skip this row if client not found
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class BillingItemType extends Model
{
use SoftDeletes;
public $timestamps = false;
protected $fillable = [
'name',
'sql_acc_code',
'nett_contribution',
'fee_type',
'type',
'campaign_type',
];
protected $casts = [
'nett_contribution' => 'boolean',
];
public function paymentItems(): HasMany
{
return $this->hasMany(ClientInvoicePaymentItem::class, 'billing_item_types_id');
}
}

View File

@ -42,28 +42,28 @@ public function getLatestRemainingAmountAttribute(): string
return $this->latestRemainingAmount();
}
public function latestRemainingAmount(?callable $invoiceSpendingResolver = null): string
public function latestRemainingAmount(): string
{
$invoices = $this->relationLoaded('invoices')
? $this->invoices
: $this->invoices()->get(['client_id', 'is_credit_card', 'nett_amount', 'total_spending']);
: $this->invoices()->with('payments.items.billingItemType')->get();
$invoices->loadMissing('payments.items.billingItemType');
$adjustments = $this->relationLoaded('invoiceAdjustments')
? $this->invoiceAdjustments
: $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']);
$nettAmount = $invoices
->sum(fn (ClientInvoice $invoice) => (float) ($invoice->nett_amount ?? 0));
$items = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments)
->flatMap(fn ($payment) => $payment->items);
$billableSpending = $invoices
->reject(fn (ClientInvoice $invoice) => $invoice->is_credit_card)
->sum(function (ClientInvoice $invoice) use ($invoiceSpendingResolver) {
if ($invoiceSpendingResolver !== null) {
return (float) $invoiceSpendingResolver($invoice);
}
$nettAmount = $items
->filter(fn ($item) => ! $item->is_creditcard && ($item->billingItemType?->nett_contribution ?? false))
->sum(fn ($item) => (float) ($item->final_net_amount ?? $item->net_amount ?? 0));
return (float) ($invoice->total_spending ?? 0);
});
$billableSpending = $items
->reject(fn ($item) => $item->is_creditcard)
->sum(fn ($item) => (float) ($item->spending ?? 0));
$adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) {
$amount = (float) ($adjustment->amount ?? 0);

View File

@ -17,40 +17,15 @@ class ClientInvoice extends Model
'pending_client_name',
'invoice_no',
'linked_invoice_id',
'is_credit_card',
'is_paid',
'approved_at',
'start_date',
'end_date',
'payment_no',
'amount',
'tax_percent',
'media_fee',
'media_fee_amount',
'media_fee_tax',
'management_fee',
'management_fee_amount',
'management_fee_tax',
'nett_amount',
'total_spending',
'total_sem_amount',
'total_net_amount',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_credit_card' => 'boolean',
'is_paid' => 'boolean',
'approved_at' => 'datetime',
'amount' => 'decimal:2',
'tax_percent' => 'decimal:2',
'media_fee' => 'decimal:2',
'media_fee_amount' => 'decimal:2',
'media_fee_tax' => 'decimal:2',
'management_fee' => 'decimal:2',
'management_fee_amount' => 'decimal:2',
'management_fee_tax' => 'decimal:2',
'nett_amount' => 'decimal:2',
'total_spending' => 'decimal:2',
'total_sem_amount' => 'decimal:6',
'total_net_amount' => 'decimal:6',
];
public function client(): BelongsTo
@ -68,4 +43,8 @@ public function linkedInvoices(): HasMany
return $this->hasMany(self::class, 'linked_invoice_id');
}
public function payments(): HasMany
{
return $this->hasMany(ClientInvoicePayment::class, 'client_invoice_id');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClientInvoicePayment extends Model
{
public $timestamps = false;
protected $fillable = [
'client_invoice_id',
'payment_no',
'payment_total_amount',
'payment_nett_amount',
];
protected $casts = [
'payment_total_amount' => 'decimal:6',
'payment_nett_amount' => 'decimal:6',
];
public function invoice(): BelongsTo
{
return $this->belongsTo(ClientInvoice::class, 'client_invoice_id');
}
public function items(): HasMany
{
return $this->hasMany(ClientInvoicePaymentItem::class, 'client_invoice_payment_id');
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientInvoicePaymentItem extends Model
{
public $timestamps = false;
protected $fillable = [
'client_invoice_payment_id',
'billing_item_types_id',
'start_date',
'end_date',
'payment_item_amount',
'tax_percentage',
'net_amount',
'withholding_tax',
'final_net_amount',
'spending',
'is_creditcard',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'payment_item_amount' => 'decimal:6',
'tax_percentage' => 'decimal:2',
'net_amount' => 'decimal:6',
'withholding_tax' => 'decimal:2',
'final_net_amount' => 'decimal:6',
'spending' => 'decimal:6',
'is_creditcard' => 'boolean',
];
public function payment(): BelongsTo
{
return $this->belongsTo(ClientInvoicePayment::class, 'client_invoice_payment_id');
}
public function billingItemType(): BelongsTo
{
return $this->belongsTo(BillingItemType::class, 'billing_item_types_id');
}
}

View File

@ -0,0 +1,285 @@
<?php
namespace App\Services;
use App\Models\BillingItemType;
use App\Models\ClientInvoice;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ClientInvoicePaymentSyncService
{
public const MEDIA_SEARCH_NAME = 'Google Ads Search (Media Fee)';
public const MANAGEMENT_SEARCH_NAME = 'Management Fee (Google Search Ads)';
public const MANAGEMENT_DEMAND_GEN_NAME = 'Management Fee (Google Demand Gen Ads)';
public const MEDIA_DEMAND_GEN_NAME = 'Google Demand Gen Ads (Media Fee)';
public function sync(ClientInvoice $invoice, array $payments): ClientInvoice
{
return DB::transaction(function () use ($invoice, $payments) {
$manualTotalSemAmount = $invoice->total_sem_amount;
$manualTotalNetAmount = $invoice->total_net_amount;
$billingItemTypes = BillingItemType::withTrashed()
->whereIn('id', collect($payments)->flatMap(fn (array $payment) => $payment['items'] ?? [])
->pluck('billing_item_types_id')
->filter()
->unique()
->values())
->get()
->keyBy('id');
$invoice->payments()->with('items')->get()->each(function ($payment) {
$payment->items()->delete();
$payment->delete();
});
$totals = $this->emptyTotals();
foreach ($payments as $paymentPayload) {
$payment = $invoice->payments()->create([
'payment_no' => $paymentPayload['payment_no'] ?? null,
'payment_total_amount' => $this->amount($paymentPayload['payment_total_amount'] ?? 0),
'payment_nett_amount' => $this->amount($paymentPayload['payment_nett_amount'] ?? 0),
]);
$totals['amount'] += (float) $payment->payment_total_amount;
$totals['payment_no'] ??= $payment->payment_no;
foreach ($paymentPayload['items'] ?? [] as $itemPayload) {
$billingItemType = $billingItemTypes->get((int) ($itemPayload['billing_item_types_id'] ?? 0));
$grossAmount = $this->amount($itemPayload['payment_item_amount'] ?? 0);
$netAmount = $this->amount($itemPayload['net_amount'] ?? 0);
$finalNetAmount = $this->amount($itemPayload['final_net_amount'] ?? $netAmount);
$spending = $this->amount($itemPayload['spending'] ?? 0);
$isCreditCard = (bool) ($itemPayload['is_creditcard'] ?? false);
$payment->items()->create([
'billing_item_types_id' => $billingItemType?->id ?? $itemPayload['billing_item_types_id'],
'start_date' => $itemPayload['start_date'] ?? null,
'end_date' => $itemPayload['end_date'] ?? null,
'payment_item_amount' => $grossAmount,
'tax_percentage' => $this->amount($itemPayload['tax_percentage'] ?? 0),
'net_amount' => $netAmount,
'withholding_tax' => $this->amount($itemPayload['withholding_tax'] ?? 0),
'final_net_amount' => $finalNetAmount,
'spending' => $spending,
'is_creditcard' => $isCreditCard,
]);
$this->addItemToTotals($totals, $billingItemType, [
'gross_amount' => $grossAmount,
'net_amount' => $netAmount,
'final_net_amount' => $finalNetAmount,
'spending' => $spending,
'is_creditcard' => $isCreditCard,
'start_date' => $itemPayload['start_date'] ?? null,
'end_date' => $itemPayload['end_date'] ?? null,
]);
}
}
$invoice->update(
$this->legacyInvoicePayload(
$totals,
$manualTotalSemAmount,
$manualTotalNetAmount,
)
);
return $invoice->refresh()->load('payments.items.billingItemType');
});
}
public function syncLegacyFees(ClientInvoice $invoice, bool $replaceExisting = false): ClientInvoice
{
if (! $replaceExisting && $invoice->payments()->exists()) {
return $invoice;
}
return $this->sync($invoice, $this->legacyPaymentsFor($invoice));
}
public function legacyPaymentsFor(ClientInvoice $invoice): array
{
$this->ensureDefaultItemTypes();
$mediaType = BillingItemType::where('name', self::MEDIA_SEARCH_NAME)->firstOrFail();
$managementType = BillingItemType::where('name', self::MANAGEMENT_SEARCH_NAME)->firstOrFail();
$taxPercentage = (float) ($invoice->tax_percent ?? 0);
$mediaGross = (float) ($invoice->media_fee ?? 0);
$managementGross = (float) ($invoice->management_fee ?? 0);
$mediaNet = (float) ($invoice->media_fee_amount ?? $this->netFromGross($mediaGross, $taxPercentage));
$managementNet = (float) ($invoice->management_fee_amount ?? $this->netFromGross($managementGross, $taxPercentage));
$mediaFinalNet = (float) ($invoice->nett_amount ?? $mediaNet);
$paymentTotal = $mediaGross + $managementGross;
return [[
'payment_no' => $invoice->payment_no,
'payment_total_amount' => $paymentTotal,
'payment_tax_percentage' => $taxPercentage,
'payment_nett_amount' => $this->netFromGross($paymentTotal, $taxPercentage),
// 'items' => [
// [
// 'billing_item_types_id' => $mediaType->id,
// 'start_date' => $invoice->start_date?->toDateString(),
// 'end_date' => $invoice->end_date?->toDateString(),
// 'payment_item_amount' => $mediaGross,
// 'tax_percentage' => $taxPercentage,
// 'net_amount' => $mediaNet,
// 'withholding_tax' => 0,
// 'final_net_amount' => $mediaFinalNet,
// 'spending' => (float) ($invoice->total_spending ?? 0),
// 'is_creditcard' => (bool) $invoice->is_credit_card,
// ],
// [
// 'billing_item_types_id' => $managementType->id,
// 'start_date' => null,
// 'end_date' => null,
// 'payment_item_amount' => $managementGross,
// 'tax_percentage' => $taxPercentage,
// 'net_amount' => $managementNet,
// 'withholding_tax' => 0,
// 'final_net_amount' => $managementNet,
// 'spending' => 0,
// 'is_creditcard' => false,
// ],
// ],
]];
}
public function ensureDefaultItemTypes(): Collection
{
return collect($this->defaultItemTypes())->map(function (array $itemType) {
$billingItemType = BillingItemType::withTrashed()->updateOrCreate(
['name' => $itemType['name']],
$itemType
);
if ($billingItemType->trashed()) {
$billingItemType->restore();
}
return $billingItemType;
});
}
public function defaultItemTypes(): array
{
return [
[
'name' => self::MEDIA_SEARCH_NAME,
'sql_acc_code' => 'G03',
'nett_contribution' => true,
'fee_type' => 'Media',
'type' => 'Google',
'campaign_type' => 'Search',
],
[
'name' => self::MANAGEMENT_SEARCH_NAME,
'sql_acc_code' => 'GOOGLE',
'nett_contribution' => false,
'fee_type' => 'Management',
'type' => 'Google',
'campaign_type' => 'Search',
],
[
'name' => self::MANAGEMENT_DEMAND_GEN_NAME,
'sql_acc_code' => 'M05',
'nett_contribution' => false,
'fee_type' => 'Management',
'type' => 'Google',
'campaign_type' => 'Demand Gen',
],
[
'name' => self::MEDIA_DEMAND_GEN_NAME,
'sql_acc_code' => 'G06',
'nett_contribution' => true,
'fee_type' => 'Media',
'type' => 'Google',
'campaign_type' => 'Demand Gen',
],
];
}
private function addItemToTotals(array &$totals, ?BillingItemType $billingItemType, array $item): void
{
$feeType = strtolower((string) $billingItemType?->fee_type);
if ($feeType === 'media') {
$totals['media_fee'] += $item['gross_amount'];
$totals['media_fee_amount'] += $item['net_amount'];
$totals['media_fee_tax'] += max(0, $item['gross_amount'] - $item['net_amount']);
}
if ($feeType === 'management') {
$totals['management_fee'] += $item['gross_amount'];
$totals['management_fee_amount'] += $item['net_amount'];
$totals['management_fee_tax'] += max(0, $item['gross_amount'] - $item['net_amount']);
}
if ($billingItemType?->nett_contribution) {
$totals['nett_amount'] += $item['final_net_amount'];
}
$totals['total_sem_amount'] += $item['gross_amount'];
$totals['total_spending'] += $item['spending'];
$totals['is_credit_card'] = $totals['is_credit_card'] || $item['is_creditcard'];
if ($item['start_date'] !== null) {
$totals['start_date'] ??= $item['start_date'];
}
if ($item['end_date'] !== null) {
$totals['end_date'] ??= $item['end_date'];
}
}
private function legacyInvoicePayload(
array $totals,
mixed $manualTotalSemAmount = null,
mixed $manualTotalNetAmount = null,
): array
{
return [
'total_sem_amount' => $manualTotalSemAmount ?? $totals['total_sem_amount'],
'total_net_amount' => $manualTotalNetAmount ?? $totals['nett_amount'],
];
}
private function emptyTotals(): array
{
return [
'payment_no' => null,
'start_date' => null,
'end_date' => null,
'amount' => 0,
'management_fee' => 0,
'management_fee_amount' => 0,
'management_fee_tax' => 0,
'media_fee' => 0,
'media_fee_amount' => 0,
'media_fee_tax' => 0,
'nett_amount' => 0,
'total_spending' => 0,
'total_sem_amount' => 0,
'payment_tax_percentage' => null,
'is_credit_card' => false,
];
}
private function netFromGross(float $grossAmount, float $taxPercentage): float
{
return $taxPercentage > 0
? $grossAmount / (1 + ($taxPercentage / 100))
: $grossAmount;
}
private function amount(mixed $amount): float
{
return is_numeric($amount) ? (float) $amount : 0.0;
}
}

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('billing_item_types', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('sql_acc_code')->nullable();
$table->boolean('nett_contribution')->default(false);
$table->string('fee_type')->nullable();
$table->string('type')->nullable();
$table->string('campaign_type')->nullable();
$table->softDeletes();
$table->unique(['name', 'sql_acc_code']);
});
Schema::create('client_invoice_payments', function (Blueprint $table) {
$table->id();
$table->foreignId('client_invoice_id')
->constrained('client_invoices')
->cascadeOnDelete();
$table->string('payment_no')->nullable();
$table->decimal('payment_total_amount', 15, 6)->default(0);
$table->decimal('payment_tax_percentage', 8, 2)->default(0);
$table->decimal('payment_nett_amount', 15, 6)->default(0);
});
Schema::create('client_invoice_payment_items', function (Blueprint $table) {
$table->id();
$table->foreignId('client_invoice_payment_id')
->constrained('client_invoice_payments')
->cascadeOnDelete();
$table->foreignId('billing_item_types_id')
->constrained('billing_item_types')
->restrictOnDelete();
$table->date('start_date')->nullable();
$table->date('end_date')->nullable();
$table->decimal('payment_item_amount', 15, 6)->default(0);
$table->decimal('tax_percentage', 8, 2)->default(0);
$table->decimal('net_amount', 15, 6)->default(0);
$table->decimal('withholding_tax', 8, 2)->default(0);
$table->decimal('final_net_amount', 15, 6)->default(0);
$table->decimal('spending', 15, 6)->default(0);
$table->boolean('is_creditcard')->default(false);
});
}
public function down(): void
{
Schema::dropIfExists('client_invoice_payment_items');
Schema::dropIfExists('client_invoice_payments');
Schema::dropIfExists('billing_item_types');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('client_invoices', 'total_sem_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->decimal('total_sem_amount', 15, 6)->nullable()->after('total_spending');
});
}
if (! Schema::hasColumn('client_invoices', 'total_net_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->decimal('total_net_amount', 15, 6)->nullable()->after('total_sem_amount');
});
}
}
public function down(): void
{
if (Schema::hasColumn('client_invoices', 'total_net_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->dropColumn('total_net_amount');
});
}
if (Schema::hasColumn('client_invoices', 'total_sem_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->dropColumn('total_sem_amount');
});
}
}
};

View File

@ -0,0 +1,131 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$this->dropColumns('client_invoices', [
'start_date',
'end_date',
'payment_no',
'amount',
'tax_percent',
'media_fee',
'media_fee_amount',
'media_fee_tax',
'management_fee',
'management_fee_amount',
'management_fee_tax',
'nett_amount',
'total_spending',
'is_credit_card',
'is_paid',
]);
$this->dropColumns('client_invoice_payments', [
'payment_tax_percentage',
]);
}
public function down(): void
{
if (Schema::hasTable('client_invoices')) {
Schema::table('client_invoices', function (Blueprint $table) {
if (! Schema::hasColumn('client_invoices', 'start_date')) {
$table->date('start_date')->nullable();
}
if (! Schema::hasColumn('client_invoices', 'end_date')) {
$table->date('end_date')->nullable();
}
if (! Schema::hasColumn('client_invoices', 'payment_no')) {
$table->string('payment_no')->nullable();
}
if (! Schema::hasColumn('client_invoices', 'amount')) {
$table->decimal('amount', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'tax_percent')) {
$table->decimal('tax_percent', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'media_fee')) {
$table->decimal('media_fee', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'media_fee_amount')) {
$table->decimal('media_fee_amount', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'media_fee_tax')) {
$table->decimal('media_fee_tax', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'management_fee')) {
$table->decimal('management_fee', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'management_fee_amount')) {
$table->decimal('management_fee_amount', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'management_fee_tax')) {
$table->decimal('management_fee_tax', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'nett_amount')) {
$table->decimal('nett_amount', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'total_spending')) {
$table->decimal('total_spending', 10, 2)->nullable();
}
if (! Schema::hasColumn('client_invoices', 'is_credit_card')) {
$table->boolean('is_credit_card')->default(false);
}
if (! Schema::hasColumn('client_invoices', 'is_paid')) {
$table->boolean('is_paid')->default(false);
}
});
}
if (Schema::hasTable('client_invoice_payments')) {
Schema::table('client_invoice_payments', function (Blueprint $table) {
if (! Schema::hasColumn('client_invoice_payments', 'payment_tax_percentage')) {
$table->decimal('payment_tax_percentage', 8, 2)->default(0);
}
});
}
}
/**
* @param array<int, string> $columns
*/
private function dropColumns(string $table, array $columns): void
{
if (! Schema::hasTable($table)) {
return;
}
$existingColumns = array_values(array_filter(
$columns,
fn (string $column): bool => Schema::hasColumn($table, $column),
));
if ($existingColumns === []) {
return;
}
Schema::table($table, function (Blueprint $blueprint) use ($existingColumns) {
$blueprint->dropColumn($existingColumns);
});
}
};

View File

@ -0,0 +1,14 @@
<?php
namespace Database\Seeders;
use App\Services\ClientInvoicePaymentSyncService;
use Illuminate\Database\Seeder;
class BillingItemTypeSeeder extends Seeder
{
public function run(): void
{
app(ClientInvoicePaymentSyncService::class)->ensureDefaultItemTypes();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Seeders;
use App\Models\ClientInvoice;
use App\Services\ClientInvoicePaymentSyncService;
use Illuminate\Database\Seeder;
class ClientInvoicePaymentItemBackfillSeeder extends Seeder
{
public function run(): void
{
$syncService = app(ClientInvoicePaymentSyncService::class);
$syncService->ensureDefaultItemTypes();
ClientInvoice::query()
->whereDoesntHave('payments')
->where(function ($query) {
$query
->whereNotNull('media_fee')
->orWhereNotNull('management_fee');
})
->orderBy('id')
->chunkById(100, function ($invoices) use ($syncService) {
foreach ($invoices as $invoice) {
$syncService->syncLegacyFees($invoice);
}
});
}
}

View File

@ -15,7 +15,11 @@ public function run(): void
{
// User::factory(10)->create();
$this->call(RoleSeeder::class);
$this->call([
RoleSeeder::class,
BillingItemTypeSeeder::class,
ClientInvoicePaymentItemBackfillSeeder::class,
]);
$user = User::firstOrCreate(
['email' => 'test@example.com'],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,16 @@
import React from "react";
import AppLayout from "@/layouts/app-layout";
import { useForm } from "@inertiajs/react";
import { Button, Card, Container, Group, Title } from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react";
import { Link } from "@inertiajs/react";
import InvoiceForm, { InvoiceFormValues } from "@/forms/account/InvoiceForm";
import { ClientInvoice } from "@/types";
import InvoiceForm, {
BillingItemTypeOption,
InvoiceFormValues,
createPayment,
} from '@/forms/account/InvoiceForm';
import AppLayout from '@/layouts/app-layout';
import { ClientInvoice } from '@/types';
import { Link, useForm } from '@inertiajs/react';
import { Button, Container, Group, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import React from 'react';
type InvoiceOptionSource = Pick<ClientInvoice, "id" | "invoice_no"> & {
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
linked_invoice_id?: number | null;
};
@ -15,64 +18,76 @@ interface Props {
clientId: number;
customerId: string;
availableInvoices: InvoiceOptionSource[];
billingItemTypes: BillingItemTypeOption[];
}
export default function Page({ clientId, customerId, availableInvoices }: Props) {
const parseAmount = (value?: string) => {
const parsed = Number.parseFloat(value ?? '');
return Number.isFinite(parsed) ? parsed : 0;
};
const finalNetAmount = (netAmount: number, withholdingTax: number) =>
netAmount / (1 + withholdingTax / 100);
export default function Page({
clientId,
customerId,
availableInvoices,
billingItemTypes,
}: Props) {
const form = useForm<InvoiceFormValues>({
client_id: String(clientId),
customer_id: customerId,
invoice_no: "",
linked_invoice_id: "",
is_credit_card: false,
invoice_no: '',
linked_invoice_id: '',
is_paid: false,
payment_no: "",
start_date: "",
end_date: "",
amount: "",
management_fee: "",
management_fee_amount: "",
management_fee_tax: "",
media_fee: "",
media_fee_amount: "",
media_fee_tax: "",
tax_percent: "",
total_spending: "",
nett_amount: "",
total_sem_amount: '0.00',
total_net_amount: '0.00',
payments: [createPayment(billingItemTypes)],
});
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.data.nett_amount) {
form.setError("nett_amount", "Media Nett Amount is required.");
return;
}
const totalSpending = form.data.total_spending
? parseFloat(form.data.total_spending)
: null;
form.transform((data) => ({
...data,
linked_invoice_id: data.linked_invoice_id || null,
is_credit_card: !!data.is_credit_card,
is_paid: !!data.is_paid,
payment_no: data.payment_no || null,
start_date: data.start_date || null,
end_date: data.end_date || null,
amount: parseFloat(data.amount) || 0,
management_fee: parseFloat(data.management_fee) || 0,
management_fee_amount: data.management_fee_amount ? parseFloat(data.management_fee_amount) : null,
management_fee_tax: data.management_fee_tax ? parseFloat(data.management_fee_tax) : null,
media_fee: parseFloat(data.media_fee) || 0,
media_fee_amount: data.media_fee_amount ? parseFloat(data.media_fee_amount) : null,
media_fee_tax: data.media_fee_tax ? parseFloat(data.media_fee_tax) : null,
tax_percent: parseFloat(data.tax_percent) || 0,
total_spending: totalSpending,
nett_amount: parseFloat(form.data.nett_amount) || 0,
total_sem_amount: parseFloat(data.total_sem_amount) || 0,
total_net_amount: parseFloat(data.total_net_amount) || 0,
payments: data.payments.map((payment) => ({
...payment,
payment_no: payment.payment_no || null,
payment_total_amount:
parseFloat(payment.payment_total_amount) || 0,
payment_nett_amount:
parseFloat(payment.payment_nett_amount) || 0,
items: payment.items.map((item) => ({
...item,
start_date: item.start_date || null,
end_date: item.end_date || null,
billing_item_types_id: parseInt(
item.billing_item_types_id,
10,
),
payment_item_amount: parseAmount(
item.payment_item_amount,
),
tax_percentage: parseAmount(item.tax_percentage),
net_amount: parseAmount(item.net_amount),
withholding_tax: parseAmount(item.withholding_tax),
final_net_amount: finalNetAmount(
parseAmount(item.net_amount),
parseAmount(item.withholding_tax),
),
spending: parseFloat(item.spending) || 0,
is_creditcard: !!item.is_creditcard,
})),
})),
}));
form.post(route("client-invoices.store"));
form.post(route('client-invoices.store'));
};
const invoiceOptions = availableInvoices.map((invoice) => ({
@ -87,7 +102,9 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
<Title order={2}>Add invoice</Title>
<Button
component={Link}
href={route("google-ads.accounts.show", { id: customerId })}
href={route('google-ads.accounts.show', {
id: customerId,
})}
leftIcon={<IconArrowLeft size={16} />}
variant="outline"
>
@ -95,9 +112,12 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
</Button>
</Group>
<Card withBorder>
<InvoiceForm form={form} onSubmit={handleSubmit} invoiceOptions={invoiceOptions} />
</Card>
<InvoiceForm
form={form}
onSubmit={handleSubmit}
invoiceOptions={invoiceOptions}
billingItemTypes={billingItemTypes}
/>
</Container>
</AppLayout>
);

View File

@ -1,139 +1,305 @@
import React from "react";
import AppLayout from "@/layouts/app-layout";
import { router, useForm } from "@inertiajs/react";
import { Badge, Button, Card, Container, Group, Title } from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react";
import { Link } from "@inertiajs/react";
import InvoiceForm, { InvoiceFormValues } from "@/forms/account/InvoiceForm";
import { ClientInvoice } from "@/types";
import InvoiceForm, {
BillingItemTypeOption,
InvoiceFormValues,
InvoicePaymentFormValues,
createPayment,
createPaymentItem,
} from '@/forms/account/InvoiceForm';
import AppLayout from '@/layouts/app-layout';
import { ClientInvoice } from '@/types';
import { Link, router, useForm } from '@inertiajs/react';
import { Badge, Button, Container, Group, Title } from '@mantine/core';
import { IconArrowLeft, IconFileDollar } from '@tabler/icons-react';
import React from 'react';
type InvoiceOptionSource = Pick<ClientInvoice, "id" | "invoice_no"> & {
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
linked_invoice_id?: number | null;
};
type InvoicePaymentItemSource = {
billing_item_types_id: number;
start_date: string | null;
end_date: string | null;
payment_item_amount: string | number;
tax_percentage: string | number;
net_amount: string | number;
withholding_tax: string | number;
final_net_amount: string | number;
spending: string | number;
is_creditcard: boolean;
};
type InvoicePaymentSource = {
payment_no: string | null;
payment_total_amount: string | number;
payment_nett_amount: string | number;
items: InvoicePaymentItemSource[];
};
interface Props {
invoice: ClientInvoice & { client?: { customer_id: string } };
invoice: ClientInvoice & {
pending_sql_acc_code?: string | null;
pending_client_name?: string | null;
client?: {
id: number;
name: string;
customer_id: string;
};
payments?: InvoicePaymentSource[];
total_sem_amount?: number | string | null;
total_net_amount?: number | string | null;
};
existingClient?: { id: number; name: string; customer_id: string } | null;
unlinkedClients: { value: string; label: string }[];
availableInvoices: InvoiceOptionSource[];
billingItemTypes: BillingItemTypeOption[];
}
export default function Page({ invoice, availableInvoices }: Props) {
const valueString = (value: string | number | null | undefined) =>
value === null || value === undefined ? '' : String(value);
const parseAmount = (value?: string | number | null) => {
const parsed = Number.parseFloat(String(value ?? ''));
return Number.isFinite(parsed) ? parsed : 0;
};
const finalNetAmount = (netAmount: number, withholdingTax: number) =>
netAmount / (1 + withholdingTax / 100);
const paymentItemsFromInvoice = (
invoice: Props['invoice'],
billingItemTypes: BillingItemTypeOption[],
): InvoicePaymentFormValues[] => {
if (invoice.payments?.length) {
return invoice.payments.map((payment) => ({
payment_no: payment.payment_no ?? '',
payment_total_amount: valueString(payment.payment_total_amount),
payment_nett_amount: valueString(payment.payment_nett_amount),
items: payment.items.map((item) => ({
billing_item_types_id: String(item.billing_item_types_id),
start_date: item.start_date ?? '',
end_date: item.end_date ?? '',
payment_item_amount: valueString(item.payment_item_amount),
tax_percentage: valueString(item.tax_percentage),
net_amount: valueString(item.net_amount),
withholding_tax: valueString(item.withholding_tax),
final_net_amount: valueString(item.final_net_amount),
spending: valueString(item.spending),
is_creditcard: !!item.is_creditcard,
})),
}));
}
const mediaSearch = billingItemTypes.find(
(itemType) => itemType.name === 'Google Ads Search (Media Fee)',
);
const managementSearch = billingItemTypes.find(
(itemType) => itemType.name === 'Management Fee (Google Search Ads)',
);
const fallbackPayment = createPayment(billingItemTypes, {
payment_no: invoice.payment_no ?? '',
payment_total_amount: valueString(
(Number(invoice.media_fee ?? 0) || 0) +
(Number(invoice.management_fee ?? 0) || 0),
),
payment_nett_amount: valueString(
invoice.total_net_amount ?? invoice.nett_amount ?? 0,
),
});
return [
{
...fallbackPayment,
items: [
createPaymentItem(
String(mediaSearch?.id ?? billingItemTypes[0]?.id ?? ''),
{
start_date: invoice.start_date ?? '',
end_date: invoice.end_date ?? '',
payment_item_amount: valueString(
invoice.media_fee ?? 0,
),
tax_percentage: valueString(invoice.tax_percent ?? 0),
net_amount: valueString(
invoice.media_fee_amount ??
invoice.nett_amount ??
0,
),
withholding_tax: '0',
final_net_amount: valueString(
invoice.nett_amount ??
invoice.media_fee_amount ??
0,
),
spending: valueString(invoice.total_spending ?? 0),
is_creditcard: !!invoice.is_credit_card,
},
),
createPaymentItem(
String(
managementSearch?.id ?? billingItemTypes[0]?.id ?? '',
),
{
payment_item_amount: valueString(
invoice.management_fee ?? 0,
),
tax_percentage: valueString(invoice.tax_percent ?? 0),
net_amount: valueString(
invoice.management_fee_amount ??
invoice.management_fee ??
0,
),
withholding_tax: '0',
final_net_amount: valueString(
invoice.management_fee_amount ??
invoice.management_fee ??
0,
),
spending: '0',
},
),
],
},
];
};
export default function Page({
invoice,
existingClient,
unlinkedClients,
availableInvoices,
billingItemTypes,
}: Props) {
const resolvedClient = invoice.client ?? existingClient ?? null;
const form = useForm<InvoiceFormValues>({
invoice_no: invoice.invoice_no ?? "",
linked_invoice_id: invoice.linked_invoice_id ? String(invoice.linked_invoice_id) : "",
is_credit_card: !!invoice.is_credit_card,
invoice_no: invoice.invoice_no ?? '',
linked_invoice_id: invoice.linked_invoice_id
? String(invoice.linked_invoice_id)
: '',
is_paid: !!invoice.is_paid,
payment_no: invoice.payment_no ?? "",
start_date: invoice.start_date ?? "",
end_date: invoice.end_date ?? "",
amount: invoice.amount !== null && invoice.amount !== undefined ? String(invoice.amount) : "",
management_fee:
invoice.management_fee !== null && invoice.management_fee !== undefined
? String(invoice.management_fee)
: "",
management_fee_amount:
invoice.management_fee_amount !== null && invoice.management_fee_amount !== undefined
? String(invoice.management_fee_amount)
: "",
management_fee_tax:
invoice.management_fee_tax !== null && invoice.management_fee_tax !== undefined
? String(invoice.management_fee_tax)
: "",
media_fee:
invoice.media_fee !== null && invoice.media_fee !== undefined
? String(invoice.media_fee)
: "",
media_fee_amount:
invoice.media_fee_amount !== null && invoice.media_fee_amount !== undefined
? String(invoice.media_fee_amount)
: "",
media_fee_tax:
invoice.media_fee_tax !== null && invoice.media_fee_tax !== undefined
? String(invoice.media_fee_tax)
: "",
tax_percent:
invoice.tax_percent !== null && invoice.tax_percent !== undefined
? String(invoice.tax_percent)
: "",
total_spending:
invoice.total_spending !== null && invoice.total_spending !== undefined
? String(invoice.total_spending)
: "",
client_id: invoice.client_id ? String(invoice.client_id) : undefined,
customer_id: invoice.client?.customer_id ?? "",
nett_amount: invoice.nett_amount !== null && invoice.nett_amount !== undefined ? String(invoice.nett_amount) : "",
client_id: resolvedClient ? String(resolvedClient.id) : '',
customer_id: resolvedClient?.customer_id ?? '',
total_sem_amount: valueString(invoice.total_sem_amount ?? '0.00'),
total_net_amount: valueString(
invoice.total_net_amount ?? invoice.nett_amount ?? '0.00',
),
payments: paymentItemsFromInvoice(invoice, billingItemTypes),
});
const isApproved = !!invoice.approved_at;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.data.nett_amount) {
form.setError("nett_amount", "Media Nett Amount is required.");
return;
}
const totalSpending = form.data.total_spending
? parseFloat(form.data.total_spending)
: null;
form.transform((data) => ({
...data,
linked_invoice_id: data.linked_invoice_id || null,
is_credit_card: !!data.is_credit_card,
is_paid: !!data.is_paid,
payment_no: data.payment_no || null,
start_date: data.start_date || null,
end_date: data.end_date || null,
amount: parseFloat(data.amount) || 0,
management_fee: parseFloat(data.management_fee) || 0,
media_fee: parseFloat(data.media_fee) || 0,
tax_percent: parseFloat(data.tax_percent) || 0,
total_spending: totalSpending,
nett_amount: parseFloat(form.data.nett_amount) || 0,
total_sem_amount: parseFloat(data.total_sem_amount) || 0,
total_net_amount: parseFloat(data.total_net_amount) || 0,
payments: data.payments.map((payment) => ({
...payment,
payment_no: payment.payment_no || null,
payment_total_amount:
parseFloat(payment.payment_total_amount) || 0,
payment_nett_amount:
parseFloat(payment.payment_nett_amount) || 0,
items: payment.items.map((item) => ({
...item,
start_date: item.start_date || null,
end_date: item.end_date || null,
billing_item_types_id: parseInt(
item.billing_item_types_id,
10,
),
payment_item_amount: parseAmount(
item.payment_item_amount,
),
tax_percentage: parseAmount(item.tax_percentage),
net_amount: parseAmount(item.net_amount),
withholding_tax: parseAmount(item.withholding_tax),
final_net_amount: finalNetAmount(
parseAmount(item.net_amount),
parseAmount(item.withholding_tax),
),
spending: parseFloat(item.spending) || 0,
is_creditcard: !!item.is_creditcard,
})),
})),
}));
form.put(route("client-invoices.update", { invoice: invoice.id }));
form.put(route('client-invoices.update', { invoice: invoice.id }));
};
const handleApprove = () => {
router.patch(route("client-invoices.approve", { invoice: invoice.id }));
router.patch(route('client-invoices.approve', { invoice: invoice.id }));
};
const invoiceOptions = availableInvoices.map((item) => ({
value: String(item.id),
label: item.invoice_no,
}));
const requiresClient = !invoice.client_id;
return (
<AppLayout>
<Container size="lg" px="xs">
<Group position="apart" mb="md">
<Group gap="sm">
<Group spacing="sm">
<Title order={2}>Edit invoice</Title>
<Badge color={isApproved ? "green" : "yellow"} variant="light">
{isApproved ? "Approved" : "Pending approval"}
<Badge
color={isApproved ? 'green' : 'yellow'}
variant="light"
>
{isApproved ? 'Approved' : 'Pending approval'}
</Badge>
</Group>
<Group gap="sm">
<Group spacing="sm">
{/* {!isApproved && (
<Button color="green" onClick={handleApprove}>
Approve invoice
</Button>
)} */}
<Button
component="a"
href={route('client-invoices.getPdfInvoice', {
id: invoice.id,
})}
target="_blank"
rel="noopener noreferrer"
leftIcon={<IconFileDollar size={16} />}
variant="light"
color="green"
>
View invoice
</Button>
<Button
component={Link}
href={route("google-ads.accounts.show", { id: invoice.client?.customer_id ?? "" })}
href={
resolvedClient?.customer_id
? route('google-ads.accounts.show', {
id: resolvedClient.customer_id,
})
: route('dashboard')
}
leftIcon={<IconArrowLeft size={16} />}
variant="outline"
>
Back to account
{resolvedClient ? 'Back to account' : 'Back'}
</Button>
</Group>
</Group>
<Card withBorder>
<InvoiceForm form={form} onSubmit={handleSubmit} invoiceOptions={invoiceOptions} />
</Card>
<InvoiceForm
form={form}
onSubmit={handleSubmit}
invoiceOptions={invoiceOptions}
billingItemTypes={billingItemTypes}
showClientLink
requiresClient={requiresClient}
clientOptions={unlinkedClients}
pendingClientName={invoice.pending_client_name ?? null}
/>
</Container>
</AppLayout>
);

View File

@ -22,7 +22,6 @@ import {
IconBuilding,
IconChecklist,
IconFileInvoice,
IconLinkOff,
} from '@tabler/icons-react';
type DashboardStats = {
@ -37,9 +36,7 @@ type RecentInvoice = {
id: number;
invoice_no: string;
approved_at: string | null;
management_fee: string | number | null;
media_fee: string | number | null;
nett_amount: string | number | null;
total_net_amount: string | number | null;
created_at: string | null;
client: {
name: string;
@ -293,7 +290,7 @@ export default function Dashboard({
<tr>
<th>Invoice</th>
<th>Client</th>
<th style={{ textAlign: 'right' }}>Nett</th>
<th style={{ textAlign: 'right' }}>Net</th>
<th>Status</th>
</tr>
</thead>
@ -313,7 +310,7 @@ export default function Dashboard({
<Text size="sm" lineClamp={1}>{invoice.client?.name ?? '-'}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Text size="sm" weight={700}>RM {formatAmount(invoice.nett_amount)}</Text>
<Text size="sm" weight={700}>RM {formatAmount(invoice.total_net_amount)}</Text>
</td>
<td>
<Badge color={invoice.approved_at ? 'green' : 'orange'} variant="light">

View File

@ -63,42 +63,85 @@ export interface Client {
sales_person?: string;
}
export interface Role{
id:number;
name:string;
created_at:string;
updated_at:string;
export interface Role {
id: number;
name: string;
created_at: string;
updated_at: string;
}
export interface ClientInvoice {
id: number;
client_id: number;
client_id: number | null;
pending_sql_acc_code?: string | null;
pending_client_name?: string | null;
invoice_no: string;
linked_invoice_id?: number | null;
approved_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
total_spend?: number | string;
total_sem_amount?: number | string | null;
total_net_amount?: number | string | null;
is_credit_card?: boolean;
is_paid?: boolean;
approved_at?: string | null;
start_date?: string | null;
end_date?: string | null;
payment_no?: string | null;
amount?: number | string | null;
management_fee?: number | string | null;
management_fee_amount?: number | string | null;
management_fee_tax?: number | string | null;
media_fee?: number | string | null;
media_fee_amount?: number | string | null;
media_fee_tax?: number | string | null;
tax_percent?: number | string | null;
nett_amount?: number | string | null;
total_spending?: number | string | null;
payments?: ClientInvoicePayment[];
linked_invoices?: ClientInvoice[];
}
export interface BillingItemType {
id: number;
name: string;
sql_acc_code: string | null;
nett_contribution: boolean;
fee_type: string | null;
type: string | null;
campaign_type: string | null;
}
export interface ClientInvoicePayment {
id?: number;
client_invoice_id?: number;
payment_no: string | null;
payment_total_amount: number | string;
payment_tax_percentage?: number | string;
payment_nett_amount: number | string;
items: ClientInvoicePaymentItem[];
}
export interface ClientInvoicePaymentItem {
id?: number;
client_invoice_payment_id?: number;
billing_item_types_id: number;
billing_item_type?: BillingItemType;
start_date: string | null;
end_date: string | null;
payment_no: string | null;
amount: number;
total_spend?: number | string;
management_fee?: number;
management_fee_amount?: number;
management_fee_tax?: number;
media_fee?: number;
media_fee_amount?: number;
media_fee_tax?: number;
tax_percent?: number;
nett_amount?: number;
total_spending?: number | null;
linked_invoices?: ClientInvoice[];
payment_item_amount: number | string;
tax_percentage: number | string;
net_amount: number | string;
withholding_tax: number | string;
final_net_amount: number | string;
spending: number | string;
is_creditcard: boolean;
}
export interface ClientInvoiceAdjustment {
id: number;
client_id?: number | null;
entry_type: "debit" | "credit";
entry_type: 'debit' | 'credit';
amount: number;
remark: string | null;
created_at?: string | null;
@ -157,4 +200,4 @@ export interface ProjectActivity {
notification_sent_at: Date | null;
}
export type FormStatus = "create" | "update";
export type FormStatus = 'create' | 'update';

View File

@ -98,6 +98,105 @@
->assertJsonPath('invoice.pending_client_name', 'New Client');
});
it('opens the edit form for pending invoices that still need a client', function () {
$user = User::factory()->create();
$invoice = ClientInvoice::create([
'client_id' => null,
'pending_sql_acc_code' => 'SEM-008',
'pending_client_name' => 'Pending Client',
'invoice_no' => 'INV-NEEDS-CLIENT',
'approved_at' => null,
'management_fee' => 120,
'media_fee' => 880,
]);
$this->actingAs($user)
->get(route('client-invoices.edit', $invoice))
->assertOk()
->assertInertia(fn ($page) => $page
->component('client-invoices/edit')
->where('invoice.id', $invoice->id)
->where('invoice.pending_sql_acc_code', 'SEM-008')
->where('unlinkedClients', [])
);
});
it('links a pending invoice client while saving the edit form', function () {
$user = User::factory()->create();
$client = Client::create([
'name' => 'Acme',
'customer_id' => '1234567896',
'status' => 'ENABLED',
'time_zone' => 'Asia/Kuala_Lumpur',
'industry' => 'Marketing',
'sql_acc_code' => null,
]);
$invoice = ClientInvoice::create([
'client_id' => null,
'pending_sql_acc_code' => 'SEM-009',
'pending_client_name' => 'Acme',
'invoice_no' => 'INV-LINK-EDIT',
'approved_at' => null,
'management_fee' => 120,
'media_fee' => 880,
]);
$this->actingAs($user)
->put(route('client-invoices.update', $invoice), [
'client_id' => $client->id,
'invoice_no' => 'INV-LINK-EDIT',
'management_fee' => 120,
'media_fee' => 880,
])
->assertRedirect();
$invoice->refresh();
expect($invoice->client_id)->toBe($client->id);
expect($invoice->pending_sql_acc_code)->toBeNull();
expect($invoice->approved_at)->not->toBeNull();
expect($client->refresh()->sql_acc_code)->toBeNull();
});
it('changes the linked google client from the edit form', function () {
$user = User::factory()->create();
$originalClient = Client::create([
'name' => 'Original',
'customer_id' => '1234567897',
'status' => 'ENABLED',
'time_zone' => 'Asia/Kuala_Lumpur',
'industry' => 'Marketing',
'sql_acc_code' => 'SEM-010',
]);
$newClient = Client::create([
'name' => 'Replacement',
'customer_id' => '1234567898',
'status' => 'ENABLED',
'time_zone' => 'Asia/Kuala_Lumpur',
'industry' => 'Marketing',
'sql_acc_code' => 'SEM-011',
]);
$invoice = ClientInvoice::create([
'client_id' => $originalClient->id,
'invoice_no' => 'INV-CHANGE-CLIENT',
'approved_at' => null,
'management_fee' => 120,
'media_fee' => 880,
]);
$this->actingAs($user)
->put(route('client-invoices.update', $invoice), [
'client_id' => $newClient->id,
'invoice_no' => 'INV-CHANGE-CLIENT',
'management_fee' => 120,
'media_fee' => 880,
])
->assertRedirect();
expect($invoice->refresh()->client_id)->toBe($newClient->id);
});
it('lists client invoices pending approval through the api', function () {
$client = Client::create([
'name' => 'Acme',