Initial financial project import

This commit is contained in:
seo
2026-06-07 00:33:58 +09:00
commit faeab5f0f5
50 changed files with 11267 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<?php
$financialSecretFile = '/home/seo/secret/financial.php';
$financialSecretConfig = is_file($financialSecretFile) ? require $financialSecretFile : [];
return [
'host' => getenv('FINANCIAL_DB_HOST') ?: ($financialSecretConfig['host'] ?? '127.0.0.1'),
'port' => (int)(getenv('FINANCIAL_DB_PORT') ?: ($financialSecretConfig['port'] ?? 3306)),
'dbname' => getenv('FINANCIAL_DB_NAME') ?: ($financialSecretConfig['dbname'] ?? 'financial'),
'username' => getenv('FINANCIAL_DB_USER') ?: ($financialSecretConfig['username'] ?? 'financial'),
'password' => getenv('FINANCIAL_DB_PASSWORD') ?: ($financialSecretConfig['password'] ?? ''),
'charset' => getenv('FINANCIAL_DB_CHARSET') ?: ($financialSecretConfig['charset'] ?? 'utf8mb4'),
];
+76
View File
@@ -0,0 +1,76 @@
<?php
require_once __DIR__ . '/db.php';
function recalculate_account_balance(int $accountId): void
{
$pdo = db();
$stmt = $pdo->prepare("SELECT id, account_type, opening_balance FROM accounts WHERE id = ?");
$stmt->execute([$accountId]);
$account = $stmt->fetch();
if (!$account) {
return;
}
$opening = (float)$account['opening_balance'];
$type = $account['account_type'];
$balance = $opening;
if ($type === 'bank' || $type === 'cash' || $type === 'other') {
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE
WHEN transaction_type = 'income' AND account_id = :id THEN amount
ELSE 0 END), 0) AS income_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'expense' AND account_id = :id THEN amount
ELSE 0 END), 0) AS expense_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'transfer' AND account_id = :id THEN amount
ELSE 0 END), 0) AS transfer_out_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'transfer' AND related_account_id = :id THEN amount
ELSE 0 END), 0) AS transfer_in_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'card_payment' AND account_id = :id THEN amount
ELSE 0 END), 0) AS card_payment_sum
FROM transactions
WHERE account_id = :id OR related_account_id = :id
");
$stmt->execute(['id' => $accountId]);
$row = $stmt->fetch();
$balance += (float)$row['income_sum'];
$balance -= (float)$row['expense_sum'];
$balance -= (float)$row['transfer_out_sum'];
$balance += (float)$row['transfer_in_sum'];
$balance -= (float)$row['card_payment_sum'];
} elseif ($type === 'card') {
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE
WHEN transaction_type = 'expense' AND account_id = :id THEN amount
ELSE 0 END), 0) AS card_use_sum,
COALESCE(SUM(CASE
WHEN transaction_type = 'card_payment' AND related_account_id = :id THEN amount
ELSE 0 END), 0) AS paid_sum
FROM transactions
WHERE account_id = :id OR related_account_id = :id
");
$stmt->execute(['id' => $accountId]);
$row = $stmt->fetch();
$balance += (float)$row['card_use_sum'];
$balance -= (float)$row['paid_sum'];
}
$stmt = $pdo->prepare("UPDATE accounts SET current_balance = ? WHERE id = ?");
$stmt->execute([$balance, $accountId]);
}
+284
View File
@@ -0,0 +1,284 @@
<?php
require_once __DIR__ . '/db.php';
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
function send_private_no_store_headers(): void
{
if (headers_sent()) {
return;
}
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' .
htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') .
'">';
}
function verify_csrf_token(?string $token): bool
{
return is_string($token)
&& isset($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
function require_valid_csrf_for_post(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
return;
}
if (verify_csrf_token($_POST['csrf_token'] ?? null)) {
return;
}
http_response_code(419);
exit('Invalid CSRF token.');
}
function enable_csrf_form_injection(): void
{
if (PHP_SAPI === 'cli' || defined('FINANCIAL_CSRF_INJECTION_ENABLED')) {
return;
}
define('FINANCIAL_CSRF_INJECTION_ENABLED', true);
ob_start(static function (string $buffer): string {
if (
stripos($buffer, '<form') === false ||
!preg_match('/method\s*=\s*["\']?post/i', $buffer)
) {
return $buffer;
}
$field = csrf_field();
return preg_replace_callback(
'/<form\b([^>]*)>/i',
static function (array $matches) use ($field): string {
$tag = $matches[0];
if (
stripos($tag, 'method="post"') === false &&
stripos($tag, "method='post'") === false &&
!preg_match('/method\s*=\s*post/i', $tag)
) {
return $tag;
}
if (stripos($tag, 'csrf_token') !== false) {
return $tag;
}
return $tag . $field;
},
$buffer
) ?? $buffer;
});
}
function throttle_login_attempts(string $username): void
{
$key = 'login_attempts_' . hash('sha256', strtolower($username) . '|' . ($_SERVER['REMOTE_ADDR'] ?? ''));
$now = time();
$attempt = $_SESSION[$key] ?? ['count' => 0, 'first_at' => $now];
if (($now - (int)$attempt['first_at']) > 900) {
$attempt = ['count' => 0, 'first_at' => $now];
}
if ((int)$attempt['count'] >= 8) {
throw new RuntimeException('로그인 시도가 많습니다. 잠시 후 다시 시도하세요.');
}
$attempt['count']++;
$_SESSION[$key] = $attempt;
}
function clear_login_attempts(string $username): void
{
$key = 'login_attempts_' . hash('sha256', strtolower($username) . '|' . ($_SERVER['REMOTE_ADDR'] ?? ''));
unset($_SESSION[$key]);
}
function login_user(array $user, bool $remember = false): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['username'] = $user['username'];
if ($remember) {
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$pdo = db();
$stmt = $pdo->prepare("
UPDATE users
SET remember_token = ?, remember_expires_at = ?
WHERE id = ?
");
$stmt->execute([$tokenHash, $expiresAt, $user['id']]);
setcookie(
'remember_token',
$token,
[
'expires' => strtotime('+30 days'),
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
}
}
function logout_user(): void
{
if (!empty($_SESSION['user_id'])) {
$pdo = db();
$stmt = $pdo->prepare("
UPDATE users
SET remember_token = NULL, remember_expires_at = NULL
WHERE id = ?
");
$stmt->execute([$_SESSION['user_id']]);
}
setcookie(
'remember_token',
'',
[
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'] ?? '', $params['secure'] ?? false, $params['httponly'] ?? false);
}
session_destroy();
}
function try_auto_login(): void
{
if (!empty($_SESSION['user_id'])) {
return;
}
if (empty($_COOKIE['remember_token'])) {
return;
}
$token = $_COOKIE['remember_token'];
$tokenHash = hash('sha256', $token);
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM users
WHERE remember_token = ?
AND remember_expires_at IS NOT NULL
AND remember_expires_at > NOW()
LIMIT 1
");
$stmt->execute([$tokenHash]);
$user = $stmt->fetch();
if (!$user) {
setcookie(
'remember_token',
'',
[
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
return;
}
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['username'] = $user['username'];
$newToken = bin2hex(random_bytes(32));
$newHash = hash('sha256', $newToken);
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$stmt = $pdo->prepare("
UPDATE users
SET remember_token = ?, remember_expires_at = ?
WHERE id = ?
");
$stmt->execute([$newHash, $expiresAt, $user['id']]);
setcookie(
'remember_token',
$newToken,
[
'expires' => strtotime('+30 days'),
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]
);
}
function check_auth(): void
{
try_auto_login();
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
send_private_no_store_headers();
}
function user_id(): int
{
return (int)($_SESSION['user_id'] ?? 0);
}
send_private_no_store_headers();
require_valid_csrf_for_post();
enable_csrf_form_injection();
+274
View File
@@ -0,0 +1,274 @@
<?php
function normalize_card_kind(array $account): ?string
{
$kind = strtolower(trim((string)($account['card_kind'] ?? '')));
if ($kind === 'credit') {
return 'credit';
}
if ($kind === 'check') {
return 'check';
}
return null;
}
function safe_date_ts(string $date): ?int
{
$ts = strtotime($date);
return $ts === false ? null : $ts;
}
function card_month_day_date(string $billingYearMonth, int $monthOffset, int $day): ?DateTime
{
if (!preg_match('/^\d{4}-\d{2}$/', $billingYearMonth)) {
return null;
}
if ($day < 1 || $day > 31) {
return null;
}
$base = new DateTime($billingYearMonth . '-01');
if ($monthOffset !== 0) {
$base->modify(($monthOffset > 0 ? '+' : '') . $monthOffset . ' month');
}
$lastDay = (int)$base->format('t');
$realDay = min($day, $lastDay);
$base->setDate(
(int)$base->format('Y'),
(int)$base->format('m'),
$realDay
);
return $base;
}
function account_has_statement_period(array $account): bool
{
return isset(
$account['statement_start_month_offset'],
$account['statement_start_day'],
$account['statement_end_month_offset'],
$account['statement_end_day']
)
&& $account['statement_start_month_offset'] !== null
&& $account['statement_start_day'] !== null
&& $account['statement_end_month_offset'] !== null
&& $account['statement_end_day'] !== null
&& (int)$account['statement_start_day'] >= 1
&& (int)$account['statement_start_day'] <= 31
&& (int)$account['statement_end_day'] >= 1
&& (int)$account['statement_end_day'] <= 31;
}
function transaction_date_in_card_statement_month(array $account, string $transactionDate, string $billingYearMonth): bool
{
$txTs = safe_date_ts($transactionDate);
if ($txTs === null) {
return false;
}
if (!account_has_statement_period($account)) {
return false;
}
$start = card_month_day_date(
$billingYearMonth,
(int)$account['statement_start_month_offset'],
(int)$account['statement_start_day']
);
$end = card_month_day_date(
$billingYearMonth,
(int)$account['statement_end_month_offset'],
(int)$account['statement_end_day']
);
if (!$start || !$end) {
return false;
}
$tx = new DateTime(date('Y-m-d', $txTs));
return $tx >= $start && $tx <= $end;
}
function get_card_billing_year_month_by_statement_period(array $account, string $transactionDate): ?string
{
$txTs = safe_date_ts($transactionDate);
if ($txTs === null) {
return null;
}
if (!account_has_statement_period($account)) {
return null;
}
$tx = new DateTime(date('Y-m-d', $txTs));
/*
* 청구월 기준 사용기간 예:
* 2026-05 청구월 = 2026-04-11 ~ 2026-05-10
*
* 거래일 주변의 청구월만 검사하면 됨.
* 전월/당월/익월/익익월까지 여유 있게 검사.
*/
$base = new DateTime($tx->format('Y-m-01'));
for ($i = -1; $i <= 2; $i++) {
$candidate = clone $base;
if ($i !== 0) {
$candidate->modify(($i > 0 ? '+' : '') . $i . ' month');
}
$candidateYm = $candidate->format('Y-m');
if (transaction_date_in_card_statement_month($account, $transactionDate, $candidateYm)) {
return $candidateYm;
}
}
return null;
}
function get_card_billing_year_month(array $account, string $transactionDate): ?string
{
if (($account['account_type'] ?? '') !== 'card') {
return null;
}
$ts = safe_date_ts($transactionDate);
if ($ts === null) {
return null;
}
$cardKind = normalize_card_kind($account);
// 체크카드는 즉시형으로 보고 거래월 그대로
if ($cardKind === 'check') {
return date('Y-m', $ts);
}
// 신용카드인데 신용공여기간 계산을 안 쓰면 거래월 그대로
if (empty($account['use_credit_grace_period'])) {
return date('Y-m', $ts);
}
// 신규 방식: 카드사별 실제 사용기간 설정 우선
$statementYm = get_card_billing_year_month_by_statement_period($account, $transactionDate);
if ($statementYm !== null) {
return $statementYm;
}
// fallback: 기존 billing_day 단순 방식
$billingDay = (int)($account['billing_day'] ?? 0);
if ($billingDay <= 0 || $billingDay > 31) {
return date('Y-m', $ts);
}
$dt = new DateTime(date('Y-m-d', $ts));
$day = (int)$dt->format('d');
if ($day <= $billingDay) {
return $dt->format('Y-m');
}
$dt->modify('first day of next month');
return $dt->format('Y-m');
}
function get_card_payment_date(array $account, string $billingYearMonth): ?string
{
if (($account['account_type'] ?? '') !== 'card') {
return null;
}
if (!preg_match('/^\d{4}-\d{2}$/', $billingYearMonth)) {
return null;
}
$paymentDay = (int)($account['payment_day'] ?? 0);
if ($paymentDay <= 0 || $paymentDay > 31) {
return null;
}
[$year, $month] = explode('-', $billingYearMonth);
$year = (int)$year;
$month = (int)$month;
$firstDay = new DateTime(sprintf('%04d-%02d-01', $year, $month));
$lastDay = (int)$firstDay->format('t');
$day = min($paymentDay, $lastDay);
$firstDay->setDate($year, $month, $day);
return $firstDay->format('Y-m-d');
}
function get_card_statement_period_label(array $account): ?string
{
if (!account_has_statement_period($account)) {
return null;
}
$startOffset = (int)$account['statement_start_month_offset'];
$startDay = (int)$account['statement_start_day'];
$endOffset = (int)$account['statement_end_month_offset'];
$endDay = (int)$account['statement_end_day'];
$monthText = function (int $offset): string {
if ($offset === -2) return '전전월';
if ($offset === -1) return '전월';
if ($offset === 0) return '당월';
if ($offset === 1) return '익월';
if ($offset === 2) return '익익월';
return $offset . '개월';
};
return $monthText($startOffset) . ' ' . $startDay . '일 ~ ' .
$monthText($endOffset) . ' ' . $endDay . '일 사용분';
}
function get_card_billing_label(array $account): string
{
if (($account['account_type'] ?? '') !== 'card') {
return '-';
}
$cardKind = normalize_card_kind($account);
if ($cardKind === 'check') {
return '체크카드 · 즉시출금';
}
if ($cardKind === 'credit') {
$paymentDay = (int)($account['payment_day'] ?? 0);
if (!empty($account['billing_cycle_memo'])) {
return (string)$account['billing_cycle_memo'];
}
$periodLabel = get_card_statement_period_label($account);
if ($periodLabel !== null && $paymentDay > 0) {
return '신용카드 · ' . $periodLabel . ' / 납부일 ' . $paymentDay . '일';
}
if ($periodLabel !== null) {
return '신용카드 · ' . $periodLabel;
}
$billingDay = (int)($account['billing_day'] ?? 0);
if ($billingDay > 0 && $paymentDay > 0) {
return '신용카드 · 결제기준일 ' . $billingDay . '일 / 납부일 ' . $paymentDay . '일';
}
return '신용카드';
}
return '카드';
}
+32
View File
@@ -0,0 +1,32 @@
<?php
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['dbname'],
$config['charset']
);
$pdo = new PDO(
$dsn,
$config['username'],
$config['password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
return $pdo;
}
+98
View File
@@ -0,0 +1,98 @@
<?php
function h($value): string
{
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
}
function won($value): string
{
return number_format((float)$value, 0) . '원';
}
function money_plain($value, int $decimals = 0): string
{
return number_format((float)$value, $decimals);
}
function numf($value, int $decimals = 0): string
{
return number_format((float)$value, $decimals);
}
function intvalf($value): string
{
return number_format((int)$value);
}
function percentf($value, int $decimals = 1): string
{
return number_format((float)$value, $decimals) . '%';
}
function blank_to_dash($value): string
{
$value = trim((string)($value ?? ''));
return $value === '' ? '-' : $value;
}
function ymd($value): string
{
if (empty($value)) {
return '-';
}
$ts = strtotime((string)$value);
if ($ts === false) {
return h((string)$value);
}
return date('Y-m-d', $ts);
}
function ym($value): string
{
if (empty($value)) {
return '-';
}
$ts = strtotime((string)$value . '-01');
if ($ts === false) {
return h((string)$value);
}
return date('Y-m', $ts);
}
function redirect(string $url): void
{
header("Location: {$url}");
exit;
}
function set_flash_message(string $type, string $message): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['flash_message'] = [
'type' => $type,
'message' => $message,
];
}
function get_flash_message(): ?array
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['flash_message'])) {
return null;
}
$flash = $_SESSION['flash_message'];
unset($_SESSION['flash_message']);
return $flash;
}
+704
View File
@@ -0,0 +1,704 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/account_service.php';
require_once __DIR__ . '/card_billing_service.php';
function split_amount_evenly(float $amount, int $months): array
{
if ($months <= 0) {
throw new RuntimeException('개월 수가 올바르지 않습니다.');
}
// 원화 기준: 소수점 제거
$amount = (int)floor($amount);
$base = intdiv($amount, $months);
$remainder = $amount % $months;
$amounts = array_fill(0, $months, $base);
// 남는 원 단위는 마지막 회차에 몰아줌
if ($remainder > 0) {
$amounts[$months - 1] += $remainder;
}
return $amounts;
}
function calculate_installment_interest_total(
float $principalAmount,
int $installmentMonths,
float $annualInterestRate
): float {
if ($installmentMonths <= 1 || $annualInterestRate <= 0) {
return 0.0;
}
$principalAmount = floor($principalAmount);
$monthlyRate = ($annualInterestRate / 100.0) / 12.0;
$averageOutstanding = $principalAmount / 2.0;
$interestTotal = $averageOutstanding * $monthlyRate * $installmentMonths;
// 원화 기준 버림
return (float)floor($interestTotal);
}
function create_installment_schedule(
int $userId,
int $transactionId,
int $accountId,
float $principalAmount,
int $installmentMonths,
string $transactionDate,
float $annualInterestRate = 0.0,
?float $interestTotal = null,
?float $totalBilledAmount = null,
string $interestType = 'none'
): void {
$pdo = db();
if ($installmentMonths <= 1) {
return;
}
// 원화 기준
$principalAmount = (float)floor($principalAmount);
$annualInterestRate = round($annualInterestRate, 4);
if ($interestTotal === null) {
$interestTotal = calculate_installment_interest_total(
$principalAmount,
$installmentMonths,
$annualInterestRate
);
}
$interestTotal = (float)floor($interestTotal);
if ($totalBilledAmount === null) {
$totalBilledAmount = $principalAmount + $interestTotal;
}
$totalBilledAmount = (float)floor($totalBilledAmount);
if ($totalBilledAmount < $principalAmount) {
throw new RuntimeException('총 청구금액은 원금보다 작을 수 없습니다.');
}
if (($principalAmount + $interestTotal) !== $totalBilledAmount) {
throw new RuntimeException('원금 + 총이자와 총 청구금액이 일치해야 합니다.');
}
$pdo->beginTransaction();
try {
$stmtAcc = $pdo->prepare("
SELECT *
FROM accounts
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmtAcc->execute([$accountId, $userId]);
$account = $stmtAcc->fetch();
/*
* 핵심:
* 1회차 시작월은 거래월이 아니라
* 카드사 신용공여기간 계산 결과인 billing_year_month 기준.
*
* 예:
* 2026-04-24 사용 + 우리카드 25일 결제
* => startYm = 2026-05
* => 1회차 2026-05
*/
$startYm = null;
if ($account) {
$startYm = get_card_billing_year_month($account, $transactionDate);
}
if (!$startYm) {
$startYm = date('Y-m', strtotime($transactionDate));
}
$principalParts = split_amount_evenly($principalAmount, $installmentMonths);
$interestParts = split_amount_evenly($interestTotal, $installmentMonths);
$stmt = $pdo->prepare("
INSERT INTO installments
(
user_id,
transaction_id,
account_id,
principal_amount,
interest_total,
total_billed_amount,
installment_months,
annual_interest_rate,
start_year_month,
interest_type,
current_cycle,
is_completed,
prepaid_principal_amount,
prepaid_interest_amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0, 0)
");
$stmt->execute([
$userId,
$transactionId,
$accountId,
$principalAmount,
$interestTotal,
$totalBilledAmount,
$installmentMonths,
$annualInterestRate,
$startYm,
$annualInterestRate > 0 ? $interestType : 'none'
]);
$installmentId = (int)$pdo->lastInsertId();
$stmt = $pdo->prepare("
INSERT INTO installment_schedules
(
installment_id,
cycle_no,
bill_year_month,
principal_amount,
interest_amount,
total_amount,
is_billed,
billed_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$currentYm = date('Y-m');
for ($i = 1; $i <= $installmentMonths; $i++) {
$ym = date('Y-m', strtotime($startYm . '-01 +' . ($i - 1) . ' month'));
$principalPart = (float)$principalParts[$i - 1];
$interestPart = (float)$interestParts[$i - 1];
$totalPart = $principalPart + $interestPart;
// 과거 청구월은 자동 청구완료
$isBilled = ($ym < $currentYm) ? 1 : 0;
$billedAt = $isBilled ? date('Y-m-d H:i:s') : null;
$stmt->execute([
$installmentId,
$i,
$ym,
$principalPart,
$interestPart,
$totalPart,
$isBilled,
$billedAt
]);
}
recalculate_installment_status($installmentId);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function delete_installment_by_transaction_id(int $transactionId): void
{
$pdo = db();
$stmt = $pdo->prepare("DELETE FROM installments WHERE transaction_id = ?");
$stmt->execute([$transactionId]);
}
function get_installment_due_this_month(int $userId, string $yearMonth): float
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(s.total_amount), 0) AS total_due
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE i.user_id = ?
AND s.bill_year_month = ?
AND s.is_billed = 0
");
$stmt->execute([$userId, $yearMonth]);
$row = $stmt->fetch();
return (float)($row['total_due'] ?? 0);
}
function get_installment_remaining_principal(int $userId): float
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(s.principal_amount), 0) AS total_principal
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE i.user_id = ?
AND s.is_billed = 0
");
$stmt->execute([$userId]);
$row = $stmt->fetch();
return (float)($row['total_principal'] ?? 0);
}
function get_installment_remaining_interest(int $userId): float
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(s.interest_amount), 0) AS total_interest
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE i.user_id = ?
AND s.is_billed = 0
");
$stmt->execute([$userId]);
$row = $stmt->fetch();
return (float)($row['total_interest'] ?? 0);
}
function get_installment_remaining_total(int $userId): float
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(s.total_amount), 0) AS remaining_total
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE i.user_id = ?
AND s.is_billed = 0
");
$stmt->execute([$userId]);
$row = $stmt->fetch();
return (float)($row['remaining_total'] ?? 0);
}
function recalculate_installment_status(int $installmentId): void
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN is_billed = 0 THEN 1 ELSE 0 END), 0) AS remaining_count,
COALESCE(MIN(CASE WHEN is_billed = 0 THEN cycle_no ELSE NULL END), 0) AS next_cycle
FROM installment_schedules
WHERE installment_id = ?
");
$stmt->execute([$installmentId]);
$row = $stmt->fetch();
$remainingCount = (int)($row['remaining_count'] ?? 0);
$nextCycle = (int)($row['next_cycle'] ?? 0);
$stmt = $pdo->prepare("
UPDATE installments
SET
current_cycle = ?,
is_completed = ?
WHERE id = ?
");
$stmt->execute([
$nextCycle > 0 ? $nextCycle : 0,
$remainingCount === 0 ? 1 : 0,
$installmentId
]);
}
function mark_installment_month_billed_for_card_payment(
int $userId,
int $cardAccountId,
string $yearMonth
): int {
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT s.id, s.installment_id
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE i.user_id = ?
AND i.account_id = ?
AND s.bill_year_month = ?
AND s.is_billed = 0
ORDER BY s.id ASC
");
$stmt->execute([$userId, $cardAccountId, $yearMonth]);
$rows = $stmt->fetchAll();
if (!$rows) {
$pdo->commit();
return 0;
}
$scheduleIds = array_column($rows, 'id');
$installmentIds = array_values(array_unique(array_map(
fn($r) => (int)$r['installment_id'],
$rows
)));
$placeholders = implode(',', array_fill(0, count($scheduleIds), '?'));
$stmt = $pdo->prepare("
UPDATE installment_schedules
SET is_billed = 1,
billed_at = NOW()
WHERE id IN ($placeholders)
");
$stmt->execute($scheduleIds);
foreach ($installmentIds as $installmentId) {
recalculate_installment_status($installmentId);
}
$pdo->commit();
return count($scheduleIds);
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function prepay_installment(
int $userId,
int $installmentId,
int $paymentAccountId,
string $prepayDate,
float $prepayPrincipalAmount,
float $prepayInterestAmount = 0.0,
?string $description = null,
?int $targetScheduleId = null
): void {
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT i.*, t.merchant_name
FROM installments i
JOIN transactions t ON t.id = i.transaction_id
WHERE i.id = ?
AND i.user_id = ?
");
$stmt->execute([$installmentId, $userId]);
$installment = $stmt->fetch();
if (!$installment) {
throw new RuntimeException('할부 정보를 찾을 수 없습니다.');
}
$prepayPrincipalAmount = (float)floor($prepayPrincipalAmount);
$prepayInterestAmount = (float)floor($prepayInterestAmount);
if ($prepayPrincipalAmount <= 0 && $prepayInterestAmount <= 0) {
throw new RuntimeException('선결제 금액이 0보다 커야 합니다.');
}
$sql = "
SELECT id, principal_amount, interest_amount, total_amount
FROM installment_schedules
WHERE installment_id = ?
AND is_billed = 0
";
$params = [$installmentId];
if ($targetScheduleId !== null && $targetScheduleId > 0) {
$sql .= " AND id = ? ";
$params[] = $targetScheduleId;
}
$sql .= " ORDER BY cycle_no ASC ";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$remainingSchedules = $stmt->fetchAll();
if (!$remainingSchedules) {
throw new RuntimeException('남아있는 회차가 없습니다.');
}
$remainingPrincipal = (float)floor(array_sum(array_map(
fn($r) => (float)$r['principal_amount'],
$remainingSchedules
)));
$remainingInterest = (float)floor(array_sum(array_map(
fn($r) => (float)$r['interest_amount'],
$remainingSchedules
)));
// 초과 입력 시 남은 금액까지만 자동 보정
if ($prepayPrincipalAmount > $remainingPrincipal) {
$prepayPrincipalAmount = $remainingPrincipal;
}
if ($prepayInterestAmount > $remainingInterest) {
$prepayInterestAmount = $remainingInterest;
}
if ($prepayPrincipalAmount <= 0 && $prepayInterestAmount <= 0) {
throw new RuntimeException('남아있는 선결제 가능 금액이 없습니다.');
}
$remainingPrincipalToApply = $prepayPrincipalAmount;
$remainingInterestToApply = $prepayInterestAmount;
foreach ($remainingSchedules as $schedule) {
$scheduleId = (int)$schedule['id'];
$principal = (float)$schedule['principal_amount'];
$interest = (float)$schedule['interest_amount'];
$newPrincipal = $principal;
$newInterest = $interest;
if ($remainingPrincipalToApply > 0) {
$deduct = min($newPrincipal, $remainingPrincipalToApply);
$newPrincipal = $newPrincipal - $deduct;
$remainingPrincipalToApply = $remainingPrincipalToApply - $deduct;
}
if ($remainingInterestToApply > 0) {
$deduct = min($newInterest, $remainingInterestToApply);
$newInterest = $newInterest - $deduct;
$remainingInterestToApply = $remainingInterestToApply - $deduct;
}
$newPrincipal = (float)max(0, floor($newPrincipal));
$newInterest = (float)max(0, floor($newInterest));
$newTotal = $newPrincipal + $newInterest;
$isNowZero = ($newTotal <= 0) ? 1 : 0;
$stmt2 = $pdo->prepare("
UPDATE installment_schedules
SET principal_amount = ?,
interest_amount = ?,
total_amount = ?,
is_billed = CASE WHEN ? = 1 THEN 1 ELSE is_billed END,
billed_at = CASE WHEN ? = 1 THEN NOW() ELSE billed_at END
WHERE id = ?
");
$stmt2->execute([
$newPrincipal,
$newInterest,
$newTotal,
$isNowZero,
$isNowZero,
$scheduleId
]);
}
$totalAmount = $prepayPrincipalAmount + $prepayInterestAmount;
$stmt = $pdo->prepare("
INSERT INTO installment_prepayments
(
user_id,
installment_id,
account_id,
prepay_date,
principal_amount,
interest_amount,
total_amount,
description
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$userId,
$installmentId,
$paymentAccountId,
$prepayDate,
$prepayPrincipalAmount,
$prepayInterestAmount,
$totalAmount,
$description
]);
$stmt = $pdo->prepare("
UPDATE installments
SET
prepaid_principal_amount = prepaid_principal_amount + ?,
prepaid_interest_amount = prepaid_interest_amount + ?
WHERE id = ?
");
$stmt->execute([
$prepayPrincipalAmount,
$prepayInterestAmount,
$installmentId
]);
recalculate_installment_status($installmentId);
$stmt = $pdo->prepare("
INSERT INTO transactions
(
user_id,
account_id,
category_id,
transaction_type,
amount,
is_installment,
installment_months,
installment_interest_rate,
installment_interest_total,
installment_total_billed,
installment_prepay_amount,
transaction_date,
merchant_name,
description,
related_account_id,
fingerprint
)
VALUES (?, ?, ?, 'expense', ?, 0, NULL, 0, 0, NULL, ?, ?, ?, ?, NULL, ?)
");
$desc = $description ?: '할부 선결제/중도상환';
$fingerprint = hash('sha256', implode('|', [
$userId,
$paymentAccountId,
'installment_prepay',
$installmentId,
$prepayDate,
number_format($totalAmount, 2, '.', ''),
$desc
]));
$stmtCat = $pdo->prepare("
SELECT id
FROM categories
WHERE user_id = ?
AND category_type = 'expense'
AND name = '기타지출'
LIMIT 1
");
$stmtCat->execute([$userId]);
$category = $stmtCat->fetch();
if (!$category) {
throw new RuntimeException('선결제 기록용 expense 카테고리(기타지출)를 찾을 수 없습니다.');
}
$stmt->execute([
$userId,
$paymentAccountId,
(int)$category['id'],
$totalAmount,
$totalAmount,
$prepayDate,
$installment['merchant_name'],
'[할부 선결제] ' . $desc,
$fingerprint
]);
$pdo->commit();
recalculate_account_balance($paymentAccountId);
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function rebuild_all_installments_for_user(int $userId): int
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT
id,
account_id,
amount,
transaction_date,
installment_months,
installment_interest_rate,
installment_interest_total,
installment_total_billed
FROM transactions
WHERE user_id = ?
AND transaction_type = 'expense'
AND is_installment = 1
AND installment_months > 1
ORDER BY transaction_date ASC, id ASC
");
$stmt->execute([$userId]);
$rows = $stmt->fetchAll();
$stmt = $pdo->prepare("
DELETE ip
FROM installment_prepayments ip
JOIN installments i ON i.id = ip.installment_id
WHERE i.user_id = ?
");
$stmt->execute([$userId]);
$stmt = $pdo->prepare("
DELETE s
FROM installment_schedules s
JOIN installments i ON i.id = s.installment_id
WHERE i.user_id = ?
");
$stmt->execute([$userId]);
$stmt = $pdo->prepare("
DELETE FROM installments
WHERE user_id = ?
");
$stmt->execute([$userId]);
$pdo->commit();
$count = 0;
foreach ($rows as $row) {
create_installment_schedule(
$userId,
(int)$row['id'],
(int)$row['account_id'],
(float)$row['amount'],
(int)$row['installment_months'],
(string)$row['transaction_date'],
(float)($row['installment_interest_rate'] ?? 0),
$row['installment_interest_total'] !== null
? (float)$row['installment_interest_total']
: null,
$row['installment_total_billed'] !== null
? (float)$row['installment_total_billed']
: null,
((float)($row['installment_interest_rate'] ?? 0) > 0)
? 'fixed_total'
: 'none'
);
$count++;
}
return $count;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
<?php
require_once __DIR__ . '/db.php';
function normalize_merchant_text(string $text): string
{
$text = trim($text);
$text = mb_strtolower($text, 'UTF-8');
$removeWords = [
'주식회사',
'(주)',
'㈜',
'유한회사',
'영농조합법인',
'농업회사법인',
'사단법인',
'재단법인',
'법인',
'카드',
'체크',
'승인',
'취소',
'일시불',
'할부',
'누적',
'잔액',
];
foreach ($removeWords as $word) {
$text = str_replace($word, '', $text);
}
$replace = [
' ' => '',
'-' => '',
'_' => '',
'(' => '',
')' => '',
'[' => '',
']' => '',
'{' => '',
'}' => '',
'.' => '',
',' => '',
':' => '',
';' => '',
'/' => '',
'\\' => '',
'|' => '',
'\'' => '',
'"' => '',
"\t" => '',
"\n" => '',
"\r" => '',
];
$text = strtr($text, $replace);
// 금액/시간/승인번호처럼 추천에 방해되는 숫자 덩어리 완화
$text = preg_replace('/\d{4,}/u', '', $text);
return trim((string)$text);
}
function map_transaction_type_to_category_type(string $transactionType): string
{
if ($transactionType === 'income') {
return 'income';
}
if ($transactionType === 'expense') {
return 'expense';
}
return 'transfer';
}
function merchant_starts_with(string $haystack, string $needle): bool
{
if ($needle === '') {
return false;
}
return mb_substr($haystack, 0, mb_strlen($needle, 'UTF-8'), 'UTF-8') === $needle;
}
function suggest_category_from_merchant(int $userId, string $merchantText, string $transactionType): ?array
{
$pdo = db();
$merchantText = trim($merchantText);
if ($merchantText === '') {
return null;
}
$normalized = normalize_merchant_text($merchantText);
if ($normalized === '') {
return null;
}
$categoryType = map_transaction_type_to_category_type($transactionType);
$stmt = $pdo->prepare("
SELECT
r.id,
r.pattern_text,
r.normalized_pattern,
r.match_type,
r.priority,
r.confidence,
c.id AS category_id,
c.name AS category_name,
c.category_type
FROM merchant_pattern_rules r
JOIN categories c
ON c.id = r.category_id
AND c.user_id = r.user_id
WHERE r.user_id = ?
AND r.is_active = 1
AND c.is_active = 1
AND c.category_type = ?
AND r.normalized_pattern IS NOT NULL
AND r.normalized_pattern <> ''
ORDER BY
CASE r.match_type
WHEN 'exact' THEN 1
WHEN 'prefix' THEN 2
ELSE 3
END ASC,
r.priority ASC,
CHAR_LENGTH(r.normalized_pattern) DESC,
r.id ASC
");
$stmt->execute([$userId, $categoryType]);
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$pattern = (string)$row['normalized_pattern'];
if ($pattern === '') {
continue;
}
$matched = false;
if ($row['match_type'] === 'exact') {
$matched = ($normalized === $pattern);
} elseif ($row['match_type'] === 'prefix') {
$matched = merchant_starts_with($normalized, $pattern);
} else {
$matched = mb_strpos($normalized, $pattern, 0, 'UTF-8') !== false;
}
if ($matched) {
return [
'rule_id' => (int)$row['id'],
'pattern_text' => (string)$row['pattern_text'],
'keyword' => (string)$row['pattern_text'],
'match_type' => (string)$row['match_type'],
'priority' => (int)$row['priority'],
'confidence' => (float)$row['confidence'],
'category_id' => (int)$row['category_id'],
'category_name' => (string)$row['category_name'],
'category_type' => (string)$row['category_type'],
'normalized_input' => $normalized,
];
}
}
return null;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/transaction_service.php';
function apply_recurring_transactions_for_month(int $userId, string $ym, bool $skipDuplicates = true): int
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM recurring_transactions
WHERE user_id = ?
AND is_active = 1
AND (last_applied_ym IS NULL OR last_applied_ym <> ?)
ORDER BY id ASC
");
$stmt->execute([$userId, $ym]);
$items = $stmt->fetchAll();
$count = 0;
$lastDay = (int)date('t', strtotime($ym . '-01'));
foreach ($items as $item) {
$day = min((int)$item['day_of_month'], $lastDay);
$date = sprintf('%s-%02d', $ym, $day);
$inserted = create_transaction([
'user_id' => $userId,
'account_id' => (int)$item['account_id'],
'category_id' => (int)$item['category_id'],
'transaction_type' => $item['transaction_type'],
'amount' => (float)$item['amount'],
'transaction_date' => $date,
'merchant_name' => $item['merchant_name'],
'description' => $item['description'],
'related_account_id' => $item['related_account_id'] ? (int)$item['related_account_id'] : null,
], $skipDuplicates);
$stmt2 = $pdo->prepare("UPDATE recurring_transactions SET last_applied_ym = ? WHERE id = ?");
$stmt2->execute([$ym, $item['id']]);
if ($inserted) {
$count++;
}
}
return $count;
}
+325
View File
@@ -0,0 +1,325 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/helpers.php';
/*
테이블 필요
CREATE TABLE IF NOT EXISTS user_transaction_defaults (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
default_account_id BIGINT UNSIGNED DEFAULT NULL,
default_card_account_id BIGINT UNSIGNED DEFAULT NULL,
default_income_category_id BIGINT UNSIGNED DEFAULT NULL,
default_expense_category_id BIGINT UNSIGNED DEFAULT NULL,
default_transfer_category_id BIGINT UNSIGNED DEFAULT NULL,
default_card_payment_category_id BIGINT UNSIGNED DEFAULT NULL,
keep_last_values TINYINT(1) NOT NULL DEFAULT 1,
continue_after_save TINYINT(1) NOT NULL DEFAULT 1,
quick_amounts VARCHAR(255) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
);
*/
function get_transaction_form_defaults(int $userId): array
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM user_transaction_defaults
WHERE user_id = ?
LIMIT 1
");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row) {
return get_transaction_form_default_seed();
}
$row['keep_last_values'] = (int)$row['keep_last_values'];
$row['continue_after_save'] = (int)$row['continue_after_save'];
$row['quick_amounts'] = parse_quick_amounts($row['quick_amounts'] ?? '');
return array_merge(get_transaction_form_default_seed(), $row);
}
function get_transaction_form_default_seed(): array
{
return [
'default_account_id' => null,
'default_card_account_id' => null,
'default_income_category_id' => null,
'default_expense_category_id' => null,
'default_transfer_category_id' => null,
'default_card_payment_category_id' => null,
'keep_last_values' => 1,
'continue_after_save' => 1,
'quick_amounts' => [10000, 30000, 50000, 100000],
];
}
function save_transaction_form_defaults(int $userId, array $data): void
{
$pdo = db();
$quickAmounts = normalize_quick_amounts(
$data['quick_amounts'] ?? []
);
$stmt = $pdo->prepare("
INSERT INTO user_transaction_defaults
(
user_id,
default_account_id,
default_card_account_id,
default_income_category_id,
default_expense_category_id,
default_transfer_category_id,
default_card_payment_category_id,
keep_last_values,
continue_after_save,
quick_amounts
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
default_account_id = VALUES(default_account_id),
default_card_account_id = VALUES(default_card_account_id),
default_income_category_id = VALUES(default_income_category_id),
default_expense_category_id = VALUES(default_expense_category_id),
default_transfer_category_id = VALUES(default_transfer_category_id),
default_card_payment_category_id = VALUES(default_card_payment_category_id),
keep_last_values = VALUES(keep_last_values),
continue_after_save = VALUES(continue_after_save),
quick_amounts = VALUES(quick_amounts)
");
$stmt->execute([
$userId,
nullable_id($data['default_account_id'] ?? null),
nullable_id($data['default_card_account_id'] ?? null),
nullable_id($data['default_income_category_id'] ?? null),
nullable_id($data['default_expense_category_id'] ?? null),
nullable_id($data['default_transfer_category_id'] ?? null),
nullable_id($data['default_card_payment_category_id'] ?? null),
!empty($data['keep_last_values']) ? 1 : 0,
!empty($data['continue_after_save']) ? 1 : 0,
implode(',', $quickAmounts),
]);
}
function nullable_id($value): ?int
{
$v = (int)$value;
return $v > 0 ? $v : null;
}
function parse_quick_amounts(string $csv): array
{
if (trim($csv) === '') {
return [10000, 30000, 50000, 100000];
}
$items = explode(',', $csv);
$result = [];
foreach ($items as $item) {
$v = (int)trim($item);
if ($v > 0) {
$result[] = $v;
}
}
if (!$result) {
return [10000, 30000, 50000, 100000];
}
return array_values(array_unique($result));
}
function normalize_quick_amounts(array $items): array
{
$result = [];
foreach ($items as $item) {
$v = (int)$item;
if ($v > 0) {
$result[] = $v;
}
}
if (!$result) {
$result = [10000, 30000, 50000, 100000];
}
$result = array_values(array_unique($result));
sort($result);
return $result;
}
/**
* 거래 등록 화면 초기값
*/
function build_transaction_form_state(
int $userId,
array $defaults,
?array $lastState = null
): array {
$state = [
'transaction_date' => current_date_ymd(),
'transaction_type' => 'expense',
'account_id' => $defaults['default_account_id'] ?: 0,
'related_account_id' => 0,
'category_id' => $defaults['default_expense_category_id'] ?: 0,
'amount' => '',
'merchant_name' => '',
'description' => '',
'is_installment' => 0,
'installment_months' => '',
'installment_interest_rate' => '',
'continue_after_save' => $defaults['continue_after_save'],
'keep_last_values' => $defaults['keep_last_values'],
'save_as_defaults' => 0,
];
if (!empty($defaults['keep_last_values']) && is_array($lastState)) {
$state = array_merge($state, $lastState);
$state['transaction_date'] = current_date_ymd();
$state['amount'] = '';
}
return $state;
}
/**
* 유형 변경 시 기본 카테고리 자동 선택
*/
function apply_default_category_by_type(array &$state, array $defaults): void
{
$type = $state['transaction_type'] ?? 'expense';
if ($type === 'income') {
$state['category_id'] = $defaults['default_income_category_id'] ?: 0;
return;
}
if ($type === 'expense') {
$state['category_id'] = $defaults['default_expense_category_id'] ?: 0;
return;
}
if ($type === 'transfer') {
$state['category_id'] = $defaults['default_transfer_category_id'] ?: 0;
return;
}
if ($type === 'card_payment') {
$state['category_id'] = $defaults['default_card_payment_category_id'] ?: 0;
return;
}
}
/**
* 카드 지출이면 카드 기본계좌 우선 적용
*/
function apply_default_card_account(array &$state, array $defaults): void
{
if (($state['transaction_type'] ?? '') !== 'expense') {
return;
}
if (!empty($defaults['default_card_account_id'])) {
$state['account_id'] = (int)$defaults['default_card_account_id'];
}
}
/**
* 저장 후 마지막 상태 저장
*/
function remember_transaction_form_state(array $state): void
{
$_SESSION['tx_create_last_form'] = [
'transaction_type' => $state['transaction_type'],
'account_id' => $state['account_id'],
'related_account_id' => $state['related_account_id'],
'category_id' => $state['category_id'],
'merchant_name' => $state['merchant_name'],
'description' => '',
'is_installment' => $state['is_installment'],
'installment_months' => $state['installment_months'],
'installment_interest_rate' => $state['installment_interest_rate'],
'continue_after_save' => $state['continue_after_save'],
'keep_last_values' => $state['keep_last_values'],
'transaction_date' => current_date_ymd(),
'amount' => '',
];
}
function get_remembered_transaction_form_state(): ?array
{
return $_SESSION['tx_create_last_form'] ?? null;
}
function clear_remembered_transaction_form_state(): void
{
unset($_SESSION['tx_create_last_form']);
}
/**
* 기본값 자동 저장용 데이터 만들기
*/
function make_default_payload_from_form(array $state): array
{
$payload = [
'default_account_id' => 0,
'default_card_account_id' => 0,
'default_income_category_id' => 0,
'default_expense_category_id' => 0,
'default_transfer_category_id' => 0,
'default_card_payment_category_id' => 0,
'keep_last_values' => $state['keep_last_values'] ?? 1,
'continue_after_save' => $state['continue_after_save'] ?? 1,
];
$type = $state['transaction_type'] ?? 'expense';
if ($type === 'income') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_income_category_id'] = $state['category_id'];
} elseif ($type === 'expense') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_expense_category_id'] = $state['category_id'];
$payload['default_card_account_id'] = $state['account_id'];
} elseif ($type === 'transfer') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_transfer_category_id'] = $state['category_id'];
} elseif ($type === 'card_payment') {
$payload['default_account_id'] = $state['account_id'];
$payload['default_card_payment_category_id'] = $state['category_id'];
}
return $payload;
}
+441
View File
@@ -0,0 +1,441 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/account_service.php';
require_once __DIR__ . '/installment_service.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/card_billing_service.php';
function build_transaction_fingerprint(
int $userId,
int $accountId,
?int $relatedAccountId,
int $categoryId,
string $transactionType,
float $amount,
string $transactionDate,
?string $merchantName,
?string $description
): string {
$raw = implode('|', [
$userId,
$accountId,
$relatedAccountId ?? 0,
$categoryId,
$transactionType,
number_format($amount, 2, '.', ''),
$transactionDate,
trim((string)$merchantName),
trim((string)$description),
]);
return hash('sha256', $raw);
}
function get_account_for_transaction(int $userId, int $accountId): ?array
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$accountId, $userId]);
$row = $stmt->fetch();
return $row ?: null;
}
function create_transaction(array $data, bool $skipIfDuplicate = false): bool
{
$pdo = db();
$pdo->beginTransaction();
try {
$fingerprint = build_transaction_fingerprint(
(int)$data['user_id'],
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
(int)$data['category_id'],
(string)$data['transaction_type'],
(float)$data['amount'],
(string)$data['transaction_date'],
$data['merchant_name'] ?? null,
$data['description'] ?? null
);
if ($skipIfDuplicate) {
$stmt = $pdo->prepare("
SELECT id
FROM transactions
WHERE user_id = ?
AND fingerprint = ?
LIMIT 1
");
$stmt->execute([$data['user_id'], $fingerprint]);
if ($stmt->fetch()) {
$pdo->commit();
return false;
}
}
$account = get_account_for_transaction(
(int)$data['user_id'],
(int)$data['account_id']
);
$billingYearMonth = null;
if ($account) {
$billingYearMonth = get_card_billing_year_month(
$account,
(string)$data['transaction_date']
);
}
$isInstallment = !empty($data['is_installment']) ? 1 : 0;
$installmentMonths = !empty($data['installment_months']) ? (int)$data['installment_months'] : null;
$installmentInterestRate = !empty($data['installment_interest_rate']) ? (float)$data['installment_interest_rate'] : 0.0;
$installmentInterestTotal =
isset($data['installment_interest_total']) &&
$data['installment_interest_total'] !== null &&
$data['installment_interest_total'] !== ''
? (float)$data['installment_interest_total']
: null;
$installmentTotalBilled =
isset($data['installment_total_billed']) &&
$data['installment_total_billed'] !== null &&
$data['installment_total_billed'] !== ''
? (float)$data['installment_total_billed']
: null;
if ($isInstallment && ($installmentMonths === null || $installmentMonths <= 1)) {
throw new RuntimeException('할부 개월 수가 올바르지 않습니다.');
}
if ($isInstallment) {
if ($installmentInterestTotal === null) {
$installmentInterestTotal = calculate_installment_interest_total(
(float)$data['amount'],
$installmentMonths,
$installmentInterestRate
);
}
$expectedTotal = round(
(float)$data['amount'] + (float)$installmentInterestTotal,
2
);
if ($installmentTotalBilled === null) {
$installmentTotalBilled = $expectedTotal;
}
if (round($installmentTotalBilled, 2) !== $expectedTotal) {
throw new RuntimeException('총 청구금액은 원금 + 할부이자와 같아야 합니다.');
}
} else {
$installmentMonths = null;
$installmentInterestRate = 0.0;
$installmentInterestTotal = 0.0;
$installmentTotalBilled = null;
}
$stmt = $pdo->prepare("
INSERT INTO transactions
(
user_id,
account_id,
category_id,
transaction_type,
amount,
is_installment,
installment_months,
installment_interest_rate,
installment_interest_total,
installment_total_billed,
installment_prepay_amount,
transaction_date,
billing_year_month,
merchant_name,
description,
related_account_id,
fingerprint
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$data['user_id'],
$data['account_id'],
$data['category_id'],
$data['transaction_type'],
$data['amount'],
$isInstallment,
$installmentMonths,
$installmentInterestRate,
$installmentInterestTotal,
$installmentTotalBilled,
$data['transaction_date'],
$billingYearMonth,
$data['merchant_name'],
$data['description'],
$data['related_account_id'],
$fingerprint,
]);
$transactionId = (int)$pdo->lastInsertId();
$pdo->commit();
if (
$data['transaction_type'] === 'expense' &&
$isInstallment === 1 &&
$installmentMonths !== null &&
$installmentMonths > 1
) {
create_installment_schedule(
(int)$data['user_id'],
$transactionId,
(int)$data['account_id'],
(float)$data['amount'],
$installmentMonths,
(string)$data['transaction_date'],
$installmentInterestRate,
$installmentInterestTotal,
$installmentTotalBilled,
$installmentInterestRate > 0 ? 'fixed_total' : 'none'
);
}
if (
$data['transaction_type'] === 'card_payment' &&
!empty($data['related_account_id'])
) {
$yearMonth = date('Y-m', strtotime((string)$data['transaction_date']));
$processedCount = mark_installment_month_billed_for_card_payment(
(int)$data['user_id'],
(int)$data['related_account_id'],
$yearMonth
);
if ($processedCount > 0) {
set_flash_message(
'success',
$yearMonth . ' 할부 청구 회차 ' . intvalf($processedCount) . '건 자동 처리'
);
}
}
recalculate_account_balance((int)$data['account_id']);
if (!empty($data['related_account_id'])) {
recalculate_account_balance((int)$data['related_account_id']);
}
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function update_transaction(int $transactionId, int $userId, array $data): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT account_id, related_account_id
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$transactionId, $userId]);
$old = $stmt->fetch();
if (!$old) {
throw new RuntimeException('거래를 찾을 수 없습니다.');
}
$fingerprint = build_transaction_fingerprint(
$userId,
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
(int)$data['category_id'],
(string)$data['transaction_type'],
(float)$data['amount'],
(string)$data['transaction_date'],
$data['merchant_name'] ?? null,
$data['description'] ?? null
);
$account = get_account_for_transaction(
$userId,
(int)$data['account_id']
);
$billingYearMonth = null;
if ($account) {
$billingYearMonth = get_card_billing_year_month(
$account,
(string)$data['transaction_date']
);
}
$isInstallment = !empty($data['is_installment']) ? 1 : 0;
$installmentMonths = !empty($data['installment_months']) ? (int)$data['installment_months'] : null;
$installmentInterestRate = !empty($data['installment_interest_rate']) ? (float)$data['installment_interest_rate'] : 0.0;
$stmt = $pdo->prepare("
UPDATE transactions
SET
account_id = ?,
category_id = ?,
transaction_type = ?,
amount = ?,
is_installment = ?,
installment_months = ?,
installment_interest_rate = ?,
installment_interest_total = ?,
installment_total_billed = ?,
transaction_date = ?,
billing_year_month = ?,
merchant_name = ?,
description = ?,
related_account_id = ?,
fingerprint = ?
WHERE id = ?
AND user_id = ?
");
$stmt->execute([
$data['account_id'],
$data['category_id'],
$data['transaction_type'],
$data['amount'],
$isInstallment,
$installmentMonths,
$installmentInterestRate,
$data['installment_interest_total'] ?? 0,
$data['installment_total_billed'] ?? null,
$data['transaction_date'],
$billingYearMonth,
$data['merchant_name'],
$data['description'],
$data['related_account_id'],
$fingerprint,
$transactionId,
$userId
]);
delete_installment_by_transaction_id($transactionId);
if (
$data['transaction_type'] === 'expense' &&
$isInstallment === 1 &&
$installmentMonths !== null &&
$installmentMonths > 1
) {
$interestTotal = calculate_installment_interest_total(
(float)$data['amount'],
$installmentMonths,
$installmentInterestRate
);
$totalBilled = round(
(float)$data['amount'] + (float)$interestTotal,
2
);
create_installment_schedule(
$userId,
$transactionId,
(int)$data['account_id'],
(float)$data['amount'],
$installmentMonths,
(string)$data['transaction_date'],
$installmentInterestRate,
$interestTotal,
$totalBilled,
$installmentInterestRate > 0 ? 'fixed_total' : 'none'
);
}
$pdo->commit();
$recalcIds = array_unique(array_filter([
(int)$old['account_id'],
!empty($old['related_account_id']) ? (int)$old['related_account_id'] : null,
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
]));
foreach ($recalcIds as $id) {
recalculate_account_balance((int)$id);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function delete_transaction(int $transactionId, int $userId): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT account_id, related_account_id
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$transactionId, $userId]);
$row = $stmt->fetch();
if (!$row) {
throw new RuntimeException('거래를 찾을 수 없습니다.');
}
delete_installment_by_transaction_id($transactionId);
$stmt = $pdo->prepare("
DELETE FROM transactions
WHERE id = ?
AND user_id = ?
");
$stmt->execute([$transactionId, $userId]);
$pdo->commit();
recalculate_account_balance((int)$row['account_id']);
if (!empty($row['related_account_id'])) {
recalculate_account_balance((int)$row['related_account_id']);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
+6
View File
@@ -0,0 +1,6 @@
</div>
<script src="/assets/vendor/bootstrap.bundle.min.js"></script>
<script src="https://chaegeon.com/log/logger.js"></script>
<script src="/assets/pwa.js"></script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<?php require_once __DIR__ . '/../lib/helpers.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리</title>
<meta name="description" content="수입·지출 가계부, 계좌·카드 관리, 대출 상환 일정, 카드 할부 청구, 자동 분류 규칙까지 한 번에 관리하는 개인 금융 통합 서비스 Financial.">
<meta name="keywords" content="가계부, 자산관리, 개인재무, 대출관리, 할부관리, 카드관리, 수입지출, 금융관리, Financial">
<meta name="author" content="Financial">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#0b2a66">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Financial">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<link rel="shortcut icon" href="/favicon.png?v=2">
<link rel="apple-touch-icon" href="/favicon.png?v=2">
<link rel="manifest" href="/manifest.webmanifest">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Financial">
<meta property="og:title" content="Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리">
<meta property="og:description" content="계좌, 카드, 가계부, 대출, 할부를 한 곳에서 쉽고 체계적으로 관리하세요.">
<meta property="og:image" content="https://seo.chaegeon.com/favicon.png">
<meta property="og:url" content="https://seo.chaegeon.com/">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Financial">
<meta name="twitter:description" content="개인 금융 통합 관리 서비스">
<meta name="twitter:image" content="https://seo.chaegeon.com/favicon.png">
<link href="/assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="/assets/app.css" rel="stylesheet">
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
<script src="/assets/vendor/chart.umd.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg sticky-top">
<div class="container">
<a class="navbar-brand" href="/dashboard.php">Financial</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#topnav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="topnav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="/dashboard.php">대시보드</a></li>
<li class="nav-item"><a class="nav-link" href="/accounts.php">계좌/카드</a></li>
<li class="nav-item"><a class="nav-link" href="/transactions.php">거래내역</a></li>
<li class="nav-item"><a class="nav-link" href="/installments.php">할부내역</a></li>
<li class="nav-item"><a class="nav-link" href="/transaction_create.php">거래등록</a></li>
<li class="nav-item"><a class="nav-link" href="/categories.php">카테고리</a></li>
<li class="nav-item"><a class="nav-link" href="/merchant_rules.php">자동분류</a></li>
<li class="nav-item"><a class="nav-link" href="/recurring.php">고정거래</a></li>
<li class="nav-item"><a class="nav-link" href="/loans.php">대출</a></li>
</ul>
<div class="d-flex align-items-center gap-3">
<span class="text-secondary small"><?= h($_SESSION['username'] ?? '') ?></span>
<a class="btn btn-outline-secondary btn-sm" href="/logout.php">로그아웃</a>
</div>
</div>
</div>
</nav>
<nav class="app-tabbar" aria-label="주요 메뉴">
<a href="/dashboard.php" class="app-tabbar-item">대시보드</a>
<a href="/transaction_create.php" class="app-tabbar-item app-tabbar-primary">거래등록</a>
<a href="/transactions.php" class="app-tabbar-item">거래내역</a>
<a href="/accounts.php" class="app-tabbar-item">계좌</a>
<a href="/loans.php" class="app-tabbar-item">대출</a>
</nav>
<div class="container py-4">
<?php $flash = function_exists('get_flash_message') ? get_flash_message() : null; ?>
<?php if ($flash): ?>
<div class="alert alert-<?= h($flash['type']) ?> mb-4">
<?= h($flash['message']) ?>
</div>
<?php endif; ?>