Stripe
Stripe 플러그인은 Stripe의 결제 및 구독 기능을 Better Auth와 통합합니다. 결제와 인증은 종종 밀접하게 결합되어 있기 때문에 이 플러그인은 Stripe를 애플리케이션에 통합하는 것을 단순화하여 고객 생성, 구독 관리 및 웹훅 처리를 처리합니다.
기능
- 사용자가 가입할 때 자동으로 Stripe 고객 생성
- 구독 플랜 및 가격 관리
- 구독 라이프사이클 이벤트 처리 (생성, 업데이트, 취소)
- 서명 검증으로 Stripe 웹훅을 안전하게 처리
- 애플리케이션에 구독 데이터 노출
- 평가판 기간 및 구독 업그레이드 지원
- 자동 평가판 남용 방지 - 사용자는 모든 플랜에서 계정당 한 번의 평가판만 받을 수 있습니다
- 사용자 또는 조직과 구독을 연결하는 유연한 참조 시스템
- 좌석 관리를 통한 팀 구독 지원
설치
플러그인 설치
먼저 플러그인을 설치하세요:
npm install @better-auth/stripe별도의 클라이언트와 서버 설정을 사용하는 경우 프로젝트의 두 부분 모두에 플러그인을 설치해야 합니다.
auth 구성에 플러그인 추가
import { betterAuth } from "better-auth"
import { stripe } from "@better-auth/stripe"
import Stripe from "stripe"
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-02-24.acacia",
})
export const auth = betterAuth({
// ... 기존 구성
plugins: [
stripe({
stripeClient,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
})
]
})클라이언트 플러그인 추가
import { createAuthClient } from "better-auth/client"
import { stripeClient } from "@better-auth/stripe/client"
export const client = createAuthClient({
// ... 기존 구성
plugins: [
stripeClient({
subscription: true //구독 관리를 활성화하려면
})
]
})데이터베이스 마이그레이션
마이그레이션을 실행하거나 스키마를 생성하여 필요한 테이블을 데이터베이스에 추가하세요.
npx @better-auth/cli migratenpx @better-auth/cli generate수동으로 테이블을 추가하려면 스키마 섹션을 참조하세요.
Stripe 웹훅 설정
Stripe 대시보드에서 다음을 가리키는 웹훅 엔드포인트를 생성하세요:
https://your-domain.com/api/auth/stripe/webhook/api/auth는 auth 서버의 기본 경로입니다.
최소한 다음 이벤트를 선택해야 합니다:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deleted
Stripe에서 제공하는 웹훅 서명 시크릿을 저장하고 STRIPE_WEBHOOK_SECRET으로 환경 변수에 추가하세요.
사용법
고객 관리
구독을 활성화하지 않고도 고객 관리만을 위해 이 플러그인을 사용할 수 있습니다. Stripe 고객을 사용자에게 연결하고 싶을 때 유용합니다.
기본적으로 사용자가 가입하면 createCustomerOnSignUp: true로 설정한 경우 Stripe 고객이 자동으로 생성됩니다. 이 고객은 데이터베이스의 사용자에게 연결됩니다.
고객 생성 프로세스를 사용자 지정할 수 있습니다:
stripe({
// ... 기타 옵션
createCustomerOnSignUp: true,
onCustomerCreate: async ({ customer, stripeCustomer, user }, request) => {
// 새로 생성된 고객으로 무언가를 수행
console.log(`사용자 ${user.id}에 대해 고객 ${customer.id}가 생성되었습니다`);
},
getCustomerCreateParams: async ({ user, session }, request) => {
// Stripe 고객 생성 매개변수 사용자 지정
return {
metadata: {
referralSource: user.metadata?.referralSource
}
};
}
})구독 관리
플랜 정의
구독 플랜을 정적 또는 동적으로 정의할 수 있습니다:
// 정적 플랜
subscription: {
enabled: true,
plans: [
{
name: "basic", // 플랜 이름, 데이터베이스에 저장될 때 자동으로 소문자로 변환됩니다
priceId: "price_1234567890", // stripe의 가격 ID
annualDiscountPriceId: "price_1234567890", // (선택 사항) 할인이 적용된 연간 청구의 가격 ID
limits: {
projects: 5,
storage: 10
}
},
{
name: "pro",
priceId: "price_0987654321",
limits: {
projects: 20,
storage: 50
},
freeTrial: {
days: 14,
}
}
]
}
// 동적 플랜 (데이터베이스 또는 API에서 가져옴)
subscription: {
enabled: true,
plans: async () => {
const plans = await db.query("SELECT * FROM plans");
return plans.map(plan => ({
name: plan.name,
priceId: plan.stripe_price_id,
limits: JSON.parse(plan.limits)
}));
}
}자세한 내용은 플랜 구성을 참조하세요.
구독 생성
구독을 생성하려면 subscription.upgrade 메서드를 사용하세요:
const { data, error } = await authClient.subscription.upgrade({ plan: "pro", // required annual: true, referenceId: "123", subscriptionId: "sub_123", metadata, seats: 1, successUrl, // required cancelUrl, // required returnUrl, disableRedirect: true, // required});| Prop | Description | Type |
|---|---|---|
plan | 업그레이드할 플랜의 이름. | string |
annual? | 연간 플랜으로 업그레이드할지 여부. | boolean |
referenceId? | 업그레이드할 구독의 참조 ID. | string |
subscriptionId? | 업그레이드할 구독의 ID. | string |
metadata? | Record<string, any> | |
seats? | 업그레이드할 좌석 수 (해당되는 경우). | number |
successUrl | 구독 성공 후 리디렉션할 콜백 URL. | string |
cancelUrl | 설정된 경우 체크아웃에 뒤로 버튼이 표시되고 결제를 취소하면 고객이 여기로 이동합니다. | string |
returnUrl? | 고객이 청구 포털의 웹사이트로 돌아가기 링크를 클릭할 때 이동할 URL. | string |
disableRedirect | 구독 성공 후 리디렉션 비활성화. | boolean |
간단한 예제:
await client.subscription.upgrade({
plan: "pro",
successUrl: "/dashboard",
cancelUrl: "/pricing",
annual: true, // 선택 사항: 연간 플랜으로 업그레이드
referenceId: "org_123", // 선택 사항: 기본값은 현재 로그인한 사용자 ID
seats: 5 // 선택 사항: 팀 플랜용
});이렇게 하면 Checkout Session이 생성되고 사용자가 Stripe Checkout 페이지로 리디렉션됩니다.
사용자에게 이미 활성 구독이 있는 경우 subscriptionId 매개변수를 반드시 제공해야 합니다. 그렇지 않으면 사용자가 두 플랜 모두에 구독하고 요금을 지불하게 됩니다.
중요:
successUrl매개변수는 체크아웃 완료와 웹훅 처리 사이의 경쟁 조건을 처리하기 위해 내부적으로 수정됩니다. 플러그인은 성공 페이지로 리디렉션하기 전에 구독 상태가 올바르게 업데이트되도록 하는 중간 리디렉션을 생성합니다.
const { error } = await client.subscription.upgrade({
plan: "pro",
successUrl: "/dashboard",
cancelUrl: "/pricing",
});
if(error) {
alert(error.message);
}각 참조 ID (사용자 또는 조직)에 대해 한 번에 하나의 활성 또는 평가판 구독만 지원됩니다. 플러그인은 현재 동일한 참조 ID에 대해 여러 개의 동시 활성 구독을 지원하지 않습니다.
플랜 전환
구독을 다른 플랜으로 전환하려면 subscription.upgrade 메서드를 사용하세요:
await client.subscription.upgrade({
plan: "pro",
successUrl: "/dashboard",
cancelUrl: "/pricing",
subscriptionId: "sub_123", // 사용자의 현재 플랜의 Stripe 구독 ID
});이렇게 하면 사용자가 새 플랜에만 요금을 지불하고 두 플랜 모두에 대해 지불하지 않습니다.
활성 구독 목록
사용자의 활성 구독을 가져오려면:
const { data: subscriptions, error } = await authClient.subscription.list({ referenceId: '123',});// 활성 구독 가져오기const activeSubscription = subscriptions.find( sub => sub.status === "active" || sub.status === "trialing");// 구독 제한 확인const projectLimit = subscriptions?.limits?.projects || 0;| Prop | Description | Type |
|---|---|---|
referenceId? | 나열할 구독의 참조 ID. | string |
구독 취소
구독을 취소하려면:
const { data, error } = await authClient.subscription.cancel({ referenceId: 'org_123', subscriptionId: 'sub_123', returnUrl: '/account', // required});| Prop | Description | Type |
|---|---|---|
referenceId? | 취소할 구독의 참조 ID. 기본값은 userId입니다. | string |
subscriptionId? | 취소할 구독의 ID. | string |
returnUrl | 고객이 청구 포털의 웹사이트로 돌아가기 링크를 클릭할 때 이동할 URL. | string |
이렇게 하면 사용자가 Stripe Billing Portal로 리디렉션되어 구독을 취소할 수 있습니다.
취소된 구독 복원
사용자가 구독을 취소한 후 (구독 기간이 끝나기 전에) 마음이 바뀌면 구독을 복원할 수 있습니다:
const { data, error } = await authClient.subscription.restore({ referenceId: '123', subscriptionId: 'sub_123',});| Prop | Description | Type |
|---|---|---|
referenceId? | 복원할 구독의 참조 ID. 기본값은 userId입니다. | string |
subscriptionId? | 복원할 구독의 ID. | string |
이렇게 하면 이전에 청구 기간 종료 시 취소되도록 설정된 구독 (cancelAtPeriodEnd: true)이 다시 활성화됩니다. 구독은 자동으로 계속 갱신됩니다.
참고: 이것은 여전히 활성 상태이지만 기간 종료 시 취소되도록 표시된 구독에만 작동합니다. 이미 종료된 구독은 복원할 수 없습니다.
청구 포털 세션 생성
고객이 구독을 관리하고, 결제 방법을 업데이트하고, 청구 내역을 볼 수 있는 Stripe 청구 포털 세션을 생성하려면:
const { data, error } = await authClient.subscription.billingPortal({ locale, referenceId: "123", returnUrl,});| Prop | Description | Type |
|---|---|---|
locale? | 고객 포털이 표시되는 로케일의 IETF 언어 태그. 비어 있거나 auto인 경우 브라우저의 로케일이 사용됩니다. | string |
referenceId? | 업그레이드할 구독의 참조 ID. | string |
returnUrl? | 구독 성공 후 리디렉션할 반환 URL. | string |
지원되는 로케일에 대해서는 IETF 언어 태그 문서를 참조하세요.
이 엔드포인트는 Stripe 청구 포털 세션을 생성하고 응답에서 URL을 data.url로 반환합니다. 사용자를 이 URL로 리디렉션하여 구독, 결제 방법 및 청구 내역을 관리할 수 있습니다.
참조 시스템
기본적으로 구독은 사용자 ID와 연결됩니다. 그러나 사용자 지정 참조 ID를 사용하여 조직과 같은 다른 엔터티와 구독을 연결할 수 있습니다:
// 조직에 대한 구독 생성
await client.subscription.upgrade({
plan: "pro",
referenceId: "org_123456",
successUrl: "/dashboard",
cancelUrl: "/pricing",
seats: 5 // 팀 플랜의 좌석 수
});
// 조직의 구독 목록
const { data: subscriptions } = await client.subscription.list({
query: {
referenceId: "org_123456"
}
});좌석이 있는 팀 구독
팀 또는 조직 플랜의 경우 좌석 수를 지정할 수 있습니다:
await client.subscription.upgrade({
plan: "team",
referenceId: "org_123456",
seats: 10, // 10명의 팀 멤버
successUrl: "/org/billing/success",
cancelUrl: "/org/billing"
});seats 매개변수는 구독 항목의 수량으로 Stripe에 전달됩니다. 이 값을 애플리케이션 로직에서 사용하여 팀이나 조직의 멤버 수를 제한할 수 있습니다.
참조 ID를 승인하려면 authorizeReference 함수를 구현하세요:
subscription: {
// ... 기타 옵션
authorizeReference: async ({ user, session, referenceId, action }) => {
// 사용자가 이 참조에 대한 구독을 관리할 권한이 있는지 확인
if (action === "upgrade-subscription" || action === "cancel-subscription" || action === "restore-subscription") {
const org = await db.member.findFirst({
where: {
organizationId: referenceId,
userId: user.id
}
});
return org?.role === "owner"
}
return true;
}
}웹훅 처리
플러그인은 일반적인 웹훅 이벤트를 자동으로 처리합니다:
checkout.session.completed: 체크아웃 후 구독 상태 업데이트customer.subscription.updated: 변경 시 구독 세부 정보 업데이트customer.subscription.deleted: 구독을 취소됨으로 표시
사용자 지정 이벤트도 처리할 수 있습니다:
stripe({
// ... 기타 옵션
onEvent: async (event) => {
// 모든 Stripe 이벤트 처리
switch (event.type) {
case "invoice.paid":
// 지불된 송장 처리
break;
case "payment_intent.succeeded":
// 성공적인 결제 처리
break;
}
}
})구독 라이프사이클 훅
다양한 구독 라이프사이클 이벤트에 연결할 수 있습니다:
subscription: {
// ... 기타 옵션
onSubscriptionComplete: async ({ event, subscription, stripeSubscription, plan }) => {
// 구독이 성공적으로 생성될 때 호출됨
await sendWelcomeEmail(subscription.referenceId, plan.name);
},
onSubscriptionUpdate: async ({ event, subscription }) => {
// 구독이 업데이트될 때 호출됨
console.log(`구독 ${subscription.id}가 업데이트되었습니다`);
},
onSubscriptionCancel: async ({ event, subscription, stripeSubscription, cancellationDetails }) => {
// 구독이 취소될 때 호출됨
await sendCancellationEmail(subscription.referenceId);
},
onSubscriptionDeleted: async ({ event, subscription, stripeSubscription }) => {
// 구독이 삭제될 때 호출됨
console.log(`구독 ${subscription.id}가 삭제되었습니다`);
}
}평가판 기간
플랜에 대한 평가판 기간을 구성할 수 있습니다:
{
name: "pro",
priceId: "price_0987654321",
freeTrial: {
days: 14,
onTrialStart: async (subscription) => {
// 평가판이 시작될 때 호출됨
await sendTrialStartEmail(subscription.referenceId);
},
onTrialEnd: async ({ subscription, user }, request) => {
// 평가판이 종료될 때 호출됨
await sendTrialEndEmail(user.email);
},
onTrialExpired: async (subscription) => {
// 평가판이 전환 없이 만료될 때 호출됨
await sendTrialExpiredEmail(subscription.referenceId);
}
}
}스키마
Stripe 플러그인은 데이터베이스에 다음 테이블을 추가합니다:
User
테이블 이름: user
| Field Name | Type | Key | Description |
|---|---|---|---|
| stripeCustomerId | string | Stripe 고객 ID |
Subscription
테이블 이름: subscription
| Field Name | Type | Key | Description |
|---|---|---|---|
| id | string | 각 구독의 고유 식별자 | |
| plan | string | - | 구독 플랜의 이름 |
| referenceId | string | - | 이 구독이 연결된 ID (기본적으로 사용자 ID) |
| stripeCustomerId | string | Stripe 고객 ID | |
| stripeSubscriptionId | string | Stripe 구독 ID | |
| status | string | - | 구독의 상태 (active, canceled 등) |
| periodStart | Date | 현재 청구 기간의 시작 날짜 | |
| periodEnd | Date | 현재 청구 기간의 종료 날짜 | |
| cancelAtPeriodEnd | boolean | 구독이 기간 종료 시 취소될지 여부 | |
| seats | number | 팀 플랜의 좌석 수 | |
| trialStart | Date | 평가판 기간의 시작 날짜 | |
| trialEnd | Date | 평가판 기간의 종료 날짜 |
스키마 사용자 지정
스키마 테이블 이름이나 필드를 변경하려면 Stripe 플러그인에 schema 옵션을 전달할 수 있습니다:
stripe({
// ... 기타 옵션
schema: {
subscription: {
modelName: "stripeSubscriptions", // subscription 테이블을 stripeSubscriptions에 매핑
fields: {
plan: "planName" // plan 필드를 planName에 매핑
}
}
}
})옵션
주요 옵션
stripeClient: Stripe - Stripe 클라이언트 인스턴스. 필수.
stripeWebhookSecret: string - Stripe의 웹훅 서명 시크릿. 필수.
createCustomerOnSignUp: boolean - 사용자가 가입할 때 자동으로 Stripe 고객을 생성할지 여부. 기본값: false.
onCustomerCreate: (data: { customer: Customer, stripeCustomer: Stripe.Customer, user: User }, request?: Request) => Promise<void> - 고객이 생성된 후 호출되는 함수.
getCustomerCreateParams: (data: { user: User, session: Session }, request?: Request) => Promise<{}> - Stripe 고객 생성 매개변수를 사용자 지정하는 함수.
onEvent: (event: Stripe.Event) => Promise<void> - 모든 Stripe 웹훅 이벤트에 대해 호출되는 함수.
구독 옵션
enabled: boolean - 구독 기능을 활성화할지 여부. 필수.
plans: Plan[] | (() => Promise<Plan[]>) - 구독 플랜의 배열 또는 플랜을 반환하는 함수. 구독이 활성화된 경우 필수.
requireEmailVerification: boolean - 구독 업그레이드를 허용하기 전에 이메일 확인이 필요한지 여부. 기본값: false.
authorizeReference: (data: { user: User, session: Session, referenceId: string, action: "upgrade-subscription" | "list-subscription" | "cancel-subscription" | "restore-subscription"}, request?: Request) => Promise<boolean> - 참조 ID를 승인하는 함수.
플랜 구성
각 플랜은 다음 속성을 가질 수 있습니다:
name: string - 플랜의 이름. 필수.
priceId: string - Stripe 가격 ID. lookupKey를 사용하지 않는 한 필수.
lookupKey: string - Stripe 가격 조회 키. priceId의 대안.
annualDiscountPriceId: string - 연간 청구를 위한 가격 ID.
annualDiscountLookupKey: string - 연간 청구를 위한 Stripe 가격 조회 키. annualDiscountPriceId의 대안.
limits: Record<string, number> - 플랜과 연결된 제한 (예: { projects: 10, storage: 5 }).
group: string - 플랜의 그룹 이름, 플랜을 분류하는 데 유용합니다.
freeTrial: 평가판 구성을 포함하는 객체:
- days:
number- 평가판 일 수. - onTrialStart:
(subscription: Subscription) => Promise<void>- 평가판이 시작될 때 호출됨. - onTrialEnd:
(data: { subscription: Subscription, user: User }, request?: Request) => Promise<void>- 평가판이 종료될 때 호출됨. - onTrialExpired:
(subscription: Subscription) => Promise<void>- 평가판이 전환 없이 만료될 때 호출됨.
고급 사용법
조직과 함께 사용
Stripe 플러그인은 조직 플러그인과 잘 작동합니다. 개별 사용자 대신 조직과 구독을 연결할 수 있습니다:
// 활성 조직 가져오기
const { data: activeOrg } = client.useActiveOrganization();
// 조직에 대한 구독 생성
await client.subscription.upgrade({
plan: "team",
referenceId: activeOrg.id,
seats: 10,
annual: true, // 연간 플랜으로 업그레이드 (선택 사항)
successUrl: "/org/billing/success",
cancelUrl: "/org/billing"
});사용자가 조직의 구독을 관리할 권한이 있는지 확인하기 위해 authorizeReference 함수를 구현하세요:
authorizeReference: async ({ user, referenceId, action }) => {
const member = await db.members.findFirst({
where: {
userId: user.id,
organizationId: referenceId
}
});
return member?.role === "owner" || member?.role === "admin";
}사용자 지정 Checkout Session 매개변수
추가 매개변수로 Stripe Checkout 세션을 사용자 지정할 수 있습니다:
getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => {
return {
params: {
allow_promotion_codes: true,
tax_id_collection: {
enabled: true
},
billing_address_collection: "required",
custom_text: {
submit: {
message: "구독을 즉시 시작하겠습니다"
}
},
metadata: {
planType: "business",
referralCode: user.metadata?.referralCode
}
},
options: {
idempotencyKey: `sub_${user.id}_${plan.name}_${Date.now()}`
}
};
}세금 징수
고객으로부터 세금 ID를 수집하려면 tax_id_collection을 true로 설정하세요:
subscription: {
// ... 기타 옵션
getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => {
return {
params: {
tax_id_collection: {
enabled: true
}
}
};
}
}자동 세금 계산
고객의 위치를 사용하여 자동 세금 계산을 활성화하려면 automatic_tax를 true로 설정하세요. 이 매개변수를 활성화하면 Checkout이 세금 계산에 필요한 청구 주소 정보를 수집합니다. 이 기능이 작동하려면 먼저 Stripe 대시보드에서 세금 등록을 설정하고 구성해야 합니다.
subscription: {
// ... 기타 옵션
getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => {
return {
params: {
automatic_tax: {
enabled: true
}
}
};
}
}평가판 기간 관리
Stripe 플러그인은 사용자가 여러 무료 평가판을 받는 것을 자동으로 방지합니다. 사용자가 평가판 기간을 사용하면 (어떤 플랜이든) 모든 플랜에서 추가 평가판을 받을 수 없습니다.
작동 방식:
- 시스템은 각 사용자의 모든 플랜에서 평가판 사용을 추적합니다
- 사용자가 평가판이 있는 플랜을 구독하면 시스템이 구독 기록을 확인합니다
- 사용자가 평가판을 받은 적이 있는 경우 (
trialStart/trialEnd필드 또는trialing상태로 표시됨) 새 평가판이 제공되지 않습니다 - 이것은 사용자가 구독을 취소하고 다시 구독하여 여러 무료 평가판을 받는 남용을 방지합니다
예시 시나리오:
- 사용자가 7일 평가판으로 "Starter" 플랜을 구독
- 사용자가 평가판 후 구독을 취소
- 사용자가 "Premium" 플랜을 구독하려고 시도 - 평가판이 제공되지 않음
- 사용자는 Premium 플랜에 대해 즉시 요금이 청구됩니다
이 동작은 자동이며 추가 구성이 필요하지 않습니다. 평가판 자격은 구독 생성 시 결정되며 구성을 통해 재정의할 수 없습니다.
문제 해결
웹훅 문제
웹훅이 올바르게 처리되지 않는 경우:
- Stripe 대시보드에서 웹훅 URL이 올바르게 구성되었는지 확인하세요
- 웹훅 서명 시크릿이 올바른지 확인하세요
- Stripe 대시보드에서 필요한 모든 이벤트를 선택했는지 확인하세요
- 웹훅 처리 중 오류가 있는지 서버 로그를 확인하세요
구독 상태 문제
구독 상태가 올바르게 업데이트되지 않는 경우:
- 웹훅 이벤트가 수신되고 처리되고 있는지 확인하세요
stripeCustomerId와stripeSubscriptionId필드가 올바르게 채워졌는지 확인하세요- 참조 ID가 애플리케이션과 Stripe 간에 일치하는지 확인하세요
로컬에서 웹훅 테스트
로컬 개발의 경우 Stripe CLI를 사용하여 웹훅을 로컬 환경으로 전달할 수 있습니다:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook이렇게 하면 로컬 환경에서 사용할 수 있는 웹훅 서명 시크릿이 제공됩니다.