sanitize( get_option( self::OPTION, array() ) ); return is_wp_error( $result ) ? array( 'enabled' => 0, 'levels' => array(), 'badges' => array(), ) : $result; } /** * Validate every submitted definition and allowlisted merchant field. * * @param mixed $input Submitted configuration. * @return array|\WP_Error */ public function sanitize( $input ) { $error = new \WP_Error( 'devoynx_recognition_invalid', __( 'Use a name and a whole-number threshold from 1 to 1,000,000,000. Membership level thresholds must be unique.', 'devoynx-rewards' ) ); if ( ! is_array( $input ) ) { return $error; } $result = array( 'enabled' => empty( $input['enabled'] ) ? 0 : 1, 'levels' => array(), 'badges' => array(), ); foreach ( array( 'levels', 'badges' ) as $kind ) { $rows = $input[ $kind ] ?? array(); if ( ! is_array( $rows ) ) { return $error; } foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! is_scalar( $row['name'] ?? '' ) || ! is_scalar( $row['description'] ?? '' ) || ! is_scalar( $row['threshold'] ?? '' ) ) { return $error; } $name = sanitize_text_field( (string) ( $row['name'] ?? '' ) ); if ( '' === $name ) { continue; } $threshold = filter_var( $row['threshold'] ?? '', FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1, 'max_range' => 1000000000, ), ) ); $metric = 'levels' === $kind ? 'net_earned' : ( $row['metric'] ?? 'net_earned' ); if ( false === $threshold || ! in_array( $metric, array( 'net_earned', 'net_redeemed' ), true ) ) { return $error; } $result[ $kind ][] = array( 'name' => mb_substr( $name, 0, 80 ), 'description' => mb_substr( sanitize_text_field( (string) ( $row['description'] ?? '' ) ), 0, 240 ), 'threshold' => $threshold, 'metric' => $metric, ); } } $thresholds = array(); foreach ( $result['levels'] as $level ) { if ( isset( $thresholds[ $level['threshold'] ] ) ) { return $error; } $thresholds[ $level['threshold'] ] = true; } return $result; } /** Save only authorized, nonce-protected POST configuration. */ public function save() { if ( 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) || ! current_user_can( 'manage_devoynx_rewards' ) ) { wp_die( esc_html__( 'Permission denied.', 'devoynx-rewards' ), 403 ); } check_admin_referer( 'devoynx_rewards_recognition' ); // The validator checks every scalar, bounds and supported fields before persistence. $settings = $this->sanitize( isset( $_POST['recognition'] ) ? wp_unslash( $_POST['recognition'] ) : array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Strict allowlist validation in sanitize() directly above persistence. if ( ! is_wp_error( $settings ) ) { update_option( self::OPTION, $settings, false ); } wp_safe_redirect( add_query_arg( array( 'page' => 'devoynx-rewards', 'tab' => 'recognition', 'recognition_result' => is_wp_error( $settings ) ? 'invalid' : 'saved', ), admin_url( 'admin.php' ) ) ); exit; } /** * Derive progress from the immutable ledger, subtracting outstanding correction debt. * * Spending and expiry do not revoke earned recognition. Reversals and refund * debt do. Redeemed progress is net of returned redemptions. No read awards points. * * @param int $user_id Customer ID. * @return array */ public function progress( $user_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Fresh indexed customer aggregate from the canonical ledger; no alternate balance is stored. $earned = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(CASE WHEN from_bucket = '' THEN points ELSE 0 END),0) - COALESCE(SUM(CASE WHEN to_bucket = 'reversed' THEN points ELSE 0 END),0) FROM %i WHERE user_id = %d", $wpdb->prefix . 'devoynx_rewards_ledger', absint( $user_id ) ) ); $balance = ( new LedgerRepository() )->get_balance( $user_id ); $settings = $this->settings(); $result = array( 'net_earned' => max( 0, $earned - absint( get_user_meta( $user_id, '_devoynx_rewards_reversal_due', true ) ) ), 'net_redeemed' => $balance['redeemed'], 'current' => null, 'levels' => array(), 'badges' => array(), ); foreach ( array( 'levels', 'badges' ) as $kind ) { foreach ( $settings[ $kind ] as $definition ) { $definition['value'] = $result[ $definition['metric'] ]; $definition['qualified'] = $definition['value'] >= $definition['threshold']; $definition['remaining'] = max( 0, $definition['threshold'] - $definition['value'] ); $result[ $kind ][] = $definition; if ( 'levels' === $kind && $definition['qualified'] && ( ! $result['current'] || $definition['threshold'] > $result['current']['threshold'] ) ) { $result['current'] = $definition; } } } return $result; } /** * Render only the signed-in owner's Free recognition progress. * * @param int $user_id Wallet owner. */ public function render_wallet( $user_id ) { $settings = $this->settings(); if ( ! $settings['enabled'] || ! $user_id || get_current_user_id() !== (int) $user_id ) { return; } $progress = $this->progress( $user_id ); ?>

cards( $progress['levels'], 'levels' ); ?>

cards( $progress['badges'], 'badges' ); ?>
' . esc_html__( 'The store has not configured milestones here yet.', 'devoynx-rewards' ) . '

'; return; } echo '
'; foreach ( $items as $item ) { $metric = 'net_redeemed' === $item['metric'] ? __( 'Net points used', 'devoynx-rewards' ) : __( 'Net lifetime points earned', 'devoynx-rewards' ); ?>

:

'; } /** * Render all saved definitions with accessible repeatable controls. * * @param string $tab Current admin tab. */ public function render_admin( $tab ) { if ( 'recognition' !== $tab || ! current_user_can( 'manage_devoynx_rewards' ) ) { return; } $settings = $this->settings(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only result notice after the capability check. $result = isset( $_GET['recognition_result'] ) ? sanitize_key( wp_unslash( $_GET['recognition_result'] ) ) : ''; ?>

__( 'Membership levels', 'devoynx-rewards' ), 'badges' => __( 'Achievement badges', 'devoynx-rewards' ), ) as $kind => $title ) : ?>

$item ) { $this->definition_fields( $kind, $title, $index, $item ); } ?>

'', 'description' => '', 'threshold' => '', 'metric' => 'net_earned', ) ); $prefix = 'recognition[' . $kind . '][' . absint( $index ) . ']'; ?>