Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
3b98490
Agent モデル切り替えとプリペイドクレジット課金基盤を追加(Phase 1 / ADR-0012)
claude Jun 12, 2026
13a54cc
クレジット残高表示をサイドバーへ移管(ADR-0012)
claude Jun 13, 2026
1fc5989
モデル選択をグローバル化しプランカードで切り替え(ADR-0012)
claude Jun 13, 2026
627e42a
モデル別の使用量サマリをモデル選択モーダルに表示(ADR-0012)
claude Jun 13, 2026
dc0fb19
モデル目安を「N クレジットで平均M回」に変更しトークン購入画面を追加(ADR-0012)
claude Jun 13, 2026
a64f950
クレジットを円ペッグ(1cr=¥1)化し回数アンカーを全箇所に追加(ADR-0012)
claude Jun 13, 2026
1484597
購入画面を任意クレジット入力フォームに変更(リアルタイム円換算 / ADR-0012)
claude Jun 13, 2026
96cbd2a
ci: npm audit を allowlist 対応に置換し esbuild dev 専用 advisory を許容
claude Jun 13, 2026
788927b
fix: CodeRabbit レビュー指摘の対応(課金の原子性・入力検証・ドキュメント整合)
claude Jun 13, 2026
607cc87
fix: CodeRabbit 再レビュー指摘の対応(audit fail-closed・rollback 責務整理)
claude Jun 13, 2026
9e80ada
test: ModelSelectModal テストの表示文言を SSoT 参照に置換
claude Jun 14, 2026
a254226
fix: CodeRabbit 追加指摘の対応(パース失敗時の課金確定・型の厳格化・テスト SSoT化)
claude Jun 14, 2026
d41b71a
fix: リトライ LLMError 時の課金漏れ修正とテスト文言の SSoT 化
claude Jun 14, 2026
56cafad
chore: Playwright E2E の生成物を gitignore に追加
claude Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ jobs:
run: npm ci --prefix frontend

- name: 脆弱性スキャン (npm audit)
run: npm audit --audit-level=high --prefix frontend
run: node frontend/scripts/audit-check.mjs

- name: Lint frontend
run: npm run lint --prefix frontend
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ htmlcov/
node_modules/
dist/
frontend/.test-dist/
# Playwright E2E の生成物(実行ごとに作られるローカル成果物)
frontend/test-results/
frontend/playwright-report/
frontend/tsconfig.app.tsbuildinfo
frontend/tsconfig.node.tsbuildinfo
frontend/vite.config.js
Expand Down
88 changes: 88 additions & 0 deletions backend/alembic_migrations/versions/0044_add_credit_billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""プリペイドクレジット課金の基盤テーブルを追加する(ADR-0012)

- users.credit_balance: 残高キャッシュ(正本は credit_transactions 台帳)。
既存行は残高 0 から開始するため server_default="0"
- credit_transactions: 付与・消費の追記専用台帳。stripe_session_id の UNIQUE 制約は
Phase 2(Stripe Webhook)の二重付与防止用に最初から張る
- agent_usage_logs: Agent チャットの実トークン使用量記録(無料モデル含む)

libSQL (SQLite 互換) は ADD COLUMN を直接サポートするため upgrade は op.add_column を使う。
users は子テーブルから FK 参照される親テーブルのため batch_alter_table(テーブル再作成)を
使わない。素のカラムは SQLite/libSQL 3.35+ の ALTER TABLE DROP COLUMN で直接削除できる。

Revision ID: 0044_add_credit_billing
Revises: 0043_add_contact_to_resumes
Create Date: 2026-06-12 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0044_add_credit_billing"
down_revision: Union[str, None] = "0043_add_contact_to_resumes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"credit_balance", sa.Integer(), nullable=False, server_default="0"
),
)
op.create_table(
"credit_transactions",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("amount", sa.Integer(), nullable=False),
sa.Column("balance_after", sa.Integer(), nullable=False),
sa.Column("transaction_type", sa.String(length=20), nullable=False),
sa.Column("description", sa.String(length=200), nullable=True),
sa.Column("stripe_session_id", sa.String(length=255), nullable=True, unique=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(
"ix_credit_transactions_user_id", "credit_transactions", ["user_id"]
)
op.create_table(
"agent_usage_logs",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("model_alias", sa.String(length=20), nullable=False),
sa.Column("input_tokens", sa.Integer(), nullable=False),
sa.Column("output_tokens", sa.Integer(), nullable=False),
sa.Column("credit_cost", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_agent_usage_logs_user_id", "agent_usage_logs", ["user_id"])


def downgrade() -> None:
op.drop_index("ix_agent_usage_logs_user_id", table_name="agent_usage_logs")
op.drop_table("agent_usage_logs")
op.drop_index("ix_credit_transactions_user_id", table_name="credit_transactions")
op.drop_table("credit_transactions")
op.drop_column("users", "credit_balance")
2 changes: 2 additions & 0 deletions backend/app/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class ErrorCode(str, Enum):
# Agent(LLM チャット / ADR-0010)
AGENT_LLM_ERROR = "AGENT_LLM_ERROR"
AGENT_PARSE_ERROR = "AGENT_PARSE_ERROR"
# 課金(プリペイドクレジット / ADR-0012)
INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS"
# アプリケーション全体
RATE_LIMITED = "RATE_LIMITED"
# サーバー
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .routers import ( # noqa: E402
agent_router,
auth_router,
billing_router,
blog_router,
github_link_router,
health_router,
Expand Down Expand Up @@ -215,3 +216,4 @@ async def dispatch(self, request: Request, call_next) -> Response:
app.include_router(notifications_router)
app.include_router(internal_router)
app.include_router(agent_router)
app.include_router(billing_router)
4 changes: 4 additions & 0 deletions backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
"parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。",
"target_required": "このスコープでは対象の指定が必要です。",
"target_not_found": "指定された対象が見つかりません。"
},
"billing": {
"insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
"grant_user_not_found": "付与対象のユーザーが見つかりません。"
}
},
"notification": {
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""SQLAlchemy モデル。"""

from .billing import AgentUsageLog, CreditTransaction
from .blog import BlogAccount, BlogArticle, BlogArticleTag
from .cache import GitHubLinkCache
from .master_data import MQualification, MTechnologyStack
Expand All @@ -18,9 +19,11 @@
from .user import User

__all__ = [
"AgentUsageLog",
"BlogAccount",
"BlogArticle",
"BlogArticleTag",
"CreditTransaction",
"GitHubLinkCache",
"MQualification",
"MTechnologyStack",
Expand Down
70 changes: 70 additions & 0 deletions backend/app/models/billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""クレジット課金モデル(ADR-0012)。

``credit_transactions`` は付与・消費の全履歴を持つ追記専用の台帳(正本)。
``users.credit_balance`` はそのキャッシュで、台帳と同一トランザクション内で更新する。
``agent_usage_logs`` はモデル別の実トークン使用量の記録(無料モデルも記録する)。
"""

import uuid
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column

from ..db import Base


class CreditTransaction(Base):
"""クレジットの付与・消費 1 件分の台帳エントリ(追記専用)。"""

__tablename__ = "credit_transactions"

id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# 符号付きの増減量(付与: 正 / 消費: 負)
amount: Mapped[int] = mapped_column(Integer, nullable=False)
# 本トランザクション適用後の残高(監査・デバッグ用のスナップショット)
balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
# 種別: consumption(チャット消費)/ admin_grant(管理者付与)/ purchase(Stripe 購入)
transaction_type: Mapped[str] = mapped_column(String(20), nullable=False)
description: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
# Stripe Checkout Session ID。UNIQUE 制約により Webhook 再送時の二重付与を防ぐ
# (Phase 2 で使用。Phase 1 では常に NULL / ADR-0012)
stripe_session_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, unique=True, default=None
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=func.now(),
server_default=func.now(),
nullable=False,
)


class AgentUsageLog(Base):
"""Agent チャット 1 回分の実トークン使用量の記録(コスト分析用)。"""

__tablename__ = "agent_usage_logs"

id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# model_catalog.py のエイリアス(haiku / sonnet)
model_alias: Mapped[str] = mapped_column(String(20), nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
# 消費クレジット(無料モデルは 0)
credit_cost: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=func.now(),
server_default=func.now(),
nullable=False,
)
7 changes: 6 additions & 1 deletion backend/app/models/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime

from sqlalchemy import DateTime, String, func
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column

from ..db import Base
Expand All @@ -16,6 +16,11 @@ class User(Base):
github_id: Mapped[int | None] = mapped_column(nullable=True, unique=True, default=None)
github_token: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
refresh_jti: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None)
# プリペイドクレジット残高のキャッシュ(正本は credit_transactions 台帳 / ADR-0012)。
# 事前チェック通過後の事後減算により一時的に負になりうる(有界損失を許容)
credit_balance: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
Expand Down
2 changes: 2 additions & 0 deletions backend/app/repositories/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Repository 層。"""

from .base import BaseMasterRepository, SingleUserDocumentRepository
from .billing import BillingRepository
from .blog import BlogAccountRepository, BlogArticleRepository
from .master_data import MQualificationRepository, MTechnologyStackRepository
from .resume import ResumeRepository
from .user import UserRepository

__all__ = [
"BaseMasterRepository",
"BillingRepository",
"BlogAccountRepository",
"BlogArticleRepository",
"MQualificationRepository",
Expand Down
Loading
Loading