feat: changes to invoice and payment details
This commit is contained in:
parent
688ac63c1c
commit
d9eaaeaa92
@ -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);
|
||||
}
|
||||
}
|
||||
@ -3,42 +3,73 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Customers;
|
||||
use App\Models\ClientInvoice;
|
||||
use App\Models\ClientUserAssignation;
|
||||
use App\Models\User;
|
||||
use App\Services\ClientInvoiceApprovalService;
|
||||
use App\Services\ClientInvoicePaymentSyncService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\GoogleAdsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Rap2hpoutre\FastExcel\FastExcel;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CreateClientInvoice extends Command
|
||||
{
|
||||
protected $signature = 'customer:create-invoice';
|
||||
|
||||
protected $description = 'Create client invoice';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$adsService = new GoogleAdsService();
|
||||
$approvalService = new ClientInvoiceApprovalService();
|
||||
$paymentSyncService = app(ClientInvoicePaymentSyncService::class);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_EJ.csv'));
|
||||
// $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_HJ.csv'));
|
||||
// $collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_K.csv'));
|
||||
$array = $collection->toArray();
|
||||
foreach ($array as $row) {
|
||||
$startDate = Carbon::parse($row['start_date'])->format('Y-m-d');
|
||||
$endDate = Carbon::parse($row['end_date'])->format('Y-m-d');
|
||||
$client = Client::where('customer_id', str_replace('-', '', $row['customer_id']))->first();
|
||||
if ($client) {
|
||||
$client->update([
|
||||
'industry' => $row['industry'],
|
||||
$linkedInvoices = [];
|
||||
$linkedInvoiceTargets = $this->linkedInvoiceTargets($array);
|
||||
|
||||
foreach ($this->groupRowsByInvoice($array) as $invoiceNo => $invoiceRows) {
|
||||
$invoiceClient = null;
|
||||
$linkedInvoiceNo = null;
|
||||
$payments = [];
|
||||
$totalSemAmount = 0.0;
|
||||
$totalNetAmount = 0.0;
|
||||
|
||||
foreach ($invoiceRows as $row) {
|
||||
$customerId = str_replace('-', '', (string) $this->rowValue($row, 'customer_id', ''));
|
||||
$startDate = $this->date($this->rowValue($row, 'start_date'));
|
||||
$endDate = $this->date($this->rowValue($row, 'end_date'));
|
||||
$client = Client::where('customer_id', $customerId)->first();
|
||||
|
||||
if (! $client) {
|
||||
Log::warning('Client not found for customer_id: '.$customerId, [
|
||||
'invoice_no' => $invoiceNo,
|
||||
]);
|
||||
$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) {
|
||||
ClientUserAssignation::updateOrCreate(
|
||||
[
|
||||
@ -61,65 +92,66 @@ public function handle()
|
||||
]
|
||||
);
|
||||
}
|
||||
$row['client_id'] = $client->id;
|
||||
// if ($client->status != 'CANCELED') {
|
||||
// $campaigns = $adsService->listCampaigns($row['customer_id']);
|
||||
// Log::info('Hydrated client data', [
|
||||
// 'campaigns' => $campaigns,
|
||||
// ]);
|
||||
// foreach ($campaigns as $campaign) {
|
||||
// Log::info('Hydrated client data', [
|
||||
// 'campaigns' => $campaign['id'],
|
||||
// ]);
|
||||
|
||||
// if (empty($invoice->start_date) || empty($invoice->end_date)) {
|
||||
// $totalSpend = 0;
|
||||
// $spend += number_format($totalSpend, 2, '.', '');
|
||||
// } else {
|
||||
// $metrics = $adsService->listCampaignsMetricsById(
|
||||
// $row['customer_id'],
|
||||
// $campaign['id'],
|
||||
// $startDate,
|
||||
// $endDate
|
||||
// );
|
||||
// Log::info('Hydrated client data', [
|
||||
// 'metrics' => $metrics,
|
||||
// ]);
|
||||
// $totalSpend = array_sum(array_column($metrics, 'actual_spend'));
|
||||
// $spend += number_format($totalSpend, 2, '.', '');
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
$spend = 0;
|
||||
// }
|
||||
$managementFee = intval(str_replace(',', '', $row['management_fee'])) ?? 0;
|
||||
$mediaFee = intval(str_replace(',', '', $row['media_fee'])) ?? 0;
|
||||
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0;
|
||||
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0;
|
||||
$rowLinkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', ''));
|
||||
$managementFee = $this->amount($this->rowValue($row, 'management_fee', 0));
|
||||
$mediaFee = $this->amount($this->rowValue($row, 'media_fee', 0));
|
||||
$paymentNettAmount = $mediaFee + $managementFee;
|
||||
$tax = $this->tax($this->rowValue($row, 'tax', 0), $paymentNettAmount, $startDate);
|
||||
$paymentTotalAmount = $paymentNettAmount + $tax['amount'];
|
||||
$isCreditCard = $mediaFee == 0 && ! in_array($invoiceNo, $linkedInvoiceTargets, true);
|
||||
|
||||
$payments[] = [
|
||||
'payment_no' => $this->nullableString($this->rowValue($row, 'payment_no')),
|
||||
'payment_total_amount' => $paymentTotalAmount,
|
||||
'payment_nett_amount' => $paymentNettAmount,
|
||||
'items' => $this->paymentItems(
|
||||
$mediaFee,
|
||||
$managementFee,
|
||||
$tax['percentage'],
|
||||
$startDate,
|
||||
$endDate,
|
||||
$isCreditCard,
|
||||
),
|
||||
];
|
||||
|
||||
$totalSemAmount += $paymentTotalAmount;
|
||||
$totalNetAmount += $paymentNettAmount;
|
||||
|
||||
if ($rowLinkedInvoiceNo !== null) {
|
||||
$linkedInvoiceNo ??= $rowLinkedInvoiceNo;
|
||||
}
|
||||
}
|
||||
|
||||
if ($invoiceClient === null || $payments === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoice = ClientInvoice::updateOrCreate(
|
||||
['invoice_no' => $row['invoice_no']],
|
||||
['invoice_no' => $invoiceNo],
|
||||
[
|
||||
'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,
|
||||
'client_id' => $invoiceClient->id,
|
||||
'approved_at' => now(),
|
||||
'total_sem_amount' => $totalSemAmount,
|
||||
'total_net_amount' => $totalNetAmount,
|
||||
]
|
||||
);
|
||||
|
||||
$approvalService->approve($invoice);
|
||||
} else {
|
||||
Log::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id']));
|
||||
continue; // Skip this row if client not found
|
||||
$paymentSyncService->sync($invoice, $payments);
|
||||
|
||||
if ($linkedInvoiceNo !== null) {
|
||||
$linkedInvoices[$invoiceNo] = $linkedInvoiceNo;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($linkedInvoices as $invoiceNo => $linkedInvoiceNo) {
|
||||
$invoice = ClientInvoice::where('invoice_no', $invoiceNo)->first();
|
||||
$linkedInvoice = ClientInvoice::where('invoice_no', $linkedInvoiceNo)->first();
|
||||
|
||||
if ($invoice && $linkedInvoice) {
|
||||
$invoice->update([
|
||||
'linked_invoice_id' => $linkedInvoice->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +161,217 @@ public function handle()
|
||||
Log::error('Error project linkage : '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function rowValue(array $row, string $key, mixed $default = null): mixed
|
||||
{
|
||||
$normalizedKey = $this->normalizeHeader($key);
|
||||
|
||||
foreach ($row as $rowKey => $value) {
|
||||
if ($this->normalizeHeader((string) $rowKey) === $normalizedKey) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $header): string
|
||||
{
|
||||
return trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($header)), '_');
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function amount(mixed $value): float
|
||||
{
|
||||
$normalized = preg_replace('/[^0-9.\-]/', '', (string) $value);
|
||||
|
||||
return is_numeric($normalized) ? (float) $normalized : 0.0;
|
||||
}
|
||||
|
||||
private function invoiceNo(mixed $value): string
|
||||
{
|
||||
$invoiceNo = trim((string) $value);
|
||||
|
||||
if (preg_match('/^\s*([A-Za-z0-9]+)/', $invoiceNo, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return $invoiceNo;
|
||||
}
|
||||
|
||||
private function linkedInvoiceNo(mixed $value): ?string
|
||||
{
|
||||
$linkedInvoiceNo = $this->invoiceNo($value);
|
||||
|
||||
if ($linkedInvoiceNo === '' || $linkedInvoiceNo === '0') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $linkedInvoiceNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function groupRowsByInvoice(array $rows): array
|
||||
{
|
||||
$groupedRows = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$invoiceNo = $this->invoiceNo($this->rowValue($row, 'invoice_no'));
|
||||
|
||||
if ($invoiceNo === '') {
|
||||
Log::warning('Invoice row skipped because invoice_no is empty.');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupedRows[$invoiceNo][] = $row;
|
||||
}
|
||||
|
||||
return $groupedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function linkedInvoiceTargets(array $rows): array
|
||||
{
|
||||
$invoiceNumbers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$linkedInvoiceNo = $this->linkedInvoiceNo($this->rowValue($row, 'linked_invoice_no', ''));
|
||||
|
||||
if ($linkedInvoiceNo !== null) {
|
||||
$invoiceNumbers[] = $linkedInvoiceNo;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($invoiceNumbers));
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?Carbon
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = trim((string) $value);
|
||||
|
||||
foreach (['d/m/y', 'd/m/Y', 'Y-m-d', 'd-m-y', 'd-m-Y'] as $format) {
|
||||
try {
|
||||
return Carbon::createFromFormat($format, $date)->startOfDay();
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return Carbon::parse($date)->startOfDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{percentage: float, amount: float}
|
||||
*/
|
||||
private function tax(mixed $value, float $amount, ?Carbon $startDate): array
|
||||
{
|
||||
$tax = $this->amount($value);
|
||||
|
||||
if ($tax > 100 && $amount > 0) {
|
||||
return [
|
||||
'percentage' => ($tax / $amount) * 100,
|
||||
'amount' => $tax,
|
||||
];
|
||||
}
|
||||
|
||||
if ($tax > 0) {
|
||||
return [
|
||||
'percentage' => $tax,
|
||||
'amount' => $this->taxAmount($amount, $tax),
|
||||
];
|
||||
}
|
||||
|
||||
if ($startDate !== null && $startDate->greaterThanOrEqualTo(Carbon::parse('2024-03-01'))) {
|
||||
return [
|
||||
'percentage' => 8,
|
||||
'amount' => $this->taxAmount($amount, 8),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'percentage' => 6,
|
||||
'amount' => $this->taxAmount($amount, 6),
|
||||
];
|
||||
}
|
||||
|
||||
private function taxAmount(float $amount, float $taxPercentage): float
|
||||
{
|
||||
return $amount * ($taxPercentage / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function paymentItems(
|
||||
float $mediaFee,
|
||||
float $managementFee,
|
||||
float $taxPercentage,
|
||||
?Carbon $startDate,
|
||||
?Carbon $endDate,
|
||||
bool $isCreditCard,
|
||||
): array {
|
||||
$items = [];
|
||||
|
||||
if ($mediaFee > 0 || $isCreditCard) {
|
||||
$mediaTax = $this->taxAmount($mediaFee, $taxPercentage);
|
||||
$items[] = [
|
||||
'billing_item_types_id' => 1,
|
||||
'start_date' => $startDate?->format('Y-m-d'),
|
||||
'end_date' => $endDate?->format('Y-m-d'),
|
||||
'payment_item_amount' => $mediaFee + $mediaTax,
|
||||
'tax_percentage' => $taxPercentage,
|
||||
'net_amount' => $mediaFee,
|
||||
'withholding_tax' => $taxPercentage,
|
||||
'final_net_amount' => $this->finalNetAmount($mediaFee, $taxPercentage),
|
||||
'spending' => 0,
|
||||
'is_creditcard' => $isCreditCard,
|
||||
];
|
||||
}
|
||||
|
||||
if ($managementFee > 0) {
|
||||
$managementTax = $this->taxAmount($managementFee, $taxPercentage);
|
||||
$items[] = [
|
||||
'billing_item_types_id' => 2,
|
||||
'start_date' => null,
|
||||
'end_date' => null,
|
||||
'payment_item_amount' => $managementFee + $managementTax,
|
||||
'tax_percentage' => $taxPercentage,
|
||||
'net_amount' => $managementFee,
|
||||
'withholding_tax' => 0,
|
||||
'final_net_amount' => $this->finalNetAmount($managementFee, 0),
|
||||
'spending' => 0,
|
||||
'is_creditcard' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function finalNetAmount(float $netAmount, float $withholdingTax): float
|
||||
{
|
||||
return $netAmount / (1 + ($withholdingTax / 100));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ClientInvoice;
|
||||
use App\Services\ClientLookupService;
|
||||
use App\Services\ClientInvoiceApprovalService;
|
||||
use App\Services\ClientInvoicePaymentSyncService;
|
||||
use App\Services\ClientLookupService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@ -16,6 +17,7 @@ class ClientInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ClientInvoiceApprovalService $approvalService,
|
||||
private ClientInvoicePaymentSyncService $paymentSyncService,
|
||||
private ClientLookupService $clientLookupService,
|
||||
) {
|
||||
}
|
||||
@ -23,7 +25,7 @@ public function __construct(
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$invoices = ClientInvoice::query()
|
||||
->with('client:id,name,customer_id')
|
||||
->with('client:id,name,customer_id', 'payments.items.billingItemType')
|
||||
->whereNull('approved_at')
|
||||
->latest('id')
|
||||
->get([
|
||||
@ -32,17 +34,23 @@ public function pending(): JsonResponse
|
||||
'pending_sql_acc_code',
|
||||
'pending_client_name',
|
||||
'invoice_no',
|
||||
'is_credit_card',
|
||||
'is_paid',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'payment_no',
|
||||
'amount',
|
||||
'management_fee',
|
||||
'management_fee_amount',
|
||||
'management_fee_tax',
|
||||
'media_fee',
|
||||
'media_fee_amount',
|
||||
'media_fee_tax',
|
||||
'tax_percent',
|
||||
'nett_amount',
|
||||
'total_spending',
|
||||
'total_sem_amount',
|
||||
'total_net_amount',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
@ -68,26 +76,25 @@ public function store(Request $request): JsonResponse
|
||||
'client_id' => ['nullable', 'exists:clients,id'],
|
||||
'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'],
|
||||
'client_name' => ['nullable', 'string'],
|
||||
'invoice_no' => ['required', 'string'],
|
||||
'linked_invoice_id' => ['nullable', 'integer'],
|
||||
'is_credit_card' => ['nullable', 'boolean'],
|
||||
'is_paid' => ['nullable', 'boolean'],
|
||||
'payment_no' => ['nullable', 'string'],
|
||||
'start_date' => ['nullable', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'management_fee' => ['required', 'numeric', 'min:0'],
|
||||
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'media_fee' => ['required', 'numeric', 'min:0'],
|
||||
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'payments' => ['nullable', 'array', 'min:1'],
|
||||
'invoice' => ['nullable', 'array'],
|
||||
'nett_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'total_spending' => ['nullable', 'numeric', 'min:0'],
|
||||
'sem_invoice_items' => ['nullable', 'array'],
|
||||
'sem_items' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$mediaFee = $validated['media_fee'];
|
||||
// return response()->json($validated['sem_invoice_items']);
|
||||
// Log::debug('Validated request data for creating client invoice.', [
|
||||
// 'validated' => $validated,
|
||||
// ]);
|
||||
// Log::info('Received request to create client invoice.');
|
||||
|
||||
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
|
||||
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
|
||||
// return response()->json([
|
||||
// 'message' => 'Invoice creation endpoint is under development.',
|
||||
// ], 501);
|
||||
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null);
|
||||
$client = ! empty($validated['client_id'])
|
||||
? \App\Models\Client::find($validated['client_id'])
|
||||
@ -106,35 +113,66 @@ public function store(Request $request): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
// return response()->json([
|
||||
// 'message' => 'Invoice creation endpoint is under development.',
|
||||
// 'items' => $validated['sem_items']
|
||||
// ], 501);
|
||||
$semItems = [];
|
||||
if (! empty($validated['sem_items'])) {
|
||||
foreach ($validated['sem_items'] as $item) {
|
||||
$semItems[] = [
|
||||
'sql_acc_code' => $item['sql_acc_code'] ?? null,
|
||||
'amount' => $item['exact_price'] ?? null,
|
||||
'tax_percent' => $item['item']['sql_acc_tax_percent'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
// return response()->json([
|
||||
// 'message' => 'Invoice creation endpoint is under development.',
|
||||
// ], 501);
|
||||
// if (! empty($validated['sem_invoice_items'])) {
|
||||
// foreach ($validated['sem_invoice_items'] as $item) {
|
||||
// Log::debug('Validating SEM invoice item.', [
|
||||
// 'item' => $item,
|
||||
// ]);
|
||||
// // if (! isset($item['sql_acc_code']) || ! isset($item['amount'])) {
|
||||
// // throw ValidationException::withMessages([
|
||||
// // 'sem_invoice_items' => 'Each SEM invoice item must include sql_acc_code and amount.',
|
||||
// // ]);
|
||||
// // }
|
||||
// }
|
||||
// }
|
||||
return response()->json([
|
||||
'message' => 'Invoice creation endpoint is under development.',
|
||||
'items' => $semItems
|
||||
], 501);
|
||||
$invoice = ClientInvoice::create([
|
||||
'client_id' => $client?->id,
|
||||
'pending_sql_acc_code' => $client === null ? $sqlAccCode : null,
|
||||
'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null,
|
||||
'invoice_no' => $validated['invoice_no'],
|
||||
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
|
||||
// 'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
|
||||
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
|
||||
'is_paid' => (bool) ($validated['is_paid'] ?? false),
|
||||
// 'is_paid' => (bool) ($validated['is_paid'] ?? false),
|
||||
'approved_at' => null,
|
||||
'payment_no' => $validated['payment_no'] ?? null,
|
||||
'start_date' => $validated['start_date'] ?? null,
|
||||
'end_date' => $validated['end_date'] ?? null,
|
||||
'management_fee' => $validated['management_fee'],
|
||||
'management_fee_amount' => $validated['management_fee_amount'] ?? null,
|
||||
'management_fee_tax' => $validated['management_fee_tax'] ?? null,
|
||||
'media_fee' => $validated['media_fee'],
|
||||
'media_fee_amount' => $validated['media_fee_amount'] ?? null,
|
||||
'media_fee_tax' => $validated['media_fee_tax'] ?? null,
|
||||
'tax_percent' => null,
|
||||
'nett_amount' => $nettAmount,
|
||||
'total_spending' => $validated['total_spending'] ?? null,
|
||||
// 'payment_no' => $validated['payment_no'] ?? null,
|
||||
// 'start_date' => $validated['start_date'] ?? null,
|
||||
// 'end_date' => $validated['end_date'] ?? null,
|
||||
// 'amount' => $mediaFee + $managementFee,
|
||||
// 'total_spending' => $validated['total_spending'] ?? null,
|
||||
]);
|
||||
|
||||
$this->approvalService->requireApproval($invoice);
|
||||
$invoice = $this->paymentSyncService->sync(
|
||||
$invoice,
|
||||
$validated['payments'] ?? $this->paymentSyncService->legacyPaymentsFor($invoice)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invoice created and marked for approval.',
|
||||
'invoice' => $invoice->fresh(),
|
||||
], 201);
|
||||
// $this->approvalService->requireApproval($invoice);
|
||||
|
||||
// return response()->json([
|
||||
// 'message' => 'Invoice created and marked for approval.',
|
||||
// 'invoice' => $invoice->fresh('payments.items.billingItemType'),
|
||||
// ], 201);
|
||||
}
|
||||
|
||||
public function approve(ClientInvoice $invoice): JsonResponse
|
||||
@ -313,4 +351,11 @@ private function invoiceBillingTotalsFromPayments(array $payments): array
|
||||
'management_fee' => $payment['invoice_management_fee'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function netFromGross(float $grossAmount, float $taxPercent): float
|
||||
{
|
||||
return $taxPercent > 0
|
||||
? $grossAmount / (1 + ($taxPercent / 100))
|
||||
: $grossAmount;
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ public function store(Request $request, Client $client)
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->route('google-ads.accounts.show', ['id' => $client->customer_id])
|
||||
->with('message-info', 'Adjustment added successfully.');
|
||||
}
|
||||
|
||||
@ -44,10 +44,11 @@ public function destroy(ClientInvoiceAdjustment $adjustment)
|
||||
{
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403);
|
||||
|
||||
$client = $adjustment->client;
|
||||
$adjustment->delete();
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->route('google-ads.accounts.show', ['id' => $client->customer_id])
|
||||
->with('message-info', 'Adjustment deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,29 +2,32 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BillingItemType;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCustomer;
|
||||
use App\Models\ClientInvoice;
|
||||
use App\Models\ClientUserAssignation;
|
||||
use App\Services\ClientInvoiceApprovalService;
|
||||
use App\Services\ClientInvoicePaymentSyncService;
|
||||
use App\Services\ClientLookupService;
|
||||
use App\Services\UserHierarchyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ClientInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ClientInvoiceApprovalService $approvalService,
|
||||
private ClientInvoicePaymentSyncService $paymentSyncService,
|
||||
private UserHierarchyService $hierarchyService,
|
||||
private ClientLookupService $clientLookupService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
@ -47,23 +50,33 @@ public function create(Request $request): Response
|
||||
'clientId' => $clientId,
|
||||
'customerId' => $customerId,
|
||||
'availableInvoices' => $availableInvoices,
|
||||
'billingItemTypes' => $this->billingItemTypesForForm(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(ClientInvoice $invoice): Response
|
||||
{
|
||||
abort_if($invoice->client === null, 409, 'Create the client before editing this invoice.');
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
|
||||
$invoice->load('client');
|
||||
$resolvedClient = $invoice->client
|
||||
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
|
||||
if ($resolvedClient !== null) {
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $resolvedClient), 403);
|
||||
}
|
||||
|
||||
$availableInvoices = ClientInvoice::query()
|
||||
->where('client_id', $invoice->client_id)
|
||||
$availableInvoices = $resolvedClient === null
|
||||
? collect()
|
||||
: ClientInvoice::query()
|
||||
->where('client_id', $resolvedClient->id)
|
||||
->where('id', '!=', $invoice->id)
|
||||
->orderBy('invoice_no')
|
||||
->get(['id', 'invoice_no', 'linked_invoice_id']);
|
||||
|
||||
return Inertia::render('client-invoices/edit', [
|
||||
'invoice' => $invoice->load('client'),
|
||||
'invoice' => $invoice->load('client', 'payments.items.billingItemType'),
|
||||
'availableInvoices' => $availableInvoices,
|
||||
'billingItemTypes' => $this->billingItemTypesForForm(),
|
||||
'existingClient' => $invoice->client === null ? $resolvedClient : null,
|
||||
'unlinkedClients' => $this->clientOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -71,7 +84,7 @@ public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'client_id' => ['required', 'exists:clients,id'],
|
||||
'customer_id' => ['required', 'string'],
|
||||
'customer_id' => ['nullable', 'string'],
|
||||
'invoice_no' => ['required', 'string'],
|
||||
'linked_invoice_id' => [
|
||||
'nullable',
|
||||
@ -80,117 +93,274 @@ public function store(Request $request)
|
||||
return $query->where('client_id', $request->integer('client_id'));
|
||||
}),
|
||||
],
|
||||
'is_credit_card' => ['nullable', 'boolean'],
|
||||
'is_paid' => ['nullable', 'boolean'],
|
||||
'payment_no' => ['nullable', 'string'],
|
||||
'start_date' => ['nullable', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'management_fee' => ['required', 'numeric', 'min:0'],
|
||||
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'media_fee' => ['required', 'numeric', 'min:0'],
|
||||
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'total_spending' => ['nullable', 'numeric', 'min:0'],
|
||||
...$this->paymentValidationRules(),
|
||||
]);
|
||||
|
||||
$client = Client::findOrFail($validated['client_id']);
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
|
||||
|
||||
$mediaFee = $validated['media_fee'];
|
||||
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
|
||||
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
|
||||
|
||||
$invoice = DB::transaction(function () use ($validated) {
|
||||
$invoice = ClientInvoice::create([
|
||||
'client_id' => $validated['client_id'],
|
||||
'invoice_no' => $validated['invoice_no'],
|
||||
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
|
||||
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
|
||||
'is_paid' => (bool) ($validated['is_paid'] ?? false),
|
||||
'approved_at' => null,
|
||||
'payment_no' => $validated['payment_no'] ?? null,
|
||||
'start_date' => $validated['start_date'] ?? null,
|
||||
'end_date' => $validated['end_date'] ?? null,
|
||||
'management_fee' => $validated['management_fee'],
|
||||
'management_fee_amount' => $validated['management_fee_amount'] ?? null,
|
||||
'management_fee_tax' => $validated['management_fee_tax'] ?? null,
|
||||
'media_fee' => $validated['media_fee'],
|
||||
'media_fee_amount' => $validated['media_fee_amount'] ?? null,
|
||||
'media_fee_tax' => $validated['media_fee_tax'] ?? null,
|
||||
'tax_percent' => $taxPercent,
|
||||
'nett_amount' => $nettAmount,
|
||||
'total_spending' => $validated['total_spending'] ?? null,
|
||||
'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
|
||||
'total_net_amount' => $validated['total_net_amount'] ?? 0,
|
||||
]);
|
||||
|
||||
return $this->paymentSyncService->sync(
|
||||
$invoice,
|
||||
$this->paymentsPayload($validated, $invoice)
|
||||
);
|
||||
});
|
||||
|
||||
$this->approvalService->approve($invoice);
|
||||
|
||||
return redirect()
|
||||
->route('google-ads.accounts.show', ['id' => $validated['customer_id']])
|
||||
->route('google-ads.accounts.show', ['id' => $validated['customer_id'] ?? $client->customer_id])
|
||||
->with('message-info', 'Invoice created successfully.');
|
||||
}
|
||||
|
||||
public function update(Request $request, ClientInvoice $invoice)
|
||||
{
|
||||
abort_if($invoice->client === null, 409, 'Create the client before updating this invoice.');
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
|
||||
$invoice->load('client');
|
||||
$resolvedClient = $invoice->client
|
||||
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
|
||||
|
||||
if ($resolvedClient !== null) {
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $resolvedClient), 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'client_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
'exists:clients,id',
|
||||
],
|
||||
'invoice_no' => ['required', 'string'],
|
||||
'linked_invoice_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('client_invoices', 'id')->where(function ($query) use ($invoice) {
|
||||
Rule::exists('client_invoices', 'id')->where(function ($query) use ($request, $invoice) {
|
||||
return $query
|
||||
->where('client_id', $invoice->client_id)
|
||||
->where('client_id', $request->integer('client_id') ?: $invoice->client_id)
|
||||
->where('id', '!=', $invoice->id);
|
||||
}),
|
||||
],
|
||||
'is_credit_card' => ['nullable', 'boolean'],
|
||||
'is_paid' => ['nullable', 'boolean'],
|
||||
...$this->paymentValidationRules(),
|
||||
]);
|
||||
|
||||
$invoice = DB::transaction(function () use ($invoice, $validated) {
|
||||
if ($invoice->client_id === null) {
|
||||
$this->linkInvoiceClient($invoice, $validated);
|
||||
$invoice->refresh();
|
||||
} elseif ($invoice->client_id !== (int) $validated['client_id']) {
|
||||
$client = Client::findOrFail($validated['client_id']);
|
||||
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
|
||||
|
||||
$invoice->update([
|
||||
'client_id' => $client->id,
|
||||
'pending_sql_acc_code' => null,
|
||||
'pending_client_name' => null,
|
||||
'linked_invoice_id' => null,
|
||||
]);
|
||||
$invoice->refresh();
|
||||
}
|
||||
|
||||
$invoice->update([
|
||||
'invoice_no' => $validated['invoice_no'],
|
||||
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
|
||||
'total_sem_amount' => $validated['total_sem_amount'] ?? 0,
|
||||
'total_net_amount' => $validated['total_net_amount'] ?? 0,
|
||||
]);
|
||||
|
||||
return $this->paymentSyncService->sync(
|
||||
$invoice,
|
||||
$this->paymentsPayload($validated, $invoice)
|
||||
);
|
||||
});
|
||||
|
||||
if (empty($invoice->approved_at)) {
|
||||
$this->approvalService->approve($invoice);
|
||||
}
|
||||
|
||||
$invoice->load('client');
|
||||
|
||||
return redirect()
|
||||
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
|
||||
->with('message-info', 'Invoice updated successfully.');
|
||||
}
|
||||
|
||||
private function paymentValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'payments' => ['nullable', 'array', 'min:1'],
|
||||
'payments.*.payment_no' => ['nullable', 'string'],
|
||||
'payments.*.payment_total_amount' => ['required_with:payments', 'numeric', 'min:0'],
|
||||
'payments.*.payment_nett_amount' => ['required_with:payments', 'numeric', 'min:0'],
|
||||
'payments.*.items' => ['required_with:payments', 'array', 'min:1'],
|
||||
'payments.*.items.*.billing_item_types_id' => ['required_with:payments', 'integer', 'exists:billing_item_types,id'],
|
||||
'payments.*.items.*.start_date' => ['nullable', 'date'],
|
||||
'payments.*.items.*.end_date' => ['nullable', 'date'],
|
||||
'payments.*.items.*.payment_item_amount' => ['required_with:payments', 'numeric', 'min:0'],
|
||||
'payments.*.items.*.tax_percentage' => ['required_with:payments', 'numeric', 'min:0', 'max:100'],
|
||||
'payments.*.items.*.net_amount' => ['required_with:payments', 'numeric', 'min:0'],
|
||||
'payments.*.items.*.withholding_tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'payments.*.items.*.final_net_amount' => ['required_with:payments', 'numeric', 'min:0'],
|
||||
'payments.*.items.*.spending' => ['nullable', 'numeric', 'min:0'],
|
||||
'payments.*.items.*.is_creditcard' => ['nullable', 'boolean'],
|
||||
'payment_no' => ['nullable', 'string'],
|
||||
'start_date' => ['nullable', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'amount' => ['required', 'numeric', 'min:0'],
|
||||
'management_fee' => ['required', 'numeric', 'min:0'],
|
||||
'is_credit_card' => ['nullable', 'boolean'],
|
||||
'total_sem_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'total_net_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'management_fee' => ['required_without:payments', 'numeric', 'min:0'],
|
||||
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'media_fee' => ['required', 'numeric', 'min:0'],
|
||||
'media_fee' => ['required_without:payments', 'numeric', 'min:0'],
|
||||
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'nett_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'total_spending' => ['nullable', 'numeric', 'min:0'],
|
||||
]);
|
||||
|
||||
$managementFee = $validated['management_fee'];
|
||||
$mediaFee = $validated['media_fee'];
|
||||
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
|
||||
$nettAmount = $validated['nett_amount'] ?? ($mediaFee / (1 + ($taxPercent / 100)));
|
||||
|
||||
$invoice->update([
|
||||
'invoice_no' => $validated['invoice_no'],
|
||||
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
|
||||
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
|
||||
'is_paid' => (bool) ($validated['is_paid'] ?? false),
|
||||
'payment_no' => $validated['payment_no'] ?? null,
|
||||
'start_date' => $validated['start_date'] ?? null,
|
||||
'end_date' => $validated['end_date'] ?? null,
|
||||
'amount' => $validated['amount'],
|
||||
'management_fee' => $managementFee,
|
||||
'media_fee' => $validated['media_fee'],
|
||||
'tax_percent' => $taxPercent,
|
||||
'nett_amount' => $nettAmount,
|
||||
'total_spending' => $validated['total_spending'] ?? null,
|
||||
]);
|
||||
|
||||
if(empty($invoice->approved_at)) {
|
||||
$this->approvalService->approve($invoice);
|
||||
];
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
|
||||
->with('message-info', 'Invoice updated successfully.');
|
||||
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)
|
||||
@ -211,7 +381,20 @@ public function destroy(ClientInvoice $invoice)
|
||||
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();
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
@ -279,28 +462,10 @@ public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\
|
||||
->with('message-info', 'Invoice '.$invoice->invoice_no.' has been linked to '.$existingClient->name.'.');
|
||||
}
|
||||
|
||||
$unlinkedClients = Client::query()
|
||||
->where(function ($query) {
|
||||
$query->whereNull('sql_acc_code')
|
||||
->orWhere('sql_acc_code', '');
|
||||
})
|
||||
->whereDoesntHave('customers', function ($query) {
|
||||
$query->whereNotNull('sql_acc_code')
|
||||
->where('sql_acc_code', '!=', '');
|
||||
})
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
|
||||
->map(fn (Client $client) => [
|
||||
'value' => (string) $client->id,
|
||||
'label' => trim($client->name.' ('.$client->customer_id.')'),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('client-invoices/create-client', [
|
||||
'invoice' => $invoice->load('client'),
|
||||
'existingClient' => null,
|
||||
'unlinkedClients' => $unlinkedClients,
|
||||
'unlinkedClients' => $this->unlinkedClientOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -332,6 +497,10 @@ public function storeClient(Request $request, ClientInvoice $invoice)
|
||||
|
||||
$selectedClientIsLinked = Client::query()
|
||||
->where('id', $validated['client_id'])
|
||||
->where(function ($query) {
|
||||
$query->whereNotNull('sql_acc_code')
|
||||
->where('sql_acc_code', '!=', '');
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($selectedClientIsLinked) {
|
||||
|
||||
@ -74,18 +74,14 @@ public function __invoke(Request $request): Response
|
||||
'client_id',
|
||||
'invoice_no',
|
||||
'approved_at',
|
||||
'management_fee',
|
||||
'media_fee',
|
||||
'nett_amount',
|
||||
'total_net_amount',
|
||||
'created_at',
|
||||
])
|
||||
->map(fn (ClientInvoice $invoice) => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_no' => $invoice->invoice_no,
|
||||
'approved_at' => $invoice->approved_at?->toDateTimeString(),
|
||||
'management_fee' => $invoice->management_fee,
|
||||
'media_fee' => $invoice->media_fee,
|
||||
'nett_amount' => $invoice->nett_amount,
|
||||
'total_net_amount' => $invoice->total_net_amount,
|
||||
'created_at' => $invoice->created_at?->toDateString(),
|
||||
'client' => $invoice->client,
|
||||
])
|
||||
|
||||
@ -3,35 +3,36 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCustomer;
|
||||
use App\Models\ClientInvoice;
|
||||
use App\Models\ClientInvoiceAdjustment;
|
||||
use App\Models\ClientProjectActivities;
|
||||
use App\Models\ClientUserAssignation;
|
||||
use App\Models\ClientInvoiceAdjustment;
|
||||
use App\Models\ClientCustomer;
|
||||
use App\Models\GoogleCampaignMetric;
|
||||
use App\Models\User;
|
||||
use App\Models\ClientInvoice;
|
||||
use App\Services\ClientInvoicePaymentSyncService;
|
||||
use App\Services\GoogleAdsService;
|
||||
use App\Services\UserHierarchyService;
|
||||
use Carbon\Carbon;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log as FacadesLog;
|
||||
use Inertia\Inertia;
|
||||
use Rap2hpoutre\FastExcel\FastExcel;
|
||||
|
||||
class GoogleAdsController extends Controller
|
||||
{
|
||||
protected $adsService;
|
||||
|
||||
private const GOOGLE_COMPANY_SYNC_LOCK = 'google-ads:get-company-details:running';
|
||||
|
||||
public function __construct(
|
||||
GoogleAdsService $adsService,
|
||||
private ClientInvoicePaymentSyncService $paymentSyncService,
|
||||
private UserHierarchyService $hierarchyService,
|
||||
)
|
||||
{
|
||||
) {
|
||||
$this->adsService = $adsService;
|
||||
}
|
||||
|
||||
@ -46,7 +47,7 @@ public function accounts()
|
||||
// $customerMap = $accounts->keyBy('customer_id');
|
||||
$localClients = $this->hierarchyService
|
||||
->scopeClientsVisibleTo(Client::query(), Auth::user())
|
||||
->with('assignations.user', 'customers', 'invoices', 'invoiceAdjustments')
|
||||
->with('assignations.user', 'customers', 'invoices.payments.items.billingItemType', 'invoiceAdjustments')
|
||||
->get();
|
||||
$customerMap = $localClients->map(function ($data) {
|
||||
$assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON);
|
||||
@ -55,24 +56,11 @@ public function accounts()
|
||||
$data['sql_acc_code'] = implode(',', $data->customers->pluck('sql_acc_code')->toArray());
|
||||
$data['assigned_person'] = $assignedPerson?->user?->name;
|
||||
$data['sales_person'] = $salesPerson?->user?->name;
|
||||
$data['latest_remaining_amount'] = $data->latestRemainingAmount(function (ClientInvoice $invoice) use ($data) {
|
||||
if (empty($invoice->start_date) || empty($invoice->end_date)) {
|
||||
return 0;
|
||||
}
|
||||
$data['latest_remaining_amount'] = $data->latestRemainingAmount();
|
||||
|
||||
return GoogleCampaignMetric::query()
|
||||
->join('google_campaigns', 'google_campaign_metrics.google_campaign_id', '=', 'google_campaigns.id')
|
||||
->where('google_campaigns.client_id', $data->id)
|
||||
->whereNull('google_campaigns.deleted_at')
|
||||
->whereNull('google_campaign_metrics.deleted_at')
|
||||
->whereBetween('google_campaign_metrics.date', [
|
||||
$invoice->start_date?->toDateString(),
|
||||
$invoice->end_date?->toDateString(),
|
||||
])
|
||||
->sum('google_campaign_metrics.actual_spend');
|
||||
});
|
||||
return $data;
|
||||
});
|
||||
|
||||
return Inertia::render('campaigns/index', [
|
||||
'clients' => $customerMap->values()->all(),
|
||||
'googleCompanySyncRunning' => Cache::has(self::GOOGLE_COMPANY_SYNC_LOCK),
|
||||
@ -214,6 +202,7 @@ public function updateAccount(Request $request, $id)
|
||||
foreach ($assignmentValues as $role => $userId) {
|
||||
if ($userId === null) {
|
||||
$localClient->assignations()->where('role', $role)->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -231,12 +220,14 @@ public function updateAccount(Request $request, $id)
|
||||
public function campaigns($id)
|
||||
{
|
||||
$campaigns = $this->adsService->listCampaigns($id);
|
||||
|
||||
return response()->json($campaigns);
|
||||
}
|
||||
|
||||
public function listCampaignsMetrics($id, $startDate, $endDate)
|
||||
{
|
||||
$campaigns = $this->adsService->listCampaignsMetrics($id, $startDate, $endDate);
|
||||
|
||||
return response()->json($campaigns);
|
||||
}
|
||||
|
||||
@ -251,7 +242,7 @@ private function hydrateClient(array $account): array
|
||||
]
|
||||
);
|
||||
// dd($localClient);
|
||||
$localClient->load(['assignations.user', 'invoices']);
|
||||
$localClient->load(['assignations.user', 'invoices.payments.items.billingItemType']);
|
||||
|
||||
$assignments = $localClient->assignations
|
||||
->mapWithKeys(function (ClientUserAssignation $assignation) {
|
||||
@ -287,61 +278,64 @@ private function hydrateClient(array $account): array
|
||||
->all();
|
||||
|
||||
$invoices = $localClient->invoices
|
||||
->map(function ($invoice) use ($account) {
|
||||
$campaigns = $this->adsService->listCampaigns($account['id']);
|
||||
$totalInvoiceSpend = 0;
|
||||
FacadesLog::info('Hydrated client data', [
|
||||
'campaigns' => $campaigns,
|
||||
]);
|
||||
foreach ($campaigns as $campaign) {
|
||||
FacadesLog::info('Hydrated client data', [
|
||||
'campaigns' => $campaign['id'],
|
||||
]);
|
||||
|
||||
if (empty($invoice->start_date) || empty($invoice->end_date)) {
|
||||
continue;
|
||||
} else {
|
||||
$metrics = $this->adsService->listCampaignsMetricsById(
|
||||
$account['id'],
|
||||
$campaign['id'],
|
||||
$invoice->start_date?->toDateString() ?? null,
|
||||
$invoice->end_date?->toDateString() ?? null
|
||||
);
|
||||
FacadesLog::info('Hydrated client data', [
|
||||
'metrics' => $metrics,
|
||||
]);
|
||||
$totalSpend = array_sum(array_column($metrics, 'actual_spend'));
|
||||
$totalInvoiceSpend += $totalSpend;
|
||||
}
|
||||
}
|
||||
->map(function ($invoice) {
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'client_id' => $invoice->client_id,
|
||||
'invoice_no' => $invoice->invoice_no,
|
||||
'linked_invoice_id' => $invoice->linked_invoice_id,
|
||||
'is_credit_card' => $invoice->is_credit_card,
|
||||
'is_paid' => $invoice->is_paid,
|
||||
'start_date' => $invoice->start_date?->toDateString(),
|
||||
'end_date' => $invoice->end_date?->toDateString(),
|
||||
'payment_no' => $invoice->payment_no,
|
||||
'amount' => $invoice->amount,
|
||||
'total_spend' => number_format($totalInvoiceSpend, 2, '.', ''),
|
||||
'management_fee' => $invoice->management_fee,
|
||||
'management_fee_amount' => $invoice->management_fee_amount,
|
||||
'management_fee_tax' => $invoice->management_fee_tax,
|
||||
'media_fee' => $invoice->media_fee,
|
||||
'media_fee_amount' => $invoice->media_fee_amount,
|
||||
'media_fee_tax' => $invoice->media_fee_tax,
|
||||
'tax_percent' => $invoice->tax_percent,
|
||||
'nett_amount' => $invoice->nett_amount,
|
||||
'total_spending' => $invoice->total_spending,
|
||||
'approved_at' => $invoice->approved_at?->toDateTimeString(),
|
||||
'total_sem_amount' => $invoice->total_sem_amount,
|
||||
'total_net_amount' => $invoice->total_net_amount,
|
||||
'created_at' => $invoice->created_at?->toDateTimeString(),
|
||||
'updated_at' => $invoice->updated_at?->toDateTimeString(),
|
||||
'payments' => $invoice->payments
|
||||
->map(fn ($payment) => [
|
||||
'id' => $payment->id,
|
||||
'client_invoice_id' => $payment->client_invoice_id,
|
||||
'payment_no' => $payment->payment_no,
|
||||
'payment_total_amount' => $payment->payment_total_amount,
|
||||
'payment_nett_amount' => $payment->payment_nett_amount,
|
||||
'items' => $payment->items
|
||||
->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'client_invoice_payment_id' => $item->client_invoice_payment_id,
|
||||
'billing_item_types_id' => $item->billing_item_types_id,
|
||||
'billing_item_type' => $item->billingItemType
|
||||
? [
|
||||
'id' => $item->billingItemType->id,
|
||||
'name' => $item->billingItemType->name,
|
||||
'sql_acc_code' => $item->billingItemType->sql_acc_code,
|
||||
'nett_contribution' => $item->billingItemType->nett_contribution,
|
||||
'fee_type' => $item->billingItemType->fee_type,
|
||||
'type' => $item->billingItemType->type,
|
||||
'campaign_type' => $item->billingItemType->campaign_type,
|
||||
]
|
||||
: null,
|
||||
'start_date' => $item->start_date?->toDateString(),
|
||||
'end_date' => $item->end_date?->toDateString(),
|
||||
'payment_item_amount' => $item->payment_item_amount,
|
||||
'tax_percentage' => $item->tax_percentage,
|
||||
'net_amount' => $item->net_amount,
|
||||
'withholding_tax' => $item->withholding_tax,
|
||||
'final_net_amount' => $item->final_net_amount,
|
||||
'spending' => $item->spending,
|
||||
'is_creditcard' => $item->is_creditcard,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// dd($invoices);
|
||||
|
||||
$campaigns = $this->adsService->listCampaigns($localClient->customer_id);
|
||||
$campaigns = [];
|
||||
// if($localClient->status !='CLOSED' && $localClient->status != 'CANCELED'){
|
||||
// $campaigns = $this->adsService->listCampaigns($localClient->customer_id);
|
||||
// }
|
||||
|
||||
$lifeTimeSpend = 0;
|
||||
|
||||
@ -395,6 +389,7 @@ private function hydrateClient(array $account): array
|
||||
'users' => $users,
|
||||
'invoices' => $invoices,
|
||||
]);
|
||||
|
||||
return [
|
||||
$localClient,
|
||||
$assignments,
|
||||
@ -475,27 +470,54 @@ public function insertCSVDataToDB()
|
||||
$mediaFee = intval($row['media_fee']);
|
||||
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0;
|
||||
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0;
|
||||
$billingItemTypes = $this->paymentSyncService->ensureDefaultItemTypes()->keyBy('name');
|
||||
$items = [];
|
||||
|
||||
ClientInvoice::updateOrCreate(
|
||||
if ($mediaFee > 0) {
|
||||
$items[] = [
|
||||
'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MEDIA_SEARCH_NAME]->id,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'payment_item_amount' => $mediaFee,
|
||||
'tax_percentage' => 8,
|
||||
'net_amount' => $mediaFeeAmount,
|
||||
'withholding_tax' => 0,
|
||||
'final_net_amount' => $mediaFeeAmount,
|
||||
'spending' => $spend,
|
||||
'is_creditcard' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($managementFee > 0) {
|
||||
$items[] = [
|
||||
'billing_item_types_id' => $billingItemTypes[ClientInvoicePaymentSyncService::MANAGEMENT_SEARCH_NAME]->id,
|
||||
'start_date' => null,
|
||||
'end_date' => null,
|
||||
'payment_item_amount' => $managementFee,
|
||||
'tax_percentage' => 8,
|
||||
'net_amount' => $managementFeeAmount,
|
||||
'withholding_tax' => 0,
|
||||
'final_net_amount' => $managementFeeAmount,
|
||||
'spending' => 0,
|
||||
'is_creditcard' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$invoice = ClientInvoice::updateOrCreate(
|
||||
['invoice_no' => $row['invoice_no']],
|
||||
[
|
||||
'client_id' => $row['client_id'],
|
||||
'is_credit_card' => $mediaFee == 0 ? 1 : 0,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'management_fee' => $managementFee,
|
||||
'management_fee_amount' => $managementFeeAmount,
|
||||
'management_fee_tax' => $managementFee - $managementFeeAmount,
|
||||
'media_fee' => $mediaFee,
|
||||
'media_fee_amount' => $mediaFeeAmount,
|
||||
'media_fee_tax' => $mediaFee - $mediaFeeAmount,
|
||||
'tax_percent' => 8,
|
||||
'nett_amount' => $mediaFeeAmount,
|
||||
'total_spending' => $spend,
|
||||
]
|
||||
);
|
||||
$this->paymentSyncService->sync($invoice, [[
|
||||
'payment_no' => null,
|
||||
'payment_total_amount' => $mediaFee + $managementFee,
|
||||
'payment_nett_amount' => $mediaFeeAmount + $managementFeeAmount,
|
||||
'items' => $items,
|
||||
]]);
|
||||
} else {
|
||||
FacadesLog::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id']));
|
||||
|
||||
continue; // Skip this row if client not found
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Models/BillingItemType.php
Normal file
32
app/Models/BillingItemType.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -42,28 +42,28 @@ public function getLatestRemainingAmountAttribute(): string
|
||||
return $this->latestRemainingAmount();
|
||||
}
|
||||
|
||||
public function latestRemainingAmount(?callable $invoiceSpendingResolver = null): string
|
||||
public function latestRemainingAmount(): string
|
||||
{
|
||||
$invoices = $this->relationLoaded('invoices')
|
||||
? $this->invoices
|
||||
: $this->invoices()->get(['client_id', 'is_credit_card', 'nett_amount', 'total_spending']);
|
||||
: $this->invoices()->with('payments.items.billingItemType')->get();
|
||||
|
||||
$invoices->loadMissing('payments.items.billingItemType');
|
||||
|
||||
$adjustments = $this->relationLoaded('invoiceAdjustments')
|
||||
? $this->invoiceAdjustments
|
||||
: $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']);
|
||||
|
||||
$nettAmount = $invoices
|
||||
->sum(fn (ClientInvoice $invoice) => (float) ($invoice->nett_amount ?? 0));
|
||||
$items = $invoices->flatMap(fn (ClientInvoice $invoice) => $invoice->payments)
|
||||
->flatMap(fn ($payment) => $payment->items);
|
||||
|
||||
$billableSpending = $invoices
|
||||
->reject(fn (ClientInvoice $invoice) => $invoice->is_credit_card)
|
||||
->sum(function (ClientInvoice $invoice) use ($invoiceSpendingResolver) {
|
||||
if ($invoiceSpendingResolver !== null) {
|
||||
return (float) $invoiceSpendingResolver($invoice);
|
||||
}
|
||||
$nettAmount = $items
|
||||
->filter(fn ($item) => ! $item->is_creditcard && ($item->billingItemType?->nett_contribution ?? false))
|
||||
->sum(fn ($item) => (float) ($item->final_net_amount ?? $item->net_amount ?? 0));
|
||||
|
||||
return (float) ($invoice->total_spending ?? 0);
|
||||
});
|
||||
$billableSpending = $items
|
||||
->reject(fn ($item) => $item->is_creditcard)
|
||||
->sum(fn ($item) => (float) ($item->spending ?? 0));
|
||||
|
||||
$adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) {
|
||||
$amount = (float) ($adjustment->amount ?? 0);
|
||||
|
||||
@ -17,40 +17,15 @@ class ClientInvoice extends Model
|
||||
'pending_client_name',
|
||||
'invoice_no',
|
||||
'linked_invoice_id',
|
||||
'is_credit_card',
|
||||
'is_paid',
|
||||
'approved_at',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'payment_no',
|
||||
'amount',
|
||||
'tax_percent',
|
||||
'media_fee',
|
||||
'media_fee_amount',
|
||||
'media_fee_tax',
|
||||
'management_fee',
|
||||
'management_fee_amount',
|
||||
'management_fee_tax',
|
||||
'nett_amount',
|
||||
'total_spending',
|
||||
'total_sem_amount',
|
||||
'total_net_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_credit_card' => 'boolean',
|
||||
'is_paid' => 'boolean',
|
||||
'approved_at' => 'datetime',
|
||||
'amount' => 'decimal:2',
|
||||
'tax_percent' => 'decimal:2',
|
||||
'media_fee' => 'decimal:2',
|
||||
'media_fee_amount' => 'decimal:2',
|
||||
'media_fee_tax' => 'decimal:2',
|
||||
'management_fee' => 'decimal:2',
|
||||
'management_fee_amount' => 'decimal:2',
|
||||
'management_fee_tax' => 'decimal:2',
|
||||
'nett_amount' => 'decimal:2',
|
||||
'total_spending' => 'decimal:2',
|
||||
'total_sem_amount' => 'decimal:6',
|
||||
'total_net_amount' => 'decimal:6',
|
||||
];
|
||||
|
||||
public function client(): BelongsTo
|
||||
@ -68,4 +43,8 @@ public function linkedInvoices(): HasMany
|
||||
return $this->hasMany(self::class, 'linked_invoice_id');
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientInvoicePayment::class, 'client_invoice_id');
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Models/ClientInvoicePayment.php
Normal file
34
app/Models/ClientInvoicePayment.php
Normal 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');
|
||||
}
|
||||
}
|
||||
47
app/Models/ClientInvoicePaymentItem.php
Normal file
47
app/Models/ClientInvoicePaymentItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
285
app/Services/ClientInvoicePaymentSyncService.php
Normal file
285
app/Services/ClientInvoicePaymentSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
14
database/seeders/BillingItemTypeSeeder.php
Normal file
14
database/seeders/BillingItemTypeSeeder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
30
database/seeders/ClientInvoicePaymentItemBackfillSeeder.php
Normal file
30
database/seeders/ClientInvoicePaymentItemBackfillSeeder.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,11 @@ public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
$this->call(RoleSeeder::class);
|
||||
$this->call([
|
||||
RoleSeeder::class,
|
||||
BillingItemTypeSeeder::class,
|
||||
ClientInvoicePaymentItemBackfillSeeder::class,
|
||||
]);
|
||||
|
||||
$user = User::firstOrCreate(
|
||||
['email' => 'test@example.com'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,16 @@
|
||||
import React from "react";
|
||||
import AppLayout from "@/layouts/app-layout";
|
||||
import { useForm } from "@inertiajs/react";
|
||||
import { Button, Card, Container, Group, Title } from "@mantine/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Link } from "@inertiajs/react";
|
||||
import InvoiceForm, { InvoiceFormValues } from "@/forms/account/InvoiceForm";
|
||||
import { ClientInvoice } from "@/types";
|
||||
import InvoiceForm, {
|
||||
BillingItemTypeOption,
|
||||
InvoiceFormValues,
|
||||
createPayment,
|
||||
} from '@/forms/account/InvoiceForm';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { ClientInvoice } from '@/types';
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Button, Container, Group, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
type InvoiceOptionSource = Pick<ClientInvoice, "id" | "invoice_no"> & {
|
||||
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
|
||||
linked_invoice_id?: number | null;
|
||||
};
|
||||
|
||||
@ -15,64 +18,76 @@ interface Props {
|
||||
clientId: number;
|
||||
customerId: string;
|
||||
availableInvoices: InvoiceOptionSource[];
|
||||
billingItemTypes: BillingItemTypeOption[];
|
||||
}
|
||||
|
||||
export default function Page({ clientId, customerId, availableInvoices }: Props) {
|
||||
const parseAmount = (value?: string) => {
|
||||
const parsed = Number.parseFloat(value ?? '');
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const finalNetAmount = (netAmount: number, withholdingTax: number) =>
|
||||
netAmount / (1 + withholdingTax / 100);
|
||||
|
||||
export default function Page({
|
||||
clientId,
|
||||
customerId,
|
||||
availableInvoices,
|
||||
billingItemTypes,
|
||||
}: Props) {
|
||||
const form = useForm<InvoiceFormValues>({
|
||||
client_id: String(clientId),
|
||||
customer_id: customerId,
|
||||
invoice_no: "",
|
||||
linked_invoice_id: "",
|
||||
is_credit_card: false,
|
||||
invoice_no: '',
|
||||
linked_invoice_id: '',
|
||||
is_paid: false,
|
||||
payment_no: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
amount: "",
|
||||
management_fee: "",
|
||||
management_fee_amount: "",
|
||||
management_fee_tax: "",
|
||||
media_fee: "",
|
||||
media_fee_amount: "",
|
||||
media_fee_tax: "",
|
||||
tax_percent: "",
|
||||
total_spending: "",
|
||||
nett_amount: "",
|
||||
total_sem_amount: '0.00',
|
||||
total_net_amount: '0.00',
|
||||
payments: [createPayment(billingItemTypes)],
|
||||
});
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.data.nett_amount) {
|
||||
form.setError("nett_amount", "Media Nett Amount is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSpending = form.data.total_spending
|
||||
? parseFloat(form.data.total_spending)
|
||||
: null;
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
linked_invoice_id: data.linked_invoice_id || null,
|
||||
is_credit_card: !!data.is_credit_card,
|
||||
is_paid: !!data.is_paid,
|
||||
payment_no: data.payment_no || null,
|
||||
start_date: data.start_date || null,
|
||||
end_date: data.end_date || null,
|
||||
amount: parseFloat(data.amount) || 0,
|
||||
management_fee: parseFloat(data.management_fee) || 0,
|
||||
management_fee_amount: data.management_fee_amount ? parseFloat(data.management_fee_amount) : null,
|
||||
management_fee_tax: data.management_fee_tax ? parseFloat(data.management_fee_tax) : null,
|
||||
media_fee: parseFloat(data.media_fee) || 0,
|
||||
media_fee_amount: data.media_fee_amount ? parseFloat(data.media_fee_amount) : null,
|
||||
media_fee_tax: data.media_fee_tax ? parseFloat(data.media_fee_tax) : null,
|
||||
tax_percent: parseFloat(data.tax_percent) || 0,
|
||||
total_spending: totalSpending,
|
||||
nett_amount: parseFloat(form.data.nett_amount) || 0,
|
||||
total_sem_amount: parseFloat(data.total_sem_amount) || 0,
|
||||
total_net_amount: parseFloat(data.total_net_amount) || 0,
|
||||
payments: data.payments.map((payment) => ({
|
||||
...payment,
|
||||
payment_no: payment.payment_no || null,
|
||||
payment_total_amount:
|
||||
parseFloat(payment.payment_total_amount) || 0,
|
||||
payment_nett_amount:
|
||||
parseFloat(payment.payment_nett_amount) || 0,
|
||||
items: payment.items.map((item) => ({
|
||||
...item,
|
||||
start_date: item.start_date || null,
|
||||
end_date: item.end_date || null,
|
||||
billing_item_types_id: parseInt(
|
||||
item.billing_item_types_id,
|
||||
10,
|
||||
),
|
||||
payment_item_amount: parseAmount(
|
||||
item.payment_item_amount,
|
||||
),
|
||||
tax_percentage: parseAmount(item.tax_percentage),
|
||||
net_amount: parseAmount(item.net_amount),
|
||||
withholding_tax: parseAmount(item.withholding_tax),
|
||||
final_net_amount: finalNetAmount(
|
||||
parseAmount(item.net_amount),
|
||||
parseAmount(item.withholding_tax),
|
||||
),
|
||||
spending: parseFloat(item.spending) || 0,
|
||||
is_creditcard: !!item.is_creditcard,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
form.post(route("client-invoices.store"));
|
||||
form.post(route('client-invoices.store'));
|
||||
};
|
||||
|
||||
const invoiceOptions = availableInvoices.map((invoice) => ({
|
||||
@ -87,7 +102,9 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
|
||||
<Title order={2}>Add invoice</Title>
|
||||
<Button
|
||||
component={Link}
|
||||
href={route("google-ads.accounts.show", { id: customerId })}
|
||||
href={route('google-ads.accounts.show', {
|
||||
id: customerId,
|
||||
})}
|
||||
leftIcon={<IconArrowLeft size={16} />}
|
||||
variant="outline"
|
||||
>
|
||||
@ -95,9 +112,12 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder>
|
||||
<InvoiceForm form={form} onSubmit={handleSubmit} invoiceOptions={invoiceOptions} />
|
||||
</Card>
|
||||
<InvoiceForm
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
invoiceOptions={invoiceOptions}
|
||||
billingItemTypes={billingItemTypes}
|
||||
/>
|
||||
</Container>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@ -1,139 +1,305 @@
|
||||
import React from "react";
|
||||
import AppLayout from "@/layouts/app-layout";
|
||||
import { router, useForm } from "@inertiajs/react";
|
||||
import { Badge, Button, Card, Container, Group, Title } from "@mantine/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Link } from "@inertiajs/react";
|
||||
import InvoiceForm, { InvoiceFormValues } from "@/forms/account/InvoiceForm";
|
||||
import { ClientInvoice } from "@/types";
|
||||
import InvoiceForm, {
|
||||
BillingItemTypeOption,
|
||||
InvoiceFormValues,
|
||||
InvoicePaymentFormValues,
|
||||
createPayment,
|
||||
createPaymentItem,
|
||||
} from '@/forms/account/InvoiceForm';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { ClientInvoice } from '@/types';
|
||||
import { Link, router, useForm } from '@inertiajs/react';
|
||||
import { Badge, Button, Container, Group, Title } from '@mantine/core';
|
||||
import { IconArrowLeft, IconFileDollar } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
type InvoiceOptionSource = Pick<ClientInvoice, "id" | "invoice_no"> & {
|
||||
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
|
||||
linked_invoice_id?: number | null;
|
||||
};
|
||||
|
||||
type InvoicePaymentItemSource = {
|
||||
billing_item_types_id: number;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
payment_item_amount: string | number;
|
||||
tax_percentage: string | number;
|
||||
net_amount: string | number;
|
||||
withholding_tax: string | number;
|
||||
final_net_amount: string | number;
|
||||
spending: string | number;
|
||||
is_creditcard: boolean;
|
||||
};
|
||||
|
||||
type InvoicePaymentSource = {
|
||||
payment_no: string | null;
|
||||
payment_total_amount: string | number;
|
||||
payment_nett_amount: string | number;
|
||||
items: InvoicePaymentItemSource[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
invoice: ClientInvoice & { client?: { customer_id: string } };
|
||||
invoice: ClientInvoice & {
|
||||
pending_sql_acc_code?: string | null;
|
||||
pending_client_name?: string | null;
|
||||
client?: {
|
||||
id: number;
|
||||
name: string;
|
||||
customer_id: string;
|
||||
};
|
||||
payments?: InvoicePaymentSource[];
|
||||
total_sem_amount?: number | string | null;
|
||||
total_net_amount?: number | string | null;
|
||||
};
|
||||
existingClient?: { id: number; name: string; customer_id: string } | null;
|
||||
unlinkedClients: { value: string; label: string }[];
|
||||
availableInvoices: InvoiceOptionSource[];
|
||||
billingItemTypes: BillingItemTypeOption[];
|
||||
}
|
||||
|
||||
export default function Page({ invoice, availableInvoices }: Props) {
|
||||
const valueString = (value: string | number | null | undefined) =>
|
||||
value === null || value === undefined ? '' : String(value);
|
||||
|
||||
const parseAmount = (value?: string | number | null) => {
|
||||
const parsed = Number.parseFloat(String(value ?? ''));
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const finalNetAmount = (netAmount: number, withholdingTax: number) =>
|
||||
netAmount / (1 + withholdingTax / 100);
|
||||
|
||||
const paymentItemsFromInvoice = (
|
||||
invoice: Props['invoice'],
|
||||
billingItemTypes: BillingItemTypeOption[],
|
||||
): InvoicePaymentFormValues[] => {
|
||||
if (invoice.payments?.length) {
|
||||
return invoice.payments.map((payment) => ({
|
||||
payment_no: payment.payment_no ?? '',
|
||||
payment_total_amount: valueString(payment.payment_total_amount),
|
||||
payment_nett_amount: valueString(payment.payment_nett_amount),
|
||||
items: payment.items.map((item) => ({
|
||||
billing_item_types_id: String(item.billing_item_types_id),
|
||||
start_date: item.start_date ?? '',
|
||||
end_date: item.end_date ?? '',
|
||||
payment_item_amount: valueString(item.payment_item_amount),
|
||||
tax_percentage: valueString(item.tax_percentage),
|
||||
net_amount: valueString(item.net_amount),
|
||||
withholding_tax: valueString(item.withholding_tax),
|
||||
final_net_amount: valueString(item.final_net_amount),
|
||||
spending: valueString(item.spending),
|
||||
is_creditcard: !!item.is_creditcard,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
const mediaSearch = billingItemTypes.find(
|
||||
(itemType) => itemType.name === 'Google Ads Search (Media Fee)',
|
||||
);
|
||||
const managementSearch = billingItemTypes.find(
|
||||
(itemType) => itemType.name === 'Management Fee (Google Search Ads)',
|
||||
);
|
||||
const fallbackPayment = createPayment(billingItemTypes, {
|
||||
payment_no: invoice.payment_no ?? '',
|
||||
payment_total_amount: valueString(
|
||||
(Number(invoice.media_fee ?? 0) || 0) +
|
||||
(Number(invoice.management_fee ?? 0) || 0),
|
||||
),
|
||||
payment_nett_amount: valueString(
|
||||
invoice.total_net_amount ?? invoice.nett_amount ?? 0,
|
||||
),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
...fallbackPayment,
|
||||
items: [
|
||||
createPaymentItem(
|
||||
String(mediaSearch?.id ?? billingItemTypes[0]?.id ?? ''),
|
||||
{
|
||||
start_date: invoice.start_date ?? '',
|
||||
end_date: invoice.end_date ?? '',
|
||||
payment_item_amount: valueString(
|
||||
invoice.media_fee ?? 0,
|
||||
),
|
||||
tax_percentage: valueString(invoice.tax_percent ?? 0),
|
||||
net_amount: valueString(
|
||||
invoice.media_fee_amount ??
|
||||
invoice.nett_amount ??
|
||||
0,
|
||||
),
|
||||
withholding_tax: '0',
|
||||
final_net_amount: valueString(
|
||||
invoice.nett_amount ??
|
||||
invoice.media_fee_amount ??
|
||||
0,
|
||||
),
|
||||
spending: valueString(invoice.total_spending ?? 0),
|
||||
is_creditcard: !!invoice.is_credit_card,
|
||||
},
|
||||
),
|
||||
createPaymentItem(
|
||||
String(
|
||||
managementSearch?.id ?? billingItemTypes[0]?.id ?? '',
|
||||
),
|
||||
{
|
||||
payment_item_amount: valueString(
|
||||
invoice.management_fee ?? 0,
|
||||
),
|
||||
tax_percentage: valueString(invoice.tax_percent ?? 0),
|
||||
net_amount: valueString(
|
||||
invoice.management_fee_amount ??
|
||||
invoice.management_fee ??
|
||||
0,
|
||||
),
|
||||
withholding_tax: '0',
|
||||
final_net_amount: valueString(
|
||||
invoice.management_fee_amount ??
|
||||
invoice.management_fee ??
|
||||
0,
|
||||
),
|
||||
spending: '0',
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
invoice,
|
||||
existingClient,
|
||||
unlinkedClients,
|
||||
availableInvoices,
|
||||
billingItemTypes,
|
||||
}: Props) {
|
||||
const resolvedClient = invoice.client ?? existingClient ?? null;
|
||||
const form = useForm<InvoiceFormValues>({
|
||||
invoice_no: invoice.invoice_no ?? "",
|
||||
linked_invoice_id: invoice.linked_invoice_id ? String(invoice.linked_invoice_id) : "",
|
||||
is_credit_card: !!invoice.is_credit_card,
|
||||
invoice_no: invoice.invoice_no ?? '',
|
||||
linked_invoice_id: invoice.linked_invoice_id
|
||||
? String(invoice.linked_invoice_id)
|
||||
: '',
|
||||
is_paid: !!invoice.is_paid,
|
||||
payment_no: invoice.payment_no ?? "",
|
||||
start_date: invoice.start_date ?? "",
|
||||
end_date: invoice.end_date ?? "",
|
||||
amount: invoice.amount !== null && invoice.amount !== undefined ? String(invoice.amount) : "",
|
||||
management_fee:
|
||||
invoice.management_fee !== null && invoice.management_fee !== undefined
|
||||
? String(invoice.management_fee)
|
||||
: "",
|
||||
management_fee_amount:
|
||||
invoice.management_fee_amount !== null && invoice.management_fee_amount !== undefined
|
||||
? String(invoice.management_fee_amount)
|
||||
: "",
|
||||
management_fee_tax:
|
||||
invoice.management_fee_tax !== null && invoice.management_fee_tax !== undefined
|
||||
? String(invoice.management_fee_tax)
|
||||
: "",
|
||||
media_fee:
|
||||
invoice.media_fee !== null && invoice.media_fee !== undefined
|
||||
? String(invoice.media_fee)
|
||||
: "",
|
||||
media_fee_amount:
|
||||
invoice.media_fee_amount !== null && invoice.media_fee_amount !== undefined
|
||||
? String(invoice.media_fee_amount)
|
||||
: "",
|
||||
media_fee_tax:
|
||||
invoice.media_fee_tax !== null && invoice.media_fee_tax !== undefined
|
||||
? String(invoice.media_fee_tax)
|
||||
: "",
|
||||
tax_percent:
|
||||
invoice.tax_percent !== null && invoice.tax_percent !== undefined
|
||||
? String(invoice.tax_percent)
|
||||
: "",
|
||||
total_spending:
|
||||
invoice.total_spending !== null && invoice.total_spending !== undefined
|
||||
? String(invoice.total_spending)
|
||||
: "",
|
||||
client_id: invoice.client_id ? String(invoice.client_id) : undefined,
|
||||
customer_id: invoice.client?.customer_id ?? "",
|
||||
nett_amount: invoice.nett_amount !== null && invoice.nett_amount !== undefined ? String(invoice.nett_amount) : "",
|
||||
client_id: resolvedClient ? String(resolvedClient.id) : '',
|
||||
customer_id: resolvedClient?.customer_id ?? '',
|
||||
total_sem_amount: valueString(invoice.total_sem_amount ?? '0.00'),
|
||||
total_net_amount: valueString(
|
||||
invoice.total_net_amount ?? invoice.nett_amount ?? '0.00',
|
||||
),
|
||||
payments: paymentItemsFromInvoice(invoice, billingItemTypes),
|
||||
});
|
||||
const isApproved = !!invoice.approved_at;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.data.nett_amount) {
|
||||
form.setError("nett_amount", "Media Nett Amount is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSpending = form.data.total_spending
|
||||
? parseFloat(form.data.total_spending)
|
||||
: null;
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
linked_invoice_id: data.linked_invoice_id || null,
|
||||
is_credit_card: !!data.is_credit_card,
|
||||
is_paid: !!data.is_paid,
|
||||
payment_no: data.payment_no || null,
|
||||
start_date: data.start_date || null,
|
||||
end_date: data.end_date || null,
|
||||
amount: parseFloat(data.amount) || 0,
|
||||
management_fee: parseFloat(data.management_fee) || 0,
|
||||
media_fee: parseFloat(data.media_fee) || 0,
|
||||
tax_percent: parseFloat(data.tax_percent) || 0,
|
||||
total_spending: totalSpending,
|
||||
nett_amount: parseFloat(form.data.nett_amount) || 0,
|
||||
total_sem_amount: parseFloat(data.total_sem_amount) || 0,
|
||||
total_net_amount: parseFloat(data.total_net_amount) || 0,
|
||||
payments: data.payments.map((payment) => ({
|
||||
...payment,
|
||||
payment_no: payment.payment_no || null,
|
||||
payment_total_amount:
|
||||
parseFloat(payment.payment_total_amount) || 0,
|
||||
payment_nett_amount:
|
||||
parseFloat(payment.payment_nett_amount) || 0,
|
||||
items: payment.items.map((item) => ({
|
||||
...item,
|
||||
start_date: item.start_date || null,
|
||||
end_date: item.end_date || null,
|
||||
billing_item_types_id: parseInt(
|
||||
item.billing_item_types_id,
|
||||
10,
|
||||
),
|
||||
payment_item_amount: parseAmount(
|
||||
item.payment_item_amount,
|
||||
),
|
||||
tax_percentage: parseAmount(item.tax_percentage),
|
||||
net_amount: parseAmount(item.net_amount),
|
||||
withholding_tax: parseAmount(item.withholding_tax),
|
||||
final_net_amount: finalNetAmount(
|
||||
parseAmount(item.net_amount),
|
||||
parseAmount(item.withholding_tax),
|
||||
),
|
||||
spending: parseFloat(item.spending) || 0,
|
||||
is_creditcard: !!item.is_creditcard,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
form.put(route("client-invoices.update", { invoice: invoice.id }));
|
||||
form.put(route('client-invoices.update', { invoice: invoice.id }));
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
router.patch(route("client-invoices.approve", { invoice: invoice.id }));
|
||||
router.patch(route('client-invoices.approve', { invoice: invoice.id }));
|
||||
};
|
||||
|
||||
const invoiceOptions = availableInvoices.map((item) => ({
|
||||
value: String(item.id),
|
||||
label: item.invoice_no,
|
||||
}));
|
||||
const requiresClient = !invoice.client_id;
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Container size="lg" px="xs">
|
||||
<Group position="apart" mb="md">
|
||||
<Group gap="sm">
|
||||
<Group spacing="sm">
|
||||
<Title order={2}>Edit invoice</Title>
|
||||
<Badge color={isApproved ? "green" : "yellow"} variant="light">
|
||||
{isApproved ? "Approved" : "Pending approval"}
|
||||
<Badge
|
||||
color={isApproved ? 'green' : 'yellow'}
|
||||
variant="light"
|
||||
>
|
||||
{isApproved ? 'Approved' : 'Pending approval'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Group spacing="sm">
|
||||
{/* {!isApproved && (
|
||||
<Button color="green" onClick={handleApprove}>
|
||||
Approve invoice
|
||||
</Button>
|
||||
)} */}
|
||||
<Button
|
||||
component="a"
|
||||
href={route('client-invoices.getPdfInvoice', {
|
||||
id: invoice.id,
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftIcon={<IconFileDollar size={16} />}
|
||||
variant="light"
|
||||
color="green"
|
||||
>
|
||||
View invoice
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href={route("google-ads.accounts.show", { id: invoice.client?.customer_id ?? "" })}
|
||||
href={
|
||||
resolvedClient?.customer_id
|
||||
? route('google-ads.accounts.show', {
|
||||
id: resolvedClient.customer_id,
|
||||
})
|
||||
: route('dashboard')
|
||||
}
|
||||
leftIcon={<IconArrowLeft size={16} />}
|
||||
variant="outline"
|
||||
>
|
||||
Back to account
|
||||
{resolvedClient ? 'Back to account' : 'Back'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Card withBorder>
|
||||
<InvoiceForm form={form} onSubmit={handleSubmit} invoiceOptions={invoiceOptions} />
|
||||
</Card>
|
||||
<InvoiceForm
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
invoiceOptions={invoiceOptions}
|
||||
billingItemTypes={billingItemTypes}
|
||||
showClientLink
|
||||
requiresClient={requiresClient}
|
||||
clientOptions={unlinkedClients}
|
||||
pendingClientName={invoice.pending_client_name ?? null}
|
||||
/>
|
||||
</Container>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
IconBuilding,
|
||||
IconChecklist,
|
||||
IconFileInvoice,
|
||||
IconLinkOff,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
type DashboardStats = {
|
||||
@ -37,9 +36,7 @@ type RecentInvoice = {
|
||||
id: number;
|
||||
invoice_no: string;
|
||||
approved_at: string | null;
|
||||
management_fee: string | number | null;
|
||||
media_fee: string | number | null;
|
||||
nett_amount: string | number | null;
|
||||
total_net_amount: string | number | null;
|
||||
created_at: string | null;
|
||||
client: {
|
||||
name: string;
|
||||
@ -293,7 +290,7 @@ export default function Dashboard({
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Client</th>
|
||||
<th style={{ textAlign: 'right' }}>Nett</th>
|
||||
<th style={{ textAlign: 'right' }}>Net</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -313,7 +310,7 @@ export default function Dashboard({
|
||||
<Text size="sm" lineClamp={1}>{invoice.client?.name ?? '-'}</Text>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<Text size="sm" weight={700}>RM {formatAmount(invoice.nett_amount)}</Text>
|
||||
<Text size="sm" weight={700}>RM {formatAmount(invoice.total_net_amount)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Badge color={invoice.approved_at ? 'green' : 'orange'} variant="light">
|
||||
|
||||
87
resources/js/types/index.d.ts
vendored
87
resources/js/types/index.d.ts
vendored
@ -63,42 +63,85 @@ export interface Client {
|
||||
sales_person?: string;
|
||||
}
|
||||
|
||||
export interface Role{
|
||||
id:number;
|
||||
name:string;
|
||||
created_at:string;
|
||||
updated_at:string;
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ClientInvoice {
|
||||
id: number;
|
||||
client_id: number;
|
||||
client_id: number | null;
|
||||
pending_sql_acc_code?: string | null;
|
||||
pending_client_name?: string | null;
|
||||
invoice_no: string;
|
||||
linked_invoice_id?: number | null;
|
||||
approved_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
total_spend?: number | string;
|
||||
total_sem_amount?: number | string | null;
|
||||
total_net_amount?: number | string | null;
|
||||
is_credit_card?: boolean;
|
||||
is_paid?: boolean;
|
||||
approved_at?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
payment_no?: string | null;
|
||||
amount?: number | string | null;
|
||||
management_fee?: number | string | null;
|
||||
management_fee_amount?: number | string | null;
|
||||
management_fee_tax?: number | string | null;
|
||||
media_fee?: number | string | null;
|
||||
media_fee_amount?: number | string | null;
|
||||
media_fee_tax?: number | string | null;
|
||||
tax_percent?: number | string | null;
|
||||
nett_amount?: number | string | null;
|
||||
total_spending?: number | string | null;
|
||||
payments?: ClientInvoicePayment[];
|
||||
linked_invoices?: ClientInvoice[];
|
||||
}
|
||||
|
||||
export interface BillingItemType {
|
||||
id: number;
|
||||
name: string;
|
||||
sql_acc_code: string | null;
|
||||
nett_contribution: boolean;
|
||||
fee_type: string | null;
|
||||
type: string | null;
|
||||
campaign_type: string | null;
|
||||
}
|
||||
|
||||
export interface ClientInvoicePayment {
|
||||
id?: number;
|
||||
client_invoice_id?: number;
|
||||
payment_no: string | null;
|
||||
payment_total_amount: number | string;
|
||||
payment_tax_percentage?: number | string;
|
||||
payment_nett_amount: number | string;
|
||||
items: ClientInvoicePaymentItem[];
|
||||
}
|
||||
|
||||
export interface ClientInvoicePaymentItem {
|
||||
id?: number;
|
||||
client_invoice_payment_id?: number;
|
||||
billing_item_types_id: number;
|
||||
billing_item_type?: BillingItemType;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
payment_no: string | null;
|
||||
amount: number;
|
||||
total_spend?: number | string;
|
||||
management_fee?: number;
|
||||
management_fee_amount?: number;
|
||||
management_fee_tax?: number;
|
||||
media_fee?: number;
|
||||
media_fee_amount?: number;
|
||||
media_fee_tax?: number;
|
||||
tax_percent?: number;
|
||||
nett_amount?: number;
|
||||
total_spending?: number | null;
|
||||
linked_invoices?: ClientInvoice[];
|
||||
payment_item_amount: number | string;
|
||||
tax_percentage: number | string;
|
||||
net_amount: number | string;
|
||||
withholding_tax: number | string;
|
||||
final_net_amount: number | string;
|
||||
spending: number | string;
|
||||
is_creditcard: boolean;
|
||||
}
|
||||
|
||||
export interface ClientInvoiceAdjustment {
|
||||
id: number;
|
||||
client_id?: number | null;
|
||||
entry_type: "debit" | "credit";
|
||||
entry_type: 'debit' | 'credit';
|
||||
amount: number;
|
||||
remark: string | null;
|
||||
created_at?: string | null;
|
||||
@ -157,4 +200,4 @@ export interface ProjectActivity {
|
||||
notification_sent_at: Date | null;
|
||||
}
|
||||
|
||||
export type FormStatus = "create" | "update";
|
||||
export type FormStatus = 'create' | 'update';
|
||||
|
||||
@ -98,6 +98,105 @@
|
||||
->assertJsonPath('invoice.pending_client_name', 'New Client');
|
||||
});
|
||||
|
||||
it('opens the edit form for pending invoices that still need a client', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$invoice = ClientInvoice::create([
|
||||
'client_id' => null,
|
||||
'pending_sql_acc_code' => 'SEM-008',
|
||||
'pending_client_name' => 'Pending Client',
|
||||
'invoice_no' => 'INV-NEEDS-CLIENT',
|
||||
'approved_at' => null,
|
||||
'management_fee' => 120,
|
||||
'media_fee' => 880,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('client-invoices.edit', $invoice))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('client-invoices/edit')
|
||||
->where('invoice.id', $invoice->id)
|
||||
->where('invoice.pending_sql_acc_code', 'SEM-008')
|
||||
->where('unlinkedClients', [])
|
||||
);
|
||||
});
|
||||
|
||||
it('links a pending invoice client while saving the edit form', function () {
|
||||
$user = User::factory()->create();
|
||||
$client = Client::create([
|
||||
'name' => 'Acme',
|
||||
'customer_id' => '1234567896',
|
||||
'status' => 'ENABLED',
|
||||
'time_zone' => 'Asia/Kuala_Lumpur',
|
||||
'industry' => 'Marketing',
|
||||
'sql_acc_code' => null,
|
||||
]);
|
||||
$invoice = ClientInvoice::create([
|
||||
'client_id' => null,
|
||||
'pending_sql_acc_code' => 'SEM-009',
|
||||
'pending_client_name' => 'Acme',
|
||||
'invoice_no' => 'INV-LINK-EDIT',
|
||||
'approved_at' => null,
|
||||
'management_fee' => 120,
|
||||
'media_fee' => 880,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('client-invoices.update', $invoice), [
|
||||
'client_id' => $client->id,
|
||||
'invoice_no' => 'INV-LINK-EDIT',
|
||||
'management_fee' => 120,
|
||||
'media_fee' => 880,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$invoice->refresh();
|
||||
|
||||
expect($invoice->client_id)->toBe($client->id);
|
||||
expect($invoice->pending_sql_acc_code)->toBeNull();
|
||||
expect($invoice->approved_at)->not->toBeNull();
|
||||
expect($client->refresh()->sql_acc_code)->toBeNull();
|
||||
});
|
||||
|
||||
it('changes the linked google client from the edit form', function () {
|
||||
$user = User::factory()->create();
|
||||
$originalClient = Client::create([
|
||||
'name' => 'Original',
|
||||
'customer_id' => '1234567897',
|
||||
'status' => 'ENABLED',
|
||||
'time_zone' => 'Asia/Kuala_Lumpur',
|
||||
'industry' => 'Marketing',
|
||||
'sql_acc_code' => 'SEM-010',
|
||||
]);
|
||||
$newClient = Client::create([
|
||||
'name' => 'Replacement',
|
||||
'customer_id' => '1234567898',
|
||||
'status' => 'ENABLED',
|
||||
'time_zone' => 'Asia/Kuala_Lumpur',
|
||||
'industry' => 'Marketing',
|
||||
'sql_acc_code' => 'SEM-011',
|
||||
]);
|
||||
$invoice = ClientInvoice::create([
|
||||
'client_id' => $originalClient->id,
|
||||
'invoice_no' => 'INV-CHANGE-CLIENT',
|
||||
'approved_at' => null,
|
||||
'management_fee' => 120,
|
||||
'media_fee' => 880,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('client-invoices.update', $invoice), [
|
||||
'client_id' => $newClient->id,
|
||||
'invoice_no' => 'INV-CHANGE-CLIENT',
|
||||
'management_fee' => 120,
|
||||
'media_fee' => 880,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
expect($invoice->refresh()->client_id)->toBe($newClient->id);
|
||||
});
|
||||
|
||||
it('lists client invoices pending approval through the api', function () {
|
||||
$client = Client::create([
|
||||
'name' => 'Acme',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user