- Bill 모델: V8 확장 필드 54개 추가 (증권종류, 할인, 배서, 추심, 개서, 부도 등) - Bill 상태: 수취/발행 어음·수표별 세분화된 상태 체계 - BillService: assignV8Fields/syncInstallments 헬퍼 추출, instrument_type/medium 필터 - BillInstallment: type/counterparty 필드 추가 - Loan 모델: holding/used/disposed 상태 + metadata(JSON) 필드 추가 - LoanService: 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding) - FormRequest: V8 확장 필드 검증 규칙 추가 - 마이그레이션: bills V8 필드 + loans metadata 컬럼 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
731 lines
26 KiB
PHP
731 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Tenants\Loan;
|
|
use App\Models\Tenants\Withdrawal;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
class LoanService extends Service
|
|
{
|
|
// =========================================================================
|
|
// 가지급금 목록/상세
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 가지급금 목록
|
|
*/
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['user:id,name,email', 'creator:id,name']);
|
|
|
|
// 카테고리 필터
|
|
if (! empty($params['category'])) {
|
|
$query->where('category', $params['category']);
|
|
}
|
|
|
|
// 사용자 필터
|
|
if (! empty($params['user_id'])) {
|
|
$query->where('user_id', $params['user_id']);
|
|
}
|
|
|
|
// 상태 필터
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
|
|
// 날짜 범위 필터
|
|
if (! empty($params['start_date'])) {
|
|
$query->where('loan_date', '>=', $params['start_date']);
|
|
}
|
|
if (! empty($params['end_date'])) {
|
|
$query->where('loan_date', '<=', $params['end_date']);
|
|
}
|
|
|
|
// 검색 (사용자명, 목적)
|
|
if (! empty($params['search'])) {
|
|
$search = $params['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->whereHas('user', function ($userQ) use ($search) {
|
|
$userQ->where('name', 'like', "%{$search}%");
|
|
})->orWhere('purpose', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'loan_date';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 가지급금 상세
|
|
*/
|
|
public function show(int $id): Loan
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with([
|
|
'user:id,name,email',
|
|
'withdrawal',
|
|
'creator:id,name',
|
|
'updater:id,name',
|
|
])
|
|
->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* 가지급금 요약 (특정 사용자 또는 전체)
|
|
*/
|
|
public function summary(?int $userId = null, ?string $category = null): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Loan::query()
|
|
->where('tenant_id', $tenantId);
|
|
|
|
if ($userId) {
|
|
$query->where('user_id', $userId);
|
|
}
|
|
|
|
if ($category) {
|
|
$query->where('category', $category);
|
|
}
|
|
|
|
// 상품권 카테고리: holding/used/disposed 상태별 집계 추가
|
|
$isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE;
|
|
|
|
$selectRaw = '
|
|
COUNT(*) as total_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as partial_count,
|
|
SUM(amount) as total_amount,
|
|
SUM(COALESCE(settlement_amount, 0)) as total_settled,
|
|
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
|
|
';
|
|
$bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL];
|
|
|
|
if ($isGiftCertificate) {
|
|
$selectRaw .= ',
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count,
|
|
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count,
|
|
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count
|
|
';
|
|
$bindings = array_merge($bindings, [
|
|
Loan::STATUS_HOLDING, Loan::STATUS_HOLDING,
|
|
Loan::STATUS_USED, Loan::STATUS_USED,
|
|
Loan::STATUS_DISPOSED,
|
|
]);
|
|
}
|
|
|
|
$stats = $query->selectRaw($selectRaw, $bindings)->first();
|
|
|
|
$result = [
|
|
'total_count' => (int) $stats->total_count,
|
|
'outstanding_count' => (int) $stats->outstanding_count,
|
|
'settled_count' => (int) $stats->settled_count,
|
|
'partial_count' => (int) $stats->partial_count,
|
|
'total_amount' => (float) $stats->total_amount,
|
|
'total_settled' => (float) $stats->total_settled,
|
|
'total_outstanding' => (float) $stats->total_outstanding,
|
|
];
|
|
|
|
if ($isGiftCertificate) {
|
|
$result['holding_count'] = (int) $stats->holding_count;
|
|
$result['holding_amount'] = (float) $stats->holding_amount;
|
|
$result['used_count'] = (int) $stats->used_count;
|
|
$result['used_amount'] = (float) $stats->used_amount;
|
|
$result['disposed_count'] = (int) $stats->disposed_count;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 가지급금 생성/수정/삭제
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 가지급금 생성
|
|
*/
|
|
public function store(array $data): Loan
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
|
// 출금 내역 연결 검증
|
|
$withdrawalId = null;
|
|
if (! empty($data['withdrawal_id'])) {
|
|
$withdrawal = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $data['withdrawal_id'])
|
|
->first();
|
|
|
|
if (! $withdrawal) {
|
|
throw new BadRequestHttpException(__('error.loan.invalid_withdrawal'));
|
|
}
|
|
$withdrawalId = $withdrawal->id;
|
|
}
|
|
|
|
// 상품권: user_id 미지정 시 현재 사용자로 대체
|
|
$loanUserId = $data['user_id'] ?? $userId;
|
|
|
|
// 상태 결정: 상품권은 holding, 그 외는 outstanding
|
|
$category = $data['category'] ?? null;
|
|
$status = $data['status']
|
|
?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING);
|
|
|
|
return Loan::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $loanUserId,
|
|
'loan_date' => $data['loan_date'],
|
|
'amount' => $data['amount'],
|
|
'purpose' => $data['purpose'] ?? null,
|
|
'status' => $status,
|
|
'category' => $category,
|
|
'metadata' => $data['metadata'] ?? null,
|
|
'withdrawal_id' => $withdrawalId,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 가지급금 수정
|
|
*/
|
|
public function update(int $id, array $data): Loan
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$loan = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $loan->isEditable()) {
|
|
throw new BadRequestHttpException(__('error.loan.not_editable'));
|
|
}
|
|
|
|
// 출금 내역 연결 검증
|
|
if (isset($data['withdrawal_id']) && $data['withdrawal_id']) {
|
|
$withdrawal = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $data['withdrawal_id'])
|
|
->first();
|
|
|
|
if (! $withdrawal) {
|
|
throw new BadRequestHttpException(__('error.loan.invalid_withdrawal'));
|
|
}
|
|
}
|
|
|
|
$fillData = [
|
|
'user_id' => $data['user_id'] ?? $loan->user_id,
|
|
'loan_date' => $data['loan_date'] ?? $loan->loan_date,
|
|
'amount' => $data['amount'] ?? $loan->amount,
|
|
'purpose' => $data['purpose'] ?? $loan->purpose,
|
|
'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id,
|
|
'updated_by' => $userId,
|
|
];
|
|
|
|
if (isset($data['category'])) {
|
|
$fillData['category'] = $data['category'];
|
|
}
|
|
if (array_key_exists('metadata', $data)) {
|
|
$fillData['metadata'] = $data['metadata'];
|
|
}
|
|
if (isset($data['status'])) {
|
|
$fillData['status'] = $data['status'];
|
|
}
|
|
if (array_key_exists('settlement_date', $data)) {
|
|
$fillData['settlement_date'] = $data['settlement_date'];
|
|
}
|
|
|
|
$loan->fill($fillData);
|
|
|
|
$loan->save();
|
|
|
|
return $loan->fresh(['user:id,name,email', 'creator:id,name']);
|
|
}
|
|
|
|
/**
|
|
* 가지급금 삭제
|
|
*/
|
|
public function destroy(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$loan = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $loan->isDeletable()) {
|
|
throw new BadRequestHttpException(__('error.loan.not_deletable'));
|
|
}
|
|
|
|
$loan->deleted_by = $userId;
|
|
$loan->save();
|
|
$loan->delete();
|
|
|
|
return true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 정산 처리
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 가지급금 정산
|
|
*/
|
|
public function settle(int $id, array $data): Loan
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
|
$loan = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $loan->isSettleable()) {
|
|
throw new BadRequestHttpException(__('error.loan.not_settleable'));
|
|
}
|
|
|
|
$settlementAmount = (float) $data['settlement_amount'];
|
|
$currentSettled = (float) ($loan->settlement_amount ?? 0);
|
|
$totalSettled = $currentSettled + $settlementAmount;
|
|
$loanAmount = (float) $loan->amount;
|
|
|
|
// 정산 금액이 가지급금액을 초과하는지 확인
|
|
if ($totalSettled > $loanAmount) {
|
|
throw new BadRequestHttpException(__('error.loan.settlement_exceeds'));
|
|
}
|
|
|
|
// 상태 결정
|
|
$status = Loan::STATUS_PARTIAL;
|
|
if (abs($totalSettled - $loanAmount) < 0.01) { // 부동소수점 비교
|
|
$status = Loan::STATUS_SETTLED;
|
|
}
|
|
|
|
$loan->settlement_date = $data['settlement_date'];
|
|
$loan->settlement_amount = $totalSettled;
|
|
$loan->status = $status;
|
|
$loan->updated_by = $userId;
|
|
$loan->save();
|
|
|
|
return $loan->fresh(['user:id,name,email']);
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// 인정이자 계산
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 인정이자 일괄 계산
|
|
*
|
|
* @param int $year 계산 연도
|
|
* @param int|null $userId 특정 사용자 (미지정시 전체)
|
|
*/
|
|
public function calculateInterest(int $year, ?int $userId = null): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL]);
|
|
|
|
if ($userId) {
|
|
$query->where('user_id', $userId);
|
|
}
|
|
|
|
$loans = $query->with('user:id,name,email')->get();
|
|
|
|
$interestRate = Loan::getInterestRate($year);
|
|
$baseDate = now()->endOfYear()->year === $year
|
|
? now()
|
|
: now()->setYear($year)->endOfYear();
|
|
|
|
$results = [];
|
|
$totalBalance = 0;
|
|
$totalInterest = 0;
|
|
$totalCorporateTax = 0;
|
|
$totalIncomeTax = 0;
|
|
$totalLocalTax = 0;
|
|
|
|
foreach ($loans as $loan) {
|
|
// 연도 내 경과일수 계산
|
|
$startOfYear = now()->setYear($year)->startOfYear();
|
|
$effectiveStartDate = $loan->loan_date->greaterThan($startOfYear)
|
|
? $loan->loan_date
|
|
: $startOfYear;
|
|
|
|
$elapsedDays = $effectiveStartDate->diffInDays($baseDate);
|
|
$balance = $loan->outstanding_amount;
|
|
|
|
$interest = $loan->calculateRecognizedInterest($elapsedDays, $year);
|
|
$taxes = $loan->calculateTaxes($interest);
|
|
|
|
$results[] = [
|
|
'loan_id' => $loan->id,
|
|
'user' => [
|
|
'id' => $loan->user->id,
|
|
'name' => $loan->user->name,
|
|
'email' => $loan->user->email,
|
|
],
|
|
'loan_date' => $loan->loan_date->toDateString(),
|
|
'amount' => (float) $loan->amount,
|
|
'settlement_amount' => (float) ($loan->settlement_amount ?? 0),
|
|
'outstanding_amount' => $balance,
|
|
'elapsed_days' => $elapsedDays,
|
|
'interest_rate' => $interestRate,
|
|
'recognized_interest' => $taxes['recognized_interest'],
|
|
'corporate_tax' => $taxes['corporate_tax'],
|
|
'income_tax' => $taxes['income_tax'],
|
|
'local_tax' => $taxes['local_tax'],
|
|
'total_tax' => $taxes['total_tax'],
|
|
];
|
|
|
|
$totalBalance += $balance;
|
|
$totalInterest += $taxes['recognized_interest'];
|
|
$totalCorporateTax += $taxes['corporate_tax'];
|
|
$totalIncomeTax += $taxes['income_tax'];
|
|
$totalLocalTax += $taxes['local_tax'];
|
|
}
|
|
|
|
return [
|
|
'year' => $year,
|
|
'interest_rate' => $interestRate,
|
|
'base_date' => $baseDate->toDateString(),
|
|
'summary' => [
|
|
'total_balance' => round($totalBalance, 2),
|
|
'total_recognized_interest' => round($totalInterest, 2),
|
|
'total_corporate_tax' => round($totalCorporateTax, 2),
|
|
'total_income_tax' => round($totalIncomeTax, 2),
|
|
'total_local_tax' => round($totalLocalTax, 2),
|
|
'total_tax' => round($totalCorporateTax + $totalIncomeTax + $totalLocalTax, 2),
|
|
],
|
|
'details' => $results,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 가지급금 대시보드 데이터
|
|
*
|
|
* CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공
|
|
* D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류)
|
|
*
|
|
* @return array{
|
|
* summary: array{
|
|
* total_outstanding: float,
|
|
* recognized_interest: float,
|
|
* outstanding_count: int
|
|
* },
|
|
* category_breakdown: array<string, array{
|
|
* outstanding_amount: float,
|
|
* total_count: int,
|
|
* unverified_count: int
|
|
* }>,
|
|
* loans: array
|
|
* }
|
|
*/
|
|
public function dashboard(?string $startDate = null, ?string $endDate = null): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$currentYear = now()->year;
|
|
|
|
// 날짜 필터 조건 클로저
|
|
$applyDateFilter = function ($query) use ($startDate, $endDate) {
|
|
if ($startDate) {
|
|
$query->where('loan_date', '>=', $startDate);
|
|
}
|
|
if ($endDate) {
|
|
$query->where('loan_date', '<=', $endDate);
|
|
}
|
|
return $query;
|
|
};
|
|
|
|
// 1. Summary 데이터 (날짜 필터 적용)
|
|
$summaryQuery = Loan::query()->where('tenant_id', $tenantId);
|
|
$applyDateFilter($summaryQuery);
|
|
|
|
$stats = $summaryQuery->selectRaw('
|
|
COUNT(*) as total_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
|
|
SUM(amount) as total_amount,
|
|
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
|
|
', [Loan::STATUS_OUTSTANDING])
|
|
->first();
|
|
|
|
// 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관)
|
|
$interestData = $this->calculateInterest($currentYear);
|
|
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
|
|
|
|
// 3. 카테고리별 집계 (날짜 필터 적용)
|
|
$categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate);
|
|
|
|
// 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용)
|
|
$loansQuery = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['user:id,name,email', 'withdrawal']);
|
|
$applyDateFilter($loansQuery);
|
|
|
|
$loans = $loansQuery
|
|
->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [
|
|
Loan::STATUS_OUTSTANDING,
|
|
Loan::STATUS_PARTIAL,
|
|
])
|
|
->orderByDesc('loan_date')
|
|
->limit(50)
|
|
->get()
|
|
->map(function ($loan) {
|
|
return [
|
|
'id' => $loan->id,
|
|
'loan_date' => $loan->loan_date->format('Y-m-d'),
|
|
'user_name' => $loan->user?->name ?? '미지정',
|
|
'category' => $loan->category_label,
|
|
'amount' => (float) $loan->amount,
|
|
'status' => $loan->status,
|
|
'content' => $loan->purpose ?? '',
|
|
];
|
|
})
|
|
->toArray();
|
|
|
|
return [
|
|
'summary' => [
|
|
'total_outstanding' => (float) ($stats->total_outstanding ?? 0),
|
|
'recognized_interest' => (float) $recognizedInterest,
|
|
'outstanding_count' => (int) ($stats->outstanding_count ?? 0),
|
|
],
|
|
'category_breakdown' => $categoryBreakdown,
|
|
'loans' => $loans,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 가지급금 집계
|
|
*
|
|
* @return array<string, array{outstanding_amount: float, total_count: int, unverified_count: int}>
|
|
*/
|
|
private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array
|
|
{
|
|
// 기본값: 4개 카테고리 모두 0으로 초기화
|
|
$breakdown = [];
|
|
foreach (Loan::CATEGORIES as $category) {
|
|
$breakdown[$category] = [
|
|
'outstanding_amount' => 0.0,
|
|
'total_count' => 0,
|
|
'unverified_count' => 0,
|
|
];
|
|
}
|
|
|
|
// 카테고리별 집계 (summary와 동일하게 전체 대상, 날짜 필터 적용)
|
|
$query = Loan::query()
|
|
->where('tenant_id', $tenantId);
|
|
|
|
if ($startDate) {
|
|
$query->where('loan_date', '>=', $startDate);
|
|
}
|
|
if ($endDate) {
|
|
$query->where('loan_date', '<=', $endDate);
|
|
}
|
|
|
|
// NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의
|
|
// getOutstandingAmountAttribute() accessor와 이름 충돌 방지
|
|
$stats = $query
|
|
->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding')
|
|
->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count')
|
|
->groupBy('category')
|
|
->get();
|
|
|
|
foreach ($stats as $stat) {
|
|
$cat = $stat->category ?? Loan::CATEGORY_CARD;
|
|
if (isset($breakdown[$cat])) {
|
|
$breakdown[$cat] = [
|
|
'outstanding_amount' => (float) $stat->cat_outstanding,
|
|
'total_count' => (int) $stat->total_count,
|
|
'unverified_count' => (int) $stat->unverified_count,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $breakdown;
|
|
}
|
|
|
|
/**
|
|
* 세금 시뮬레이션 데이터
|
|
*
|
|
* CEO 대시보드 카드/가지급금 관리 섹션(cm2) 세금 비교 분석용 데이터 제공
|
|
*
|
|
* @param int $year 시뮬레이션 연도
|
|
* @return array{
|
|
* year: int,
|
|
* loan_summary: array{
|
|
* total_outstanding: float,
|
|
* recognized_interest: float,
|
|
* interest_rate: float
|
|
* },
|
|
* corporate_tax: array{
|
|
* without_loan: array{taxable_income: float, tax_amount: float},
|
|
* with_loan: array{taxable_income: float, tax_amount: float},
|
|
* difference: float,
|
|
* rate_info: string
|
|
* },
|
|
* income_tax: array{
|
|
* without_loan: array{taxable_income: float, tax_rate: string, tax_amount: float},
|
|
* with_loan: array{taxable_income: float, tax_rate: string, tax_amount: float},
|
|
* difference: float,
|
|
* breakdown: array{income_tax: float, local_tax: float, insurance: float}
|
|
* }
|
|
* }
|
|
*/
|
|
public function taxSimulation(int $year): array
|
|
{
|
|
// 1. 가지급금 요약 데이터
|
|
$summaryData = $this->summary();
|
|
$totalOutstanding = (float) $summaryData['total_outstanding'];
|
|
|
|
// 2. 인정이자 계산
|
|
$interestData = $this->calculateInterest($year);
|
|
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
|
|
$interestRate = Loan::getInterestRate($year);
|
|
|
|
// 3. 법인세 비교 계산
|
|
// - 가지급금이 없을 때: 인정이자가 비용으로 처리되지 않음
|
|
// - 가지급금이 있을 때: 인정이자만큼 추가 과세
|
|
$corporateTaxRate = Loan::CORPORATE_TAX_RATE;
|
|
$corporateTaxWithout = [
|
|
'taxable_income' => 0.0,
|
|
'tax_amount' => 0.0,
|
|
];
|
|
$corporateTaxWith = [
|
|
'taxable_income' => $recognizedInterest,
|
|
'tax_amount' => round($recognizedInterest * $corporateTaxRate, 2),
|
|
];
|
|
$corporateTaxDifference = $corporateTaxWith['tax_amount'] - $corporateTaxWithout['tax_amount'];
|
|
|
|
// 4. 소득세 비교 계산 (대표이사 상여처분 시)
|
|
$incomeTaxRate = Loan::INCOME_TAX_RATE;
|
|
$localTaxRate = Loan::LOCAL_TAX_RATE;
|
|
$insuranceRate = 0.09; // 4대보험 약 9%
|
|
|
|
$incomeTaxWithout = [
|
|
'taxable_income' => 0.0,
|
|
'tax_rate' => '0%',
|
|
'tax_amount' => 0.0,
|
|
];
|
|
|
|
$incomeTaxAmount = round($recognizedInterest * $incomeTaxRate, 2);
|
|
$localTaxAmount = round($incomeTaxAmount * $localTaxRate, 2);
|
|
$insuranceAmount = round($recognizedInterest * $insuranceRate, 2);
|
|
|
|
$incomeTaxWith = [
|
|
'taxable_income' => $recognizedInterest,
|
|
'tax_rate' => ($incomeTaxRate * 100).'%',
|
|
'tax_amount' => $incomeTaxAmount + $localTaxAmount,
|
|
];
|
|
|
|
$incomeTaxDifference = $incomeTaxWith['tax_amount'] - $incomeTaxWithout['tax_amount'];
|
|
|
|
return [
|
|
'year' => $year,
|
|
'loan_summary' => [
|
|
'total_outstanding' => $totalOutstanding,
|
|
'recognized_interest' => (float) $recognizedInterest,
|
|
'interest_rate' => $interestRate,
|
|
],
|
|
'corporate_tax' => [
|
|
'without_loan' => $corporateTaxWithout,
|
|
'with_loan' => $corporateTaxWith,
|
|
'difference' => round($corporateTaxDifference, 2),
|
|
'rate_info' => '법인세 '.($corporateTaxRate * 100).'% 적용',
|
|
],
|
|
'income_tax' => [
|
|
'without_loan' => $incomeTaxWithout,
|
|
'with_loan' => $incomeTaxWith,
|
|
'difference' => round($incomeTaxDifference, 2),
|
|
'breakdown' => [
|
|
'income_tax' => $incomeTaxAmount,
|
|
'local_tax' => $localTaxAmount,
|
|
'insurance' => $insuranceAmount,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 인정이자 리포트 (연도별 요약)
|
|
*/
|
|
public function interestReport(int $year): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// 사용자별 가지급금 집계
|
|
$userLoans = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereYear('loan_date', '<=', $year)
|
|
->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL])
|
|
->select('user_id')
|
|
->selectRaw('SUM(amount) as total_amount')
|
|
->selectRaw('SUM(COALESCE(settlement_amount, 0)) as total_settled')
|
|
->selectRaw('SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding')
|
|
->selectRaw('COUNT(*) as loan_count')
|
|
->groupBy('user_id')
|
|
->with('user:id,name,email')
|
|
->get();
|
|
|
|
$interestRate = Loan::getInterestRate($year);
|
|
$results = [];
|
|
|
|
foreach ($userLoans as $userLoan) {
|
|
$userInterest = $this->calculateInterest($year, $userLoan->user_id);
|
|
|
|
$results[] = [
|
|
'user' => [
|
|
'id' => $userLoan->user_id,
|
|
'name' => $userLoan->user?->name ?? 'Unknown',
|
|
'email' => $userLoan->user?->email ?? '',
|
|
],
|
|
'loan_count' => $userLoan->loan_count,
|
|
'total_amount' => (float) $userLoan->total_amount,
|
|
'total_settled' => (float) $userLoan->total_settled,
|
|
'total_outstanding' => (float) $userLoan->total_outstanding,
|
|
'recognized_interest' => $userInterest['summary']['total_recognized_interest'],
|
|
'total_tax' => $userInterest['summary']['total_tax'],
|
|
];
|
|
}
|
|
|
|
// 전체 합계
|
|
$grandTotal = [
|
|
'total_amount' => array_sum(array_column($results, 'total_amount')),
|
|
'total_outstanding' => array_sum(array_column($results, 'total_outstanding')),
|
|
'recognized_interest' => array_sum(array_column($results, 'recognized_interest')),
|
|
'total_tax' => array_sum(array_column($results, 'total_tax')),
|
|
];
|
|
|
|
return [
|
|
'year' => $year,
|
|
'interest_rate' => $interestRate,
|
|
'users' => $results,
|
|
'grand_total' => $grandTotal,
|
|
];
|
|
}
|
|
}
|