Initial financial project import
This commit is contained in:
@@ -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; ?>
|
||||
Reference in New Issue
Block a user