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
+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;
}