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();