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