# v5: Money Forward API - 데이터 조회 ## ⚠️ 실제 구현 결과 이번 구현에서는 Business API의 제한된 scope (`mfc/admin/tenant.read`)로 인해 Tenant 정보만 조회가 가능했습니다. ### 조회 가능한 데이터 **현재 구현**: - ✅ `/v2/tenant` - 사업체 기본 정보 ```json { "tenant_code": "1911-2783", "tenant_name": "株式会社PlanitAI" } ``` **추가 scope 필요**: - ❌ 거래 내역 - `deal:read` scope 필요 - ❌ 계정 정보 - `account_item:read` scope 필요 - ❌ 파트너 정보 - `partner:read` scope 필요 ## Money Forward Business API 엔드포인트 ### API Base URL ``` https://api.biz.moneyforward.com ``` ### 주요 엔드포인트 | 엔드포인트 | 설명 | |-----------|------| | `/user` | 사용자 정보 | | `/accounts` | 연동된 계좌 목록 | | `/transactions` | 거래 내역 (가장 중요!) | | `/categories` | 카테고리 목록 | ## API 클라이언트 구현 ### src/moneyforward/client.ts ```typescript import axios, { AxiosInstance } from 'axios'; import { MoneyForwardAuth } from './auth'; import { MFAccount, MFTransaction, MFCategory } from '../types'; export class MoneyForwardClient { private auth: MoneyForwardAuth; private client: AxiosInstance; private baseUrl = 'https://moneyforward.com/api/v1'; constructor() { this.auth = new MoneyForwardAuth(); // axios 인스턴스 생성 this.client = axios.create({ baseURL: this.baseUrl, headers: { 'Content-Type': 'application/json', }, }); // 요청 인터셉터: 자동으로 Access Token 추가 this.client.interceptors.request.use(async (config) => { const accessToken = await this.auth.getValidAccessToken(); config.headers.Authorization = `Bearer ${accessToken}`; return config; }); // 응답 인터셉터: 에러 처리 this.client.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { // Unauthorized - 토큰 갱신 필요 console.log('⚠️ 401 Unauthorized - 토큰 갱신 중...'); const tokens = await this.auth.loadTokens(); if (tokens) { await this.auth.refreshAccessToken(tokens.refresh_token); // 재시도 return this.client.request(error.config); } } return Promise.reject(error); } ); } /** * 사용자 정보 조회 */ async getUser() { const response = await this.client.get('/user'); return response.data; } /** * 계좌 목록 조회 */ async getAccounts(): Promise { const response = await this.client.get('/accounts'); return response.data.accounts; } /** * 거래 내역 조회 */ async getTransactions(params?: { from_date?: string; // YYYY-MM-DD to_date?: string; // YYYY-MM-DD account_id?: string; limit?: number; // 기본 100, 최대 100 offset?: number; // 페이지네이션 }): Promise { const response = await this.client.get('/transactions', { params }); return response.data.transactions; } /** * 카테고리 목록 조회 */ async getCategories(): Promise { const response = await this.client.get('/categories'); return response.data.categories; } /** * 특정 기간의 모든 거래 내역 조회 (페이지네이션 자동 처리) */ async getAllTransactions( fromDate: string, toDate: string ): Promise { const allTransactions: MFTransaction[] = []; let offset = 0; const limit = 100; while (true) { console.log(`📥 거래 내역 조회 중... (offset: ${offset})`); const transactions = await this.getTransactions({ from_date: fromDate, to_date: toDate, limit, offset, }); if (transactions.length === 0) { break; } allTransactions.push(...transactions); if (transactions.length < limit) { // 마지막 페이지 break; } offset += limit; // Rate Limiting 방지를 위한 딜레이 await this.sleep(500); } console.log(`✅ 총 ${allTransactions.length}건의 거래 내역 조회 완료\n`); return allTransactions; } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } } ``` ## 거래 데이터 조회 스크립트 ### src/cli-fetch-transactions.ts ```typescript import 'dotenv/config'; import { MoneyForwardClient } from './moneyforward/client'; import { format, subMonths } from 'date-fns'; async function main() { console.log('💰 Money Forward 거래 내역 조회\n'); const client = new MoneyForwardClient(); try { // 1. 사용자 정보 확인 console.log('👤 사용자 정보 조회 중...'); const user = await client.getUser(); console.log(`✅ 사용자: ${user.email}\n`); // 2. 계좌 목록 조회 console.log('🏦 계좌 목록 조회 중...'); const accounts = await client.getAccounts(); console.log(`✅ 연동된 계좌: ${accounts.length}개\n`); accounts.forEach((account, index) => { console.log(`${index + 1}. ${account.name} (${account.type})`); console.log(` 잔액: ¥${account.balance.toLocaleString()}`); }); console.log(); // 3. 거래 내역 조회 (최근 1개월) const toDate = format(new Date(), 'yyyy-MM-dd'); const fromDate = format(subMonths(new Date(), 1), 'yyyy-MM-dd'); console.log(`📅 조회 기간: ${fromDate} ~ ${toDate}\n`); const transactions = await client.getAllTransactions(fromDate, toDate); // 4. 기본 통계 계산 const totalIncome = transactions .filter((t) => t.is_income) .reduce((sum, t) => sum + t.amount, 0); const totalExpense = transactions .filter((t) => !t.is_income) .reduce((sum, t) => sum + t.amount, 0); console.log('📊 기본 통계'); console.log('─'.repeat(50)); console.log(`총 수입: ¥${totalIncome.toLocaleString()}`); console.log(`총 지출: ¥${totalExpense.toLocaleString()}`); console.log(`순자산 변동: ¥${(totalIncome - totalExpense).toLocaleString()}`); console.log(`거래 건수: ${transactions.length}건\n`); // 5. 최근 거래 10건 출력 console.log('📝 최근 거래 내역 (10건)'); console.log('─'.repeat(50)); transactions.slice(0, 10).forEach((t, index) => { const sign = t.is_income ? '+' : '-'; const emoji = t.is_income ? '💰' : '💸'; console.log(`${index + 1}. ${emoji} ${t.date} | ${t.content}`); console.log(` ${sign}¥${t.amount.toLocaleString()} (${t.category.name})`); }); console.log('\n✅ 조회 완료!'); } catch (error) { if (error instanceof Error) { console.error('❌ 에러:', error.message); } else { console.error('❌ 알 수 없는 에러:', error); } process.exit(1); } } main(); ``` ### package.json 스크립트 추가 ```json { "scripts": { "dev": "tsx src/index.ts", "auth": "tsx src/cli-auth.ts", "fetch": "tsx src/cli-fetch-transactions.ts", "build": "tsc" } } ``` ## 실행 예제 ### 1. 거래 내역 조회 ```bash npm run fetch ``` 예상 출력: ``` 💰 Money Forward 거래 내역 조회 👤 사용자 정보 조회 중... ✅ 사용자: user@example.com 🏦 계좌 목록 조회 중... ✅ 연동된 계좌: 3개 1. UFJ銀行 普通預金 (bank) 잔액: ¥450,000 2. 楽天カード (card) 잔액: ¥-35,200 3. PayPay (emoney) 잔액: ¥8,500 📅 조회 기간: 2025-10-30 ~ 2025-11-30 📥 거래 내역 조회 중... (offset: 0) 📥 거래 내역 조회 중... (offset: 100) ✅ 총 137건의 거래 내역 조회 완료 📊 기본 통계 ────────────────────────────────────────────────── 총 수입: ¥500,000 총 지출: ¥320,000 순자산 변동: ¥180,000 거래 건수: 137건 📝 최근 거래 내역 (10건) ────────────────────────────────────────────────── 1. 💸 2025-11-29 | セブンイレブン -¥850 (食費) 2. 💸 2025-11-29 | Amazon -¥3,980 (日用品) 3. 💰 2025-11-28 | 給料 +¥250,000 (給与) 4. 💸 2025-11-27 | スターバックス -¥680 (カフェ) 5. 💸 2025-11-27 | Uber Eats -¥1,200 (食費) ... ✅ 조회 완료! ``` ## 거래 데이터 분석 유틸리티 ### src/utils/transaction-analyzer.ts ```typescript import { MFTransaction } from '../types'; export class TransactionAnalyzer { /** * 카테고리별 집계 */ static groupByCategory(transactions: MFTransaction[]) { const categoryMap = new Map(); transactions.forEach((t) => { const categoryName = t.category.name; const current = categoryMap.get(categoryName) || 0; categoryMap.set(categoryName, current + t.amount); }); // 금액 순으로 정렬 return Array.from(categoryMap.entries()) .map(([name, amount]) => ({ name, amount })) .sort((a, b) => b.amount - a.amount); } /** * 수입/지출 분리 */ static splitIncomeExpense(transactions: MFTransaction[]) { return { income: transactions.filter((t) => t.is_income), expense: transactions.filter((t) => !t.is_income), }; } /** * 날짜별 집계 */ static groupByDate(transactions: MFTransaction[]) { const dateMap = new Map(); transactions.forEach((t) => { const current = dateMap.get(t.date) || 0; dateMap.set(t.date, current + t.amount); }); return Array.from(dateMap.entries()) .map(([date, amount]) => ({ date, amount })) .sort((a, b) => a.date.localeCompare(b.date)); } /** * TOP N 지출 항목 */ static getTopExpenses(transactions: MFTransaction[], n: number = 10) { return transactions .filter((t) => !t.is_income) .sort((a, b) => b.amount - a.amount) .slice(0, n); } /** * 기본 통계 */ static getBasicStats(transactions: MFTransaction[]) { const { income, expense } = this.splitIncomeExpense(transactions); const totalIncome = income.reduce((sum, t) => sum + t.amount, 0); const totalExpense = expense.reduce((sum, t) => sum + t.amount, 0); return { totalIncome, totalExpense, netChange: totalIncome - totalExpense, transactionCount: transactions.length, incomeCount: income.length, expenseCount: expense.length, avgIncome: income.length > 0 ? totalIncome / income.length : 0, avgExpense: expense.length > 0 ? totalExpense / expense.length : 0, }; } } ``` ## 거래 데이터 포맷팅 ### src/utils/formatter.ts ```typescript import { MFTransaction } from '../types'; export class Formatter { /** * 금액 포맷팅 */ static formatAmount(amount: number, currency: string = '¥'): string { return `${currency}${amount.toLocaleString()}`; } /** * 날짜 포맷팅 */ static formatDate(date: string): string { const d = new Date(date); return d.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit', }); } /** * 거래 내역을 텍스트로 변환 (AI 프롬프트용) */ static transactionsToText(transactions: MFTransaction[]): string { return transactions .map((t) => { const sign = t.is_income ? '+' : '-'; return `${t.date}: ${t.content} - ${sign}¥${t.amount.toLocaleString()} (${t.category.name})`; }) .join('\n'); } /** * 카테고리 통계를 텍스트로 변환 */ static categoryStatsToText( categories: Array<{ name: string; amount: number }> ): string { return categories .map((c, i) => `${i + 1}. ${c.name}: ¥${c.amount.toLocaleString()}`) .join('\n'); } } ``` ## 데이터 저장 ### src/utils/file-saver.ts ```typescript import fs from 'fs/promises'; import path from 'path'; import { MFTransaction } from '../types'; export class FileSaver { /** * JSON 파일로 저장 */ static async saveAsJSON( data: any, filename: string, directory: string = './data' ): Promise { await fs.mkdir(directory, { recursive: true }); const filepath = path.join(directory, filename); await fs.writeFile(filepath, JSON.stringify(data, null, 2)); console.log(`✅ 파일 저장: ${filepath}`); } /** * CSV 파일로 저장 */ static async saveAsCSV( transactions: MFTransaction[], filename: string, directory: string = './data' ): Promise { const header = 'Date,Content,Amount,IsIncome,Category,Account\n'; const rows = transactions.map((t) => [ t.date, `"${t.content}"`, t.amount, t.is_income, `"${t.category.name}"`, `"${t.account.name}"`, ].join(',') ); const csv = header + rows.join('\n'); await fs.mkdir(directory, { recursive: true }); const filepath = path.join(directory, filename); await fs.writeFile(filepath, csv); console.log(`✅ CSV 저장: ${filepath}`); } } ``` ## 체크리스트 v5를 완료하기 전에 다음을 확인하세요: - [ ] `MoneyForwardClient` 클래스 구현 완료 - [ ] `cli-fetch-transactions.ts` 스크립트 생성 - [ ] `npm run fetch` 실행하여 거래 내역 조회 성공 - [ ] `TransactionAnalyzer` 유틸리티 구현 - [ ] `Formatter` 유틸리티 구현 - [ ] 기본 통계 계산 확인 - [ ] 카테고리별 집계 확인 ## 다음 단계 v6에서는 Google Cloud 프로젝트를 생성하고 Vertex AI를 설정하여 AI 분석 준비를 완료합니다. --- **작성일**: 2025-11-30 **상태**: ✅ 완료 **다음**: v6 - Google Cloud 및 Vertex AI 설정