From 2dd7ba65a4243e7a8b395693519bee7d31bcf6bb Mon Sep 17 00:00:00 2001 From: frontend-guesung Date: Sun, 13 Sep 2026 16:54:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=9B=90?= =?UTF-8?q?=EC=9E=A5=EA=B3=BC=20AI=20=EC=82=AC=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=9D=84=20=EA=B5=AC=EC=B6=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구독 청구를 멱등하게 처리하고 DB 저장 한도와 AI 비용 상한을 서버에서 강제한다. --- .../src/app/api/billing/billingGuards.test.ts | 74 ++++ apps/web/src/app/api/billing/config/route.ts | 29 ++ .../app/api/billing/cron/reconcile/route.ts | 24 ++ .../src/app/api/billing/cron/renew/route.ts | 60 ++++ .../src/app/api/billing/issue-key/route.ts | 75 ++++ .../src/app/api/billing/subscribe/route.ts | 54 +++ .../src/app/api/billing/subscription/route.ts | 112 ++++++ apps/web/src/app/api/openai/category/route.ts | 50 ++- apps/web/src/app/api/openai/chat/route.ts | 55 ++- apps/web/src/app/api/openai/route.ts | 37 +- apps/web/src/app/api/openai/util.ts | 30 ++ .../src/app/api/openai/webpage-qa/route.ts | 43 ++- apps/web/src/modules/billing/admin.ts | 15 + apps/web/src/modules/billing/aiUsage.test.ts | 42 +++ apps/web/src/modules/billing/aiUsage.ts | 98 ++++++ apps/web/src/modules/billing/auth.ts | 28 ++ .../billing/completeSubscriptionCharge.ts | 47 +++ apps/web/src/modules/billing/config.ts | 8 + apps/web/src/modules/billing/cron.ts | 26 ++ apps/web/src/modules/billing/crypto.test.ts | 28 ++ apps/web/src/modules/billing/crypto.ts | 61 ++++ .../billing/dispatchSubscriptionCharge.ts | 75 ++++ apps/web/src/modules/billing/index.ts | 10 + .../web/src/modules/billing/migration.test.ts | 53 +++ .../billing/reconcileSubscriptionCharges.ts | 83 +++++ .../src/modules/billing/subscription.test.ts | 327 ++++++++++++++++++ apps/web/src/modules/billing/subscription.ts | 106 ++++++ apps/web/src/modules/billing/toss.test.ts | 74 ++++ apps/web/src/modules/billing/toss.ts | 146 ++++++++ docs/architecture.md | 2 +- docs/environment-variables.md | 18 + ...913160000_add_billing_and_usage_limits.sql | 275 +++++++++++++++ 32 files changed, 2141 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/app/api/billing/billingGuards.test.ts create mode 100644 apps/web/src/app/api/billing/config/route.ts create mode 100644 apps/web/src/app/api/billing/cron/reconcile/route.ts create mode 100644 apps/web/src/app/api/billing/cron/renew/route.ts create mode 100644 apps/web/src/app/api/billing/issue-key/route.ts create mode 100644 apps/web/src/app/api/billing/subscribe/route.ts create mode 100644 apps/web/src/app/api/billing/subscription/route.ts create mode 100644 apps/web/src/modules/billing/admin.ts create mode 100644 apps/web/src/modules/billing/aiUsage.test.ts create mode 100644 apps/web/src/modules/billing/aiUsage.ts create mode 100644 apps/web/src/modules/billing/auth.ts create mode 100644 apps/web/src/modules/billing/completeSubscriptionCharge.ts create mode 100644 apps/web/src/modules/billing/config.ts create mode 100644 apps/web/src/modules/billing/cron.ts create mode 100644 apps/web/src/modules/billing/crypto.test.ts create mode 100644 apps/web/src/modules/billing/crypto.ts create mode 100644 apps/web/src/modules/billing/dispatchSubscriptionCharge.ts create mode 100644 apps/web/src/modules/billing/index.ts create mode 100644 apps/web/src/modules/billing/migration.test.ts create mode 100644 apps/web/src/modules/billing/reconcileSubscriptionCharges.ts create mode 100644 apps/web/src/modules/billing/subscription.test.ts create mode 100644 apps/web/src/modules/billing/subscription.ts create mode 100644 apps/web/src/modules/billing/toss.test.ts create mode 100644 apps/web/src/modules/billing/toss.ts create mode 100644 packages/supabase-edge-functions/supabase/migrations/20260913160000_add_billing_and_usage_limits.sql diff --git a/apps/web/src/app/api/billing/billingGuards.test.ts b/apps/web/src/app/api/billing/billingGuards.test.ts new file mode 100644 index 000000000..3350437c4 --- /dev/null +++ b/apps/web/src/app/api/billing/billingGuards.test.ts @@ -0,0 +1,74 @@ +import { NextRequest } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + createAdmin: vi.fn(), + charge: vi.fn(), + reconcile: vi.fn(), + verifyCron: vi.fn(), +})); +vi.mock("@src/modules/billing", () => ({ + authenticateBillingRequest: mocks.authenticate, + createBillingAdminClient: mocks.createAdmin, +})); +vi.mock("@src/modules/billing/subscription", () => ({ + chargeSubscription: mocks.charge, +})); +vi.mock("@src/modules/billing/reconcileSubscriptionCharges", () => ({ + reconcileSubscriptionCharges: mocks.reconcile, +})); +vi.mock("@src/modules/billing/cron", () => ({ + verifyBillingCronRequest: mocks.verifyCron, +})); +vi.mock("@src/modules/billing/crypto", () => ({ encryptBillingKey: vi.fn() })); +vi.mock("@src/modules/billing/toss", () => ({ issueTossBillingKey: vi.fn() })); + +import { POST as reconcile } from "./cron/reconcile/route"; +import { POST as renew } from "./cron/renew/route"; +import { POST as issueKey } from "./issue-key/route"; +import { POST as subscribe } from "./subscribe/route"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); +describe("billing server rollout guard", () => { + it.each([subscribe, issueKey, renew, reconcile])( + "returns 503 before any database or provider call while disabled", + async (handler) => { + vi.stubEnv("BILLING_ENABLED", "false"); + const response = await handler( + new NextRequest("https://example.com/api/billing", { method: "POST" }), + ); + expect(response.status).toBe(503); + expect(mocks.createAdmin).not.toHaveBeenCalled(); + expect(mocks.charge).not.toHaveBeenCalled(); + expect(mocks.reconcile).not.toHaveBeenCalled(); + }, + ); + it("returns 409 when a new subscription request targets an active subscription", async () => { + vi.stubEnv("BILLING_ENABLED", "true"); + mocks.authenticate.mockResolvedValue("user"); + mocks.charge.mockRejectedValue(new Error("ACTIVE_SUBSCRIPTION_EXISTS")); + const response = await subscribe( + new NextRequest("https://example.com/api/billing/subscribe", { + method: "POST", + headers: { "idempotency-key": "new-key" }, + }), + ); + expect(response.status).toBe(409); + }); + it("returns 202 rather than success for an unresolved existing charge", async () => { + vi.stubEnv("BILLING_ENABLED", "true"); + mocks.authenticate.mockResolvedValue("user"); + mocks.charge.mockResolvedValue({ orderId: "old-order", status: "unknown" }); + const response = await subscribe( + new NextRequest("https://example.com/api/billing/subscribe", { + method: "POST", + headers: { "idempotency-key": "new-key" }, + }), + ); + expect(response.status).toBe(202); + }); +}); diff --git a/apps/web/src/app/api/billing/config/route.ts b/apps/web/src/app/api/billing/config/route.ts new file mode 100644 index 000000000..6a8091211 --- /dev/null +++ b/apps/web/src/app/api/billing/config/route.ts @@ -0,0 +1,29 @@ +import { authenticateBillingRequest } from "@src/modules/billing"; +import { SUBSCRIPTION_PRICE_KRW } from "@src/modules/billing/config"; +import { type NextRequest, NextResponse } from "next/server"; + +/** 결제창에 필요한 공개 설정을 인증된 사용자에게 반환합니다. */ +export const GET = async (request: NextRequest) => { + const userId = await authenticateBillingRequest(request); + + if (!userId) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 }, + ); + } + + const clientKey = process.env.NEXT_PUBLIC_TOSS_PAYMENTS_CLIENT_KEY; + const billingEnabled = + process.env.BILLING_ENABLED === "true" && + Boolean(clientKey && process.env.TOSS_PAYMENTS_SECRET_KEY); + + return NextResponse.json({ + amount: SUBSCRIPTION_PRICE_KRW, + currency: "KRW", + priceVersion: "monthly-500-v1", + customerKey: `webmemo_${userId.replaceAll("-", "")}`, + clientKey: clientKey ?? null, + billingEnabled, + }); +}; diff --git a/apps/web/src/app/api/billing/cron/reconcile/route.ts b/apps/web/src/app/api/billing/cron/reconcile/route.ts new file mode 100644 index 000000000..81d6647ee --- /dev/null +++ b/apps/web/src/app/api/billing/cron/reconcile/route.ts @@ -0,0 +1,24 @@ +import { verifyBillingCronRequest } from "@src/modules/billing/cron"; +import { reconcileSubscriptionCharges } from "@src/modules/billing/reconcileSubscriptionCharges"; +import { type NextRequest, NextResponse } from "next/server"; + +/** 조회 결과와 DB 저장이 모두 확인된 청구만 재조정 완료로 처리합니다. */ +export const POST = async (request: NextRequest) => { + if (process.env.BILLING_ENABLED !== "true") { + return NextResponse.json( + { message: "Billing is not enabled" }, + { status: 503 }, + ); + } + if (!verifyBillingCronRequest(request)) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + try { + return NextResponse.json(await reconcileSubscriptionCharges()); + } catch { + return NextResponse.json( + { message: "Charge lookup failed" }, + { status: 500 }, + ); + } +}; diff --git a/apps/web/src/app/api/billing/cron/renew/route.ts b/apps/web/src/app/api/billing/cron/renew/route.ts new file mode 100644 index 000000000..2ecfeaa19 --- /dev/null +++ b/apps/web/src/app/api/billing/cron/renew/route.ts @@ -0,0 +1,60 @@ +import { createBillingAdminClient } from "@src/modules/billing"; +import { verifyBillingCronRequest } from "@src/modules/billing/cron"; +import { chargeSubscription } from "@src/modules/billing/subscription"; +import { type NextRequest, NextResponse } from "next/server"; + +/** 만료된 활성 구독을 멱등적으로 재청구합니다. */ +export const POST = async (request: NextRequest) => { + if (process.env.BILLING_ENABLED !== "true") { + return NextResponse.json( + { message: "Billing is not enabled" }, + { status: 503 }, + ); + } + if (!verifyBillingCronRequest(request)) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const billingSchema = createBillingAdminClient().schema("billing"); + const now = new Date().toISOString(); + const { data: subscriptions, error } = await billingSchema + .from("subscriptions") + .select("user_id,current_period_end,cancel_at_period_end") + .eq("status", "active") + .eq("cancel_at_period_end", false) + .lte("current_period_end", now) + .limit(100); + + if (error) { + return NextResponse.json( + { message: "Subscription lookup failed" }, + { status: 500 }, + ); + } + + let renewedCount = 0; + + for (const subscription of subscriptions ?? []) { + if (subscription.cancel_at_period_end) { + continue; + } + + try { + const result = await chargeSubscription({ + userId: subscription.user_id, + idempotencyKey: `renew:${subscription.user_id}:${subscription.current_period_end}`, + }); + + if (result.status === "succeeded") { + renewedCount += 1; + } + } catch (renewalError) { + console.error("Subscription renewal needs reconciliation", renewalError); + } + } + + return NextResponse.json({ + processedCount: subscriptions?.length ?? 0, + renewedCount, + }); +}; diff --git a/apps/web/src/app/api/billing/issue-key/route.ts b/apps/web/src/app/api/billing/issue-key/route.ts new file mode 100644 index 000000000..effeb29de --- /dev/null +++ b/apps/web/src/app/api/billing/issue-key/route.ts @@ -0,0 +1,75 @@ +import { + authenticateBillingRequest, + createBillingAdminClient, +} from "@src/modules/billing"; +import { encryptBillingKey } from "@src/modules/billing/crypto"; +import { issueTossBillingKey } from "@src/modules/billing/toss"; +import { type NextRequest, NextResponse } from "next/server"; + +/** 토스 인증키를 서버 전용 빌링키로 교환하고 암호화해 저장합니다. */ +export const POST = async (request: NextRequest) => { + if (process.env.BILLING_ENABLED !== "true") { + return NextResponse.json( + { message: "Billing is not enabled" }, + { status: 503 }, + ); + } + const userId = await authenticateBillingRequest(request); + + if (!userId) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 }, + ); + } + + const body = (await request.json()) as { + authKey?: unknown; + customerKey?: unknown; + }; + + if ( + typeof body.authKey !== "string" || + typeof body.customerKey !== "string" + ) { + return NextResponse.json({ message: "Invalid request" }, { status: 400 }); + } + const expectedCustomerKey = `webmemo_${userId.replaceAll("-", "")}`; + + if (body.customerKey !== expectedCustomerKey) { + return NextResponse.json( + { message: "Invalid customer key" }, + { status: 400 }, + ); + } + + try { + const authorization = await issueTossBillingKey({ + authKey: body.authKey, + customerKey: expectedCustomerKey, + }); + const billingAdminClient = createBillingAdminClient(); + const { error } = await billingAdminClient + .schema("billing") + .from("customer_secrets") + .upsert({ + user_id: userId, + toss_customer_key: authorization.customerKey, + billing_key_ciphertext: encryptBillingKey(authorization.billingKey), + updated_at: new Date().toISOString(), + }); + + if (error) { + throw error; + } + + return NextResponse.json({ registered: true }); + } catch (error) { + console.error("Billing key issue failed", error); + + return NextResponse.json( + { message: "Billing key registration failed" }, + { status: 502 }, + ); + } +}; diff --git a/apps/web/src/app/api/billing/subscribe/route.ts b/apps/web/src/app/api/billing/subscribe/route.ts new file mode 100644 index 000000000..a1ce9f5c0 --- /dev/null +++ b/apps/web/src/app/api/billing/subscribe/route.ts @@ -0,0 +1,54 @@ +import { authenticateBillingRequest } from "@src/modules/billing"; +import { chargeSubscription } from "@src/modules/billing/subscription"; +import { type NextRequest, NextResponse } from "next/server"; + +/** 등록된 빌링키로 첫 구독 결제를 실행합니다. */ +export const POST = async (request: NextRequest) => { + if (process.env.BILLING_ENABLED !== "true") { + return NextResponse.json( + { message: "Billing is not enabled" }, + { status: 503 }, + ); + } + const userId = await authenticateBillingRequest(request); + + if (!userId) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 }, + ); + } + + const idempotencyKey = request.headers.get("idempotency-key"); + + if (!idempotencyKey || idempotencyKey.length > 100) { + return NextResponse.json( + { message: "A valid idempotency-key is required" }, + { status: 400 }, + ); + } + + try { + const result = await chargeSubscription({ userId, idempotencyKey }); + + return NextResponse.json(result, { + status: ["pending", "unknown"].includes(result.status) ? 202 : 200, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === "ACTIVE_SUBSCRIPTION_EXISTS" + ) { + return NextResponse.json( + { message: "Subscription already active" }, + { status: 409 }, + ); + } + console.error("Subscription charge failed", error); + + return NextResponse.json( + { message: "Payment result is being confirmed" }, + { status: 202 }, + ); + } +}; diff --git a/apps/web/src/app/api/billing/subscription/route.ts b/apps/web/src/app/api/billing/subscription/route.ts new file mode 100644 index 000000000..7302b0b44 --- /dev/null +++ b/apps/web/src/app/api/billing/subscription/route.ts @@ -0,0 +1,112 @@ +import { + authenticateBillingRequest, + createBillingAdminClient, +} from "@src/modules/billing"; +import { type NextRequest, NextResponse } from "next/server"; + +/** 현재 사용자의 구독 상태와 AI 주기 사용량을 반환합니다. */ +export const GET = async (request: NextRequest) => { + const userId = await authenticateBillingRequest(request); + + if (!userId) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 }, + ); + } + + const billingSchema = createBillingAdminClient().schema("billing"); + const { data: subscription, error: subscriptionError } = await billingSchema + .from("subscriptions") + .select( + "status,current_period_start,current_period_end,cancel_at_period_end,price_krw", + ) + .eq("user_id", userId) + .maybeSingle(); + const { data: latestCharge, error: latestChargeError } = await billingSchema + .from("charges") + .select( + "order_id,idempotency_key,status,requested_at,resolved_at,failure_message", + ) + .eq("user_id", userId) + .order("requested_at", { ascending: false }) + .limit(1) + .maybeSingle(); + const { data: billingSecret, error: billingSecretError } = await billingSchema + .from("customer_secrets") + .select("user_id") + .eq("user_id", userId) + .maybeSingle(); + let aiUsageCount = 0; + const { count: memoCount, error: memoCountError } = + await createBillingAdminClient() + .schema("memo") + .from("memo") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId); + + if ( + subscriptionError || + latestChargeError || + billingSecretError || + memoCountError + ) { + return NextResponse.json( + { message: "Subscription state is temporarily unavailable" }, + { status: 503 }, + ); + } + + if (subscription?.current_period_start && subscription.current_period_end) { + const { count, error: aiUsageError } = await billingSchema + .from("ai_usage") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId) + .eq("period_start", subscription.current_period_start) + .eq("period_end", subscription.current_period_end) + .neq("status", "released"); + if (aiUsageError) { + return NextResponse.json( + { message: "Subscription state is temporarily unavailable" }, + { status: 503 }, + ); + } + + aiUsageCount = count ?? 0; + } + + return NextResponse.json({ + subscription, + latestCharge, + hasBillingKey: Boolean(billingSecret), + memoCount: memoCount ?? 0, + memoLimit: 50, + aiUsageCount, + aiUsageLimit: 30, + }); +}; + +/** 구독을 즉시 삭제하지 않고 현재 주기 만료 시점에 해지합니다. */ +export const DELETE = async (request: NextRequest) => { + const userId = await authenticateBillingRequest(request); + + if (!userId) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 }, + ); + } + + const { error } = await createBillingAdminClient() + .schema("billing") + .rpc("cancel_subscription", { target_user_id: userId }); + + if (error) { + return NextResponse.json( + { message: "Cancellation failed" }, + { status: 500 }, + ); + } + + return NextResponse.json({ cancelAtPeriodEnd: true }); +}; diff --git a/apps/web/src/app/api/openai/category/route.ts b/apps/web/src/app/api/openai/category/route.ts index a262d713f..c504d0340 100644 --- a/apps/web/src/app/api/openai/category/route.ts +++ b/apps/web/src/app/api/openai/category/route.ts @@ -1,3 +1,8 @@ +import { + calculateAiCostMicros, + reserveAiUsage, + settleAiUsage, +} from "@src/modules/billing"; import { CHROME_EXTENSION_ID } from "@web-memo/shared/constants"; import { type NextRequest, NextResponse } from "next/server"; import OpenAI from "openai"; @@ -13,12 +18,14 @@ import { } from "./util"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const MAX_CATEGORY_PROMPT_BYTES = 24_000; if (!OPENAI_API_KEY) { console.warn("OPENAI_API_KEY is not configured"); } -export async function POST(request: NextRequest) { +/** 인증된 유료 사용자의 메모 카테고리를 추천합니다. */ +export const POST = async (request: NextRequest) => { if (!OPENAI_API_KEY) { return createErrorResponse( "OpenAI API key not configured", @@ -46,12 +53,31 @@ export async function POST(request: NextRequest) { ); } + const prompt = buildCategoryPrompt(body); + const totalPromptBytes = new TextEncoder().encode( + `${SYSTEM_MESSAGE}\n${prompt}`, + ).byteLength; + + if (totalPromptBytes > MAX_CATEGORY_PROMPT_BYTES) { + return createErrorResponse( + ERROR_MESSAGES.CONTEXT_TOO_LONG, + HTTP_STATUS.BAD_REQUEST, + ); + } + + const usageReservation = await reserveAiUsage(request, "category"); + + if (!usageReservation.isAllowed) { + return createErrorResponse( + usageReservation.message, + usageReservation.status, + ); + } + const openai = new OpenAI({ apiKey: OPENAI_API_KEY, }); - const prompt = buildCategoryPrompt(body); - const completion = await openai.chat.completions.create({ model: OPENAI_MODEL, messages: [ @@ -66,7 +92,18 @@ export async function POST(request: NextRequest) { ], temperature: OPENAI_SETTINGS.temperature, response_format: OPENAI_SETTINGS.responseFormat, + max_tokens: 200, }); + const usage = completion.usage; + await settleAiUsage( + usageReservation.reservationId, + usage + ? calculateAiCostMicros({ + promptTokens: usage.prompt_tokens, + completionTokens: usage.completion_tokens, + }) + : undefined, + ); const responseContent = completion.choices[0]?.message?.content; @@ -122,11 +159,12 @@ export async function POST(request: NextRequest) { HTTP_STATUS.INTERNAL_SERVER_ERROR, ); } -} +}; -export async function OPTIONS() { +/** 카테고리 추천 API의 CORS 사전 요청을 처리합니다. */ +export const OPTIONS = async () => { return new Response(null, { status: 200, headers: CORS_HEADERS, }); -} +}; diff --git a/apps/web/src/app/api/openai/chat/route.ts b/apps/web/src/app/api/openai/chat/route.ts index 2badfb3a2..165a342e7 100644 --- a/apps/web/src/app/api/openai/chat/route.ts +++ b/apps/web/src/app/api/openai/chat/route.ts @@ -1,3 +1,8 @@ +import { + calculateAiCostMicros, + reserveAiUsage, + settleAiUsage, +} from "@src/modules/billing"; import { CHROME_EXTENSION_ID } from "@web-memo/shared/constants"; import type { NextRequest } from "next/server"; import type { ChatCompletionMessageParam } from "openai/resources.mjs"; @@ -10,7 +15,8 @@ import { } from "../util"; import { CHAT_SYSTEM_PROMPT } from "./constant"; -export async function POST(request: NextRequest) { +/** 인증된 유료 사용자의 현재 페이지 기반 채팅을 스트리밍합니다. */ +export const POST = async (request: NextRequest) => { try { const origin = request.headers.get("origin"); const validOrigin = `chrome-extension://${CHROME_EXTENSION_ID}`; @@ -33,13 +39,52 @@ export async function POST(request: NextRequest) { ); } + const pageContent = + typeof context?.pageContent === "string" ? context.pageContent : ""; + const messageCharacters = (messages as ChatCompletionMessageParam[]).reduce( + (total, message) => { + return ( + total + + (typeof message.content === "string" + ? new TextEncoder().encode(message.content).byteLength + : 0) + ); + }, + 0, + ); + + const pageContentBytes = new TextEncoder().encode(pageContent).byteLength; + + if (messageCharacters + pageContentBytes > 24_000) { + return createErrorResponse( + ERROR_MESSAGES.CONTEXT_TOO_LONG, + HTTP_STATUS.BAD_REQUEST, + ); + } + + const usageReservation = await reserveAiUsage(request, "chat"); + + if (!usageReservation.isAllowed) { + return createErrorResponse( + usageReservation.message, + usageReservation.status, + ); + } + const systemPrompt = buildSystemPrompt(context); const fullMessages: ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, ...(messages as ChatCompletionMessageParam[]), ]; - return createStreamingResponse(fullMessages); + const response = createStreamingResponse(fullMessages, async (usage) => { + await settleAiUsage( + usageReservation.reservationId, + calculateAiCostMicros(usage), + ); + }); + + return response; } catch (error) { console.error("Chat route handler error:", error); @@ -52,15 +97,15 @@ export async function POST(request: NextRequest) { HTTP_STATUS.INTERNAL_SERVER_ERROR, ); } -} +}; -function buildSystemPrompt(context?: ChatContext): string { +const buildSystemPrompt = (context?: ChatContext): string => { if (!context?.pageContent) { return CHAT_SYSTEM_PROMPT.DEFAULT; } return `${CHAT_SYSTEM_PROMPT.DEFAULT}${context.pageContent}`; -} +}; interface ChatContext { pageContent?: string; diff --git a/apps/web/src/app/api/openai/route.ts b/apps/web/src/app/api/openai/route.ts index 57f1ae1a9..bfffda389 100644 --- a/apps/web/src/app/api/openai/route.ts +++ b/apps/web/src/app/api/openai/route.ts @@ -1,3 +1,8 @@ +import { + calculateAiCostMicros, + reserveAiUsage, + settleAiUsage, +} from "@src/modules/billing"; import { CHROME_EXTENSION_ID } from "@web-memo/shared/constants"; import type { NextRequest } from "next/server"; import type { ChatCompletionMessageParam } from "openai/resources.mjs"; @@ -10,9 +15,7 @@ import { validateMessages, } from "./util"; -export const runtime = "edge"; - -function getClientIp(request: NextRequest): string { +const getClientIp = (request: NextRequest): string => { const forwardedFor = request.headers.get("x-forwarded-for"); if (forwardedFor) { return forwardedFor.split(",")[0].trim(); @@ -24,9 +27,10 @@ function getClientIp(request: NextRequest): string { } return "unknown"; -} +}; -export async function POST(request: NextRequest) { +/** 인증된 확장 사용자의 페이지 요약 요청을 스트리밍합니다. */ +export const POST = async (request: NextRequest) => { try { const origin = request.headers.get("origin"); const validOrigin = `chrome-extension://${CHROME_EXTENSION_ID}`; @@ -61,7 +65,26 @@ export async function POST(request: NextRequest) { ); } - return createStreamingResponse(messages as ChatCompletionMessageParam[]); + const usageReservation = await reserveAiUsage(request, "summary"); + + if (!usageReservation.isAllowed) { + return createErrorResponse( + usageReservation.message, + usageReservation.status, + ); + } + + const response = createStreamingResponse( + messages as ChatCompletionMessageParam[], + async (usage) => { + await settleAiUsage( + usageReservation.reservationId, + calculateAiCostMicros(usage), + ); + }, + ); + + return response; } catch (error) { console.error("Route handler error:", error); @@ -74,4 +97,4 @@ export async function POST(request: NextRequest) { HTTP_STATUS.INTERNAL_SERVER_ERROR, ); } -} +}; diff --git a/apps/web/src/app/api/openai/util.ts b/apps/web/src/app/api/openai/util.ts index 7f928f43e..886ab76f8 100644 --- a/apps/web/src/app/api/openai/util.ts +++ b/apps/web/src/app/api/openai/util.ts @@ -9,12 +9,18 @@ import { CORS_HEADERS, ERROR_MESSAGES, HTTP_STATUS } from "./constant"; import type { ValidationResult } from "./type"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const MAX_MESSAGE_COUNT = 40; +const MAX_TOTAL_MESSAGE_CHARACTERS = 24_000; export const validateMessages = (messages: unknown): ValidationResult => { if (!messages || !Array.isArray(messages) || messages.length === 0) { return { isValid: false, error: ERROR_MESSAGES.MISSING_MESSAGES }; } + if (messages.length > MAX_MESSAGE_COUNT) { + return { isValid: false, error: ERROR_MESSAGES.CONTEXT_TOO_LONG }; + } + const isValidMessage = messages.every((msg: unknown) => { if (!msg || typeof msg !== "object") return false; const message = msg as Record; @@ -27,6 +33,18 @@ export const validateMessages = (messages: unknown): ValidationResult => { return { isValid: false, error: ERROR_MESSAGES.INVALID_MESSAGE_FORMAT }; } + const totalCharacters = messages.reduce((total, message) => { + return ( + total + + new TextEncoder().encode((message as { content: string }).content) + .byteLength + ); + }, 0); + + if (totalCharacters > MAX_TOTAL_MESSAGE_CHARACTERS) { + return { isValid: false, error: ERROR_MESSAGES.CONTEXT_TOO_LONG }; + } + return { isValid: true }; }; @@ -68,6 +86,10 @@ export const handleOpenAIError = (error: Error) => { export const createStreamingResponse = ( messages: ChatCompletionMessageParam[], + onUsage?: (usage: { + promptTokens: number; + completionTokens: number; + }) => Promise, ) => { const openai = new OpenAI({ apiKey: OPENAI_API_KEY, @@ -82,10 +104,18 @@ export const createStreamingResponse = ( model: "gpt-4o-mini", messages, stream: true, + stream_options: { include_usage: true }, temperature: 0.3, + max_tokens: 1_000, }); for await (const chunk of stream) { + if (chunk.usage && onUsage) { + await onUsage({ + promptTokens: chunk.usage.prompt_tokens, + completionTokens: chunk.usage.completion_tokens, + }); + } const content = chunk.choices[0]?.delta?.content; if (content) { controller.enqueue( diff --git a/apps/web/src/app/api/openai/webpage-qa/route.ts b/apps/web/src/app/api/openai/webpage-qa/route.ts index 873611ddf..d3194eb99 100644 --- a/apps/web/src/app/api/openai/webpage-qa/route.ts +++ b/apps/web/src/app/api/openai/webpage-qa/route.ts @@ -1,3 +1,8 @@ +import { + calculateAiCostMicros, + reserveAiUsage, + settleAiUsage, +} from "@src/modules/billing"; import { createClient } from "@supabase/supabase-js"; import { SUPABASE } from "@web-memo/shared/constants"; import { type NextRequest, NextResponse } from "next/server"; @@ -31,7 +36,8 @@ const verifyUser = async (request: NextRequest) => { return user; }; -export async function POST(request: NextRequest) { +/** 인증된 유료 사용자의 모바일 페이지 요약과 질의응답을 처리합니다. */ +export const POST = async (request: NextRequest) => { if (!OPENAI_API_KEY) { return createErrorResponse( "OpenAI API key not configured", @@ -47,7 +53,6 @@ export async function POST(request: NextRequest) { HTTP_STATUS.FORBIDDEN, ); } - const body = await request.json(); const content = typeof body.content === "string" ? body.content : ""; const question = typeof body.question === "string" ? body.question : ""; @@ -59,6 +64,22 @@ export async function POST(request: NextRequest) { ); } + if (question.length > 2_000) { + return createErrorResponse( + ERROR_MESSAGES.CONTEXT_TOO_LONG, + HTTP_STATUS.BAD_REQUEST, + ); + } + + const usageReservation = await reserveAiUsage(request, "webpage-qa"); + + if (!usageReservation.isAllowed) { + return createErrorResponse( + usageReservation.message, + usageReservation.status, + ); + } + const truncatedContent = content.slice(0, PAGE_CONTENT_MAX_LENGTH); const isQuestion = !!question.trim(); @@ -79,7 +100,18 @@ export async function POST(request: NextRequest) { }, ], temperature: OPENAI_SETTINGS.temperature, + max_tokens: 1_000, }); + const usage = completion.usage; + await settleAiUsage( + usageReservation.reservationId, + usage + ? calculateAiCostMicros({ + promptTokens: usage.prompt_tokens, + completionTokens: usage.completion_tokens, + }) + : undefined, + ); const responseContent = completion.choices[0]?.message?.content; @@ -106,11 +138,12 @@ export async function POST(request: NextRequest) { HTTP_STATUS.INTERNAL_SERVER_ERROR, ); } -} +}; -export async function OPTIONS() { +/** 모바일 페이지 AI API의 CORS 사전 요청을 처리합니다. */ +export const OPTIONS = async () => { return new Response(null, { status: 200, headers: CORS_HEADERS, }); -} +}; diff --git a/apps/web/src/modules/billing/admin.ts b/apps/web/src/modules/billing/admin.ts new file mode 100644 index 000000000..3d922e3b3 --- /dev/null +++ b/apps/web/src/modules/billing/admin.ts @@ -0,0 +1,15 @@ +import { createClient } from "@supabase/supabase-js"; +import { SUPABASE } from "@web-memo/shared/constants"; + +/** 서비스 역할로 billing 스키마에 접근하는 서버 전용 클라이언트를 만듭니다. */ +export const createBillingAdminClient = () => { + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!serviceRoleKey) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY is not configured"); + } + + return createClient(SUPABASE.url, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); +}; diff --git a/apps/web/src/modules/billing/aiUsage.test.ts b/apps/web/src/modules/billing/aiUsage.test.ts new file mode 100644 index 000000000..3a7b8c1ec --- /dev/null +++ b/apps/web/src/modules/billing/aiUsage.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { calculateAiCostMicros } from "./aiUsage"; +import { AI_RESERVATION_COST_MICROS } from "./config"; + +describe("calculateAiCostMicros", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("실제 토큰과 운영 환율로 원화 마이크로 비용을 계산한다", () => { + vi.stubEnv("OPENAI_USD_TO_KRW_RATE", "1400"); + + expect( + calculateAiCostMicros({ + promptTokens: 1_000, + completionTokens: 500, + }), + ).toBe(630_000); + }); + + it("환율이 없으면 예약 상한을 유지한다", () => { + vi.stubEnv("OPENAI_USD_TO_KRW_RATE", ""); + + expect( + calculateAiCostMicros({ + promptTokens: 1, + completionTokens: 1, + }), + ).toBe(AI_RESERVATION_COST_MICROS); + }); + + it("실제 비용이 예약 상한보다 크면 축소하지 않는다", () => { + vi.stubEnv("OPENAI_USD_TO_KRW_RATE", "1400"); + + expect( + calculateAiCostMicros({ + promptTokens: 100_000, + completionTokens: 100_000, + }), + ).toBeGreaterThan(AI_RESERVATION_COST_MICROS); + }); +}); diff --git a/apps/web/src/modules/billing/aiUsage.ts b/apps/web/src/modules/billing/aiUsage.ts new file mode 100644 index 000000000..7d321ffb5 --- /dev/null +++ b/apps/web/src/modules/billing/aiUsage.ts @@ -0,0 +1,98 @@ +import type { NextRequest } from "next/server"; +import { createBillingAdminClient } from "./admin"; +import { authenticateBillingRequest } from "./auth"; +import { AI_RESERVATION_COST_MICROS } from "./config"; + +/** AI 사용량 예약 결과입니다. */ +export type TAiUsageReservationResult = + | { isAllowed: true; userId: string; reservationId: string } + | { isAllowed: false; status: number; message: string }; + +/** 인증·구독·AI 주기 한도를 원자적으로 검증하고 비용을 예약합니다. */ +export const reserveAiUsage = async ( + request: NextRequest, + feature: string, +): Promise => { + const exchangeRate = Number(process.env.OPENAI_USD_TO_KRW_RATE); + + if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { + return { + isAllowed: false, + status: 503, + message: "AI cost configuration is unavailable", + }; + } + + const userId = await authenticateBillingRequest(request); + + if (!userId) { + return { + isAllowed: false, + status: 401, + message: "Authentication required", + }; + } + + const billingAdminClient = createBillingAdminClient(); + const maximumRequestCostMicros = calculateAiCostMicros({ + promptTokens: 24_000, + completionTokens: 1_000, + }); + const { data, error } = await billingAdminClient + .schema("billing") + .rpc("reserve_ai_usage", { + target_user_id: userId, + target_feature: feature, + target_estimated_cost_micros: Math.max( + AI_RESERVATION_COST_MICROS, + maximumRequestCostMicros, + ), + }); + + if (error || typeof data !== "string") { + return { + isAllowed: false, + status: error?.message.includes("ACTIVE_SUBSCRIPTION_REQUIRED") + ? 402 + : 429, + message: error?.message ?? "AI usage could not be reserved", + }; + } + + return { isAllowed: true, userId, reservationId: data }; +}; + +/** AI 호출이 시작된 예약을 예상 비용으로 확정합니다. */ +export const settleAiUsage = async ( + reservationId: string, + actualCostMicros = AI_RESERVATION_COST_MICROS, +): Promise => { + const billingAdminClient = createBillingAdminClient(); + const { error } = await billingAdminClient + .schema("billing") + .rpc("settle_ai_usage", { + target_reservation_id: reservationId, + target_actual_cost_micros: actualCostMicros, + }); + + if (error) { + console.error("AI usage settlement failed", error); + } +}; + +/** GPT-4o mini 토큰 사용량을 원화 마이크로 단위 비용으로 변환합니다. */ +export const calculateAiCostMicros = (input: { + promptTokens: number; + completionTokens: number; +}): number => { + const exchangeRate = Number(process.env.OPENAI_USD_TO_KRW_RATE); + + if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { + return AI_RESERVATION_COST_MICROS; + } + + const inputCostMicros = input.promptTokens * 0.15 * exchangeRate; + const outputCostMicros = input.completionTokens * 0.6 * exchangeRate; + + return Math.max(0, Math.ceil(inputCostMicros + outputCostMicros)); +}; diff --git a/apps/web/src/modules/billing/auth.ts b/apps/web/src/modules/billing/auth.ts new file mode 100644 index 000000000..3af9c0c7f --- /dev/null +++ b/apps/web/src/modules/billing/auth.ts @@ -0,0 +1,28 @@ +import { createClient } from "@supabase/supabase-js"; +import { SUPABASE } from "@web-memo/shared/constants"; +import type { NextRequest } from "next/server"; + +/** API 요청의 Supabase Bearer 토큰을 검증하고 사용자 ID를 반환합니다. */ +export const authenticateBillingRequest = async ( + request: NextRequest, +): Promise => { + const authorizationHeader = request.headers.get("authorization"); + const accessToken = authorizationHeader?.startsWith("Bearer ") + ? authorizationHeader.slice(7) + : null; + + if (!accessToken) { + return null; + } + + const supabaseClient = createClient(SUPABASE.url, SUPABASE.anonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + const { data, error } = await supabaseClient.auth.getUser(accessToken); + + if (error || !data.user) { + return null; + } + + return data.user.id; +}; diff --git a/apps/web/src/modules/billing/completeSubscriptionCharge.ts b/apps/web/src/modules/billing/completeSubscriptionCharge.ts new file mode 100644 index 000000000..2ddc24b47 --- /dev/null +++ b/apps/web/src/modules/billing/completeSubscriptionCharge.ts @@ -0,0 +1,47 @@ +import { createBillingAdminClient } from "./admin"; +import type { IFTossBillingPayment } from "./toss"; + +/** 결제가 완료된 원장과 구독을 저장하기 위한 입력입니다. */ +interface IFCompleteSubscriptionChargeInput { + userId: string; + orderId: string; + requestedAt: string; + payment: IFTossBillingPayment; +} + +/** 동일 사용자 청구 시작과 직렬화된 DB 트랜잭션으로 구독·원장을 함께 확정합니다. */ +export const completeSubscriptionCharge = async ( + input: IFCompleteSubscriptionChargeInput, +): Promise => { + if ( + input.payment.status !== "DONE" || + input.payment.orderId !== input.orderId + ) { + throw new Error("PAYMENT_NOT_CONFIRMED"); + } + const periodStart = new Date(input.payment.approvedAt ?? input.requestedAt); + if (Number.isNaN(periodStart.getTime())) { + throw new Error("PAYMENT_PERIOD_INVALID"); + } + const periodEnd = new Date(periodStart); + const originalDay = periodEnd.getUTCDate(); + periodEnd.setUTCDate(1); + periodEnd.setUTCMonth(periodEnd.getUTCMonth() + 1); + const finalDay = new Date( + Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth() + 1, 0), + ).getUTCDate(); + periodEnd.setUTCDate(Math.min(originalDay, finalDay)); + const { error } = await createBillingAdminClient() + .schema("billing") + .rpc("complete_subscription_charge", { + target_user_id: input.userId, + target_order_id: input.orderId, + target_period_start: periodStart.toISOString(), + target_period_end: periodEnd.toISOString(), + target_payment_key: input.payment.paymentKey, + target_payment: input.payment, + }); + if (error) { + throw error; + } +}; diff --git a/apps/web/src/modules/billing/config.ts b/apps/web/src/modules/billing/config.ts new file mode 100644 index 000000000..372567d80 --- /dev/null +++ b/apps/web/src/modules/billing/config.ts @@ -0,0 +1,8 @@ +/** 토스 결제 API 기본 URL입니다. */ +export const TOSS_PAYMENTS_API_URL = "https://api.tosspayments.com/v1"; + +/** 월 구독 가격입니다. */ +export const SUBSCRIPTION_PRICE_KRW = 500; + +/** AI 호출 1회에 예약하는 보수적 최대 비용입니다. */ +export const AI_RESERVATION_COST_MICROS = 3_000_000; diff --git a/apps/web/src/modules/billing/cron.ts b/apps/web/src/modules/billing/cron.ts new file mode 100644 index 000000000..7ef00f46a --- /dev/null +++ b/apps/web/src/modules/billing/cron.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from "next/server"; + +/** Supabase Cron 요청의 공유 비밀을 상수 시간으로 검증합니다. */ +export const verifyBillingCronRequest = (request: NextRequest): boolean => { + const expectedSecret = process.env.BILLING_CRON_SECRET; + const providedSecret = request.headers + .get("authorization") + ?.replace("Bearer ", ""); + + if ( + !expectedSecret || + !providedSecret || + expectedSecret.length !== providedSecret.length + ) { + return false; + } + + let difference = 0; + + for (let index = 0; index < expectedSecret.length; index += 1) { + difference |= + expectedSecret.charCodeAt(index) ^ providedSecret.charCodeAt(index); + } + + return difference === 0; +}; diff --git a/apps/web/src/modules/billing/crypto.test.ts b/apps/web/src/modules/billing/crypto.test.ts new file mode 100644 index 000000000..50443c3ea --- /dev/null +++ b/apps/web/src/modules/billing/crypto.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { decryptBillingKey, encryptBillingKey } from "./crypto"; + +const ORIGINAL_ENCRYPTION_KEY = process.env.BILLING_KEY_ENCRYPTION_KEY; + +afterEach(() => { + process.env.BILLING_KEY_ENCRYPTION_KEY = ORIGINAL_ENCRYPTION_KEY; +}); + +describe("billing key encryption", () => { + it("encrypts without exposing plaintext and decrypts losslessly", () => { + process.env.BILLING_KEY_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString( + "base64", + ); + const ciphertext = encryptBillingKey("billing-key-secret"); + + expect(ciphertext).not.toContain("billing-key-secret"); + expect(decryptBillingKey(ciphertext)).toBe("billing-key-secret"); + }); + + it("fails closed when the encryption key is missing", () => { + delete process.env.BILLING_KEY_ENCRYPTION_KEY; + + expect(() => encryptBillingKey("billing-key-secret")).toThrow( + "BILLING_KEY_ENCRYPTION_KEY is not configured", + ); + }); +}); diff --git a/apps/web/src/modules/billing/crypto.ts b/apps/web/src/modules/billing/crypto.ts new file mode 100644 index 000000000..2a390ccb4 --- /dev/null +++ b/apps/web/src/modules/billing/crypto.ts @@ -0,0 +1,61 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; + +const getEncryptionKey = (): Buffer => { + const encodedKey = process.env.BILLING_KEY_ENCRYPTION_KEY; + + if (!encodedKey) { + throw new Error("BILLING_KEY_ENCRYPTION_KEY is not configured"); + } + + const key = Buffer.from(encodedKey, "base64"); + + if (key.length !== 32) { + throw new Error("BILLING_KEY_ENCRYPTION_KEY must be 32 bytes in base64"); + } + + return key; +}; + +/** 빌링키를 AES-256-GCM으로 암호화합니다. */ +export const encryptBillingKey = (billingKey: string): string => { + const initializationVector = randomBytes(12); + const cipher = createCipheriv( + ALGORITHM, + getEncryptionKey(), + initializationVector, + ); + const encrypted = Buffer.concat([ + cipher.update(billingKey, "utf8"), + cipher.final(), + ]); + + return [ + initializationVector.toString("base64"), + cipher.getAuthTag().toString("base64"), + encrypted.toString("base64"), + ].join("."); +}; + +/** 서버 비밀 테이블에 저장된 빌링키를 복호화합니다. */ +export const decryptBillingKey = (ciphertext: string): string => { + const [initializationVector, authenticationTag, encrypted] = + ciphertext.split("."); + + if (!initializationVector || !authenticationTag || !encrypted) { + throw new Error("Encrypted billing key has an invalid format"); + } + + const decipher = createDecipheriv( + ALGORITHM, + getEncryptionKey(), + Buffer.from(initializationVector, "base64"), + ); + decipher.setAuthTag(Buffer.from(authenticationTag, "base64")); + + return Buffer.concat([ + decipher.update(Buffer.from(encrypted, "base64")), + decipher.final(), + ]).toString("utf8"); +}; diff --git a/apps/web/src/modules/billing/dispatchSubscriptionCharge.ts b/apps/web/src/modules/billing/dispatchSubscriptionCharge.ts new file mode 100644 index 000000000..7e46e0762 --- /dev/null +++ b/apps/web/src/modules/billing/dispatchSubscriptionCharge.ts @@ -0,0 +1,75 @@ +import { createBillingAdminClient } from "./admin"; +import { completeSubscriptionCharge } from "./completeSubscriptionCharge"; +import { decryptBillingKey } from "./crypto"; +import { chargeTossBillingKey, isDefinitiveTossRejection } from "./toss"; + +/** 원장에 확보한 불변 주문 정보와 서버 전용 빌링키입니다. */ +interface IFDispatchSubscriptionChargeInput { + userId: string; + orderId: string; + requestedAt: string; + amount: number; + secret: { billing_key_ciphertext: string; toss_customer_key: string }; + isRecovery: boolean; +} + +/** DB에서 발송 가능 여부를 확정한 뒤 항상 동일 주문·토스 멱등성 키로 청구합니다. */ +export const dispatchSubscriptionCharge = async ( + input: IFDispatchSubscriptionChargeInput, +) => { + const billingSchema = createBillingAdminClient().schema("billing"); + const { data: dispatchStatus, error: dispatchError } = + await billingSchema.rpc("prepare_charge_dispatch", { + target_user_id: input.userId, + target_order_id: input.orderId, + target_recovery: input.isRecovery, + }); + if (dispatchError) { + throw dispatchError; + } + if (dispatchStatus !== "dispatch") { + return { + orderId: input.orderId, + status: String(dispatchStatus ?? "unknown"), + }; + } + try { + const payment = await chargeTossBillingKey({ + billingKey: decryptBillingKey(input.secret.billing_key_ciphertext), + customerKey: input.secret.toss_customer_key, + amount: input.amount, + orderId: input.orderId, + orderName: "Web Memo Pro 1개월", + }); + await completeSubscriptionCharge({ + userId: input.userId, + orderId: input.orderId, + requestedAt: input.requestedAt, + payment, + }); + + return { orderId: input.orderId, status: "succeeded" }; + } catch (error) { + const isRejected = isDefinitiveTossRejection(error); + const { error: persistenceError } = await billingSchema + .from("charges") + .update({ + status: isRejected ? "failed" : "unknown", + failure_message: + error instanceof Error ? error.message : "Charge persistence failed", + resolved_at: isRejected ? new Date().toISOString() : null, + }) + .eq("order_id", input.orderId) + .in("status", ["pending", "unknown"]); + if (persistenceError) { + console.error( + "Charge remains pending for reconciliation", + persistenceError, + ); + } + if (isRejected && !persistenceError) { + return { orderId: input.orderId, status: "failed" }; + } + throw error; + } +}; diff --git a/apps/web/src/modules/billing/index.ts b/apps/web/src/modules/billing/index.ts new file mode 100644 index 000000000..ce6a8e84d --- /dev/null +++ b/apps/web/src/modules/billing/index.ts @@ -0,0 +1,10 @@ +/** 결제 관리자 클라이언트를 내보냅니다. */ +export { createBillingAdminClient } from "./admin"; +/** AI 사용량 가드를 내보냅니다. */ +export { + calculateAiCostMicros, + reserveAiUsage, + settleAiUsage, +} from "./aiUsage"; +/** 결제 API 인증 함수를 내보냅니다. */ +export { authenticateBillingRequest } from "./auth"; diff --git a/apps/web/src/modules/billing/migration.test.ts b/apps/web/src/modules/billing/migration.test.ts new file mode 100644 index 000000000..a3f77b1a6 --- /dev/null +++ b/apps/web/src/modules/billing/migration.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const MIGRATION = readFileSync( + new URL( + "../../../../../packages/supabase-edge-functions/supabase/migrations/20260913160000_add_billing_and_usage_limits.sql", + import.meta.url, + ), + "utf8", +); + +describe("billing database guard definitions", () => { + it("locks the memo owner before checking existing rows and the free quota", () => { + const memoGuard = MIGRATION.slice( + MIGRATION.indexOf("function billing.enforce_memo_limit()"), + MIGRATION.indexOf("drop trigger if exists enforce_free_memo_limit"), + ); + expect(memoGuard).toContain( + "pg_advisory_xact_lock(hashtextextended('memo-limit:' || new.user_id::text, 0))", + ); + expect(memoGuard.indexOf("pg_advisory_xact_lock")).toBeLessThan( + memoGuard.indexOf("select count(*)"), + ); + expect(memoGuard).toContain("where id = new.id and user_id = new.user_id"); + expect(memoGuard.indexOf("where id = new.id")).toBeLessThan( + memoGuard.indexOf("select count(*)"), + ); + }); + it("prevents concurrent unresolved charges with different idempotency keys", () => { + expect(MIGRATION).toContain( + "create unique index charges_one_unresolved_per_user_idx on billing.charges (user_id) where status in ('pending', 'unknown')", + ); + expect(MIGRATION).toContain( + "create trigger enforce_charge_start before insert on billing.charges", + ); + expect(MIGRATION).toContain("message = 'ACTIVE_SUBSCRIPTION_EXISTS'"); + }); + it("serializes atomic completion with new charge insertion", () => { + const complete = MIGRATION.slice( + MIGRATION.indexOf("function billing.complete_subscription_charge("), + MIGRATION.indexOf("function billing.enforce_memo_limit()"), + ); + expect(complete).toContain( + "pg_advisory_xact_lock(hashtextextended('billing-charge:' || target_user_id::text, 0))", + ); + expect(complete.indexOf("insert into billing.subscriptions")).toBeLessThan( + complete.indexOf("update billing.charges set status = 'succeeded'"), + ); + expect(complete).toContain( + "cancel_at_period_end = billing.subscriptions.cancel_at_period_end", + ); + }); +}); diff --git a/apps/web/src/modules/billing/reconcileSubscriptionCharges.ts b/apps/web/src/modules/billing/reconcileSubscriptionCharges.ts new file mode 100644 index 000000000..1a4321d3c --- /dev/null +++ b/apps/web/src/modules/billing/reconcileSubscriptionCharges.ts @@ -0,0 +1,83 @@ +import { createBillingAdminClient } from "./admin"; +import { completeSubscriptionCharge } from "./completeSubscriptionCharge"; +import { dispatchSubscriptionCharge } from "./dispatchSubscriptionCharge"; +import { getTossPaymentByOrderId, isTossOrderNotFound } from "./toss"; + +/** 오래된 pending 및 unknown 청구를 동일 주문으로 복구하며 불확실한 상태를 실패로 단정하지 않습니다. */ +export const reconcileSubscriptionCharges = async () => { + const billingSchema = createBillingAdminClient().schema("billing"); + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const { data: charges, error } = await billingSchema + .from("charges") + .select("id,order_id,user_id,requested_at,amount_krw") + .in("status", ["pending", "unknown"]) + .lte("requested_at", tenMinutesAgo) + .limit(100); + if (error) { + throw error; + } + let resolvedCount = 0; + for (const charge of charges ?? []) { + try { + const payment = await getTossPaymentByOrderId(charge.order_id); + if (payment.orderId !== charge.order_id) { + throw new Error("PAYMENT_ORDER_MISMATCH"); + } + if (payment.status === "DONE") { + await completeSubscriptionCharge({ + userId: charge.user_id, + orderId: charge.order_id, + requestedAt: charge.requested_at, + payment, + }); + resolvedCount += 1; + } else if (["ABORTED", "CANCELED", "EXPIRED"].includes(payment.status)) { + const { error: updateError } = await billingSchema + .from("charges") + .update({ + status: "failed", + toss_payment_key: payment.paymentKey, + resolved_at: new Date().toISOString(), + raw_response: payment, + }) + .eq("id", charge.id) + .select("id") + .single(); + if (updateError) { + throw updateError; + } + resolvedCount += 1; + } + } catch (lookupError) { + if (isTossOrderNotFound(lookupError)) { + try { + const { data: secret, error: secretError } = await billingSchema + .from("customer_secrets") + .select("toss_customer_key,billing_key_ciphertext") + .eq("user_id", charge.user_id) + .single(); + if (secretError || !secret) { + throw secretError ?? new Error("BILLING_KEY_NOT_REGISTERED"); + } + const result = await dispatchSubscriptionCharge({ + userId: charge.user_id, + orderId: charge.order_id, + requestedAt: charge.requested_at, + amount: charge.amount_krw, + secret, + isRecovery: true, + }); + if (["succeeded", "failed"].includes(result.status)) { + resolvedCount += 1; + } + } catch (recoveryError) { + console.error("Charge recovery remains unresolved", recoveryError); + } + } else { + console.error("Charge remains unresolved", lookupError); + } + } + } + + return { processedCount: charges?.length ?? 0, resolvedCount }; +}; diff --git a/apps/web/src/modules/billing/subscription.test.ts b/apps/web/src/modules/billing/subscription.test.ts new file mode 100644 index 000000000..bcc6de92f --- /dev/null +++ b/apps/web/src/modules/billing/subscription.test.ts @@ -0,0 +1,327 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** Supabase 쿼리의 명시적 결과입니다. */ +interface IFQueryResult { + data?: unknown; + error?: unknown; +} +const mocks = vi.hoisted(() => ({ + responses: [] as IFQueryResult[], + writes: [] as Array<{ table: string; operation: string; value: unknown }>, + charge: vi.fn(), + lookup: vi.fn(), + complete: vi.fn(), +})); +vi.mock("./admin", () => ({ + createBillingAdminClient: () => ({ + schema: () => ({ + rpc: mocks.complete, + from: (table: string) => { + const response = mocks.responses.shift() ?? { data: null, error: null }; + const query = { + ...response, + select: (_columns?: string) => query, + eq: (_column: string, _value: unknown) => query, + in: (_column: string, _value: unknown) => query, + gt: (_column: string, _value: unknown) => query, + lte: (_column: string, _value: unknown) => query, + limit: (_limit: number) => query, + maybeSingle: () => query, + single: () => query, + insert: (value: unknown) => { + mocks.writes.push({ table, operation: "insert", value }); + return query; + }, + upsert: (value: unknown) => { + mocks.writes.push({ table, operation: "upsert", value }); + return query; + }, + update: (value: unknown) => { + mocks.writes.push({ table, operation: "update", value }); + return query; + }, + }; + return query; + }, + }), + }), +})); +vi.mock("./crypto", () => ({ decryptBillingKey: () => "test-key" })); +vi.mock("./toss", async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + chargeTossBillingKey: mocks.charge, + getTossPaymentByOrderId: mocks.lookup, + }; +}); + +import { completeSubscriptionCharge } from "./completeSubscriptionCharge"; +import { reconcileSubscriptionCharges } from "./reconcileSubscriptionCharges"; +import { chargeSubscription } from "./subscription"; + +beforeEach(() => { + mocks.responses.length = 0; + mocks.writes.length = 0; + vi.clearAllMocks(); + mocks.complete.mockImplementation(async (name: string) => ({ + data: name === "prepare_charge_dispatch" ? "dispatch" : null, + error: null, + })); +}); + +const prepareNewCharge = () => { + mocks.responses.push( + { data: null }, + { data: null }, + { data: null }, + { + data: { + toss_customer_key: "customer", + billing_key_ciphertext: "encrypted", + }, + }, + { error: null }, + ); + mocks.charge.mockImplementation(async (input: { orderId: string }) => ({ + paymentKey: "payment", + orderId: input.orderId, + status: "DONE", + })); +}; + +describe("charge persistence safeguards", () => { + it("does not return success when subscription persistence fails", async () => { + prepareNewCharge(); + mocks.complete.mockImplementation(async (name: string) => + name === "prepare_charge_dispatch" + ? { data: "dispatch" } + : { error: new Error("subscription db failed") }, + ); + mocks.responses.push({ error: null }); + await expect( + chargeSubscription({ userId: "user", idempotencyKey: "new" }), + ).rejects.toThrow("subscription db failed"); + expect(mocks.writes.at(-1)?.value).toMatchObject({ + status: "unknown", + resolved_at: null, + }); + expect( + mocks.writes.some( + (write) => + write.table === "charges" && + (write.value as { status?: string }).status === "succeeded", + ), + ).toBe(false); + }); + it("does not return success when charge persistence fails", async () => { + prepareNewCharge(); + mocks.complete.mockImplementation(async (name: string) => + name === "prepare_charge_dispatch" + ? { data: "dispatch" } + : { error: new Error("charge db failed") }, + ); + mocks.responses.push({ error: null }); + await expect( + chargeSubscription({ userId: "user", idempotencyKey: "new" }), + ).rejects.toThrow("charge db failed"); + expect(mocks.writes.at(-1)?.value).toMatchObject({ status: "unknown" }); + }); + it("blocks a new key when another charge is unknown", async () => { + mocks.responses.push( + { data: null }, + { data: { order_id: "existing", status: "unknown" } }, + ); + expect( + await chargeSubscription({ userId: "user", idempotencyKey: "different" }), + ).toEqual({ orderId: "existing", status: "unknown" }); + expect(mocks.charge).not.toHaveBeenCalled(); + expect(mocks.writes).toHaveLength(0); + }); + it("blocks a new key while the subscription is active", async () => { + mocks.responses.push( + { data: null }, + { data: null }, + { data: { user_id: "user" } }, + ); + await expect( + chargeSubscription({ userId: "user", idempotencyKey: "different" }), + ).rejects.toThrow("ACTIVE_SUBSCRIPTION_EXISTS"); + expect(mocks.charge).not.toHaveBeenCalled(); + }); + it("does not charge after a lookup failure", async () => { + mocks.responses.push({ error: new Error("lookup failed") }); + await expect( + chargeSubscription({ userId: "user", idempotencyKey: "new" }), + ).rejects.toThrow("lookup failed"); + expect(mocks.charge).not.toHaveBeenCalled(); + }); + it("finalizes a definitive provider decline as failed", async () => { + prepareNewCharge(); + mocks.charge.mockRejectedValue( + Object.assign(new Error("TOSS_REJECTED_CARD"), { + httpStatus: 400, + isChargeRequest: true, + }), + ); + mocks.responses.push({ error: null }); + expect( + (await chargeSubscription({ userId: "user", idempotencyKey: "new" })) + .status, + ).toBe("failed"); + expect(mocks.writes.at(-1)?.value).toMatchObject({ status: "failed" }); + }); + it("keeps network failures unresolved", async () => { + prepareNewCharge(); + mocks.charge.mockRejectedValue(new Error("network timeout")); + mocks.responses.push({ error: null }); + await expect( + chargeSubscription({ userId: "user", idempotencyKey: "new" }), + ).rejects.toThrow("network timeout"); + expect(mocks.writes.at(-1)?.value).toMatchObject({ status: "unknown" }); + }); + it("uses a stable provider approval period when retrying reconciliation", async () => { + await completeSubscriptionCharge({ + userId: "user", + orderId: "order", + requestedAt: "2026-09-01T00:00:00.000Z", + payment: { + paymentKey: "key", + orderId: "order", + status: "DONE", + approvedAt: "2026-09-02T00:00:00.000Z", + }, + }); + expect(mocks.complete).toHaveBeenCalledWith( + "complete_subscription_charge", + expect.objectContaining({ + target_period_start: "2026-09-02T00:00:00.000Z", + }), + ); + }); + it("clamps a January 31 monthly period to the last day of February", async () => { + await completeSubscriptionCharge({ + userId: "user", + orderId: "order", + requestedAt: "2026-01-31T12:00:00.000Z", + payment: { paymentKey: "key", orderId: "order", status: "DONE" }, + }); + expect(mocks.complete).toHaveBeenCalledWith( + "complete_subscription_charge", + expect.objectContaining({ + target_period_end: "2026-02-28T12:00:00.000Z", + }), + ); + }); +}); + +describe("reconciliation persistence", () => { + it("does not count a charge as resolved when its subscription write fails", async () => { + mocks.responses.push({ + data: [ + { + id: "charge", + order_id: "order", + user_id: "user", + requested_at: "2026-09-01T00:00:00.000Z", + }, + ], + }); + mocks.complete.mockResolvedValue({ error: new Error("db unavailable") }); + mocks.lookup.mockResolvedValue({ + paymentKey: "key", + orderId: "order", + status: "DONE", + }); + expect(await reconcileSubscriptionCharges()).toEqual({ + processedCount: 1, + resolvedCount: 0, + }); + expect(mocks.writes.some((write) => write.table === "charges")).toBe(false); + }); + it("does not convert a nonterminal provider state to failed", async () => { + mocks.responses.push({ + data: [ + { + id: "charge", + order_id: "order", + user_id: "user", + requested_at: "2026-09-01T00:00:00.000Z", + }, + ], + }); + mocks.lookup.mockResolvedValue({ + paymentKey: "key", + orderId: "order", + status: "IN_PROGRESS", + }); + expect(await reconcileSubscriptionCharges()).toEqual({ + processedCount: 1, + resolvedCount: 0, + }); + expect(mocks.writes).toHaveLength(0); + }); +}); + +describe("safe same-order recovery", () => { + it("replays an existing key without creating another ledger or provider charge", async () => { + mocks.responses.push({ + data: { order_id: "same-order", status: "unknown" }, + }); + expect( + await chargeSubscription({ userId: "user", idempotencyKey: "same-key" }), + ).toEqual({ orderId: "same-order", status: "unknown" }); + expect(mocks.charge).not.toHaveBeenCalled(); + }); + it.each(["failed", "dispatch"])( + "recovers a missing provider order using DB dispatch state %s", + async (dispatchState) => { + mocks.responses.push( + { + data: [ + { + id: "id", + user_id: "user", + order_id: "original-order", + requested_at: "2026-09-01T00:00:00Z", + amount_krw: 500, + }, + ], + }, + { + data: { + toss_customer_key: "customer", + billing_key_ciphertext: "encrypted", + }, + }, + ); + mocks.lookup.mockRejectedValue( + Object.assign(new Error("missing"), { + httpStatus: 404, + code: "NOT_FOUND_PAYMENT", + }), + ); + mocks.complete.mockImplementation(async (name: string) => ({ + data: name === "prepare_charge_dispatch" ? dispatchState : null, + error: null, + })); + mocks.charge.mockResolvedValue({ + orderId: "original-order", + paymentKey: "payment", + status: "DONE", + }); + expect(await reconcileSubscriptionCharges()).toEqual({ + processedCount: 1, + resolvedCount: 1, + }); + if (dispatchState === "dispatch") { + expect(mocks.charge).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "original-order", amount: 500 }), + ); + } else { + expect(mocks.charge).not.toHaveBeenCalled(); + } + }, + ); +}); diff --git a/apps/web/src/modules/billing/subscription.ts b/apps/web/src/modules/billing/subscription.ts new file mode 100644 index 000000000..6e0be5f0a --- /dev/null +++ b/apps/web/src/modules/billing/subscription.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; +import { createBillingAdminClient } from "./admin"; +import { SUBSCRIPTION_PRICE_KRW } from "./config"; +import { dispatchSubscriptionCharge } from "./dispatchSubscriptionCharge"; + +/** 구독 청구 요청에 필요한 사용자 정보입니다. */ +export interface IFChargeSubscriptionInput { + /** 청구할 사용자 ID입니다. */ + userId: string; + /** 재시도에서 재사용할 멱등성 키입니다. */ + idempotencyKey: string; +} + +/** 기존 청구·활성 기간을 확인하고 DB 원장 확보 후에만 토스에 청구합니다. */ +export const chargeSubscription = async ( + input: IFChargeSubscriptionInput, +): Promise<{ orderId: string; status: string }> => { + const billingSchema = createBillingAdminClient().schema("billing"); + const { data: existingCharge, error: existingChargeError } = + await billingSchema + .from("charges") + .select("order_id,status") + .eq("user_id", input.userId) + .eq("idempotency_key", input.idempotencyKey) + .maybeSingle(); + if (existingChargeError) { + throw existingChargeError; + } + if (existingCharge) { + return { orderId: existingCharge.order_id, status: existingCharge.status }; + } + const { data: pendingCharge, error: pendingChargeError } = await billingSchema + .from("charges") + .select("order_id,status") + .eq("user_id", input.userId) + .in("status", ["pending", "unknown"]) + .limit(1) + .maybeSingle(); + if (pendingChargeError) { + throw pendingChargeError; + } + if (pendingCharge) { + return { orderId: pendingCharge.order_id, status: pendingCharge.status }; + } + const { data: subscription, error: subscriptionError } = await billingSchema + .from("subscriptions") + .select("user_id") + .eq("user_id", input.userId) + .eq("status", "active") + .gt("current_period_end", new Date().toISOString()) + .maybeSingle(); + if (subscriptionError) { + throw subscriptionError; + } + if (subscription) { + throw new Error("ACTIVE_SUBSCRIPTION_EXISTS"); + } + const { data: secret, error: secretError } = await billingSchema + .from("customer_secrets") + .select("toss_customer_key,billing_key_ciphertext") + .eq("user_id", input.userId) + .single(); + if (secretError || !secret) { + throw new Error("BILLING_KEY_NOT_REGISTERED"); + } + const orderId = `webmemo-${randomUUID()}`; + const requestedAt = new Date().toISOString(); + const { error: insertError } = await billingSchema.from("charges").insert({ + user_id: input.userId, + order_id: orderId, + idempotency_key: input.idempotencyKey, + amount_krw: SUBSCRIPTION_PRICE_KRW, + status: "pending", + requested_at: requestedAt, + }); + if (insertError) { + if (insertError.message.includes("ACTIVE_SUBSCRIPTION_EXISTS")) { + throw new Error("ACTIVE_SUBSCRIPTION_EXISTS"); + } + if (insertError.code === "23505") { + const { data: concurrentCharge, error: concurrentError } = + await billingSchema + .from("charges") + .select("order_id,status") + .eq("user_id", input.userId) + .in("status", ["pending", "unknown"]) + .limit(1) + .maybeSingle(); + if (!concurrentError && concurrentCharge) { + return { + orderId: concurrentCharge.order_id, + status: concurrentCharge.status, + }; + } + } + throw insertError; + } + return dispatchSubscriptionCharge({ + userId: input.userId, + orderId, + requestedAt, + amount: SUBSCRIPTION_PRICE_KRW, + secret, + isRecovery: false, + }); +}; diff --git a/apps/web/src/modules/billing/toss.test.ts b/apps/web/src/modules/billing/toss.test.ts new file mode 100644 index 000000000..3277d65cb --- /dev/null +++ b/apps/web/src/modules/billing/toss.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { chargeTossBillingKey, isDefinitiveTossRejection } from "./toss"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); +describe("Toss failure classification", () => { + it("recognizes an explicit 400 charge rejection", async () => { + vi.stubEnv("TOSS_PAYMENTS_SECRET_KEY", "test-secret"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "REJECT_CARD_PAYMENT" }), { + status: 400, + }), + ), + ); + try { + await chargeTossBillingKey({ + billingKey: "key", + customerKey: "customer", + amount: 500, + orderId: "order", + orderName: "subscription", + }); + expect.unreachable(); + } catch (error) { + expect(isDefinitiveTossRejection(error)).toBe(true); + } + }); + it.each([408, 409, 429, 500, 502, 503])( + "does not finalize uncertain HTTP %i as failed", + (httpStatus) => { + expect( + isDefinitiveTossRejection( + Object.assign(new Error("provider error"), { + httpStatus, + isChargeRequest: true, + }), + ), + ).toBe(false); + }, + ); + it("does not treat a failed lookup or network error as a declined charge", () => { + expect( + isDefinitiveTossRejection( + Object.assign(new Error("lookup not found"), { + httpStatus: 404, + isChargeRequest: false, + }), + ), + ).toBe(false); + expect(isDefinitiveTossRejection(new Error("network timeout"))).toBe(false); + }); +}); + +it("uses the immutable order ID as the provider idempotency key", async () => { + vi.stubEnv("TOSS_PAYMENTS_SECRET_KEY", "test-secret"); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ status: "DONE" }))); + vi.stubGlobal("fetch", fetchMock); + await chargeTossBillingKey({ + billingKey: "key", + customerKey: "customer", + amount: 500, + orderId: "same-order", + orderName: "subscription", + }); + expect(fetchMock.mock.calls[0][1].headers["Idempotency-Key"]).toBe( + "same-order", + ); +}); diff --git a/apps/web/src/modules/billing/toss.ts b/apps/web/src/modules/billing/toss.ts new file mode 100644 index 000000000..6169fd5c7 --- /dev/null +++ b/apps/web/src/modules/billing/toss.ts @@ -0,0 +1,146 @@ +import { TOSS_PAYMENTS_API_URL } from "./config"; + +/** 토스 빌링키 발급 결과입니다. */ +export interface IFTossBillingAuthorization { + /** 발급된 빌링키입니다. */ + billingKey: string; + /** 고객 식별자입니다. */ + customerKey: string; +} + +/** 토스 자동결제 응답입니다. */ +export interface IFTossBillingPayment { + /** 토스 결제 식별자입니다. */ + paymentKey: string; + /** 주문 식별자입니다. */ + orderId: string; + /** 결제 승인 시각입니다. */ + approvedAt?: string; + /** 결제 상태입니다. */ + status: string; +} + +const getAuthorizationHeader = (): string => { + const secretKey = process.env.TOSS_PAYMENTS_SECRET_KEY; + + if (!secretKey) { + throw new Error("TOSS_PAYMENTS_SECRET_KEY is not configured"); + } + + return `Basic ${Buffer.from(`${secretKey}:`).toString("base64")}`; +}; + +const requestToss = async ( + path: string, + body: Record, +): Promise => { + const response = await fetch(`${TOSS_PAYMENTS_API_URL}${path}`, { + method: "POST", + headers: { + Authorization: getAuthorizationHeader(), + "Content-Type": "application/json", + ...(typeof body.orderId === "string" + ? { "Idempotency-Key": body.orderId } + : {}), + }, + body: JSON.stringify(body), + }); + const responseBody = (await response.json()) as T & { + code?: string; + message?: string; + }; + + if (!response.ok) { + throw Object.assign( + new Error(`TOSS_${responseBody.code ?? "REQUEST_FAILED"}`), + { + httpStatus: response.status, + isChargeRequest: + path.startsWith("/billing/") && !path.includes("authorizations"), + }, + ); + } + + return responseBody; +}; + +/** 주문 식별자로 토스 결제 결과를 조회합니다. */ +export const getTossPaymentByOrderId = async ( + orderId: string, +): Promise => { + const response = await fetch( + `${TOSS_PAYMENTS_API_URL}/payments/orders/${encodeURIComponent(orderId)}`, + { headers: { Authorization: getAuthorizationHeader() } }, + ); + const responseBody = (await response.json()) as IFTossBillingPayment & { + code?: string; + message?: string; + }; + + if (!response.ok) { + throw Object.assign( + new Error(`TOSS_${responseBody.code ?? "LOOKUP_FAILED"}`), + { + httpStatus: response.status, + code: responseBody.code, + isChargeRequest: false, + }, + ); + } + + return responseBody; +}; + +/** 인증키를 토스 빌링키로 교환합니다. */ +export const issueTossBillingKey = async (input: { + authKey: string; + customerKey: string; +}): Promise => + requestToss( + "/billing/authorizations/issue", + input, + ); + +/** 저장된 빌링키로 토스 자동결제를 요청합니다. */ +export const chargeTossBillingKey = async (input: { + billingKey: string; + customerKey: string; + amount: number; + orderId: string; + orderName: string; +}): Promise => { + const { billingKey, ...requestBody } = input; + + return requestToss( + `/billing/${billingKey}`, + requestBody, + ); +}; + +/** 네트워크·서버·충돌·타임아웃 응답과 구별되는 명시적인 청구 거절인지 확인합니다. */ +export const isDefinitiveTossRejection = (error: unknown): boolean => { + if ( + !(error instanceof Error) || + !("httpStatus" in error) || + !("isChargeRequest" in error) + ) { + return false; + } + const status = error.httpStatus; + + return ( + error.isChargeRequest === true && + typeof status === "number" && + status >= 400 && + status < 500 && + ![408, 409, 429].includes(status) + ); +}; + +/** 조회 API가 해당 주문의 부재를 명시적으로 확인했는지 판별합니다. */ +export const isTossOrderNotFound = (error: unknown): boolean => + error instanceof Error && + "httpStatus" in error && + error.httpStatus === 404 && + "code" in error && + error.code === "NOT_FOUND_PAYMENT"; diff --git a/docs/architecture.md b/docs/architecture.md index d81f114f0..32e9a414f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ | 스키마 변경 절차 | ① `migrations/`에 SQL 추가 → ② 원격 DB에 적용 → ③ `pnpm generate-supabase-type` → ④ 관련 query/mutation 훅 갱신.
**통합 E2E가 실제 프로덕션 Supabase를 치므로 스키마는 머지 전이 아니라 push 전에 적용돼 있어야 합니다.** `supabase db push`는 히스토리 불일치로 막혀 있어 Management API로 단일 SQL을 실행합니다 | | API 규약 | 경로는 `/api/<도메인>/<행위>`, 소문자 kebab-case.
응답은 Route Handler에서 `NextResponse.json()`으로 반환하고, 에러는 상태 코드 + `{ message }` 형태로 통일합니다.
Server Action에서는 try/catch 대신 **에러를 값으로 반환**합니다. 반대로 서비스 계층(훅에서 부르는 쪽)은 TanStack Query가 잡을 수 있도록 사용자 친화적 에러를 throw합니다 | | 인증 | **Supabase Auth.** Google·Kakao OAuth + 이메일. 콜백은 `/auth/callback`(OAuth)과 `/auth/callback-email`.
세션은 `@supabase/ssr` 쿠키. 확장은 웹이 심은 `access_token`/`refresh_token` 쿠키를 `chrome.cookies`로 읽어갑니다 — **쿠키 이름이 양쪽에서 정확히 일치해야 로그인 연동이 동작합니다**(`packages/shared/src/constants/SupabaseConfig.ts`).
보호 라우트는 `(auth)` 그룹으로 구분합니다 | -| 외부 연동 | OpenAI(요약·카테고리·QA) · Upstash Redis(레이트리밋) · Slack(피드백/알림) · youtube-transcript(자막) · Sentry.
**키가 필요한 호출은 전부 서버(Route Handler)에서만 합니다.** 클라이언트에서 직접 부르지 않습니다.
OpenAI 호출은 실비로 과금되므로 새 기능을 붙일 때 호출 빈도와 레이트리밋을 함께 정합니다 | +| 외부 연동 | OpenAI(요약·카테고리·QA) · 토스페이먼츠(카드 빌링) · Upstash Redis(레이트리밋) · Slack(피드백/알림) · youtube-transcript(자막) · Sentry.
**키가 필요한 호출은 전부 서버(Route Handler)에서만 합니다.** 클라이언트에서 직접 부르지 않습니다. 토스 빌링키는 암호화해 비공개 billing 스키마에 저장하고, 반복 청구는 Supabase Cron이 인증된 Route Handler를 호출합니다.
OpenAI 호출은 실비로 과금되므로 구독 권한과 주기별 횟수·비용 예약을 서버에서 함께 검사합니다 | ## QA 실행 diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 056dcd3c8..96042170a 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -346,6 +346,24 @@ ID)이 전부 고정값이라 상수만 읽습니다. 앱에는 스테이징 배 gitignore 대상이라 EAS 샌드박스에 복사되지 않아 iOS 빌드가 깨지던 제약 자체가 앱에서는 사라졌습니다. +### 결제 (`apps/web` 서버 전용) + +결제 비밀은 모두 `apps/web/.env`와 Vercel 프로젝트 환경변수에만 둡니다. 값은 +문서·PR·클라이언트 번들에 기록하지 않습니다. + +| 이름 | 용도 | +| --- | --- | +| `NEXT_PUBLIC_TOSS_PAYMENTS_CLIENT_KEY` | 토스 카드 등록창 초기화에 사용하는 공개 키 | +| `TOSS_PAYMENTS_SECRET_KEY` | 빌링키 발급·승인·조회 서버 인증 | +| `SUPABASE_SERVICE_ROLE_KEY` | 비공개 billing 스키마 원장 처리 | +| `BILLING_KEY_ENCRYPTION_KEY` | 빌링키 AES-256-GCM 암호화용 32바이트 base64 키 | +| `BILLING_CRON_SECRET` | Supabase Cron에서 결제 작업 Route Handler를 호출할 때 사용하는 인증 값 | +| `BILLING_ENABLED` | 계약·정책·운영 준비가 끝난 환경에서만 `true`로 설정하는 출시 스위치 | +| `OPENAI_USD_TO_KRW_RATE` | AI 실제 토큰 비용을 원화로 정산할 때 사용하는 운영 환율 | + +위 값 중 하나라도 없으면 결제 기능은 실패 폐쇄 방식으로 비활성화합니다. 테스트와 +운영은 서로 다른 토스 상점·Supabase 프로젝트·암호화 키를 사용합니다. + ### Supabase Edge Functions `packages/supabase-edge-functions`는 Supabase 플랫폼이 주입하는 예약 변수를 diff --git a/packages/supabase-edge-functions/supabase/migrations/20260913160000_add_billing_and_usage_limits.sql b/packages/supabase-edge-functions/supabase/migrations/20260913160000_add_billing_and_usage_limits.sql new file mode 100644 index 000000000..6ad41e70c --- /dev/null +++ b/packages/supabase-edge-functions/supabase/migrations/20260913160000_add_billing_and_usage_limits.sql @@ -0,0 +1,275 @@ +create schema if not exists billing; + +create type billing.subscription_status as enum ('inactive', 'active', 'past_due', 'cancelled'); +create type billing.charge_status as enum ('pending', 'succeeded', 'failed', 'unknown'); + +create table billing.customer_secrets ( + user_id uuid primary key references auth.users(id) on delete cascade, + toss_customer_key text not null unique, + billing_key_ciphertext text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table billing.subscriptions ( + user_id uuid primary key references auth.users(id) on delete cascade, + status billing.subscription_status not null default 'inactive', + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at_period_end boolean not null default false, + price_krw integer not null default 500 check (price_krw > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table billing.charges ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + order_id text not null unique, + idempotency_key text not null unique, + amount_krw integer not null check (amount_krw > 0), + status billing.charge_status not null default 'pending', + toss_payment_key text, + failure_code text, + failure_message text, + requested_at timestamptz not null default now(), + resolved_at timestamptz, + dispatched_at timestamptz, + raw_response jsonb +); + +create table billing.ai_usage ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + period_start timestamptz not null, + period_end timestamptz not null, + feature text not null, + estimated_cost_micros integer not null check (estimated_cost_micros >= 0), + actual_cost_micros integer check (actual_cost_micros >= 0), + status text not null check (status in ('reserved', 'settled', 'released')), + created_at timestamptz not null default now() +); + +create unique index charges_one_unresolved_per_user_idx on billing.charges (user_id) where status in ('pending', 'unknown'); + +create index charges_user_requested_idx on billing.charges (user_id, requested_at desc); +create index charges_unknown_idx on billing.charges (requested_at) where status = 'unknown'; +create index ai_usage_period_idx on billing.ai_usage (user_id, period_start, period_end) where status <> 'released'; + +alter table billing.customer_secrets enable row level security; +alter table billing.subscriptions enable row level security; +alter table billing.charges enable row level security; +alter table billing.ai_usage enable row level security; + +create policy subscription_select_own on billing.subscriptions for select using (auth.uid() = user_id); +create policy charge_select_own on billing.charges for select using (auth.uid() = user_id); +create policy ai_usage_select_own on billing.ai_usage for select using (auth.uid() = user_id); + +revoke all on schema billing from anon, authenticated; +grant usage on schema billing to authenticated, service_role; +grant select on billing.subscriptions, billing.charges, billing.ai_usage to authenticated; +grant all on all tables in schema billing to service_role; + +create or replace function billing.is_paid_user(target_user_id uuid) +returns boolean language sql stable security definer set search_path = billing, public as $$ + select exists ( + select 1 from billing.subscriptions + where user_id = target_user_id and status = 'active' and current_period_end > now() + ); +$$; + +create or replace function billing.enforce_charge_start() +returns trigger language plpgsql security definer set search_path = billing, public as $$ +begin + perform pg_advisory_xact_lock(hashtextextended('billing-charge:' || new.user_id::text, 0)); + if billing.is_paid_user(new.user_id) then + raise exception using errcode = 'P0001', message = 'ACTIVE_SUBSCRIPTION_EXISTS'; + end if; + if new.idempotency_key like 'renew:%' and exists ( + select 1 from billing.subscriptions where user_id = new.user_id and cancel_at_period_end + ) then + raise exception using errcode = 'P0001', message = 'SUBSCRIPTION_RENEWAL_CANCELLED'; + end if; + return new; +end; +$$; +create trigger enforce_charge_start before insert on billing.charges +for each row execute function billing.enforce_charge_start(); + +-- 해지·원장 생성·발송·완료는 모두 같은 사용자 lock으로 순서를 결정합니다. +create or replace function billing.cancel_subscription(target_user_id uuid) +returns void language plpgsql security definer set search_path = billing, public as $$ +begin + perform pg_advisory_xact_lock(hashtextextended('billing-charge:' || target_user_id::text, 0)); + update billing.subscriptions set cancel_at_period_end = true, updated_at = now() + where user_id = target_user_id; +end; +$$; +revoke all on function billing.cancel_subscription(uuid) from public, anon, authenticated; +grant execute on function billing.cancel_subscription(uuid) to service_role; + +create or replace function billing.prepare_charge_dispatch( + target_user_id uuid, target_order_id text, target_recovery boolean default false +) returns text language plpgsql security definer set search_path = billing, public as $$ +declare + charge billing.charges%rowtype; +begin + perform pg_advisory_xact_lock(hashtextextended('billing-charge:' || target_user_id::text, 0)); + select * into charge from billing.charges + where user_id = target_user_id and order_id = target_order_id for update; + if not found then + raise exception 'CHARGE_NOT_FOUND'; + end if; + if charge.status not in ('pending', 'unknown') then + return charge.status::text; + end if; + if charge.dispatched_at is not null then + -- 토스 멱등성 보존 기간(15일) 안에서만 같은 주문을 재전송합니다. + if target_recovery and charge.dispatched_at > now() - interval '14 days' then + return 'dispatch'; + end if; + return 'unknown'; + end if; + if target_recovery or (charge.idempotency_key like 'renew:%' and exists ( + select 1 from billing.subscriptions where user_id = target_user_id and cancel_at_period_end + )) then + update billing.charges set status = 'failed', resolved_at = now(), + failure_code = 'CHARGE_NOT_DISPATCHED', failure_message = 'Charge was not sent to the payment provider' + where id = charge.id; + return 'failed'; + end if; + update billing.charges set dispatched_at = now() where id = charge.id; + return 'dispatch'; +end; +$$; +revoke all on function billing.prepare_charge_dispatch(uuid, text, boolean) from public, anon, authenticated; +grant execute on function billing.prepare_charge_dispatch(uuid, text, boolean) to service_role; + +create or replace function billing.complete_subscription_charge( + target_user_id uuid, + target_order_id text, + target_period_start timestamptz, + target_period_end timestamptz, + target_payment_key text, + target_payment jsonb +) returns void language plpgsql security definer set search_path = billing, public as $$ +declare + charge billing.charges%rowtype; +begin + perform pg_advisory_xact_lock(hashtextextended('billing-charge:' || target_user_id::text, 0)); + select * into charge from billing.charges + where user_id = target_user_id and order_id = target_order_id for update; + if not found then + raise exception using errcode = 'P0001', message = 'CHARGE_NOT_FOUND'; + end if; + if charge.status = 'succeeded' then + return; + end if; + if target_period_end <= target_period_start or target_payment->>'status' is distinct from 'DONE' + or target_payment->>'orderId' is distinct from target_order_id then + raise exception using errcode = 'P0001', message = 'PAYMENT_NOT_CONFIRMED'; + end if; + insert into billing.subscriptions(user_id, status, current_period_start, current_period_end, cancel_at_period_end, price_krw) + values(target_user_id, 'active', target_period_start, target_period_end, false, charge.amount_krw) + on conflict (user_id) do update set + status = 'active', current_period_start = excluded.current_period_start, + current_period_end = excluded.current_period_end, price_krw = excluded.price_krw, + cancel_at_period_end = billing.subscriptions.cancel_at_period_end, + updated_at = now(); + update billing.charges set status = 'succeeded', toss_payment_key = target_payment_key, + resolved_at = now(), raw_response = target_payment + where id = charge.id; +end; +$$; +revoke all on function billing.complete_subscription_charge(uuid, text, timestamptz, timestamptz, text, jsonb) from public, anon, authenticated; +grant execute on function billing.complete_subscription_charge(uuid, text, timestamptz, timestamptz, text, jsonb) to service_role; + +create or replace function billing.enforce_memo_limit() +returns trigger language plpgsql security definer set search_path = billing, memo, public as $$ +begin + perform pg_advisory_xact_lock(hashtextextended('memo-limit:' || new.user_id::text, 0)); + -- ON CONFLICT DO UPDATE에도 BEFORE INSERT가 실행되므로 기존 소유 행의 편집은 허용합니다. + if exists (select 1 from memo.memo where id = new.id and user_id = new.user_id) then + return new; + end if; + if billing.is_paid_user(new.user_id) then + return new; + end if; + if (select count(*) from memo.memo where user_id = new.user_id) >= 50 then + raise exception using errcode = 'P0001', message = 'FREE_MEMO_LIMIT_EXCEEDED'; + end if; + return new; +end; +$$; + +drop trigger if exists enforce_free_memo_limit on memo.memo; +create trigger enforce_free_memo_limit before insert on memo.memo +for each row execute function billing.enforce_memo_limit(); + +create or replace function billing.reserve_ai_usage( + target_user_id uuid, + target_feature text, + target_estimated_cost_micros integer +) returns uuid language plpgsql security definer set search_path = billing, public as $$ +declare + subscription billing.subscriptions%rowtype; + usage_count integer; + usage_cost bigint; + reservation_id uuid; +begin + select * into subscription from billing.subscriptions + where user_id = target_user_id and status = 'active' and current_period_end > now() + for update; + if not found then + raise exception using errcode = 'P0001', message = 'ACTIVE_SUBSCRIPTION_REQUIRED'; + end if; + select count(*), coalesce(sum(coalesce(actual_cost_micros, estimated_cost_micros)), 0) + into usage_count, usage_cost from billing.ai_usage + where user_id = target_user_id + and period_start = subscription.current_period_start + and period_end = subscription.current_period_end + and status <> 'released'; + if usage_count >= 30 then + raise exception using errcode = 'P0001', message = 'AI_USAGE_LIMIT_EXCEEDED'; + end if; + if usage_cost + target_estimated_cost_micros > 100000000 then + raise exception using errcode = 'P0001', message = 'AI_COST_LIMIT_EXCEEDED'; + end if; + insert into billing.ai_usage(user_id, period_start, period_end, feature, estimated_cost_micros, status) + values(target_user_id, subscription.current_period_start, subscription.current_period_end, target_feature, target_estimated_cost_micros, 'reserved') + returning id into reservation_id; + return reservation_id; +end; +$$; + +revoke all on function billing.reserve_ai_usage(uuid, text, integer) from public, anon, authenticated; +grant execute on function billing.reserve_ai_usage(uuid, text, integer) to service_role; + +create or replace function billing.settle_ai_usage( + target_reservation_id uuid, + target_actual_cost_micros integer +) returns void language sql security definer set search_path = billing, public as $$ + update billing.ai_usage + set status = 'settled', actual_cost_micros = target_actual_cost_micros + where id = target_reservation_id and status = 'reserved'; +$$; + +revoke all on function billing.settle_ai_usage(uuid, integer) from public, anon, authenticated; +grant execute on function billing.settle_ai_usage(uuid, integer) to service_role; + +create extension if not exists pg_cron with schema extensions; +create extension if not exists pg_net with schema extensions; +select cron.schedule('billing-reconcile-unknown-charges', '*/10 * * * *', $$ + select net.http_post( + url := current_setting('app.settings.billing_reconcile_url', true), + headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.billing_cron_secret', true)), + body := '{}'::jsonb + ) where current_setting('app.settings.billing_reconcile_url', true) <> '' +$$); +select cron.schedule('billing-renew-subscriptions', '0 * * * *', $$ + select net.http_post( + url := current_setting('app.settings.billing_renew_url', true), + headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.billing_cron_secret', true)), + body := '{}'::jsonb + ) where current_setting('app.settings.billing_renew_url', true) <> '' +$$); From e7a906e8e72c0000ba6c2bad30018cf40fdefea9 Mon Sep 17 00:00:00 2001 From: frontend-guesung Date: Sun, 13 Sep 2026 16:54:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=9B=B9=20=EC=9A=94=EA=B8=88?= =?UTF-8?q?=EC=A0=9C=EC=99=80=20=EA=B5=AC=EB=8F=85=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 월 500원 상품 안내부터 카드 등록, 결제 복구, 기간 말 해지까지 한 흐름으로 제공한다. --- .../billing/_components/BillingOverview.tsx | 287 ++++++++++++++++++ .../(no-auth)/billing/_components/index.ts | 2 + .../billing/_hooks/useBillingActions.test.ts | 77 +++++ .../billing/_hooks/useBillingActions.ts | 258 ++++++++++++++++ .../src/app/[lng]/(no-auth)/billing/page.tsx | 13 + .../[lng]/(no-auth)/billing/result/page.tsx | 13 + .../pricing/_components/PricingCards.tsx | 67 ++++ .../(no-auth)/pricing/_components/index.ts | 2 + .../src/app/[lng]/(no-auth)/pricing/page.tsx | 9 + .../web/src/components/Header/HeaderRight.tsx | 14 +- .../modules/i18n/locales/en/translation.json | 56 +++- .../modules/i18n/locales/ko/translation.json | 56 +++- packages/shared/package.json | 7 + packages/shared/src/hooks/billing/index.ts | 7 + .../billing/useSubscriptionQuery.test.ts | 72 +++++ .../src/hooks/billing/useSubscriptionQuery.ts | 85 ++++++ 16 files changed, 1011 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx create mode 100644 apps/web/src/app/[lng]/(no-auth)/billing/_components/index.ts create mode 100644 apps/web/src/app/[lng]/(no-auth)/billing/_hooks/useBillingActions.test.ts create mode 100644 apps/web/src/app/[lng]/(no-auth)/billing/_hooks/useBillingActions.ts create mode 100644 apps/web/src/app/[lng]/(no-auth)/billing/page.tsx create mode 100644 apps/web/src/app/[lng]/(no-auth)/billing/result/page.tsx create mode 100644 apps/web/src/app/[lng]/(no-auth)/pricing/_components/PricingCards.tsx create mode 100644 apps/web/src/app/[lng]/(no-auth)/pricing/_components/index.ts create mode 100644 apps/web/src/app/[lng]/(no-auth)/pricing/page.tsx create mode 100644 packages/shared/src/hooks/billing/index.ts create mode 100644 packages/shared/src/hooks/billing/useSubscriptionQuery.test.ts create mode 100644 packages/shared/src/hooks/billing/useSubscriptionQuery.ts diff --git a/apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx b/apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx new file mode 100644 index 000000000..3fbea616c --- /dev/null +++ b/apps/web/src/app/[lng]/(no-auth)/billing/_components/BillingOverview.tsx @@ -0,0 +1,287 @@ +"use client"; + +import type { LanguageType } from "@src/modules/i18n"; +import useTranslation from "@src/modules/i18n/util.client"; +import { hasPaidSubscription } from "@web-memo/shared/hooks/billing"; +import { + Alert, + AlertDescription, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Checkbox, + Label, + Skeleton, +} from "@web-memo/ui"; +import Link from "next/link"; +import Script from "next/script"; +import { useState } from "react"; +import { useBillingActions } from "../_hooks/useBillingActions"; + +/** 결제 성공을 추정하지 않고 서버 구독 상태와 다음 행동을 표시합니다. */ +export const BillingOverview = (props: LanguageType) => { + const { t } = useTranslation(props.lng); + const billing = useBillingActions(props.lng); + const [hasBillingConsent, setHasBillingConsent] = useState(false); + const subscription = billing.subscriptionQuery.data?.subscription; + const isPaid = hasPaidSubscription(subscription ?? null); + const mutations = [ + billing.registerCardMutation, + billing.issueKeyMutation, + billing.subscribeMutation, + billing.cancelMutation, + ]; + const isBillingBusy = mutations.some((mutation) => mutation.isPending); + const billingError = mutations.find((mutation) => mutation.error)?.error; + const periodEnd = subscription?.current_period_end; + const isBillingReady = + billing.configQuery.data?.billingEnabled && + !billing.configQuery.isError && + !billing.subscriptionQuery.isError; + + return ( +
+

{t("billing.manage")}

+ {!billing.userId ? ( + + +

{t("billing.loginRequired")}

+ +
+
+ ) : ( + <> + {billing.configQuery.data?.billingEnabled && ( +