refactor(billing): remove Stripe; payments are Payple (web) and Google Play (mobile)
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 1m13s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / 모바일 린트·타입·Jest (push) Failing after 1m4s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 37s
ci / .NET API 서버 테스트 (push) Successful in 27s
deploy-site / deploy (push) Failing after 20s
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 1m13s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / 모바일 린트·타입·Jest (push) Failing after 1m4s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 37s
ci / .NET API 서버 테스트 (push) Successful in 27s
deploy-site / deploy (push) Failing after 20s
Stripe is not used. Keeping its checkout, portal and webhook paths meant a
second payment provider, a second return-URL format and dead UI.
- Delete the stripe-checkout, stripe-portal and stripe-webhook functions and
their config; billing-catalog serves Payple prices only, and the web parser
rejects a catalog that still mixes in Stripe prices.
- Web: drop the Stripe checkout/portal buttons, provider toggle and return
notices; billing shows Payple only. Past rows with provider='stripe' are
still displayed ("Stripe (종료)") with a support contact instead of a portal.
- Desktop: delete the Stripe checkout modal, payment IPC channels, preload
namespace and their types; "Remove ads with Pro" opens the web billing page
via license.openBilling. Support/refund copy names Payple.
- billingUrl() loses the Stripe-only success/canceled result option; the
Deno contract is regenerated.
- Migrations and the DB's accepted provider values are untouched (history).
- Docs and the backlog record the removal (MON-04, EXT-STRIPE-01, GAP-BILL-03).
Verified: typecheck (desktop/web/admin/api-client/mobile), contract:check,
deno check all functions, deno test 80/80, desktop 1478/1480 on the Electron
runtime (2 known environment failures), web and admin builds, release
metadata and mobile boundary self-tests, eslint on changed files.
This commit is contained in:
parent
7224e43bfb
commit
eedd127ea7
50 changed files with 97 additions and 2600 deletions
|
|
@ -102,19 +102,10 @@ verify_jwt = false
|
|||
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
||||
verify_jwt = false
|
||||
|
||||
[functions.stripe-checkout]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.billing-catalog]
|
||||
# requireUser supports modern publishable keys and validates the access token.
|
||||
verify_jwt = false
|
||||
|
||||
[functions.stripe-portal]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.stripe-webhook]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.payple-checkout]
|
||||
verify_jwt = false
|
||||
|
||||
|
|
|
|||
|
|
@ -1,65 +1,33 @@
|
|||
import {
|
||||
createBillingCatalog,
|
||||
parseStripeCatalogPrice,
|
||||
} from './billing-catalog.ts'
|
||||
import { createBillingCatalog } from './billing-catalog.ts'
|
||||
import { PLAN_PRICE_KRW } from './core-contract.generated.ts'
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
Deno.test('Stripe catalog accepts only the exact active recurring price', () => {
|
||||
const parsed = parseStripeCatalogPrice({
|
||||
id: 'price_pro',
|
||||
active: true,
|
||||
type: 'recurring',
|
||||
unit_amount: 990,
|
||||
currency: 'usd',
|
||||
recurring: { interval: 'month', interval_count: 1 },
|
||||
}, 'price_pro')
|
||||
assert(parsed?.unit_amount === 990, 'amount must be preserved')
|
||||
assert(parsed?.currency === 'USD', 'currency must be normalized')
|
||||
assert(parsed?.interval === 'month', 'interval must be preserved')
|
||||
})
|
||||
|
||||
Deno.test('Stripe catalog rejects mismatched, inactive, free, malformed, and one-time prices', () => {
|
||||
const base = {
|
||||
id: 'price_pro',
|
||||
active: true,
|
||||
type: 'recurring',
|
||||
unit_amount: 990,
|
||||
currency: 'usd',
|
||||
recurring: { interval: 'month', interval_count: 1 },
|
||||
}
|
||||
const invalid = [
|
||||
{ ...base, id: 'price_other' },
|
||||
{ ...base, active: false },
|
||||
{ ...base, type: 'one_time', recurring: null },
|
||||
{ ...base, unit_amount: 0 },
|
||||
{ ...base, currency: 'US$' },
|
||||
{ ...base, recurring: { interval: 'minute', interval_count: 1 } },
|
||||
{ ...base, recurring: { interval: 'month', interval_count: 0 } },
|
||||
]
|
||||
for (const value of invalid) {
|
||||
assert(parseStripeCatalogPrice(value, 'price_pro') === null, 'invalid Stripe price must fail closed')
|
||||
}
|
||||
})
|
||||
|
||||
Deno.test('catalog exposes only configured provider prices and never invents a fallback', () => {
|
||||
Deno.test('catalog exposes only configured Payple prices and never invents a fallback', () => {
|
||||
const catalog = createBillingCatalog({
|
||||
payple: { pro: PLAN_PRICE_KRW.pro, pro_plus: PLAN_PRICE_KRW.pro_plus },
|
||||
stripe: {
|
||||
pro: { unit_amount: 990, currency: 'USD', interval: 'month', interval_count: 1 },
|
||||
},
|
||||
})
|
||||
assert(catalog.plans[0].prices.length === 2, 'pro must expose two verified providers')
|
||||
assert(catalog.plans[1].prices.length === 1, 'pro plus must omit unavailable Stripe')
|
||||
assert(
|
||||
catalog.plans.every((plan) => plan.prices.length === 1 && plan.prices[0].provider === 'payple'),
|
||||
'each paid plan must expose exactly one Payple price',
|
||||
)
|
||||
assert(
|
||||
catalog.plans[0].prices[0].unit_amount === PLAN_PRICE_KRW.pro
|
||||
&& catalog.plans[1].prices[0].unit_amount === PLAN_PRICE_KRW.pro_plus,
|
||||
'Payple prices must come from the core plan catalog',
|
||||
)
|
||||
assert(
|
||||
catalog.plans.every((plan) => plan.prices[0].currency === 'KRW' && plan.prices[0].interval === 'month'),
|
||||
'Payple prices are monthly KRW',
|
||||
)
|
||||
|
||||
const unavailable = createBillingCatalog({})
|
||||
assert(unavailable.plans.every((plan) => plan.prices.length === 0), 'missing configuration must stay unavailable')
|
||||
})
|
||||
|
||||
Deno.test('catalog rejects non-positive or non-integer Payple amounts', () => {
|
||||
const catalog = createBillingCatalog({ payple: { pro: 0, pro_plus: 1.5 } })
|
||||
assert(catalog.plans.every((plan) => plan.prices.length === 0), 'invalid amounts must fail closed')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export type BillingCatalogTier = 'pro' | 'pro_plus'
|
||||
export type BillingCatalogProvider = 'payple' | 'stripe'
|
||||
/** 웹 결제는 Payple(KRW) 하나다. 모바일은 Google Play 스토어 가격을 쓰고 이 카탈로그에 오지 않는다. */
|
||||
export type BillingCatalogProvider = 'payple'
|
||||
|
||||
export interface BillingCatalogPrice {
|
||||
provider: BillingCatalogProvider
|
||||
|
|
@ -19,54 +20,8 @@ export interface BillingCatalogResponse {
|
|||
plans: BillingCatalogPlan[]
|
||||
}
|
||||
|
||||
interface StripePriceRecord {
|
||||
id?: unknown
|
||||
active?: unknown
|
||||
type?: unknown
|
||||
unit_amount?: unknown
|
||||
currency?: unknown
|
||||
recurring?: {
|
||||
interval?: unknown
|
||||
interval_count?: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
const CURRENCY_PATTERN = /^[a-z]{3}$/
|
||||
const INTERVALS = new Set(['day', 'week', 'month', 'year'])
|
||||
|
||||
export function parseStripeCatalogPrice(
|
||||
value: unknown,
|
||||
expectedId: string,
|
||||
): Omit<BillingCatalogPrice, 'provider'> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const price = value as StripePriceRecord
|
||||
if (
|
||||
price.id !== expectedId
|
||||
|| price.active !== true
|
||||
|| price.type !== 'recurring'
|
||||
|| !Number.isSafeInteger(price.unit_amount)
|
||||
|| (price.unit_amount as number) < 1
|
||||
|| typeof price.currency !== 'string'
|
||||
|| !CURRENCY_PATTERN.test(price.currency)
|
||||
|| !price.recurring
|
||||
|| typeof price.recurring.interval !== 'string'
|
||||
|| !INTERVALS.has(price.recurring.interval)
|
||||
|| !Number.isSafeInteger(price.recurring.interval_count)
|
||||
|| (price.recurring.interval_count as number) < 1
|
||||
|| (price.recurring.interval_count as number) > 12
|
||||
) return null
|
||||
|
||||
return {
|
||||
unit_amount: price.unit_amount as number,
|
||||
currency: price.currency.toUpperCase(),
|
||||
interval: price.recurring.interval as BillingCatalogPrice['interval'],
|
||||
interval_count: price.recurring.interval_count as number,
|
||||
}
|
||||
}
|
||||
|
||||
export function createBillingCatalog(input: {
|
||||
payple?: Record<BillingCatalogTier, number> | null
|
||||
stripe?: Partial<Record<BillingCatalogTier, Omit<BillingCatalogPrice, 'provider'>>> | null
|
||||
}): BillingCatalogResponse {
|
||||
const tiers: BillingCatalogTier[] = ['pro', 'pro_plus']
|
||||
return {
|
||||
|
|
@ -83,8 +38,6 @@ export function createBillingCatalog(input: {
|
|||
interval_count: 1,
|
||||
})
|
||||
}
|
||||
const stripePrice = input.stripe?.[tier]
|
||||
if (stripePrice) prices.push({ provider: 'stripe', ...stripePrice })
|
||||
return { tier, prices }
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type PlanTier = 'free' | PaidPlanTier
|
|||
|
||||
/**
|
||||
* 월 요금(원). Payple 청구 금액이며, 기존 구독자도 다음 갱신부터 이 값으로 청구된다.
|
||||
* Stripe(USD)·Google Play 가격은 각 콘솔에서 따로 관리한다.
|
||||
* Google Play 가격은 Play Console에서 따로 관리한다. 웹 결제는 Payple(KRW) 하나다.
|
||||
*/
|
||||
export const PLAN_PRICE_KRW: Readonly<Record<PlanTier, number>> = {
|
||||
free: 0,
|
||||
|
|
@ -122,19 +122,13 @@ export const SITE_URLS = {
|
|||
acceptInvite: `${PUBLIC_SITE_ORIGIN}/accept-invite/`,
|
||||
} as const
|
||||
|
||||
/** 결제 결과로 돌아올 때 붙는 쿼리. apps/web 결제 페이지가 읽는 이름과 같다. */
|
||||
export type BillingReturn = 'success' | 'canceled'
|
||||
|
||||
/**
|
||||
* 웹 결제 페이지 URL.
|
||||
* 웹 결제 페이지 URL. 결제는 이 페이지 안의 Payple 창에서 끝나므로 복귀 쿼리가 없다.
|
||||
* - `tier`: 고를 요금제를 미리 선택한다.
|
||||
* - `result`: 외부 결제(Stripe 등)에서 돌아올 때의 결과.
|
||||
*/
|
||||
export function billingUrl(options: { tier?: PaidPlanTier; result?: BillingReturn } = {}): string {
|
||||
export function billingUrl(options: { tier?: PaidPlanTier } = {}): string {
|
||||
const params = new URLSearchParams()
|
||||
if (options.tier) params.set('tier', options.tier)
|
||||
if (options.result === 'success') params.set('success', '1')
|
||||
if (options.result === 'canceled') params.set('canceled', '1')
|
||||
const query = params.toString()
|
||||
return `${WEB_APP_URL}/billing${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import {
|
||||
createBillingCatalog,
|
||||
parseStripeCatalogPrice,
|
||||
type BillingCatalogPrice,
|
||||
type BillingCatalogTier,
|
||||
} from '../_shared/billing-catalog.ts'
|
||||
import {
|
||||
|
|
@ -23,20 +21,6 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|||
return new Response(JSON.stringify(body), { status, headers: NO_STORE_HEADERS })
|
||||
}
|
||||
|
||||
async function loadStripePrice(
|
||||
secretKey: string,
|
||||
priceId: string,
|
||||
): Promise<Omit<BillingCatalogPrice, 'provider'> | null> {
|
||||
const response = await fetch(`https://api.stripe.com/v1/prices/${encodeURIComponent(priceId)}`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${secretKey}` },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const payload = await response.json().catch(() => null)
|
||||
return parseStripeCatalogPrice(payload, priceId)
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
|
@ -56,24 +40,7 @@ Deno.serve(async (req: Request) => {
|
|||
if (!(error instanceof PaypleConfigurationError)) throw error
|
||||
}
|
||||
|
||||
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
|
||||
const stripeIds: Record<BillingCatalogTier, string> = {
|
||||
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
|
||||
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
|
||||
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
|
||||
}
|
||||
const stripe: Partial<Record<BillingCatalogTier, Omit<BillingCatalogPrice, 'provider'>>> = {}
|
||||
if (stripeSecret) {
|
||||
const tiers: BillingCatalogTier[] = ['pro', 'pro_plus']
|
||||
await Promise.all(tiers.map(async (tier) => {
|
||||
const priceId = stripeIds[tier]
|
||||
if (!priceId) return
|
||||
const price = await loadStripePrice(stripeSecret, priceId).catch(() => null)
|
||||
if (price) stripe[tier] = price
|
||||
}))
|
||||
}
|
||||
|
||||
return jsonResponse(createBillingCatalog({ payple, stripe }))
|
||||
return jsonResponse(createBillingCatalog({ payple }))
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
||||
return authErrorResponse(error as AuthError, NO_STORE_HEADERS)
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
interface CheckoutRequest {
|
||||
tier?: unknown
|
||||
success_url?: unknown
|
||||
cancel_url?: unknown
|
||||
idempotency_key?: unknown
|
||||
}
|
||||
|
||||
interface OperationReservation {
|
||||
created?: boolean
|
||||
operation_id?: string
|
||||
state?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeTier(value: unknown): 'pro' | 'pro_plus' | null {
|
||||
if (value === 'pro') return 'pro'
|
||||
// team is accepted only as an input compatibility alias. It is never stored.
|
||||
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
|
||||
return null
|
||||
}
|
||||
|
||||
function parseReturnUrls(success: unknown, cancel: unknown): { success: string; cancel: string } | null {
|
||||
if (typeof success !== 'string' || typeof cancel !== 'string') return null
|
||||
try {
|
||||
const successUrl = new URL(success)
|
||||
const cancelUrl = new URL(cancel)
|
||||
const validProtocol = (url: URL) => url.protocol === 'https:'
|
||||
|| (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))
|
||||
if (
|
||||
!validProtocol(successUrl)
|
||||
|| !validProtocol(cancelUrl)
|
||||
|| successUrl.origin !== cancelUrl.origin
|
||||
|| successUrl.username
|
||||
|| successUrl.password
|
||||
|| cancelUrl.username
|
||||
|| cancelUrl.password
|
||||
) return null
|
||||
return { success: successUrl.toString(), cancel: cancelUrl.toString() }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stripeHeaders(secretKey: string, idempotencyKey?: string): HeadersInit {
|
||||
return {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
||||
|
||||
const serviceClient = createServiceRoleClient()
|
||||
let operationId: string | null = null
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const body = await req.json() as CheckoutRequest
|
||||
const tier = normalizeTier(body.tier)
|
||||
const urls = parseReturnUrls(body.success_url, body.cancel_url)
|
||||
if (
|
||||
!tier
|
||||
|| !urls
|
||||
|| (body.idempotency_key !== undefined
|
||||
&& (typeof body.idempotency_key !== 'string'
|
||||
|| !/^[A-Za-z0-9._:-]{12,160}$/.test(body.idempotency_key)))
|
||||
) {
|
||||
return jsonResponse({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
|
||||
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
|
||||
const priceMap = {
|
||||
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
|
||||
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
|
||||
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
|
||||
}
|
||||
const priceId = priceMap[tier]
|
||||
if (!stripeKey || !priceId) {
|
||||
return jsonResponse({ error: 'stripe_not_configured' }, 503)
|
||||
}
|
||||
|
||||
const idempotencyKey = typeof body.idempotency_key === 'string'
|
||||
? body.idempotency_key
|
||||
: `stripe-checkout:${crypto.randomUUID()}`
|
||||
const providerOrderId = `STRIPE-${crypto.randomUUID()}`
|
||||
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
|
||||
'reserve_payment_provider_operation',
|
||||
{
|
||||
p_user_id: user.id,
|
||||
p_provider: 'stripe',
|
||||
p_operation_type: 'checkout',
|
||||
p_requested_tier: tier,
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_provider_order_id: providerOrderId,
|
||||
p_provider_resource_id: null,
|
||||
},
|
||||
)
|
||||
if (reservationError) throw new Error('payment_reservation_failed')
|
||||
const reservation = reservationData as OperationReservation | null
|
||||
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
|
||||
return jsonResponse({
|
||||
error: reservation?.reason ?? 'payment_operation_in_progress',
|
||||
state: reservation?.state ?? 'rejected',
|
||||
}, 409)
|
||||
}
|
||||
operationId = reservation.operation_id
|
||||
|
||||
const { data: subscription, error: subscriptionError } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
if (subscriptionError) throw new Error('subscription_lookup_failed')
|
||||
let customerId = typeof subscription?.stripe_customer_id === 'string'
|
||||
? subscription.stripe_customer_id
|
||||
: null
|
||||
|
||||
if (!customerId) {
|
||||
const customerResponse = await fetch('https://api.stripe.com/v1/customers', {
|
||||
method: 'POST',
|
||||
headers: stripeHeaders(stripeKey, `customer-${operationId}`),
|
||||
body: new URLSearchParams({
|
||||
email: user.email ?? '',
|
||||
'metadata[user_id]': user.id,
|
||||
}),
|
||||
})
|
||||
if (!customerResponse.ok) throw new Error('stripe_customer_creation_failed')
|
||||
const customer = await customerResponse.json() as { id?: unknown }
|
||||
if (typeof customer.id !== 'string' || !customer.id.startsWith('cus_')) {
|
||||
throw new Error('stripe_customer_response_invalid')
|
||||
}
|
||||
customerId = customer.id
|
||||
}
|
||||
|
||||
const sessionResponse = await fetch('https://api.stripe.com/v1/checkout/sessions', {
|
||||
method: 'POST',
|
||||
headers: stripeHeaders(stripeKey, `checkout-${operationId}`),
|
||||
body: new URLSearchParams({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
'line_items[0][price]': priceId,
|
||||
'line_items[0][quantity]': '1',
|
||||
success_url: urls.success,
|
||||
cancel_url: urls.cancel,
|
||||
client_reference_id: user.id,
|
||||
'metadata[user_id]': user.id,
|
||||
'metadata[tier]': tier,
|
||||
'metadata[operation_id]': operationId,
|
||||
'subscription_data[metadata][user_id]': user.id,
|
||||
'subscription_data[metadata][tier]': tier,
|
||||
'subscription_data[metadata][operation_id]': operationId,
|
||||
}),
|
||||
})
|
||||
if (!sessionResponse.ok) throw new Error('stripe_checkout_creation_failed')
|
||||
const session = await sessionResponse.json() as { id?: unknown; url?: unknown }
|
||||
if (
|
||||
typeof session.id !== 'string'
|
||||
|| !session.id.startsWith('cs_')
|
||||
|| typeof session.url !== 'string'
|
||||
|| !session.url.startsWith('https://checkout.stripe.com/')
|
||||
) {
|
||||
throw new Error('stripe_checkout_response_invalid')
|
||||
}
|
||||
|
||||
const { error: operationError } = await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'external_created',
|
||||
p_external_reference: session.id,
|
||||
p_error_code: null,
|
||||
})
|
||||
if (operationError) throw new Error('payment_operation_update_failed')
|
||||
|
||||
return jsonResponse({ url: session.url })
|
||||
} catch (error) {
|
||||
if (operationId) {
|
||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'failed',
|
||||
p_external_reference: null,
|
||||
p_error_code: 'stripe_checkout_failed',
|
||||
})
|
||||
}
|
||||
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
||||
return authErrorResponse(error as AuthError, corsHeaders)
|
||||
}
|
||||
return jsonResponse({ error: 'stripe_checkout_failed' }, 502)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
// server/supabase/functions/stripe-portal/index.ts
|
||||
// Stripe Customer Portal 세션 생성 — 로그인한 사용자가 자신의 구독을 관리할 수 있도록.
|
||||
// 응답: { url } → 클라이언트가 redirect.
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
interface PortalRequest {
|
||||
return_url?: unknown
|
||||
}
|
||||
|
||||
function validReturnUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.username || url.password) return null
|
||||
if (url.protocol === 'https:') return url.toString()
|
||||
if (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return url.toString()
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const body = (await req.json()) as PortalRequest
|
||||
const returnUrl = validReturnUrl(body.return_url)
|
||||
if (!returnUrl) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_return_url' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
|
||||
if (!stripeKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'stripe_not_configured' }),
|
||||
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 기존 customer_id 조회
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const { data: sub } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
const customerId = (sub?.stripe_customer_id as string | null | undefined) ?? null
|
||||
if (!customerId?.startsWith('cus_')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'no_customer',
|
||||
message: '활성 구독이 없습니다. 먼저 업그레이드하세요.'
|
||||
}),
|
||||
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Portal session 생성
|
||||
const portalResp = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${stripeKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
customer: customerId,
|
||||
return_url: returnUrl
|
||||
})
|
||||
})
|
||||
|
||||
if (!portalResp.ok) {
|
||||
throw new Error('stripe_portal_creation_failed')
|
||||
}
|
||||
|
||||
const data = (await portalResp.json()) as { url?: unknown }
|
||||
if (typeof data.url !== 'string') throw new Error('stripe_portal_response_invalid')
|
||||
const portalUrl = new URL(data.url)
|
||||
if (portalUrl.protocol !== 'https:' || !portalUrl.hostname.endsWith('.stripe.com')) {
|
||||
throw new Error('stripe_portal_response_invalid')
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ url: data.url }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
return new Response(JSON.stringify({ error: 'stripe_portal_failed' }), {
|
||||
status: 502,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import {
|
||||
constantTimeEqual,
|
||||
normalizeStripeTier,
|
||||
tierFromStripeSubscriptionPrice,
|
||||
verifyStripeSignature,
|
||||
} from './index.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function stripeHeader(payload: string, secret: string, timestamp: number): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
key,
|
||||
new TextEncoder().encode(`${timestamp}.${payload}`),
|
||||
)
|
||||
const hex = Array.from(new Uint8Array(signature))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return `t=${timestamp},v1=${hex}`
|
||||
}
|
||||
|
||||
Deno.test('Stripe signature verifies exact payload and rejects tampering', async () => {
|
||||
const now = 1_800_000_000
|
||||
const payload = JSON.stringify({ id: 'evt_test', type: 'customer.subscription.updated' })
|
||||
const header = await stripeHeader(payload, 'whsec_test', now)
|
||||
assert(await verifyStripeSignature(payload, header, 'whsec_test', 300, now * 1000), 'valid signature')
|
||||
assert(
|
||||
!(await verifyStripeSignature(`${payload} `, header, 'whsec_test', 300, now * 1000)),
|
||||
'payload mutation must fail',
|
||||
)
|
||||
assert(
|
||||
!(await verifyStripeSignature(payload, header, 'different', 300, now * 1000)),
|
||||
'wrong secret must fail',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('Stripe signature rejects stale and ambiguous timestamp headers', async () => {
|
||||
const timestamp = 1_800_000_000
|
||||
const payload = '{}'
|
||||
const header = await stripeHeader(payload, 'whsec_test', timestamp)
|
||||
assert(
|
||||
!(await verifyStripeSignature(payload, header, 'whsec_test', 300, (timestamp + 301) * 1000)),
|
||||
'stale signature must fail',
|
||||
)
|
||||
assert(
|
||||
!(await verifyStripeSignature(payload, `${header},t=${timestamp}`, 'whsec_test', 300, timestamp * 1000)),
|
||||
'multiple timestamp fields must fail',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('Stripe tier is derived from the exact configured subscription price', () => {
|
||||
const prices = { pro: 'price_pro', pro_plus: 'price_pro_plus' }
|
||||
assert(tierFromStripeSubscriptionPrice({
|
||||
items: { data: [{ price: { id: 'price_pro' } }] },
|
||||
}, prices) === 'pro', 'pro price must map to pro')
|
||||
assert(tierFromStripeSubscriptionPrice({
|
||||
items: { data: [{ price: { id: 'price_pro_plus' } }] },
|
||||
}, prices) === 'pro_plus', 'pro plus price must map to pro_plus')
|
||||
assert(tierFromStripeSubscriptionPrice({
|
||||
items: { data: [{ price: { id: 'price_attacker' } }] },
|
||||
}, prices) === null, 'unknown price must fail closed')
|
||||
assert(tierFromStripeSubscriptionPrice({
|
||||
items: { data: [{ price: { id: 'price_pro' } }, { price: { id: 'price_pro_plus' } }] },
|
||||
}, prices) === null, 'ambiguous multi-price subscription must fail closed')
|
||||
assert(normalizeStripeTier('team') === 'pro_plus', 'legacy team alias maps only to pro_plus')
|
||||
assert(normalizeStripeTier('enterprise') === null, 'unknown metadata tier must fail')
|
||||
})
|
||||
|
||||
Deno.test('constant-time comparator rejects length and value mismatch', () => {
|
||||
assert(constantTimeEqual('abc', 'abc'), 'equal strings')
|
||||
assert(!constantTimeEqual('abc', 'abd'), 'different strings')
|
||||
assert(!constantTimeEqual('abc', 'ab'), 'different lengths')
|
||||
})
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
interface StripeEvent {
|
||||
id: string
|
||||
type: string
|
||||
created: number
|
||||
data: { object: Record<string, unknown> }
|
||||
}
|
||||
|
||||
interface StripeMetadata {
|
||||
user_id?: string
|
||||
tier?: string
|
||||
operation_id?: string
|
||||
}
|
||||
|
||||
interface ProviderApplyResult {
|
||||
applied?: boolean
|
||||
duplicate?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export function constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let mismatch = 0
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index)
|
||||
}
|
||||
return mismatch === 0
|
||||
}
|
||||
|
||||
export async function verifyStripeSignature(
|
||||
payload: string,
|
||||
signatureHeader: string,
|
||||
secret: string,
|
||||
toleranceSec = 300,
|
||||
nowMs = Date.now(),
|
||||
): Promise<boolean> {
|
||||
if (!signatureHeader || !secret || !payload || toleranceSec <= 0) return false
|
||||
const parts = signatureHeader.split(',').map((part) => part.trim())
|
||||
const timestampParts = parts.filter((part) => part.startsWith('t='))
|
||||
const signatures = parts
|
||||
.filter((part) => part.startsWith('v1='))
|
||||
.map((part) => part.slice(3).toLowerCase())
|
||||
.filter((part) => /^[0-9a-f]{64}$/.test(part))
|
||||
if (timestampParts.length !== 1 || signatures.length === 0) return false
|
||||
|
||||
const timestamp = Number(timestampParts[0].slice(2))
|
||||
if (!Number.isInteger(timestamp) || timestamp <= 0) return false
|
||||
const nowSeconds = Math.floor(nowMs / 1000)
|
||||
if (Math.abs(nowSeconds - timestamp) > toleranceSec) return false
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
key,
|
||||
new TextEncoder().encode(`${timestamp}.${payload}`),
|
||||
)
|
||||
const expected = Array.from(new Uint8Array(signature))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return signatures.some((candidate) => constantTimeEqual(candidate, expected))
|
||||
}
|
||||
|
||||
export async function sha256Payload(payload: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload))
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function normalizeStripeTier(value: unknown): 'pro' | 'pro_plus' | null {
|
||||
if (value === 'pro') return 'pro'
|
||||
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
|
||||
return null
|
||||
}
|
||||
|
||||
export function tierFromStripeSubscriptionPrice(
|
||||
subscription: Record<string, unknown>,
|
||||
prices: { pro: string; pro_plus: string },
|
||||
): 'pro' | 'pro_plus' | null {
|
||||
const items = subscription.items as { data?: unknown } | undefined
|
||||
if (!Array.isArray(items?.data) || items.data.length !== 1) return null
|
||||
const item = items.data[0] as { price?: { id?: unknown } } | undefined
|
||||
const priceId = asString(item?.price?.id)
|
||||
if (priceId && priceId === prices.pro) return 'pro'
|
||||
if (priceId && priceId === prices.pro_plus) return 'pro_plus'
|
||||
return null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function epochToIso(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
|
||||
function stripePeriodValue(subscription: Record<string, unknown>, field: 'current_period_start' | 'current_period_end'): unknown {
|
||||
if (subscription[field] !== undefined) return subscription[field]
|
||||
const items = subscription.items as { data?: unknown } | undefined
|
||||
if (!Array.isArray(items?.data) || items.data.length !== 1) return undefined
|
||||
return (items.data[0] as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
function stripeSubscriptionEntitled(status: string): boolean {
|
||||
return ['active', 'trialing', 'past_due'].includes(status)
|
||||
}
|
||||
|
||||
function isUuid(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
}
|
||||
|
||||
export async function stripeWebhookHandler(req: Request): Promise<Response> {
|
||||
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 })
|
||||
|
||||
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')?.trim() ?? ''
|
||||
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
|
||||
if (!webhookSecret || !stripeSecret) {
|
||||
return new Response('Stripe webhook not configured', { status: 503 })
|
||||
}
|
||||
|
||||
const payload = await req.text()
|
||||
const validSignature = await verifyStripeSignature(
|
||||
payload,
|
||||
req.headers.get('stripe-signature') ?? '',
|
||||
webhookSecret,
|
||||
)
|
||||
if (!validSignature) return new Response('Invalid signature', { status: 400 })
|
||||
|
||||
let event: StripeEvent
|
||||
try {
|
||||
event = JSON.parse(payload) as StripeEvent
|
||||
} catch {
|
||||
return new Response('Invalid JSON', { status: 400 })
|
||||
}
|
||||
if (
|
||||
!/^evt_[A-Za-z0-9]+$/.test(event.id ?? '')
|
||||
|| typeof event.type !== 'string'
|
||||
|| !Number.isInteger(event.created)
|
||||
|| event.created <= 0
|
||||
|| !event.data?.object
|
||||
) {
|
||||
return new Response('Invalid event', { status: 400 })
|
||||
}
|
||||
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const payloadDigest = await sha256Payload(payload)
|
||||
try {
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object
|
||||
const metadata = (session.metadata ?? {}) as StripeMetadata
|
||||
const userId = asString(metadata.user_id) ?? asString(session.client_reference_id)
|
||||
const subscriptionId = asString(session.subscription)
|
||||
if (!userId || !subscriptionId) {
|
||||
return new Response(JSON.stringify({ received: true, ignored: 'missing_correlation' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const { error: observationError } = await serviceClient.rpc(
|
||||
'record_payment_provider_observation',
|
||||
{
|
||||
p_user_id: userId,
|
||||
p_provider: 'stripe',
|
||||
p_event_id: event.id,
|
||||
p_event_created_at: new Date(event.created * 1000).toISOString(),
|
||||
p_event_type: event.type,
|
||||
p_payload_digest: payloadDigest,
|
||||
p_provider_resource_id: subscriptionId,
|
||||
},
|
||||
)
|
||||
if (observationError) throw new Error('stripe_observation_failed')
|
||||
|
||||
if (isUuid(metadata.operation_id)) {
|
||||
const { error: operationError } = await serviceClient.rpc(
|
||||
'mark_payment_provider_operation',
|
||||
{
|
||||
p_operation_id: metadata.operation_id,
|
||||
p_state: 'external_created',
|
||||
p_external_reference: asString(session.id) ?? subscriptionId,
|
||||
p_error_code: null,
|
||||
},
|
||||
)
|
||||
if (operationError) throw new Error('stripe_operation_update_failed')
|
||||
}
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (![
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
].includes(event.type)) {
|
||||
return new Response(JSON.stringify({ received: true, ignored: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let subscription = event.data.object
|
||||
const subscriptionId = asString(subscription.id)
|
||||
if (!subscriptionId) return new Response('Invalid subscription', { status: 400 })
|
||||
|
||||
// Stripe signs each delivery but does not guarantee delivery order. For
|
||||
// non-deletion events, retrieve the subscription's current authoritative
|
||||
// state so a late event cannot resurrect an older state from its payload.
|
||||
if (event.type !== 'customer.subscription.deleted') {
|
||||
const currentResponse = await fetch(
|
||||
`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
|
||||
{ headers: { Authorization: `Bearer ${stripeSecret}` } },
|
||||
)
|
||||
if (!currentResponse.ok) throw new Error('stripe_subscription_verification_failed')
|
||||
const current = await currentResponse.json() as Record<string, unknown>
|
||||
if (asString(current.id) !== subscriptionId) {
|
||||
throw new Error('stripe_subscription_verification_mismatch')
|
||||
}
|
||||
subscription = current
|
||||
}
|
||||
|
||||
const customerId = asString(subscription.customer)
|
||||
const metadata = (subscription.metadata ?? {}) as StripeMetadata
|
||||
if (!customerId) return new Response('Invalid subscription', { status: 400 })
|
||||
|
||||
const prices = {
|
||||
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
|
||||
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
|
||||
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
|
||||
}
|
||||
if (!prices.pro && !prices.pro_plus) {
|
||||
return new Response('Stripe prices not configured', { status: 503 })
|
||||
}
|
||||
const priceTier = tierFromStripeSubscriptionPrice(subscription, prices)
|
||||
const metadataTier = normalizeStripeTier(metadata.tier)
|
||||
if (!priceTier || (metadataTier && metadataTier !== priceTier)) {
|
||||
return new Response('Subscription price mismatch', { status: 422 })
|
||||
}
|
||||
|
||||
let userId = asString(metadata.user_id)
|
||||
const tier: 'pro' | 'pro_plus' = priceTier
|
||||
if (!userId) {
|
||||
const { data: existing, error: lookupError } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('user_id, tier')
|
||||
.eq('stripe_subscription_id', subscriptionId)
|
||||
.maybeSingle()
|
||||
if (lookupError) throw new Error('stripe_subscription_lookup_failed')
|
||||
userId ??= asString(existing?.user_id)
|
||||
const existingTier = normalizeStripeTier(existing?.tier)
|
||||
if (existingTier && existingTier !== priceTier) {
|
||||
return new Response('Stored subscription price mismatch', { status: 422 })
|
||||
}
|
||||
}
|
||||
if (!userId) return new Response('Missing subscription owner', { status: 422 })
|
||||
|
||||
const status = event.type === 'customer.subscription.deleted'
|
||||
? 'canceled'
|
||||
: asString(subscription.status)
|
||||
if (!status || ![
|
||||
'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete',
|
||||
'incomplete_expired', 'paused',
|
||||
].includes(status)) {
|
||||
return new Response('Invalid subscription status', { status: 400 })
|
||||
}
|
||||
const entitled = stripeSubscriptionEntitled(status)
|
||||
const cancelAtPeriodEnd = subscription.cancel_at_period_end === true
|
||||
const periodEnd = epochToIso(stripePeriodValue(subscription, 'current_period_end'))
|
||||
const cancelAt = epochToIso(subscription.cancel_at)
|
||||
?? (cancelAtPeriodEnd ? periodEnd : null)
|
||||
const { data: applyData, error: applyError } = await serviceClient.rpc(
|
||||
'apply_payment_provider_event',
|
||||
{
|
||||
p_user_id: userId,
|
||||
p_provider: 'stripe',
|
||||
p_event_id: event.id,
|
||||
p_event_created_at: new Date(event.created * 1000).toISOString(),
|
||||
p_event_type: event.type,
|
||||
p_payload_digest: payloadDigest,
|
||||
p_provider_resource_id: subscriptionId,
|
||||
p_tier: entitled ? tier : 'free',
|
||||
p_status: status,
|
||||
p_entitled: entitled,
|
||||
p_current_period_start: epochToIso(stripePeriodValue(subscription, 'current_period_start')),
|
||||
p_current_period_end: periodEnd,
|
||||
p_cancel_at: cancelAt,
|
||||
p_auto_renewing: entitled && !cancelAtPeriodEnd,
|
||||
p_provider_customer_id: customerId,
|
||||
p_provider_order_id: null,
|
||||
p_store_product_id: null,
|
||||
p_store_purchase_id: null,
|
||||
p_operation_id: isUuid(metadata.operation_id) ? metadata.operation_id : null,
|
||||
},
|
||||
)
|
||||
if (applyError) throw new Error('stripe_entitlement_apply_failed')
|
||||
const result = applyData as ProviderApplyResult | null
|
||||
return new Response(JSON.stringify({
|
||||
received: true,
|
||||
applied: result?.applied ?? false,
|
||||
duplicate: result?.duplicate ?? false,
|
||||
reason: result?.reason,
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'stripe_webhook_processing_failed' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) Deno.serve(stripeWebhookHandler)
|
||||
Loading…
Add table
Add a link
Reference in a new issue