PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.3
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.3
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 66 releases
better-payment / includes / AI / Support / DateGuard.php

DateGuard.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.3, at includes/AI/Support/DateGuard.php

114 lines 4.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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