feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,8 +1,3 @@
|
|||
// server/supabase/functions/payple-manage/index.ts
|
||||
// Payple 구독 관리 — 취소 (빌링키 해지 + tier 다운그레이드)
|
||||
// stripe-portal 대체.
|
||||
// verify_jwt = false (requireUser로 직접 인증)
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
|
@ -10,10 +5,26 @@ import {
|
|||
getPaypleConfig,
|
||||
paypleAuth,
|
||||
paypleDeleteBillingKey,
|
||||
PaypleConfigurationError,
|
||||
sha256Text,
|
||||
} from '../_shared/payple.ts'
|
||||
|
||||
interface ManageRequest {
|
||||
action: 'cancel' | 'info'
|
||||
action?: unknown
|
||||
idempotency_key?: unknown
|
||||
}
|
||||
|
||||
interface OperationReservation {
|
||||
created?: boolean
|
||||
operation_id?: string
|
||||
state?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
applied?: boolean
|
||||
duplicate?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
|
|
@ -23,77 +34,165 @@ function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|||
})
|
||||
}
|
||||
|
||||
// @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)
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
const serviceClient = createServiceRoleClient()
|
||||
let operationId: string | null = null
|
||||
let externalCancellationCompleted = false
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const body = (await req.json()) as ManageRequest
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const body = await req.json() as ManageRequest
|
||||
if (
|
||||
body.action !== 'cancel'
|
||||
&& body.action !== 'info'
|
||||
) return jsonResponse({ error: 'invalid_action' }, 400)
|
||||
if (
|
||||
body.idempotency_key !== undefined
|
||||
&& (typeof body.idempotency_key !== 'string'
|
||||
|| !/^[A-Za-z0-9._:-]{12,160}$/.test(body.idempotency_key))
|
||||
) return jsonResponse({ error: 'invalid_idempotency_key' }, 400)
|
||||
|
||||
// 현재 구독 정보 조회
|
||||
const { data: sub } = await serviceClient
|
||||
const { data: subscription, error: subscriptionError } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('tier, status, payment_provider, payple_payer_id, current_period_end')
|
||||
.select(
|
||||
'tier, status, provider, provider_resource_id, payple_payer_id, current_period_start, current_period_end, cancel_at, auto_renewing',
|
||||
)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (subscriptionError) throw new Error('subscription_lookup_failed')
|
||||
if (!subscription) return jsonResponse({ error: 'subscription_not_found' }, 404)
|
||||
|
||||
if (!sub) {
|
||||
return jsonResponse({ error: 'No subscription found' }, 404)
|
||||
}
|
||||
|
||||
// info: 현재 구독 상태 반환
|
||||
if (body.action === 'info') {
|
||||
return jsonResponse({
|
||||
tier: sub.tier,
|
||||
status: sub.status,
|
||||
payment_provider: sub.payment_provider,
|
||||
current_period_end: sub.current_period_end,
|
||||
has_billing_key: !!sub.payple_payer_id,
|
||||
tier: subscription.tier,
|
||||
status: subscription.status,
|
||||
provider: subscription.provider,
|
||||
payment_provider: subscription.provider,
|
||||
current_period_end: subscription.current_period_end,
|
||||
cancel_at: subscription.cancel_at,
|
||||
auto_renewing: subscription.auto_renewing,
|
||||
has_billing_key: typeof subscription.payple_payer_id === 'string',
|
||||
})
|
||||
}
|
||||
|
||||
// cancel: 구독 취소
|
||||
if (body.action === 'cancel') {
|
||||
if (sub.payment_provider !== 'payple' || !sub.payple_payer_id) {
|
||||
return jsonResponse({ error: 'No active Payple subscription to cancel' }, 400)
|
||||
}
|
||||
|
||||
// 1. Payple 빌링키 해지
|
||||
const config = getPaypleConfig()
|
||||
const auth = await paypleAuth(config, { payWork: 'PUSERDEL' })
|
||||
await paypleDeleteBillingKey(config, auth, sub.payple_payer_id)
|
||||
|
||||
// 2. DB 업데이트 — 현재 구독 기간이 끝날 때까지 유지
|
||||
await serviceClient
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'canceled',
|
||||
cancel_at: sub.current_period_end ?? new Date().toISOString(),
|
||||
payple_payer_id: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (
|
||||
subscription.provider !== 'payple'
|
||||
|| typeof subscription.provider_resource_id !== 'string'
|
||||
|| typeof subscription.payple_payer_id !== 'string'
|
||||
|| (subscription.tier !== 'pro' && subscription.tier !== 'pro_plus')
|
||||
) {
|
||||
return jsonResponse({ error: 'payple_subscription_not_cancellable' }, 409)
|
||||
}
|
||||
if (subscription.auto_renewing === false && subscription.cancel_at) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
message: 'Subscription will be canceled at the end of the current period',
|
||||
cancel_at: sub.current_period_end,
|
||||
duplicate: true,
|
||||
cancel_at: subscription.cancel_at,
|
||||
})
|
||||
}
|
||||
|
||||
return jsonResponse({ error: 'Invalid action. Must be cancel or info' }, 400)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
const config = getPaypleConfig()
|
||||
const idempotencyKey = typeof body.idempotency_key === 'string'
|
||||
? body.idempotency_key
|
||||
: `payple-cancel:${crypto.randomUUID()}`
|
||||
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
|
||||
'reserve_payment_provider_operation',
|
||||
{
|
||||
p_user_id: user.id,
|
||||
p_provider: 'payple',
|
||||
p_operation_type: 'cancellation',
|
||||
p_requested_tier: null,
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_provider_order_id: null,
|
||||
p_provider_resource_id: subscription.provider_resource_id,
|
||||
},
|
||||
)
|
||||
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)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return jsonResponse({ error: message }, 500)
|
||||
operationId = reservation.operation_id
|
||||
|
||||
const auth = await paypleAuth(config, { payWork: 'PUSERDEL' })
|
||||
await paypleDeleteBillingKey(config, auth, subscription.payple_payer_id)
|
||||
externalCancellationCompleted = true
|
||||
|
||||
const eventTime = new Date()
|
||||
const periodEnd = typeof subscription.current_period_end === 'string'
|
||||
? subscription.current_period_end
|
||||
: eventTime.toISOString()
|
||||
const { error: operationError } = await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'external_created',
|
||||
p_external_reference: subscription.provider_resource_id,
|
||||
p_error_code: null,
|
||||
})
|
||||
if (operationError) throw new Error('payment_operation_update_failed')
|
||||
|
||||
const { data: applyData, error: applyError } = await serviceClient.rpc(
|
||||
'apply_payment_provider_event',
|
||||
{
|
||||
p_user_id: user.id,
|
||||
p_provider: 'payple',
|
||||
p_event_id: `manage-cancel:${operationId}`,
|
||||
p_event_created_at: eventTime.toISOString(),
|
||||
p_event_type: 'billing_key.revoked',
|
||||
p_payload_digest: await sha256Text(
|
||||
`${user.id}:${subscription.provider_resource_id}:${operationId}`,
|
||||
),
|
||||
p_provider_resource_id: subscription.provider_resource_id,
|
||||
p_tier: subscription.tier,
|
||||
p_status: 'canceled',
|
||||
p_entitled: true,
|
||||
p_current_period_start: subscription.current_period_start,
|
||||
p_current_period_end: periodEnd,
|
||||
p_cancel_at: periodEnd,
|
||||
p_auto_renewing: false,
|
||||
// Empty is an explicit instruction to clear the deleted Payple key;
|
||||
// provider_resource_id remains as the ownership correlation key.
|
||||
p_provider_customer_id: '',
|
||||
p_provider_order_id: null,
|
||||
p_store_product_id: null,
|
||||
p_store_purchase_id: null,
|
||||
p_operation_id: operationId,
|
||||
},
|
||||
)
|
||||
if (applyError) throw new Error('entitlement_persistence_failed')
|
||||
const result = applyData as ApplyResult | null
|
||||
if (!result?.applied && !result?.duplicate) {
|
||||
return jsonResponse({
|
||||
error: 'cancellation_requires_reconciliation',
|
||||
reason: result?.reason ?? 'entitlement_not_applied',
|
||||
}, 409)
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, cancel_at: periodEnd })
|
||||
} catch (error) {
|
||||
if (operationId && !externalCancellationCompleted) {
|
||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'failed',
|
||||
p_external_reference: null,
|
||||
p_error_code: 'payple_cancellation_failed',
|
||||
})
|
||||
}
|
||||
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
||||
return authErrorResponse(error as AuthError, corsHeaders)
|
||||
}
|
||||
if (error instanceof PaypleConfigurationError) {
|
||||
return jsonResponse({ error: error.code }, 503)
|
||||
}
|
||||
return jsonResponse({
|
||||
error: externalCancellationCompleted
|
||||
? 'cancellation_requires_reconciliation'
|
||||
: 'payple_cancellation_failed',
|
||||
}, externalCancellationCompleted ? 409 : 502)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue