feat:부가세 확정 신고 시 예정 세액 차감 반영

- 확정(C) 기간 조회 시 대응하는 예정(P) 기간의 netVat 자동 계산
- 예정 환급세액 → "예정신고 미환급세액"으로 차감 표시
- 예정 납부세액 → "예정신고 기납부세액"으로 차감 표시
- 최종 납부세액 = 확정 산출세액 - 예정 차감액
- 상단 요약 카드에도 최종 세액 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-11 11:21:42 +09:00
parent 02685371f9
commit bc43479d00
2 changed files with 144 additions and 5 deletions

View File

@@ -263,8 +263,15 @@ public function index(Request $request): JsonResponse
'cardPurchaseSupply' => $cardPurchaseSupply,
'cardPurchaseVat' => $cardPurchaseVat,
'total' => $allRecords->count(),
'preliminaryVat' => null,
];
// 확정(C) 기간이면 대응하는 예정(P)의 netVat를 계산
if ($period && str_ends_with($period, 'C')) {
$prelimPeriod = substr($period, 0, -1) . 'P';
$stats['preliminaryVat'] = $this->calculatePeriodNetVat($tenantId, $prelimPeriod);
}
// 사용 중인 기간 목록
$periods = VatRecord::forTenant($tenantId)
->select('period')
@@ -360,6 +367,99 @@ public function destroy(int $id): JsonResponse
]);
}
/**
* 특정 기간의 순 부가세 계산 (매출세액 - 매입세액)
* 양수: 납부세액, 음수: 환급세액
*/
private function calculatePeriodNetVat(int $tenantId, string $period): int
{
[$startDate, $endDate] = $this->periodToDateRange($period);
if (!$startDate || !$endDate) {
return 0;
}
$startDateYmd = str_replace('-', '', $startDate);
$endDateYmd = str_replace('-', '', $endDate);
$taxTypeMap = [1 => 'taxable', 2 => 'zero_rated', 3 => 'exempt'];
// 홈택스 매출세액 (과세+영세)
$salesVat = (int) HometaxInvoice::where('tenant_id', $tenantId)
->sales()
->period($startDate, $endDate)
->whereIn('tax_type', [1, 2])
->sum('tax_amount');
// 홈택스 매입세액 (과세+영세)
$purchaseVat = (int) HometaxInvoice::where('tenant_id', $tenantId)
->purchase()
->period($startDate, $endDate)
->whereIn('tax_type', [1, 2])
->sum('tax_amount');
// 카드 매입세액 (공제분만)
$cardVat = 0;
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDateYmd, $endDateYmd);
$cardTransactions = BarobillCardTransaction::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDateYmd, $endDateYmd])
->get();
$splitsByKey = CardTransactionSplit::getByDateRange($tenantId, $startDateYmd, $endDateYmd);
$splitsByPartialKey = [];
foreach ($splitsByKey as $fullKey => $splits) {
$parts = explode('|', $fullKey);
if (count($parts) >= 3) {
$partialKey = $parts[0] . '|' . $parts[1] . '|' . $parts[2];
$splitsByPartialKey[$partialKey] = $splits;
}
}
foreach ($cardTransactions as $card) {
if ($hiddenKeys->contains($card->unique_key)) {
continue;
}
$splits = $splitsByKey[$card->unique_key] ?? null;
if (!$splits) {
$cardPartialKey = $card->card_num . '|' . $card->use_dt . '|' . $card->approval_num;
$splits = $splitsByPartialKey[$cardPartialKey] ?? null;
}
if ($splits && count($splits) > 0) {
foreach ($splits as $split) {
if ($split->deduction_type === 'deductible') {
$cardVat += (int) $split->split_tax;
}
}
} else {
if ($card->deduction_type === 'deductible') {
$cardVat += (int) ($card->modified_tax ?? $card->tax);
}
}
}
// 수동입력 매출세액 (과세+영세)
$manualSalesVat = (int) VatRecord::forTenant($tenantId)
->where('period', $period)
->where('type', 'sales')
->whereIn('tax_type', ['taxable', 'zero_rated'])
->sum('vat_amount');
// 수동입력 매입세액 (과세+영세)
$manualPurchaseVat = (int) VatRecord::forTenant($tenantId)
->where('period', $period)
->where('type', 'purchase')
->whereIn('tax_type', ['taxable', 'zero_rated'])
->sum('vat_amount');
$totalSalesVat = $salesVat + $manualSalesVat;
$totalPurchaseVat = $purchaseVat + $cardVat + $manualPurchaseVat;
return $totalSalesVat - $totalPurchaseVat;
}
/**
* 부가세 신고기간을 날짜 범위로 변환
* YYYY-1P: 1기 예정 (0101~0331)

View File

@@ -183,6 +183,14 @@ function VatManagement() {
const cardPurchaseVat = stats.cardPurchaseVat || 0;
const netVat = salesVat - purchaseVat;
// 확정 기간일 때 예정 세액 차감
const isConfirmedPeriod = filterPeriod.endsWith('C');
const preliminaryVat = stats.preliminaryVat; // 양수=납부, 음수=환급, null=예정아님
const hasPreliminary = isConfirmedPeriod && preliminaryVat !== null && preliminaryVat !== undefined;
// 예정 납부세액이든 환급세액이든 절대값으로 차감 (이미 정산된 금액)
const preliminaryDeduction = hasPreliminary ? Math.abs(preliminaryVat) : 0;
const finalNetVat = netVat - preliminaryDeduction;
const handleAdd = () => { setModalMode('add'); setFormData({ ...initialFormState, period: filterPeriod }); setShowModal(true); };
const handleEdit = (item) => {
if (item.isCardTransaction || item.isHometax) return;
@@ -346,9 +354,12 @@ function VatManagement() {
<p className="text-xl font-bold text-pink-600">{formatCurrency(purchaseVat)}</p>
<p className="text-xs text-gray-500">공급가액: {formatCurrency(purchaseSupply)}</p>
</div>
<div className={`rounded-lg p-4 ${netVat >= 0 ? 'bg-amber-50' : 'bg-blue-50'}`}>
<div className="flex items-center gap-2 mb-1"><Calculator className={`w-4 h-4 ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`} /><span className={`text-sm ${netVat >= 0 ? 'text-amber-700' : 'text-blue-700'}`}>{netVat >= 0 ? '납부세액' : '환급세액'}</span></div>
<p className={`text-xl font-bold ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>{formatCurrency(Math.abs(netVat))}</p>
<div className={`rounded-lg p-4 ${(hasPreliminary ? finalNetVat : netVat) >= 0 ? 'bg-amber-50' : 'bg-blue-50'}`}>
<div className="flex items-center gap-2 mb-1"><Calculator className={`w-4 h-4 ${(hasPreliminary ? finalNetVat : netVat) >= 0 ? 'text-amber-600' : 'text-blue-600'}`} /><span className={`text-sm ${(hasPreliminary ? finalNetVat : netVat) >= 0 ? 'text-amber-700' : 'text-blue-700'}`}>{(hasPreliminary ? finalNetVat : netVat) >= 0 ? '납부세액' : '환급세액'}</span></div>
<p className={`text-xl font-bold ${(hasPreliminary ? finalNetVat : netVat) >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>{formatCurrency(Math.abs(hasPreliminary ? finalNetVat : netVat))}</p>
{hasPreliminary && preliminaryDeduction > 0 && (
<p className="text-xs text-gray-500">예정 차감 : {formatCurrency(Math.abs(netVat))}</p>
)}
</div>
<div className="flex items-center">
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg">
@@ -403,10 +414,38 @@ function VatManagement() {
<td className="px-6 py-3 text-sm text-right text-purple-600">{formatCurrency(cardPurchaseSupply)}</td>
<td className="px-6 py-3 text-sm text-right text-purple-600 font-medium">({formatCurrency(cardPurchaseVat)})</td>
</tr>
{hasPreliminary && preliminaryDeduction > 0 && (
<>
<tr className="border-b border-gray-200 bg-gray-50/50">
<td className="px-6 py-3 text-sm font-medium">
{netVat >= 0 ? '납부세액(확정 자체)' : '환급세액(확정 자체)'}
</td>
<td className="px-6 py-3 text-sm text-right">-</td>
<td className={`px-6 py-3 text-sm text-right font-medium ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>
{formatCurrency(Math.abs(netVat))}
</td>
</tr>
<tr className="border-b border-gray-200 bg-indigo-50/50">
<td className="px-6 py-3 text-sm text-indigo-700 font-medium">
{preliminaryVat <= 0 ? '예정신고 미환급세액' : '예정신고 기납부세액'}
</td>
<td className="px-6 py-3 text-sm text-right">-</td>
<td className="px-6 py-3 text-sm text-right text-indigo-600 font-medium">
({formatCurrency(preliminaryDeduction)})
</td>
</tr>
</>
)}
<tr className="bg-gray-50 font-bold">
<td className="px-6 py-3 text-sm">{netVat >= 0 ? '납부세액' : '환급세액'}</td>
<td className="px-6 py-3 text-sm">
{hasPreliminary
? (finalNetVat >= 0 ? '최종 납부세액' : '최종 환급세액')
: (netVat >= 0 ? '납부세액' : '환급세액')}
</td>
<td className="px-6 py-3 text-sm text-right">-</td>
<td className={`px-6 py-3 text-sm text-right ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>{formatCurrency(Math.abs(netVat))}</td>
<td className={`px-6 py-3 text-sm text-right ${(hasPreliminary ? finalNetVat : netVat) >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>
{formatCurrency(Math.abs(hasPreliminary ? finalNetVat : netVat))}
</td>
</tr>
</tbody>
</table>