0) { $amounts[$months - 1] += $remainder; } return $amounts; } function calculate_installment_interest_total( float $principalAmount, int $installmentMonths, float $annualInterestRate ): float { if ($installmentMonths <= 1 || $annualInterestRate <= 0) { return 0.0; } $principalAmount = floor($principalAmount); $monthlyRate = ($annualInterestRate / 100.0) / 12.0; $averageOutstanding = $principalAmount / 2.0; $interestTotal = $averageOutstanding * $monthlyRate * $installmentMonths; // 원화 기준 버림 return (float)floor($interestTotal); } function create_installment_schedule( int $userId, int $transactionId, int $accountId, float $principalAmount, int $installmentMonths, string $transactionDate, float $annualInterestRate = 0.0, ?float $interestTotal = null, ?float $totalBilledAmount = null, string $interestType = 'none' ): void { $pdo = db(); if ($installmentMonths <= 1) { return; } // 원화 기준 $principalAmount = (float)floor($principalAmount); $annualInterestRate = round($annualInterestRate, 4); if ($interestTotal === null) { $interestTotal = calculate_installment_interest_total( $principalAmount, $installmentMonths, $annualInterestRate ); } $interestTotal = (float)floor($interestTotal); if ($totalBilledAmount === null) { $totalBilledAmount = $principalAmount + $interestTotal; } $totalBilledAmount = (float)floor($totalBilledAmount); if ($totalBilledAmount < $principalAmount) { throw new RuntimeException('총 청구금액은 원금보다 작을 수 없습니다.'); } if (($principalAmount + $interestTotal) !== $totalBilledAmount) { throw new RuntimeException('원금 + 총이자와 총 청구금액이 일치해야 합니다.'); } $pdo->beginTransaction(); try { $stmtAcc = $pdo->prepare(" SELECT * FROM accounts WHERE id = ? AND user_id = ? LIMIT 1 "); $stmtAcc->execute([$accountId, $userId]); $account = $stmtAcc->fetch(); /* * 핵심: * 1회차 시작월은 거래월이 아니라 * 카드사 신용공여기간 계산 결과인 billing_year_month 기준. * * 예: * 2026-04-24 사용 + 우리카드 25일 결제 * => startYm = 2026-05 * => 1회차 2026-05 */ $startYm = null; if ($account) { $startYm = get_card_billing_year_month($account, $transactionDate); } if (!$startYm) { $startYm = date('Y-m', strtotime($transactionDate)); } $principalParts = split_amount_evenly($principalAmount, $installmentMonths); $interestParts = split_amount_evenly($interestTotal, $installmentMonths); $stmt = $pdo->prepare(" INSERT INTO installments ( user_id, transaction_id, account_id, principal_amount, interest_total, total_billed_amount, installment_months, annual_interest_rate, start_year_month, interest_type, current_cycle, is_completed, prepaid_principal_amount, prepaid_interest_amount ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0, 0) "); $stmt->execute([ $userId, $transactionId, $accountId, $principalAmount, $interestTotal, $totalBilledAmount, $installmentMonths, $annualInterestRate, $startYm, $annualInterestRate > 0 ? $interestType : 'none' ]); $installmentId = (int)$pdo->lastInsertId(); $stmt = $pdo->prepare(" INSERT INTO installment_schedules ( installment_id, cycle_no, bill_year_month, principal_amount, interest_amount, total_amount, is_billed, billed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $currentYm = date('Y-m'); for ($i = 1; $i <= $installmentMonths; $i++) { $ym = date('Y-m', strtotime($startYm . '-01 +' . ($i - 1) . ' month')); $principalPart = (float)$principalParts[$i - 1]; $interestPart = (float)$interestParts[$i - 1]; $totalPart = $principalPart + $interestPart; // 과거 청구월은 자동 청구완료 $isBilled = ($ym < $currentYm) ? 1 : 0; $billedAt = $isBilled ? date('Y-m-d H:i:s') : null; $stmt->execute([ $installmentId, $i, $ym, $principalPart, $interestPart, $totalPart, $isBilled, $billedAt ]); } recalculate_installment_status($installmentId); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; } } function delete_installment_by_transaction_id(int $transactionId): void { $pdo = db(); $stmt = $pdo->prepare("DELETE FROM installments WHERE transaction_id = ?"); $stmt->execute([$transactionId]); } function get_installment_due_this_month(int $userId, string $yearMonth): float { $pdo = db(); $stmt = $pdo->prepare(" SELECT COALESCE(SUM(s.total_amount), 0) AS total_due FROM installment_schedules s JOIN installments i ON i.id = s.installment_id WHERE i.user_id = ? AND s.bill_year_month = ? AND s.is_billed = 0 "); $stmt->execute([$userId, $yearMonth]); $row = $stmt->fetch(); return (float)($row['total_due'] ?? 0); } function get_installment_remaining_principal(int $userId): float { $pdo = db(); $stmt = $pdo->prepare(" SELECT COALESCE(SUM(s.principal_amount), 0) AS total_principal FROM installment_schedules s JOIN installments i ON i.id = s.installment_id WHERE i.user_id = ? AND s.is_billed = 0 "); $stmt->execute([$userId]); $row = $stmt->fetch(); return (float)($row['total_principal'] ?? 0); } function get_installment_remaining_interest(int $userId): float { $pdo = db(); $stmt = $pdo->prepare(" SELECT COALESCE(SUM(s.interest_amount), 0) AS total_interest FROM installment_schedules s JOIN installments i ON i.id = s.installment_id WHERE i.user_id = ? AND s.is_billed = 0 "); $stmt->execute([$userId]); $row = $stmt->fetch(); return (float)($row['total_interest'] ?? 0); } function get_installment_remaining_total(int $userId): float { $pdo = db(); $stmt = $pdo->prepare(" SELECT COALESCE(SUM(s.total_amount), 0) AS remaining_total FROM installment_schedules s JOIN installments i ON i.id = s.installment_id WHERE i.user_id = ? AND s.is_billed = 0 "); $stmt->execute([$userId]); $row = $stmt->fetch(); return (float)($row['remaining_total'] ?? 0); } function recalculate_installment_status(int $installmentId): void { $pdo = db(); $stmt = $pdo->prepare(" SELECT COALESCE(SUM(CASE WHEN is_billed = 0 THEN 1 ELSE 0 END), 0) AS remaining_count, COALESCE(MIN(CASE WHEN is_billed = 0 THEN cycle_no ELSE NULL END), 0) AS next_cycle FROM installment_schedules WHERE installment_id = ? "); $stmt->execute([$installmentId]); $row = $stmt->fetch(); $remainingCount = (int)($row['remaining_count'] ?? 0); $nextCycle = (int)($row['next_cycle'] ?? 0); $stmt = $pdo->prepare(" UPDATE installments SET current_cycle = ?, is_completed = ? WHERE id = ? "); $stmt->execute([ $nextCycle > 0 ? $nextCycle : 0, $remainingCount === 0 ? 1 : 0, $installmentId ]); } function mark_installment_month_billed_for_card_payment( int $userId, int $cardAccountId, string $yearMonth ): int { $pdo = db(); $pdo->beginTransaction(); try { $stmt = $pdo->prepare(" SELECT s.id, s.installment_id FROM installment_schedules s JOIN installments i ON i.id = s.installment_id WHERE i.user_id = ? AND i.account_id = ? AND s.bill_year_month = ? AND s.is_billed = 0 ORDER BY s.id ASC "); $stmt->execute([$userId, $cardAccountId, $yearMonth]); $rows = $stmt->fetchAll(); if (!$rows) { $pdo->commit(); return 0; } $scheduleIds = array_column($rows, 'id'); $installmentIds = array_values(array_unique(array_map( fn($r) => (int)$r['installment_id'], $rows ))); $placeholders = implode(',', array_fill(0, count($scheduleIds), '?')); $stmt = $pdo->prepare(" UPDATE installment_schedules SET is_billed = 1, billed_at = NOW() WHERE id IN ($placeholders) "); $stmt->execute($scheduleIds); foreach ($installmentIds as $installmentId) { recalculate_installment_status($installmentId); } $pdo->commit(); return count($scheduleIds); } catch (Throwable $e) { $pdo->rollBack(); throw $e; } } function prepay_installment( int $userId, int $installmentId, int $paymentAccountId, string $prepayDate, float $prepayPrincipalAmount, float $prepayInterestAmount = 0.0, ?string $description = null, ?int $targetScheduleId = null ): void { $pdo = db(); $pdo->beginTransaction(); try { $stmt = $pdo->prepare(" SELECT i.*, t.merchant_name FROM installments i JOIN transactions t ON t.id = i.transaction_id WHERE i.id = ? AND i.user_id = ? "); $stmt->execute([$installmentId, $userId]); $installment = $stmt->fetch(); if (!$installment) { throw new RuntimeException('할부 정보를 찾을 수 없습니다.'); } $prepayPrincipalAmount = (float)floor($prepayPrincipalAmount); $prepayInterestAmount = (float)floor($prepayInterestAmount); if ($prepayPrincipalAmount <= 0 && $prepayInterestAmount <= 0) { throw new RuntimeException('선결제 금액이 0보다 커야 합니다.'); } $sql = " SELECT id, principal_amount, interest_amount, total_amount FROM installment_schedules WHERE installment_id = ? AND is_billed = 0 "; $params = [$installmentId]; if ($targetScheduleId !== null && $targetScheduleId > 0) { $sql .= " AND id = ? "; $params[] = $targetScheduleId; } $sql .= " ORDER BY cycle_no ASC "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $remainingSchedules = $stmt->fetchAll(); if (!$remainingSchedules) { throw new RuntimeException('남아있는 회차가 없습니다.'); } $remainingPrincipal = (float)floor(array_sum(array_map( fn($r) => (float)$r['principal_amount'], $remainingSchedules ))); $remainingInterest = (float)floor(array_sum(array_map( fn($r) => (float)$r['interest_amount'], $remainingSchedules ))); // 초과 입력 시 남은 금액까지만 자동 보정 if ($prepayPrincipalAmount > $remainingPrincipal) { $prepayPrincipalAmount = $remainingPrincipal; } if ($prepayInterestAmount > $remainingInterest) { $prepayInterestAmount = $remainingInterest; } if ($prepayPrincipalAmount <= 0 && $prepayInterestAmount <= 0) { throw new RuntimeException('남아있는 선결제 가능 금액이 없습니다.'); } $remainingPrincipalToApply = $prepayPrincipalAmount; $remainingInterestToApply = $prepayInterestAmount; foreach ($remainingSchedules as $schedule) { $scheduleId = (int)$schedule['id']; $principal = (float)$schedule['principal_amount']; $interest = (float)$schedule['interest_amount']; $newPrincipal = $principal; $newInterest = $interest; if ($remainingPrincipalToApply > 0) { $deduct = min($newPrincipal, $remainingPrincipalToApply); $newPrincipal = $newPrincipal - $deduct; $remainingPrincipalToApply = $remainingPrincipalToApply - $deduct; } if ($remainingInterestToApply > 0) { $deduct = min($newInterest, $remainingInterestToApply); $newInterest = $newInterest - $deduct; $remainingInterestToApply = $remainingInterestToApply - $deduct; } $newPrincipal = (float)max(0, floor($newPrincipal)); $newInterest = (float)max(0, floor($newInterest)); $newTotal = $newPrincipal + $newInterest; $isNowZero = ($newTotal <= 0) ? 1 : 0; $stmt2 = $pdo->prepare(" UPDATE installment_schedules SET principal_amount = ?, interest_amount = ?, total_amount = ?, is_billed = CASE WHEN ? = 1 THEN 1 ELSE is_billed END, billed_at = CASE WHEN ? = 1 THEN NOW() ELSE billed_at END WHERE id = ? "); $stmt2->execute([ $newPrincipal, $newInterest, $newTotal, $isNowZero, $isNowZero, $scheduleId ]); } $totalAmount = $prepayPrincipalAmount + $prepayInterestAmount; $stmt = $pdo->prepare(" INSERT INTO installment_prepayments ( user_id, installment_id, account_id, prepay_date, principal_amount, interest_amount, total_amount, description ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $userId, $installmentId, $paymentAccountId, $prepayDate, $prepayPrincipalAmount, $prepayInterestAmount, $totalAmount, $description ]); $stmt = $pdo->prepare(" UPDATE installments SET prepaid_principal_amount = prepaid_principal_amount + ?, prepaid_interest_amount = prepaid_interest_amount + ? WHERE id = ? "); $stmt->execute([ $prepayPrincipalAmount, $prepayInterestAmount, $installmentId ]); recalculate_installment_status($installmentId); $stmt = $pdo->prepare(" INSERT INTO transactions ( user_id, account_id, category_id, transaction_type, amount, is_installment, installment_months, installment_interest_rate, installment_interest_total, installment_total_billed, installment_prepay_amount, transaction_date, merchant_name, description, related_account_id, fingerprint ) VALUES (?, ?, ?, 'expense', ?, 0, NULL, 0, 0, NULL, ?, ?, ?, ?, NULL, ?) "); $desc = $description ?: '할부 선결제/중도상환'; $fingerprint = hash('sha256', implode('|', [ $userId, $paymentAccountId, 'installment_prepay', $installmentId, $prepayDate, number_format($totalAmount, 2, '.', ''), $desc ])); $stmtCat = $pdo->prepare(" SELECT id FROM categories WHERE user_id = ? AND category_type = 'expense' AND name = '기타지출' LIMIT 1 "); $stmtCat->execute([$userId]); $category = $stmtCat->fetch(); if (!$category) { throw new RuntimeException('선결제 기록용 expense 카테고리(기타지출)를 찾을 수 없습니다.'); } $stmt->execute([ $userId, $paymentAccountId, (int)$category['id'], $totalAmount, $totalAmount, $prepayDate, $installment['merchant_name'], '[할부 선결제] ' . $desc, $fingerprint ]); $pdo->commit(); recalculate_account_balance($paymentAccountId); } catch (Throwable $e) { $pdo->rollBack(); throw $e; } } function rebuild_all_installments_for_user(int $userId): int { $pdo = db(); $pdo->beginTransaction(); try { $stmt = $pdo->prepare(" SELECT id, account_id, amount, transaction_date, installment_months, installment_interest_rate, installment_interest_total, installment_total_billed FROM transactions WHERE user_id = ? AND transaction_type = 'expense' AND is_installment = 1 AND installment_months > 1 ORDER BY transaction_date ASC, id ASC "); $stmt->execute([$userId]); $rows = $stmt->fetchAll(); $stmt = $pdo->prepare(" DELETE ip FROM installment_prepayments ip JOIN installments i ON i.id = ip.installment_id WHERE i.user_id = ? "); $stmt->execute([$userId]); $stmt = $pdo->prepare(" DELETE s FROM installment_schedules s JOIN installments i ON i.id = s.installment_id WHERE i.user_id = ? "); $stmt->execute([$userId]); $stmt = $pdo->prepare(" DELETE FROM installments WHERE user_id = ? "); $stmt->execute([$userId]); $pdo->commit(); $count = 0; foreach ($rows as $row) { create_installment_schedule( $userId, (int)$row['id'], (int)$row['account_id'], (float)$row['amount'], (int)$row['installment_months'], (string)$row['transaction_date'], (float)($row['installment_interest_rate'] ?? 0), $row['installment_interest_total'] !== null ? (float)$row['installment_interest_total'] : null, $row['installment_total_billed'] !== null ? (float)$row['installment_total_billed'] : null, ((float)($row['installment_interest_rate'] ?? 0) > 0) ? 'fixed_total' : 'none' ); $count++; } return $count; } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } }