get_results($wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'active' AND next_run_at <= %s ORDER BY next_run_at ASC LIMIT 200", $now ), ARRAY_A); foreach ($due as $enrollment) { $this->runStep($enrollment); } } /** @param array $enrollment */ private function runStep(array $enrollment): void { global $wpdb; $table = EnrollmentService::table(); $sequence = (string) $enrollment['sequence']; $stepIndex = (int) $enrollment['step_index']; $subId = (int) $enrollment['subscription_id']; $steps = SequenceLibrary::steps($sequence); // Guard: sequence emptied or index out of range → complete. if (!isset($steps[$stepIndex])) { $this->complete($enrollment); return; } $this->dispatch($steps[$stepIndex], $subId, $sequence, $stepIndex); $next = $stepIndex + 1; if (isset($steps[$next])) { $created = strtotime((string) $enrollment['created_at']) ?: time(); $wpdb->update($table, [ 'step_index' => $next, 'next_run_at' => gmdate('Y-m-d H:i:s', $created + (int) $steps[$next]['offset_hours'] * HOUR_IN_SECONDS), ], ['id' => (int) $enrollment['id']]); } else { $this->complete($enrollment); } } /** * @param array{action:string, template:string, meta?:array} $step */ private function dispatch(array $step, int $subId, string $sequence, int $stepIndex): void { switch ($step['action']) { case 'coupon_email': $code = $this->createRetentionCoupon($subId, (array) ($step['meta'] ?? [])); LifecycleMailer::send($subId, $step['template'], ['coupon_code' => $code]); break; case 'pause_offer': case 'email': default: LifecycleMailer::send($subId, $step['template']); break; } do_action('abovault_sequence_step_ran', $subId, $sequence, $stepIndex, $step); } private function complete(array $enrollment): void { global $wpdb; $wpdb->update( EnrollmentService::table(), ['status' => 'completed', 'next_run_at' => null], ['id' => (int) $enrollment['id']] ); do_action('abovault_sequence_completed', (int) $enrollment['subscription_id'], (string) $enrollment['sequence']); } /** * Time-based triggers (daily): renewal reminders (period ending soon) + anniversaries. */ public function evaluateScheduledTriggers(): void { if (get_option('abovault_retention_enabled', 'yes') !== 'yes') { return; } global $wpdb; $t = Subscription::table(); $now = current_time('mysql', true); // Renewal reminders: active, not cancelling, period ends within reminder window. $windowDays = (int) apply_filters('abovault_renewal_reminder_days', 3); $soon = gmdate('Y-m-d H:i:s', strtotime($now) + $windowDays * DAY_IN_SECONDS); $ids = $wpdb->get_col($wpdb->prepare( "SELECT id FROM {$t} WHERE status = %s AND cancel_at_period_end = 0 AND current_period_end BETWEEN %s AND %s", Subscription::STATUS_ACTIVE, $now, $soon )); foreach ($ids as $id) { EnrollmentService::enroll((int) $id, 'renewal_reminder', 'period_ending'); } // Anniversaries: created ~N*365 days ago (same calendar day). $anniv = $wpdb->get_col($wpdb->prepare( "SELECT id FROM {$t} WHERE status IN (%s, %s) AND DATEDIFF(%s, created_at) > 0 AND MOD(DATEDIFF(%s, created_at), 365) = 0", Subscription::STATUS_ACTIVE, Subscription::STATUS_TRIALING, $now, $now )); foreach ($anniv as $id) { EnrollmentService::enroll((int) $id, 'loyalty', 'anniversary'); } } /** * Create a single-use % coupon for a subscriber. @return string coupon code */ private function createRetentionCoupon(int $subId, array $meta): string { if (!class_exists('WC_Coupon')) { return ''; } $percent = (int) ($meta['percent'] ?? 15); $days = (int) ($meta['days_valid'] ?? 30); $code = 'ABOVAULT-' . strtoupper(wp_generate_password(6, false)); $coupon = new \WC_Coupon(); $coupon->set_code($code); $coupon->set_discount_type('percent'); $coupon->set_amount($percent); $coupon->set_individual_use(true); $coupon->set_usage_limit(1); $coupon->set_date_expires(time() + $days * DAY_IN_SECONDS); $coupon->save(); return $code; } }