balance(); if ( is_wp_error( $balance ) || ! isset( $balance['credits'] ) ) { return; } set_transient( 'a11yfy_balance', $balance, 15 * MINUTE_IN_SECONDS ); if ( $has_pending ) { self::resume_pending( $balance ); } if ( $threshold <= 0 ) { return; } if ( (int) $balance['credits'] >= $threshold ) { delete_transient( 'a11yfy_low_credit' ); // Recovered above the threshold — re-arm, so the NEXT crossing // emails again (top-up → new dip → new email). delete_option( self::LOW_CREDIT_NOTIFIED_OPTION ); return; } set_transient( 'a11yfy_low_credit', array( 'available' => (int) $balance['credits'], 'delegated' => ! empty( $balance['delegated'] ), 'billing_org_name' => isset( $balance['billing_org_name'] ) ? (string) $balance['billing_org_name'] : '', ), DAY_IN_SECONDS ); if ( ! get_option( self::LOW_CREDIT_NOTIFIED_OPTION ) ) { if ( ! empty( $balance['delegated'] ) ) { // Delegated allowance: the recipient cannot top up — the parent // organization controls the limit. $org = isset( $balance['billing_org_name'] ) ? (string) $balance['billing_org_name'] : ''; $org = '' !== $org ? $org : __( 'your partner organization', 'a11yfy-pdf-accessibility-checker-fixer' ); $body = sprintf( /* translators: 1: remaining delegated credits, 2: parent organization name. */ __( "Your a11yfy delegated credit allowance is down to %1\$d credits. PDF remediation will stop when it reaches zero.\n\nYour credits are provided by %2\$s — ask them to raise your limit, or switch to your own credits in your a11yfy organization settings.", 'a11yfy-pdf-accessibility-checker-fixer' ), (int) $balance['credits'], $org ); } else { $body = sprintf( /* translators: 1: credit balance, 2: top-up URL. */ __( "Your a11yfy credit balance is down to %1\$d credits. PDF remediation will stop when it reaches zero.\n\nTop up at: %2\$s", 'a11yfy-pdf-accessibility-checker-fixer' ), (int) $balance['credits'], 'https://a11yfy.com' ); } wp_mail( A11yfy_Settings::notify_email(), __( '[a11yfy] Low credit balance', 'a11yfy-pdf-accessibility-checker-fixer' ), $body ); update_option( self::LOW_CREDIT_NOTIFIED_OPTION, time(), false ); } } /** * Resume parked visitor requests (feature spec 2026-08-03 §3.4): walk the * pending attachments oldest-request-first and enqueue as many jobs as the * fresh balance covers by the rolling worst-case estimate. The hard gate * stays the server-side atomic reserve — a 402 at submit re-parks the * requests (A11yfy_RemediateService::handle_submit_error()). * * @param array $balance Fresh /v1/balance payload. */ private static function resume_pending( array $balance ) { $credits_left = isset( $balance['credits'] ) ? (int) $balance['credits'] : 0; if ( $credits_left <= 0 ) { return; } foreach ( A11yfy_Requests::pending_attachments( 20 ) as $attachment_id ) { if ( A11yfy_Jobs::has_active( $attachment_id ) ) { // A job is already running (e.g. admin fixed it manually) — the // terminal action will notify the subscribers either way. A11yfy_Requests::set_status_for_attachment( $attachment_id, 'queued' ); continue; } $estimate = A11yfy_Guardrails::estimate( array( $attachment_id ) ); if ( $credits_left < (int) $estimate['max'] ) { break; // Oldest-first: do not skip ahead past an unaffordable doc. } $credits_left -= (int) $estimate['max']; A11yfy_Requests::set_status_for_attachment( $attachment_id, 'queued' ); self::enqueue_remediation( $attachment_id, 'visitor' ); } } /** * Cached balance for UI (15 min transient, refreshed by the hourly check; * the Dashboard passes $refresh = true so a fresh top-up shows immediately). * * @param bool $refresh Force an API call. * @return array|null */ public static function balance( $refresh = false ) { $cached = get_transient( 'a11yfy_balance' ); if ( ! $refresh && is_array( $cached ) ) { return $cached; } if ( ! A11yfy_Settings::is_connected() ) { return null; } $client = new A11yfy_ApiClient(); $balance = $client->balance(); if ( is_wp_error( $balance ) ) { return is_array( $cached ) ? $cached : null; } set_transient( 'a11yfy_balance', $balance, 15 * MINUTE_IN_SECONDS ); return $balance; } }