prefix . 'chargeforms_subscriptions'; } /** * Stateless proof that whoever holds this token was handed it by us — * for the "Manage your subscription" link in a payment receipt email * (Subscription_Portal::handle()), which an anonymous payer (not a * logged-in WP user) must be able to use weeks after the email was * sent. wp_hash() derives from WordPress's own AUTH_KEY/AUTH_SALT * (never stored anywhere, unique per install) — same trust primitive * core itself uses for auth cookies, so no new secret or DB column is * needed, and the token has no separate expiry to manage (it stays * valid as long as the subscription row does). */ public static function sign_manage_token( $id ) { return substr( wp_hash( 'chargeforms_subscription_manage|' . (int) $id, 'auth' ), 0, 32 ); } public static function verify_manage_token( $id, $token ) { return hash_equals( self::sign_manage_token( $id ), (string) $token ); } public static function create( array $data ) { global $wpdb; $row = [ 'form_id' => (int) ( $data['form_id'] ?? 0 ), 'entry_id' => (int) ( $data['entry_id'] ?? 0 ), 'gateway' => sanitize_key( $data['gateway'] ?? 'stripe' ), 'gateway_subscription_id' => sanitize_text_field( $data['gateway_subscription_id'] ?? '' ), 'gateway_customer_id' => sanitize_text_field( $data['gateway_customer_id'] ?? '' ), 'plan_interval' => in_array( $data['plan_interval'] ?? '', [ 'month', 'year' ], true ) ? $data['plan_interval'] : 'month', 'amount' => (float) ( $data['amount'] ?? 0 ), 'currency' => strtolower( sanitize_text_field( $data['currency'] ?? 'usd' ) ), 'status' => in_array( $data['status'] ?? '', self::STATUSES, true ) ? $data['status'] : 'incomplete', 'trial_end' => $data['trial_end'] ?? null, 'current_period_end' => $data['current_period_end'] ?? null, 'cancel_at_period_end' => ! empty( $data['cancel_at_period_end'] ) ? 1 : 0, 'created_at' => current_time( 'mysql', true ), 'updated_at' => current_time( 'mysql', true ), ]; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. self::table(), $row, [ '%d', '%d', '%s', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ] ); return $wpdb->insert_id; } public static function get( $id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::table(), $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. return $row ? self::hydrate( $row ) : null; } /** The lookup every webhook event and the reconciliation cron actually run — one row per (gateway, gateway_subscription_id), enforced by a unique key in the schema (see Install::activate()). */ public static function get_by_gateway_id( $gateway, $gateway_subscription_id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. 'SELECT * FROM %i WHERE gateway = %s AND gateway_subscription_id = %s LIMIT 1', self::table(), sanitize_key( $gateway ), $gateway_subscription_id ), ARRAY_A ); return $row ? self::hydrate( $row ) : null; } public static function for_entry( $entry_id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE entry_id = %d ORDER BY id DESC LIMIT 1', self::table(), $entry_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. return $row ? self::hydrate( $row ) : null; } public static function for_form( $form_id ) { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE form_id = %d ORDER BY created_at DESC', self::table(), $form_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. return array_map( [ __CLASS__, 'hydrate' ], $rows ?: [] ); } /** * Whitelisted column update — every caller (webhook handler, cancel * action, reconciler) passes only the columns it actually knows * changed rather than a full row, so one event type updating `status` * can never accidentally null out `trial_end`/`current_period_end` * that a DIFFERENT event type is responsible for. */ public static function update( $id, array $data ) { global $wpdb; $allowed = [ 'status' => '%s', 'gateway_customer_id' => '%s', 'trial_end' => '%s', 'current_period_end' => '%s', 'cancel_at_period_end' => '%d', 'amount' => '%f', 'plan_interval' => '%s', ]; $row = []; $formats = []; foreach ( $allowed as $column => $format ) { if ( array_key_exists( $column, $data ) ) { $row[ $column ] = $data[ $column ]; $formats[] = $format; } } if ( ! $row ) { return false; } $row['updated_at'] = current_time( 'mysql', true ); $formats[] = '%s'; return false !== $wpdb->update( self::table(), $row, [ 'id' => (int) $id ], $formats, [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } /** * Subscriptions the reconciliation cron should re-check: still in a * "gateway could still be moving this forward" status, but not * touched by any webhook (or this cron) recently — the actual safety * net against a missed/failed webhook delivery. Capped via `$limit` * so one run can never fire an unbounded number of Stripe API calls. */ public static function stale_active( $older_than_hours, $limit ) { global $wpdb; $statuses = [ 'incomplete', 'trialing', 'active', 'past_due' ]; $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( (int) $older_than_hours * HOUR_IN_SECONDS ) ); $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. "SELECT * FROM %i WHERE status IN ({$placeholders}) AND updated_at < %s ORDER BY updated_at ASC LIMIT %d", array_merge( [ self::table() ], $statuses, [ $cutoff, (int) $limit ] ) ), ARRAY_A ); return array_map( [ __CLASS__, 'hydrate' ], $rows ?: [] ); } private static function hydrate( array $row ) { $row['id'] = (int) $row['id']; $row['form_id'] = (int) $row['form_id']; $row['entry_id'] = (int) $row['entry_id']; $row['amount'] = (float) $row['amount']; $row['cancel_at_period_end'] = (bool) $row['cancel_at_period_end']; return $row; } }