diff --git a/backend/alembic_migrations/versions/0051_drop_billing.py b/backend/alembic_migrations/versions/0051_drop_billing.py new file mode 100644 index 00000000..119da617 --- /dev/null +++ b/backend/alembic_migrations/versions/0051_drop_billing.py @@ -0,0 +1,51 @@ +"""drop billing(credit_transactions / agent_usage_logs / users.credit_balance) + +プリペイド課金・使用ログ課金を撤去(ADR-0023 / #522)。abuse 防止は日次レート制限 +(#521 / agent_daily_usage)へ移行済み。 + +- credit_transactions テーブル削除(クレジット台帳) +- agent_usage_logs テーブル削除(使用ログ) +- users.credit_balance カラム削除(残高キャッシュ。libSQL/SQLite 3.35+ は素の + カラムの直接 DROP COLUMN をサポートするため batch を使わない) + +Revision ID: 0051_drop_billing +Revises: 0050_create_agent_daily_usage +Create Date: 2026-07-22 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0051_drop_billing" +down_revision: Union[str, None] = "0050_create_agent_daily_usage" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(name: str) -> bool: + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"), + {"name": name}, + ) + return result.fetchone() is not None + + +def upgrade() -> None: + if _table_exists("credit_transactions"): + op.drop_table("credit_transactions") + if _table_exists("agent_usage_logs"): + op.drop_table("agent_usage_logs") + op.drop_column("users", "credit_balance") + + +def downgrade() -> None: + # 課金は機能撤去のため復元しない(no-op / ADR-0023)。 + # users.credit_balance のみ、スキーマ整合のため復元する。 + op.add_column( + "users", + sa.Column("credit_balance", sa.Integer(), nullable=False, server_default="0"), + ) diff --git a/backend/app/core/env_keys.py b/backend/app/core/env_keys.py index a708ed3a..365adf15 100644 --- a/backend/app/core/env_keys.py +++ b/backend/app/core/env_keys.py @@ -132,13 +132,6 @@ # (services/agent/rate_limit.py の DEFAULT_AGENT_DAILY_LIMIT)を使う。 AGENT_DAILY_LIMIT = "AGENT_DAILY_LIMIT" -# --- 決済(Stripe Checkout / ADR-0012 Phase 2) --- - -# 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止 -STRIPE_SECRET_KEY = "STRIPE_SECRET_KEY" -# Webhook 署名検証用シークレット(whsec_...)。ログ出力禁止 -STRIPE_WEBHOOK_SECRET = "STRIPE_WEBHOOK_SECRET" - # --- アプリ起動制御 --- APP_BOOTSTRAPPED = "APP_BOOTSTRAPPED" diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 3b053b14..3fa568f2 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -39,10 +39,6 @@ class ErrorCode(str, Enum): AGENT_PARSE_ERROR = "AGENT_PARSE_ERROR" # Agent の日次利用上限(#521 / ADR-0023) AGENT_DAILY_LIMIT_EXCEEDED = "AGENT_DAILY_LIMIT_EXCEEDED" - # 課金(プリペイドクレジット / ADR-0012) - INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS" - # 決済(Stripe Checkout / ADR-0012 Phase 2) - PAYMENT_ERROR = "PAYMENT_ERROR" # アプリケーション全体 RATE_LIMITED = "RATE_LIMITED" # サーバー diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index 6f64884c..22756c51 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -234,29 +234,6 @@ def get_ollama_timeout_seconds() -> float: return value if value > 0 else 300.0 -def get_stripe_secret_key() -> str: - """Stripe シークレットキー(sk_...)を取得する。値はログや例外メッセージに含めないこと。""" - return os.getenv(env_keys.STRIPE_SECRET_KEY, "").strip() - - -def get_stripe_webhook_secret() -> str: - """Stripe Webhook 署名シークレット(whsec_...)を取得する。値はログに含めないこと。""" - return os.getenv(env_keys.STRIPE_WEBHOOK_SECRET, "").strip() - - -def get_billing_return_base_url() -> str: - """Stripe Checkout の success/cancel リダイレクト先のベース URL を返す。 - - フロントエンド(Cloudflare Pages)の URL を指す。OAuth と同じ ``CALLBACK_BASE_URL`` - を優先し、未設定ならローカル開発向けに CORS の先頭オリジンへフォールバックする。 - """ - base = get_callback_base_url() - if base: - return base - origins = get_cors_origins() - return origins[0].rstrip("/") if origins else "" - - def get_log_format() -> str: """ログフォーマット指定(json / text / 空)を小文字で取得する。""" return os.getenv(env_keys.LOG_FORMAT, "").strip().lower() diff --git a/backend/app/main.py b/backend/app/main.py index c5500264..75f84a27 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,7 +31,6 @@ from .routers import ( # noqa: E402 agent_router, auth_router, - billing_router, github_link_router, health_router, internal_router, @@ -133,9 +132,8 @@ async def _unhandled_exception_handler(request: Request, exc: Exception): ) -# Stripe Webhook は Cloudflare Pages を経由せず Cloud Run へ直接届くため -# X-Internal-Secret を持たない。署名検証(Stripe-Signature)で別途防御する(ADR-0012) -_INTERNAL_SECRET_SKIP_PATHS = {"/health", "/api/billing/webhook"} +# INTERNAL_SECRET 検証をスキップするパス(ヘルスチェックのみ) +_INTERNAL_SECRET_SKIP_PATHS = {"/health"} _INTERNAL_SECRET_HEADER = "x-internal-secret" @@ -216,4 +214,3 @@ 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 46fa02e3..cc91bdc7 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -72,14 +72,6 @@ "draft_not_ready": "経歴書ドラフトの生成が完了していません。生成を実行してからダウンロードしてください。", "skill_display_no_skills": "表示名を提案できるスキルがありません。先に GitHub 連携を実行してください。", "skill_display_invalid_identity": "確定対象に連携結果に存在しないスキルが含まれています。" - }, - "billing": { - "insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。", - "grant_user_not_found": "付与対象のユーザーが見つかりません。", - "checkout_unavailable": "クレジット購入機能は現在利用できません。時間をおいて再度お試しください。", - "checkout_failed": "決済ページの作成に失敗しました。時間をおいて再度お試しください。", - "webhook_unavailable": "決済通知を処理できません。", - "webhook_invalid": "決済通知の検証に失敗しました。" } }, "notification": { diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9ede5835..aff9074f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,6 @@ """SQLAlchemy モデル。""" from .agent_usage import AgentDailyUsage -from .billing import AgentUsageLog, CreditTransaction from .cache import GitHubLinkCache, ResumeDraftCache from .master_data import MQualification, MTechnologyStack from .notification import Notification @@ -26,8 +25,6 @@ __all__ = [ "AgentDailyUsage", - "AgentUsageLog", - "CreditTransaction", "GitHubLinkCache", "GitHubSkill", "GitHubSkillDisplayDecision", diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py deleted file mode 100644 index ecb3dbfd..00000000 --- a/backend/app/models/billing.py +++ /dev/null @@ -1,70 +0,0 @@ -"""クレジット課金モデル(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 f932f332..c5d3a11a 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, Integer, String, func +from sqlalchemy import DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column from ..db import Base @@ -16,11 +16,6 @@ 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 8925557b..9e804f77 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -1,7 +1,6 @@ """Repository 層。""" from .base import BaseMasterRepository, SingleUserDocumentRepository -from .billing import BillingRepository from .github_link import GitHubLinkCacheRepository from .master_data import MQualificationRepository, MTechnologyStackRepository from .resume import ResumeRepository @@ -10,7 +9,6 @@ __all__ = [ "BaseMasterRepository", - "BillingRepository", "GitHubLinkCacheRepository", "GitHubSkillRepository", "MQualificationRepository", diff --git a/backend/app/repositories/billing.py b/backend/app/repositories/billing.py deleted file mode 100644 index 6b88c332..00000000 --- a/backend/app/repositories/billing.py +++ /dev/null @@ -1,175 +0,0 @@ -"""クレジット課金リポジトリ(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 find_by_stripe_session_id( - self, stripe_session_id: str - ) -> CreditTransaction | None: - """指定の Stripe Checkout Session ID の台帳エントリを返す(冪等性チェック用)。 - - ``stripe_session_id`` は全体で UNIQUE のため user_id ではスコープしない - (Webhook 再送・同一セッションの二重付与を検出する / ADR-0012)。 - """ - return self.db.scalar( - select(CreditTransaction).where( - CreditTransaction.stripe_session_id == stripe_session_id - ) - ) - - 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 850f71c9..7aa3b4f3 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -1,6 +1,5 @@ from .agent import router as agent_router from .auth import router as auth_router -from .billing import router as billing_router from .github_link import router as github_link_router from .health import router as health_router from .internal import router as internal_router @@ -11,7 +10,6 @@ __all__ = [ "agent_router", "auth_router", - "billing_router", "github_link_router", "health_router", "internal_router", diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index d121dba8..73903650 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -24,7 +24,6 @@ from ..services.agent.chat_service import ( AgentResponseParseError, AgentTargetNotFoundError, - AgentUsage, ) from ..services.agent.context_builder import build_reference_context from ..services.agent.llm.base import LLMError @@ -34,8 +33,6 @@ ResumeDraftSourceUnavailableError, build_draft_source, ) -from ..services.billing import credit_service -from ..services.billing.credit_service import InsufficientCreditsError from ..services.pdf.generators.resume_generator import build_resume_pdf from ..services.tasks import AsyncTaskCacheService, TaskType from .download_utils import stream_pdf @@ -63,13 +60,6 @@ def _enforce_agent_daily_limit(db: Session, user_id: str) -> None: ) -def _record_usage_after_llm( - db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None -) -> None: - """LLM 応答後のクレジット消費・使用ログ記録(billing の共通後処理へ委譲)。""" - credit_service.record_usage_after_llm(db, user_id, usage, description=description) - - @router.post("/chat", response_model=AgentChatResponse) @limiter.limit("10/minute") async def agent_chat( @@ -81,22 +71,12 @@ async def agent_chat( """選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 career_summary / self_pr スコープでは GitHub 分析サマリーを参照情報として付与する。 - レスポンスはフロントの state にのみ適用され、DB は更新しない - (クレジット消費・使用ログの記録は除く / ADR-0012)。 + レスポンスはフロントの state にのみ適用され、DB は更新しない(ADR-0010)。 ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 + abuse 防止は日次レート制限で行う(ADR-0023 で課金を撤去)。 """ # 日次利用上限(abuse 防止 / #521・ADR-0023)を LLM 呼び出し前に確認する _enforce_agent_daily_limit(db, user.id) - # 有料モデル(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) result = await chat_service.run_agent_chat(body, reference) @@ -106,37 +86,18 @@ async def agent_chat( code=ErrorCode.VALIDATION_ERROR, message=get_error("agent.target_not_found"), ) - except LLMError as exc: - # リトライ呼び出しが失敗した場合、1 回目分の消費済みトークンを課金してから - # 502 を返す(課金漏れを防ぐ / ADR-0012)。課金記録自体の失敗はログに残し、 - # 本来の LLM 失敗(502)を優先して返す - if exc.usage is not None: - try: - _record_usage_after_llm(db, user.id, exc.usage) - except Exception: - logger.error("LLM 失敗時のクレジット消費記録に失敗", exc_info=True) + except LLMError: raise_app_error( status_code=502, code=ErrorCode.AGENT_LLM_ERROR, message=get_error("agent.llm_failed"), ) - except AgentResponseParseError as exc: - # リトライ後も失敗。消費済みトークン(リトライ含む API 原価)があれば課金を - # 確定してから 502 を返す(課金漏れを防ぐ / ADR-0012)。課金記録自体の失敗は - # ログに残し、本来のパース失敗(502)を優先して返す - if exc.usage is not None: - try: - _record_usage_after_llm(db, user.id, exc.usage) - except Exception: - logger.error("パース失敗時のクレジット消費記録に失敗", exc_info=True) + except AgentResponseParseError: raise_app_error( status_code=502, code=ErrorCode.AGENT_PARSE_ERROR, message=get_error("agent.parse_failed"), ) - # 実トークン量に基づくクレジット消費 + 使用ログ記録(haiku はログのみ)。 - # 記録失敗は応答を返さず 500 にする(課金漏れを黙って通さない / ADR-0012) - _record_usage_after_llm(db, user.id, result.usage) return result.response @@ -155,19 +116,10 @@ async def start_resume_draft( (職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。生成物(payload)は ``resume_draft_cache`` に保存され、``GET /resume-draft/pdf`` でダウンロードできる。 確定した職務経歴書(``resumes``)とは別物で、そちらには書き込まない。 - 課金は生成タスク側で確定する(残高の事前チェックのみ本エンドポイントで行う / ADR-0012)。 + abuse 防止は日次レート制限で行う(ADR-0023 で課金を撤去)。 """ # 日次利用上限(abuse 防止 / #521・ADR-0023)を生成開始前に確認する _enforce_agent_daily_limit(db, user.id) - # 有料モデルは生成を開始する前に残高をチェックする(チャットと同一契約 / 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"), - ) # 連携キャッシュ + スキル証跡の読み取り(SELECT のみ)で事前検証し、409 を即時に返す。 # 0 件(NoRepositories)は再連携で回復しないため別導線を案内する(サブクラスを先に catch) try: diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py deleted file mode 100644 index 14bd32c2..00000000 --- a/backend/app/routers/billing.py +++ /dev/null @@ -1,232 +0,0 @@ -"""クレジット課金エンドポイント(ADR-0012)。 - -Phase 1: 残高照会・台帳履歴・管理者付与。 -Phase 2: Stripe Checkout(購入セッション作成・Webhook 入金)。 -""" - -import logging - -from fastapi import APIRouter, Depends, Request -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 limiter, verify_admin_token -from ..core.settings import get_billing_return_base_url -from ..db import get_db -from ..models import User -from ..repositories import BillingRepository, UserRepository -from ..schemas.billing import ( - AdminCreditGrantRequest, - AgentUsageSummaryEntry, - CheckoutSessionRequest, - CheckoutSessionResponse, - CreditBalanceResponse, - CreditPackResponse, - CreditTransactionResponse, - ModelRateEntry, -) -from ..services.agent.model_catalog import MODEL_CATALOG, baseline_credits_per_chat -from ..services.billing import credit_service, stripe_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("/checkout", response_model=CheckoutSessionResponse) -@limiter.limit("20/minute") -def create_checkout( - request: Request, - body: CheckoutSessionRequest, - user: User = Depends(get_current_user), -) -> CheckoutSessionResponse: - """クレジット購入の Stripe Checkout セッションを作成し、決済ページ URL を返す(ADR-0012)。 - - 外部 API(Stripe)を呼ぶ高コスト endpoint のため rate limit を付与する。 - 入金確定は Webhook(checkout.session.completed)が正であり、本エンドポイントは - 決済ページへ誘導する URL を返すだけで残高は更新しない。 - """ - base_url = get_billing_return_base_url() - success_url = f"{base_url}/billing?checkout=success" - cancel_url = f"{base_url}/billing?checkout=cancel" - try: - checkout_url = stripe_service.create_checkout_session( - user_id=user.id, - credits=body.credits, - success_url=success_url, - cancel_url=cancel_url, - ) - except stripe_service.StripeNotConfiguredError: - raise_app_error( - status_code=503, - code=ErrorCode.PAYMENT_ERROR, - message=get_error("billing.checkout_unavailable"), - ) - except stripe_service.StripeCheckoutError: - raise_app_error( - status_code=502, - code=ErrorCode.PAYMENT_ERROR, - message=get_error("billing.checkout_failed"), - ) - return CheckoutSessionResponse(checkout_url=checkout_url) - - -@router.post("/webhook") -async def stripe_webhook( - request: Request, - db: Session = Depends(get_db), -) -> dict[str, bool]: - """Stripe Webhook(checkout.session.completed)でクレジットを付与する(ADR-0012)。 - - 署名検証必須。入金確定はこのエンドポイントが正で、付与の冪等性は - credit_transactions.stripe_session_id の UNIQUE 制約で担保する。呼び出し元は Stripe の - ため get_current_user は付けない(認証 Cookie は届かない)。Cloudflare を経由せず - Cloud Run へ直接届くため InternalSecretMiddleware の対象外にしてある(main.py 参照)。 - """ - payload = await request.body() - signature = request.headers.get("Stripe-Signature") - try: - event = stripe_service.parse_webhook_event(payload, signature) - except stripe_service.StripeNotConfiguredError: - raise_app_error( - status_code=503, - code=ErrorCode.PAYMENT_ERROR, - message=get_error("billing.webhook_unavailable"), - ) - except stripe_service.WebhookVerificationError: - raise_app_error( - status_code=400, - code=ErrorCode.VALIDATION_ERROR, - message=get_error("billing.webhook_invalid"), - ) - - purchase = stripe_service.extract_completed_purchase(event) - if purchase is not None: - target = UserRepository(db).get_by_id(purchase.user_id) - if target is None: - # 付与対象が存在しない(退会等)。再送しても解決しないため 200 で受領する - logger.warning( - "Webhook 付与対象ユーザーが存在しません: user_id=%s", purchase.user_id - ) - else: - credit_service.record_stripe_purchase( - db, - purchase.user_id, - purchase.credits, - stripe_session_id=purchase.stripe_session_id, - ) - return {"received": True} - - -@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/routers/github_link/endpoints.py b/backend/app/routers/github_link/endpoints.py index 62928b63..f0b02859 100644 --- a/backend/app/routers/github_link/endpoints.py +++ b/backend/app/routers/github_link/endpoints.py @@ -41,13 +41,12 @@ from ...schemas.shared import TaskAcceptedResponse, TaskStatusResponse from ...services.agent.chat_service import AgentResponseParseError from ...services.agent.llm.base import LLMError +from ...services.agent.rate_limit import AgentRateLimitExceededError, enforce_daily_limit from ...services.agent.skill_display import ( MAX_SKILLS_PER_PROPOSAL, SkillForProposal, propose_skill_display_names, ) -from ...services.billing import credit_service -from ...services.billing.credit_service import InsufficientCreditsError from ...services.intelligence.github_link_service import get_or_create_github_link_cache from ...services.intelligence.skills.types import SKILL_KIND_LANGUAGE from ...services.tasks import AsyncTaskCacheService, TaskType @@ -151,16 +150,19 @@ async def propose_skill_display_names_endpoint( agent は提案するだけで確定・DB 更新はしない(D8 / P4)。提案結果はレスポンスとして返し、 ユーザーがレビュー・編集して ``PUT /skills/display-decisions`` で確定する。 - 外部 LLM を呼ぶ高コスト endpoint のため rate limit を付与し、課金はチャットと同一契約。 + 外部 LLM を呼ぶ高コスト endpoint のため、slowapi の分間 rate limit に加えて + ユーザ単位の日次レート制限で abuse を防ぐ(ADR-0023 で課金を撤去した代替)。 """ - # 有料モデルは LLM を呼ぶ前に残高をチェックする(事後減算 / ADR-0012) + # 日次利用上限(abuse 防止 / #521・ADR-0023)を LLM 呼び出し前に確認する try: - credit_service.ensure_can_use_model(db, user.id, body.model) - except InsufficientCreditsError: + enforce_daily_limit(db, user.id) + db.commit() + except AgentRateLimitExceededError: + db.commit() raise_app_error( - status_code=402, - code=ErrorCode.INSUFFICIENT_CREDITS, - message=get_error("billing.insufficient_credits"), + status_code=429, + code=ErrorCode.AGENT_DAILY_LIMIT_EXCEEDED, + message=get_error("agent.daily_limit_exceeded"), ) skills = GitHubSkillRepository(db, user.id).list_for_user() @@ -190,33 +192,19 @@ async def propose_skill_display_names_endpoint( try: result = await propose_skill_display_names(body.model, proposal_inputs) - except LLMError as exc: - # 消費済みトークンがあれば課金してから 502(課金漏れ防止 / ADR-0012) - if exc.usage is not None: - try: - credit_service.record_usage_after_llm(db, user.id, exc.usage) - except Exception: - logger.error("表示名提案の LLM 失敗時のクレジット記録に失敗", exc_info=True) + except LLMError: raise_app_error( status_code=502, code=ErrorCode.AGENT_LLM_ERROR, message=get_error("agent.llm_failed"), ) - except AgentResponseParseError as exc: - if exc.usage is not None: - try: - credit_service.record_usage_after_llm(db, user.id, exc.usage) - except Exception: - logger.error("表示名提案のパース失敗時のクレジット記録に失敗", exc_info=True) + except AgentResponseParseError: raise_app_error( status_code=502, code=ErrorCode.AGENT_PARSE_ERROR, message=get_error("agent.parse_failed"), ) - credit_service.record_usage_after_llm( - db, user.id, result.usage, description=f"スキル表示名提案({body.model})" - ) return SkillDisplayProposeResponse( groups=[ SkillDisplayProposedGroup( diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index 5f2461f7..1e02ee55 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -19,7 +19,7 @@ AgentScope = Literal["project", "career_summary", "self_pr", "experience"] # 選択可能な LLM モデルのエイリアス(ADR-0012 / ADR-0013)。実モデル ID・プロバイダ・ -# 課金レートは services/agent/model_catalog.py の MODEL_CATALOG が正本(キー集合を一致させる) +# モデル定義は services/agent/model_catalog.py の MODEL_CATALOG が正本(キー集合を一致させる) AgentModelAlias = Literal[ "haiku", # Anthropic Claude Haiku(無料枠) "sonnet", # Anthropic Claude Sonnet @@ -116,7 +116,7 @@ class AgentChatRequest(BaseModel): scope: AgentScope prompt: str = Field(min_length=1, max_length=2000) - # 使用モデル。haiku は無料・使い放題、sonnet は有料(クレジット消費 / ADR-0012)。 + # 使用モデル。ADR-0023 で課金を撤去したため全モデル無料(マルチプロバイダは #523 で縮退予定)。 # デフォルト haiku で既存クライアントと後方互換 model: AgentModelAlias = "haiku" resume: AgentResumeContext @@ -144,7 +144,7 @@ class ResumeDraftRequest(BaseModel): クライアントが指定するのは使用モデルのみ。 """ - # 使用モデル。既定は無料枠の haiku(ADR-0012 の課金契約はチャットと共通) + # 使用モデル。既定は haiku(ADR-0023 で課金撤去済み・全モデル無料) model: AgentModelAlias = "haiku" diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py deleted file mode 100644 index 28b754b1..00000000 --- a/backend/app/schemas/billing.py +++ /dev/null @@ -1,86 +0,0 @@ -"""クレジット課金の Pydantic スキーマ(ADR-0012)。""" - -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field - -# 任意クレジット購入の入力範囲(誤入力の桁あふれ・極小購入を防ぐ)。 -# web/src/utils/creditEstimate.ts の MIN/MAX_PURCHASE_CREDITS と一致させる -MIN_PURCHASE_CREDITS = 100 -MAX_PURCHASE_CREDITS = 1_000_000 - - -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 CheckoutSessionRequest(BaseModel): - """クレジット購入の Stripe Checkout セッション作成リクエスト(ADR-0012 Phase 2)。""" - - # 1 クレジット = ¥1。入力範囲は誤入力の桁あふれ・極小購入を防ぐ - credits: int = Field(ge=MIN_PURCHASE_CREDITS, le=MAX_PURCHASE_CREDITS) - - -class CheckoutSessionResponse(BaseModel): - """Stripe Checkout 決済ページの URL。フロントはこの URL へリダイレクトする。""" - - checkout_url: str - - -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/schemas/github_skill.py b/backend/app/schemas/github_skill.py index 98e57af2..c52e337f 100644 --- a/backend/app/schemas/github_skill.py +++ b/backend/app/schemas/github_skill.py @@ -99,7 +99,7 @@ class SkillDisplayProposeRequest(BaseModel): 提案対象スキルはサーバーが連携結果から決めるため、クライアントは使用モデルのみ指定する。 """ - # 使用モデル。既定は無料枠の haiku(課金契約はチャットと共通 / ADR-0012) + # 使用モデル。既定は haiku(ADR-0023 で課金撤去済み・全モデル無料) model: AgentModelAlias = "haiku" diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 49844af8..7c91afab 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -43,9 +43,8 @@ class AgentTargetNotFoundError(Exception): class AgentResponseParseError(Exception): """LLM 応答の JSON パースまたはスキーマ検証に失敗。 - リトライ後も失敗した場合、消費済みトークンの課金漏れを防ぐため確定済みの - 使用量を ``usage`` に載せて呼び出し元(router)へ伝播する(ADR-0012)。 - パース前段(リトライ未到達)の失敗では ``usage`` は None。 + リトライ後も失敗した場合、確定済みの使用量を ``usage`` に載せて呼び出し元(router)へ + 伝播する(観測用。ADR-0023 で課金は撤去)。パース前段(リトライ未到達)の失敗では ``usage`` は None。 """ def __init__(self, message: str, *, usage: "AgentUsage | None" = None) -> None: @@ -55,10 +54,10 @@ def __init__(self, message: str, *, usage: "AgentUsage | None" = None) -> None: @dataclass(frozen=True) class AgentUsage: - """チャット 1 回分の実トークン使用量(リトライ分を含む合算 / ADR-0012)。 + """チャット 1 回分の実トークン使用量(リトライ分を含む合算)。 - router がクレジット消費・使用ログ記録(services/billing)へ渡す。 - 本モジュールは DB に触れない原則を維持するため、記録自体は行わない。 + ADR-0023 で課金を撤去したため現在は消費されないが、将来の観測・計測用に + トークン計上インフラとして温存する。本モジュールは DB に触れない原則を維持する。 """ model: AgentModelAlias @@ -68,7 +67,7 @@ class AgentUsage: @dataclass(frozen=True) class AgentChatResult: - """run_agent_chat の戻り値(API レスポンス + 課金用の使用量)。""" + """run_agent_chat の戻り値(API レスポンス + 観測用の使用量)。""" response: AgentChatResponse usage: AgentUsage @@ -79,7 +78,7 @@ def _make_usage( ) -> AgentUsage: """リクエストのモデルと合算トークンから AgentUsage を組み立てる。 - 課金漏れ防止(ADR-0012)のため成功・失敗の各パスで同じ形の使用量を作る箇所を集約する。 + 成功・失敗の各パスで同じ形の使用量を作る箇所を集約する(観測用 / ADR-0023 で課金撤去)。 """ return AgentUsage( model=request.model, input_tokens=input_tokens, output_tokens=output_tokens @@ -298,14 +297,14 @@ async def run_agent_chat( client = get_llm_client(spec.provider) output_schema = _SCOPE_SCHEMAS[request.scope] - # リトライしても 1 回目の API 原価は発生しているため、使用量は全呼び出しの合算で課金する + # リトライしても 1 回目の API 原価は発生しているため、使用量は全呼び出しの合算で記録する(観測用) input_tokens = 0 output_tokens = 0 async def _generate_and_account(call_messages: list[dict], *, label: str): """LLM を呼び出し、合算トークンへ加算してから生応答を返す。 - 課金漏れ防止(ADR-0012)のため、初回・リトライの両方でトークン加算経路を + 観測用(ADR-0023 で課金撤去)のため、初回・リトライの両方でトークン加算経路を この 1 箇所に集約する。``client.generate`` が失敗した場合は加算前に例外が伝播し、 呼び出し元で確定済みの合算使用量を載せて再 raise する(使用量の二重計上を防ぐ)。 """ @@ -347,15 +346,13 @@ async def _generate_and_account(call_messages: list[dict], *, label: str): try: result = await _generate_and_account(retry_messages, label="リトライ") except LLMError as retry_exc: - # リトライ呼び出し自体が失敗。1 回目の API 原価は発生済みのため使用量を - # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) + # リトライ呼び出し自体が失敗。合算した使用量(観測用)を載せて伝播する(ADR-0023 で課金撤去) retry_exc.usage = _make_usage(request, input_tokens, output_tokens) raise try: response = _parse_response(result.text, request.scope) except AgentResponseParseError as retry_exc: - # 2 回目も失敗。1・2 回目分の API 原価は発生済みのため、合算した使用量を - # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) + # 2 回目も失敗。合算した使用量(観測用)を載せて伝播する(ADR-0023 で課金撤去) raise AgentResponseParseError( str(retry_exc), usage=_make_usage(request, input_tokens, output_tokens), diff --git a/backend/app/services/agent/llm/google_client.py b/backend/app/services/agent/llm/google_client.py index 6f93335f..4bbb3ea1 100644 --- a/backend/app/services/agent/llm/google_client.py +++ b/backend/app/services/agent/llm/google_client.py @@ -68,7 +68,7 @@ async def generate( raise wrap_api_error("Google", exc) from exc text = require_text("Google", response.text) - # usage はクレジット課金(ADR-0012)の根拠となるため実測値をそのまま返す + # usage は観測用(トークン計上)のため実測値をそのまま返す usage = response.usage_metadata return LLMResult( text=text, diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index f6078e95..35cd2079 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -43,7 +43,7 @@ async def generate( """Ollama /api/chat に JSON Schema 形式の format を付与して呼び出し、応答テキストを返す。 model_id(Anthropic 用の実モデル ID)は使わず、ローカル設定(OLLAMA_MODEL)の - モデルを使う。トークン使用量は返せないため 0 とする(ローカルは無料扱い / ADR-0012)。 + モデルを使う。トークン使用量は返せないため 0 とする(ローカルは無料扱い)。 """ payload = { "model": self._model, diff --git a/backend/app/services/agent/model_catalog.py b/backend/app/services/agent/model_catalog.py index abba9813..a5177462 100644 --- a/backend/app/services/agent/model_catalog.py +++ b/backend/app/services/agent/model_catalog.py @@ -1,63 +1,24 @@ -"""Agent で選択可能な LLM モデルのカタログ(SSoT / ADR-0012)。 +"""Agent で選択可能な LLM モデルのカタログ(SSoT)。 -クライアントはエイリアス("haiku" / "sonnet")のみを指定し、実モデル ID と -課金レートは本モジュールでマップする。任意のモデル文字列をクライアントから -受け付けない(コスト爆発・未検証モデルの注入を防ぐ)。 +クライアントはエイリアス("haiku" / "sonnet" 等)のみを指定し、実モデル ID は +本モジュールでマップする。任意のモデル文字列をクライアントから受け付けない +(コスト爆発・未検証モデルの注入を防ぐ)。 -クレジット単位は「1 クレジット = ¥1」(円ペッグ)。消費レートは API 原価(USD)を -円換算し、マージン係数を乗じて算出する。為替変動は YEN_PER_USD の調整で吸収する。 -定数の根拠は ADR-0012 を参照。 +ADR-0023 で課金を撤去したため、クレジットレート・is_free は持たない。 +プロバイダ選択(ADR-0013)は #523 でさらに Haiku + Ollama へ縮退予定。 """ -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 - -# 各社 API の公表原価(USD / 100 万トークン)。改定時はここだけ直す。 -# Anthropic -_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 -# Google Gemini 2.5(ADR-0013) -_GEMINI_FLASH_INPUT_USD_PER_MTOK = 0.30 -_GEMINI_FLASH_OUTPUT_USD_PER_MTOK = 2.50 -_GEMINI_PRO_INPUT_USD_PER_MTOK = 1.25 -_GEMINI_PRO_OUTPUT_USD_PER_MTOK = 10.0 -# OpenAI GPT(ADR-0013)。gpt-mini = 廉価(gpt-4o-mini 系)、gpt = 高級(GPT-4.1/5 系) -_GPT_MINI_INPUT_USD_PER_MTOK = 0.15 -_GPT_MINI_OUTPUT_USD_PER_MTOK = 0.60 -_GPT_INPUT_USD_PER_MTOK = 2.0 -_GPT_OUTPUT_USD_PER_MTOK = 8.0 - # LLM プロバイダ識別子(factory.get_llm_client の分岐キー / ADR-0013) PROVIDER_ANTHROPIC = "anthropic" PROVIDER_GOOGLE = "google" PROVIDER_OPENAI = "openai" _VALID_PROVIDERS = frozenset({PROVIDER_ANTHROPIC, PROVIDER_GOOGLE, PROVIDER_OPENAI}) -# 「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: @@ -67,11 +28,6 @@ class ModelSpec: provider: str # 各プロバイダ API に渡す実モデル ID model_id: str - # True なら残高チェック・クレジット消費を行わない(使用ログのみ記録) - is_free: bool - # マージン込みの消費レート(クレジット / 100 万トークン) - input_credits_per_mtok: int - output_credits_per_mtok: int # エイリアス → モデル定義(キー集合は schemas/agent.py の AgentModelAlias と @@ -82,46 +38,26 @@ class ModelSpec: # Vertex AI の Anthropic model id は版指定が要る(@日付)。Sonnet 4.6 は版指定 # 不要だが Haiku 4.5 は @20251001 が必須(ADR-0015 / Vertex Model Garden 正本) model_id="claude-haiku-4-5@20251001", - 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( provider=PROVIDER_ANTHROPIC, 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), ), "gemini-flash": ModelSpec( provider=PROVIDER_GOOGLE, model_id="gemini-2.5-flash", - # 低単価のため無料枠として開放(実 API 原価は運営が負担 / ADR-0013) - is_free=True, - input_credits_per_mtok=_credits_per_mtok(_GEMINI_FLASH_INPUT_USD_PER_MTOK), - output_credits_per_mtok=_credits_per_mtok(_GEMINI_FLASH_OUTPUT_USD_PER_MTOK), ), "gemini-pro": ModelSpec( provider=PROVIDER_GOOGLE, model_id="gemini-2.5-pro", - is_free=False, - input_credits_per_mtok=_credits_per_mtok(_GEMINI_PRO_INPUT_USD_PER_MTOK), - output_credits_per_mtok=_credits_per_mtok(_GEMINI_PRO_OUTPUT_USD_PER_MTOK), ), "gpt-mini": ModelSpec( provider=PROVIDER_OPENAI, model_id="gpt-4o-mini", - # 低単価のため無料枠として開放(実 API 原価は運営が負担 / ADR-0013) - is_free=True, - input_credits_per_mtok=_credits_per_mtok(_GPT_MINI_INPUT_USD_PER_MTOK), - output_credits_per_mtok=_credits_per_mtok(_GPT_MINI_OUTPUT_USD_PER_MTOK), ), "gpt": ModelSpec( provider=PROVIDER_OPENAI, model_id="gpt-4.1", - is_free=False, - input_credits_per_mtok=_credits_per_mtok(_GPT_INPUT_USD_PER_MTOK), - output_credits_per_mtok=_credits_per_mtok(_GPT_OUTPUT_USD_PER_MTOK), ), } @@ -145,27 +81,3 @@ class ModelSpec: 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/agent/resume_draft/draft_service.py b/backend/app/services/agent/resume_draft/draft_service.py index 3d4464d2..a9278971 100644 --- a/backend/app/services/agent/resume_draft/draft_service.py +++ b/backend/app/services/agent/resume_draft/draft_service.py @@ -1,8 +1,8 @@ """経歴書ドラフト生成の中核ロジック(骨格構築 → LLM → 検証 → マージ / ADR-0018)。 本モジュールは DB に触れない(DB 読み取りは router → context.py 経由のみ)。 -LLM 呼び出しの失敗契約(LLMError / AgentResponseParseError に usage を載せて課金漏れを -防ぐ・リトライは 1 回のみ)はチャット(chat_service)と同一。呼び出しの流れも同じ形だが、 +LLM 呼び出しの失敗契約(LLMError / AgentResponseParseError に usage〈観測用〉を載せる・ +リトライは 1 回のみ)はチャット(chat_service)と同一。ADR-0023 で課金は撤去。呼び出しの流れも同じ形だが、 入出力(骨格 payload / ドラフト出力スキーマ)が異なるため現時点では共通化しない (Rule of Three / .claude/rules/common/duplication.md)。 """ @@ -43,7 +43,7 @@ @dataclass(frozen=True) class ResumeDraftResult: - """run_resume_draft の戻り値(PDF 生成用 payload + 課金用の使用量)。""" + """run_resume_draft の戻り値(PDF 生成用 payload + 観測用の使用量)。""" payload: dict usage: AgentUsage @@ -153,7 +153,7 @@ def _merge_output(skeleton: dict, selected: list, output: _DraftOutput) -> dict: async def run_resume_draft( model: AgentModelAlias, source: DraftSource, *, today: date | None = None ) -> ResumeDraftResult: - """経歴書ドラフト payload を生成し、課金用の実トークン使用量とともに返す。 + """経歴書ドラフト payload を生成し、観測用の実トークン使用量とともに返す。 Args: model: モデルエイリアス(AgentModelAlias。router のスキーマで検証済み)。 @@ -182,7 +182,7 @@ async def run_resume_draft( len(user_prompt), ) - # リトライしても 1 回目の API 原価は発生しているため、使用量は合算で課金する(ADR-0012) + # リトライしても 1 回目の API 原価は発生しているため、使用量は合算で記録する(観測用 / ADR-0023 で課金撤去) input_tokens = 0 output_tokens = 0 @@ -224,12 +224,12 @@ async def _generate_and_account(call_messages: list[dict[str, str]], *, label: s try: result = await _generate_and_account(retry_messages, label="リトライ") except LLMError as retry_exc: - # 1 回目の API 原価は発生済み。使用量を載せて router 側で課金を確定させる(ADR-0012) + # 1 回目の API 原価は発生済み。使用量(観測用)を載せて伝播する(ADR-0023 で課金撤去) retry_exc.usage = _usage() raise try: output = _parse_draft(result.text, set(allowed_names)) except AgentResponseParseError as retry_exc: - # 2 回目も失敗。合算使用量を載せて伝播する(課金漏れ防止 / ADR-0012) + # 2 回目も失敗。合算使用量(観測用)を載せて伝播する(ADR-0023 で課金撤去) raise AgentResponseParseError(str(retry_exc), usage=_usage()) from retry_exc return ResumeDraftResult(payload=_merge_output(skeleton, selected, output), usage=_usage()) diff --git a/backend/app/services/agent/resume_draft/run_task.py b/backend/app/services/agent/resume_draft/run_task.py index 500f6faf..03d92004 100644 --- a/backend/app/services/agent/resume_draft/run_task.py +++ b/backend/app/services/agent/resume_draft/run_task.py @@ -1,17 +1,16 @@ """経歴書ドラフト生成タスクの実行サービス(非同期 / ADR-0018)。 worker(``services/tasks``)から呼ばれ、LLM でドラフト payload を生成 → PDF レンダリング検証 -→ 課金確定 → ``ResumeDraftCache`` へ保存する。状態遷移・失敗通知は worker が担い、本モジュールは +→ ``ResumeDraftCache`` へ保存する。状態遷移・失敗通知は worker が担い、本モジュールは 成功時に ``status="completed"`` + ``result`` を書き戻し、失敗時は ``NonRetryableError`` を raise する。 -DB 書き込み(課金・結果保存・状態遷移)は本モジュールと repository に閉じ込める。 +DB 書き込み(結果保存・状態遷移)は本モジュールと repository に閉じ込める。 ``draft_service`` / ``mapper`` / ``context`` の DB 非依存原則(context は SELECT のみ)は維持する。 -課金順序の不変条件(同期版 router から移設 / ADR-0012・0018): - - LLM/パース失敗時は消費済みトークンを **必ず課金** する(API 原価は発生済み)。 - - PDF レンダリング成功後にのみ本課金を確定する(レンダリング失敗=課金しない)。 - - 課金記録自体の失敗は ``NonRetryableError`` に包んで dead_letter にする - (リトライで LLM を再実行=再課金しないため。「課金漏れ・二重課金を黙って通さない」)。 +冪等性の不変条件(ADR-0018・0020。課金は ADR-0023 で撤去済み): + - 完了済み(``status="completed"`` かつ ``result`` あり)タスクの再配信は再実行しない + (Cloud Tasks の at-least-once 再配信で LLM を無駄に再実行しないため)。 + - PDF レンダリング失敗時は結果を保存せず ``NonRetryableError`` で dead_letter にする。 libSQL (Hrana over HTTP) の idle stream timeout を避けるため、LLM 呼び出しの前後で セッションを開閉する(run_github_link と同方針)。 @@ -23,7 +22,6 @@ from ....core.messages import get_error from ....models import User from ....repositories.resume_draft import ResumeDraftCacheRepository -from ....services.billing import credit_service from ....services.pdf.generators.resume_generator import build_resume_pdf from ...tasks.exceptions import NonRetryableError from ...tasks.handlers.base import SessionFactory @@ -45,10 +43,10 @@ async def run_resume_draft_task(session_factory: SessionFactory, payload: dict) フェーズ構成: - A: payload 検証 + 入力取得(SELECT)+ processing マーク(短命セッション) - B: LLM 生成 + PDF レンダリング検証(DB セッション無し) - - C: 課金確定 → 結果書き戻し(新セッション) + - C: 結果書き戻し(新セッション) 失敗はすべて ``NonRetryableError`` で worker に dead_letter を委ねる - (LLM/PDF 失敗はリトライで回復せず再課金の恐れがあるため)。 + (LLM/PDF 失敗はリトライで回復しないため)。 """ user_id = payload.get("user_id") model = payload.get("model") @@ -57,8 +55,6 @@ async def run_resume_draft_task(session_factory: SessionFactory, payload: dict) logger.error(message, extra={"payload_keys": list(payload.keys())}) raise NonRetryableError(f"{message} (payload_keys={list(payload.keys())})") - usage_description = f"経歴書ドラフト生成({model})" - # ── フェーズA: 入力取得 + processing マーク ────────────────────────────── with session_factory() as db: cache = ResumeDraftCacheRepository(db).get_by_user(user_id) @@ -67,10 +63,9 @@ async def run_resume_draft_task(session_factory: SessionFactory, payload: dict) logger.error(message, extra={"user_id": user_id}) raise NonRetryableError(f"{message} (user_id={user_id})") # 冪等ガード: 既に completed かつ result があるなら再実行しない。 - # フェーズC の課金+結果保存を原子的に確定した後、worker が ack する前に - # プロセスが落ちると Cloud Tasks が同一メッセージを再配信しうる。ここで - # 短絡しないと LLM を再実行して二重課金する(手動再実行は router が status を - # pending へ戻すため本ガードには掛からない)。 + # フェーズC の結果保存を確定した後、worker が ack する前にプロセスが落ちると + # Cloud Tasks が同一メッセージを再配信しうる。ここで短絡しないと LLM を無駄に + # 再実行する(手動再実行は router が status を pending へ戻すため本ガードには掛からない)。 if cache.status == "completed" and cache.result: logger.info("経歴書ドラフトは完了済みのため再実行をスキップ", extra={"user_id": user_id}) return @@ -96,10 +91,8 @@ async def run_resume_draft_task(session_factory: SessionFactory, payload: dict) try: result = await run_resume_draft(model, source) except LLMError as exc: - _charge_consumed_usage(session_factory, user_id, exc.usage, usage_description) raise NonRetryableError(get_error("agent.llm_failed")) from exc except AgentResponseParseError as exc: - _charge_consumed_usage(session_factory, user_id, exc.usage, usage_description) raise NonRetryableError(get_error("agent.parse_failed")) from exc # PDF レンダリングを検証する。失敗した場合は課金せず dead_letter にする @@ -110,45 +103,16 @@ async def run_resume_draft_task(session_factory: SessionFactory, payload: dict) logger.error("経歴書ドラフト PDF のレンダリングに失敗", exc_info=True) raise NonRetryableError(get_error("agent.draft_pdf_failed")) from exc - # ── フェーズC: 課金確定 + 結果書き戻し(単一トランザクションで原子的に)────── - # 本課金は PDF レンダリング成功後にのみ行う。課金と結果保存は**同一セッション**で - # staged し、``record_chat_usage`` 内の commit が両者を 1 トランザクションで確定する - # (SQLAlchemy の commit は pending 変更を一括 flush する)。これにより「課金済みだが - # 結果未保存」の窓が無くなり、その状態からのリトライによる二重課金が構造的に起きない。 - # 課金記録の失敗は同一トランザクションを rollback(結果保存も巻き戻る)した上で - # NonRetryable に包み dead_letter 化する(リトライで LLM を再実行=再課金しないため)。 + # ── フェーズC: 結果書き戻し(新セッション)────── with session_factory() as db: cache = ResumeDraftCacheRepository(db).get_by_user(user_id) if not cache: # 結果の保存先(ユーザーの ResumeDraftCache)が消えた(例: ユーザー削除の - # CASCADE)。保存先が無いだけで課金確定はせず終了する。 + # CASCADE)。保存先が無いだけなので終了する。 logger.warning("結果書き戻し時にキャッシュが見つかりません", extra={"user_id": user_id}) return cache.result = result.payload cache.status = "completed" cache.error_message = None cache.completed_at = _now() - try: - credit_service.record_chat_usage( - db, user_id, result.usage, description=usage_description - ) - except Exception as exc: - logger.error("経歴書ドラフト生成のクレジット消費記録に失敗", exc_info=True) - raise NonRetryableError("課金記録に失敗しました") from exc - - -def _charge_consumed_usage( - session_factory: SessionFactory, user_id: str, usage, usage_description: str -) -> None: - """LLM/パース失敗時に、消費済みトークン(API 原価が発生済み)を課金する。 - - 課金記録自体の失敗はログに残して握りつぶす(本来の LLM/パース失敗を dead_letter として - 確定させることを優先する。課金の取りこぼしより二重の状態破壊を避ける / ADR-0012)。 - """ - if usage is None: - return - with session_factory() as db: - try: - credit_service.record_chat_usage(db, user_id, usage, description=usage_description) - except Exception: - logger.error("失敗パスでのクレジット消費記録に失敗", exc_info=True) + db.commit() diff --git a/backend/app/services/billing/__init__.py b/backend/app/services/billing/__init__.py deleted file mode 100644 index 594ac1bb..00000000 --- a/backend/app/services/billing/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""クレジット課金サービス(ADR-0012)。""" diff --git a/backend/app/services/billing/credit_service.py b/backend/app/services/billing/credit_service.py deleted file mode 100644 index 59783dfe..00000000 --- a/backend/app/services/billing/credit_service.py +++ /dev/null @@ -1,162 +0,0 @@ -"""クレジット残高の検証・消費・付与のビジネスロジック(ADR-0012)。 - -HTTP 知識(ステータスコード・HTTPException)は持ち込まない。 -router 側で ``InsufficientCreditsError`` → 402 ``INSUFFICIENT_CREDITS`` に変換する。 -""" - -import logging -from typing import Literal - -from sqlalchemy.exc import IntegrityError -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, *, description: str | None = None -) -> int | None: - """LLM 利用 1 回分の使用量を記録し、有料モデルならクレジットを消費する。 - - 無料モデルは使用ログのみ記録して None を返す。有料モデルは残高減算・台帳追記・ - 使用ログ記録を単一トランザクションで原子的に確定し、適用後残高を返す。記録の - 失敗(途中での例外)は全体を rollback して呼び出し元へ伝播させる - (課金漏れ・課金とログの不整合を黙って通さない方針 / ADR-0012)。 - - description は台帳の用途表示。省略時は既定のチャット文言(経歴書ドラフト生成 - など別用途の呼び出し元が上書きする / ADR-0018)。 - """ - 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=description or 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 record_usage_after_llm( - db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None -) -> None: - """LLM 応答後のクレジット消費・使用ログ記録を、ストリームを開き直してから行う。 - - LLM 呼び出しの await 中にリクエストの DB セッションがアイドルになり、libSQL - (Hrana over HTTP)のストリームが idle timeout で失効する。失効したまま commit - すると ``STREAM_EXPIRED`` で 400 → 500 になり課金記録も落ちるため、``db.close()`` で - 失効ストリームを解放してから記録する(次の SELECT/commit が新規コネクション=新規 - Hrana ストリームを取得して正常に確定できる)。LLM を await する各 router 共通の後処理。 - """ - db.close() - record_chat_usage(db, user_id, usage, description=description) - - -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, - ) - - -def record_stripe_purchase( - db: Session, - user_id: str, - credits: int, - *, - stripe_session_id: str, -) -> int | None: - """Stripe Checkout の入金確定でクレジットを付与する(Webhook から呼ぶ / ADR-0012)。 - - 付与の冪等性は ``credit_transactions.stripe_session_id`` の UNIQUE 制約で担保する。 - 既に同一セッションで付与済みなら付与せず ``None`` を返す(Webhook 再送に対する冪等性)。 - 付与した場合は適用後残高を返す。 - """ - repo = BillingRepository(db, user_id) - if repo.find_by_stripe_session_id(stripe_session_id) is not None: - logger.info("Stripe 購入は処理済みのため付与をスキップ: session_id=%s", stripe_session_id) - return None - try: - balance_after = grant_credits( - db, - user_id, - credits, - transaction_type=TRANSACTION_TYPE_PURCHASE, - description=f"クレジット購入({credits:,} クレジット)", - stripe_session_id=stripe_session_id, - ) - except IntegrityError: - # 事前チェックと INSERT の間に同一イベントが先に commit した競合。二重付与を防ぐ。 - # apply_transaction が既に rollback 済みのため、ここでは付与スキップとして扱う - logger.info("Stripe 購入の競合を検出し付与をスキップ: session_id=%s", stripe_session_id) - return None - logger.info( - "Stripe 購入を付与: user_id=%s credits=%d balance_after=%d session_id=%s", - user_id, - credits, - balance_after, - stripe_session_id, - ) - return balance_after diff --git a/backend/app/services/billing/pricing.py b/backend/app/services/billing/pricing.py deleted file mode 100644 index e03d277b..00000000 --- a/backend/app/services/billing/pricing.py +++ /dev/null @@ -1,37 +0,0 @@ -"""クレジットパック(購入単位)の定義(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/app/services/billing/stripe_service.py b/backend/app/services/billing/stripe_service.py deleted file mode 100644 index 6e057c17..00000000 --- a/backend/app/services/billing/stripe_service.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Stripe Checkout 連携(ADR-0012 Phase 2)。 - -カード情報は Stripe ホストの決済ページで入力され、自サーバーは一切扱わない -(PCI DSS 非対象)。入金確定は Webhook(``checkout.session.completed``)が正で、 -署名検証を必須とする。リダイレクト戻り(success_url)は付与に使わない。 - -HTTP 知識(HTTPException・ステータスコード)は持ち込まない。router 側で本モジュールの -ドメイン例外を ``raise_app_error`` へ変換する。 -""" - -import logging -from dataclasses import dataclass - -import stripe - -from ...core import settings - -logger = logging.getLogger(__name__) - -# Checkout の通貨。日本円は Stripe のゼロ十進通貨のため unit_amount = 円額そのもの -_CURRENCY = "jpy" -_PRODUCT_NAME = "DevForge クレジット" -# 入金確定イベント。これ以外は付与対象外として無視する -CHECKOUT_COMPLETED_EVENT = "checkout.session.completed" - - -class StripeNotConfiguredError(Exception): - """STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET が未設定(決済機能が無効)。""" - - -class StripeCheckoutError(Exception): - """Stripe API 呼び出しに失敗した(Checkout セッションを作成できない)。""" - - -class WebhookVerificationError(Exception): - """Webhook の署名検証に失敗した(不正なペイロード / 署名)。""" - - -@dataclass(frozen=True) -class CompletedPurchase: - """入金確定済みの購入情報(Webhook から付与に使う)。""" - - user_id: str - credits: int - stripe_session_id: str - - -def create_checkout_session( - *, user_id: str, credits: int, success_url: str, cancel_url: str -) -> str: - """クレジット購入の Checkout Session を作成し、決済ページ URL を返す。 - - 1 クレジット = ¥1(ADR-0012)。``price_data`` で動的に価格を渡し、Stripe ダッシュボードの - Price オブジェクトに依存しない。``metadata`` に user_id / credits を載せ、Webhook 側で - 付与に使う。 - - Raises: - StripeNotConfiguredError: STRIPE_SECRET_KEY が未設定。 - StripeCheckoutError: Stripe API 呼び出しに失敗、または URL が得られなかった。 - """ - secret_key = settings.get_stripe_secret_key() - if not secret_key: - raise StripeNotConfiguredError("STRIPE_SECRET_KEY が設定されていません") - try: - session = stripe.checkout.Session.create( - api_key=secret_key, - mode="payment", - line_items=[ - { - "price_data": { - "currency": _CURRENCY, - "product_data": {"name": _PRODUCT_NAME}, - "unit_amount": credits, # 1cr=¥1 / JPY はゼロ十進 - }, - "quantity": 1, - } - ], - success_url=success_url, - cancel_url=cancel_url, - client_reference_id=user_id, - metadata={"user_id": user_id, "credits": str(credits)}, - ) - except stripe.StripeError as exc: - # API キー等の秘密情報を含めないため例外型のみログに残す - logger.warning("Stripe Checkout セッション作成に失敗: %s", type(exc).__name__) - raise StripeCheckoutError(f"Stripe error: {type(exc).__name__}") from exc - - if not session.url: - raise StripeCheckoutError("Stripe が Checkout URL を返しませんでした") - return session.url - - -def parse_webhook_event(payload: bytes, signature: str | None) -> stripe.Event: - """Webhook ペイロードの署名を検証し、イベントを返す(fail-closed)。 - - 署名検証は STRIPE_WEBHOOK_SECRET 必須。秘密未設定・署名欠落・検証失敗・不正ペイロードは - 例外を送出し、付与処理に進ませない。 - - Raises: - StripeNotConfiguredError: STRIPE_WEBHOOK_SECRET が未設定。 - WebhookVerificationError: 署名欠落・検証失敗・不正ペイロード。 - """ - webhook_secret = settings.get_stripe_webhook_secret() - if not webhook_secret: - raise StripeNotConfiguredError("STRIPE_WEBHOOK_SECRET が設定されていません") - if not signature: - raise WebhookVerificationError("Stripe-Signature ヘッダーがありません") - try: - return stripe.Webhook.construct_event(payload, signature, webhook_secret) - except (ValueError, stripe.SignatureVerificationError) as exc: - logger.warning("Stripe Webhook 署名検証に失敗: %s", type(exc).__name__) - raise WebhookVerificationError( - f"Webhook verification failed: {type(exc).__name__}" - ) from exc - - -def extract_completed_purchase(event: stripe.Event) -> CompletedPurchase | None: - """入金確定イベントから付与情報を取り出す。付与対象でなければ ``None``。 - - 対象外イベント・未払い(``payment_status != "paid"``)・metadata 欠落/不正は ``None`` を返す - (Webhook は 200 で受領しつつ付与をスキップする / 課金漏れより安全側)。 - """ - if event["type"] != CHECKOUT_COMPLETED_EVENT: - return None - session = event["data"]["object"] - if session.get("payment_status") != "paid": - logger.info( - "Checkout 未払いのため付与をスキップ: status=%s", session.get("payment_status") - ) - return None - session_id = session.get("id") - metadata = session.get("metadata") or {} - user_id = metadata.get("user_id") - raw_credits = metadata.get("credits") - if not session_id or not user_id or raw_credits is None: - logger.warning("Checkout イベントの metadata が不足: session_id=%s", session_id) - return None - try: - credits = int(raw_credits) - except (TypeError, ValueError): - logger.warning("Checkout metadata の credits が不正: %r", raw_credits) - return None - if credits <= 0: - logger.warning("Checkout metadata の credits が非正: %d", credits) - return None - return CompletedPurchase( - user_id=user_id, credits=credits, stripe_session_id=session_id - ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 023ac98b..11d2cbe1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -31,8 +31,6 @@ dependencies = [ "anthropic[vertex]==0.117.0", "google-genai==2.12.1", "openai==2.46.0", - # Stripe Checkout / Webhook 署名検証(ADR-0012 Phase 2)。公式ライブラリを使う - "stripe==15.3.1", "PyGithub==2.9.1", "slowapi==0.1.10", "ruff==0.15.22", diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py deleted file mode 100644 index cfcfffdc..00000000 --- a/backend/tests/test_billing.py +++ /dev/null @@ -1,941 +0,0 @@ -"""クレジット課金(ADR-0012)の統合テスト。 - -LLM(外部 API)のみモックし、残高チェック・消費・台帳記録・使用ログは -実 DB(テスト用 SQLite セッション)を通す。 -""" - -from typing import cast, get_args - -import pytest -import stripe -from app.core import settings -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, stripe_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 _completed_event( - user_id: str, - credits: int, - *, - session_id: str = "cs_test_123", - payment_status: str = "paid", - event_type: str = "checkout.session.completed", -) -> dict: - """Stripe Webhook の checkout.session.completed イベント相当の dict を返す。 - - stripe.Event は dict ライクに振る舞うため、署名検証をモックする際はこの構造で代用できる。 - """ - return { - "type": event_type, - "data": { - "object": { - "id": session_id, - "payment_status": payment_status, - "metadata": {"user_id": user_id, "credits": str(credits)}, - } - }, - } - - -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 と無料/有料フラグが解決できる。""" - # Vertex AI の Anthropic model id は版指定が要る(Haiku は @20251001 必須 / ADR-0015) - assert MODEL_CATALOG["haiku"].model_id == "claude-haiku-4-5@20251001" - 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_model_catalog_providers_are_valid() -> None: - """全 spec の provider は既知の識別子(anthropic / google / openai)である。""" - valid = {"anthropic", "google", "openai"} - assert all(spec.provider in valid for spec in MODEL_CATALOG.values()) - - -def test_model_catalog_multi_provider_entries() -> None: - """Gemini / GPT エイリアスが正しいプロバイダ・実モデル ID で登録されている(ADR-0013)。""" - assert MODEL_CATALOG["gemini-flash"].provider == "google" - assert MODEL_CATALOG["gemini-flash"].model_id == "gemini-2.5-flash" - assert MODEL_CATALOG["gemini-pro"].provider == "google" - assert MODEL_CATALOG["gpt-mini"].provider == "openai" - assert MODEL_CATALOG["gpt"].provider == "openai" - - -def test_model_catalog_free_and_paid_tiers() -> None: - """低単価モデル(haiku / gemini-flash / gpt-mini)は無料枠、上位は有料(ADR-0013)。""" - for alias in ("haiku", "gemini-flash", "gpt-mini"): - assert MODEL_CATALOG[alias].is_free is True - assert calculate_credit_cost(alias, 10_000, 1_500) == 0 - for alias in ("sonnet", "gemini-pro", "gpt"): - assert MODEL_CATALOG[alias].is_free is False - # 有料モデルは非ゼロのトークンで必ずコストが出る - assert calculate_credit_cost(alias, 10_000, 1_500) > 0 - - -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 provider: 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@20251001" - 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 provider: 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_refreshes_session_before_recording_usage( - client: TestClient, monkeypatch -) -> None: - """LLM 応答後、消費記録の前に DB セッションを開き直す(Hrana STREAM_EXPIRED 回帰防止)。 - - libSQL(Hrana over HTTP)は LLM の await 中に idle stream が失効するため、消費記録の - commit 前に ``db.close()`` で失効ストリームを解放し、新規ストリームで commit させる。 - 本テストは「close が record_chat_usage より前に呼ばれる」順序と、消費が正しく確定する - ことを検証する。 - """ - fake = _FakeLLM( - response=_llm_json("self_pr", "改善した自己PR"), - input_tokens=1000, - output_tokens=1000, - ) - monkeypatch.setattr(chat_service, "get_llm_client", lambda provider: fake) - headers = auth_header(client, "billing-refresh") - user_id = _get_user_id(client, "billing-refresh") - credit_service.grant_credits( - client._db_session, - user_id, - 10_000, - transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT, - ) - - events: list[str] = [] - original_close = client._db_session.close - monkeypatch.setattr( - client._db_session, - "close", - lambda *a, **kw: (events.append("close"), original_close(*a, **kw))[1], - ) - import app.routers.agent as agent_router - - original_record = credit_service.record_chat_usage - - def _spy_record(*a, **kw): - events.append("record") - return original_record(*a, **kw) - - monkeypatch.setattr(agent_router.credit_service, "record_chat_usage", _spy_record) - - resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers) - - assert resp.status_code == 200 - # close が record より前に呼ばれている - assert events == ["close", "record"] - # セッション刷新後も消費が正しく確定している - assert BillingRepository(client._db_session, user_id).get_balance() == 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 provider: 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_requires_auth(client: TestClient, monkeypatch) -> None: - """トークン無しの /api/agent/chat は 401(get_current_user ガードの回帰防止)。 - - 認証が外れると未ログインで LLM を呼べてしまうため、ガード欠落を直接固定する。 - """ - fake = _FakeLLM(response=_llm_json("self_pr", "提案")) - monkeypatch.setattr(chat_service, "get_llm_client", lambda provider: fake) - - resp = client.post("/api/agent/chat", json=_chat_payload("haiku")) - - assert resp.status_code == 401 - # 認証で弾かれるため LLM は呼ばれない - assert fake.received_messages is None - - -@pytest.mark.parametrize("model", ["gemini-pro", "gpt"]) -def test_chat_paid_model_with_zero_balance_returns_402( - client: TestClient, monkeypatch, model: str -) -> None: - """有料モデル(gemini-pro / gpt)も残高 0 で 402。is_free 判定の SSoT 化を endpoint で固定。 - - 残高ゲートが sonnet だけでなく全有料モデルに効くこと(ハードコード回帰でバイパスされない)を守る。 - """ - fake = _FakeLLM(response=_llm_json("self_pr", "提案")) - monkeypatch.setattr(chat_service, "get_llm_client", lambda provider: fake) - headers = auth_header(client, f"billing-empty-{model}") - - resp = client.post("/api/agent/chat", json=_chat_payload(model), headers=headers) - - assert resp.status_code == 402 - assert resp.json()["code"] == "INSUFFICIENT_CREDITS" - # 残高不足で弾かれるため LLM は呼ばれない - 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 provider: 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 provider: 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 provider: 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 provider: 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 provider: 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 provider: 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 provider: 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 provider: 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 - - -# --- ユニットテスト(Stripe 購入の冪等性 / ADR-0012 Phase 2) --- - - -def test_record_stripe_purchase_grants_once(client: TestClient) -> None: - """同一 stripe_session_id の Webhook 再送は二重付与しない(UNIQUE 制約 + 事前チェック)。""" - auth_header(client, "billing-purchase-idem") - user_id = _get_user_id(client, "billing-purchase-idem") - db = client._db_session - - first = credit_service.record_stripe_purchase( - db, user_id, 1_000, stripe_session_id="cs_idem_1" - ) - second = credit_service.record_stripe_purchase( - db, user_id, 1_000, stripe_session_id="cs_idem_1" - ) - - assert first == 1_000 - # 2 回目は処理済みのため None(付与スキップ) - assert second is None - repo = BillingRepository(db, user_id) - assert repo.get_balance() == 1_000 - purchases = [ - t for t in repo.list_transactions() - if t.transaction_type == credit_service.TRANSACTION_TYPE_PURCHASE - ] - assert len(purchases) == 1 - assert purchases[0].amount == 1_000 - assert purchases[0].stripe_session_id == "cs_idem_1" - - -def test_extract_completed_purchase_skips_unpaid() -> None: - """未払い(payment_status != paid)のイベントは付与対象にしない。""" - event = _completed_event("u1", 1_000, payment_status="unpaid") - # stripe.Event は dict ライクに振る舞う(helper は同等 dict を返す)。境界で型を合わせる。 - assert stripe_service.extract_completed_purchase(cast(stripe.Event, event)) is None - - -def test_extract_completed_purchase_skips_other_event_types() -> None: - """checkout.session.completed 以外のイベントは付与対象にしない。""" - event = _completed_event("u1", 1_000, event_type="payment_intent.created") - assert stripe_service.extract_completed_purchase(cast(stripe.Event, event)) is None - - -# --- 統合テスト(Checkout セッション作成) --- - - -def test_create_checkout_returns_url(client: TestClient, monkeypatch) -> None: - """POST /api/billing/checkout は Stripe Checkout の URL を返し、入力どおりに呼び出す。""" - headers = auth_header(client, "billing-checkout") - captured: dict = {} - - def _fake_create(*, user_id, credits, success_url, cancel_url): - captured.update( - user_id=user_id, - credits=credits, - success_url=success_url, - cancel_url=cancel_url, - ) - return "https://checkout.stripe.com/c/pay/cs_test_123" - - monkeypatch.setattr(stripe_service, "create_checkout_session", _fake_create) - - resp = client.post("/api/billing/checkout", json={"credits": 1_000}, headers=headers) - - assert resp.status_code == 200 - assert resp.json() == {"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_123"} - assert captured["credits"] == 1_000 - # success/cancel URL はサーバー側で信頼済みベース URL から組み立てる(オープンリダイレクト防止) - assert captured["success_url"].endswith("/billing?checkout=success") - assert captured["cancel_url"].endswith("/billing?checkout=cancel") - - -def test_create_checkout_requires_auth(client: TestClient) -> None: - """未ログインの Checkout 作成は 401。""" - resp = client.post("/api/billing/checkout", json={"credits": 1_000}) - assert resp.status_code == 401 - - -def test_create_checkout_rejects_out_of_range(client: TestClient) -> None: - """購入クレジットが下限未満はスキーマ検証で 422。""" - headers = auth_header(client, "billing-checkout-range") - resp = client.post("/api/billing/checkout", json={"credits": 1}, headers=headers) - assert resp.status_code == 422 - - -def test_create_checkout_unconfigured_returns_503(client: TestClient, monkeypatch) -> None: - """STRIPE_SECRET_KEY 未設定なら 503 PAYMENT_ERROR(決済機能無効)。""" - headers = auth_header(client, "billing-checkout-unconfigured") - monkeypatch.setattr(settings, "get_stripe_secret_key", lambda: "") - - resp = client.post("/api/billing/checkout", json={"credits": 1_000}, headers=headers) - - assert resp.status_code == 503 - assert resp.json()["code"] == "PAYMENT_ERROR" - - -def test_create_checkout_stripe_error_returns_502(client: TestClient, monkeypatch) -> None: - """Stripe API 呼び出し失敗は 502 PAYMENT_ERROR。""" - headers = auth_header(client, "billing-checkout-error") - - def _boom(**_kwargs): - raise stripe_service.StripeCheckoutError("Stripe error: APIError") - - monkeypatch.setattr(stripe_service, "create_checkout_session", _boom) - - resp = client.post("/api/billing/checkout", json={"credits": 1_000}, headers=headers) - - assert resp.status_code == 502 - assert resp.json()["code"] == "PAYMENT_ERROR" - - -# --- 統合テスト(Stripe Webhook 入金処理) --- - - -def test_webhook_grants_credits_on_completed(client: TestClient, monkeypatch) -> None: - """checkout.session.completed(支払い済み)でクレジットを付与し purchase 台帳を記録する。""" - auth_header(client, "billing-webhook") - user_id = _get_user_id(client, "billing-webhook") - client.cookies.clear() # Stripe からの受信を模す(Cookie なし → CSRF 対象外) - event = _completed_event(user_id, 2_000, session_id="cs_webhook_1") - monkeypatch.setattr(stripe_service, "parse_webhook_event", lambda *_a, **_k: event) - - resp = client.post( - "/api/billing/webhook", - content=b"{}", - headers={"Stripe-Signature": "t=1,v1=sig"}, - ) - - assert resp.status_code == 200 - assert resp.json() == {"received": True} - repo = BillingRepository(client._db_session, user_id) - assert repo.get_balance() == 2_000 - purchases = [ - t for t in repo.list_transactions() - if t.transaction_type == credit_service.TRANSACTION_TYPE_PURCHASE - ] - assert len(purchases) == 1 - assert purchases[0].amount == 2_000 - assert purchases[0].stripe_session_id == "cs_webhook_1" - - -def test_webhook_is_idempotent_on_resend(client: TestClient, monkeypatch) -> None: - """同一イベントの再送では二重付与しない(Webhook 再送に対する冪等性)。""" - auth_header(client, "billing-webhook-idem") - user_id = _get_user_id(client, "billing-webhook-idem") - client.cookies.clear() # Stripe からの受信を模す(Cookie なし → CSRF 対象外) - event = _completed_event(user_id, 2_000, session_id="cs_webhook_resend") - monkeypatch.setattr(stripe_service, "parse_webhook_event", lambda *_a, **_k: event) - - first = client.post("/api/billing/webhook", content=b"{}", headers={"Stripe-Signature": "s"}) - second = client.post("/api/billing/webhook", content=b"{}", headers={"Stripe-Signature": "s"}) - - assert first.status_code == 200 - assert second.status_code == 200 - repo = BillingRepository(client._db_session, user_id) - # 2 回受信しても付与は 1 回分のみ - assert repo.get_balance() == 2_000 - purchases = [ - t for t in repo.list_transactions() - if t.transaction_type == credit_service.TRANSACTION_TYPE_PURCHASE - ] - assert len(purchases) == 1 - - -def test_webhook_invalid_signature_returns_400(client: TestClient, monkeypatch) -> None: - """署名検証に失敗したら 400(付与しない)。""" - def _bad_sig(*_a, **_k): - raise stripe_service.WebhookVerificationError("Webhook verification failed") - - monkeypatch.setattr(stripe_service, "parse_webhook_event", _bad_sig) - - resp = client.post("/api/billing/webhook", content=b"{}", headers={"Stripe-Signature": "bad"}) - - assert resp.status_code == 400 - - -def test_webhook_unconfigured_returns_503(client: TestClient) -> None: - """STRIPE_WEBHOOK_SECRET 未設定なら署名検証できず 503(fail-closed)。""" - resp = client.post("/api/billing/webhook", content=b"{}", headers={"Stripe-Signature": "x"}) - assert resp.status_code == 503 - - -def test_webhook_ignores_non_completed_event(client: TestClient, monkeypatch) -> None: - """付与対象外イベントは 200 で受領しつつ付与しない。""" - auth_header(client, "billing-webhook-other") - user_id = _get_user_id(client, "billing-webhook-other") - client.cookies.clear() # Stripe からの受信を模す(Cookie なし → CSRF 対象外) - event = _completed_event(user_id, 2_000, event_type="payment_intent.created") - monkeypatch.setattr(stripe_service, "parse_webhook_event", lambda *_a, **_k: event) - - resp = client.post("/api/billing/webhook", content=b"{}", headers={"Stripe-Signature": "s"}) - - assert resp.status_code == 200 - assert BillingRepository(client._db_session, user_id).get_balance() == 0 - - -def test_webhook_unknown_user_returns_200_without_grant(client: TestClient, monkeypatch) -> None: - """metadata のユーザーが存在しなくても 200 で受領する(再送で解決しないため)。""" - event = _completed_event("no-such-user-id", 2_000, session_id="cs_unknown") - monkeypatch.setattr(stripe_service, "parse_webhook_event", lambda *_a, **_k: event) - - resp = client.post("/api/billing/webhook", content=b"{}", headers={"Stripe-Signature": "s"}) - - assert resp.status_code == 200 - assert resp.json() == {"received": True} - - -def test_webhook_path_is_internal_secret_exempt() -> None: - """Webhook は Cloudflare を経由せず Cloud Run へ直接届くため InternalSecret 検証の対象外。""" - import app.main as main_module - - assert "/api/billing/webhook" in main_module._INTERNAL_SECRET_SKIP_PATHS diff --git a/backend/tests/test_resume_draft_api.py b/backend/tests/test_resume_draft_api.py index 7797c85e..37baeb2a 100644 --- a/backend/tests/test_resume_draft_api.py +++ b/backend/tests/test_resume_draft_api.py @@ -104,13 +104,6 @@ def test_resume_draft_run_requires_github_login(client: TestClient) -> None: assert res.status_code == 403 -def test_resume_draft_run_requires_credits_for_paid_model(client: TestClient) -> None: - """有料モデルは残高 0 だと enqueue 前に 402。""" - headers = auth_header(client, github_id=1) - res = client.post("/api/agent/resume-draft/run", json={"model": "sonnet"}, headers=headers) - assert res.status_code == 402 - - def test_resume_draft_run_conflict_without_link_cache(client: TestClient) -> None: """連携未実行は 409(GitHub 連携の実行を促す)。""" headers = auth_header(client, github_id=1) diff --git a/backend/tests/test_skill_display_api.py b/backend/tests/test_skill_display_api.py index 12ebd15c..667cf213 100644 --- a/backend/tests/test_skill_display_api.py +++ b/backend/tests/test_skill_display_api.py @@ -1,6 +1,6 @@ """スキル表示名の human-in-the-loop 提案・確定 API のテスト(ADR-0016 D11)。 -DB はモックせず実 SQLite セッションに当てる。LLM のみモックする。propose の 402/502、 +DB はモックせず実 SQLite セッションに当てる。LLM のみモックする。propose の 502、 confirm の authz、確定の反映、連携の洗い替えに対する確定の耐性を検証する。 """ @@ -143,17 +143,6 @@ def test_propose_404_when_only_languages(client) -> None: assert resp.status_code == 404 -def test_propose_402_for_paid_model_without_credits(client) -> None: - """有料モデルは残高 0 だと LLM を呼ぶ前に 402。""" - headers = auth_header(client, "disp_paid") - uid = _user_id(client, "disp_paid") - GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) - resp = client.post( - "/api/github-link/skills/display-names/propose", json={"model": "sonnet"}, headers=headers - ) - assert resp.status_code == 402 - - def test_propose_502_on_llm_error(client, monkeypatch) -> None: """LLM 失敗は 502(AGENT_LLM_ERROR)。""" headers = auth_header(client, "disp_llmerr") diff --git a/backend/tests/test_worker/test_resume_draft.py b/backend/tests/test_worker/test_resume_draft.py index 2dfd9b18..0719e7e2 100644 --- a/backend/tests/test_worker/test_resume_draft.py +++ b/backend/tests/test_worker/test_resume_draft.py @@ -1,17 +1,17 @@ """run_resume_draft_task(経歴書ドラフト生成タスク本体 / ADR-0018 非同期化)の単体テスト。 -LLM のみモックし、連携データ読み取り・ルールベースマッピング・PDF レンダリング・課金配線は -実コードを通す(DB は実 SQLite セッション)。課金順序の不変条件を固定する: - - 成功: completed + result 保存 + 使用ログ記録(無料モデルは credit_cost=0)。 - - パース失敗: 消費済みトークンを課金してから NonRetryableError(worker が dead_letter 化)。 - - PDF 生成失敗: 課金せず NonRetryableError(使用ログを残さない)。 +LLM のみモックし、連携データ読み取り・ルールベースマッピング・PDF レンダリングは +実コードを通す(DB は実 SQLite セッション)。状態遷移の不変条件を固定する(課金は +ADR-0023 で撤去済み): + - 成功: completed + result 保存。 + - パース失敗: NonRetryableError(worker が dead_letter 化)。結果は保存しない。 + - PDF 生成失敗: NonRetryableError。結果は保存しない。 """ import json import pytest from app.models import GitHubLinkCache, ResumeDraftCache -from app.models.billing import AgentUsageLog from app.models.skill import GitHubSkill, GitHubSkillEvidence from app.repositories import UserRepository from app.services.agent.llm.base import LLMClient, LLMResult @@ -92,8 +92,8 @@ def _seed(db: Session, username: str = "draft-user", *, repos: bool = True): return user -def test_task_completes_and_records_usage(db_session, session_factory, monkeypatch): - """成功: status=completed + result 保存 + 使用ログ記録(無料モデルは credit_cost=0)。""" +def test_task_completes(db_session, session_factory, monkeypatch): + """成功: status=completed + result 保存。""" user = _seed(db_session) monkeypatch.setattr(draft_service, "get_llm_client", lambda provider: _FakeLLM(_draft_response())) @@ -106,15 +106,9 @@ def test_task_completes_and_records_usage(db_session, session_factory, monkeypat assert draft.result["career_summary"] == "生成された職務要約。" assert draft.completed_at is not None - (log,) = db_session.query(AgentUsageLog).all() - assert log.model_alias == "haiku" - assert log.input_tokens == 100 - assert log.output_tokens == 200 - assert log.credit_cost == 0 - -def test_task_parse_failure_charges_and_raises(db_session, session_factory, monkeypatch): - """パース失敗: 消費済みトークン(2 コール合算)を課金し NonRetryableError を送出する。""" +def test_task_parse_failure_raises(db_session, session_factory, monkeypatch): + """パース失敗: NonRetryableError を送出し、結果は保存しない。""" user = _seed(db_session) monkeypatch.setattr( draft_service, "get_llm_client", lambda provider: _FakeLLM("JSON ではない応答") @@ -124,13 +118,13 @@ def test_task_parse_failure_charges_and_raises(db_session, session_factory, monk _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"})) db_session.expire_all() - (log,) = db_session.query(AgentUsageLog).all() - assert log.input_tokens == 200 - assert log.output_tokens == 400 + draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one() + assert draft.result is None + assert draft.status != "completed" -def test_task_pdf_failure_not_charged(db_session, session_factory, monkeypatch): - """PDF 生成失敗: 課金せず NonRetryableError を送出する(使用ログを残さない)。""" +def test_task_pdf_failure_raises(db_session, session_factory, monkeypatch): + """PDF 生成失敗: NonRetryableError を送出し、結果は保存しない。""" user = _seed(db_session) monkeypatch.setattr(draft_service, "get_llm_client", lambda provider: _FakeLLM(_draft_response())) @@ -143,7 +137,9 @@ def _fail_pdf(_payload): _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"})) db_session.expire_all() - assert db_session.query(AgentUsageLog).count() == 0 + draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one() + assert draft.result is None + assert draft.status != "completed" def test_task_source_unavailable_raises_non_retryable(db_session, session_factory): @@ -154,10 +150,10 @@ def test_task_source_unavailable_raises_non_retryable(db_session, session_factor def test_task_skips_when_already_completed(db_session, session_factory, monkeypatch): - """冪等ガード: 既に completed + result のタスク再配信は再実行せず、課金も LLM 呼び出しもしない。 + """冪等ガード: 既に completed + result のタスク再配信は再実行せず LLM を呼ばない。 - 原子 commit 後・worker の ack 前にプロセスが落ちると Cloud Tasks が同一メッセージを - 再配信しうる。フェーズA の短絡が無いと LLM を再実行して二重課金するため、その回帰を固定する。 + 結果保存後・worker の ack 前にプロセスが落ちると Cloud Tasks が同一メッセージを + 再配信しうる。フェーズA の短絡が無いと LLM を無駄に再実行するため、その回帰を固定する。 """ user = _seed(db_session) draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one() @@ -173,8 +169,7 @@ def _fail_llm(provider): _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"})) db_session.expire_all() - # 課金(使用ログ)は発生しない。既存の completed 結果も上書きされない。 - assert db_session.query(AgentUsageLog).count() == 0 + # 既存の completed 結果は上書きされない(LLM 再実行なし)。 draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one() assert draft.status == "completed" assert draft.result["career_summary"] == "既存の結果。" diff --git a/backend/uv.lock b/backend/uv.lock index efdd72a3..f42e83ac 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -335,7 +335,6 @@ dependencies = [ { name = "sqlalchemy" }, { name = "sqlalchemy-libsql" }, { name = "starlette" }, - { name = "stripe" }, { name = "uvicorn", extra = ["standard"] }, { name = "weasyprint" }, ] @@ -371,7 +370,6 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==2.0.51" }, { name = "sqlalchemy-libsql", specifier = "==0.2.0" }, { name = "starlette", specifier = "==1.3.1" }, - { name = "stripe", specifier = "==15.3.1" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.51.0" }, { name = "weasyprint", specifier = "==69.0" }, ] @@ -1494,19 +1492,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] -[[package]] -name = "stripe" -version = "15.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/a0/52b623a5c07238af6282dfc07ba8c8a9b332f4d7ef69d0006e8a52e6b34d/stripe-15.3.1.tar.gz", hash = "sha256:2f9a8d961e5c2ec19149fb4e0b47d9709d014a68592cdb1f4efd044abc8d2359", size = 1516385, upload-time = "2026-07-16T00:47:46.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/7a/58f266c262bbf903e2eca438ef752ed0fcc7273c9a4bc42edc20effa962f/stripe-15.3.1-py3-none-any.whl", hash = "sha256:f7c87a9a9072db7316e9ba497b4dbaa1dbae51f67ca201ba6155fe8b6487e5e3", size = 2173610, upload-time = "2026-07-16T00:47:45.173Z" }, -] - [[package]] name = "tenacity" version = "9.1.4" diff --git a/docker-compose.yml b/docker-compose.yml index 8ca5d0c4..5a3aa0f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,9 +52,6 @@ services: OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS} # Agent の日次利用上限(#521 / ADR-0023)。未設定なら backend 既定値(50) AGENT_DAILY_LIMIT: ${AGENT_DAILY_LIMIT} - # 決済(Stripe Checkout / ADR-0012 Phase 2)。未設定なら購入機能は無効 - STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} - STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} LOG_LEVEL: ${LOG_LEVEL} LOG_FORMAT: ${LOG_FORMAT} APP_VERSION: ${APP_VERSION} diff --git a/docs/api.md b/docs/api.md index bc167fe8..d977eb10 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,17 +57,7 @@ REST API エンドポイント一覧と、バックエンド/フロントエ - `POST /api/notifications/read-all`: 全て既読 ### Agent(LLM チャット / ADR-0010) -- `POST /api/agent/chat`: 選択スコープ(`project` / `experience` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min。`model` はマルチプロバイダ(ADR-0013)から選択可能 — 無料: `haiku` / `gemini-flash` / `gpt-mini`、有料(クレジット消費): `sonnet` / `gemini-pro` / `gpt`。プロバイダはモデルエイリアスに紐づいて切り替わる。有料モデルで残高不足の場合は 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/checkout`: クレジット購入の Stripe Checkout セッションを作成し決済ページ URL を返す(Phase 2・要ログイン・rate limit) -- `POST /api/billing/webhook`: Stripe Webhook(`checkout.session.completed`)でクレジットを付与(Phase 2・`Stripe-Signature` 署名検証必須・認証ガード無し) -- `POST /api/billing/admin/grant`: ユーザーへのクレジット付与(管理者・`ADMIN_TOKEN` Bearer 認証) +- `POST /api/agent/chat`: 選択スコープ(`project` / `experience` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min に加え、ユーザ単位の日次上限(`AGENT_DAILY_LIMIT`)で abuse を防ぐ(超過は 429 `AGENT_DAILY_LIMIT_EXCEEDED` / ADR-0023 で課金は撤去)。`model` はモデルエイリアスから選択可能(マルチプロバイダは #523 で Haiku + Ollama へ縮退予定) ### 内部 API(Cloud Tasks コールバック専用) - `POST /internal/tasks/{task_type}`: Cloud Tasks からのタスク実行リクエストを受け付ける。`TASK_RUNNER=cloud_tasks` の場合は `X-CloudTasks-QueueName` ヘッダで検証 @@ -138,13 +128,6 @@ REST API エンドポイント一覧と、バックエンド/フロントエ | `OLLAMA_TIMEOUT_SECONDS` | ローカル Ollama 呼び出しの HTTP タイムアウト秒数(既定: `300`) | | `AGENT_DAILY_LIMIT` | Agent エンドポイント(`/agent/chat`・`/agent/resume-draft/run`)のユーザ単位日次リクエスト上限(#521・ADR-0023。未設定時の既定: `50`)。本番は未設定=既定値で運用 | -### 決済(Stripe Checkout / ADR-0012 Phase 2) - -| 変数 | 用途 | -|---|---| -| `STRIPE_SECRET_KEY` | Stripe シークレットキー(本番は Secret Manager `stripe-secret-key` から注入)。未設定なら購入機能は無効(`/api/billing/checkout` が 503) | -| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 署名シークレット(`whsec_...`、本番は Secret Manager `stripe-webhook-secret` から注入)。`/api/billing/webhook` の署名検証に必須 | - ### 運用・ロギング | 変数 | 用途 | diff --git a/infra/modules/cloud_run/main.tf b/infra/modules/cloud_run/main.tf index 6383685b..4bc20be9 100644 --- a/infra/modules/cloud_run/main.tf +++ b/infra/modules/cloud_run/main.tf @@ -14,9 +14,6 @@ locals { # (least privilege)。apply で当該シークレットが destroy される点に注意。 # OpenAI のみ GCP に存在せず API キーを継続使用(注入は enable_extra_llm_providers が true の環境のみ) "openai-api-key", - # 決済(Stripe Checkout / ADR-0012 Phase 2)の API キーと Webhook 署名シークレット - "stripe-secret-key", - "stripe-webhook-secret", # 棚卸し TODO: "field-encryption-key"(FIELD_ENCRYPTION_KEY / Fernet 鍵)は # このリストから除外し対応する Secret Manager シークレットを削除すること。 # 削除前に全環境(dev/stg/prod)の Cloud Run 設定から環境変数を外すこと。 @@ -28,8 +25,6 @@ locals { JWT_PUBLIC_KEY = "jwt-public-key" INTERNAL_SECRET = "internal-secret" TURSO_AUTH_TOKEN = "turso-auth-token" - STRIPE_SECRET_KEY = "stripe-secret-key" - STRIPE_WEBHOOK_SECRET = "stripe-webhook-secret" } github_secret_env = var.enable_github_oauth ? { GITHUB_CLIENT_ID = "github-client-id" diff --git a/web/e2e/agent-chat.spec.ts b/web/e2e/agent-chat.spec.ts index 747926bc..1b7abac2 100644 --- a/web/e2e/agent-chat.spec.ts +++ b/web/e2e/agent-chat.spec.ts @@ -104,14 +104,6 @@ test.describe("Agent チャットウィジェット", () => { 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() ?? "{}"); @@ -126,14 +118,12 @@ test.describe("Agent チャットウィジェット", () => { await waitForAuthenticatedLayout(page); const sidebar = page.locator("aside").first(); - // 使用モデル・残高はサイドバーに常時表示される(既定は Haiku) + // 使用モデルはサイドバーに常時表示される(既定は 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 4.6", { exact: true })).toBeVisible(); @@ -147,37 +137,6 @@ test.describe("Agent チャットウィジェット", () => { 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/web/e2e/billing.spec.ts b/web/e2e/billing.spec.ts deleted file mode 100644 index 6a2cb189..00000000 --- a/web/e2e/billing.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; -import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; - -/** - * トークン購入画面(ADR-0012)E2E。 - * UserMenu の「クレジットを購入」→ /billing。残高・パック・履歴が表示され、 - * 購入ボタン押下で Stripe Checkout セッションを作成し決済ページへ遷移する(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 4\.6 約166回/)).toBeVisible(); - - // preset で入力欄が埋まる - await page.getByRole("button", { name: "1,100" }).click(); - await expect(main.getByText("¥1,100")).toBeVisible(); - - // 購入ボタン → Checkout セッション作成 → 返却 URL(決済ページ)へリダイレクト。 - // スタブ URL は baseURL / ポート差異に強くするため現在のページオリジンから動的に組み立てる - const stubUrl = new URL("/__stripe_stub", page.url()).href; - await page.route("**/api/billing/checkout", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ checkout_url: stubUrl }), - }), - ); - // 外部決済ページの代わりに同一オリジンのスタブへ遷移させ、ナビゲーションを安定させる - await page.route("**/__stripe_stub", (route) => - route.fulfill({ - status: 200, - contentType: "text/html", - body: "stripe checkout stub", - }), - ); - - const checkoutRequest = page.waitForRequest("**/api/billing/checkout"); - await page.getByRole("button", { name: "購入する" }).click(); - const request = await checkoutRequest; - expect(request.postDataJSON()).toEqual({ credits: 1100 }); - // 決済ページ(スタブ)へ遷移する - await expect(page).toHaveURL(/__stripe_stub/); -}); diff --git a/web/e2e/helpers/auth.ts b/web/e2e/helpers/auth.ts index 05e38c90..7d9ce6c3 100644 --- a/web/e2e/helpers/auth.ts +++ b/web/e2e/helpers/auth.ts @@ -45,36 +45,6 @@ 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/web/src/api/billing.ts b/web/src/api/billing.ts deleted file mode 100644 index be586802..00000000 --- a/web/src/api/billing.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { request } from "./client"; -import { PATHS } from "./paths"; -import type { - AgentUsageSummaryEntry, - CheckoutSessionResponse, - 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" }); -} - -/** - * クレジット購入の Stripe Checkout セッションを作成し、決済ページの URL を取得する(ADR-0012)。 - * 返却された URL へリダイレクトすると Stripe ホストの決済ページに遷移する。 - */ -export function createCheckoutSession(credits: number): Promise { - return request(PATHS.billing.checkout, { - method: "POST", - body: JSON.stringify({ credits }), - }); -} diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index f2703ffc..5881e149 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -21,9 +21,9 @@ export interface paths { * @description 選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 * * career_summary / self_pr スコープでは GitHub 分析サマリーを参照情報として付与する。 - * レスポンスはフロントの state にのみ適用され、DB は更新しない - * (クレジット消費・使用ログの記録は除く / ADR-0012)。 + * レスポンスはフロントの state にのみ適用され、DB は更新しない(ADR-0010)。 * ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 + * abuse 防止は日次レート制限で行う(ADR-0023 で課金を撤去)。 */ post: operations["agent_chat_api_agent_chat_post"]; delete?: never; @@ -72,7 +72,7 @@ export interface paths { * (職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。生成物(payload)は * ``resume_draft_cache`` に保存され、``GET /resume-draft/pdf`` でダウンロードできる。 * 確定した職務経歴書(``resumes``)とは別物で、そちらには書き込まない。 - * 課金は生成タスク側で確定する(残高の事前チェックのみ本エンドポイントで行う / ADR-0012)。 + * abuse 防止は日次レート制限で行う(ADR-0023 で課金を撤去)。 */ post: operations["start_resume_draft_api_agent_resume_draft_run_post"]; delete?: never; @@ -101,186 +101,6 @@ 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/checkout": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create Checkout - * @description クレジット購入の Stripe Checkout セッションを作成し、決済ページ URL を返す(ADR-0012)。 - * - * 外部 API(Stripe)を呼ぶ高コスト endpoint のため rate limit を付与する。 - * 入金確定は Webhook(checkout.session.completed)が正であり、本エンドポイントは - * 決済ページへ誘導する URL を返すだけで残高は更新しない。 - */ - post: operations["create_checkout_api_billing_checkout_post"]; - 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/billing/webhook": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Stripe Webhook - * @description Stripe Webhook(checkout.session.completed)でクレジットを付与する(ADR-0012)。 - * - * 署名検証必須。入金確定はこのエンドポイントが正で、付与の冪等性は - * credit_transactions.stripe_session_id の UNIQUE 制約で担保する。呼び出し元は Stripe の - * ため get_current_user は付けない(認証 Cookie は届かない)。Cloudflare を経由せず - * Cloud Run へ直接届くため InternalSecretMiddleware の対象外にしてある(main.py 参照)。 - */ - post: operations["stripe_webhook_api_billing_webhook_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/github-link/cache": { parameters: { query?: never; @@ -457,7 +277,8 @@ export interface paths { * * agent は提案するだけで確定・DB 更新はしない(D8 / P4)。提案結果はレスポンスとして返し、 * ユーザーがレビュー・編集して ``PUT /skills/display-decisions`` で確定する。 - * 外部 LLM を呼ぶ高コスト endpoint のため rate limit を付与し、課金はチャットと同一契約。 + * 外部 LLM を呼ぶ高コスト endpoint のため、slowapi の分間 rate limit に加えて + * ユーザ単位の日次レート制限で abuse を防ぐ(ADR-0023 で課金を撤去した代替)。 */ post: operations["propose_skill_display_names_endpoint_api_github_link_skills_display_names_propose_post"]; delete?: never; @@ -899,18 +720,6 @@ 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 チャットのリクエスト。スコープ選択は必須。 @@ -1090,22 +899,6 @@ 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; - }; /** * AnalyzedRepoSummary * @description 連携で分析したリポジトリ 1 件分のサマリ(ADR-0018)。 @@ -1153,22 +946,6 @@ export interface components { /** Warning Message */ warning_message?: string | null; }; - /** - * CheckoutSessionRequest - * @description クレジット購入の Stripe Checkout セッション作成リクエスト(ADR-0012 Phase 2)。 - */ - CheckoutSessionRequest: { - /** Credits */ - credits: number; - }; - /** - * CheckoutSessionResponse - * @description Stripe Checkout 決済ページの URL。フロントはこの URL へリダイレクトする。 - */ - CheckoutSessionResponse: { - /** Checkout Url */ - checkout_url: string; - }; /** * Client * @description ユーザ(常駐先/クライアント企業)。 @@ -1257,49 +1034,6 @@ 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: { /** Business Description */ @@ -1535,21 +1269,6 @@ 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 通知レスポンス。 @@ -2211,196 +1930,6 @@ 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"]; - }; - }; - }; - }; - create_checkout_api_billing_checkout_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CheckoutSessionRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CheckoutSessionResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - 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"][]; - }; - }; - }; - }; - stripe_webhook_api_billing_webhook_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: boolean; - }; - }; - }; - }; - }; get_cache_api_github_link_cache_get: { parameters: { query?: never; diff --git a/web/src/api/paths.ts b/web/src/api/paths.ts index a1558d0b..9aa17c7e 100644 --- a/web/src/api/paths.ts +++ b/web/src/api/paths.ts @@ -33,14 +33,6 @@ export const PATHS = { resumeDraftStatus: "/api/agent/resume-draft/status", resumeDraftPdf: "/api/agent/resume-draft/pdf", }, - billing: { - balance: "/api/billing/balance", - transactions: "/api/billing/transactions", - usageSummary: "/api/billing/usage-summary", - packs: "/api/billing/packs", - modelRates: "/api/billing/model-rates", - checkout: "/api/billing/checkout", - }, resumes: { base: "/api/resumes", latest: "/api/resumes/latest", diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9e52e97f..1bd7b15a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -162,26 +162,3 @@ 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"]; - -/** クレジット購入の Checkout セッション作成リクエスト。backend `schemas/billing.py:CheckoutSessionRequest`。 */ -export type CheckoutSessionRequest = Schemas["CheckoutSessionRequest"]; - -/** Stripe Checkout 決済ページの URL。backend `schemas/billing.py:CheckoutSessionResponse`。 */ -export type CheckoutSessionResponse = Schemas["CheckoutSessionResponse"]; diff --git a/web/src/components/SidebarLayout.tsx b/web/src/components/SidebarLayout.tsx index f02918ad..11729b53 100644 --- a/web/src/components/SidebarLayout.tsx +++ b/web/src/components/SidebarLayout.tsx @@ -10,8 +10,6 @@ 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"; @@ -72,9 +70,6 @@ export function SidebarLayout({ return (
- {/* 残高(アカウント属性)をサイドバーと Agent ウィジェットで共有する(ADR-0012)。 - 認証済みの間だけ取得し、ウィジェットの有料モデル消費後に refresh で更新される。 */} -
@@ -169,9 +164,8 @@ export function SidebarLayout({ )}
- {/* AI ステータス(使用モデル + クレジット残高)は認証済みのみ表示。 */} + {/* AI ステータス(使用モデル)は認証済みのみ表示。 */} {isAuthenticated && } - {isAuthenticated && } {/* 通知ベルは認証済みのみ(未認証はポーリングで 401 を連発しないよう出さない)。 */} {isAuthenticated && } setModelSelectOpen(true) : undefined} - onOpenBilling={isAuthenticated ? () => navigate("/billing") : undefined} />
{children ?? }
- {/* モデル選択モーダルは残高 Provider 配下(残高不足の警告表示に balance を使う)。 */} {isAuthenticated && modelSelectOpen && ( setModelSelectOpen(false)} /> )} -
); } diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index b410a7d4..e14b038a 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react"; import { AGENT_MODEL_MESSAGES, AUTH_PROMPT_MESSAGES, - BILLING_PAGE_MESSAGES, EXTERNAL_LINKS, UI_MESSAGES, } from "../constants/messages"; @@ -26,7 +25,6 @@ export function UserMenu({ onLogout, onLogin, onOpenModelSelect, - onOpenBilling, }: { isAuthenticated: boolean; username: string | null; @@ -34,10 +32,8 @@ export function UserMenu({ onToggleTheme: () => void; onLogout: () => void; onLogin: () => void; - /** AI モデル選択モーダルを開く(認証済みのみ表示 / ADR-0012)。 */ + /** AI モデル選択モーダルを開く(認証済みのみ表示)。 */ onOpenModelSelect?: () => void; - /** トークン購入画面へ遷移する(認証済みのみ表示。モデル切り替えとは分離 / ADR-0012)。 */ - onOpenBilling?: () => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -79,20 +75,7 @@ export function UserMenu({ {AGENT_MODEL_MESSAGES.MENU_ITEM} )} - {/* トークン購入はモデル切り替えとは別項目(購入と切り替えを分離 / ADR-0012)。 */} - {isAuthenticated && onOpenBilling && ( - - )} - {(onOpenModelSelect || onOpenBilling) && isAuthenticated && ( + {onOpenModelSelect && isAuthenticated && (
)} {/* 未認証ではログインはトリガー側のボタンに集約するため、メニューには出さない。 */} diff --git a/web/src/components/agent/AgentModelBadge.tsx b/web/src/components/agent/AgentModelBadge.tsx index 76da88ef..e6bdf732 100644 --- a/web/src/components/agent/AgentModelBadge.tsx +++ b/web/src/components/agent/AgentModelBadge.tsx @@ -4,7 +4,7 @@ import { useAppSelector } from "../../store"; import styles from "./AgentModelBadge.module.css"; /** - * サイドバーに常時表示する「使用モデル」(ADR-0012)。表示専用。 + * サイドバーに常時表示する「使用モデル」。表示専用。 * 切り替えは UserMenu → モデル選択モーダルで行う(ここからは切り替えない)。 */ export function AgentModelBadge() { @@ -15,7 +15,6 @@ export function AgentModelBadge() {
{AGENT_MODEL_MESSAGES.SIDEBAR_LABEL} {option.name} - {option.isPaid && {AGENT_MODEL_MESSAGES.PAID_BADGE}}
); } diff --git a/web/src/components/agent/ModelSelectModal.test.tsx b/web/src/components/agent/ModelSelectModal.test.tsx index af6b03ab..36465552 100644 --- a/web/src/components/agent/ModelSelectModal.test.tsx +++ b/web/src/components/agent/ModelSelectModal.test.tsx @@ -1,41 +1,20 @@ import { configureStore } from "@reduxjs/toolkit"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { Provider } from "react-redux"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { 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 { AGENT_MODEL_OPTIONS } from "../../constants/agentModels"; +import { AGENT_MODEL_MESSAGES } from "../../constants/messages"; import agentModelReducer from "../../store/agentModelSlice"; -import { - CreditBalanceContext, - type CreditBalanceContextValue, -} from "../billing/creditBalanceContext"; import { ModelSelectModal } from "./ModelSelectModal"; -// 表示文言は SSoT(constants/messages・agentModels)から引き、テストとメッセージ正本の乖離を防ぐ。 +// 表示文言は SSoT(constants/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") { @@ -45,38 +24,13 @@ function makeStore(model: "haiku" | "sonnet" = "haiku") { }); } -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 }[]; - rates?: { model: string; is_free: boolean; baseline_credits_per_chat: number }[]; -}) { - getAgentUsageSummaryMock.mockResolvedValue(opts.usage ?? []); - getModelRatesMock.mockResolvedValue(opts.rates ?? DEFAULT_RATES); +function renderModal(opts: { store?: Store; onClose?: () => void }) { const store = opts.store ?? makeStore(); - const balanceValue: CreditBalanceContextValue = { - balance: opts.balance ?? null, - loading: false, - error: null, - refresh: vi.fn(), - }; - const wrapper = (ui: ReactNode) => ( - - {ui} - - ); + 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({}); @@ -99,84 +53,4 @@ describe("ModelSelectModal", () => { 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("model_rates の is_free が静的 isPaid より優先される(rates で有料化された Haiku は有料バッジ)", async () => { - // runtime 正本は model_rates API。静的には無料の haiku を rates 側で有料に倒すと、 - // カードは有料バッジ + 残高不足警告(残高 0)を出す(PR-C: 表示を rates 由来へ)。 - renderModal({ - balance: 0, - rates: [{ model: "haiku", is_free: false, baseline_credits_per_chat: 8 }], - }); - const haikuCard = screen.getByRole("button", { name: haikuNamePattern }); - await waitFor(() => { - expect(within(haikuCard).getByText(AGENT_MODEL_MESSAGES.PAID_BADGE)).toBeTruthy(); - }); - expect(within(haikuCard).getByText(AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT)).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/web/src/components/agent/ModelSelectModal.tsx b/web/src/components/agent/ModelSelectModal.tsx index 3f812b8a..6e7e6d43 100644 --- a/web/src/components/agent/ModelSelectModal.tsx +++ b/web/src/components/agent/ModelSelectModal.tsx @@ -1,60 +1,26 @@ import type { AgentModelAlias } from "../../api/types"; -import { - CREDIT_ESTIMATE_REFERENCE, - getModelOptionsByProvider, -} from "../../constants/agentModels"; -import { - AGENT_MODEL_MESSAGES, - creditsForChatsLabel, - modelUsageLabel, -} from "../../constants/messages"; -import { useAgentUsageSummary } from "../../hooks/useAgentUsageSummary"; -import { useModelRates } from "../../hooks/useModelRates"; +import { getModelOptionsByProvider } from "../../constants/agentModels"; +import { AGENT_MODEL_MESSAGES } from "../../constants/messages"; 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。 + * AI モデル選択モーダル。Claude のプラン選択のようなカード UI。 * * UserMenu から開く。カード選択で即時にグローバル設定(redux)へ反映して閉じる。 - * 有料(Sonnet)は残高不足のとき警告を出すが、選択自体は許可する - * (送信時に 402 で課金導線へ誘導する設計 / ADR-0012)。 + * ADR-0023 で課金を撤去したため、全モデルが無料で利用できる(マルチプロバイダは + * #523 で Haiku + Ollama へ縮退予定)。 */ 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, ratesByModel } = useModelRates(true); - - // 有料/無料判定の runtime 正本は model_rates API(`is_free`)。取得済みならそれを使い、 - // 未取得(初期/ローディング/エラー)の間は agentModels の静的 isPaid をフォールバックにする。 - const isPaidModel = (option: { alias: AgentModelAlias; isPaid: boolean }): boolean => { - const rate = ratesByModel[option.alias]; - return rate ? !rate.is_free : option.isPaid; - }; 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 (
void }) {
{group.options.map((option) => { const isCurrent = option.alias === currentModel; - const paid = isPaidModel(option); - // 残高未取得(null: 初期/ローディング/エラー)の間は不足扱いにしない - const insufficient = paid && balance !== null && balance <= 0; - const usage = getUsage(option.alias); - const chatCount = usage?.chat_count ?? 0; - const creditCost = usage?.credit_cost ?? 0; - const perReference = paid - ? estimatePerReference(option.alias, creditCost, chatCount) - : null; return ( ); })} diff --git a/web/src/components/billing/BillingView.module.css b/web/src/components/billing/BillingView.module.css deleted file mode 100644 index 0b6ea2ca..00000000 --- a/web/src/components/billing/BillingView.module.css +++ /dev/null @@ -1,150 +0,0 @@ -.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/web/src/components/billing/BillingView.test.tsx b/web/src/components/billing/BillingView.test.tsx deleted file mode 100644 index 4bb4627d..00000000 --- a/web/src/components/billing/BillingView.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { BILLING_PAGE_MESSAGES } from "../../constants/messages"; - -const createCheckoutSessionMock = vi.fn(); -const refreshMock = vi.fn(); -const showSuccessMock = vi.fn(); -const showErrorMock = vi.fn(); - -vi.mock("../../api/billing", () => ({ - createCheckoutSession: (...args: unknown[]) => createCheckoutSessionMock(...args), -})); - -vi.mock("../../hooks/useBillingPage", () => ({ - useBillingPage: () => ({ - balance: 12000, - packs: [{ id: "starter", name: "スターター", price_jpy: 500, credits: 500 }], - transactions: [], - paidRate: 12, - loading: false, - error: null, - refresh: refreshMock, - }), -})); - -vi.mock("../ui/toast", () => ({ - useToast: () => ({ showSuccess: showSuccessMock, showError: showErrorMock }), -})); - -import { BillingView } from "./BillingView"; - -const originalLocation = window.location; - -/** - * window.location をテスト用のプレーンオブジェクトに差し替える。 - * jsdom の location.assign は spyOn で再定義できないため、location ごと置き換える。 - * history.replaceState はクエリ除去を再現するようモックする。 - */ -function stubLocation(search: string) { - const locationObj = { - href: `http://localhost/billing${search}`, - pathname: "/billing", - search, - assign: vi.fn(), - }; - Object.defineProperty(window, "location", { configurable: true, value: locationObj }); - vi.spyOn(window.history, "replaceState").mockImplementation(((_s, _t, url) => { - locationObj.search = ""; - if (url) locationObj.pathname = String(url); - }) as typeof window.history.replaceState); - return locationObj; -} - -beforeEach(() => { - createCheckoutSessionMock.mockReset(); - refreshMock.mockReset(); - showSuccessMock.mockReset(); - showErrorMock.mockReset(); -}); - -afterEach(() => { - Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); - vi.restoreAllMocks(); -}); - -describe("BillingView 決済導線(ADR-0012 Phase 2)", () => { - it("購入ボタンで Checkout セッションを作成し、返却 URL へリダイレクトする", async () => { - const location = stubLocation(""); - createCheckoutSessionMock.mockResolvedValue({ - checkout_url: "https://checkout.stripe.com/c/pay/cs_test_1", - }); - - render(); - fireEvent.change(screen.getByLabelText(BILLING_PAGE_MESSAGES.INPUT_LABEL), { - target: { value: "1000" }, - }); - fireEvent.click(screen.getByRole("button", { name: BILLING_PAGE_MESSAGES.PURCHASE_BUTTON })); - - await waitFor(() => { - expect(createCheckoutSessionMock).toHaveBeenCalledWith(1000); - }); - expect(location.assign).toHaveBeenCalledWith("https://checkout.stripe.com/c/pay/cs_test_1"); - }); - - it("Checkout 作成に失敗したらエラートーストを出す(リダイレクトしない)", async () => { - const location = stubLocation(""); - createCheckoutSessionMock.mockRejectedValue(new Error("失敗")); - - render(); - fireEvent.change(screen.getByLabelText(BILLING_PAGE_MESSAGES.INPUT_LABEL), { - target: { value: "1000" }, - }); - fireEvent.click(screen.getByRole("button", { name: BILLING_PAGE_MESSAGES.PURCHASE_BUTTON })); - - await waitFor(() => { - expect(showErrorMock).toHaveBeenCalled(); - }); - expect(location.assign).not.toHaveBeenCalled(); - }); - - it("?checkout=success で戻ると成功トーストを出し残高を再取得する", async () => { - const location = stubLocation("?checkout=success"); - - render(); - - await waitFor(() => { - expect(showSuccessMock).toHaveBeenCalledWith(BILLING_PAGE_MESSAGES.CHECKOUT_SUCCESS); - }); - expect(refreshMock).toHaveBeenCalled(); - // クエリは除去され、リロード時の再通知を防ぐ - expect(location.search).toBe(""); - }); - - it("?checkout=cancel で戻るとキャンセル通知のみ(再取得しない)", async () => { - const location = stubLocation("?checkout=cancel"); - - render(); - - await waitFor(() => { - expect(showSuccessMock).toHaveBeenCalledWith(BILLING_PAGE_MESSAGES.CHECKOUT_CANCELED); - }); - expect(refreshMock).not.toHaveBeenCalled(); - expect(location.search).toBe(""); - }); -}); diff --git a/web/src/components/billing/BillingView.tsx b/web/src/components/billing/BillingView.tsx deleted file mode 100644 index d706ad94..00000000 --- a/web/src/components/billing/BillingView.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { useEffect, useRef } from "react"; - -import { createCheckoutSession } from "../../api/billing"; -import { getModelOption } from "../../constants/agentModels"; -import { - BILLING_PAGE_MESSAGES, - FALLBACK_MESSAGES, - formatCreditAmount, - modelChatsEstimateLabel, - transactionAmountLabel, - transactionTypeLabel, -} from "../../constants/messages"; -import { useBillingPage } from "../../hooks/useBillingPage"; -import { toAppError } from "../../utils/appError"; -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; - -// Stripe Checkout の戻り先 URL に付与するクエリ(backend の success_url / cancel_url と一致) -const CHECKOUT_QUERY_KEY = "checkout"; -const CHECKOUT_RESULT_SUCCESS = "success"; -const CHECKOUT_RESULT_CANCEL = "cancel"; - -/** - * トークン購入画面(ADR-0012)。残高・購入パック・取引履歴を表示する。 - * - * 購入ボタン押下で Stripe Checkout セッションを作成し、決済ページへリダイレクトする。 - * 決済後は success_url / cancel_url(`?checkout=success|cancel`)へ戻り、結果に応じて - * トーストを表示して残高を再取得する(入金確定は Webhook が正のため反映に遅延がありうる)。 - */ -export function BillingView() { - const { balance, packs, transactions, paidRate, loading, error, refresh } = useBillingPage(); - const { showSuccess, showError } = useToast(); - // 決済結果トーストは初回マウント時に 1 度だけ処理する(refresh による再描画で多重発火させない) - const checkoutResultHandled = useRef(false); - - useEffect(() => { - if (checkoutResultHandled.current) return; - const params = new URLSearchParams(window.location.search); - const result = params.get(CHECKOUT_QUERY_KEY); - if (result !== CHECKOUT_RESULT_SUCCESS && result !== CHECKOUT_RESULT_CANCEL) return; - checkoutResultHandled.current = true; - - if (result === CHECKOUT_RESULT_SUCCESS) { - showSuccess(BILLING_PAGE_MESSAGES.CHECKOUT_SUCCESS); - void refresh(); - } else { - showSuccess(BILLING_PAGE_MESSAGES.CHECKOUT_CANCELED); - } - // クエリを除去してリロード時の再通知を防ぐ(履歴は置き換える) - window.history.replaceState({}, "", window.location.pathname); - }, [refresh, showSuccess]); - - const handlePurchase = async (credits: number) => { - try { - const { checkout_url } = await createCheckoutSession(credits); - // Stripe ホストの決済ページへ遷移する - window.location.assign(checkout_url); - } catch (e) { - showError(toAppError(e, FALLBACK_MESSAGES.CHECKOUT)); - } - }; - - 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.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/web/src/components/billing/CreditBalanceBadge.module.css b/web/src/components/billing/CreditBalanceBadge.module.css deleted file mode 100644 index c7d4aee2..00000000 --- a/web/src/components/billing/CreditBalanceBadge.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.badge { - display: flex; - flex-direction: column; - gap: 2px; - padding: 0.4rem 0.75rem; - margin-bottom: 0.4rem; - font-size: 0.78rem; - /* サイドバー背景は両テーマで暗色のため、本文用の --text-* ではなくサイドバー専用変数を使う - (ライトモードで暗背景に暗文字となり読めなくなるのを防ぐ)。 */ - color: var(--sidebar-text); - border-bottom: 1px solid var(--sidebar-border); -} - -.row { - display: flex; - align-items: center; - gap: 6px; -} - -.label { - color: var(--sidebar-text); -} - -.value { - font-weight: 600; - color: var(--sidebar-title); - font-variant-numeric: tabular-nums; -} - -.estimate { - font-size: 0.72rem; - color: var(--sidebar-text); - opacity: 0.8; -} - -.error { - color: var(--warning-text); -} diff --git a/web/src/components/billing/CreditBalanceBadge.tsx b/web/src/components/billing/CreditBalanceBadge.tsx deleted file mode 100644 index 1d610c05..00000000 --- a/web/src/components/billing/CreditBalanceBadge.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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/web/src/components/billing/CreditBalanceProvider.test.tsx b/web/src/components/billing/CreditBalanceProvider.test.tsx deleted file mode 100644 index 4f437d31..00000000 --- a/web/src/components/billing/CreditBalanceProvider.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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/web/src/components/billing/CreditBalanceProvider.tsx b/web/src/components/billing/CreditBalanceProvider.tsx deleted file mode 100644 index ff96f321..00000000 --- a/web/src/components/billing/CreditBalanceProvider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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/web/src/components/billing/CreditPurchaseForm.module.css b/web/src/components/billing/CreditPurchaseForm.module.css deleted file mode 100644 index 10d527d7..00000000 --- a/web/src/components/billing/CreditPurchaseForm.module.css +++ /dev/null @@ -1,112 +0,0 @@ -.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/web/src/components/billing/CreditPurchaseForm.test.tsx b/web/src/components/billing/CreditPurchaseForm.test.tsx deleted file mode 100644 index e9cbc731..00000000 --- a/web/src/components/billing/CreditPurchaseForm.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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/web/src/components/billing/CreditPurchaseForm.tsx b/web/src/components/billing/CreditPurchaseForm.tsx deleted file mode 100644 index 534a3efd..00000000 --- a/web/src/components/billing/CreditPurchaseForm.tsx +++ /dev/null @@ -1,115 +0,0 @@ -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/web/src/components/billing/creditBalanceContext.ts b/web/src/components/billing/creditBalanceContext.ts deleted file mode 100644 index fe8e5259..00000000 --- a/web/src/components/billing/creditBalanceContext.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/web/src/components/forms/AgentChatWidget.test.tsx b/web/src/components/forms/AgentChatWidget.test.tsx index 4dfab3cb..61151937 100644 --- a/web/src/components/forms/AgentChatWidget.test.tsx +++ b/web/src/components/forms/AgentChatWidget.test.tsx @@ -8,15 +8,10 @@ import { AGENT_MESSAGES } from "../../constants/messages"; import { createInitialCareerForm } from "../../formMappers"; import agentModelReducer from "../../store/agentModelSlice"; import formCacheReducer from "../../store/formCacheSlice"; -import { - CreditBalanceContext, - type CreditBalanceContextValue, -} from "../billing/creditBalanceContext"; import { ToastProvider } from "../ui/toast"; import { AgentChatWidget } from "./AgentChatWidget"; -// useAgentChat はネットワークを伴うため send をモックし、ウィジェット側の -// 「有料モデルなら送信後に残高を再取得する」配線だけを検証対象にする。 +// useAgentChat はネットワークを伴うため send をモックし、ウィジェットの送信配線を検証する。 const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() })); vi.mock("../../hooks/career/useAgentChat", () => ({ useAgentChat: () => ({ @@ -37,28 +32,18 @@ function makeStore(model: AgentModelAlias) { } function renderWidget(model: AgentModelAlias) { - const refresh = vi.fn().mockResolvedValue(undefined); - const balanceValue: CreditBalanceContextValue = { - balance: 1000, - loading: false, - error: null, - refresh, - }; render( - - - - - + + + , ); - return { refresh }; } /** パネルを開いて依頼文を入力し、送信ボタンを押すまでの操作。 */ @@ -75,31 +60,16 @@ beforeEach(() => { sendMock.mockResolvedValue(undefined); }); -describe("AgentChatWidget の残高再取得", () => { - it("有料モデル(gemini-pro)で送信すると送信後に残高を再取得する", async () => { - const { refresh } = renderWidget("gemini-pro"); - - openAndSend("自己PRを改善して"); - - await waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); - }); - - it("有料モデル(gpt)でも送信後に残高を再取得する", async () => { - const { refresh } = renderWidget("gpt"); - - openAndSend("職務要約を整えて"); - - await waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); - }); - - it("無料モデル(haiku)では残高を再取得しない", async () => { - const { refresh } = renderWidget("haiku"); +describe("AgentChatWidget の送信", () => { + it("依頼文を入力して送信すると選択中モデルで send が呼ばれる", async () => { + renderWidget("haiku"); openAndSend("自己PRを改善して"); await waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); - expect(refresh).not.toHaveBeenCalled(); + // send(form, scope, target, text, model) の順で選択中モデルが渡る + const call = sendMock.mock.calls[0]; + expect(call[3]).toBe("自己PRを改善して"); + expect(call[4]).toBe("haiku"); }); }); diff --git a/web/src/components/forms/AgentChatWidget.tsx b/web/src/components/forms/AgentChatWidget.tsx index 0a112512..742343dd 100644 --- a/web/src/components/forms/AgentChatWidget.tsx +++ b/web/src/components/forms/AgentChatWidget.tsx @@ -13,11 +13,9 @@ 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"; -import { getModelOption } from "../../constants/agentModels"; import styles from "./AgentChatWidget.module.css"; type Props = { @@ -107,12 +105,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } /** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */ const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null); const { entries, sending, error, send, markApplied, clearError } = useAgentChat(); - // モデル・残高はともにサイドバーで表示・切り替えする(ADR-0012)。ウィジェットは - // 有料モデル消費後にサイドバーの残高を最新化するため refresh のみ利用する - // 有料判定の正本は agentModels.ts の isPaid(sonnet / gpt / gemini-pro)。 - // ここでハードコードすると ADR-0013 のマルチプロバイダ追加でドリフトするため getModelOption に委ねる。 - const isPaidModel = getModelOption(model).isPaid; - const { refresh: refreshBalance } = useCreditBalanceContext(); + // モデルはサイドバーで表示・切り替えする。abuse 防止は日次レート制限(ADR-0023 で課金撤去)。 const { showSuccess } = useToast(); useMessageToast(error, "error"); @@ -146,22 +139,10 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } const handleSend = () => { if (!canSend) return; clearError(); - void sendAndRefreshBalance(scope, getTarget(), prompt.trim()); + void send(form, scope, getTarget(), prompt.trim(), model); 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) => { @@ -306,7 +287,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } onSelect={(suggestion) => { if (sending) return; clearError(); - void sendAndRefreshBalance(entry.scope, entry.target, suggestion); + void send(form, entry.scope, entry.target, suggestion, model); }} /> )} diff --git a/web/src/components/forms/CareerResumeForm.test.tsx b/web/src/components/forms/CareerResumeForm.test.tsx index 4d74caa8..7a981756 100644 --- a/web/src/components/forms/CareerResumeForm.test.tsx +++ b/web/src/components/forms/CareerResumeForm.test.tsx @@ -5,7 +5,6 @@ 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"; @@ -35,12 +34,9 @@ function renderAnonymousForm() { render( - {/* 本番ではフォームは常に SidebarLayout(残高 Provider)配下。未認証は enabled=false で取得しない */} - - - - - + + + , ); diff --git a/web/src/constants/agentModels.ts b/web/src/constants/agentModels.ts index e9524142..e1c78f8c 100644 --- a/web/src/constants/agentModels.ts +++ b/web/src/constants/agentModels.ts @@ -1,9 +1,10 @@ /** - * 選択可能な AI モデル(ADR-0012)の表示メタデータ。 + * 選択可能な AI モデルの表示メタデータ。 * - * エイリアスの正本は backend `services/agent/model_catalog.py`(実モデル ID・課金レート)。 - * ここはフロントの表示用(製品名・説明・コスト目安)の SSoT で、プランカード UI と - * サイドバーの使用モデル表示が参照する。 + * エイリアスの正本は backend `services/agent/model_catalog.py`(実モデル ID)。 + * ここはフロントの表示用(製品名・説明)の SSoT で、プランカード UI と + * サイドバーの使用モデル表示が参照する。ADR-0023 で課金を撤去したため、 + * 課金レート・有料/無料の区別は持たない(マルチプロバイダは #523 で縮退予定)。 */ import type { AgentModelAlias } from "../api/types"; @@ -18,10 +19,7 @@ export type AgentModelOption = { provider: AgentModelProvider; /** 製品名(翻訳しない) */ name: string; - /** クレジットを消費するか(有料バッジ・残高チェックの表示に使う) */ - isPaid: boolean; tagline: string; - costHint: string; }; /** プロバイダの列見出し表示名(製品名なので翻訳しない)。 */ @@ -38,74 +36,53 @@ export const AGENT_PROVIDER_ORDER: readonly AgentModelProvider[] = [ "google", ]; -// 「N クレジットで平均M回」の目安に使う基準クレジット量(残高に依存しない比率表示)。 -// 1 クレジット = ¥1 なので 1,000 クレジット = ¥1,000(手に取りやすい基準額) -export const CREDIT_ESTIMATE_REFERENCE = 1_000; - /** * エイリアス → 表示メタデータ。`Record` で型付けすることで、 * backend が `AgentModelAlias`(OpenAPI codegen 経由で `generated.ts` に反映)へ * 新モデルを追加した瞬間、ここにメタ未定義のままだと**コンパイルエラー**になる。 * これにより「モデル選択 UI に新モデルが出ない」同期忘れを型で防ぐ(BE 側の * model_catalog ↔ AgentModelAlias drift ガードと対になる FE 側ガード)。 - * - * `isPaid` の正本は backend `model_catalog.is_free`(その反転)。runtime では - * model_rates API(`is_free`)が SSoT で、ここは API 未取得時の静的フォールバック。 - * 両者の乖離は agentModels.test.ts の drift テストで検出する。 */ const AGENT_MODEL_META: Record = { haiku: { alias: "haiku", provider: "anthropic", name: "Haiku 4.5", - isPaid: false, tagline: AGENT_MODEL_MESSAGES.HAIKU_TAGLINE, - costHint: AGENT_MODEL_MESSAGES.HAIKU_COST, }, sonnet: { alias: "sonnet", provider: "anthropic", name: "Sonnet 4.6", - isPaid: true, tagline: AGENT_MODEL_MESSAGES.SONNET_TAGLINE, - costHint: AGENT_MODEL_MESSAGES.SONNET_COST, }, "gpt-mini": { alias: "gpt-mini", provider: "openai", name: "GPT-4o mini", - isPaid: false, tagline: AGENT_MODEL_MESSAGES.GPT_MINI_TAGLINE, - costHint: AGENT_MODEL_MESSAGES.GPT_MINI_COST, }, gpt: { alias: "gpt", provider: "openai", name: "GPT-4.1", - isPaid: true, tagline: AGENT_MODEL_MESSAGES.GPT_TAGLINE, - costHint: AGENT_MODEL_MESSAGES.GPT_COST, }, "gemini-flash": { alias: "gemini-flash", provider: "google", name: "Gemini 2.5 Flash", - isPaid: false, tagline: AGENT_MODEL_MESSAGES.GEMINI_FLASH_TAGLINE, - costHint: AGENT_MODEL_MESSAGES.GEMINI_FLASH_COST, }, "gemini-pro": { alias: "gemini-pro", provider: "google", name: "Gemini 2.5 Pro", - isPaid: true, tagline: AGENT_MODEL_MESSAGES.GEMINI_PRO_TAGLINE, - costHint: AGENT_MODEL_MESSAGES.GEMINI_PRO_COST, }, }; -// 表示順は Record の定義順(プロバイダごとに無料→有料)。getModelOptionsByProvider が -// AGENT_PROVIDER_ORDER で列に振り分ける。 +// 表示順は Record の定義順。getModelOptionsByProvider が AGENT_PROVIDER_ORDER で列に振り分ける。 export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = Object.values(AGENT_MODEL_META); /** エイリアスから表示メタデータを引く。未知の値は先頭(haiku)にフォールバック。 */ diff --git a/web/src/constants/errorCodes.ts b/web/src/constants/errorCodes.ts index afd32ea5..ca9d4dc2 100644 --- a/web/src/constants/errorCodes.ts +++ b/web/src/constants/errorCodes.ts @@ -32,10 +32,6 @@ export const ERROR_CODES = [ "AGENT_PARSE_ERROR", // Agent の日次利用上限(#521 / ADR-0023) "AGENT_DAILY_LIMIT_EXCEEDED", - // 課金(プリペイドクレジット / ADR-0012) - "INSUFFICIENT_CREDITS", - // 決済(Stripe Checkout / ADR-0012 Phase 2) - "PAYMENT_ERROR", // アプリケーション全体 "RATE_LIMITED", // サーバー diff --git a/web/src/constants/errorMessages.ts b/web/src/constants/errorMessages.ts index db904d87..9b7bc94d 100644 --- a/web/src/constants/errorMessages.ts +++ b/web/src/constants/errorMessages.ts @@ -51,15 +51,6 @@ export const ERROR_CONFIG: Record< message: "本日の AI 利用回数の上限に達しました", recovery: null, }, - INSUFFICIENT_CREDITS: { - // fn は null(自動切替はしない)。ユーザー自身がモデル選択で切り替える手動アクション - message: "クレジット残高が不足しています", - recovery: { label: "モデル選択で Haiku(無料)に切り替える", fn: null }, - }, - PAYMENT_ERROR: { - message: "決済処理に失敗しました", - recovery: { label: "少し待って再試行", fn: null }, - }, RATE_LIMITED: { message: "リクエストが集中しています", recovery: { label: "少し待って再試行", fn: null }, diff --git a/web/src/constants/messages.ts b/web/src/constants/messages.ts index a602e280..abfaac1b 100644 --- a/web/src/constants/messages.ts +++ b/web/src/constants/messages.ts @@ -62,9 +62,6 @@ export const FALLBACK_MESSAGES = { GITHUB_LINK: "連携に失敗しました", AGENT_CHAT: "AI への送信に失敗しました", RESUME_DRAFT: "経歴書ドラフトの生成に失敗しました", - CREDIT_BALANCE: "クレジット残高の取得に失敗しました", - USAGE_SUMMARY: "利用状況の取得に失敗しました", - CHECKOUT: "決済ページへの遷移に失敗しました", SKILL_FETCH: "スキルの取得に失敗しました", SKILL_DISPLAY_PROPOSE: "表示名の提案に失敗しました", SKILL_DISPLAY_CONFIRM: "表示名の確定に失敗しました", @@ -341,111 +338,19 @@ export const AGENT_MESSAGES = { LOGIN_REQUIRED: "devforge Agent を使うにはログインが必要です。", } as const; -/** AI モデル選択(ADR-0012)の UI 文言。製品名(Haiku / Sonnet)は constants/agentModels.ts。 */ +/** AI モデル選択の UI 文言。製品名(Haiku / Sonnet)は constants/agentModels.ts。 */ export const AGENT_MODEL_MESSAGES = { SIDEBAR_LABEL: "使用モデル", MENU_ITEM: "AI モデルを切り替え", MODAL_TITLE: "AI モデルを選択", MODAL_DESCRIPTION: - "依頼の精度とコストに合わせて選べます。設定はこの端末に保存され、すべての AI 機能で使われます。", + "依頼の精度に合わせて選べます。設定はこの端末に保存され、すべての AI 機能で使われます。", CLOSE_LABEL: "閉じる", CURRENT_BADGE: "選択中", - FREE_BADGE: "無料", - PAID_BADGE: "有料", - // 課金(チャージ)導線はここに混ぜず、購入は専用サーフェスに分離する(ADR-0012) - INSUFFICIENT_HINT: "残高が不足しています。Haiku をご利用ください。", - USAGE_NONE: "まだ利用していません", - HAIKU_TAGLINE: "高速・標準精度。無料で使い放題。", - HAIKU_COST: "消費クレジット: なし", + HAIKU_TAGLINE: "高速・標準精度。日常の依頼に。", SONNET_TAGLINE: "高精度。重要な仕上げや複雑な依頼に。", - SONNET_COST: "消費クレジット: 利用したトークン量に応じて", - GEMINI_FLASH_TAGLINE: "高速・標準精度。日本語も自然で無料で使い放題。", - GEMINI_FLASH_COST: "消費クレジット: なし", + GEMINI_FLASH_TAGLINE: "高速・標準精度。日本語も自然。", GEMINI_PRO_TAGLINE: "高精度。長文構成や難しい依頼に。", - GEMINI_PRO_COST: "消費クレジット: 利用したトークン量に応じて", - GPT_MINI_TAGLINE: "高速・標準精度。構造化出力が得意で無料。", - GPT_MINI_COST: "消費クレジット: なし", + GPT_MINI_TAGLINE: "高速・標準精度。構造化出力が得意。", GPT_TAGLINE: "高精度・厳格な構造化出力。仕上げに。", - GPT_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_SUCCESS: "お支払いが完了しました。残高に反映されるまで少し時間がかかる場合があります。", - CHECKOUT_CANCELED: "購入をキャンセルしました。", - 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/web/src/hooks/useAgentUsageSummary.test.ts b/web/src/hooks/useAgentUsageSummary.test.ts deleted file mode 100644 index e02e23d9..00000000 --- a/web/src/hooks/useAgentUsageSummary.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/web/src/hooks/useAgentUsageSummary.ts b/web/src/hooks/useAgentUsageSummary.ts deleted file mode 100644 index 2c5afa00..00000000 --- a/web/src/hooks/useAgentUsageSummary.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * モデル別の使用量サマリ(ADR-0012)を取得するフック。 - * - * モデル選択モーダルが開いたときに取得し、各モデルカードへ - * 「これまでの利用回数・消費クレジット」と残回数目安を表示する。 - * 取得ライフサイクルは useAsyncResource に委譲し、ここではエイリアス → サマリの - * 引きやすい Map への変換とアクセサだけを担う。 - */ - -import { useCallback } from "react"; - -import { getAgentUsageSummary } from "../api/billing"; -import type { AgentModelAlias, AgentUsageSummaryEntry } from "../api/types"; -import { FALLBACK_MESSAGES } from "../constants/messages"; -import { useAsyncResource } from "./useAsyncResource"; - -/** エイリアス → サマリの引きやすい Map に変換した状態。 */ -export type UsageByModel = Record; - -export function useAgentUsageSummary(enabled: boolean) { - const { data: usageByModel, loading, error, refresh } = useAsyncResource( - async () => { - const entries = await getAgentUsageSummary(); - const map: UsageByModel = {}; - for (const entry of entries) { - map[entry.model] = entry; - } - return map; - }, - { enabled, initialData: {}, fallbackMessage: FALLBACK_MESSAGES.USAGE_SUMMARY }, - ); - - /** 指定モデルのサマリを返す(未利用なら undefined)。 */ - const getUsage = useCallback( - (alias: AgentModelAlias): AgentUsageSummaryEntry | undefined => usageByModel[alias], - [usageByModel], - ); - - return { usageByModel, getUsage, loading, error, refresh }; -} diff --git a/web/src/hooks/useBillingPage.test.ts b/web/src/hooks/useBillingPage.test.ts deleted file mode 100644 index e704ce29..00000000 --- a/web/src/hooks/useBillingPage.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -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/web/src/hooks/useBillingPage.ts b/web/src/hooks/useBillingPage.ts deleted file mode 100644 index 041654e1..00000000 --- a/web/src/hooks/useBillingPage.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * トークン購入画面(ADR-0012)のデータ取得フック。 - * - * 残高・購入パック・取引履歴をまとめて取得する。購入後は呼び出し側が refresh する。 - * 取得ライフサイクル(loading / error / seq ガード)は useAsyncResource に委譲する。 - */ - -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"; -import { useAsyncResource } from "./useAsyncResource"; - -/** 購入画面に必要なデータをまとめて取得した結果。 */ -type BillingPageData = { - balance: number | null; - packs: CreditPackResponse[]; - transactions: CreditTransactionResponse[]; - // 回数目安の基準: 有料モデル(Sonnet)の標準消費レート(null なら回数を出さない) - paidRate: number | null; -}; - -const INITIAL_DATA: BillingPageData = { - balance: null, - packs: [], - transactions: [], - paidRate: null, -}; - -export function useBillingPage() { - const { data, loading, error, refresh } = useAsyncResource( - async () => { - const [balanceRes, packsRes, transactionsRes, ratesRes] = await Promise.all([ - getCreditBalance(), - getCreditPacks(), - getCreditTransactions(), - getModelRates(), - ]); - const paid: ModelRateEntry | undefined = ratesRes.find( - (r) => r.model === PAID_REFERENCE_MODEL, - ); - return { - balance: balanceRes.balance, - packs: packsRes, - transactions: transactionsRes, - paidRate: paid && !paid.is_free ? paid.baseline_credits_per_chat : null, - }; - }, - { initialData: INITIAL_DATA, fallbackMessage: FALLBACK_MESSAGES.CREDIT_BALANCE }, - ); - - return { ...data, loading, error, refresh }; -} diff --git a/web/src/hooks/useCreditBalance.test.ts b/web/src/hooks/useCreditBalance.test.ts deleted file mode 100644 index 2f8388f4..00000000 --- a/web/src/hooks/useCreditBalance.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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/web/src/hooks/useCreditBalance.ts b/web/src/hooks/useCreditBalance.ts deleted file mode 100644 index 13400026..00000000 --- a/web/src/hooks/useCreditBalance.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * クレジット残高(ADR-0012)の取得・更新フック。 - * - * sonnet(有料モデル)選択時のみ取得する(enabled フラグ)。 - * チャット送信後は呼び出し側が refresh() で最新残高に更新する。 - * 取得ライフサイクル(loading / error / seq ガード)は useAsyncResource に委譲する。 - */ - -import { getCreditBalance } from "../api/billing"; -import { FALLBACK_MESSAGES } from "../constants/messages"; -import { useAsyncResource } from "./useAsyncResource"; - -export function useCreditBalance(enabled: boolean) { - const { data: balance, loading, error, refresh } = useAsyncResource( - async () => (await getCreditBalance()).balance, - { enabled, initialData: null, fallbackMessage: FALLBACK_MESSAGES.CREDIT_BALANCE }, - ); - - return { balance, loading, error, refresh }; -} diff --git a/web/src/hooks/useModelRates.test.ts b/web/src/hooks/useModelRates.test.ts deleted file mode 100644 index 50cd340b..00000000 --- a/web/src/hooks/useModelRates.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/web/src/hooks/useModelRates.ts b/web/src/hooks/useModelRates.ts deleted file mode 100644 index 8dcf144a..00000000 --- a/web/src/hooks/useModelRates.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * モデル別の標準消費レート(ADR-0012)を取得するフック。 - * - * 「Sonnet 約N回」の回数目安を出すために使う。利用実績のあるユーザーは - * 実測平均を優先し、本レート(ベースライン)は新規ユーザーのフォールバック。 - * 取得ライフサイクルは useAsyncResource に委譲し、ここでは Map 化とアクセサだけを担う。 - */ - -import { useCallback } from "react"; - -import { getModelRates } from "../api/billing"; -import type { AgentModelAlias, ModelRateEntry } from "../api/types"; -import { FALLBACK_MESSAGES } from "../constants/messages"; -import { useAsyncResource } from "./useAsyncResource"; - -export type RatesByModel = Record; - -export function useModelRates(enabled: boolean) { - const { data: ratesByModel, loading, error, refresh } = useAsyncResource( - async () => { - const entries = await getModelRates(); - const map: RatesByModel = {}; - for (const entry of entries) { - map[entry.model] = entry; - } - return map; - }, - { enabled, initialData: {}, fallbackMessage: FALLBACK_MESSAGES.USAGE_SUMMARY }, - ); - - /** 指定モデルの 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/web/src/pages/BillingPage.tsx b/web/src/pages/BillingPage.tsx deleted file mode 100644 index f2a484e2..00000000 --- a/web/src/pages/BillingPage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BillingView } from "../components/billing/BillingView"; - -export default function BillingPage() { - return ; -} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index e1b8ad39..e139b04b 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -6,7 +6,6 @@ import { LoadingOverlay } from "../components/LoadingOverlay"; import { PrivateRoute, PublicRoute, type AuthUser } from "./guards"; import CareerPage from "../pages/CareerPage"; import GitHubLinkPage from "../pages/GitHubLinkPage"; -import BillingPage from "../pages/BillingPage"; import GitHubCallbackPage from "../pages/GitHubCallbackPage"; import LoginPage from "../pages/LoginPage"; import NotFoundPage from "../pages/NotFoundPage"; @@ -100,7 +99,6 @@ export default function AppRoutes({ } > } /> - } /> diff --git a/web/src/utils/creditEstimate.test.ts b/web/src/utils/creditEstimate.test.ts deleted file mode 100644 index 8a6b296d..00000000 --- a/web/src/utils/creditEstimate.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/web/src/utils/creditEstimate.ts b/web/src/utils/creditEstimate.ts deleted file mode 100644 index 0986daa8..00000000 --- a/web/src/utils/creditEstimate.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * クレジット → 利用回数の目安計算(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); -}