added option to import payments from csv file

This commit is contained in:
Simon Pocrnjič
2025-10-02 22:09:05 +02:00
parent 971a9e89d1
commit 12de0186cf
21 changed files with 2828 additions and 824 deletions
@@ -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,
+31 -1
View File
@@ -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;
});
}
+2 -2
View File
@@ -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
View File
@@ -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
}
+339 -3
View File
@@ -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;
}