import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { executeOrderCash } from "@/lib/kis/trade"; import { DashboardStockCashOrderResponse, } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { parseKisAccountParts } from "@/lib/kis/account"; import { createKisApiErrorResponse, KIS_API_ERROR_CODE, toKisApiErrorMessage, } from "@/app/api/kis/_response"; import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; /** * @file app/api/kis/domestic/order-cash/route.ts * @description 국내주식 현금 주문 API */ const orderCashBodySchema = z .object({ symbol: z .string() .trim() .regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."), side: z.enum(["buy", "sell"], { message: "주문 구분(side)은 buy/sell만 허용됩니다.", }), orderType: z.enum(["limit", "market"], { message: "주문 유형(orderType)은 limit/market만 허용됩니다.", }), quantity: z.coerce .number() .int("주문수량은 정수여야 합니다.") .positive("주문수량은 1주 이상이어야 합니다."), price: z.coerce.number(), accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."), accountProductCode: z.string().trim().optional(), }) .superRefine((body, ctx) => { if (body.orderType === "limit" && body.price <= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["price"], message: "지정가 주문은 주문가격이 0보다 커야 합니다.", }); } if (body.orderType === "market" && body.price < 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["price"], message: "시장가 주문은 주문가격이 0 이상이어야 합니다.", }); } const accountParts = parseKisAccountParts( body.accountNo, body.accountProductCode, ); if (!accountParts) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["accountNo"], message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", }); } }); export async function POST(request: NextRequest) { const credentials = readKisCredentialsFromHeaders(request.headers); const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const hasSession = await hasKisApiSession(); if (!hasSession) { return createKisApiErrorResponse({ status: 401, code: KIS_API_ERROR_CODE.AUTH_REQUIRED, message: "로그인이 필요합니다.", tradingEnv, }); } if (!hasKisConfig(credentials)) { return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, message: "KIS API 키 설정이 필요합니다.", tradingEnv, }); } try { let rawBody: unknown = {}; try { rawBody = (await request.json()) as unknown; } catch { return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.INVALID_REQUEST, message: "요청 본문(JSON)을 읽을 수 없습니다.", tradingEnv, }); } const parsed = orderCashBodySchema.safeParse(rawBody); if (!parsed.success) { const firstIssue = parsed.error.issues[0]; return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.INVALID_REQUEST, message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.", tradingEnv, }); } const body = parsed.data; const accountParts = parseKisAccountParts( body.accountNo, body.accountProductCode, ); if (!accountParts) { return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED, message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", tradingEnv, }); } const output = await executeOrderCash( { symbol: body.symbol, side: body.side, orderType: body.orderType, quantity: body.quantity, price: body.price, accountNo: accountParts.accountNo, accountProductCode: accountParts.accountProductCode, }, credentials, ); const response: DashboardStockCashOrderResponse = { ok: true, tradingEnv, message: "주문이 전송되었습니다.", orderNo: output.ODNO, orderTime: output.ORD_TMD, orderOrgNo: output.KRX_FWDG_ORD_ORGNO, }; return NextResponse.json(response); } catch (error) { return createKisApiErrorResponse({ status: 500, code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."), tradingEnv, }); } }