fix(payple-renew): reconcile renewals whose charge outcome was left unknown
This commit is contained in:
parent
06e96c58f4
commit
f4724ddf53
4 changed files with 980 additions and 234 deletions
|
|
@ -1,36 +1,20 @@
|
|||
import { corsHeaders } from '../_shared/cors.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import {
|
||||
calcSubscriptionPeriod,
|
||||
generateOrderId,
|
||||
getPaypleConfig,
|
||||
parsePaypleTimestamp,
|
||||
paypleAuth,
|
||||
paypleBilling,
|
||||
PaypleBillingError,
|
||||
PaypleConfigurationError,
|
||||
payplePaymentEventDigest,
|
||||
payplePaymentEventId,
|
||||
sha256Text,
|
||||
TIER_GOODS_NAME,
|
||||
TIER_PRICE,
|
||||
} from '../_shared/payple.ts'
|
||||
import { createPaypleRenewalGateway, createSupabaseRenewalStore } from './adapters.ts'
|
||||
import {
|
||||
normalizeRenewalCandidate,
|
||||
type RenewalDeps,
|
||||
type RenewalResult,
|
||||
renewSubscription,
|
||||
} from './renewal.ts'
|
||||
|
||||
const MAX_RENEWAL_FAILURES = 3
|
||||
|
||||
interface RenewalResult {
|
||||
userId: string
|
||||
tier: string
|
||||
success: boolean
|
||||
orderId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface OperationReservation {
|
||||
created?: boolean
|
||||
operation_id?: string
|
||||
reason?: string
|
||||
}
|
||||
type ServiceClient = ReturnType<typeof createServiceRoleClient>
|
||||
|
||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
|
|
@ -39,237 +23,117 @@ function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|||
})
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
||||
|
||||
function isAuthorizedCron(req: Request): boolean {
|
||||
const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
|
||||
const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? ''
|
||||
if (!cronSecret || token.length !== cronSecret.length) {
|
||||
return jsonResponse({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
if (!cronSecret || token.length !== cronSecret.length) return false
|
||||
let mismatch = 0
|
||||
for (let index = 0; index < token.length; index += 1) {
|
||||
mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index)
|
||||
}
|
||||
if (mismatch !== 0) return jsonResponse({ error: 'unauthorized' }, 401)
|
||||
return mismatch === 0
|
||||
}
|
||||
|
||||
/** End-of-term cancellations are revoked through the same serialized RPC. */
|
||||
async function expireEndedSubscriptions(serviceClient: ServiceClient, now: Date): Promise<number> {
|
||||
const { data: endingSubscriptions, error: endingError } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('user_id, tier, provider_resource_id, current_period_start, current_period_end')
|
||||
.eq('provider', 'payple')
|
||||
.eq('auto_renewing', false)
|
||||
.not('provider_resource_id', 'is', null)
|
||||
.lte('current_period_end', now.toISOString())
|
||||
if (endingError) throw new Error('ending_subscription_query_failed')
|
||||
for (const subscription of endingSubscriptions ?? []) {
|
||||
if (
|
||||
typeof subscription.user_id !== 'string'
|
||||
|| typeof subscription.provider_resource_id !== 'string'
|
||||
) continue
|
||||
const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}`
|
||||
const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', {
|
||||
p_user_id: subscription.user_id,
|
||||
p_provider: 'payple',
|
||||
p_event_id: eventId.slice(0, 255),
|
||||
p_event_created_at: now.toISOString(),
|
||||
p_event_type: 'subscription.scheduled_expiry',
|
||||
p_payload_digest: await sha256Text(eventId),
|
||||
p_provider_resource_id: subscription.provider_resource_id,
|
||||
p_tier: 'free',
|
||||
p_status: 'expired',
|
||||
p_entitled: false,
|
||||
p_current_period_start: subscription.current_period_start,
|
||||
p_current_period_end: subscription.current_period_end,
|
||||
p_cancel_at: subscription.current_period_end,
|
||||
p_auto_renewing: false,
|
||||
p_provider_customer_id: null,
|
||||
p_provider_order_id: null,
|
||||
p_store_product_id: null,
|
||||
p_store_purchase_id: null,
|
||||
p_operation_id: null,
|
||||
})
|
||||
if (expireError) throw new Error('scheduled_expiry_failed')
|
||||
}
|
||||
return endingSubscriptions?.length ?? 0
|
||||
}
|
||||
|
||||
async function selectDueRenewals(
|
||||
serviceClient: ServiceClient,
|
||||
now: Date,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const { data, error } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select(
|
||||
'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures',
|
||||
)
|
||||
.eq('provider', 'payple')
|
||||
.eq('auto_renewing', true)
|
||||
.in('status', ['active', 'past_due'])
|
||||
.not('payple_payer_id', 'is', null)
|
||||
.lte('current_period_end', now.toISOString())
|
||||
if (error) throw new Error('renewal_query_failed')
|
||||
return (data ?? []) as Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export async function paypleRenewHandler(req: Request): Promise<Response> {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
||||
if (!isAuthorizedCron(req)) return jsonResponse({ error: 'unauthorized' }, 401)
|
||||
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const now = new Date()
|
||||
const results: RenewalResult[] = []
|
||||
try {
|
||||
// End-of-term cancellations are revoked through the same serialized RPC.
|
||||
const { data: endingSubscriptions, error: endingError } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('user_id, tier, provider_resource_id, current_period_start, current_period_end')
|
||||
.eq('provider', 'payple')
|
||||
.eq('auto_renewing', false)
|
||||
.not('provider_resource_id', 'is', null)
|
||||
.lte('current_period_end', now.toISOString())
|
||||
if (endingError) throw new Error('ending_subscription_query_failed')
|
||||
for (const subscription of endingSubscriptions ?? []) {
|
||||
if (
|
||||
typeof subscription.user_id !== 'string'
|
||||
|| typeof subscription.provider_resource_id !== 'string'
|
||||
) continue
|
||||
const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}`
|
||||
const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', {
|
||||
p_user_id: subscription.user_id,
|
||||
p_provider: 'payple',
|
||||
p_event_id: eventId.slice(0, 255),
|
||||
p_event_created_at: now.toISOString(),
|
||||
p_event_type: 'subscription.scheduled_expiry',
|
||||
p_payload_digest: await sha256Text(eventId),
|
||||
p_provider_resource_id: subscription.provider_resource_id,
|
||||
p_tier: 'free',
|
||||
p_status: 'expired',
|
||||
p_entitled: false,
|
||||
p_current_period_start: subscription.current_period_start,
|
||||
p_current_period_end: subscription.current_period_end,
|
||||
p_cancel_at: subscription.current_period_end,
|
||||
p_auto_renewing: false,
|
||||
p_provider_customer_id: null,
|
||||
p_provider_order_id: null,
|
||||
p_store_product_id: null,
|
||||
p_store_purchase_id: null,
|
||||
p_operation_id: null,
|
||||
})
|
||||
if (expireError) throw new Error('scheduled_expiry_failed')
|
||||
const expired = await expireEndedSubscriptions(serviceClient, now)
|
||||
const dueRenewals = await selectDueRenewals(serviceClient, now)
|
||||
if (!dueRenewals.length) {
|
||||
return jsonResponse({ renewed: 0, failed: 0, expired, results })
|
||||
}
|
||||
|
||||
const { data: expiredSubscriptions, error: queryError } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select(
|
||||
'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures',
|
||||
)
|
||||
.eq('provider', 'payple')
|
||||
.eq('auto_renewing', true)
|
||||
.in('status', ['active', 'past_due'])
|
||||
.not('payple_payer_id', 'is', null)
|
||||
.lte('current_period_end', now.toISOString())
|
||||
if (queryError) throw new Error('renewal_query_failed')
|
||||
if (!expiredSubscriptions?.length) {
|
||||
return jsonResponse({ renewed: 0, failed: 0, expired: endingSubscriptions?.length ?? 0, results })
|
||||
const deps: RenewalDeps = {
|
||||
store: createSupabaseRenewalStore(serviceClient),
|
||||
gateway: await createPaypleRenewalGateway(getPaypleConfig()),
|
||||
now: () => new Date(),
|
||||
newOrderId: (userId) => generateOrderId(userId),
|
||||
}
|
||||
|
||||
const config = getPaypleConfig()
|
||||
const auth = await paypleAuth(config, { simpleFlag: true })
|
||||
|
||||
for (const subscription of expiredSubscriptions) {
|
||||
const userId = typeof subscription.user_id === 'string' ? subscription.user_id : ''
|
||||
const tier = subscription.tier === 'pro' || subscription.tier === 'pro_plus'
|
||||
? subscription.tier
|
||||
: null
|
||||
const payerId = typeof subscription.payple_payer_id === 'string'
|
||||
? subscription.payple_payer_id
|
||||
: null
|
||||
const resourceId = typeof subscription.provider_resource_id === 'string'
|
||||
? subscription.provider_resource_id
|
||||
: null
|
||||
if (!userId || !tier || !payerId || !resourceId) {
|
||||
results.push({ userId, tier: String(subscription.tier), success: false, error: 'invalid_subscription' })
|
||||
for (const row of dueRenewals) {
|
||||
const candidate = normalizeRenewalCandidate(row)
|
||||
if (!candidate) {
|
||||
results.push({
|
||||
userId: typeof row.user_id === 'string' ? row.user_id : '',
|
||||
tier: String(row.tier),
|
||||
success: false,
|
||||
error: 'invalid_subscription',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const attempt = Number(subscription.renewal_failures ?? 0) + 1
|
||||
const periodKey = new Date(subscription.current_period_end as string).getTime()
|
||||
const idempotencyKey = `payple-renew:${userId}:${periodKey}:attempt${attempt}`
|
||||
const orderId = generateOrderId(userId)
|
||||
let operationId: string | null = null
|
||||
let charged = false
|
||||
try {
|
||||
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
|
||||
'reserve_payment_provider_operation',
|
||||
{
|
||||
p_user_id: userId,
|
||||
p_provider: 'payple',
|
||||
p_operation_type: 'renewal',
|
||||
p_requested_tier: tier,
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_provider_order_id: orderId,
|
||||
p_provider_resource_id: resourceId,
|
||||
},
|
||||
)
|
||||
if (reservationError) throw new Error('renewal_reservation_failed')
|
||||
const reservation = reservationData as OperationReservation | null
|
||||
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
|
||||
results.push({
|
||||
userId,
|
||||
tier,
|
||||
success: false,
|
||||
error: reservation?.reason ?? 'renewal_operation_in_progress',
|
||||
})
|
||||
continue
|
||||
}
|
||||
operationId = reservation.operation_id
|
||||
|
||||
const billing = await paypleBilling(config, auth, {
|
||||
payerId,
|
||||
amount: TIER_PRICE[tier],
|
||||
orderId,
|
||||
goodsName: TIER_GOODS_NAME[tier],
|
||||
})
|
||||
charged = true
|
||||
const { error: chargedError } = await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'charged',
|
||||
p_external_reference: billing.PCD_PAY_OID || orderId,
|
||||
p_error_code: null,
|
||||
})
|
||||
if (chargedError) throw new Error('renewal_operation_update_failed')
|
||||
if (
|
||||
billing.PCD_PAY_OID !== orderId
|
||||
|| billing.PCD_PAY_TOTAL !== String(TIER_PRICE[tier])
|
||||
|| (billing.PCD_PAYER_ID && billing.PCD_PAYER_ID !== payerId)
|
||||
) throw new Error('renewal_response_mismatch')
|
||||
|
||||
const eventTime = billing.PCD_PAY_TIME
|
||||
? parsePaypleTimestamp(billing.PCD_PAY_TIME)
|
||||
: new Date()
|
||||
const { start, end } = calcSubscriptionPeriod(eventTime)
|
||||
|
||||
const { data: applyData, error: applyError } = await serviceClient.rpc(
|
||||
'apply_payment_provider_event',
|
||||
{
|
||||
p_user_id: userId,
|
||||
p_provider: 'payple',
|
||||
p_event_id: payplePaymentEventId(orderId),
|
||||
p_event_created_at: eventTime.toISOString(),
|
||||
p_event_type: 'payment.completed',
|
||||
p_payload_digest: await payplePaymentEventDigest({
|
||||
orderId,
|
||||
payerId,
|
||||
payType: 'card',
|
||||
amount: TIER_PRICE[tier],
|
||||
}),
|
||||
p_provider_resource_id: resourceId,
|
||||
p_tier: tier,
|
||||
p_status: 'active',
|
||||
p_entitled: true,
|
||||
p_current_period_start: start,
|
||||
p_current_period_end: end,
|
||||
p_cancel_at: null,
|
||||
p_auto_renewing: true,
|
||||
p_provider_customer_id: payerId,
|
||||
p_provider_order_id: orderId,
|
||||
p_store_product_id: null,
|
||||
p_store_purchase_id: null,
|
||||
p_operation_id: operationId,
|
||||
},
|
||||
)
|
||||
if (applyError) throw new Error('renewal_entitlement_apply_failed')
|
||||
const applied = applyData as { applied?: boolean; duplicate?: boolean; reason?: string } | null
|
||||
if (!applied?.applied && !applied?.duplicate) {
|
||||
throw new Error('renewal_requires_reconciliation')
|
||||
}
|
||||
results.push({ userId, tier, success: true, orderId })
|
||||
} catch (error) {
|
||||
const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive
|
||||
if (operationId && !charged) {
|
||||
if (chargeOutcomeUnknown) {
|
||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'external_created',
|
||||
p_external_reference: orderId,
|
||||
p_error_code: null,
|
||||
})
|
||||
} else if (error instanceof PaypleBillingError && error.definitive) {
|
||||
const eventTime = new Date()
|
||||
await serviceClient.rpc('record_payment_provider_renewal_failure', {
|
||||
p_user_id: userId,
|
||||
p_provider: 'payple',
|
||||
p_event_id: `renewal-failed:${operationId}`,
|
||||
p_event_created_at: eventTime.toISOString(),
|
||||
p_payload_digest: await sha256Text(`${operationId}:payple_renewal_failed`),
|
||||
p_provider_resource_id: resourceId,
|
||||
p_error_code: 'payple_renewal_failed',
|
||||
p_failure_threshold: MAX_RENEWAL_FAILURES,
|
||||
p_operation_id: operationId,
|
||||
})
|
||||
} else {
|
||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'failed',
|
||||
p_external_reference: null,
|
||||
p_error_code: 'payple_renewal_failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
userId,
|
||||
tier,
|
||||
success: false,
|
||||
error: charged || chargeOutcomeUnknown
|
||||
? 'renewal_requires_reconciliation'
|
||||
: 'payple_renewal_failed',
|
||||
})
|
||||
}
|
||||
results.push(await renewSubscription(deps, candidate))
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
renewed: results.filter((result) => result.success).length,
|
||||
failed: results.filter((result) => !result.success).length,
|
||||
expired: endingSubscriptions?.length ?? 0,
|
||||
expired,
|
||||
results,
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
@ -278,4 +142,6 @@ Deno.serve(async (req: Request) => {
|
|||
}
|
||||
return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.main) Deno.serve(paypleRenewHandler)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue