704 lines
21 KiB
PHP
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;
|
|
}
|
|
} |