added option to import payments from csv file
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
use App\Models\Booking;
|
||||
use App\Models\Payment;
|
||||
use App\Models\PaymentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -20,11 +20,12 @@ public function index(Account $account): Response
|
||||
$payments = Payment::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('paid_at')
|
||||
->get(['id', 'amount_cents', 'currency', 'reference', 'paid_at', 'created_at'])
|
||||
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'paid_at', 'created_at'])
|
||||
->map(function (Payment $p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'amount' => $p->amount, // accessor divides cents
|
||||
'amount' => (float) $p->amount,
|
||||
'balance_before' => (float) ($p->balance_before ?? 0),
|
||||
'currency' => $p->currency,
|
||||
'reference' => $p->reference,
|
||||
'paid_at' => $p->paid_at,
|
||||
@@ -43,11 +44,12 @@ public function list(Account $account): JsonResponse
|
||||
$payments = Payment::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('paid_at')
|
||||
->get(['id', 'amount_cents', 'currency', 'reference', 'paid_at', 'created_at'])
|
||||
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'paid_at', 'created_at'])
|
||||
->map(function (Payment $p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'amount' => $p->amount,
|
||||
'amount' => (float) $p->amount,
|
||||
'balance_before' => (float) ($p->balance_before ?? 0),
|
||||
'currency' => $p->currency,
|
||||
'reference' => $p->reference,
|
||||
'paid_at' => optional($p->paid_at)?->toDateString(),
|
||||
@@ -76,7 +78,8 @@ public function store(StorePaymentRequest $request, Account $account): RedirectR
|
||||
|
||||
$payment = Payment::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'amount_cents' => $amountCents,
|
||||
'balance_before' => (float) ($account->balance_amount ?? 0),
|
||||
'amount' => (float) $validated['amount'],
|
||||
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||
'reference' => $validated['reference'] ?? null,
|
||||
'paid_at' => $validated['paid_at'] ?? now(),
|
||||
@@ -94,10 +97,17 @@ public function store(StorePaymentRequest $request, Account $account): RedirectR
|
||||
'booked_at' => $payment->paid_at ?? now(),
|
||||
]);
|
||||
|
||||
// Optionally create an activity entry with default decision/action
|
||||
// Optionally create an activity entry with default decision/action
|
||||
if ($settings && ($settings->create_activity_on_payment ?? false)) {
|
||||
$note = $settings->activity_note_template ?? 'Prejeto plačilo';
|
||||
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency], $note);
|
||||
|
||||
// Append balance context and cause
|
||||
$account->refresh();
|
||||
$beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.$payment->currency;
|
||||
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$payment->currency;
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)";
|
||||
|
||||
$account->loadMissing('contract');
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
if ($clientCaseId) {
|
||||
@@ -139,6 +149,7 @@ public function destroy(Account $account, Payment $payment): RedirectResponse|Js
|
||||
|
||||
if (request()->wantsJson()) {
|
||||
$account->refresh();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
|
||||
@@ -152,6 +152,7 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
|
||||
|
||||
// Preserve segment filter if present
|
||||
$segment = request('segment');
|
||||
|
||||
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
|
||||
}
|
||||
|
||||
@@ -172,6 +173,8 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||
$shouldUpsertAccount = (! is_null($initial)) || (! is_null($balance)) || $request->has('account_type_id');
|
||||
if ($shouldUpsertAccount) {
|
||||
$accountData = [];
|
||||
// Track old balance before applying changes
|
||||
$oldBalance = (float) optional($contract->account)->balance_amount;
|
||||
if (! is_null($initial)) {
|
||||
$accountData['initial_amount'] = $initial;
|
||||
}
|
||||
@@ -189,12 +192,37 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||
$accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData);
|
||||
$contract->account()->create($accountData);
|
||||
}
|
||||
|
||||
// After update/create, if balance_amount changed (and not through a payment), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $accountData)) {
|
||||
$newBalance = (float) optional($contract->account)->fresh()->balance_amount;
|
||||
if ($newBalance !== $oldBalance) {
|
||||
try {
|
||||
$currency = optional(\App\Models\PaymentSetting::query()->first())->default_currency ?? 'EUR';
|
||||
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
|
||||
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
|
||||
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
|
||||
\App\Models\Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => null,
|
||||
'note' => $note,
|
||||
'action_id' => null,
|
||||
'decision_id' => null,
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Preserve segment filter if present
|
||||
$segment = request('segment');
|
||||
|
||||
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
|
||||
}
|
||||
|
||||
@@ -210,7 +238,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
]);
|
||||
|
||||
|
||||
// Map contract_uuid to contract_id within the same client case, if provided
|
||||
$contractId = null;
|
||||
if (! empty($attributes['contract_uuid'])) {
|
||||
@@ -276,6 +303,7 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re
|
||||
|
||||
// Preserve segment filter if present
|
||||
$segment = request('segment');
|
||||
|
||||
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
|
||||
}
|
||||
|
||||
@@ -1048,11 +1076,13 @@ public function show(ClientCase $clientCase)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $q->paginate(20, ['*'], 'activities')->withQueryString();
|
||||
})(),
|
||||
function ($p) {
|
||||
$p->getCollection()->transform(function ($a) {
|
||||
$a->setAttribute('user_name', optional($a->user)->name);
|
||||
|
||||
return $a;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,10 +255,10 @@ public function saveMappings(Request $request, Import $import)
|
||||
$data = $request->validate([
|
||||
'mappings' => 'required|array',
|
||||
'mappings.*.source_column' => 'required|string',
|
||||
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
|
||||
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'mappings.*.target_field' => 'required|string',
|
||||
'mappings.*.transform' => 'nullable|string|in:trim,upper,lower,decimal,ref',
|
||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both',
|
||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'mappings.*.options' => 'nullable|array',
|
||||
]);
|
||||
|
||||
|
||||
@@ -77,7 +77,13 @@ public function suggest(Request $request)
|
||||
if (! is_array($cols)) {
|
||||
$cols = [];
|
||||
}
|
||||
$entities = ImportEntity::all();
|
||||
// Optional filter: only suggest for specific entity keys (e.g., template meta.entities)
|
||||
$only = $request->input('only_entities');
|
||||
$query = ImportEntity::query()->orderByRaw("(ui->>'order')::int nulls last");
|
||||
if (is_array($only) && ! empty($only)) {
|
||||
$query->whereIn('key', array_values(array_filter($only, fn ($v) => is_string($v) && $v !== '')));
|
||||
}
|
||||
$entities = $query->get();
|
||||
$suggestions = [];
|
||||
foreach ($cols as $col) {
|
||||
$s = $this->suggestFor($col, $entities);
|
||||
@@ -92,6 +98,8 @@ public function suggest(Request $request)
|
||||
private function suggestFor(string $source, $entities): ?array
|
||||
{
|
||||
$s = trim(mb_strtolower($source));
|
||||
$best = null;
|
||||
$bestRank = PHP_INT_MAX;
|
||||
foreach ($entities as $ent) {
|
||||
$rules = (array) ($ent->rules ?? []);
|
||||
foreach ($rules as $rule) {
|
||||
@@ -101,15 +109,27 @@ private function suggestFor(string $source, $entities): ?array
|
||||
continue;
|
||||
}
|
||||
if (@preg_match($pattern, $s)) {
|
||||
return [
|
||||
'entity' => $ent->key, // UI key (plural except person)
|
||||
'field' => $field,
|
||||
'canonical_root' => $ent->canonical_root,
|
||||
];
|
||||
// Rank preferences: payments over address for amount/date-like terms
|
||||
$rank = (int) ($ent->ui['order'] ?? 999);
|
||||
$isPayment = ($ent->canonical_root ?? null) === 'payment';
|
||||
$isAddress = ($ent->canonical_root ?? null) === 'address';
|
||||
if ($isPayment) {
|
||||
$rank -= 10; // boost payments
|
||||
} elseif ($isAddress && in_array($field, ['amount', 'payment_date', 'payment_nu', 'reference'])) {
|
||||
$rank += 10; // demote addresses for these
|
||||
}
|
||||
if ($rank < $bestRank) {
|
||||
$bestRank = $rank;
|
||||
$best = [
|
||||
'entity' => $ent->key,
|
||||
'field' => $field,
|
||||
'canonical_root' => $ent->canonical_root,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return $best;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,19 +110,21 @@ public function store(Request $request)
|
||||
'client_id' => 'nullable|integer|exists:clients,id',
|
||||
'is_active' => 'boolean',
|
||||
'entities' => 'nullable|array',
|
||||
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts',
|
||||
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'mappings' => 'array',
|
||||
'mappings.*.source_column' => 'required|string',
|
||||
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
|
||||
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'mappings.*.target_field' => 'nullable|string',
|
||||
'mappings.*.transform' => 'nullable|string|max:50',
|
||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both',
|
||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'mappings.*.options' => 'nullable|array',
|
||||
'mappings.*.position' => 'nullable|integer',
|
||||
'meta' => 'nullable|array',
|
||||
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
||||
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
||||
'meta.payments_import' => 'nullable|boolean',
|
||||
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
||||
])->validate();
|
||||
|
||||
// Ensure decision belongs to action if both provided in meta
|
||||
@@ -138,6 +140,11 @@ public function store(Request $request)
|
||||
}
|
||||
$template = null;
|
||||
DB::transaction(function () use (&$template, $request, $data) {
|
||||
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
|
||||
$entities = $data['entities'] ?? [];
|
||||
if ($paymentsImport) {
|
||||
$entities = ['contracts', 'accounts', 'payments'];
|
||||
}
|
||||
$template = ImportTemplate::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => $data['name'],
|
||||
@@ -149,10 +156,12 @@ public function store(Request $request)
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'meta' => array_filter([
|
||||
'entities' => $data['entities'] ?? [],
|
||||
'entities' => $entities,
|
||||
'segment_id' => data_get($data, 'meta.segment_id'),
|
||||
'decision_id' => data_get($data, 'meta.decision_id'),
|
||||
'action_id' => data_get($data, 'meta.action_id'),
|
||||
'payments_import' => $paymentsImport ?: null,
|
||||
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
|
||||
], fn ($v) => ! is_null($v) && $v !== ''),
|
||||
]);
|
||||
|
||||
@@ -232,10 +241,10 @@ public function addMapping(Request $request, ImportTemplate $template)
|
||||
}
|
||||
$data = validator($raw, [
|
||||
'source_column' => 'required|string',
|
||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
|
||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'target_field' => 'nullable|string',
|
||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both',
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'options' => 'nullable|array',
|
||||
'position' => 'nullable|integer',
|
||||
])->validate();
|
||||
@@ -295,6 +304,8 @@ public function update(Request $request, ImportTemplate $template)
|
||||
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
||||
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
||||
'meta.payments_import' => 'nullable|boolean',
|
||||
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
||||
])->validate();
|
||||
|
||||
// Validate decision/action consistency on update as well
|
||||
@@ -316,7 +327,7 @@ public function update(Request $request, ImportTemplate $template)
|
||||
if (array_key_exists('delimiter', $newMeta) && (! is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) {
|
||||
unset($newMeta['delimiter']);
|
||||
}
|
||||
foreach (['segment_id', 'decision_id', 'action_id'] as $k) {
|
||||
foreach (['segment_id', 'decision_id', 'action_id', 'payments_import', 'contract_key_mode'] as $k) {
|
||||
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
|
||||
unset($newMeta[$k]);
|
||||
}
|
||||
@@ -331,7 +342,16 @@ public function update(Request $request, ImportTemplate $template)
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? $template->is_active,
|
||||
'sample_headers' => $data['sample_headers'] ?? $template->sample_headers,
|
||||
'meta' => $newMeta,
|
||||
'meta' => (function () use ($newMeta) {
|
||||
// If payments import mode is enabled, force entities sequence in meta
|
||||
$meta = $newMeta;
|
||||
$payments = (bool) ($meta['payments_import'] ?? false);
|
||||
if ($payments) {
|
||||
$meta['entities'] = ['contracts', 'accounts', 'payments'];
|
||||
}
|
||||
|
||||
return $meta;
|
||||
})(),
|
||||
]);
|
||||
|
||||
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
|
||||
@@ -348,14 +368,21 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
||||
}
|
||||
$data = validator($raw, [
|
||||
'sources' => 'required|string', // comma and/or newline separated
|
||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
|
||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both',
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||
])->validate();
|
||||
|
||||
$list = preg_split('/\r?\n|,/', $data['sources']);
|
||||
$list = array_values(array_filter(array_map(fn ($s) => trim($s), $list), fn ($s) => $s !== ''));
|
||||
// Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace
|
||||
$list = preg_split('/[\r\n,;]+/', $data['sources']);
|
||||
$list = array_values(array_filter(array_map(function ($s) {
|
||||
$s = trim((string) $s);
|
||||
// remove surrounding double/single quotes if present
|
||||
$s = preg_replace('/^([\"\'])|([\"\'])$/u', '', $s) ?? $s;
|
||||
|
||||
return $s;
|
||||
}, $list), fn ($s) => $s !== ''));
|
||||
|
||||
if (empty($list)) {
|
||||
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
|
||||
@@ -437,10 +464,10 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
|
||||
}
|
||||
$data = validator($raw, [
|
||||
'source_column' => 'required|string',
|
||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
|
||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'target_field' => 'nullable|string',
|
||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both',
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'options' => 'nullable|array',
|
||||
'position' => 'nullable|integer',
|
||||
])->validate();
|
||||
|
||||
+5
-23
@@ -2,13 +2,11 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Models\Activity;
|
||||
|
||||
class Payment extends Model
|
||||
{
|
||||
@@ -17,7 +15,8 @@ class Payment extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'amount_cents',
|
||||
'amount',
|
||||
'balance_before',
|
||||
'currency',
|
||||
'reference',
|
||||
'paid_at',
|
||||
@@ -31,7 +30,8 @@ protected function casts(): array
|
||||
return [
|
||||
'paid_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'amount_cents' => 'integer',
|
||||
'amount' => 'decimal:4',
|
||||
'balance_before' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -55,23 +55,5 @@ public function type(): BelongsTo
|
||||
return $this->belongsTo(\App\Models\PaymentType::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor to expose decimal amount for JSON serialization and UI convenience.
|
||||
*/
|
||||
protected function amount(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$cents = (int) ($this->attributes['amount_cents'] ?? 0);
|
||||
|
||||
return $cents / 100;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutator to set amount via decimal; stores in cents.
|
||||
*/
|
||||
public function setAmountAttribute($value): void
|
||||
{
|
||||
$this->attributes['amount_cents'] = (int) round(((float) $value) * 100);
|
||||
}
|
||||
// amount is stored as decimal(20,4); default Eloquent get/set is sufficient
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use App\Models\ImportEntity;
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportRow;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonAddress;
|
||||
@@ -68,6 +69,10 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
$this->validateMappingRoots($mappings, $validRoots);
|
||||
|
||||
$header = $import->meta['columns'] ?? null;
|
||||
// Template meta flags
|
||||
$tplMeta = optional($import->template)->meta ?? [];
|
||||
$paymentsImport = (bool) ($tplMeta['payments_import'] ?? false);
|
||||
$contractKeyMode = $tplMeta['contract_key_mode'] ?? null;
|
||||
// Prefer explicitly chosen delimiter, then template meta, else detected
|
||||
$delimiter = $import->meta['forced_delimiter']
|
||||
?? optional($import->template)->meta['delimiter']
|
||||
@@ -102,6 +107,43 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
'level' => 'info',
|
||||
'message' => 'Processing started.',
|
||||
]);
|
||||
// Preflight recommendations for payments-import
|
||||
if ($paymentsImport) {
|
||||
$hasContractRef = $this->mappingIncludes($mappings, 'contract.reference');
|
||||
$hasPaymentAmount = $this->mappingIncludes($mappings, 'payment.amount');
|
||||
$hasPaymentDate = $this->mappingIncludes($mappings, 'payment.payment_date') || $this->mappingIncludes($mappings, 'payment.paid_at');
|
||||
$hasPaymentRef = $this->mappingIncludes($mappings, 'payment.reference');
|
||||
$hasPaymentNu = $this->mappingIncludes($mappings, 'payment.payment_nu');
|
||||
$missing = [];
|
||||
if (! $hasContractRef) {
|
||||
$missing[] = 'contract.reference';
|
||||
}
|
||||
if (! $hasPaymentAmount) {
|
||||
$missing[] = 'payment.amount';
|
||||
}
|
||||
if (! $hasPaymentDate) {
|
||||
$missing[] = 'payment.payment_date';
|
||||
}
|
||||
if (! empty($missing)) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'payments_mapping_recommendation',
|
||||
'level' => 'warning',
|
||||
'message' => 'Payments import: recommended mappings missing.',
|
||||
'context' => ['missing' => $missing],
|
||||
]);
|
||||
}
|
||||
if (! $hasPaymentRef && ! $hasPaymentNu) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'payments_idempotency_recommendation',
|
||||
'level' => 'warning',
|
||||
'message' => 'For idempotency, map payment.reference (preferred) or payment.payment_nu.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$rowNum = 0;
|
||||
if ($hasHeader) {
|
||||
@@ -177,7 +219,32 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
// Contracts
|
||||
$contractResult = null;
|
||||
if (isset($mapped['contract'])) {
|
||||
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
|
||||
// In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only
|
||||
if ($paymentsImport && $contractKeyMode === 'reference') {
|
||||
$ref = $mapped['contract']['reference'] ?? null;
|
||||
if (is_string($ref)) {
|
||||
$ref = preg_replace('/\s+/', '', trim($ref));
|
||||
}
|
||||
if ($ref) {
|
||||
$q = Contract::query()
|
||||
->when($import->client_id, function ($q2, $clientId) {
|
||||
$q2->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId);
|
||||
})
|
||||
->where('contracts.reference', $ref)
|
||||
->select('contracts.*');
|
||||
$found = $q->first();
|
||||
if ($found) {
|
||||
$contractResult = ['action' => 'resolved', 'contract' => $found];
|
||||
} else {
|
||||
$contractResult = null; // let requireContract logic flag invalid later
|
||||
}
|
||||
} else {
|
||||
$contractResult = null;
|
||||
}
|
||||
} else {
|
||||
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
|
||||
}
|
||||
if ($contractResult['action'] === 'skipped') {
|
||||
// Even if no contract fields were updated, we may still need to apply template meta
|
||||
// like attaching a segment or creating an activity. Do that if we have the contract.
|
||||
@@ -330,6 +397,233 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
$ccId = $contractResult['contract']->client_case_id;
|
||||
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
|
||||
}
|
||||
|
||||
// Payments: when present, require account resolution and create payment
|
||||
if (isset($mapped['payment'])) {
|
||||
// If no account yet, try to resolve via contract + first account
|
||||
$accountIdForPayment = $accountResult['account']->id ?? null;
|
||||
|
||||
// If payments-import mode with contract_key_mode=reference and we have contract.reference mapped, resolve by reference only
|
||||
$tplMeta = optional($import->template)->meta ?? [];
|
||||
$paymentsImport = (bool) ($tplMeta['payments_import'] ?? false);
|
||||
$contractKeyMode = $tplMeta['contract_key_mode'] ?? null;
|
||||
if (! $accountIdForPayment && $paymentsImport && $contractKeyMode === 'reference') {
|
||||
$contractRef = $mapped['contract']['reference'] ?? null;
|
||||
if ($contractRef) {
|
||||
$contract = \App\Models\Contract::query()
|
||||
->when($import->client_id, function ($q, $clientId) {
|
||||
$q->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId);
|
||||
})
|
||||
->where('contracts.reference', $contractRef)
|
||||
->select('contracts.id')
|
||||
->first();
|
||||
if ($contract) {
|
||||
$accountIdForPayment = \App\Models\Account::where('contract_id', $contract->id)->value('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $accountIdForPayment) {
|
||||
$invalid++;
|
||||
$importRow->update(['status' => 'invalid', 'errors' => ['Payment requires an account. Could not resolve account for payment.']]);
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'row_invalid',
|
||||
'level' => 'error',
|
||||
'message' => 'Payment requires an account (not resolved).',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build payment payload
|
||||
$p = $mapped['payment'];
|
||||
// Normalize reference and payment number (if provided)
|
||||
$refVal = isset($p['reference']) ? (is_string($p['reference']) ? trim($p['reference']) : $p['reference']) : null;
|
||||
$nuVal = isset($p['payment_nu']) ? (is_string($p['payment_nu']) ? trim($p['payment_nu']) : $p['payment_nu']) : null;
|
||||
$payload = [
|
||||
'account_id' => $accountIdForPayment,
|
||||
'reference' => $p['reference'] ?? null,
|
||||
'paid_at' => $p['payment_date'] ?? ($p['paid_at'] ?? null),
|
||||
'currency' => $p['currency'] ?? 'EUR',
|
||||
'created_by' => $user?->getAuthIdentifier(),
|
||||
];
|
||||
// Attach payment_nu into meta for idempotency if provided
|
||||
$meta = [];
|
||||
if (is_array($p['meta'] ?? null)) {
|
||||
$meta = $p['meta'];
|
||||
}
|
||||
if (! empty($nuVal)) {
|
||||
$meta['payment_nu'] = $nuVal;
|
||||
}
|
||||
if (! empty($meta)) {
|
||||
$payload['meta'] = $meta;
|
||||
}
|
||||
// Amount: accept either amount (preferred) or legacy amount_cents; convert cents -> decimal
|
||||
if (array_key_exists('amount', $p)) {
|
||||
$payload['amount'] = is_string($p['amount']) ? (float) $this->normalizeDecimal($p['amount']) : (float) $p['amount'];
|
||||
} elseif (array_key_exists('amount_cents', $p)) {
|
||||
$payload['amount'] = ((int) $p['amount_cents']) / 100.0;
|
||||
}
|
||||
|
||||
// Idempotency: skip creating if a payment with same (account_id, reference) already exists
|
||||
if (! empty($refVal)) {
|
||||
$exists = Payment::query()
|
||||
->where('account_id', $accountIdForPayment)
|
||||
->where('reference', $refVal)
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
$importRow->update(['status' => 'skipped']);
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'payment_duplicate_skipped',
|
||||
'level' => 'info',
|
||||
'message' => 'Skipped duplicate payment (by reference).',
|
||||
'context' => [
|
||||
'account_id' => $accountIdForPayment,
|
||||
'reference' => $refVal,
|
||||
],
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$payment = new Payment;
|
||||
$payment->fill($payload);
|
||||
// Save the account balance before applying this payment
|
||||
$accForBal = Account::find($accountIdForPayment);
|
||||
if ($accForBal) {
|
||||
$payment->balance_before = (float) ($accForBal->balance_amount ?? 0);
|
||||
}
|
||||
// If amount not in payload yet but provided, set it directly
|
||||
if (! array_key_exists('amount', $payload) && isset($p['amount'])) {
|
||||
$payment->amount = (float) $this->normalizeDecimal($p['amount']);
|
||||
}
|
||||
try {
|
||||
$payment->save();
|
||||
} catch (\Throwable $e) {
|
||||
// Gracefully skip if unique index on (account_id, reference) is violated due to race conditions
|
||||
if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) {
|
||||
$skipped++;
|
||||
$importRow->update(['status' => 'skipped']);
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'payment_duplicate_skipped_db',
|
||||
'level' => 'info',
|
||||
'message' => 'Skipped duplicate payment due to database unique constraint (account_id, reference).',
|
||||
'context' => [
|
||||
'account_id' => $accountIdForPayment,
|
||||
'reference' => $refVal,
|
||||
],
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Option A: create a credit booking so account balance updates via booking events
|
||||
try {
|
||||
if (isset($payment->amount)) {
|
||||
\App\Models\Booking::query()->create([
|
||||
'account_id' => $accountIdForPayment,
|
||||
'payment_id' => $payment->id,
|
||||
'amount_cents' => (int) round(((float) $payment->amount) * 100),
|
||||
'type' => 'credit',
|
||||
'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo',
|
||||
'booked_at' => $payment->paid_at ?? now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'booking_create_failed',
|
||||
'level' => 'warning',
|
||||
'message' => 'Failed to create booking for payment: '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Optionally create an activity entry for this payment
|
||||
try {
|
||||
$settings = \App\Models\PaymentSetting::query()->first();
|
||||
if ($settings && ($settings->create_activity_on_payment ?? false)) {
|
||||
$amountCents = (int) round(((float) $payment->amount) * 100);
|
||||
$note = $settings->activity_note_template ?? 'Prejeto plačilo';
|
||||
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], $note);
|
||||
|
||||
// Append balance context (before/after) and mark cause as payment
|
||||
// At this point, booking has been created so the account balance should reflect the new amount
|
||||
$accountAfter = Account::find($accountIdForPayment);
|
||||
$beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR');
|
||||
$afterStr = number_format((float) ($accountAfter?->balance_amount ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR');
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)";
|
||||
|
||||
// Resolve client_case_id via account->contract
|
||||
$accountForActivity = $accForBal ?: Account::find($accountIdForPayment);
|
||||
$accountForActivity?->loadMissing('contract');
|
||||
$contractId = $accountForActivity?->contract_id;
|
||||
$clientCaseId = $accountForActivity?->contract?->client_case_id;
|
||||
|
||||
if ($clientCaseId) {
|
||||
$activity = \App\Models\Activity::query()->create([
|
||||
'due_date' => null,
|
||||
'amount' => $amountCents / 100,
|
||||
'note' => $note,
|
||||
'action_id' => $settings->default_action_id,
|
||||
'decision_id' => $settings->default_decision_id,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
$payment->update(['activity_id' => $activity->id]);
|
||||
} else {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'payment_activity_skipped',
|
||||
'level' => 'info',
|
||||
'message' => 'Skipped creating activity for payment due to missing client_case_id on contract.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'payment_activity_failed',
|
||||
'level' => 'warning',
|
||||
'message' => 'Failed to create activity for payment: '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$imported++;
|
||||
$importRow->update([
|
||||
'status' => 'imported',
|
||||
'entity_type' => Payment::class,
|
||||
'entity_id' => $payment->id,
|
||||
]);
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'row_imported',
|
||||
'level' => 'info',
|
||||
'message' => 'Inserted payment',
|
||||
'context' => ['id' => $payment->id],
|
||||
]);
|
||||
}
|
||||
// If we have a contract reference, resolve existing contract for this client and derive person
|
||||
if (! $personIdForRow && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) {
|
||||
$existingContract = Contract::query()
|
||||
@@ -709,10 +1003,16 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||
continue;
|
||||
}
|
||||
$value = $acc[$field] ?? null;
|
||||
if (in_array($field, ['balance_amount','initial_amount'], true) && is_string($value)) {
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||
$value = $this->normalizeDecimal($value);
|
||||
}
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if ($mode === 'keyref') {
|
||||
// treat as insert-only field (lookup + create), never update
|
||||
$applyInsert[$field] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$applyModeByField[$field] = $mode;
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $value;
|
||||
@@ -731,9 +1031,39 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No non-null changes'];
|
||||
}
|
||||
// Track balance change
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
||||
if ($newBalance !== $oldBalance) {
|
||||
try {
|
||||
$contractId = $existing->contract_id;
|
||||
$clientCaseId = Contract::where('id', $contractId)->value('client_case_id');
|
||||
$currency = optional(\App\Models\PaymentSetting::query()->first())->default_currency ?? 'EUR';
|
||||
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
|
||||
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
|
||||
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
|
||||
if ($clientCaseId) {
|
||||
Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => null,
|
||||
'note' => $note,
|
||||
'action_id' => null,
|
||||
'decision_id' => null,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-fatal: ignore activity creation failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// also include contract hints for downstream contact resolution
|
||||
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId];
|
||||
} else {
|
||||
@@ -743,7 +1073,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||
if ((! array_key_exists('initial_amount', $applyInsert) || is_null($applyInsert['initial_amount'] ?? null))
|
||||
&& array_key_exists('balance_amount', $applyInsert)
|
||||
&& ($applyInsert['balance_amount'] !== null && $applyInsert['balance_amount'] !== '')
|
||||
&& ($initMode === null || in_array($initMode, ['insert','both'], true))) {
|
||||
&& ($initMode === null || in_array($initMode, ['insert', 'both'], true))) {
|
||||
$applyInsert['initial_amount'] = $applyInsert['balance_amount'];
|
||||
}
|
||||
if (empty($applyInsert)) {
|
||||
@@ -902,6 +1232,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||
$value = preg_replace('/\s+/', '', trim((string) $value));
|
||||
}
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
// keyref: used as lookup and applied on insert, but not on update
|
||||
if ($mode === 'keyref') {
|
||||
$applyInsert[$field] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user