Plugin Directory

Changeset 3776286


Ignore:
Timestamp:
10/31/2026 02:56:18 AM (33 hours ago)
Author:
avakodeforge
Message:

Release 1.0.3 (trunk + tag; see readme changelog)

Location:
clearwayforge
Files:
22 added
70 edited
1 copied

Legend:

Unmodified
Added
Removed
  • clearwayforge/tags/1.0.3/assets/admin/admin.js

    r3702012 r3776286  
    1111 * ── Почему числа форматируются здесь, а считаются на сервере ─────────────
    1212 *
    13  * Все суммы приходят целыми центами, доли — базисными пунктами. Клиент их
    14  * только показывает. Любая арифметика здесь означала бы второй источник
     13 * Все суммы приходят целыми минорными единицами, доли — базисными пунктами.
     14 * Клиент их только показывает; сколько знаков после запятой у единицы, говорит
     15 * сервер (`cwforgeAdmin.minorUnit` = `wc_get_price_decimals()`, та же точность,
     16 * с которой заказ записан): иены (0 знаков) и динары (3) — не центы. Любая арифметика здесь означала бы второй источник
    1517 * истины: экран и письмо-дайджест начали бы показывать разные числа, и
    1618 * поймать это можно было бы только глазами.
    … …  
    2931        var apiFetch = wp.apiFetch;
    3032
    31         /** Центы в строку с валютой. Форматирование локали делает браузер. */
     33        /**
     34         * Записанные единицы в строку с валютой. Форматирование локали делает
     35         * браузер, а место точки — настройка магазина `config.minorUnit`: 1500
     36         * при нуле знаков это 1500 иен, а не 15 (BUG-CLEAR-ATTR-02). WordPress
     37         * отдаёт скаляры локализации строками, поэтому число разбирается заново;
     38         * без него или вне 0-4 остаются два знака, как было.
     39         */
    3240        function money( cents, currency ) {
    33                 var value = ( cents || 0 ) / 100;
     41                var digits = parseInt( config.minorUnit, 10 );
     42                if ( isNaN( digits ) || digits < 0 || digits > 4 ) {
     43                        digits = 2;
     44                }
     45                var value = ( cents || 0 ) / Math.pow( 10, digits );
    3446                try {
    3547                        return new Intl.NumberFormat( undefined, {
    3648                                style: 'currency',
    3749                                currency: currency || 'USD',
    38                                 maximumFractionDigits: 2,
     50                                minimumFractionDigits: digits,
     51                                maximumFractionDigits: digits,
    3952                        } ).format( value );
    4053                } catch ( e ) {
    4154                        // Неизвестный код валюты роняет Intl. Магазин с экзотической
    4255                        // валютой не должен получать пустой экран вместо цифр.
    43                         return value.toFixed( 2 ) + ' ' + ( currency || '' );
     56                        return value.toFixed( digits ) + ' ' + ( currency || '' );
    4457                }
    4558        }
    … …  
    444457                                { className: 'cwforge-stats' },
    445458                                el( Stat, { label: 'AI-attributed orders', value: String( data.totals.orders ) } ),
     459                                // Выручка выше уже за вычетом возвратов; сами возвраты отдельной строкой.
     460                                el( Stat, { label: 'Refunded', value: money( data.totals.refunded_cents || 0, currency ) } ),
    446461                                el( Stat, { label: 'AI sessions', value: String( data.totals.sessions ) } ),
    447462                                el( Stat, { label: 'Recovered sessions', value: percent( recovered.share_bp ) } ),
    … …  
    743758                var uninstall = uninstallState[ 0 ];
    744759                var setUninstall = uninstallState[ 1 ];
     760                // Доверенные прокси: черновик текста правится в поле и уходит одной
     761                // кнопкой; сервер сохраняет годные записи и называет отброшенные.
     762                var proxiesState = useState( { value: '', saved: '', message: '', rejected: [], overLimit: 0, limit: 0 } );
     763                var proxies = proxiesState[ 0 ];
     764                var setProxies = proxiesState[ 1 ];
     765                // Список прокси правит администратор; менеджеру магазина поле только читается.
     766                var proxiesAllowedState = useState( false );
     767                var proxiesAllowed = proxiesAllowedState[ 0 ];
     768                var setProxiesAllowed = proxiesAllowedState[ 1 ];
    745769
    746770                function load() {
    … …  
    764788                                                saved: '',
    765789                                        } );
     790                                        setProxies( { value: data.trusted_proxies || '', saved: '', message: '', rejected: [], overLimit: 0, limit: data.trusted_proxies_limit || 0 } );
     791                                        setProxiesAllowed( !! data.can_change_proxies );
    766792                                } )
    767793                                .catch( function ( error ) {
    … …  
    814840                                .catch( function () {
    815841                                        setUninstall( { value: ! next, allowed: uninstall.allowed, saved: 'error' } );
     842                                } );
     843                }
     844
     845                function saveProxies() {
     846                        var draft = proxies.value;
     847                        setProxies( { value: draft, saved: 'saving', message: '', rejected: [], overLimit: 0, limit: proxies.limit } );
     848                        apiFetch( {
     849                                path: '/cwforge/v1/settings',
     850                                method: 'POST',
     851                                data: { trusted_proxies: draft },
     852                        } )
     853                                .then( function ( data ) {
     854                                        // Показываем то, что сервер сохранил (без повторов и лишних пробелов),
     855                                        // и называем записи, которые он отбросил.
     856                                        setProxies( {
     857                                                value: data.trusted_proxies || '',
     858                                                saved: 'saved',
     859                                                message: '',
     860                                                rejected: Array.isArray( data.trusted_proxies_rejected ) ? data.trusted_proxies_rejected : [],
     861                                                overLimit: data.trusted_proxies_over_limit || 0,
     862                                                limit: data.trusted_proxies_limit || proxies.limit,
     863                                        } );
     864                                } )
     865                                .catch( function ( error ) {
     866                                        // Черновик остаётся как есть: владелец правит запись, а не вводит всё заново.
     867                                        setProxies( {
     868                                                value: draft,
     869                                                saved: 'error',
     870                                                message: error && error.message ? String( error.message ) : '',
     871                                                rejected: [],
     872                                                overLimit: 0,
     873                                                limit: proxies.limit,
     874                                        } );
    816875                                } );
    817876                }
    … …  
    919978                                'p',
    920979                                { className: 'description' },
    921                                 'The dashboard and the revenue by AI agent report credit every order in the window with this model. ' +
     980                                'The model applies to orders placed from the moment you switch it. Earlier orders keep the model they ' +
     981                                        'were recorded with, so past reports are not recalculated. ' +
    922982                                        'The AI channel column on an order keeps the model that was in force when the order was placed.'
    923983                        ),
    … …  
    9481008                        ),
    9491009                        savedNote( ranges.saved ),
     1010                        el( 'h3', null, 'Trusted reverse proxies' ),
     1011                        el(
     1012                                'label',
     1013                                { htmlFor: 'cwforge-trusted-proxies' },
     1014                                'Networks of the proxies in front of this site, one per line (a network such as 10.0.0.0/8, or a single address)'
     1015                        ),
     1016                        el( 'textarea', {
     1017                                id: 'cwforge-trusted-proxies',
     1018                                className: 'large-text code',
     1019                                rows: 4,
     1020                                placeholder: '10.0.0.0/8',
     1021                                value: proxies.value,
     1022                                disabled: ! proxiesAllowed || proxies.saved === 'saving',
     1023                                onChange: function ( event ) {
     1024                                        setProxies( { value: event.target.value, saved: '', message: '', rejected: [], overLimit: 0, limit: proxies.limit } );
     1025                                },
     1026                        } ),
     1027                        el(
     1028                                'p',
     1029                                { className: 'description' },
     1030                                'Leave this empty unless a reverse proxy or load balancer that you control sits in front of the site. ' +
     1031                                        'While the list is empty the plugin checks crawler visits against the address of the connection only ' +
     1032                                        'and never reads the X-Forwarded-For header, which any visitor can forge. Behind a proxy that address ' +
     1033                                        'is the proxy itself, so crawler visits are shown as "declared". When the connection comes from a ' +
     1034                                        'network listed here, the plugin uses the right-most X-Forwarded-For address that is not in the list.'
     1035                        ),
     1036                        el(
     1037                                'button',
     1038                                {
     1039                                        type: 'button',
     1040                                        className: 'button',
     1041                                        disabled: ! proxiesAllowed || proxies.saved === 'saving',
     1042                                        onClick: saveProxies,
     1043                                },
     1044                                'Save proxies'
     1045                        ),
     1046                        proxiesAllowed
     1047                                ? null
     1048                                : el( 'p', { className: 'description' }, 'Only an administrator can change the trusted proxies.' ),
     1049                        proxies.saved === 'saved' ? el( 'p', { role: 'status' }, 'Saved.' ) : null,
     1050                        proxies.saved === 'saved' && proxies.rejected.length
     1051                                ? el(
     1052                                        'p',
     1053                                        { className: 'notice notice-warning', role: 'alert' },
     1054                                        'Not saved, because they are not an IP address or network: ' + proxies.rejected.join( ', ' )
     1055                                )
     1056                                : null,
     1057                        proxies.saved === 'saved' && proxies.overLimit
     1058                                ? el(
     1059                                        'p',
     1060                                        { className: 'notice notice-warning', role: 'alert' },
     1061                                        'Only the first ' + proxies.limit + ' entries are kept; ' + proxies.overLimit + ' more were not saved.'
     1062                                )
     1063                                : null,
     1064                        proxies.saved === 'error'
     1065                                ? el(
     1066                                        'p',
     1067                                        { className: 'notice notice-error' },
     1068                                        'Could not save' + ( proxies.message ? ': ' + proxies.message : '. Try again.' )
     1069                                )
     1070                                : null,
    9501071                        el( 'h3', null, 'When the plugin is deleted' ),
    9511072                        el(
    … …  
    9831104                /*
    9841105                 * Экраны бесплатного ядра, и только они. Очередь одобрений сюда не
    985                  * входит и входить не может: это код уровня Growth, а этот файл едет
     1106                 * входит и входить не может: это платный код Guard, а этот файл едет
    9861107                 * в бесплатном zip на wp.org, где реализация платной функции на диске
    9871108                 * — отказ на ревью (docs/free-pro-split.md §5). Её рисует свой скрипт
  • clearwayforge/tags/1.0.3/clearwayforge.php

    r3720707 r3776286  
    22
    33/**
    4  * Plugin Name:       Clearway Forge for WooCommerce
     4 * Plugin Name:       Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce
    55 * Plugin URI:        https://clearwayforge.com/
    66 * Description:       See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database.
    7  * Version:           1.0.2
     7 * Version:           1.0.3
    88 * Requires at least: 6.5
    99 * Requires PHP:      8.2
    … …  
    3838}
    3939
    40 const CWFORGE_VERSION = '1.0.2';
     40const CWFORGE_VERSION = '1.0.3';
    4141const CWFORGE_MIN_PHP = '8.2';
    4242const CWFORGE_MIN_WP = '6.5';
  • clearwayforge/tags/1.0.3/languages/clearwayforge.pot

    r3702012 r3776286  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Clearway Forge for WooCommerce 0.1.0\n"
    6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/clearwayforge\n"
     5"Project-Id-Version: Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce 1.0.3\n"
     6"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/plugin\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    88"Language-Team: LANGUAGE <LL@li.org>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-26T18:36:47+00:00\n"
     12"POT-Creation-Date: 2026-10-30T22:19:42+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1717#. Plugin Name of the plugin
    1818#: clearwayforge.php
    19 msgid "Clearway Forge for WooCommerce"
     19msgid "Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce"
    2020msgstr ""
    2121
    2222#. Plugin URI of the plugin
    2323#: clearwayforge.php
    24 msgid "https://github.com/avakode/clearway-forge"
     24msgid "https://clearwayforge.com/"
    2525msgstr ""
    2626
    2727#. Description of the plugin
    2828#: clearwayforge.php
    29 msgid "See what AI assistants earn you, make your store readable to them, and keep agent actions under your control."
     29msgid "See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database."
    3030msgstr ""
    3131
    … …  
    3535msgstr ""
    3636
    37 #: src/Admin/Menu.php:47
    38 #: src/Admin/Menu.php:48
    39 #: src/Admin/Menu.php:109
     37#: src/Admin/AgentRevenueScreen.php:83
     38#: src/Admin/Menu.php:101
     39msgid "Revenue by AI agent"
     40msgstr ""
     41
     42#. translators: %d: number of days in the longest window the report can show.
     43#: src/Admin/AgentRevenueScreen.php:94
     44#, php-format
     45msgid "Showing the last %d days, the longest window the report can show. Nothing was deleted."
     46msgstr ""
     47
     48#: src/Admin/AgentRevenueScreen.php:126
     49msgid "Period"
     50msgstr ""
     51
     52#: src/Admin/AgentRevenueScreen.php:139
     53msgid "Show"
     54msgstr ""
     55
     56#: src/Admin/AgentRevenueScreen.php:151
     57msgid "AI agent"
     58msgstr ""
     59
     60#: src/Admin/AgentRevenueScreen.php:152
     61msgid "Revenue"
     62msgstr ""
     63
     64#: src/Admin/AgentRevenueScreen.php:153
     65msgid "Orders"
     66msgstr ""
     67
     68#: src/Admin/AgentRevenueScreen.php:154
     69msgid "Sessions"
     70msgstr ""
     71
     72#: src/Admin/AgentRevenueScreen.php:155
     73msgid "Conversion"
     74msgstr ""
     75
     76#: src/Admin/AgentRevenueScreen.php:156
     77msgid "Average order"
     78msgstr ""
     79
     80#: src/Admin/AgentRevenueScreen.php:157
     81msgid "Share"
     82msgstr ""
     83
     84#: src/Admin/AgentRevenueScreen.php:167
     85msgid "No AI-attributed orders in this period yet."
     86msgstr ""
     87
     88#: src/Admin/AgentRevenueScreen.php:187
     89msgid "Total"
     90msgstr ""
     91
     92#. translators: %s: revenue proven by a referrer or a campaign tag.
     93#: src/Admin/AgentRevenueScreen.php:194
     94#, php-format
     95msgid "%s proven by a referrer or a tag"
     96msgstr ""
     97
     98#: src/Admin/AgentRevenueScreen.php:203
     99msgid "Refunded"
     100msgstr ""
     101
     102#: src/Admin/AgentRevenueScreen.php:205
     103msgid "Already taken off the revenue above."
     104msgstr ""
     105
     106#. translators: 1: start date, 2: end date, 3: IANA time zone name.
     107#: src/Admin/AgentRevenueScreen.php:214
     108#, php-format
     109msgid "Computed by your own store, %1$s to %2$s in %3$s."
     110msgstr ""
     111
     112#: src/Admin/AgentRevenueScreen.php:220
     113msgid "Nothing left your server to produce it."
     114msgstr ""
     115
     116#: src/Admin/Menu.php:86
     117#: src/Admin/Menu.php:200
    40118msgid "AI Channel"
    41119msgstr ""
    42120
    43 #: src/Admin/Menu.php:58
    44 #: src/Admin/Menu.php:114
     121#: src/Admin/Menu.php:91
     122msgid "Measurement"
     123msgstr ""
     124
     125#: src/Admin/Menu.php:102
     126msgid "Revenue by agent"
     127msgstr ""
     128
     129#: src/Admin/Menu.php:110
     130#: src/Admin/Menu.php:205
    45131msgid "AI Channel settings"
    46132msgstr ""
    47133
    48 #: src/Admin/Menu.php:59
     134#: src/Admin/Menu.php:111
    49135msgid "Settings"
    50136msgstr ""
    51137
    52 #: src/Admin/Menu.php:67
     138#: src/Admin/Menu.php:119
     139#: src/Admin/Menu.php:211
     140msgid "AI Channel health"
     141msgstr ""
     142
    53143#: src/Admin/Menu.php:120
    54 msgid "AI Channel health"
    55 msgstr ""
    56 
    57 #: src/Admin/Menu.php:68
    58144msgid "Health"
    59145msgstr ""
    60146
    61 #: src/Admin/Menu.php:136
     147#: src/Admin/Menu.php:131
     148msgid "AI Channel catalog"
     149msgstr ""
     150
     151#: src/Admin/Menu.php:132
     152#: src/Admin/Menu.php:217
     153msgid "Catalog"
     154msgstr ""
     155
     156#: src/Admin/Menu.php:141
     157msgid "AI Channel agents"
     158msgstr ""
     159
     160#: src/Admin/Menu.php:142
     161#: src/Admin/Menu.php:223
     162msgid "Agents"
     163msgstr ""
     164
     165#: src/Admin/Menu.php:274
     166msgid "The Clearway Forge add-on has not added any screens here yet."
     167msgstr ""
     168
     169#: src/Admin/Menu.php:304
     170msgid "Catalog needs an active WooCommerce store."
     171msgstr ""
     172
     173#: src/Admin/Menu.php:305
     174msgid "Feed drift and SKU tasks come with the separate Clearway Forge add-on."
     175msgstr ""
     176
     177#: src/Admin/Menu.php:309
     178msgid "Agents needs an active WooCommerce store."
     179msgstr ""
     180
     181#: src/Admin/Menu.php:310
     182msgid "Agent keys, scopes and the journal come with the separate Clearway Forge add-on."
     183msgstr ""
     184
     185#: src/Admin/Menu.php:334
     186#: src/Admin/Menu.php:393
     187msgid "About the add-on"
     188msgstr ""
     189
     190#: src/Admin/Menu.php:367
    62191msgid "Loading your AI channel data..."
    63192msgstr ""
    64193
    65 #: src/Admin/Menu.php:155
    66 msgid "Available in the paid add-on"
    67 msgstr ""
    68 
    69 #: src/Admin/Menu.php:162
    70 msgid "(active)"
    71 msgstr ""
    72 
    73 #: src/Admin/Menu.php:167
    74 msgid "What the add-on adds"
    75 msgstr ""
    76 
    77 #. translators: %d: maximum number of days available without the paid add-on.
    78 #: src/Infra/Rest/Routes.php:143
    79 #, php-format
    80 msgid "Reporting windows longer than %d days need the paid add-on."
    81 msgstr ""
     194#: src/Admin/Menu.php:385
     195msgid "In the separate Clearway Forge add-on"
     196msgstr ""
     197
     198#: src/Admin/OrdersColumn.php:99
     199#: src/Admin/OrdersColumn.php:105
     200msgid "AI channel"
     201msgstr ""
     202
     203#. translators: %d: largest reporting window the plugin accepts.
     204#: src/Infra/Rest/Routes.php:189
     205#, php-format
     206msgid "Reporting windows longer than %d days are not supported."
     207msgstr ""
  • clearwayforge/tags/1.0.3/readme.txt

    r3774326 r3776286  
    55Tested up to: 7.1
    66Requires PHP: 8.2
    7 Stable tag: 1.0.2
     7Stable tag: 1.0.3
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    168168== Changelog ==
    169169
     170= 1.0.3 =
     171* Revenue is shown net of refunds. The dashboard and Revenue by agent take a refund off the order's own period and show the refunded total on its own line. Refunds made before the update are counted in the background, 200 orders at a time.
     172* Changing the attribution model no longer hides your history: each order stays under the model it was recorded with, and the new model applies to orders from then on.
     173* Amounts follow your store's number of price decimals (zero-decimal currencies included) in the dashboard and Revenue by agent. The privacy export shows the attributed amount as an amount instead of a raw count of cents.
     174* llms.txt and llms-full.txt: page-cache plugins are told not to store them (the DONOTCACHEPAGE constant, and a no-cache header for LiteSpeed Cache). Both documents now share one freshness of 15 minutes: Cache-Control said 1 hour and the server copy of llms.txt lived 6 hours. Saving a product still clears the server copy.
     175* llms.txt and llms-full.txt on Polylang and WPML sites list one product per translation group, in your default language. Without a multilingual plugin nothing changes.
     176* llms-full.txt lists up to 500 products instead of repeating the short list, says "Showing N of M products" when it is cut, and points to the JSON catalogue feed when the paid add-on serves it.
     177* On a multisite network with subdirectories, /sub/llms.txt now returns that site's own document.
     178* A listener on the `clearwayforge/ai_order` hook that throws no longer breaks checkout. The exception is written as one line to WooCommerce > Status > Logs (source clearwayforge) by class name and the folder of the plugin it came from, with no message and no order data, and the order goes through.
     179* New setting, Trusted reverse proxies: crawler address verification reads X-Forwarded-For for requests that arrive from a proxy on your list and ignores it for every other request. The list is empty by default, so the header is ignored. Changing it needs an administrator. IPv4 addresses carried in IPv6 notation now match the IPv4 vendor ranges.
     180* Health: the UTC timezone warning no longer misses sites whose timezone is set by name.
     181* The retention clean-up job is put back in the queue from the admin when activation missed it (an FTP update, a migrated database).
     182* Deleting the plugin before the paid add-on keeps your "delete all data" choice for the add-on, so its tables go too.
     183
    170184= 1.0.2 =
    171185* llms.txt and llms-full.txt show prices, names and descriptions as plain characters: a dollar price carried the HTML entity for the "$" sign instead of the sign itself, and a sale price repeated itself. A sale now reads the way WooCommerce says it to screen readers ("Original price was: $150.00. Current price is: $129.00."). The documents cached by 1.0.1 are not served after the update.
  • clearwayforge/tags/1.0.3/src/Admin/AgentRevenueScreen.php

    r3702012 r3776286  
    9191                esc_html(
    9292                    sprintf(
    93                         /* translators: %d: number of days the free plugin can show. */
     93                        /* translators: %d: number of days in the longest window the report can show. */
    9494                        __(
    95                             'Showing the last %d days. Longer windows need the paid add-on; nothing was deleted.',
     95                            'Showing the last %d days, the longest window the report can show. Nothing was deleted.',
    9696                            'clearwayforge'
    9797                        ),
    … …  
    196196            )
    197197        ));
    198         echo '</tr></tfoot></table>';
     198        echo '</tr>';
     199        // Выручка в таблице уже за вычетом возвратов; сумма возвратов стоит
     200        // отдельной строкой (BUG-CLEAR-MISC-01, п. 14).
     201        printf(
     202            '<tr class="cwforge-refunded"><th scope="row">%s</th><td>%s</td><td colspan="5">%s</td></tr>',
     203            esc_html__('Refunded', 'clearwayforge'),
     204            esc_html($totals['refunded']),
     205            esc_html__('Already taken off the revenue above.', 'clearwayforge')
     206        );
     207        echo '</tfoot></table>';
    199208
    200209        printf(
  • clearwayforge/tags/1.0.3/src/Admin/Assets.php

    r3702012 r3776286  
    2727namespace ClearwayForge\Admin;
    2828
     29use ClearwayForge\Infra\Report\StoreMinorUnit;
    2930use ClearwayForge\Infra\Rest\Routes;
    3031
    … …  
    5556            'nonce' => wp_create_nonce('wp_rest'),
    5657            'currency' => get_woocommerce_currency(),
     58            // Знаков после запятой у записанных сумм: `money()` ставит точку по
     59            // ним, а не делением на сто (BUG-CLEAR-ATTR-02).
     60            'minorUnit' => StoreMinorUnit::current(),
    5761            'timezone' => wp_timezone_string(),
    5862            'settingsUrl' => esc_url_raw(admin_url('admin.php?page=cwforge-settings')),
  • clearwayforge/tags/1.0.3/src/Domain/Attribution/Allocation.php

    r3702012 r3776286  
    33/**
    44 * Доля заказа, приписанная одному касанию.
     5 *
     6 * ── Возврат (BUG-CLEAR-MISC-01, п. 14) ───────────────────────────────────
     7 *
     8 * `$amount` — сумма доли в момент заказа, она не меняется: движок атрибуции
     9 * считает её один раз. Возврат приходит позже и хранится рядом
     10 * (`$refundedCents`, доля возврата, пришедшаяся на эту строку): отчёты
     11 * показывают выручку за вычетом возврата (`netCents()`), а сам возврат
     12 * отдельной строкой.
    513 */
    614
    … …  
    1624        public readonly Touch $touch,
    1725        public readonly Money $amount,
    18         public readonly int $weight
     26        public readonly int $weight,
     27        public readonly int $refundedCents = 0
    1928    ) {
    2029    }
     30
     31    /**
     32     * Возврат в пределах доли: вернуть больше, чем заплачено за долю, нельзя,
     33     * поэтому лишнее (повторный разбор с меньшей суммой, ошибка округления)
     34     * отрезается, а выручка не уходит в минус.
     35     */
     36    public function effectiveRefundCents(): int
     37    {
     38        return max(0, min($this->refundedCents, $this->amount->cents));
     39    }
     40
     41    /** Доля за вычетом возврата — то, что попадает в выручку. */
     42    public function netCents(): int
     43    {
     44        return $this->amount->cents - $this->effectiveRefundCents();
     45    }
    2146}
  • clearwayforge/tags/1.0.3/src/Domain/Attribution/AttributionModel.php

    r3702012 r3776286  
    44 * Как делить заказ между касаниями.
    55 *
    6  * Free-тариф получает только `LastTouch`, и это не искусственное урезание:
    7  * мульти-тач осмыслен, когда истории накопилось хотя бы на месяц, а тридцать
    8  * дней истории — ровно граница free.
     6 * Все четыре модели входят в бесплатный плагин (readme на wp.org обещает это
     7 * прямо), и платный аддон их не отнимает. Модель, выбранная в настройках,
     8 * действует на заказы с момента выбора: каждый заказ записывается под ней и
     9 * потом читается под ней же, задним числом отчёты не пересчитываются
     10 * (`TouchRepository`, `DashboardAssembler`).
    911 */
    1012
  • clearwayforge/tags/1.0.3/src/Domain/Llms/StoreProfile.php

    r3702012 r3776286  
    1919     * @param list<LlmsLink> $policies
    2020     * @param list<LlmsLink> $pages
     21     * @param ?int           $productsTotal Сколько товаров в каталоге всего. `products`
     22     *                                      — только та часть, что загружена; без этого
     23     *                                      числа документ не может сказать, что обрезан.
    2124     */
    2225    public function __construct(
    … …  
    3033        public readonly string $currency = 'USD',
    3134        public readonly ?string $shippingNote = null,
    32         public readonly ?string $returnsNote = null
     35        public readonly ?string $returnsNote = null,
     36        public readonly ?int $productsTotal = null
    3337    ) {
    3438    }
  • clearwayforge/tags/1.0.3/src/Domain/Report/AgentRevenueTable.php

    r3702012 r3776286  
    3232{
    3333    /**
     34     * `revenue_cents` — за вычетом возвратов, `refunded_cents` — сами возвраты
     35     * по этому источнику (BUG-CLEAR-MISC-01, п. 14).
     36     *
    3437     * @return list<array{agent:string,label:string,orders:int,sessions:int,revenue_cents:int,
    35      *                    revenue:string,aov:string,aov_cents:int,conversion:string,conversion_bp:int,
    36      *                    share:string,share_bp:int}>
     38     *                    revenue:string,refunded_cents:int,refunded:string,aov:string,aov_cents:int,
     39     *                    conversion:string,conversion_bp:int,share:string,share_bp:int}>
    3740     */
    3841    public static function rows(DashboardReport $report): array
    … …  
    4750                'sessions' => $row->sessions,
    4851                'revenue_cents' => $row->revenueCents,
    49                 'revenue' => MoneyText::format($row->revenueCents, $report->currency),
    50                 'aov' => MoneyText::format($row->aovCents(), $report->currency),
     52                'revenue' => MoneyText::format($row->revenueCents, $report->currency, $report->minorUnit),
     53                'refunded_cents' => $row->refundedCents,
     54                'refunded' => MoneyText::format($row->refundedCents, $report->currency, $report->minorUnit),
     55                'aov' => MoneyText::format($row->aovCents(), $report->currency, $report->minorUnit),
    5156                'aov_cents' => $row->aovCents(),
    5257                'conversion' => MoneyText::percent($row->conversionBp()),
    … …  
    6873     * молча чинить.
    6974     *
    70      * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string,proven:string}
     75     * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string,
     76     *                    refunded_cents:int,refunded:string,proven:string}
    7177     */
    7278    public static function totals(DashboardReport $report): array
    … …  
    8288            'revenue_cents' => $report->revenueCents,
    8389            'rows_cents' => $rowsCents,
    84             'revenue' => MoneyText::format($report->revenueCents, $report->currency),
    85             'proven' => MoneyText::format($report->provenRevenueCents, $report->currency),
     90            'revenue' => MoneyText::format($report->revenueCents, $report->currency, $report->minorUnit),
     91            'refunded_cents' => $report->refundedCents,
     92            'refunded' => MoneyText::format($report->refundedCents, $report->currency, $report->minorUnit),
     93            'proven' => MoneyText::format($report->provenRevenueCents, $report->currency, $report->minorUnit),
    8694        ];
    8795    }
  • clearwayforge/tags/1.0.3/src/Domain/Report/DashboardBuilder.php

    r3702012 r3776286  
    2424     * @param list<Touch>           $sessions По одному касанию на сессию окна.
    2525     * @param list<CrawlerRow>      $crawlers Агрегаты заходов ботов за окно.
     26     * @param int                   $minorUnit Знаков после запятой у записанных сумм
     27     *                                         (`wc_get_price_decimals()`), отчёт только несёт их до печати.
    2628     */
    2729    public function build(
    … …  
    3032        array $sessions,
    3133        array $crawlers = [],
    32         string $currency = 'USD'
     34        string $currency = 'USD',
     35        int $minorUnit = 2
    3336    ): DashboardReport {
    3437        $orders = array_values(array_filter($orders, static fn (AttributedOrder $o): bool => $window->contains($o->atMs)));
    … …  
    3841        $provenRevenueBySource = [];
    3942        $ordersBySource = [];
     43        $refundedBySource = [];
    4044        $totalRevenue = 0;
    4145        $provenRevenue = 0;
     46        $totalRefunded = 0;
    4247        $attributedOrders = 0;
    4348
    … …  
    5459            foreach ($order->result->allocations as $allocation) {
    5560                $key = $allocation->touch->source->value;
    56                 $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $allocation->amount->cents;
    57                 $totalRevenue += $allocation->amount->cents;
     61                // Выручка — за вычетом возврата по этой доле (BUG-CLEAR-MISC-01,
     62                // п. 14); сам возврат копится отдельно.
     63                $net = $allocation->netCents();
     64                $refunded = $allocation->effectiveRefundCents();
     65                $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $net;
     66                $refundedBySource[$key] = ($refundedBySource[$key] ?? 0) + $refunded;
     67                $totalRevenue += $net;
     68                $totalRefunded += $refunded;
    5869                if ($allocation->touch->isProof()) {
    59                     $provenRevenue += $allocation->amount->cents;
     70                    $provenRevenue += $net;
    6071                    // Та же доказанность, только по строке источника, а не
    6172                    // по всему магазину — таблица «Where the money came
    6273                    // from» обязана иметь ту же пару observed/estimated,
    6374                    // что и заголовок (ревью major-3).
    64                     $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $allocation->amount->cents;
     75                    $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $net;
    6576                }
    6677                if (!isset($seenSources[$key])) {
    … …  
    8495        }
    8596
    86         $bySource = $this->sourceRows($revenueBySource, $ordersBySource, $sessionsBySource, $provenRevenueBySource);
     97        $bySource = $this->sourceRows(
     98            $revenueBySource,
     99            $ordersBySource,
     100            $sessionsBySource,
     101            $provenRevenueBySource,
     102            $refundedBySource
     103        );
    87104        $days = $this->days($window, $orders);
    88105
    … …  
    98115            recoveredSessions: $recoveredSessions,
    99116            provenRevenueCents: $provenRevenue,
     117            refundedCents: $totalRefunded,
     118            minorUnit: $minorUnit,
    100119        );
    101120    }
    … …  
    111130     * @param array<string,int> $provenRevenue Доля `$revenue` за этот источник,
    112131     *        доказанная реферером или меткой (ревью major-3).
     132     * @param array<string,int> $refunded Возвраты по источнику (уже вычтены из `$revenue`).
    113133     * @return list<SourceRow>
    114134     */
    115     private function sourceRows(array $revenue, array $orders, array $sessions, array $provenRevenue = []): array
    116     {
     135    private function sourceRows(
     136        array $revenue,
     137        array $orders,
     138        array $sessions,
     139        array $provenRevenue = [],
     140        array $refunded = []
     141    ): array {
    117142        $present = [];
    118143        foreach (AiSource::ordered() as $source) {
    … …  
    153178                $provenRevenue[$key] ?? 0,
    154179                $observedShares[$i] ?? 0,
     180                $refunded[$key] ?? 0,
    155181            );
    156182        }
    … …  
    190216            }
    191217            $revenue[$key] += array_sum(array_map(
    192                 static fn (Allocation $a): int => $a->amount->cents,
     218                static fn (Allocation $a): int => $a->netCents(),
    193219                $order->result->allocations
    194220            ));
    … …  
    197223            // observed/estimated, что и заголовок (ревью major-3).
    198224            $provenRevenue[$key] += array_sum(array_map(
    199                 static fn (Allocation $a): int => $a->touch->isProof() ? $a->amount->cents : 0,
     225                static fn (Allocation $a): int => $a->touch->isProof() ? $a->netCents() : 0,
    200226                $order->result->allocations
    201227            ));
  • clearwayforge/tags/1.0.3/src/Domain/Report/MoneyText.php

    r3702012 r3776286  
    1818 * ── Про арифметику ───────────────────────────────────────────────────────
    1919 *
    20  * Её здесь нет. Центы уже целые, деление на сто — это разрез строки, а не
    21  * деление; `float` не участвует ни на одном шаге (AGENTS.md §3).
     20 * Её здесь нет. Единицы уже целые, деление на степень десяти — это разрез
     21 * строки, а не деление; `float` не участвует ни на одном шаге (AGENTS.md §3).
    2222 *
    2323 * ── Про скудный список символов ──────────────────────────────────────────
    … …  
    3434final class MoneyText
    3535{
    36     /** Целые центы в строку: `-1234550` и `USD` дают `-$12,345.50`. */
    37     public static function format(int $cents, string $currency = 'USD'): string
     36    /**
     37     * Целые единицы в строку: `-1234550` и `USD` дают `-$12,345.50`.
     38     *
     39     * `$decimals` — знаков после запятой у записанной суммы (BUG-CLEAR-ATTR-02):
     40     * 1500 иен при нуле знаков печатаются «JPY 1,500», 12345 динаров-миллей при
     41     * трёх — «BHD 12.345». Прежняя константа «сто» превращала их в «15.00» и
     42     * «123.45». Границы 0–4 — те же, что принимает `Money::fromDecimalString()`.
     43     */
     44    public static function format(int $cents, string $currency = 'USD', int $decimals = 2): string
    3845    {
     46        $decimals = max(0, min(4, $decimals));
    3947        $sign = $cents < 0 ? '-' : '';
    4048        $abs = abs($cents);
     49        $text = $sign . self::symbol($currency);
    4150
    42         return $sign
    43             . self::symbol($currency)
    44             . self::group(intdiv($abs, 100))
     51        if ($decimals === 0) {
     52            return $text . self::group($abs);
     53        }
     54
     55        $factor = 10 ** $decimals;
     56
     57        return $text
     58            . self::group(intdiv($abs, $factor))
    4559            . '.'
    46             . str_pad((string) ($abs % 100), 2, '0', STR_PAD_LEFT);
     60            . str_pad((string) ($abs % $factor), $decimals, '0', STR_PAD_LEFT);
    4761    }
    4862
  • clearwayforge/tags/1.0.3/src/Domain/Report/SourceRow.php

    r3702012 r3776286  
    5050        public readonly int $provenRevenueCents = 0,
    5151        /** Доля в базисных пунктах, посчитанная по весам `provenRevenueCents`, а не по `revenueCents`. */
    52         public readonly int $observedShareBp = 0
     52        public readonly int $observedShareBp = 0,
     53        /** Возвраты по заказам этого источника; уже вычтены из `revenueCents`. */
     54        public readonly int $refundedCents = 0
    5355    ) {
    5456    }
    … …  
    113115            'observed_aov_cents' => $this->observedAovCents(),
    114116            'observed_share_bp' => $this->observedShareBp,
     117            // BUG-CLEAR-MISC-01, п. 14: `revenue_cents` выше уже за вычетом
     118            // возвратов, а сам возврат — здесь, отдельным числом.
     119            'refunded_cents' => $this->refundedCents,
    115120        ];
    116121    }
  • clearwayforge/tags/1.0.3/src/Infra/Attribution/OrderObserver.php

    r3702012 r3776286  
    140140        }
    141141
    142         /**
    143          * Заказ засчитан ИИ-каналу.
    144          *
    145          * Срабатывает один раз на заказ — не на касание, — сразу после того,
    146          * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с
    147          * наибольшей долей; полная раскладка мультитач-заказа едет в
    148          * `$attribution['sources']`.
    149          *
    150          * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё,
    151          * прочитал бы таблицу без этого заказа и решил, что его нет.
    152          *
    153          * @since 1.0.0
    154          *
    155          * @param int                 $order_id    Идентификатор заказа WooCommerce.
    156          * @param string              $agent       Значение AiSource: 'chatgpt', 'perplexity'.
    157          * @param array<string,mixed> $attribution Модель, валюта, суммы в целых
    158          *                                         центах и раскладка по источникам.
     142        /*
     143         * Хук — побочный: чужой код атрибуции не должен стоить магазину
     144         * продажи. `do_action()` не ловит исключений слушателей, а мы стоим
     145         * внутри оформления заказа (после записи разбора, до оплаты), поэтому
     146         * без `try` ошибка чужого слушателя дошла бы до покупателя как
     147         * «не удалось оформить заказ». Ловим всё, что бросается, включая
     148         * `\Error` (несовпавшая сигнатура слушателя).
    159149         */
    160         // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает
    161         do_action(AiOrderEvent::HOOK, ...$event->hookArgs());
     150        try {
     151            /**
     152             * Заказ засчитан ИИ-каналу.
     153             *
     154             * Срабатывает один раз на заказ — не на касание, — сразу после того,
     155             * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с
     156             * наибольшей долей; полная раскладка мультитач-заказа едет в
     157             * `$attribution['sources']`.
     158             *
     159             * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё,
     160             * прочитал бы таблицу без этого заказа и решил, что его нет.
     161             *
     162             * Гарантия слушателям (CLEAR-ATTR-15): A listener exception does not
     163             * break checkout. The observer catches any `\Throwable`, writes one
     164             * WooCommerce log line (level `warning`, source `clearwayforge`; see
     165             * WooCommerce > Status > Logs) with the hook name, the exception class
     166             * and the plugin folder (or file name) it came from, and nothing else
     167             * (no message, order number, email or amounts), and the order goes
     168             * through.
     169             * The listeners after the failing one are not called on that call,
     170             * because the exception stops `do_action()` for that firing. A
     171             * listener must therefore be safe to skip and must not rely on being
     172             * last; one that must not be skipped belongs first in the priority
     173             * order or has to catch its own errors.
     174             *
     175             * @since 1.0.0
     176             *
     177             * @param int                 $order_id    Идентификатор заказа WooCommerce.
     178             * @param string              $agent       Значение AiSource: 'chatgpt', 'perplexity'.
     179             * @param array<string,mixed> $attribution Модель, валюта, суммы в целых
     180             *                                         центах и раскладка по источникам.
     181             */
     182            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает
     183            do_action(AiOrderEvent::HOOK, ...$event->hookArgs());
     184        } catch (\Throwable $listenerError) {
     185            /*
     186             * Одна строка: имя хука, класс исключения и виновник — папка
     187             * плагина, из файла которого оно прилетело (первый сегмент пути
     188             * под WP_PLUGIN_DIR), иначе имя файла. Ни текста исключения
     189             * (в нём чужой код может держать номер заказа или почту), ни
     190             * аргументов хука, ни полного пути. Имя класса анонимного
     191             * исключения (`RuntimeException@anonymous\0<файл>:<строка>$0`) —
     192             * до байта NUL: после него идут полный путь и номер строки, а
     193             * байту NUL в строке лога не место.
     194             *
     195             * Строка идёт в журнал WooCommerce (WooCommerce > Status > Logs,
     196             * источник `clearwayforge`), а не в error_log: в пакет wordpress.org
     197             * вызовы error_log не попадают. Без WooCommerce заказов нет, и
     198             * писать нечего.
     199             */
     200            if (function_exists('wc_get_logger')) {
     201                try {
     202                    wc_get_logger()->warning(
     203                        sprintf(
     204                            '[clearwayforge] a listener on %s threw %s in %s; the order was not affected.',
     205                            AiOrderEvent::HOOK,
     206                            explode("\0", $listenerError::class, 2)[0],
     207                            self::listenerSource($listenerError->getFile(), defined('WP_PLUGIN_DIR') ? (string) constant('WP_PLUGIN_DIR') : null)
     208                        ),
     209                        ['source' => 'clearwayforge']
     210                    );
     211                } catch (\Throwable) {
     212                    // Сломанный обработчик журнала (WooCommerce даёт их добавлять другим плагинам) не отменяет гарантию выше: заказ всё равно проходит.
     213                }
     214            }
     215        }
     216    }
     217
     218    /**
     219     * Кто бросил исключение, для строки лога: имя папки плагина, если файл
     220     * лежит под каталогом плагинов (первый сегмент пути после него; плагин из
     221     * одного файла — имя этого файла), иначе имя файла. Каталог плагинов
     222     * проверяется и как задан, и по `realpath()`: он может быть символической
     223     * ссылкой, а PHP отдаёт путь файла уже раскрытым. Полный путь в лог не
     224     * попадает.
     225     *
     226     * @internal Открыт только ради теста.
     227     */
     228    public static function listenerSource(string $file, ?string $pluginDir): string
     229    {
     230        $file = str_replace('\\', '/', $file);
     231
     232        $roots = [];
     233        if ($pluginDir !== null && $pluginDir !== '') {
     234            $roots[] = $pluginDir;
     235            $real = realpath($pluginDir);
     236            if ($real !== false) {
     237                $roots[] = $real;
     238            }
     239        }
     240
     241        foreach ($roots as $root) {
     242            $root = rtrim(str_replace('\\', '/', $root), '/') . '/';
     243            if (strpos($file, $root) !== 0) {
     244                continue;
     245            }
     246            $first = explode('/', substr($file, strlen($root)), 2)[0];
     247            if ($first !== '') {
     248                return $first;
     249            }
     250        }
     251
     252        return basename($file);
    162253    }
    163254
  • clearwayforge/tags/1.0.3/src/Infra/Collect/VisitCollector.php

    r3702012 r3776286  
    153153     * Адрес для проверки сети краулера.
    154154     *
    155      * Читается только `REMOTE_ADDR`: заголовки вроде `X-Forwarded-For`
    156      * подделываются кем угодно, и поверить им означало бы дать любому
    157      * скраперу способ объявить себя проверенным GPTBot одной строкой. За
    158      * обратным прокси адрес будет неверным — и тогда классификатор честно
    159      * скажет `declared` вместо `verified`, что лучше, чем ложное `verified`.
     155     * Читается `REMOTE_ADDR`, а заголовки прокси (`X-Forwarded-For`) — только
     156     * если владелец назвал доверенные прокси в настройках, и по правилам
     157     * `RequestAddress`. Без списка заголовок не читается никогда:
     158     * подделываемый кем угодно, он давал бы любому скраперу способ объявить
     159     * себя проверенным GPTBot одной строкой. За обратным прокси без списка
     160     * адрес будет неверным — и классификатор честно скажет `declared` вместо
     161     * `verified`, что лучше, чем ложное `verified`.
    160162     */
    161163    private function trustedAddress(): ?string
    162164    {
    163         if (!isset($_SERVER['REMOTE_ADDR']) || !is_string($_SERVER['REMOTE_ADDR'])) {
    164             return null;
    165         }
    166 
    167         $address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
    168 
    169         return $address === '' ? null : $address;
     165        return RequestAddress::fromServer($_SERVER);
    170166    }
    171167
  • clearwayforge/tags/1.0.3/src/Infra/Db/Schema.php

    r3702012 r3776286  
    3636     * Версия схемы. Инкрементируется при любом изменении определений ниже;
    3737     * `Migrator` сравнивает её с сохранённой и решает, звать ли `dbDelta`.
     38     *
     39     * 2 — `touches.refunded_cents` (BUG-CLEAR-MISC-01, п. 14): возврат по
     40     * заказу хранится рядом с суммой доли, отчёты вычитают его при чтении.
    3841     */
    39     public const VERSION = 1;
     42    public const VERSION = 2;
    4043
    4144    /** Общий префикс таблиц плагина поверх префикса WordPress. */
    … …  
    115118             * сменить, и старые отчёты обязаны остаться такими, какими он их
    116119             * видел, иначе выручка прошлого месяца меняется задним числом.
     120             *
     121             * `refunded_cents` — доля возврата, пришедшаяся на эту строку
     122             * (`TouchRepository::setOrderRefund()`). Сумма доли остаётся
     123             * как была, отчёты вычитают возврат при чтении: возврат уменьшает
     124             * заказ, к которому сделан, в периоде самого заказа.
    117125             */
    118126            'touches' => "CREATE TABLE {$p}touches (
    … …  
    125133  model VARCHAR(16) NOT NULL DEFAULT 'last',
    126134  amount_cents BIGINT(20) NOT NULL DEFAULT 0,
     135  refunded_cents BIGINT(20) NOT NULL DEFAULT 0,
    127136  currency CHAR(3) NOT NULL DEFAULT 'USD',
    128137  captured_ms BIGINT(20) UNSIGNED NOT NULL,
  • clearwayforge/tags/1.0.3/src/Infra/Health/HealthReport.php

    r3702012 r3776286  
    107107            [
    108108                'label' => 'Site timezone',
    109                 'problem' => (wp_timezone_string() === 'UTC' && get_option('gmt_offset') === '0')
    110                     ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.'
    111                     : null,
     109                'problem' => self::timezoneProblem(wp_timezone_string(), get_option('gmt_offset')),
    112110            ],
    113111        ];
     112    }
     113
     114    /**
     115     * Предупреждение о поясе UTC — или `null`, если сайт считает сутки по местному поясу.
     116     *
     117     * Смещение сравнивается числом, а не строкой: на сайте с поясом по названию ядро
     118     * отвечает на `get_option('gmt_offset')` через `pre_option_gmt_offset` и отдаёт
     119     * `float 0.0`, а не `'0'`, и строгое сравнение со строкой скрывало предупреждение
     120     * на каждом таком сайте (BUG-CLEAR-DASH-01).
     121     *
     122     * @param mixed $gmtOffset Значение `get_option('gmt_offset')`: число, строка или `false`.
     123     */
     124    public static function timezoneProblem(string $timezone, mixed $gmtOffset): ?string
     125    {
     126        $offset = is_int($gmtOffset) || is_float($gmtOffset) || is_string($gmtOffset) ? (float) $gmtOffset : 0.0;
     127
     128        return $timezone === 'UTC' && $offset === 0.0
     129            ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.'
     130            : null;
    114131    }
    115132
  • clearwayforge/tags/1.0.3/src/Infra/Llms/LlmsRoutes.php

    r3720707 r3776286  
    1616 * кеша каждый заход бота собирал бы полный список категорий и товаров —
    1717 * несколько запросов к базе на каждый из тысяч заходов в сутки.
     18 *
     19 * Страничные кеши плагинов (WP Super Cache, LiteSpeed Cache и другие)
     20 * документ не держат, а всё, что стоит перед сайтом, держит его
     21 * `DocumentCaching::MAX_AGE_SECONDS` (15 минут, как `catalog.json`). Чужие
     22 * кеши мы не чистим (NEIGH-04).
    1823 */
    1924
    … …  
    2328
    2429use ClearwayForge\Domain\Llms\LlmsGenerator;
     30use ClearwayForge\Infra\Pro\Bridge;
    2531use ClearwayForge\Infra\Support\Options;
    2632
    … …  
    3440     *
    3541     * Число в конце ключа — версия того, как собирается документ. Меняется
    36      * сборка — меняется число: иначе обновлённый сайт до шести часов отдаёт
     42     * сборка — меняется число: иначе обновлённый сайт до пятнадцати минут отдаёт
    3743     * текст, собранный прошлой версией, а сторож дрейфа принимает разницу
    3844     * между старым кешем и новой сборкой за изменение каталога. 2 — 1.0.2:
    39      * цены и названия без HTML-сущностей.
    40      */
    41     public const CACHE_MAP = 'cwforge_llms_map_2';
    42     public const CACHE_FULL = 'cwforge_llms_full_2';
    43     public const CACHE_TTL = 6 * HOUR_IN_SECONDS;
     45     * цены и названия без HTML-сущностей. 3 — полная выгрузка грузит до
     46     * `LlmsGenerator::FULL_PRODUCT_LIMIT` товаров, а обе пишут «Showing N of
     47     * M products», когда каталог больше окна (BUG-CLEAR-MISC-01). 4 (только
     48     * полная выгрузка) — она называет адрес машинного фида, когда магазин его
     49     * отдаёт, и без обрезки (BUG-CLEAR-LLMS-02).
     50     */
     51    public const CACHE_MAP = 'cwforge_llms_map_3';
     52    public const CACHE_FULL = 'cwforge_llms_full_4';
     53
     54    /**
     55     * Серверный кеш живёт столько же, сколько документ обещает всем, кто стоит
     56     * перед сайтом: одна константа `DocumentCaching::MAX_AGE_SECONDS` (900 с)
     57     * на `llms.txt`, `llms-full.txt` и `catalog.json`. Сохранение товара кеш
     58     * сбрасывает сразу (`invalidate()`), срок — страховка от правок мимо
     59     * сохранения (импорт, прямая запись в базу).
     60     */
     61    public const CACHE_TTL = DocumentCaching::MAX_AGE_SECONDS;
     62
     63    /**
     64     * Хук аддона: набор платных возможностей изменился (активация, окончание,
     65     * освобождение лицензии). Ядро о лицензиях не знает и только сбрасывает кеш.
     66     */
     67    public const PAID_FEATURES_CHANGED = 'cwforge_paid_features_changed';
    4468
    4569    /** Ключи прошлых версий сборки: их чистит удаление плагина. */
    46     public const RETIRED_CACHE_KEYS = ['cwforge_llms_map', 'cwforge_llms_full'];
     70    public const RETIRED_CACHE_KEYS = [
     71        'cwforge_llms_map',
     72        'cwforge_llms_full',
     73        'cwforge_llms_map_2',
     74        'cwforge_llms_full_2',
     75        'cwforge_llms_full_3',
     76    ];
    4777
    4878    public function register(): void
    … …  
    5383        add_action('woocommerce_update_product', [$this, 'invalidate']);
    5484        add_action('update_option_' . Options::NAME, [$this, 'invalidate']);
     85        // Строка про машинный фид зависит от лицензии аддона (`feedUrl()`): аддон сообщает
     86        // об изменении набора платных возможностей, и документ собирается заново.
     87        add_action(self::PAID_FEATURES_CHANGED, [$this, 'invalidate']);
    5588    }
    5689
    … …  
    6699            $path = (string) (wp_parse_url($requestUri, PHP_URL_PATH) ?? '/');
    67100        }
    68         $path = '/' . ltrim($path, '/');
     101        // Дочерний сайт мультисайта с подпапками живёт по `/sub/`, и его документ — `/sub/llms.txt`
     102        // (BUG-CLEAR-LLMS-01): путь главной страницы этого сайта снимается до сравнения.
     103        $homePath = (string) (wp_parse_url(home_url('/'), PHP_URL_PATH) ?? '/');
     104
     105        $document = self::documentFor($path, $homePath);
     106        if ($document === 'map') {
     107            $this->serve(self::CACHE_MAP, false);
     108        }
     109        if ($document === 'full') {
     110            $this->serve(self::CACHE_FULL, true);
     111        }
     112    }
     113
     114    /**
     115     * Какой документ просит этот путь: `map` (`llms.txt`), `full` (`llms-full.txt`) или `null`.
     116     *
     117     * Сравнение точное: ни регистра, ни косой черты в конце, ни вложенных папок. Корневой
     118     * `/llms.txt` для сайта с home `/sub/` — документ корневого сайта, а не этого; каталог
     119     * корня ему не подставляется.
     120     *
     121     * @param string $requestPath Путь запроса, как пришёл (`/sub/llms.txt`).
     122     * @param string $homePath    Путь главной страницы этого сайта: `/` или `/sub/` на мультисайте с подпапками.
     123     */
     124    public static function documentFor(string $requestPath, string $homePath): ?string
     125    {
     126        $path = '/' . ltrim($requestPath, '/');
     127        $home = rtrim('/' . ltrim($homePath, '/'), '/');
     128
     129        if ($home !== '') {
     130            if (strpos($path, $home . '/') !== 0) {
     131                return null;
     132            }
     133            $path = substr($path, strlen($home));
     134        }
    69135
    70136        if ($path === '/llms.txt') {
    71             $this->serve(self::CACHE_MAP, false);
     137            return 'map';
    72138        }
    73139        if ($path === '/llms-full.txt') {
    74             $this->serve(self::CACHE_FULL, true);
    75         }
     140            return 'full';
     141        }
     142
     143        return null;
     144    }
     145
     146    /**
     147     * Строки заголовков ответа: тип, длина, запрет угадывания типа и срок жизни.
     148     *
     149     * Отдельным методом, чтобы заголовки проверялись без вывода и `exit`.
     150     * Срок свежести и отказ страничным кешам берутся из `DocumentCaching`:
     151     * число одно с `catalog.json` платного аддона.
     152     *
     153     * @return list<string>
     154     */
     155    public static function responseHeaders(int $length): array
     156    {
     157        return [
     158            'Content-Type: text/plain; charset=utf-8',
     159            'Content-Length: ' . $length,
     160            'X-Robots-Tag: noindex',
     161            // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом.
     162            'X-Content-Type-Options: nosniff',
     163            'Cache-Control: ' . DocumentCaching::cacheControl(),
     164            DocumentCaching::LITESPEED_HEADER . ': ' . DocumentCaching::liteSpeedControl(),
     165        ];
     166    }
     167
     168    /**
     169     * Положить собранный документ в серверный кеш на `CACHE_TTL`.
     170     *
     171     * Отдельным методом, чтобы срок проверялся без вывода и `exit`.
     172     */
     173    public static function remember(string $cacheKey, string $body): void
     174    {
     175        set_transient($cacheKey, $body, self::CACHE_TTL);
    76176    }
    77177
    … …  
    80180        $body = get_transient($cacheKey);
    81181        if (!is_string($body) || $body === '') {
    82             $store = (new StoreProfileBuilder())->build();
    83             $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', 50)));
     182            $store = (new StoreProfileBuilder())->build($full);
     183            $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT)));
    84184            $document = $full
    85                 ? $generator->full($store)
    86                 : $generator->map($store, rest_url('cwforge/v1/catalog.json'));
     185                ? $generator->full($store, self::feedUrl())
     186                : $generator->map($store, self::feedUrl());
    87187            $body = $document->render();
    88             set_transient($cacheKey, $body, self::CACHE_TTL);
     188            self::remember($cacheKey, $body);
    89189        }
    90190
    91191        // Заголовки ставятся до вывода и включают длину: бот, читающий поток
    92192        // без длины, не может отличить обрыв соединения от конца документа.
    93         header('Content-Type: text/plain; charset=utf-8');
    94         header('Content-Length: ' . strlen($body));
    95         header('X-Robots-Tag: noindex');
    96         // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом.
    97         header('X-Content-Type-Options: nosniff');
    98         header('Cache-Control: public, max-age=3600');
     193        foreach (self::responseHeaders(strlen($body)) as $line) {
     194            header($line);
     195        }
     196        // Страничные кеши плагинов документ не держат; свежесть для всего, что стоит перед сайтом, — наша (DocumentCaching).
     197        DocumentCaching::excludeFromPageCaches();
    99198
    100199        echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ответ text/plain с nosniff: HTML-экранирование исказило бы документ для агентов.
    … …  
    102201    }
    103202
     203    /**
     204     * Адрес машинного фида — только если этот магазин его отдаёт.
     205     *
     206     * Фид живёт в платном аддоне и закрыт без лицензии. Строка «полный
     207     * каталог — по такому-то адресу», ведущая на отказ, хуже её отсутствия.
     208     */
     209    private static function feedUrl(): ?string
     210    {
     211        return Bridge::instance()->isEnabled('catalog-feed') ? rest_url('cwforge/v1/catalog.json') : null;
     212    }
     213
    104214    public function invalidate(): void
    105215    {
  • clearwayforge/tags/1.0.3/src/Infra/Llms/StoreProfileBuilder.php

    r3720707 r3776286  
    2525namespace ClearwayForge\Infra\Llms;
    2626
     27use ClearwayForge\Domain\Llms\LlmsGenerator;
    2728use ClearwayForge\Domain\Llms\LlmsLink;
    2829use ClearwayForge\Domain\Llms\PlainText;
    … …  
    3435final class StoreProfileBuilder
    3536{
    36     public function build(): StoreProfile
    37     {
     37    /**
     38     * @param bool $full Профиль для `/llms-full.txt`: товаров грузится больше
     39     *                   (`LlmsGenerator::fetchLimit()`), а общее число уходит в документ.
     40     */
     41    public function build(bool $full = false): StoreProfile
     42    {
     43        [$products, $total] = $this->products($full);
     44
    3845        return new StoreProfile(
    3946            name: PlainText::fromHtml(get_bloginfo('name')),
    … …  
    4148            description: PlainText::fromHtml(get_bloginfo('description')),
    4249            categories: $this->categories(),
    43             products: $this->products(),
     50            products: $products,
    4451            policies: $this->policies(),
    4552            pages: $this->pages(),
    … …  
    4754            shippingNote: $this->shippingNote(),
    4855            returnsNote: null,
     56            productsTotal: $total,
    4957        );
    5058    }
    … …  
    8492    }
    8593
    86     /** @return list<LlmsLink> */
    87     private function products(): array
    88     {
    89         $limit = max(1, Options::int('llms_product_limit', 50));
    90 
    91         $products = wc_get_products([
     94    /** @return array{0:list<LlmsLink>,1:?int} Товары окна и сколько их в каталоге всего. */
     95    private function products(bool $full): array
     96    {
     97        $limit = LlmsGenerator::fetchLimit($full, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT));
     98
     99        // Один товар на группу переводов, как в catalog.json (NEIGH-07): `ProductTranslations` — единственное место правила.
     100        $result = ProductTranslations::products([
    92101            'status' => 'publish',
    93102            'limit' => $limit,
    … …  
    95104            'order' => 'DESC',
    96105            'visibility' => 'catalog',
     106            'paginate' => true,
    97107        ]);
    98108
     109        // С `paginate` WooCommerce отдаёт объект с `products` и `total`; без
     110        // него (старые версии, чужие фильтры) — просто список.
     111        $total = null;
     112        $products = $result;
     113        if (is_object($result)) {
     114            $products = $result->products ?? [];
     115            $total = isset($result->total) ? (int) $result->total : null;
     116        }
     117
    99118        if (!is_array($products)) {
    100             return [];
     119            return [[], null];
    101120        }
    102121
    … …  
    113132        }
    114133
    115         return $links;
     134        return [$links, $total];
    116135    }
    117136
  • clearwayforge/tags/1.0.3/src/Infra/Report/DashboardAssembler.php

    r3702012 r3776286  
    2525 * ── Что здесь НЕ происходит ──────────────────────────────────────────────
    2626 *
     27 * Возвраты (BUG-CLEAR-MISC-01, п. 14): сумма доли хранится как была, а
     28 * возврат по заказу лежит рядом (`refunded_cents`) и приходит в `Allocation`.
     29 * Отчёт показывает выручку за вычетом возвратов в периоде самого заказа,
     30 * а сумму возвратов — отдельным числом; здесь возврат только передаётся
     31 * дальше, вычитает его `DashboardBuilder`.
     32 *
    2733 * Пересчёта атрибуции нет: суммы посчитаны в момент заказа и сохранены.
    2834 * Пересчитать их сейчас значило бы применить сегодняшнюю модель к прошлому
    2935 * месяцу и показать владельцу не ту выручку, которую он видел вчера.
     36 *
     37 * Тем более текущая модель не отбирает строки: заказ читается под той
     38 * моделью, под которой записан (`TouchRepository::inWindow()`), и после
     39 * смены настройки старые заказы остаются в отчёте (BUG-CLEAR-ATTR-01).
     40 * Поэтому `$model` в `assemble()` — не фильтр, а запасное значение для
     41 * строки, записанной моделью, которой эта версия не знает.
    3042 */
    3143
    … …  
    6274    }
    6375
    64     public function assemble(ReportWindow $window, AttributionModel $model, string $currency): DashboardReport
    65     {
     76    /**
     77     * @param int|null $minorUnit Знаков после запятой у записанных сумм. Без
     78     *                            указания — настройка магазина, та же, что
     79     *                            читала запись заказа (BUG-CLEAR-ATTR-02).
     80     */
     81    public function assemble(
     82        ReportWindow $window,
     83        AttributionModel $model,
     84        string $currency,
     85        ?int $minorUnit = null
     86    ): DashboardReport {
    6687        return (new DashboardBuilder())->build(
    6788            $window,
    … …  
    7091            $this->crawlerRows($window),
    7192            $currency,
     93            $minorUnit ?? StoreMinorUnit::current(),
    7294        );
    7395    }
    … …  
    81103    {
    82104        $byOrder = [];
    83         foreach ($this->touches->inWindow($fromMs, $toMs, $model->value) as $row) {
     105        foreach ($this->touches->inWindow($fromMs, $toMs) as $row) {
    84106            $source = AiSource::tryFrom($row['source']);
    85107            $via = RecoveredVia::tryFrom($row['recovered_via']);
    … …  
    99121            $byOrder[$row['order_id']]['at'] = $row['captured_ms'];
    100122            $byOrder[$row['order_id']]['currency'] = $row['currency'];
     123            $byOrder[$row['order_id']]['model'] = AttributionModel::tryFrom($row['model']) ?? $model;
    101124            $byOrder[$row['order_id']]['allocations'][] = new Allocation(
    102125                $touch,
    103126                Money::fromCents($row['amount_cents'], $row['currency']),
    104                 1
     127                1,
     128                $row['refunded_cents']
    105129            );
    106130        }
    … …  
    115139                (int) $orderId,
    116140                (int) $data['at'],
    117                 new AttributionResult($model, $data['allocations'], $total)
     141                new AttributionResult($data['model'], $data['allocations'], $total)
    118142            );
    119143        }
  • clearwayforge/tags/1.0.3/src/Infra/Repository/TouchRepository.php

    r3702012 r3776286  
    77 * модель атрибуции, и отчёт за прошлый месяц обязан остаться таким, каким он
    88 * его видел. Выручка, меняющаяся задним числом, обесценивает весь дашборд.
     9 *
     10 * Отсюда правило чтения (BUG-CLEAR-ATTR-01): заказ считается той моделью, под
     11 * которой он записан первой, и текущая настройка модели строки не отбирает.
     12 * Смена модели действует на заказы с момента смены (`OrderObserver` пишет их
     13 * под новой), а прежние заказы остаются в дашборде, Revenue by agent, CSV и
     14 * дайджесте с теми же суммами. Если у заказа есть строки под двумя моделями
     15 * (повторный разбор), читается набор первой записанной: иначе заказ
     16 * посчитался бы дважды.
    917 */
    1018
    … …  
    3139
    3240use ClearwayForge\Domain\Attribution\AttributionResult;
     41use ClearwayForge\Domain\Report\RefundSplit;
    3342use ClearwayForge\Infra\Db\Schema;
    3443
    … …  
    8897
    8998    /**
    90      * Разобранные заказы окна.
     99     * Разобранные заказы окна — каждый под моделью, с которой он записан.
     100     *
     101     * Подзапрос берёт модель самой ранней строки заказа (`id` растёт со
     102     * вставкой), так что повторный разбор под другой моделью выручку не
     103     * удваивает и не подменяет. Уникальный ключ (order_id, source, model)
     104     * ведёт подзапрос по префиксу `order_id`.
     105     *
     106     * `refunded_cents` — доля возврата, пришедшаяся на строку; отчёт вычитает
     107     * её из суммы строки в периоде самого заказа (BUG-CLEAR-MISC-01, п. 14).
    91108     *
    92109     * @return list<array{order_id:int,source:string,recovered_via:string,confidence:int,
    93      *                    amount_cents:int,currency:string,captured_ms:int,model:string}>
    94      */
    95     public function inWindow(int $fromMs, int $toMs, string $model): array
     110     *                    amount_cents:int,refunded_cents:int,currency:string,captured_ms:int,model:string}>
     111     */
     112    public function inWindow(int $fromMs, int $toMs): array
    96113    {
    97114        global $wpdb;
    … …  
    100117        $rows = $wpdb->get_results(
    101118            $wpdb->prepare(
    102                 'SELECT order_id, source, recovered_via, confidence, amount_cents, currency, captured_ms, model
    103                  FROM %i
    104                  WHERE captured_ms >= %d AND captured_ms <= %d AND model = %s
    105                  ORDER BY captured_ms ASC',
     119                'SELECT t.order_id, t.source, t.recovered_via, t.confidence, t.amount_cents,
     120                        t.refunded_cents, t.currency, t.captured_ms, t.model
     121                 FROM %i t
     122                 WHERE t.captured_ms >= %d AND t.captured_ms <= %d
     123                   AND t.model = (
     124                       SELECT h.model FROM %i h
     125                       WHERE h.order_id = t.order_id
     126                       ORDER BY h.id ASC
     127                       LIMIT 1
     128                   )
     129                 ORDER BY t.captured_ms ASC, t.id ASC',
    106130                $table,
    107131                $fromMs,
    108132                $toMs,
    109                 $model
     133                $table
    110134            ),
    111135            ARRAY_A
    … …  
    123147                'confidence' => (int) $r['confidence'],
    124148                'amount_cents' => (int) $r['amount_cents'],
     149                'refunded_cents' => (int) $r['refunded_cents'],
    125150                'currency' => (string) $r['currency'],
    126151                'captured_ms' => (int) $r['captured_ms'],
    … …  
    131156    }
    132157
     158    /** Есть ли у заказа разбор: дёшево отсекает возвраты по заказам, которых мы не считали. */
     159    public function hasOrder(int $orderId): bool
     160    {
     161        global $wpdb;
     162
     163        $table = Schema::table($this->wpPrefix, 'touches');
     164
     165        return $wpdb->get_var($wpdb->prepare('SELECT 1 FROM %i WHERE order_id = %d LIMIT 1', $table, $orderId)) !== null;
     166    }
     167
     168    /**
     169     * Записать, сколько по заказу возвращено всего (BUG-CLEAR-MISC-01, п. 14).
     170     *
     171     * Принимает итог, а не приращение: WooCommerce отвечает «возвращено по
     172     * заказу столько», и повторный вызов с тем же числом, удаление возврата и
     173     * новый частичный возврат дают верный результат без учёта порядка событий.
     174     * Итог делится между строками заказа пропорционально их суммам
     175     * (`RefundSplit`) и ложится в те строки, что читает `inWindow()` —
     176     * записанные под первой моделью заказа.
     177     *
     178     * @return int Сколько строк заказа обновлено; 0 — разбора у заказа нет.
     179     */
     180    public function setOrderRefund(int $orderId, int $refundedCents): int
     181    {
     182        global $wpdb;
     183
     184        $table = Schema::table($this->wpPrefix, 'touches');
     185        $rows = $wpdb->get_results(
     186            $wpdb->prepare(
     187                'SELECT t.id, t.amount_cents
     188                 FROM %i t
     189                 WHERE t.order_id = %d
     190                   AND t.model = (
     191                       SELECT h.model FROM %i h
     192                       WHERE h.order_id = t.order_id
     193                       ORDER BY h.id ASC
     194                       LIMIT 1
     195                   )
     196                 ORDER BY t.id ASC',
     197                $table,
     198                $orderId,
     199                $table
     200            ),
     201            ARRAY_A
     202        );
     203
     204        if (!is_array($rows) || $rows === []) {
     205            return 0;
     206        }
     207
     208        $parts = RefundSplit::apportion(
     209            $refundedCents,
     210            array_values(array_map(static fn (array $r): int => (int) $r['amount_cents'], $rows))
     211        );
     212
     213        foreach (array_values($rows) as $i => $row) {
     214            $wpdb->query(
     215                $wpdb->prepare(
     216                    'UPDATE %i SET refunded_cents = %d WHERE id = %d',
     217                    $table,
     218                    $parts[$i],
     219                    (int) $row['id']
     220                )
     221            );
     222        }
     223
     224        return count($rows);
     225    }
     226
     227    /**
     228     * Следующая пачка различных заказов с разбором, по возрастанию номера.
     229     *
     230     * Курсором служит сам номер заказа: «всё, что больше последнего
     231     * обработанного». Ключ `(order_id, source, model)` отдаёт такую выборку
     232     * без сортировки по таблице целиком (досчёт возвратов, п. 14).
     233     *
     234     * @return list<int>
     235     */
     236    public function orderIdsAfter(int $afterOrderId, int $limit): array
     237    {
     238        global $wpdb;
     239
     240        $table = Schema::table($this->wpPrefix, 'touches');
     241        $ids = $wpdb->get_col(
     242            $wpdb->prepare(
     243                'SELECT DISTINCT order_id FROM %i WHERE order_id > %d ORDER BY order_id ASC LIMIT %d',
     244                $table,
     245                $afterOrderId,
     246                max(1, $limit)
     247            )
     248        );
     249
     250        return is_array($ids) ? array_values(array_map('intval', $ids)) : [];
     251    }
     252
    133253    public function deleteOrder(int $orderId): int
    134254    {
  • clearwayforge/tags/1.0.3/src/Infra/Rest/Routes.php

    r3702012 r3776286  
    132132                            'required' => false,
    133133                        ),
     134                        'trusted_proxies'        => array(
     135                            'type'     => 'string',
     136                            'required' => false,
     137                        ),
    134138                        'delete_data_on_uninstall' => array(
    135139                            'type'     => 'boolean',
  • clearwayforge/tags/1.0.3/src/Infra/Rest/SettingsController.php

    r3702012 r3776286  
    2626
    2727use ClearwayForge\Domain\Attribution\AttributionModel;
     28use ClearwayForge\Domain\Crawler\TrustedProxies;
    2829use ClearwayForge\Infra\Scheduler\CrawlerRangeSources;
    2930use ClearwayForge\Infra\Scheduler\Maintenance;
    … …  
    7475        }
    7576
     77        /*
     78         * Доверенные прокси (BUG-CLEAR-MISC-01, п. 10): от списка зависит, чьему
     79         * слову об адресе верит проверка краулеров. Запись, не являющаяся
     80         * адресом или сетью, отбрасывается, остальные сохраняются; отброшенные
     81         * возвращаются в ответе по имени (`trusted_proxies_rejected`), чтобы
     82         * опечатка не осталась незамеченной. Пустая строка очищает список — и
     83         * заголовки прокси снова не читаются.
     84         */
     85        $rejectedProxies = [];
     86        $overLimitProxies = 0;
     87        if ($request->get_param('trusted_proxies') !== null) {
     88            if (!current_user_can('manage_options')) {
     89                // Список решает, чьему заголовку верить, — это доверие к сети, а
     90                // не настройка отчётов: менеджеру магазина (`manage_woocommerce`)
     91                // оно не принадлежит. Отказ до любой записи, как у удаления данных.
     92                return new WP_REST_Response(
     93                    ['code' => 'cwforge_forbidden', 'message' => 'Only an administrator can change the trusted proxies.'],
     94                    403
     95                );
     96            }
     97
     98            $proxies = TrustedProxies::fromText((string) $request->get_param('trusted_proxies'));
     99            $values['trusted_proxies'] = $proxies->cidrs();
     100            $rejectedProxies = $proxies->rejected();
     101            $overLimitProxies = count($proxies->overLimit());
     102        }
     103
    76104        $deleteOnUninstall = $request->get_param('delete_data_on_uninstall');
    77105        if ($deleteOnUninstall !== null && !current_user_can('delete_plugins')) {
    … …  
    96124        }
    97125
    98         return new WP_REST_Response(self::payload(), 200);
     126        return new WP_REST_Response(self::payload($rejectedProxies, $overLimitProxies), 200);
    99127    }
    100128
    101129    /**
     130     * @param list<string> $rejectedProxies Записи списка прокси, отброшенные в этом запросе как «не адрес».
     131     * @param int $overLimitProxies Сколько годных записей не поместилось в потолок списка в этом запросе.
    102132     * @return array{
    103133     *     show_estimated_revenue:bool,
    … …  
    106136     *     crawler_ranges_refresh:bool,
    107137     *     crawler_ranges_hosts:list<string>,
     138     *     trusted_proxies:string,
     139     *     trusted_proxies_rejected:list<string>,
     140     *     trusted_proxies_over_limit:int,
     141     *     trusted_proxies_limit:int,
     142     *     can_change_proxies:bool,
    108143     *     delete_data_on_uninstall:bool,
    109144     *     can_change_uninstall:bool
    110145     * }
    111146     */
    112     private static function payload(): array
     147    private static function payload(array $rejectedProxies = [], int $overLimitProxies = 0): array
    113148    {
    114149        $current = AttributionModel::tryFrom(Options::string('attribution_model', 'last')) ?? AttributionModel::LastTouch;
    … …  
    125160            // не из текста: согласие даётся на то, что реально произойдёт.
    126161            'crawler_ranges_hosts'     => self::hosts((new CrawlerRangeSources())->all()),
     162            // Список как текст для поля ввода: по записи на строку.
     163            'trusted_proxies'          => implode("\n", TrustedProxies::fromStored(Options::get('trusted_proxies', array()))->cidrs()),
     164            // Что не прошло при последнем сохранении; на чтении всегда пусто.
     165            'trusted_proxies_rejected' => $rejectedProxies,
     166            // Годные записи сверх потолка: считаются отдельно, на чтении всегда 0.
     167            'trusted_proxies_over_limit' => $overLimitProxies,
     168            'trusted_proxies_limit'    => TrustedProxies::MAX,
     169            'can_change_proxies'       => current_user_can('manage_options'),
    127170            'delete_data_on_uninstall' => get_option(Options::DELETE_ON_UNINSTALL) === 'yes',
    128171            'can_change_uninstall'     => current_user_can('delete_plugins'),
  • clearwayforge/tags/1.0.3/src/Infra/Scheduler/Maintenance.php

    r3720707 r3776286  
    3636    public const GROUP = 'clearwayforge';
    3737
     38    /** Метка «очередь проверена»: самолечение не чаще раза в окно, а не на каждый запрос админки. */
     39    public const SCHEDULE_CHECKED = 'cwforge_schedule_checked';
     40
    3841    /** Скачанные сети вендоров. */
    3942    public const RANGES_OPTION = 'cwforge_crawler_ranges';
    … …  
    5457        add_action(self::HOOK_PURGE, [$this, 'purge']);
    5558        add_action(self::HOOK_RANGES, [$this, 'refreshRanges']);
     59        // Только запросы админки: на витрине лишний вопрос к очереди — это
     60        // накладные расходы на каждом хите покупателя (принцип 1 в AGENTS.md).
     61        add_action('admin_init', [self::class, 'ensureScheduled']);
     62    }
     63
     64    /**
     65     * Вернуть уборку в очередь, если активация её не поставила.
     66     *
     67     * `scheduleAll()` зовёт только активация, а плагин попадает на сайт и
     68     * без неё: обновление по FTP, перенос базы без таблиц Action Scheduler,
     69     * очередь, вычищенная чужим инструментом, Action Scheduler, которого не
     70     * было при активации. Без задачи `cwforge_purge_old_data` срок хранения
     71     * (`retention_days`) тихо перестаёт действовать, и таблицы растут. Ту же
     72     * оговорку про FTP несёт `Migrator::migrateIfNeeded()` в `Plugin::wire()`.
     73     *
     74     * Метка «проверено» ставится только когда очередь доступна: иначе одна
     75     * неудачная попытка отключила бы самолечение на всё окно.
     76     */
     77    public static function ensureScheduled(): void
     78    {
     79        if (!function_exists('as_next_scheduled_action')) {
     80            return;
     81        }
     82
     83        if (get_transient(self::SCHEDULE_CHECKED) !== false) {
     84            return;
     85        }
     86
     87        set_transient(self::SCHEDULE_CHECKED, 1, 12 * HOUR_IN_SECONDS);
     88        self::scheduleAll();
    5689    }
    5790
    … …  
    81114
    82115        as_unschedule_all_actions(self::HOOK_PURGE, [], self::GROUP);
     116        // Досчёт возвратов: поставленная пачка не должна пережить плагин (п. 14).
     117        as_unschedule_all_actions(\ClearwayForge\Infra\Attribution\RefundBackfill::HOOK, [], self::GROUP);
    83118        self::unscheduleRanges();
    84119    }
  • clearwayforge/tags/1.0.3/uninstall.php

    r3720707 r3776286  
    3939            \ClearwayForge\Infra\Support\Options::NAME,
    4040            \ClearwayForge\Infra\Scheduler\Maintenance::RANGES_OPTION,
    41             \ClearwayForge\Infra\Support\Options::DELETE_ON_UNINSTALL,
     41            \ClearwayForge\Infra\Attribution\RefundBackfill::OPTION,
    4242        ] as $cwforge_option
    4343    ) {
    … …  
    4545    }
    4646
     47    /*
     48     * Согласие владельца общее для Free и платного аддона: надпись на экране
     49     * настроек — «Delete all Clearway Forge data». Пока на сайте остались
     50     * данные аддона (его метка версии схемы), флаг остаётся, иначе аддон,
     51     * удаляемый вторым, не найдёт согласия и оставит свои таблицы
     52     * (BUG-CLEAR-MISC-01). Аддон уберёт флаг сам, когда уйдёт последним.
     53     */
     54    if (get_option('cwforge_pro_schema_version', null) === null) {
     55        delete_option(\ClearwayForge\Infra\Support\Options::DELETE_ON_UNINSTALL);
     56    }
     57
     58    delete_transient(\ClearwayForge\Infra\Scheduler\Maintenance::SCHEDULE_CHECKED);
    4759    delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_MAP);
    4860    delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_FULL);
  • clearwayforge/tags/1.0.3/vendor/composer/autoload_classmap.php

    r3720707 r3776286  
    2525    'ClearwayForge\\Domain\\Attribution\\SourceDetector' => $baseDir . '/src/Domain/Attribution/SourceDetector.php',
    2626    'ClearwayForge\\Domain\\Attribution\\Touch' => $baseDir . '/src/Domain/Attribution/Touch.php',
     27    'ClearwayForge\\Domain\\Crawler\\ClientAddress' => $baseDir . '/src/Domain/Crawler/ClientAddress.php',
    2728    'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => $baseDir . '/src/Domain/Crawler/CrawlerClassifier.php',
    2829    'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => $baseDir . '/src/Domain/Crawler/CrawlerCorpus.php',
    … …  
    3132    'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => $baseDir . '/src/Domain/Crawler/CrawlerKind.php',
    3233    'ClearwayForge\\Domain\\Crawler\\IpRange' => $baseDir . '/src/Domain/Crawler/IpRange.php',
     34    'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => $baseDir . '/src/Domain/Crawler/TrustedProxies.php',
    3335    'ClearwayForge\\Domain\\Crawler\\Verification' => $baseDir . '/src/Domain/Crawler/Verification.php',
    3436    'ClearwayForge\\Domain\\Llms\\LlmsBlock' => $baseDir . '/src/Domain/Llms/LlmsBlock.php',
    … …  
    5254    'ClearwayForge\\Domain\\Report\\MoneyText' => $baseDir . '/src/Domain/Report/MoneyText.php',
    5355    'ClearwayForge\\Domain\\Report\\PeriodSelector' => $baseDir . '/src/Domain/Report/PeriodSelector.php',
     56    'ClearwayForge\\Domain\\Report\\RefundSplit' => $baseDir . '/src/Domain/Report/RefundSplit.php',
    5457    'ClearwayForge\\Domain\\Report\\ReportWindow' => $baseDir . '/src/Domain/Report/ReportWindow.php',
    5558    'ClearwayForge\\Domain\\Report\\SourceRow' => $baseDir . '/src/Domain/Report/SourceRow.php',
    … …  
    6366    'ClearwayForge\\Infra\\Attribution\\OrderMeta' => $baseDir . '/src/Infra/Attribution/OrderMeta.php',
    6467    'ClearwayForge\\Infra\\Attribution\\OrderObserver' => $baseDir . '/src/Infra/Attribution/OrderObserver.php',
     68    'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => $baseDir . '/src/Infra/Attribution/OrderRefunds.php',
    6569    'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => $baseDir . '/src/Infra/Attribution/OrderVerdictWriter.php',
     70    'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => $baseDir . '/src/Infra/Attribution/RefundBackfill.php',
     71    'ClearwayForge\\Infra\\Attribution\\RefundObserver' => $baseDir . '/src/Infra/Attribution/RefundObserver.php',
    6672    'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => $baseDir . '/src/Infra/Attribution/WcOrderMeta.php',
     73    'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => $baseDir . '/src/Infra/Attribution/WcOrderRefunds.php',
     74    'ClearwayForge\\Infra\\Collect\\RequestAddress' => $baseDir . '/src/Infra/Collect/RequestAddress.php',
    6775    'ClearwayForge\\Infra\\Collect\\RequestSignals' => $baseDir . '/src/Infra/Collect/RequestSignals.php',
    6876    'ClearwayForge\\Infra\\Collect\\VisitCollector' => $baseDir . '/src/Infra/Collect/VisitCollector.php',
    … …  
    7381    'ClearwayForge\\Infra\\Db\\Schema' => $baseDir . '/src/Infra/Db/Schema.php',
    7482    'ClearwayForge\\Infra\\Health\\HealthReport' => $baseDir . '/src/Infra/Health/HealthReport.php',
     83    'ClearwayForge\\Infra\\Llms\\DocumentCaching' => $baseDir . '/src/Infra/Llms/DocumentCaching.php',
    7584    'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => $baseDir . '/src/Infra/Llms/LlmsRoutes.php',
     85    'ClearwayForge\\Infra\\Llms\\ProductTranslations' => $baseDir . '/src/Infra/Llms/ProductTranslations.php',
    7686    'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => $baseDir . '/src/Infra/Llms/StoreProfileBuilder.php',
    7787    'ClearwayForge\\Infra\\Plugin' => $baseDir . '/src/Infra/Plugin.php',
    … …  
    7989    'ClearwayForge\\Infra\\Pro\\Bridge' => $baseDir . '/src/Infra/Pro/Bridge.php',
    8090    'ClearwayForge\\Infra\\Report\\DashboardAssembler' => $baseDir . '/src/Infra/Report/DashboardAssembler.php',
     91    'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => $baseDir . '/src/Infra/Report/StoreMinorUnit.php',
    8192    'ClearwayForge\\Infra\\Repository\\HitRepository' => $baseDir . '/src/Infra/Repository/HitRepository.php',
    8293    'ClearwayForge\\Infra\\Repository\\SessionRepository' => $baseDir . '/src/Infra/Repository/SessionRepository.php',
  • clearwayforge/tags/1.0.3/vendor/composer/autoload_static.php

    r3720707 r3776286  
    4040        'ClearwayForge\\Domain\\Attribution\\SourceDetector' => __DIR__ . '/../..' . '/src/Domain/Attribution/SourceDetector.php',
    4141        'ClearwayForge\\Domain\\Attribution\\Touch' => __DIR__ . '/../..' . '/src/Domain/Attribution/Touch.php',
     42        'ClearwayForge\\Domain\\Crawler\\ClientAddress' => __DIR__ . '/../..' . '/src/Domain/Crawler/ClientAddress.php',
    4243        'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerClassifier.php',
    4344        'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerCorpus.php',
    … …  
    4647        'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerKind.php',
    4748        'ClearwayForge\\Domain\\Crawler\\IpRange' => __DIR__ . '/../..' . '/src/Domain/Crawler/IpRange.php',
     49        'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => __DIR__ . '/../..' . '/src/Domain/Crawler/TrustedProxies.php',
    4850        'ClearwayForge\\Domain\\Crawler\\Verification' => __DIR__ . '/../..' . '/src/Domain/Crawler/Verification.php',
    4951        'ClearwayForge\\Domain\\Llms\\LlmsBlock' => __DIR__ . '/../..' . '/src/Domain/Llms/LlmsBlock.php',
    … …  
    6769        'ClearwayForge\\Domain\\Report\\MoneyText' => __DIR__ . '/../..' . '/src/Domain/Report/MoneyText.php',
    6870        'ClearwayForge\\Domain\\Report\\PeriodSelector' => __DIR__ . '/../..' . '/src/Domain/Report/PeriodSelector.php',
     71        'ClearwayForge\\Domain\\Report\\RefundSplit' => __DIR__ . '/../..' . '/src/Domain/Report/RefundSplit.php',
    6972        'ClearwayForge\\Domain\\Report\\ReportWindow' => __DIR__ . '/../..' . '/src/Domain/Report/ReportWindow.php',
    7073        'ClearwayForge\\Domain\\Report\\SourceRow' => __DIR__ . '/../..' . '/src/Domain/Report/SourceRow.php',
    … …  
    7881        'ClearwayForge\\Infra\\Attribution\\OrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderMeta.php',
    7982        'ClearwayForge\\Infra\\Attribution\\OrderObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderObserver.php',
     83        'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderRefunds.php',
    8084        'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderVerdictWriter.php',
     85        'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundBackfill.php',
     86        'ClearwayForge\\Infra\\Attribution\\RefundObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundObserver.php',
    8187        'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderMeta.php',
     88        'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderRefunds.php',
     89        'ClearwayForge\\Infra\\Collect\\RequestAddress' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestAddress.php',
    8290        'ClearwayForge\\Infra\\Collect\\RequestSignals' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestSignals.php',
    8391        'ClearwayForge\\Infra\\Collect\\VisitCollector' => __DIR__ . '/../..' . '/src/Infra/Collect/VisitCollector.php',
    … …  
    8896        'ClearwayForge\\Infra\\Db\\Schema' => __DIR__ . '/../..' . '/src/Infra/Db/Schema.php',
    8997        'ClearwayForge\\Infra\\Health\\HealthReport' => __DIR__ . '/../..' . '/src/Infra/Health/HealthReport.php',
     98        'ClearwayForge\\Infra\\Llms\\DocumentCaching' => __DIR__ . '/../..' . '/src/Infra/Llms/DocumentCaching.php',
    9099        'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => __DIR__ . '/../..' . '/src/Infra/Llms/LlmsRoutes.php',
     100        'ClearwayForge\\Infra\\Llms\\ProductTranslations' => __DIR__ . '/../..' . '/src/Infra/Llms/ProductTranslations.php',
    91101        'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => __DIR__ . '/../..' . '/src/Infra/Llms/StoreProfileBuilder.php',
    92102        'ClearwayForge\\Infra\\Plugin' => __DIR__ . '/../..' . '/src/Infra/Plugin.php',
    … …  
    94104        'ClearwayForge\\Infra\\Pro\\Bridge' => __DIR__ . '/../..' . '/src/Infra/Pro/Bridge.php',
    95105        'ClearwayForge\\Infra\\Report\\DashboardAssembler' => __DIR__ . '/../..' . '/src/Infra/Report/DashboardAssembler.php',
     106        'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => __DIR__ . '/../..' . '/src/Infra/Report/StoreMinorUnit.php',
    96107        'ClearwayForge\\Infra\\Repository\\HitRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/HitRepository.php',
    97108        'ClearwayForge\\Infra\\Repository\\SessionRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/SessionRepository.php',
  • clearwayforge/tags/1.0.3/vendor/composer/installed.php

    r3720707 r3776286  
    44        'pretty_version' => 'dev-main',
    55        'version' => 'dev-main',
    6         'reference' => 'ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',
     6        'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    1414            'pretty_version' => 'dev-main',
    1515            'version' => 'dev-main',
    16             'reference' => 'ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',
     16            'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c',
    1717            'type' => 'wordpress-plugin',
    1818            'install_path' => __DIR__ . '/../../',
  • clearwayforge/trunk/assets/admin/admin.js

    r3702012 r3776286  
    1111 * ── Почему числа форматируются здесь, а считаются на сервере ─────────────
    1212 *
    13  * Все суммы приходят целыми центами, доли — базисными пунктами. Клиент их
    14  * только показывает. Любая арифметика здесь означала бы второй источник
     13 * Все суммы приходят целыми минорными единицами, доли — базисными пунктами.
     14 * Клиент их только показывает; сколько знаков после запятой у единицы, говорит
     15 * сервер (`cwforgeAdmin.minorUnit` = `wc_get_price_decimals()`, та же точность,
     16 * с которой заказ записан): иены (0 знаков) и динары (3) — не центы. Любая арифметика здесь означала бы второй источник
    1517 * истины: экран и письмо-дайджест начали бы показывать разные числа, и
    1618 * поймать это можно было бы только глазами.
    … …  
    2931        var apiFetch = wp.apiFetch;
    3032
    31         /** Центы в строку с валютой. Форматирование локали делает браузер. */
     33        /**
     34         * Записанные единицы в строку с валютой. Форматирование локали делает
     35         * браузер, а место точки — настройка магазина `config.minorUnit`: 1500
     36         * при нуле знаков это 1500 иен, а не 15 (BUG-CLEAR-ATTR-02). WordPress
     37         * отдаёт скаляры локализации строками, поэтому число разбирается заново;
     38         * без него или вне 0-4 остаются два знака, как было.
     39         */
    3240        function money( cents, currency ) {
    33                 var value = ( cents || 0 ) / 100;
     41                var digits = parseInt( config.minorUnit, 10 );
     42                if ( isNaN( digits ) || digits < 0 || digits > 4 ) {
     43                        digits = 2;
     44                }
     45                var value = ( cents || 0 ) / Math.pow( 10, digits );
    3446                try {
    3547                        return new Intl.NumberFormat( undefined, {
    3648                                style: 'currency',
    3749                                currency: currency || 'USD',
    38                                 maximumFractionDigits: 2,
     50                                minimumFractionDigits: digits,
     51                                maximumFractionDigits: digits,
    3952                        } ).format( value );
    4053                } catch ( e ) {
    4154                        // Неизвестный код валюты роняет Intl. Магазин с экзотической
    4255                        // валютой не должен получать пустой экран вместо цифр.
    43                         return value.toFixed( 2 ) + ' ' + ( currency || '' );
     56                        return value.toFixed( digits ) + ' ' + ( currency || '' );
    4457                }
    4558        }
    … …  
    444457                                { className: 'cwforge-stats' },
    445458                                el( Stat, { label: 'AI-attributed orders', value: String( data.totals.orders ) } ),
     459                                // Выручка выше уже за вычетом возвратов; сами возвраты отдельной строкой.
     460                                el( Stat, { label: 'Refunded', value: money( data.totals.refunded_cents || 0, currency ) } ),
    446461                                el( Stat, { label: 'AI sessions', value: String( data.totals.sessions ) } ),
    447462                                el( Stat, { label: 'Recovered sessions', value: percent( recovered.share_bp ) } ),
    … …  
    743758                var uninstall = uninstallState[ 0 ];
    744759                var setUninstall = uninstallState[ 1 ];
     760                // Доверенные прокси: черновик текста правится в поле и уходит одной
     761                // кнопкой; сервер сохраняет годные записи и называет отброшенные.
     762                var proxiesState = useState( { value: '', saved: '', message: '', rejected: [], overLimit: 0, limit: 0 } );
     763                var proxies = proxiesState[ 0 ];
     764                var setProxies = proxiesState[ 1 ];
     765                // Список прокси правит администратор; менеджеру магазина поле только читается.
     766                var proxiesAllowedState = useState( false );
     767                var proxiesAllowed = proxiesAllowedState[ 0 ];
     768                var setProxiesAllowed = proxiesAllowedState[ 1 ];
    745769
    746770                function load() {
    … …  
    764788                                                saved: '',
    765789                                        } );
     790                                        setProxies( { value: data.trusted_proxies || '', saved: '', message: '', rejected: [], overLimit: 0, limit: data.trusted_proxies_limit || 0 } );
     791                                        setProxiesAllowed( !! data.can_change_proxies );
    766792                                } )
    767793                                .catch( function ( error ) {
    … …  
    814840                                .catch( function () {
    815841                                        setUninstall( { value: ! next, allowed: uninstall.allowed, saved: 'error' } );
     842                                } );
     843                }
     844
     845                function saveProxies() {
     846                        var draft = proxies.value;
     847                        setProxies( { value: draft, saved: 'saving', message: '', rejected: [], overLimit: 0, limit: proxies.limit } );
     848                        apiFetch( {
     849                                path: '/cwforge/v1/settings',
     850                                method: 'POST',
     851                                data: { trusted_proxies: draft },
     852                        } )
     853                                .then( function ( data ) {
     854                                        // Показываем то, что сервер сохранил (без повторов и лишних пробелов),
     855                                        // и называем записи, которые он отбросил.
     856                                        setProxies( {
     857                                                value: data.trusted_proxies || '',
     858                                                saved: 'saved',
     859                                                message: '',
     860                                                rejected: Array.isArray( data.trusted_proxies_rejected ) ? data.trusted_proxies_rejected : [],
     861                                                overLimit: data.trusted_proxies_over_limit || 0,
     862                                                limit: data.trusted_proxies_limit || proxies.limit,
     863                                        } );
     864                                } )
     865                                .catch( function ( error ) {
     866                                        // Черновик остаётся как есть: владелец правит запись, а не вводит всё заново.
     867                                        setProxies( {
     868                                                value: draft,
     869                                                saved: 'error',
     870                                                message: error && error.message ? String( error.message ) : '',
     871                                                rejected: [],
     872                                                overLimit: 0,
     873                                                limit: proxies.limit,
     874                                        } );
    816875                                } );
    817876                }
    … …  
    919978                                'p',
    920979                                { className: 'description' },
    921                                 'The dashboard and the revenue by AI agent report credit every order in the window with this model. ' +
     980                                'The model applies to orders placed from the moment you switch it. Earlier orders keep the model they ' +
     981                                        'were recorded with, so past reports are not recalculated. ' +
    922982                                        'The AI channel column on an order keeps the model that was in force when the order was placed.'
    923983                        ),
    … …  
    9481008                        ),
    9491009                        savedNote( ranges.saved ),
     1010                        el( 'h3', null, 'Trusted reverse proxies' ),
     1011                        el(
     1012                                'label',
     1013                                { htmlFor: 'cwforge-trusted-proxies' },
     1014                                'Networks of the proxies in front of this site, one per line (a network such as 10.0.0.0/8, or a single address)'
     1015                        ),
     1016                        el( 'textarea', {
     1017                                id: 'cwforge-trusted-proxies',
     1018                                className: 'large-text code',
     1019                                rows: 4,
     1020                                placeholder: '10.0.0.0/8',
     1021                                value: proxies.value,
     1022                                disabled: ! proxiesAllowed || proxies.saved === 'saving',
     1023                                onChange: function ( event ) {
     1024                                        setProxies( { value: event.target.value, saved: '', message: '', rejected: [], overLimit: 0, limit: proxies.limit } );
     1025                                },
     1026                        } ),
     1027                        el(
     1028                                'p',
     1029                                { className: 'description' },
     1030                                'Leave this empty unless a reverse proxy or load balancer that you control sits in front of the site. ' +
     1031                                        'While the list is empty the plugin checks crawler visits against the address of the connection only ' +
     1032                                        'and never reads the X-Forwarded-For header, which any visitor can forge. Behind a proxy that address ' +
     1033                                        'is the proxy itself, so crawler visits are shown as "declared". When the connection comes from a ' +
     1034                                        'network listed here, the plugin uses the right-most X-Forwarded-For address that is not in the list.'
     1035                        ),
     1036                        el(
     1037                                'button',
     1038                                {
     1039                                        type: 'button',
     1040                                        className: 'button',
     1041                                        disabled: ! proxiesAllowed || proxies.saved === 'saving',
     1042                                        onClick: saveProxies,
     1043                                },
     1044                                'Save proxies'
     1045                        ),
     1046                        proxiesAllowed
     1047                                ? null
     1048                                : el( 'p', { className: 'description' }, 'Only an administrator can change the trusted proxies.' ),
     1049                        proxies.saved === 'saved' ? el( 'p', { role: 'status' }, 'Saved.' ) : null,
     1050                        proxies.saved === 'saved' && proxies.rejected.length
     1051                                ? el(
     1052                                        'p',
     1053                                        { className: 'notice notice-warning', role: 'alert' },
     1054                                        'Not saved, because they are not an IP address or network: ' + proxies.rejected.join( ', ' )
     1055                                )
     1056                                : null,
     1057                        proxies.saved === 'saved' && proxies.overLimit
     1058                                ? el(
     1059                                        'p',
     1060                                        { className: 'notice notice-warning', role: 'alert' },
     1061                                        'Only the first ' + proxies.limit + ' entries are kept; ' + proxies.overLimit + ' more were not saved.'
     1062                                )
     1063                                : null,
     1064                        proxies.saved === 'error'
     1065                                ? el(
     1066                                        'p',
     1067                                        { className: 'notice notice-error' },
     1068                                        'Could not save' + ( proxies.message ? ': ' + proxies.message : '. Try again.' )
     1069                                )
     1070                                : null,
    9501071                        el( 'h3', null, 'When the plugin is deleted' ),
    9511072                        el(
    … …  
    9831104                /*
    9841105                 * Экраны бесплатного ядра, и только они. Очередь одобрений сюда не
    985                  * входит и входить не может: это код уровня Growth, а этот файл едет
     1106                 * входит и входить не может: это платный код Guard, а этот файл едет
    9861107                 * в бесплатном zip на wp.org, где реализация платной функции на диске
    9871108                 * — отказ на ревью (docs/free-pro-split.md §5). Её рисует свой скрипт
  • clearwayforge/trunk/clearwayforge.php

    r3720707 r3776286  
    22
    33/**
    4  * Plugin Name:       Clearway Forge for WooCommerce
     4 * Plugin Name:       Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce
    55 * Plugin URI:        https://clearwayforge.com/
    66 * Description:       See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database.
    7  * Version:           1.0.2
     7 * Version:           1.0.3
    88 * Requires at least: 6.5
    99 * Requires PHP:      8.2
    … …  
    3838}
    3939
    40 const CWFORGE_VERSION = '1.0.2';
     40const CWFORGE_VERSION = '1.0.3';
    4141const CWFORGE_MIN_PHP = '8.2';
    4242const CWFORGE_MIN_WP = '6.5';
  • clearwayforge/trunk/languages/clearwayforge.pot

    r3702012 r3776286  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Clearway Forge for WooCommerce 0.1.0\n"
    6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/clearwayforge\n"
     5"Project-Id-Version: Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce 1.0.3\n"
     6"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/plugin\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    88"Language-Team: LANGUAGE <LL@li.org>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-26T18:36:47+00:00\n"
     12"POT-Creation-Date: 2026-10-30T22:19:42+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1717#. Plugin Name of the plugin
    1818#: clearwayforge.php
    19 msgid "Clearway Forge for WooCommerce"
     19msgid "Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce"
    2020msgstr ""
    2121
    2222#. Plugin URI of the plugin
    2323#: clearwayforge.php
    24 msgid "https://github.com/avakode/clearway-forge"
     24msgid "https://clearwayforge.com/"
    2525msgstr ""
    2626
    2727#. Description of the plugin
    2828#: clearwayforge.php
    29 msgid "See what AI assistants earn you, make your store readable to them, and keep agent actions under your control."
     29msgid "See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database."
    3030msgstr ""
    3131
    … …  
    3535msgstr ""
    3636
    37 #: src/Admin/Menu.php:47
    38 #: src/Admin/Menu.php:48
    39 #: src/Admin/Menu.php:109
     37#: src/Admin/AgentRevenueScreen.php:83
     38#: src/Admin/Menu.php:101
     39msgid "Revenue by AI agent"
     40msgstr ""
     41
     42#. translators: %d: number of days in the longest window the report can show.
     43#: src/Admin/AgentRevenueScreen.php:94
     44#, php-format
     45msgid "Showing the last %d days, the longest window the report can show. Nothing was deleted."
     46msgstr ""
     47
     48#: src/Admin/AgentRevenueScreen.php:126
     49msgid "Period"
     50msgstr ""
     51
     52#: src/Admin/AgentRevenueScreen.php:139
     53msgid "Show"
     54msgstr ""
     55
     56#: src/Admin/AgentRevenueScreen.php:151
     57msgid "AI agent"
     58msgstr ""
     59
     60#: src/Admin/AgentRevenueScreen.php:152
     61msgid "Revenue"
     62msgstr ""
     63
     64#: src/Admin/AgentRevenueScreen.php:153
     65msgid "Orders"
     66msgstr ""
     67
     68#: src/Admin/AgentRevenueScreen.php:154
     69msgid "Sessions"
     70msgstr ""
     71
     72#: src/Admin/AgentRevenueScreen.php:155
     73msgid "Conversion"
     74msgstr ""
     75
     76#: src/Admin/AgentRevenueScreen.php:156
     77msgid "Average order"
     78msgstr ""
     79
     80#: src/Admin/AgentRevenueScreen.php:157
     81msgid "Share"
     82msgstr ""
     83
     84#: src/Admin/AgentRevenueScreen.php:167
     85msgid "No AI-attributed orders in this period yet."
     86msgstr ""
     87
     88#: src/Admin/AgentRevenueScreen.php:187
     89msgid "Total"
     90msgstr ""
     91
     92#. translators: %s: revenue proven by a referrer or a campaign tag.
     93#: src/Admin/AgentRevenueScreen.php:194
     94#, php-format
     95msgid "%s proven by a referrer or a tag"
     96msgstr ""
     97
     98#: src/Admin/AgentRevenueScreen.php:203
     99msgid "Refunded"
     100msgstr ""
     101
     102#: src/Admin/AgentRevenueScreen.php:205
     103msgid "Already taken off the revenue above."
     104msgstr ""
     105
     106#. translators: 1: start date, 2: end date, 3: IANA time zone name.
     107#: src/Admin/AgentRevenueScreen.php:214
     108#, php-format
     109msgid "Computed by your own store, %1$s to %2$s in %3$s."
     110msgstr ""
     111
     112#: src/Admin/AgentRevenueScreen.php:220
     113msgid "Nothing left your server to produce it."
     114msgstr ""
     115
     116#: src/Admin/Menu.php:86
     117#: src/Admin/Menu.php:200
    40118msgid "AI Channel"
    41119msgstr ""
    42120
    43 #: src/Admin/Menu.php:58
    44 #: src/Admin/Menu.php:114
     121#: src/Admin/Menu.php:91
     122msgid "Measurement"
     123msgstr ""
     124
     125#: src/Admin/Menu.php:102
     126msgid "Revenue by agent"
     127msgstr ""
     128
     129#: src/Admin/Menu.php:110
     130#: src/Admin/Menu.php:205
    45131msgid "AI Channel settings"
    46132msgstr ""
    47133
    48 #: src/Admin/Menu.php:59
     134#: src/Admin/Menu.php:111
    49135msgid "Settings"
    50136msgstr ""
    51137
    52 #: src/Admin/Menu.php:67
     138#: src/Admin/Menu.php:119
     139#: src/Admin/Menu.php:211
     140msgid "AI Channel health"
     141msgstr ""
     142
    53143#: src/Admin/Menu.php:120
    54 msgid "AI Channel health"
    55 msgstr ""
    56 
    57 #: src/Admin/Menu.php:68
    58144msgid "Health"
    59145msgstr ""
    60146
    61 #: src/Admin/Menu.php:136
     147#: src/Admin/Menu.php:131
     148msgid "AI Channel catalog"
     149msgstr ""
     150
     151#: src/Admin/Menu.php:132
     152#: src/Admin/Menu.php:217
     153msgid "Catalog"
     154msgstr ""
     155
     156#: src/Admin/Menu.php:141
     157msgid "AI Channel agents"
     158msgstr ""
     159
     160#: src/Admin/Menu.php:142
     161#: src/Admin/Menu.php:223
     162msgid "Agents"
     163msgstr ""
     164
     165#: src/Admin/Menu.php:274
     166msgid "The Clearway Forge add-on has not added any screens here yet."
     167msgstr ""
     168
     169#: src/Admin/Menu.php:304
     170msgid "Catalog needs an active WooCommerce store."
     171msgstr ""
     172
     173#: src/Admin/Menu.php:305
     174msgid "Feed drift and SKU tasks come with the separate Clearway Forge add-on."
     175msgstr ""
     176
     177#: src/Admin/Menu.php:309
     178msgid "Agents needs an active WooCommerce store."
     179msgstr ""
     180
     181#: src/Admin/Menu.php:310
     182msgid "Agent keys, scopes and the journal come with the separate Clearway Forge add-on."
     183msgstr ""
     184
     185#: src/Admin/Menu.php:334
     186#: src/Admin/Menu.php:393
     187msgid "About the add-on"
     188msgstr ""
     189
     190#: src/Admin/Menu.php:367
    62191msgid "Loading your AI channel data..."
    63192msgstr ""
    64193
    65 #: src/Admin/Menu.php:155
    66 msgid "Available in the paid add-on"
    67 msgstr ""
    68 
    69 #: src/Admin/Menu.php:162
    70 msgid "(active)"
    71 msgstr ""
    72 
    73 #: src/Admin/Menu.php:167
    74 msgid "What the add-on adds"
    75 msgstr ""
    76 
    77 #. translators: %d: maximum number of days available without the paid add-on.
    78 #: src/Infra/Rest/Routes.php:143
    79 #, php-format
    80 msgid "Reporting windows longer than %d days need the paid add-on."
    81 msgstr ""
     194#: src/Admin/Menu.php:385
     195msgid "In the separate Clearway Forge add-on"
     196msgstr ""
     197
     198#: src/Admin/OrdersColumn.php:99
     199#: src/Admin/OrdersColumn.php:105
     200msgid "AI channel"
     201msgstr ""
     202
     203#. translators: %d: largest reporting window the plugin accepts.
     204#: src/Infra/Rest/Routes.php:189
     205#, php-format
     206msgid "Reporting windows longer than %d days are not supported."
     207msgstr ""
  • clearwayforge/trunk/readme.txt

    r3774326 r3776286  
    55Tested up to: 7.1
    66Requires PHP: 8.2
    7 Stable tag: 1.0.2
     7Stable tag: 1.0.3
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    168168== Changelog ==
    169169
     170= 1.0.3 =
     171* Revenue is shown net of refunds. The dashboard and Revenue by agent take a refund off the order's own period and show the refunded total on its own line. Refunds made before the update are counted in the background, 200 orders at a time.
     172* Changing the attribution model no longer hides your history: each order stays under the model it was recorded with, and the new model applies to orders from then on.
     173* Amounts follow your store's number of price decimals (zero-decimal currencies included) in the dashboard and Revenue by agent. The privacy export shows the attributed amount as an amount instead of a raw count of cents.
     174* llms.txt and llms-full.txt: page-cache plugins are told not to store them (the DONOTCACHEPAGE constant, and a no-cache header for LiteSpeed Cache). Both documents now share one freshness of 15 minutes: Cache-Control said 1 hour and the server copy of llms.txt lived 6 hours. Saving a product still clears the server copy.
     175* llms.txt and llms-full.txt on Polylang and WPML sites list one product per translation group, in your default language. Without a multilingual plugin nothing changes.
     176* llms-full.txt lists up to 500 products instead of repeating the short list, says "Showing N of M products" when it is cut, and points to the JSON catalogue feed when the paid add-on serves it.
     177* On a multisite network with subdirectories, /sub/llms.txt now returns that site's own document.
     178* A listener on the `clearwayforge/ai_order` hook that throws no longer breaks checkout. The exception is written as one line to WooCommerce > Status > Logs (source clearwayforge) by class name and the folder of the plugin it came from, with no message and no order data, and the order goes through.
     179* New setting, Trusted reverse proxies: crawler address verification reads X-Forwarded-For for requests that arrive from a proxy on your list and ignores it for every other request. The list is empty by default, so the header is ignored. Changing it needs an administrator. IPv4 addresses carried in IPv6 notation now match the IPv4 vendor ranges.
     180* Health: the UTC timezone warning no longer misses sites whose timezone is set by name.
     181* The retention clean-up job is put back in the queue from the admin when activation missed it (an FTP update, a migrated database).
     182* Deleting the plugin before the paid add-on keeps your "delete all data" choice for the add-on, so its tables go too.
     183
    170184= 1.0.2 =
    171185* llms.txt and llms-full.txt show prices, names and descriptions as plain characters: a dollar price carried the HTML entity for the "$" sign instead of the sign itself, and a sale price repeated itself. A sale now reads the way WooCommerce says it to screen readers ("Original price was: $150.00. Current price is: $129.00."). The documents cached by 1.0.1 are not served after the update.
  • clearwayforge/trunk/src/Admin/AgentRevenueScreen.php

    r3702012 r3776286  
    9191                esc_html(
    9292                    sprintf(
    93                         /* translators: %d: number of days the free plugin can show. */
     93                        /* translators: %d: number of days in the longest window the report can show. */
    9494                        __(
    95                             'Showing the last %d days. Longer windows need the paid add-on; nothing was deleted.',
     95                            'Showing the last %d days, the longest window the report can show. Nothing was deleted.',
    9696                            'clearwayforge'
    9797                        ),
    … …  
    196196            )
    197197        ));
    198         echo '</tr></tfoot></table>';
     198        echo '</tr>';
     199        // Выручка в таблице уже за вычетом возвратов; сумма возвратов стоит
     200        // отдельной строкой (BUG-CLEAR-MISC-01, п. 14).
     201        printf(
     202            '<tr class="cwforge-refunded"><th scope="row">%s</th><td>%s</td><td colspan="5">%s</td></tr>',
     203            esc_html__('Refunded', 'clearwayforge'),
     204            esc_html($totals['refunded']),
     205            esc_html__('Already taken off the revenue above.', 'clearwayforge')
     206        );
     207        echo '</tfoot></table>';
    199208
    200209        printf(
  • clearwayforge/trunk/src/Admin/Assets.php

    r3702012 r3776286  
    2727namespace ClearwayForge\Admin;
    2828
     29use ClearwayForge\Infra\Report\StoreMinorUnit;
    2930use ClearwayForge\Infra\Rest\Routes;
    3031
    … …  
    5556            'nonce' => wp_create_nonce('wp_rest'),
    5657            'currency' => get_woocommerce_currency(),
     58            // Знаков после запятой у записанных сумм: `money()` ставит точку по
     59            // ним, а не делением на сто (BUG-CLEAR-ATTR-02).
     60            'minorUnit' => StoreMinorUnit::current(),
    5761            'timezone' => wp_timezone_string(),
    5862            'settingsUrl' => esc_url_raw(admin_url('admin.php?page=cwforge-settings')),
  • clearwayforge/trunk/src/Domain/Attribution/Allocation.php

    r3702012 r3776286  
    33/**
    44 * Доля заказа, приписанная одному касанию.
     5 *
     6 * ── Возврат (BUG-CLEAR-MISC-01, п. 14) ───────────────────────────────────
     7 *
     8 * `$amount` — сумма доли в момент заказа, она не меняется: движок атрибуции
     9 * считает её один раз. Возврат приходит позже и хранится рядом
     10 * (`$refundedCents`, доля возврата, пришедшаяся на эту строку): отчёты
     11 * показывают выручку за вычетом возврата (`netCents()`), а сам возврат
     12 * отдельной строкой.
    513 */
    614
    … …  
    1624        public readonly Touch $touch,
    1725        public readonly Money $amount,
    18         public readonly int $weight
     26        public readonly int $weight,
     27        public readonly int $refundedCents = 0
    1928    ) {
    2029    }
     30
     31    /**
     32     * Возврат в пределах доли: вернуть больше, чем заплачено за долю, нельзя,
     33     * поэтому лишнее (повторный разбор с меньшей суммой, ошибка округления)
     34     * отрезается, а выручка не уходит в минус.
     35     */
     36    public function effectiveRefundCents(): int
     37    {
     38        return max(0, min($this->refundedCents, $this->amount->cents));
     39    }
     40
     41    /** Доля за вычетом возврата — то, что попадает в выручку. */
     42    public function netCents(): int
     43    {
     44        return $this->amount->cents - $this->effectiveRefundCents();
     45    }
    2146}
  • clearwayforge/trunk/src/Domain/Attribution/AttributionModel.php

    r3702012 r3776286  
    44 * Как делить заказ между касаниями.
    55 *
    6  * Free-тариф получает только `LastTouch`, и это не искусственное урезание:
    7  * мульти-тач осмыслен, когда истории накопилось хотя бы на месяц, а тридцать
    8  * дней истории — ровно граница free.
     6 * Все четыре модели входят в бесплатный плагин (readme на wp.org обещает это
     7 * прямо), и платный аддон их не отнимает. Модель, выбранная в настройках,
     8 * действует на заказы с момента выбора: каждый заказ записывается под ней и
     9 * потом читается под ней же, задним числом отчёты не пересчитываются
     10 * (`TouchRepository`, `DashboardAssembler`).
    911 */
    1012
  • clearwayforge/trunk/src/Domain/Llms/StoreProfile.php

    r3702012 r3776286  
    1919     * @param list<LlmsLink> $policies
    2020     * @param list<LlmsLink> $pages
     21     * @param ?int           $productsTotal Сколько товаров в каталоге всего. `products`
     22     *                                      — только та часть, что загружена; без этого
     23     *                                      числа документ не может сказать, что обрезан.
    2124     */
    2225    public function __construct(
    … …  
    3033        public readonly string $currency = 'USD',
    3134        public readonly ?string $shippingNote = null,
    32         public readonly ?string $returnsNote = null
     35        public readonly ?string $returnsNote = null,
     36        public readonly ?int $productsTotal = null
    3337    ) {
    3438    }
  • clearwayforge/trunk/src/Domain/Report/AgentRevenueTable.php

    r3702012 r3776286  
    3232{
    3333    /**
     34     * `revenue_cents` — за вычетом возвратов, `refunded_cents` — сами возвраты
     35     * по этому источнику (BUG-CLEAR-MISC-01, п. 14).
     36     *
    3437     * @return list<array{agent:string,label:string,orders:int,sessions:int,revenue_cents:int,
    35      *                    revenue:string,aov:string,aov_cents:int,conversion:string,conversion_bp:int,
    36      *                    share:string,share_bp:int}>
     38     *                    revenue:string,refunded_cents:int,refunded:string,aov:string,aov_cents:int,
     39     *                    conversion:string,conversion_bp:int,share:string,share_bp:int}>
    3740     */
    3841    public static function rows(DashboardReport $report): array
    … …  
    4750                'sessions' => $row->sessions,
    4851                'revenue_cents' => $row->revenueCents,
    49                 'revenue' => MoneyText::format($row->revenueCents, $report->currency),
    50                 'aov' => MoneyText::format($row->aovCents(), $report->currency),
     52                'revenue' => MoneyText::format($row->revenueCents, $report->currency, $report->minorUnit),
     53                'refunded_cents' => $row->refundedCents,
     54                'refunded' => MoneyText::format($row->refundedCents, $report->currency, $report->minorUnit),
     55                'aov' => MoneyText::format($row->aovCents(), $report->currency, $report->minorUnit),
    5156                'aov_cents' => $row->aovCents(),
    5257                'conversion' => MoneyText::percent($row->conversionBp()),
    … …  
    6873     * молча чинить.
    6974     *
    70      * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string,proven:string}
     75     * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string,
     76     *                    refunded_cents:int,refunded:string,proven:string}
    7177     */
    7278    public static function totals(DashboardReport $report): array
    … …  
    8288            'revenue_cents' => $report->revenueCents,
    8389            'rows_cents' => $rowsCents,
    84             'revenue' => MoneyText::format($report->revenueCents, $report->currency),
    85             'proven' => MoneyText::format($report->provenRevenueCents, $report->currency),
     90            'revenue' => MoneyText::format($report->revenueCents, $report->currency, $report->minorUnit),
     91            'refunded_cents' => $report->refundedCents,
     92            'refunded' => MoneyText::format($report->refundedCents, $report->currency, $report->minorUnit),
     93            'proven' => MoneyText::format($report->provenRevenueCents, $report->currency, $report->minorUnit),
    8694        ];
    8795    }
  • clearwayforge/trunk/src/Domain/Report/DashboardBuilder.php

    r3702012 r3776286  
    2424     * @param list<Touch>           $sessions По одному касанию на сессию окна.
    2525     * @param list<CrawlerRow>      $crawlers Агрегаты заходов ботов за окно.
     26     * @param int                   $minorUnit Знаков после запятой у записанных сумм
     27     *                                         (`wc_get_price_decimals()`), отчёт только несёт их до печати.
    2628     */
    2729    public function build(
    … …  
    3032        array $sessions,
    3133        array $crawlers = [],
    32         string $currency = 'USD'
     34        string $currency = 'USD',
     35        int $minorUnit = 2
    3336    ): DashboardReport {
    3437        $orders = array_values(array_filter($orders, static fn (AttributedOrder $o): bool => $window->contains($o->atMs)));
    … …  
    3841        $provenRevenueBySource = [];
    3942        $ordersBySource = [];
     43        $refundedBySource = [];
    4044        $totalRevenue = 0;
    4145        $provenRevenue = 0;
     46        $totalRefunded = 0;
    4247        $attributedOrders = 0;
    4348
    … …  
    5459            foreach ($order->result->allocations as $allocation) {
    5560                $key = $allocation->touch->source->value;
    56                 $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $allocation->amount->cents;
    57                 $totalRevenue += $allocation->amount->cents;
     61                // Выручка — за вычетом возврата по этой доле (BUG-CLEAR-MISC-01,
     62                // п. 14); сам возврат копится отдельно.
     63                $net = $allocation->netCents();
     64                $refunded = $allocation->effectiveRefundCents();
     65                $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $net;
     66                $refundedBySource[$key] = ($refundedBySource[$key] ?? 0) + $refunded;
     67                $totalRevenue += $net;
     68                $totalRefunded += $refunded;
    5869                if ($allocation->touch->isProof()) {
    59                     $provenRevenue += $allocation->amount->cents;
     70                    $provenRevenue += $net;
    6071                    // Та же доказанность, только по строке источника, а не
    6172                    // по всему магазину — таблица «Where the money came
    6273                    // from» обязана иметь ту же пару observed/estimated,
    6374                    // что и заголовок (ревью major-3).
    64                     $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $allocation->amount->cents;
     75                    $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $net;
    6576                }
    6677                if (!isset($seenSources[$key])) {
    … …  
    8495        }
    8596
    86         $bySource = $this->sourceRows($revenueBySource, $ordersBySource, $sessionsBySource, $provenRevenueBySource);
     97        $bySource = $this->sourceRows(
     98            $revenueBySource,
     99            $ordersBySource,
     100            $sessionsBySource,
     101            $provenRevenueBySource,
     102            $refundedBySource
     103        );
    87104        $days = $this->days($window, $orders);
    88105
    … …  
    98115            recoveredSessions: $recoveredSessions,
    99116            provenRevenueCents: $provenRevenue,
     117            refundedCents: $totalRefunded,
     118            minorUnit: $minorUnit,
    100119        );
    101120    }
    … …  
    111130     * @param array<string,int> $provenRevenue Доля `$revenue` за этот источник,
    112131     *        доказанная реферером или меткой (ревью major-3).
     132     * @param array<string,int> $refunded Возвраты по источнику (уже вычтены из `$revenue`).
    113133     * @return list<SourceRow>
    114134     */
    115     private function sourceRows(array $revenue, array $orders, array $sessions, array $provenRevenue = []): array
    116     {
     135    private function sourceRows(
     136        array $revenue,
     137        array $orders,
     138        array $sessions,
     139        array $provenRevenue = [],
     140        array $refunded = []
     141    ): array {
    117142        $present = [];
    118143        foreach (AiSource::ordered() as $source) {
    … …  
    153178                $provenRevenue[$key] ?? 0,
    154179                $observedShares[$i] ?? 0,
     180                $refunded[$key] ?? 0,
    155181            );
    156182        }
    … …  
    190216            }
    191217            $revenue[$key] += array_sum(array_map(
    192                 static fn (Allocation $a): int => $a->amount->cents,
     218                static fn (Allocation $a): int => $a->netCents(),
    193219                $order->result->allocations
    194220            ));
    … …  
    197223            // observed/estimated, что и заголовок (ревью major-3).
    198224            $provenRevenue[$key] += array_sum(array_map(
    199                 static fn (Allocation $a): int => $a->touch->isProof() ? $a->amount->cents : 0,
     225                static fn (Allocation $a): int => $a->touch->isProof() ? $a->netCents() : 0,
    200226                $order->result->allocations
    201227            ));
  • clearwayforge/trunk/src/Domain/Report/MoneyText.php

    r3702012 r3776286  
    1818 * ── Про арифметику ───────────────────────────────────────────────────────
    1919 *
    20  * Её здесь нет. Центы уже целые, деление на сто — это разрез строки, а не
    21  * деление; `float` не участвует ни на одном шаге (AGENTS.md §3).
     20 * Её здесь нет. Единицы уже целые, деление на степень десяти — это разрез
     21 * строки, а не деление; `float` не участвует ни на одном шаге (AGENTS.md §3).
    2222 *
    2323 * ── Про скудный список символов ──────────────────────────────────────────
    … …  
    3434final class MoneyText
    3535{
    36     /** Целые центы в строку: `-1234550` и `USD` дают `-$12,345.50`. */
    37     public static function format(int $cents, string $currency = 'USD'): string
     36    /**
     37     * Целые единицы в строку: `-1234550` и `USD` дают `-$12,345.50`.
     38     *
     39     * `$decimals` — знаков после запятой у записанной суммы (BUG-CLEAR-ATTR-02):
     40     * 1500 иен при нуле знаков печатаются «JPY 1,500», 12345 динаров-миллей при
     41     * трёх — «BHD 12.345». Прежняя константа «сто» превращала их в «15.00» и
     42     * «123.45». Границы 0–4 — те же, что принимает `Money::fromDecimalString()`.
     43     */
     44    public static function format(int $cents, string $currency = 'USD', int $decimals = 2): string
    3845    {
     46        $decimals = max(0, min(4, $decimals));
    3947        $sign = $cents < 0 ? '-' : '';
    4048        $abs = abs($cents);
     49        $text = $sign . self::symbol($currency);
    4150
    42         return $sign
    43             . self::symbol($currency)
    44             . self::group(intdiv($abs, 100))
     51        if ($decimals === 0) {
     52            return $text . self::group($abs);
     53        }
     54
     55        $factor = 10 ** $decimals;
     56
     57        return $text
     58            . self::group(intdiv($abs, $factor))
    4559            . '.'
    46             . str_pad((string) ($abs % 100), 2, '0', STR_PAD_LEFT);
     60            . str_pad((string) ($abs % $factor), $decimals, '0', STR_PAD_LEFT);
    4761    }
    4862
  • clearwayforge/trunk/src/Domain/Report/SourceRow.php

    r3702012 r3776286  
    5050        public readonly int $provenRevenueCents = 0,
    5151        /** Доля в базисных пунктах, посчитанная по весам `provenRevenueCents`, а не по `revenueCents`. */
    52         public readonly int $observedShareBp = 0
     52        public readonly int $observedShareBp = 0,
     53        /** Возвраты по заказам этого источника; уже вычтены из `revenueCents`. */
     54        public readonly int $refundedCents = 0
    5355    ) {
    5456    }
    … …  
    113115            'observed_aov_cents' => $this->observedAovCents(),
    114116            'observed_share_bp' => $this->observedShareBp,
     117            // BUG-CLEAR-MISC-01, п. 14: `revenue_cents` выше уже за вычетом
     118            // возвратов, а сам возврат — здесь, отдельным числом.
     119            'refunded_cents' => $this->refundedCents,
    115120        ];
    116121    }
  • clearwayforge/trunk/src/Infra/Attribution/OrderObserver.php

    r3702012 r3776286  
    140140        }
    141141
    142         /**
    143          * Заказ засчитан ИИ-каналу.
    144          *
    145          * Срабатывает один раз на заказ — не на касание, — сразу после того,
    146          * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с
    147          * наибольшей долей; полная раскладка мультитач-заказа едет в
    148          * `$attribution['sources']`.
    149          *
    150          * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё,
    151          * прочитал бы таблицу без этого заказа и решил, что его нет.
    152          *
    153          * @since 1.0.0
    154          *
    155          * @param int                 $order_id    Идентификатор заказа WooCommerce.
    156          * @param string              $agent       Значение AiSource: 'chatgpt', 'perplexity'.
    157          * @param array<string,mixed> $attribution Модель, валюта, суммы в целых
    158          *                                         центах и раскладка по источникам.
     142        /*
     143         * Хук — побочный: чужой код атрибуции не должен стоить магазину
     144         * продажи. `do_action()` не ловит исключений слушателей, а мы стоим
     145         * внутри оформления заказа (после записи разбора, до оплаты), поэтому
     146         * без `try` ошибка чужого слушателя дошла бы до покупателя как
     147         * «не удалось оформить заказ». Ловим всё, что бросается, включая
     148         * `\Error` (несовпавшая сигнатура слушателя).
    159149         */
    160         // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает
    161         do_action(AiOrderEvent::HOOK, ...$event->hookArgs());
     150        try {
     151            /**
     152             * Заказ засчитан ИИ-каналу.
     153             *
     154             * Срабатывает один раз на заказ — не на касание, — сразу после того,
     155             * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с
     156             * наибольшей долей; полная раскладка мультитач-заказа едет в
     157             * `$attribution['sources']`.
     158             *
     159             * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё,
     160             * прочитал бы таблицу без этого заказа и решил, что его нет.
     161             *
     162             * Гарантия слушателям (CLEAR-ATTR-15): A listener exception does not
     163             * break checkout. The observer catches any `\Throwable`, writes one
     164             * WooCommerce log line (level `warning`, source `clearwayforge`; see
     165             * WooCommerce > Status > Logs) with the hook name, the exception class
     166             * and the plugin folder (or file name) it came from, and nothing else
     167             * (no message, order number, email or amounts), and the order goes
     168             * through.
     169             * The listeners after the failing one are not called on that call,
     170             * because the exception stops `do_action()` for that firing. A
     171             * listener must therefore be safe to skip and must not rely on being
     172             * last; one that must not be skipped belongs first in the priority
     173             * order or has to catch its own errors.
     174             *
     175             * @since 1.0.0
     176             *
     177             * @param int                 $order_id    Идентификатор заказа WooCommerce.
     178             * @param string              $agent       Значение AiSource: 'chatgpt', 'perplexity'.
     179             * @param array<string,mixed> $attribution Модель, валюта, суммы в целых
     180             *                                         центах и раскладка по источникам.
     181             */
     182            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает
     183            do_action(AiOrderEvent::HOOK, ...$event->hookArgs());
     184        } catch (\Throwable $listenerError) {
     185            /*
     186             * Одна строка: имя хука, класс исключения и виновник — папка
     187             * плагина, из файла которого оно прилетело (первый сегмент пути
     188             * под WP_PLUGIN_DIR), иначе имя файла. Ни текста исключения
     189             * (в нём чужой код может держать номер заказа или почту), ни
     190             * аргументов хука, ни полного пути. Имя класса анонимного
     191             * исключения (`RuntimeException@anonymous\0<файл>:<строка>$0`) —
     192             * до байта NUL: после него идут полный путь и номер строки, а
     193             * байту NUL в строке лога не место.
     194             *
     195             * Строка идёт в журнал WooCommerce (WooCommerce > Status > Logs,
     196             * источник `clearwayforge`), а не в error_log: в пакет wordpress.org
     197             * вызовы error_log не попадают. Без WooCommerce заказов нет, и
     198             * писать нечего.
     199             */
     200            if (function_exists('wc_get_logger')) {
     201                try {
     202                    wc_get_logger()->warning(
     203                        sprintf(
     204                            '[clearwayforge] a listener on %s threw %s in %s; the order was not affected.',
     205                            AiOrderEvent::HOOK,
     206                            explode("\0", $listenerError::class, 2)[0],
     207                            self::listenerSource($listenerError->getFile(), defined('WP_PLUGIN_DIR') ? (string) constant('WP_PLUGIN_DIR') : null)
     208                        ),
     209                        ['source' => 'clearwayforge']
     210                    );
     211                } catch (\Throwable) {
     212                    // Сломанный обработчик журнала (WooCommerce даёт их добавлять другим плагинам) не отменяет гарантию выше: заказ всё равно проходит.
     213                }
     214            }
     215        }
     216    }
     217
     218    /**
     219     * Кто бросил исключение, для строки лога: имя папки плагина, если файл
     220     * лежит под каталогом плагинов (первый сегмент пути после него; плагин из
     221     * одного файла — имя этого файла), иначе имя файла. Каталог плагинов
     222     * проверяется и как задан, и по `realpath()`: он может быть символической
     223     * ссылкой, а PHP отдаёт путь файла уже раскрытым. Полный путь в лог не
     224     * попадает.
     225     *
     226     * @internal Открыт только ради теста.
     227     */
     228    public static function listenerSource(string $file, ?string $pluginDir): string
     229    {
     230        $file = str_replace('\\', '/', $file);
     231
     232        $roots = [];
     233        if ($pluginDir !== null && $pluginDir !== '') {
     234            $roots[] = $pluginDir;
     235            $real = realpath($pluginDir);
     236            if ($real !== false) {
     237                $roots[] = $real;
     238            }
     239        }
     240
     241        foreach ($roots as $root) {
     242            $root = rtrim(str_replace('\\', '/', $root), '/') . '/';
     243            if (strpos($file, $root) !== 0) {
     244                continue;
     245            }
     246            $first = explode('/', substr($file, strlen($root)), 2)[0];
     247            if ($first !== '') {
     248                return $first;
     249            }
     250        }
     251
     252        return basename($file);
    162253    }
    163254
  • clearwayforge/trunk/src/Infra/Collect/VisitCollector.php

    r3702012 r3776286  
    153153     * Адрес для проверки сети краулера.
    154154     *
    155      * Читается только `REMOTE_ADDR`: заголовки вроде `X-Forwarded-For`
    156      * подделываются кем угодно, и поверить им означало бы дать любому
    157      * скраперу способ объявить себя проверенным GPTBot одной строкой. За
    158      * обратным прокси адрес будет неверным — и тогда классификатор честно
    159      * скажет `declared` вместо `verified`, что лучше, чем ложное `verified`.
     155     * Читается `REMOTE_ADDR`, а заголовки прокси (`X-Forwarded-For`) — только
     156     * если владелец назвал доверенные прокси в настройках, и по правилам
     157     * `RequestAddress`. Без списка заголовок не читается никогда:
     158     * подделываемый кем угодно, он давал бы любому скраперу способ объявить
     159     * себя проверенным GPTBot одной строкой. За обратным прокси без списка
     160     * адрес будет неверным — и классификатор честно скажет `declared` вместо
     161     * `verified`, что лучше, чем ложное `verified`.
    160162     */
    161163    private function trustedAddress(): ?string
    162164    {
    163         if (!isset($_SERVER['REMOTE_ADDR']) || !is_string($_SERVER['REMOTE_ADDR'])) {
    164             return null;
    165         }
    166 
    167         $address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
    168 
    169         return $address === '' ? null : $address;
     165        return RequestAddress::fromServer($_SERVER);
    170166    }
    171167
  • clearwayforge/trunk/src/Infra/Db/Schema.php

    r3702012 r3776286  
    3636     * Версия схемы. Инкрементируется при любом изменении определений ниже;
    3737     * `Migrator` сравнивает её с сохранённой и решает, звать ли `dbDelta`.
     38     *
     39     * 2 — `touches.refunded_cents` (BUG-CLEAR-MISC-01, п. 14): возврат по
     40     * заказу хранится рядом с суммой доли, отчёты вычитают его при чтении.
    3841     */
    39     public const VERSION = 1;
     42    public const VERSION = 2;
    4043
    4144    /** Общий префикс таблиц плагина поверх префикса WordPress. */
    … …  
    115118             * сменить, и старые отчёты обязаны остаться такими, какими он их
    116119             * видел, иначе выручка прошлого месяца меняется задним числом.
     120             *
     121             * `refunded_cents` — доля возврата, пришедшаяся на эту строку
     122             * (`TouchRepository::setOrderRefund()`). Сумма доли остаётся
     123             * как была, отчёты вычитают возврат при чтении: возврат уменьшает
     124             * заказ, к которому сделан, в периоде самого заказа.
    117125             */
    118126            'touches' => "CREATE TABLE {$p}touches (
    … …  
    125133  model VARCHAR(16) NOT NULL DEFAULT 'last',
    126134  amount_cents BIGINT(20) NOT NULL DEFAULT 0,
     135  refunded_cents BIGINT(20) NOT NULL DEFAULT 0,
    127136  currency CHAR(3) NOT NULL DEFAULT 'USD',
    128137  captured_ms BIGINT(20) UNSIGNED NOT NULL,
  • clearwayforge/trunk/src/Infra/Health/HealthReport.php

    r3702012 r3776286  
    107107            [
    108108                'label' => 'Site timezone',
    109                 'problem' => (wp_timezone_string() === 'UTC' && get_option('gmt_offset') === '0')
    110                     ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.'
    111                     : null,
     109                'problem' => self::timezoneProblem(wp_timezone_string(), get_option('gmt_offset')),
    112110            ],
    113111        ];
     112    }
     113
     114    /**
     115     * Предупреждение о поясе UTC — или `null`, если сайт считает сутки по местному поясу.
     116     *
     117     * Смещение сравнивается числом, а не строкой: на сайте с поясом по названию ядро
     118     * отвечает на `get_option('gmt_offset')` через `pre_option_gmt_offset` и отдаёт
     119     * `float 0.0`, а не `'0'`, и строгое сравнение со строкой скрывало предупреждение
     120     * на каждом таком сайте (BUG-CLEAR-DASH-01).
     121     *
     122     * @param mixed $gmtOffset Значение `get_option('gmt_offset')`: число, строка или `false`.
     123     */
     124    public static function timezoneProblem(string $timezone, mixed $gmtOffset): ?string
     125    {
     126        $offset = is_int($gmtOffset) || is_float($gmtOffset) || is_string($gmtOffset) ? (float) $gmtOffset : 0.0;
     127
     128        return $timezone === 'UTC' && $offset === 0.0
     129            ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.'
     130            : null;
    114131    }
    115132
  • clearwayforge/trunk/src/Infra/Llms/LlmsRoutes.php

    r3720707 r3776286  
    1616 * кеша каждый заход бота собирал бы полный список категорий и товаров —
    1717 * несколько запросов к базе на каждый из тысяч заходов в сутки.
     18 *
     19 * Страничные кеши плагинов (WP Super Cache, LiteSpeed Cache и другие)
     20 * документ не держат, а всё, что стоит перед сайтом, держит его
     21 * `DocumentCaching::MAX_AGE_SECONDS` (15 минут, как `catalog.json`). Чужие
     22 * кеши мы не чистим (NEIGH-04).
    1823 */
    1924
    … …  
    2328
    2429use ClearwayForge\Domain\Llms\LlmsGenerator;
     30use ClearwayForge\Infra\Pro\Bridge;
    2531use ClearwayForge\Infra\Support\Options;
    2632
    … …  
    3440     *
    3541     * Число в конце ключа — версия того, как собирается документ. Меняется
    36      * сборка — меняется число: иначе обновлённый сайт до шести часов отдаёт
     42     * сборка — меняется число: иначе обновлённый сайт до пятнадцати минут отдаёт
    3743     * текст, собранный прошлой версией, а сторож дрейфа принимает разницу
    3844     * между старым кешем и новой сборкой за изменение каталога. 2 — 1.0.2:
    39      * цены и названия без HTML-сущностей.
    40      */
    41     public const CACHE_MAP = 'cwforge_llms_map_2';
    42     public const CACHE_FULL = 'cwforge_llms_full_2';
    43     public const CACHE_TTL = 6 * HOUR_IN_SECONDS;
     45     * цены и названия без HTML-сущностей. 3 — полная выгрузка грузит до
     46     * `LlmsGenerator::FULL_PRODUCT_LIMIT` товаров, а обе пишут «Showing N of
     47     * M products», когда каталог больше окна (BUG-CLEAR-MISC-01). 4 (только
     48     * полная выгрузка) — она называет адрес машинного фида, когда магазин его
     49     * отдаёт, и без обрезки (BUG-CLEAR-LLMS-02).
     50     */
     51    public const CACHE_MAP = 'cwforge_llms_map_3';
     52    public const CACHE_FULL = 'cwforge_llms_full_4';
     53
     54    /**
     55     * Серверный кеш живёт столько же, сколько документ обещает всем, кто стоит
     56     * перед сайтом: одна константа `DocumentCaching::MAX_AGE_SECONDS` (900 с)
     57     * на `llms.txt`, `llms-full.txt` и `catalog.json`. Сохранение товара кеш
     58     * сбрасывает сразу (`invalidate()`), срок — страховка от правок мимо
     59     * сохранения (импорт, прямая запись в базу).
     60     */
     61    public const CACHE_TTL = DocumentCaching::MAX_AGE_SECONDS;
     62
     63    /**
     64     * Хук аддона: набор платных возможностей изменился (активация, окончание,
     65     * освобождение лицензии). Ядро о лицензиях не знает и только сбрасывает кеш.
     66     */
     67    public const PAID_FEATURES_CHANGED = 'cwforge_paid_features_changed';
    4468
    4569    /** Ключи прошлых версий сборки: их чистит удаление плагина. */
    46     public const RETIRED_CACHE_KEYS = ['cwforge_llms_map', 'cwforge_llms_full'];
     70    public const RETIRED_CACHE_KEYS = [
     71        'cwforge_llms_map',
     72        'cwforge_llms_full',
     73        'cwforge_llms_map_2',
     74        'cwforge_llms_full_2',
     75        'cwforge_llms_full_3',
     76    ];
    4777
    4878    public function register(): void
    … …  
    5383        add_action('woocommerce_update_product', [$this, 'invalidate']);
    5484        add_action('update_option_' . Options::NAME, [$this, 'invalidate']);
     85        // Строка про машинный фид зависит от лицензии аддона (`feedUrl()`): аддон сообщает
     86        // об изменении набора платных возможностей, и документ собирается заново.
     87        add_action(self::PAID_FEATURES_CHANGED, [$this, 'invalidate']);
    5588    }
    5689
    … …  
    6699            $path = (string) (wp_parse_url($requestUri, PHP_URL_PATH) ?? '/');
    67100        }
    68         $path = '/' . ltrim($path, '/');
     101        // Дочерний сайт мультисайта с подпапками живёт по `/sub/`, и его документ — `/sub/llms.txt`
     102        // (BUG-CLEAR-LLMS-01): путь главной страницы этого сайта снимается до сравнения.
     103        $homePath = (string) (wp_parse_url(home_url('/'), PHP_URL_PATH) ?? '/');
     104
     105        $document = self::documentFor($path, $homePath);
     106        if ($document === 'map') {
     107            $this->serve(self::CACHE_MAP, false);
     108        }
     109        if ($document === 'full') {
     110            $this->serve(self::CACHE_FULL, true);
     111        }
     112    }
     113
     114    /**
     115     * Какой документ просит этот путь: `map` (`llms.txt`), `full` (`llms-full.txt`) или `null`.
     116     *
     117     * Сравнение точное: ни регистра, ни косой черты в конце, ни вложенных папок. Корневой
     118     * `/llms.txt` для сайта с home `/sub/` — документ корневого сайта, а не этого; каталог
     119     * корня ему не подставляется.
     120     *
     121     * @param string $requestPath Путь запроса, как пришёл (`/sub/llms.txt`).
     122     * @param string $homePath    Путь главной страницы этого сайта: `/` или `/sub/` на мультисайте с подпапками.
     123     */
     124    public static function documentFor(string $requestPath, string $homePath): ?string
     125    {
     126        $path = '/' . ltrim($requestPath, '/');
     127        $home = rtrim('/' . ltrim($homePath, '/'), '/');
     128
     129        if ($home !== '') {
     130            if (strpos($path, $home . '/') !== 0) {
     131                return null;
     132            }
     133            $path = substr($path, strlen($home));
     134        }
    69135
    70136        if ($path === '/llms.txt') {
    71             $this->serve(self::CACHE_MAP, false);
     137            return 'map';
    72138        }
    73139        if ($path === '/llms-full.txt') {
    74             $this->serve(self::CACHE_FULL, true);
    75         }
     140            return 'full';
     141        }
     142
     143        return null;
     144    }
     145
     146    /**
     147     * Строки заголовков ответа: тип, длина, запрет угадывания типа и срок жизни.
     148     *
     149     * Отдельным методом, чтобы заголовки проверялись без вывода и `exit`.
     150     * Срок свежести и отказ страничным кешам берутся из `DocumentCaching`:
     151     * число одно с `catalog.json` платного аддона.
     152     *
     153     * @return list<string>
     154     */
     155    public static function responseHeaders(int $length): array
     156    {
     157        return [
     158            'Content-Type: text/plain; charset=utf-8',
     159            'Content-Length: ' . $length,
     160            'X-Robots-Tag: noindex',
     161            // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом.
     162            'X-Content-Type-Options: nosniff',
     163            'Cache-Control: ' . DocumentCaching::cacheControl(),
     164            DocumentCaching::LITESPEED_HEADER . ': ' . DocumentCaching::liteSpeedControl(),
     165        ];
     166    }
     167
     168    /**
     169     * Положить собранный документ в серверный кеш на `CACHE_TTL`.
     170     *
     171     * Отдельным методом, чтобы срок проверялся без вывода и `exit`.
     172     */
     173    public static function remember(string $cacheKey, string $body): void
     174    {
     175        set_transient($cacheKey, $body, self::CACHE_TTL);
    76176    }
    77177
    … …  
    80180        $body = get_transient($cacheKey);
    81181        if (!is_string($body) || $body === '') {
    82             $store = (new StoreProfileBuilder())->build();
    83             $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', 50)));
     182            $store = (new StoreProfileBuilder())->build($full);
     183            $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT)));
    84184            $document = $full
    85                 ? $generator->full($store)
    86                 : $generator->map($store, rest_url('cwforge/v1/catalog.json'));
     185                ? $generator->full($store, self::feedUrl())
     186                : $generator->map($store, self::feedUrl());
    87187            $body = $document->render();
    88             set_transient($cacheKey, $body, self::CACHE_TTL);
     188            self::remember($cacheKey, $body);
    89189        }
    90190
    91191        // Заголовки ставятся до вывода и включают длину: бот, читающий поток
    92192        // без длины, не может отличить обрыв соединения от конца документа.
    93         header('Content-Type: text/plain; charset=utf-8');
    94         header('Content-Length: ' . strlen($body));
    95         header('X-Robots-Tag: noindex');
    96         // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом.
    97         header('X-Content-Type-Options: nosniff');
    98         header('Cache-Control: public, max-age=3600');
     193        foreach (self::responseHeaders(strlen($body)) as $line) {
     194            header($line);
     195        }
     196        // Страничные кеши плагинов документ не держат; свежесть для всего, что стоит перед сайтом, — наша (DocumentCaching).
     197        DocumentCaching::excludeFromPageCaches();
    99198
    100199        echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ответ text/plain с nosniff: HTML-экранирование исказило бы документ для агентов.
    … …  
    102201    }
    103202
     203    /**
     204     * Адрес машинного фида — только если этот магазин его отдаёт.
     205     *
     206     * Фид живёт в платном аддоне и закрыт без лицензии. Строка «полный
     207     * каталог — по такому-то адресу», ведущая на отказ, хуже её отсутствия.
     208     */
     209    private static function feedUrl(): ?string
     210    {
     211        return Bridge::instance()->isEnabled('catalog-feed') ? rest_url('cwforge/v1/catalog.json') : null;
     212    }
     213
    104214    public function invalidate(): void
    105215    {
  • clearwayforge/trunk/src/Infra/Llms/StoreProfileBuilder.php

    r3720707 r3776286  
    2525namespace ClearwayForge\Infra\Llms;
    2626
     27use ClearwayForge\Domain\Llms\LlmsGenerator;
    2728use ClearwayForge\Domain\Llms\LlmsLink;
    2829use ClearwayForge\Domain\Llms\PlainText;
    … …  
    3435final class StoreProfileBuilder
    3536{
    36     public function build(): StoreProfile
    37     {
     37    /**
     38     * @param bool $full Профиль для `/llms-full.txt`: товаров грузится больше
     39     *                   (`LlmsGenerator::fetchLimit()`), а общее число уходит в документ.
     40     */
     41    public function build(bool $full = false): StoreProfile
     42    {
     43        [$products, $total] = $this->products($full);
     44
    3845        return new StoreProfile(
    3946            name: PlainText::fromHtml(get_bloginfo('name')),
    … …  
    4148            description: PlainText::fromHtml(get_bloginfo('description')),
    4249            categories: $this->categories(),
    43             products: $this->products(),
     50            products: $products,
    4451            policies: $this->policies(),
    4552            pages: $this->pages(),
    … …  
    4754            shippingNote: $this->shippingNote(),
    4855            returnsNote: null,
     56            productsTotal: $total,
    4957        );
    5058    }
    … …  
    8492    }
    8593
    86     /** @return list<LlmsLink> */
    87     private function products(): array
    88     {
    89         $limit = max(1, Options::int('llms_product_limit', 50));
    90 
    91         $products = wc_get_products([
     94    /** @return array{0:list<LlmsLink>,1:?int} Товары окна и сколько их в каталоге всего. */
     95    private function products(bool $full): array
     96    {
     97        $limit = LlmsGenerator::fetchLimit($full, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT));
     98
     99        // Один товар на группу переводов, как в catalog.json (NEIGH-07): `ProductTranslations` — единственное место правила.
     100        $result = ProductTranslations::products([
    92101            'status' => 'publish',
    93102            'limit' => $limit,
    … …  
    95104            'order' => 'DESC',
    96105            'visibility' => 'catalog',
     106            'paginate' => true,
    97107        ]);
    98108
     109        // С `paginate` WooCommerce отдаёт объект с `products` и `total`; без
     110        // него (старые версии, чужие фильтры) — просто список.
     111        $total = null;
     112        $products = $result;
     113        if (is_object($result)) {
     114            $products = $result->products ?? [];
     115            $total = isset($result->total) ? (int) $result->total : null;
     116        }
     117
    99118        if (!is_array($products)) {
    100             return [];
     119            return [[], null];
    101120        }
    102121
    … …  
    113132        }
    114133
    115         return $links;
     134        return [$links, $total];
    116135    }
    117136
  • clearwayforge/trunk/src/Infra/Report/DashboardAssembler.php

    r3702012 r3776286  
    2525 * ── Что здесь НЕ происходит ──────────────────────────────────────────────
    2626 *
     27 * Возвраты (BUG-CLEAR-MISC-01, п. 14): сумма доли хранится как была, а
     28 * возврат по заказу лежит рядом (`refunded_cents`) и приходит в `Allocation`.
     29 * Отчёт показывает выручку за вычетом возвратов в периоде самого заказа,
     30 * а сумму возвратов — отдельным числом; здесь возврат только передаётся
     31 * дальше, вычитает его `DashboardBuilder`.
     32 *
    2733 * Пересчёта атрибуции нет: суммы посчитаны в момент заказа и сохранены.
    2834 * Пересчитать их сейчас значило бы применить сегодняшнюю модель к прошлому
    2935 * месяцу и показать владельцу не ту выручку, которую он видел вчера.
     36 *
     37 * Тем более текущая модель не отбирает строки: заказ читается под той
     38 * моделью, под которой записан (`TouchRepository::inWindow()`), и после
     39 * смены настройки старые заказы остаются в отчёте (BUG-CLEAR-ATTR-01).
     40 * Поэтому `$model` в `assemble()` — не фильтр, а запасное значение для
     41 * строки, записанной моделью, которой эта версия не знает.
    3042 */
    3143
    … …  
    6274    }
    6375
    64     public function assemble(ReportWindow $window, AttributionModel $model, string $currency): DashboardReport
    65     {
     76    /**
     77     * @param int|null $minorUnit Знаков после запятой у записанных сумм. Без
     78     *                            указания — настройка магазина, та же, что
     79     *                            читала запись заказа (BUG-CLEAR-ATTR-02).
     80     */
     81    public function assemble(
     82        ReportWindow $window,
     83        AttributionModel $model,
     84        string $currency,
     85        ?int $minorUnit = null
     86    ): DashboardReport {
    6687        return (new DashboardBuilder())->build(
    6788            $window,
    … …  
    7091            $this->crawlerRows($window),
    7192            $currency,
     93            $minorUnit ?? StoreMinorUnit::current(),
    7294        );
    7395    }
    … …  
    81103    {
    82104        $byOrder = [];
    83         foreach ($this->touches->inWindow($fromMs, $toMs, $model->value) as $row) {
     105        foreach ($this->touches->inWindow($fromMs, $toMs) as $row) {
    84106            $source = AiSource::tryFrom($row['source']);
    85107            $via = RecoveredVia::tryFrom($row['recovered_via']);
    … …  
    99121            $byOrder[$row['order_id']]['at'] = $row['captured_ms'];
    100122            $byOrder[$row['order_id']]['currency'] = $row['currency'];
     123            $byOrder[$row['order_id']]['model'] = AttributionModel::tryFrom($row['model']) ?? $model;
    101124            $byOrder[$row['order_id']]['allocations'][] = new Allocation(
    102125                $touch,
    103126                Money::fromCents($row['amount_cents'], $row['currency']),
    104                 1
     127                1,
     128                $row['refunded_cents']
    105129            );
    106130        }
    … …  
    115139                (int) $orderId,
    116140                (int) $data['at'],
    117                 new AttributionResult($model, $data['allocations'], $total)
     141                new AttributionResult($data['model'], $data['allocations'], $total)
    118142            );
    119143        }
  • clearwayforge/trunk/src/Infra/Repository/TouchRepository.php

    r3702012 r3776286  
    77 * модель атрибуции, и отчёт за прошлый месяц обязан остаться таким, каким он
    88 * его видел. Выручка, меняющаяся задним числом, обесценивает весь дашборд.
     9 *
     10 * Отсюда правило чтения (BUG-CLEAR-ATTR-01): заказ считается той моделью, под
     11 * которой он записан первой, и текущая настройка модели строки не отбирает.
     12 * Смена модели действует на заказы с момента смены (`OrderObserver` пишет их
     13 * под новой), а прежние заказы остаются в дашборде, Revenue by agent, CSV и
     14 * дайджесте с теми же суммами. Если у заказа есть строки под двумя моделями
     15 * (повторный разбор), читается набор первой записанной: иначе заказ
     16 * посчитался бы дважды.
    917 */
    1018
    … …  
    3139
    3240use ClearwayForge\Domain\Attribution\AttributionResult;
     41use ClearwayForge\Domain\Report\RefundSplit;
    3342use ClearwayForge\Infra\Db\Schema;
    3443
    … …  
    8897
    8998    /**
    90      * Разобранные заказы окна.
     99     * Разобранные заказы окна — каждый под моделью, с которой он записан.
     100     *
     101     * Подзапрос берёт модель самой ранней строки заказа (`id` растёт со
     102     * вставкой), так что повторный разбор под другой моделью выручку не
     103     * удваивает и не подменяет. Уникальный ключ (order_id, source, model)
     104     * ведёт подзапрос по префиксу `order_id`.
     105     *
     106     * `refunded_cents` — доля возврата, пришедшаяся на строку; отчёт вычитает
     107     * её из суммы строки в периоде самого заказа (BUG-CLEAR-MISC-01, п. 14).
    91108     *
    92109     * @return list<array{order_id:int,source:string,recovered_via:string,confidence:int,
    93      *                    amount_cents:int,currency:string,captured_ms:int,model:string}>
    94      */
    95     public function inWindow(int $fromMs, int $toMs, string $model): array
     110     *                    amount_cents:int,refunded_cents:int,currency:string,captured_ms:int,model:string}>
     111     */
     112    public function inWindow(int $fromMs, int $toMs): array
    96113    {
    97114        global $wpdb;
    … …  
    100117        $rows = $wpdb->get_results(
    101118            $wpdb->prepare(
    102                 'SELECT order_id, source, recovered_via, confidence, amount_cents, currency, captured_ms, model
    103                  FROM %i
    104                  WHERE captured_ms >= %d AND captured_ms <= %d AND model = %s
    105                  ORDER BY captured_ms ASC',
     119                'SELECT t.order_id, t.source, t.recovered_via, t.confidence, t.amount_cents,
     120                        t.refunded_cents, t.currency, t.captured_ms, t.model
     121                 FROM %i t
     122                 WHERE t.captured_ms >= %d AND t.captured_ms <= %d
     123                   AND t.model = (
     124                       SELECT h.model FROM %i h
     125                       WHERE h.order_id = t.order_id
     126                       ORDER BY h.id ASC
     127                       LIMIT 1
     128                   )
     129                 ORDER BY t.captured_ms ASC, t.id ASC',
    106130                $table,
    107131                $fromMs,
    108132                $toMs,
    109                 $model
     133                $table
    110134            ),
    111135            ARRAY_A
    … …  
    123147                'confidence' => (int) $r['confidence'],
    124148                'amount_cents' => (int) $r['amount_cents'],
     149                'refunded_cents' => (int) $r['refunded_cents'],
    125150                'currency' => (string) $r['currency'],
    126151                'captured_ms' => (int) $r['captured_ms'],
    … …  
    131156    }
    132157
     158    /** Есть ли у заказа разбор: дёшево отсекает возвраты по заказам, которых мы не считали. */
     159    public function hasOrder(int $orderId): bool
     160    {
     161        global $wpdb;
     162
     163        $table = Schema::table($this->wpPrefix, 'touches');
     164
     165        return $wpdb->get_var($wpdb->prepare('SELECT 1 FROM %i WHERE order_id = %d LIMIT 1', $table, $orderId)) !== null;
     166    }
     167
     168    /**
     169     * Записать, сколько по заказу возвращено всего (BUG-CLEAR-MISC-01, п. 14).
     170     *
     171     * Принимает итог, а не приращение: WooCommerce отвечает «возвращено по
     172     * заказу столько», и повторный вызов с тем же числом, удаление возврата и
     173     * новый частичный возврат дают верный результат без учёта порядка событий.
     174     * Итог делится между строками заказа пропорционально их суммам
     175     * (`RefundSplit`) и ложится в те строки, что читает `inWindow()` —
     176     * записанные под первой моделью заказа.
     177     *
     178     * @return int Сколько строк заказа обновлено; 0 — разбора у заказа нет.
     179     */
     180    public function setOrderRefund(int $orderId, int $refundedCents): int
     181    {
     182        global $wpdb;
     183
     184        $table = Schema::table($this->wpPrefix, 'touches');
     185        $rows = $wpdb->get_results(
     186            $wpdb->prepare(
     187                'SELECT t.id, t.amount_cents
     188                 FROM %i t
     189                 WHERE t.order_id = %d
     190                   AND t.model = (
     191                       SELECT h.model FROM %i h
     192                       WHERE h.order_id = t.order_id
     193                       ORDER BY h.id ASC
     194                       LIMIT 1
     195                   )
     196                 ORDER BY t.id ASC',
     197                $table,
     198                $orderId,
     199                $table
     200            ),
     201            ARRAY_A
     202        );
     203
     204        if (!is_array($rows) || $rows === []) {
     205            return 0;
     206        }
     207
     208        $parts = RefundSplit::apportion(
     209            $refundedCents,
     210            array_values(array_map(static fn (array $r): int => (int) $r['amount_cents'], $rows))
     211        );
     212
     213        foreach (array_values($rows) as $i => $row) {
     214            $wpdb->query(
     215                $wpdb->prepare(
     216                    'UPDATE %i SET refunded_cents = %d WHERE id = %d',
     217                    $table,
     218                    $parts[$i],
     219                    (int) $row['id']
     220                )
     221            );
     222        }
     223
     224        return count($rows);
     225    }
     226
     227    /**
     228     * Следующая пачка различных заказов с разбором, по возрастанию номера.
     229     *
     230     * Курсором служит сам номер заказа: «всё, что больше последнего
     231     * обработанного». Ключ `(order_id, source, model)` отдаёт такую выборку
     232     * без сортировки по таблице целиком (досчёт возвратов, п. 14).
     233     *
     234     * @return list<int>
     235     */
     236    public function orderIdsAfter(int $afterOrderId, int $limit): array
     237    {
     238        global $wpdb;
     239
     240        $table = Schema::table($this->wpPrefix, 'touches');
     241        $ids = $wpdb->get_col(
     242            $wpdb->prepare(
     243                'SELECT DISTINCT order_id FROM %i WHERE order_id > %d ORDER BY order_id ASC LIMIT %d',
     244                $table,
     245                $afterOrderId,
     246                max(1, $limit)
     247            )
     248        );
     249
     250        return is_array($ids) ? array_values(array_map('intval', $ids)) : [];
     251    }
     252
    133253    public function deleteOrder(int $orderId): int
    134254    {
  • clearwayforge/trunk/src/Infra/Rest/Routes.php

    r3702012 r3776286  
    132132                            'required' => false,
    133133                        ),
     134                        'trusted_proxies'        => array(
     135                            'type'     => 'string',
     136                            'required' => false,
     137                        ),
    134138                        'delete_data_on_uninstall' => array(
    135139                            'type'     => 'boolean',
  • clearwayforge/trunk/src/Infra/Rest/SettingsController.php

    r3702012 r3776286  
    2626
    2727use ClearwayForge\Domain\Attribution\AttributionModel;
     28use ClearwayForge\Domain\Crawler\TrustedProxies;
    2829use ClearwayForge\Infra\Scheduler\CrawlerRangeSources;
    2930use ClearwayForge\Infra\Scheduler\Maintenance;
    … …  
    7475        }
    7576
     77        /*
     78         * Доверенные прокси (BUG-CLEAR-MISC-01, п. 10): от списка зависит, чьему
     79         * слову об адресе верит проверка краулеров. Запись, не являющаяся
     80         * адресом или сетью, отбрасывается, остальные сохраняются; отброшенные
     81         * возвращаются в ответе по имени (`trusted_proxies_rejected`), чтобы
     82         * опечатка не осталась незамеченной. Пустая строка очищает список — и
     83         * заголовки прокси снова не читаются.
     84         */
     85        $rejectedProxies = [];
     86        $overLimitProxies = 0;
     87        if ($request->get_param('trusted_proxies') !== null) {
     88            if (!current_user_can('manage_options')) {
     89                // Список решает, чьему заголовку верить, — это доверие к сети, а
     90                // не настройка отчётов: менеджеру магазина (`manage_woocommerce`)
     91                // оно не принадлежит. Отказ до любой записи, как у удаления данных.
     92                return new WP_REST_Response(
     93                    ['code' => 'cwforge_forbidden', 'message' => 'Only an administrator can change the trusted proxies.'],
     94                    403
     95                );
     96            }
     97
     98            $proxies = TrustedProxies::fromText((string) $request->get_param('trusted_proxies'));
     99            $values['trusted_proxies'] = $proxies->cidrs();
     100            $rejectedProxies = $proxies->rejected();
     101            $overLimitProxies = count($proxies->overLimit());
     102        }
     103
    76104        $deleteOnUninstall = $request->get_param('delete_data_on_uninstall');
    77105        if ($deleteOnUninstall !== null && !current_user_can('delete_plugins')) {
    … …  
    96124        }
    97125
    98         return new WP_REST_Response(self::payload(), 200);
     126        return new WP_REST_Response(self::payload($rejectedProxies, $overLimitProxies), 200);
    99127    }
    100128
    101129    /**
     130     * @param list<string> $rejectedProxies Записи списка прокси, отброшенные в этом запросе как «не адрес».
     131     * @param int $overLimitProxies Сколько годных записей не поместилось в потолок списка в этом запросе.
    102132     * @return array{
    103133     *     show_estimated_revenue:bool,
    … …  
    106136     *     crawler_ranges_refresh:bool,
    107137     *     crawler_ranges_hosts:list<string>,
     138     *     trusted_proxies:string,
     139     *     trusted_proxies_rejected:list<string>,
     140     *     trusted_proxies_over_limit:int,
     141     *     trusted_proxies_limit:int,
     142     *     can_change_proxies:bool,
    108143     *     delete_data_on_uninstall:bool,
    109144     *     can_change_uninstall:bool
    110145     * }
    111146     */
    112     private static function payload(): array
     147    private static function payload(array $rejectedProxies = [], int $overLimitProxies = 0): array
    113148    {
    114149        $current = AttributionModel::tryFrom(Options::string('attribution_model', 'last')) ?? AttributionModel::LastTouch;
    … …  
    125160            // не из текста: согласие даётся на то, что реально произойдёт.
    126161            'crawler_ranges_hosts'     => self::hosts((new CrawlerRangeSources())->all()),
     162            // Список как текст для поля ввода: по записи на строку.
     163            'trusted_proxies'          => implode("\n", TrustedProxies::fromStored(Options::get('trusted_proxies', array()))->cidrs()),
     164            // Что не прошло при последнем сохранении; на чтении всегда пусто.
     165            'trusted_proxies_rejected' => $rejectedProxies,
     166            // Годные записи сверх потолка: считаются отдельно, на чтении всегда 0.
     167            'trusted_proxies_over_limit' => $overLimitProxies,
     168            'trusted_proxies_limit'    => TrustedProxies::MAX,
     169            'can_change_proxies'       => current_user_can('manage_options'),
    127170            'delete_data_on_uninstall' => get_option(Options::DELETE_ON_UNINSTALL) === 'yes',
    128171            'can_change_uninstall'     => current_user_can('delete_plugins'),
  • clearwayforge/trunk/src/Infra/Scheduler/Maintenance.php

    r3720707 r3776286  
    3636    public const GROUP = 'clearwayforge';
    3737
     38    /** Метка «очередь проверена»: самолечение не чаще раза в окно, а не на каждый запрос админки. */
     39    public const SCHEDULE_CHECKED = 'cwforge_schedule_checked';
     40
    3841    /** Скачанные сети вендоров. */
    3942    public const RANGES_OPTION = 'cwforge_crawler_ranges';
    … …  
    5457        add_action(self::HOOK_PURGE, [$this, 'purge']);
    5558        add_action(self::HOOK_RANGES, [$this, 'refreshRanges']);
     59        // Только запросы админки: на витрине лишний вопрос к очереди — это
     60        // накладные расходы на каждом хите покупателя (принцип 1 в AGENTS.md).
     61        add_action('admin_init', [self::class, 'ensureScheduled']);
     62    }
     63
     64    /**
     65     * Вернуть уборку в очередь, если активация её не поставила.
     66     *
     67     * `scheduleAll()` зовёт только активация, а плагин попадает на сайт и
     68     * без неё: обновление по FTP, перенос базы без таблиц Action Scheduler,
     69     * очередь, вычищенная чужим инструментом, Action Scheduler, которого не
     70     * было при активации. Без задачи `cwforge_purge_old_data` срок хранения
     71     * (`retention_days`) тихо перестаёт действовать, и таблицы растут. Ту же
     72     * оговорку про FTP несёт `Migrator::migrateIfNeeded()` в `Plugin::wire()`.
     73     *
     74     * Метка «проверено» ставится только когда очередь доступна: иначе одна
     75     * неудачная попытка отключила бы самолечение на всё окно.
     76     */
     77    public static function ensureScheduled(): void
     78    {
     79        if (!function_exists('as_next_scheduled_action')) {
     80            return;
     81        }
     82
     83        if (get_transient(self::SCHEDULE_CHECKED) !== false) {
     84            return;
     85        }
     86
     87        set_transient(self::SCHEDULE_CHECKED, 1, 12 * HOUR_IN_SECONDS);
     88        self::scheduleAll();
    5689    }
    5790
    … …  
    81114
    82115        as_unschedule_all_actions(self::HOOK_PURGE, [], self::GROUP);
     116        // Досчёт возвратов: поставленная пачка не должна пережить плагин (п. 14).
     117        as_unschedule_all_actions(\ClearwayForge\Infra\Attribution\RefundBackfill::HOOK, [], self::GROUP);
    83118        self::unscheduleRanges();
    84119    }
  • clearwayforge/trunk/uninstall.php

    r3720707 r3776286  
    3939            \ClearwayForge\Infra\Support\Options::NAME,
    4040            \ClearwayForge\Infra\Scheduler\Maintenance::RANGES_OPTION,
    41             \ClearwayForge\Infra\Support\Options::DELETE_ON_UNINSTALL,
     41            \ClearwayForge\Infra\Attribution\RefundBackfill::OPTION,
    4242        ] as $cwforge_option
    4343    ) {
    … …  
    4545    }
    4646
     47    /*
     48     * Согласие владельца общее для Free и платного аддона: надпись на экране
     49     * настроек — «Delete all Clearway Forge data». Пока на сайте остались
     50     * данные аддона (его метка версии схемы), флаг остаётся, иначе аддон,
     51     * удаляемый вторым, не найдёт согласия и оставит свои таблицы
     52     * (BUG-CLEAR-MISC-01). Аддон уберёт флаг сам, когда уйдёт последним.
     53     */
     54    if (get_option('cwforge_pro_schema_version', null) === null) {
     55        delete_option(\ClearwayForge\Infra\Support\Options::DELETE_ON_UNINSTALL);
     56    }
     57
     58    delete_transient(\ClearwayForge\Infra\Scheduler\Maintenance::SCHEDULE_CHECKED);
    4759    delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_MAP);
    4860    delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_FULL);
  • clearwayforge/trunk/vendor/composer/autoload_classmap.php

    r3720707 r3776286  
    2525    'ClearwayForge\\Domain\\Attribution\\SourceDetector' => $baseDir . '/src/Domain/Attribution/SourceDetector.php',
    2626    'ClearwayForge\\Domain\\Attribution\\Touch' => $baseDir . '/src/Domain/Attribution/Touch.php',
     27    'ClearwayForge\\Domain\\Crawler\\ClientAddress' => $baseDir . '/src/Domain/Crawler/ClientAddress.php',
    2728    'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => $baseDir . '/src/Domain/Crawler/CrawlerClassifier.php',
    2829    'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => $baseDir . '/src/Domain/Crawler/CrawlerCorpus.php',
    … …  
    3132    'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => $baseDir . '/src/Domain/Crawler/CrawlerKind.php',
    3233    'ClearwayForge\\Domain\\Crawler\\IpRange' => $baseDir . '/src/Domain/Crawler/IpRange.php',
     34    'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => $baseDir . '/src/Domain/Crawler/TrustedProxies.php',
    3335    'ClearwayForge\\Domain\\Crawler\\Verification' => $baseDir . '/src/Domain/Crawler/Verification.php',
    3436    'ClearwayForge\\Domain\\Llms\\LlmsBlock' => $baseDir . '/src/Domain/Llms/LlmsBlock.php',
    … …  
    5254    'ClearwayForge\\Domain\\Report\\MoneyText' => $baseDir . '/src/Domain/Report/MoneyText.php',
    5355    'ClearwayForge\\Domain\\Report\\PeriodSelector' => $baseDir . '/src/Domain/Report/PeriodSelector.php',
     56    'ClearwayForge\\Domain\\Report\\RefundSplit' => $baseDir . '/src/Domain/Report/RefundSplit.php',
    5457    'ClearwayForge\\Domain\\Report\\ReportWindow' => $baseDir . '/src/Domain/Report/ReportWindow.php',
    5558    'ClearwayForge\\Domain\\Report\\SourceRow' => $baseDir . '/src/Domain/Report/SourceRow.php',
    … …  
    6366    'ClearwayForge\\Infra\\Attribution\\OrderMeta' => $baseDir . '/src/Infra/Attribution/OrderMeta.php',
    6467    'ClearwayForge\\Infra\\Attribution\\OrderObserver' => $baseDir . '/src/Infra/Attribution/OrderObserver.php',
     68    'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => $baseDir . '/src/Infra/Attribution/OrderRefunds.php',
    6569    'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => $baseDir . '/src/Infra/Attribution/OrderVerdictWriter.php',
     70    'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => $baseDir . '/src/Infra/Attribution/RefundBackfill.php',
     71    'ClearwayForge\\Infra\\Attribution\\RefundObserver' => $baseDir . '/src/Infra/Attribution/RefundObserver.php',
    6672    'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => $baseDir . '/src/Infra/Attribution/WcOrderMeta.php',
     73    'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => $baseDir . '/src/Infra/Attribution/WcOrderRefunds.php',
     74    'ClearwayForge\\Infra\\Collect\\RequestAddress' => $baseDir . '/src/Infra/Collect/RequestAddress.php',
    6775    'ClearwayForge\\Infra\\Collect\\RequestSignals' => $baseDir . '/src/Infra/Collect/RequestSignals.php',
    6876    'ClearwayForge\\Infra\\Collect\\VisitCollector' => $baseDir . '/src/Infra/Collect/VisitCollector.php',
    … …  
    7381    'ClearwayForge\\Infra\\Db\\Schema' => $baseDir . '/src/Infra/Db/Schema.php',
    7482    'ClearwayForge\\Infra\\Health\\HealthReport' => $baseDir . '/src/Infra/Health/HealthReport.php',
     83    'ClearwayForge\\Infra\\Llms\\DocumentCaching' => $baseDir . '/src/Infra/Llms/DocumentCaching.php',
    7584    'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => $baseDir . '/src/Infra/Llms/LlmsRoutes.php',
     85    'ClearwayForge\\Infra\\Llms\\ProductTranslations' => $baseDir . '/src/Infra/Llms/ProductTranslations.php',
    7686    'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => $baseDir . '/src/Infra/Llms/StoreProfileBuilder.php',
    7787    'ClearwayForge\\Infra\\Plugin' => $baseDir . '/src/Infra/Plugin.php',
    … …  
    7989    'ClearwayForge\\Infra\\Pro\\Bridge' => $baseDir . '/src/Infra/Pro/Bridge.php',
    8090    'ClearwayForge\\Infra\\Report\\DashboardAssembler' => $baseDir . '/src/Infra/Report/DashboardAssembler.php',
     91    'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => $baseDir . '/src/Infra/Report/StoreMinorUnit.php',
    8192    'ClearwayForge\\Infra\\Repository\\HitRepository' => $baseDir . '/src/Infra/Repository/HitRepository.php',
    8293    'ClearwayForge\\Infra\\Repository\\SessionRepository' => $baseDir . '/src/Infra/Repository/SessionRepository.php',
  • clearwayforge/trunk/vendor/composer/autoload_static.php

    r3720707 r3776286  
    4040        'ClearwayForge\\Domain\\Attribution\\SourceDetector' => __DIR__ . '/../..' . '/src/Domain/Attribution/SourceDetector.php',
    4141        'ClearwayForge\\Domain\\Attribution\\Touch' => __DIR__ . '/../..' . '/src/Domain/Attribution/Touch.php',
     42        'ClearwayForge\\Domain\\Crawler\\ClientAddress' => __DIR__ . '/../..' . '/src/Domain/Crawler/ClientAddress.php',
    4243        'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerClassifier.php',
    4344        'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerCorpus.php',
    … …  
    4647        'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerKind.php',
    4748        'ClearwayForge\\Domain\\Crawler\\IpRange' => __DIR__ . '/../..' . '/src/Domain/Crawler/IpRange.php',
     49        'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => __DIR__ . '/../..' . '/src/Domain/Crawler/TrustedProxies.php',
    4850        'ClearwayForge\\Domain\\Crawler\\Verification' => __DIR__ . '/../..' . '/src/Domain/Crawler/Verification.php',
    4951        'ClearwayForge\\Domain\\Llms\\LlmsBlock' => __DIR__ . '/../..' . '/src/Domain/Llms/LlmsBlock.php',
    … …  
    6769        'ClearwayForge\\Domain\\Report\\MoneyText' => __DIR__ . '/../..' . '/src/Domain/Report/MoneyText.php',
    6870        'ClearwayForge\\Domain\\Report\\PeriodSelector' => __DIR__ . '/../..' . '/src/Domain/Report/PeriodSelector.php',
     71        'ClearwayForge\\Domain\\Report\\RefundSplit' => __DIR__ . '/../..' . '/src/Domain/Report/RefundSplit.php',
    6972        'ClearwayForge\\Domain\\Report\\ReportWindow' => __DIR__ . '/../..' . '/src/Domain/Report/ReportWindow.php',
    7073        'ClearwayForge\\Domain\\Report\\SourceRow' => __DIR__ . '/../..' . '/src/Domain/Report/SourceRow.php',
    … …  
    7881        'ClearwayForge\\Infra\\Attribution\\OrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderMeta.php',
    7982        'ClearwayForge\\Infra\\Attribution\\OrderObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderObserver.php',
     83        'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderRefunds.php',
    8084        'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderVerdictWriter.php',
     85        'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundBackfill.php',
     86        'ClearwayForge\\Infra\\Attribution\\RefundObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundObserver.php',
    8187        'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderMeta.php',
     88        'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderRefunds.php',
     89        'ClearwayForge\\Infra\\Collect\\RequestAddress' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestAddress.php',
    8290        'ClearwayForge\\Infra\\Collect\\RequestSignals' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestSignals.php',
    8391        'ClearwayForge\\Infra\\Collect\\VisitCollector' => __DIR__ . '/../..' . '/src/Infra/Collect/VisitCollector.php',
    … …  
    8896        'ClearwayForge\\Infra\\Db\\Schema' => __DIR__ . '/../..' . '/src/Infra/Db/Schema.php',
    8997        'ClearwayForge\\Infra\\Health\\HealthReport' => __DIR__ . '/../..' . '/src/Infra/Health/HealthReport.php',
     98        'ClearwayForge\\Infra\\Llms\\DocumentCaching' => __DIR__ . '/../..' . '/src/Infra/Llms/DocumentCaching.php',
    9099        'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => __DIR__ . '/../..' . '/src/Infra/Llms/LlmsRoutes.php',
     100        'ClearwayForge\\Infra\\Llms\\ProductTranslations' => __DIR__ . '/../..' . '/src/Infra/Llms/ProductTranslations.php',
    91101        'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => __DIR__ . '/../..' . '/src/Infra/Llms/StoreProfileBuilder.php',
    92102        'ClearwayForge\\Infra\\Plugin' => __DIR__ . '/../..' . '/src/Infra/Plugin.php',
    … …  
    94104        'ClearwayForge\\Infra\\Pro\\Bridge' => __DIR__ . '/../..' . '/src/Infra/Pro/Bridge.php',
    95105        'ClearwayForge\\Infra\\Report\\DashboardAssembler' => __DIR__ . '/../..' . '/src/Infra/Report/DashboardAssembler.php',
     106        'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => __DIR__ . '/../..' . '/src/Infra/Report/StoreMinorUnit.php',
    96107        'ClearwayForge\\Infra\\Repository\\HitRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/HitRepository.php',
    97108        'ClearwayForge\\Infra\\Repository\\SessionRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/SessionRepository.php',
  • clearwayforge/trunk/vendor/composer/installed.php

    r3720707 r3776286  
    44        'pretty_version' => 'dev-main',
    55        'version' => 'dev-main',
    6         'reference' => 'ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',
     6        'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    1414            'pretty_version' => 'dev-main',
    1515            'version' => 'dev-main',
    16             'reference' => 'ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',
     16            'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c',
    1717            'type' => 'wordpress-plugin',
    1818            'install_path' => __DIR__ . '/../../',
Note: See TracChangeset for help on using the changeset viewer.