Files
financial/app/lib/installment_service.php
T
2026-06-07 00:33:58 +09:00

704 lines
21 KiB
PHP

<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/account_service.php';
require_once __DIR__ . '/card_billing_service.php';
function split_amount_evenly(float $amount, int $months): array
{
if ($months <= 0) {
throw new RuntimeException('개월 수가 올바르지 않습니다.');
}
// 원화 기준: 소수점 제거
$amount = (int)floor($amount);
$base = intdiv($amount, $months);
$remainder = $amount % $months;
$amounts = array_fill(0, $months, $base);
// 남는 원 단위는 마지막 회차에 몰아줌
if ($remainder > 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;
}
}