'free', 'abandoned_carts' => false, 'notifications_limit' => 1, 'grandfathered_until' => null, 'checked_at' => '', '_source' => 'default', ); } // ------------------------------------------------------------------------- // Passive read (page rendering) — cache-first, never blocks // ------------------------------------------------------------------------- /** * Entitlements for PASSIVE rendering: transient → last-known → a background * best-effort refresh. Never throws, never blocks a user action. * * @return array */ public static function get(): array { $cached = get_transient( self::TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } // No transient — try a live refresh (short timeout). On success it caches // and returns fresh; on failure it returns last-known / defaults. $fresh = self::refresh(); if ( $fresh['ok'] ) { return $fresh['entitlements']; } return self::last_known(); } /** * LIVE check for a GATED ACTION. No cache read — always hits the network * (3s). On success, persists last-known + ever-paid and refreshes the * transient. On failure, falls back to last-known (spec 1e) so a paying * customer is never blocked. * * @return array { * @type bool $ok Whether the live call succeeded. * @type array $entitlements Effective entitlements to act on. * @type bool $stale True when falling back to last-known. * } */ public static function live(): array { $result = self::refresh(); if ( $result['ok'] ) { return array( 'ok' => true, 'entitlements' => $result['entitlements'], 'stale' => false, ); } // Failure: use last known. If last known was paid → allow. Only a // never-verified key defaults to locked. return array( 'ok' => false, 'entitlements' => self::last_known(), 'stale' => true, ); } /** * Perform the network fetch and persist results. Shared by get()/live(). * * @return array { @type bool $ok; @type array $entitlements; } */ private static function refresh(): array { $api = SixRsh_API::instance(); if ( ! $api ) { return array( 'ok' => false, 'entitlements' => self::last_known() ); } $resp = $api->get_entitlements( self::LIVE_TIMEOUT ); if ( empty( $resp['success'] ) || ! is_array( $resp['data'] ?? null ) ) { return array( 'ok' => false, 'entitlements' => self::last_known() ); } $d = $resp['data']; $entitlements = array( 'plan' => isset( $d['plan'] ) ? sanitize_text_field( (string) $d['plan'] ) : 'free', 'abandoned_carts' => ! empty( $d['abandoned_carts'] ), 'notifications_limit' => array_key_exists( 'notifications_limit', $d ) && null !== $d['notifications_limit'] ? (int) $d['notifications_limit'] : null, 'grandfathered_until' => ! empty( $d['grandfathered_until'] ) ? sanitize_text_field( (string) $d['grandfathered_until'] ) : null, 'checked_at' => ! empty( $d['checked_at'] ) ? sanitize_text_field( (string) $d['checked_at'] ) : gmdate( 'c' ), '_source' => 'live', ); // Persist: transient (passive) + last-known (durable) + ever-paid flag. set_transient( self::TRANSIENT, $entitlements, self::CACHE_TTL ); update_option( self::OPTION_LAST_KNOWN, $entitlements, false ); // "Paid" == abandoned_carts unlocked OR unlimited notifications == a paid // tier. Once true, it stays true so a later outage can't relock a payer. if ( $entitlements['abandoned_carts'] || null === $entitlements['notifications_limit'] ) { update_option( self::OPTION_EVER_PAID, 1, false ); } return array( 'ok' => true, 'entitlements' => $entitlements ); } /** * Last-known-good entitlements from the durable option, or safe defaults. * * @return array */ public static function last_known(): array { $opt = get_option( self::OPTION_LAST_KNOWN ); if ( is_array( $opt ) && isset( $opt['abandoned_carts'] ) ) { $opt['_source'] = 'last_known'; return $opt; } return self::defaults(); } /** Whether this key has EVER verified as a paid plan. */ public static function ever_paid(): bool { return (bool) get_option( self::OPTION_EVER_PAID, 0 ); } // ------------------------------------------------------------------------- // Convenience accessors (passive) // ------------------------------------------------------------------------- /** Abandoned carts allowed for passive rendering? */ public static function can_use_abandoned_carts(): bool { $e = self::get(); return ! empty( $e['abandoned_carts'] ); } /** Notifications limit (int) or null for unlimited, for passive rendering. */ public static function notifications_limit() { $e = self::get(); return array_key_exists( 'notifications_limit', $e ) ? $e['notifications_limit'] : 1; } /** Grandfather end date (ISO string) or null. */ public static function grandfathered_until() { $e = self::get(); return $e['grandfathered_until'] ?? null; } /** Current plan slug for passive rendering. */ public static function plan(): string { $e = self::get(); return (string) ( $e['plan'] ?? 'free' ); } // ------------------------------------------------------------------------- // Notification counting // ------------------------------------------------------------------------- /** * Count of currently ENABLED plugin notifications (per-event options). * * @return int */ public static function enabled_notification_count(): int { $slugs = self::all_event_slugs(); $count = 0; foreach ( $slugs as $slug ) { if ( get_option( 'sixrsh_event_' . $slug . '_enabled', false ) ) { $count++; } } return $count; } /** * All possible notification event slugs (special events + WC statuses). * * @return string[] */ public static function all_event_slugs(): array { $slugs = array( 'order_placed', 'new_customer' ); if ( function_exists( 'wc_get_order_statuses' ) ) { $slugs = array_merge( $slugs, array_keys( wc_get_order_statuses() ) ); } return array_values( array_unique( $slugs ) ); } /** * Whether enabling ONE MORE notification is allowed under the current limit. * Uses passive entitlements — the caller does a LIVE re-check first for the * actual gate decision (so a just-upgraded user is unlocked instantly). * * @param array|null $entitlements Optional pre-fetched (live) entitlements. * * @return bool */ public static function can_enable_another_notification( ?array $entitlements = null ): bool { $e = $entitlements ?? self::get(); $limit = array_key_exists( 'notifications_limit', $e ) ? $e['notifications_limit'] : 1; if ( null === $limit ) { return true; // unlimited } return self::enabled_notification_count() < (int) $limit; } // ------------------------------------------------------------------------- // Cache invalidation // ------------------------------------------------------------------------- /** Delete the passive transient (forces a fresh fetch next render). */ public static function bust(): void { delete_transient( self::TRANSIENT ); } /** * Reset ALL entitlement state — used when the API key changes/disconnects so * a new key never inherits the old one's paid status. */ public static function reset(): void { delete_transient( self::TRANSIENT ); delete_option( self::OPTION_LAST_KNOWN ); delete_option( self::OPTION_EVER_PAID ); delete_option( self::OPTION_CALLBACK_SECRET ); delete_option( self::OPTION_CALLBACK_REGISTERED ); } // ------------------------------------------------------------------------- // Callback registration + webhook receiver // ------------------------------------------------------------------------- /** The plan.changed webhook URL for this store (REST route below). */ public static function callback_url(): string { return rest_url( '6rsh/v1/plan-changed' ); } /** * Register (or refresh) the plan.changed callback URL with 6rsh. Idempotent; * safe to call on every connect. Stores the returned signing secret. * * @return bool True when registered. */ public static function register_callback(): bool { $api = SixRsh_API::instance(); if ( ! $api ) { return false; } $resp = $api->register_callback( self::callback_url() ); if ( empty( $resp['success'] ) || empty( $resp['data']['secret'] ) ) { return false; } update_option( self::OPTION_CALLBACK_SECRET, sanitize_text_field( (string) $resp['data']['secret'] ), false ); update_option( self::OPTION_CALLBACK_REGISTERED, 1, false ); return true; } /** * Register the REST route that receives signed plan.changed webhooks. * Hooked from the main plugin file on rest_api_init. * * @return void */ public static function register_webhook_route(): void { register_rest_route( '6rsh/v1', '/plan-changed', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'handle_webhook' ), 'permission_callback' => '__return_true', // auth is the HMAC signature ) ); } /** * Handle an inbound plan.changed webhook: verify HMAC, then bust the cache so * the next render/action fetches the new plan. * * @param WP_REST_Request $request * * @return WP_REST_Response */ public static function handle_webhook( WP_REST_Request $request ): WP_REST_Response { $secret = (string) get_option( self::OPTION_CALLBACK_SECRET, '' ); if ( '' === $secret ) { return new WP_REST_Response( array( 'success' => false, 'reason' => 'not_registered' ), 400 ); } $raw = $request->get_body(); $signature = (string) $request->get_header( 'x_6rsh_signature' ); $expected = 'sha256=' . hash_hmac( 'sha256', $raw, $secret ); if ( ! $signature || ! hash_equals( $expected, $signature ) ) { return new WP_REST_Response( array( 'success' => false, 'reason' => 'bad_signature' ), 401 ); } // Valid signature → the server-side plan changed. Bust the passive cache; // the next gated action re-checks live and picks up the new plan. We also // proactively refresh so a passive page paints fresh immediately. self::bust(); self::refresh(); return new WP_REST_Response( array( 'success' => true ), 200 ); } }