diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94357d26..7fda356d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0d0c9759..eab38884 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/alembic_migrations/versions/0044_add_credit_billing.py b/backend/alembic_migrations/versions/0044_add_credit_billing.py new file mode 100644 index 00000000..8057618d --- /dev/null +++ b/backend/alembic_migrations/versions/0044_add_credit_billing.py @@ -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") diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index f18c27ee..cb54bcb3 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -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" # サーバー diff --git a/backend/app/main.py b/backend/app/main.py index f4f48f30..1ce8101b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,6 +31,7 @@ from .routers import ( # noqa: E402 agent_router, auth_router, + billing_router, blog_router, github_link_router, health_router, @@ -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) diff --git a/backend/app/messages.json b/backend/app/messages.json index 4ce0d7b3..3fff65a8 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -73,6 +73,10 @@ "parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。", "target_required": "このスコープでは対象の指定が必要です。", "target_not_found": "指定された対象が見つかりません。" + }, + "billing": { + "insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。", + "grant_user_not_found": "付与対象のユーザーが見つかりません。" } }, "notification": { diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index abf4a70d..6b89451f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 @@ -18,9 +19,11 @@ from .user import User __all__ = [ + "AgentUsageLog", "BlogAccount", "BlogArticle", "BlogArticleTag", + "CreditTransaction", "GitHubLinkCache", "MQualification", "MTechnologyStack", diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py new file mode 100644 index 00000000..ecb3dbfd --- /dev/null +++ b/backend/app/models/billing.py @@ -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, + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c5d3a11a..f932f332 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 @@ -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(), diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index 41f3b72c..aecbc272 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -1,6 +1,7 @@ """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 @@ -8,6 +9,7 @@ __all__ = [ "BaseMasterRepository", + "BillingRepository", "BlogAccountRepository", "BlogArticleRepository", "MQualificationRepository", diff --git a/backend/app/repositories/billing.py b/backend/app/repositories/billing.py new file mode 100644 index 00000000..c69a31ef --- /dev/null +++ b/backend/app/repositories/billing.py @@ -0,0 +1,161 @@ +"""クレジット課金リポジトリ(ADR-0012)。 + +残高更新は ``UPDATE users SET credit_balance = credit_balance + :delta`` の +原子的 UPDATE で行い、同一トランザクション内で台帳(credit_transactions)へ +追記してから commit する。Cloud Run 単一インスタンス(ADR-0005)前提のため +分散ロックは持たない。 +""" + +from sqlalchemy import func, select, update +from sqlalchemy.engine import Row +from sqlalchemy.orm import Session + +from ..models.billing import AgentUsageLog, CreditTransaction +from ..models.user import User + + +class BillingRepository: + """クレジット残高・台帳・使用ログのデータアクセス層。""" + + def __init__(self, db: Session, user_id: str): + self.db = db + self.user_id = user_id + + def get_balance(self) -> int: + """現在のクレジット残高を返す。""" + balance = self.db.scalar( + select(User.credit_balance).where(User.id == self.user_id) + ) + return balance or 0 + + def _stage_balance_change( + self, + *, + amount: int, + transaction_type: str, + description: str | None = None, + stripe_session_id: str | None = None, + ) -> int: + """残高を原子的に増減し台帳へ追記する(commit はしない)。適用後残高を返す。 + + commit は呼び出し側の責務。単独付与(``apply_transaction``)と、消費+使用ログを + 1 トランザクションにまとめる用途(``record_chat_consumption``)の両方から使う。 + """ + self.db.execute( + update(User) + .where(User.id == self.user_id) + .values(credit_balance=User.credit_balance + amount) + ) + balance_after = self.db.scalar( + select(User.credit_balance).where(User.id == self.user_id) + ) + if balance_after is None: + # CASCADE 削除等でユーザーが消えた直後の競合。残高不明のまま進めない。 + # rollback はトランザクション境界を持つ呼び出し側(commit する側)に委ねる + raise RuntimeError(f"残高更新対象のユーザーが存在しません: {self.user_id}") + self.db.add( + CreditTransaction( + user_id=self.user_id, + amount=amount, + balance_after=balance_after, + transaction_type=transaction_type, + description=description, + stripe_session_id=stripe_session_id, + ) + ) + return balance_after + + def apply_transaction( + self, + *, + amount: int, + transaction_type: str, + description: str | None = None, + stripe_session_id: str | None = None, + ) -> int: + """残高を原子的に増減し、台帳へ追記して適用後残高を返す。 + + amount は符号付き(付与: 正 / 消費: 負)。残高更新と台帳追記を + 同一トランザクションで commit する(片方だけ反映される状態を作らない)。 + """ + try: + balance_after = self._stage_balance_change( + amount=amount, + transaction_type=transaction_type, + description=description, + stripe_session_id=stripe_session_id, + ) + self.db.commit() + except Exception: + self.db.rollback() + raise + return balance_after + + def record_chat_consumption( + self, + *, + amount: int, + transaction_type: str, + description: str, + model_alias: str, + input_tokens: int, + output_tokens: int, + credit_cost: int, + ) -> int | None: + """Agent チャット 1 回分の「クレジット消費 + 使用ログ」を単一トランザクションで確定する。 + + amount は符号付き消費額(消費: 負 / 無料モデル: 0)。0 のときは残高更新・台帳追記を + スキップし使用ログのみ記録する。残高更新・台帳・使用ログはすべて同一 commit で確定し、 + いずれか失敗時は全体を rollback する(課金済みなのに使用ログが無い、等の半端な状態を + 作らない / ADR-0012)。適用後残高を返す(無料モデルは None)。 + """ + balance_after: int | None = None + try: + if amount != 0: + balance_after = self._stage_balance_change( + amount=amount, + transaction_type=transaction_type, + description=description, + ) + self.db.add( + AgentUsageLog( + user_id=self.user_id, + model_alias=model_alias, + input_tokens=input_tokens, + output_tokens=output_tokens, + credit_cost=credit_cost, + ) + ) + self.db.commit() + except Exception: + self.db.rollback() + raise + return balance_after + + def list_transactions(self, limit: int = 50) -> list[CreditTransaction]: + """台帳履歴を新しい順に返す。""" + stmt = ( + select(CreditTransaction) + .where(CreditTransaction.user_id == self.user_id) + .order_by(CreditTransaction.created_at.desc(), CreditTransaction.id) + .limit(limit) + ) + return list(self.db.scalars(stmt).all()) + + def usage_summary(self) -> list[Row]: + """使用ログをモデル別に集計し、(model_alias, chat_count, input/output tokens, + credit_cost) の行を返す。利用実績のないモデルは行に現れない + (表示側がモデル一覧の正本を持ち、0 件は表示側で補う / ADR-0012)。 + """ + stmt = ( + select( + AgentUsageLog.model_alias.label("model_alias"), + func.count().label("chat_count"), + func.coalesce(func.sum(AgentUsageLog.input_tokens), 0).label("input_tokens"), + func.coalesce(func.sum(AgentUsageLog.output_tokens), 0).label("output_tokens"), + func.coalesce(func.sum(AgentUsageLog.credit_cost), 0).label("credit_cost"), + ) + .where(AgentUsageLog.user_id == self.user_id) + .group_by(AgentUsageLog.model_alias) + ) + return list(self.db.execute(stmt).all()) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 0875771b..2f2dbcd1 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -1,5 +1,6 @@ from .agent import router as agent_router from .auth import router as auth_router +from .billing import router as billing_router from .blog import router as blog_router from .github_link import router as github_link_router from .health import router as health_router @@ -11,6 +12,7 @@ __all__ = [ "agent_router", "auth_router", + "billing_router", "blog_router", "github_link_router", "health_router", diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index 703ee784..8219d915 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -24,6 +24,8 @@ ) from ..services.agent.context_builder import build_reference_context from ..services.agent.llm.base import LLMError +from ..services.billing import credit_service +from ..services.billing.credit_service import InsufficientCreditsError logger = logging.getLogger(__name__) @@ -41,27 +43,58 @@ async def agent_chat( """選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 career_summary / self_pr スコープでは GitHub・ブログ分析サマリーを参照情報として付与する。 - レスポンスはフロントの state にのみ適用され、DB は更新しない。 + レスポンスはフロントの state にのみ適用され、DB は更新しない + (クレジット消費・使用ログの記録は除く / ADR-0012)。 ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 """ + # 有料モデル(sonnet)は LLM を呼ぶ前に残高をチェックする。実コストは応答後に + # 確定するため事後減算とし、チェック通過後の負残高は許容する(ADR-0012) + try: + credit_service.ensure_can_use_model(db, user.id, body.model) + except InsufficientCreditsError: + raise_app_error( + status_code=402, + code=ErrorCode.INSUFFICIENT_CREDITS, + message=get_error("billing.insufficient_credits"), + ) try: reference = build_reference_context(db, user.id, body.scope) - return await chat_service.run_agent_chat(body, reference) + result = await chat_service.run_agent_chat(body, reference) except AgentTargetNotFoundError: raise_app_error( status_code=422, code=ErrorCode.VALIDATION_ERROR, message=get_error("agent.target_not_found"), ) - except LLMError: + except LLMError as exc: + # リトライ呼び出しが失敗した場合、1 回目分の消費済みトークンを課金してから + # 502 を返す(課金漏れを防ぐ / ADR-0012)。課金記録自体の失敗はログに残し、 + # 本来の LLM 失敗(502)を優先して返す + if exc.usage is not None: + try: + credit_service.record_chat_usage(db, user.id, exc.usage) + except Exception: + logger.error("LLM 失敗時のクレジット消費記録に失敗", exc_info=True) raise_app_error( status_code=502, code=ErrorCode.AGENT_LLM_ERROR, message=get_error("agent.llm_failed"), ) - except AgentResponseParseError: + except AgentResponseParseError as exc: + # リトライ後も失敗。消費済みトークン(リトライ含む API 原価)があれば課金を + # 確定してから 502 を返す(課金漏れを防ぐ / ADR-0012)。課金記録自体の失敗は + # ログに残し、本来のパース失敗(502)を優先して返す + if exc.usage is not None: + try: + credit_service.record_chat_usage(db, user.id, exc.usage) + except Exception: + logger.error("パース失敗時のクレジット消費記録に失敗", exc_info=True) raise_app_error( status_code=502, code=ErrorCode.AGENT_PARSE_ERROR, message=get_error("agent.parse_failed"), ) + # 実トークン量に基づくクレジット消費 + 使用ログ記録(haiku はログのみ)。 + # 記録失敗は応答を返さず 500 にする(課金漏れを黙って通さない / ADR-0012) + credit_service.record_chat_usage(db, user.id, result.usage) + return result.response diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py new file mode 100644 index 00000000..5a47d1a9 --- /dev/null +++ b/backend/app/routers/billing.py @@ -0,0 +1,144 @@ +"""クレジット課金エンドポイント(ADR-0012)。 + +Phase 1: 残高照会・台帳履歴・管理者付与のみ。 +Phase 2 で Stripe Checkout(購入セッション作成・Webhook 入金)を追加する。 +""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from ..core.errors import ErrorCode, raise_app_error +from ..core.messages import get_error +from ..core.security.auth import get_current_user +from ..core.security.dependencies import verify_admin_token +from ..db import get_db +from ..models import User +from ..repositories import BillingRepository, UserRepository +from ..schemas.billing import ( + AdminCreditGrantRequest, + AgentUsageSummaryEntry, + CreditBalanceResponse, + CreditPackResponse, + CreditTransactionResponse, + ModelRateEntry, +) +from ..services.agent.model_catalog import MODEL_CATALOG, baseline_credits_per_chat +from ..services.billing import credit_service +from ..services.billing.pricing import CREDIT_PACKS + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/billing", tags=["billing"]) + + +@router.get("/balance", response_model=CreditBalanceResponse) +def get_credit_balance( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> CreditBalanceResponse: + """ログインユーザーのクレジット残高を返す。""" + return CreditBalanceResponse(balance=BillingRepository(db, user.id).get_balance()) + + +@router.get("/transactions", response_model=list[CreditTransactionResponse]) +def list_credit_transactions( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[CreditTransactionResponse]: + """クレジット台帳履歴(付与・消費)を新しい順に返す。""" + transactions = BillingRepository(db, user.id).list_transactions() + return [CreditTransactionResponse.model_validate(t) for t in transactions] + + +@router.get("/packs", response_model=list[CreditPackResponse]) +def list_credit_packs( + _: User = Depends(get_current_user), +) -> list[CreditPackResponse]: + """購入可能なクレジットパック一覧を返す(トークン購入画面用 / ADR-0012)。 + + 価格・付与クレジットの正本は services/billing/pricing.py。 + """ + return [ + CreditPackResponse( + id=pack.id, name=pack.name, price_jpy=pack.price_jpy, credits=pack.credits + ) + for pack in CREDIT_PACKS + ] + + +@router.get("/model-rates", response_model=list[ModelRateEntry]) +def list_model_rates( + _: User = Depends(get_current_user), +) -> list[ModelRateEntry]: + """モデル別の標準消費レート(回数目安の算出用 / ADR-0012)を返す。 + + フロントは残高・パック・モデルカードを「Sonnet 約N回」に換算するのに使う。 + 利用実績のあるユーザーは usage-summary の実測平均を優先し、本値は新規ユーザーの + フォールバックとして使う。 + """ + return [ + ModelRateEntry( + model=alias, + is_free=spec.is_free, + baseline_credits_per_chat=baseline_credits_per_chat(alias), + ) + for alias, spec in MODEL_CATALOG.items() + ] + + +@router.get("/usage-summary", response_model=list[AgentUsageSummaryEntry]) +def get_usage_summary( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[AgentUsageSummaryEntry]: + """モデル別の使用量サマリ(チャット回数・トークン・消費クレジット)を返す。 + + モデル選択モーダルで「あなたの利用実績」を表示するために使う。残りチャット回数の + 目安は残高と組み合わせてフロントで算出する。 + """ + rows = BillingRepository(db, user.id).usage_summary() + return [ + AgentUsageSummaryEntry( + model=row.model_alias, + chat_count=row.chat_count, + input_tokens=row.input_tokens, + output_tokens=row.output_tokens, + credit_cost=row.credit_cost, + ) + for row in rows + ] + + +@router.post("/admin/grant", response_model=CreditBalanceResponse) +def admin_grant_credits( + body: AdminCreditGrantRequest, + _: None = Depends(verify_admin_token), + db: Session = Depends(get_db), +) -> CreditBalanceResponse: + """管理者がユーザーへクレジットを付与する(Phase 1 の残高調整・テスト用)。 + + ADMIN_TOKEN(Bearer)認証。Stripe 導入後も返金・補填時の残高調整用に残す。 + """ + target = UserRepository(db).get_by_username(body.username) + if target is None: + raise_app_error( + status_code=404, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("billing.grant_user_not_found"), + ) + balance_after = credit_service.grant_credits( + db, + target.id, + body.amount, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + description=body.description, + ) + logger.info( + "管理者クレジット付与: username=%s amount=%d balance_after=%d", + body.username, + body.amount, + balance_after, + ) + return CreditBalanceResponse(balance=balance_after) diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index 64002214..4f8db8f7 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -18,6 +18,10 @@ AgentScope = Literal["project", "career_summary", "self_pr", "experience"] +# 選択可能な LLM モデルのエイリアス(ADR-0012)。実モデル ID・課金レートは +# services/agent/model_catalog.py の MODEL_CATALOG が正本(キー集合を一致させる) +AgentModelAlias = Literal["haiku", "sonnet"] + class AgentTechnologyStack(BaseModel): """LLM コンテキスト用の技術スタック(保存契約より緩い)。""" @@ -105,6 +109,9 @@ class AgentChatRequest(BaseModel): scope: AgentScope prompt: str = Field(min_length=1, max_length=2000) + # 使用モデル。haiku は無料・使い放題、sonnet は有料(クレジット消費 / ADR-0012)。 + # デフォルト haiku で既存クライアントと後方互換 + model: AgentModelAlias = "haiku" resume: AgentResumeContext # project は ProjectTarget | ExperienceTarget | None の union(ProjectTarget を先に配置)。 # ExperienceTarget は extra="forbid" により 3 キー payload がマッチしないため diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py new file mode 100644 index 00000000..833654bb --- /dev/null +++ b/backend/app/schemas/billing.py @@ -0,0 +1,68 @@ +"""クレジット課金の Pydantic スキーマ(ADR-0012)。""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class CreditBalanceResponse(BaseModel): + """クレジット残高。""" + + balance: int + + +class CreditTransactionResponse(BaseModel): + """クレジット台帳エントリ 1 件(履歴表示用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: str + # 符号付きの増減量(付与: 正 / 消費: 負) + amount: int + balance_after: int + # consumption / admin_grant / purchase + transaction_type: str + description: str | None = None + created_at: datetime + + +class CreditPackResponse(BaseModel): + """購入可能なクレジットパック 1 種(トークン購入画面用 / ADR-0012)。""" + + id: str + name: str + price_jpy: int + credits: int + + +class ModelRateEntry(BaseModel): + """モデル別の標準消費レート(回数目安の算出用 / ADR-0012)。 + + 1 クレジット = ¥1。``baseline_credits_per_chat`` は標準的な 1 回の消費の概算で、 + フロントが残高・パックを「Sonnet 約N回」に換算するのに使う(無料モデルは 0)。 + """ + + model: str + is_free: bool + baseline_credits_per_chat: int + + +class AgentUsageSummaryEntry(BaseModel): + """モデル別の使用量サマリ 1 件(モデル選択モーダルの利用実績表示用 / ADR-0012)。""" + + # model_catalog.py のエイリアス(haiku / sonnet) + model: str + chat_count: int + input_tokens: int + output_tokens: int + # 消費クレジット合計(無料モデルは 0) + credit_cost: int + + +class AdminCreditGrantRequest(BaseModel): + """管理者によるクレジット付与(Phase 1 の残高調整・テスト用)。""" + + username: str = Field(min_length=1, max_length=120) + # 1 回の付与上限は 1,000 万クレジット($1,000 相当)。誤入力の桁あふれを防ぐ + amount: int = Field(gt=0, le=10_000_000) + description: str | None = Field(default=None, max_length=200) diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 2c33e009..ab8e207b 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -8,6 +8,7 @@ import json import logging +from dataclasses import dataclass from pathlib import Path from pydantic import ValidationError @@ -16,11 +17,14 @@ AgentChatRequest, AgentChatResponse, AgentExperienceContext, + AgentModelAlias, AgentOperation, AgentProjectContext, ExperienceTarget, ) +from .llm.base import LLMError from .llm.factory import get_llm_client +from .model_catalog import get_model_spec from .output_schema import ( MAX_SUGGESTION_LENGTH, MAX_SUGGESTIONS, @@ -36,7 +40,37 @@ class AgentTargetNotFoundError(Exception): class AgentResponseParseError(Exception): - """LLM 応答の JSON パースまたはスキーマ検証に失敗。""" + """LLM 応答の JSON パースまたはスキーマ検証に失敗。 + + リトライ後も失敗した場合、消費済みトークンの課金漏れを防ぐため確定済みの + 使用量を ``usage`` に載せて呼び出し元(router)へ伝播する(ADR-0012)。 + パース前段(リトライ未到達)の失敗では ``usage`` は None。 + """ + + def __init__(self, message: str, *, usage: "AgentUsage | None" = None) -> None: + super().__init__(message) + self.usage = usage + + +@dataclass(frozen=True) +class AgentUsage: + """チャット 1 回分の実トークン使用量(リトライ分を含む合算 / ADR-0012)。 + + router がクレジット消費・使用ログ記録(services/billing)へ渡す。 + 本モジュールは DB に触れない原則を維持するため、記録自体は行わない。 + """ + + model: AgentModelAlias + input_tokens: int + output_tokens: int + + +@dataclass(frozen=True) +class AgentChatResult: + """run_agent_chat の戻り値(API レスポンス + 課金用の使用量)。""" + + response: AgentChatResponse + usage: AgentUsage # 許可外の field 名を返された時の正規化先。スコープ選択で編集対象は確定しているため、 # 小型 LLM が「自己PR」等の field 名を返しても既定 field の提案として救済する。 @@ -212,8 +246,8 @@ def _parse_response(raw: str, scope: str) -> AgentChatResponse: async def run_agent_chat( request: AgentChatRequest, reference: dict | None = None -) -> AgentChatResponse: - """Agent チャットを実行する。 +) -> AgentChatResult: + """Agent チャットを実行し、レスポンスと実トークン使用量を返す。 Args: reference: GitHub/ブログ参照コンテキスト(career_summary / self_pr スコープのみ有効)。 @@ -225,6 +259,8 @@ async def run_agent_chat( LLMError: LLM 呼び出しの失敗(llm.base 参照)。 """ system_prompt = _SCOPE_PROMPTS[request.scope] + # エイリアス → 実モデル ID の解決はサーバー側で行う(クライアントに実 ID を持たせない) + model_id = get_model_spec(request.model).model_id user_prompt = ( f"# 編集対象スコープ\n{request.scope}\n\n" f"# 現在の内容\n{_build_context(request, reference)}\n\n" @@ -233,8 +269,9 @@ async def run_agent_chat( # 調査用ログはメタデータのみ出す。レジュメ本文・プロンプト本文は個人情報を含むため # DEBUG でもログに載せない(.claude/rules/security.md「ログへの秘密情報出力禁止」) logger.debug( - "Agent LLM 入力: scope=%s target=%s history=%d resume_len=%d user_prompt_len=%d", + "Agent LLM 入力: scope=%s model=%s target=%s history=%d resume_len=%d user_prompt_len=%d", request.scope, + request.model, request.target, len(request.history), len(request.resume.model_dump_json()), @@ -246,10 +283,13 @@ async def run_agent_chat( messages.append({"role": "user", "content": user_prompt}) client = get_llm_client() output_schema = _SCOPE_SCHEMAS[request.scope] - raw = await client.generate(system_prompt, messages, output_schema) - logger.debug("Agent LLM 生応答(パース前): len=%d", len(raw)) + result = await client.generate(system_prompt, messages, output_schema, model_id) + # リトライしても 1 回目の API 原価は発生しているため、使用量は全呼び出しの合算で課金する + input_tokens = result.input_tokens + output_tokens = result.output_tokens + logger.debug("Agent LLM 生応答(パース前): len=%d", len(result.text)) try: - return _parse_response(raw, request.scope) + response = _parse_response(result.text, request.scope) except AgentResponseParseError as exc: # スキーマ違反応答は 1 回だけリトライする。バリデーションエラーの内容を # フィードバックして再生成させ、2 回目も失敗したらそのまま raise @@ -257,7 +297,7 @@ async def run_agent_chat( logger.warning("LLM 応答が出力契約に違反したためリトライ: %s", type(exc).__name__) retry_messages = [ *messages, - {"role": "assistant", "content": raw}, + {"role": "assistant", "content": result.text}, { "role": "user", "content": ( @@ -267,6 +307,36 @@ async def run_agent_chat( ), }, ] - raw = await client.generate(system_prompt, retry_messages, output_schema) - logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(raw)) - return _parse_response(raw, request.scope) + try: + result = await client.generate( + system_prompt, retry_messages, output_schema, model_id + ) + except LLMError as retry_exc: + # リトライ呼び出し自体が失敗。1 回目の API 原価は発生済みのため使用量を + # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) + retry_exc.usage = AgentUsage( + model=request.model, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + raise + input_tokens += result.input_tokens + output_tokens += result.output_tokens + logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(result.text)) + try: + response = _parse_response(result.text, request.scope) + except AgentResponseParseError as retry_exc: + # 2 回目も失敗。1・2 回目分の API 原価は発生済みのため、合算した使用量を + # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) + raise AgentResponseParseError( + str(retry_exc), + usage=AgentUsage( + model=request.model, + input_tokens=input_tokens, + output_tokens=output_tokens, + ), + ) from retry_exc + usage = AgentUsage( + model=request.model, input_tokens=input_tokens, output_tokens=output_tokens + ) + return AgentChatResult(response=response, usage=usage) diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py index cc165875..6a9f2c2b 100644 --- a/backend/app/services/agent/llm/anthropic_client.py +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -1,4 +1,4 @@ -"""Anthropic API クライアント(本番用。モデル: Claude Haiku 4.5)。""" +"""Anthropic API クライアント(本番用。モデルは model_catalog.py で解決)。""" import json import logging @@ -7,13 +7,10 @@ from ....core import settings from ..output_schema import TOOL_NAME, build_tool_definition -from .base import LLMClient, LLMError +from .base import LLMClient, LLMError, LLMResult logger = logging.getLogger(__name__) -# 差分 operations の小さい JSON を返す用途のため Haiku を採用(ADR-0010)。 -# 精度不足が判明した場合は claude-sonnet-4-6 へ切り替える。 -_MODEL = "claude-haiku-4-5" # operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限 _MAX_TOKENS = 4096 _TIMEOUT_SECONDS = 60.0 @@ -38,11 +35,12 @@ async def generate( system_prompt: str, messages: list[dict[str, str]], output_schema: dict, - ) -> str: - """tool use 強制で Anthropic API を呼び出し、tool input を JSON 文字列で返す。""" + model_id: str, + ) -> LLMResult: + """tool use 強制で Anthropic API を呼び出し、tool input と実使用量を返す。""" try: response = await self._client.messages.create( - model=_MODEL, + model=model_id, max_tokens=_MAX_TOKENS, temperature=_TEMPERATURE, system=system_prompt, @@ -66,5 +64,10 @@ async def generate( ) if block is None: raise LLMError("Anthropic API が tool_use 応答を返しませんでした") - # 履歴契約(前回応答を JSON 文字列で持ち回す)を維持するため再シリアライズして返す - return json.dumps(block.input, ensure_ascii=False) + # 履歴契約(前回応答を JSON 文字列で持ち回す)を維持するため再シリアライズして返す。 + # usage はクレジット課金(ADR-0012)の根拠となるため実測値をそのまま返す + return LLMResult( + text=json.dumps(block.input, ensure_ascii=False), + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + ) diff --git a/backend/app/services/agent/llm/base.py b/backend/app/services/agent/llm/base.py index 397f8813..2d096634 100644 --- a/backend/app/services/agent/llm/base.py +++ b/backend/app/services/agent/llm/base.py @@ -1,6 +1,11 @@ """LLM クライアントの抽象基底クラスと共通例外。""" from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..chat_service import AgentUsage class LLMError(Exception): @@ -8,8 +13,29 @@ class LLMError(Exception): プロバイダ固有の例外は各クライアントで本例外にラップする。 呼び出し側(router)は本例外を 502 + ``AGENT_LLM_ERROR`` にマッピングする。 + + リトライ前の試行で消費済みトークンがある状態で本例外が起きた場合、課金漏れを + 防ぐため確定済みの使用量を ``usage`` に載せて伝播する(chat_service が設定 / + ADR-0012)。通常の発生時(消費前の失敗)は ``None``。 + """ + + def __init__(self, message: str = "", *, usage: "AgentUsage | None" = None) -> None: + super().__init__(message) + self.usage = usage + + +@dataclass(frozen=True) +class LLMResult: + """LLM 1 回呼び出しの結果(応答テキスト + 実トークン使用量)。 + + トークン数はクレジット課金(ADR-0012)の根拠となる実測値。 + 使用量を返せないプロバイダ(Ollama 等のローカル実装)は 0 を返す。 """ + text: str + input_tokens: int = 0 + output_tokens: int = 0 + class LLMClient(ABC): """LLM プロバイダの共通インターフェース。 @@ -24,14 +50,17 @@ async def generate( system_prompt: str, messages: list[dict[str, str]], output_schema: dict, - ) -> str: - """system プロンプトと会話 messages を渡して構造化応答(JSON 文字列)を返す。 + model_id: str, + ) -> LLMResult: + """system プロンプトと会話 messages を渡して構造化応答と使用量を返す。 messages は ``[{"role": "user" | "assistant", "content": str}, ...]`` で、 末尾が今回の user プロンプト(マルチターン時は先頭側に履歴が並ぶ)。 output_schema は応答が従うべき JSON Schema(output_schema.py で構築)。 各プロバイダの構造化出力機構(Anthropic: tool use 強制 / Ollama: format) - に渡し、戻り値はスキーマに従う JSON のシリアライズ文字列とする。 + に渡し、``LLMResult.text`` はスキーマに従う JSON のシリアライズ文字列とする。 + model_id は model_catalog.py が解決した実モデル ID(Anthropic 用)。 + 自前のモデル設定を持つプロバイダ(Ollama)は無視してよい。 Raises: LLMError: タイムアウト・接続失敗・API エラー時。 diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index 1e122a7d..dc8a341a 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -6,7 +6,7 @@ import httpx from ....core import settings -from .base import LLMClient, LLMError +from .base import LLMClient, LLMError, LLMResult logger = logging.getLogger(__name__) @@ -31,8 +31,13 @@ async def generate( system_prompt: str, messages: list[dict[str, str]], output_schema: dict, - ) -> str: - """Ollama /api/chat に JSON Schema 形式の format を付与して呼び出し、応答テキストを返す。""" + model_id: str, + ) -> LLMResult: + """Ollama /api/chat に JSON Schema 形式の format を付与して呼び出し、応答テキストを返す。 + + model_id(Anthropic 用の実モデル ID)は使わず、ローカル設定(OLLAMA_MODEL)の + モデルを使う。トークン使用量は返せないため 0 とする(ローカルは無料扱い / ADR-0012)。 + """ payload = { "model": self._model, "messages": [{"role": "system", "content": system_prompt}, *messages], @@ -64,4 +69,4 @@ async def generate( text = data.get("message", {}).get("content", "") if not text: raise LLMError("Ollama から空の応答が返されました") - return text + return LLMResult(text=text) diff --git a/backend/app/services/agent/model_catalog.py b/backend/app/services/agent/model_catalog.py new file mode 100644 index 00000000..af237edf --- /dev/null +++ b/backend/app/services/agent/model_catalog.py @@ -0,0 +1,109 @@ +"""Agent で選択可能な LLM モデルのカタログ(SSoT / ADR-0012)。 + +クライアントはエイリアス("haiku" / "sonnet")のみを指定し、実モデル ID と +課金レートは本モジュールでマップする。任意のモデル文字列をクライアントから +受け付けない(コスト爆発・未検証モデルの注入を防ぐ)。 + +クレジット単位は「1 クレジット = ¥1」(円ペッグ)。消費レートは API 原価(USD)を +円換算し、マージン係数を乗じて算出する。為替変動は YEN_PER_USD の調整で吸収する。 +定数の根拠は ADR-0012 を参照。 +""" + +import math +from dataclasses import dataclass +from typing import get_args + +from ...schemas.agent import AgentModelAlias + +# 1 クレジットあたりの円換算(1 クレジット = ¥1)。クライアント表示の正本 +YEN_PER_CREDIT = 1 +# 円換算に使う想定為替(USD→JPY)。為替変動はここの調整で吸収する +YEN_PER_USD = 150 +# API 原価に乗せるマージン係数(為替変動・キャッシュ未ヒット・運用コストのバッファ) +MARGIN_MULTIPLIER = 1.5 + +# Anthropic API の公表原価(USD / 100 万トークン)。改定時はここだけ直す +_HAIKU_INPUT_USD_PER_MTOK = 1.0 +_HAIKU_OUTPUT_USD_PER_MTOK = 5.0 +_SONNET_INPUT_USD_PER_MTOK = 3.0 +_SONNET_OUTPUT_USD_PER_MTOK = 15.0 + +# 「N クレジットで平均M回」「約N回」の目安に使う標準的な 1 回の消費トークン。 +# Agent はシステムプロンプト + レジュメ + 参照 + 履歴で入力が大きいため、入力多めの +# 概算を置く。利用実績ゼロの新規ユーザー向けのフォールバック(実消費は +# agent_usage_logs の実測値で算出。本値は概算で、実データに合わせて調整可) +BASELINE_INPUT_TOKENS = 10_000 +BASELINE_OUTPUT_TOKENS = 1_500 + + +def _credits_per_mtok(usd_per_mtok: float) -> int: + """USD/MTok の原価を、マージン込みのクレジット/MTok レート(円ペッグ)に換算する。""" + yen_per_mtok = usd_per_mtok * YEN_PER_USD * MARGIN_MULTIPLIER + return round(yen_per_mtok / YEN_PER_CREDIT) + + +@dataclass(frozen=True) +class ModelSpec: + """エイリアス 1 件分のモデル定義。""" + + # Anthropic API に渡す実モデル ID + model_id: str + # True なら残高チェック・クレジット消費を行わない(使用ログのみ記録) + is_free: bool + # マージン込みの消費レート(クレジット / 100 万トークン) + input_credits_per_mtok: int + output_credits_per_mtok: int + + +# エイリアス → モデル定義(キー集合は schemas/agent.py の AgentModelAlias と +# 一致させる。drift は test_model_catalog_matches_schema_alias で検出する) +MODEL_CATALOG: dict[str, ModelSpec] = { + "haiku": ModelSpec( + model_id="claude-haiku-4-5", + is_free=True, + input_credits_per_mtok=_credits_per_mtok(_HAIKU_INPUT_USD_PER_MTOK), + output_credits_per_mtok=_credits_per_mtok(_HAIKU_OUTPUT_USD_PER_MTOK), + ), + "sonnet": ModelSpec( + model_id="claude-sonnet-4-6", + is_free=False, + input_credits_per_mtok=_credits_per_mtok(_SONNET_INPUT_USD_PER_MTOK), + output_credits_per_mtok=_credits_per_mtok(_SONNET_OUTPUT_USD_PER_MTOK), + ), +} + +# モジュールロード時にスキーマの Literal とカタログの drift を fail fast で検出する。 +# assert は -O / PYTHONOPTIMIZE で除去されるため、不変条件は明示的に raise する +if set(MODEL_CATALOG) != set(get_args(AgentModelAlias)): + raise RuntimeError( + "MODEL_CATALOG のキーは schemas/agent.py の AgentModelAlias と一致させること" + ) + + +def get_model_spec(alias: str) -> ModelSpec: + """エイリアスからモデル定義を返す。未知のエイリアスは KeyError(スキーマで検証済み前提)。""" + return MODEL_CATALOG[alias] + + +def calculate_credit_cost(alias: str, input_tokens: int, output_tokens: int) -> int: + """実トークン数からクレジット消費量を算出する。無料モデルは常に 0。 + + 端数は切り上げ(ceil)とし、課金対象の利用で 0 クレジットにならないようにする。 + """ + spec = get_model_spec(alias) + if spec.is_free: + return 0 + raw = ( + input_tokens * spec.input_credits_per_mtok + + output_tokens * spec.output_credits_per_mtok + ) + return math.ceil(raw / 1_000_000) + + +def baseline_credits_per_chat(alias: str) -> int: + """標準的な 1 回のチャットの消費クレジット(回数目安用 / ADR-0012)。 + + 利用実績ゼロの新規ユーザーでも「N クレジットで約M回」を出すための概算。 + 無料モデルは 0。 + """ + return calculate_credit_cost(alias, BASELINE_INPUT_TOKENS, BASELINE_OUTPUT_TOKENS) diff --git a/backend/app/services/billing/__init__.py b/backend/app/services/billing/__init__.py new file mode 100644 index 00000000..594ac1bb --- /dev/null +++ b/backend/app/services/billing/__init__.py @@ -0,0 +1 @@ +"""クレジット課金サービス(ADR-0012)。""" diff --git a/backend/app/services/billing/credit_service.py b/backend/app/services/billing/credit_service.py new file mode 100644 index 00000000..7fba52f9 --- /dev/null +++ b/backend/app/services/billing/credit_service.py @@ -0,0 +1,100 @@ +"""クレジット残高の検証・消費・付与のビジネスロジック(ADR-0012)。 + +HTTP 知識(ステータスコード・HTTPException)は持ち込まない。 +router 側で ``InsufficientCreditsError`` → 402 ``INSUFFICIENT_CREDITS`` に変換する。 +""" + +import logging +from typing import Literal + +from sqlalchemy.orm import Session + +from ...repositories.billing import BillingRepository +from ..agent.chat_service import AgentUsage +from ..agent.model_catalog import calculate_credit_cost, get_model_spec + +logger = logging.getLogger(__name__) + +# 台帳の transaction_type の閉じた集合(models/billing.py の transaction_type と同期)。 +# 自由文字列を許すとタイポで台帳が汚れ、種別フィルタ/集計が壊れるため Literal で縛る +TransactionType = Literal["consumption", "admin_grant", "purchase"] +TRANSACTION_TYPE_CONSUMPTION: TransactionType = "consumption" +TRANSACTION_TYPE_ADMIN_GRANT: TransactionType = "admin_grant" +TRANSACTION_TYPE_PURCHASE: TransactionType = "purchase" + + +class InsufficientCreditsError(Exception): + """有料モデルの利用に必要なクレジット残高が不足している。""" + + +def ensure_can_use_model(db: Session, user_id: str, model_alias: str) -> None: + """有料モデル利用前の残高チェック。無料モデルは常に通す。 + + 残高 > 0 を要求する。実コストは LLM 応答後にしか確定しないため、 + チェック通過後の消費で残高が負になることは許容する(有界損失 / ADR-0012)。 + + Raises: + InsufficientCreditsError: 有料モデルで残高が 0 以下。 + """ + if get_model_spec(model_alias).is_free: + return + balance = BillingRepository(db, user_id).get_balance() + if balance <= 0: + raise InsufficientCreditsError( + f"クレジット残高が不足しています: balance={balance}" + ) + + +def record_chat_usage(db: Session, user_id: str, usage: AgentUsage) -> int | None: + """チャット 1 回分の使用量を記録し、有料モデルならクレジットを消費する。 + + 無料モデルは使用ログのみ記録して None を返す。有料モデルは残高減算・台帳追記・ + 使用ログ記録を単一トランザクションで原子的に確定し、適用後残高を返す。記録の + 失敗(途中での例外)は全体を rollback して呼び出し元へ伝播させる + (課金漏れ・課金とログの不整合を黙って通さない方針 / ADR-0012)。 + """ + cost = calculate_credit_cost(usage.model, usage.input_tokens, usage.output_tokens) + balance_after = BillingRepository(db, user_id).record_chat_consumption( + amount=-cost, + transaction_type=TRANSACTION_TYPE_CONSUMPTION, + description=f"Agent チャット({usage.model})", + model_alias=usage.model, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + credit_cost=cost, + ) + if cost > 0: + logger.info( + "クレジット消費: model=%s in=%d out=%d cost=%d balance_after=%s", + usage.model, + usage.input_tokens, + usage.output_tokens, + cost, + balance_after, + ) + return balance_after + + +def grant_credits( + db: Session, + user_id: str, + amount: int, + *, + transaction_type: TransactionType, + description: str | None = None, + stripe_session_id: str | None = None, +) -> int: + """クレジットを付与し、適用後残高を返す。 + + Phase 1 は管理者付与(admin_grant)のみ。Phase 2 で Stripe 購入(purchase)からも + 呼ばれる(stripe_session_id の UNIQUE 制約で二重付与を防ぐ / ADR-0012)。 + """ + if amount <= 0: + # スキーマ検証をすり抜けた負値付与(実質消費)を防ぐ最終ガード + raise ValueError(f"付与額は正の値であること: amount={amount}") + return BillingRepository(db, user_id).apply_transaction( + amount=amount, + transaction_type=transaction_type, + description=description, + stripe_session_id=stripe_session_id, + ) diff --git a/backend/app/services/billing/pricing.py b/backend/app/services/billing/pricing.py new file mode 100644 index 00000000..e03d277b --- /dev/null +++ b/backend/app/services/billing/pricing.py @@ -0,0 +1,37 @@ +"""クレジットパック(購入単位)の定義(ADR-0012)。 + +価格・付与クレジットの正本。トークン購入画面のパック一覧と、Phase 2 の +Stripe Checkout(`price_data` 動的生成)の双方がここを参照する。 +為替変動は本ファイルの値の調整で吸収する(自動連動はしない)。 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CreditPack: + """購入クレジットパック 1 種。""" + + # Checkout の metadata で受け渡す安定 ID(変更しない) + id: str + name: str + # 販売価格(日本円・税込想定) + price_jpy: int + # 付与クレジット(ボーナス込み) + credits: int + + +# 1 クレジット = ¥1(ADR-0012)。スターターは等価、スタンダード以上はボーナス上乗せ。 +# 目安: Sonnet 1 回 ≒ 9 クレジット(standard 1,100 クレジット ≒ 120 回) +CREDIT_PACKS: list[CreditPack] = [ + CreditPack(id="starter", name="スターター", price_jpy=500, credits=500), + CreditPack(id="standard", name="スタンダード", price_jpy=1_000, credits=1_100), + CreditPack(id="pro", name="プロ", price_jpy=3_000, credits=3_500), +] + +_PACKS_BY_ID = {pack.id: pack for pack in CREDIT_PACKS} + + +def get_pack(pack_id: str) -> CreditPack | None: + """ID からパックを引く(未知の ID は None)。Phase 2 の Checkout 作成で使う。""" + return _PACKS_BY_ID.get(pack_id) diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index ad0309fb..787d3845 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -15,7 +15,7 @@ AgentTargetNotFoundError, _parse_response, ) -from app.services.agent.llm.base import LLMClient, LLMError +from app.services.agent.llm.base import LLMClient, LLMError, LLMResult from app.services.agent.output_schema import ( MAX_SUGGESTION_LENGTH, MAX_SUGGESTIONS, @@ -32,28 +32,43 @@ class _FakeLLM(LLMClient): """テスト用の LLM クライアント(固定応答 or 例外)。受信した入力を記録する。""" - def __init__(self, response: str | None = None, error: Exception | None = None): + def __init__( + self, + response: str | None = None, + error: Exception | None = None, + input_tokens: int = 0, + output_tokens: int = 0, + ): """固定応答または送出例外をセットし、受信内容記録フィールドを初期化する。""" self._response = response self._error = error + self._input_tokens = input_tokens + self._output_tokens = output_tokens self.received_system_prompt: str | None = None self.received_messages: list[dict[str, str]] | None = None self.received_output_schema: dict | None = None + self.received_model_id: str | None = None async def generate( self, system_prompt: str, messages: list[dict[str, str]], output_schema: dict, - ) -> str: + model_id: str, + ) -> LLMResult: """受信引数を記録し、設定済みの例外を raise するか固定応答を返す。""" self.received_system_prompt = system_prompt self.received_messages = messages self.received_output_schema = output_schema + self.received_model_id = model_id if self._error: raise self._error assert self._response is not None - return self._response + return LLMResult( + text=self._response, + input_tokens=self._input_tokens, + output_tokens=self._output_tokens, + ) def _mock_llm(monkeypatch, *, response: str | None = None, error: Exception | None = None): @@ -66,9 +81,13 @@ def _mock_llm(monkeypatch, *, response: str | None = None, error: Exception | No class _SequentialFakeLLM(LLMClient): """呼び出しごとに用意した応答を順に返す LLM クライアント(リトライ検証用)。""" - def __init__(self, responses: list[str]): + def __init__( + self, responses: list[str], input_tokens: int = 0, output_tokens: int = 0 + ): """順に返すべき応答リストを受け取り、コール記録リストを初期化する。""" self._responses = list(responses) + self._input_tokens = input_tokens + self._output_tokens = output_tokens self.calls: list[list[dict[str, str]]] = [] async def generate( @@ -76,10 +95,15 @@ async def generate( system_prompt: str, messages: list[dict[str, str]], output_schema: dict, - ) -> str: + model_id: str, + ) -> LLMResult: """受信 messages を記録し、呼び出し順に対応した応答を返す。""" self.calls.append(messages) - return self._responses[len(self.calls) - 1] + return LLMResult( + text=self._responses[len(self.calls) - 1], + input_tokens=self._input_tokens, + output_tokens=self._output_tokens, + ) def _resume_payload() -> dict: diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py new file mode 100644 index 00000000..dc53c455 --- /dev/null +++ b/backend/tests/test_billing.py @@ -0,0 +1,588 @@ +"""クレジット課金(ADR-0012)の統合テスト。 + +LLM(外部 API)のみモックし、残高チェック・消費・台帳記録・使用ログは +実 DB(テスト用 SQLite セッション)を通す。 +""" + +from typing import get_args + +import pytest +from app.models import AgentUsageLog +from app.repositories import BillingRepository, UserRepository +from app.schemas.agent import AgentModelAlias +from app.services.agent import chat_service +from app.services.agent.chat_service import AgentUsage +from app.services.agent.llm.base import LLMClient, LLMError, LLMResult +from app.services.agent.model_catalog import ( + MARGIN_MULTIPLIER, + MODEL_CATALOG, + YEN_PER_CREDIT, + YEN_PER_USD, + baseline_credits_per_chat, + calculate_credit_cost, +) +from app.services.billing import credit_service +from fastapi.testclient import TestClient + +from conftest import auth_header +from test_agent import _FakeLLM, _llm_json, _resume_payload, _SequentialFakeLLM + +_ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"} + + +def _chat_payload(model: str | None = None) -> dict: + """self_pr スコープの最小チャットペイロードを返す。""" + payload = { + "scope": "self_pr", + "prompt": "もっと魅力的にしてください", + "resume": _resume_payload(), + } + if model is not None: + payload["model"] = model + return payload + + +def _get_user_id(client: TestClient, username: str) -> str: + """auth_header で作成済みのユーザー ID を返す。""" + user = UserRepository(client._db_session).get_by_username(username) + assert user is not None + return user.id + + +# --- ユニットテスト(model_catalog: エイリアス・課金レート) --- + + +def test_model_catalog_matches_schema_alias() -> None: + """MODEL_CATALOG のキー集合はスキーマの AgentModelAlias と一致する(drift 防止)。""" + assert set(MODEL_CATALOG) == set(get_args(AgentModelAlias)) + + +def test_model_catalog_resolves_real_model_ids() -> None: + """エイリアスから実モデル ID と無料/有料フラグが解決できる。""" + assert MODEL_CATALOG["haiku"].model_id == "claude-haiku-4-5" + assert MODEL_CATALOG["haiku"].is_free is True + assert MODEL_CATALOG["sonnet"].model_id == "claude-sonnet-4-6" + assert MODEL_CATALOG["sonnet"].is_free is False + + +def test_credit_rates_are_yen_pegged_with_margin() -> None: + """消費レートは API 原価(USD/MTok)を円換算しマージンを乗せて算出される(1cr=¥1 / ADR-0012)。""" + sonnet = MODEL_CATALOG["sonnet"] + assert sonnet.input_credits_per_mtok == round(3.0 * YEN_PER_USD * MARGIN_MULTIPLIER / YEN_PER_CREDIT) + assert sonnet.output_credits_per_mtok == round(15.0 * YEN_PER_USD * MARGIN_MULTIPLIER / YEN_PER_CREDIT) + # 1 クレジット = ¥1 なので Sonnet 入力は 675 クレジット/MTok(= $3 × ¥150 × 1.5) + assert sonnet.input_credits_per_mtok == 675 + assert sonnet.output_credits_per_mtok == 3375 + + +def test_calculate_credit_cost_free_model_is_zero() -> None: + """無料モデル(haiku)はトークン数に関わらずコスト 0。""" + assert calculate_credit_cost("haiku", 100_000, 100_000) == 0 + + +def test_calculate_credit_cost_sonnet_exact() -> None: + """sonnet のコストは入出力レートの合算(クレジット/MTok)から算出される。""" + # input 1000 tok × 675/MTok = 0.675 / output 1000 tok × 3375/MTok = 3.375 → 計 4.05 → ceil 5 + assert calculate_credit_cost("sonnet", 1000, 1000) == 5 + + +def test_calculate_credit_cost_rounds_up() -> None: + """端数は切り上げ、課金対象の利用が 0 クレジットにならない。""" + # input 1 tok = 675 / 1e6 = 0.000675 → 1 クレジットに切り上げ + assert calculate_credit_cost("sonnet", 1, 0) == 1 + + +def test_baseline_credits_per_chat() -> None: + """標準的な 1 回の消費(回数目安用)。Sonnet は概算 12 クレジット、Haiku は 0。""" + # 10000×675/1e6 + 1500×3375/1e6 = 6.75 + 5.0625 = 11.8125 → ceil 12 + assert baseline_credits_per_chat("sonnet") == 12 + assert baseline_credits_per_chat("haiku") == 0 + + +def test_grant_credits_rejects_non_positive(db_session) -> None: + """付与額 0 以下は ValueError(実質消費になる付与を防ぐ最終ガード)。""" + with pytest.raises(ValueError, match="付与額"): + credit_service.grant_credits( + db_session, + "dummy-user-id", + 0, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + +# --- 統合テスト(チャット課金フロー) --- + + +def test_chat_haiku_works_with_zero_balance(client: TestClient, monkeypatch) -> None: + """haiku(無料)は残高 0 でも利用でき、使用ログのみ記録される。""" + fake = _FakeLLM( + response=_llm_json("self_pr", "改善した自己PR"), + input_tokens=1200, + output_tokens=300, + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-haiku") + + resp = client.post("/api/agent/chat", json=_chat_payload(), headers=headers) + + assert resp.status_code == 200 + assert fake.received_model_id == "claude-haiku-4-5" + user_id = _get_user_id(client, "billing-haiku") + repo = BillingRepository(client._db_session, user_id) + assert repo.get_balance() == 0 + assert repo.list_transactions() == [] + logs = client._db_session.query(AgentUsageLog).filter_by(user_id=user_id).all() + assert len(logs) == 1 + assert logs[0].model_alias == "haiku" + assert logs[0].input_tokens == 1200 + assert logs[0].output_tokens == 300 + assert logs[0].credit_cost == 0 + + +def test_chat_sonnet_consumes_credits(client: TestClient, monkeypatch) -> None: + """sonnet は実トークン量からコストを算出し、残高減算 + 台帳 + 使用ログを記録する。""" + fake = _FakeLLM( + response=_llm_json("self_pr", "改善した自己PR"), + input_tokens=1000, + output_tokens=1000, + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-sonnet") + user_id = _get_user_id(client, "billing-sonnet") + credit_service.grant_credits( + client._db_session, + user_id, + 10_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + assert resp.status_code == 200 + assert fake.received_model_id == "claude-sonnet-4-6" + repo = BillingRepository(client._db_session, user_id) + # コスト: 1000×675/MTok + 1000×3375/MTok = 4.05 → ceil 5(1cr=¥1) + assert repo.get_balance() == 10_000 - 5 + transactions = repo.list_transactions() + consumption = [ + t for t in transactions + if t.transaction_type == credit_service.TRANSACTION_TYPE_CONSUMPTION + ] + assert len(consumption) == 1 + assert consumption[0].amount == -5 + assert consumption[0].balance_after == 10_000 - 5 + + +def test_chat_sonnet_with_zero_balance_returns_402(client: TestClient, monkeypatch) -> None: + """sonnet は残高 0 で 402 INSUFFICIENT_CREDITS。LLM は呼ばれない。""" + fake = _FakeLLM(response=_llm_json("self_pr", "提案")) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-empty") + + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + assert resp.status_code == 402 + # AppErrorResponse はトップレベルに code / message を持つ(main.py の例外ハンドラ) + assert resp.json()["code"] == "INSUFFICIENT_CREDITS" + assert fake.received_messages is None + + +def test_chat_sonnet_allows_negative_balance(client: TestClient, monkeypatch) -> None: + """事前チェック通過後の実コストが残高を超えた場合は負残高で確定する(ADR-0012)。""" + fake = _FakeLLM( + response=_llm_json("self_pr", "提案"), + input_tokens=1000, + output_tokens=1000, + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-negative") + user_id = _get_user_id(client, "billing-negative") + # 残高 3 で事前チェックは通るが、実コスト 5 が上回り負残高になる + credit_service.grant_credits( + client._db_session, + user_id, + 3, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + assert resp.status_code == 200 + assert BillingRepository(client._db_session, user_id).get_balance() == 3 - 5 + + +def test_chat_sonnet_retry_usage_is_summed(client: TestClient, monkeypatch) -> None: + """パース失敗 → リトライ成功時は 2 回分の実トークン量を合算して課金する。""" + fake = _SequentialFakeLLM( + ["JSON ではない応答", _llm_json("self_pr", "再生成の提案")], + input_tokens=1000, + output_tokens=1000, + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-retry") + user_id = _get_user_id(client, "billing-retry") + credit_service.grant_credits( + client._db_session, + user_id, + 10_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + assert resp.status_code == 200 + assert len(fake.calls) == 2 + # 2 呼び出し合算 2000/2000 トークン: (2000×675 + 2000×3375)/1e6 = 8.1 → ceil 9 + assert BillingRepository(client._db_session, user_id).get_balance() == 10_000 - 9 + + +def test_chat_sonnet_retry_failure_still_bills_usage( + client: TestClient, monkeypatch +) -> None: + """パース失敗 → リトライも失敗(502)でも、消費済み 2 回分の API 原価は課金する。 + + リトライ後も失敗した場合に課金をスキップすると、有料モデルの呼び出し原価が + 課金漏れになる。502 を返しつつ残高減算・使用ログ記録を確定させる(ADR-0012)。 + """ + fake = _SequentialFakeLLM( + ["不正応答 1 回目", "不正応答 2 回目"], + input_tokens=1000, + output_tokens=1000, + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-retry-fail") + user_id = _get_user_id(client, "billing-retry-fail") + credit_service.grant_credits( + client._db_session, + user_id, + 10_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + assert resp.status_code == 502 + assert resp.json()["code"] == "AGENT_PARSE_ERROR" + assert len(fake.calls) == 2 + repo = BillingRepository(client._db_session, user_id) + # 2 呼び出し合算 2000/2000 トークン: (2000×675 + 2000×3375)/1e6 = 8.1 → ceil 9 + assert repo.get_balance() == 10_000 - 9 + consumption = [ + t + for t in repo.list_transactions() + if t.transaction_type == credit_service.TRANSACTION_TYPE_CONSUMPTION + ] + assert len(consumption) == 1 + assert consumption[0].amount == -9 + logs = client._db_session.query(AgentUsageLog).filter_by(user_id=user_id).all() + assert len(logs) == 1 + assert logs[0].model_alias == "sonnet" + assert logs[0].credit_cost == 9 + + +def test_chat_sonnet_retry_llm_error_still_bills_first_attempt( + client: TestClient, monkeypatch +) -> None: + """1 回目成功(パース失敗)→ リトライ呼び出しが LLMError でも 1 回目分は課金する。 + + リトライの API 呼び出し自体が失敗(502 AGENT_LLM_ERROR)しても、1 回目の原価は + 発生済みのため課金を確定させる(課金漏れを防ぐ / ADR-0012)。 + """ + + class _FailOnRetryLLM(LLMClient): + """1 回目はパース不能応答(課金対象トークンつき)、2 回目は LLMError を投げる。""" + + def __init__(self) -> None: + self.calls = 0 + + async def generate(self, *_args, **_kwargs) -> LLMResult: + self.calls += 1 + if self.calls == 1: + return LLMResult(text="不正応答", input_tokens=1000, output_tokens=1000) + raise LLMError("リトライ呼び出し失敗(テスト)") + + fake = _FailOnRetryLLM() + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-retry-llm-error") + user_id = _get_user_id(client, "billing-retry-llm-error") + credit_service.grant_credits( + client._db_session, + user_id, + 10_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + assert resp.status_code == 502 + assert resp.json()["code"] == "AGENT_LLM_ERROR" + assert fake.calls == 2 + repo = BillingRepository(client._db_session, user_id) + # 1 回目のみ 1000/1000 トークン: (1000×675 + 1000×3375)/1e6 = 4.05 → ceil 5 + assert repo.get_balance() == 10_000 - 5 + logs = client._db_session.query(AgentUsageLog).filter_by(user_id=user_id).all() + assert len(logs) == 1 + assert logs[0].credit_cost == 5 + + +def test_record_chat_usage_is_atomic_on_log_failure( + client: TestClient, monkeypatch +) -> None: + """使用ログ記録が失敗したらクレジット消費も rollback される(課金済み・ログ無しを防ぐ / ADR-0012)。""" + auth_header(client, "billing-atomic") + user_id = _get_user_id(client, "billing-atomic") + db = client._db_session + credit_service.grant_credits( + db, + user_id, + 10_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + # 単一トランザクションの後半(使用ログ insert)で例外を起こす + import app.repositories.billing as billing_repo + + def _boom(*_args, **_kwargs): + raise RuntimeError("使用ログ記録失敗(テスト)") + + monkeypatch.setattr(billing_repo, "AgentUsageLog", _boom) + + usage = AgentUsage(model="sonnet", input_tokens=1000, output_tokens=1000) + with pytest.raises(RuntimeError, match="使用ログ"): + credit_service.record_chat_usage(db, user_id, usage) + + # 消費が rollback され、残高据え置き・consumption 台帳なし・使用ログなし + repo = BillingRepository(db, user_id) + assert repo.get_balance() == 10_000 + consumption = [ + t + for t in repo.list_transactions() + if t.transaction_type == credit_service.TRANSACTION_TYPE_CONSUMPTION + ] + assert consumption == [] + assert db.query(AgentUsageLog).filter_by(user_id=user_id).all() == [] + + +def test_chat_rejects_unknown_model_alias(client: TestClient, monkeypatch) -> None: + """カタログ外のモデル指定はスキーマ検証で 422(実モデル ID の注入を防ぐ)。""" + fake = _FakeLLM(response=_llm_json("self_pr", "提案")) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-unknown-model") + + resp = client.post( + "/api/agent/chat", + json=_chat_payload("claude-opus-4-8"), + headers=headers, + ) + + assert resp.status_code == 422 + assert fake.received_messages is None + + +# --- 統合テスト(残高・台帳エンドポイント) --- + + +def test_get_balance_returns_current_balance(client: TestClient) -> None: + """GET /api/billing/balance は現在残高を返す。""" + auth_header(client, "billing-balance") + user_id = _get_user_id(client, "billing-balance") + credit_service.grant_credits( + client._db_session, + user_id, + 5_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + + resp = client.get("/api/billing/balance") + + assert resp.status_code == 200 + assert resp.json() == {"balance": 5_000} + + +def test_get_balance_requires_auth(client: TestClient) -> None: + """未ログインの残高照会は 401。""" + resp = client.get("/api/billing/balance") + assert resp.status_code == 401 + + +def test_list_transactions_returns_history(client: TestClient, monkeypatch) -> None: + """GET /api/billing/transactions は付与・消費の履歴を返す。""" + fake = _FakeLLM( + response=_llm_json("self_pr", "提案"), input_tokens=1000, output_tokens=1000 + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + headers = auth_header(client, "billing-history") + user_id = _get_user_id(client, "billing-history") + credit_service.grant_credits( + client._db_session, + user_id, + 1_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + description="テスト付与", + ) + resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + assert resp.status_code == 200 + + resp = client.get("/api/billing/transactions") + + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 2 + types = {entry["transaction_type"] for entry in body} + assert types == {"admin_grant", "consumption"} + # 台帳エントリは金額と適用後残高のスナップショットを持つ + consumption = next(e for e in body if e["transaction_type"] == "consumption") + assert consumption["amount"] == -5 + assert consumption["balance_after"] == 1_000 - 5 + + +# --- 統合テスト(使用量サマリ) --- + + +def test_usage_summary_aggregates_per_model(client: TestClient, monkeypatch) -> None: + """GET /api/billing/usage-summary はモデル別にチャット回数・トークン・消費を集計する。""" + headers = auth_header(client, "billing-usage") + user_id = _get_user_id(client, "billing-usage") + credit_service.grant_credits( + client._db_session, + user_id, + 10_000, + transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, + ) + # haiku 2 回(無料)・sonnet 1 回(有料) + haiku = _FakeLLM( + response=_llm_json("self_pr", "提案"), input_tokens=500, output_tokens=200 + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: haiku) + client.post("/api/agent/chat", json=_chat_payload(), headers=headers) + client.post("/api/agent/chat", json=_chat_payload(), headers=headers) + sonnet = _FakeLLM( + response=_llm_json("self_pr", "提案"), input_tokens=1000, output_tokens=1000 + ) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: sonnet) + client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) + + resp = client.get("/api/billing/usage-summary") + + assert resp.status_code == 200 + by_model = {entry["model"]: entry for entry in resp.json()} + assert by_model["haiku"]["chat_count"] == 2 + assert by_model["haiku"]["input_tokens"] == 1000 + assert by_model["haiku"]["output_tokens"] == 400 + assert by_model["haiku"]["credit_cost"] == 0 + assert by_model["sonnet"]["chat_count"] == 1 + assert by_model["sonnet"]["credit_cost"] == 5 + + +def test_usage_summary_empty_when_no_usage(client: TestClient) -> None: + """利用実績が無ければ空配列(モデル一覧の補完は表示側が担う)。""" + auth_header(client, "billing-no-usage") + resp = client.get("/api/billing/usage-summary") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_usage_summary_requires_auth(client: TestClient) -> None: + """未ログインの使用量サマリ取得は 401。""" + resp = client.get("/api/billing/usage-summary") + assert resp.status_code == 401 + + +# --- 統合テスト(クレジットパック) --- + + +def test_list_credit_packs(client: TestClient) -> None: + """GET /api/billing/packs は pricing.py のパック一覧を返す。""" + auth_header(client, "billing-packs") + resp = client.get("/api/billing/packs") + assert resp.status_code == 200 + packs = resp.json() + ids = [p["id"] for p in packs] + assert ids == ["starter", "standard", "pro"] + starter = packs[0] + assert starter["price_jpy"] == 500 + # 1 クレジット = ¥1。スターターは等価 + assert starter["credits"] == 500 + + +def test_list_model_rates(client: TestClient) -> None: + """GET /api/billing/model-rates はモデル別の標準消費レートを返す。""" + auth_header(client, "billing-rates") + resp = client.get("/api/billing/model-rates") + assert resp.status_code == 200 + by_model = {entry["model"]: entry for entry in resp.json()} + assert by_model["haiku"]["is_free"] is True + assert by_model["haiku"]["baseline_credits_per_chat"] == 0 + assert by_model["sonnet"]["is_free"] is False + assert by_model["sonnet"]["baseline_credits_per_chat"] == 12 + + +def test_list_model_rates_requires_auth(client: TestClient) -> None: + """未ログインのレート取得は 401。""" + resp = client.get("/api/billing/model-rates") + assert resp.status_code == 401 + + +def test_list_credit_packs_requires_auth(client: TestClient) -> None: + """未ログインのパック一覧取得は 401。""" + resp = client.get("/api/billing/packs") + assert resp.status_code == 401 + + +# --- 統合テスト(管理者付与) --- + + +def test_admin_grant_adds_credits(client: TestClient) -> None: + """ADMIN_TOKEN 認証でユーザーへクレジットを付与できる。""" + auth_header(client, "billing-grant-target") + client.cookies.clear() # Bearer 認証のみで呼ぶ(CSRF チェック対象外であることも担保) + + resp = client.post( + "/api/billing/admin/grant", + json={"username": "billing-grant-target", "amount": 30_000, "description": "テスト"}, + headers=_ADMIN_HEADERS, + ) + + assert resp.status_code == 200 + assert resp.json() == {"balance": 30_000} + + +def test_admin_grant_requires_bearer_token(client: TestClient) -> None: + """Bearer トークンなしの付与は 401。""" + resp = client.post( + "/api/billing/admin/grant", + json={"username": "anyone", "amount": 100}, + ) + assert resp.status_code == 401 + + +def test_admin_grant_rejects_wrong_token(client: TestClient) -> None: + """不正な Bearer トークンは 403。""" + resp = client.post( + "/api/billing/admin/grant", + json={"username": "anyone", "amount": 100}, + headers={"Authorization": "Bearer wrong-token"}, + ) + assert resp.status_code == 403 + + +def test_admin_grant_unknown_user_returns_404(client: TestClient) -> None: + """存在しないユーザーへの付与は 404。""" + resp = client.post( + "/api/billing/admin/grant", + json={"username": "no-such-user", "amount": 100}, + headers=_ADMIN_HEADERS, + ) + assert resp.status_code == 404 + + +def test_admin_grant_rejects_non_positive_amount(client: TestClient) -> None: + """付与額 0 以下はスキーマ検証で 422。""" + auth_header(client, "billing-grant-zero") + client.cookies.clear() + resp = client.post( + "/api/billing/admin/grant", + json={"username": "billing-grant-zero", "amount": 0}, + headers=_ADMIN_HEADERS, + ) + assert resp.status_code == 422 diff --git a/docs/adr/0012-agent-model-switching-and-prepaid-billing.md b/docs/adr/0012-agent-model-switching-and-prepaid-billing.md new file mode 100644 index 00000000..f007c9fb --- /dev/null +++ b/docs/adr/0012-agent-model-switching-and-prepaid-billing.md @@ -0,0 +1,115 @@ +# ADR-0012: Agent モデル切り替えとプリペイドクレジット課金 + +## ステータス + +Accepted + +## コンテキスト + +DevForge Agent(ADR-0010)の LLM モデルは `claude-haiku-4-5` をハードコードしている。 +精度が欲しい場面で上位モデル(Sonnet)を使いたいが、上位モデルは API 原価が高く +(Haiku: $1/$5、Sonnet: $3/$15 per 1M トークン入力/出力)、無制限に開放すると +運用コストが青天井になる。そこで「Haiku は無料・使い放題、Sonnet は有料」とする +モデル切り替え + 課金機能を導入する。 + +制約: + +- Agent チャットの既存契約(`AgentChatResponse` の構造、DB 非更新原則、エラー契約)は + 崩さない(ADR-0010 / `.claude/rules/backend/agent.md`) +- Cloud Run 単一インスタンス(ADR-0005)+ Turso (libSQL)。分散ロックは不要 +- 決済機能の運用は初経験のため、未払い・請求トラブルのリスクを最小化したい + +## 決定内容 + +### 1. モデル切り替えは「エイリアス方式」 + +- クライアントは `AgentChatRequest.model: Literal["haiku", "sonnet"]`(デフォルト `"haiku"`)で + モデルを指定する。実モデル ID(`claude-haiku-4-5` 等)はサーバー側の + `services/agent/model_catalog.py`(SSoT)でマップする +- 任意のモデル文字列をクライアントから受け付けない(コスト爆発・未検証モデルの注入を防ぐ) +- `LLMClient.generate()` の契約を変更し、応答テキストに加えて実トークン使用量 + (`input_tokens` / `output_tokens`)を返す(従来は `response.usage` を捨てていた) + +### 2. 課金はプリペイドクレジット方式 + +- ユーザーは事前にクレジットを購入し、Sonnet 利用時に実トークン量に応じて消費する +- **1 クレジット = $0.0001**(USD ペッグ)。消費レートは API 原価 × **マージン係数 1.5** + (為替・キャッシュ未ヒット・運用コストのバッファ) +- 課金フロー(1 リクエスト): + 1. 事前チェック: 残高 > 0(不足なら 402 `INSUFFICIENT_CREDITS`) + 2. LLM 呼び出し → 実トークン数取得 + 3. 事後減算: 原子的 UPDATE で残高から実コストを引き、台帳 + 使用ログを記録 +- **負残高を許容する**: 事前チェック通過後の実コストが残高を上回った場合、残高は負になる。 + 1 リクエストの最大コストは `max_tokens=4096` で有界(〜1,200 クレジット ≈ $0.12)であり、 + 予約(reserve-then-settle)方式の複雑さに見合わないと判断した +- **消費記録の失敗は 500 エラーにする**: LLM 応答が得られても課金記録に失敗した場合は + 応答を返さない(課金漏れを黙って通すより、ユーザーに再試行させる方を選ぶ) +- Haiku(無料モデル)は残高チェック・減算なし。使用ログのみ記録する(コスト分析用) + +### 3. データモデルは「台帳 + キャッシュ残高」 + +- `credit_transactions`(台帳・追記専用): 全ての付与/消費を符号付き `amount` と + `balance_after` で記録する。監査とデバッグの正本 +- `users.credit_balance`(キャッシュ残高): 事前チェックと表示用。台帳と同一トランザクション内で + 原子的 UPDATE(`credit_balance = credit_balance - :cost`)により更新する +- `agent_usage_logs`: モデル別の実トークン数・コストを記録(無料モデル含む) + +### 4. 決済は Stripe Checkout(Phase 2) + +- Stripe Checkout Session(`mode="payment"`)でクレジットパックを販売する。 + カード情報は Stripe ホストの決済ページで入力され、自サーバーは一切扱わない + (PCI DSS 対応不要) +- **入金確定は Webhook(`checkout.session.completed`)が正**。リダイレクト戻り + (success_url)はユーザーがブラウザを閉じると発生しないため、付与処理に使わない +- Webhook は `Stripe-Signature` の署名検証必須。冪等性は + `credit_transactions.stripe_session_id` の UNIQUE 制約で担保する(同一イベント再送でも + 二重付与しない) +- パック定義(価格・クレジット数)はコード(`services/billing/pricing.py`)を SSoT とし、 + Checkout には `price_data` で動的に渡す(Stripe ダッシュボードの Price オブジェクトに + 依存しない) + +### 5. リリースは 2 フェーズ + +- **Phase 1**: モデル切り替え + 使用量記録 + クレジット基盤(残高は ADMIN_TOKEN 認証の + 付与エンドポイントでテスト) +- **Phase 2**: Stripe Checkout 購入フロー + Webhook 入金処理 + 課金ページ + +## 代替案 + +- **月額サブスクリプション(Stripe Billing)**: UX はシンプルだが、更新・解約・支払い失敗 + リトライ(dunning)の運用が必要で初導入には重い。使い放題は原価リスクも大きい +- **従量後払い(Stripe Metered Billing)**: 最も柔軟だが未払いリスクと使用量レポートの + 正確性担保が必要で、実装・運用難度が最も高い。プリペイドは「残高 + 1 リクエスト分の + 有界な負残高(max_tokens=4096 ≈ 1,200 クレジット)」までしか使われないことが構造的に + 保証される(負残高許容は上記「負残高を許容する」と同方針) +- **予約方式(reserve-then-settle)**: 事前に最大コストを引き当てて事後精算する方式。 + 二重引当の解放漏れ等の障害モードが増えるため、負残高許容(有界損失)を選んだ +- **残高をテーブル分離(balances テーブル)**: 1 ユーザー 1 行のため `users` カラムで十分 + +## トレードオフ・既知のリスク + +- 負残高: 最大 1 リクエスト分(≈$0.12)の取りはぐれを許容する +- USD ペッグのクレジットを円建てパックで販売するため、為替変動はパック価格定数の + 調整で吸収する(自動連動しない) +- 返金・チャージバックはスコープ外(Stripe ダッシュボードで手動対応 + 管理者付与で + 残高調整) +- 消費記録失敗を 500 にする方針は「LLM 原価は発生したのに応答を捨てる」ケースを生むが、 + 頻度は低く(DB 障害時のみ)、課金漏れの常態化より安全側 +- Ollama(ローカル開発)は無料扱い・トークン数 0 で記録される。モデルエイリアスは + anthropic プロバイダのときのみ実質的な意味を持つ + +## 将来の移行条件 + +- Opus 等の第 3 のモデル追加: `model_catalog.py` への 1 エントリ追加 + スキーマの + Literal 拡張で対応できる設計とする +- 利用が増え同時実行が問題になったら(マルチインスタンス化)、残高更新を + 楽観ロックまたは台帳集計方式へ移行する +- 法人利用等でポストペイドが必要になったら Stripe Metered Billing を再検討する + +## 関連リンク + +- ADR-0005: Cloud Run Single Instance(単一インスタンス前提の原子的 UPDATE) +- ADR-0010: DevForge Agent(チャット契約・DB 非更新原則) +- `.claude/rules/backend/agent.md`(Agent 実装の不変条件) +- Stripe Checkout: https://docs.stripe.com/payments/checkout +- Stripe Webhook 署名検証: https://docs.stripe.com/webhooks#verify-official-libraries diff --git a/docs/api.md b/docs/api.md index 7cbe9050..ccff91de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -70,7 +70,15 @@ REST API エンドポイント一覧と、バックエンド/フロントエ - `POST /api/notifications/read-all`: 全て既読 ### Agent(LLM チャット / ADR-0010) -- `POST /api/agent/chat`: 選択スコープ(`project` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min +- `POST /api/agent/chat`: 選択スコープ(`project` / `experience` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min。`model`(`haiku`: 無料 / `sonnet`: 有料・クレジット消費)を指定可能。sonnet で残高不足の場合は 402 `INSUFFICIENT_CREDITS`(ADR-0012) + +### 課金(プリペイドクレジット / ADR-0012) +- `GET /api/billing/balance`: ログインユーザーのクレジット残高 +- `GET /api/billing/transactions`: クレジット台帳履歴(付与・消費、新しい順) +- `GET /api/billing/packs`: 購入可能なクレジットパック一覧(`pricing.py` が正本) +- `GET /api/billing/model-rates`: モデル別の標準消費レート(回数目安用) +- `GET /api/billing/usage-summary`: ログインユーザーのモデル別利用実績サマリ +- `POST /api/billing/admin/grant`: ユーザーへのクレジット付与(管理者・`ADMIN_TOKEN` Bearer 認証) ### 内部 API(Cloud Tasks コールバック専用) - `POST /internal/tasks/{task_type}`: Cloud Tasks からのタスク実行リクエストを受け付ける。`TASK_RUNNER=cloud_tasks` の場合は `X-CloudTasks-QueueName` ヘッダで検証 diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts index 9d8ce795..104eb195 100644 --- a/frontend/e2e/agent-chat.spec.ts +++ b/frontend/e2e/agent-chat.spec.ts @@ -38,6 +38,15 @@ async function setupResumeApi(page: Page) { ); } +/** UserMenu → モデル選択モーダル経由で使用モデルを切り替える(ADR-0012)。 */ +async function selectModel(page: Page, modelName: "Haiku" | "Sonnet") { + await page.getByRole("button", { name: "e2e-test-user" }).click(); + await page.getByRole("button", { name: "AI モデルを切り替え" }).click(); + const dialog = page.getByRole("dialog"); + // modelName はリテラル union("Haiku" | "Sonnet")。部分一致で対象ボタンを特定する + await dialog.getByRole("button", { name: modelName, exact: false }).click(); +} + test.describe("Agent チャットウィジェット", () => { test.beforeEach(async ({ page }) => { await setupAuth(page); @@ -92,6 +101,83 @@ test.describe("Agent チャットウィジェット", () => { await expect(page.getByRole("button", { name: "フォームに反映" })).toHaveCount(0); }); + test("使用モデルはサイドバーに表示され、UserMenu のモーダルで Sonnet に切り替えられる", async ({ + page, + }) => { + // サイドバーの残高(ADR-0012)。setupAuth のデフォルトより後に登録して上書きする + await page.route("**/api/billing/balance", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ balance: 12000 }), + }), + ); + let chatRequestBody: Record | null = null; + await page.route("**/api/agent/chat", async (route) => { + chatRequestBody = JSON.parse(route.request().postData() ?? "{}"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ message: "高精度モデルの提案です。", operations: [] }), + }); + }); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + const sidebar = page.locator("aside").first(); + // 使用モデル・残高はサイドバーに常時表示される(既定は Haiku) + await expect(sidebar.getByText("使用モデル")).toBeVisible(); + await expect(sidebar.getByText("Haiku")).toBeVisible(); + await expect(sidebar.getByText("クレジット残高")).toBeVisible(); + await expect(sidebar.getByText("12,000")).toBeVisible(); + + // UserMenu → モデル選択モーダル → Sonnet カードで切り替え + // (残高バッジの「Sonnet 約N回」と区別するため使用モデル値を厳密一致で確認) + await selectModel(page, "Sonnet"); + await expect(sidebar.getByText("Sonnet", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "devforge Agent" }).click(); + await page + .getByPlaceholder("例: 成果がより伝わる文章にしてください") + .fill("プロらしい文章にして"); + await page.getByRole("button", { name: "送信", exact: true }).click(); + + await expect(page.getByText("高精度モデルの提案です。")).toBeVisible(); + expect(chatRequestBody).toMatchObject({ model: "sonnet" }); + }); + + test("Sonnet で残高不足(402)はエラートーストで通知される", async ({ page }) => { + await page.route("**/api/agent/chat", (route) => + route.fulfill({ + status: 402, + contentType: "application/json", + body: JSON.stringify({ + code: "INSUFFICIENT_CREDITS", + message: + "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。", + }), + }), + ); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 残高 0(setupAuth の既定)で Sonnet に切り替えてから送信 + await selectModel(page, "Sonnet"); + await page.getByRole("button", { name: "devforge Agent" }).click(); + await page + .getByPlaceholder("例: 成果がより伝わる文章にしてください") + .fill("改善して"); + await page.getByRole("button", { name: "送信", exact: true }).click(); + + await expect( + page.getByText( + "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。", + ), + ).toBeVisible(); + }); + test("LLM 失敗(502)はエラートーストで通知される", async ({ page }) => { await page.route("**/api/agent/chat", (route) => route.fulfill({ diff --git a/frontend/e2e/billing.spec.ts b/frontend/e2e/billing.spec.ts new file mode 100644 index 00000000..b6bacaf8 --- /dev/null +++ b/frontend/e2e/billing.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Page } from "@playwright/test"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * トークン購入画面(ADR-0012)E2E。 + * UserMenu の「クレジットを購入」→ /billing。残高・パック・履歴が表示され、 + * 購入ボタン押下で準備中トーストが出る(Stripe 連携は Phase 2)。 + */ + +async function setupBillingApi(page: Page) { + await page.route("**/api/billing/balance", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ balance: 12000 }), + }), + ); + await page.route("**/api/billing/packs", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: "starter", name: "スターター", price_jpy: 500, credits: 500 }, + { id: "standard", name: "スタンダード", price_jpy: 1000, credits: 1100 }, + { id: "pro", name: "プロ", price_jpy: 3000, credits: 3500 }, + ]), + }), + ); + await page.route("**/api/billing/transactions", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: "t1", + amount: -270, + balance_after: 12000, + transaction_type: "consumption", + description: "Agent チャット(sonnet)", + created_at: "2026-06-13T02:00:00Z", + }, + { + id: "t2", + amount: 30000, + balance_after: 12270, + transaction_type: "admin_grant", + description: "テスト付与", + created_at: "2026-06-12T09:00:00Z", + }, + ]), + }), + ); +} + +test("UserMenu からトークン購入画面を開き、残高・パック・履歴が表示される", async ({ page }) => { + await setupAuth(page); + await setupBillingApi(page); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + await page.getByRole("button", { name: "e2e-test-user" }).click(); + await page.getByRole("button", { name: "クレジットを購入" }).click(); + + await expect(page).toHaveURL(/\/billing$/); + // 残高(サイドバーの残高バッジと区別するため main 内に限定) + const main = page.locator("main"); + await expect(main.getByText("現在の残高")).toBeVisible(); + await expect(main.getByText("12,000")).toBeVisible(); + // 履歴 + await expect(main.getByText("Agent チャット(sonnet)")).toBeVisible(); + + // 任意クレジット入力 → onChange でリアルタイム円換算 + 回数(残高12,000/Sonnet標準12 → ) + const amountInput = page.getByLabel("購入するクレジット数"); + await amountInput.fill("2000"); + await expect(main.getByText("¥2,000")).toBeVisible(); + await expect(main.getByText(/Sonnet 約166回/)).toBeVisible(); + + // preset で入力欄が埋まる + await page.getByRole("button", { name: "1,100" }).click(); + await expect(main.getByText("¥1,100")).toBeVisible(); + + // 購入ボタン → 準備中トースト(Stripe は Phase 2) + await page.getByRole("button", { name: "購入する" }).click(); + await expect( + page.getByText("クレジット購入(Stripe 決済)は現在準備中です。"), + ).toBeVisible(); +}); diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts index 7d9ce6c3..05e38c90 100644 --- a/frontend/e2e/helpers/auth.ts +++ b/frontend/e2e/helpers/auth.ts @@ -45,6 +45,36 @@ export async function setupAuth(page: Page) { }), ); + // クレジット残高をモック(サイドバーが認証済み全ページで取得する / ADR-0012。デフォルト: 0) + await page.route("**/api/billing/balance", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ balance: 0 }), + }), + ); + + // モデル別使用量サマリをモック(モデル選択モーダルが開くと取得する / ADR-0012。デフォルト: 空) + await page.route("**/api/billing/usage-summary", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }), + ); + + // モデル別の標準消費レートをモック(サイドバー残高バッジ・モーダルが取得する / ADR-0012) + await page.route("**/api/billing/model-rates", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { model: "haiku", is_free: true, baseline_credits_per_chat: 0 }, + { model: "sonnet", is_free: false, baseline_credits_per_chat: 12 }, + ]), + }), + ); + // 認証 API をモック(最高優先) await page.route("**/auth/me", (route) => route.fulfill({ diff --git a/frontend/scripts/audit-check.mjs b/frontend/scripts/audit-check.mjs new file mode 100644 index 00000000..6d64fb16 --- /dev/null +++ b/frontend/scripts/audit-check.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// npm audit のラッパー。`--audit-level=high` 相当のゲートを維持しつつ、 +// 明示的に allowlist した advisory (GHSA) だけを「許容済み」として除外する。 +// +// 背景: +// 素の `npm audit --audit-level=high` は「lockfile に脆弱バージョンが存在するか」だけを +// 見ており、実際に攻撃面へ到達可能かは区別しない。下記 2 件はいずれも esbuild の +// dev 専用 advisory で、本番バンドルにも CI(Linux) ランタイムにも到達しない。 +// かつ修正版 (esbuild 0.28.1) は vite 6 のビルドを壊し、回避には vite 8 (Rolldown 移行) +// が必要 / wrangler には修正版が存在しない、という事情がある。 +// そのため allowlist で時限的に許容し、恒久対応 (vite 8 移行) は別 PR で追う。 +// +// 注意: +// ここに無い High / Critical advisory が新たに出た場合は従来どおり CI を落とす。 +// allowlist は「個別の GHSA を理由付きで明示許容する」ためだけに使う。 +// `--force` での一括無視はしない(.claude/rules/security.md)。 + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const frontendDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// 許容する advisory。GHSA ID をキーに、理由と見直し期限を残す。 +// 恒久対応 (vite 8 / Rolldown 移行) 完了時にエントリを削除すること。 +const ALLOWLIST = { + "GHSA-gv7w-rqvm-qjhr": { + reason: + "esbuild の Deno モジュール install-time RCE (NPM_CONFIG_REGISTRY 経由)。" + + "DevForge は npm(Linux) で導入し Deno 不使用のため到達不能。dev 依存。", + reviewBy: "2026-09-30 (vite 8 / Rolldown 移行で esbuild 0.28.1 化を目指す)", + }, + "GHSA-g7r4-m6w7-qqqr": { + reason: + "esbuild 自身の dev サーバーを Windows で起動した際の任意ファイル読み取り。" + + "dev サーバーは Vite(Linux)、esbuild serve は未使用のため到達不能。dev 依存。", + reviewBy: "2026-09-30 (vite 8 / Rolldown 移行で esbuild 0.28.1 化を目指す)", + }, +}; + +const BLOCKING_SEVERITIES = new Set(["high", "critical"]); + +// `npm audit --json` は脆弱性検出時に exit code !=0 を返すため、例外から stdout を回収する。 +// registry/auth/lockfile 等の実行失敗時も JSON を stdout に書き出し、その中に error を含める。 +function runAudit() { + try { + return execFileSync("npm", ["audit", "--json"], { + cwd: frontendDir, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + } catch (err) { + // JSON が stdout に出ていれば(脆弱性検出 or audit 失敗)回収する。 + // stdout が空=JSON すら出ない致命的クラッシュは本物のエラーとして投げる。 + if (typeof err.stdout === "string" && err.stdout.length > 0) return err.stdout; + throw err; + } +} + +function ghsaIdFromUrl(url) { + // 例: https://github.com/advisories/GHSA-gv7w-rqvm-qjhr + const m = /\/(GHSA-[\w-]+)\b/.exec(url ?? ""); + return m ? m[1] : null; +} + +const audit = JSON.parse(runAudit()); + +// npm audit 自体が registry/auth/lockfile 等で失敗した場合は JSON に error が乗る。 +// これを成功扱い(脆弱性ゼロ)にすると fail-open になるため、必ず CI を落とす。 +if (audit.error) { + console.error( + "npm audit の実行に失敗しました:", + audit.error.summary ?? audit.error.code ?? audit.error.message ?? audit.error, + ); + process.exit(1); +} + +// 各パッケージの via から「advisory 本体オブジェクト」を集約し、GHSA 単位で重複排除する。 +const advisories = new Map(); // ghsaId -> { id, title, severity, url } +for (const vuln of Object.values(audit.vulnerabilities ?? {})) { + for (const via of vuln.via ?? []) { + if (typeof via !== "object") continue; // 文字列 via は別パッケージへの参照なのでスキップ + const id = ghsaIdFromUrl(via.url); + if (!id) continue; + if (!advisories.has(id)) { + advisories.set(id, { + id, + title: via.title ?? "(no title)", + severity: via.severity ?? "unknown", + url: via.url ?? "", + }); + } + } +} + +const blocking = []; +const allowed = []; +for (const adv of advisories.values()) { + if (!BLOCKING_SEVERITIES.has(adv.severity)) continue; // high/critical のみゲート対象 + if (ALLOWLIST[adv.id]) allowed.push(adv); + else blocking.push(adv); +} + +if (allowed.length > 0) { + console.log("許容済み advisory (allowlist):"); + for (const adv of allowed) { + const meta = ALLOWLIST[adv.id]; + console.log(` - ${adv.id} [${adv.severity}] ${adv.title}`); + console.log(` 理由: ${meta.reason}`); + console.log(` 見直し: ${meta.reviewBy}`); + } +} + +if (blocking.length > 0) { + console.error("\n未許容の High/Critical advisory を検出しました:"); + for (const adv of blocking) { + console.error(` - ${adv.id} [${adv.severity}] ${adv.title}`); + console.error(` ${adv.url}`); + } + console.error( + "\nallowlist で無視せず、脆弱なパッケージを更新してください (.claude/rules/security.md)。", + ); + process.exit(1); +} + +console.log("\nHigh/Critical の未許容 advisory はありません。"); diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts new file mode 100644 index 00000000..e0017237 --- /dev/null +++ b/frontend/src/api/billing.ts @@ -0,0 +1,37 @@ +import { request } from "./client"; +import { PATHS } from "./paths"; +import type { + AgentUsageSummaryEntry, + CreditBalanceResponse, + CreditPackResponse, + CreditTransactionResponse, + ModelRateEntry, +} from "./types"; + +/** + * クレジット残高を取得する(ADR-0012)。 + * sonnet(有料モデル)選択時の残高表示と、チャット送信後の残高更新に使う。 + */ +export function getCreditBalance(): Promise { + return request(PATHS.billing.balance, { method: "GET" }); +} + +/** クレジット台帳履歴(付与・消費)を新しい順に取得する。 */ +export function getCreditTransactions(): Promise { + return request(PATHS.billing.transactions, { method: "GET" }); +} + +/** モデル別の使用量サマリ(チャット回数・トークン・消費クレジット)を取得する。 */ +export function getAgentUsageSummary(): Promise { + return request(PATHS.billing.usageSummary, { method: "GET" }); +} + +/** 購入可能なクレジットパック一覧を取得する。 */ +export function getCreditPacks(): Promise { + return request(PATHS.billing.packs, { method: "GET" }); +} + +/** モデル別の標準消費レート(回数目安用)を取得する。 */ +export function getModelRates(): Promise { + return request(PATHS.billing.modelRates, { method: "GET" }); +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 62b8574b..30cd2b9e 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -21,7 +21,8 @@ export interface paths { * @description 選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 * * career_summary / self_pr スコープでは GitHub・ブログ分析サマリーを参照情報として付与する。 - * レスポンスはフロントの state にのみ適用され、DB は更新しない。 + * レスポンスはフロントの state にのみ適用され、DB は更新しない + * (クレジット消費・使用ログの記録は除く / ADR-0012)。 * ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 */ post: operations["agent_chat_api_agent_chat_post"]; @@ -31,6 +32,137 @@ export interface paths { patch?: never; trace?: never; }; + "/api/billing/admin/grant": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Grant Credits + * @description 管理者がユーザーへクレジットを付与する(Phase 1 の残高調整・テスト用)。 + * + * ADMIN_TOKEN(Bearer)認証。Stripe 導入後も返金・補填時の残高調整用に残す。 + */ + post: operations["admin_grant_credits_api_billing_admin_grant_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/billing/balance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Credit Balance + * @description ログインユーザーのクレジット残高を返す。 + */ + get: operations["get_credit_balance_api_billing_balance_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/billing/model-rates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Model Rates + * @description モデル別の標準消費レート(回数目安の算出用 / ADR-0012)を返す。 + * + * フロントは残高・パック・モデルカードを「Sonnet 約N回」に換算するのに使う。 + * 利用実績のあるユーザーは usage-summary の実測平均を優先し、本値は新規ユーザーの + * フォールバックとして使う。 + */ + get: operations["list_model_rates_api_billing_model_rates_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/billing/packs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Credit Packs + * @description 購入可能なクレジットパック一覧を返す(トークン購入画面用 / ADR-0012)。 + * + * 価格・付与クレジットの正本は services/billing/pricing.py。 + */ + get: operations["list_credit_packs_api_billing_packs_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/billing/transactions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Credit Transactions + * @description クレジット台帳履歴(付与・消費)を新しい順に返す。 + */ + get: operations["list_credit_transactions_api_billing_transactions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/billing/usage-summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Usage Summary + * @description モデル別の使用量サマリ(チャット回数・トークン・消費クレジット)を返す。 + * + * モデル選択モーダルで「あなたの利用実績」を表示するために使う。残りチャット回数の + * 目安は残高と組み合わせてフロントで算出する。 + */ + get: operations["get_usage_summary_api_billing_usage_summary_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/blog/accounts": { parameters: { query?: never; @@ -718,6 +850,18 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * AdminCreditGrantRequest + * @description 管理者によるクレジット付与(Phase 1 の残高調整・テスト用)。 + */ + AdminCreditGrantRequest: { + /** Amount */ + amount: number; + /** Description */ + description?: string | null; + /** Username */ + username: string; + }; /** * AgentChatRequest * @description Agent チャットのリクエスト。スコープ選択は必須。 @@ -725,6 +869,12 @@ export interface components { AgentChatRequest: { /** History */ history?: components["schemas"]["AgentHistoryEntry"][]; + /** + * Model + * @default haiku + * @enum {string} + */ + model: "haiku" | "sonnet"; /** Prompt */ prompt: string; resume: components["schemas"]["AgentResumeContext"]; @@ -891,6 +1041,22 @@ export interface components { */ name: string; }; + /** + * AgentUsageSummaryEntry + * @description モデル別の使用量サマリ 1 件(モデル選択モーダルの利用実績表示用 / ADR-0012)。 + */ + AgentUsageSummaryEntry: { + /** Chat Count */ + chat_count: number; + /** Credit Cost */ + credit_cost: number; + /** Input Tokens */ + input_tokens: number; + /** Model */ + model: string; + /** Output Tokens */ + output_tokens: number; + }; /** * BlogAccountCreate * @description ブログ連携アカウントの作成リクエスト。 @@ -1175,6 +1341,49 @@ export interface components { */ level: number; }; + /** + * CreditBalanceResponse + * @description クレジット残高。 + */ + CreditBalanceResponse: { + /** Balance */ + balance: number; + }; + /** + * CreditPackResponse + * @description 購入可能なクレジットパック 1 種(トークン購入画面用 / ADR-0012)。 + */ + CreditPackResponse: { + /** Credits */ + credits: number; + /** Id */ + id: string; + /** Name */ + name: string; + /** Price Jpy */ + price_jpy: number; + }; + /** + * CreditTransactionResponse + * @description クレジット台帳エントリ 1 件(履歴表示用)。 + */ + CreditTransactionResponse: { + /** Amount */ + amount: number; + /** Balance After */ + balance_after: number; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Description */ + description?: string | null; + /** Id */ + id: string; + /** Transaction Type */ + transaction_type: string; + }; /** Experience */ "Experience-Input": { /** Business Description */ @@ -1392,6 +1601,21 @@ export interface components { */ sort_order: number; }; + /** + * ModelRateEntry + * @description モデル別の標準消費レート(回数目安の算出用 / ADR-0012)。 + * + * 1 クレジット = ¥1。``baseline_credits_per_chat`` は標準的な 1 回の消費の概算で、 + * フロントが残高・パックを「Sonnet 約N回」に換算するのに使う(無料モデルは 0)。 + */ + ModelRateEntry: { + /** Baseline Credits Per Chat */ + baseline_credits_per_chat: number; + /** Is Free */ + is_free: boolean; + /** Model */ + model: string; + }; /** * NotificationResponse * @description 通知レスポンス。 @@ -1803,6 +2027,141 @@ export interface operations { }; }; }; + admin_grant_credits_api_billing_admin_grant_post: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCreditGrantRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditBalanceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_credit_balance_api_billing_balance_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditBalanceResponse"]; + }; + }; + }; + }; + list_model_rates_api_billing_model_rates_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelRateEntry"][]; + }; + }; + }; + }; + list_credit_packs_api_billing_packs_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditPackResponse"][]; + }; + }; + }; + }; + list_credit_transactions_api_billing_transactions_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditTransactionResponse"][]; + }; + }; + }; + }; + get_usage_summary_api_billing_usage_summary_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentUsageSummaryEntry"][]; + }; + }; + }; + }; list_accounts_api_blog_accounts_get: { parameters: { query?: never; diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts index 3f3bff41..bbe8d6c6 100644 --- a/frontend/src/api/paths.ts +++ b/frontend/src/api/paths.ts @@ -30,6 +30,13 @@ export const PATHS = { agent: { chat: "/api/agent/chat", }, + billing: { + balance: "/api/billing/balance", + transactions: "/api/billing/transactions", + usageSummary: "/api/billing/usage-summary", + packs: "/api/billing/packs", + modelRates: "/api/billing/model-rates", + }, resumes: { base: "/api/resumes", latest: "/api/resumes/latest", diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 564a58ac..1e69e720 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -141,3 +141,23 @@ export type ProjectTarget = Schemas["ProjectTarget"]; /** experience スコープの対象指定。backend `schemas/agent.py:ExperienceTarget`。 */ export type ExperienceTarget = Schemas["ExperienceTarget"]; + +/** 選択可能な LLM モデルのエイリアス。backend `schemas/agent.py:AgentModelAlias`(ADR-0012)。 */ +export type AgentModelAlias = NonNullable; + +// ── 課金(billing.py / ADR-0012)───────────────────────────────────────── + +/** クレジット残高。backend `schemas/billing.py:CreditBalanceResponse`。 */ +export type CreditBalanceResponse = Schemas["CreditBalanceResponse"]; + +/** クレジット台帳エントリ。backend `schemas/billing.py:CreditTransactionResponse`。 */ +export type CreditTransactionResponse = Schemas["CreditTransactionResponse"]; + +/** モデル別の使用量サマリ。backend `schemas/billing.py:AgentUsageSummaryEntry`。 */ +export type AgentUsageSummaryEntry = Schemas["AgentUsageSummaryEntry"]; + +/** 購入可能なクレジットパック。backend `schemas/billing.py:CreditPackResponse`。 */ +export type CreditPackResponse = Schemas["CreditPackResponse"]; + +/** モデル別の標準消費レート(回数目安用)。backend `schemas/billing.py:ModelRateEntry`。 */ +export type ModelRateEntry = Schemas["ModelRateEntry"]; diff --git a/frontend/src/components/SidebarLayout.tsx b/frontend/src/components/SidebarLayout.tsx index 4f971c46..ff89b475 100644 --- a/frontend/src/components/SidebarLayout.tsx +++ b/frontend/src/components/SidebarLayout.tsx @@ -8,6 +8,10 @@ import type { Theme } from "../hooks/useTheme"; import { AUTH_PROMPT_MESSAGES, UI_MESSAGES } from "../constants/messages"; import { NotificationBell } from "./NotificationBell"; import { UserMenu } from "./UserMenu"; +import { AgentModelBadge } from "./agent/AgentModelBadge"; +import { ModelSelectModal } from "./agent/ModelSelectModal"; +import { CreditBalanceBadge } from "./billing/CreditBalanceBadge"; +import { CreditBalanceProvider } from "./billing/CreditBalanceProvider"; import { useLoginPrompt } from "./auth/loginPromptContext"; import { ChevronDownIcon } from "./icons/ChevronDownIcon"; import shared from "../styles/shared.module.css"; @@ -43,6 +47,8 @@ export function SidebarLayout({ const [includeForks, setIncludeForks] = useState(false); // サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。 const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // AI モデル選択モーダルの開閉(UserMenu から開く / ADR-0012)。 + const [modelSelectOpen, setModelSelectOpen] = useState(false); const isAuthenticated = user !== null; @@ -66,6 +72,9 @@ export function SidebarLayout({ return (
+ {/* 残高(アカウント属性)をサイドバーと Agent ウィジェットで共有する(ADR-0012)。 + 認証済みの間だけ取得し、ウィジェットの有料モデル消費後に refresh で更新される。 */} +
@@ -181,6 +190,9 @@ export function SidebarLayout({ )}
+ {/* AI ステータス(使用モデル + クレジット残高)は認証済みのみ表示。 */} + {isAuthenticated && } + {isAuthenticated && } {/* 通知ベルは認証済みのみ(未認証はポーリングで 401 を連発しないよう出さない)。 */} {isAuthenticated && } setModelSelectOpen(true) : undefined} + onOpenBilling={isAuthenticated ? () => navigate("/billing") : undefined} />
{children ?? }
+ {/* モデル選択モーダルは残高 Provider 配下(残高不足の警告表示に balance を使う)。 */} + {isAuthenticated && modelSelectOpen && ( + setModelSelectOpen(false)} /> + )} +
); } diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx index c9e8ee13..040656fa 100644 --- a/frontend/src/components/UserMenu.tsx +++ b/frontend/src/components/UserMenu.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { AUTH_PROMPT_MESSAGES, EXTERNAL_LINKS, UI_MESSAGES } from "../constants/messages"; +import { + AGENT_MODEL_MESSAGES, + AUTH_PROMPT_MESSAGES, + BILLING_PAGE_MESSAGES, + EXTERNAL_LINKS, + UI_MESSAGES, +} from "../constants/messages"; import type { Theme } from "../hooks/useTheme"; import { GitHubMarkIcon } from "./icons/GitHubMarkIcon"; import styles from "./UserMenu.module.css"; @@ -18,6 +24,8 @@ export function UserMenu({ onToggleTheme, onLogout, onLogin, + onOpenModelSelect, + onOpenBilling, }: { isAuthenticated: boolean; username: string | null; @@ -25,6 +33,10 @@ export function UserMenu({ onToggleTheme: () => void; onLogout: () => void; onLogin: () => void; + /** AI モデル選択モーダルを開く(認証済みのみ表示 / ADR-0012)。 */ + onOpenModelSelect?: () => void; + /** トークン購入画面へ遷移する(認証済みのみ表示。モデル切り替えとは分離 / ADR-0012)。 */ + onOpenBilling?: () => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -53,6 +65,35 @@ export function UserMenu({
+ {/* AI モデル切り替えは認証済みのみ(モデル選択はログインユーザーの設定 / ADR-0012)。 */} + {isAuthenticated && onOpenModelSelect && ( + + )} + {/* トークン購入はモデル切り替えとは別項目(購入と切り替えを分離 / ADR-0012)。 */} + {isAuthenticated && onOpenBilling && ( + + )} + {(onOpenModelSelect || onOpenBilling) && isAuthenticated && ( +
+ )} {/* 未認証ではログインはトリガー側のボタンに集約するため、メニューには出さない。 */} {isAuthenticated && ( <> diff --git a/frontend/src/components/agent/AgentModelBadge.module.css b/frontend/src/components/agent/AgentModelBadge.module.css new file mode 100644 index 00000000..8e19d1e0 --- /dev/null +++ b/frontend/src/components/agent/AgentModelBadge.module.css @@ -0,0 +1,27 @@ +.badge { + display: flex; + align-items: center; + gap: 6px; + padding: 0.4rem 0.75rem; + font-size: 0.78rem; + color: var(--text-secondary); +} + +.label { + color: var(--text-secondary); +} + +.value { + font-weight: 600; + color: var(--text-primary); +} + +.paid { + margin-left: auto; + padding: 0.05rem 0.4rem; + font-size: 0.68rem; + font-weight: 600; + color: var(--accent); + border: 1px solid var(--accent); + border-radius: 999px; +} diff --git a/frontend/src/components/agent/AgentModelBadge.tsx b/frontend/src/components/agent/AgentModelBadge.tsx new file mode 100644 index 00000000..76da88ef --- /dev/null +++ b/frontend/src/components/agent/AgentModelBadge.tsx @@ -0,0 +1,21 @@ +import { getModelOption } from "../../constants/agentModels"; +import { AGENT_MODEL_MESSAGES } from "../../constants/messages"; +import { useAppSelector } from "../../store"; +import styles from "./AgentModelBadge.module.css"; + +/** + * サイドバーに常時表示する「使用モデル」(ADR-0012)。表示専用。 + * 切り替えは UserMenu → モデル選択モーダルで行う(ここからは切り替えない)。 + */ +export function AgentModelBadge() { + const model = useAppSelector((state) => state.agentModel.model); + const option = getModelOption(model); + + return ( +
+ {AGENT_MODEL_MESSAGES.SIDEBAR_LABEL} + {option.name} + {option.isPaid && {AGENT_MODEL_MESSAGES.PAID_BADGE}} +
+ ); +} diff --git a/frontend/src/components/agent/ModelSelectModal.module.css b/frontend/src/components/agent/ModelSelectModal.module.css new file mode 100644 index 00000000..28543a05 --- /dev/null +++ b/frontend/src/components/agent/ModelSelectModal.module.css @@ -0,0 +1,139 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.45); +} + +.dialog { + width: 100%; + max-width: 460px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.25rem; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.title { + margin: 0; + font-size: 1.05rem; + color: var(--text-primary); +} + +.close { + background: none; + border: none; + font-size: 1.4rem; + line-height: 1; + color: var(--text-secondary); + cursor: pointer; +} + +.description { + margin: 0.5rem 0 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.cards { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.card { + display: block; + width: 100%; + text-align: left; + background: var(--bg-input); + border: 1px solid var(--border-input); + border-radius: 10px; + padding: 0.9rem 1rem; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.card:hover { + border-color: var(--accent); +} + +.cardCurrent { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; +} + +.cardHead { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cardName { + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); +} + +.freeBadge, +.paidBadge { + padding: 0.05rem 0.45rem; + font-size: 0.68rem; + font-weight: 600; + border-radius: 999px; +} + +.freeBadge { + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.paidBadge { + color: var(--accent); + border: 1px solid var(--accent); +} + +.currentBadge { + margin-left: auto; + padding: 0.05rem 0.45rem; + font-size: 0.68rem; + font-weight: 600; + color: #fff; + background: var(--accent); + border-radius: 999px; +} + +.cardTagline { + margin: 0.45rem 0 0.25rem; + font-size: 0.85rem; + color: var(--text-primary); +} + +.cardCost { + margin: 0; + font-size: 0.78rem; + color: var(--text-secondary); +} + +.cardUsage { + margin: 0.35rem 0 0; + font-size: 0.75rem; + color: var(--text-secondary); + opacity: 0.85; +} + +.insufficient { + margin: 0.5rem 0 0; + font-size: 0.78rem; + color: var(--warning-text); +} diff --git a/frontend/src/components/agent/ModelSelectModal.test.tsx b/frontend/src/components/agent/ModelSelectModal.test.tsx new file mode 100644 index 00000000..039f7125 --- /dev/null +++ b/frontend/src/components/agent/ModelSelectModal.test.tsx @@ -0,0 +1,167 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AGENT_MODEL_OPTIONS, CREDIT_ESTIMATE_REFERENCE } from "../../constants/agentModels"; +import { + AGENT_MODEL_MESSAGES, + creditsForChatsLabel, + modelUsageLabel, +} from "../../constants/messages"; +import agentModelReducer from "../../store/agentModelSlice"; +import { + CreditBalanceContext, + type CreditBalanceContextValue, +} from "../billing/creditBalanceContext"; +import { ModelSelectModal } from "./ModelSelectModal"; + +// 表示文言は SSoT(constants/messages・agentModels)から引き、テストとメッセージ正本の乖離を防ぐ。 +const HAIKU_NAME = AGENT_MODEL_OPTIONS.find((option) => option.alias === "haiku")!.name; +const SONNET_NAME = AGENT_MODEL_OPTIONS.find((option) => option.alias === "sonnet")!.name; +const haikuNamePattern = new RegExp(`^${HAIKU_NAME}`); +const sonnetNamePattern = new RegExp(`^${SONNET_NAME}`); + +const getAgentUsageSummaryMock = vi.fn(); +const getModelRatesMock = vi.fn(); + +vi.mock("../../api/billing", () => ({ + getAgentUsageSummary: (...args: unknown[]) => getAgentUsageSummaryMock(...args), + getModelRates: (...args: unknown[]) => getModelRatesMock(...args), +})); + +const DEFAULT_RATES = [ + { model: "haiku", is_free: true, baseline_credits_per_chat: 0 }, + { model: "sonnet", is_free: false, baseline_credits_per_chat: 12 }, +]; + +type Store = ReturnType; + +function makeStore(model: "haiku" | "sonnet" = "haiku") { + return configureStore({ + reducer: { agentModel: agentModelReducer }, + preloadedState: { agentModel: { model } }, + }); +} + +function renderModal(opts: { + store?: Store; + balance?: number | null; + onClose?: () => void; + usage?: { model: string; chat_count: number; input_tokens: number; output_tokens: number; credit_cost: number }[]; +}) { + getAgentUsageSummaryMock.mockResolvedValue(opts.usage ?? []); + getModelRatesMock.mockResolvedValue(DEFAULT_RATES); + const store = opts.store ?? makeStore(); + const balanceValue: CreditBalanceContextValue = { + balance: opts.balance ?? null, + loading: false, + error: null, + refresh: vi.fn(), + }; + const wrapper = (ui: ReactNode) => ( + + {ui} + + ); + render(wrapper()); + return store; +} + +beforeEach(() => { + getAgentUsageSummaryMock.mockReset(); + getAgentUsageSummaryMock.mockResolvedValue([]); + getModelRatesMock.mockReset(); + getModelRatesMock.mockResolvedValue(DEFAULT_RATES); +}); + +describe("ModelSelectModal", () => { + it("Haiku / Sonnet のカードが表示される", () => { + renderModal({}); + expect(screen.getByRole("button", { name: haikuNamePattern })).toBeTruthy(); + expect(screen.getByRole("button", { name: sonnetNamePattern })).toBeTruthy(); + }); + + it("現在選択中のモデルカードに『選択中』が付く", () => { + renderModal({ store: makeStore("sonnet") }); + const sonnetCard = screen.getByRole("button", { name: sonnetNamePattern }); + expect(within(sonnetCard).getByText(AGENT_MODEL_MESSAGES.CURRENT_BADGE)).toBeTruthy(); + }); + + it("カードを押すとグローバル設定が切り替わり、モーダルが閉じる", () => { + const onClose = vi.fn(); + const store = renderModal({ onClose }); + + fireEvent.click(screen.getByRole("button", { name: sonnetNamePattern })); + + expect(store.getState().agentModel.model).toBe("sonnet"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("残高 0 で Sonnet カードに残高不足の警告を出す(選択自体は許可)", () => { + renderModal({ balance: 0 }); + const sonnetCard = screen.getByRole("button", { name: sonnetNamePattern }); + expect(within(sonnetCard).getByText(AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT)).toBeTruthy(); + // 無料カードには警告を出さない + const haikuCard = screen.getByRole("button", { name: haikuNamePattern }); + expect(within(haikuCard).queryByText(AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT)).toBeNull(); + }); + + it("残高が十分なら Sonnet カードに警告を出さない", () => { + renderModal({ balance: 5000 }); + const sonnetCard = screen.getByRole("button", { name: sonnetNamePattern }); + expect(within(sonnetCard).queryByText(AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT)).toBeNull(); + }); + + it("残高未取得(null)の間は Sonnet カードに残高不足の警告を出さない", () => { + renderModal({ balance: null }); + const sonnetCard = screen.getByRole("button", { name: sonnetNamePattern }); + expect(within(sonnetCard).queryByText(AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT)).toBeNull(); + }); + + it("利用実績があると実測平均から『1,000クレジットで平均N回』を表示する", async () => { + renderModal({ + balance: 2700, + usage: [ + { model: "sonnet", chat_count: 4, input_tokens: 4000, output_tokens: 4000, credit_cost: 20 }, + ], + }); + + const sonnetCard = screen.getByRole("button", { name: sonnetNamePattern }); + // これまで 4 回・約 20 クレジット消費(実測平均 5/回) + await waitFor(() => { + expect(within(sonnetCard).getByText(modelUsageLabel(4, 20))).toBeTruthy(); + }); + // 平均 5/回 → 1,000 クレジットで平均 200 回(残高には依存しない) + expect( + within(sonnetCard).getByText(creditsForChatsLabel(CREDIT_ESTIMATE_REFERENCE, 200), { + exact: false, + }), + ).toBeTruthy(); + }); + + it("利用実績が無くてもベースラインレートで平均回数を出す", async () => { + // usage 空・rates baseline sonnet=12 → 1,000 / 12 = 83 + renderModal({ balance: 1000 }); + const sonnetCard = screen.getByRole("button", { name: sonnetNamePattern }); + await waitFor(() => { + expect( + within(sonnetCard).getByText(creditsForChatsLabel(CREDIT_ESTIMATE_REFERENCE, 83), { + exact: false, + }), + ).toBeTruthy(); + }); + // 利用実績のテキストは「まだ利用していません」 + expect(within(sonnetCard).getByText(AGENT_MODEL_MESSAGES.USAGE_NONE)).toBeTruthy(); + }); + + it("無料モデル(Haiku)には回数目安を出さない", async () => { + renderModal({ balance: 1000 }); + const haikuCard = screen.getByRole("button", { name: haikuNamePattern }); + await waitFor(() => { + expect(within(haikuCard).getByText(AGENT_MODEL_MESSAGES.USAGE_NONE)).toBeTruthy(); + }); + expect(within(haikuCard).queryByText(/クレジットで平均/)).toBeNull(); + }); +}); diff --git a/frontend/src/components/agent/ModelSelectModal.tsx b/frontend/src/components/agent/ModelSelectModal.tsx new file mode 100644 index 00000000..e4f9b005 --- /dev/null +++ b/frontend/src/components/agent/ModelSelectModal.tsx @@ -0,0 +1,121 @@ +import type { AgentModelAlias } from "../../api/types"; +import { AGENT_MODEL_OPTIONS, CREDIT_ESTIMATE_REFERENCE } from "../../constants/agentModels"; +import { + AGENT_MODEL_MESSAGES, + creditsForChatsLabel, + modelUsageLabel, +} from "../../constants/messages"; +import { useAgentUsageSummary } from "../../hooks/useAgentUsageSummary"; +import { useModelRates } from "../../hooks/useModelRates"; +import { useAppDispatch, useAppSelector } from "../../store"; +import { setAgentModel } from "../../store/agentModelSlice"; +import { useCreditBalanceContext } from "../billing/creditBalanceContext"; +import styles from "./ModelSelectModal.module.css"; + +/** + * AI モデル選択モーダル(ADR-0012)。Claude のプラン選択のようなカード UI。 + * + * UserMenu から開く。カード選択で即時にグローバル設定(redux)へ反映して閉じる。 + * 有料(Sonnet)は残高不足のとき警告を出すが、選択自体は許可する + * (送信時に 402 で課金導線へ誘導する設計 / ADR-0012)。 + */ +export function ModelSelectModal({ onClose }: { onClose: () => void }) { + const dispatch = useAppDispatch(); + const currentModel = useAppSelector((state) => state.agentModel.model); + const { balance } = useCreditBalanceContext(); + // モーダルが開いている間だけ利用実績と標準レートを取得する + const { getUsage } = useAgentUsageSummary(true); + const { getBaselineRate } = useModelRates(true); + + const select = (alias: AgentModelAlias) => { + dispatch(setAgentModel(alias)); + onClose(); + }; + + /** 基準クレジット(1,000)あたりの平均利用回数の目安。残高には依存しない。 + * 1 回あたりの消費 = 実績があれば実測平均(累計消費 / 回数)、無ければ標準レート。 */ + const estimatePerReference = ( + alias: AgentModelAlias, + creditCost: number, + chatCount: number, + ): number | null => { + const perChat = + chatCount > 0 && creditCost > 0 ? creditCost / chatCount : getBaselineRate(alias); + if (!perChat || perChat <= 0) return null; + return Math.floor(CREDIT_ESTIMATE_REFERENCE / perChat); + }; + + return ( +
+
e.stopPropagation()} + > +
+

{AGENT_MODEL_MESSAGES.MODAL_TITLE}

+ +
+

{AGENT_MODEL_MESSAGES.MODAL_DESCRIPTION}

+
+ {AGENT_MODEL_OPTIONS.map((option) => { + const isCurrent = option.alias === currentModel; + // 残高未取得(null: 初期/ローディング/エラー)の間は不足扱いにしない + const insufficient = option.isPaid && balance !== null && balance <= 0; + const usage = getUsage(option.alias); + const chatCount = usage?.chat_count ?? 0; + const creditCost = usage?.credit_cost ?? 0; + const perReference = option.isPaid + ? estimatePerReference(option.alias, creditCost, chatCount) + : null; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/billing/BillingView.module.css b/frontend/src/components/billing/BillingView.module.css new file mode 100644 index 00000000..0b6ea2ca --- /dev/null +++ b/frontend/src/components/billing/BillingView.module.css @@ -0,0 +1,150 @@ +.page { + max-width: 880px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.title { + margin: 0; + font-size: 1.4rem; + color: var(--text-primary); +} + +.balanceCard { + display: flex; + flex-direction: column; + align-items: flex-end; + padding: 0.6rem 1.1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; +} + +.balanceLabel { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.balanceValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.balanceUnit { + margin-left: 0.3rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); +} + +.balanceEstimate { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.error { + color: var(--warning-text); + font-size: 0.85rem; +} + +.loading { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.section { + margin-top: 2rem; +} + +.sectionHead { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.sectionTitle { + margin: 0; + font-size: 1.05rem; + color: var(--text-primary); +} + +.preparingBadge { + padding: 0.1rem 0.5rem; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 999px; +} + +.sectionNote { + margin: 0.4rem 0 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.history { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.historyRow { + display: grid; + grid-template-columns: 4rem 1fr auto 6rem; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1rem; + font-size: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.historyRow:last-child { + border-bottom: none; +} + +.historyType { + font-weight: 600; + color: var(--text-secondary); +} + +.historyDesc { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyPlus { + font-weight: 700; + color: var(--accent); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.historyMinus { + font-weight: 700; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.historyDate { + color: var(--text-secondary); + text-align: right; + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/components/billing/BillingView.tsx b/frontend/src/components/billing/BillingView.tsx new file mode 100644 index 00000000..651bd547 --- /dev/null +++ b/frontend/src/components/billing/BillingView.tsx @@ -0,0 +1,89 @@ +import { getModelOption } from "../../constants/agentModels"; +import { + BILLING_PAGE_MESSAGES, + formatCreditAmount, + modelChatsEstimateLabel, + transactionAmountLabel, + transactionTypeLabel, +} from "../../constants/messages"; +import { useBillingPage } from "../../hooks/useBillingPage"; +import { estimateChats, PAID_REFERENCE_MODEL } from "../../utils/creditEstimate"; +import { useToast } from "../ui/toast"; +import { CreditPurchaseForm } from "./CreditPurchaseForm"; +import styles from "./BillingView.module.css"; + +const PAID_MODEL_NAME = getModelOption(PAID_REFERENCE_MODEL).name; + +/** + * トークン購入画面(ADR-0012)。残高・購入パック・取引履歴を表示する。 + * + * 購入導線(Stripe Checkout)は Phase 2 で実装する。現状はパックと購入ボタンを + * 表示し、押下時に準備中である旨を通知する(画面は確認できる状態に保つ)。 + */ +export function BillingView() { + const { balance, packs, transactions, paidRate, loading, error } = useBillingPage(); + const { showSuccess } = useToast(); + + // Phase 2 で onPurchase(credits) を受け、POST /api/billing/checkout → Stripe Checkout へ遷移させる + const handlePurchase = () => { + showSuccess(BILLING_PAGE_MESSAGES.CHECKOUT_PREPARING); + }; + + const balanceChats = balance !== null ? estimateChats(balance, paidRate) : null; + + return ( +
+
+

{BILLING_PAGE_MESSAGES.TITLE}

+
+ {BILLING_PAGE_MESSAGES.BALANCE_LABEL} + + {balance !== null ? formatCreditAmount(balance) : "—"} + {BILLING_PAGE_MESSAGES.CREDITS_UNIT} + + {balanceChats !== null && ( + + {modelChatsEstimateLabel(PAID_MODEL_NAME, balanceChats)} + + )} +
+
+ + {error &&

{error}

} + {loading &&

{BILLING_PAGE_MESSAGES.LOADING}

} + +
+
+

{BILLING_PAGE_MESSAGES.PACKS_TITLE}

+ {BILLING_PAGE_MESSAGES.PREPARING_BADGE} +
+

{BILLING_PAGE_MESSAGES.PACKS_NOTE}

+ +
+ +
+

{BILLING_PAGE_MESSAGES.HISTORY_TITLE}

+ {transactions.length === 0 ? ( +

{BILLING_PAGE_MESSAGES.HISTORY_EMPTY}

+ ) : ( +
    + {transactions.map((tx) => ( +
  • + {transactionTypeLabel(tx.transaction_type)} + {tx.description ?? ""} + + {transactionAmountLabel(tx.amount)} + + + {new Date(tx.created_at).toLocaleDateString("ja-JP")} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/billing/CreditBalanceBadge.module.css b/frontend/src/components/billing/CreditBalanceBadge.module.css new file mode 100644 index 00000000..1abb0ceb --- /dev/null +++ b/frontend/src/components/billing/CreditBalanceBadge.module.css @@ -0,0 +1,36 @@ +.badge { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0.4rem 0.75rem; + margin-bottom: 0.4rem; + font-size: 0.78rem; + color: var(--text-secondary); + border-bottom: 1px solid var(--sidebar-border); +} + +.row { + display: flex; + align-items: center; + gap: 6px; +} + +.label { + color: var(--text-secondary); +} + +.value { + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.estimate { + font-size: 0.72rem; + color: var(--text-secondary); + opacity: 0.8; +} + +.error { + color: var(--warning-text); +} diff --git a/frontend/src/components/billing/CreditBalanceBadge.tsx b/frontend/src/components/billing/CreditBalanceBadge.tsx new file mode 100644 index 00000000..1d610c05 --- /dev/null +++ b/frontend/src/components/billing/CreditBalanceBadge.tsx @@ -0,0 +1,44 @@ +import { getModelOption } from "../../constants/agentModels"; +import { + BILLING_MESSAGES, + formatCreditAmount, + modelChatsEstimateLabel, +} from "../../constants/messages"; +import { useModelRates } from "../../hooks/useModelRates"; +import { estimateChats, PAID_REFERENCE_MODEL } from "../../utils/creditEstimate"; +import { useCreditBalanceContext } from "./creditBalanceContext"; +import styles from "./CreditBalanceBadge.module.css"; + +/** + * サイドバーに常時表示するクレジット残高(ADR-0012)。1 クレジット = ¥1。 + * 残高に加えて「Sonnet 約N回」の回数目安を併記する(直感的な残量把握のため)。 + * 値の正本は CreditBalanceProvider(消費後はウィジェットが refresh して更新する)。 + */ +export function CreditBalanceBadge() { + const { balance, error } = useCreditBalanceContext(); + const { getBaselineRate } = useModelRates(true); + + const chats = + balance !== null ? estimateChats(balance, getBaselineRate(PAID_REFERENCE_MODEL)) : null; + const paidModelName = getModelOption(PAID_REFERENCE_MODEL).name; + + return ( +
+
+ {BILLING_MESSAGES.SIDEBAR_LABEL} + {balance !== null ? ( + {formatCreditAmount(balance)} + ) : ( + + {error ?? BILLING_MESSAGES.BALANCE_LOADING} + + )} +
+ {chats !== null && ( + + {modelChatsEstimateLabel(paidModelName, chats)} + + )} +
+ ); +} diff --git a/frontend/src/components/billing/CreditBalanceProvider.test.tsx b/frontend/src/components/billing/CreditBalanceProvider.test.tsx new file mode 100644 index 00000000..4f437d31 --- /dev/null +++ b/frontend/src/components/billing/CreditBalanceProvider.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CreditBalanceProvider } from "./CreditBalanceProvider"; +import { useCreditBalanceContext } from "./creditBalanceContext"; + +const getCreditBalanceMock = vi.fn(); + +vi.mock("../../api/billing", () => ({ + getCreditBalance: (...args: unknown[]) => getCreditBalanceMock(...args), +})); + +function BalanceConsumer() { + const { balance } = useCreditBalanceContext(); + return balance={balance === null ? "null" : balance}; +} + +beforeEach(() => { + getCreditBalanceMock.mockReset(); +}); + +describe("CreditBalanceProvider / useCreditBalanceContext", () => { + it("enabled=true で取得した残高を配下へ配布する", async () => { + getCreditBalanceMock.mockResolvedValue({ balance: 8500 }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("balance=8500")).toBeTruthy(); + }); + }); + + it("enabled=false(未認証)では取得せず balance は null のまま", () => { + getCreditBalanceMock.mockResolvedValue({ balance: 8500 }); + + render( + + + , + ); + + expect(getCreditBalanceMock).not.toHaveBeenCalled(); + expect(screen.getByText("balance=null")).toBeTruthy(); + }); + + it("Provider 外で useCreditBalanceContext を使うと例外(配線ミスを握りつぶさない)", () => { + // render 中の throw をコンソールへ吐かせないため一時的に握る + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + "useCreditBalanceContext must be used within a CreditBalanceProvider", + ); + spy.mockRestore(); + }); +}); diff --git a/frontend/src/components/billing/CreditBalanceProvider.tsx b/frontend/src/components/billing/CreditBalanceProvider.tsx new file mode 100644 index 00000000..ff96f321 --- /dev/null +++ b/frontend/src/components/billing/CreditBalanceProvider.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +import { useCreditBalance } from "../../hooks/useCreditBalance"; +import { CreditBalanceContext } from "./creditBalanceContext"; + +/** + * クレジット残高(ADR-0012)を 1 つだけ保持し、配下のどこからでも + * `useCreditBalanceContext()` で参照できるようにする Provider。 + * + * サイドバー(残高表示)と Agent ウィジェット(消費後の `refresh`)が同じ状態を共有する。 + * `enabled`(= 認証済み)の間だけ残高を取得する。未認証では取得しない(401 連発を防ぐ)。 + */ +export function CreditBalanceProvider({ + enabled, + children, +}: { + enabled: boolean; + children: ReactNode; +}) { + const state = useCreditBalance(enabled); + return ( + {children} + ); +} diff --git a/frontend/src/components/billing/CreditPurchaseForm.module.css b/frontend/src/components/billing/CreditPurchaseForm.module.css new file mode 100644 index 00000000..10d527d7 --- /dev/null +++ b/frontend/src/components/billing/CreditPurchaseForm.module.css @@ -0,0 +1,112 @@ +.form { + max-width: 420px; + padding: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; +} + +.label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.inputRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.input { + flex: 1; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-input); + border-radius: 8px; + background: var(--bg-input); + color: var(--text-primary); + font-size: 1.1rem; + font-variant-numeric: tabular-nums; +} + +.unit { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.conversion { + margin: 0.6rem 0 0; + font-size: 0.95rem; +} + +.yen { + font-size: 1.3rem; + font-weight: 700; + color: var(--accent); +} + +.chats { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.hint { + margin: 0.6rem 0 0; + font-size: 0.78rem; + color: var(--text-secondary); +} + +.presets { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 1rem; +} + +.presetsLabel { + font-size: 0.78rem; + color: var(--text-secondary); + margin-right: 0.2rem; +} + +.preset { + padding: 0.3rem 0.7rem; + font-size: 0.82rem; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--border-input); + border-radius: 999px; + cursor: pointer; + font-variant-numeric: tabular-nums; + transition: border-color 0.15s; +} + +.preset:hover { + border-color: var(--accent); +} + +.purchaseButton { + margin-top: 1.25rem; + width: 100%; + padding: 0.65rem 0.75rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.purchaseButton:hover:not(:disabled) { + opacity: 0.9; +} + +.purchaseButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/frontend/src/components/billing/CreditPurchaseForm.test.tsx b/frontend/src/components/billing/CreditPurchaseForm.test.tsx new file mode 100644 index 00000000..e9cbc731 --- /dev/null +++ b/frontend/src/components/billing/CreditPurchaseForm.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { CreditPackResponse } from "../../api/types"; +import { getModelOption } from "../../constants/agentModels"; +import { + BILLING_PAGE_MESSAGES, + formatCreditAmount, + formatYen, + modelChatsEstimateLabel, + purchaseRangeHint, +} from "../../constants/messages"; +import { + MAX_PURCHASE_CREDITS, + MIN_PURCHASE_CREDITS, + PAID_REFERENCE_MODEL, +} from "../../utils/creditEstimate"; +import { CreditPurchaseForm } from "./CreditPurchaseForm"; + +const PACKS: CreditPackResponse[] = [ + { id: "starter", name: "スターター", price_jpy: 500, credits: 500 }, + { id: "standard", name: "スタンダード", price_jpy: 1000, credits: 1100 }, +]; + +// 表示文言は SSoT(messages / agentModels)から取得し、リテラルを直書きしない +const PAID_MODEL_NAME = getModelOption(PAID_REFERENCE_MODEL).name; +const INPUT_LABEL = BILLING_PAGE_MESSAGES.INPUT_LABEL; +const PURCHASE_BUTTON = BILLING_PAGE_MESSAGES.PURCHASE_BUTTON; +const RANGE_HINT = purchaseRangeHint(MIN_PURCHASE_CREDITS, MAX_PURCHASE_CREDITS); + +function renderForm(paidRate: number | null = 12, onPurchase = vi.fn()) { + render(); + return { onPurchase }; +} + +describe("CreditPurchaseForm", () => { + it("入力に応じてリアルタイムで円換算と回数目安を表示する", () => { + renderForm(12); + const input = screen.getByLabelText(INPUT_LABEL); + + fireEvent.change(input, { target: { value: "1000" } }); + + // 1 クレジット = ¥1 + expect(screen.getByText(formatYen(1000))).toBeTruthy(); + // 1,000 / 12 = 83 回 + expect(screen.getByText(modelChatsEstimateLabel(PAID_MODEL_NAME, 83), { exact: false })).toBeTruthy(); + }); + + it("値を変えると円換算もその場で更新される", () => { + renderForm(12); + const input = screen.getByLabelText(INPUT_LABEL); + + fireEvent.change(input, { target: { value: "1000" } }); + expect(screen.getByText(formatYen(1000))).toBeTruthy(); + fireEvent.change(input, { target: { value: "2500" } }); + expect(screen.getByText(formatYen(2500))).toBeTruthy(); + expect(screen.queryByText(formatYen(1000))).toBeNull(); + }); + + it("未入力・範囲外は購入ボタンが無効で入力ヒントを表示する", () => { + renderForm(); + // 未入力 + expect(screen.getByRole("button", { name: PURCHASE_BUTTON })).toHaveProperty("disabled", true); + expect(screen.getByText(RANGE_HINT)).toBeTruthy(); + // 下限未満 + fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: "10" } }); + expect(screen.getByRole("button", { name: PURCHASE_BUTTON })).toHaveProperty("disabled", true); + }); + + it("preset を押すと入力欄が埋まり購入ボタンが有効になる", () => { + renderForm(); + fireEvent.click(screen.getByRole("button", { name: formatCreditAmount(1100) })); + + expect(screen.getByLabelText(INPUT_LABEL)).toHaveProperty("value", "1100"); + expect(screen.getByText(formatYen(1100))).toBeTruthy(); + expect(screen.getByRole("button", { name: PURCHASE_BUTTON })).toHaveProperty("disabled", false); + }); + + it("有効な値で購入ボタンを押すと入力クレジット数で onPurchase が呼ばれる", () => { + const { onPurchase } = renderForm(); + fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: "3000" } }); + fireEvent.click(screen.getByRole("button", { name: PURCHASE_BUTTON })); + + expect(onPurchase).toHaveBeenCalledWith(3000); + }); + + it("paidRate が null(回数算出不能)なら円換算のみ表示する", () => { + renderForm(null); + fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: "1000" } }); + + expect(screen.getByText(formatYen(1000))).toBeTruthy(); + // paidRate=null では回数目安が描画されない。SSoT ヘルパーが生成する文言(部分一致)が + // 不在であることで「Sonnet 約N回」が出ていないことを検証する + expect( + screen.queryByText(modelChatsEstimateLabel(PAID_MODEL_NAME, 83), { exact: false }), + ).toBeNull(); + }); +}); diff --git a/frontend/src/components/billing/CreditPurchaseForm.tsx b/frontend/src/components/billing/CreditPurchaseForm.tsx new file mode 100644 index 00000000..534a3efd --- /dev/null +++ b/frontend/src/components/billing/CreditPurchaseForm.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; + +import type { CreditPackResponse } from "../../api/types"; +import { getModelOption } from "../../constants/agentModels"; +import { + BILLING_PAGE_MESSAGES, + formatCreditAmount, + formatYen, + modelChatsEstimateLabel, + purchaseRangeHint, +} from "../../constants/messages"; +import { + creditsToYen, + estimateChats, + MAX_PURCHASE_CREDITS, + MIN_PURCHASE_CREDITS, + PAID_REFERENCE_MODEL, +} from "../../utils/creditEstimate"; +import styles from "./CreditPurchaseForm.module.css"; + +const PAID_MODEL_NAME = getModelOption(PAID_REFERENCE_MODEL).name; + +/** + * 任意クレジット数の購入フォーム(ADR-0012)。 + * + * 入力された数量を onChange でリアルタイムに円換算(1 クレジット = ¥1)し、 + * 「Sonnet 約N回」の回数目安も併せて表示する。パックはクイック選択の preset として使う。 + * 実際の決済(Stripe Checkout)は Phase 2。 + */ +export function CreditPurchaseForm({ + packs, + paidRate, + onPurchase, +}: { + packs: CreditPackResponse[]; + paidRate: number | null; + onPurchase: (credits: number) => void; +}) { + const [creditInput, setCreditInput] = useState(""); + + // 整数文字列のみ受理する。parseInt は "100.9" を 100 に切り詰めてしまい、 + // 誤入力が別の有効額として課金されるのを防ぐ(厳密パース) + const normalized = creditInput.trim(); + const parsed = Number(normalized); + const isValid = + /^\d+$/.test(normalized) && + Number.isSafeInteger(parsed) && + parsed >= MIN_PURCHASE_CREDITS && + parsed <= MAX_PURCHASE_CREDITS; + const yen = isValid ? creditsToYen(parsed) : null; + const chats = isValid ? estimateChats(parsed, paidRate) : null; + + return ( +
+ +
+ setCreditInput(e.target.value)} + placeholder={BILLING_PAGE_MESSAGES.INPUT_PLACEHOLDER} + /> + {BILLING_PAGE_MESSAGES.CREDITS_UNIT} +
+ + {yen !== null ? ( +

+ {formatYen(yen)} + {chats !== null && ( + + ・{modelChatsEstimateLabel(PAID_MODEL_NAME, chats)} + + )} +

+ ) : ( +

+ {purchaseRangeHint(MIN_PURCHASE_CREDITS, MAX_PURCHASE_CREDITS)} +

+ )} + +
+ {BILLING_PAGE_MESSAGES.PRESETS_LABEL} + {packs.map((pack) => ( + + ))} +
+ + +
+ ); +} diff --git a/frontend/src/components/billing/creditBalanceContext.ts b/frontend/src/components/billing/creditBalanceContext.ts new file mode 100644 index 00000000..fe8e5259 --- /dev/null +++ b/frontend/src/components/billing/creditBalanceContext.ts @@ -0,0 +1,29 @@ +import { createContext, useContext } from "react"; + +/** + * クレジット残高(ADR-0012)をアプリ横断で配布するコンテキスト。 + * + * 残高は「Agent の機能」ではなく「アカウントの属性」のため、サイドバー(常時表示)と + * Agent ウィジェット(消費後の再取得)の双方が同じ 1 つの状態を共有する。 + * Provider 本体は {@link ./CreditBalanceProvider} を参照。 + */ +export type CreditBalanceContextValue = { + /** 現在残高。未取得・無効化中は null。 */ + balance: number | null; + loading: boolean; + error: string | null; + /** 最新残高を取り直す(有料モデル消費後に呼ぶ)。 */ + refresh: () => Promise; +}; + +export const CreditBalanceContext = createContext(null); + +/** クレジット残高の状態を取得する。Provider 配下でのみ利用できる。 */ +export function useCreditBalanceContext(): CreditBalanceContextValue { + const ctx = useContext(CreditBalanceContext); + if (!ctx) { + // 配線ミスを握りつぶさず即座に気付けるようにする(開発者向け内部エラー)。 + throw new Error("useCreditBalanceContext must be used within a CreditBalanceProvider"); + } + return ctx; +} diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx index 36a6ebf3..0282cbef 100644 --- a/frontend/src/components/forms/AgentChatWidget.tsx +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -12,6 +12,8 @@ import { useCallback, useMemo, useState } from "react"; import type { ExperienceTarget, ProjectTarget } from "../../api/types"; import { AGENT_MESSAGES } from "../../constants/messages"; import { useAgentChat, type AgentChatEntry } from "../../hooks/career/useAgentChat"; +import { useAppSelector } from "../../store"; +import { useCreditBalanceContext } from "../billing/creditBalanceContext"; import type { CareerFormState } from "../../payloadBuilders"; import { useMessageToast, useToast } from "../ui/toast"; import { applyAgentOperations, type AgentScope } from "../../utils/agentOperations"; @@ -96,12 +98,18 @@ function buildExperienceOptions(form: CareerFormState): ExperienceOption[] { export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }: Props) { const [open, setOpen] = useState(false); const [scope, setScope] = useState("career_summary"); + // 使用モデルはグローバル設定(サイドバー表示 / UserMenu で切り替え)。ここでは読むだけ + const model = useAppSelector((state) => state.agentModel.model); const [projectTargetIndex, setProjectTargetIndex] = useState(0); const [experienceTargetIndex, setExperienceTargetIndex] = useState(0); const [prompt, setPrompt] = useState(""); /** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */ const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null); const { entries, sending, error, send, markApplied, clearError } = useAgentChat(); + // モデル・残高はともにサイドバーで表示・切り替えする(ADR-0012)。ウィジェットは + // 有料モデル消費後にサイドバーの残高を最新化するため refresh のみ利用する + const isPaidModel = model === "sonnet"; + const { refresh: refreshBalance } = useCreditBalanceContext(); const { showSuccess } = useToast(); useMessageToast(error, "error"); @@ -135,10 +143,22 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } const handleSend = () => { if (!canSend) return; clearError(); - void send(form, scope, getTarget(), prompt.trim()); + void sendAndRefreshBalance(scope, getTarget(), prompt.trim()); setPrompt(""); }; + /** 送信後、有料モデルなら残高を最新化する(消費は実トークン量で事後確定するため)。 */ + const sendAndRefreshBalance = async ( + sendScope: AgentScope, + target: ProjectTarget | ExperienceTarget | null, + text: string, + ) => { + await send(form, sendScope, target, text, model); + if (isPaidModel) { + void refreshBalance(); + } + }; + // パネル左上のハンドルをドラッグしてリサイズする。パネルは右下固定なので // ポインタが左上に動くほど大きくなる(差分を加算) const handleResizeStart = useCallback((e: React.PointerEvent) => { @@ -283,7 +303,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } onSelect={(suggestion) => { if (sending) return; clearError(); - void send(form, entry.scope, entry.target, suggestion); + void sendAndRefreshBalance(entry.scope, entry.target, suggestion); }} /> )} diff --git a/frontend/src/components/forms/CareerResumeForm.test.tsx b/frontend/src/components/forms/CareerResumeForm.test.tsx index 8d5f0f18..4d74caa8 100644 --- a/frontend/src/components/forms/CareerResumeForm.test.tsx +++ b/frontend/src/components/forms/CareerResumeForm.test.tsx @@ -5,7 +5,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { CareerResumeForm } from "./CareerResumeForm"; import { LoginPromptContext } from "../auth/loginPromptContext"; +import { CreditBalanceProvider } from "../billing/CreditBalanceProvider"; import { ToastProvider } from "../ui/toast"; +import agentModelReducer from "../../store/agentModelSlice"; import formCacheReducer from "../../store/formCacheSlice"; import { UI_MESSAGES, VALIDATION_MESSAGES } from "../../constants/messages"; @@ -22,7 +24,9 @@ vi.mock("../../api/master-data", () => ({ /** formCache だけを持つ最小ストアを作る。 */ function makeStore() { - return configureStore({ reducer: { formCache: formCacheReducer } }); + return configureStore({ + reducer: { formCache: formCacheReducer, agentModel: agentModelReducer }, + }); } /** 未ログイン状態の CareerResumeForm を描画し、requestLogin スパイを返す。 */ @@ -31,9 +35,12 @@ function renderAnonymousForm() { render( - - - + {/* 本番ではフォームは常に SidebarLayout(残高 Provider)配下。未認証は enabled=false で取得しない */} + + + + + , ); diff --git a/frontend/src/constants/agentModels.ts b/frontend/src/constants/agentModels.ts new file mode 100644 index 00000000..7f0f96ed --- /dev/null +++ b/frontend/src/constants/agentModels.ts @@ -0,0 +1,46 @@ +/** + * 選択可能な AI モデル(ADR-0012)の表示メタデータ。 + * + * エイリアスの正本は backend `services/agent/model_catalog.py`(実モデル ID・課金レート)。 + * ここはフロントの表示用(製品名・説明・コスト目安)の SSoT で、プランカード UI と + * サイドバーの使用モデル表示が参照する。 + */ + +import type { AgentModelAlias } from "../api/types"; +import { AGENT_MODEL_MESSAGES } from "./messages"; + +export type AgentModelOption = { + alias: AgentModelAlias; + /** 製品名(翻訳しない) */ + name: string; + /** クレジットを消費するか(有料バッジ・残高チェックの表示に使う) */ + isPaid: boolean; + tagline: string; + costHint: string; +}; + +// 「N クレジットで平均M回」の目安に使う基準クレジット量(残高に依存しない比率表示)。 +// 1 クレジット = ¥1 なので 1,000 クレジット = ¥1,000(手に取りやすい基準額) +export const CREDIT_ESTIMATE_REFERENCE = 1_000; + +export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ + { + alias: "haiku", + name: "Haiku", + isPaid: false, + tagline: AGENT_MODEL_MESSAGES.HAIKU_TAGLINE, + costHint: AGENT_MODEL_MESSAGES.HAIKU_COST, + }, + { + alias: "sonnet", + name: "Sonnet", + isPaid: true, + tagline: AGENT_MODEL_MESSAGES.SONNET_TAGLINE, + costHint: AGENT_MODEL_MESSAGES.SONNET_COST, + }, +]; + +/** エイリアスから表示メタデータを引く。未知の値は先頭(haiku)にフォールバック。 */ +export function getModelOption(alias: AgentModelAlias): AgentModelOption { + return AGENT_MODEL_OPTIONS.find((option) => option.alias === alias) ?? AGENT_MODEL_OPTIONS[0]; +} diff --git a/frontend/src/constants/errorCodes.ts b/frontend/src/constants/errorCodes.ts index 746f268e..9311e8ef 100644 --- a/frontend/src/constants/errorCodes.ts +++ b/frontend/src/constants/errorCodes.ts @@ -30,6 +30,8 @@ export const ERROR_CODES = [ // Agent(LLM チャット / ADR-0010) "AGENT_LLM_ERROR", "AGENT_PARSE_ERROR", + // 課金(プリペイドクレジット / ADR-0012) + "INSUFFICIENT_CREDITS", // アプリケーション全体 "RATE_LIMITED", // サーバー diff --git a/frontend/src/constants/errorMessages.ts b/frontend/src/constants/errorMessages.ts index bf107d59..84a050ea 100644 --- a/frontend/src/constants/errorMessages.ts +++ b/frontend/src/constants/errorMessages.ts @@ -47,6 +47,11 @@ export const ERROR_CONFIG: Record< message: "AI の応答を解釈できませんでした", recovery: { label: "もう一度試す", fn: null }, }, + INSUFFICIENT_CREDITS: { + // fn は null(自動切替はしない)。ユーザー自身がモデル選択で切り替える手動アクション + message: "クレジット残高が不足しています", + recovery: { label: "モデル選択で Haiku(無料)に切り替える", fn: null }, + }, RATE_LIMITED: { message: "リクエストが集中しています", recovery: { label: "少し待って再試行", fn: null }, diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 6d4316f7..b0ae1be6 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -67,6 +67,8 @@ export const FALLBACK_MESSAGES = { GITHUB_OAUTH_START: "GitHub OAuth の開始に失敗しました", GITHUB_LINK: "連携に失敗しました", AGENT_CHAT: "AI への送信に失敗しました", + CREDIT_BALANCE: "クレジット残高の取得に失敗しました", + USAGE_SUMMARY: "利用状況の取得に失敗しました", } as const; /** @@ -391,3 +393,104 @@ export const AGENT_MESSAGES = { EMPTY_STATE: "編集対象を選んで、改善したい内容を AI に依頼してください。反映後も保存するまで DB は変更されません。", LOGIN_REQUIRED: "devforge Agent を使うにはログインが必要です。", } as const; + +/** AI モデル選択(ADR-0012)の UI 文言。製品名(Haiku / Sonnet)は constants/agentModels.ts。 */ +export const AGENT_MODEL_MESSAGES = { + SIDEBAR_LABEL: "使用モデル", + MENU_ITEM: "AI モデルを切り替え", + MODAL_TITLE: "AI モデルを選択", + MODAL_DESCRIPTION: + "依頼の精度とコストに合わせて選べます。設定はこの端末に保存され、すべての AI 機能で使われます。", + CLOSE_LABEL: "閉じる", + CURRENT_BADGE: "選択中", + FREE_BADGE: "無料", + PAID_BADGE: "有料", + // 課金(チャージ)導線はここに混ぜず、購入は専用サーフェスに分離する(ADR-0012) + INSUFFICIENT_HINT: "残高が不足しています。Haiku をご利用ください。", + USAGE_NONE: "まだ利用していません", + HAIKU_TAGLINE: "高速・標準精度。無料で使い放題。", + HAIKU_COST: "消費クレジット: なし", + SONNET_TAGLINE: "高精度。重要な仕上げや複雑な依頼に。", + SONNET_COST: "消費クレジット: 利用したトークン量に応じて", +} as const; + +/** モデルカードの利用実績ラベル(これまでの回数・消費クレジット)。 */ +export function modelUsageLabel(chatCount: number, creditCost: number): string { + const count = chatCount.toLocaleString("ja-JP"); + if (creditCost > 0) { + return `これまで ${count} 回・約 ${creditCost.toLocaleString("ja-JP")} クレジット消費`; + } + return `これまで ${count} 回利用`; +} + +/** 一定クレジットあたりの平均利用回数の目安ラベル(残高に依存しない比率表示)。 */ +export function creditsForChatsLabel(referenceCredits: number, chats: number): string { + return `${referenceCredits.toLocaleString("ja-JP")} クレジットで平均 ${chats.toLocaleString( + "ja-JP", + )} 回`; +} + +/** 残高・パックの「{モデル名} 約N回」回数アンカー。suffix で「回」「回分」を切替。 */ +export function modelChatsEstimateLabel( + modelName: string, + chats: number, + suffix: "回" | "回分" = "回", +): string { + return `${modelName} 約${chats.toLocaleString("ja-JP")}${suffix}`; +} + +/** クレジット課金(ADR-0012)の UI 文言。 */ +export const BILLING_MESSAGES = { + BALANCE_LOADING: "残高を確認中...", + SIDEBAR_LABEL: "クレジット残高", +} as const; + +/** クレジット残高の数値を 3 桁区切りで整形する(単位ラベルは別途付与)。 */ +export function formatCreditAmount(balance: number): string { + return balance.toLocaleString("ja-JP"); +} + +/** トークン購入画面(ADR-0012)の UI 文言。 */ +export const BILLING_PAGE_MESSAGES = { + TITLE: "クレジット", + MENU_ITEM: "クレジットを購入", + BALANCE_LABEL: "現在の残高", + CREDITS_UNIT: "クレジット", + PACKS_TITLE: "クレジットを購入", + PACKS_NOTE: "Sonnet など有料モデルの利用に使えます。Haiku は無料で使い放題です。", + INPUT_LABEL: "購入するクレジット数", + INPUT_PLACEHOLDER: "例: 1000", + PRESETS_LABEL: "クイック選択", + PURCHASE_BUTTON: "購入する", + CHECKOUT_PREPARING: "クレジット購入(Stripe 決済)は現在準備中です。", + PREPARING_BADGE: "準備中", + HISTORY_TITLE: "利用・購入履歴", + HISTORY_EMPTY: "まだ履歴はありません。", + LOADING: "読み込み中...", + TYPE_PURCHASE: "購入", + TYPE_CONSUMPTION: "消費", + TYPE_ADMIN_GRANT: "付与", +} as const; + +/** 金額を日本円表記に整形する。 */ +export function formatYen(amount: number): string { + return `¥${amount.toLocaleString("ja-JP")}`; +} + +/** 台帳の種別コードを表示ラベルに変換する。 */ +export function transactionTypeLabel(type: string): string { + if (type === "purchase") return BILLING_PAGE_MESSAGES.TYPE_PURCHASE; + if (type === "consumption") return BILLING_PAGE_MESSAGES.TYPE_CONSUMPTION; + return BILLING_PAGE_MESSAGES.TYPE_ADMIN_GRANT; +} + +/** 台帳の符号付き増減量を表示文字列に整形する(付与/購入は +、消費は -)。 */ +export function transactionAmountLabel(amount: number): string { + const sign = amount > 0 ? "+" : ""; + return `${sign}${amount.toLocaleString("ja-JP")}`; +} + +/** 購入クレジットの入力範囲ヒント。 */ +export function purchaseRangeHint(min: number, max: number): string { + return `${min.toLocaleString("ja-JP")}〜${max.toLocaleString("ja-JP")} クレジットで入力してください`; +} diff --git a/frontend/src/hooks/career/useAgentChat.test.ts b/frontend/src/hooks/career/useAgentChat.test.ts index 0cd7d8a9..c8012405 100644 --- a/frontend/src/hooks/career/useAgentChat.test.ts +++ b/frontend/src/hooks/career/useAgentChat.test.ts @@ -202,6 +202,28 @@ describe("useAgentChat", () => { expect(lastCall.history[0]).toEqual({ role: "user", text: "依頼2" }); }); + it("model 未指定の送信では haiku(無料)を送る", async () => { + postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] }); + const { result } = renderHook(() => useAgentChat()); + + await act(async () => { + await result.current.send(form, "self_pr", null, "改善して"); + }); + + expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ model: "haiku" })); + }); + + it("sonnet 指定の送信では model=sonnet を送る(有料モデル / ADR-0012)", async () => { + postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] }); + const { result } = renderHook(() => useAgentChat()); + + await act(async () => { + await result.current.send(form, "self_pr", null, "改善して", "sonnet"); + }); + + expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ model: "sonnet" })); + }); + it("experience スコープでは target を送る", async () => { postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] }); const { result } = renderHook(() => useAgentChat()); diff --git a/frontend/src/hooks/career/useAgentChat.ts b/frontend/src/hooks/career/useAgentChat.ts index 4ff1bf18..36a801b6 100644 --- a/frontend/src/hooks/career/useAgentChat.ts +++ b/frontend/src/hooks/career/useAgentChat.ts @@ -11,6 +11,7 @@ import { useCallback, useState } from "react"; import { postAgentChat } from "../../api/agent"; import type { AgentHistoryEntry, + AgentModelAlias, AgentOperation, ExperienceTarget, ProjectTarget, @@ -69,6 +70,7 @@ export function useAgentChat() { scope: AgentScope, target: ProjectTarget | ExperienceTarget | null, prompt: string, + model: AgentModelAlias = "haiku", ) => { setError(null); setSending(true); @@ -88,6 +90,8 @@ export function useAgentChat() { const response = await postAgentChat({ scope, prompt, + // 使用モデル(haiku: 無料 / sonnet: 有料・クレジット消費 / ADR-0012) + model, resume: buildAgentResumeContext(form), target: scope === "project" || scope === "experience" ? target : null, history: buildHistory(entries), diff --git a/frontend/src/hooks/useAgentUsageSummary.test.ts b/frontend/src/hooks/useAgentUsageSummary.test.ts new file mode 100644 index 00000000..e02e23d9 --- /dev/null +++ b/frontend/src/hooks/useAgentUsageSummary.test.ts @@ -0,0 +1,50 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FALLBACK_MESSAGES } from "../constants/messages"; +import { useAgentUsageSummary } from "./useAgentUsageSummary"; + +const getAgentUsageSummaryMock = vi.fn(); + +vi.mock("../api/billing", () => ({ + getAgentUsageSummary: (...args: unknown[]) => getAgentUsageSummaryMock(...args), +})); + +beforeEach(() => { + getAgentUsageSummaryMock.mockReset(); +}); + +describe("useAgentUsageSummary", () => { + it("enabled=true でモデル別サマリを取得し getUsage で引ける", async () => { + getAgentUsageSummaryMock.mockResolvedValue([ + { model: "sonnet", chat_count: 3, input_tokens: 100, output_tokens: 200, credit_cost: 810 }, + ]); + + const { result } = renderHook(() => useAgentUsageSummary(true)); + + await waitFor(() => { + expect(result.current.getUsage("sonnet")?.chat_count).toBe(3); + }); + // 未利用モデルは undefined + expect(result.current.getUsage("haiku")).toBeUndefined(); + }); + + it("enabled=false の間は取得しない", () => { + getAgentUsageSummaryMock.mockResolvedValue([]); + + const { result } = renderHook(() => useAgentUsageSummary(false)); + + expect(getAgentUsageSummaryMock).not.toHaveBeenCalled(); + expect(result.current.getUsage("sonnet")).toBeUndefined(); + }); + + it("取得失敗時は error にメッセージが入る", async () => { + getAgentUsageSummaryMock.mockRejectedValue("network down"); + + const { result } = renderHook(() => useAgentUsageSummary(true)); + + await waitFor(() => { + expect(result.current.error).toBe(FALLBACK_MESSAGES.USAGE_SUMMARY); + }); + }); +}); diff --git a/frontend/src/hooks/useAgentUsageSummary.ts b/frontend/src/hooks/useAgentUsageSummary.ts new file mode 100644 index 00000000..ff735b60 --- /dev/null +++ b/frontend/src/hooks/useAgentUsageSummary.ts @@ -0,0 +1,52 @@ +/** + * モデル別の使用量サマリ(ADR-0012)を取得するフック。 + * + * モデル選択モーダルが開いたときに取得し、各モデルカードへ + * 「これまでの利用回数・消費クレジット」と残回数目安を表示する。 + */ + +import { useCallback, useEffect, useState } from "react"; + +import { getAgentUsageSummary } from "../api/billing"; +import type { AgentModelAlias, AgentUsageSummaryEntry } from "../api/types"; +import { FALLBACK_MESSAGES } from "../constants/messages"; + +/** エイリアス → サマリの引きやすい Map に変換した状態。 */ +export type UsageByModel = Record; + +export function useAgentUsageSummary(enabled: boolean) { + const [usageByModel, setUsageByModel] = useState({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const entries = await getAgentUsageSummary(); + const map: UsageByModel = {}; + for (const entry of entries) { + map[entry.model] = entry; + } + setUsageByModel(map); + } catch (e) { + setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.USAGE_SUMMARY); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (enabled) { + void refresh(); + } + }, [enabled, refresh]); + + /** 指定モデルのサマリを返す(未利用なら undefined)。 */ + const getUsage = useCallback( + (alias: AgentModelAlias): AgentUsageSummaryEntry | undefined => usageByModel[alias], + [usageByModel], + ); + + return { usageByModel, getUsage, loading, error, refresh }; +} diff --git a/frontend/src/hooks/useBillingPage.test.ts b/frontend/src/hooks/useBillingPage.test.ts new file mode 100644 index 00000000..e704ce29 --- /dev/null +++ b/frontend/src/hooks/useBillingPage.test.ts @@ -0,0 +1,80 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useBillingPage } from "./useBillingPage"; + +const getCreditBalanceMock = vi.fn(); +const getCreditPacksMock = vi.fn(); +const getCreditTransactionsMock = vi.fn(); +const getModelRatesMock = vi.fn(); + +vi.mock("../api/billing", () => ({ + getCreditBalance: () => getCreditBalanceMock(), + getCreditPacks: () => getCreditPacksMock(), + getCreditTransactions: () => getCreditTransactionsMock(), + getModelRates: () => getModelRatesMock(), +})); + +beforeEach(() => { + getCreditBalanceMock.mockReset(); + getCreditPacksMock.mockReset(); + getCreditTransactionsMock.mockReset(); + getModelRatesMock.mockReset(); + getModelRatesMock.mockResolvedValue([ + { model: "sonnet", is_free: false, baseline_credits_per_chat: 12 }, + ]); +}); + +describe("useBillingPage", () => { + it("残高・パック・履歴をまとめて取得する", async () => { + getCreditBalanceMock.mockResolvedValue({ balance: 12000 }); + getCreditPacksMock.mockResolvedValue([ + { id: "starter", name: "スターター", price_jpy: 500, credits: 30000 }, + ]); + getCreditTransactionsMock.mockResolvedValue([ + { + id: "t1", + amount: 30000, + balance_after: 30000, + transaction_type: "purchase", + description: null, + created_at: "2026-06-13T00:00:00Z", + }, + ]); + + const { result } = renderHook(() => useBillingPage()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.balance).toBe(12000); + expect(result.current.packs).toHaveLength(1); + expect(result.current.transactions).toHaveLength(1); + // 有料モデル(Sonnet)の標準消費レートも取得する(回数目安に使う) + expect(result.current.paidRate).toBe(12); + expect(result.current.error).toBeNull(); + }); + + it("いずれかの取得に失敗したら error を立てる", async () => { + getCreditBalanceMock.mockResolvedValue({ balance: 0 }); + getCreditPacksMock.mockRejectedValue(new Error("パック取得失敗")); + getCreditTransactionsMock.mockResolvedValue([]); + + const { result } = renderHook(() => useBillingPage()); + + await waitFor(() => { + expect(result.current.error).toBe("パック取得失敗"); + }); + expect(result.current.loading).toBe(false); + }); + + it("初期状態は loading=true", () => { + getCreditBalanceMock.mockResolvedValue({ balance: 0 }); + getCreditPacksMock.mockResolvedValue([]); + getCreditTransactionsMock.mockResolvedValue([]); + + const { result } = renderHook(() => useBillingPage()); + + expect(result.current.loading).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useBillingPage.ts b/frontend/src/hooks/useBillingPage.ts new file mode 100644 index 00000000..c6e1265f --- /dev/null +++ b/frontend/src/hooks/useBillingPage.ts @@ -0,0 +1,61 @@ +/** + * トークン購入画面(ADR-0012)のデータ取得フック。 + * + * 残高・購入パック・取引履歴をまとめて取得する。購入後は呼び出し側が refresh する。 + */ + +import { useCallback, useEffect, useState } from "react"; + +import { + getCreditBalance, + getCreditPacks, + getCreditTransactions, + getModelRates, +} from "../api/billing"; +import type { + CreditPackResponse, + CreditTransactionResponse, + ModelRateEntry, +} from "../api/types"; +import { FALLBACK_MESSAGES } from "../constants/messages"; +import { PAID_REFERENCE_MODEL } from "../utils/creditEstimate"; + +export function useBillingPage() { + const [balance, setBalance] = useState(null); + const [packs, setPacks] = useState([]); + const [transactions, setTransactions] = useState([]); + // 回数目安の基準: 有料モデル(Sonnet)の標準消費レート(null なら回数を出さない) + const [paidRate, setPaidRate] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [balanceRes, packsRes, transactionsRes, ratesRes] = await Promise.all([ + getCreditBalance(), + getCreditPacks(), + getCreditTransactions(), + getModelRates(), + ]); + setBalance(balanceRes.balance); + setPacks(packsRes); + setTransactions(transactionsRes); + const paid: ModelRateEntry | undefined = ratesRes.find( + (r) => r.model === PAID_REFERENCE_MODEL, + ); + setPaidRate(paid && !paid.is_free ? paid.baseline_credits_per_chat : null); + } catch (e) { + setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.CREDIT_BALANCE); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { balance, packs, transactions, paidRate, loading, error, refresh }; +} diff --git a/frontend/src/hooks/useCreditBalance.test.ts b/frontend/src/hooks/useCreditBalance.test.ts new file mode 100644 index 00000000..2f8388f4 --- /dev/null +++ b/frontend/src/hooks/useCreditBalance.test.ts @@ -0,0 +1,102 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FALLBACK_MESSAGES } from "../constants/messages"; +import { useCreditBalance } from "./useCreditBalance"; + +const getCreditBalanceMock = vi.fn(); + +vi.mock("../api/billing", () => ({ + getCreditBalance: (...args: unknown[]) => getCreditBalanceMock(...args), +})); + +beforeEach(() => { + getCreditBalanceMock.mockReset(); +}); + +describe("useCreditBalance", () => { + it("enabled=true で残高を取得し balance に保持する", async () => { + getCreditBalanceMock.mockResolvedValue({ balance: 12_000 }); + + const { result } = renderHook(() => useCreditBalance(true)); + + await waitFor(() => { + expect(result.current.balance).toBe(12_000); + }); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("enabled=false の間は取得しない(無料モデル選択中は残高 API を呼ばない)", () => { + getCreditBalanceMock.mockResolvedValue({ balance: 100 }); + + const { result } = renderHook(() => useCreditBalance(false)); + + expect(getCreditBalanceMock).not.toHaveBeenCalled(); + expect(result.current.balance).toBeNull(); + }); + + it("取得失敗時は error にメッセージが入り loading が解除される", async () => { + getCreditBalanceMock.mockRejectedValue(new Error("クレジット残高が不足しています")); + + const { result } = renderHook(() => useCreditBalance(true)); + + await waitFor(() => { + expect(result.current.error).toBe("クレジット残高が不足しています"); + }); + expect(result.current.loading).toBe(false); + expect(result.current.balance).toBeNull(); + }); + + it("Error 以外の reject では fallback メッセージを使う", async () => { + getCreditBalanceMock.mockRejectedValue("unexpected"); + + const { result } = renderHook(() => useCreditBalance(true)); + + await waitFor(() => { + expect(result.current.error).toBe(FALLBACK_MESSAGES.CREDIT_BALANCE); + }); + }); + + it("refresh で最新残高に更新される", async () => { + getCreditBalanceMock.mockResolvedValueOnce({ balance: 1_000 }); + getCreditBalanceMock.mockResolvedValueOnce({ balance: 730 }); + + const { result } = renderHook(() => useCreditBalance(true)); + await waitFor(() => { + expect(result.current.balance).toBe(1_000); + }); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.balance).toBe(730); + }); + + it("古い refresh 応答は新しい応答を上書きしない(リクエスト順序の整合)", async () => { + // enabled=false で自動取得を抑止し、手動 refresh の順序だけを検証する + let resolveStale: (v: { balance: number }) => void = () => {}; + const stalePending = new Promise<{ balance: number }>((resolve) => { + resolveStale = resolve; + }); + // 1 回目(古い)は保留、2 回目(新しい)は即解決 + getCreditBalanceMock.mockReturnValueOnce(stalePending); + getCreditBalanceMock.mockResolvedValueOnce({ balance: 730 }); + + const { result } = renderHook(() => useCreditBalance(false)); + + await act(async () => { + void result.current.refresh(); // seq=1(保留中) + await result.current.refresh(); // seq=2(先に解決 → 730) + }); + expect(result.current.balance).toBe(730); + + // 後から古い応答が解決しても最新(730)を上書きしない + await act(async () => { + resolveStale({ balance: 9_999 }); + await stalePending; + }); + expect(result.current.balance).toBe(730); + }); +}); diff --git a/frontend/src/hooks/useCreditBalance.ts b/frontend/src/hooks/useCreditBalance.ts new file mode 100644 index 00000000..6bce2089 --- /dev/null +++ b/frontend/src/hooks/useCreditBalance.ts @@ -0,0 +1,43 @@ +/** + * クレジット残高(ADR-0012)の取得・更新フック。 + * + * sonnet(有料モデル)選択時のみ取得する(enabled フラグ)。 + * チャット送信後は呼び出し側が refresh() で最新残高に更新する。 + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { getCreditBalance } from "../api/billing"; +import { FALLBACK_MESSAGES } from "../constants/messages"; + +export function useCreditBalance(enabled: boolean) { + const [balance, setBalance] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // refresh が重なったとき、古い応答が新しい状態を上書きしないよう最新だけ反映する + const requestSeqRef = useRef(0); + + const refresh = useCallback(async () => { + const seq = ++requestSeqRef.current; + setLoading(true); + setError(null); + try { + const response = await getCreditBalance(); + if (seq === requestSeqRef.current) setBalance(response.balance); + } catch (e) { + if (seq === requestSeqRef.current) { + setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.CREDIT_BALANCE); + } + } finally { + if (seq === requestSeqRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + if (enabled) { + void refresh(); + } + }, [enabled, refresh]); + + return { balance, loading, error, refresh }; +} diff --git a/frontend/src/hooks/useModelRates.test.ts b/frontend/src/hooks/useModelRates.test.ts new file mode 100644 index 00000000..50cd340b --- /dev/null +++ b/frontend/src/hooks/useModelRates.test.ts @@ -0,0 +1,50 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useModelRates } from "./useModelRates"; + +const getModelRatesMock = vi.fn(); + +vi.mock("../api/billing", () => ({ + getModelRates: () => getModelRatesMock(), +})); + +beforeEach(() => { + getModelRatesMock.mockReset(); +}); + +describe("useModelRates", () => { + it("enabled=true で有料モデルの標準レートを引ける", async () => { + getModelRatesMock.mockResolvedValue([ + { model: "haiku", is_free: true, baseline_credits_per_chat: 0 }, + { model: "sonnet", is_free: false, baseline_credits_per_chat: 12 }, + ]); + + const { result } = renderHook(() => useModelRates(true)); + + await waitFor(() => { + expect(result.current.getBaselineRate("sonnet")).toBe(12); + }); + // 無料モデルはレートを返さない(回数目安を出さない) + expect(result.current.getBaselineRate("haiku")).toBeNull(); + }); + + it("enabled=false の間は取得しない", () => { + getModelRatesMock.mockResolvedValue([]); + + const { result } = renderHook(() => useModelRates(false)); + + expect(getModelRatesMock).not.toHaveBeenCalled(); + expect(result.current.getBaselineRate("sonnet")).toBeNull(); + }); + + it("取得失敗時は error を立てる", async () => { + getModelRatesMock.mockRejectedValue(new Error("レート取得失敗")); + + const { result } = renderHook(() => useModelRates(true)); + + await waitFor(() => { + expect(result.current.error).toBe("レート取得失敗"); + }); + }); +}); diff --git a/frontend/src/hooks/useModelRates.ts b/frontend/src/hooks/useModelRates.ts new file mode 100644 index 00000000..c07fcc4a --- /dev/null +++ b/frontend/src/hooks/useModelRates.ts @@ -0,0 +1,55 @@ +/** + * モデル別の標準消費レート(ADR-0012)を取得するフック。 + * + * 「Sonnet 約N回」の回数目安を出すために使う。利用実績のあるユーザーは + * 実測平均を優先し、本レート(ベースライン)は新規ユーザーのフォールバック。 + */ + +import { useCallback, useEffect, useState } from "react"; + +import { getModelRates } from "../api/billing"; +import type { AgentModelAlias, ModelRateEntry } from "../api/types"; +import { FALLBACK_MESSAGES } from "../constants/messages"; + +export type RatesByModel = Record; + +export function useModelRates(enabled: boolean) { + const [ratesByModel, setRatesByModel] = useState({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const entries = await getModelRates(); + const map: RatesByModel = {}; + for (const entry of entries) { + map[entry.model] = entry; + } + setRatesByModel(map); + } catch (e) { + setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.USAGE_SUMMARY); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (enabled) { + void refresh(); + } + }, [enabled, refresh]); + + /** 指定モデルの 1 回あたり標準消費クレジット(未取得・無料モデルは null)。 */ + const getBaselineRate = useCallback( + (alias: AgentModelAlias): number | null => { + const entry = ratesByModel[alias]; + if (!entry || entry.is_free) return null; + return entry.baseline_credits_per_chat; + }, + [ratesByModel], + ); + + return { ratesByModel, getBaselineRate, loading, error, refresh }; +} diff --git a/frontend/src/pages/BillingPage.tsx b/frontend/src/pages/BillingPage.tsx new file mode 100644 index 00000000..f2a484e2 --- /dev/null +++ b/frontend/src/pages/BillingPage.tsx @@ -0,0 +1,5 @@ +import { BillingView } from "../components/billing/BillingView"; + +export default function BillingPage() { + return ; +} diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx index 6d30c773..7f89c963 100644 --- a/frontend/src/router/routes.tsx +++ b/frontend/src/router/routes.tsx @@ -7,6 +7,7 @@ import { PrivateRoute, PublicRoute, type AuthUser } from "./guards"; import CareerPage from "../pages/CareerPage"; import GitHubLinkPage from "../pages/GitHubLinkPage"; import BlogPage from "../pages/BlogPage"; +import BillingPage from "../pages/BillingPage"; import GitHubCallbackPage from "../pages/GitHubCallbackPage"; import LoginPage from "../pages/LoginPage"; import NotFoundPage from "../pages/NotFoundPage"; @@ -101,6 +102,7 @@ export default function AppRoutes({ > } /> } /> + } /> diff --git a/frontend/src/store/agentModelSlice.test.ts b/frontend/src/store/agentModelSlice.test.ts new file mode 100644 index 00000000..959c48d4 --- /dev/null +++ b/frontend/src/store/agentModelSlice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import reducer, { setAgentModel } from "./agentModelSlice"; + +describe("agentModelSlice", () => { + it("初期状態は無料の haiku(誤課金を防ぐ安全側の既定)", () => { + const state = reducer(undefined, { type: "@@INIT" }); + expect(state.model).toBe("haiku"); + }); + + it("setAgentModel で sonnet に切り替わる", () => { + const state = reducer({ model: "haiku" }, setAgentModel("sonnet")); + expect(state.model).toBe("sonnet"); + }); + + it("setAgentModel で haiku に戻せる", () => { + const state = reducer({ model: "sonnet" }, setAgentModel("haiku")); + expect(state.model).toBe("haiku"); + }); +}); diff --git a/frontend/src/store/agentModelSlice.ts b/frontend/src/store/agentModelSlice.ts new file mode 100644 index 00000000..3c9274d6 --- /dev/null +++ b/frontend/src/store/agentModelSlice.ts @@ -0,0 +1,30 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; + +import type { AgentModelAlias } from "../api/types"; + +/** + * 使用する AI モデル(ADR-0012)のグローバル設定。 + * + * モデル選択は「Agent ウィジェットのローカル状態」ではなく「アカウント横断の UI 設定」。 + * サイドバーに常時表示し、UserMenu のモデル選択モーダルから切り替える。 + * redux-persist で端末に永続化する(ドメインの事実ではないため DB には持たない)。 + */ +interface AgentModelState { + model: AgentModelAlias; +} + +// 既定は無料の haiku(誤課金を防ぐ安全側の初期値) +const initialState: AgentModelState = { model: "haiku" }; + +const agentModelSlice = createSlice({ + name: "agentModel", + initialState, + reducers: { + setAgentModel(state, action: PayloadAction) { + state.model = action.payload; + }, + }, +}); + +export const { setAgentModel } = agentModelSlice.actions; +export default agentModelSlice.reducer; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index c4e07871..a35bc82e 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -1,11 +1,14 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { persistReducer, persistStore } from "redux-persist"; import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; +import agentModelReducer from "./agentModelSlice"; import formCacheReducer from "./formCacheSlice"; import { persistConfig } from "./persistConfig"; const rootReducer = combineReducers({ formCache: formCacheReducer, + // 使用モデルのグローバル設定(永続化する。PII を含まない / ADR-0012) + agentModel: agentModelReducer, }); /** persistConfig の blacklist に基づき機密スライスを除外した永続化リデューサー */ diff --git a/frontend/src/utils/creditEstimate.test.ts b/frontend/src/utils/creditEstimate.test.ts new file mode 100644 index 00000000..8a6b296d --- /dev/null +++ b/frontend/src/utils/creditEstimate.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { creditsToYen, estimateChats } from "./creditEstimate"; + +describe("estimateChats", () => { + it("クレジット ÷ 1回あたり消費の floor を返す", () => { + // 1000 / 12 = 83.3 → 83 + expect(estimateChats(1000, 12)).toBe(83); + }); + + it("残高 0 は 0 回(負ではないので算出する)", () => { + expect(estimateChats(0, 12)).toBe(0); + }); + + it("負の残高では目安を出さない(『約 -N 回』を防ぐ)", () => { + expect(estimateChats(-50, 12)).toBeNull(); + }); + + it("1回あたり消費が 0 / null / undefined なら null", () => { + expect(estimateChats(1000, 0)).toBeNull(); + expect(estimateChats(1000, null)).toBeNull(); + expect(estimateChats(1000, undefined)).toBeNull(); + }); +}); + +describe("creditsToYen", () => { + it("1 クレジット = ¥1 で換算する", () => { + expect(creditsToYen(500)).toBe(500); + }); +}); diff --git a/frontend/src/utils/creditEstimate.ts b/frontend/src/utils/creditEstimate.ts new file mode 100644 index 00000000..0986daa8 --- /dev/null +++ b/frontend/src/utils/creditEstimate.ts @@ -0,0 +1,35 @@ +/** + * クレジット → 利用回数の目安計算(ADR-0012)。 + * + * 1 クレジット = ¥1。回数の目安は「クレジット ÷ 1回あたりの消費」で算出する。 + * 「回数」の基準モデルは有料モデル(Sonnet)。無料モデル(消費 0)は目安を出さない。 + */ + +import type { AgentModelAlias } from "../api/types"; + +/** 回数アンカーの基準にする有料モデル。 */ +export const PAID_REFERENCE_MODEL: AgentModelAlias = "sonnet"; + +// 1 クレジット = ¥1(ADR-0012)。購入フォームの円換算に使う。 +// ※ペッグの正本は backend(pricing / model_catalog)。表示用の換算係数として保持する +export const YEN_PER_CREDIT = 1; + +// 任意クレジット購入の入力範囲(誤入力の桁あふれ・極小購入を防ぐ) +export const MIN_PURCHASE_CREDITS = 100; +export const MAX_PURCHASE_CREDITS = 1_000_000; + +/** クレジット数を円に換算する(1 クレジット = ¥1)。 */ +export function creditsToYen(credits: number): number { + return credits * YEN_PER_CREDIT; +} + +/** クレジット量と 1 回あたりの消費から利用回数の目安を返す。算出不能なら null。 */ +export function estimateChats( + credits: number, + creditsPerChat: number | null | undefined, +): number | null { + // 負残高(ADR-0012 の有界損失で発生しうる)では「約 -N 回」になり混乱するため出さない + if (credits < 0) return null; + if (!creditsPerChat || creditsPerChat <= 0) return null; + return Math.floor(credits / creditsPerChat); +}