# better-payment/trunk/includes/Models/SubscriptionRelationModel.php

Better Payment – Instant Payments, Donations, Fundraising with Subscriptions &amp; More, version trunk. 421 lines.

- Page: https://pluginprobe.com/plugins/better-payment/trunk/code/includes/Models/SubscriptionRelationModel.php
- Raw: https://pluginprobe.com/plugins/better-payment/trunk/raw/includes/Models/SubscriptionRelationModel.php
- Modified: 2026-08-19T10:06:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/better-payment/trunk/code/includes/Models/SubscriptionRelationModel.php#L10-L20`.

```php
<?php

namespace Better_Payment\Lite\Models;

/**
 * Exit if accessed directly
 */
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * E-commerce subscription <-> order relation model.
 *
 * Owns the `{prefix}better_payment_subscription_order` table (created in
 * Installer::get_schema()). Every row links an e-commerce subscription to
 * one of its orders and names the integration that owns it in `source`
 * ('woo', 'fluentcart', 'surecart', ...), so several e-commerce integrations
 * can share the table without their ids colliding.
 *
 * Contract: **e-commerce subscription data only.** Better Payment's own
 * (Elementor/campaign) subscription payments live in the `better_payment`
 * transactions table and must never be written here.
 *
 * What a row means:
 * - `subscription_id` — the integration's subscription identifier. For the
 *   WooCommerce module that is the parent (subscription) order id.
 * - `order_id`        — the related order: the parent order itself for a
 *   `new` row, a renewal order for a `renew` row.
 * - `order_item_id`   — the subscription line item on that order (0 when the
 *   integration has no item-level ids).
 *
 * @since 2.4.0
 */
class SubscriptionRelationModel {

    /**
     * Relation types (row = the order that STARTED the subscription vs. a
     * renewal of it).
     */
    const TYPE_NEW   = 'new';
    const TYPE_RENEW = 'renew';

    /**
     * Known e-commerce sources. The column accepts any sanitized slug (a new
     * integration should not need a core edit to record rows) — these consts
     * exist so bundled integrations never typo their own name.
     */
    const SOURCE_WOO        = 'woo';
    const SOURCE_FLUENTCART = 'fluentcart';
    const SOURCE_SURECART   = 'surecart';

    /**
     * The full table name.
     *
     * @return string
     */
    public static function get_table_name() {
        global $wpdb;

        return $wpdb->prefix . 'better_payment_subscription_order';
    }

    /**
     * Relation types as key => translated label.
     *
     * @return array
     */
    public static function types() {
        $types = array(
            self::TYPE_NEW   => __( 'New Subscription Order', 'better-payment' ),
            self::TYPE_RENEW => __( 'Renewal Order', 'better-payment' ),
        );

        /**
         * Filters the subscription order relation types.
         *
         * @since 2.4.0
         *
         * @param array $types Type key => translated label.
         */
        return apply_filters( 'better_payment/subscription/order_relation_types', $types );
    }

    /**
     * Known sources as key => label, for admin UI listings.
     *
     * @return array
     */
    public static function known_sources() {
        $sources = array(
            self::SOURCE_WOO        => __( 'WooCommerce', 'better-payment' ),
            self::SOURCE_FLUENTCART => __( 'FluentCart', 'better-payment' ),
            self::SOURCE_SURECART   => __( 'SureCart', 'better-payment' ),
        );

        /**
         * Filters the known e-commerce subscription sources.
         *
         * @since 2.4.0
         *
         * @param array $sources Source slug => label.
         */
        return apply_filters( 'better_payment/subscription/order_relation_sources', $sources );
    }

    /**
     * Pure: clamp a relation type to the registered set ('' when unknown —
     * an unknown type must fail record(), not be silently rewritten).
     *
     * @param mixed $type Raw value.
     * @return string
     */
    public static function sanitize_type( $type ) {
        $type = sanitize_key( (string) $type );

        return array_key_exists( $type, self::types() ) ? $type : '';
    }

    /**
     * Pure: sanitize a source slug. Any non-empty sanitized slug is accepted
     * (a third-party integration must be able to record its own source
     * without filtering a whitelist first); '' when unusable. Capped at the
     * column width.
     *
     * @param mixed $source Raw value.
     * @return string
     */
    public static function sanitize_source( $source ) {
        $source = sanitize_key( (string) $source );

        return substr( $source, 0, 50 );
    }

    /**
     * Record one subscription <-> order relation. Idempotent: an identical
     * row (same subscription, order, type and source) is never duplicated.
     *
     * @param array $args {
     *     @type int    $subscription_id Integration's subscription id (> 0).
     *     @type int    $order_id        Related order id (> 0).
     *     @type int    $order_item_id   Subscription line item id (optional, default 0).
     *     @type string $type            self::TYPE_NEW | self::TYPE_RENEW.
     *     @type string $source          E-commerce source slug (e.g. self::SOURCE_WOO).
     * }
     * @return int Row id (the existing row's id when already recorded), 0 on invalid input or failure.
     */
    public static function record( $args ) {
        global $wpdb;

        $subscription_id = isset( $args['subscription_id'] ) ? absint( $args['subscription_id'] ) : 0;
        $order_id        = isset( $args['order_id'] ) ? absint( $args['order_id'] ) : 0;
        $order_item_id   = isset( $args['order_item_id'] ) ? absint( $args['order_item_id'] ) : 0;
        $type            = self::sanitize_type( isset( $args['type'] ) ? $args['type'] : '' );
        $source          = self::sanitize_source( isset( $args['source'] ) ? $args['source'] : '' );

        if ( $subscription_id <= 0 || $order_id <= 0 || '' === $type || '' === $source ) {
            return 0;
        }

        $existing = self::find_id( $subscription_id, $order_id, $type, $source );

        if ( $existing > 0 ) {
            return $existing;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
        $inserted = $wpdb->insert(
            self::get_table_name(),
            array(
                'subscription_id' => $subscription_id,
                'order_id'        => $order_id,
                'order_item_id'   => $order_item_id,
                'type'            => $type,
                'source'          => $source,
            ),
            array( '%d', '%d', '%d', '%s', '%s' )
        );

        return $inserted ? (int) $wpdb->insert_id : 0;
    }

    /**
     * The id of an already-recorded identical relation row (0 = none).
     *
     * @param int    $subscription_id Subscription id.
     * @param int    $order_id        Order id.
     * @param string $type            Relation type.
     * @param string $source          Source slug.
     * @return int
     */
    public static function find_id( $subscription_id, $order_id, $type, $source ) {
        global $wpdb;

        $table = self::get_table_name();

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table owned by this model; name is not user input.
        return (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT id FROM {$table} WHERE subscription_id = %d AND order_id = %d AND type = %s AND source = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                absint( $subscription_id ),
                absint( $order_id ),
                (string) $type,
                (string) $source
            )
        );
    }

    /**
     * All relation rows of one subscription, oldest first. Optionally
     * restricted to one source.
     *
     * @param int    $subscription_id Subscription id.
     * @param string $source          Optional source slug ('' = any).
     * @return object[] Rows (id, subscription_id, order_id, order_item_id, type, source).
     */
    public static function get_orders( $subscription_id, $source = '' ) {
        global $wpdb;

        $table  = self::get_table_name();
        $source = self::sanitize_source( $source );

        if ( '' !== $source ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM {$table} WHERE subscription_id = %d AND source = %s ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    absint( $subscription_id ),
                    $source
                )
            );
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM {$table} WHERE subscription_id = %d ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    absint( $subscription_id )
                )
            );
        }

        return is_array( $rows ) ? $rows : array();
    }

    /**
     * The subscription an order belongs to (0 = none recorded). Optionally
     * restricted to one source — pass it when the caller knows the
     * integration, since order ids from different platforms may collide.
     *
     * @param int    $order_id Order id.
     * @param string $source   Optional source slug ('' = any).
     * @return int
     */
    public static function get_subscription_id( $order_id, $source = '' ) {
        global $wpdb;

        $table  = self::get_table_name();
        $source = self::sanitize_source( $source );

        if ( '' !== $source ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
            return (int) $wpdb->get_var(
                $wpdb->prepare(
                    "SELECT subscription_id FROM {$table} WHERE order_id = %d AND source = %s ORDER BY id ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    absint( $order_id ),
                    $source
                )
            );
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
        return (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT subscription_id FROM {$table} WHERE order_id = %d ORDER BY id ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                absint( $order_id )
            )
        );
    }

    /**
     * Paginated list of distinct e-commerce subscriptions, newest first.
     *
     * One entry per (subscription_id, source) pair — a subscription with ten
     * renewal orders is still one row. `renewal_orders` counts its recorded
     * renewals. Ordered by subscription_id DESC (for the bundled integrations
     * the id is a post/order id, so descending ≈ newest subscription first).
     *
     * @param array $args paged, per_page
     * @return array { subscriptions: object[], total: int, pages: int, page: int, per_page: int }
     * @since 2.4.0
     */
    public static function get_subscriptions_paginated( $args = array() ) {
        global $wpdb;

        $defaults = array(
            'paged'    => 1,
            'per_page' => 20,
        );
        $args     = wp_parse_args( $args, $defaults );
        $table    = self::get_table_name();
        $paged    = max( 1, intval( $args['paged'] ) );
        $per_page = max( 1, intval( $args['per_page'] ) );
        $offset   = ( $paged - 1 ) * $per_page;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table owned by this model; name is not user input.
        $total = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM ( SELECT subscription_id FROM {$table} GROUP BY subscription_id, source ) AS grouped" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT subscription_id, source,
                        SUM( CASE WHEN type = %s THEN 1 ELSE 0 END ) AS renewal_orders
                 FROM {$table}
                 GROUP BY subscription_id, source
                 ORDER BY subscription_id DESC, source ASC
                 LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                self::TYPE_RENEW,
                $per_page,
                $offset
            )
        );

        return array(
            'subscriptions' => is_array( $rows ) ? $rows : array(),
            'total'         => $total,
            'pages'         => $total > 0 ? (int) ceil( $total / $per_page ) : 1,
            'page'          => $paged,
            'per_page'      => $per_page,
        );
    }

    /**
     * Every distinct e-commerce subscription as a grouped row — the same
     * shape and order get_subscriptions_paginated() returns, without the
     * LIMIT.
     *
     * Exists for the admin list's status/search filters, which cannot become
     * a WHERE clause: the fields they match on (status, customer, product)
     * are not in this table, they arrive when each row is hydrated by its
     * integration. Filtering therefore has to hydrate the whole set first,
     * and this is the query that yields it. Callers that are NOT filtering
     * must keep using get_subscriptions_paginated() — the fast path exists
     * precisely so the default pageview never pays for this.
     *
     * The GROUP BY and ORDER BY must stay identical to
     * get_subscriptions_paginated()'s, or a filtered list would come back in
     * a different order from an unfiltered one.
     *
     * @return object[] Rows with subscription_id, source, renewal_orders.
     * @since 2.4.0
     */
    public static function get_subscription_groups() {
        global $wpdb;

        $table = self::get_table_name();

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT subscription_id, source,
                        SUM( CASE WHEN type = %s THEN 1 ELSE 0 END ) AS renewal_orders
                 FROM {$table}
                 GROUP BY subscription_id, source
                 ORDER BY subscription_id DESC, source ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                self::TYPE_RENEW
            )
        );

        return is_array( $rows ) ? $rows : array();
    }

    /**
     * Every distinct e-commerce subscription as (subscription_id, source)
     * pairs — the unpaginated id list behind get_subscriptions_paginated().
     * Used for whole-table aggregates (the admin tab's summary counts).
     *
     * @return object[] Rows with subscription_id + source.
     * @since 2.4.0
     */
    public static function get_distinct_subscriptions() {
        global $wpdb;

        $table = self::get_table_name();

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
        $rows = $wpdb->get_results(
            "SELECT subscription_id, source FROM {$table} GROUP BY subscription_id, source" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        );

        return is_array( $rows ) ? $rows : array();
    }

    /**
     * Delete every relation row of one subscription (housekeeping — e.g. when
     * an integration erases a subscription). Optionally one source only.
     *
     * @param int    $subscription_id Subscription id.
     * @param string $source          Optional source slug ('' = any).
     * @return int Rows deleted.
     */
    public static function delete_for_subscription( $subscription_id, $source = '' ) {
        global $wpdb;

        $where  = array( 'subscription_id' => absint( $subscription_id ) );
        $format = array( '%d' );
        $source = self::sanitize_source( $source );

        if ( '' !== $source ) {
            $where['source'] = $source;
            $format[]        = '%s';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table owned by this model.
        $deleted = $wpdb->delete( self::get_table_name(), $where, $format );

        return is_numeric( $deleted ) ? (int) $deleted : 0;
    }
}

```
