isset( $stored['price_leos'] ) ? max( 0, (int) $stored['price_leos'] ) : self::FALLBACK_PRICE_LEOS, 'offer_uses' => isset( $stored['offer_uses'] ) ? max( 1, (int) $stored['offer_uses'] ) : self::FALLBACK_USES, 'offer_ttl_seconds' => isset( $stored['offer_ttl_seconds'] ) ? max( 1, (int) $stored['offer_ttl_seconds'] ) : self::FALLBACK_TTL_SECONDS, 'permits' => self::sanitize_string_list( $stored['permits'] ?? null, self::FALLBACK_PERMITS ), 'prohibits' => self::sanitize_string_list( $stored['prohibits'] ?? null, self::FALLBACK_PROHIBITS ), 'paymentType' => self::sanitize_payment_type( $stored['paymentType'] ?? null ), ]; } /** * Has the publisher edited the site-wide defaults at all? Returns true iff * any field differs from the platform fallback. KPI denominator: a publisher * who registers without ever opening Settings → Offers does NOT count as * "priced" even though `register_post()` still emits `offer.priced` with * `source: 'fallback'` for the audit trail. */ public static function site_defaults_were_set(): bool { return self::source_for( self::site_defaults() ) === 'site-default'; } /** * Resolve the canonical terms blob for a post. * * Layer order: per-post `rsl_terms` → site-wide `prweb_offer_defaults` → platform fallback. * The `_source` key names which layer answered (per-post / site-default / fallback) so * the audit-event emit can carry it through. Strip `_source` before persisting * back into `rsl_terms` JSON. * * @return array{ * template:string, * label:string, * scope:string, * permit:string, * permits:string[], * prohibit:string, * prohibits:string[], * paymentType:string, * price_leos:int, * offer_uses:int, * offer_ttl_seconds:int, * daily_cap_cents:int, * user_class:string, * geo:string[], * _source:string * } */ public static function resolve_for_post( int $post_id ): array { $row = self::load_metadata_row( $post_id ); $per_post = self::extract_per_post_terms( $row ); $site = self::site_defaults(); $resolved = self::merge_layers( $per_post, $site ); return $resolved; } /** * Public sanitizer + canonicalizer for raw input from any save path. * Both the offers-tab save handler and the content-detail save handler * normalize through this so `rsl_terms` JSON ends up with one shape. * * Mirrors what `Prweb_Ajax_Offers::terms_from_request()` produced before * R1.5 — same field set, same defaults — but re-homed here so non-AJAX * call sites (resolver, tests, future non-WP SDK) can use it. */ public static function canonicalize( array $input ): array { $payment = self::sanitize_payment_type( $input['paymentType'] ?? ( $input['payment_type'] ?? null ) ); $price = max( 0, (int) ( $input['price_leos'] ?? self::FALLBACK_PRICE_LEOS ) ); // free / blocked offers force price to zero — preserves existing offers-tab behavior. if ( 'free' === $payment || 'blocked' === $payment ) { $price = 0; } $permits = self::sanitize_string_list( $input['permits'] ?? ( $input['permit'] ?? null ), self::FALLBACK_PERMITS ); $prohibits = self::sanitize_string_list( $input['prohibits'] ?? ( $input['prohibit'] ?? null ), self::FALLBACK_PROHIBITS ); $geo = self::sanitize_string_list( $input['geo'] ?? null, [] ); $user_cls = self::sanitize_user_class( $input['user_class'] ?? null ); return [ 'template' => isset( $input['template'] ) ? sanitize_key( (string) $input['template'] ) : 'standard-ppc', 'label' => isset( $input['label'] ) ? sanitize_text_field( (string) $input['label'] ) : '', 'scope' => isset( $input['scope'] ) ? sanitize_text_field( (string) $input['scope'] ) : '', 'permit' => $permits[0] ?? self::FALLBACK_PERMITS[0], 'permits' => $permits, 'prohibit' => $prohibits[0] ?? self::FALLBACK_PROHIBITS[0], 'prohibits' => $prohibits, 'paymentType' => $payment, 'price_leos' => $price, 'offer_uses' => max( 1, (int) ( $input['offer_uses'] ?? ( $input['uses'] ?? self::FALLBACK_USES ) ) ), 'offer_ttl_seconds' => max( 1, (int) ( $input['offer_ttl_seconds'] ?? ( $input['ttl'] ?? self::FALLBACK_TTL_SECONDS ) ) ), 'daily_cap_cents' => max( 0, (int) ( $input['daily_cap_cents'] ?? 0 ) ), 'user_class' => $user_cls, 'geo' => $geo, ]; } /** * Did the price change between two resolved/canonical terms blobs? */ public static function diff_priced( array $before, array $after ): bool { return (int) ( $before['price_leos'] ?? -1 ) !== (int) ( $after['price_leos'] ?? -1 ); } /** * Which non-price fields changed? Returns the changed-field names suitable * for `offer.terms_set` payload. Empty array means no terms-only change. */ public static function diff_terms( array $before, array $after ): array { $changed = []; $compare = [ 'permits' => 'list', 'prohibits' => 'list', 'offer_uses' => 'int', 'offer_ttl_seconds' => 'int', 'paymentType' => 'string', ]; foreach ( $compare as $field => $kind ) { $a = $before[ $field ] ?? null; $b = $after[ $field ] ?? null; if ( 'list' === $kind ) { $a = is_array( $a ) ? array_values( $a ) : []; $b = is_array( $b ) ? array_values( $b ) : []; if ( $a !== $b ) { $changed[] = $field; } } elseif ( 'int' === $kind ) { if ( (int) $a !== (int) $b ) { $changed[] = $field; } } else { if ( (string) $a !== (string) $b ) { $changed[] = $field; } } } return $changed; } /** * Which resolver layer answered for the given resolved terms? * Used to populate the `source` discriminator on `offer.priced` / `offer.terms_set`. * * - 'per-post' — at least one field came from `rsl_terms` * - 'site-default' — site-wide defaults differ from platform fallback (publisher edited Settings → Offers) * - 'fallback' — no override anywhere; platform constants */ public static function source_for( array $resolved ): string { if ( ( $resolved['_source'] ?? '' ) === 'per-post' ) { return 'per-post'; } $fallback_blob = [ 'price_leos' => self::FALLBACK_PRICE_LEOS, 'offer_uses' => self::FALLBACK_USES, 'offer_ttl_seconds' => self::FALLBACK_TTL_SECONDS, 'permits' => self::FALLBACK_PERMITS, 'prohibits' => self::FALLBACK_PROHIBITS, 'paymentType' => self::FALLBACK_PAYMENT_TYPE, ]; foreach ( $fallback_blob as $field => $expected ) { $observed = $resolved[ $field ] ?? null; if ( is_array( $expected ) ) { if ( ! is_array( $observed ) || array_values( $observed ) !== $expected ) { return 'site-default'; } continue; } if ( (string) $observed !== (string) $expected ) { return 'site-default'; } } return 'fallback'; } /** * Strip resolver-private keys (e.g. `_source`) before persisting. Returns * the canonical terms shape the offers-tab and content-detail editors agree on. */ public static function strip_private_keys( array $resolved ): array { unset( $resolved['_source'] ); return $resolved; } // ----------------------------------------------------------------------- // Internals // ----------------------------------------------------------------------- private static function load_metadata_row( int $post_id ): ?object { global $wpdb; if ( ! ( $wpdb instanceof wpdb ) ) { return null; } $table = Prweb_Brand::table( self::TERMS_TABLE ); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT rsl_terms FROM %i WHERE post_id = %d', $table, $post_id ) ); return $row instanceof \stdClass ? $row : null; } /** * Extract per-post overrides from the row's `rsl_terms` JSON. * Returns null on no row / invalid JSON / completely-empty terms. */ private static function extract_per_post_terms( ?object $row ): ?array { if ( ! $row || empty( $row->rsl_terms ) ) { return null; } $terms = json_decode( (string) $row->rsl_terms, true ); if ( ! is_array( $terms ) || empty( $terms ) ) { return null; } // A row that only carries the legacy two-key blob (`{permit, prohibit}`) // — written by the pre-R1.5 hardcoded register path — is NOT a publisher // override; treat it as null so site-wide defaults can populate the // missing fields. $keys = array_keys( $terms ); sort( $keys ); if ( $keys === [ 'permit', 'prohibit' ] ) { return null; } return $terms; } /** * Merge per-post → site → fallback. Per-post wins on every field it provides; * site fills missing fields; fallback fills the rest. * * Marks `_source: 'per-post'` if any field came from per_post; this gets * read by `source_for()` when the audit emit decides the discriminator. */ private static function merge_layers( ?array $per_post, array $site ): array { $blob = self::canonicalize( [ 'price_leos' => $site['price_leos'], 'offer_uses' => $site['offer_uses'], 'offer_ttl_seconds' => $site['offer_ttl_seconds'], 'permits' => $site['permits'], 'prohibits' => $site['prohibits'], 'paymentType' => $site['paymentType'], ] ); $blob['_source'] = 'site-or-fallback'; if ( null === $per_post ) { return $blob; } // Per-post override path — let canonicalize() normalize then overlay only // fields the per-post blob actually carries. $per_post_canonical = self::canonicalize( $per_post ); $touched = false; foreach ( [ 'template', 'label', 'scope', 'permit', 'permits', 'prohibit', 'prohibits', 'paymentType', 'price_leos', 'offer_uses', 'offer_ttl_seconds', 'daily_cap_cents', 'user_class', 'geo', ] as $field ) { if ( array_key_exists( $field, $per_post ) && null !== $per_post[ $field ] && '' !== $per_post[ $field ] ) { $blob[ $field ] = $per_post_canonical[ $field ]; $touched = true; } } if ( $touched ) { $blob['_source'] = 'per-post'; } return $blob; } private static function sanitize_string_list( mixed $value, array $fallback ): array { if ( is_string( $value ) ) { $value = explode( ',', $value ); } if ( ! is_array( $value ) ) { return $fallback; } $out = []; foreach ( $value as $item ) { $item = sanitize_key( trim( (string) $item ) ); if ( $item !== '' ) { $out[] = $item; } } $out = array_values( array_unique( $out ) ); return empty( $out ) ? $fallback : $out; } private static function sanitize_payment_type( mixed $value ): string { $value = is_string( $value ) ? sanitize_key( $value ) : ''; $allowed = [ 'inference', 'free', 'purchase', 'blocked' ]; return in_array( $value, $allowed, true ) ? $value : self::FALLBACK_PAYMENT_TYPE; } private static function sanitize_user_class( mixed $value ): string { $value = is_string( $value ) ? sanitize_key( $value ) : ''; $allowed = [ 'all', 'commercial', 'research', 'education' ]; return in_array( $value, $allowed, true ) ? $value : 'all'; } }