| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\AI\Support; |
| 4 |
|
| 5 |
if ( ! defined( 'ABSPATH' ) ) { |
| 6 |
exit; |
| 7 |
} |
| 8 |
|
| 9 |
/** |
| 10 |
* Campaign date normalisation, and the one rule that must hold everywhere: |
| 11 |
* **an AI-produced campaign end date is always in the future.** |
| 12 |
* |
| 13 |
* A model has no clock. Asked for an end date it answers from its training |
| 14 |
* distribution, and the dates in that distribution are historical — so a campaign |
| 15 |
* generated today acquired a deadline in 2024, opened having already closed, |
| 16 |
* showed zero days remaining and no progress bar, and quietly told every visitor |
| 17 |
* the appeal was over. It is not a cosmetic bug: it is a fundraising page that |
| 18 |
* refuses donations on its first day. |
| 19 |
* |
| 20 |
* This lived only in {@see \Better_Payment\Lite\AI\Services\UserFieldGuard}, and |
| 21 |
* only on one of its two branches — the case where the client had NOT declared |
| 22 |
* the field. Four routes therefore reached storage unchecked: |
| 23 |
* |
| 24 |
* 1. **Conversational edit** (`/ai/chat` → `CampaignEditor`) — never ran the |
| 25 |
* guard at all, so "give it a deadline" wrote whatever the model said. |
| 26 |
* 2. **Analyze → Apply** — same; the suggestion was validated but never dated. |
| 27 |
* 3. **Generate with a declared value** — the model's op was dropped and the |
| 28 |
* declared value re-asserted, unchecked. |
| 29 |
* 4. **Regenerate** — pins the campaign's *current* meta as declared fields |
| 30 |
* (`useConversation.js`), so a campaign that had already acquired a past date |
| 31 |
* carried it verbatim through every subsequent redesign. |
| 32 |
* |
| 33 |
* Hence a shared helper called from the **validator** (which every model-produced |
| 34 |
* operation passes through, whatever the mode) as well as the field guard. The |
| 35 |
* prompt asks for the same thing in `rules.php`; a prompt is a request, and this |
| 36 |
* is the part that holds. |
| 37 |
* |
| 38 |
* Note this constrains **AI output only**. A human editing the Settings panel by |
| 39 |
* hand may still set any date — closing a campaign retroactively is a legitimate |
| 40 |
* thing for an owner to do, and it is not what this class is about. |
| 41 |
*/ |
| 42 |
class DateGuard { |
| 43 |
|
| 44 |
/** |
| 45 |
* Coerce a date to `Y-m-d`, or '' when it is not a real date. |
| 46 |
* |
| 47 |
* The wizard always sends `Y-m-d`; a model asked for an end date may answer |
| 48 |
* in prose ("31 December 2026"). Both settle here so a stored end date is |
| 49 |
* always in the one format {@see \Better_Payment\Lite\Campaign\CampaignStats} |
| 50 |
* and the renderer parse. |
| 51 |
* |
| 52 |
* @param string $value |
| 53 |
* @return string |
| 54 |
*/ |
| 55 |
public static function to_iso( string $value ): string { |
| 56 |
$value = trim( $value ); |
| 57 |
if ( '' === $value ) { |
| 58 |
return ''; |
| 59 |
} |
| 60 |
|
| 61 |
if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m ) ) { |
| 62 |
return checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ? $value : ''; |
| 63 |
} |
| 64 |
|
| 65 |
$timestamp = strtotime( $value ); |
| 66 |
|
| 67 |
return false !== $timestamp ? gmdate( 'Y-m-d', $timestamp ) : ''; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Whether an ISO date has already elapsed in the site's timezone. |
| 72 |
* |
| 73 |
* Compared against the **end** of that day, so "today" is not past — a |
| 74 |
* campaign may legitimately close today, and treating today as elapsed would |
| 75 |
* reject the one same-day deadline a user might deliberately set. |
| 76 |
* |
| 77 |
* An unparseable value returns false: "not a date" is not "a past date", and |
| 78 |
* the callers drop those separately. Answering true here would conflate the |
| 79 |
* two and make a malformed value look like a deliberate backdate. |
| 80 |
* |
| 81 |
* @param string $iso |
| 82 |
* @return bool |
| 83 |
*/ |
| 84 |
public static function is_past( string $iso ): bool { |
| 85 |
$iso = self::to_iso( $iso ); |
| 86 |
if ( '' === $iso ) { |
| 87 |
return false; |
| 88 |
} |
| 89 |
|
| 90 |
return strtotime( $iso . ' 23:59:59' ) < (int) current_time( 'timestamp' ); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Normalise a model-supplied end date, or return '' if it may not be stored. |
| 95 |
* |
| 96 |
* The single decision point: a value survives only when it parses AND lies in |
| 97 |
* the future. Callers treat '' as "drop the operation", which leaves the field |
| 98 |
* unset — a valid, intentional state that renders as no deadline, rather than |
| 99 |
* a wrong one that renders as an expired campaign. |
| 100 |
* |
| 101 |
* @param mixed $value Raw value from an operation or a declared field. |
| 102 |
* @return string `Y-m-d`, or '' when unusable. |
| 103 |
*/ |
| 104 |
public static function usable_end_date( $value ): string { |
| 105 |
if ( ! is_scalar( $value ) ) { |
| 106 |
return ''; |
| 107 |
} |
| 108 |
|
| 109 |
$iso = self::to_iso( (string) $value ); |
| 110 |
|
| 111 |
return ( '' === $iso || self::is_past( $iso ) ) ? '' : $iso; |
| 112 |
} |
| 113 |
} |
| 114 |
|