0). * @param string $reason Short reason code (≤60 chars). * @param int $ref_id Optional reference ID (order_id, post_id, …). * @param string $ref_type Optional reference type ('order', 'post', 'manual', …). * @return array{ok:bool, balance:int, ledger_id:int, error?:string} */ public static function credit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '' ): array { if ( $delta <= 0 ) { return array( 'ok' => false, 'error' => 'credit delta must be positive', ); } return self::transact( $user_id, $delta, $reason, $ref_id, $ref_type, false ); } /** * Debit points from a user (negative delta applied internally). * * @param int $user_id WordPress user ID. * @param int $delta Points to deduct (positive number; stored as negative). * @param string $reason Short reason code (≤60 chars). * @param int $ref_id Optional reference ID. * @param string $ref_type Optional reference type. * @param bool $allow_overdraft When true, debit proceeds even if balance < delta. * @return array{ok:bool, balance:int, ledger_id:int, error?:string} */ public static function debit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '', bool $allow_overdraft = false ): array { if ( $delta <= 0 ) { return array( 'ok' => false, 'error' => 'debit delta must be positive', ); } return self::transact( $user_id, -$delta, $reason, $ref_id, $ref_type, $allow_overdraft ); } /** * Return current points balance for a user. * * Reads directly from the flat table, bypassing usermeta EAV. * * @param int $user_id WordPress user ID. * @return int Balance (0 when user has no membership row). */ public static function get_balance( int $user_id ): int { global $wpdb; $table = $wpdb->prefix . 'wpdo_user_membership'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $balance = $wpdb->get_var( $wpdb->prepare( "SELECT points_balance FROM `{$table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $user_id ) ); return (int) ( $balance ?? 0 ); } /** * Retrieve ledger entries for a user, newest-first. * * The idx_user_created covering index makes this O(log N) regardless of total rows. * * @param int $user_id WordPress user ID. * @param int $limit Max rows to return (default 20). * @param int $offset Row offset for pagination (default 0). * @return array */ public static function get_ledger( int $user_id, int $limit = 20, int $offset = 0 ): array { global $wpdb; $table = $wpdb->prefix . 'wpdo_user_points_ledger'; $limit = max( 1, min( 500, $limit ) ); $offset = max( 0, $offset ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, delta, balance_after, reason, ref_id, ref_type, created_at FROM `{$table}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $user_id, $limit, $offset ), ARRAY_A ); return $rows ?: array(); } // ── Internal ───────────────────────────────────────────────────────────── /** * Core atomic transaction: SELECT … FOR UPDATE → validate → UPDATE balance → INSERT ledger. * * All balance mutations (credit and debit) route through this single method. * TMDO_DB::begin() must be called before SELECT … FOR UPDATE; otherwise * InnoDB ignores the lock hint and the serialisation guarantee is lost. * * @param int $user_id WordPress user ID. * @param int $delta Signed delta (positive = credit, negative = debit). * @param string $reason Reason code stored in ledger. * @param int $ref_id Reference ID (0 = none). * @param string $ref_type Reference type ('' = none). * @param bool $allow_overdraft Skip balance-floor check when true. * @return array{ok:bool, balance:int, ledger_id:int, error?:string} */ private static function transact( int $user_id, int $delta, string $reason, int $ref_id, string $ref_type, bool $allow_overdraft ): array { global $wpdb; $mem_table = $wpdb->prefix . 'wpdo_user_membership'; $ledger_table = $wpdb->prefix . 'wpdo_user_points_ledger'; // Truncate reason to column width to avoid silent DB truncation. $reason = substr( $reason, 0, 60 ); $ref_type = substr( $ref_type, 0, 30 ); TMDO_DB::begin(); try { // Lock the membership row for this user so concurrent writes wait. // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $current_balance = $wpdb->get_var( $wpdb->prepare( "SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $user_id ) ); $current_balance = (int) ( $current_balance ?? 0 ); $new_balance = $current_balance + $delta; // Reject negative-result debits unless overdraft is explicitly allowed. if ( ! $allow_overdraft && $new_balance < 0 ) { TMDO_DB::rollback(); return array( 'ok' => false, 'error' => 'insufficient_balance', ); } // Upsert with relative increment — prevents concurrent first-credit race. // SELECT FOR UPDATE does not lock a non-existent row, so two simultaneous // first-credits both read balance=0. Using VALUES(points_balance) here means // InnoDB serialises the two INSERTs: the loser hits ON DUPLICATE KEY and // applies a relative +delta instead of overwriting with an absolute value. $upserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "INSERT INTO `{$mem_table}` (user_id, points_balance) VALUES (%d, %d) ON DUPLICATE KEY UPDATE points_balance = points_balance + VALUES(points_balance)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $user_id, $delta ) ); if ( false === $upserted ) { TMDO_DB::rollback(); TMDO_Logger::error( 'points_manager', 'transact', "Membership upsert failed for user {$user_id}: {$wpdb->last_error}" ); return array( 'ok' => false, 'error' => 'db_error', ); } // Re-read actual balance so ledger and return value are correct even when // ON DUPLICATE KEY UPDATE resolved a concurrent race on the first upsert. // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $new_balance = (int) $wpdb->get_var( $wpdb->prepare( "SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $user_id ) ); // Append ledger row. $ledger_data = array( 'user_id' => $user_id, 'delta' => $delta, 'balance_after' => $new_balance, 'reason' => $reason, 'created_at' => current_time( 'mysql' ), ); $ledger_fmt = array( '%d', '%d', '%d', '%s', '%s' ); if ( $ref_id ) { $ledger_data['ref_id'] = $ref_id; $ledger_fmt[] = '%d'; } if ( '' !== $ref_type ) { $ledger_data['ref_type'] = $ref_type; $ledger_fmt[] = '%s'; } $inserted = $wpdb->insert( $ledger_table, $ledger_data, $ledger_fmt ); if ( false === $inserted ) { TMDO_DB::rollback(); TMDO_Logger::error( 'points_manager', 'transact', "Ledger insert failed for user {$user_id}: {$wpdb->last_error}" ); return array( 'ok' => false, 'error' => 'db_error', ); } $ledger_id = (int) $wpdb->insert_id; TMDO_DB::commit(); // Notify subscribers — match Hook Bus signature: (type, id, key, value, result, op, before). do_action( 'wpdo_after_write', 'user', $user_id, 'points_balance', $new_balance, true, 'update', $current_balance ); return array( 'ok' => true, 'balance' => $new_balance, 'ledger_id' => $ledger_id, ); } catch ( \Throwable $e ) { TMDO_DB::rollback(); TMDO_Logger::error( 'points_manager', 'transact', $e->getMessage() ); return array( 'ok' => false, 'error' => 'exception', ); } } }