Skip to content

Commit adcc754

Browse files
committed
feat: POS Terminal, Bank Reconciliation, Helpdesk SLA, CRM Email Sequences — 45 tests passing
Modules completed: - POS Terminal: live checkout screen, product grid, cart, cash/card/digital_wallet payment, receipt page, sessions list. PosTerminalController routes wired into existing pos.php. - Accounting Bank Reconciliation: BankAccount + BankTransaction + AutoPostingRule models, 3 migrations, BankAccountController + BankReconciliationController + AutoPostingRuleController, keyword-based auto-posting that reconciles on import. - Helpdesk SLA Escalations: TicketEscalation model (migration 2027_01_05_100002), SlaController with breach detection, manual escalate, resolve endpoints. SLA policy CRUD via existing HelpdeskSlaPolicy model. - CRM Email Sequences + Lead Scoring: EmailSequence, EmailSequenceStep, EmailSequenceEnrollment, LeadScoringRule models with 4 migrations, EmailSequenceController (enroll/pause/activate/unsubscribe), LeadScoringController (rules + scored lead ranking). React pages: POS/Terminal, POS/Sessions, POS/Receipt, Accounting/BankAccounts/Index, Accounting/BankTransactions/Index, Accounting/Reconciliation/Index, Accounting/AutoPostingRules/Index, Helpdesk/Sla/Policies, Helpdesk/Sla/Escalations, CRM/EmailSequences/Index, CRM/EmailSequences/Show, CRM/LeadScoring/Rules, CRM/LeadScoring/Scores. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent b658a15 commit adcc754

44 files changed

Lines changed: 3551 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Accounting\Models\Account;
7+
use App\Modules\Accounting\Models\AutoPostingRule;
8+
use App\Modules\Accounting\Models\BankAccount;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class AutoPostingRuleController extends Controller
15+
{
16+
public function index(BankAccount $bankAccount): Response
17+
{
18+
$rules = $bankAccount->autoPostingRules()->with(['debitAccount', 'creditAccount'])->get();
19+
$accounts = Account::orderBy('name')->get(['id', 'name', 'code', 'type']);
20+
21+
return Inertia::render('Accounting/AutoPostingRules/Index', [
22+
'bankAccount' => $bankAccount,
23+
'rules' => $rules,
24+
'accounts' => $accounts,
25+
]);
26+
}
27+
28+
public function store(Request $request, BankAccount $bankAccount): RedirectResponse
29+
{
30+
$validated = $request->validate([
31+
'name' => 'required|string|max:255',
32+
'match_keyword' => 'nullable|string|max:255',
33+
'match_type' => 'required|in:description,reference,amount',
34+
'debit_account_id' => 'required|exists:chart_of_accounts,id',
35+
'credit_account_id' => 'required|exists:chart_of_accounts,id',
36+
]);
37+
38+
AutoPostingRule::create([
39+
...$validated,
40+
'tenant_id' => auth()->user()->tenant_id,
41+
'bank_account_id' => $bankAccount->id,
42+
]);
43+
44+
return back()->with('success', 'Rule created.');
45+
}
46+
47+
public function destroy(BankAccount $bankAccount, AutoPostingRule $rule): RedirectResponse
48+
{
49+
$rule->delete();
50+
51+
return back()->with('success', 'Rule deleted.');
52+
}
53+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Accounting\Models\BankAccount;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class BankAccountController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$accounts = BankAccount::with('account')->orderBy('name')->get();
17+
18+
return Inertia::render('Accounting/BankAccounts/Index', [
19+
'bankAccounts' => $accounts,
20+
]);
21+
}
22+
23+
public function store(Request $request): RedirectResponse
24+
{
25+
$validated = $request->validate([
26+
'name' => 'required|string|max:255',
27+
'bank_name' => 'required|string|max:255',
28+
'account_number' => 'required|string|max:255',
29+
'currency' => 'required|string|size:3',
30+
'account_id' => 'nullable|exists:chart_of_accounts,id',
31+
]);
32+
33+
BankAccount::create([
34+
...$validated,
35+
'tenant_id' => auth()->user()->tenant_id,
36+
]);
37+
38+
return back()->with('success', 'Bank account created.');
39+
}
40+
41+
public function update(Request $request, BankAccount $bankAccount): RedirectResponse
42+
{
43+
$validated = $request->validate([
44+
'name' => 'sometimes|string|max:255',
45+
'bank_name' => 'sometimes|string|max:255',
46+
'is_active' => 'sometimes|boolean',
47+
'account_id' => 'nullable|exists:chart_of_accounts,id',
48+
]);
49+
50+
$bankAccount->update($validated);
51+
52+
return back()->with('success', 'Bank account updated.');
53+
}
54+
55+
public function destroy(BankAccount $bankAccount): RedirectResponse
56+
{
57+
$bankAccount->delete();
58+
59+
return back()->with('success', 'Bank account deleted.');
60+
}
61+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Accounting\Models\AutoPostingRule;
7+
use App\Modules\Accounting\Models\BankAccount;
8+
use App\Modules\Accounting\Models\BankTransaction;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class BankReconciliationController extends Controller
16+
{
17+
public function index(BankAccount $bankAccount): Response
18+
{
19+
$unreconciled = $bankAccount->unreconciledTransactions()
20+
->orderBy('transaction_date')
21+
->get();
22+
23+
return Inertia::render('Accounting/Reconciliation/Index', [
24+
'bankAccount' => $bankAccount,
25+
'transactions' => $unreconciled,
26+
'reconciledBalance' => $bankAccount->reconciledBalance(),
27+
]);
28+
}
29+
30+
public function transactions(Request $request, BankAccount $bankAccount): Response
31+
{
32+
$transactions = $bankAccount->transactions()
33+
->when($request->status, fn ($q) => $q->where('status', $request->status))
34+
->orderByDesc('transaction_date')
35+
->paginate(50)
36+
->withQueryString();
37+
38+
return Inertia::render('Accounting/BankTransactions/Index', [
39+
'bankAccount' => $bankAccount,
40+
'transactions' => $transactions,
41+
'filters' => $request->only(['status']),
42+
]);
43+
}
44+
45+
public function importTransaction(Request $request, BankAccount $bankAccount): RedirectResponse
46+
{
47+
$validated = $request->validate([
48+
'transaction_date' => 'required|date',
49+
'description' => 'nullable|string',
50+
'reference' => 'nullable|string',
51+
'type' => 'required|in:debit,credit',
52+
'amount' => 'required|numeric|min:0.01',
53+
]);
54+
55+
$txn = BankTransaction::create([
56+
...$validated,
57+
'tenant_id' => auth()->user()->tenant_id,
58+
'bank_account_id' => $bankAccount->id,
59+
'status' => 'unreconciled',
60+
]);
61+
62+
// Try auto-posting rules
63+
$rules = AutoPostingRule::where('bank_account_id', $bankAccount->id)
64+
->where('is_active', true)
65+
->get();
66+
67+
foreach ($rules as $rule) {
68+
if ($rule->matches($txn)) {
69+
$txn->reconcile();
70+
break;
71+
}
72+
}
73+
74+
return back()->with('success', 'Transaction imported.');
75+
}
76+
77+
public function reconcile(BankAccount $bankAccount, BankTransaction $transaction): JsonResponse
78+
{
79+
$transaction->reconcile();
80+
81+
return response()->json(['ok' => true, 'reconciled_balance' => $bankAccount->fresh()->reconciledBalance()]);
82+
}
83+
84+
public function unreconcile(BankAccount $bankAccount, BankTransaction $transaction): JsonResponse
85+
{
86+
$transaction->unreconcile();
87+
88+
return response()->json(['ok' => true]);
89+
}
90+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class AutoPostingRule extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'bank_account_id',
16+
'debit_account_id',
17+
'credit_account_id',
18+
'name',
19+
'match_keyword',
20+
'match_type',
21+
'is_active',
22+
];
23+
24+
protected $attributes = [
25+
'is_active' => true,
26+
];
27+
28+
protected $casts = [
29+
'is_active' => 'boolean',
30+
];
31+
32+
public function bankAccount(): BelongsTo
33+
{
34+
return $this->belongsTo(BankAccount::class, 'bank_account_id');
35+
}
36+
37+
public function debitAccount(): BelongsTo
38+
{
39+
return $this->belongsTo(Account::class, 'debit_account_id');
40+
}
41+
42+
public function creditAccount(): BelongsTo
43+
{
44+
return $this->belongsTo(Account::class, 'credit_account_id');
45+
}
46+
47+
public function matches(BankTransaction $transaction): bool
48+
{
49+
if (!$this->is_active || !$this->match_keyword) {
50+
return false;
51+
}
52+
53+
$keyword = strtolower($this->match_keyword);
54+
$value = match ($this->match_type) {
55+
'description' => strtolower($transaction->description ?? ''),
56+
'reference' => strtolower($transaction->reference ?? ''),
57+
'amount' => (string) $transaction->amount,
58+
default => '',
59+
};
60+
61+
return str_contains($value, $keyword);
62+
}
63+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class BankAccount extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'account_id',
17+
'name',
18+
'bank_name',
19+
'account_number',
20+
'currency',
21+
'current_balance',
22+
'last_reconciled_at',
23+
'is_active',
24+
];
25+
26+
protected $casts = [
27+
'current_balance' => 'float',
28+
'is_active' => 'boolean',
29+
'last_reconciled_at' => 'date',
30+
];
31+
32+
public function account(): BelongsTo
33+
{
34+
return $this->belongsTo(Account::class, 'account_id');
35+
}
36+
37+
public function transactions(): HasMany
38+
{
39+
return $this->hasMany(BankTransaction::class, 'bank_account_id');
40+
}
41+
42+
public function autoPostingRules(): HasMany
43+
{
44+
return $this->hasMany(AutoPostingRule::class, 'bank_account_id');
45+
}
46+
47+
public function unreconciledTransactions(): HasMany
48+
{
49+
return $this->transactions()->where('status', 'unreconciled');
50+
}
51+
52+
public function reconciledBalance(): float
53+
{
54+
$debits = $this->transactions()->where('status', 'reconciled')->where('type', 'debit')->sum('amount');
55+
$credits = $this->transactions()->where('status', 'reconciled')->where('type', 'credit')->sum('amount');
56+
57+
return (float) ($credits - $debits);
58+
}
59+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Modules\Accounting\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class BankTransaction extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'bank_account_id',
16+
'journal_entry_id',
17+
'transaction_date',
18+
'reference',
19+
'description',
20+
'type',
21+
'amount',
22+
'status',
23+
'reconciled_at',
24+
];
25+
26+
protected $casts = [
27+
'transaction_date' => 'date',
28+
'reconciled_at' => 'datetime',
29+
'amount' => 'float',
30+
];
31+
32+
public function bankAccount(): BelongsTo
33+
{
34+
return $this->belongsTo(BankAccount::class, 'bank_account_id');
35+
}
36+
37+
public function journalEntry(): BelongsTo
38+
{
39+
return $this->belongsTo(JournalEntry::class, 'journal_entry_id');
40+
}
41+
42+
public function reconcile(): void
43+
{
44+
$this->status = 'reconciled';
45+
$this->reconciled_at = now();
46+
$this->save();
47+
48+
$this->bankAccount->last_reconciled_at = now()->toDateString();
49+
$this->bankAccount->save();
50+
}
51+
52+
public function unreconcile(): void
53+
{
54+
$this->status = 'unreconciled';
55+
$this->reconciled_at = null;
56+
$this->save();
57+
}
58+
}

0 commit comments

Comments
 (0)