Stripe

Stripe 플러그인은 Stripe의 결제 및 구독 기능을 Better Auth와 통합합니다. 결제와 인증은 종종 밀접하게 결합되어 있기 때문에 이 플러그인은 Stripe를 애플리케이션에 통합하는 것을 단순화하여 고객 생성, 구독 관리 및 웹훅 처리를 처리합니다.

기능

  • 사용자가 가입할 때 자동으로 Stripe 고객 생성
  • 구독 플랜 및 가격 관리
  • 구독 라이프사이클 이벤트 처리 (생성, 업데이트, 취소)
  • 서명 검증으로 Stripe 웹훅을 안전하게 처리
  • 애플리케이션에 구독 데이터 노출
  • 평가판 기간 및 구독 업그레이드 지원
  • 자동 평가판 남용 방지 - 사용자는 모든 플랜에서 계정당 한 번의 평가판만 받을 수 있습니다
  • 사용자 또는 조직과 구독을 연결하는 유연한 참조 시스템
  • 좌석 관리를 통한 팀 구독 지원

설치

플러그인 설치

먼저 플러그인을 설치하세요:

npm install @better-auth/stripe

별도의 클라이언트와 서버 설정을 사용하는 경우 프로젝트의 두 부분 모두에 플러그인을 설치해야 합니다.

Stripe SDK 설치

다음으로 서버에 Stripe SDK를 설치하세요:

npm install stripe@^18.0.0

auth 구성에 플러그인 추가

auth.ts
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,
        })
    ]
})

클라이언트 플러그인 추가

auth-client.ts
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 migrate
npx @better-auth/cli generate

수동으로 테이블을 추가하려면 스키마 섹션을 참조하세요.

Stripe 웹훅 설정

Stripe 대시보드에서 다음을 가리키는 웹훅 엔드포인트를 생성하세요:

https://your-domain.com/api/auth/stripe/webhook

/api/auth는 auth 서버의 기본 경로입니다.

최소한 다음 이벤트를 선택해야 합니다:

  • checkout.session.completed
  • customer.subscription.updated
  • customer.subscription.deleted

Stripe에서 제공하는 웹훅 서명 시크릿을 저장하고 STRIPE_WEBHOOK_SECRET으로 환경 변수에 추가하세요.

사용법

고객 관리

구독을 활성화하지 않고도 고객 관리만을 위해 이 플러그인을 사용할 수 있습니다. Stripe 고객을 사용자에게 연결하고 싶을 때 유용합니다.

기본적으로 사용자가 가입하면 createCustomerOnSignUp: true로 설정한 경우 Stripe 고객이 자동으로 생성됩니다. 이 고객은 데이터베이스의 사용자에게 연결됩니다. 고객 생성 프로세스를 사용자 지정할 수 있습니다:

auth.ts
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
            }
        };
    }
})

구독 관리

플랜 정의

구독 플랜을 정적 또는 동적으로 정의할 수 있습니다:

auth.ts
// 정적 플랜
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 메서드를 사용하세요:

POST
/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});
PropDescriptionType
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

간단한 예제:

client.ts
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 메서드를 사용하세요:

client.ts
await client.subscription.upgrade({
    plan: "pro",
    successUrl: "/dashboard",
    cancelUrl: "/pricing",
    subscriptionId: "sub_123", // 사용자의 현재 플랜의 Stripe 구독 ID
});

이렇게 하면 사용자가 새 플랜에만 요금을 지불하고 두 플랜 모두에 대해 지불하지 않습니다.

활성 구독 목록

사용자의 활성 구독을 가져오려면:

GET
/subscription/list
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;
PropDescriptionType
referenceId?
나열할 구독의 참조 ID.
string

구독 취소

구독을 취소하려면:

POST
/subscription/cancel
const { data, error } = await authClient.subscription.cancel({    referenceId: 'org_123',    subscriptionId: 'sub_123',    returnUrl: '/account', // required});
PropDescriptionType
referenceId?
취소할 구독의 참조 ID. 기본값은 userId입니다.
string
subscriptionId?
취소할 구독의 ID.
string
returnUrl
고객이 청구 포털의 웹사이트로 돌아가기 링크를 클릭할 때 이동할 URL.
string

이렇게 하면 사용자가 Stripe Billing Portal로 리디렉션되어 구독을 취소할 수 있습니다.

취소된 구독 복원

사용자가 구독을 취소한 후 (구독 기간이 끝나기 전에) 마음이 바뀌면 구독을 복원할 수 있습니다:

POST
/subscription/restore
const { data, error } = await authClient.subscription.restore({    referenceId: '123',    subscriptionId: 'sub_123',});
PropDescriptionType
referenceId?
복원할 구독의 참조 ID. 기본값은 userId입니다.
string
subscriptionId?
복원할 구독의 ID.
string

이렇게 하면 이전에 청구 기간 종료 시 취소되도록 설정된 구독 (cancelAtPeriodEnd: true)이 다시 활성화됩니다. 구독은 자동으로 계속 갱신됩니다.

참고: 이것은 여전히 활성 상태이지만 기간 종료 시 취소되도록 표시된 구독에만 작동합니다. 이미 종료된 구독은 복원할 수 없습니다.

청구 포털 세션 생성

고객이 구독을 관리하고, 결제 방법을 업데이트하고, 청구 내역을 볼 수 있는 Stripe 청구 포털 세션을 생성하려면:

POST
/subscription/billing-portal
const { data, error } = await authClient.subscription.billingPortal({    locale,    referenceId: "123",    returnUrl,});
PropDescriptionType
locale?
고객 포털이 표시되는 로케일의 IETF 언어 태그. 비어 있거나 auto인 경우 브라우저의 로케일이 사용됩니다.
string
referenceId?
업그레이드할 구독의 참조 ID.
string
returnUrl?
구독 성공 후 리디렉션할 반환 URL.
string

지원되는 로케일에 대해서는 IETF 언어 태그 문서를 참조하세요.

이 엔드포인트는 Stripe 청구 포털 세션을 생성하고 응답에서 URL을 data.url로 반환합니다. 사용자를 이 URL로 리디렉션하여 구독, 결제 방법 및 청구 내역을 관리할 수 있습니다.

참조 시스템

기본적으로 구독은 사용자 ID와 연결됩니다. 그러나 사용자 지정 참조 ID를 사용하여 조직과 같은 다른 엔터티와 구독을 연결할 수 있습니다:

client.ts
// 조직에 대한 구독 생성
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 함수를 구현하세요:

auth.ts
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: 구독을 취소됨으로 표시

사용자 지정 이벤트도 처리할 수 있습니다:

auth.ts
stripe({
    // ... 기타 옵션
    onEvent: async (event) => {
        // 모든 Stripe 이벤트 처리
        switch (event.type) {
            case "invoice.paid":
                // 지불된 송장 처리
                break;
            case "payment_intent.succeeded":
                // 성공적인 결제 처리
                break;
        }
    }
})

구독 라이프사이클 훅

다양한 구독 라이프사이클 이벤트에 연결할 수 있습니다:

auth.ts
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}가 삭제되었습니다`);
    }
}

평가판 기간

플랜에 대한 평가판 기간을 구성할 수 있습니다:

auth.ts
{
    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 NameTypeKeyDescription
stripeCustomerIdstringStripe 고객 ID

Subscription

테이블 이름: subscription

Field NameTypeKeyDescription
idstring각 구독의 고유 식별자
planstring-구독 플랜의 이름
referenceIdstring-이 구독이 연결된 ID (기본적으로 사용자 ID)
stripeCustomerIdstringStripe 고객 ID
stripeSubscriptionIdstringStripe 구독 ID
statusstring-구독의 상태 (active, canceled 등)
periodStartDate현재 청구 기간의 시작 날짜
periodEndDate현재 청구 기간의 종료 날짜
cancelAtPeriodEndboolean구독이 기간 종료 시 취소될지 여부
seatsnumber팀 플랜의 좌석 수
trialStartDate평가판 기간의 시작 날짜
trialEndDate평가판 기간의 종료 날짜

스키마 사용자 지정

스키마 테이블 이름이나 필드를 변경하려면 Stripe 플러그인에 schema 옵션을 전달할 수 있습니다:

auth.ts
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 플러그인은 조직 플러그인과 잘 작동합니다. 개별 사용자 대신 조직과 구독을 연결할 수 있습니다:

client.ts
// 활성 조직 가져오기
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 함수를 구현하세요:

auth.ts
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 세션을 사용자 지정할 수 있습니다:

auth.ts
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로 설정하세요:

auth.ts
subscription: {
    // ... 기타 옵션
    getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => {
        return {
            params: {
                tax_id_collection: {
                    enabled: true
                }
            }
        };
    }
}

자동 세금 계산

고객의 위치를 사용하여 자동 세금 계산을 활성화하려면 automatic_tax를 true로 설정하세요. 이 매개변수를 활성화하면 Checkout이 세금 계산에 필요한 청구 주소 정보를 수집합니다. 이 기능이 작동하려면 먼저 Stripe 대시보드에서 세금 등록을 설정하고 구성해야 합니다.

auth.ts
subscription: {
    // ... 기타 옵션
    getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => {
        return {
            params: {
                automatic_tax: {
                    enabled: true
                }
            }
        };
    }
}

평가판 기간 관리

Stripe 플러그인은 사용자가 여러 무료 평가판을 받는 것을 자동으로 방지합니다. 사용자가 평가판 기간을 사용하면 (어떤 플랜이든) 모든 플랜에서 추가 평가판을 받을 수 없습니다.

작동 방식:

  • 시스템은 각 사용자의 모든 플랜에서 평가판 사용을 추적합니다
  • 사용자가 평가판이 있는 플랜을 구독하면 시스템이 구독 기록을 확인합니다
  • 사용자가 평가판을 받은 적이 있는 경우 (trialStart/trialEnd 필드 또는 trialing 상태로 표시됨) 새 평가판이 제공되지 않습니다
  • 이것은 사용자가 구독을 취소하고 다시 구독하여 여러 무료 평가판을 받는 남용을 방지합니다

예시 시나리오:

  1. 사용자가 7일 평가판으로 "Starter" 플랜을 구독
  2. 사용자가 평가판 후 구독을 취소
  3. 사용자가 "Premium" 플랜을 구독하려고 시도 - 평가판이 제공되지 않음
  4. 사용자는 Premium 플랜에 대해 즉시 요금이 청구됩니다

이 동작은 자동이며 추가 구성이 필요하지 않습니다. 평가판 자격은 구독 생성 시 결정되며 구성을 통해 재정의할 수 없습니다.

문제 해결

웹훅 문제

웹훅이 올바르게 처리되지 않는 경우:

  1. Stripe 대시보드에서 웹훅 URL이 올바르게 구성되었는지 확인하세요
  2. 웹훅 서명 시크릿이 올바른지 확인하세요
  3. Stripe 대시보드에서 필요한 모든 이벤트를 선택했는지 확인하세요
  4. 웹훅 처리 중 오류가 있는지 서버 로그를 확인하세요

구독 상태 문제

구독 상태가 올바르게 업데이트되지 않는 경우:

  1. 웹훅 이벤트가 수신되고 처리되고 있는지 확인하세요
  2. stripeCustomerIdstripeSubscriptionId 필드가 올바르게 채워졌는지 확인하세요
  3. 참조 ID가 애플리케이션과 Stripe 간에 일치하는지 확인하세요

로컬에서 웹훅 테스트

로컬 개발의 경우 Stripe CLI를 사용하여 웹훅을 로컬 환경으로 전달할 수 있습니다:

stripe listen --forward-to localhost:3000/api/auth/stripe/webhook

이렇게 하면 로컬 환경에서 사용할 수 있는 웹훅 서명 시크릿이 제공됩니다.