Initial financial project import

This commit is contained in:
seo
2026-06-07 00:33:58 +09:00
commit faeab5f0f5
50 changed files with 11267 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.env
.agents/
.codex/
*.log
*.db
*.sqlite
*.sql
cache/
tmp/
uploads/
secrets/
secret/
+33
View File
@@ -0,0 +1,33 @@
# Financial
PHP based personal finance service for accounts, transactions, categories, installments, loans, recurring transactions, and merchant classification rules.
## Main Features
- Server-rendered authenticated screens for personal finance data.
- Account, transaction, installment, loan, recurring transaction, and merchant rule management.
- CSRF protected form POST workflows.
- Category suggestion API based on merchant rules and confidence.
## Main APIs
- `public/api/category_suggest.php`
- Standard authenticated form POST save flows.
## Structure
- `app/config/database.php`: DB config bridge.
- `app/lib/auth.php`: authentication, remember token, CSRF, and no-store headers.
- `app/lib/*_service.php`: domain services.
- `public/*.php`: screens and form handlers.
## Secrets
DB credentials are loaded from `/home/seo/secret/financial.php`. Do not commit that file.
## Security
- Uses login sessions and remember tokens.
- POST forms validate CSRF tokens.
- Personal financial responses should remain private and no-store.
+12
View File
@@ -0,0 +1,12 @@
<?php
$financialSecretFile = '/home/seo/secret/financial.php';
$financialSecretConfig = is_file($financialSecretFile) ? require $financialSecretFile : [];
return [
'host' => getenv('FINANCIAL_DB_HOST') ?: ($financialSecretConfig['host'] ?? '127.0.0.1'),
'port' => (int)(getenv('FINANCIAL_DB_PORT') ?: ($financialSecretConfig['port'] ?? 3306)),
'dbname' => getenv('FINANCIAL_DB_NAME') ?: ($financialSecretConfig['dbname'] ?? 'financial'),
'username' => getenv('FINANCIAL_DB_USER') ?: ($financialSecretConfig['username'] ?? 'financial'),
'password' => getenv('FINANCIAL_DB_PASSWORD') ?: ($financialSecretConfig['password'] ?? ''),
'charset' => getenv('FINANCIAL_DB_CHARSET') ?: ($financialSecretConfig['charset'] ?? 'utf8mb4'),
];
+76
View File
@@ -0,0 +1,76 @@
<?php
require_once __DIR__ . '/db.php';
function recalculate_account_balance(int $accountId): void
{
$pdo = db();
$stmt = $pdo->prepare("SELECT id, account_type, opening_balance FROM accounts WHERE id = ?");
$stmt->execute([$accountId]);
$account = $stmt->fetch();
if (!$account) {
return;
}
$opening = (float)$account['opening_balance'];
$type = $account['account_type'];
$balance = $opening;
if ($type === 'bank' || $type === 'cash' || $type === 'other') {
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE
WHEN transaction_type = 'income' AND account_id = :id THEN amount
ELSE 0 END), 0) AS income_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'expense' AND account_id = :id THEN amount
ELSE 0 END), 0) AS expense_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'transfer' AND account_id = :id THEN amount
ELSE 0 END), 0) AS transfer_out_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'transfer' AND related_account_id = :id THEN amount
ELSE 0 END), 0) AS transfer_in_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'card_payment' AND account_id = :id THEN amount
ELSE 0 END), 0) AS card_payment_sum
FROM transactions
WHERE account_id = :id OR related_account_id = :id
");
$stmt->execute(['id' => $accountId]);
$row = $stmt->fetch();
$balance += (float)$row['income_sum'];
$balance -= (float)$row['expense_sum'];
$balance -= (float)$row['transfer_out_sum'];
$balance += (float)$row['transfer_in_sum'];
$balance -= (float)$row['card_payment_sum'];
} elseif ($type === 'card') {
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE
WHEN transaction_type = 'expense' AND account_id = :id THEN amount
ELSE 0 END), 0) AS card_use_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'card_payment' AND related_account_id = :id THEN amount
ELSE 0 END), 0) AS paid_sum
FROM transactions
WHERE account_id = :id OR related_account_id = :id
");
$stmt->execute(['id' => $accountId]);
$row = $stmt->fetch();
$balance += (float)$row['card_use_sum'];
$balance -= (float)$row['paid_sum'];
}
$stmt = $pdo->prepare("UPDATE accounts SET current_balance = ? WHERE id = ?");
$stmt->execute([$balance, $accountId]);
}
+284
View File
@@ -0,0 +1,284 @@
<?php
require_once __DIR__ . '/db.php';
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
function send_private_no_store_headers(): void
{
if (headers_sent()) {
return;
}
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' .
htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') .
'">';
}
function verify_csrf_token(?string $token): bool
{
return is_string($token)
&& isset($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
function require_valid_csrf_for_post(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
return;
}
if (verify_csrf_token($_POST['csrf_token'] ?? null)) {
return;
}
http_response_code(419);
exit('Invalid CSRF token.');
}
function enable_csrf_form_injection(): void
{
if (PHP_SAPI === 'cli' || defined('FINANCIAL_CSRF_INJECTION_ENABLED')) {
return;
}
define('FINANCIAL_CSRF_INJECTION_ENABLED', true);
ob_start(static function (string $buffer): string {
if (
stripos($buffer, '<form') === false ||
!preg_match('/method\s*=\s*["\']?post/i', $buffer)
) {
return $buffer;
}
$field = csrf_field();
return preg_replace_callback(
'/<form\b([^>]*)>/i',
static function (array $matches) use ($field): string {
$tag = $matches[0];
if (
stripos($tag, 'method="post"') === false &&
stripos($tag, "method='post'") === false &&
!preg_match('/method\s*=\s*post/i', $tag)
) {
return $tag;
}
if (stripos($tag, 'csrf_token') !== false) {
return $tag;
}
return $tag . $field;
},
$buffer
) ?? $buffer;
});
}
function throttle_login_attempts(string $username): void
{
$key = 'login_attempts_' . hash('sha256', strtolower($username) . '|' . ($_SERVER['REMOTE_ADDR'] ?? ''));
$now = time();
$attempt = $_SESSION[$key] ?? ['count' => 0, 'first_at' => $now];
if (($now - (int)$attempt['first_at']) > 900) {
$attempt = ['count' => 0, 'first_at' => $now];
}
if ((int)$attempt['count'] >= 8) {
throw new RuntimeException('로그인 시도가 많습니다. 잠시 후 다시 시도하세요.');
}
$attempt['count']++;
$_SESSION[$key] = $attempt;
}
function clear_login_attempts(string $username): void
{
$key = 'login_attempts_' . hash('sha256', strtolower($username) . '|' . ($_SERVER['REMOTE_ADDR'] ?? ''));
unset($_SESSION[$key]);
}
function login_user(array $user, bool $remember = false): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['username'] = $user['username'];
if ($remember) {
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$pdo = db();
$stmt = $pdo->prepare("
UPDATE users
SET remember_token = ?, remember_expires_at = ?
WHERE id = ?
");
$stmt->execute([$tokenHash, $expiresAt, $user['id']]);
setcookie(
'remember_token',
$token,
[
'expires' => strtotime('+30 days'),
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
}
}
function logout_user(): void
{
if (!empty($_SESSION['user_id'])) {
$pdo = db();
$stmt = $pdo->prepare("
UPDATE users
SET remember_token = NULL, remember_expires_at = NULL
WHERE id = ?
");
$stmt->execute([$_SESSION['user_id']]);
}
setcookie(
'remember_token',
'',
[
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'] ?? '', $params['secure'] ?? false, $params['httponly'] ?? false);
}
session_destroy();
}
function try_auto_login(): void
{
if (!empty($_SESSION['user_id'])) {
return;
}
if (empty($_COOKIE['remember_token'])) {
return;
}
$token = $_COOKIE['remember_token'];
$tokenHash = hash('sha256', $token);
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM users
WHERE remember_token = ?
AND remember_expires_at IS NOT NULL
AND remember_expires_at > NOW()
LIMIT 1
");
$stmt->execute([$tokenHash]);
$user = $stmt->fetch();
if (!$user) {
setcookie(
'remember_token',
'',
[
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
return;
}
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['username'] = $user['username'];
$newToken = bin2hex(random_bytes(32));
$newHash = hash('sha256', $newToken);
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$stmt = $pdo->prepare("
UPDATE users
SET remember_token = ?, remember_expires_at = ?
WHERE id = ?
");
$stmt->execute([$newHash, $expiresAt, $user['id']]);
setcookie(
'remember_token',
$newToken,
[
'expires' => strtotime('+30 days'),
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
}
function check_auth(): void
{
try_auto_login();
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
send_private_no_store_headers();
}
function user_id(): int
{
return (int)($_SESSION['user_id'] ?? 0);
}
send_private_no_store_headers();
require_valid_csrf_for_post();
enable_csrf_form_injection();
+274
View File
@@ -0,0 +1,274 @@
<?php
function normalize_card_kind(array $account): ?string
{
$kind = strtolower(trim((string)($account['card_kind'] ?? '')));
if ($kind === 'credit') {
return 'credit';
}
if ($kind === 'check') {
return 'check';
}
return null;
}
function safe_date_ts(string $date): ?int
{
$ts = strtotime($date);
return $ts === false ? null : $ts;
}
function card_month_day_date(string $billingYearMonth, int $monthOffset, int $day): ?DateTime
{
if (!preg_match('/^\d{4}-\d{2}$/', $billingYearMonth)) {
return null;
}
if ($day < 1 || $day > 31) {
return null;
}
$base = new DateTime($billingYearMonth . '-01');
if ($monthOffset !== 0) {
$base->modify(($monthOffset > 0 ? '+' : '') . $monthOffset . ' month');
}
$lastDay = (int)$base->format('t');
$realDay = min($day, $lastDay);
$base->setDate(
(int)$base->format('Y'),
(int)$base->format('m'),
$realDay
);
return $base;
}
function account_has_statement_period(array $account): bool
{
return isset(
$account['statement_start_month_offset'],
$account['statement_start_day'],
$account['statement_end_month_offset'],
$account['statement_end_day']
)
&& $account['statement_start_month_offset'] !== null
&& $account['statement_start_day'] !== null
&& $account['statement_end_month_offset'] !== null
&& $account['statement_end_day'] !== null
&& (int)$account['statement_start_day'] >= 1
&& (int)$account['statement_start_day'] <= 31
&& (int)$account['statement_end_day'] >= 1
&& (int)$account['statement_end_day'] <= 31;
}
function transaction_date_in_card_statement_month(array $account, string $transactionDate, string $billingYearMonth): bool
{
$txTs = safe_date_ts($transactionDate);
if ($txTs === null) {
return false;
}
if (!account_has_statement_period($account)) {
return false;
}
$start = card_month_day_date(
$billingYearMonth,
(int)$account['statement_start_month_offset'],
(int)$account['statement_start_day']
);
$end = card_month_day_date(
$billingYearMonth,
(int)$account['statement_end_month_offset'],
(int)$account['statement_end_day']
);
if (!$start || !$end) {
return false;
}
$tx = new DateTime(date('Y-m-d', $txTs));
return $tx >= $start && $tx <= $end;
}
function get_card_billing_year_month_by_statement_period(array $account, string $transactionDate): ?string
{
$txTs = safe_date_ts($transactionDate);
if ($txTs === null) {
return null;
}
if (!account_has_statement_period($account)) {
return null;
}
$tx = new DateTime(date('Y-m-d', $txTs));
/*
* 청구월 기준 사용기간 예:
* 2026-05 청구월 = 2026-04-11 ~ 2026-05-10
*
* 거래일 주변의 청구월만 검사하면 됨.
* 전월/당월/익월/익익월까지 여유 있게 검사.
*/
$base = new DateTime($tx->format('Y-m-01'));
for ($i = -1; $i <= 2; $i++) {
$candidate = clone $base;
if ($i !== 0) {
$candidate->modify(($i > 0 ? '+' : '') . $i . ' month');
}
$candidateYm = $candidate->format('Y-m');
if (transaction_date_in_card_statement_month($account, $transactionDate, $candidateYm)) {
return $candidateYm;
}
}
return null;
}
function get_card_billing_year_month(array $account, string $transactionDate): ?string
{
if (($account['account_type'] ?? '') !== 'card') {
return null;
}
$ts = safe_date_ts($transactionDate);
if ($ts === null) {
return null;
}
$cardKind = normalize_card_kind($account);
// 체크카드는 즉시형으로 보고 거래월 그대로
if ($cardKind === 'check') {
return date('Y-m', $ts);
}
// 신용카드인데 신용공여기간 계산을 안 쓰면 거래월 그대로
if (empty($account['use_credit_grace_period'])) {
return date('Y-m', $ts);
}
// 신규 방식: 카드사별 실제 사용기간 설정 우선
$statementYm = get_card_billing_year_month_by_statement_period($account, $transactionDate);
if ($statementYm !== null) {
return $statementYm;
}
// fallback: 기존 billing_day 단순 방식
$billingDay = (int)($account['billing_day'] ?? 0);
if ($billingDay <= 0 || $billingDay > 31) {
return date('Y-m', $ts);
}
$dt = new DateTime(date('Y-m-d', $ts));
$day = (int)$dt->format('d');
if ($day <= $billingDay) {
return $dt->format('Y-m');
}
$dt->modify('first day of next month');
return $dt->format('Y-m');
}
function get_card_payment_date(array $account, string $billingYearMonth): ?string
{
if (($account['account_type'] ?? '') !== 'card') {
return null;
}
if (!preg_match('/^\d{4}-\d{2}$/', $billingYearMonth)) {
return null;
}
$paymentDay = (int)($account['payment_day'] ?? 0);
if ($paymentDay <= 0 || $paymentDay > 31) {
return null;
}
[$year, $month] = explode('-', $billingYearMonth);
$year = (int)$year;
$month = (int)$month;
$firstDay = new DateTime(sprintf('%04d-%02d-01', $year, $month));
$lastDay = (int)$firstDay->format('t');
$day = min($paymentDay, $lastDay);
$firstDay->setDate($year, $month, $day);
return $firstDay->format('Y-m-d');
}
function get_card_statement_period_label(array $account): ?string
{
if (!account_has_statement_period($account)) {
return null;
}
$startOffset = (int)$account['statement_start_month_offset'];
$startDay = (int)$account['statement_start_day'];
$endOffset = (int)$account['statement_end_month_offset'];
$endDay = (int)$account['statement_end_day'];
$monthText = function (int $offset): string {
if ($offset === -2) return '전전월';
if ($offset === -1) return '전월';
if ($offset === 0) return '당월';
if ($offset === 1) return '익월';
if ($offset === 2) return '익익월';
return $offset . '개월';
};
return $monthText($startOffset) . ' ' . $startDay . '일 ~ ' .
$monthText($endOffset) . ' ' . $endDay . '일 사용분';
}
function get_card_billing_label(array $account): string
{
if (($account['account_type'] ?? '') !== 'card') {
return '-';
}
$cardKind = normalize_card_kind($account);
if ($cardKind === 'check') {
return '체크카드 · 즉시출금';
}
if ($cardKind === 'credit') {
$paymentDay = (int)($account['payment_day'] ?? 0);
if (!empty($account['billing_cycle_memo'])) {
return (string)$account['billing_cycle_memo'];
}
$periodLabel = get_card_statement_period_label($account);
if ($periodLabel !== null && $paymentDay > 0) {
return '신용카드 · ' . $periodLabel . ' / 납부일 ' . $paymentDay . '일';
}
if ($periodLabel !== null) {
return '신용카드 · ' . $periodLabel;
}
$billingDay = (int)($account['billing_day'] ?? 0);
if ($billingDay > 0 && $paymentDay > 0) {
return '신용카드 · 결제기준일 ' . $billingDay . '일 / 납부일 ' . $paymentDay . '일';
}
return '신용카드';
}
return '카드';
}
+32
View File
@@ -0,0 +1,32 @@
<?php
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['dbname'],
$config['charset']
);
$pdo = new PDO(
$dsn,
$config['username'],
$config['password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
return $pdo;
}
+98
View File
@@ -0,0 +1,98 @@
<?php
function h($value): string
{
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
}
function won($value): string
{
return number_format((float)$value, 0) . '원';
}
function money_plain($value, int $decimals = 0): string
{
return number_format((float)$value, $decimals);
}
function numf($value, int $decimals = 0): string
{
return number_format((float)$value, $decimals);
}
function intvalf($value): string
{
return number_format((int)$value);
}
function percentf($value, int $decimals = 1): string
{
return number_format((float)$value, $decimals) . '%';
}
function blank_to_dash($value): string
{
$value = trim((string)($value ?? ''));
return $value === '' ? '-' : $value;
}
function ymd($value): string
{
if (empty($value)) {
return '-';
}
$ts = strtotime((string)$value);
if ($ts === false) {
return h((string)$value);
}
return date('Y-m-d', $ts);
}
function ym($value): string
{
if (empty($value)) {
return '-';
}
$ts = strtotime((string)$value . '-01');
if ($ts === false) {
return h((string)$value);
}
return date('Y-m', $ts);
}
function redirect(string $url): void
{
header("Location: {$url}");
exit;
}
function set_flash_message(string $type, string $message): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['flash_message'] = [
'type' => $type,
'message' => $message,
];
}
function get_flash_message(): ?array
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['flash_message'])) {
return null;
}
$flash = $_SESSION['flash_message'];
unset($_SESSION['flash_message']);
return $flash;
}
+704
View File
@@ -0,0 +1,704 @@
<?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;
}
}
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
<?php
require_once __DIR__ . '/db.php';
function normalize_merchant_text(string $text): string
{
$text = trim($text);
$text = mb_strtolower($text, 'UTF-8');
$removeWords = [
'주식회사',
'(주)',
'㈜',
'유한회사',
'영농조합법인',
'농업회사법인',
'사단법인',
'재단법인',
'법인',
'카드',
'체크',
'승인',
'취소',
'일시불',
'할부',
'누적',
'잔액',
];
foreach ($removeWords as $word) {
$text = str_replace($word, '', $text);
}
$replace = [
' ' => '',
'-' => '',
'_' => '',
'(' => '',
')' => '',
'[' => '',
']' => '',
'{' => '',
'}' => '',
'.' => '',
',' => '',
':' => '',
';' => '',
'/' => '',
'\\' => '',
'|' => '',
'\'' => '',
'"' => '',
"\t" => '',
"\n" => '',
"\r" => '',
];
$text = strtr($text, $replace);
// 금액/시간/승인번호처럼 추천에 방해되는 숫자 덩어리 완화
$text = preg_replace('/\d{4,}/u', '', $text);
return trim((string)$text);
}
function map_transaction_type_to_category_type(string $transactionType): string
{
if ($transactionType === 'income') {
return 'income';
}
if ($transactionType === 'expense') {
return 'expense';
}
return 'transfer';
}
function merchant_starts_with(string $haystack, string $needle): bool
{
if ($needle === '') {
return false;
}
return mb_substr($haystack, 0, mb_strlen($needle, 'UTF-8'), 'UTF-8') === $needle;
}
function suggest_category_from_merchant(int $userId, string $merchantText, string $transactionType): ?array
{
$pdo = db();
$merchantText = trim($merchantText);
if ($merchantText === '') {
return null;
}
$normalized = normalize_merchant_text($merchantText);
if ($normalized === '') {
return null;
}
$categoryType = map_transaction_type_to_category_type($transactionType);
$stmt = $pdo->prepare("
SELECT
r.id,
r.pattern_text,
r.normalized_pattern,
r.match_type,
r.priority,
r.confidence,
c.id AS category_id,
c.name AS category_name,
c.category_type
FROM merchant_pattern_rules r
JOIN categories c
ON c.id = r.category_id
AND c.user_id = r.user_id
WHERE r.user_id = ?
AND r.is_active = 1
AND c.is_active = 1
AND c.category_type = ?
AND r.normalized_pattern IS NOT NULL
AND r.normalized_pattern <> ''
ORDER BY
CASE r.match_type
WHEN 'exact' THEN 1
WHEN 'prefix' THEN 2
ELSE 3
END ASC,
r.priority ASC,
CHAR_LENGTH(r.normalized_pattern) DESC,
r.id ASC
");
$stmt->execute([$userId, $categoryType]);
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$pattern = (string)$row['normalized_pattern'];
if ($pattern === '') {
continue;
}
$matched = false;
if ($row['match_type'] === 'exact') {
$matched = ($normalized === $pattern);
} elseif ($row['match_type'] === 'prefix') {
$matched = merchant_starts_with($normalized, $pattern);
} else {
$matched = mb_strpos($normalized, $pattern, 0, 'UTF-8') !== false;
}
if ($matched) {
return [
'rule_id' => (int)$row['id'],
'pattern_text' => (string)$row['pattern_text'],
'keyword' => (string)$row['pattern_text'],
'match_type' => (string)$row['match_type'],
'priority' => (int)$row['priority'],
'confidence' => (float)$row['confidence'],
'category_id' => (int)$row['category_id'],
'category_name' => (string)$row['category_name'],
'category_type' => (string)$row['category_type'],
'normalized_input' => $normalized,
];
}
}
return null;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/transaction_service.php';
function apply_recurring_transactions_for_month(int $userId, string $ym, bool $skipDuplicates = true): int
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM recurring_transactions
WHERE user_id = ?
AND is_active = 1
AND (last_applied_ym IS NULL OR last_applied_ym <> ?)
ORDER BY id ASC
");
$stmt->execute([$userId, $ym]);
$items = $stmt->fetchAll();
$count = 0;
$lastDay = (int)date('t', strtotime($ym . '-01'));
foreach ($items as $item) {
$day = min((int)$item['day_of_month'], $lastDay);
$date = sprintf('%s-%02d', $ym, $day);
$inserted = create_transaction([
'user_id' => $userId,
'account_id' => (int)$item['account_id'],
'category_id' => (int)$item['category_id'],
'transaction_type' => $item['transaction_type'],
'amount' => (float)$item['amount'],
'transaction_date' => $date,
'merchant_name' => $item['merchant_name'],
'description' => $item['description'],
'related_account_id' => $item['related_account_id'] ? (int)$item['related_account_id'] : null,
], $skipDuplicates);
$stmt2 = $pdo->prepare("UPDATE recurring_transactions SET last_applied_ym = ? WHERE id = ?");
$stmt2->execute([$ym, $item['id']]);
if ($inserted) {
$count++;
}
}
return $count;
}
+325
View File
@@ -0,0 +1,325 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/helpers.php';
/*
테이블 필요
CREATE TABLE IF NOT EXISTS user_transaction_defaults (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
default_account_id BIGINT UNSIGNED DEFAULT NULL,
default_card_account_id BIGINT UNSIGNED DEFAULT NULL,
default_income_category_id BIGINT UNSIGNED DEFAULT NULL,
default_expense_category_id BIGINT UNSIGNED DEFAULT NULL,
default_transfer_category_id BIGINT UNSIGNED DEFAULT NULL,
default_card_payment_category_id BIGINT UNSIGNED DEFAULT NULL,
keep_last_values TINYINT(1) NOT NULL DEFAULT 1,
continue_after_save TINYINT(1) NOT NULL DEFAULT 1,
quick_amounts VARCHAR(255) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
);
*/
function get_transaction_form_defaults(int $userId): array
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM user_transaction_defaults
WHERE user_id = ?
LIMIT 1
");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row) {
return get_transaction_form_default_seed();
}
$row['keep_last_values'] = (int)$row['keep_last_values'];
$row['continue_after_save'] = (int)$row['continue_after_save'];
$row['quick_amounts'] = parse_quick_amounts($row['quick_amounts'] ?? '');
return array_merge(get_transaction_form_default_seed(), $row);
}
function get_transaction_form_default_seed(): array
{
return [
'default_account_id' => null,
'default_card_account_id' => null,
'default_income_category_id' => null,
'default_expense_category_id' => null,
'default_transfer_category_id' => null,
'default_card_payment_category_id' => null,
'keep_last_values' => 1,
'continue_after_save' => 1,
'quick_amounts' => [10000, 30000, 50000, 100000],
];
}
function save_transaction_form_defaults(int $userId, array $data): void
{
$pdo = db();
$quickAmounts = normalize_quick_amounts(
$data['quick_amounts'] ?? []
);
$stmt = $pdo->prepare("
INSERT INTO user_transaction_defaults
(
user_id,
default_account_id,
default_card_account_id,
default_income_category_id,
default_expense_category_id,
default_transfer_category_id,
default_card_payment_category_id,
keep_last_values,
continue_after_save,
quick_amounts
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
default_account_id = VALUES(default_account_id),
default_card_account_id = VALUES(default_card_account_id),
default_income_category_id = VALUES(default_income_category_id),
default_expense_category_id = VALUES(default_expense_category_id),
default_transfer_category_id = VALUES(default_transfer_category_id),
default_card_payment_category_id = VALUES(default_card_payment_category_id),
keep_last_values = VALUES(keep_last_values),
continue_after_save = VALUES(continue_after_save),
quick_amounts = VALUES(quick_amounts)
");
$stmt->execute([
$userId,
nullable_id($data['default_account_id'] ?? null),
nullable_id($data['default_card_account_id'] ?? null),
nullable_id($data['default_income_category_id'] ?? null),
nullable_id($data['default_expense_category_id'] ?? null),
nullable_id($data['default_transfer_category_id'] ?? null),
nullable_id($data['default_card_payment_category_id'] ?? null),
!empty($data['keep_last_values']) ? 1 : 0,
!empty($data['continue_after_save']) ? 1 : 0,
implode(',', $quickAmounts),
]);
}
function nullable_id($value): ?int
{
$v = (int)$value;
return $v > 0 ? $v : null;
}
function parse_quick_amounts(string $csv): array
{
if (trim($csv) === '') {
return [10000, 30000, 50000, 100000];
}
$items = explode(',', $csv);
$result = [];
foreach ($items as $item) {
$v = (int)trim($item);
if ($v > 0) {
$result[] = $v;
}
}
if (!$result) {
return [10000, 30000, 50000, 100000];
}
return array_values(array_unique($result));
}
function normalize_quick_amounts(array $items): array
{
$result = [];
foreach ($items as $item) {
$v = (int)$item;
if ($v > 0) {
$result[] = $v;
}
}
if (!$result) {
$result = [10000, 30000, 50000, 100000];
}
$result = array_values(array_unique($result));
sort($result);
return $result;
}
/**
* 거래 등록 화면 초기값
*/
function build_transaction_form_state(
int $userId,
array $defaults,
?array $lastState = null
): array {
$state = [
'transaction_date' => current_date_ymd(),
'transaction_type' => 'expense',
'account_id' => $defaults['default_account_id'] ?: 0,
'related_account_id' => 0,
'category_id' => $defaults['default_expense_category_id'] ?: 0,
'amount' => '',
'merchant_name' => '',
'description' => '',
'is_installment' => 0,
'installment_months' => '',
'installment_interest_rate' => '',
'continue_after_save' => $defaults['continue_after_save'],
'keep_last_values' => $defaults['keep_last_values'],
'save_as_defaults' => 0,
];
if (!empty($defaults['keep_last_values']) && is_array($lastState)) {
$state = array_merge($state, $lastState);
$state['transaction_date'] = current_date_ymd();
$state['amount'] = '';
}
return $state;
}
/**
* 유형 변경 시 기본 카테고리 자동 선택
*/
function apply_default_category_by_type(array &$state, array $defaults): void
{
$type = $state['transaction_type'] ?? 'expense';
if ($type === 'income') {
$state['category_id'] = $defaults['default_income_category_id'] ?: 0;
return;
}
if ($type === 'expense') {
$state['category_id'] = $defaults['default_expense_category_id'] ?: 0;
return;
}
if ($type === 'transfer') {
$state['category_id'] = $defaults['default_transfer_category_id'] ?: 0;
return;
}
if ($type === 'card_payment') {
$state['category_id'] = $defaults['default_card_payment_category_id'] ?: 0;
return;
}
}
/**
* 카드 지출이면 카드 기본계좌 우선 적용
*/
function apply_default_card_account(array &$state, array $defaults): void
{
if (($state['transaction_type'] ?? '') !== 'expense') {
return;
}
if (!empty($defaults['default_card_account_id'])) {
$state['account_id'] = (int)$defaults['default_card_account_id'];
}
}
/**
* 저장 후 마지막 상태 저장
*/
function remember_transaction_form_state(array $state): void
{
$_SESSION['tx_create_last_form'] = [
'transaction_type' => $state['transaction_type'],
'account_id' => $state['account_id'],
'related_account_id' => $state['related_account_id'],
'category_id' => $state['category_id'],
'merchant_name' => $state['merchant_name'],
'description' => '',
'is_installment' => $state['is_installment'],
'installment_months' => $state['installment_months'],
'installment_interest_rate' => $state['installment_interest_rate'],
'continue_after_save' => $state['continue_after_save'],
'keep_last_values' => $state['keep_last_values'],
'transaction_date' => current_date_ymd(),
'amount' => '',
];
}
function get_remembered_transaction_form_state(): ?array
{
return $_SESSION['tx_create_last_form'] ?? null;
}
function clear_remembered_transaction_form_state(): void
{
unset($_SESSION['tx_create_last_form']);
}
/**
* 기본값 자동 저장용 데이터 만들기
*/
function make_default_payload_from_form(array $state): array
{
$payload = [
'default_account_id' => 0,
'default_card_account_id' => 0,
'default_income_category_id' => 0,
'default_expense_category_id' => 0,
'default_transfer_category_id' => 0,
'default_card_payment_category_id' => 0,
'keep_last_values' => $state['keep_last_values'] ?? 1,
'continue_after_save' => $state['continue_after_save'] ?? 1,
];
$type = $state['transaction_type'] ?? 'expense';
if ($type === 'income') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_income_category_id'] = $state['category_id'];
} elseif ($type === 'expense') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_expense_category_id'] = $state['category_id'];
$payload['default_card_account_id'] = $state['account_id'];
} elseif ($type === 'transfer') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_transfer_category_id'] = $state['category_id'];
} elseif ($type === 'card_payment') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_card_payment_category_id'] = $state['category_id'];
}
return $payload;
}
+441
View File
@@ -0,0 +1,441 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/account_service.php';
require_once __DIR__ . '/installment_service.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/card_billing_service.php';
function build_transaction_fingerprint(
int $userId,
int $accountId,
?int $relatedAccountId,
int $categoryId,
string $transactionType,
float $amount,
string $transactionDate,
?string $merchantName,
?string $description
): string {
$raw = implode('|', [
$userId,
$accountId,
$relatedAccountId ?? 0,
$categoryId,
$transactionType,
number_format($amount, 2, '.', ''),
$transactionDate,
trim((string)$merchantName),
trim((string)$description),
]);
return hash('sha256', $raw);
}
function get_account_for_transaction(int $userId, int $accountId): ?array
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$accountId, $userId]);
$row = $stmt->fetch();
return $row ?: null;
}
function create_transaction(array $data, bool $skipIfDuplicate = false): bool
{
$pdo = db();
$pdo->beginTransaction();
try {
$fingerprint = build_transaction_fingerprint(
(int)$data['user_id'],
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
(int)$data['category_id'],
(string)$data['transaction_type'],
(float)$data['amount'],
(string)$data['transaction_date'],
$data['merchant_name'] ?? null,
$data['description'] ?? null
);
if ($skipIfDuplicate) {
$stmt = $pdo->prepare("
SELECT id
FROM transactions
WHERE user_id = ?
AND fingerprint = ?
LIMIT 1
");
$stmt->execute([$data['user_id'], $fingerprint]);
if ($stmt->fetch()) {
$pdo->commit();
return false;
}
}
$account = get_account_for_transaction(
(int)$data['user_id'],
(int)$data['account_id']
);
$billingYearMonth = null;
if ($account) {
$billingYearMonth = get_card_billing_year_month(
$account,
(string)$data['transaction_date']
);
}
$isInstallment = !empty($data['is_installment']) ? 1 : 0;
$installmentMonths = !empty($data['installment_months']) ? (int)$data['installment_months'] : null;
$installmentInterestRate = !empty($data['installment_interest_rate']) ? (float)$data['installment_interest_rate'] : 0.0;
$installmentInterestTotal =
isset($data['installment_interest_total']) &&
$data['installment_interest_total'] !== null &&
$data['installment_interest_total'] !== ''
? (float)$data['installment_interest_total']
: null;
$installmentTotalBilled =
isset($data['installment_total_billed']) &&
$data['installment_total_billed'] !== null &&
$data['installment_total_billed'] !== ''
? (float)$data['installment_total_billed']
: null;
if ($isInstallment && ($installmentMonths === null || $installmentMonths <= 1)) {
throw new RuntimeException('할부 개월 수가 올바르지 않습니다.');
}
if ($isInstallment) {
if ($installmentInterestTotal === null) {
$installmentInterestTotal = calculate_installment_interest_total(
(float)$data['amount'],
$installmentMonths,
$installmentInterestRate
);
}
$expectedTotal = round(
(float)$data['amount'] + (float)$installmentInterestTotal,
2
);
if ($installmentTotalBilled === null) {
$installmentTotalBilled = $expectedTotal;
}
if (round($installmentTotalBilled, 2) !== $expectedTotal) {
throw new RuntimeException('총 청구금액은 원금 + 할부이자와 같아야 합니다.');
}
} else {
$installmentMonths = null;
$installmentInterestRate = 0.0;
$installmentInterestTotal = 0.0;
$installmentTotalBilled = null;
}
$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,
billing_year_month,
merchant_name,
description,
related_account_id,
fingerprint
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$data['user_id'],
$data['account_id'],
$data['category_id'],
$data['transaction_type'],
$data['amount'],
$isInstallment,
$installmentMonths,
$installmentInterestRate,
$installmentInterestTotal,
$installmentTotalBilled,
$data['transaction_date'],
$billingYearMonth,
$data['merchant_name'],
$data['description'],
$data['related_account_id'],
$fingerprint,
]);
$transactionId = (int)$pdo->lastInsertId();
$pdo->commit();
if (
$data['transaction_type'] === 'expense' &&
$isInstallment === 1 &&
$installmentMonths !== null &&
$installmentMonths > 1
) {
create_installment_schedule(
(int)$data['user_id'],
$transactionId,
(int)$data['account_id'],
(float)$data['amount'],
$installmentMonths,
(string)$data['transaction_date'],
$installmentInterestRate,
$installmentInterestTotal,
$installmentTotalBilled,
$installmentInterestRate > 0 ? 'fixed_total' : 'none'
);
}
if (
$data['transaction_type'] === 'card_payment' &&
!empty($data['related_account_id'])
) {
$yearMonth = date('Y-m', strtotime((string)$data['transaction_date']));
$processedCount = mark_installment_month_billed_for_card_payment(
(int)$data['user_id'],
(int)$data['related_account_id'],
$yearMonth
);
if ($processedCount > 0) {
set_flash_message(
'success',
$yearMonth . ' 할부 청구 회차 ' . intvalf($processedCount) . '건 자동 처리'
);
}
}
recalculate_account_balance((int)$data['account_id']);
if (!empty($data['related_account_id'])) {
recalculate_account_balance((int)$data['related_account_id']);
}
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function update_transaction(int $transactionId, int $userId, array $data): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT account_id, related_account_id
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$transactionId, $userId]);
$old = $stmt->fetch();
if (!$old) {
throw new RuntimeException('거래를 찾을 수 없습니다.');
}
$fingerprint = build_transaction_fingerprint(
$userId,
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
(int)$data['category_id'],
(string)$data['transaction_type'],
(float)$data['amount'],
(string)$data['transaction_date'],
$data['merchant_name'] ?? null,
$data['description'] ?? null
);
$account = get_account_for_transaction(
$userId,
(int)$data['account_id']
);
$billingYearMonth = null;
if ($account) {
$billingYearMonth = get_card_billing_year_month(
$account,
(string)$data['transaction_date']
);
}
$isInstallment = !empty($data['is_installment']) ? 1 : 0;
$installmentMonths = !empty($data['installment_months']) ? (int)$data['installment_months'] : null;
$installmentInterestRate = !empty($data['installment_interest_rate']) ? (float)$data['installment_interest_rate'] : 0.0;
$stmt = $pdo->prepare("
UPDATE transactions
SET
account_id = ?,
category_id = ?,
transaction_type = ?,
amount = ?,
is_installment = ?,
installment_months = ?,
installment_interest_rate = ?,
installment_interest_total = ?,
installment_total_billed = ?,
transaction_date = ?,
billing_year_month = ?,
merchant_name = ?,
description = ?,
related_account_id = ?,
fingerprint = ?
WHERE id = ?
AND user_id = ?
");
$stmt->execute([
$data['account_id'],
$data['category_id'],
$data['transaction_type'],
$data['amount'],
$isInstallment,
$installmentMonths,
$installmentInterestRate,
$data['installment_interest_total'] ?? 0,
$data['installment_total_billed'] ?? null,
$data['transaction_date'],
$billingYearMonth,
$data['merchant_name'],
$data['description'],
$data['related_account_id'],
$fingerprint,
$transactionId,
$userId
]);
delete_installment_by_transaction_id($transactionId);
if (
$data['transaction_type'] === 'expense' &&
$isInstallment === 1 &&
$installmentMonths !== null &&
$installmentMonths > 1
) {
$interestTotal = calculate_installment_interest_total(
(float)$data['amount'],
$installmentMonths,
$installmentInterestRate
);
$totalBilled = round(
(float)$data['amount'] + (float)$interestTotal,
2
);
create_installment_schedule(
$userId,
$transactionId,
(int)$data['account_id'],
(float)$data['amount'],
$installmentMonths,
(string)$data['transaction_date'],
$installmentInterestRate,
$interestTotal,
$totalBilled,
$installmentInterestRate > 0 ? 'fixed_total' : 'none'
);
}
$pdo->commit();
$recalcIds = array_unique(array_filter([
(int)$old['account_id'],
!empty($old['related_account_id']) ? (int)$old['related_account_id'] : null,
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
]));
foreach ($recalcIds as $id) {
recalculate_account_balance((int)$id);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function delete_transaction(int $transactionId, int $userId): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT account_id, related_account_id
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$transactionId, $userId]);
$row = $stmt->fetch();
if (!$row) {
throw new RuntimeException('거래를 찾을 수 없습니다.');
}
delete_installment_by_transaction_id($transactionId);
$stmt = $pdo->prepare("
DELETE FROM transactions
WHERE id = ?
AND user_id = ?
");
$stmt->execute([$transactionId, $userId]);
$pdo->commit();
recalculate_account_balance((int)$row['account_id']);
if (!empty($row['related_account_id'])) {
recalculate_account_balance((int)$row['related_account_id']);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
+6
View File
@@ -0,0 +1,6 @@
</div>
<script src="/assets/vendor/bootstrap.bundle.min.js"></script>
<script src="https://chaegeon.com/log/logger.js"></script>
<script src="/assets/pwa.js"></script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<?php require_once __DIR__ . '/../lib/helpers.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리</title>
<meta name="description" content="수입·지출 가계부, 계좌·카드 관리, 대출 상환 일정, 카드 할부 청구, 자동 분류 규칙까지 한 번에 관리하는 개인 금융 통합 서비스 Financial.">
<meta name="keywords" content="가계부, 자산관리, 개인재무, 대출관리, 할부관리, 카드관리, 수입지출, 금융관리, Financial">
<meta name="author" content="Financial">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#0b2a66">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Financial">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<link rel="shortcut icon" href="/favicon.png?v=2">
<link rel="apple-touch-icon" href="/favicon.png?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Financial">
<meta property="og:title" content="Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리">
<meta property="og:description" content="계좌, 카드, 가계부, 대출, 할부를 한 곳에서 쉽고 체계적으로 관리하세요.">
<meta property="og:image" content="https://seo.chaegeon.com/favicon.png">
<meta property="og:url" content="https://seo.chaegeon.com/">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Financial">
<meta name="twitter:description" content="개인 금융 통합 관리 서비스">
<meta name="twitter:image" content="https://seo.chaegeon.com/favicon.png">
<link href="/assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="/assets/app.css" rel="stylesheet">
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
<script src="/assets/vendor/chart.umd.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg sticky-top">
<div class="container">
<a class="navbar-brand" href="/dashboard.php">Financial</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#topnav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="topnav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="/dashboard.php">대시보드</a></li>
<li class="nav-item"><a class="nav-link" href="/accounts.php">계좌/카드</a></li>
<li class="nav-item"><a class="nav-link" href="/transactions.php">거래내역</a></li>
<li class="nav-item"><a class="nav-link" href="/installments.php">할부내역</a></li>
<li class="nav-item"><a class="nav-link" href="/transaction_create.php">거래등록</a></li>
<li class="nav-item"><a class="nav-link" href="/categories.php">카테고리</a></li>
<li class="nav-item"><a class="nav-link" href="/merchant_rules.php">자동분류</a></li>
<li class="nav-item"><a class="nav-link" href="/recurring.php">고정거래</a></li>
<li class="nav-item"><a class="nav-link" href="/loans.php">대출</a></li>
</ul>
<div class="d-flex align-items-center gap-3">
<span class="text-secondary small"><?= h($_SESSION['username'] ?? '') ?></span>
<a class="btn btn-outline-secondary btn-sm" href="/logout.php">로그아웃</a>
</div>
</div>
</div>
</nav>
<nav class="app-tabbar" aria-label="주요 메뉴">
<a href="/dashboard.php" class="app-tabbar-item">대시보드</a>
<a href="/transaction_create.php" class="app-tabbar-item app-tabbar-primary">거래등록</a>
<a href="/transactions.php" class="app-tabbar-item">거래내역</a>
<a href="/accounts.php" class="app-tabbar-item">계좌</a>
<a href="/loans.php" class="app-tabbar-item">대출</a>
</nav>
<div class="container py-4">
<?php $flash = function_exists('get_flash_message') ? get_flash_message() : null; ?>
<?php if ($flash): ?>
<div class="alert alert-<?= h($flash['type']) ?> mb-4">
<?= h($flash['message']) ?>
</div>
<?php endif; ?>
+329
View File
@@ -0,0 +1,329 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$accountType = $_POST['account_type'] ?? '';
$institutionName = trim($_POST['institution_name'] ?? '');
$accountName = trim($_POST['account_name'] ?? '');
$openingBalance = (float)str_replace(',', '', (string)($_POST['opening_balance'] ?? 0));
$cardKind = $_POST['card_kind'] ?? null;
$billingDay = !empty($_POST['billing_day']) ? (int)$_POST['billing_day'] : null;
$paymentDay = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
$useCreditGracePeriod = !empty($_POST['use_credit_grace_period']) ? 1 : 0;
$billingCycleMemo = trim($_POST['billing_cycle_memo'] ?? '');
$statementStartMonthOffset = isset($_POST['statement_start_month_offset']) && $_POST['statement_start_month_offset'] !== ''
? (int)$_POST['statement_start_month_offset']
: null;
$statementStartDay = !empty($_POST['statement_start_day']) ? (int)$_POST['statement_start_day'] : null;
$statementEndMonthOffset = isset($_POST['statement_end_month_offset']) && $_POST['statement_end_month_offset'] !== ''
? (int)$_POST['statement_end_month_offset']
: null;
$statementEndDay = !empty($_POST['statement_end_day']) ? (int)$_POST['statement_end_day'] : null;
if (!in_array($accountType, ['bank', 'card', 'cash', 'other'], true)) {
throw new RuntimeException('계정 유형이 올바르지 않습니다.');
}
if ($institutionName === '' || $accountName === '') {
throw new RuntimeException('기관명과 계좌명을 입력하세요.');
}
if ($accountType !== 'card') {
$cardKind = null;
$billingDay = null;
$paymentDay = null;
$useCreditGracePeriod = 0;
$billingCycleMemo = null;
$statementStartMonthOffset = null;
$statementStartDay = null;
$statementEndMonthOffset = null;
$statementEndDay = null;
} else {
if (!in_array($cardKind, ['credit', 'check'], true)) {
throw new RuntimeException('카드 종류를 선택하세요.');
}
if ($cardKind === 'check') {
$billingDay = null;
$paymentDay = null;
$useCreditGracePeriod = 0;
$billingCycleMemo = '즉시출금';
$statementStartMonthOffset = null;
$statementStartDay = null;
$statementEndMonthOffset = null;
$statementEndDay = null;
}
if ($cardKind === 'credit') {
if ($paymentDay === null || $paymentDay < 1 || $paymentDay > 31) {
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
}
if ($useCreditGracePeriod) {
if ($statementStartMonthOffset === null || $statementEndMonthOffset === null) {
throw new RuntimeException('사용기간 기준 월을 선택하세요.');
}
if ($statementStartDay === null || $statementStartDay < 1 || $statementStartDay > 31) {
throw new RuntimeException('사용기간 시작일은 1~31 사이여야 합니다.');
}
if ($statementEndDay === null || $statementEndDay < 1 || $statementEndDay > 31) {
throw new RuntimeException('사용기간 종료일은 1~31 사이여야 합니다.');
}
} else {
$statementStartMonthOffset = null;
$statementStartDay = null;
$statementEndMonthOffset = null;
$statementEndDay = null;
}
if ($billingCycleMemo === '') {
$billingCycleMemo = null;
}
}
}
$stmt = $pdo->prepare("
INSERT INTO accounts
(
user_id,
account_type,
institution_name,
account_name,
opening_balance,
current_balance,
is_active,
card_kind,
billing_day,
payment_day,
use_credit_grace_period,
statement_start_month_offset,
statement_start_day,
statement_end_month_offset,
statement_end_day,
billing_cycle_memo
)
VALUES
(?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$uid,
$accountType,
$institutionName,
$accountName,
$openingBalance,
$openingBalance,
$cardKind,
$billingDay,
$paymentDay,
$useCreditGracePeriod,
$statementStartMonthOffset,
$statementStartDay,
$statementEndMonthOffset,
$statementEndDay,
$billingCycleMemo
]);
redirect('/accounts.php');
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>계좌 / 카드 추가</h2>
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3" id="accountCreateForm">
<div class="col-12 col-md-4">
<label class="form-label">유형</label>
<select name="account_type" id="account_type" class="form-select" required>
<option value="bank">은행계좌</option>
<option value="card">카드</option>
<option value="cash">현금</option>
<option value="other">기타</option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">기관명</label>
<input type="text" name="institution_name" class="form-control" placeholder="예: IBK, 우리, 농협" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">계좌명</label>
<input type="text" name="account_name" class="form-control" placeholder="예: IBK 신용카드 / IBK 체크카드" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">시작 잔액</label>
<input type="text" name="opening_balance" id="opening_balance" class="form-control" value="0" inputmode="numeric" required>
</div>
<div class="col-12" id="card_setting_wrap" style="display:none;">
<div class="loan-create-highlight">
<div class="title">카드 설정</div>
<div class="desc">
카드사마다 신용공여기간이 다르므로 직접 설정 가능합니다.
</div>
<div class="row g-3 mt-1">
<div class="col-12 col-md-3">
<label class="form-label">카드 종류</label>
<select name="card_kind" id="card_kind" class="form-select">
<option value="">선택하세요</option>
<option value="credit">신용카드</option>
<option value="check">체크카드</option>
</select>
</div>
<div class="col-12 col-md-3 credit-only">
<label class="form-label">납부일</label>
<input type="number" name="payment_day" class="form-control" min="1" max="31" placeholder="예: 25">
</div>
<div class="col-12 col-md-3 credit-only d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="use_credit_grace_period" id="use_credit_grace_period" value="1" checked>
<label class="form-check-label">신용공여기간 계산 사용</label>
</div>
</div>
<div class="col-12 credit-only">
<hr>
<div class="fw-bold mb-2">청구월 기준 사용기간</div>
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">시작 월 기준</label>
<select name="statement_start_month_offset" class="form-select">
<option value="-2">전전월</option>
<option value="-1" selected>전월</option>
<option value="0">당월</option>
<option value="1">익월</option>
</select>
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">시작일</label>
<input type="number" name="statement_start_day" class="form-control" min="1" max="31" placeholder="예: 11">
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">종료 월 기준</label>
<select name="statement_end_month_offset" class="form-select">
<option value="-2">전전월</option>
<option value="-1">전월</option>
<option value="0" selected>당월</option>
<option value="1">익월</option>
</select>
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">종료일</label>
<input type="number" name="statement_end_day" class="form-control" min="1" max="31" placeholder="예: 10">
</div>
<div class="col-12 credit-only">
<label class="form-label">메모</label>
<input type="text" name="billing_cycle_memo" class="form-control" placeholder="예: 전월 11일 ~ 당월 10일 사용분">
</div>
<div class="col-12 check-only" style="display:none;">
<div class="alert alert-secondary mb-0">
체크카드는 즉시출금 기준으로 처리됩니다.
</div>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-primary">추가</button>
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
</div>
</form>
</div>
</div>
<script>
(function () {
const accountTypeEl = document.getElementById('account_type');
const cardWrapEl = document.getElementById('card_setting_wrap');
const cardKindEl = document.getElementById('card_kind');
const useGraceEl = document.getElementById('use_credit_grace_period');
const creditOnlyEls = document.querySelectorAll('.credit-only');
const creditPeriodOnlyEls = document.querySelectorAll('.credit-period-only');
const checkOnlyEls = document.querySelectorAll('.check-only');
const openingBalanceEl = document.getElementById('opening_balance');
function comma(v) {
v = String(v).replace(/,/g, '').replace(/[^\d.-]/g, '');
if (!v) return '';
return Number(v).toLocaleString('ko-KR');
}
function show(nodes, on) {
nodes.forEach(el => el.style.display = on ? '' : 'none');
}
function refresh() {
const isCard = accountTypeEl.value === 'card';
const isCredit = cardKindEl.value === 'credit';
const isCheck = cardKindEl.value === 'check';
const useGrace = useGraceEl.checked;
cardWrapEl.style.display = isCard ? '' : 'none';
show(creditOnlyEls, isCredit);
show(checkOnlyEls, isCheck);
show(creditPeriodOnlyEls, isCredit && useGrace);
}
openingBalanceEl.addEventListener('input', function () {
this.value = comma(this.value);
});
document.getElementById('accountCreateForm').addEventListener('submit', function () {
openingBalanceEl.value = openingBalanceEl.value.replace(/,/g, '');
});
accountTypeEl.addEventListener('change', refresh);
cardKindEl.addEventListener('change', refresh);
useGraceEl.addEventListener('change', refresh);
refresh();
})();
</script>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+362
View File
@@ -0,0 +1,362 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/account_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$id = (int)($_GET['id'] ?? 0);
$error = '';
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE id = ? AND user_id = ?");
$stmt->execute([$id, $uid]);
$account = $stmt->fetch();
if (!$account) {
exit('계좌를 찾을 수 없습니다.');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$accountType = $_POST['account_type'] ?? '';
$institutionName = trim($_POST['institution_name'] ?? '');
$accountName = trim($_POST['account_name'] ?? '');
$openingBalance = (float)str_replace(',', '', (string)($_POST['opening_balance'] ?? 0));
$isActive = isset($_POST['is_active']) ? 1 : 0;
$cardKind = $_POST['card_kind'] ?? null;
$billingDay = !empty($_POST['billing_day']) ? (int)$_POST['billing_day'] : null;
$paymentDay = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
$useCreditGracePeriod = !empty($_POST['use_credit_grace_period']) ? 1 : 0;
$billingCycleMemo = trim($_POST['billing_cycle_memo'] ?? '');
$statementStartMonthOffset = isset($_POST['statement_start_month_offset']) && $_POST['statement_start_month_offset'] !== ''
? (int)$_POST['statement_start_month_offset']
: null;
$statementStartDay = !empty($_POST['statement_start_day']) ? (int)$_POST['statement_start_day'] : null;
$statementEndMonthOffset = isset($_POST['statement_end_month_offset']) && $_POST['statement_end_month_offset'] !== ''
? (int)$_POST['statement_end_month_offset']
: null;
$statementEndDay = !empty($_POST['statement_end_day']) ? (int)$_POST['statement_end_day'] : null;
if (!in_array($accountType, ['bank', 'card', 'cash', 'other'], true)) {
throw new RuntimeException('계정 유형이 올바르지 않습니다.');
}
if ($institutionName === '' || $accountName === '') {
throw new RuntimeException('기관명과 계좌명을 입력하세요.');
}
if ($accountType !== 'card') {
$cardKind = null;
$billingDay = null;
$paymentDay = null;
$useCreditGracePeriod = 0;
$billingCycleMemo = null;
$statementStartMonthOffset = null;
$statementStartDay = null;
$statementEndMonthOffset = null;
$statementEndDay = null;
} else {
if (!in_array($cardKind, ['credit', 'check'], true)) {
throw new RuntimeException('카드 종류를 선택하세요.');
}
if ($cardKind === 'check') {
$billingDay = null;
$paymentDay = null;
$useCreditGracePeriod = 0;
$billingCycleMemo = '즉시출금';
$statementStartMonthOffset = null;
$statementStartDay = null;
$statementEndMonthOffset = null;
$statementEndDay = null;
}
if ($cardKind === 'credit') {
if ($paymentDay === null || $paymentDay < 1 || $paymentDay > 31) {
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
}
if ($useCreditGracePeriod) {
if ($statementStartMonthOffset === null || $statementEndMonthOffset === null) {
throw new RuntimeException('사용기간의 시작/종료 월 기준을 선택하세요.');
}
if ($statementStartDay === null || $statementStartDay < 1 || $statementStartDay > 31) {
throw new RuntimeException('사용기간 시작일은 1~31 사이여야 합니다.');
}
if ($statementEndDay === null || $statementEndDay < 1 || $statementEndDay > 31) {
throw new RuntimeException('사용기간 종료일은 1~31 사이여야 합니다.');
}
} else {
$statementStartMonthOffset = null;
$statementStartDay = null;
$statementEndMonthOffset = null;
$statementEndDay = null;
}
if ($billingCycleMemo === '') {
$billingCycleMemo = null;
}
if ($billingDay !== null && ($billingDay < 1 || $billingDay > 31)) {
throw new RuntimeException('구형 결제기준일은 1~31 사이여야 합니다.');
}
}
}
$stmt = $pdo->prepare("
UPDATE accounts
SET
account_type = ?,
institution_name = ?,
account_name = ?,
opening_balance = ?,
is_active = ?,
card_kind = ?,
billing_day = ?,
payment_day = ?,
use_credit_grace_period = ?,
statement_start_month_offset = ?,
statement_start_day = ?,
statement_end_month_offset = ?,
statement_end_day = ?,
billing_cycle_memo = ?
WHERE id = ?
AND user_id = ?
");
$stmt->execute([
$accountType,
$institutionName,
$accountName,
$openingBalance,
$isActive,
$cardKind,
$billingDay,
$paymentDay,
$useCreditGracePeriod,
$statementStartMonthOffset,
$statementStartDay,
$statementEndMonthOffset,
$statementEndDay,
$billingCycleMemo,
$id,
$uid
]);
recalculate_account_balance($id);
redirect('/accounts.php');
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>계좌 / 카드 수정</h2>
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3" id="accountEditForm">
<div class="col-12 col-md-4">
<label class="form-label">유형</label>
<select name="account_type" id="account_type" class="form-select" required>
<?php foreach (['bank'=>'은행계좌','card'=>'카드','cash'=>'현금','other'=>'기타'] as $k => $v): ?>
<option value="<?= $k ?>" <?= $account['account_type'] === $k ? 'selected' : '' ?>><?= $v ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">기관명</label>
<input type="text" name="institution_name" class="form-control" value="<?= h($account['institution_name']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">계좌명</label>
<input type="text" name="account_name" class="form-control" value="<?= h($account['account_name']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">시작 잔액</label>
<input type="text" name="opening_balance" id="opening_balance" class="form-control" inputmode="numeric" value="<?= h(money_plain($account['opening_balance'])) ?>" required>
</div>
<div class="col-12 col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" <?= $account['is_active'] ? 'checked' : '' ?>>
<label class="form-check-label" for="is_active">활성</label>
</div>
</div>
<div class="col-12" id="card_setting_wrap" style="display:none;">
<div class="loan-create-highlight">
<div class="title">카드 설정</div>
<div class="desc">
카드사/결제일마다 신용공여기간이 다르므로 사용기간을 직접 설정합니다.
</div>
<div class="row g-3 mt-1">
<div class="col-12 col-md-3">
<label class="form-label">카드 종류</label>
<select name="card_kind" id="card_kind" class="form-select">
<option value="">선택하세요</option>
<option value="credit" <?= ($account['card_kind'] ?? '') === 'credit' ? 'selected' : '' ?>>신용카드</option>
<option value="check" <?= ($account['card_kind'] ?? '') === 'check' ? 'selected' : '' ?>>체크카드</option>
</select>
</div>
<div class="col-12 col-md-3 credit-only">
<label class="form-label">납부일</label>
<input type="number" name="payment_day" id="payment_day" class="form-control" min="1" max="31" value="<?= h((string)($account['payment_day'] ?? '')) ?>" placeholder="예: 25">
</div>
<div class="col-12 col-md-3 credit-only d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="use_credit_grace_period" id="use_credit_grace_period" value="1" <?= !empty($account['use_credit_grace_period']) ? 'checked' : '' ?>>
<label class="form-check-label" for="use_credit_grace_period">신용공여기간 계산 사용</label>
</div>
</div>
<div class="col-12 credit-only">
<hr>
<div class="fw-bold mb-2">청구월 기준 사용기간</div>
<div class="form-text mb-3">
예: 결제일 25일 기준 IBK는 “전월 11일 ~ 당월 10일 사용분” → 시작 월 기준 전월 / 시작일 11 / 종료 월 기준 당월 / 종료일 10
</div>
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">시작 월 기준</label>
<select name="statement_start_month_offset" class="form-select">
<?php foreach ([-2=>'전전월', -1=>'전월', 0=>'당월', 1=>'익월'] as $k => $v): ?>
<option value="<?= $k ?>" <?= (string)($account['statement_start_month_offset'] ?? '') === (string)$k ? 'selected' : '' ?>><?= $v ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">시작일</label>
<input type="number" name="statement_start_day" class="form-control" min="1" max="31" value="<?= h((string)($account['statement_start_day'] ?? '')) ?>" placeholder="예: 11">
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">종료 월 기준</label>
<select name="statement_end_month_offset" class="form-select">
<?php foreach ([-2=>'전전월', -1=>'전월', 0=>'당월', 1=>'익월'] as $k => $v): ?>
<option value="<?= $k ?>" <?= (string)($account['statement_end_month_offset'] ?? '') === (string)$k ? 'selected' : '' ?>><?= $v ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3 credit-period-only">
<label class="form-label">종료일</label>
<input type="number" name="statement_end_day" class="form-control" min="1" max="31" value="<?= h((string)($account['statement_end_day'] ?? '')) ?>" placeholder="예: 10">
</div>
<div class="col-12 credit-only">
<label class="form-label">신용공여기간 메모</label>
<input type="text" name="billing_cycle_memo" class="form-control" value="<?= h($account['billing_cycle_memo'] ?? '') ?>" placeholder="예: 전월 11일 ~ 당월 10일 사용분">
</div>
<div class="col-12 credit-only">
<div class="alert alert-info mb-0">
결제일 25일 기준 예시:
IBK 신용카드 = 전월 11일 ~ 당월 10일,
우리카드 = 전월 12일 ~ 당월 11일.
신규 카드사는 카드사 안내표를 보고 직접 입력하면 됩니다.
</div>
</div>
<div class="col-12 check-only" style="display:none;">
<div class="alert alert-secondary mb-0">
체크카드는 신용공여기간 없이 거래월 기준으로 반영됩니다.
</div>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button class="btn btn-primary">저장</button>
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
</div>
</form>
</div>
</div>
<script>
(function () {
const accountTypeEl = document.getElementById('account_type');
const cardWrapEl = document.getElementById('card_setting_wrap');
const cardKindEl = document.getElementById('card_kind');
const creditOnlyEls = document.querySelectorAll('.credit-only');
const creditPeriodOnlyEls = document.querySelectorAll('.credit-period-only');
const checkOnlyEls = document.querySelectorAll('.check-only');
const useGraceEl = document.getElementById('use_credit_grace_period');
const openingBalanceEl = document.getElementById('opening_balance');
function formatWithComma(value) {
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.-]/g, '');
if (normalized === '' || normalized === '-') return normalized;
const negative = normalized.startsWith('-');
const raw = negative ? normalized.slice(1) : normalized;
const parts = raw.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (negative ? '-' : '') + parts.join('.');
}
function setDisplay(nodes, visible) {
nodes.forEach(el => el.style.display = visible ? '' : 'none');
}
function toggleCardSettings() {
const isCard = accountTypeEl.value === 'card';
const isCredit = cardKindEl.value === 'credit';
const isCheck = cardKindEl.value === 'check';
const useGrace = useGraceEl && useGraceEl.checked;
cardWrapEl.style.display = isCard ? '' : 'none';
if (!isCard) {
return;
}
setDisplay(creditOnlyEls, isCredit);
setDisplay(checkOnlyEls, isCheck);
setDisplay(creditPeriodOnlyEls, isCredit && useGrace);
}
openingBalanceEl.addEventListener('input', function () {
this.value = formatWithComma(this.value);
});
document.getElementById('accountEditForm').addEventListener('submit', function () {
openingBalanceEl.value = openingBalanceEl.value.replace(/,/g, '');
});
accountTypeEl.addEventListener('change', toggleCardSettings);
cardKindEl.addEventListener('change', toggleCardSettings);
if (useGraceEl) {
useGraceEl.addEventListener('change', toggleCardSettings);
}
toggleCardSettings();
})();
</script>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+174
View File
@@ -0,0 +1,174 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/card_billing_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$ym = $_GET['ym'] ?? date('Y-m');
$nextYm = date('Y-m', strtotime($ym . '-01 +1 month'));
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE user_id = ?
ORDER BY FIELD(account_type, 'bank', 'card', 'cash', 'other'), id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$cardDueMap = [];
$stmt = $pdo->prepare("
SELECT
t.account_id,
t.billing_year_month,
COALESCE(SUM(t.amount),0) total_amount
FROM transactions t
JOIN accounts a ON a.id=t.account_id
WHERE t.user_id=?
AND t.transaction_type='expense'
AND a.account_type='card'
AND a.card_kind='credit'
AND COALESCE(t.is_installment,0)=0
AND t.billing_year_month IN (?,?)
GROUP BY t.account_id,t.billing_year_month
");
$stmt->execute([$uid,$ym,$nextYm]);
foreach ($stmt->fetchAll() as $row) {
$aid = (int)$row['account_id'];
$billYm = $row['billing_year_month'];
$cardDueMap[$aid][$billYm] =
($cardDueMap[$aid][$billYm] ?? 0) + (float)$row['total_amount'];
}
$stmt = $pdo->prepare("
SELECT
i.account_id AS account_id,
s.bill_year_month,
COALESCE(SUM(s.total_amount),0) total_amount
FROM installment_schedules s
JOIN installments i ON i.id=s.installment_id
WHERE i.user_id=?
AND s.is_billed=0
AND s.total_amount>0
AND s.bill_year_month IN (?,?)
GROUP BY i.account_id,s.bill_year_month
");
$stmt->execute([$uid,$ym,$nextYm]);
foreach ($stmt->fetchAll() as $row) {
$aid = (int)$row['account_id'];
$billYm = $row['bill_year_month'];
$cardDueMap[$aid][$billYm] =
($cardDueMap[$aid][$billYm] ?? 0) + (float)$row['total_amount'];
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>계좌 / 카드 현황</h2>
<div class="d-flex gap-2">
<form method="get" class="d-flex gap-2">
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
<button class="btn btn-outline-primary">조회</button>
</form>
<a href="/account_create.php" class="btn btn-primary">추가</a>
</div>
</div>
<div class="row g-3">
<?php foreach ($accounts as $account): ?>
<?php
$isCard = $account['account_type'] === 'card';
$cardKind = $account['card_kind'] ?? null;
$thisMonthDue = $cardDueMap[(int)$account['id']][$ym] ?? 0;
$nextMonthDue = $cardDueMap[(int)$account['id']][$nextYm] ?? 0;
?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card finance-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
<div>
<div class="eyebrow">
<?= h(blank_to_dash($account['institution_name'])) ?> / <?= h($account['account_type']) ?>
</div>
<div class="card-title-lg"><?= h($account['account_name']) ?></div>
</div>
<span class="badge <?= $account['is_active'] ? 'text-bg-success' : 'text-bg-secondary' ?>">
<?= $account['is_active'] ? 'active' : 'inactive' ?>
</span>
</div>
<?php if ($isCard): ?>
<div class="mb-3">
<span class="badge <?= $cardKind === 'credit' ? 'text-bg-warning' : 'text-bg-info' ?>">
<?= $cardKind === 'credit' ? '신용카드' : ($cardKind === 'check' ? '체크카드' : '카드') ?>
</span>
</div>
<?php if ($cardKind === 'credit'): ?>
<div class="row g-3">
<div class="col-6">
<div class="stat-label"><?= h($ym) ?> 청구예정</div>
<div class="stat-value text-danger"><?= won($thisMonthDue) ?></div>
</div>
<div class="col-6">
<div class="stat-label"><?= h($nextYm) ?> 이월예정</div>
<div class="stat-value"><?= won($nextMonthDue) ?></div>
</div>
<div class="col-6">
<div class="stat-label">결제기준일</div>
<div class="stat-value">
<?= !empty($account['billing_day']) ? intvalf($account['billing_day']) . '일' : '-' ?>
</div>
</div>
<div class="col-6">
<div class="stat-label">납부일</div>
<div class="stat-value">
<?= !empty($account['payment_day']) ? intvalf($account['payment_day']) . '일' : '-' ?>
</div>
</div>
</div>
<div class="small text-secondary mt-3">
<?= h(get_card_billing_label($account)) ?>
</div>
<?php else: ?>
<div class="stat-label">체크카드 잔액 기준</div>
<div class="stat-value text-primary"><?= won($account['current_balance']) ?></div>
<div class="small text-secondary mt-2">체크카드는 신용공여기간 없이 거래월 기준으로 반영됩니다.</div>
<?php endif; ?>
<?php else: ?>
<div class="stat-label">현재 잔액</div>
<div class="stat-value text-primary"><?= won($account['current_balance']) ?></div>
<?php endif; ?>
<div class="mt-3 d-flex gap-2">
<a href="/account_edit.php?id=<?= $account['id'] ?>" class="btn btn-sm btn-outline-primary">수정</a>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (!$accounts): ?>
<div class="col-12">
<div class="card finance-card">
<div class="card-body empty-state">등록된 계좌/카드가 없습니다.</div>
</div>
</div>
<?php endif; ?>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+61
View File
@@ -0,0 +1,61 @@
<?php
require_once __DIR__ . '/../../app/lib/auth.php';
require_once __DIR__ . '/../../app/lib/merchant_pattern_service.php';
check_auth();
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
$merchant = trim((string)($_GET['merchant_name'] ?? ''));
$transactionType = trim((string)($_GET['transaction_type'] ?? ''));
$allowedTypes = ['income', 'expense', 'transfer'];
if ($merchant === '' || $transactionType === '' || !in_array($transactionType, $allowedTypes, true)) {
echo json_encode([
'ok' => true,
'found' => false,
], JSON_UNESCAPED_UNICODE);
exit;
}
if (mb_strlen($merchant, 'UTF-8') < 2) {
echo json_encode([
'ok' => true,
'found' => false,
], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$suggested = suggest_category_from_merchant(user_id(), $merchant, $transactionType);
if (!$suggested) {
echo json_encode([
'ok' => true,
'found' => false,
], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode([
'ok' => true,
'found' => true,
'category_id' => (int)$suggested['category_id'],
'category_name' => (string)$suggested['category_name'],
'category_type' => (string)$suggested['category_type'],
'pattern_text' => (string)($suggested['pattern_text'] ?? ''),
'keyword' => (string)($suggested['keyword'] ?? $suggested['pattern_text'] ?? ''),
'match_type' => (string)($suggested['match_type'] ?? ''),
'priority' => (int)($suggested['priority'] ?? 0),
'confidence' => (float)($suggested['confidence'] ?? 0),
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'found' => false,
'message' => '자동추천 조회 실패',
], JSON_UNESCAPED_UNICODE);
}
+576
View File
@@ -0,0 +1,576 @@
:root {
--app-bg: #f3f6fb;
--card-bg: #ffffff;
--text-main: #07152b;
--text-sub: #51627a;
--border: #dbe4f0;
--line: #e7edf5;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--danger: #dc2626;
--success: #16a34a;
--warning: #f59e0b;
--shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--app-bg);
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 15px;
font-weight: 600;
}
.app-tabbar {
display: none;
}
main,
.container,
.container-fluid {
max-width: 1480px;
margin: 0 auto;
padding: 28px 22px 60px;
}
/* top nav */
.navbar,
header.navbar {
min-height: 52px;
background: #fff !important;
border-bottom: 1px solid var(--line);
padding: 0 20px;
}
.navbar-brand {
font-size: 22px;
font-weight: 900;
color: var(--text-main) !important;
margin-right: 26px;
}
.navbar-nav {
gap: 2px;
}
.nav-link {
color: #334155 !important;
font-size: 15px;
font-weight: 800;
padding: 15px 9px !important;
white-space: nowrap;
}
.nav-link:hover {
color: var(--primary) !important;
}
/* page head */
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.page-head h1,
.page-head h2 {
margin: 0;
font-size: 32px;
font-weight: 950;
letter-spacing: -0.05em;
}
/* card */
.card,
.finance-card,
.finance-summary-card {
border: 0 !important;
border-radius: 16px !important;
background: var(--card-bg);
box-shadow: var(--shadow);
}
.card-body {
padding: 24px !important;
}
.finance-summary-card .card-body {
padding: 26px !important;
}
.finance-summary-income {
background: linear-gradient(135deg, #dcfce7, #ffffff);
}
.finance-summary-expense {
background: linear-gradient(135deg, #fee2e2, #ffffff);
}
.finance-summary-loan {
background: linear-gradient(135deg, #fef3c7, #ffffff);
}
.finance-summary-net {
background: linear-gradient(135deg, #dbeafe, #ffffff);
}
/* text */
.eyebrow {
color: var(--text-sub);
font-size: 13px;
font-weight: 900;
margin-bottom: 8px;
}
.card-title-lg {
font-size: 24px;
font-weight: 950;
letter-spacing: -0.05em;
line-height: 1.25;
word-break: keep-all;
}
.card-title-sm {
font-size: 20px;
font-weight: 950;
letter-spacing: -0.04em;
line-height: 1.25;
}
.stat-label {
color: #334155;
font-size: 13px;
font-weight: 900;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 950;
letter-spacing: -0.04em;
line-height: 1.2;
}
.hero-value {
font-size: 34px;
font-weight: 950;
letter-spacing: -0.05em;
line-height: 1.15;
}
.small,
.form-text,
.text-secondary {
color: var(--text-sub) !important;
font-weight: 650;
}
/* amount colors */
.text-primary,
.amount-income {
color: #0b63ff !important;
}
.text-danger,
.amount-expense {
color: #c90000 !important;
}
.amount-card {
color: #7c3aed !important;
}
.amount-transfer {
color: var(--text-main);
}
/* forms */
.form-label {
color: #26364d;
font-size: 14px;
font-weight: 900;
margin-bottom: 8px;
}
.form-control,
.form-select {
min-height: 48px;
border-radius: 12px !important;
border: 1px solid #c9d5e6 !important;
color: var(--text-main);
font-weight: 700;
background-color: #fff;
}
.form-control:focus,
.form-select:focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 0.18rem rgba(37, 99, 235, 0.12) !important;
}
/* buttons */
.btn {
border-radius: 12px !important;
font-weight: 900 !important;
min-height: 42px;
padding: 9px 16px;
}
.btn-sm {
min-height: 34px;
padding: 6px 12px;
border-radius: 10px !important;
font-size: 13px;
}
.btn-primary {
background: var(--primary) !important;
border-color: var(--primary) !important;
}
.btn-primary:hover {
background: var(--primary-dark) !important;
}
.btn-outline-primary {
color: var(--primary) !important;
border-color: #adc6ff !important;
}
.btn-outline-danger {
color: var(--danger) !important;
border-color: #f5b5b5 !important;
}
/* badges */
.badge {
border-radius: 999px;
padding: 7px 11px;
font-size: 12px;
font-weight: 900;
}
.text-bg-success {
background: #dcfce7 !important;
color: #047857 !important;
}
.text-bg-warning {
background: #fef3c7 !important;
color: #92400e !important;
}
.text-bg-secondary {
background: #e5e7eb !important;
color: #374151 !important;
}
.text-bg-info {
background: #dbeafe !important;
color: #1d4ed8 !important;
}
/* tables */
.table {
margin-bottom: 0;
color: var(--text-main);
}
.table th {
color: #334155;
font-size: 13px;
font-weight: 950;
white-space: nowrap;
border-bottom: 1px solid var(--border) !important;
padding: 14px 10px !important;
}
.table td {
font-size: 15px;
font-weight: 650;
vertical-align: middle;
padding: 14px 10px !important;
border-bottom: 1px solid var(--line) !important;
}
.table-hover tbody tr:hover {
background: #f8fbff;
}
.mobile-scroll {
width: 100%;
overflow-x: auto;
}
.mobile-scroll table {
min-width: 900px;
}
/* progress */
.finance-progress,
.progress.finance-progress {
width: 100%;
height: 12px;
background: #e5edf7;
border-radius: 999px;
overflow: hidden;
}
.finance-progress .progress-bar,
.progress.finance-progress .progress-bar {
height: 100%;
background: linear-gradient(90deg, #2563eb, #60a5fa);
border-radius: 999px;
}
/* highlight */
.loan-create-highlight {
border: 1px solid #bfdbfe;
background: #eff6ff;
border-radius: 16px;
padding: 18px;
}
.loan-create-highlight .title {
color: #1d4ed8;
font-size: 15px;
font-weight: 950;
margin-bottom: 6px;
}
.loan-create-highlight .desc {
color: #475569;
font-size: 13px;
font-weight: 700;
}
/* alert */
.alert {
border: 0;
border-radius: 14px;
font-weight: 800;
box-shadow: var(--shadow);
}
/* empty */
.empty-state {
text-align: center;
color: var(--text-sub);
font-weight: 800;
padding: 42px 10px;
}
/* charts */
canvas {
min-height: 280px;
}
/* card link */
a.text-decoration-none .finance-card,
a .finance-card {
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
a.text-decoration-none:hover .finance-card,
a:hover .finance-card {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.1);
}
/* loan/installment cards */
.finance-card .row.g-3 {
row-gap: 18px !important;
}
.finance-card .stat-value {
word-break: keep-all;
}
/* long text */
.text-truncate-cell {
max-width: 220px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
td {
word-break: keep-all;
}
/* danger border */
.border-danger {
border: 1px solid rgba(220, 38, 38, 0.75) !important;
}
/* responsive */
@media (max-width: 1200px) {
.nav-link {
font-size: 14px;
padding-left: 6px !important;
padding-right: 6px !important;
}
.hero-value {
font-size: 30px;
}
.stat-value {
font-size: 22px;
}
}
@media (max-width: 768px) {
body {
padding-bottom: 76px;
}
main,
.container,
.container-fluid {
padding: 20px 14px 44px;
}
.page-head {
align-items: stretch;
}
.page-head h1,
.page-head h2 {
font-size: 28px;
}
.page-head > * {
width: 100%;
}
.page-head .d-flex,
.page-head form {
width: 100%;
}
.page-head .btn {
flex: 1;
}
.card-body {
padding: 18px !important;
}
.hero-value {
font-size: 28px;
}
.stat-value {
font-size: 21px;
}
.card-title-lg {
font-size: 21px;
}
.navbar {
overflow-x: auto;
white-space: nowrap;
}
.navbar-nav {
flex-direction: row;
}
.app-tabbar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1030;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
border-top: 1px solid var(--line);
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.08);
}
.app-tabbar-item {
display: flex;
min-height: 42px;
align-items: center;
justify-content: center;
border-radius: 10px;
color: #334155;
font-size: 12px;
font-weight: 900;
text-decoration: none;
white-space: nowrap;
}
.app-tabbar-primary {
background: var(--primary);
color: #fff;
}
.transaction-list-table {
min-width: 0 !important;
border-collapse: separate;
border-spacing: 0 12px;
}
.transaction-list-table thead {
display: none;
}
.transaction-list-table,
.transaction-list-table tbody,
.transaction-list-table tr,
.transaction-list-table td {
display: block;
width: 100%;
}
.transaction-list-table tr {
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
}
.transaction-list-table td {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
border-bottom: 0 !important;
padding: 6px 0 !important;
text-align: right !important;
word-break: break-word;
}
.transaction-list-table td::before {
content: attr(data-label);
flex: 0 0 78px;
color: var(--text-sub);
font-size: 12px;
font-weight: 900;
text-align: left;
}
.transaction-list-table td:last-child {
justify-content: flex-end;
padding-top: 12px !important;
}
.transaction-list-table td:last-child::before {
display: none;
}
}
+9
View File
@@ -0,0 +1,9 @@
(function () {
if (!('serviceWorker' in navigator)) {
return;
}
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function () {});
});
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+117
View File
@@ -0,0 +1,117 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$name = trim($_POST['name'] ?? '');
$type = $_POST['category_type'] ?? '';
$sort = (int)($_POST['sort_order'] ?? 0);
if ($name === '') {
throw new RuntimeException('카테고리명을 입력하세요.');
}
if (!in_array($type, ['income','expense','transfer'], true)) {
throw new RuntimeException('카테고리 유형이 올바르지 않습니다.');
}
$stmt = $pdo->prepare("
INSERT INTO categories (user_id, category_type, name, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
");
$stmt->execute([$uid, $type, $name, $sort]);
redirect('/categories.php');
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$stmt = $pdo->prepare("
SELECT *
FROM categories
WHERE user_id = ?
ORDER BY category_type, sort_order, id
");
$stmt->execute([$uid]);
$list = $stmt->fetchAll();
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>카테고리 관리</h2>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="row g-4">
<div class="col-12 col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="post" class="row g-3">
<div class="col-12">
<label class="form-label">유형</label>
<select name="category_type" class="form-select">
<option value="expense">지출</option>
<option value="income">수입</option>
<option value="transfer">이동</option>
</select>
</div>
<div class="col-12">
<label class="form-label">카테고리명</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="col-12">
<label class="form-label">정렬순서</label>
<input type="number" name="sort_order" class="form-control" value="0">
</div>
<div class="col-12">
<button class="btn btn-primary">추가</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-body mobile-scroll">
<table class="table mb-0">
<thead>
<tr>
<th>ID</th>
<th>유형</th>
<th>이름</th>
<th>정렬</th>
<th>활성</th>
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<tr>
<td><?= $row['id'] ?></td>
<td><?= h($row['category_type']) ?></td>
<td><?= h($row['name']) ?></td>
<td><?= $row['sort_order'] ?></td>
<td><?= $row['is_active'] ? 'Y' : 'N' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+498
View File
@@ -0,0 +1,498 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/installment_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$ym = $_GET['ym'] ?? date('Y-m');
$start = $ym . '-01';
$end = date('Y-m-t', strtotime($start));
$today = date('Y-m-d');
$nextYm = date('Y-m', strtotime($ym . '-01 +1 month'));
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'income' THEN amount ELSE 0 END), 0) AS income_total,
COALESCE(SUM(CASE WHEN transaction_type = 'expense' THEN amount ELSE 0 END), 0) AS expense_total,
COALESCE(SUM(CASE WHEN transaction_type = 'card_payment' THEN amount ELSE 0 END), 0) AS card_payment_total
FROM transactions
WHERE user_id = ?
AND transaction_date BETWEEN ? AND ?
");
$stmt->execute([$uid, $start, $end]);
$summary = $stmt->fetch();
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN account_type IN ('bank','cash','other') THEN current_balance ELSE 0 END), 0) AS liquid_assets
FROM accounts
WHERE user_id = ?
AND is_active = 1
");
$stmt->execute([$uid]);
$assetSummary = $stmt->fetch();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(t.amount), 0)
FROM transactions t
JOIN accounts a ON a.id = t.account_id
WHERE t.user_id = ?
AND t.transaction_type = 'expense'
AND a.account_type = 'card'
AND a.card_kind = 'credit'
AND t.billing_year_month = ?
AND COALESCE(t.is_installment, 0) = 0
");
$stmt->execute([$uid, $ym]);
$cardDueThisMonth = (float)$stmt->fetchColumn();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(t.amount), 0)
FROM transactions t
JOIN accounts a ON a.id = t.account_id
WHERE t.user_id = ?
AND t.transaction_type = 'expense'
AND a.account_type = 'card'
AND a.card_kind = 'credit'
AND t.billing_year_month = ?
AND COALESCE(t.is_installment, 0) = 0
");
$stmt->execute([$uid, $nextYm]);
$cardDueNextMonth = (float)$stmt->fetchColumn();
$installmentDueNextMonth = get_installment_due_this_month($uid, $nextYm);
$cardTotalDueNextMonth = $cardDueNextMonth + $installmentDueNextMonth;
$installmentDueThisMonth = get_installment_due_this_month($uid, $ym);
$installmentRemainingPrincipal = get_installment_remaining_principal($uid);
$installmentRemainingInterest = get_installment_remaining_interest($uid);
$installmentRemainingTotal = get_installment_remaining_total($uid);
$cardTotalDueThisMonth = $cardDueThisMonth + $installmentDueThisMonth;
$stmt = $pdo->prepare("
SELECT c.name, SUM(t.amount) AS total_amount
FROM transactions t
JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ?
AND t.transaction_type = 'expense'
AND t.transaction_date BETWEEN ? AND ?
GROUP BY c.id, c.name
ORDER BY total_amount DESC
LIMIT 8
");
$stmt->execute([$uid, $start, $end]);
$expenseByCategory = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT
DATE_FORMAT(transaction_date, '%d') AS day_num,
COALESCE(SUM(CASE WHEN transaction_type = 'expense' THEN amount ELSE 0 END), 0) AS daily_expense
FROM transactions
WHERE user_id = ?
AND transaction_date BETWEEN ? AND ?
GROUP BY DATE_FORMAT(transaction_date, '%Y-%m-%d'), DATE_FORMAT(transaction_date, '%d')
ORDER BY DATE_FORMAT(transaction_date, '%Y-%m-%d') ASC
");
$stmt->execute([$uid, $start, $end]);
$dailyRows = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE user_id = ?
AND is_active = 1
ORDER BY FIELD(account_type, 'bank', 'card', 'cash', 'other'), id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(scheduled_principal), 0) AS due_principal,
COALESCE(SUM(scheduled_interest), 0) AS due_interest,
COALESCE(SUM(scheduled_total), 0) AS due_total
FROM loan_schedules ls
JOIN loans l ON l.id = ls.loan_id
WHERE l.user_id = ?
AND ls.is_paid = 0
AND ls.due_date BETWEEN ? AND ?
");
$stmt->execute([$uid, $start, $end]);
$loanDue = $stmt->fetch();
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(scheduled_principal), 0) AS overdue_principal,
COALESCE(SUM(scheduled_interest), 0) AS overdue_interest,
COALESCE(SUM(scheduled_total), 0) AS overdue_total
FROM loan_schedules ls
JOIN loans l ON l.id = ls.loan_id
WHERE l.user_id = ?
AND ls.is_paid = 0
AND ls.due_date < ?
");
$stmt->execute([$uid, $today]);
$loanOverdue = $stmt->fetch();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(current_principal_balance), 0) AS remaining_principal
FROM loans
WHERE user_id = ?
AND status = 'active'
");
$stmt->execute([$uid]);
$loanPrincipalRow = $stmt->fetch();
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN ls.is_paid = 0 THEN ls.scheduled_interest ELSE 0 END), 0) AS remaining_interest,
COALESCE(SUM(CASE WHEN ls.is_paid = 0 THEN ls.scheduled_total ELSE 0 END), 0) AS remaining_total
FROM loan_schedules ls
JOIN loans l ON l.id = ls.loan_id
WHERE l.user_id = ?
AND l.status = 'active'
");
$stmt->execute([$uid]);
$loanRemaining = $stmt->fetch();
$stmt = $pdo->prepare("
SELECT
DATE_FORMAT(payment_date, '%d') AS day_num,
COALESCE(SUM(total_amount), 0) AS daily_loan_payment
FROM loan_payments
WHERE user_id = ?
AND payment_date BETWEEN ? AND ?
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d'), DATE_FORMAT(payment_date, '%d')
ORDER BY DATE_FORMAT(payment_date, '%Y-%m-%d') ASC
");
$stmt->execute([$uid, $start, $end]);
$loanPaymentRows = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT
l.loan_name,
l.lender_name,
ls.due_date,
ls.scheduled_principal,
ls.scheduled_interest,
ls.scheduled_total
FROM loan_schedules ls
JOIN loans l ON l.id = ls.loan_id
WHERE l.user_id = ?
AND ls.is_paid = 0
AND ls.due_date BETWEEN ? AND ?
ORDER BY ls.due_date ASC, l.id ASC
LIMIT 10
");
$stmt->execute([$uid, $start, $end]);
$loanUpcoming = $stmt->fetchAll();
$liquidAssets = (float)($assetSummary['liquid_assets'] ?? 0);
$incomeTotal = (float)($summary['income_total'] ?? 0);
$expenseTotal = (float)($summary['expense_total'] ?? 0);
$cardPaymentTotal = (float)($summary['card_payment_total'] ?? 0);
$loanDuePrincipal = (float)($loanDue['due_principal'] ?? 0);
$loanDueInterest = (float)($loanDue['due_interest'] ?? 0);
$loanDueTotal = (float)($loanDue['due_total'] ?? 0);
$loanOverduePrincipal = (float)($loanOverdue['overdue_principal'] ?? 0);
$loanOverdueInterest = (float)($loanOverdue['overdue_interest'] ?? 0);
$loanOverdueTotal = (float)($loanOverdue['overdue_total'] ?? 0);
$loanRemainingPrincipal = (float)($loanPrincipalRow['remaining_principal'] ?? 0);
$loanRemainingInterest = (float)($loanRemaining['remaining_interest'] ?? 0);
$loanRemainingTotal = (float)($loanRemaining['remaining_total'] ?? 0);
$netCashFlow = $incomeTotal - $expenseTotal - $loanDueTotal;
$categoryLabels = array_map(fn($r) => $r['name'], $expenseByCategory);
$categoryValues = array_map(fn($r) => (float)$r['total_amount'], $expenseByCategory);
$dayLabels = array_map(fn($r) => $r['day_num'] . '일', $dailyRows);
$dayValues = array_map(fn($r) => (float)$r['daily_expense'], $dailyRows);
$loanDayLabels = array_map(fn($r) => $r['day_num'] . '일', $loanPaymentRows);
$loanDayValues = array_map(fn($r) => (float)$r['daily_loan_payment'], $loanPaymentRows);
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>대시보드</h2>
<form method="get" class="d-flex gap-2">
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
<button class="btn btn-outline-primary">조회</button>
</form>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-summary-card finance-summary-income h-100">
<div class="card-body">
<div class="stat-label">이번 수입</div>
<div class="hero-value"><?= won($incomeTotal) ?></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-summary-card finance-summary-expense h-100">
<div class="card-body">
<div class="stat-label">이번 지출</div>
<div class="hero-value"><?= won($expenseTotal) ?></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-summary-card finance-summary-loan h-100">
<div class="card-body">
<div class="stat-label">이번 카드 총청구액</div>
<div class="hero-value"><?= won($cardTotalDueThisMonth) ?></div>
<div class="small text-secondary mt-2">
일반 <?= won($cardDueThisMonth) ?> · 할부 <?= won($installmentDueThisMonth) ?>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-summary-card finance-summary-net h-100">
<div class="card-body">
<div class="stat-label">이번 대출 납부 예정액</div>
<div class="hero-value"><?= won($loanDueTotal) ?></div>
<div class="small text-secondary mt-2">
원금 <?= won($loanDuePrincipal) ?> · 이자 <?= won($loanDueInterest) ?>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">유동자산 합계</div>
<div class="stat-value text-primary"><?= won($liquidAssets) ?></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">다음달 카드 예정액</div>
<div class="stat-value text-danger"><?= won($cardTotalDueNextMonth) ?></div>
<div class="small text-secondary mt-2">
일반 <?= won($cardDueNextMonth) ?> · 할부 <?= won($installmentDueNextMonth) ?>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">남은 할부 총액</div>
<div class="stat-value"><?= won($installmentRemainingTotal) ?></div>
<div class="small text-secondary mt-2">
원금 <?= won($installmentRemainingPrincipal) ?> · 이자 <?= won($installmentRemainingInterest) ?>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card finance-card h-100 border-danger">
<div class="card-body">
<div class="stat-label">대출 미납액</div>
<div class="stat-value text-danger"><?= won($loanOverdueTotal) ?></div>
<div class="small text-secondary mt-2">
원금 <?= won($loanOverduePrincipal) ?> · 이자 <?= won($loanOverdueInterest) ?>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">남은 대출 원금</div>
<div class="stat-value text-primary"><?= won($loanRemainingPrincipal) ?></div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">남은 대출 이자</div>
<div class="stat-value text-danger"><?= won($loanRemainingInterest) ?></div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">이번 순현금흐름</div>
<div class="stat-value <?= $netCashFlow >= 0 ? 'text-primary' : 'text-danger' ?>">
<?= won($netCashFlow) ?>
</div>
<div class="small text-secondary mt-2">수입 - 지출 - 대출예정</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<?php foreach ($accounts as $account): ?>
<div class="col-12 col-md-6 col-xl-4">
<a href="/transactions.php?ym=<?= h($ym) ?>&account_id=<?= (int)$account['id'] ?>" class="text-decoration-none">
<div class="card finance-card h-100">
<div class="card-body">
<div class="eyebrow"><?= h($account['institution_name']) ?> / <?= h($account['account_type']) ?></div>
<div class="card-title-sm"><?= h($account['account_name']) ?></div>
<div class="stat-value <?= $account['account_type'] === 'card' ? 'text-danger' : 'text-primary' ?>">
<?= won($account['current_balance']) ?>
</div>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-xl-4">
<div class="card finance-card h-100">
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">카테고리별 지출</div>
<div class="card-body pt-0">
<canvas id="categoryChart" style="min-height:280px;"></canvas>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card finance-card h-100">
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">일자별 지출</div>
<div class="card-body pt-0">
<canvas id="dailyChart" style="min-height:280px;"></canvas>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card finance-card h-100">
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">일자별 대출 납부</div>
<div class="card-body pt-0">
<canvas id="loanPaymentChart" style="min-height:280px;"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="card finance-card">
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">이번 대출 납부 예정</div>
<div class="card-body pt-0 mobile-scroll">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>대출명</th>
<th>기관</th>
<th>납부일</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
</tr>
</thead>
<tbody>
<?php foreach ($loanUpcoming as $row): ?>
<tr>
<td><?= h($row['loan_name']) ?></td>
<td><?= h($row['lender_name'] ?: '-') ?></td>
<td><?= ymd($row['due_date']) ?></td>
<td class="text-end"><?= won($row['scheduled_principal']) ?></td>
<td class="text-end text-danger"><?= won($row['scheduled_interest']) ?></td>
<td class="text-end fw-bold"><?= won($row['scheduled_total']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$loanUpcoming): ?>
<tr>
<td colspan="6" class="text-center text-secondary py-5">이번 대출 납부 예정이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const categoryLabels = <?= json_encode($categoryLabels, JSON_UNESCAPED_UNICODE) ?>;
const categoryValues = <?= json_encode($categoryValues, JSON_UNESCAPED_UNICODE) ?>;
const dayLabels = <?= json_encode($dayLabels, JSON_UNESCAPED_UNICODE) ?>;
const dayValues = <?= json_encode($dayValues, JSON_UNESCAPED_UNICODE) ?>;
const loanDayLabels = <?= json_encode($loanDayLabels, JSON_UNESCAPED_UNICODE) ?>;
const loanDayValues = <?= json_encode($loanDayValues, JSON_UNESCAPED_UNICODE) ?>;
new Chart(document.getElementById('categoryChart'), {
type: 'doughnut',
data: {
labels: categoryLabels,
datasets: [{ data: categoryValues }]
},
options: {
plugins: { legend: { position: 'bottom' } }
}
});
new Chart(document.getElementById('dailyChart'), {
type: 'bar',
data: {
labels: dayLabels,
datasets: [{
label: '지출',
data: dayValues,
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: true
}
});
new Chart(document.getElementById('loanPaymentChart'), {
type: 'bar',
data: {
labels: loanDayLabels,
datasets: [{
label: '대출 납부',
data: loanDayValues,
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: true
}
});
</script>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

+3
View File
@@ -0,0 +1,3 @@
<?php
header('Location: /dashboard.php');
exit;
+430
View File
@@ -0,0 +1,430 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$msg = '';
$ym = $_GET['ym'] ?? date('Y-m');
$accountId = (int)($_GET['account_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$mode = $_POST['mode'] ?? '';
require_once __DIR__ . '/../app/lib/installment_service.php';
if ($mode === 'mark_billed') {
$scheduleId = (int)($_POST['schedule_id'] ?? 0);
$stmt = $pdo->prepare("
SELECT s.installment_id
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE s.id = ?
AND i.user_id = ?
");
$stmt->execute([$scheduleId, $uid]);
$row = $stmt->fetch();
if (!$row) {
throw new RuntimeException('회차를 찾을 수 없습니다.');
}
$stmt = $pdo->prepare("
UPDATE installment_schedules s
JOIN installments i ON i.id = s.installment_id
SET s.is_billed = 1,
s.billed_at = NOW()
WHERE s.id = ?
AND i.user_id = ?
");
$stmt->execute([$scheduleId, $uid]);
recalculate_installment_status((int)$row['installment_id']);
$msg = '청구완료 처리되었습니다.';
}
if ($mode === 'mark_unbilled') {
$scheduleId = (int)($_POST['schedule_id'] ?? 0);
$stmt = $pdo->prepare("
SELECT s.installment_id
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE s.id = ?
AND i.user_id = ?
");
$stmt->execute([$scheduleId, $uid]);
$row = $stmt->fetch();
if (!$row) {
throw new RuntimeException('회차를 찾을 수 없습니다.');
}
$stmt = $pdo->prepare("
UPDATE installment_schedules s
JOIN installments i ON i.id = s.installment_id
SET s.is_billed = 0,
s.billed_at = NULL
WHERE s.id = ?
AND i.user_id = ?
");
$stmt->execute([$scheduleId, $uid]);
recalculate_installment_status((int)$row['installment_id']);
$msg = '청구완료 취소되었습니다.';
}
if ($mode === 'mark_month_all_billed' || $mode === 'mark_month_all_unbilled') {
$targetYm = trim($_POST['target_ym'] ?? '');
if ($targetYm === '') {
throw new RuntimeException('대상 월이 없습니다.');
}
$wantBilled = ($mode === 'mark_month_all_billed');
$fromState = $wantBilled ? 0 : 1;
$params = [$uid, $targetYm];
$sql = "
SELECT s.id, s.installment_id
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
JOIN transactions t ON t.id = i.transaction_id
WHERE i.user_id = ?
AND s.bill_year_month = ?
AND s.is_billed = ?
";
$params[] = $fromState;
if ($accountId > 0) {
$sql .= " AND i.account_id = ? ";
$params[] = $accountId;
}
if ($q !== '') {
$sql .= " AND (t.merchant_name LIKE ? OR t.description LIKE ?) ";
$like = '%' . $q . '%';
$params[] = $like;
$params[] = $like;
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
if ($rows) {
$scheduleIds = array_column($rows, 'id');
$installmentIds = array_values(array_unique(array_column($rows, 'installment_id')));
$placeholders = implode(',', array_fill(0, count($scheduleIds), '?'));
if ($wantBilled) {
$stmt = $pdo->prepare("
UPDATE installment_schedules
SET is_billed = 1,
billed_at = NOW()
WHERE id IN ($placeholders)
");
} else {
$stmt = $pdo->prepare("
UPDATE installment_schedules
SET is_billed = 0,
billed_at = NULL
WHERE id IN ($placeholders)
");
}
$stmt->execute($scheduleIds);
foreach ($installmentIds as $iid) {
recalculate_installment_status((int)$iid);
}
}
$msg = $wantBilled
? $targetYm . ' 조회건 전체 청구완료 처리되었습니다.'
: $targetYm . ' 조회건 전체 취소되었습니다.';
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$params = [$uid, $ym];
$where = [
"i.user_id = ?",
"s.bill_year_month = ?"
];
if ($accountId > 0) {
$where[] = "i.account_id = ?";
$params[] = $accountId;
}
if ($q !== '') {
$where[] = "(t.merchant_name LIKE ? OR t.description LIKE ? OR a.account_name LIKE ?)";
$like = '%' . $q . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$sql = "
SELECT
s.id AS schedule_id,
s.installment_id,
s.cycle_no,
s.bill_year_month,
s.principal_amount,
s.interest_amount,
s.total_amount,
s.is_billed,
s.billed_at,
i.installment_months,
i.annual_interest_rate,
a.account_name,
a.institution_name,
t.merchant_name,
t.description
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
JOIN accounts a ON a.id = i.account_id
JOIN transactions t ON t.id = i.transaction_id
WHERE " . implode(' AND ', $where) . "
ORDER BY s.is_billed ASC, a.account_name ASC, s.cycle_no ASC, s.id ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$list = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT id, account_name, institution_name
FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type = 'card'
ORDER BY id ASC
");
$stmt->execute([$uid]);
$cardAccounts = $stmt->fetchAll();
$summaryStmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN s.is_billed=0 THEN s.principal_amount ELSE 0 END),0) AS unbilled_principal,
COALESCE(SUM(CASE WHEN s.is_billed=0 THEN s.interest_amount ELSE 0 END),0) AS unbilled_interest,
COALESCE(SUM(CASE WHEN s.is_billed=0 THEN s.total_amount ELSE 0 END),0) AS unbilled_total,
COALESCE(SUM(CASE WHEN s.is_billed=1 THEN s.total_amount ELSE 0 END),0) AS billed_total
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
JOIN transactions t ON t.id = i.transaction_id
JOIN accounts a ON a.id = i.account_id
WHERE " . implode(' AND ', $where));
$summaryStmt->execute($params);
$summary = $summaryStmt->fetch();
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>할부 청구 관리</h2>
<div class="d-flex flex-wrap gap-2">
<a href="/installments.php" class="btn btn-outline-secondary">할부 목록</a>
</div>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="card finance-card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-12 col-md-3">
<label class="form-label">청구월</label>
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
</div>
<div class="col-12 col-md-3">
<label class="form-label">카드</label>
<select name="account_id" class="form-select">
<option value="0">전체</option>
<?php foreach ($cardAccounts as $acc): ?>
<option value="<?= $acc['id'] ?>" <?= $accountId === (int)$acc['id'] ? 'selected' : '' ?>>
<?= h($acc['institution_name']) ?> / <?= h($acc['account_name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">검색</label>
<input type="text" name="q" class="form-control" value="<?= h($q) ?>" placeholder="사용처, 메모, 카드명">
</div>
<div class="col-12 col-md-2 d-flex align-items-end gap-2">
<button class="btn btn-primary w-100">조회</button>
</div>
</form>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">미청구 원금</div>
<div class="stat-value"><?= won($summary['unbilled_principal']) ?></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">미청구 이자</div>
<div class="stat-value text-danger"><?= won($summary['unbilled_interest']) ?></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">미청구 합계</div>
<div class="stat-value"><?= won($summary['unbilled_total']) ?></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="card finance-card h-100">
<div class="card-body">
<div class="stat-label">청구완료 합계</div>
<div class="stat-value text-primary"><?= won($summary['billed_total']) ?></div>
</div>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-4">
<form method="post">
<input type="hidden" name="mode" value="mark_month_all_billed">
<input type="hidden" name="target_ym" value="<?= h($ym) ?>">
<button class="btn btn-primary" onclick="return confirm('조회 조건 전체를 청구완료 처리하시겠습니까?');">
전체 청구완료
</button>
</form>
<form method="post">
<input type="hidden" name="mode" value="mark_month_all_unbilled">
<input type="hidden" name="target_ym" value="<?= h($ym) ?>">
<button class="btn btn-outline-danger" onclick="return confirm('조회 조건 전체를 취소하시겠습니까?');">
전체 취소
</button>
</form>
</div>
<div class="card finance-card">
<div class="card-body mobile-scroll">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>카드</th>
<th>사용처</th>
<th>회차</th>
<th>청구월</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<tr>
<td>
<div class="fw-bold"><?= h($row['account_name']) ?></div>
<div class="small text-secondary"><?= h($row['institution_name']) ?></div>
</td>
<td>
<div class="fw-bold"><?= h($row['merchant_name'] ?: '-') ?></div>
<div class="small text-secondary"><?= h($row['description'] ?: '-') ?></div>
<div class="small text-secondary">연이자율 <?= numf($row['annual_interest_rate'], 2) ?>%</div>
</td>
<td><?= intvalf($row['cycle_no']) ?> / <?= intvalf($row['installment_months']) ?></td>
<td><?= h($row['bill_year_month']) ?></td>
<td class="text-end"><?= won($row['principal_amount']) ?></td>
<td class="text-end text-danger"><?= won($row['interest_amount']) ?></td>
<td class="text-end fw-bold"><?= won($row['total_amount']) ?></td>
<td>
<?php if ((int)$row['is_billed'] === 1): ?>
<span class="badge text-bg-success">청구완료</span>
<?php if (!empty($row['billed_at'])): ?>
<div class="small text-secondary mt-1">
<?= h(date('Y-m-d H:i', strtotime($row['billed_at']))) ?>
</div>
<?php endif; ?>
<?php else: ?>
<span class="badge text-bg-secondary">미청구</span>
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php if ((int)$row['is_billed'] === 0): ?>
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="mark_billed">
<input type="hidden" name="schedule_id" value="<?= $row['schedule_id'] ?>">
<button class="btn btn-sm btn-primary">완료</button>
</form>
<?php else: ?>
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="mark_unbilled">
<input type="hidden" name="schedule_id" value="<?= $row['schedule_id'] ?>">
<button class="btn btn-sm btn-outline-danger">취소</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$list): ?>
<tr>
<td colspan="9" class="text-center text-secondary py-5">조회 결과가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+554
View File
@@ -0,0 +1,554 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/installment_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$msg = '';
$installmentId = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare("
SELECT
i.*,
a.account_name,
a.institution_name,
t.transaction_date,
t.merchant_name,
t.description,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.principal_amount ELSE 0 END), 0) AS remaining_principal,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.interest_amount ELSE 0 END), 0) AS remaining_interest,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.total_amount ELSE 0 END), 0) AS remaining_total,
COALESCE(SUM(CASE WHEN s.is_billed = 1 THEN s.total_amount ELSE 0 END), 0) AS billed_total,
COALESCE(MIN(CASE WHEN s.is_billed = 0 THEN s.cycle_no ELSE NULL END), 0) AS next_cycle
FROM installments i
JOIN accounts a ON a.id = i.account_id
JOIN transactions t ON t.id = i.transaction_id
LEFT JOIN installment_schedules s ON s.installment_id = i.id
WHERE i.id = ?
AND i.user_id = ?
GROUP BY
i.id, i.user_id, i.transaction_id, i.account_id,
i.principal_amount, i.interest_total, i.total_billed_amount,
i.installment_months, i.annual_interest_rate, i.start_year_month,
i.interest_type, i.current_cycle, i.is_completed,
i.prepaid_principal_amount, i.prepaid_interest_amount,
i.created_at, i.updated_at,
a.account_name, a.institution_name,
t.transaction_date, t.merchant_name, t.description
");
$stmt->execute([$installmentId, $uid]);
$installment = $stmt->fetch();
if (!$installment) {
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>할부 선결제 / 중도상환</h2>
<a href="/installments.php" class="btn btn-outline-secondary">할부내역</a>
</div>
<div class="card finance-card">
<div class="card-body text-center py-5">
<div class="card-title-sm mb-3">할부 정보를 찾을 없습니다.</div>
<div class="text-secondary mb-4">
이미 초기화되었거나 삭제된 할부 내역일 있습니다.
</div>
<div class="d-flex justify-content-center gap-2 flex-wrap">
<a href="/installments.php" class="btn btn-primary">할부내역으로 이동</a>
<button type="button" class="btn btn-outline-secondary" onclick="history.back();">뒤로가기</button>
</div>
</div>
</div>
<?php
require __DIR__ . '/../app/views/footer.php';
exit;
}
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type IN ('bank','cash','other')
ORDER BY FIELD(account_type, 'bank', 'cash', 'other'), id ASC
");
$stmt->execute([$uid]);
$paymentAccounts = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$paymentAccountId = (int)($_POST['payment_account_id'] ?? 0);
$prepayDate = $_POST['prepay_date'] ?? date('Y-m-d');
$prepayPrincipal = (float)str_replace(',', '', (string)($_POST['prepay_principal_amount'] ?? 0));
$prepayInterest = (float)str_replace(',', '', (string)($_POST['prepay_interest_amount'] ?? 0));
$description = trim($_POST['description'] ?? '') ?: null;
$targetScheduleId = !empty($_POST['target_schedule_id'])
? (int)$_POST['target_schedule_id']
: null;
if ($paymentAccountId <= 0) {
throw new RuntimeException('결제 계좌를 선택하세요.');
}
prepay_installment(
$uid,
$installmentId,
$paymentAccountId,
$prepayDate,
$prepayPrincipal,
$prepayInterest,
$description,
$targetScheduleId
);
$msg = '선결제/중도상환이 처리되었습니다.';
$stmt = $pdo->prepare("
SELECT
i.*,
a.account_name,
a.institution_name,
t.transaction_date,
t.merchant_name,
t.description,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.principal_amount ELSE 0 END), 0) AS remaining_principal,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.interest_amount ELSE 0 END), 0) AS remaining_interest,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.total_amount ELSE 0 END), 0) AS remaining_total,
COALESCE(SUM(CASE WHEN s.is_billed = 1 THEN s.total_amount ELSE 0 END), 0) AS billed_total,
COALESCE(MIN(CASE WHEN s.is_billed = 0 THEN s.cycle_no ELSE NULL END), 0) AS next_cycle
FROM installments i
JOIN accounts a ON a.id = i.account_id
JOIN transactions t ON t.id = i.transaction_id
LEFT JOIN installment_schedules s ON s.installment_id = i.id
WHERE i.id = ?
AND i.user_id = ?
GROUP BY
i.id, i.user_id, i.transaction_id, i.account_id,
i.principal_amount, i.interest_total, i.total_billed_amount,
i.installment_months, i.annual_interest_rate, i.start_year_month,
i.interest_type, i.current_cycle, i.is_completed,
i.prepaid_principal_amount, i.prepaid_interest_amount,
i.created_at, i.updated_at,
a.account_name, a.institution_name,
t.transaction_date, t.merchant_name, t.description
");
$stmt->execute([$installmentId, $uid]);
$installment = $stmt->fetch();
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$stmt = $pdo->prepare("
SELECT *
FROM installment_schedules
WHERE installment_id = ?
ORDER BY cycle_no ASC
");
$stmt->execute([$installmentId]);
$schedules = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT *
FROM installment_prepayments
WHERE installment_id = ?
AND user_id = ?
ORDER BY prepay_date DESC, id DESC
");
$stmt->execute([$installmentId, $uid]);
$prepayments = $stmt->fetchAll();
$remainingPrincipal = (float)($installment['remaining_principal'] ?? 0);
$remainingInterest = (float)($installment['remaining_interest'] ?? 0);
$remainingTotal = (float)($installment['remaining_total'] ?? 0);
$totalBilled = (float)($installment['total_billed_amount'] ?? 0);
$billedTotal = (float)($installment['billed_total'] ?? 0);
$progress = $totalBilled > 0 ? (($totalBilled - $remainingTotal) / $totalBilled) * 100 : 0;
$progress = max(0, min(100, $progress));
$barWidth = $progress > 0 ? max($progress, 2) : 0;
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>할부 선결제 / 중도상환</h2>
<div class="d-flex flex-wrap gap-2">
<a href="/installments.php" class="btn btn-outline-secondary">할부내역</a>
<a href="/installment_billing.php?ym=<?= h($installment['start_year_month']) ?>&account_id=<?= h((string)$installment['account_id']) ?>" class="btn btn-outline-primary">청구관리</a>
</div>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="card finance-card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-2">
<div>
<div class="eyebrow"><?= h($installment['institution_name']) ?> / <?= h($installment['account_name']) ?></div>
<div class="card-title-lg"><?= h($installment['merchant_name'] ?: '사용처 없음') ?></div>
</div>
<span class="badge <?= $remainingTotal > 0 ? 'text-bg-warning' : 'text-bg-success' ?>">
<?= $remainingTotal > 0 ? '진행중' : '완료' ?>
</span>
</div>
<div class="small text-secondary mb-3">
결제일 <?= ymd($installment['transaction_date']) ?>
· 시작 청구월 <?= h($installment['start_year_month']) ?>
· <?= intvalf($installment['installment_months']) ?>개월
<?php if (!empty($installment['description'])): ?>
<br><?= h($installment['description']) ?>
<?php endif; ?>
</div>
<div class="row g-3">
<div class="col-6">
<div class="stat-label">남은 원금</div>
<div class="stat-value"><?= won($remainingPrincipal) ?></div>
</div>
<div class="col-6">
<div class="stat-label">남은 이자</div>
<div class="stat-value text-danger"><?= won($remainingInterest) ?></div>
</div>
<div class="col-6">
<div class="stat-label">남은 총액</div>
<div class="stat-value"><?= won($remainingTotal) ?></div>
</div>
<div class="col-6">
<div class="stat-label">연이자율</div>
<div class="stat-value"><?= numf($installment['annual_interest_rate'], 2) ?>%</div>
</div>
<div class="col-6">
<div class="stat-label"> 청구금액</div>
<div class="stat-value"><?= won($totalBilled) ?></div>
</div>
<div class="col-6">
<div class="stat-label">청구완료 금액</div>
<div class="stat-value text-primary"><?= won($billedTotal) ?></div>
</div>
</div>
<div class="mt-3">
<div class="finance-progress">
<div class="progress-bar" role="progressbar" style="width: <?= number_format($barWidth, 1) ?>%"></div>
</div>
<div class="small text-secondary mt-2">
진행률 <?= percentf($progress) ?>
<?php if ((int)$installment['next_cycle'] > 0): ?>
· 다음 <?= intvalf($installment['next_cycle']) ?>회차
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="card finance-card">
<div class="card-body">
<div class="card-title-sm mb-3">선결제 입력</div>
<?php if ($remainingTotal <= 0): ?>
<div class="alert alert-success mb-0">
남아있는 할부 금액이 없습니다.
</div>
<?php else: ?>
<form method="post" class="row g-3" id="prepayForm">
<input type="hidden" name="target_schedule_id" id="target_schedule_id" value="">
<div class="col-12">
<label class="form-label">선결제 출금 계좌</label>
<select name="payment_account_id" class="form-select" required>
<option value="">선택하세요</option>
<?php foreach ($paymentAccounts as $acc): ?>
<option value="<?= $acc['id'] ?>">
<?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label">선결제일</label>
<input type="date" name="prepay_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">선결제 원금</label>
<input
type="text"
name="prepay_principal_amount"
id="prepay_principal_amount"
class="form-control"
inputmode="numeric"
value="0"
required
>
<div class="mt-2 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary fill-principal">남은 원금</button>
<button type="button" class="btn btn-sm btn-outline-secondary clear-principal">초기화</button>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">선결제 이자</label>
<input
type="text"
name="prepay_interest_amount"
id="prepay_interest_amount"
class="form-control"
inputmode="numeric"
value="0"
>
<div class="mt-2 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary fill-interest">남은 이자</button>
<button type="button" class="btn btn-sm btn-outline-secondary clear-interest">초기화</button>
</div>
</div>
<div class="col-12">
<div class="loan-create-highlight">
<div class="title">빠른 입력</div>
<div class="desc">남은 금액 전체를 선결제하려면 전체상환 버튼을 사용하세요.</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary" id="fill_all_btn">전체상환 입력</button>
<button type="button" class="btn btn-outline-secondary" id="clear_all_btn">전체 초기화</button>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label">메모</label>
<input type="text" name="description" id="description" class="form-control" placeholder="예: 카드 선결제">
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button class="btn btn-primary" onclick="return confirm('선결제/중도상환 처리하시겠습니까?');">
선결제 실행
</button>
<a href="/installments.php" class="btn btn-outline-secondary">취소</a>
</div>
</form>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card finance-card mb-4">
<div class="card-body">
<div class="card-title-sm mb-3">남은 회차 상세</div>
<div class="mobile-scroll">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>회차</th>
<th>청구월</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
<th>상태</th>
<th>선납</th>
</tr>
</thead>
<tbody>
<?php foreach ($schedules as $s): ?>
<?php $isBilled = (int)$s['is_billed'] === 1; ?>
<tr class="<?= $isBilled ? 'text-secondary' : '' ?>">
<td><?= intvalf($s['cycle_no']) ?>회차</td>
<td><?= h($s['bill_year_month']) ?></td>
<td class="text-end"><?= won($s['principal_amount']) ?></td>
<td class="text-end text-danger"><?= won($s['interest_amount']) ?></td>
<td class="text-end fw-bold"><?= won($s['total_amount']) ?></td>
<td>
<?php if ($isBilled): ?>
<span class="badge text-bg-success">청구완료</span>
<?php else: ?>
<span class="badge text-bg-secondary">미청구</span>
<?php endif; ?>
</td>
<td>
<?php if (!$isBilled && (float)$s['total_amount'] > 0): ?>
<button
type="button"
class="btn btn-sm btn-outline-primary cycle-prepay-btn"
data-schedule-id="<?= (int)$s['id'] ?>"
data-cycle="<?= (int)$s['cycle_no'] ?>"
data-principal="<?= (float)$s['principal_amount'] ?>"
data-interest="<?= (float)$s['interest_amount'] ?>"
>
선납
</button>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$schedules): ?>
<tr>
<td colspan="7" class="text-center text-secondary py-4">회차 정보가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="card finance-card">
<div class="card-body">
<div class="card-title-sm mb-3">선결제 이력</div>
<div class="mobile-scroll">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>일자</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
<th>메모</th>
</tr>
</thead>
<tbody>
<?php foreach ($prepayments as $p): ?>
<tr>
<td><?= ymd($p['prepay_date']) ?></td>
<td class="text-end"><?= won($p['principal_amount']) ?></td>
<td class="text-end text-danger"><?= won($p['interest_amount']) ?></td>
<td class="text-end fw-bold"><?= won($p['total_amount']) ?></td>
<td><?= h($p['description'] ?: '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$prepayments): ?>
<tr>
<td colspan="5" class="text-center text-secondary py-4">선결제 이력이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const remainPrincipal = <?= json_encode($remainingPrincipal) ?>;
const remainInterest = <?= json_encode($remainingInterest) ?>;
const principalEl = document.getElementById('prepay_principal_amount');
const interestEl = document.getElementById('prepay_interest_amount');
if (!principalEl || !interestEl) return;
function formatWithComma(value) {
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.]/g, '');
if (normalized === '') return '';
const parts = normalized.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
function setMoney(el, value) {
el.value = formatWithComma(Math.round(Number(value || 0)));
}
principalEl.addEventListener('input', function () {
this.value = formatWithComma(this.value);
});
interestEl.addEventListener('input', function () {
this.value = formatWithComma(this.value);
});
document.querySelector('.fill-principal')?.addEventListener('click', function () {
setMoney(principalEl, remainPrincipal);
});
document.querySelector('.clear-principal')?.addEventListener('click', function () {
principalEl.value = '0';
});
document.querySelector('.fill-interest')?.addEventListener('click', function () {
setMoney(interestEl, remainInterest);
});
document.querySelector('.clear-interest')?.addEventListener('click', function () {
interestEl.value = '0';
});
document.getElementById('fill_all_btn')?.addEventListener('click', function () {
setMoney(principalEl, remainPrincipal);
setMoney(interestEl, remainInterest);
const targetScheduleEl = document.getElementById('target_schedule_id');
if (targetScheduleEl) targetScheduleEl.value = '';
});
document.getElementById('clear_all_btn')?.addEventListener('click', function () {
principalEl.value = '0';
interestEl.value = '0';
const targetScheduleEl = document.getElementById('target_schedule_id');
if (targetScheduleEl) targetScheduleEl.value = '';
});
document.getElementById('prepayForm')?.addEventListener('submit', function () {
principalEl.value = principalEl.value.replace(/,/g, '');
interestEl.value = interestEl.value.replace(/,/g, '');
});
const descEl = document.getElementById('description');
const targetScheduleEl = document.getElementById('target_schedule_id');
document.querySelectorAll('.cycle-prepay-btn').forEach(btn => {
btn.addEventListener('click', function () {
const cycle = this.dataset.cycle;
const principal = Number(this.dataset.principal || 0);
const interest = Number(this.dataset.interest || 0);
setMoney(principalEl, principal);
setMoney(interestEl, interest);
if (targetScheduleEl) {
targetScheduleEl.value = this.dataset.scheduleId || '';
}
if (descEl) {
descEl.value = cycle + '회차 선납';
}
principalEl.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
});
})();
</script>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+325
View File
@@ -0,0 +1,325 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/installment_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$msg = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$mode = $_POST['mode'] ?? '';
if ($mode === 'rebuild_installments') {
$count = rebuild_all_installments_for_user($uid);
$msg = '할부 자동반영 재적용 완료 (' . number_format($count) . '건)';
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$ym = $_GET['ym'] ?? '';
$accountId = (int)($_GET['account_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
$params = [$uid];
$where = ["i.user_id = ?"];
if ($ym !== '') {
$where[] = "i.start_year_month = ?";
$params[] = $ym;
}
if ($accountId > 0) {
$where[] = "i.account_id = ?";
$params[] = $accountId;
}
if ($q !== '') {
$where[] = "(t.merchant_name LIKE ? OR t.description LIKE ? OR a.account_name LIKE ?)";
$like = '%' . $q . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$sql = "
SELECT
i.*,
a.account_name,
a.institution_name,
t.transaction_date,
t.merchant_name,
t.description,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.principal_amount ELSE 0 END), 0) AS remaining_principal,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.interest_amount ELSE 0 END), 0) AS remaining_interest,
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.total_amount ELSE 0 END), 0) AS remaining_total,
COALESCE(SUM(CASE WHEN s.is_billed = 1 THEN s.total_amount ELSE 0 END), 0) AS billed_total,
COALESCE(MIN(CASE WHEN s.is_billed = 0 THEN s.cycle_no ELSE NULL END), 0) AS next_cycle
FROM installments i
JOIN accounts a ON a.id = i.account_id
JOIN transactions t ON t.id = i.transaction_id
LEFT JOIN installment_schedules s ON s.installment_id = i.id
WHERE " . implode(' AND ', $where) . "
GROUP BY
i.id, i.user_id, i.transaction_id, i.account_id,
i.principal_amount, i.interest_total, i.total_billed_amount,
i.installment_months, i.annual_interest_rate, i.start_year_month,
i.interest_type, i.current_cycle, i.is_completed,
i.prepaid_principal_amount, i.prepaid_interest_amount,
i.created_at, i.updated_at,
a.account_name, a.institution_name,
t.transaction_date, t.merchant_name, t.description
ORDER BY i.created_at DESC, i.id DESC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$installments = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT id, account_name, institution_name
FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type = 'card'
ORDER BY id ASC
");
$stmt->execute([$uid]);
$cardAccounts = $stmt->fetchAll();
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>할부 내역</h2>
<div class="d-flex flex-wrap gap-2">
<a href="/installment_billing.php" class="btn btn-outline-primary">
할부 청구 관리
</a>
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="rebuild_installments">
<button
class="btn btn-outline-danger"
onclick="return confirm('기존 할부 스케줄을 초기화 후 재생성합니다. 진행하시겠습니까?');">
자동반영 리셋 재적용
</button>
</form>
</div>
</div>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="card finance-card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-12 col-md-3">
<label class="form-label">시작월</label>
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
</div>
<div class="col-12 col-md-3">
<label class="form-label">카드</label>
<select name="account_id" class="form-select">
<option value="0">전체</option>
<?php foreach ($cardAccounts as $acc): ?>
<option value="<?= $acc['id'] ?>" <?= $accountId === (int)$acc['id'] ? 'selected' : '' ?>>
<?= h($acc['institution_name']) ?> / <?= h($acc['account_name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">검색</label>
<input type="text" name="q" class="form-control" value="<?= h($q) ?>" placeholder="사용처, 메모, 카드명">
</div>
<div class="col-12 col-md-2 d-flex align-items-end gap-2">
<button class="btn btn-primary w-100">조회</button>
<a href="/installments.php" class="btn btn-outline-secondary w-100">초기화</a>
</div>
</form>
</div>
</div>
<div class="row g-3">
<?php foreach ($installments as $row): ?>
<?php
$progress = 0;
if ((float)$row['total_billed_amount'] > 0) {
$progress = (($row['total_billed_amount'] - $row['remaining_total']) / (float)$row['total_billed_amount']) * 100;
$progress = max(0, min(100, $progress));
}
$scheduleStmt = $pdo->prepare("
SELECT *
FROM installment_schedules
WHERE installment_id = ?
ORDER BY cycle_no ASC
");
$scheduleStmt->execute([$row['id']]);
$schedules = $scheduleStmt->fetchAll();
?>
<div class="col-12">
<div class="card finance-card">
<div class="card-body">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
<div>
<div class="eyebrow"><?= h($row['institution_name']) ?> / <?= h($row['account_name']) ?></div>
<div class="card-title-lg"><?= h($row['merchant_name'] ?: '사용처 없음') ?></div>
<div class="text-secondary small">
결제일 <?= h($row['transaction_date']) ?>
<?php if (!empty($row['description'])): ?>
· <?= h($row['description']) ?>
<?php endif; ?>
</div>
</div>
<div class="text-xl-end">
<span class="badge <?= (float)$row['remaining_total'] > 0 ? 'text-bg-warning' : 'text-bg-success' ?>">
<?= (float)$row['remaining_total'] > 0 ? '진행중' : '완료' ?>
</span>
<div class="small text-secondary mt-2">연이자율 <?= h((string)$row['annual_interest_rate']) ?>%</div>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-6 col-md-3">
<div class="stat-label">원금</div>
<div class="stat-value"><?= won($row['principal_amount']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label"> 할부이자</div>
<div class="stat-value text-danger"><?= won($row['interest_total']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label"> 청구금액</div>
<div class="stat-value"><?= won($row['total_billed_amount']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">개월 </div>
<div class="stat-value"><?= h((string)$row['installment_months']) ?>개월</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 원금</div>
<div class="stat-value"><?= won($row['remaining_principal']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 이자</div>
<div class="stat-value text-danger"><?= won($row['remaining_interest']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 총액</div>
<div class="stat-value"><?= won($row['remaining_total']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">다음 회차</div>
<div class="stat-value"><?= (int)$row['next_cycle'] > 0 ? ((int)$row['next_cycle'] . '회차') : '-' ?></div>
</div>
</div>
<div class="mt-3">
<div class="progress finance-progress">
<div class="progress-bar" role="progressbar" style="width: <?= number_format($progress, 1) ?>%"></div>
</div>
<div class="small text-secondary mt-2">진행률 <?= number_format($progress, 1) ?>%</div>
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<button
class="btn btn-sm btn-outline-primary"
type="button"
data-bs-toggle="collapse"
data-bs-target="#schedule-<?= $row['id'] ?>"
>
회차별 보기
</button>
<?php if ((float)$row['remaining_total'] > 0): ?>
<a href="/installment_prepay.php?id=<?= $row['id'] ?>" class="btn btn-sm btn-outline-danger">선결제 / 중도상환</a>
<?php endif; ?>
<a href="/installment_billing.php?ym=<?= h($row['start_year_month']) ?>&account_id=<?= $row['account_id'] ?>&q=<?= urlencode((string)($row['merchant_name'] ?? '')) ?>" class="btn btn-sm btn-outline-secondary">
청구관리로 보기
</a>
</div>
<div class="collapse mt-3" id="schedule-<?= $row['id'] ?>">
<div class="mobile-scroll">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>회차</th>
<th>청구월</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach ($schedules as $s): ?>
<tr>
<td><?= $s['cycle_no'] ?>회차</td>
<td><?= h($s['bill_year_month']) ?></td>
<td class="text-end"><?= won($s['principal_amount']) ?></td>
<td class="text-end text-danger"><?= won($s['interest_amount']) ?></td>
<td class="text-end fw-bold"><?= won($s['total_amount']) ?></td>
<td>
<?php if ((int)$s['is_billed'] === 1): ?>
<span class="badge text-bg-success">청구완료</span>
<?php else: ?>
<span class="badge text-bg-secondary">미청구</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$schedules): ?>
<tr>
<td colspan="6" class="text-center text-secondary py-4">회차 정보가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (!$installments): ?>
<div class="col-12">
<div class="card finance-card">
<div class="card-body text-center text-secondary py-5">
등록된 할부 내역이 없습니다.
</div>
</div>
</div>
<?php endif; ?>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+247
View File
@@ -0,0 +1,247 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/loan_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$stmt = $pdo->prepare("
SELECT * FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type IN ('bank','cash','other')
ORDER BY id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$loanName = trim($_POST['loan_name'] ?? '');
$lenderName = trim($_POST['lender_name'] ?? '');
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
$annualInterestRate = (float)($_POST['annual_interest_rate'] ?? 0);
$startDate = $_POST['start_date'] ?? date('Y-m-d');
$graceMonths = (int)($_POST['grace_period_months'] ?? 0);
$repaymentMonths = (int)($_POST['repayment_months'] ?? 0);
$repaymentMethod = $_POST['repayment_method'] ?? '';
$paymentDay = (int)($_POST['payment_day'] ?? 1);
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
$description = trim($_POST['description'] ?? '');
$createFullHistory = !empty($_POST['create_full_history']) ? 1 : 0;
if ($loanName === '') {
throw new RuntimeException('대출명을 입력하세요.');
}
if ($principalAmount <= 0) {
throw new RuntimeException('대출원금은 0보다 커야 합니다.');
}
if ($annualInterestRate < 0) {
throw new RuntimeException('연이자율이 올바르지 않습니다.');
}
if ($repaymentMonths <= 0) {
throw new RuntimeException('상환개월 수는 1 이상이어야 합니다.');
}
if ($paymentDay < 1 || $paymentDay > 31) {
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
}
$allowed = [
'interest_only_then_equal_payment',
'interest_only_then_equal_principal',
'interest_only_then_bullet',
'equal_payment',
'equal_principal',
'bullet',
];
if (!in_array($repaymentMethod, $allowed, true)) {
throw new RuntimeException('상환방식이 올바르지 않습니다.');
}
if ($createFullHistory && !$accountId) {
throw new RuntimeException('완전 반영 방식을 사용할 때는 입출금 계좌를 선택하세요.');
}
create_loan_with_full_backfill([
'user_id' => $uid,
'account_id' => $accountId,
'loan_name' => $loanName,
'lender_name' => $lenderName,
'principal_amount' => $principalAmount,
'annual_interest_rate' => $annualInterestRate,
'start_date' => $startDate,
'maturity_date' => null,
'grace_period_months' => $graceMonths,
'repayment_months' => $repaymentMonths,
'repayment_method' => $repaymentMethod,
'payment_day' => $paymentDay,
'description' => $description,
'create_full_history' => $createFullHistory,
'today_date' => date('Y-m-d'),
]);
redirect('/loans.php');
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
require __DIR__ . '/../app/views/header.php';
?>
<style>
.loan-create-highlight {
border: 1px solid rgba(37, 99, 235, 0.18);
background: linear-gradient(180deg, rgba(37,99,235,0.06), rgba(37,99,235,0.03));
border-radius: 16px;
padding: 18px 18px 16px 18px;
}
.loan-create-highlight .title {
font-weight: 700;
margin-bottom: 8px;
}
.loan-create-highlight .desc {
color: #5b6475;
font-size: 14px;
line-height: 1.6;
}
.loan-create-check {
margin-top: 14px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(37, 99, 235, 0.14);
}
.loan-create-check .form-check-label {
font-weight: 700;
}
.loan-create-check small {
display: block;
color: #6b7280;
margin-top: 6px;
line-height: 1.5;
}
</style>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">대출 등록</h2>
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">대출명</label>
<input type="text" name="loan_name" class="form-control" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">대출기관</label>
<input type="text" name="lender_name" class="form-control">
</div>
<div class="col-12 col-md-4">
<label class="form-label">대출원금</label>
<input type="number" name="principal_amount" class="form-control" min="1" step="1" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">연이자율(%)</label>
<input type="number" name="annual_interest_rate" class="form-control" min="0" step="0.01" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">시작일</label>
<input type="date" name="start_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">거치기간(개월)</label>
<input type="number" name="grace_period_months" class="form-control" min="0" value="0" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">상환기간(개월)</label>
<input type="number" name="repayment_months" class="form-control" min="1" value="12" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">납부일</label>
<input type="number" name="payment_day" class="form-control" min="1" max="31" value="1" required>
</div>
<div class="col-12">
<label class="form-label">상환방식</label>
<select name="repayment_method" class="form-select" required>
<option value="interest_only_then_equal_payment">거치 원리금균등</option>
<option value="interest_only_then_equal_principal">거치 원금균등</option>
<option value="interest_only_then_bullet">거치 만기일시상환</option>
<option value="equal_payment">원리금균등</option>
<option value="equal_principal">원금균등</option>
<option value="bullet">만기일시상환</option>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">입출금 계좌</label>
<select name="account_id" class="form-select">
<option value="">선택안함</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= $acc['id'] ?>"><?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)</option>
<?php endforeach; ?>
</select>
<div class="form-text">과거 거래까지 자동 생성하려면 계좌를 선택하세요.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">메모</label>
<input type="text" name="description" class="form-control">
</div>
<div class="col-12">
<div class="loan-create-highlight">
<div class="title">완전 반영 방식</div>
<div class="desc">
대출 시작일이 과거라면, 대출 실행 거래와 오늘까지의 과거 상환 거래를 자동으로 모두 생성합니다.
미래 회차는 예정 스케줄로 남겨둡니다.
</div>
<div class="loan-create-check">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="create_full_history" id="create_full_history" value="1" checked>
<label class="form-check-label" for="create_full_history">
대출 실행 + 과거 납부분 거래내역까지 전부 자동 생성
</label>
<small>
: 2024년에 받은 대출을 지금 등록하면<br>
대출 실행일의 유입 거래 1 + 오늘까지 도래한 상환 거래들이 자동 생성됩니다.
</small>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-primary">저장</button>
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
</div>
</form>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+206
View File
@@ -0,0 +1,206 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/loan_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$id = (int)($_GET['id'] ?? 0);
$error = '';
$msg = '';
$stmt = $pdo->prepare("
SELECT *
FROM loans
WHERE id = ? AND user_id = ?
");
$stmt->execute([$id, $uid]);
$loan = $stmt->fetch();
if (!$loan) {
exit('대출 정보를 찾을 수 없습니다.');
}
$stmt = $pdo->prepare("
SELECT * FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type IN ('bank','cash','other')
ORDER BY id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$scheduleId = (int)($_POST['schedule_id'] ?? 0);
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
$paymentDate = $_POST['payment_date'] ?? date('Y-m-d');
$description = trim($_POST['description'] ?? '') ?: null;
pay_loan_schedule($uid, $scheduleId, $accountId, $paymentDate, $description);
$msg = '상환 처리되었습니다.';
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$stmt = $pdo->prepare("
SELECT
ls.*,
lp.payment_date,
lp.description AS payment_description,
lp.is_auto_generated,
lp.payment_type
FROM loan_schedules ls
LEFT JOIN loan_payments lp
ON lp.loan_schedule_id = ls.id
WHERE ls.loan_id = ?
ORDER BY ls.cycle_no ASC
");
$stmt->execute([$id]);
$schedules = $stmt->fetchAll();
$summary = get_loan_remaining_summary($id);
require __DIR__ . '/../app/views/header.php';
?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">대출 상세</h2>
<div class="d-flex gap-2">
<?php if ($loan['status'] === 'active'): ?>
<a href="/loan_prepay.php?id=<?= $loan['id'] ?>" class="btn btn-outline-danger">중도상환</a>
<?php endif; ?>
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
</div>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="card finance-card mb-4">
<div class="card-body">
<div class="eyebrow"><?= h($loan['lender_name'] ?: '-') ?></div>
<div class="card-title-lg"><?= h($loan['loan_name']) ?></div>
<div class="row g-3 mt-2">
<div class="col-6 col-md-3">
<div class="stat-label">원금</div>
<div class="stat-value"><?= won($loan['principal_amount']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">연이자율</div>
<div class="stat-value"><?= h((string)$loan['annual_interest_rate']) ?>%</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">거치기간</div>
<div class="stat-value"><?= h((string)$loan['grace_period_months']) ?>개월</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">상환기간</div>
<div class="stat-value"><?= h((string)$loan['repayment_months']) ?>개월</div>
</div>
<div class="col-6 col-md-4">
<div class="stat-label">남은 원금</div>
<div class="stat-value"><?= won($summary['remaining_principal']) ?></div>
</div>
<div class="col-6 col-md-4">
<div class="stat-label">남은 이자</div>
<div class="stat-value text-danger"><?= won($summary['remaining_interest']) ?></div>
</div>
<div class="col-6 col-md-4">
<div class="stat-label">남은 총액</div>
<div class="stat-value"><?= won($summary['remaining_total']) ?></div>
</div>
</div>
</div>
</div>
<div class="card finance-card">
<div class="card-body mobile-scroll">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>회차</th>
<th>납부일</th>
<th>구간</th>
<th class="text-end">기초원금</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
<th class="text-end">기말원금</th>
<th>상태</th>
<th>처리</th>
</tr>
</thead>
<tbody>
<?php foreach ($schedules as $s): ?>
<tr>
<td><?= $s['cycle_no'] ?></td>
<td><?= h($s['due_date']) ?></td>
<td><?= $s['payment_phase'] === 'grace' ? '거치' : '상환' ?></td>
<td class="text-end"><?= won($s['opening_principal']) ?></td>
<td class="text-end"><?= won($s['scheduled_principal']) ?></td>
<td class="text-end text-danger"><?= won($s['scheduled_interest']) ?></td>
<td class="text-end fw-bold"><?= won($s['scheduled_total']) ?></td>
<td class="text-end"><?= won($s['closing_principal']) ?></td>
<td>
<?php if ((int)$s['is_paid'] === 1): ?>
<?php if ((int)($s['is_auto_generated'] ?? 0) === 1): ?>
<span class="badge text-bg-info">자동반영</span>
<?php else: ?>
<span class="badge text-bg-success">납부완료</span>
<?php endif; ?>
<?php if (!empty($s['payment_date'])): ?>
<div class="small text-secondary mt-1"><?= h($s['payment_date']) ?></div>
<?php endif; ?>
<?php if (!empty($s['payment_description'])): ?>
<div class="small text-secondary"><?= h($s['payment_description']) ?></div>
<?php endif; ?>
<?php else: ?>
<span class="badge text-bg-secondary">예정</span>
<?php endif; ?>
</td>
<td>
<?php if ((int)$s['is_paid'] === 0): ?>
<form method="post" class="d-flex flex-column gap-2">
<input type="hidden" name="schedule_id" value="<?= $s['id'] ?>">
<select name="account_id" class="form-select form-select-sm">
<option value="">출금계좌</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= $acc['id'] ?>"><?= h($acc['account_name']) ?></option>
<?php endforeach; ?>
</select>
<input type="date" name="payment_date" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
<button class="btn btn-sm btn-primary">상환처리</button>
</form>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$schedules): ?>
<tr>
<td colspan="10" class="text-center text-secondary py-5">스케줄이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+223
View File
@@ -0,0 +1,223 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/loan_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
$error = '';
$stmt = $pdo->prepare("
SELECT *
FROM loans
WHERE id = ? AND user_id = ?
LIMIT 1
");
$stmt->execute([$id, $uid]);
$loan = $stmt->fetch();
if (!$loan) {
exit('대출 정보를 찾을 수 없습니다.');
}
$stmt = $pdo->prepare("
SELECT * FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type IN ('bank','cash','other')
ORDER BY id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$loanName = trim($_POST['loan_name'] ?? '');
$lenderName = trim($_POST['lender_name'] ?? '');
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
$annualInterestRate = (float)($_POST['annual_interest_rate'] ?? 0);
$startDate = $_POST['start_date'] ?? date('Y-m-d');
$graceMonths = (int)($_POST['grace_period_months'] ?? 0);
$repaymentMonths = (int)($_POST['repayment_months'] ?? 0);
$repaymentMethod = $_POST['repayment_method'] ?? '';
$paymentDay = (int)($_POST['payment_day'] ?? 1);
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
$description = trim($_POST['description'] ?? '');
$createFullHistory = !empty($_POST['create_full_history']) ? 1 : 0;
if ($loanName === '') {
throw new RuntimeException('대출명을 입력하세요.');
}
if ($principalAmount <= 0) {
throw new RuntimeException('대출원금은 0보다 커야 합니다.');
}
if ($annualInterestRate < 0) {
throw new RuntimeException('연이자율이 올바르지 않습니다.');
}
if ($repaymentMonths <= 0) {
throw new RuntimeException('상환개월 수는 1 이상이어야 합니다.');
}
if ($paymentDay < 1 || $paymentDay > 31) {
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
}
$allowed = [
'interest_only_then_equal_payment',
'interest_only_then_equal_principal',
'interest_only_then_bullet',
'equal_payment',
'equal_principal',
'bullet',
];
if (!in_array($repaymentMethod, $allowed, true)) {
throw new RuntimeException('상환방식이 올바르지 않습니다.');
}
update_loan_and_rebuild_full_history($uid, $id, [
'account_id' => $accountId,
'loan_name' => $loanName,
'lender_name' => $lenderName,
'principal_amount' => $principalAmount,
'annual_interest_rate' => $annualInterestRate,
'start_date' => $startDate,
'grace_period_months' => $graceMonths,
'repayment_months' => $repaymentMonths,
'repayment_method' => $repaymentMethod,
'payment_day' => $paymentDay,
'description' => $description,
'create_full_history' => $createFullHistory,
'today_date' => date('Y-m-d'),
]);
header('Location: /loans.php');
exit;
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>대출 수정</h2>
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3">
<input type="hidden" name="id" value="<?= $loan['id'] ?>">
<div class="col-12 col-md-6">
<label class="form-label">대출명</label>
<input type="text" name="loan_name" class="form-control" value="<?= h($loan['loan_name']) ?>" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">대출기관</label>
<input type="text" name="lender_name" class="form-control" value="<?= h($loan['lender_name'] ?? '') ?>">
</div>
<div class="col-12 col-md-4">
<label class="form-label">대출원금</label>
<input type="number" name="principal_amount" class="form-control" min="1" step="1" value="<?= h((string)$loan['principal_amount']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">연이자율(%)</label>
<input type="number" name="annual_interest_rate" class="form-control" min="0" step="0.01" value="<?= h((string)$loan['annual_interest_rate']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">시작일</label>
<input type="date" name="start_date" class="form-control" value="<?= h($loan['start_date']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">거치기간(개월)</label>
<input type="number" name="grace_period_months" class="form-control" min="0" value="<?= h((string)$loan['grace_period_months']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">상환기간(개월)</label>
<input type="number" name="repayment_months" class="form-control" min="1" value="<?= h((string)$loan['repayment_months']) ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">납부일</label>
<input type="number" name="payment_day" class="form-control" min="1" max="31" value="<?= h((string)$loan['payment_day']) ?>" required>
</div>
<div class="col-12">
<label class="form-label">상환방식</label>
<select name="repayment_method" class="form-select" required>
<option value="interest_only_then_equal_payment" <?= $loan['repayment_method'] === 'interest_only_then_equal_payment' ? 'selected' : '' ?>>거치 후 원리금균등</option>
<option value="interest_only_then_equal_principal" <?= $loan['repayment_method'] === 'interest_only_then_equal_principal' ? 'selected' : '' ?>>거치 후 원금균등</option>
<option value="interest_only_then_bullet" <?= $loan['repayment_method'] === 'interest_only_then_bullet' ? 'selected' : '' ?>>거치 후 만기일시상환</option>
<option value="equal_payment" <?= $loan['repayment_method'] === 'equal_payment' ? 'selected' : '' ?>>원리금균등</option>
<option value="equal_principal" <?= $loan['repayment_method'] === 'equal_principal' ? 'selected' : '' ?>>원금균등</option>
<option value="bullet" <?= $loan['repayment_method'] === 'bullet' ? 'selected' : '' ?>>만기일시상환</option>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">입출금 계좌</label>
<select name="account_id" class="form-select">
<option value="">선택안함</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= $acc['id'] ?>" <?= (int)$loan['account_id'] === (int)$acc['id'] ? 'selected' : '' ?>>
<?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">메모</label>
<input type="text" name="description" class="form-control" value="<?= h($loan['description'] ?? '') ?>">
</div>
<div class="col-12">
<div class="loan-create-highlight">
<div class="title">수정 전체 재계산</div>
<div class="desc">
대출 정보를 수정하면 기존 스케줄과 자동반영 이력을 다시 생성합니다.
</div>
<div class="loan-create-check">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="create_full_history" id="create_full_history" value="1" checked>
<label class="form-check-label" for="create_full_history">
수정 자동반영까지 다시 적용
</label>
<small>
과거 회차는 자동 반영, 미래 회차는 예정 상태로 다시 생성합니다.
</small>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-primary">저장</button>
<a href="/loans.php" class="btn btn-outline-secondary">취소</a>
</div>
</form>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+153
View File
@@ -0,0 +1,153 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/loan_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$msg = '';
$stmt = $pdo->prepare("SELECT * FROM loans WHERE user_id = ? AND is_active = 1 ORDER BY id DESC");
$stmt->execute([$uid]);
$loans = $stmt->fetchAll();
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE user_id = ? AND is_active = 1 AND account_type IN ('bank','cash','other') ORDER BY id ASC");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$stmt = $pdo->prepare("SELECT * FROM categories WHERE user_id = ? AND category_type = 'expense' AND is_active = 1 ORDER BY sort_order, id");
$stmt->execute([$uid]);
$categories = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$loanId = (int)($_POST['loan_id'] ?? 0);
$accountId = (int)($_POST['account_id'] ?? 0);
$paymentDate = $_POST['payment_date'] ?? date('Y-m-d');
$totalAmount = (float)($_POST['total_amount'] ?? 0);
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
$interestAmount = (float)($_POST['interest_amount'] ?? 0);
$feeAmount = (float)($_POST['fee_amount'] ?? 0);
$categoryId = (int)($_POST['category_id'] ?? 0);
$description = trim($_POST['description'] ?? '') ?: null;
$skipDuplicate = isset($_POST['skip_duplicate']);
if ($loanId <= 0 || $accountId <= 0 || $categoryId <= 0) {
throw new RuntimeException('대출, 출금계좌, 카테고리를 선택하세요.');
}
if ($totalAmount <= 0) {
throw new RuntimeException('총 납부금액은 0보다 커야 합니다.');
}
if (($principalAmount + $interestAmount + $feeAmount) != $totalAmount) {
throw new RuntimeException('원금 + 이자 + 수수료 합계가 총 납부금액과 같아야 합니다.');
}
$inserted = create_loan_payment([
'user_id' => $uid,
'loan_id' => $loanId,
'account_id' => $accountId,
'payment_date' => $paymentDate,
'total_amount' => $totalAmount,
'principal_amount' => $principalAmount,
'interest_amount' => $interestAmount,
'fee_amount' => $feeAmount,
'category_id' => $categoryId,
'description' => $description,
], $skipDuplicate);
$msg = $inserted ? '상환 내역이 저장되었습니다.' : '중복으로 판단되어 건너뛰었습니다.';
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
require __DIR__ . '/../app/views/header.php';
?>
<h2 class="mb-4">대출 상환 등록</h2>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3">
<div class="col-md-4">
<label class="form-label">대출</label>
<select name="loan_id" class="form-select" required>
<option value="">선택하세요</option>
<?php foreach ($loans as $loan): ?>
<option value="<?= $loan['id'] ?>"><?= h($loan['loan_name']) ?> / <?= h($loan['institution_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">출금 계좌</label>
<select name="account_id" class="form-select" required>
<option value="">선택하세요</option>
<?php foreach ($accounts as $account): ?>
<option value="<?= $account['id'] ?>"><?= h($account['account_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">거래 카테고리</label>
<select name="category_id" class="form-select" required>
<?php foreach ($categories as $category): ?>
<option value="<?= $category['id'] ?>"><?= h($category['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label">상환일</label>
<input type="date" name="payment_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-md-3">
<label class="form-label"> 납부금액</label>
<input type="number" name="total_amount" class="form-control" min="1" step="1" required>
</div>
<div class="col-md-2">
<label class="form-label">원금</label>
<input type="number" name="principal_amount" class="form-control" min="0" step="1" required>
</div>
<div class="col-md-2">
<label class="form-label">이자</label>
<input type="number" name="interest_amount" class="form-control" min="0" step="1" value="0" required>
</div>
<div class="col-md-2">
<label class="form-label">수수료</label>
<input type="number" name="fee_amount" class="form-control" min="0" step="1" value="0" required>
</div>
<div class="col-md-8">
<label class="form-label">메모</label>
<input type="text" name="description" class="form-control" placeholder="예: 5월 정기상환">
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="skip_duplicate" id="skip_duplicate" checked>
<label class="form-check-label" for="skip_duplicate">중복이면 건너뛰기</label>
</div>
</div>
<div class="col-12">
<button class="btn btn-primary">상환 저장</button>
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
</div>
</form>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+271
View File
@@ -0,0 +1,271 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/loan_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$id = (int)($_GET['id'] ?? 0);
$error = '';
$msg = '';
$stmt = $pdo->prepare("
SELECT *
FROM loans
WHERE id = ? AND user_id = ?
");
$stmt->execute([$id, $uid]);
$loan = $stmt->fetch();
if (!$loan) {
exit('대출 정보를 찾을 수 없습니다.');
}
$stmt = $pdo->prepare("
SELECT * FROM accounts
WHERE user_id = ?
AND is_active = 1
AND account_type IN ('bank','cash','other')
ORDER BY id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
$paymentDate = $_POST['payment_date'] ?? date('Y-m-d');
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
$interestAmount = (float)($_POST['interest_amount'] ?? 0);
$feeAmount = (float)($_POST['fee_amount'] ?? 0);
$description = trim($_POST['description'] ?? '') ?: null;
prepay_loan(
$uid,
$id,
$accountId,
$paymentDate,
$principalAmount,
$interestAmount,
$feeAmount,
$description
);
$msg = '중도상환 처리되었습니다.';
$stmt->execute([$id, $uid]);
$loan = $stmt->fetch();
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$summary = get_loan_remaining_summary($id);
$stmt = $pdo->prepare("
SELECT *
FROM loan_schedules
WHERE loan_id = ?
ORDER BY cycle_no ASC
");
$stmt->execute([$id]);
$schedules = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT *
FROM loan_payments
WHERE loan_id = ?
AND payment_type = 'prepayment'
ORDER BY payment_date DESC, id DESC
");
$stmt->execute([$id]);
$prepayments = $stmt->fetchAll();
require __DIR__ . '/../app/views/header.php';
?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">대출 중도상환</h2>
<a href="/loan_detail.php?id=<?= $loan['id'] ?>" class="btn btn-outline-secondary">대출 상세</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="card finance-card">
<div class="card-body">
<div class="eyebrow"><?= h($loan['lender_name'] ?: '-') ?></div>
<div class="card-title-lg"><?= h($loan['loan_name']) ?></div>
<div class="row g-3 mt-2">
<div class="col-6">
<div class="stat-label">현재 남은 원금</div>
<div class="stat-value"><?= won($loan['current_principal_balance']) ?></div>
</div>
<div class="col-6">
<div class="stat-label">연이자율</div>
<div class="stat-value"><?= h((string)$loan['annual_interest_rate']) ?>%</div>
</div>
<div class="col-6">
<div class="stat-label">남은 스케줄 원금</div>
<div class="stat-value"><?= won($summary['remaining_principal']) ?></div>
</div>
<div class="col-6">
<div class="stat-label">남은 스케줄 이자</div>
<div class="stat-value text-danger"><?= won($summary['remaining_interest']) ?></div>
</div>
</div>
<hr>
<form method="post" class="row g-3">
<div class="col-12">
<label class="form-label">출금계좌</label>
<select name="account_id" class="form-select">
<option value="">선택안함</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= $acc['id'] ?>"><?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label">중도상환일</label>
<input type="date" name="payment_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">중도상환 원금</label>
<input type="number" name="principal_amount" class="form-control" min="0" step="1" value="0" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">추가 이자</label>
<input type="number" name="interest_amount" class="form-control" min="0" step="1" value="0">
</div>
<div class="col-12 col-md-4">
<label class="form-label">수수료</label>
<input type="number" name="fee_amount" class="form-control" min="0" step="1" value="0">
</div>
<div class="col-12">
<label class="form-label">메모</label>
<input type="text" name="description" class="form-control" placeholder="예: 중도상환">
</div>
<div class="col-12">
<button class="btn btn-danger" onclick="return confirm('중도상환 처리 후 남은 회차 스케줄이 재계산됩니다. 진행하시겠습니까?');">
중도상환 실행
</button>
</div>
</form>
</div>
</div>
<div class="card finance-card mt-4">
<div class="card-body">
<h5 class="mb-3">중도상환 이력</h5>
<div class="mobile-scroll">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>일자</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">수수료</th>
<th class="text-end">합계</th>
</tr>
</thead>
<tbody>
<?php foreach ($prepayments as $p): ?>
<tr>
<td>
<?= h($p['payment_date']) ?>
<?php if (!empty($p['description'])): ?>
<div class="small text-secondary"><?= h($p['description']) ?></div>
<?php endif; ?>
</td>
<td class="text-end"><?= won($p['principal_amount']) ?></td>
<td class="text-end text-danger"><?= won($p['interest_amount']) ?></td>
<td class="text-end text-danger"><?= won($p['fee_amount']) ?></td>
<td class="text-end fw-bold"><?= won($p['total_amount']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$prepayments): ?>
<tr>
<td colspan="5" class="text-center text-secondary py-4">중도상환 이력이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card finance-card">
<div class="card-body">
<h5 class="mb-3">현재 스케줄</h5>
<div class="mobile-scroll">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>회차</th>
<th>납부일</th>
<th>구간</th>
<th class="text-end">기초원금</th>
<th class="text-end">원금</th>
<th class="text-end">이자</th>
<th class="text-end">합계</th>
<th class="text-end">기말원금</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach ($schedules as $s): ?>
<tr>
<td><?= $s['cycle_no'] ?></td>
<td><?= h($s['due_date']) ?></td>
<td><?= $s['payment_phase'] === 'grace' ? '거치' : '상환' ?></td>
<td class="text-end"><?= won($s['opening_principal']) ?></td>
<td class="text-end"><?= won($s['scheduled_principal']) ?></td>
<td class="text-end text-danger"><?= won($s['scheduled_interest']) ?></td>
<td class="text-end fw-bold"><?= won($s['scheduled_total']) ?></td>
<td class="text-end"><?= won($s['closing_principal']) ?></td>
<td>
<?php if ((int)$s['is_paid'] === 1): ?>
<span class="badge text-bg-success">납부완료</span>
<?php else: ?>
<span class="badge text-bg-secondary">미납</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$schedules): ?>
<tr>
<td colspan="9" class="text-center text-secondary py-4">스케줄이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+276
View File
@@ -0,0 +1,276 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/loan_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$mode = $_POST['mode'] ?? '';
if ($mode === 'delete') {
$loanId = (int)($_POST['loan_id'] ?? 0);
if ($loanId <= 0) {
throw new RuntimeException('대출 ID가 올바르지 않습니다.');
}
delete_loan_with_history($uid, $loanId);
$msg = '대출 및 관련 자동 생성 이력이 삭제되었습니다.';
}
if ($mode === 'reset_auto_history') {
$loanId = (int)($_POST['loan_id'] ?? 0);
if ($loanId <= 0) {
throw new RuntimeException('대출 ID가 올바르지 않습니다.');
}
reset_loan_auto_history_and_reapply($uid, $loanId, date('Y-m-d'));
$msg = '자동반영 이력을 초기화하고 오늘 기준으로 다시 반영했습니다.';
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$stmt = $pdo->prepare("
SELECT
l.*,
a.account_name,
a.institution_name,
(
SELECT COUNT(*)
FROM loan_schedules ls
WHERE ls.loan_id = l.id
AND ls.is_paid = 0
) AS unpaid_count,
(
SELECT COUNT(*)
FROM loan_schedules ls
WHERE ls.loan_id = l.id
AND ls.is_paid = 1
) AS paid_count,
(
SELECT COUNT(*)
FROM loan_schedules ls
WHERE ls.loan_id = l.id
) AS total_count,
(
SELECT MIN(ls.due_date)
FROM loan_schedules ls
WHERE ls.loan_id = l.id
AND ls.is_paid = 0
) AS next_due_date,
(
SELECT MAX(ls.due_date)
FROM loan_schedules ls
WHERE ls.loan_id = l.id
) AS maturity_due_date,
(
SELECT ls.scheduled_total
FROM loan_schedules ls
WHERE ls.loan_id = l.id
AND ls.is_paid = 0
ORDER BY ls.due_date ASC, ls.cycle_no ASC
LIMIT 1
) AS next_payment_amount,
(
SELECT ls.payment_phase
FROM loan_schedules ls
WHERE ls.loan_id = l.id
AND ls.is_paid = 0
ORDER BY ls.due_date ASC, ls.cycle_no ASC
LIMIT 1
) AS next_payment_phase,
(
SELECT COALESCE(SUM(ls.scheduled_total), 0)
FROM loan_schedules ls
WHERE ls.loan_id = l.id
AND ls.is_paid = 0
) AS remaining_total_amount
FROM loans l
LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.user_id = ?
ORDER BY
CASE WHEN l.status = 'active' THEN 0 ELSE 1 END,
l.id DESC
");
$stmt->execute([$uid]);
$loans = $stmt->fetchAll();
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>대출 목록</h2>
<a href="/loan_create.php" class="btn btn-primary">대출 등록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="row g-3">
<?php foreach ($loans as $loan): ?>
<?php
$summary = get_loan_remaining_summary((int)$loan['id']);
$paidCount = (int)($loan['paid_count'] ?? 0);
$totalCount = (int)($loan['total_count'] ?? 0);
$progress = $totalCount > 0 ? round(($paidCount / $totalCount) * 100, 1) : 0.0;
$phaseText = '-';
if (($loan['next_payment_phase'] ?? '') === 'grace') {
$phaseText = '거치 중';
} elseif (($loan['next_payment_phase'] ?? '') === 'repayment') {
$phaseText = '상환 중';
} elseif ($loan['status'] === 'closed') {
$phaseText = '종료';
}
?>
<div class="col-12 col-xxl-6">
<div class="card finance-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="eyebrow">
<?= h($loan['lender_name'] ?: '-') ?>
<?php if (!empty($loan['account_name'])): ?>
· <?= h($loan['account_name']) ?>
<?php endif; ?>
</div>
<div class="card-title-lg"><?= h($loan['loan_name']) ?></div>
</div>
<div class="text-end">
<span class="badge <?= $loan['status'] === 'active' ? 'text-bg-warning' : 'text-bg-success' ?>">
<?= $loan['status'] === 'active' ? '진행중' : '종료' ?>
</span>
<div class="small text-secondary mt-2"><?= h($phaseText) ?></div>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-6 col-md-3">
<div class="stat-label">대출원금</div>
<div class="stat-value"><?= won($loan['principal_amount']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">연이자율</div>
<div class="stat-value"><?= h((string)$loan['annual_interest_rate']) ?>%</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">거치기간</div>
<div class="stat-value"><?= h((string)$loan['grace_period_months']) ?>개월</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">상환기간</div>
<div class="stat-value"><?= h((string)$loan['repayment_months']) ?>개월</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 원금</div>
<div class="stat-value"><?= won($summary['remaining_principal']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 이자</div>
<div class="stat-value text-danger"><?= won($summary['remaining_interest']) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 총액</div>
<div class="stat-value"><?= won($loan['remaining_total_amount'] ?? 0) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">다음 회차 금액</div>
<div class="stat-value"><?= won($loan['next_payment_amount'] ?? 0) ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">다음 납부일</div>
<div class="stat-value"><?= h($loan['next_due_date'] ?: '-') ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">만기일자</div>
<div class="stat-value"><?= h($loan['maturity_due_date'] ?: '-') ?></div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">남은 회차 </div>
<div class="stat-value"><?= h((string)$loan['unpaid_count']) ?>회</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-label">진행률</div>
<div class="stat-value"><?= number_format($progress, 1) ?>%</div>
</div>
</div>
<div class="mt-3">
<div class="finance-progress">
<div class="progress-bar" role="progressbar" style="width: <?= $progress ?>%"></div>
</div>
<div class="small text-secondary mt-2">
완료 <?= h((string)$paidCount) ?>회 / 전체 <?= h((string)$totalCount) ?>회
</div>
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<a href="/loan_detail.php?id=<?= $loan['id'] ?>" class="btn btn-sm btn-outline-primary">상세/스케줄</a>
<a href="/loan_edit.php?id=<?= $loan['id'] ?>" class="btn btn-sm btn-outline-secondary">수정</a>
<?php if ($loan['status'] === 'active'): ?>
<a href="/loan_prepay.php?id=<?= $loan['id'] ?>" class="btn btn-sm btn-outline-danger">중도상환</a>
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="reset_auto_history">
<input type="hidden" name="loan_id" value="<?= $loan['id'] ?>">
<button class="btn btn-sm btn-outline-warning" onclick="return confirm('자동반영 이력과 자동 생성 거래를 삭제하고 오늘 기준으로 다시 반영합니다. 계속하시겠습니까?');">
자동반영 리셋 재적용
</button>
</form>
<?php endif; ?>
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="delete">
<input type="hidden" name="loan_id" value="<?= $loan['id'] ?>">
<button class="btn btn-sm btn-outline-dark" onclick="return confirm('대출, 상환 스케줄, 대출 납부이력, 자동 생성 거래내역까지 삭제됩니다. 계속하시겠습니까?');">
삭제
</button>
</form>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (!$loans): ?>
<div class="col-12">
<div class="card finance-card">
<div class="card-body empty-state">
등록된 대출이 없습니다.
</div>
</div>
</div>
<?php endif; ?>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+125
View File
@@ -0,0 +1,125 @@
<?php
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/helpers.php';
if (!empty($_SESSION['user_id'])) {
header('Location: /dashboard.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$username = trim($_POST['username'] ?? '');
$password = trim($_POST['password'] ?? '');
$remember = !empty($_POST['remember']);
if ($username === '' || $password === '') {
throw new RuntimeException('아이디와 비밀번호를 입력하세요.');
}
throttle_login_attempts($username);
$pdo = db();
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? LIMIT 1");
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new RuntimeException('로그인 정보가 올바르지 않습니다.');
}
clear_login_attempts($username);
login_user($user, $remember);
header('Location: /dashboard.php');
exit;
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리</title>
<meta name="description" content="수입·지출 가계부, 계좌·카드 관리, 대출 상환 일정, 카드 할부 청구, 자동 분류 규칙까지 한 번에 관리하는 개인 금융 통합 서비스 Financial.">
<meta name="keywords" content="가계부, 자산관리, 개인재무, 대출관리, 할부관리, 카드관리, 수입지출, 금융관리, Financial">
<meta name="author" content="Financial">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#0b2a66">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Financial">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<link rel="shortcut icon" href="/favicon.png?v=2">
<link rel="apple-touch-icon" href="/favicon.png?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Financial">
<meta property="og:title" content="Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리">
<meta property="og:description" content="계좌, 카드, 가계부, 대출, 할부를 한 곳에서 쉽고 체계적으로 관리하세요.">
<meta property="og:image" content="https://seo.chaegeon.com/favicon.png">
<meta property="og:url" content="https://seo.chaegeon.com/">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Financial">
<meta name="twitter:description" content="개인 금융 통합 관리 서비스">
<meta name="twitter:image" content="https://seo.chaegeon.com/favicon.png">
<link href="/assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="/assets/app.css" rel="stylesheet">
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
</head>
<body>
<div class="container py-5" style="max-width: 460px;">
<div class="card finance-card">
<div class="card-body p-4">
<h2 class="mb-4">로그인</h2>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<form method="post" class="row g-3">
<div class="col-12">
<label class="form-label">아이디</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="col-12">
<label class="form-label">비밀번호</label>
<input type="password" name="password" class="form-control" required>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="remember" id="remember" value="1">
<label class="form-check-label" for="remember">자동로그인</label>
</div>
</div>
<div class="col-12">
<button class="btn btn-primary w-100">로그인</button>
</div>
<div class="col-12 text-center">
<a href="/register.php" class="text-decoration-none">PIN 코드로 회원가입</a>
</div>
</form>
</div>
</div>
</div>
<script src="https://chaegeon.com/log/logger.js"></script>
<script src="/assets/pwa.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
logout_user();
header('Location: /login.php');
exit;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "Financial",
"short_name": "Financial",
"description": "개인 자산관리, 가계부, 대출, 할부 통합 관리",
"start_url": "/dashboard.php",
"scope": "/",
"display": "standalone",
"background_color": "#f3f6fb",
"theme_color": "#0b2a66",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.png?v=2",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon.png?v=2",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "거래 등록",
"short_name": "등록",
"url": "/transaction_create.php",
"description": "새 수입, 지출, 이체 거래를 등록합니다."
},
{
"name": "거래내역",
"short_name": "내역",
"url": "/transactions.php",
"description": "월별 거래내역을 확인합니다."
}
]
}
+264
View File
@@ -0,0 +1,264 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$mode = $_POST['mode'] ?? 'create';
if ($mode === 'create') {
$keyword = trim($_POST['keyword'] ?? '');
$categoryId = (int)($_POST['category_id'] ?? 0);
$priority = (int)($_POST['priority'] ?? 100);
if ($keyword === '') {
throw new RuntimeException('키워드를 입력하세요.');
}
if ($categoryId <= 0) {
throw new RuntimeException('카테고리를 선택하세요.');
}
$stmt = $pdo->prepare("
INSERT INTO merchant_pattern_rules
(user_id, pattern_text, normalized_pattern, match_type, category_id, priority, confidence, is_active, memo)
VALUES (?, ?, ?, 'contains', ?, ?, 0.90, 1, 'manual')
");
$normalized = mb_strtolower($keyword,'UTF-8');
$normalized = str_replace([' ','-','(',')','.',',','㈜'],'',$normalized);
$stmt->execute([$uid, $keyword, $normalized, $categoryId, $priority]);
$msg = '규칙이 추가되었습니다.';
}
if ($mode === 'update') {
$id = (int)($_POST['id'] ?? 0);
$keyword = trim($_POST['keyword'] ?? '');
$categoryId = (int)($_POST['category_id'] ?? 0);
$priority = (int)($_POST['priority'] ?? 100);
$isActive = isset($_POST['is_active']) ? 1 : 0;
if ($id <= 0) {
throw new RuntimeException('규칙 ID가 올바르지 않습니다.');
}
if ($keyword === '') {
throw new RuntimeException('키워드를 입력하세요.');
}
if ($categoryId <= 0) {
throw new RuntimeException('카테고리를 선택하세요.');
}
$stmt = $pdo->prepare("
UPDATE merchant_pattern_rules
SET pattern_text = ?, normalized_pattern = ?, category_id = ?, priority = ?, is_active = ?
WHERE id = ? AND user_id = ?
");
$normalized = mb_strtolower($keyword,'UTF-8');
$normalized = str_replace([' ','-','(',')','.',',','㈜'],'',$normalized);
$stmt->execute([
$keyword,
$normalized,
$categoryId,
$priority,
$isActive,
$id,
$uid
]);
$msg = '규칙이 수정되었습니다.';
}
if ($mode === 'delete') {
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
throw new RuntimeException('규칙 ID가 올바르지 않습니다.');
}
$stmt = $pdo->prepare("DELETE FROM merchant_pattern_rules WHERE id = ? AND user_id = ?");
$stmt->execute([$id, $uid]);
$msg = '규칙이 삭제되었습니다.';
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$stmt = $pdo->prepare("
SELECT id, category_type, name
FROM categories
WHERE user_id = ?
AND is_active = 1
ORDER BY category_type, sort_order, id
");
$stmt->execute([$uid]);
$categories = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT
r.*,
c.name AS category_name,
c.category_type
FROM merchant_pattern_rules r
JOIN categories c ON c.id = r.category_id
WHERE r.user_id = ?
ORDER BY r.priority ASC, r.id ASC
");
$stmt->execute([$uid]);
$rules = $stmt->fetchAll();
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>상호명 자동분류 규칙</h2>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($msg): ?>
<div class="alert alert-success"><?= h($msg) ?></div>
<?php endif; ?>
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card finance-card">
<div class="card-body">
<h5 class="mb-3">규칙 추가</h5>
<form method="post" class="row g-3">
<input type="hidden" name="mode" value="create">
<div class="col-12">
<label class="form-label">키워드</label>
<input type="text" name="keyword" class="form-control" placeholder="예: 스타벅스, 쿠팡, 배달의민족" required>
</div>
<div class="col-12">
<label class="form-label">카테고리</label>
<select name="category_id" class="form-select" required>
<option value="">선택하세요</option>
<?php foreach ($categories as $c): ?>
<option value="<?= $c['id'] ?>">
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label">우선순위</label>
<input type="number" name="priority" class="form-control" value="100" min="1" required>
<div class="form-text">숫자가 작을수록 우선 적용됩니다.</div>
</div>
<div class="col-12">
<button class="btn btn-primary">추가</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card finance-card">
<div class="card-body mobile-scroll">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>ID</th>
<th>키워드</th>
<th>카테고리</th>
<th>우선순위</th>
<th>활성</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<?php foreach ($rules as $rule): ?>
<tr>
<td><?= $rule['id'] ?></td>
<td><?= h($rule['pattern_text']) ?></td>
<td>[<?= h($rule['category_type']) ?>] <?= h($rule['category_name']) ?></td>
<td><?= $rule['priority'] ?></td>
<td><?= $rule['is_active'] ? 'Y' : 'N' ?></td>
<td class="text-nowrap">
<button
type="button"
class="btn btn-sm btn-outline-primary"
data-bs-toggle="collapse"
data-bs-target="#rule-edit-<?= $rule['id'] ?>"
>
수정
</button>
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="delete">
<input type="hidden" name="id" value="<?= $rule['id'] ?>">
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('삭제하시겠습니까?');">삭제</button>
</form>
</td>
</tr>
<tr class="collapse" id="rule-edit-<?= $rule['id'] ?>">
<td colspan="6">
<form method="post" class="row g-2 p-2">
<input type="hidden" name="mode" value="update">
<input type="hidden" name="id" value="<?= $rule['id'] ?>">
<div class="col-md-3">
<label class="form-label">키워드</label>
<input type="text" name="keyword" class="form-control" value="<?= h($rule['pattern_text']) ?>" required>
</div>
<div class="col-md-4">
<label class="form-label">카테고리</label>
<select name="category_id" class="form-select" required>
<?php foreach ($categories as $c): ?>
<option value="<?= $c['id'] ?>" <?= (int)$rule['category_id'] === (int)$c['id'] ? 'selected' : '' ?>>
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<label class="form-label">우선순위</label>
<input type="number" name="priority" class="form-control" value="<?= $rule['priority'] ?>" min="1" required>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="is_active_<?= $rule['id'] ?>" <?= $rule['is_active'] ? 'checked' : '' ?>>
<label class="form-check-label" for="is_active_<?= $rule['id'] ?>">활성</label>
</div>
</div>
<div class="col-md-1 d-flex align-items-end">
<button class="btn btn-primary btn-sm w-100">저장</button>
</div>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$rules): ?>
<tr>
<td colspan="6" class="text-center text-secondary py-5">등록된 규칙이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0b2a66">
<title>Financial 오프라인</title>
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<link href="/assets/app.css" rel="stylesheet">
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
</head>
<body>
<div class="container py-5" style="max-width: 560px;">
<div class="card finance-card">
<div class="card-body p-4">
<h1 class="card-title-lg mb-3">오프라인 상태입니다</h1>
<p class="text-secondary mb-0">금융 데이터는 기기에 저장하지 않습니다. 네트워크 연결 후 다시 접속하세요.</p>
</div>
</div>
</div>
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/recurring_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$msg = '';
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE user_id = ? AND is_active = 1 ORDER BY id");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$stmt = $pdo->prepare("SELECT * FROM categories WHERE user_id = ? AND is_active = 1 ORDER BY category_type, sort_order, id");
$stmt->execute([$uid]);
$categories = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$mode = $_POST['mode'] ?? 'create';
if ($mode === 'apply_month') {
$ym = $_POST['ym'] ?? date('Y-m');
$count = apply_recurring_transactions_for_month($uid, $ym, true);
$msg = $ym . ' 적용 완료: ' . $count . '건 입력';
} else {
$stmt = $pdo->prepare("
INSERT INTO recurring_transactions
(user_id, account_id, category_id, transaction_type, amount, day_of_month, merchant_name, description, related_account_id, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
");
$stmt->execute([
$uid,
(int)$_POST['account_id'],
(int)$_POST['category_id'],
$_POST['transaction_type'],
(float)$_POST['amount'],
(int)$_POST['day_of_month'],
trim($_POST['merchant_name'] ?? '') ?: null,
trim($_POST['description'] ?? '') ?: null,
!empty($_POST['related_account_id']) ? (int)$_POST['related_account_id'] : null,
]);
redirect('/recurring.php');
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$stmt = $pdo->prepare("SELECT rt.*, a.account_name, c.name AS category_name, ra.account_name AS related_name
FROM recurring_transactions rt
JOIN accounts a ON rt.account_id = a.id
JOIN categories c ON rt.category_id = c.id
LEFT JOIN accounts ra ON rt.related_account_id = ra.id
WHERE rt.user_id = ?
ORDER BY rt.day_of_month, rt.id");
$stmt->execute([$uid]);
$list = $stmt->fetchAll();
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>고정지출 / 고정거래</h2>
</div>
<?php if ($error): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<?php if ($msg): ?><div class="alert alert-success"><?= h($msg) ?></div><?php endif; ?>
<div class="d-flex gap-2 mb-4">
<form method="post" class="d-inline">
<input type="hidden" name="mode" value="apply_month">
<input type="hidden" name="ym" value="<?= date('Y-m') ?>">
<button class="btn btn-outline-primary" onclick="return confirm('이번 달 고정거래를 자동입력하시겠습니까?');">
이번 자동입력 실행
</button>
</form>
</div>
<div class="row g-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="post" class="row g-3">
<input type="hidden" name="mode" value="create">
<div class="col-12">
<label class="form-label">유형</label>
<select name="transaction_type" class="form-select">
<option value="expense">지출</option>
<option value="income">수입</option>
<option value="card_payment">카드대금납부</option>
</select>
</div>
<div class="col-12">
<label class="form-label"> 계좌</label>
<select name="account_id" class="form-select">
<?php foreach ($accounts as $a): ?>
<option value="<?= $a['id'] ?>"><?= h($a['account_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label">관련 계좌</label>
<select name="related_account_id" class="form-select">
<option value="">선택안함</option>
<?php foreach ($accounts as $a): ?>
<option value="<?= $a['id'] ?>"><?= h($a['account_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label">카테고리</label>
<select name="category_id" class="form-select">
<?php foreach ($categories as $c): ?>
<option value="<?= $c['id'] ?>">[<?= h($c['category_type']) ?>] <?= h($c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-6">
<label class="form-label">금액</label>
<input type="number" name="amount" class="form-control" required>
</div>
<div class="col-6">
<label class="form-label">매월 일자</label>
<input type="number" name="day_of_month" min="1" max="31" class="form-control" required>
</div>
<div class="col-12">
<label class="form-label">사용처</label>
<input type="text" name="merchant_name" class="form-control">
</div>
<div class="col-12">
<label class="form-label">메모</label>
<input type="text" name="description" class="form-control">
</div>
<div class="col-12">
<button class="btn btn-primary">등록</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-body mobile-scroll">
<table class="table mb-0">
<thead>
<tr>
<th>일자</th>
<th>유형</th>
<th>계좌</th>
<th>관련</th>
<th>카테고리</th>
<th>금액</th>
<th>마지막 적용월</th>
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<tr>
<td><?= $row['day_of_month'] ?>일</td>
<td><?= h($row['transaction_type']) ?></td>
<td><?= h($row['account_name']) ?></td>
<td><?= h($row['related_name'] ?? '-') ?></td>
<td><?= h($row['category_name']) ?></td>
<td><?= won($row['amount']) ?></td>
<td><?= h($row['last_applied_ym'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+139
View File
@@ -0,0 +1,139 @@
<?php
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/helpers.php';
if (!empty($_SESSION['user_id'])) {
header('Location: /dashboard.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$pin = trim($_POST['pin'] ?? '');
$username = trim($_POST['username'] ?? '');
$password = trim($_POST['password'] ?? '');
$passwordConfirm = trim($_POST['password_confirm'] ?? '');
$requiredPin = getenv('FINANCIAL_REGISTER_PIN') ?: '0010';
if ($requiredPin === '' || !hash_equals($requiredPin, $pin)) {
throw new RuntimeException('PIN 코드가 올바르지 않습니다.');
}
if ($username === '' || $password === '') {
throw new RuntimeException('아이디와 비밀번호를 입력하세요.');
}
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
throw new RuntimeException('아이디는 영문, 숫자, 밑줄 3~30자만 가능합니다.');
}
if (mb_strlen($password, 'UTF-8') < 4) {
throw new RuntimeException('비밀번호는 4자 이상 입력하세요.');
}
if ($password !== $passwordConfirm) {
throw new RuntimeException('비밀번호 확인이 일치하지 않습니다.');
}
$pdo = db();
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ? LIMIT 1");
$stmt->execute([$username]);
if ($stmt->fetch()) {
throw new RuntimeException('이미 사용 중인 아이디입니다.');
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("
INSERT INTO users (username, password_hash, created_at)
VALUES (?, ?, NOW())
");
$stmt->execute([$username, $hash]);
$userId = (int)$pdo->lastInsertId();
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ? LIMIT 1");
$stmt->execute([$userId]);
$user = $stmt->fetch();
login_user($user, true);
header('Location: /dashboard.php');
exit;
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입</title>
<meta name="theme-color" content="#0b2a66">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Financial">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<link rel="apple-touch-icon" href="/favicon.png?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<link href="/assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="/assets/app.css" rel="stylesheet">
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
</head>
<body>
<div class="container py-5" style="max-width: 460px;">
<div class="card finance-card">
<div class="card-body p-4">
<h2 class="mb-4">회원가입</h2>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<form method="post" class="row g-3">
<div class="col-12">
<label class="form-label">가입 PIN 코드</label>
<input type="password" name="pin" class="form-control" inputmode="numeric" required>
<div class="form-text">관리자가 공유한 PIN 코드를 입력하세요.</div>
</div>
<div class="col-12">
<label class="form-label">아이디</label>
<input type="text" name="username" class="form-control" required autocomplete="username">
</div>
<div class="col-12">
<label class="form-label">비밀번호</label>
<input type="password" name="password" class="form-control" required autocomplete="new-password">
</div>
<div class="col-12">
<label class="form-label">비밀번호 확인</label>
<input type="password" name="password_confirm" class="form-control" required autocomplete="new-password">
</div>
<div class="col-12">
<button class="btn btn-primary w-100">가입하기</button>
</div>
<div class="col-12 text-center">
<a href="/login.php" class="text-decoration-none">이미 계정이 있습니다</a>
</div>
</form>
</div>
</div>
</div>
<script type="text/javascript" src="https://chaegeon.com/log/logger.js"></script>
<script src="/assets/pwa.js"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
const CACHE_NAME = 'financial-static-v1';
const STATIC_ASSETS = [
'/offline.html',
'/assets/vendor/bootstrap.min.css',
'/assets/vendor/bootstrap.bundle.min.js',
'/assets/vendor/chart.umd.js',
'/assets/app.css',
'/assets/pwa.js',
'/favicon.png?v=2',
'/manifest.webmanifest'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.catch(() => undefined)
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
))
);
self.clients.claim();
});
self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== location.origin) {
return;
}
if (url.pathname.startsWith('/api/')) {
return;
}
if (url.pathname.startsWith('/assets/vendor/') || url.pathname === '/assets/app.css' || url.pathname === '/assets/pwa.js' || url.pathname === '/manifest.webmanifest' || url.pathname === '/favicon.png') {
event.respondWith(
caches.match(request).then(cached => cached || fetch(request))
);
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
}
});
+880
View File
@@ -0,0 +1,880 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/transaction_service.php';
require_once __DIR__ . '/../app/lib/transaction_form_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$error = '';
$success = '';
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE user_id = ?
AND is_active = 1
ORDER BY
FIELD(account_type, 'bank', 'card', 'cash', 'other'),
id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT *
FROM categories
WHERE user_id = ?
AND is_active = 1
ORDER BY category_type, sort_order, id
");
$stmt->execute([$uid]);
$categories = $stmt->fetchAll();
$defaults = get_transaction_form_defaults($uid);
$lastForm = $_SESSION['tx_create_last_form'] ?? [];
$form = [
'transaction_type' => 'expense',
'account_id' => (int)($defaults['default_account_id'] ?? 0),
'related_account_id' => 0,
'category_id' => (int)($defaults['default_expense_category_id'] ?? 0),
'amount' => '',
'transaction_date' => date('Y-m-d'),
'merchant_name' => '',
'description' => '',
'is_installment' => 0,
'installment_months' => '',
'installment_interest_rate' => '0',
'installment_interest_total' => '',
'installment_total_billed' => '',
'continue_after_save' => !empty($defaults['continue_after_save']) ? 1 : 0,
'keep_last_values' => !empty($defaults['keep_last_values']) ? 1 : 0,
'save_as_defaults' => 0,
];
if (!empty($defaults['keep_last_values']) && !empty($lastForm)) {
$form = array_merge($form, $lastForm);
}
$copyId = (int)($_GET['copy_id'] ?? 0);
if ($copyId > 0 && $_SERVER['REQUEST_METHOD'] !== 'POST') {
$stmt = $pdo->prepare("
SELECT *
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$copyId, $uid]);
$copy = $stmt->fetch();
if ($copy) {
$form = array_merge($form, [
'transaction_type' => $copy['transaction_type'],
'account_id' => (int)$copy['account_id'],
'related_account_id' => (int)($copy['related_account_id'] ?? 0),
'category_id' => (int)$copy['category_id'],
'amount' => number_format((float)$copy['amount'], 0),
'transaction_date' => date('Y-m-d'),
'merchant_name' => (string)($copy['merchant_name'] ?? ''),
'description' => (string)($copy['description'] ?? ''),
'is_installment' => (int)($copy['is_installment'] ?? 0),
'installment_months' => $copy['installment_months'] ?? '',
'installment_interest_rate' => (string)($copy['installment_interest_rate'] ?? '0'),
'installment_interest_total' => !empty($copy['installment_interest_total']) ? number_format((float)$copy['installment_interest_total'], 0) : '',
'installment_total_billed' => !empty($copy['installment_total_billed']) ? number_format((float)$copy['installment_total_billed'], 0) : '',
'continue_after_save' => 0,
'keep_last_values' => 0,
'save_as_defaults' => 0,
]);
$success = '기존 거래를 복사했습니다. 날짜/메모 수정 후 저장하세요.';
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$form = [
'transaction_type' => $_POST['transaction_type'] ?? 'expense',
'account_id' => (int)($_POST['account_id'] ?? 0),
'related_account_id' => !empty($_POST['related_account_id']) ? (int)$_POST['related_account_id'] : 0,
'category_id' => (int)($_POST['category_id'] ?? 0),
'amount' => trim((string)($_POST['amount'] ?? '')),
'transaction_date' => $_POST['transaction_date'] ?? date('Y-m-d'),
'merchant_name' => trim((string)($_POST['merchant_name'] ?? '')),
'description' => trim((string)($_POST['description'] ?? '')),
'is_installment' => (int)($_POST['is_installment'] ?? 0),
'installment_months' => trim((string)($_POST['installment_months'] ?? '')),
'installment_interest_rate' => trim((string)($_POST['installment_interest_rate'] ?? '0')),
'installment_interest_total' => trim((string)($_POST['installment_interest_total'] ?? '')),
'installment_total_billed' => trim((string)($_POST['installment_total_billed'] ?? '')),
'continue_after_save' => !empty($_POST['continue_after_save']) ? 1 : 0,
'keep_last_values' => !empty($_POST['keep_last_values']) ? 1 : 0,
'save_as_defaults' => !empty($_POST['save_as_defaults']) ? 1 : 0,
];
$transactionType = $form['transaction_type'];
$accountId = (int)$form['account_id'];
$categoryId = (int)$form['category_id'];
$amount = (float)str_replace(',', '', $form['amount']);
$transactionDate = $form['transaction_date'];
$merchantName = $form['merchant_name'];
$description = $form['description'];
$relatedAccountId = $form['related_account_id'] > 0 ? (int)$form['related_account_id'] : null;
$isInstallment = (int)$form['is_installment'];
$installmentMonths = $form['installment_months'] !== '' ? (int)$form['installment_months'] : null;
$installmentInterestRate = $form['installment_interest_rate'] !== '' ? (float)$form['installment_interest_rate'] : 0.0;
$installmentInterestTotal = $form['installment_interest_total'] !== ''
? (float)str_replace(',', '', $form['installment_interest_total'])
: null;
$installmentTotalBilled = $form['installment_total_billed'] !== ''
? (float)str_replace(',', '', $form['installment_total_billed'])
: null;
if ($amount <= 0) {
throw new RuntimeException('금액은 0보다 커야 합니다.');
}
if (!in_array($transactionType, ['income', 'expense', 'transfer', 'card_payment'], true)) {
throw new RuntimeException('거래 유형이 올바르지 않습니다.');
}
if ($accountId <= 0) {
throw new RuntimeException('주 계좌/카드를 선택하세요.');
}
if ($categoryId <= 0) {
throw new RuntimeException('카테고리를 선택하세요.');
}
if (in_array($transactionType, ['transfer', 'card_payment'], true) && !$relatedAccountId) {
throw new RuntimeException('관련 계좌를 선택해야 합니다.');
}
if ($relatedAccountId && $relatedAccountId === $accountId) {
throw new RuntimeException('주 계좌와 관련 계좌는 같을 수 없습니다.');
}
if ($transactionType !== 'expense') {
$isInstallment = 0;
$installmentMonths = null;
$installmentInterestRate = 0.0;
$installmentInterestTotal = null;
$installmentTotalBilled = null;
}
create_transaction([
'user_id' => $uid,
'account_id' => $accountId,
'category_id' => $categoryId,
'transaction_type' => $transactionType,
'amount' => $amount,
'transaction_date' => $transactionDate,
'merchant_name' => $merchantName !== '' ? $merchantName : null,
'description' => $description !== '' ? $description : null,
'related_account_id' => $relatedAccountId,
'is_installment' => $isInstallment,
'installment_months' => $installmentMonths,
'installment_interest_rate' => $installmentInterestRate,
'installment_interest_total' => $installmentInterestTotal,
'installment_total_billed' => $installmentTotalBilled,
]);
if ($form['save_as_defaults']) {
$save = [
'default_account_id' => 0,
'default_card_account_id' => 0,
'default_income_category_id' => 0,
'default_expense_category_id' => 0,
'default_transfer_category_id' => 0,
'default_card_payment_category_id' => 0,
'keep_last_values' => $form['keep_last_values'],
'continue_after_save' => $form['continue_after_save'],
];
if ($transactionType === 'income') {
$save['default_account_id'] = $accountId;
$save['default_income_category_id'] = $categoryId;
} elseif ($transactionType === 'expense') {
$save['default_account_id'] = $accountId;
$save['default_expense_category_id'] = $categoryId;
} elseif ($transactionType === 'transfer') {
$save['default_account_id'] = $accountId;
$save['default_transfer_category_id'] = $categoryId;
} elseif ($transactionType === 'card_payment') {
$save['default_account_id'] = $accountId;
$save['default_card_payment_category_id'] = $categoryId;
}
foreach ($accounts as $acc) {
if ((int)$acc['id'] === $accountId && $acc['account_type'] === 'card') {
$save['default_card_account_id'] = $accountId;
break;
}
}
save_transaction_form_defaults($uid, array_merge($defaults, $save));
}
if ($form['keep_last_values']) {
$_SESSION['tx_create_last_form'] = [
'transaction_type' => $transactionType,
'account_id' => $accountId,
'related_account_id' => $relatedAccountId ?: 0,
'category_id' => $categoryId,
'amount' => '',
'transaction_date' => date('Y-m-d'),
'merchant_name' => $merchantName,
'description' => '',
'is_installment' => $isInstallment,
'installment_months' => $installmentMonths ?? '',
'installment_interest_rate' => (string)$installmentInterestRate,
'installment_interest_total' => '',
'installment_total_billed' => '',
'continue_after_save' => $form['continue_after_save'],
'keep_last_values' => $form['keep_last_values'],
'save_as_defaults' => 0,
];
} else {
unset($_SESSION['tx_create_last_form']);
}
if ($form['continue_after_save']) {
$success = '저장되었습니다. 계속 등록할 수 있습니다.';
$form['amount'] = '';
$form['description'] = '';
$form['transaction_date'] = date('Y-m-d');
$form['installment_interest_total'] = '';
$form['installment_total_billed'] = '';
} else {
redirect('/transactions.php');
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$accountsJson = [];
foreach ($accounts as $acc) {
$accountsJson[] = [
'id' => (int)$acc['id'],
'name' => $acc['account_name'],
'type' => $acc['account_type'],
'card_kind' => $acc['card_kind'] ?? null,
'billing_day' => $acc['billing_day'] ?? null,
'payment_day' => $acc['payment_day'] ?? null,
'use_credit_grace_period' => !empty($acc['use_credit_grace_period']) ? 1 : 0,
];
}
$categoriesJson = [];
foreach ($categories as $cat) {
$categoriesJson[] = [
'id' => (int)$cat['id'],
'type' => $cat['category_type'],
'name' => $cat['name'],
];
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>거래 등록</h2>
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?= h($success) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3" id="transactionForm" autocomplete="off">
<div class="col-12 col-md-3">
<label class="form-label">거래 유형</label>
<select name="transaction_type" id="transaction_type" class="form-select" required>
<option value="expense" <?= $form['transaction_type'] === 'expense' ? 'selected' : '' ?>>지출</option>
<option value="income" <?= $form['transaction_type'] === 'income' ? 'selected' : '' ?>>수입</option>
<option value="transfer" <?= $form['transaction_type'] === 'transfer' ? 'selected' : '' ?>>계좌이체</option>
<option value="card_payment" <?= $form['transaction_type'] === 'card_payment' ? 'selected' : '' ?>>카드대금납부</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label"> 계좌/카드</label>
<select name="account_id" id="account_id" class="form-select" required>
<option value="0">선택하세요</option>
<?php foreach ($accounts as $a): ?>
<option
value="<?= $a['id'] ?>"
data-account-type="<?= h($a['account_type']) ?>"
<?= (int)$form['account_id'] === (int)$a['id'] ? 'selected' : '' ?>
>
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
</option>
<?php endforeach; ?>
</select>
<div class="form-text" id="account_help_text"></div>
</div>
<div class="col-12 col-md-3">
<label class="form-label">관련 계좌</label>
<select name="related_account_id" id="related_account_id" class="form-select">
<option value="">선택안함</option>
<?php foreach ($accounts as $a): ?>
<option
value="<?= $a['id'] ?>"
data-account-type="<?= h($a['account_type']) ?>"
<?= (int)$form['related_account_id'] === (int)$a['id'] ? 'selected' : '' ?>
>
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
</option>
<?php endforeach; ?>
</select>
<div class="form-text" id="related_account_help">계좌이체/카드대금납부 선택</div>
</div>
<div class="col-12 col-md-3">
<label class="form-label">카테고리</label>
<select name="category_id" id="category_id" class="form-select" required>
<option value="0">선택하세요</option>
<?php foreach ($categories as $c): ?>
<option
value="<?= $c['id'] ?>"
data-type="<?= h($c['category_type']) ?>"
<?= (int)$form['category_id'] === (int)$c['id'] ? 'selected' : '' ?>
>
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">금액</label>
<input
type="text"
name="amount"
id="amount"
class="form-control"
value="<?= h($form['amount']) ?>"
inputmode="numeric"
required
>
<div class="mt-2 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="10000">+1</button>
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="50000">+5</button>
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="100000">+10</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="amount_clear_btn">초기화</button>
</div>
</div>
<div class="col-12 col-md-3">
<label class="form-label">거래일</label>
<input type="date" name="transaction_date" class="form-control" value="<?= h($form['transaction_date']) ?>" required>
</div>
<div class="col-12 col-md-3">
<label class="form-label">결제 방식</label>
<select name="is_installment" id="is_installment" class="form-select">
<option value="0" <?= (int)$form['is_installment'] === 0 ? 'selected' : '' ?>>일시불</option>
<option value="1" <?= (int)$form['is_installment'] === 1 ? 'selected' : '' ?>>할부</option>
</select>
</div>
<div class="col-12 col-md-3" id="installment_months_wrap" style="display:none;">
<label class="form-label">할부 개월</label>
<select name="installment_months" id="installment_months" class="form-select">
<option value="">선택하세요</option>
<?php foreach ([2,3,4,5,6,10,12,18,24] as $m): ?>
<option value="<?= $m ?>" <?= (string)$form['installment_months'] === (string)$m ? 'selected' : '' ?>>
<?= intvalf($m) ?>개월
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3" id="installment_rate_wrap" style="display:none;">
<label class="form-label">연이자율(%)</label>
<input
type="number"
name="installment_interest_rate"
id="installment_interest_rate"
class="form-control"
step="0.01"
min="0"
value="<?= h($form['installment_interest_rate']) ?>"
>
</div>
<div class="col-12 col-md-3" id="installment_interest_wrap" style="display:none;">
<label class="form-label"> 할부이자</label>
<input
type="text"
name="installment_interest_total"
id="installment_interest_total"
class="form-control"
inputmode="numeric"
value="<?= h($form['installment_interest_total']) ?>"
>
<div class="form-text">비우면 연이자율로 자동 계산</div>
</div>
<div class="col-12 col-md-3" id="installment_total_wrap" style="display:none;">
<label class="form-label"> 청구금액</label>
<input
type="text"
name="installment_total_billed"
id="installment_total_billed"
class="form-control"
inputmode="numeric"
value="<?= h($form['installment_total_billed']) ?>"
>
<div class="form-text">비우면 원금+이자로 자동 계산</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">사용처 / 거래처</label>
<input
type="text"
name="merchant_name"
id="merchant_name"
class="form-control"
placeholder="예: 스타벅스, 쿠팡, 급여"
autocomplete="off"
value="<?= h($form['merchant_name']) ?>"
>
<div class="form-text">상호명 입력 카테고리를 자동 추천합니다.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">메모</label>
<input
type="text"
name="description"
class="form-control"
placeholder="간단한 설명"
value="<?= h($form['description']) ?>"
>
</div>
<div class="col-12">
<div id="category_suggest_box" class="alert alert-info py-2 px-3 d-none mb-0"></div>
</div>
<div class="col-12">
<div class="loan-create-highlight">
<div class="title">등록 편의 설정</div>
<div class="desc">자주 쓰는 카드/계좌와 입력값을 계속 재사용할 있습니다.</div>
<div class="row g-3 mt-1">
<div class="col-12 col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="continue_after_save" id="continue_after_save" value="1" <?= !empty($form['continue_after_save']) ? 'checked' : '' ?>>
<label class="form-check-label" for="continue_after_save">저장 계속 등록</label>
</div>
</div>
<div class="col-12 col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="keep_last_values" id="keep_last_values" value="1" <?= !empty($form['keep_last_values']) ? 'checked' : '' ?>>
<label class="form-check-label" for="keep_last_values">마지막 입력값 유지</label>
</div>
</div>
<div class="col-12 col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="save_as_defaults" id="save_as_defaults" value="1">
<label class="form-check-label" for="save_as_defaults">현재 선택을 기본값으로 저장</label>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button class="btn btn-primary">저장</button>
<button type="submit" name="continue_after_save" value="1" class="btn btn-outline-primary">저장 계속 등록</button>
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
</div>
</form>
</div>
</div>
<script>
(function () {
const ACCOUNTS = <?= json_encode($accountsJson, JSON_UNESCAPED_UNICODE) ?>;
const CATEGORIES = <?= json_encode($categoriesJson, JSON_UNESCAPED_UNICODE) ?>;
const transactionTypeEl = document.getElementById('transaction_type');
const accountEl = document.getElementById('account_id');
const accountHelpTextEl = document.getElementById('account_help_text');
const relatedAccountEl = document.getElementById('related_account_id');
const relatedHelpEl = document.getElementById('related_account_help');
const categoryEl = document.getElementById('category_id');
const merchantEl = document.getElementById('merchant_name');
const suggestBoxEl = document.getElementById('category_suggest_box');
const installmentEl = document.getElementById('is_installment');
const installmentWrapEl = document.getElementById('installment_months_wrap');
const installmentMonthsEl = document.getElementById('installment_months');
const installmentRateWrapEl = document.getElementById('installment_rate_wrap');
const installmentRateEl = document.getElementById('installment_interest_rate');
const installmentInterestWrapEl = document.getElementById('installment_interest_wrap');
const installmentTotalWrapEl = document.getElementById('installment_total_wrap');
const installmentInterestEl = document.getElementById('installment_interest_total');
const installmentTotalEl = document.getElementById('installment_total_billed');
const amountEl = document.getElementById('amount');
const quickAmtButtons = document.querySelectorAll('.quick-amt');
const amountClearBtn = document.getElementById('amount_clear_btn');
const allCategoryOptions = Array.from(categoryEl.querySelectorAll('option')).map(option => ({
value: option.value,
text: option.textContent,
type: option.dataset.type
}));
let suggestTimer = null;
let lastSuggestedCategoryId = null;
function parseNumber(value) {
const normalized = String(value || '').replace(/,/g, '').trim();
const n = parseFloat(normalized);
return isNaN(n) ? 0 : n;
}
function formatWithComma(value) {
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.]/g, '');
if (normalized === '') return '';
const parts = normalized.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
function selectedAccount() {
const id = parseInt(accountEl.value || '0', 10);
return ACCOUNTS.find(a => a.id === id) || null;
}
function mapTransactionTypeToCategoryType(transactionType) {
if (transactionType === 'income') return 'income';
if (transactionType === 'expense') return 'expense';
return 'transfer';
}
function rebuildCategoryOptions() {
const transactionType = transactionTypeEl.value;
const categoryType = mapTransactionTypeToCategoryType(transactionType);
const currentValue = categoryEl.value;
const filtered = allCategoryOptions.filter(item => item.type === categoryType);
categoryEl.innerHTML = '<option value="0">선택하세요</option>';
filtered.forEach((item) => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.text;
if (item.value === currentValue) option.selected = true;
categoryEl.appendChild(option);
});
if (![...categoryEl.options].some(opt => opt.value === currentValue)) {
categoryEl.value = '0';
}
}
function filterRelatedAccountOptions() {
const transactionType = transactionTypeEl.value;
const mainAccountType = accountEl.selectedOptions[0]?.dataset.accountType || '';
const mainAccountId = accountEl.value;
Array.from(relatedAccountEl.options).forEach(option => {
if (option.value === '') {
option.hidden = false;
return;
}
const optionType = option.dataset.accountType || '';
let visible = true;
if (option.value === mainAccountId) {
visible = false;
} else if (transactionType === 'transfer') {
visible =
['bank', 'cash', 'other'].includes(mainAccountType) &&
['bank', 'cash', 'other'].includes(optionType);
} else if (transactionType === 'card_payment') {
visible =
['bank', 'cash', 'other'].includes(mainAccountType) &&
optionType === 'card';
}
option.hidden = !visible;
if (!visible && option.selected) {
relatedAccountEl.value = '';
}
});
if (transactionType === 'transfer') {
relatedAccountEl.disabled = false;
relatedAccountEl.required = true;
relatedHelpEl.textContent = '같은 주 계좌는 제외되고, 입금 계좌만 선택 가능합니다.';
} else if (transactionType === 'card_payment') {
relatedAccountEl.disabled = false;
relatedAccountEl.required = true;
relatedHelpEl.textContent = '관련 계좌에는 카드 계정만 선택 가능합니다.';
} else {
relatedAccountEl.disabled = true;
relatedAccountEl.required = false;
relatedAccountEl.value = '';
relatedHelpEl.textContent = '계좌이체/카드대금납부 시 선택';
}
}
function updateAccountHelpText() {
const acc = selectedAccount();
if (!acc) {
accountHelpTextEl.textContent = '';
return;
}
if (acc.type === 'card' && acc.card_kind === 'check') {
accountHelpTextEl.textContent = '체크카드 · 즉시 출금 기준';
return;
}
if (acc.type === 'card' && acc.card_kind === 'credit' && acc.use_credit_grace_period) {
const billingDay = acc.billing_day ? `${acc.billing_day}일` : '-';
const paymentDay = acc.payment_day ? `${acc.payment_day}일` : '-';
accountHelpTextEl.textContent = `신용카드 · 결제기준일 ${billingDay} / 납부일 ${paymentDay}`;
return;
}
accountHelpTextEl.textContent = '';
}
function calculateInterest() {
const principal = parseNumber(amountEl.value);
const months = parseInt(installmentMonthsEl.value || '0', 10) || 0;
const annualRate = parseFloat(installmentRateEl.value || '0') || 0;
if (principal <= 0 || months <= 1 || annualRate <= 0) {
if (!installmentInterestEl.dataset.userEdited) {
installmentInterestEl.value = '';
}
syncInstallmentTotal();
return;
}
const monthlyRate = (annualRate / 100) / 12;
const averageOutstanding = principal / 2;
const interestTotal = Math.round((averageOutstanding * monthlyRate * months));
if (!installmentInterestEl.dataset.userEdited || installmentInterestEl.value === '') {
installmentInterestEl.value = formatWithComma(interestTotal);
}
syncInstallmentTotal();
}
function syncInstallmentTotal() {
const principal = parseNumber(amountEl.value);
const interest = parseNumber(installmentInterestEl.value);
const calculated = principal + interest;
if (!installmentTotalEl.dataset.userEdited || installmentTotalEl.value === '') {
installmentTotalEl.value = calculated > 0 ? formatWithComma(calculated) : '';
}
}
function toggleInstallmentFields() {
const transactionType = transactionTypeEl.value;
const acc = selectedAccount();
const mainAccountType = acc ? acc.type : '';
const isExpenseCard = (transactionType === 'expense' && mainAccountType === 'card');
if (isExpenseCard) {
installmentEl.disabled = false;
if (String(installmentEl.value) === '1') {
installmentWrapEl.style.display = '';
installmentRateWrapEl.style.display = '';
installmentInterestWrapEl.style.display = '';
installmentTotalWrapEl.style.display = '';
installmentMonthsEl.required = true;
calculateInterest();
} else {
installmentWrapEl.style.display = 'none';
installmentRateWrapEl.style.display = 'none';
installmentInterestWrapEl.style.display = 'none';
installmentTotalWrapEl.style.display = 'none';
installmentMonthsEl.required = false;
installmentMonthsEl.value = '';
installmentRateEl.value = '0';
installmentInterestEl.value = '';
installmentTotalEl.value = '';
installmentInterestEl.dataset.userEdited = '';
installmentTotalEl.dataset.userEdited = '';
}
} else {
installmentEl.value = '0';
installmentEl.disabled = true;
installmentWrapEl.style.display = 'none';
installmentRateWrapEl.style.display = 'none';
installmentInterestWrapEl.style.display = 'none';
installmentTotalWrapEl.style.display = 'none';
installmentMonthsEl.required = false;
installmentMonthsEl.value = '';
installmentRateEl.value = '0';
installmentInterestEl.value = '';
installmentTotalEl.value = '';
installmentInterestEl.dataset.userEdited = '';
installmentTotalEl.dataset.userEdited = '';
}
}
function hideSuggestBox() {
suggestBoxEl.classList.add('d-none');
suggestBoxEl.textContent = '';
lastSuggestedCategoryId = null;
}
function showSuggestBox(message) {
suggestBoxEl.textContent = message;
suggestBoxEl.classList.remove('d-none');
}
async function fetchCategorySuggestion() {
const merchantName = merchantEl.value.trim();
const transactionType = transactionTypeEl.value;
const categoryType = mapTransactionTypeToCategoryType(transactionType);
if (merchantName.length < 2) {
hideSuggestBox();
return;
}
try {
const url = `/api/category_suggest.php?merchant_name=${encodeURIComponent(merchantName)}&transaction_type=${encodeURIComponent(categoryType)}`;
const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
hideSuggestBox();
return;
}
const result = await response.json();
if (!result.ok || !result.found) {
hideSuggestBox();
return;
}
const targetOption = Array.from(categoryEl.options).find(option => option.value === String(result.category_id));
if (!targetOption) {
hideSuggestBox();
return;
}
categoryEl.value = String(result.category_id);
lastSuggestedCategoryId = String(result.category_id);
showSuggestBox(`자동 추천: ${result.category_name} (규칙: ${result.keyword ?? result.pattern_text ?? ''})`);
} catch (error) {
hideSuggestBox();
}
}
function scheduleSuggestion() {
clearTimeout(suggestTimer);
suggestTimer = setTimeout(fetchCategorySuggestion, 250);
}
quickAmtButtons.forEach(btn => {
btn.addEventListener('click', function () {
const current = parseNumber(amountEl.value);
const add = parseInt(btn.dataset.add || '0', 10) || 0;
amountEl.value = formatWithComma(current + add);
calculateInterest();
});
});
amountClearBtn.addEventListener('click', function () {
amountEl.value = '';
calculateInterest();
amountEl.focus();
});
amountEl.addEventListener('input', function () {
const cursorAtEnd = this.selectionStart === this.value.length;
this.value = formatWithComma(this.value);
if (cursorAtEnd) {
this.setSelectionRange(this.value.length, this.value.length);
}
calculateInterest();
});
installmentInterestEl.addEventListener('input', function () {
installmentInterestEl.dataset.userEdited = '1';
installmentInterestEl.value = formatWithComma(installmentInterestEl.value);
syncInstallmentTotal();
});
installmentTotalEl.addEventListener('input', function () {
installmentTotalEl.dataset.userEdited = '1';
installmentTotalEl.value = formatWithComma(installmentTotalEl.value);
});
transactionTypeEl.addEventListener('change', function () {
rebuildCategoryOptions();
filterRelatedAccountOptions();
toggleInstallmentFields();
scheduleSuggestion();
});
accountEl.addEventListener('change', function () {
updateAccountHelpText();
filterRelatedAccountOptions();
toggleInstallmentFields();
});
installmentEl.addEventListener('change', toggleInstallmentFields);
installmentMonthsEl.addEventListener('change', calculateInterest);
installmentRateEl.addEventListener('input', calculateInterest);
merchantEl.addEventListener('input', scheduleSuggestion);
categoryEl.addEventListener('change', function () {
if (lastSuggestedCategoryId && categoryEl.value !== lastSuggestedCategoryId) {
hideSuggestBox();
}
});
document.getElementById('transactionForm').addEventListener('submit', function () {
amountEl.value = String(parseNumber(amountEl.value));
installmentInterestEl.value = installmentInterestEl.value === '' ? '' : String(parseNumber(installmentInterestEl.value));
installmentTotalEl.value = installmentTotalEl.value === '' ? '' : String(parseNumber(installmentTotalEl.value));
});
rebuildCategoryOptions();
updateAccountHelpText();
filterRelatedAccountOptions();
toggleInstallmentFields();
})();
</script>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+19
View File
@@ -0,0 +1,19 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/transaction_service.php';
check_auth();
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
exit('Method not allowed.');
}
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
delete_transaction($id, user_id());
}
header('Location: /transactions.php');
exit;
+656
View File
@@ -0,0 +1,656 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
require_once __DIR__ . '/../app/lib/transaction_service.php';
check_auth();
$pdo = db();
$uid = user_id();
$id = (int)($_GET['id'] ?? 0);
$error = '';
$stmt = $pdo->prepare("SELECT * FROM transactions WHERE id = ? AND user_id = ?");
$stmt->execute([$id, $uid]);
$item = $stmt->fetch();
if (!$item) {
exit('거래를 찾을 수 없습니다.');
}
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE user_id = ?
AND is_active = 1
ORDER BY FIELD(account_type, 'bank', 'card', 'cash', 'other'), id ASC
");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$stmt = $pdo->prepare("
SELECT *
FROM categories
WHERE user_id = ?
AND is_active = 1
ORDER BY category_type, sort_order, id
");
$stmt->execute([$uid]);
$categories = $stmt->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$transactionType = $_POST['transaction_type'] ?? '';
$accountId = (int)($_POST['account_id'] ?? 0);
$categoryId = (int)($_POST['category_id'] ?? 0);
$amount = (float)str_replace(',', '', (string)($_POST['amount'] ?? 0));
$transactionDate = $_POST['transaction_date'] ?? date('Y-m-d');
$merchantName = trim($_POST['merchant_name'] ?? '');
$description = trim($_POST['description'] ?? '');
$relatedAccountId = !empty($_POST['related_account_id']) ? (int)$_POST['related_account_id'] : null;
$isInstallment = (int)($_POST['is_installment'] ?? 0);
$installmentMonths = !empty($_POST['installment_months']) ? (int)$_POST['installment_months'] : null;
$installmentInterestRate = !empty($_POST['installment_interest_rate']) ? (float)$_POST['installment_interest_rate'] : 0.0;
$installmentInterestTotal = isset($_POST['installment_interest_total']) && $_POST['installment_interest_total'] !== ''
? (float)str_replace(',', '', (string)$_POST['installment_interest_total'])
: null;
$installmentTotalBilled = isset($_POST['installment_total_billed']) && $_POST['installment_total_billed'] !== ''
? (float)str_replace(',', '', (string)$_POST['installment_total_billed'])
: null;
if ($amount <= 0) throw new RuntimeException('금액은 0보다 커야 합니다.');
if (!in_array($transactionType, ['income', 'expense', 'transfer', 'card_payment'], true)) throw new RuntimeException('거래 유형이 올바르지 않습니다.');
if ($accountId <= 0) throw new RuntimeException('주 계좌/카드를 선택하세요.');
if ($categoryId <= 0) throw new RuntimeException('카테고리를 선택하세요.');
if (in_array($transactionType, ['transfer', 'card_payment'], true) && !$relatedAccountId) throw new RuntimeException('관련 계좌를 선택해야 합니다.');
if ($relatedAccountId && $relatedAccountId === $accountId) throw new RuntimeException('주 계좌와 관련 계좌는 같을 수 없습니다.');
if ($transactionType !== 'expense') {
$isInstallment = 0;
$installmentMonths = null;
$installmentInterestRate = 0.0;
$installmentInterestTotal = null;
$installmentTotalBilled = null;
}
update_transaction($id, $uid, [
'account_id' => $accountId,
'category_id' => $categoryId,
'transaction_type' => $transactionType,
'amount' => $amount,
'transaction_date' => $transactionDate,
'merchant_name' => $merchantName !== '' ? $merchantName : null,
'description' => $description !== '' ? $description : null,
'related_account_id' => $relatedAccountId,
'is_installment' => $isInstallment,
'installment_months' => $installmentMonths,
'installment_interest_rate' => $installmentInterestRate,
'installment_interest_total' => $installmentInterestTotal,
'installment_total_billed' => $installmentTotalBilled,
]);
redirect('/transactions.php');
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$accountsJson = [];
foreach ($accounts as $acc) {
$accountsJson[] = [
'id' => (int)$acc['id'],
'name' => $acc['account_name'],
'type' => $acc['account_type'],
'card_kind' => $acc['card_kind'] ?? null,
'billing_day' => $acc['billing_day'] ?? null,
'payment_day' => $acc['payment_day'] ?? null,
'use_credit_grace_period' => !empty($acc['use_credit_grace_period']) ? 1 : 0,
];
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>거래 수정</h2>
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<div class="card finance-card">
<div class="card-body">
<form method="post" class="row g-3" id="transactionEditForm" autocomplete="off">
<div class="col-12 col-md-3">
<label class="form-label">거래 유형</label>
<select name="transaction_type" id="transaction_type" class="form-select" required>
<option value="expense" <?= $item['transaction_type'] === 'expense' ? 'selected' : '' ?>>지출</option>
<option value="income" <?= $item['transaction_type'] === 'income' ? 'selected' : '' ?>>수입</option>
<option value="transfer" <?= $item['transaction_type'] === 'transfer' ? 'selected' : '' ?>>계좌이체</option>
<option value="card_payment" <?= $item['transaction_type'] === 'card_payment' ? 'selected' : '' ?>>카드대금납부</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label"> 계좌/카드</label>
<select name="account_id" id="account_id" class="form-select" required>
<?php foreach ($accounts as $a): ?>
<option
value="<?= $a['id'] ?>"
data-account-type="<?= h($a['account_type']) ?>"
<?= (int)$item['account_id'] === (int)$a['id'] ? 'selected' : '' ?>
>
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
</option>
<?php endforeach; ?>
</select>
<div class="form-text" id="account_help_text"></div>
</div>
<div class="col-12 col-md-3">
<label class="form-label">관련 계좌</label>
<select name="related_account_id" id="related_account_id" class="form-select">
<option value="">선택안함</option>
<?php foreach ($accounts as $a): ?>
<option
value="<?= $a['id'] ?>"
data-account-type="<?= h($a['account_type']) ?>"
<?= (int)($item['related_account_id'] ?? 0) === (int)$a['id'] ? 'selected' : '' ?>
>
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
</option>
<?php endforeach; ?>
</select>
<div class="form-text" id="related_account_help">계좌이체/카드대금납부 선택</div>
</div>
<div class="col-12 col-md-3">
<label class="form-label">카테고리</label>
<select name="category_id" id="category_id" class="form-select" required>
<?php foreach ($categories as $c): ?>
<option
value="<?= $c['id'] ?>"
data-type="<?= h($c['category_type']) ?>"
<?= (int)$item['category_id'] === (int)$c['id'] ? 'selected' : '' ?>
>
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">금액</label>
<input type="text" name="amount" id="amount" class="form-control" value="<?= h(money_plain($item['amount'])) ?>" inputmode="numeric" required>
<div class="mt-2 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="10000">+1</button>
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="50000">+5</button>
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="100000">+10</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="amount_clear_btn">초기화</button>
</div>
</div>
<div class="col-12 col-md-3">
<label class="form-label">거래일</label>
<input type="date" name="transaction_date" class="form-control" value="<?= h($item['transaction_date']) ?>" required>
<?php if (!empty($item['billing_year_month'])): ?>
<div class="form-text">현재 청구월: <?= h($item['billing_year_month']) ?></div>
<?php endif; ?>
</div>
<div class="col-12 col-md-3">
<label class="form-label">결제 방식</label>
<select name="is_installment" id="is_installment" class="form-select">
<option value="0" <?= empty($item['is_installment']) ? 'selected' : '' ?>>일시불</option>
<option value="1" <?= !empty($item['is_installment']) ? 'selected' : '' ?>>할부</option>
</select>
</div>
<div class="col-12 col-md-3" id="installment_months_wrap" style="display:none;">
<label class="form-label">할부 개월</label>
<select name="installment_months" id="installment_months" class="form-select">
<option value="">선택하세요</option>
<?php foreach ([2,3,4,5,6,10,12,18,24] as $month): ?>
<option value="<?= $month ?>" <?= (int)($item['installment_months'] ?? 0) === $month ? 'selected' : '' ?>>
<?= intvalf($month) ?>개월
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3" id="installment_rate_wrap" style="display:none;">
<label class="form-label">연이자율(%)</label>
<input type="number" name="installment_interest_rate" id="installment_interest_rate" class="form-control" step="0.01" min="0" value="<?= h((string)($item['installment_interest_rate'] ?? 0)) ?>">
</div>
<div class="col-12 col-md-3" id="installment_interest_wrap" style="display:none;">
<label class="form-label"> 할부이자</label>
<input type="text" name="installment_interest_total" id="installment_interest_total" class="form-control" inputmode="numeric" value="<?= h(money_plain($item['installment_interest_total'] ?? 0)) ?>">
</div>
<div class="col-12 col-md-3"
id="installment_total_wrap"
style="display:<?= !empty($item['is_installment']) ? 'block' : 'none' ?>;">
<label class="form-label"> 청구금액</label>
<input type="text"
name="installment_total_billed"
id="installment_total_billed"
class="form-control"
inputmode="numeric"
value="<?= h(!empty($item['installment_total_billed']) ? money_plain($item['installment_total_billed']) : '') ?>">
<?php if (!empty($item['is_installment'])): ?>
<div class="form-text text-primary">
현재 <?= intvalf($item['installment_months']) ?>개월 /
<?= ((float)$item['installment_interest_rate'] > 0)
? '연 ' . numf($item['installment_interest_rate'],2) . '%'
: '무이자' ?>
</div>
<?php endif; ?>
<div class="form-text">
이자 포함 실제 카드 청구 총액
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">사용처 / 거래처</label>
<input type="text" name="merchant_name" id="merchant_name" class="form-control" value="<?= h($item['merchant_name'] ?? '') ?>" autocomplete="off">
<div class="form-text">상호명 입력 카테고리를 자동 추천합니다.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">메모</label>
<input type="text" name="description" class="form-control" value="<?= h($item['description'] ?? '') ?>">
</div>
<div class="col-12">
<div id="category_suggest_box" class="alert alert-info py-2 px-3 d-none mb-0"></div>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button class="btn btn-primary">수정 저장</button>
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
<button type="submit"
formaction="/transaction_delete.php"
formmethod="post"
name="id"
value="<?= (int)$id ?>"
class="btn btn-outline-danger"
onclick="return confirm('거래를 삭제하시겠습니까?');">
삭제
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
const ACCOUNTS = <?= json_encode($accountsJson, JSON_UNESCAPED_UNICODE) ?>;
const transactionTypeEl = document.getElementById('transaction_type');
const accountEl = document.getElementById('account_id');
const accountHelpTextEl = document.getElementById('account_help_text');
const relatedAccountEl = document.getElementById('related_account_id');
const relatedHelpEl = document.getElementById('related_account_help');
const categoryEl = document.getElementById('category_id');
const merchantEl = document.getElementById('merchant_name');
const suggestBoxEl = document.getElementById('category_suggest_box');
const installmentEl = document.getElementById('is_installment');
const installmentWrapEl = document.getElementById('installment_months_wrap');
const installmentMonthsEl = document.getElementById('installment_months');
const installmentRateWrapEl = document.getElementById('installment_rate_wrap');
const installmentRateEl = document.getElementById('installment_interest_rate');
const installmentInterestWrapEl = document.getElementById('installment_interest_wrap');
const installmentTotalWrapEl = document.getElementById('installment_total_wrap');
const installmentInterestEl = document.getElementById('installment_interest_total');
const installmentTotalEl = document.getElementById('installment_total_billed');
const amountEl = document.getElementById('amount');
const quickAmtButtons = document.querySelectorAll('.quick-amt');
const amountClearBtn = document.getElementById('amount_clear_btn');
const allCategoryOptions = Array.from(categoryEl.querySelectorAll('option')).map(option => ({
value: option.value,
text: option.textContent,
type: option.dataset.type,
selected: option.selected
}));
const initialCategoryValue = categoryEl.value;
let suggestTimer = null;
let lastSuggestedCategoryId = null;
function parseNumber(value) {
const normalized = String(value || '').replace(/,/g, '').trim();
const n = parseFloat(normalized);
return isNaN(n) ? 0 : n;
}
function formatWithComma(value) {
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.]/g, '');
if (normalized === '') return '';
const parts = normalized.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
function selectedAccount() {
const id = parseInt(accountEl.value || '0', 10);
return ACCOUNTS.find(a => a.id === id) || null;
}
function mapTransactionTypeToCategoryType(transactionType) {
if (transactionType === 'income') return 'income';
if (transactionType === 'expense') return 'expense';
return 'transfer';
}
function rebuildCategoryOptions() {
const transactionType = transactionTypeEl.value;
const categoryType = mapTransactionTypeToCategoryType(transactionType);
const currentValue = categoryEl.value || initialCategoryValue;
const filtered = allCategoryOptions.filter(item => item.type === categoryType);
categoryEl.innerHTML = '';
filtered.forEach((item, index) => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.text;
if (item.value === currentValue) {
option.selected = true;
} else if (!filtered.some(x => x.value === currentValue) && index === 0) {
option.selected = true;
}
categoryEl.appendChild(option);
});
}
function filterRelatedAccountOptions() {
const transactionType = transactionTypeEl.value;
const mainAccountType = accountEl.selectedOptions[0]?.dataset.accountType || '';
const mainAccountId = accountEl.value;
Array.from(relatedAccountEl.options).forEach(option => {
if (option.value === '') {
option.hidden = false;
return;
}
const optionType = option.dataset.accountType || '';
let visible = true;
if (option.value === mainAccountId) {
visible = false;
} else if (transactionType === 'transfer') {
visible = ['bank', 'cash', 'other'].includes(mainAccountType)
&& ['bank', 'cash', 'other'].includes(optionType);
} else if (transactionType === 'card_payment') {
visible = ['bank', 'cash', 'other'].includes(mainAccountType)
&& optionType === 'card';
}
option.hidden = !visible;
if (!visible && option.selected) {
relatedAccountEl.value = '';
}
});
if (transactionType === 'transfer') {
relatedAccountEl.disabled = false;
relatedAccountEl.required = true;
relatedHelpEl.textContent = '같은 주 계좌는 제외되고, 입금 계좌만 선택 가능합니다.';
} else if (transactionType === 'card_payment') {
relatedAccountEl.disabled = false;
relatedAccountEl.required = true;
relatedHelpEl.textContent =
'관련 계좌에는 카드 계정만 선택 가능합니다. 저장 시 해당월 할부 청구도 자동 처리됩니다.';
} else {
relatedAccountEl.disabled = true;
relatedAccountEl.required = false;
relatedAccountEl.value = '';
relatedHelpEl.textContent = '계좌이체/카드대금납부 시 선택';
}
}
function updateAccountHelpText() {
const acc = selectedAccount();
if (!acc) {
accountHelpTextEl.textContent = '';
return;
}
if (acc.type === 'card' && acc.card_kind === 'check') {
accountHelpTextEl.textContent = '체크카드 · 즉시 출금 기준';
return;
}
if (acc.type === 'card' && acc.card_kind === 'credit' && acc.use_credit_grace_period) {
const billingDay = acc.billing_day ? `${acc.billing_day}일` : '-';
const paymentDay = acc.payment_day ? `${acc.payment_day}일` : '-';
accountHelpTextEl.textContent = `신용카드 · 결제기준일 ${billingDay} / 납부일 ${paymentDay}`;
return;
}
accountHelpTextEl.textContent = '';
}
function calculateInterest() {
const principal = parseNumber(amountEl.value);
const months = parseInt(installmentMonthsEl.value || '0', 10) || 0;
const annualRate = parseFloat(installmentRateEl.value || '0') || 0;
if (principal <= 0 || months <= 1 || annualRate <= 0) {
if (!installmentInterestEl.dataset.userEdited) {
installmentInterestEl.value = '';
}
syncInstallmentTotal();
return;
}
const monthlyRate = (annualRate / 100) / 12;
const averageOutstanding = principal / 2;
const interestTotal = Math.round((averageOutstanding * monthlyRate * months));
if (!installmentInterestEl.dataset.userEdited || installmentInterestEl.value === '') {
installmentInterestEl.value = formatWithComma(interestTotal);
}
syncInstallmentTotal();
}
function syncInstallmentTotal() {
const principal = parseNumber(amountEl.value);
const interest = parseNumber(installmentInterestEl.value);
const calculated = principal + interest;
if (!installmentTotalEl.dataset.userEdited || installmentTotalEl.value === '') {
installmentTotalEl.value = calculated > 0 ? formatWithComma(calculated) : '';
}
}
function toggleInstallmentFields() {
const transactionType = transactionTypeEl.value;
const acc = selectedAccount();
const isExpenseCard = transactionType === 'expense' && acc && acc.type === 'card';
if (isExpenseCard) {
installmentEl.disabled = false;
if (String(installmentEl.value) === '1') {
installmentWrapEl.style.display = '';
installmentRateWrapEl.style.display = '';
installmentInterestWrapEl.style.display = '';
installmentTotalWrapEl.style.display = '';
installmentMonthsEl.required = true;
calculateInterest();
} else {
installmentWrapEl.style.display = 'none';
installmentRateWrapEl.style.display = 'none';
installmentInterestWrapEl.style.display = 'none';
installmentTotalWrapEl.style.display = 'none';
installmentMonthsEl.required = false;
installmentMonthsEl.value = '';
installmentRateEl.value = '0';
installmentInterestEl.value = '';
installmentTotalEl.value = '';
installmentInterestEl.dataset.userEdited = '';
installmentTotalEl.dataset.userEdited = '';
}
} else {
installmentEl.value = '0';
installmentEl.disabled = true;
installmentWrapEl.style.display = 'none';
installmentRateWrapEl.style.display = 'none';
installmentInterestWrapEl.style.display = 'none';
installmentTotalWrapEl.style.display = 'none';
installmentMonthsEl.required = false;
installmentMonthsEl.value = '';
installmentRateEl.value = '0';
installmentInterestEl.value = '';
installmentTotalEl.value = '';
installmentInterestEl.dataset.userEdited = '';
installmentTotalEl.dataset.userEdited = '';
}
}
function hideSuggestBox() {
suggestBoxEl.classList.add('d-none');
suggestBoxEl.textContent = '';
lastSuggestedCategoryId = null;
}
function showSuggestBox(message) {
suggestBoxEl.textContent = message;
suggestBoxEl.classList.remove('d-none');
}
async function fetchCategorySuggestion() {
const merchantName = merchantEl.value.trim();
const categoryType = mapTransactionTypeToCategoryType(transactionTypeEl.value);
if (merchantName.length < 2) {
hideSuggestBox();
return;
}
try {
const url = `/api/category_suggest.php?merchant_name=${encodeURIComponent(merchantName)}&transaction_type=${encodeURIComponent(categoryType)}`;
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) {
hideSuggestBox();
return;
}
const result = await response.json();
if (!result.ok || !result.found) {
hideSuggestBox();
return;
}
const targetOption = Array.from(categoryEl.options).find(option => option.value === String(result.category_id));
if (!targetOption) {
hideSuggestBox();
return;
}
categoryEl.value = String(result.category_id);
lastSuggestedCategoryId = String(result.category_id);
showSuggestBox(`자동 추천: ${result.category_name} (규칙: ${result.keyword ?? result.pattern_text ?? ''})`);
} catch (error) {
hideSuggestBox();
}
}
function scheduleSuggestion() {
clearTimeout(suggestTimer);
suggestTimer = setTimeout(fetchCategorySuggestion, 250);
}
quickAmtButtons.forEach(btn => {
btn.addEventListener('click', function () {
amountEl.value = formatWithComma(parseNumber(amountEl.value) + parseInt(btn.dataset.add || '0', 10));
calculateInterest();
});
});
amountClearBtn.addEventListener('click', function () {
amountEl.value = '';
calculateInterest();
amountEl.focus();
});
amountEl.addEventListener('input', function () {
this.value = formatWithComma(this.value);
calculateInterest();
});
installmentInterestEl.addEventListener('input', function () {
installmentInterestEl.dataset.userEdited = '1';
installmentInterestEl.value = formatWithComma(installmentInterestEl.value);
syncInstallmentTotal();
});
installmentTotalEl.addEventListener('input', function () {
installmentTotalEl.dataset.userEdited = '1';
installmentTotalEl.value = formatWithComma(installmentTotalEl.value);
});
transactionTypeEl.addEventListener('change', function () {
rebuildCategoryOptions();
filterRelatedAccountOptions();
toggleInstallmentFields();
scheduleSuggestion();
});
accountEl.addEventListener('change', function () {
updateAccountHelpText();
filterRelatedAccountOptions();
toggleInstallmentFields();
});
installmentEl.addEventListener('change', toggleInstallmentFields);
installmentMonthsEl.addEventListener('change', calculateInterest);
installmentRateEl.addEventListener('input', calculateInterest);
merchantEl.addEventListener('input', scheduleSuggestion);
categoryEl.addEventListener('change', function () {
if (lastSuggestedCategoryId && categoryEl.value !== lastSuggestedCategoryId) {
hideSuggestBox();
}
});
document.getElementById('transactionEditForm').addEventListener('submit', function () {
amountEl.value = String(parseNumber(amountEl.value));
installmentInterestEl.value = installmentInterestEl.value === '' ? '' : String(parseNumber(installmentInterestEl.value));
installmentTotalEl.value = installmentTotalEl.value === '' ? '' : String(parseNumber(installmentTotalEl.value));
});
rebuildCategoryOptions();
updateAccountHelpText();
filterRelatedAccountOptions();
toggleInstallmentFields();
if (merchantEl.value.trim().length >= 2) {
scheduleSuggestion();
}
})();
</script>
<?php require __DIR__ . '/../app/views/footer.php'; ?>
+280
View File
@@ -0,0 +1,280 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
require_once __DIR__ . '/../app/lib/db.php';
require_once __DIR__ . '/../app/lib/helpers.php';
check_auth();
$pdo = db();
$uid = user_id();
$ym = $_GET['ym'] ?? date('Y-m');
$q = trim($_GET['q'] ?? '');
$type = $_GET['type'] ?? '';
$accountId = (int)($_GET['account_id'] ?? 0);
$categoryId = (int)($_GET['category_id'] ?? 0);
$start = $ym . '-01';
$end = date('Y-m-t', strtotime($start));
$params = [$uid, $start, $end];
$where = [
"t.user_id = ?",
"t.transaction_date BETWEEN ? AND ?"
];
if ($q !== '') {
$where[] = "(t.merchant_name LIKE ? OR t.description LIKE ? OR c.name LIKE ? OR a.account_name LIKE ?)";
$like = '%' . $q . '%';
array_push($params, $like, $like, $like, $like);
}
if (in_array($type, ['income', 'expense', 'transfer', 'card_payment'], true)) {
$where[] = "t.transaction_type = ?";
$params[] = $type;
}
if ($accountId > 0) {
$where[] = "t.account_id = ?";
$params[] = $accountId;
}
if ($categoryId > 0) {
$where[] = "t.category_id = ?";
$params[] = $categoryId;
}
$sql = "
SELECT
t.*,
a.account_name,
a.account_type,
a.card_kind,
ra.account_name AS related_account_name,
c.name AS category_name
FROM transactions t
JOIN accounts a ON t.account_id = a.id
LEFT JOIN accounts ra ON t.related_account_id = ra.id
JOIN categories c ON t.category_id = c.id
WHERE " . implode(' AND ', $where) . "
ORDER BY t.transaction_date DESC, t.id DESC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$list = $stmt->fetchAll();
$stmt = $pdo->prepare("SELECT id, account_name FROM accounts WHERE user_id = ? AND is_active = 1 ORDER BY id ASC");
$stmt->execute([$uid]);
$accounts = $stmt->fetchAll();
$stmt = $pdo->prepare("SELECT id, category_type, name FROM categories WHERE user_id = ? AND is_active = 1 ORDER BY category_type, sort_order, id");
$stmt->execute([$uid]);
$categories = $stmt->fetchAll();
/* 요약 */
$sumIncome = 0;
$sumExpense = 0;
$sumTransfer = 0;
$sumCardPay = 0;
foreach ($list as $row) {
$amt = (float)$row['amount'];
if ($row['transaction_type'] === 'income') $sumIncome += $amt;
elseif ($row['transaction_type'] === 'expense') $sumExpense += $amt;
elseif ($row['transaction_type'] === 'transfer') $sumTransfer += $amt;
elseif ($row['transaction_type'] === 'card_payment') $sumCardPay += $amt;
}
function tx_label(string $type): string
{
return match ($type) {
'income' => '수입',
'expense' => '지출',
'transfer' => '이체',
'card_payment' => '카드납부',
default => $type
};
}
require __DIR__ . '/../app/views/header.php';
?>
<div class="page-head">
<h2>거래내역</h2>
<a href="/transaction_create.php" class="btn btn-primary">거래등록</a>
</div>
<div class="card finance-card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-2">
<label class="form-label"></label>
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
</div>
<div class="col-md-3">
<label class="form-label">검색어</label>
<input type="text" name="q" class="form-control" value="<?= h($q) ?>" placeholder="사용처, 메모, 카테고리">
</div>
<div class="col-md-2">
<label class="form-label">유형</label>
<select name="type" class="form-select">
<option value="">전체</option>
<option value="income" <?= $type === 'income' ? 'selected' : '' ?>>수입</option>
<option value="expense" <?= $type === 'expense' ? 'selected' : '' ?>>지출</option>
<option value="transfer" <?= $type === 'transfer' ? 'selected' : '' ?>>이체</option>
<option value="card_payment" <?= $type === 'card_payment' ? 'selected' : '' ?>>카드납부</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">계좌</label>
<select name="account_id" class="form-select">
<option value="0">전체</option>
<?php foreach ($accounts as $account): ?>
<option value="<?= $account['id'] ?>" <?= $accountId === (int)$account['id'] ? 'selected' : '' ?>>
<?= h($account['account_name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label">카테고리</label>
<select name="category_id" class="form-select">
<option value="0">전체</option>
<?php foreach ($categories as $category): ?>
<option value="<?= $category['id'] ?>" <?= $categoryId === (int)$category['id'] ? 'selected' : '' ?>>
[<?= h($category['category_type']) ?>] <?= h($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-primary">조회</button>
<a href="/transactions.php" class="btn btn-outline-secondary">초기화</a>
</div>
</form>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card finance-card">
<div class="card-body">
<div class="stat-label">수입</div>
<div class="stat-value text-primary"><?= won($sumIncome) ?></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card finance-card">
<div class="card-body">
<div class="stat-label">지출</div>
<div class="stat-value text-danger"><?= won($sumExpense) ?></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card finance-card">
<div class="card-body">
<div class="stat-label">이체</div>
<div class="stat-value"><?= won($sumTransfer) ?></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card finance-card">
<div class="card-body">
<div class="stat-label">카드납부</div>
<div class="stat-value"><?= won($sumCardPay) ?></div>
</div>
</div>
</div>
</div>
<div class="card finance-card">
<div class="card-body mobile-scroll">
<table class="table table-hover align-middle mb-0 transaction-list-table">
<thead>
<tr>
<th>날짜</th>
<th>유형</th>
<th>계좌</th>
<th>관련</th>
<th>카테고리</th>
<th>사용처</th>
<th class="text-end">금액</th>
<th>부가정보</th>
<th>메모</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<?php
$class = 'amount-transfer';
if ($row['transaction_type'] === 'income') $class = 'amount-income';
if ($row['transaction_type'] === 'expense') $class = 'amount-expense';
if ($row['transaction_type'] === 'card_payment') $class = 'amount-card';
$extra = [];
if (!empty($row['billing_year_month'])) {
$extra[] = '청구월 ' . $row['billing_year_month'];
}
if (!empty($row['is_installment']) && !empty($row['installment_months'])) {
$extra[] = $row['installment_months'] . '개월 할부';
}
if ($row['account_type'] === 'card' && $row['card_kind'] === 'check') {
$extra[] = '체크카드';
}
if ($row['account_type'] === 'card' && $row['card_kind'] === 'credit') {
$extra[] = '신용카드';
}
?>
<tr>
<td data-label="날짜"><?= h($row['transaction_date']) ?></td>
<td data-label="유형"><?= h(tx_label($row['transaction_type'])) ?></td>
<td data-label="계좌"><?= h($row['account_name']) ?></td>
<td data-label="관련"><?= h($row['related_account_name'] ?? '-') ?></td>
<td data-label="카테고리"><?= h($row['category_name']) ?></td>
<td data-label="사용처"><?= h($row['merchant_name'] ?: '-') ?></td>
<td data-label="금액" class="text-end <?= $class ?>"><?= won($row['amount']) ?></td>
<td data-label="부가정보"><?= h($extra ? implode(' / ', $extra) : '-') ?></td>
<td data-label="메모"><?= h($row['description'] ?: '-') ?></td>
<td data-label="관리" class="text-nowrap">
<a href="/transaction_create.php?copy_id=<?= $row['id'] ?>"
class="btn btn-sm btn-outline-secondary">복사</a>
<a href="/transaction_edit.php?id=<?= $row['id'] ?>" class="btn btn-sm btn-outline-primary">수정</a>
<form method="post" action="/transaction_delete.php" class="d-inline">
<input type="hidden" name="id" value="<?= (int)$row['id'] ?>">
<button class="btn btn-sm btn-outline-danger"
onclick="return confirm('삭제하시겠습니까?');">삭제</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$list): ?>
<tr>
<td colspan="10" class="text-center text-secondary py-5">조회 결과가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php require __DIR__ . '/../app/views/footer.php'; ?>