@kansei-link/bantou 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/data/exclusion-rules/README.md +104 -0
- package/data/exclusion-rules/jp-tax-baseline-v1.json +185 -0
- package/data/exclusion-rules-schema.json +109 -0
- package/data/keyword-dict/README.md +91 -0
- package/data/keyword-dict/jp-tax-baseline-v1.json +398 -0
- package/data/keyword-dict-schema.json +117 -0
- package/data/tax-rules/jp-tax-rules-v1.json +170 -0
- package/dist/adapters/csv-parser.d.ts +11 -0
- package/dist/adapters/csv-parser.js +133 -0
- package/dist/adapters/freee-csv-adapter.d.ts +14 -0
- package/dist/adapters/freee-csv-adapter.js +67 -0
- package/dist/adapters/generic-adapter.d.ts +20 -0
- package/dist/adapters/generic-adapter.js +73 -0
- package/dist/adapters/index.d.ts +23 -0
- package/dist/adapters/index.js +386 -0
- package/dist/adapters/types.d.ts +111 -0
- package/dist/adapters/types.js +9 -0
- package/dist/adapters/yayoi-adapter.d.ts +46 -0
- package/dist/adapters/yayoi-adapter.js +181 -0
- package/dist/bin/freee-doctor.d.ts +3 -0
- package/dist/bin/freee-doctor.js +15 -0
- package/dist/classifier/claude-classifier.d.ts +24 -0
- package/dist/classifier/claude-classifier.js +154 -0
- package/dist/classifier/keyword-classifier.d.ts +22 -0
- package/dist/classifier/keyword-classifier.js +124 -0
- package/dist/classifier/keyword-match.d.ts +21 -0
- package/dist/classifier/keyword-match.js +57 -0
- package/dist/classifier/normalize.d.ts +3 -0
- package/dist/classifier/normalize.js +27 -0
- package/dist/classifier/two-stage-classifier.d.ts +21 -0
- package/dist/classifier/two-stage-classifier.js +51 -0
- package/dist/classifier/types.d.ts +31 -0
- package/dist/classifier/types.js +3 -0
- package/dist/connectors/freee.d.ts +115 -0
- package/dist/connectors/freee.js +177 -0
- package/dist/exclusion/exclusion-checker.d.ts +10 -0
- package/dist/exclusion/exclusion-checker.js +162 -0
- package/dist/freee-doctor.d.ts +26 -0
- package/dist/freee-doctor.js +82 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +656 -0
- package/dist/memory/cockpit-memory.d.ts +73 -0
- package/dist/memory/cockpit-memory.js +473 -0
- package/dist/memory/types.d.ts +114 -0
- package/dist/memory/types.js +11 -0
- package/dist/pipeline/confidence-router.d.ts +38 -0
- package/dist/pipeline/confidence-router.js +129 -0
- package/dist/pipeline/nightly-pipeline.d.ts +44 -0
- package/dist/pipeline/nightly-pipeline.js +497 -0
- package/dist/pipeline/types.d.ts +84 -0
- package/dist/pipeline/types.js +12 -0
- package/dist/reports/monthly-report.d.ts +64 -0
- package/dist/reports/monthly-report.js +230 -0
- package/dist/secrets.d.ts +14 -0
- package/dist/secrets.js +86 -0
- package/dist/tax-rules/tax-rule-engine.d.ts +103 -0
- package/dist/tax-rules/tax-rule-engine.js +449 -0
- package/dist/tax-rules/types.d.ts +103 -0
- package/dist/tax-rules/types.js +7 -0
- package/package.json +74 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Monthly review report generator.
|
|
2
|
+
//
|
|
3
|
+
// Takes classified transaction data and produces a practitioner-proven
|
|
4
|
+
// monthly review report in Markdown format.
|
|
5
|
+
//
|
|
6
|
+
// Features:
|
|
7
|
+
// - Category-wise expense breakdown with monthly/yearly comparison
|
|
8
|
+
// - Anomaly detection (significant deviations from expected patterns)
|
|
9
|
+
// - Action items (transactions requiring human review)
|
|
10
|
+
// - Summary KPIs (total expense, income, classification rate)
|
|
11
|
+
/**
|
|
12
|
+
* Generate a monthly report from raw transactions.
|
|
13
|
+
*
|
|
14
|
+
* @param transactions - Array of raw transactions (from freee API, CSV import, etc.)
|
|
15
|
+
* @param classifier - TwoStageClassifier instance.
|
|
16
|
+
* @param exclusion - ExclusionChecker instance.
|
|
17
|
+
* @param router - ConfidenceRouter instance.
|
|
18
|
+
* @param opts - Report options.
|
|
19
|
+
*/
|
|
20
|
+
export async function generateMonthlyReport(transactions, classifier, exclusion, router, opts) {
|
|
21
|
+
const format = opts.format || 'markdown';
|
|
22
|
+
// 1. Classify all transactions
|
|
23
|
+
const processed = [];
|
|
24
|
+
let totalExpense = 0;
|
|
25
|
+
let totalIncome = 0;
|
|
26
|
+
let autoCount = 0;
|
|
27
|
+
for (const tx of transactions) {
|
|
28
|
+
const exc = exclusion.check(tx);
|
|
29
|
+
let cls = null;
|
|
30
|
+
if (!exc.excluded) {
|
|
31
|
+
cls = await classifier.classify(tx);
|
|
32
|
+
}
|
|
33
|
+
const routing = router.route(exc, cls, {
|
|
34
|
+
amount: tx.amount,
|
|
35
|
+
partner_name: tx.partner_name,
|
|
36
|
+
is_new_partner: false,
|
|
37
|
+
date: tx.date,
|
|
38
|
+
});
|
|
39
|
+
const rt = {
|
|
40
|
+
transaction: tx,
|
|
41
|
+
category_id: cls?.category_id,
|
|
42
|
+
category_name_ja: cls?.category_name_ja,
|
|
43
|
+
confidence: cls?.confidence,
|
|
44
|
+
excluded: exc.excluded,
|
|
45
|
+
exclusion_rule: exc.rule_id,
|
|
46
|
+
action: routing.action,
|
|
47
|
+
flags: routing.flags,
|
|
48
|
+
};
|
|
49
|
+
processed.push(rt);
|
|
50
|
+
// Accumulate totals (rough heuristic: negative = income, positive = expense)
|
|
51
|
+
totalExpense += tx.amount;
|
|
52
|
+
if (routing.action === 'auto_register' || routing.action === 'auto_register_with_log') {
|
|
53
|
+
autoCount++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 2. Build category aggregates
|
|
57
|
+
const catMap = new Map();
|
|
58
|
+
for (const rt of processed) {
|
|
59
|
+
if (rt.excluded)
|
|
60
|
+
continue;
|
|
61
|
+
const catId = rt.category_id || '_unclassified';
|
|
62
|
+
const catName = rt.category_name_ja || '未分類';
|
|
63
|
+
if (!catMap.has(catId)) {
|
|
64
|
+
catMap.set(catId, {
|
|
65
|
+
category_id: catId,
|
|
66
|
+
category_name_ja: catName,
|
|
67
|
+
count: 0,
|
|
68
|
+
total_amount: 0,
|
|
69
|
+
transactions: [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const agg = catMap.get(catId);
|
|
73
|
+
agg.count++;
|
|
74
|
+
agg.total_amount += rt.transaction.amount;
|
|
75
|
+
agg.transactions.push(rt);
|
|
76
|
+
}
|
|
77
|
+
const categories = [...catMap.values()].sort((a, b) => b.total_amount - a.total_amount);
|
|
78
|
+
// 3. Detect anomalies
|
|
79
|
+
const anomalies = detectAnomalies(processed, categories);
|
|
80
|
+
// 4. Build comparison data (if provided)
|
|
81
|
+
let comparisonCategories;
|
|
82
|
+
if (opts.compare_transactions && opts.compare_transactions.length > 0) {
|
|
83
|
+
comparisonCategories = new Map();
|
|
84
|
+
for (const tx of opts.compare_transactions) {
|
|
85
|
+
const cls = await classifier.classify(tx);
|
|
86
|
+
const catId = cls?.category_id || '_unclassified';
|
|
87
|
+
comparisonCategories.set(catId, (comparisonCategories.get(catId) || 0) + tx.amount);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 5. Review items
|
|
91
|
+
const reviewItems = processed.filter(rt => rt.action === 'human_review');
|
|
92
|
+
// 6. Rates
|
|
93
|
+
const total = processed.length;
|
|
94
|
+
const classifiedCount = processed.filter(rt => rt.category_id && !rt.excluded).length;
|
|
95
|
+
const classificationRate = total > 0 ? ((classifiedCount / total) * 100).toFixed(1) + '%' : '0%';
|
|
96
|
+
const autoRegisterRate = total > 0 ? ((autoCount / total) * 100).toFixed(1) + '%' : '0%';
|
|
97
|
+
// 7. Build markdown
|
|
98
|
+
let markdown;
|
|
99
|
+
if (format === 'markdown') {
|
|
100
|
+
markdown = buildMarkdown(opts.company_name || '(Company)', opts.month, total, totalExpense, totalIncome, classificationRate, autoRegisterRate, categories, anomalies, reviewItems, comparisonCategories, opts.compare_label);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
company_name: opts.company_name,
|
|
105
|
+
month: opts.month,
|
|
106
|
+
format,
|
|
107
|
+
total_transactions: total,
|
|
108
|
+
total_expense: totalExpense,
|
|
109
|
+
total_income: totalIncome,
|
|
110
|
+
classification_rate: classificationRate,
|
|
111
|
+
auto_register_rate: autoRegisterRate,
|
|
112
|
+
categories,
|
|
113
|
+
anomalies,
|
|
114
|
+
review_items: reviewItems,
|
|
115
|
+
markdown,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// ============================================================
|
|
119
|
+
// Anomaly detection
|
|
120
|
+
// ============================================================
|
|
121
|
+
function detectAnomalies(transactions, categories) {
|
|
122
|
+
const anomalies = [];
|
|
123
|
+
// Rule 1: Single transactions > ¥500K
|
|
124
|
+
for (const rt of transactions) {
|
|
125
|
+
if (rt.transaction.amount > 500_000 && !rt.excluded) {
|
|
126
|
+
anomalies.push({
|
|
127
|
+
type: 'high_amount',
|
|
128
|
+
severity: rt.transaction.amount > 1_000_000 ? 'critical' : 'warning',
|
|
129
|
+
description: `高額取引: ¥${rt.transaction.amount.toLocaleString()}`,
|
|
130
|
+
details: `${rt.transaction.date} ${rt.transaction.memo}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Rule 2: 交際費 (entertainment) > ¥100K total — flag for 5,000円基準 review
|
|
135
|
+
const entertainment = categories.find(c => c.category_id === 'entertainment');
|
|
136
|
+
if (entertainment && entertainment.total_amount > 100_000) {
|
|
137
|
+
anomalies.push({
|
|
138
|
+
type: 'unusual_category',
|
|
139
|
+
severity: 'warning',
|
|
140
|
+
description: `交際費合計 ¥${entertainment.total_amount.toLocaleString()} — 5,000円基準/損金算入限度 確認推奨`,
|
|
141
|
+
details: `${entertainment.count}件の交際費取引`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Rule 3: Unclassified transactions > 10%
|
|
145
|
+
const unclassified = categories.find(c => c.category_id === '_unclassified');
|
|
146
|
+
if (unclassified) {
|
|
147
|
+
const total = transactions.filter(t => !t.excluded).length;
|
|
148
|
+
const ratio = total > 0 ? unclassified.count / total : 0;
|
|
149
|
+
if (ratio > 0.1) {
|
|
150
|
+
anomalies.push({
|
|
151
|
+
type: 'unusual_category',
|
|
152
|
+
severity: 'warning',
|
|
153
|
+
description: `未分類率 ${(ratio * 100).toFixed(1)}% — キーワード辞書の拡充推奨`,
|
|
154
|
+
details: `${unclassified.count}/${total} 件が未分類`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return anomalies;
|
|
159
|
+
}
|
|
160
|
+
// ============================================================
|
|
161
|
+
// Markdown builder
|
|
162
|
+
// ============================================================
|
|
163
|
+
function buildMarkdown(companyName, month, total, totalExpense, totalIncome, classificationRate, autoRegisterRate, categories, anomalies, reviewItems, comparisonCategories, compareLabel) {
|
|
164
|
+
const [year, mon] = month.split('-');
|
|
165
|
+
const title = `${companyName} — ${year}年${parseInt(mon)}月 月次レビューレポート`;
|
|
166
|
+
let md = `# ${title}\n\n`;
|
|
167
|
+
md += `> Generated: ${new Date().toISOString().slice(0, 10)} by Cockpit MCP\n\n`;
|
|
168
|
+
// ── Summary ──
|
|
169
|
+
md += `## Summary\n\n`;
|
|
170
|
+
md += `| Metric | Value |\n|---|---|\n`;
|
|
171
|
+
md += `| Total transactions | ${total} |\n`;
|
|
172
|
+
md += `| Total expense | ¥${totalExpense.toLocaleString()} |\n`;
|
|
173
|
+
md += `| Classification rate | ${classificationRate} |\n`;
|
|
174
|
+
md += `| Auto-register rate | ${autoRegisterRate} |\n`;
|
|
175
|
+
md += `| Review required | ${reviewItems.length} |\n`;
|
|
176
|
+
md += `| Anomalies detected | ${anomalies.length} |\n\n`;
|
|
177
|
+
// ── Anomalies ──
|
|
178
|
+
if (anomalies.length > 0) {
|
|
179
|
+
md += `## Anomalies\n\n`;
|
|
180
|
+
for (const a of anomalies) {
|
|
181
|
+
const icon = a.severity === 'critical' ? '[CRITICAL]' : '[WARNING]';
|
|
182
|
+
md += `- ${icon} **${a.description}**\n`;
|
|
183
|
+
md += ` ${a.details}\n`;
|
|
184
|
+
}
|
|
185
|
+
md += '\n';
|
|
186
|
+
}
|
|
187
|
+
// ── Category Breakdown ──
|
|
188
|
+
md += `## Category Breakdown\n\n`;
|
|
189
|
+
if (comparisonCategories && compareLabel) {
|
|
190
|
+
md += `| Category | Count | Amount | ${compareLabel} | Change |\n|---|---|---|---|---|\n`;
|
|
191
|
+
for (const cat of categories) {
|
|
192
|
+
const prevAmount = comparisonCategories.get(cat.category_id) || 0;
|
|
193
|
+
const change = prevAmount > 0
|
|
194
|
+
? ((cat.total_amount - prevAmount) / prevAmount * 100).toFixed(1) + '%'
|
|
195
|
+
: 'NEW';
|
|
196
|
+
const changeSign = cat.total_amount > prevAmount ? '+' : '';
|
|
197
|
+
md += `| ${cat.category_name_ja} | ${cat.count} | ¥${cat.total_amount.toLocaleString()} | ¥${prevAmount.toLocaleString()} | ${changeSign}${change} |\n`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
md += `| Category | Count | Amount | Share |\n|---|---|---|---|\n`;
|
|
202
|
+
for (const cat of categories) {
|
|
203
|
+
const share = totalExpense > 0
|
|
204
|
+
? ((cat.total_amount / totalExpense) * 100).toFixed(1) + '%'
|
|
205
|
+
: '0%';
|
|
206
|
+
md += `| ${cat.category_name_ja} | ${cat.count} | ¥${cat.total_amount.toLocaleString()} | ${share} |\n`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
md += '\n';
|
|
210
|
+
// ── Review Items ──
|
|
211
|
+
if (reviewItems.length > 0) {
|
|
212
|
+
md += `## Review Required (${reviewItems.length} items)\n\n`;
|
|
213
|
+
md += `| Date | Amount | Memo | Reason |\n|---|---|---|---|\n`;
|
|
214
|
+
for (const rt of reviewItems.slice(0, 30)) {
|
|
215
|
+
const reason = rt.flags.length > 0
|
|
216
|
+
? rt.flags.join(', ')
|
|
217
|
+
: (rt.excluded ? `Excluded: ${rt.exclusion_rule}` : 'Low confidence');
|
|
218
|
+
md += `| ${rt.transaction.date} | ¥${rt.transaction.amount.toLocaleString()} | ${rt.transaction.memo.slice(0, 35)} | ${reason} |\n`;
|
|
219
|
+
}
|
|
220
|
+
if (reviewItems.length > 30) {
|
|
221
|
+
md += `| ... | ... | ... | +${reviewItems.length - 30} more |\n`;
|
|
222
|
+
}
|
|
223
|
+
md += '\n';
|
|
224
|
+
}
|
|
225
|
+
// ── Footer ──
|
|
226
|
+
md += `---\n\n`;
|
|
227
|
+
md += `*Generated by @kansei-link/cockpit MCP — automated monthly review*\n`;
|
|
228
|
+
return md;
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=monthly-report.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface FreeeSecrets {
|
|
2
|
+
access_token: string;
|
|
3
|
+
company_id: number;
|
|
4
|
+
company_name: string;
|
|
5
|
+
token_expires_at: string;
|
|
6
|
+
client_id?: string;
|
|
7
|
+
client_secret?: string;
|
|
8
|
+
refresh_token?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function loadFreeeSecrets(secretsPath?: string, opts?: {
|
|
11
|
+
requireCompanyId?: boolean;
|
|
12
|
+
}): FreeeSecrets;
|
|
13
|
+
export declare function isTokenExpired(secrets: FreeeSecrets, bufferMinutes?: number): boolean;
|
|
14
|
+
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Secrets loader for Cockpit MCP.
|
|
2
|
+
//
|
|
3
|
+
// Sources freee credentials with env winning over file: environment variables
|
|
4
|
+
// (FREEE_ACCESS_TOKEN / FREEE_COMPANY_ID — as declared in .mcp.json / plugin settings),
|
|
5
|
+
// then the secrets file ~/.claude/secrets/freee-cockpit-dev.json. NEVER logs secret values.
|
|
6
|
+
//
|
|
7
|
+
// Production deployment will source secrets from a different mechanism
|
|
8
|
+
// (= Cloudflare Worker secret store or env vars, not this filesystem path).
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
const DEFAULT_SECRETS_PATH = path.join(os.homedir(), '.claude', 'secrets', 'freee-cockpit-dev.json');
|
|
13
|
+
export function loadFreeeSecrets(secretsPath = DEFAULT_SECRETS_PATH, opts = {}) {
|
|
14
|
+
const requireCompanyId = opts.requireCompanyId !== false; // default: required
|
|
15
|
+
// Source 1: the secrets file (now OPTIONAL — env vars are a valid alternative).
|
|
16
|
+
let data = {};
|
|
17
|
+
const fileExists = fs.existsSync(secretsPath);
|
|
18
|
+
if (fileExists) {
|
|
19
|
+
data = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
// Source 2: environment variables (env wins over file).
|
|
22
|
+
// This is what makes the documented FREEE_ACCESS_TOKEN / FREEE_COMPANY_ID actually take
|
|
23
|
+
// effect — previously they were declared in .mcp.json / plugin settings but never read here.
|
|
24
|
+
const env = process.env;
|
|
25
|
+
if (env.FREEE_ACCESS_TOKEN)
|
|
26
|
+
data.access_token = env.FREEE_ACCESS_TOKEN;
|
|
27
|
+
if (env.FREEE_COMPANY_ID)
|
|
28
|
+
data.company_id = env.FREEE_COMPANY_ID;
|
|
29
|
+
if (env.FREEE_COMPANY_NAME)
|
|
30
|
+
data.company_name = env.FREEE_COMPANY_NAME;
|
|
31
|
+
if (env.FREEE_TOKEN_EXPIRES_AT)
|
|
32
|
+
data.token_expires_at = env.FREEE_TOKEN_EXPIRES_AT;
|
|
33
|
+
if (env.FREEE_CLIENT_ID)
|
|
34
|
+
data.client_id = env.FREEE_CLIENT_ID;
|
|
35
|
+
if (env.FREEE_CLIENT_SECRET)
|
|
36
|
+
data.client_secret = env.FREEE_CLIENT_SECRET;
|
|
37
|
+
if (env.FREEE_REFRESH_TOKEN)
|
|
38
|
+
data.refresh_token = env.FREEE_REFRESH_TOKEN;
|
|
39
|
+
// Neither source provided a token → fail with a message that names BOTH paths.
|
|
40
|
+
if (!(data.freee_access_token || data.access_token)) {
|
|
41
|
+
throw new Error('freee access token not found. Provide it via the FREEE_ACCESS_TOKEN environment variable ' +
|
|
42
|
+
'(plugin settings / .mcp.json), or in the secrets file at ' + secretsPath + ' ' +
|
|
43
|
+
'(file ' + (fileExists ? 'present but missing access_token' : 'not found') + '). See README.');
|
|
44
|
+
}
|
|
45
|
+
// Field name normalization (file may use the freee_X prefix; env uses canonical names).
|
|
46
|
+
const secrets = {
|
|
47
|
+
access_token: data.freee_access_token || data.access_token,
|
|
48
|
+
company_id: Number(data.freee_company_id || data.company_id),
|
|
49
|
+
company_name: data.freee_company_name || data.company_name || '',
|
|
50
|
+
token_expires_at: data.freee_token_expires_at || data.token_expires_at || '',
|
|
51
|
+
};
|
|
52
|
+
// Optional fields (TODO placeholders are filtered out)
|
|
53
|
+
const optionalString = (v) => {
|
|
54
|
+
if (typeof v !== 'string')
|
|
55
|
+
return undefined;
|
|
56
|
+
if (v.startsWith('TODO') || v.startsWith('<') || v === '')
|
|
57
|
+
return undefined;
|
|
58
|
+
return v;
|
|
59
|
+
};
|
|
60
|
+
secrets.client_id = optionalString(data.freee_client_id || data.client_id);
|
|
61
|
+
secrets.client_secret = optionalString(data.freee_client_secret || data.client_secret);
|
|
62
|
+
secrets.refresh_token = optionalString(data.freee_refresh_token || data.refresh_token);
|
|
63
|
+
// Validate
|
|
64
|
+
if (!secrets.access_token || secrets.access_token.startsWith('<')) {
|
|
65
|
+
throw new Error('freee access token is missing or placeholder');
|
|
66
|
+
}
|
|
67
|
+
if (requireCompanyId && (!secrets.company_id || isNaN(secrets.company_id))) {
|
|
68
|
+
throw new Error('freee company_id is missing or invalid. Run `npm run doctor:freee` (or the freee_doctor tool) ' +
|
|
69
|
+
'to list accessible companies and pick the right id, then set FREEE_COMPANY_ID ' +
|
|
70
|
+
'(or company_id in the secrets file).');
|
|
71
|
+
}
|
|
72
|
+
return secrets;
|
|
73
|
+
}
|
|
74
|
+
export function isTokenExpired(secrets, bufferMinutes = 5) {
|
|
75
|
+
if (!secrets.token_expires_at)
|
|
76
|
+
return false; // unknown, assume valid
|
|
77
|
+
const expiresAt = new Date(secrets.token_expires_at).getTime();
|
|
78
|
+
// A malformed timestamp (e.g. stray spaces "16: 00: 00") yields NaN. Previously the NaN
|
|
79
|
+
// comparison silently returned false ("not expired"), so the pre-flight guard never fired and
|
|
80
|
+
// the caller got a cryptic raw 401 instead. Treat an unparseable expiry as expired.
|
|
81
|
+
if (Number.isNaN(expiresAt))
|
|
82
|
+
return true;
|
|
83
|
+
const bufferMs = bufferMinutes * 60 * 1000;
|
|
84
|
+
return expiresAt - Date.now() < bufferMs;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=secrets.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Transaction, ClassificationResult } from '../classifier/types.js';
|
|
2
|
+
import type { TaxRuleResult, InvoiceCheckResult } from './types.js';
|
|
3
|
+
export declare class TaxRuleEngine {
|
|
4
|
+
private config;
|
|
5
|
+
private normalizedOverseas;
|
|
6
|
+
private normalizedDomestic;
|
|
7
|
+
private normalizedJctIndicators;
|
|
8
|
+
private normalizedNewspaper;
|
|
9
|
+
private normalizedFoodBev;
|
|
10
|
+
private normalizedResidential;
|
|
11
|
+
private normalizedOverseasAds;
|
|
12
|
+
private normalizedDomesticAds;
|
|
13
|
+
private normalizedTakeout;
|
|
14
|
+
private normalizedFoodPurchase;
|
|
15
|
+
private normalizedCateringService;
|
|
16
|
+
constructor(configFile?: string, dataDir?: string);
|
|
17
|
+
/**
|
|
18
|
+
* Apply all post-classification rules to a transaction.
|
|
19
|
+
* Returns a TaxRuleResult describing any adjustments to make.
|
|
20
|
+
*/
|
|
21
|
+
applyRules(tx: Transaction, classification: ClassificationResult): TaxRuleResult;
|
|
22
|
+
/**
|
|
23
|
+
* If the category is in the non_taxable_categories list, immediately set
|
|
24
|
+
* tax_code to 0 with the configured reason. Returns true if handled.
|
|
25
|
+
*/
|
|
26
|
+
private resolveNonTaxableCategory;
|
|
27
|
+
/**
|
|
28
|
+
* If category is "communications" and the keyword/memo matches a known
|
|
29
|
+
* overseas SaaS provider, override tax_code to 0 (対象外).
|
|
30
|
+
*
|
|
31
|
+
* Exception: if the memo contains a JCT indicator (= the overseas provider
|
|
32
|
+
* is charging Japanese consumption tax via an invoice), keep tax_code 2.
|
|
33
|
+
*/
|
|
34
|
+
resolveOverseasSaasTaxCode(tx: Transaction, classification: ClassificationResult, out: TaxRuleResult): void;
|
|
35
|
+
/**
|
|
36
|
+
* If category is "advertising" and the keyword/memo matches a known
|
|
37
|
+
* overseas ad platform, override tax_code to 0 (対象外).
|
|
38
|
+
*
|
|
39
|
+
* Google Ads, Meta Ads etc. are invoiced from overseas entities.
|
|
40
|
+
* Same JCT exception as overseas SaaS.
|
|
41
|
+
*/
|
|
42
|
+
resolveOverseasAdTaxCode(tx: Transaction, classification: ClassificationResult, out: TaxRuleResult): void;
|
|
43
|
+
/**
|
|
44
|
+
* If category is "consumables" or "supplies" and the amount is significant,
|
|
45
|
+
* determine the correct asset capitalisation tier and add warnings.
|
|
46
|
+
*/
|
|
47
|
+
resolveAssetCapitalization(tx: Transaction, classification: ClassificationResult, out: TaxRuleResult): void;
|
|
48
|
+
/**
|
|
49
|
+
* For professional_fee category, calculate informational withholding tax
|
|
50
|
+
* (源泉徴収税額) to help tax accountants verify freee entries.
|
|
51
|
+
*/
|
|
52
|
+
calculateWithholding(tx: Transaction, classification: ClassificationResult, out: TaxRuleResult): void;
|
|
53
|
+
/**
|
|
54
|
+
* Determine the correct consumption tax rate based on category and keywords.
|
|
55
|
+
*
|
|
56
|
+
* Decision tree (Tier 2 expanded):
|
|
57
|
+
* - overseas SaaS/ads -> 0% (handled earlier)
|
|
58
|
+
* - non-taxable categories -> 0% (handled by resolveNonTaxableCategory)
|
|
59
|
+
* - books_magazines + newspaper keyword -> 8% (軽減税率)
|
|
60
|
+
* - meeting_meal + takeout/delivery -> 8%
|
|
61
|
+
* - meeting_meal + food purchase (convenience store) -> 8%
|
|
62
|
+
* - meeting_meal + catering with service -> 10%
|
|
63
|
+
* - meeting_meal (default dine-in) -> 10%
|
|
64
|
+
* - rent + residential keyword -> 0% (非課税)
|
|
65
|
+
*/
|
|
66
|
+
resolveConsumptionTaxRate(tx: Transaction, classification: ClassificationResult, out: TaxRuleResult): void;
|
|
67
|
+
/**
|
|
68
|
+
* Detailed meeting_meal consumption tax resolution (Tier 2).
|
|
69
|
+
*
|
|
70
|
+
* Priority order:
|
|
71
|
+
* 1. Catering with service (配膳あり) -> 10% standard
|
|
72
|
+
* 2. Takeout / delivery -> 8% reduced
|
|
73
|
+
* 3. Food purchase (convenience store, supermarket) -> 8% reduced
|
|
74
|
+
* 4. Default dine-in -> 10% standard
|
|
75
|
+
*/
|
|
76
|
+
private resolveMeetingMealTaxRate;
|
|
77
|
+
/**
|
|
78
|
+
* Validate an invoice registration number and calculate the transitional
|
|
79
|
+
* period deduction rate.
|
|
80
|
+
*
|
|
81
|
+
* @param registrationNumber T + 13 digits (e.g. "T1234567890123")
|
|
82
|
+
* @param txDate Transaction date (YYYY-MM-DD) for period lookup
|
|
83
|
+
* @param taxAmount Consumption tax amount for deduction calculation
|
|
84
|
+
*/
|
|
85
|
+
checkInvoice(registrationNumber: string | undefined, txDate: string, taxAmount?: number): InvoiceCheckResult;
|
|
86
|
+
/**
|
|
87
|
+
* Validate T + 13 digits format.
|
|
88
|
+
*/
|
|
89
|
+
validateRegistrationNumber(num: string | undefined): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Get the transitional period deduction rate for a given date.
|
|
92
|
+
*/
|
|
93
|
+
getTransitionalPeriod(txDate: string): {
|
|
94
|
+
deduction_rate: number;
|
|
95
|
+
label: string;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Check if the memo or matched keyword matches any provider in the list.
|
|
99
|
+
*/
|
|
100
|
+
private matchesProvider;
|
|
101
|
+
getVersion(): string;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=tax-rule-engine.d.ts.map
|