<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    points
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__points2()
{
    require_code('points');
}

/**
 * Credit points to a member from the system (use points_refund for refunds).
 *
 * @param  MEMBER $member_id The member to credit
 * @param  SHORT_TEXT $reason The reason for this credit in the logs
 * @param  integer $total_points The total points to credit (including gift points when applicable)
 * @param  BINARY $anonymous Whether this transaction should be anonymous
 * @param  ?boolean $send_notifications Whether to send notifications for this transaction (false: only the staff get it) (true: both the member and staff get it) (null: neither the member nor staff get it)
 * @param  BINARY $locked Whether this transaction is irreversible (Ignored / always 1 when $amount_gift_points is > 0)
 * @param  ID_TEXT $t_type An identifier to relate this transaction with other transactions of the same $type (e.g. content type)
 * @param  ID_TEXT $t_subtype An identifier to relate this transaction with other transactions of the same $type and $subtype (e.g. an action performed on the $type)
 * @param  ID_TEXT $t_type_id Some content or row ID of the specified $type
 * @param  ?TIME $time The time this transaction occurred (null: now)
 * @param  boolean $is_ranked Whether the points should also be added to the receiving member's rank points
 * @return ?AUTO_LINK The ID of the point transaction (null: no transaction took place)
 */
function points_credit_member(int $member_id, string $reason, int $total_points, int $anonymous = 0, ?bool $send_notifications = true, int $locked = 0, string $t_type = '', string $t_subtype = '', string $t_type_id = '', ?int $time = null, bool $is_ranked = true) : ?int
{
    if (is_guest($member_id)) { // Disallow debiting from the system to the system; makes no sense
        return null;
    }
    if ($total_points <= 0) { // Negative transactions are never allowed; 0 transactions are ignored.
        return null;
    }

    $id = points_transact($GLOBALS['FORUM_DRIVER']->get_guest_id(), $member_id, $reason, $total_points, 0, $anonymous, $send_notifications, $locked, $t_type, $t_subtype, $t_type_id, $time, false, $is_ranked);

    return $id;
}

/**
 * Debit points from a member to the system.
 *
 * @param  MEMBER $member_id The member to debit
 * @param  SHORT_TEXT $reason The reason for this debit in the logs
 * @param  integer $total_points The total points to debit (including gift points when applicable)
 * @param  ?integer $amount_gift_points The number of $total_points that should be debited from the gift points balance (null: use as many gift points as is available)
 * @param  BINARY $anonymous Whether this transaction should be anonymous
 * @param  ?boolean $send_notifications Whether to send notifications for this transaction (false: only the staff get it) (true: both the member and staff get it) (null: neither the member nor staff get it)
 * @param  BINARY $locked Whether this transaction is irreversible
 * @param  ID_TEXT $t_type An identifier to relate this transaction with other transactions of the same $type (e.g. content type)
 * @param  ID_TEXT $t_subtype An identifier to relate this transaction with other transactions of the same $type and $subtype (e.g. an action performed on the $type)
 * @param  ID_TEXT $t_type_id Some content or row ID of the specified $type
 * @param  ?TIME $time The time this transaction occurred (null: now)
 * @param  boolean $force Whether to force this debit to occur even if the member does not have enough points
 * @param  boolean $is_ranked Whether the points should also be removed from the member's rank points; unlike other APIs, this is false by default as most debits are purchases
 * @return ?AUTO_LINK The ID of the point transaction (null: no transaction took place)
 */
function points_debit_member(int $member_id, string $reason, int $total_points, ?int $amount_gift_points = 0, int $anonymous = 0, ?bool $send_notifications = true, int $locked = 0, string $t_type = '', string $t_subtype = '', string $t_type_id = '', ?int $time = null, bool $force = false, bool $is_ranked = false) : ?int
{
    if (is_guest($member_id)) { // Disallow debiting from the system to the system; makes no sense
        return null;
    }
    if ($total_points == 0) { // Negative transactions are never allowed; 0 transactions are ignored.
        return null;
    }

    $id = points_transact($member_id, $GLOBALS['FORUM_DRIVER']->get_guest_id(), $reason, $total_points, $amount_gift_points, $anonymous, $send_notifications, $locked, $t_type, $t_subtype, $t_type_id, $time, $force, $is_ranked);

    return $id;
}

/**
 * Transact points from one member to another. This uses gift points where possible and if enabled.
 *
 * @param  MEMBER $sending_member The ID of the member sending the points
 * @param  MEMBER $receiving_member The ID of the member receiving the points
 * @param  SHORT_TEXT $reason The reason for this transaction in the logs
 * @param  integer $total_points The total number of points to transact (includes gift points when applicable)
 * @param  ?integer $amount_gift_points The strict number of $total_points which should come from the sender's gift points balance (null: use as many gift points the sender has available)
 * @param  BINARY $anonymous Whether the sender should be hidden from those without the privilege to trace anonymous transactions
 * @param  ?boolean $send_notifications Whether to send notifications for this transaction (false: only the staff get it) (true: both the member and staff get it) (null: neither the member nor staff get it)
 * @param  BINARY $locked Whether this transaction is irreversible
 * @param  ID_TEXT $t_type An identifier to relate this transaction with other transactions of the same $type (e.g. content type)
 * @param  ID_TEXT $t_subtype An identifier to relate this transaction with other transactions of the same $type and $subtype (e.g. an action performed on the $type)
 * @param  ID_TEXT $t_type_id Some content or row ID of the specified $type
 * @param  ?TIME $time The time this transaction occurred (null: now)
 * @param  boolean $force Whether to force this transaction to occur even if the sender does not have enough points
 * @param  boolean $is_ranked Whether the points should affect rank points for the sending member if a debit, else the reciving member
 * @return ?AUTO_LINK The ID of the transaction (null: a transaction was not created)
 */
function points_transact(int $sending_member, int $receiving_member, string $reason, int $total_points, ?int $amount_gift_points = null, int $anonymous = 0, ?bool $send_notifications = true, int $locked = 0, string $t_type = '', string $t_subtype = '', string $t_type_id = '', ?int $time = null, bool $force = false, bool $is_ranked = true) : ?int
{
    // Negative transactions are never allowed; 0 transactions are ignored.
    if ($total_points <= 0) {
        return null;
    }

    // If more gift points than total points are specified, then limit it to total points
    if ($amount_gift_points !== null && $amount_gift_points > $total_points) {
        $amount_gift_points = $total_points;
    }

    // Determine how the points will be divided between gift and regular based on a member's available balance
    $points_to_process = _points_transact_calculate($sending_member, $total_points, $amount_gift_points, $force);
    if ($points_to_process === null) {
        return null; // Not enough points to proceed
    }
    list($actual_points, $actual_gift_points) = $points_to_process;

    // Log / process the transaction
    $id = _points_transact($sending_member, $receiving_member, $reason, $actual_points, $actual_gift_points, $anonymous, LEDGER_STATUS_NORMAL, $locked, null, $t_type, $t_subtype, $t_type_id, $time, ($is_ranked ? 1 : 0));

    // Run post-transaction hooks
    $hook_obs = find_all_hook_obs('systems', 'points_transact', 'Hook_points_transact__');
    foreach ($hook_obs as $name => $hook_ob) {
        if (method_exists($hook_ob, 'points_transact')) {
            $hook_ob->points_transact($id, $sending_member, $receiving_member, $reason, $total_points, $amount_gift_points, $anonymous, $send_notifications, $locked, $t_type, $t_subtype, $t_type_id, $time, $force, $is_ranked);
        }
    }

    // Process notifications
    if ($send_notifications !== null) {
        points_dispatch_notification($id, $sending_member, $receiving_member, $reason, $actual_points, $actual_gift_points, $anonymous, $send_notifications, false);
    }

    // Process usergroup promotion
    if (get_forum_type() == 'cns') {
        require_code('cns_posts_action');
        require_code('cns_posts_action2');
        cns_member_handle_promotion($receiving_member);
    }

    // Process activity syndication
    if ($anonymous == 0) {
        require_code('users2');
        if (has_actual_page_access(get_modal_user(), 'points')) {
            require_code('syndication');
            syndicate_described_activity((($receiving_member === null) || (is_guest($receiving_member))) ? 'points:_ACTIVITY_SEND_POINTS' : 'points:ACTIVITY_SEND_POINTS', $reason, integer_format($total_points), '', '_SEARCH:points:member:' . strval($receiving_member), '', '', 'points', 1, null, false, $receiving_member);
        }
    }

    return $id;
}

/**
 * Refund points from one member to another. This uses gift points where possible and if enabled.
 *
 * @param  MEMBER $sending_member The ID of the member refunding the points (e.g. the receiving_member in the original transaction)
 * @param  MEMBER $receiving_member The ID of the member receiving the refunded points (e.g. the sending_member in the original transaction)
 * @param  SHORT_TEXT $reason The reason for this refund in the logs
 * @param  integer $total_points The total number of points to refund (includes gift points when applicable)
 * @param  integer $amount_gift_points The number of $total_points which should be refunded as gift points (subtracted from gift_points_sent)
 * @param  BINARY $anonymous Whether the sender should be hidden from those without the privilege to trace anonymous transactions
 * @param  ?array $linked_ledger The database row of the points_ledger transaction being refunded by this (null: this refund is not related to any past ledger)
 * @param  ?boolean $send_notifications Whether to send notifications for this transaction (false: only the staff get it) (true: both the member and staff get it) (null: neither the member nor staff get it)
 * @param  ID_TEXT $t_type An identifier to relate this transaction with other transactions of the same $type (e.g. content type)
 * @param  ID_TEXT $t_subtype An identifier to relate this transaction with other transactions of the same $type and $subtype (e.g. an action performed on the $type)
 * @param  ID_TEXT $t_type_id Some content or row ID of the specified $type
 * @param  ?TIME $time The time this transaction occurred (null: now)
 * @param  integer $status The status to use for the record (see LEDGER_STATUS_*)
 * @return ?AUTO_LINK The ID of the transaction (null: a transaction was not created)
 */
function points_refund(int $sending_member, int $receiving_member, string $reason, int $total_points, int $amount_gift_points = 0, int $anonymous = 0, ?array $linked_ledger = null, ?bool $send_notifications = true, string $t_type = '', string $t_subtype = '', string $t_type_id = '', ?int $time = null, int $status = 3) : ?int
{
    // Do nothing if we are transacting 0 points
    if ($total_points == 0) {
        return null;
    }

    // Gift points should not be greater than total points to transact; that makes no sense
    if ($amount_gift_points !== null && $amount_gift_points > $total_points) {
        $amount_gift_points = $total_points;
    }

    // Determine how the points will be divided between gift and regular
    $points_to_process = _points_refund_calculate($total_points, $amount_gift_points);
    list($actual_points, $actual_gift_points) = $points_to_process;

    $is_ranked = 0; // Refunds by default should not affect rank points unless we are reversing a ledger which did affect rank points
    if ($linked_ledger !== null) {
        $is_ranked = $linked_ledger['is_ranked'];
    }

    // Actualise the transaction
    $id = _points_transact($sending_member, $receiving_member, $reason, $actual_points, $actual_gift_points, $anonymous, $status, (($status == LEDGER_STATUS_REVERSING) ? 1 : 0), $linked_ledger, $t_type, $t_subtype, $t_type_id, $time, $is_ranked);

    // Run post-transaction hooks
    $hook_obs = find_all_hook_obs('systems', 'points_transact', 'Hook_points_transact__');
    foreach ($hook_obs as $name => $hook_ob) {
        if (method_exists($hook_ob, 'points_refund')) {
            $hook_ob->points_refund($id, $sending_member, $receiving_member, $reason, $total_points, $amount_gift_points, $anonymous, $linked_ledger, $send_notifications, $t_type, $t_subtype, $t_type_id, $time, $status);
        }
    }

    // Link the other ledger / lock if applicable
    $map = [];
    if ($linked_ledger !== null) {
        $map['linked_ledger_id'] = $id;
    }
    if ($status == LEDGER_STATUS_REVERSING) {
        $map['status'] = LEDGER_STATUS_REVERSED;
        $map['locked'] = 1;
    }
    if (count($map) > 0) {
        $GLOBALS['SITE_DB']->query_update('points_ledger', $map, ['id' => $linked_ledger['id']], '', 1);
    }

    // Process notifications
    if ($send_notifications !== null) {
        points_dispatch_notification($id, $sending_member, $receiving_member, $reason, $actual_points, $actual_gift_points, $anonymous, $send_notifications, true);
    }

    // Process usergroup promotion
    if (get_forum_type() == 'cns') {
        require_code('cns_posts_action');
        require_code('cns_posts_action2');
        cns_member_handle_promotion($receiving_member);
    }

    return $id;
}

/**
 * Actualisation for processing the points transaction and putting it into the ledger.
 * This adds the points transaction into the ledger (officiating the points transaction) and re-calculates points CPFs and cache.
 *
 * @param  MEMBER $sending_member The member sending the points (use guest ID for a credit from the system)
 * @param  MEMBER $receiving_member The member receiving the points (use guest ID for a debit to the system)
 * @param  SHORT_TEXT $reason The reason for this transaction used in the logs
 * @param  integer $amount_points Number of regular points transacted (does not include gift points)
 * @param  integer $amount_gift_points Number of gift points transacted
 * @param  BINARY $anonymous Whether the sending_member for this transaction should be hidden
 * @param  integer $status The status of this transaction (see LEDGER_STATUS_*)
 * @param  BINARY $locked Whether this transaction is irreversible
 * @param  ?array $linked_ledger The database row of the points_ledger this one relates to (null: does not relate to another ledger)
 * @param  ID_TEXT $t_type An identifier to relate this transaction with other transactions of the same $type (e.g. content type)
 * @param  ID_TEXT $t_subtype An identifier to relate this transaction with other transactions of the same $type and $subtype (e.g. an action performed on the $type)
 * @param  ID_TEXT $t_type_id Some content or row ID of the specified $type
 * @param  ?TIME $time The time this transaction occurred (null: now)
 * @param  BINARY $is_ranked Whether the points should affect rank points for the sending member if a debit, else the reciving member
 * @return AUTO_LINK The ID of the point transaction in the ledger
 * @ignore
 */
function _points_transact(int $sending_member, int $receiving_member, string $reason, int $amount_points, int $amount_gift_points, int $anonymous = 0, int $status = 0, int $locked = 0, ?array $linked_ledger = null, string $t_type = '', string $t_subtype = '', string $t_type_id = '', ?int $time = null, int $is_ranked = 1) : int
{
    if ($time === null) {
        $time = time();
    }

    $map = [
        'sending_member' => $sending_member,
        'receiving_member' => $receiving_member,
        'amount_gift_points' => $amount_gift_points,
        'amount_points' => $amount_points,
        'date_and_time' => $time,
        'anonymous' => $anonymous,
        'linked_ledger_id' => (($linked_ledger !== null) ? $linked_ledger['id'] : null),
        'status' => $status,
        'locked' => $locked,
        't_type' => $t_type,
        't_subtype' => $t_subtype,
        't_type_id' => $t_type_id,
        'is_ranked' => $is_ranked,
    ];
    $map += insert_lang_comcode('reason', $reason, 4);
    $id = $GLOBALS['SITE_DB']->query_insert('points_ledger', $map, true);

    // Adjust points_balance
    $amount_total = $amount_points + $amount_gift_points;

    // Adjust CPFs (it is assumed we would never add a 'reversed' transaction; the transaction should have already existed and is being updated)
    switch ($status) {
        case LEDGER_STATUS_NORMAL:
            _points_adjust_cpf($sending_member, 'points_balance', -$amount_points);
            _points_adjust_cpf($receiving_member, 'points_balance', $amount_total);
            if ($is_ranked == 1) {
                if (!is_guest($receiving_member)) { // Receiving member earns the rank points
                    _points_adjust_cpf($receiving_member, 'points_rank', $amount_total);
                } else { // Sending member loses the rank points
                    _points_adjust_cpf($sending_member, 'points_rank', -$amount_total);
                }
            }
            break;
        case LEDGER_STATUS_REVERSING:
        case LEDGER_STATUS_REFUND:
            _points_adjust_cpf($sending_member, 'points_balance', -$amount_total);
            _points_adjust_cpf($receiving_member, 'points_balance', $amount_points);
            if ($is_ranked == 1) {
                if (!is_guest($sending_member)) { // Sending member loses the rank points (because they received them originally)
                    _points_adjust_cpf($sending_member, 'points_rank', -$amount_total);
                } else { // Receiving member gets back the rank points (because they lost them originally)
                    _points_adjust_cpf($receiving_member, 'points_rank', $amount_total);
                }
            }
            break;
        default: // LEDGER_STATUS_REVERSED (should never be added as a new transaction) or a non-valid status
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    // Flush cache
    points_flush_runtime_cache($sending_member);
    points_flush_runtime_cache($receiving_member);

    return $id;
}

/**
 * Calculate how many points will be transacted from the sender to the recipient.
 *
 * @param  MEMBER $sending_member The ID of the member sending the points
 * @param  integer $total_points The total number of points to transact (includes gift points when applicable)
 * @param  ?integer $amount_gift_points The strict number of $total_points which should come from the sender's gift points (null: use what gift points the sender has available)
 * @param  boolean $force Whether this transaction is being forced (true: this function will never return null)
 * @return ?array Duple containing the number of regular points (key 0) and number of gift points (key 1) that should be transacted from the sender (null: not enough points to satisfy the conditions specified)
 */
function _points_transact_calculate(int $sending_member, int $total_points, ?int $amount_gift_points = null, bool $force = false) : ?array
{
    $actual_points = $total_points;
    $actual_gift_points = ($amount_gift_points !== null) ? $amount_gift_points : 0;

    // Calculate how many gift points to use
    if (get_option('enable_gift_points') == '1') {
        if (!is_guest($sending_member)) {
            if (($amount_gift_points === null) || ($amount_gift_points > 0)) {
                $sender_gift_points_balance = gift_points_balance($sending_member);
                if ($amount_gift_points !== null && $sender_gift_points_balance < $amount_gift_points) {
                    if (!$force) {
                        return null; // Not enough points to proceed
                    }

                    // We are forcing the transaction; just take what we can from gift points and delegate the rest to regular points
                    $actual_points += ($amount_gift_points - $sender_gift_points_balance);
                    $actual_gift_points = $sender_gift_points_balance;
                }
                if ($amount_gift_points === null) {
                    $actual_gift_points = min($total_points, $sender_gift_points_balance);
                }
            } else {
                $actual_gift_points = 0;
            }
        }
        $actual_points -= $actual_gift_points;
    } else {
        $actual_points = $total_points;
        $actual_gift_points = 0;
    }

    // Make sure the member has enough points for the rest (unless we are forcing this transaction)
    if (!$force && !is_guest($sending_member) && ($actual_points > 0)) { // If actual_points <=0, transaction should pass even if balance is <=0
        $points_balance = points_balance($sending_member);
        if ($points_balance < $actual_points) {
            return null; // Not enough points to proceed
        }
    }

    return [$actual_points, $actual_gift_points];
}

/**
 * Calculate how many points will be refunded to the recipient.
 *
 * @param  integer $total_points The total number of points to refund (includes gift points when applicable)
 * @param  integer $amount_gift_points The strict number of $total_points which should be refunded
 * @return array Duple containing the number of regular points (key 0) and number of gift points (key 1) that should be refunded
 */
function _points_refund_calculate(int $total_points, int $amount_gift_points = 0) : array
{
    $actual_points = $total_points;
    $actual_gift_points = $amount_gift_points;

    // Calculate how many gift points to use
    if (get_option('enable_gift_points') == '1') {
        $actual_points = $total_points - $amount_gift_points;
        $actual_gift_points = $amount_gift_points;
    } else {
        $actual_points = $total_points;
        $actual_gift_points = 0;
    }

    return [$actual_points, $actual_gift_points];
}

/**
 * Fully reverse all points transactions matching the provided criteria.
 * You must provide at least one criteria to prevent accidental reversal of the entire ledger.
 *
 * @param  ?boolean $send_notifications Whether to send notifications for these transaction reversals (false: only the staff get it) (true: both the member and staff get it) (null: neither the member nor staff get it)
 * @param  ?MEMBER $sending_member Filter by member who sent the points (null: do not filter)
 * @param  ?MEMBER $receiving_member Filter by member who received the points (null: do not filter)
 * @param  ID_TEXT $t_type Filter by content type (blank: do not filter)
 * @param  ID_TEXT $t_subtype Filter by subtype / action (blank: do not filter)
 * @param  ID_TEXT $t_type_id Filter by content ID (blank: do not filter)
 * @return array An array of duples containing the ID of the original transaction, and null if the transaction could not be reversed or an array tuple of ID of the new transaction reversing, a boolean whether an escrow was also cancelled during the reversal, and an array of additional point transactions that were reversed because they had a reverse_link to this one
 */
function points_transactions_reverse_all(?bool $send_notifications = false, ?int $sending_member = null, ?int $receiving_member = null, string $t_type = '', string $t_subtype = '', string $t_type_id = '') : array
{
    // Build our where query
    $where = [];
    if ($sending_member !== null) {
        $where['sending_member'] = $sending_member;
    }
    if ($receiving_member !== null) {
        $where['receiving_member'] = $receiving_member;
    }
    if ($t_type != '') {
        $where['t_type'] = $t_type;
    }
    if ($t_subtype != '') {
        $where['t_subtype'] = $t_subtype;
    }
    if ($t_type_id != '') {
        $where['t_type_id'] = $t_type_id;
    }

    // Prevent matching all points ledger records
    if (count($where) == 0) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    $where['locked'] = 0;

    $out = [];

    $rows = $GLOBALS['SITE_DB']->query_select('points_ledger', ['id'], $where);
    foreach ($rows as $row) {
        $out[] = [$row['id'], points_transaction_reverse($row['id'], $send_notifications, true)];
    }

    return $out;
}

/**
 * Fully reverse a points transaction.
 * This creates a new transaction that reverses the old one (e.g. refunding points), links the two transactions, and locks them.
 *
 * @param  AUTO_LINK $id The ID of the transaction ledger to reverse
 * @param  ?boolean $send_notifications Whether to send notifications for this transaction (false: only the staff get it) (true: both the member and staff get it) (null: neither the member nor staff get it)
 * @param  boolean $fail_ok Whether having a missing resource or condition preventing transaction reversal should not warn_exit
 * @return ?array Tuple; the ID of the new transaction reversing, a boolean whether an escrow was also cancelled during the reversal, and an array of additional point transactions that were reversed because they had a reverse_link to this one (null: the transaction was not reversed and $fail_ok was true)
 */
function points_transaction_reverse(int $id, ?bool $send_notifications = true, bool $fail_ok = false) : ?array
{
    require_lang('points');

    $rows = $GLOBALS['SITE_DB']->query_select('points_ledger', ['*'], ['id' => $id], '', 1);
    if (!array_key_exists(0, $rows)) {
        if ($fail_ok) {
            return null;
        }
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }
    $myrow = $rows[0];

    // Cannot reverse locked transactions or transactions already reversed
    if (($myrow['locked'] == 1) || ($myrow['status'] == LEDGER_STATUS_REVERSED) || ($myrow['status'] == LEDGER_STATUS_REVERSING)) {
        if ($fail_ok) {
            return null;
        }
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    $amount_gift_points = $myrow['amount_gift_points'];
    $total_points = $myrow['amount_points'];
    $sending_member = $myrow['sending_member'];
    $receiving_member = $myrow['receiving_member'];
    $_reason = get_translated_tempcode('points_ledger', $myrow, 'reason');
    $reason = do_lang_tempcode('REVERSED_TRANSACTION', $_reason);

    // Process the reversal as a new 'reversing' transaction
    $new_record = points_refund($receiving_member, $sending_member, $reason->evaluate(), $total_points + $amount_gift_points, $amount_gift_points, $myrow['anonymous'], $myrow, $send_notifications, '', '', '', null, LEDGER_STATUS_REVERSING);

    // Log it
    log_it('REVERSE_TRANSACTION', strval($id), get_translated_text($myrow['reason']));

    // Also cancel active escrows relating to this transaction, but do not create refund transactions for them (the reversal already refunded them)
    $cancelled_escrow = false;
    require_code('points_escrow');
    $escrows = $GLOBALS['SITE_DB']->query_select('escrow', ['*'], ['original_points_ledger_id' => $id]);
    foreach ($escrows as $escrow) {
        if ($escrow['status'] < ESCROW_STATUS_PENDING) {
            continue;
        }
        $reason = do_lang_tempcode('ESCROW_CANCELLED_LEDGER_REVERSED');
        cancel_escrow($escrow['id'], get_member(), $reason->evaluate(), $escrow, false);
        $cancelled_escrow = true;
    }

    // Also reverse any transactions with reverse_link to this one (cyclic links are averted by the additional status/locked check towards the beginning of this function)
    $also_reverse = points_transactions_reverse_all(true, null, null, 'reverse_link', '', strval($id));
    $also_reversed = [];
    foreach ($also_reverse as $row2) {
        $also_reversed[] = $row2[0];
    }

    return [$new_record, $cancelled_escrow, $also_reversed];
}

/**
 * Adjust a custom profile field for points and update the cache.
 * This API method should normally never be called outside _points_transact as doing so violates the tenets of the points system.
 *
 * @param  MEMBER $member_id The member to adjust
 * @param  ID_TEXT $field_name The custom field to adjust
 * @set points_balance points_rank
 * @param  integer $adjustment By how much to adjust the field (use a negative number to subtract)
 * @ignore
 */
function _points_adjust_cpf(int $member_id, string $field_name, int $adjustment) : void
{
    // Check for invalid field
    if (!in_array($field_name, ['points_balance', 'points_rank'])) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    // Skip if there is nothing to change
    if ($adjustment == 0) {
        return;
    }

    // Skip guests
    if (is_guest($member_id)) {
        return;
    }

    $values = $GLOBALS['FORUM_DRIVER']->get_custom_fields($member_id);
    if ($values === null) {
        $values = [];
    }
    $old = array_key_exists($field_name, $values) ? @intval($values[$field_name]) : 0;
    $new = max(-2147483648, min(2147483647, $old + $adjustment)); // TODO: #3046 in tracker
    $GLOBALS['FORUM_DRIVER']->set_custom_field($member_id, $field_name, strval($new));

    // Update cache
    global $POINT_INFO_CACHE;
    if ((array_key_exists($member_id, $POINT_INFO_CACHE)) && (array_key_exists($field_name, $POINT_INFO_CACHE[$member_id]))) {
        $POINT_INFO_CACHE[$member_id][$field_name] = $new;
    }
}

/**
 * Dispatch a points transaction notification to staff and optionally to the members involved.
 *
 * @param  AUTO_LINK $id The ID of the points ledger record pertaining to this notification
 * @param  MEMBER $sending_member The member sending points
 * @param  MEMBER $receiving_member The member receiving points
 * @param  SHORT_TEXT $reason The reason for this transaction
 * @param  integer $actual_points The actual number of regular points transacted
 * @param  integer $actual_gift_points The actual number of gift points transacted
 * @param  BINARY $anonymous Whether the sender should be kept hidden
 * @param  boolean $send_member_notifications Also send the notification to the members involved in this transaction
 * @param  boolean $is_refund Whether the transaction was a refund
 */
function points_dispatch_notification(int $id, int $sending_member, int $receiving_member, string $reason, int $actual_points, int $actual_gift_points, int $anonymous = 0, bool $send_member_notifications = true, bool $is_refund = false)
{
    require_code('notifications');
    require_lang('points');

    $your_username = $GLOBALS['FORUM_DRIVER']->get_username($sending_member);
    $your_displayname = $GLOBALS['FORUM_DRIVER']->get_username($sending_member, true);
    $total_points = $actual_points + $actual_gift_points;

    // Mask sender on anonymous transactions
    if ($anonymous == 1) {
        $_your_username = do_lang('NOTIFICATION_POINTS_TRANSACTION_ANON_L');
        $_your_displayname = do_lang('NOTIFICATION_POINTS_TRANSACTION_ANON_L');
    } else {
        $_your_username = $your_username;
        $_your_displayname = $your_displayname;
    }

    $their_username = $GLOBALS['FORUM_DRIVER']->get_username($receiving_member, false, USERNAME_DEFAULT_ERROR);
    $their_displayname = $GLOBALS['FORUM_DRIVER']->get_username($receiving_member, true, USERNAME_DEFAULT_ERROR);

    if ($send_member_notifications) {
        // Sender
        if (!is_guest($sending_member)) {
            $transaction_type = is_guest($receiving_member) ? do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_DEBITED_L') : do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SENT_L');
            $url = points_url($sending_member, true);
            $to_l = '';
            if (!is_guest($receiving_member)) {
                $to_l = do_lang('NOTIFICATION_POINTS_TRANSACTION_TO_L', comcode_escape($their_displayname));
            }
            $message_raw = do_notification_lang(
                is_guest($receiving_member) ? 'NOTIFICATION_POINTS_TRANSACTION_GUEST' : 'NOTIFICATION_POINTS_TRANSACTION',
                $transaction_type,
                (($actual_gift_points !== 0) && (!$is_refund)) ? do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_GIFT_POINTS_L', comcode_escape(integer_format($actual_points)), comcode_escape(integer_format($actual_gift_points))) : do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_POINTS_L', comcode_escape(integer_format($total_points))),
                [
                    comcode_escape($to_l),
                    comcode_escape($reason),
                    comcode_escape($url->evaluate()),
                    do_lang('_POINTS', comcode_escape($your_displayname)),
                ],
                get_lang($sending_member)
            );
            $subject = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SUBJECT', $transaction_type);
            // Leave this comment: Any code overrides attaching additional information to the sender notification should go here.
            dispatch_notification('points_transaction', null, $subject->evaluate(get_lang($sending_member)), $message_raw->evaluate(get_lang($sending_member)), [$sending_member], A_FROM_SYSTEM_UNPRIVILEGED);
        }

        // Recipient
        if (!is_guest($receiving_member)) {
            $transaction_type = new Tempcode();
            if ($is_refund) {
                $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_REFUNDED_L');
            } elseif (is_guest($sending_member)) {
                $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_CREDITED_L');
            } else {
                $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_RECEIVED_L');
            }
            $url = points_url($receiving_member, true);
            $from_l = '';
            if (!is_guest($receiving_member)) {
                $from_l = do_lang('NOTIFICATION_POINTS_TRANSACTION_FROM_L', comcode_escape($_your_displayname));
            }
            $message_raw = do_notification_lang(
                is_guest($sending_member) ? 'NOTIFICATION_POINTS_TRANSACTION_GUEST' : 'NOTIFICATION_POINTS_TRANSACTION',
                $transaction_type,
                (($is_refund) && ($actual_gift_points !== 0)) ? do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_GIFT_POINTS_L', comcode_escape(integer_format($actual_points)), comcode_escape(integer_format($actual_gift_points))) : do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_POINTS_L', comcode_escape(integer_format($total_points))),
                [
                    comcode_escape($from_l),
                    comcode_escape($reason),
                    comcode_escape($url->evaluate()),
                    do_lang('_POINTS', comcode_escape($their_displayname)),
                ],
                get_lang($receiving_member)
            );
            $subject = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SUBJECT', $transaction_type);
            // Leave this comment: Any code overrides attaching additional information to the recipient notification should go here.
            dispatch_notification('points_transaction', null, $subject->evaluate(get_lang($receiving_member)), $message_raw->evaluate(get_lang($receiving_member)), [$receiving_member], A_FROM_SYSTEM_UNPRIVILEGED);
        }
    }

    // Staff notifications
    $transaction_type = new Tempcode();
    $subject = new Tempcode();
    $primary_member = '';
    $secondary_member = '';
    if ($is_refund) {
        $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_REFUNDED_L');
        $subject = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SUBJECT_STAFF', escape_html($their_username), $transaction_type);
        $primary_member = $their_username;
    } elseif (is_guest($sending_member)) {
        $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_CREDITED_L');
        $subject = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SUBJECT_STAFF', escape_html($their_username), $transaction_type);
        $primary_member = $their_username;
    } elseif (is_guest($receiving_member)) {
        $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_DEBITED_L');
        $subject = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SUBJECT_STAFF', escape_html($_your_username), $transaction_type);
        $primary_member = $_your_username;
    } else {
        $transaction_type = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_RECEIVED_L');
        $subject = do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_SUBJECT_STAFF', escape_html($their_username), $transaction_type);
        $primary_member = $their_username;
        $secondary_member = do_lang('NOTIFICATION_POINTS_TRANSACTION_FROM_L', escape_html($_your_username));
    }
    $url = build_url(['page' => 'admin_points', 'type' => 'view', 'id' => $id], get_module_zone('admin_points'), [], false, false, true);
    $message_raw = do_notification_lang(
        ($secondary_member != '') ? 'NOTIFICATION_POINTS_TRANSACTION_STAFF' : 'NOTIFICATION_POINTS_TRANSACTION_STAFF_GUEST',
        $transaction_type,
        comcode_escape($primary_member),
        [
            (($is_refund) && ($actual_gift_points !== 0)) ? do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_GIFT_POINTS_L', comcode_escape(integer_format($actual_points)), comcode_escape(integer_format($actual_gift_points))) : do_lang_tempcode('NOTIFICATION_POINTS_TRANSACTION_POINTS_L', comcode_escape(integer_format($total_points))),
            comcode_escape($secondary_member),
            $url->evaluate(),
            comcode_escape($reason),
        ]
    );
    // Leave this comment: Any code overrides attaching additional information to the staff notification should go here.
    dispatch_notification('points_transaction_staff', null, $subject->evaluate(get_site_default_lang()), $message_raw->evaluate(get_site_default_lang()), null, A_FROM_SYSTEM_UNPRIVILEGED);
}
