*/ public static function defaults(): array { /** * Filter the CRM's default settings. * * A module or extension adds its own keys here and cleans them in * `plugixa_crm_sanitize_settings`; a key that neither filter claims is discarded. * * @param array $defaults Default settings. */ return (array) apply_filters( 'plugixa_crm_settings_defaults', array( 'currency' => 'USD', // Empty on purpose: a currency with no rate entered is worth 1.0, which is the honest // answer for a site that has entered nothing. See {@see Exchange}. 'currency_rates' => array(), // OFF until an administrator switches it on: it is an outbound connection to a third // party, and wordpress.org's guideline 7 wants that consented to. See {@see ExchangeFeed}. 'currency_rates_auto' => false, // Currencies whose typed-in rate the feed must leave alone. 'currency_rates_pinned' => array(), 'date_format' => 'Y-m-d', 'theme' => 'system', 'records_per_page' => 50, ) ); } /** * The settings as they stand, with defaults filled in for anything unset. * * @return array */ public static function all(): array { return array_merge( self::defaults(), (array) get_option( self::OPTION, array() ) ); } /** * One setting. * * @param string $key Setting name. * @param mixed $default Returned when the key is set nowhere, not even in the defaults. */ public static function get( string $key, mixed $default = null ): mixed { return self::all()[ $key ] ?? $default; } /** * The currency every amount is stored and summed in. */ public static function currency(): string { return (string) self::get( 'currency', 'USD' ); } /** * What one unit of each currency is worth in the system currency. * * ONE DIRECTION ONLY, AND THE SCREEN SAYS WHICH. "The EUR rate is 1.08" is ambiguous to everybody * who reads it and unambiguous to nobody who writes it, so the stored meaning is fixed here: the * number is how many units of the SYSTEM currency one unit of the named currency buys. Getting * this backwards does not error — it produces a pipeline total that is wrong by a factor of * roughly one, which is exactly the size of mistake nobody spots. * * @return array */ public static function currency_rates(): array { $rates = self::get( 'currency_rates', array() ); return is_array( $rates ) ? array_map( 'floatval', $rates ) : array(); } /** * Save an untrusted payload, keeping only what is recognised. * * @param array $input Raw settings, merged over what is already stored. * * @return array The settings as saved. */ public static function save( array $input ): array { $was_currency = self::currency(); $was_rates = self::currency_rates(); $input = self::rebase_rates( $input ); $settings = self::sanitize( array_merge( self::all(), $input ) ); $settings = self::keep_unclaimed( $settings ); update_option( self::OPTION, $settings ); /** * Fires after the settings are saved. * * @param array $settings The saved settings. */ do_action( 'plugixa_crm_settings_saved', $settings ); if ( $settings['currency'] !== $was_currency || ! self::same_rates( $was_rates, $settings['currency_rates'] ) ) { /** * Fires when what a currency is worth has changed: a rate was typed, the feed brought new * ones, or the system currency moved and every rate was re-expressed against it. * * ONE SIGNAL FOR ALL THREE, BECAUSE THE CONSEQUENCE IS THE SAME. Anything that stored a * converted figure is now holding a number worked out at a rate that no longer stands, * and a listener that only heard about the feed would leave the other two stale. * * @param array $rates The rate table as saved. * @param string $currency The system currency. * @param string $previous The system currency before this save; the same as * `$currency` unless the base itself moved. */ do_action( 'plugixa_crm_currency_rates_changed', $settings['currency_rates'], $settings['currency'], $was_currency ); } return $settings; } /** * Whether two rate tables price every currency the same. * * Compared as numbers rather than with `==` on the arrays: key order is not a change, and a rate * that went through a float round-trip must not fire a re-price of every open deal on a save that * only changed the date format. * * @param array $a One table. * @param array $b The other. */ private static function same_rates( array $a, array $b ): bool { if ( count( $a ) !== count( $b ) ) { return false; } foreach ( $a as $code => $rate ) { if ( ! isset( $b[ $code ] ) || abs( (float) $b[ $code ] - (float) $rate ) > 1e-9 ) { return false; } } return true; } /** * Carry forward settings that are ALREADY STORED and that no filter claimed. * * THIS IS THE DIFFERENCE BETWEEN A MISSING MODULE AND A DATA LOSS. `sanitize()` builds the option * from scratch out of the keys Core and the loaded modules claim, and drops everything else. That * is correct while every module is always present -- and it stops being correct the moment a * module that owns settings is not loaded, for example an extension that was deactivated. * * Picture a site where that code is temporarily absent, so no filter claims the connector's keys. * Nothing is broken and nothing warns. Then somebody changes the date format, and that single save * rewrites the option without the connector's configuration in it -- API keys, tokens, mailbox * settings, all of it -- because the running code did not recognise them. Bringing the code back * would then find an empty configuration. * * WHAT IS PRESERVED IS ONLY WHAT WAS ALREADY ON DISK, never an unrecognised key from the payload. * That distinction is the whole safety of it: the option is autoloaded on every request, so * letting a request park arbitrary data in it would be a denial of service with a REST endpoint * in front of it. An unknown key that is already stored was written by a build that DID claim it, * and is the property of code that may come back. * * @param array $clean The sanitised settings, ready to store. * * @return array */ private static function keep_unclaimed( array $clean ): array { $stored = (array) get_option( self::OPTION, array() ); foreach ( $stored as $key => $value ) { if ( ! array_key_exists( $key, $clean ) ) { $clean[ $key ] = $value; } } return $clean; } /** * Re-express the stored rates against a new system currency. * * CHANGING THE SYSTEM CURRENCY WITHOUT THIS WOULD SILENTLY CORRUPT EVERY FIGURE ON THE SITE. The * stored numbers mean "worth this much in the system currency", so the moment that currency * changes they mean something else — every rate is now wrong, nothing errors, and the pipeline * total simply becomes a different wrong number. Clearing them would be safe but would throw away * work the user did by hand. * * So they are converted. If the rates were against USD and the new base is EUR, and one EUR was * worth 1.08 USD, then every rate is divided by 1.08 — which is exactly the arithmetic the user * would otherwise be asked to do themselves, currency by currency, without being told they had to. * * The new base's own entry is dropped: a currency is always worth one of itself. * * @param array $input The incoming payload. * * @return array */ private static function rebase_rates( array $input ): array { if ( ! array_key_exists( 'currency', $input ) ) { return $input; } $was = self::currency(); $now = strtoupper( sanitize_text_field( (string) $input['currency'] ) ); if ( $now === $was || ! in_array( $now, self::CURRENCIES, true ) ) { return $input; } // A payload that sets both at once is the user telling us the rates already suit the new base. if ( array_key_exists( 'currency_rates', $input ) ) { return $input; } $rates = self::currency_rates(); $pivot = (float) ( $rates[ $now ] ?? 0.0 ); if ( $pivot <= 0.0 ) { // No rate was ever entered for the incoming base, so there is nothing to convert against. // Keeping the old numbers would leave every one of them wrong against a base nobody // priced, which is worse than starting again with the honest 1.0. $input['currency_rates'] = array(); return $input; } $converted = array(); foreach ( $rates as $code => $rate ) { if ( $code === $now ) { continue; } $converted[ $code ] = (float) $rate / $pivot; } // The outgoing base becomes an ordinary currency, and its rate is the reciprocal of the pivot. $converted[ $was ] = 1 / $pivot; $input['currency_rates'] = $converted; return $input; } /** * Clean an untrusted settings payload. * * @param array $input Raw settings. * * @return array */ public static function sanitize( array $input ): array { $currency = strtoupper( sanitize_text_field( (string) ( $input['currency'] ?? 'USD' ) ) ); $format = sanitize_text_field( (string) ( $input['date_format'] ?? 'Y-m-d' ) ); $theme = sanitize_text_field( (string) ( $input['theme'] ?? 'system' ) ); $clean = array( 'currency' => in_array( $currency, self::CURRENCIES, true ) ? $currency : 'USD', 'currency_rates' => self::clean_rates( $input['currency_rates'] ?? array(), $currency ), 'currency_rates_auto' => rest_sanitize_boolean( $input['currency_rates_auto'] ?? false ), 'currency_rates_pinned' => self::clean_codes( $input['currency_rates_pinned'] ?? array() ), 'date_format' => in_array( $format, self::DATE_FORMATS, true ) ? $format : 'Y-m-d', 'theme' => in_array( $theme, self::THEMES, true ) ? $theme : 'system', // A page size of 100000 is not a preference, it is a denial of service against // the site's own database. 'records_per_page' => max( 10, min( 200, (int) ( $input['records_per_page'] ?? 50 ) ) ), ); /* * Settings a module owns are cleaned by that module's sanitiser — Core never learns what * they are. Anything no sanitiser claims is dropped from the PAYLOAD; a key that is already * stored survives, via `keep_unclaimed()`, so a build that cannot see a module's settings * cannot delete them either. * * A REGISTRY, NOT A FILTER. This used to be `apply_filters( 'plugixa_crm_sanitize_settings', * $clean, $input )`, which hands the RAW request payload to any listener on a public hook — * the shape wordpress.org flags as unsanitised input reaching a hook. Only code that calls * add_sanitizer() sees `$input`, and each sanitiser's job is to clean its own keys from it. */ foreach ( self::$sanitizers as $sanitizer ) { $clean = (array) $sanitizer( $clean, $input ); } return $clean; } /** * Module sanitisers, by id. * * @var array */ private static array $sanitizers = array(); /** * Register the sanitiser for the settings a module owns. * * Keyed by id, so registering twice (hooks registered again in a test, say) replaces rather than * stacks. The callable receives the settings Core has already cleaned and the raw payload, and * returns the cleaned settings with its own keys added. * * @param string $id Who owns these settings, e.g. the module id. * @param callable $sanitizer fn( array $clean, array $input ): array. */ public static function add_sanitizer( string $id, callable $sanitizer ): void { self::$sanitizers[ $id ] = $sanitizer; } /** * Remove a sanitiser. For tests and for an add-on that is switched off. * * @param string $id The id it was registered under. */ public static function remove_sanitizer( string $id ): void { unset( self::$sanitizers[ $id ] ); } /** * Narrow a rate table to currencies the CRM knows and numbers that mean something. * * A ZERO OR NEGATIVE RATE IS DROPPED RATHER THAN STORED. Zero would silently turn every amount in * that currency into nothing — a pipeline that quietly loses a deal is worse than one that shows * an unconverted figure — and a negative rate is not a mistake anything downstream could recover * from. The system currency is dropped too: it is always worth one of itself, and storing that * invites somebody to edit it. * * @param mixed $rates The submitted table. * @param string $currency The system currency. * * @return array */ private static function clean_rates( mixed $rates, string $currency ): array { if ( ! is_array( $rates ) ) { return array(); } $clean = array(); foreach ( $rates as $code => $rate ) { $code = strtoupper( sanitize_text_field( (string) $code ) ); $rate = (float) $rate; if ( $code === $currency || ! in_array( $code, self::CURRENCIES, true ) || $rate <= 0.0 ) { continue; } $clean[ $code ] = $rate; } return $clean; } /** * Narrow a list to currency codes the CRM knows, once each. * * @param mixed $codes The submitted list. * * @return list */ private static function clean_codes( mixed $codes ): array { if ( ! is_array( $codes ) ) { return array(); } $clean = array(); foreach ( $codes as $code ) { $code = strtoupper( sanitize_text_field( (string) $code ) ); if ( in_array( $code, self::CURRENCIES, true ) && ! in_array( $code, $clean, true ) ) { $clean[] = $code; } } return $clean; } }