PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.4
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.4
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 / Services / UserFieldGuard.php

UserFieldGuard.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.4, at includes/AI/Services/UserFieldGuard.php

212 lines 7.7 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\Services;
4
5 use Better_Payment\Lite\AI\Support\DateGuard;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 /**
12 * Enforces the user's own answers over anything the model produced.
13 *
14 * The Smart Prompt Wizard's optional questions ("How much do you need to raise?",
15 * "When should it end?") map onto campaign meta keys. Leaving one blank means
16 * "no preference" — `buildBrief()` omits it from the brief entirely — but a brief
17 * that says nothing is not the same as an instruction to say nothing: models
18 * routinely fill the gap with a plausible-looking goal figure or end date, and
19 * without today's date to anchor on, that date frequently lands in the past.
20 *
21 * Prompt wording alone cannot guarantee this (see Prompt/Templates/generate.php,
22 * which now asks for the same behaviour). This class is the code-level guarantee:
23 * the wizard declares which fields the user actually filled in, and every
24 * `update_meta` operation for a declared field is replaced with the user's own
25 * value — or dropped outright when they left it blank. An empty field therefore
26 * stays empty in the generated campaign, and the campaign renders as though the
27 * field was never provided (`days_remaining` null, no progress percentage).
28 *
29 * Scope is deliberately narrow:
30 * - Only the generation path calls this ({@see CampaignGenerator}). Conversational
31 * edits are direct instructions ("end it on 1 March"), so nothing is enforced there.
32 * - A key the client did not declare at all — the free-form prompt path, where
33 * the user's intent lives in prose we cannot parse — keeps the model's value.
34 * Only the past-date check still applies, since a backdated end date on a
35 * brand-new campaign is never what anyone asked for.
36 *
37 * @see CampaignGenerator::generate()
38 */
39 class UserFieldGuard {
40
41 /**
42 * Campaign meta keys that mirror an optional user-facing field.
43 *
44 * A key listed here is owned by the user: when the client declares it, the
45 * model may not set it to anything else.
46 *
47 * @return array<int, string>
48 */
49 public static function guarded_keys(): array {
50 $keys = [ 'bpc_goal_amount', 'bpc_end_date' ];
51
52 /**
53 * Filter the campaign meta keys whose value the user owns outright.
54 *
55 * @param array<int, string> $keys
56 */
57 return apply_filters( 'better_payment/ai/user_guarded_meta_keys', $keys );
58 }
59
60 /**
61 * Normalise the raw `fields` payload from the client.
62 *
63 * A key present with an empty value is meaningful — it says "the user was
64 * asked and chose to leave this blank", which is what makes the difference
65 * between enforcing emptiness and staying out of the way. A key that is
66 * absent entirely is left absent.
67 *
68 * Anything unusable (a non-numeric goal, a malformed date) normalises to the
69 * empty string rather than being dropped: the user was still asked, so the
70 * field is still theirs — it simply has no value.
71 *
72 * @param mixed $raw
73 * @return array<string, string> Declared keys only, values normalised.
74 */
75 public static function sanitize( $raw ): array {
76 if ( ! is_array( $raw ) ) {
77 return [];
78 }
79
80 $clean = [];
81 foreach ( self::guarded_keys() as $key ) {
82 if ( ! array_key_exists( $key, $raw ) ) {
83 continue;
84 }
85 $clean[ $key ] = self::normalize( $key, $raw[ $key ] );
86 }
87
88 return $clean;
89 }
90
91 /**
92 * Apply the user's declared fields to a batch of model operations.
93 *
94 * Runs before the caller derives `meta` from the operations, so the returned
95 * operations and that derived meta can never disagree.
96 *
97 * @param array $operations Validated operations from the model.
98 * @param mixed $fields The client's declared fields (raw or sanitised).
99 * @return array
100 */
101 public static function apply( array $operations, $fields ): array {
102 $declared = self::sanitize( $fields );
103
104 $kept = [];
105 foreach ( $operations as $operation ) {
106 if ( ! is_array( $operation ) || 'update_meta' !== ( $operation['op'] ?? '' ) ) {
107 $kept[] = $operation;
108 continue;
109 }
110
111 $key = (string) ( $operation['key'] ?? '' );
112
113 // The user owns this field. Drop the model's take on it; their own
114 // value (if any) is appended below.
115 if ( array_key_exists( $key, $declared ) ) {
116 continue;
117 }
118
119 if ( 'bpc_end_date' === $key ) {
120 $date = self::to_iso_date( (string) ( $operation['value'] ?? '' ) );
121 // Unparseable or already elapsed — a generated campaign must not
122 // open having already closed.
123 if ( '' === $date || self::is_past_date( $date ) ) {
124 continue;
125 }
126 $operation['value'] = $date;
127 }
128
129 $kept[] = $operation;
130 }
131
132 // Re-assert the user's own values last, so they win outright.
133 //
134 // These operations are appended AFTER validation, so they never pass
135 // through OperationValidator — the past-date rule has to be repeated
136 // here or this is a hole straight to storage. It is not hypothetical:
137 // Regenerate pins the campaign's *current* meta as declared fields
138 // (`useConversation.js`), so once a campaign had acquired a past end date
139 // by any means, every subsequent redesign carried it forward verbatim.
140 foreach ( $declared as $key => $value ) {
141 if ( '' === $value ) {
142 continue; // Left blank on purpose — it stays unset.
143 }
144
145 if ( 'bpc_end_date' === $key ) {
146 $value = DateGuard::usable_end_date( $value );
147 if ( '' === $value ) {
148 continue;
149 }
150 }
151
152 $kept[] = [
153 'op' => 'update_meta',
154 'key' => $key,
155 'value' => 'bpc_goal_amount' === $key ? (float) $value : $value,
156 ];
157 }
158
159 return $kept;
160 }
161
162 // ------------------------------------------------------------------ helpers
163
164 /**
165 * Normalise one declared field value to its storable string form, or ''.
166 *
167 * @param mixed $value
168 */
169 private static function normalize( string $key, $value ): string {
170 if ( null === $value || is_array( $value ) || is_object( $value ) || is_bool( $value ) ) {
171 return '';
172 }
173
174 $value = trim( (string) $value );
175 if ( '' === $value ) {
176 return '';
177 }
178
179 if ( 'bpc_goal_amount' === $key ) {
180 // 0 is not a goal — the renderer only shows progress above zero.
181 return is_numeric( $value ) && (float) $value > 0 ? (string) ( 0 + $value ) : '';
182 }
183
184 if ( 'bpc_end_date' === $key ) {
185 return self::to_iso_date( $value );
186 }
187
188 return sanitize_text_field( $value );
189 }
190
191 /**
192 * Coerce a date to `Y-m-d`, or '' when it is not a real date.
193 *
194 * Retained as the historical entry point; the implementation moved to
195 * {@see DateGuard} when the same rule had to hold in the operation validator
196 * too. One implementation, so the two can never disagree about what counts
197 * as a date.
198 */
199 public static function to_iso_date( string $value ): string {
200 return DateGuard::to_iso( $value );
201 }
202
203 /**
204 * Whether an ISO date has already elapsed in the site's timezone.
205 *
206 * @see DateGuard::is_past()
207 */
208 public static function is_past_date( string $iso ): bool {
209 return DateGuard::is_past( $iso );
210 }
211 }
212