CAKE20

AI Web PaaS

결제

Toss, Stripe와 PayPal 단건 결제를 공통 payment 함수로 처리합니다.

전체 결제 흐름

  • 결제 전에 서버 DB에 주문번호, 금액, 통화, 결제사와 PENDING 상태를 먼저 저장합니다.
  • payment.request에는 클라이언트가 보낸 금액이 아니라 DB에 저장한 주문 금액을 전달합니다.
  • 사용자 인증이 끝나면 서버에서 payment.confirm을 호출하고 paid 결과를 DB에 기록한 뒤 상품을 제공합니다.
  • 브라우저 성공 화면은 결제 완료 근거가 아닙니다. 서버 confirm 결과나 검증된 webhook만 신뢰합니다.
  • webhook은 누락되거나 재전송될 수 있으므로 이벤트 ID를 저장하고 같은 이벤트를 여러 번 처리해도 결과가 같게 만듭니다.
  • 환불은 원래 결제 레코드의 provider ID를 사용하고 환불 성공 결과도 별도 이력으로 남깁니다.
주문 생성(PENDING)
  → payment.request
  → 결제사 화면에서 사용자 인증
  → payment.confirm
  → 결제 결과를 DB에 기록(PAID)
  → 상품 제공

결제사 webhook
  → payment.verifyWebhook
  → 이벤트 중복 검사
  → DB 결제 상태 보정

환불 요청
  → payment.refund
  → 환불 결과를 DB에 기록

결제 기록 모델 예제

  • 이 모델은 복사해서 수정할 수 있는 권장 예제이며 Cake20이 자동 생성하거나 관리하지 않습니다.
  • Payment 한 행은 주문이 아니라 한 번의 결제 시도를 나타냅니다. 같은 주문을 다시 결제하면 새 행을 만듭니다.
  • provider에는 toss, stripe 또는 paypal을 저장합니다. 다른 결제 어댑터를 추가해도 DB enum 변경 없이 사용할 수 있습니다.
  • providerId에는 Toss paymentKey, Stripe PaymentIntent ID 또는 PayPal Order ID를 저장합니다.
  • transactionId에는 providerId와 실제 거래 ID가 다른 PayPal Capture ID 등을 저장합니다.
  • requestKey는 payment.request 전에 생성해 DB에 저장하고 같은 요청을 재시도할 때 같은 값을 전달합니다.
  • status에는 ready, requires_action, pending, paid, canceled, refunded, partially_refunded 또는 failed를 저장합니다.
  • amount는 최소 통화 단위의 정수입니다. DB의 BigInt를 payment 함수에 전달할 때 Number로 바꾸고 Number.isSafeInteger로 확인합니다.
  • request 전 ready 행을 만들고, request 결과의 providerId와 status, confirm 결과의 transactionId와 최종 status를 차례로 저장합니다.
  • 기존 주문 모델과의 relation이나 provider와 providerId의 복합 unique는 웹사이트 구조에 맞게 추가합니다. 여러 웹훅 이벤트나 환불 이력은 Payment 한 행에 합치지 말고 필요할 때 별도 모델로 둡니다.
// server/db/payment.db.ts
export const Payment = {
  id: z.id(),
  orderId: z.string().max(200).index(),
  provider: z.string().max(20),
  providerId: z.string().max(200).nullable().index(),
  transactionId: z.string().max(200).nullable(),
  requestKey: z.string().max(38).nullable().unique(),
  status: z.string().max(30).default("ready"),
  amount: z.bigint().min(1),
  currency: z.string().min(3).max(3),
  createdAt: z.date().defaultNow().timestamp(),
  updatedAt: z.raw("DateTime @updatedAt @db.Timestamp(0)")
};

결제 기록 방식은 웹사이트가 선택합니다. 기존 주문 모델에 필요한 필드만 추가하거나 이 예제를 확장해도 payment 함수 사용에는 차이가 없습니다.

결제사 설정

  • providers에는 toss, stripe와 paypal을 1개 이상 등록합니다.
  • default를 생략하면 첫 번째 결제사, currency를 생략하면 KRW를 사용합니다.
  • mode는 test 또는 live이며 생략하면 안전하게 test를 사용합니다.
  • 금액은 최소 통화 단위의 양의 정수입니다. KRW 10,000원은 10000, USD 12달러는 1200입니다.
// package.json
{
  "payment": {
    "providers": ["toss", "stripe", "paypal"],
    "default": "toss",
    "currency": "KRW",
    "mode": "test"
  }
}

결제 인증정보

  • 결제 키는 package.json이나 서버 소스가 아니라 웹사이트 설정의 암호화 Secret에 저장합니다.
  • payment.request 결과의 clientKey와 clientSecret은 결제 화면에만 전달하고 로그에 남기지 않습니다.
  • 에디터 API 테스트도 실제 결제사 API를 호출하므로 test 키와 mode: test만 사용합니다.
TOSS_CLIENT_KEY
TOSS_SECRET_KEY

STRIPE_PUBLISHABLE_KEY
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET

PAYPAL_CLIENT_ID
PAYPAL_CLIENT_SECRET
PAYPAL_WEBHOOK_ID

결제 준비

  • Toss는 clientKey와 결제창 요청값을 반환합니다.
  • Stripe는 PaymentIntent를 만들고 clientKey와 clientSecret을 반환합니다.
  • PayPal은 Order를 만들고 구매자 승인용 checkoutUrl을 반환합니다.
  • 주문번호와 금액은 클라이언트 입력을 신뢰하지 말고 서버 DB 값으로 결정합니다.
const ready = await payment.request({
  provider: "stripe",
  orderId: "order-20260724-1",
  orderName: "딸기 케이크",
  amount: 1200,
  currency: "USD",
  returnUrl: "https://example.com/pay/success",
  cancelUrl: "https://example.com/pay/fail",
  idempotencyKey: "order-20260724-1"
});

승인과 검증

  • Toss는 결제 승인 API를 호출합니다.
  • Stripe는 Stripe.js 인증 후 PaymentIntent를 다시 조회해 주문번호, 금액과 통화를 검증합니다.
  • PayPal은 구매자가 승인한 Order를 capture하고 Capture ID를 transactionId로 반환합니다.
  • 성공 결과를 DB에 먼저 기록한 뒤 상품 제공이나 후속 작업을 실행합니다.
const result = await payment.confirm({
  provider: "toss",
  id: data.paymentKey,
  orderId: order.id,
  amount: order.amount,
  idempotencyKey: `${order.id}-confirm`
});

if (result.status !== "paid") {
  throw new Error("결제가 완료되지 않았습니다.");
}

전체·부분 환불

  • amount를 생략하면 전체 환불하고 값을 넣으면 부분 환불합니다.
  • Toss는 paymentKey, Stripe는 PaymentIntent ID를 id로 사용합니다.
  • PayPal은 confirm 결과의 transactionId인 Capture ID를 id로 사용합니다.
  • 같은 작업을 재시도할 때 같은 idempotencyKey를 사용하도록 주문과 함께 저장합니다.
await payment.refund({
  provider: order.provider,
  id: order.paymentId,
  amount: 5000,
  currency: order.currency,
  reason: "고객 요청",
  idempotencyKey: `${order.id}-refund-1`
});

웹훅 검증

  • JSON으로 다시 직렬화하지 않은 원본 body가 필요합니다.
  • Stripe는 HMAC signature, PayPal은 공식 검증 API로 확인합니다.
  • Toss 일반 결제 이벤트는 paymentKey 또는 orderId로 결제를 다시 조회해 비교합니다.
  • 웹훅은 재전송될 수 있으므로 이벤트 ID와 상태 변경을 중복 처리해도 안전하게 만듭니다.
// server/api/payment/webhook.post.ts
export default async (request: Request) => {
  const event = await payment.verifyWebhook({
    provider: "stripe",
    body: await request.text(),
    headers: request.headers
  });

  // event ID로 중복 처리를 막고 DB 상태를 갱신합니다.
  return { received: event.verified };
};