$sub Row from the subscriptions table. */ public function attemptRenewal(array $sub): void { $id = (int) $sub['id']; $gateway = GatewayFactory::active(); if ($gateway === null) { // Gateway not configured — leave the sub, surface via log. do_action('abovault_gateway_unconfigured', $id); return; } $result = $gateway->chargeOffSession($sub); switch ($result['status']) { case 'succeeded': self::resolveSuccess($id); break; case 'processing': // Park as pending; do NOT advance or fail. Webhook resolves it. Subscription::transition($id, Subscription::STATUS_ACTIVE, [ 'pending_charge_ref' => (string) ($result['reference'] ?? ''), ]); do_action('abovault_renewal_processing', $id, $result['reference'] ?? ''); break; case 'failed': default: self::resolveFailure($id, (string) ($result['reason'] ?? 'charge-failed')); break; } } /** * Renewal confirmed (sync success OR payment_intent.succeeded webhook). * Advances the billing period, clears pending/retry state. */ public static function resolveSuccess(int $id): void { global $wpdb; $sub = $wpdb->get_row( $wpdb->prepare('SELECT * FROM ' . Subscription::table() . ' WHERE id = %d', $id), ARRAY_A ); if (!$sub) { return; } $nextEnd = self::advancePeriod( (string) $sub['current_period_end'], (string) $sub['interval_unit'], (int) $sub['interval_count'] ); Subscription::transition($id, Subscription::STATUS_ACTIVE, [ 'current_period_start' => $sub['current_period_end'], 'current_period_end' => $nextEnd, 'retry_count' => 0, 'pending_charge_ref' => '', 'last_payment_failed_at' => null, ]); do_action('abovault_renewal_succeeded', $id, $sub); } /** * Renewal failed (immediate reject OR payment_intent.payment_failed webhook). * → past_due, fire dunning trigger, schedule next retry (backoff) if within budget. */ public static function resolveFailure(int $id, string $reason = 'charge-failed'): void { global $wpdb; $sub = $wpdb->get_row( $wpdb->prepare('SELECT * FROM ' . Subscription::table() . ' WHERE id = %d', $id), ARRAY_A ); if (!$sub) { return; } $attempt = (int) $sub['retry_count'] + 1; $maxTries = (int) get_option('abovault_max_retries', count(self::BACKOFF_DAYS)); Subscription::transition($id, Subscription::STATUS_PAST_DUE, [ 'retry_count' => $attempt, 'pending_charge_ref' => '', 'last_payment_failed_at' => current_time('mysql', true), ]); // Retention engine listens here → dunning sequence. do_action('abovault_payment_failed', $id, $attempt, $reason); if ($attempt <= $maxTries && function_exists('as_schedule_single_action')) { $delayDays = self::BACKOFF_DAYS[min($attempt - 1, count(self::BACKOFF_DAYS) - 1)]; as_schedule_single_action( time() + $delayDays * DAY_IN_SECONDS, 'abovault_retry_charge', ['subscription_id' => $id], 'abovault' ); } // If exhausted: LifecycleProcessor's grace timer hard-cancels it. } private static function advancePeriod(string $from, string $unit, int $count): string { $ts = strtotime($from) ?: time(); return gmdate('Y-m-d H:i:s', strtotime("+{$count} {$unit}", $ts) ?: $ts); } }