isBalanced()) { throw new \InvalidArgumentException('Journal lines are not balanced.'); } if ($request->operationDate && $this->isDateLocked($request->businessId, $request->operationDate)) { throw new \RuntimeException('Accounting period is closed for the given date.'); } $existing = AccountingPostingLog::where('business_id', $request->businessId) ->where('posting_key', $request->postingKey()) ->where('status', 'posted') ->first(); if ($existing && ! $allowDuplicate) { return AccountingAccTransMapping::find($existing->acc_trans_mapping_id); } return DB::transaction(function () use ($request, $existing, $allowDuplicate) { if ($existing && $allowDuplicate) { $this->reverseByPostingKey($request->businessId, $request->postingKey(), $request->userId); } $settings = $this->accountingUtil->getAccountingSettings($request->businessId); $refNo = $request->refNo; if (empty($refNo)) { $refCount = $this->util->setAndGetReferenceCount('journal_entry'); $prefix = $settings['journal_entry_prefix'] ?? 'JE-'; $refNo = $this->util->generateReferenceNumber('journal_entry', $refCount, $request->businessId, $prefix); } $mapping = new AccountingAccTransMapping(); $mapping->business_id = $request->businessId; $mapping->ref_no = $refNo; $mapping->note = $request->note; $mapping->type = $request->mappingType; $mapping->created_by = $request->userId; $mapping->operation_date = $request->operationDate ?? now(); if (\Illuminate\Support\Facades\Schema::hasColumn('accounting_acc_trans_mappings', 'approval_status')) { $approvalService = app(JournalApprovalService::class); $mapping->approval_status = $approvalService->initialStatus($request->businessId); if ($mapping->approval_status === 'approved') { $mapping->approved_by = $request->userId; $mapping->approved_at = now(); } } $mapping->save(); $approvalService = app(JournalApprovalService::class); $postable = $approvalService->isPostable($mapping); $payload = $this->serializePostingPayload($request); if ($postable) { $this->createJournalLines($mapping, $request); } AccountingPostingLog::updateOrCreate( [ 'business_id' => $request->businessId, 'posting_key' => $request->postingKey(), ], [ 'source_type' => $request->sourceType, 'source_id' => is_numeric($request->sourceId) ? (int) $request->sourceId : null, 'acc_trans_mapping_id' => $mapping->id, 'status' => $postable ? 'posted' : 'pending', 'payload' => json_encode($payload), 'error_message' => null, 'created_by' => $request->userId, ] ); return $mapping; }); } public function postPendingJournalLines(AccountingAccTransMapping $mapping): void { if (! app(JournalApprovalService::class)->isPostable($mapping)) { return; } $log = AccountingPostingLog::where('acc_trans_mapping_id', $mapping->id) ->where('status', 'pending') ->first(); if (! $log || AccountingAccountsTransaction::where('acc_trans_mapping_id', $mapping->id)->exists()) { return; } $payload = json_decode($log->payload, true); if (empty($payload['lines'])) { return; } $request = new FinancialPostingRequest( businessId: (int) $mapping->business_id, userId: (int) ($mapping->approved_by ?? $mapping->created_by), sourceType: (string) ($log->source_type ?? 'journal_entry'), sourceId: $log->source_id ?? $mapping->id, note: (string) ($mapping->note ?? ''), lines: $payload['lines'], operationDate: $mapping->operation_date ? (string) $mapping->operation_date : null, costCenterId: $payload['cost_center_id'] ?? null, projectId: $payload['project_id'] ?? null, holdingEntityId: $payload['holding_entity_id'] ?? null, transactionId: $payload['transaction_id'] ?? null, transactionPaymentId: $payload['transaction_payment_id'] ?? null, mappingType: (string) ($mapping->type ?? 'journal_entry'), refNo: $mapping->ref_no, ); DB::transaction(function () use ($mapping, $request, $log) { $this->createJournalLines($mapping, $request); $log->update(['status' => 'posted', 'error_message' => null]); }); } protected function createJournalLines(AccountingAccTransMapping $mapping, FinancialPostingRequest $request): void { foreach ($request->lines as $line) { AccountingAccountsTransaction::create([ 'accounting_account_id' => $line['accounting_account_id'], 'amount' => $line['amount'], 'type' => $line['type'], 'sub_type' => $line['sub_type'] ?? $request->mappingType, 'created_by' => $request->userId, 'operation_date' => $mapping->operation_date, 'acc_trans_mapping_id' => $mapping->id, 'note' => $line['note'] ?? null, 'cost_center_id' => $line['cost_center_id'] ?? $request->costCenterId, 'project_id' => $line['project_id'] ?? $request->projectId, 'holding_entity_id' => $line['holding_entity_id'] ?? $request->holdingEntityId, 'transaction_id' => $line['transaction_id'] ?? $request->transactionId, 'transaction_payment_id' => $line['transaction_payment_id'] ?? $request->transactionPaymentId, 'map_type' => $line['map_type'] ?? null, 'source_type' => $request->sourceType, 'source_id' => is_numeric($request->sourceId) ? (int) $request->sourceId : null, 'posting_key' => $request->postingKey(), ]); } } protected function serializePostingPayload(FinancialPostingRequest $request): array { return [ 'lines' => $request->lines, 'mapping_type' => $request->mappingType, 'cost_center_id' => $request->costCenterId, 'project_id' => $request->projectId, 'holding_entity_id' => $request->holdingEntityId, 'transaction_id' => $request->transactionId, 'transaction_payment_id' => $request->transactionPaymentId, ]; } public function reverseByPostingKey(int $businessId, string $postingKey, int $userId): void { $log = AccountingPostingLog::where('business_id', $businessId) ->where('posting_key', $postingKey) ->where('status', 'posted') ->first(); if (! $log || ! $log->acc_trans_mapping_id) { return; } AccountingAccountsTransaction::where('acc_trans_mapping_id', $log->acc_trans_mapping_id)->delete(); AccountingAccTransMapping::where('id', $log->acc_trans_mapping_id)->delete(); $log->update(['status' => 'reversed', 'error_message' => 'Reversed by user '.$userId]); } public function linkPaymentAccountToGl(int $businessId, int $paymentAccountId, int $glAccountId): void { $paymentAccount = DB::table('accounts') ->where('id', $paymentAccountId) ->where('business_id', $businessId) ->first(['id']); if (! $paymentAccount) { throw new \InvalidArgumentException('Payment account not found for business.'); } $glAccount = DB::table('accounting_accounts') ->where('id', $glAccountId) ->where('business_id', $businessId) ->first(['id']); if (! $glAccount) { throw new \InvalidArgumentException('GL account not found for business.'); } DB::table('accounts') ->where('id', $paymentAccountId) ->where('business_id', $businessId) ->update(['accounting_account_id' => $glAccountId]); } protected function isDateLocked(int $businessId, string $date): bool { return AccountingFiscalPeriod::where('business_id', $businessId) ->where('is_closed', true) ->whereDate('start_date', '<=', $date) ->whereDate('end_date', '>=', $date) ->exists(); } }