unlimited * -1 => unlimited * absent => unlimited (caller passes null for a missing key) * int >= 1 => a real cap * * NEVER test the limit with empty()/!$limit/`?? 1` — null is the server's * wire format for "unlimited", and `$limit ?? 1` silently turns a paid, * uncapped tenant into a 1-notification Free tenant. That was the v1.6.0 * bug that showed Pro stores the Free limit banner. * * @param mixed $limit Raw limit value. * * @return bool */ public static function is_unlimited( $limit ): bool { return ( null === $limit || '' === $limit || -1 === (int) $limit ); } /** * Read notifications_limit out of an entitlements array WITHOUT collapsing * null into a cap. Returns null for unlimited, or a positive int cap. * * @param array|null $entitlements Entitlements array. * * @return int|null */ public static function limit_from( ?array $entitlements ) { if ( ! is_array( $entitlements ) || ! array_key_exists( 'notifications_limit', $entitlements ) ) { return null; // absent => unlimited } $raw = $entitlements['notifications_limit']; return self::is_unlimited( $raw ) ? null : max( 1, (int) $raw ); } /** * Default entitlements used before any successful check. Locked-but-safe: * abandoned carts off, 1 notification. A NEVER-verified key stays here on * failure (defaults to locked, per spec 1e). * * @return array */ private static function defaults(): array { return array( 'plan' => 'free', 'abandoned_carts' => false, 'notifications_limit' => 1, 'grandfathered_until' => null, // Billing lifecycle (server >= 2026-08-29). A server that does not // send these — i.e. every 6rsh before this release — leaves them at // 'none'/null, which renders NO notice. That is the backward-compat // contract in both directions. 'billing_status' => 'none', 'grace_ends_at' => 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 { // On the settings screen the page-load live fetch is the source of // truth for the whole request — never fall back to the cache while a // fresh answer is in hand (and never re-hit the network per call). if ( is_array( self::$primed_entitlements ) ) { return self::$primed_entitlements; } $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'] ), // null / -1 / absent all normalize to null == unlimited. 'notifications_limit' => self::limit_from( $d ), 'grandfathered_until' => ! empty( $d['grandfathered_until'] ) ? sanitize_text_field( (string) $d['grandfathered_until'] ) : null, // Absent on servers older than 2026-08-29 => 'none' / null => no notice. 'billing_status' => ! empty( $d['billing_status'] ) ? sanitize_key( (string) $d['billing_status'] ) : 'none', 'grace_ends_at' => ! empty( $d['grace_ends_at'] ) ? sanitize_text_field( (string) $d['grace_ends_at'] ) : 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'] || self::is_unlimited( $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; } // No snapshot (fresh install, cleared option, or a hard_clear() followed // by a failed fetch). A key that has EVER verified as paid must NOT be // re-locked just because we currently can't reach 6rsh — a paying // customer never sees a locked feature. Only a NEVER-verified key // falls through to the locked defaults. if ( self::ever_paid() ) { return array( 'plan' => 'paid', 'abandoned_carts' => true, 'notifications_limit' => null, // unlimited 'grandfathered_until' => null, // Never assume a billing problem from a fallback: an outage must // not make a paying store show a "payment failed" banner. 'billing_status' => 'none', 'grace_ends_at' => null, 'checked_at' => '', '_source' => 'ever_paid_fallback', ); } 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? * * v1.7.0: ALWAYS true. Abandoned-cart recovery is a free feature on every * 6rsh plan — the client-side lock and its upsell panel are gone for good. * The server keeps its own kill-switch (features.abandoned_cart_paid_gate) * for emergencies: if that is ever flipped, automation stops server-side, * while this plugin keeps the settings usable and never shows a lock. * The entitlements payload still drives the order-notification cap, which * is unrelated to abandoned carts. */ public static function can_use_abandoned_carts(): bool { return true; } /** Notifications limit (positive int) or null for unlimited. */ public static function notifications_limit() { return self::limit_from( self::get() ); } /** 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' ); } /** * Billing status reported by 6rsh. 'none' when the server does not send it * (older 6rsh) or the store has never transacted — both mean "nothing to * warn about". * * @return string */ public static function billing_status(): string { $e = self::get(); $v = $e['billing_status'] ?? 'none'; return '' === $v ? 'none' : (string) $v; } /** ISO timestamp when the payment-grace window closes, or null. */ public static function grace_ends_at() { $e = self::get(); return $e['grace_ends_at'] ?? null; } /** * True only while a payment has FAILED and the grace window is still open. * * Deliberately strict: it needs BOTH a past_due/grace status AND a * grace_ends_at in the future. A missing or malformed date renders nothing * rather than a banner with no deadline in it. * * @return bool */ public static function in_payment_grace(): bool { if ( ! in_array( self::billing_status(), array( 'past_due', 'grace' ), true ) ) { return false; } $ends = self::grace_ends_at(); if ( empty( $ends ) ) { return false; } $ts = strtotime( (string) $ends ); return $ts && $ts > time(); } /** Whole days left in the grace window (>= 0). */ public static function grace_days_left(): int { $ends = self::grace_ends_at(); if ( empty( $ends ) ) { return 0; } $ts = strtotime( (string) $ends ); if ( ! $ts ) { return 0; } return max( 0, (int) ceil( ( $ts - time() ) / DAY_IN_SECONDS ) ); } // ------------------------------------------------------------------------- // 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 { $limit = self::limit_from( $entitlements ?? self::get() ); if ( null === $limit ) { return true; // unlimited } return self::enabled_notification_count() < $limit; } // ------------------------------------------------------------------------- // Cache invalidation // ------------------------------------------------------------------------- /** Delete the passive transient (forces a fresh fetch next render). */ public static function bust(): void { delete_transient( self::TRANSIENT ); } /** * HARD clear for the "Re-check my plan now" escape hatch: drops BOTH the * transient and the persisted last-known snapshot, so the next live() has * nothing stale to fall back to and must re-derive from the server. * * Deliberately keeps OPTION_EVER_PAID: a store that has proven it is paid * must not be re-locked just because someone pressed re-check while the * network was down. * * @return void */ public static function hard_clear(): void { delete_transient( self::TRANSIENT ); delete_option( self::OPTION_LAST_KNOWN ); } /** * PAGE-LOAD LIVE FETCH (v1.6.1). * * Called once per load of the 6rsh settings screen, before render. Fetches * entitlements live (no cache, 3s) and persists them, so the page always * paints the CURRENT plan — opening the page is itself the refresh. The * 5-minute transient is demoted to a pure fallback for when this fails. * * Runs at most once per request. Never fatal, never blocking beyond 3s. * * @return array The entitlements the page should render from. */ public static function prime_for_admin_page(): array { if ( is_array( self::$primed_entitlements ) ) { return self::$primed_entitlements; } $result = self::live(); // live fetch; falls back to last-known self::$primed_entitlements = $result['entitlements']; // Surface a soft notice when we had to fall back (never blocking). self::$page_load_stale = ! empty( $result['stale'] ); return self::$primed_entitlements; } /** Fresh entitlements for this request, set by prime_for_admin_page(). */ public static $primed_entitlements = null; /** True when the page-load fetch fell back to last-known. */ public static $page_load_stale = false; /** * 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 ) ); } /** How old (seconds) a signed callback body may be. */ const CALLBACK_MAX_AGE = 600; /** * Whether a callback's signed `sent_at` is within CALLBACK_MAX_AGE of now * (either direction, to allow for clock drift). * * @param mixed $sent_at Unix timestamp from the signed body. * * @return bool */ public static function is_fresh( $sent_at ): bool { return is_numeric( $sent_at ) && abs( time() - (int) $sent_at ) <= self::CALLBACK_MAX_AGE; } /** * 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 ); } // Signed COD answer (customer tapped Confirm / Cancel on WhatsApp). $data = json_decode( $raw, true ); if ( is_array( $data ) && 'cod.answered' === ( $data['event'] ?? '' ) && class_exists( 'SixRsh_COD' ) ) { // The signed send time stops a captured answer being replayed later // (e.g. an old "confirmed" over a newer "cancelled"). if ( ! self::is_fresh( $data['sent_at'] ?? null ) ) { return new WP_REST_Response( array( 'success' => false, 'reason' => 'stale' ), 401 ); } $res = SixRsh_COD::apply_answer( $data ); return new WP_REST_Response( $res, ! empty( $res['success'] ) ? 200 : 422 ); } // 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 ); } }