- LoanService에 taxSimulation() 메서드 추가 - LoanController에 taxSimulation() 액션 추가 - GET /api/v1/loans/tax-simulation 라우트 등록 - Swagger LoanTaxSimulation 스키마 및 엔드포인트 문서화 - 법인세/소득세 비교 분석 데이터 제공 Co-Authored-By: Claude <noreply@anthropic.com>
584 lines
20 KiB
PHP
584 lines
20 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['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): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Loan::query()
|
|
->where('tenant_id', $tenantId);
|
|
|
|
if ($userId) {
|
|
$query->where('user_id', $userId);
|
|
}
|
|
|
|
$stats = $query->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
|
|
', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL])
|
|
->first();
|
|
|
|
return [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// 가지급금 생성/수정/삭제
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 가지급금 생성
|
|
*/
|
|
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;
|
|
}
|
|
|
|
return Loan::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $data['user_id'],
|
|
'loan_date' => $data['loan_date'],
|
|
'amount' => $data['amount'],
|
|
'purpose' => $data['purpose'] ?? null,
|
|
'status' => Loan::STATUS_OUTSTANDING,
|
|
'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'));
|
|
}
|
|
}
|
|
|
|
$loan->fill([
|
|
'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,
|
|
]);
|
|
|
|
$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 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공
|
|
*
|
|
* @return array{
|
|
* summary: array{
|
|
* total_outstanding: float,
|
|
* recognized_interest: float,
|
|
* outstanding_count: int
|
|
* },
|
|
* loans: array
|
|
* }
|
|
*/
|
|
public function dashboard(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$currentYear = now()->year;
|
|
|
|
// 1. Summary 데이터
|
|
$summaryData = $this->summary();
|
|
|
|
// 2. 인정이자 계산 (현재 연도 기준)
|
|
$interestData = $this->calculateInterest($currentYear);
|
|
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
|
|
|
|
// 3. 가지급금 목록 (최근 10건, 미정산 우선)
|
|
$loans = Loan::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['user:id,name,email', 'withdrawal'])
|
|
->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [
|
|
Loan::STATUS_OUTSTANDING,
|
|
Loan::STATUS_PARTIAL,
|
|
])
|
|
->orderByDesc('loan_date')
|
|
->limit(10)
|
|
->get()
|
|
->map(function ($loan) {
|
|
return [
|
|
'id' => $loan->id,
|
|
'loan_date' => $loan->loan_date->format('Y-m-d'),
|
|
'user_name' => $loan->user?->name ?? '미지정',
|
|
'category' => $loan->withdrawal_id ? '카드' : '계좌',
|
|
'amount' => (float) $loan->amount,
|
|
'status' => $loan->status,
|
|
'content' => $loan->purpose ?? '',
|
|
];
|
|
})
|
|
->toArray();
|
|
|
|
return [
|
|
'summary' => [
|
|
'total_outstanding' => (float) $summaryData['total_outstanding'],
|
|
'recognized_interest' => (float) $recognizedInterest,
|
|
'outstanding_count' => (int) $summaryData['outstanding_count'],
|
|
],
|
|
'loans' => $loans,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 세금 시뮬레이션 데이터
|
|
*
|
|
* 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,
|
|
];
|
|
}
|
|
}
|