added option to import payments from csv file
This commit is contained in:
@@ -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