Initial financial project import
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sql
|
||||||
|
cache/
|
||||||
|
tmp/
|
||||||
|
uploads/
|
||||||
|
secrets/
|
||||||
|
secret/
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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 '카드';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
+6
File diff suppressed because one or more lines are too long
Vendored
+14
File diff suppressed because one or more lines are too long
@@ -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'; ?>
|
||||||
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
header('Location: /dashboard.php');
|
||||||
|
exit;
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../app/lib/auth.php';
|
||||||
|
|
||||||
|
logout_user();
|
||||||
|
header('Location: /login.php');
|
||||||
|
exit;
|
||||||
@@ -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": "월별 거래내역을 확인합니다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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'; ?>
|
||||||
@@ -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>
|
||||||
@@ -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'; ?>
|
||||||
@@ -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>
|
||||||
@@ -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'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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'; ?>
|
||||||
@@ -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;
|
||||||
@@ -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'; ?>
|
||||||
@@ -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'; ?>
|
||||||
Reference in New Issue
Block a user