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; namespace App\Console\Commands;
use App\Models\Client; use App\Models\Client;
use App\Models\Customers;
use App\Models\ClientInvoice; use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation; use App\Models\ClientUserAssignation;
use App\Models\User; use App\Models\User;
use App\Services\ClientInvoiceApprovalService; use App\Services\ClientInvoicePaymentSyncService;
use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Rap2hpoutre\FastExcel\FastExcel; use Rap2hpoutre\FastExcel\FastExcel;
use Carbon\Carbon;
class CreateClientInvoice extends Command class CreateClientInvoice extends Command
{ {
protected $signature = 'customer:create-invoice'; protected $signature = 'customer:create-invoice';
protected $description = 'Create client invoice'; protected $description = 'Create client invoice';
public function handle() public function handle()
{ {
$adsService = new GoogleAdsService(); $paymentSyncService = app(ClientInvoicePaymentSyncService::class);
$approvalService = new ClientInvoiceApprovalService();
try { try {
DB::beginTransaction(); DB::beginTransaction();
$collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_EJ.csv')); $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(); $array = $collection->toArray();
foreach ($array as $row) { $linkedInvoices = [];
$startDate = Carbon::parse($row['start_date'])->format('Y-m-d'); $linkedInvoiceTargets = $this->linkedInvoiceTargets($array);
$endDate = Carbon::parse($row['end_date'])->format('Y-m-d');
$client = Client::where('customer_id', str_replace('-', '', $row['customer_id']))->first(); foreach ($this->groupRowsByInvoice($array) as $invoiceNo => $invoiceRows) {
if ($client) { $invoiceClient = null;
$client->update([ $linkedInvoiceNo = null;
'industry' => $row['industry'], $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,
]); ]);
$salesUser = User::where('name', $row['sales'])->first();
$pic = User::where('name', $row['pic'])->first(); 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' => $this->rowValue($row, 'industry'),
]);
$salesUser = User::where('name', $this->rowValue($row, 'sales'))->first();
$pic = User::where('name', $this->rowValue($row, 'pic'))->first();
if ($pic) { if ($pic) {
ClientUserAssignation::updateOrCreate( 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)) { $rowLinkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', ''));
// $totalSpend = 0; $managementFee = $this->amount($this->rowValue($row, 'management_fee', 0));
// $spend += number_format($totalSpend, 2, '.', ''); $mediaFee = $this->amount($this->rowValue($row, 'media_fee', 0));
// } else { $paymentNettAmount = $mediaFee + $managementFee;
// $metrics = $adsService->listCampaignsMetricsById( $tax = $this->tax($this->rowValue($row, 'tax', 0), $paymentNettAmount, $startDate);
// $row['customer_id'], $paymentTotalAmount = $paymentNettAmount + $tax['amount'];
// $campaign['id'], $isCreditCard = $mediaFee == 0 && ! in_array($invoiceNo, $linkedInvoiceTargets, true);
// $startDate,
// $endDate $payments[] = [
// ); 'payment_no' => $this->nullableString($this->rowValue($row, 'payment_no')),
// Log::info('Hydrated client data', [ 'payment_total_amount' => $paymentTotalAmount,
// 'metrics' => $metrics, 'payment_nett_amount' => $paymentNettAmount,
// ]); 'items' => $this->paymentItems(
// $totalSpend = array_sum(array_column($metrics, 'actual_spend')); $mediaFee,
// $spend += number_format($totalSpend, 2, '.', ''); $managementFee,
// } $tax['percentage'],
// } $startDate,
// } else { $endDate,
$spend = 0; $isCreditCard,
// } ),
$managementFee = intval(str_replace(',', '', $row['management_fee'])) ?? 0; ];
$mediaFee = intval(str_replace(',', '', $row['media_fee'])) ?? 0;
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0; $totalSemAmount += $paymentTotalAmount;
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0; $totalNetAmount += $paymentNettAmount;
if ($rowLinkedInvoiceNo !== null) {
$linkedInvoiceNo ??= $rowLinkedInvoiceNo;
}
}
if ($invoiceClient === null || $payments === []) {
continue;
}
$invoice = ClientInvoice::updateOrCreate( $invoice = ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']], ['invoice_no' => $invoiceNo],
[ [
'client_id' => $row['client_id'], 'client_id' => $invoiceClient->id,
'is_credit_card' => $mediaFee == 0 ? 1 : 0, 'approved_at' => now(),
'start_date' => $startDate, 'total_sem_amount' => $totalSemAmount,
'end_date' => $endDate, 'total_net_amount' => $totalNetAmount,
'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,
] ]
); );
$approvalService->approve($invoice); $paymentSyncService->sync($invoice, $payments);
} else {
Log::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id'])); if ($linkedInvoiceNo !== null) {
continue; // Skip this row if client not found $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(), [ Log::error('Error project linkage : '.$e->getMessage(), [
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
return 1; 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\Http\Controllers\Controller;
use App\Models\ClientInvoice; use App\Models\ClientInvoice;
use App\Services\ClientLookupService;
use App\Services\ClientInvoiceApprovalService; use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientInvoicePaymentSyncService;
use App\Services\ClientLookupService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -16,6 +17,7 @@ class ClientInvoiceController extends Controller
{ {
public function __construct( public function __construct(
private ClientInvoiceApprovalService $approvalService, private ClientInvoiceApprovalService $approvalService,
private ClientInvoicePaymentSyncService $paymentSyncService,
private ClientLookupService $clientLookupService, private ClientLookupService $clientLookupService,
) { ) {
} }
@ -23,7 +25,7 @@ public function __construct(
public function pending(): JsonResponse public function pending(): JsonResponse
{ {
$invoices = ClientInvoice::query() $invoices = ClientInvoice::query()
->with('client:id,name,customer_id') ->with('client:id,name,customer_id', 'payments.items.billingItemType')
->whereNull('approved_at') ->whereNull('approved_at')
->latest('id') ->latest('id')
->get([ ->get([
@ -32,17 +34,23 @@ public function pending(): JsonResponse
'pending_sql_acc_code', 'pending_sql_acc_code',
'pending_client_name', 'pending_client_name',
'invoice_no', 'invoice_no',
'is_credit_card',
'is_paid',
'start_date', 'start_date',
'end_date', 'end_date',
'payment_no', 'payment_no',
'amount',
'management_fee', 'management_fee',
'management_fee_amount', 'management_fee_amount',
'management_fee_tax', 'management_fee_tax',
'media_fee', 'media_fee',
'media_fee_amount', 'media_fee_amount',
'media_fee_tax', 'media_fee_tax',
'tax_percent',
'nett_amount', 'nett_amount',
'total_spending', 'total_spending',
'total_sem_amount',
'total_net_amount',
'created_at', 'created_at',
]); ]);
@ -68,26 +76,25 @@ public function store(Request $request): JsonResponse
'client_id' => ['nullable', 'exists:clients,id'], 'client_id' => ['nullable', 'exists:clients,id'],
'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'], 'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'],
'client_name' => ['nullable', 'string'], 'client_name' => ['nullable', 'string'],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => ['nullable', 'integer'], 'linked_invoice_id' => ['nullable', 'integer'],
'is_credit_card' => ['nullable', 'boolean'], 'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'], 'payments' => ['nullable', 'array', 'min:1'],
'payment_no' => ['nullable', 'string'], 'invoice' => ['nullable', 'array'],
'start_date' => ['nullable', 'date'], 'nett_amount' => ['nullable', 'numeric', 'min:0'],
'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'],
'total_spending' => ['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); // return response()->json([
$nettAmount = $mediaFee / (1 + ($taxPercent / 100)); // 'message' => 'Invoice creation endpoint is under development.',
// ], 501);
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null); $sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null);
$client = ! empty($validated['client_id']) $client = ! empty($validated['client_id'])
? \App\Models\Client::find($validated['client_id']) ? \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([ $invoice = ClientInvoice::create([
'client_id' => $client?->id, 'client_id' => $client?->id,
'pending_sql_acc_code' => $client === null ? $sqlAccCode : null, 'pending_sql_acc_code' => $client === null ? $sqlAccCode : null,
'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null, 'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null,
'invoice_no' => $validated['invoice_no'], 'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, // 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false), 'is_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, 'approved_at' => null,
'payment_no' => $validated['payment_no'] ?? null, // 'payment_no' => $validated['payment_no'] ?? null,
'start_date' => $validated['start_date'] ?? null, // 'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null, // 'end_date' => $validated['end_date'] ?? null,
'management_fee' => $validated['management_fee'], // 'amount' => $mediaFee + $managementFee,
'management_fee_amount' => $validated['management_fee_amount'] ?? null, // 'total_spending' => $validated['total_spending'] ?? 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,
]); ]);
$this->approvalService->requireApproval($invoice); $invoice = $this->paymentSyncService->sync(
$invoice,
$validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice)
);
return response()->json([ // $this->approvalService->requireApproval($invoice);
'message' => 'Invoice created and marked for approval.',
'invoice' => $invoice->fresh(), // return response()->json([
], 201); // 'message' => 'Invoice created and marked for approval.',
// 'invoice' => $invoice->fresh('payments.items.billingItemType'),
// ], 201);
} }
public function approve(ClientInvoice $invoice): JsonResponse public function approve(ClientInvoice $invoice): JsonResponse
@ -313,4 +351,11 @@ private function invoiceBillingTotalsFromPayments(array $payments): array
'management_fee' => $payment['invoice_management_fee'] ?? 0, '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() return redirect()
->back() ->route('google-ads.accounts.show', ['id' => $client->customer_id])
->with('message-info', 'Adjustment added successfully.'); ->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); abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403);
$client = $adjustment->client;
$adjustment->delete(); $adjustment->delete();
return redirect() return redirect()
->back() ->route('google-ads.accounts.show', ['id' => $client->customer_id])
->with('message-info', 'Adjustment deleted successfully.'); ->with('message-info', 'Adjustment deleted successfully.');
} }
} }

View File

@ -2,29 +2,32 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\BillingItemType;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientCustomer; use App\Models\ClientCustomer;
use App\Models\ClientInvoice; use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation; use App\Models\ClientUserAssignation;
use App\Services\ClientInvoiceApprovalService; use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientInvoicePaymentSyncService;
use App\Services\ClientLookupService; use App\Services\ClientLookupService;
use App\Services\UserHierarchyService; use App\Services\UserHierarchyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ClientInvoiceController extends Controller class ClientInvoiceController extends Controller
{ {
public function __construct( public function __construct(
private ClientInvoiceApprovalService $approvalService, private ClientInvoiceApprovalService $approvalService,
private ClientInvoicePaymentSyncService $paymentSyncService,
private UserHierarchyService $hierarchyService, private UserHierarchyService $hierarchyService,
private ClientLookupService $clientLookupService, private ClientLookupService $clientLookupService,
) { ) {}
}
public function create(Request $request): Response public function create(Request $request): Response
{ {
@ -47,23 +50,33 @@ public function create(Request $request): Response
'clientId' => $clientId, 'clientId' => $clientId,
'customerId' => $customerId, 'customerId' => $customerId,
'availableInvoices' => $availableInvoices, 'availableInvoices' => $availableInvoices,
'billingItemTypes' => $this->billingItemTypesForForm(),
]); ]);
} }
public function edit(ClientInvoice $invoice): Response public function edit(ClientInvoice $invoice): Response
{ {
abort_if($invoice->client === null, 409, 'Create the client before editing this invoice.'); $invoice->load('client');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); $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() $availableInvoices = $resolvedClient === null
->where('client_id', $invoice->client_id) ? collect()
: ClientInvoice::query()
->where('client_id', $resolvedClient->id)
->where('id', '!=', $invoice->id) ->where('id', '!=', $invoice->id)
->orderBy('invoice_no') ->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']); ->get(['id', 'invoice_no', 'linked_invoice_id']);
return Inertia::render('client-invoices/edit', [ return Inertia::render('client-invoices/edit', [
'invoice' => $invoice->load('client'), 'invoice' => $invoice->load('client', 'payments.items.billingItemType'),
'availableInvoices' => $availableInvoices, '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([ $validated = $request->validate([
'client_id' => ['required', 'exists:clients,id'], 'client_id' => ['required', 'exists:clients,id'],
'customer_id' => ['required', 'string'], 'customer_id' => ['nullable', 'string'],
'invoice_no' => ['required', 'string'], 'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [ 'linked_invoice_id' => [
'nullable', 'nullable',
@ -80,119 +93,276 @@ public function store(Request $request)
return $query->where('client_id', $request->integer('client_id')); return $query->where('client_id', $request->integer('client_id'));
}), }),
], ],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'], 'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'], ...$this->paymentValidationRules(),
'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'],
]); ]);
$client = Client::findOrFail($validated['client_id']); $client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403); abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$mediaFee = $validated['media_fee']; $invoice = DB::transaction(function () use ($validated) {
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
$invoice = ClientInvoice::create([ $invoice = ClientInvoice::create([
'client_id' => $validated['client_id'], 'client_id' => $validated['client_id'],
'invoice_no' => $validated['invoice_no'], 'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
'is_paid' => (bool) ($validated['is_paid'] ?? false),
'approved_at' => null, 'approved_at' => null,
'payment_no' => $validated['payment_no'] ?? null, 'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
'start_date' => $validated['start_date'] ?? null, 'total_net_amount' => $validated['total_net_amount'] ?? 0,
'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); $this->approvalService->approve($invoice);
return redirect() 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.'); ->with('message-info', 'Invoice created successfully.');
} }
public function update(Request $request, ClientInvoice $invoice) public function update(Request $request, ClientInvoice $invoice)
{ {
abort_if($invoice->client === null, 409, 'Create the client before updating this invoice.'); $invoice->load('client');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); $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([ $validated = $request->validate([
'client_id' => [
'required',
'integer',
'exists:clients,id',
],
'invoice_no' => ['required', 'string'], 'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [ 'linked_invoice_id' => [
'nullable', 'nullable',
'integer', 'integer',
Rule::exists('client_invoices', 'id')->where(function ($query) use ($invoice) { Rule::exists('client_invoices', 'id')->where(function ($query) use ($request, $invoice) {
return $query return $query
->where('client_id', $invoice->client_id) ->where('client_id', $request->integer('client_id') ?: $invoice->client_id)
->where('id', '!=', $invoice->id); ->where('id', '!=', $invoice->id);
}), }),
], ],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'], 'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'], ...$this->paymentValidationRules(),
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'amount' => ['required', 'numeric', 'min:0'],
'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'],
'nett_amount' => ['nullable', 'numeric', 'min:0'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]); ]);
$managementFee = $validated['management_fee']; $invoice = DB::transaction(function () use ($invoice, $validated) {
$mediaFee = $validated['media_fee']; if ($invoice->client_id === null) {
$taxPercent = (float) ($validated['tax_percent'] ?? 0); $this->linkInvoiceClient($invoice, $validated);
$nettAmount = $validated['nett_amount'] ?? ($mediaFee / (1 + ($taxPercent / 100))); $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->update([
'invoice_no' => $validated['invoice_no'], 'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null, 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false), 'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
'is_paid' => (bool) ($validated['is_paid'] ?? false), 'total_net_amount' => $validated['total_net_amount'] ?? 0,
'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,
]); ]);
return $this->paymentSyncService->sync(
$invoice,
$this->paymentsPayload($validated, $invoice)
);
});
if (empty($invoice->approved_at)) { if (empty($invoice->approved_at)) {
$this->approvalService->approve($invoice); $this->approvalService->approve($invoice);
} }
$invoice->load('client');
return redirect() return redirect()
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id]) ->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
->with('message-info', 'Invoice updated successfully.'); ->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'],
'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_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'],
];
}
private function paymentsPayload(array $validated, ClientInvoice $invoice): array
{
if (! empty($validated['payments'])) {
return $validated['payments'];
}
$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) public function approve(ClientInvoice $invoice)
{ {
abort_if($invoice->client === null, 409, 'Create the client before approving this invoice.'); abort_if($invoice->client === null, 409, 'Create the client before approving this invoice.');
@ -211,7 +381,20 @@ public function destroy(ClientInvoice $invoice)
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
} }
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(); $invoice->delete();
});
return redirect() return redirect()
->back() ->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.'.'); ->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', [ return Inertia::render('client-invoices/create-client', [
'invoice' => $invoice->load('client'), 'invoice' => $invoice->load('client'),
'existingClient' => null, 'existingClient' => null,
'unlinkedClients' => $unlinkedClients, 'unlinkedClients' => $this->unlinkedClientOptions(),
]); ]);
} }
@ -332,6 +497,10 @@ public function storeClient(Request $request, ClientInvoice $invoice)
$selectedClientIsLinked = Client::query() $selectedClientIsLinked = Client::query()
->where('id', $validated['client_id']) ->where('id', $validated['client_id'])
->where(function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->exists(); ->exists();
if ($selectedClientIsLinked) { if ($selectedClientIsLinked) {

View File

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

View File

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

View File

@ -17,40 +17,15 @@ class ClientInvoice extends Model
'pending_client_name', 'pending_client_name',
'invoice_no', 'invoice_no',
'linked_invoice_id', 'linked_invoice_id',
'is_credit_card',
'is_paid',
'approved_at', 'approved_at',
'start_date', 'total_sem_amount',
'end_date', 'total_net_amount',
'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',
]; ];
protected $casts = [ protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_credit_card' => 'boolean',
'is_paid' => 'boolean',
'approved_at' => 'datetime', 'approved_at' => 'datetime',
'amount' => 'decimal:2', 'total_sem_amount' => 'decimal:6',
'tax_percent' => 'decimal:2', 'total_net_amount' => 'decimal:6',
'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',
]; ];
public function client(): BelongsTo public function client(): BelongsTo
@ -68,4 +43,8 @@ public function linkedInvoices(): HasMany
return $this->hasMany(self::class, 'linked_invoice_id'); 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(); // User::factory(10)->create();
$this->call(RoleSeeder::class); $this->call([
RoleSeeder::class,
BillingItemTypeSeeder::class,
ClientInvoicePaymentItemBackfillSeeder::class,
]);
$user = User::firstOrCreate( $user = User::firstOrCreate(
['email' => 'test@example.com'], ['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 InvoiceForm, {
import AppLayout from "@/layouts/app-layout"; BillingItemTypeOption,
import { useForm } from "@inertiajs/react"; InvoiceFormValues,
import { Button, Card, Container, Group, Title } from "@mantine/core"; createPayment,
import { IconArrowLeft } from "@tabler/icons-react"; } from '@/forms/account/InvoiceForm';
import { Link } from "@inertiajs/react"; import AppLayout from '@/layouts/app-layout';
import InvoiceForm, { InvoiceFormValues } from "@/forms/account/InvoiceForm"; import { ClientInvoice } from '@/types';
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; linked_invoice_id?: number | null;
}; };
@ -15,64 +18,76 @@ interface Props {
clientId: number; clientId: number;
customerId: string; customerId: string;
availableInvoices: InvoiceOptionSource[]; 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>({ const form = useForm<InvoiceFormValues>({
client_id: String(clientId), client_id: String(clientId),
customer_id: customerId, customer_id: customerId,
invoice_no: "", invoice_no: '',
linked_invoice_id: "", linked_invoice_id: '',
is_credit_card: false,
is_paid: false, is_paid: false,
payment_no: "", total_sem_amount: '0.00',
start_date: "", total_net_amount: '0.00',
end_date: "", payments: [createPayment(billingItemTypes)],
amount: "",
management_fee: "",
management_fee_amount: "",
management_fee_tax: "",
media_fee: "",
media_fee_amount: "",
media_fee_tax: "",
tax_percent: "",
total_spending: "",
nett_amount: "",
}); });
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); 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) => ({ form.transform((data) => ({
...data, ...data,
linked_invoice_id: data.linked_invoice_id || null, linked_invoice_id: data.linked_invoice_id || null,
is_credit_card: !!data.is_credit_card,
is_paid: !!data.is_paid, is_paid: !!data.is_paid,
payment_no: data.payment_no || null, total_sem_amount: parseFloat(data.total_sem_amount) || 0,
start_date: data.start_date || null, total_net_amount: parseFloat(data.total_net_amount) || 0,
end_date: data.end_date || null, payments: data.payments.map((payment) => ({
amount: parseFloat(data.amount) || 0, ...payment,
management_fee: parseFloat(data.management_fee) || 0, payment_no: payment.payment_no || null,
management_fee_amount: data.management_fee_amount ? parseFloat(data.management_fee_amount) : null, payment_total_amount:
management_fee_tax: data.management_fee_tax ? parseFloat(data.management_fee_tax) : null, parseFloat(payment.payment_total_amount) || 0,
media_fee: parseFloat(data.media_fee) || 0, payment_nett_amount:
media_fee_amount: data.media_fee_amount ? parseFloat(data.media_fee_amount) : null, parseFloat(payment.payment_nett_amount) || 0,
media_fee_tax: data.media_fee_tax ? parseFloat(data.media_fee_tax) : null, items: payment.items.map((item) => ({
tax_percent: parseFloat(data.tax_percent) || 0, ...item,
total_spending: totalSpending, start_date: item.start_date || null,
nett_amount: parseFloat(form.data.nett_amount) || 0, 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) => ({ const invoiceOptions = availableInvoices.map((invoice) => ({
@ -87,7 +102,9 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
<Title order={2}>Add invoice</Title> <Title order={2}>Add invoice</Title>
<Button <Button
component={Link} component={Link}
href={route("google-ads.accounts.show", { id: customerId })} href={route('google-ads.accounts.show', {
id: customerId,
})}
leftIcon={<IconArrowLeft size={16} />} leftIcon={<IconArrowLeft size={16} />}
variant="outline" variant="outline"
> >
@ -95,9 +112,12 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
</Button> </Button>
</Group> </Group>
<Card withBorder> <InvoiceForm
<InvoiceForm form={form} onSubmit={handleSubmit} invoiceOptions={invoiceOptions} /> form={form}
</Card> onSubmit={handleSubmit}
invoiceOptions={invoiceOptions}
billingItemTypes={billingItemTypes}
/>
</Container> </Container>
</AppLayout> </AppLayout>
); );

View File

@ -1,139 +1,305 @@
import React from "react"; import InvoiceForm, {
import AppLayout from "@/layouts/app-layout"; BillingItemTypeOption,
import { router, useForm } from "@inertiajs/react"; InvoiceFormValues,
import { Badge, Button, Card, Container, Group, Title } from "@mantine/core"; InvoicePaymentFormValues,
import { IconArrowLeft } from "@tabler/icons-react"; createPayment,
import { Link } from "@inertiajs/react"; createPaymentItem,
import InvoiceForm, { InvoiceFormValues } from "@/forms/account/InvoiceForm"; } from '@/forms/account/InvoiceForm';
import { ClientInvoice } from "@/types"; 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; 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 { 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[]; 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>({ const form = useForm<InvoiceFormValues>({
invoice_no: invoice.invoice_no ?? "", invoice_no: invoice.invoice_no ?? '',
linked_invoice_id: invoice.linked_invoice_id ? String(invoice.linked_invoice_id) : "", linked_invoice_id: invoice.linked_invoice_id
is_credit_card: !!invoice.is_credit_card, ? String(invoice.linked_invoice_id)
: '',
is_paid: !!invoice.is_paid, is_paid: !!invoice.is_paid,
payment_no: invoice.payment_no ?? "", client_id: resolvedClient ? String(resolvedClient.id) : '',
start_date: invoice.start_date ?? "", customer_id: resolvedClient?.customer_id ?? '',
end_date: invoice.end_date ?? "", total_sem_amount: valueString(invoice.total_sem_amount ?? '0.00'),
amount: invoice.amount !== null && invoice.amount !== undefined ? String(invoice.amount) : "", total_net_amount: valueString(
management_fee: invoice.total_net_amount ?? invoice.nett_amount ?? '0.00',
invoice.management_fee !== null && invoice.management_fee !== undefined ),
? String(invoice.management_fee) payments: paymentItemsFromInvoice(invoice, billingItemTypes),
: "",
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) : "",
}); });
const isApproved = !!invoice.approved_at; const isApproved = !!invoice.approved_at;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); 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) => ({ form.transform((data) => ({
...data, ...data,
linked_invoice_id: data.linked_invoice_id || null, linked_invoice_id: data.linked_invoice_id || null,
is_credit_card: !!data.is_credit_card,
is_paid: !!data.is_paid, is_paid: !!data.is_paid,
payment_no: data.payment_no || null, total_sem_amount: parseFloat(data.total_sem_amount) || 0,
start_date: data.start_date || null, total_net_amount: parseFloat(data.total_net_amount) || 0,
end_date: data.end_date || null, payments: data.payments.map((payment) => ({
amount: parseFloat(data.amount) || 0, ...payment,
management_fee: parseFloat(data.management_fee) || 0, payment_no: payment.payment_no || null,
media_fee: parseFloat(data.media_fee) || 0, payment_total_amount:
tax_percent: parseFloat(data.tax_percent) || 0, parseFloat(payment.payment_total_amount) || 0,
total_spending: totalSpending, payment_nett_amount:
nett_amount: parseFloat(form.data.nett_amount) || 0, 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 = () => { 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) => ({ const invoiceOptions = availableInvoices.map((item) => ({
value: String(item.id), value: String(item.id),
label: item.invoice_no, label: item.invoice_no,
})); }));
const requiresClient = !invoice.client_id;
return ( return (
<AppLayout> <AppLayout>
<Container size="lg" px="xs"> <Container size="lg" px="xs">
<Group position="apart" mb="md"> <Group position="apart" mb="md">
<Group gap="sm"> <Group spacing="sm">
<Title order={2}>Edit invoice</Title> <Title order={2}>Edit invoice</Title>
<Badge color={isApproved ? "green" : "yellow"} variant="light"> <Badge
{isApproved ? "Approved" : "Pending approval"} color={isApproved ? 'green' : 'yellow'}
variant="light"
>
{isApproved ? 'Approved' : 'Pending approval'}
</Badge> </Badge>
</Group> </Group>
<Group gap="sm"> <Group spacing="sm">
{/* {!isApproved && ( {/* {!isApproved && (
<Button color="green" onClick={handleApprove}> <Button color="green" onClick={handleApprove}>
Approve invoice Approve invoice
</Button> </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 <Button
component={Link} 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} />} leftIcon={<IconArrowLeft size={16} />}
variant="outline" variant="outline"
> >
Back to account {resolvedClient ? 'Back to account' : 'Back'}
</Button> </Button>
</Group> </Group>
</Group> </Group>
<Card withBorder> <InvoiceForm
<InvoiceForm form={form} onSubmit={handleSubmit} invoiceOptions={invoiceOptions} /> form={form}
</Card> onSubmit={handleSubmit}
invoiceOptions={invoiceOptions}
billingItemTypes={billingItemTypes}
showClientLink
requiresClient={requiresClient}
clientOptions={unlinkedClients}
pendingClientName={invoice.pending_client_name ?? null}
/>
</Container> </Container>
</AppLayout> </AppLayout>
); );

View File

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

View File

@ -72,33 +72,76 @@ export interface Role{
export interface ClientInvoice { export interface ClientInvoice {
id: number; id: number;
client_id: number; client_id: number | null;
pending_sql_acc_code?: string | null;
pending_client_name?: string | null;
invoice_no: string; invoice_no: string;
linked_invoice_id?: number | null; 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_credit_card?: boolean;
is_paid?: 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; start_date: string | null;
end_date: string | null; end_date: string | null;
payment_no: string | null; payment_item_amount: number | string;
amount: number; tax_percentage: number | string;
total_spend?: number | string; net_amount: number | string;
management_fee?: number; withholding_tax: number | string;
management_fee_amount?: number; final_net_amount: number | string;
management_fee_tax?: number; spending: number | string;
media_fee?: number; is_creditcard: boolean;
media_fee_amount?: number;
media_fee_tax?: number;
tax_percent?: number;
nett_amount?: number;
total_spending?: number | null;
linked_invoices?: ClientInvoice[];
} }
export interface ClientInvoiceAdjustment { export interface ClientInvoiceAdjustment {
id: number; id: number;
client_id?: number | null; client_id?: number | null;
entry_type: "debit" | "credit"; entry_type: 'debit' | 'credit';
amount: number; amount: number;
remark: string | null; remark: string | null;
created_at?: string | null; created_at?: string | null;
@ -157,4 +200,4 @@ export interface ProjectActivity {
notification_sent_at: Date | null; 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'); ->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 () { it('lists client invoices pending approval through the api', function () {
$client = Client::create([ $client = Client::create([
'name' => 'Acme', 'name' => 'Acme',