- payple-renew: 만료 구독 조회 → paypleBilling → 기간 갱신 - 3회 연속 실패 시 tier=free, status=expired 다운그레이드 - migration: subscriptions.renewal_failures 컬럼 추가 - payple-webhook: 결제 완료 시 renewal_failures=0 리셋 - GitHub Actions: 매일 01:00 UTC 스케줄
113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
// server/supabase/functions/payple-webhook/index.ts
|
|
// Payple 웹훅 수신 — 결제완료, 취소, 빌링키 등록/해지 이벤트 처리.
|
|
// Payple 관리자에서 웹훅 URL을 등록해야 함.
|
|
// verify_jwt = false (외부 Payple 서버에서 호출)
|
|
|
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
|
|
interface PaypleWebhookPayload {
|
|
PCD_PAY_RST: 'success' | 'error'
|
|
PCD_PAY_CODE: string
|
|
PCD_PAY_MSG: string
|
|
PCD_PAY_TYPE: string
|
|
PCD_PAY_OID: string
|
|
PCD_PAY_TOTAL?: string
|
|
PCD_PAYER_ID?: string
|
|
PCD_PAYER_NO?: string // 우리가 전달한 user_id
|
|
PCD_PAY_CARDNAME?: string
|
|
PCD_PAY_CARDNUM?: string
|
|
PCD_PAY_TIME?: string // 결제 시간 (YYYYMMDDHHMMSS)
|
|
// 웹훅 이벤트 구분용
|
|
PCD_PAY_WORK?: string // 'AUTH' (등록), 'CERT' (등록+결제)
|
|
PCD_PAYCANCEL_FLAG?: string // 'Y' (취소 이벤트)
|
|
}
|
|
|
|
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
// @ts-expect-error — Deno 런타임 전역
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
|
|
if (req.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
}
|
|
|
|
try {
|
|
const payload = (await req.json()) as PaypleWebhookPayload
|
|
const serviceClient = createServiceRoleClient()
|
|
|
|
// 취소 이벤트
|
|
if (payload.PCD_PAYCANCEL_FLAG === 'Y') {
|
|
if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) {
|
|
// 주문번호로 구독 찾아서 상태 변경
|
|
const { data: sub } = await serviceClient
|
|
.from('subscriptions')
|
|
.select('user_id')
|
|
.eq('payple_pay_oid', payload.PCD_PAY_OID)
|
|
.maybeSingle()
|
|
|
|
if (sub) {
|
|
await serviceClient
|
|
.from('subscriptions')
|
|
.update({
|
|
status: 'canceled',
|
|
tier: 'free',
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('user_id', sub.user_id)
|
|
|
|
await serviceClient
|
|
.from('profiles')
|
|
.update({ tier: 'free', updated_at: new Date().toISOString() })
|
|
.eq('id', sub.user_id)
|
|
}
|
|
}
|
|
|
|
return jsonResponse({ received: true, event: 'cancel' })
|
|
}
|
|
|
|
// 결제 완료 이벤트
|
|
if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAYER_ID) {
|
|
// payer_id(빌링키)로 구독 찾기
|
|
const { data: sub } = await serviceClient
|
|
.from('subscriptions')
|
|
.select('user_id, tier')
|
|
.eq('payple_payer_id', payload.PCD_PAYER_ID)
|
|
.maybeSingle()
|
|
|
|
if (sub && payload.PCD_PAY_OID) {
|
|
// 주문번호 + 구독 기간 갱신 (정기결제 갱신 시)
|
|
const now = new Date()
|
|
const end = new Date(now)
|
|
end.setMonth(end.getMonth() + 1)
|
|
|
|
await serviceClient
|
|
.from('subscriptions')
|
|
.update({
|
|
payple_pay_oid: payload.PCD_PAY_OID,
|
|
status: 'active',
|
|
current_period_start: now.toISOString(),
|
|
current_period_end: end.toISOString(),
|
|
renewal_failures: 0,
|
|
updated_at: now.toISOString(),
|
|
})
|
|
.eq('user_id', sub.user_id)
|
|
}
|
|
|
|
return jsonResponse({ received: true, event: 'payment_complete' })
|
|
}
|
|
|
|
// 그 외 이벤트는 로깅만
|
|
return jsonResponse({ received: true, event: 'unknown' })
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
return jsonResponse({ error: message }, 500)
|
|
}
|
|
})
|