add payment option
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreBookingRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AccountBookingController extends Controller
|
||||
{
|
||||
public function index(Account $account): Response
|
||||
{
|
||||
$bookings = Booking::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('booked_at')
|
||||
->get(['id', 'payment_id', 'amount_cents', 'type', 'description', 'booked_at', 'created_at']);
|
||||
|
||||
return Inertia::render('Accounts/Bookings/Index', [
|
||||
'account' => $account->only(['id', 'reference', 'description']),
|
||||
'bookings' => $bookings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreBookingRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
Booking::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'payment_id' => $validated['payment_id'] ?? null,
|
||||
'amount_cents' => (int) round(((float) $validated['amount']) * 100),
|
||||
'type' => $validated['type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'booked_at' => $validated['booked_at'] ?? now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Booking created.');
|
||||
}
|
||||
|
||||
public function destroy(Account $account, Booking $booking): RedirectResponse
|
||||
{
|
||||
if ($booking->account_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$booking->delete();
|
||||
|
||||
return back()->with('success', 'Booking deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StorePaymentRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Payment;
|
||||
use App\Models\PaymentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AccountPaymentController extends Controller
|
||||
{
|
||||
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'])
|
||||
->map(function (Payment $p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'amount' => $p->amount, // accessor divides cents
|
||||
'currency' => $p->currency,
|
||||
'reference' => $p->reference,
|
||||
'paid_at' => $p->paid_at,
|
||||
'created_at' => $p->created_at,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Accounts/Payments/Index', [
|
||||
'account' => $account->only(['id', 'reference', 'description']),
|
||||
'payments' => $payments,
|
||||
]);
|
||||
}
|
||||
|
||||
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'])
|
||||
->map(function (Payment $p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'amount' => $p->amount,
|
||||
'currency' => $p->currency,
|
||||
'reference' => $p->reference,
|
||||
'paid_at' => optional($p->paid_at)?->toDateString(),
|
||||
'created_at' => optional($p->created_at)?->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'account' => [
|
||||
'id' => $account->id,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
],
|
||||
'payments' => $payments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePaymentRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$amountCents = (int) round(((float) $validated['amount']) * 100);
|
||||
|
||||
// Load defaults from settings
|
||||
$settings = PaymentSetting::query()->first();
|
||||
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
|
||||
|
||||
$payment = Payment::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'amount_cents' => $amountCents,
|
||||
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||
'reference' => $validated['reference'] ?? null,
|
||||
'paid_at' => $validated['paid_at'] ?? now(),
|
||||
'meta' => $validated['meta'] ?? null,
|
||||
'created_by' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
// Auto-create a credit booking for this payment to reduce account balance
|
||||
Booking::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'payment_id' => $payment->id,
|
||||
'amount_cents' => $amountCents,
|
||||
'type' => 'credit',
|
||||
'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo',
|
||||
'booked_at' => $payment->paid_at ?? now(),
|
||||
]);
|
||||
|
||||
// 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);
|
||||
$account->loadMissing('contract');
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
if ($clientCaseId) {
|
||||
$activity = 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' => $account->contract_id,
|
||||
]);
|
||||
// Link the payment to the activity
|
||||
$payment->update(['activity_id' => $activity->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Payment created.');
|
||||
}
|
||||
|
||||
public function destroy(Account $account, Payment $payment): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($payment->account_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delete related booking(s) to revert balance via model events
|
||||
Booking::query()->where('payment_id', $payment->id)->get()->each->delete();
|
||||
|
||||
// Optionally delete related activity
|
||||
if ($payment->activity_id ?? null) {
|
||||
$activity = Activity::query()->find($payment->activity_id);
|
||||
if ($activity) {
|
||||
$activity->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$payment->delete();
|
||||
|
||||
if (request()->wantsJson()) {
|
||||
$account->refresh();
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Payment deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdatePaymentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\PaymentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PaymentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = PaymentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = PaymentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_payment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Prejeto plačilo: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Payments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_payment' => (bool) $setting->create_activity_on_payment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdatePaymentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = PaymentSetting::query()->firstOrFail();
|
||||
|
||||
// Ensure boolean cast for checkbox
|
||||
$data['create_activity_on_payment'] = (bool) ($data['create_activity_on_payment'] ?? false);
|
||||
|
||||
$setting->fill($data)->save();
|
||||
|
||||
return back()->with('success', 'Payment settings updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreBookingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'type' => ['required', 'in:debit,credit'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'booked_at' => ['nullable', 'date'],
|
||||
'payment_id' => ['nullable', 'integer', 'exists:payments,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePaymentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'currency' => ['nullable', 'string', 'size:3'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'paid_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePaymentSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'default_currency' => ['required', 'string', 'size:3'],
|
||||
'create_activity_on_payment' => ['sometimes', 'boolean'],
|
||||
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,18 @@ public function debts(): HasMany
|
||||
return $this->hasMany(\App\Models\Debt::class);
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Payment::class);
|
||||
}
|
||||
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Booking::class);
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Contract::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,9 @@
|
||||
class AccountType extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'payment_id',
|
||||
'amount_cents',
|
||||
'type',
|
||||
'description',
|
||||
'booked_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'booked_at' => 'datetime',
|
||||
'amount_cents' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Payment::class);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (Booking $booking): void {
|
||||
$booking->applyToAccountBalance(+1);
|
||||
});
|
||||
|
||||
static::deleted(function (Booking $booking): void {
|
||||
// Soft delete should revert the effect on balance
|
||||
$booking->applyToAccountBalance(-1);
|
||||
});
|
||||
|
||||
static::restored(function (Booking $booking): void {
|
||||
// Re-apply when restored
|
||||
$booking->applyToAccountBalance(+1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply or revert the booking effect on account balance.
|
||||
*
|
||||
* @param int $multiplier +1 to apply, -1 to revert
|
||||
*/
|
||||
protected function applyToAccountBalance(int $multiplier = 1): void
|
||||
{
|
||||
$account = $this->account;
|
||||
if (! $account) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delta = ($this->amount_cents / 100.0);
|
||||
if ($this->type === 'credit') {
|
||||
// Credit decreases the receivable (balance goes down)
|
||||
$delta = -$delta;
|
||||
}
|
||||
// Debit increases receivable (balance up), credit decreases
|
||||
$account->forceFill([
|
||||
'balance_amount' => (float) ($account->balance_amount ?? 0) + ($multiplier * $delta),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,76 @@
|
||||
|
||||
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
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'amount_cents',
|
||||
'currency',
|
||||
'reference',
|
||||
'paid_at',
|
||||
'meta',
|
||||
'created_by',
|
||||
'activity_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'paid_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'amount_cents' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Booking::class);
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'default_currency',
|
||||
'create_activity_on_payment',
|
||||
'default_decision_id',
|
||||
'default_action_id',
|
||||
'activity_note_template',
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user