読み込み中
豆知識
仕訳の作成・検証・転記・保存を、日本の会計慣行(J-GAAP)・消費税法・電子帳簿保存法(原則 7 年)に沿ってどう実装しているかの正本メモです。
本文は英語のエンジニア向け仕様です。 関連:
README.md·docs/GAMIFICATION.md· 月次の書類作成は 月次精算の書類の作り方とつなぎ方(仕訳は AI 下書き、人が確定)。
The accrual-first posting flow involves four actors: the user, the document service that owns the source-of-truth document, the journal-entry service that derives + validates the entry, and PostgreSQL where the database CHECK constraint enforces double-entry as a final defence.
sequenceDiagram
autonumber
actor User as ユーザー
participant Doc as DocumentService
participant JE as JournalEntryService
participant DB as PostgreSQL
User->>Doc: 書類を確定 (POST /documents/:id/finalize)
Doc->>JE: deriveJournalFromDocument(docId)
JE->>JE: Zod 検証 (借方 = 貸方)
JE->>DB: INSERT journal_entries + lines
DB-->>JE: CHECK chk_journal_entry_balanced 成立 → ok
JE-->>Doc: { journalEntryId, postedAt }
Doc-->>User: 201 Created (確定 + 仕訳生成)
Note over JE,DB: 失敗時は全工程ロールバック<br/>(原本書類は確定状態に戻らない)
Journal entries are the single most important data structure in the system. Everything else — financial statements, tax filings, KPIs, AI advisor recommendations — is computed from them. Their integrity is non-negotiable, which is why journal posting is always exempt from quota enforcement (see GAMIFICATION.md § 8).
Every journal entry must satisfy:
Σ debits == Σ credits
Enforced at three layers:
Database CHECK constraint on journal_entries:
CONSTRAINT je_balanced CHECK (total_debit = total_credit)
Service layer in journal-entry.service.ts validates before insert/update via Zod (JournalEntryLineSchema.array().refine(balanced)).
API contract — the controller rejects unbalanced payloads with 400 JE_UNBALANCED (Japanese: 「貸借が一致していません」).
Lines are stored separately in journal_entry_lines (each row has a single debit_amount OR credit_amount, never both populated).
accounts table follows the Japanese standardized chart of accounts (J-GAAP 標準勘定科目) with eight first-class categories:
| Category | Japanese | Example accounts |
|---|---|---|
| asset | 資産 | 現金, 普通預金, 売掛金, 棚卸資産, 固定資産 |
| liability | 負債 | 買掛金, 未払金, 短期借入金, 預り金, 仮受消費税 |
| equity | 純資産 | 資本金, 利益剰余金, 繰越利益剰余金 |
| revenue | 収益 | 売上高, 受取利息, 雑収入 |
| expense | 費用 | 仕入, 給料, 支払家賃, 通信費, 交際費 |
| cost_of_sales | 売上原価 | 期首商品棚卸高, 当期商品仕入高, 期末商品棚卸高 |
| non_operating | 営業外損益 | 受取配当金, 支払利息, 為替差損益 |
| extraordinary | 特別損益 | 固定資産売却損益, 災害損失 |
Each account stores:
account_code (4-digit J-GAAP standard)name_ja / name_encategory (enum above)normal_balance (debit / credit)is_taxable (whether 消費税 applies)default_tax_rate (10 / 8 / 0)default_tax_classification (see § 3)The frontend accounts-store.ts mirrors this for autocomplete. The AI advisor uses accounts.category to reason about Profit & Loss vs Balance Sheet impact.
Each journal_entry_line carries a tax_classification enum that follows 適格請求書等保存方式 (Qualified Invoice System, インボイス制度) since Oct 2023:
| Classification | 区分 | When to use |
|---|---|---|
taxable_10 |
課税仕入 (10%) | Standard rate purchases |
taxable_8_reduced |
軽減税率 (8%) | Food, newspapers under 軽減税率 |
taxable_export_0 |
輸出免税 (0%) | Export sales |
tax_exempt |
非課税 | Land sale, government fees, medical |
out_of_scope |
不課税 | Salaries, dividends, donations |
qualified_input |
適格請求書 | Fully deductible from 仮受消費税 |
partial_input_80 |
経過措置 80% | 2023.10–2026.9 unqualified vendors |
partial_input_50 |
経過措置 50% | 2026.10–2029.9 unqualified vendors |
non_input |
仕入税額控除なし | Post-2029 unqualified vendors |
tax_amount is computed as:
tax_amount = round_jpy(line_amount_excl_tax × tax_rate × deductible_ratio)
where deductible_ratio reflects the inbound 経過措置 schedule (1.0 → 0.8 → 0.5 → 0.0).
The system records:
仮払消費税 (input tax, asset) — debit side of taxable purchases.仮受消費税 (output tax, liability) — credit side of taxable sales.未払消費税 (settlement liability) — created by the period-end 消費税 adjustment journal.stateDiagram-v2
[*] --> draft : create()
draft --> ai_suggested : aiAutomate()
ai_suggested --> draft : userEdit()
ai_suggested --> review : confirmAI()
draft --> review : submitForReview()
review --> posted : post()
review --> draft : reject(reason)
posted --> reversed : reverse()
reversed --> [*]
posted --> [*] : (immutable, archive after 7y)
draft — Editable, not visible in P&L / BS.ai_suggested — Created by AI from a document (OCR + classification). User can accept, edit, or discard. Same as draft for accounting purposes.review — Locked for editing; awaiting approval (relevant when 税理士 collaboration is enabled).posted — Immutable. Visible in financial statements. Triggers journal_entry_create XP event.reversed — A counter-entry was created. Original row stays for audit trail; the counter row references reverses_journal_entry_id.State transitions are enforced by journal-entry.service.ts and audited in journal_entry_audit_log.
The AI pipeline (ai-document-extractor.service.ts + Langfuse-traced LLM calls) creates ai_suggested JEs from uploaded documents:
upload PDF/image
│
▼
OCR (vision model) ← Langfuse trace: ocr.parse
│
▼
Classify document type ← Langfuse trace: doc.classify
(invoice / receipt / bank statement / payroll slip)
│
▼
Extract structured fields ← Langfuse trace: doc.extract
(issuer, date, line items, tax rate, qualified-invoice number)
│
▼
Map to JE template ← deterministic, account inference uses
{issuer × historical patterns × keyword rules}
│
▼
Insert JE with status='ai_suggested', link source_document_id,
confidence_score, llm_traceback
User UX:
metadata.reasoning).ai_confidence_score and switches to draft.Quality gates:
When the user closes a month (月次決算):
減価償却費 (depreciation) — based on fixed_assets schedule.前払費用 / 未払費用 (prepaid / accrued expenses) — from recurring_charges.棚卸資産 (inventory) — from snapshot count.is_period_end = true and period_end_year_month = 'YYYY-MM'.monthly_journal_complete XP event fires (200 XP, capped at 2/month — see GAMIFICATION.md § 2).monthly_close row inserted; further edits to that month's JEs require explicit re-open.Year-end (年次決算) extends the same pattern with 法人税等 / 消費税 settlement and 繰越利益剰余金 carry-forward.
電子帳簿保存法 requires:
| Requirement | How we satisfy |
|---|---|
| 真実性の確保 (truthfulness) | Append-only journal_entry_audit_log records every change with user_id, timestamp, before/after JSON. posted JEs are immutable — corrections must be reversed. |
| 見読可能性 (legibility) | All JEs viewable in the UI and exportable to PDF / CSV. Source documents linked via source_document_id and stored at full fidelity. |
| 検索機能 (searchability) | Full-text indexed description, structured indexes on entry_date, amount, account_id, partner_id. |
| タイムスタンプ要件 (timestamp) | posted_at is set by the DB (now()) and never updatable. created_at and updated_at similarly server-controlled. |
| 保存期間 7年 (retention) | journal_entries rows are never hard-deleted. A scheduled archive job moves rows older than 7 years to cold storage; the FE shows them read-only. |
| 訂正履歴 (revision history) | Every UPDATE writes a row to journal_entry_audit_log with a change_diff jsonb. reverses_journal_entry_id links reversal pairs. |
Backups: nightly Postgres dump + Supabase point-in-time recovery. All file uploads (定款, 請求書, 領収書) similarly retained 7 years.
features/journal/ — list, detail, editor (React Hook Form + Zod, balanced-validator).features/journal/queries.ts — TanStack Query hooks: useJournalEntriesQuery, usePostJournalEntryMutation, useReverseJournalEntryMutation.stores/journal-entry-store.ts — UI state only (selected month, filters, draft scratch).| File | Responsibility |
|---|---|
src/services/journal-entry.service.ts |
CRUD + state machine + audit log. |
src/services/journal-entry-validator.service.ts |
Balanced-line check, account validation, 消費税 consistency. |
src/services/period-close.service.ts |
Month/year close orchestration. |
src/services/ai-document-extractor.service.ts |
OCR + LLM extraction → ai_suggested JE. |
src/controllers/journal-entry.controller.ts |
REST controller. |
src/routes/journal-entry.routes.ts |
Mounts under /api/journal-entries — exempt from quota middleware. |
prisma/schema.prisma |
journal_entries, journal_entry_lines, journal_entry_audit_log, accounts. |
| Pitfall | Prevention |
|---|---|
| Unbalanced JE | DB CHECK + service-layer Zod refine + UI live indicator. |
| Wrong 消費税 rate on 軽減税率 items | Account default + AI classifier + line-level override with confirmation toast. |
| Missing 適格請求書番号 | partner.qualified_invoice_number checked at line-entry time; missing → suggest 経過措置 classification with explanation. |
| Posting to a closed period | monthly_close row blocks INSERT / UPDATE on locked months unless explicitly re-opened. |
| Hard delete | No DELETE endpoint exists; only "reverse" creates a counter-entry. |
| Drift between FE display and DB | Single source of truth = backend /api/journal-entries; FE never computes balances client-side for posted data. |
| AI hallucinated accounts | Post-LLM validator rejects unknown account_codes; user sees "AIが提案した勘定科目が見つかりません" with a manual picker. |
Source documents in documents are mapped to one of seven generator strategies inside src/services/journal.service.ts → generateDraftJournalEntries(). The dispatcher is a pure switch (document.document_type):
document_type enum |
Generator | Strategy | Writes new JE? |
|---|---|---|---|
SALES_INVOICE |
generateForSalesInvoiceDocument |
accrual | ✓ |
CLAIMS |
generateForClaimsDocument |
accrual (per-line expense_date, FY-period assertion) |
✓ |
SALARY_SLIP |
generateForSalarySlipDocument |
accrual (existing-filter widened to IN ('pay_slip','salary_slip')) |
✓ |
FIXED_ASSET |
generateForFixedAssetDocument |
accrual + sets up depreciation schedule | ✓ |
DEPRECIATION |
generateForDepreciationDocument |
accrual (one JE per asset/period) | ✓ |
TRAVEL |
generateForTravelDocument |
inheritance — copies linked claims doc's metadata.journalEntry to current doc's metadata.linkedJournalEntry |
✗ |
PAYMENT |
generateForPaymentDocument |
inheritance — copies linked claims/salary doc's metadata.journalEntry to current doc's metadata.linkedJournalEntry |
✗ |
async generateForTravelDocument(documentId: string, userId: string) {
// 1. Find linked CLAIMS docs via document_relationships
const relationships = await this.prisma.document_relationships.findMany({
where: { OR: [
{ from_document_id: documentId },
{ to_document_id: documentId }
]}
})
// 2. Pull metadata.journalEntry from any linked CLAIMS doc
const linkedClaimDoc = await this.prisma.documents.findFirst({
where: {
id: { in: relatedDocIds },
document_type: DocumentType.CLAIMS,
metadata: { path: ['journalEntry', 'id'], not: Prisma.JsonNull }
}
})
// 3. Idempotency check
if (currentDoc.metadata.linkedJournalEntry?.id === linkedClaimDoc.metadata.journalEntry.id) {
return { skipped: true, reason: 'already_inherited' }
}
// 4. Copy to metadata.linkedJournalEntry
await this.prisma.documents.update({
where: { id: documentId },
data: {
metadata: {
...currentMetadata,
linkedJournalEntry: {
id: linkedClaimDoc.metadata.journalEntry.id,
entryNumber: linkedClaimDoc.metadata.journalEntry.entryNumber,
totalAmount: linkedClaimDoc.metadata.journalEntry.totalAmount,
sourceDocumentId: linkedClaimDoc.id,
sourceDocumentTitle: linkedClaimDoc.title,
inheritedAt: new Date().toISOString()
}
}
}
})
}
Invariants enforced by inheritance:
journal_entries table is never written by inheritance generators.metadata.linkedJournalEntry.id always equals an existing journal_entries.id at write time — but no FK is enforced (intentional, to allow soft-delete archival).metadata.linkedJournalEntry.id?Wave 9 deliberately stores the JE link inside documents.metadata (jsonb) rather than promoting it to a typed column with an FK. Reasons:
journalEntry) and inherited (linkedJournalEntry) references across all 7 doc types.If a future Wave promotes inheritance to a first-class column, the migration would be: ALTER TABLE documents ADD COLUMN linked_journal_entry_id UUID REFERENCES journal_entries(id) ON DELETE SET NULL + a backfill from metadata.
pay_slip vs salary_slip reference_type driftProduction data has historical journal_entries.reference_type = 'pay_slip' rows from before the SALARY_SLIP enum rename. Wave 9 widens the read-side filter:
const existing = await this.prisma.journal_entries.findFirst({
where: {
reference_document_id: documentId,
reference_type: { in: ["pay_slip", "salary_slip"] }, // ← widened
status: { not: "reversed" },
},
})
The destructive backfill that normalises old 'pay_slip' rows to 'salary_slip' is a pending Wave 9 work item under prisma/migrations/_deferred_destructive/ (not yet staged as a dated SQL file) and is not auto-applied — it requires tax-advisor sign-off and a staging dry-run because it rewrites historical accounting metadata.
extraordinary items.読み込み中
豆知識