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 / Classes / Usage_Data.php

Usage_Data.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.4, at includes/Classes/Usage_Data.php

698 lines 27.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Better Payment feature-usage collection for the opt-in usage tracker.
4 *
5 * @package Better_Payment\Lite\Classes
6 */
7
8 namespace Better_Payment\Lite\Classes;
9
10 use Better_Payment\Lite\Admin\DB;
11 use Better_Payment\Lite\Models\SubscriptionRelationModel;
12 use Better_Payment\Lite\WooCommerce\Subscriptions;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Collects "which Better Payment features does this site actually use" for the
20 * `optional_data` key of the opt-in tracker payload (`Plugin_Usage_Tracker`).
21 *
22 * ## The line this class does not cross
23 *
24 * This is a PAYMENT plugin, so the boundary is stricter than a generic usage
25 * tracker's and is the first thing to check before adding a key here. Everything
26 * reported is an aggregate count, a boolean, or a fixed enum value chosen from a
27 * closed list in our own code. NOTHING below is ever collected:
28 *
29 * - **Money.** No transaction amounts, no revenue, no campaign goal or raised
30 * figures, no subscription prices. `campaigns_with_goal` counts campaigns
31 * that HAVE a goal; the amount never leaves the site.
32 * - **Transaction volume.** Deliberately absent, even as a count. "How many
33 * payments has this site taken" is commercially sensitive information about
34 * the site owner's business, not a compatibility signal, and the site URL
35 * travels in the same payload. `has_*` booleans about payments are the same
36 * class of fact and are equally excluded.
37 * - **People.** No customer names, emails, addresses, order ids, user ids, or
38 * anything derived from the transactions table or from WooCommerce orders
39 * other than a COUNT.
40 * - **Credentials.** No gateway keys, no AI API keys, no webhook secrets. The
41 * gateway/AI keys are reported only as "configured: yes/no".
42 * - **Free text.** No campaign titles, descriptions, custom CSS classes or
43 * prompt history. Every string value here comes from a fixed set we ship
44 * (template keys, element types, gateway slugs, provider slugs, currency
45 * codes), so a value can never carry content the user typed.
46 *
47 * ## Cost
48 *
49 * `collect()` runs at most once per 24h, only after the user has opted in
50 * (`Plugin_Usage_Tracker::do_tracking()` gates on both). Every query here is
51 * either a COUNT/GROUP BY, an indexed lookup, or explicitly row-capped — see
52 * `MAX_CAMPAIGNS`. Elementor widget counts are read from Elementor's own
53 * maintained aggregate rather than by scanning post meta.
54 *
55 * @since 2.3.2
56 */
57 class Usage_Data {
58
59 /**
60 * Ceiling on how many campaign layouts are parsed for element counts.
61 *
62 * The per-element breakdown needs each campaign's `_bpc_fields_layout` JSON
63 * decoded, which is the only unbounded work in this class. Sites with more
64 * campaigns than this report element usage from the most recent slice; the
65 * campaign COUNTS themselves are always exact (they come from a COUNT query,
66 * not from this loop).
67 */
68 const MAX_CAMPAIGNS = 500;
69
70 /**
71 * Elementor widget names registered by this plugin.
72 *
73 * Mirrors `Admin\Elementor\EL_Integration::init_widgets()`.
74 */
75 const WIDGETS = array(
76 'better-payment',
77 'better-payment-user-dashboard',
78 'fundraising-campaign',
79 );
80
81 /**
82 * Gutenberg block names registered by this plugin.
83 *
84 * Mirrors `Blocks\BlockManager::$blocks` + `Campaign\CampaignBlock`.
85 */
86 const BLOCKS = array(
87 'better-payment/payment-form',
88 'better-payment/user-dashboard',
89 'better-payment/campaign-display',
90 );
91
92 /**
93 * Statuses a Better Payment native WooCommerce subscription can hold.
94 *
95 * Mirrors `WooCommerce\Subscriptions::customer_status_label()`.
96 */
97 const SUBSCRIPTION_STATUSES = array( 'active', 'past_due', 'cancelled', 'completed' );
98
99 /**
100 * Build the full usage payload.
101 *
102 * Every section is independently guarded, so a missing table, an absent
103 * Elementor install or a WooCommerce-free site degrades to fewer keys rather
104 * than to a fatal inside the tracker.
105 *
106 * @return array<string, int|string> Flat map of metric name => scalar.
107 */
108 public static function collect() {
109 $data = array_merge(
110 self::widget_usage(),
111 self::block_usage(),
112 self::campaign_usage(),
113 self::subscription_usage(),
114 self::feature_configuration()
115 );
116
117 /**
118 * Filter the usage payload before it is attached to the tracker request.
119 *
120 * Return an empty array to send no usage data at all while keeping the
121 * environment/compatibility half of the tracker payload.
122 *
123 * @since 2.3.2
124 *
125 * @param mixed $data Flat map of metric name => scalar. Typed `mixed`
126 * because a third-party callback can return anything,
127 * which is what the guard below is for.
128 */
129 $data = apply_filters( 'better_payment/usage_data', $data );
130
131 return is_array( $data ) ? $data : array();
132 }
133
134 /**
135 * Elementor widget usage, keyed `widget_{name}`.
136 *
137 * Two sources, and they do not count the same thing — which is why the
138 * payload says which one produced the numbers:
139 *
140 * 1. Elementor's own `elementor_controls_usage` aggregate. Free to read
141 * and counts widget INSTANCES.
142 * 2. A `_elementor_data` scan, counting POSTS that contain the widget.
143 *
144 * The aggregate is only populated when Elementor's usage module has seen a
145 * post save, so a site that has not re-saved its pages since installing
146 * Elementor has an empty option and would otherwise report no widget usage
147 * at all — indistinguishable from genuinely using none. That blind spot is
148 * what the fallback exists for.
149 *
150 * `widget_count_basis` names the source (`instances` / `posts`) so the two
151 * are never silently averaged together on the receiving end.
152 *
153 * @return array<string, int|string>
154 */
155 protected static function widget_usage() {
156 $counts = self::widget_usage_from_elementor();
157
158 if ( ! empty( $counts ) ) {
159 $counts['widget_count_basis'] = 'instances';
160
161 return $counts;
162 }
163
164 $counts = self::widget_usage_from_post_meta();
165
166 if ( ! empty( $counts ) ) {
167 $counts['widget_count_basis'] = 'posts';
168 }
169
170 return $counts;
171 }
172
173 /**
174 * Widget INSTANCE counts from Elementor's maintained usage aggregate.
175 *
176 * @return array<string, int>
177 */
178 protected static function widget_usage_from_elementor() {
179 $usage = get_option( 'elementor_controls_usage', array() );
180
181 if ( empty( $usage ) || ! is_array( $usage ) ) {
182 return array();
183 }
184
185 $counts = array();
186
187 // Shape: [ doc_type => [ widget_name => [ 'count' => int, ... ] ] ].
188 foreach ( $usage as $elements ) {
189 if ( ! is_array( $elements ) ) {
190 continue;
191 }
192
193 foreach ( $elements as $widget_name => $element_data ) {
194 if ( ! in_array( $widget_name, self::WIDGETS, true ) ) {
195 continue;
196 }
197
198 if ( ! is_array( $element_data ) || ! isset( $element_data['count'] ) ) {
199 continue;
200 }
201
202 $key = 'widget_' . $widget_name;
203 $counts[ $key ] = isset( $counts[ $key ] )
204 ? $counts[ $key ] + (int) $element_data['count']
205 : (int) $element_data['count'];
206 }
207 }
208
209 return array_filter( $counts );
210 }
211
212 /**
213 * Widget usage as a count of POSTS whose `_elementor_data` contains it.
214 *
215 * One `SUM(... LIKE ...)` pass restricted by `meta_key` (which is indexed),
216 * so this touches only Elementor's own rows rather than all of post meta.
217 * Matched on the JSON fragment `"widgetType":"{name}"` — a bare name match
218 * would also hit a widget's saved settings that happen to mention it.
219 *
220 * @return array<string, int>
221 */
222 protected static function widget_usage_from_post_meta() {
223 global $wpdb;
224
225 $selects = array();
226 $params = array();
227
228 foreach ( self::WIDGETS as $widget_name ) {
229 $selects[] = 'SUM( pm.meta_value LIKE %s ) AS ' . self::widget_alias( $widget_name );
230 // Elementor stores its tree JSON-encoded, unspaced.
231 $params[] = '%"widgetType":"' . $wpdb->esc_like( $widget_name ) . '"%';
232 }
233
234 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $selects is built from a class constant; every user-facing value is a placeholder.
235 $sql = 'SELECT ' . implode( ', ', $selects ) . "
236 FROM {$wpdb->postmeta} pm
237 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
238 WHERE pm.meta_key = '_elementor_data'
239 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )";
240
241 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
242 $row = $wpdb->get_row( $wpdb->prepare( $sql, $params ), ARRAY_A );
243 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
244
245 if ( empty( $row ) ) {
246 return array();
247 }
248
249 $counts = array();
250
251 foreach ( self::WIDGETS as $widget_name ) {
252 $alias = self::widget_alias( $widget_name );
253 $count = isset( $row[ $alias ] ) ? (int) $row[ $alias ] : 0;
254
255 if ( $count > 0 ) {
256 $counts[ 'widget_' . $widget_name ] = $count;
257 }
258 }
259
260 return $counts;
261 }
262
263 /**
264 * Gutenberg block usage, keyed `block_{name}` — the number of posts that
265 * contain each block.
266 *
267 * One `SUM(... LIKE ...)` query rather than three `COUNT(*)`s, matched on the
268 * block comment delimiter (`<!-- wp:better-payment/payment-form`) so a block
269 * name that is a prefix of another cannot inflate its neighbour. Revisions
270 * and auto-drafts are excluded — a revision is a copy of content already
271 * counted through its parent.
272 *
273 * @return array<string, int>
274 */
275 protected static function block_usage() {
276 global $wpdb;
277
278 $selects = array();
279 $params = array();
280
281 foreach ( self::BLOCKS as $block_name ) {
282 $selects[] = 'SUM( post_content LIKE %s ) AS ' . self::block_alias( $block_name );
283 // Trailing space/newline is not matched: a block may be self-closing
284 // (`/-->`), have attributes (`{"x":1}`), or neither.
285 $params[] = '%<!-- wp:' . $wpdb->esc_like( $block_name ) . '%';
286 }
287
288 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $selects is built from class constants; every user-facing value is a placeholder.
289 $sql = 'SELECT ' . implode( ', ', $selects ) . "
290 FROM {$wpdb->posts}
291 WHERE post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
292 AND post_type NOT IN ( 'revision' )";
293
294 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
295 $row = $wpdb->get_row( $wpdb->prepare( $sql, $params ), ARRAY_A );
296 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
297
298 if ( empty( $row ) ) {
299 return array();
300 }
301
302 $counts = array();
303
304 foreach ( self::BLOCKS as $block_name ) {
305 $alias = self::block_alias( $block_name );
306 $count = isset( $row[ $alias ] ) ? (int) $row[ $alias ] : 0;
307
308 if ( $count > 0 ) {
309 $counts[ 'block_' . self::short_block_name( $block_name ) ] = $count;
310 }
311 }
312
313 return $counts;
314 }
315
316 /**
317 * Campaign Builder usage.
318 *
319 * Three signals, in ascending order of usefulness:
320 *
321 * - `campaigns_{status}` — how many campaigns exist, and how many made it
322 * out of draft. The gap between the two is the builder's drop-off rate.
323 * - `campaign_template_*` — which starting templates people actually pick
324 * (including `ai-assistant`, the AI-generated path).
325 * - `campaign_element_*` — which elements survive on a saved campaign.
326 * This is the campaign equivalent of widget usage and the only way to
327 * tell a shipped element from an ignored one.
328 *
329 * @return array<string, int>
330 */
331 protected static function campaign_usage() {
332 global $wpdb;
333
334 $counts = array();
335
336 $status_counts = wp_count_posts( 'bp_campaign' );
337
338 foreach ( array( 'publish', 'draft' ) as $status ) {
339 if ( isset( $status_counts->$status ) && (int) $status_counts->$status > 0 ) {
340 $counts[ 'campaigns_' . $status ] = (int) $status_counts->$status;
341 }
342 }
343
344 if ( empty( $counts ) ) {
345 // No campaigns at all — skip three queries that can only return zero.
346 return $counts;
347 }
348
349 // Campaigns that carry a fundraising goal / a deadline. The VALUES are
350 // never collected, only whether the field is in use.
351 foreach ( array( '_bpc_goal_amount' => 'campaigns_with_goal', '_bpc_end_date' => 'campaigns_with_end_date' ) as $meta_key => $metric ) {
352 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
353 $count = (int) $wpdb->get_var(
354 $wpdb->prepare(
355 "SELECT COUNT(*)
356 FROM {$wpdb->postmeta} pm
357 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
358 WHERE pm.meta_key = %s
359 AND pm.meta_value != ''
360 AND pm.meta_value != '0'
361 AND p.post_type = 'bp_campaign'
362 AND p.post_status NOT IN ( 'trash', 'auto-draft' )",
363 $meta_key
364 )
365 );
366
367 if ( $count > 0 ) {
368 $counts[ $metric ] = $count;
369 }
370 }
371
372 // Which starting template each campaign was built from.
373 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
374 $templates = $wpdb->get_results(
375 "SELECT pm.meta_value AS template_key, COUNT(*) AS total
376 FROM {$wpdb->postmeta} pm
377 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
378 WHERE pm.meta_key = '_bpc_template_key'
379 AND pm.meta_value != ''
380 AND p.post_type = 'bp_campaign'
381 AND p.post_status NOT IN ( 'trash', 'auto-draft' )
382 GROUP BY pm.meta_value",
383 ARRAY_A
384 );
385
386 if ( ! empty( $templates ) ) {
387 foreach ( $templates as $template ) {
388 $key = sanitize_key( $template['template_key'] );
389
390 if ( '' !== $key ) {
391 $counts[ 'campaign_template_' . $key ] = (int) $template['total'];
392 }
393 }
394 }
395
396 return array_merge( $counts, self::campaign_element_usage() );
397 }
398
399 /**
400 * Per-element counts across saved campaign layouts, keyed
401 * `campaign_element_{type}`.
402 *
403 * @return array<string, int>
404 */
405 protected static function campaign_element_usage() {
406 global $wpdb;
407
408 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
409 $layouts = $wpdb->get_col(
410 $wpdb->prepare(
411 "SELECT pm.meta_value
412 FROM {$wpdb->postmeta} pm
413 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
414 WHERE pm.meta_key = '_bpc_fields_layout'
415 AND pm.meta_value != ''
416 AND p.post_type = 'bp_campaign'
417 AND p.post_status NOT IN ( 'trash', 'auto-draft' )
418 ORDER BY p.ID DESC
419 LIMIT %d",
420 self::MAX_CAMPAIGNS
421 )
422 );
423
424 if ( empty( $layouts ) ) {
425 return array();
426 }
427
428 $counts = array();
429
430 foreach ( $layouts as $layout_json ) {
431 $layout = json_decode( (string) $layout_json, true );
432
433 if ( ! is_array( $layout ) ) {
434 continue;
435 }
436
437 // Legacy campaigns store a flat element list; current ones store
438 // { layout, columns: [ { elements: [] } ] }. Normalize to columns.
439 $columns = isset( $layout['columns'] ) && is_array( $layout['columns'] )
440 ? $layout['columns']
441 : array( array( 'elements' => $layout ) );
442
443 foreach ( $columns as $column ) {
444 if ( empty( $column['elements'] ) || ! is_array( $column['elements'] ) ) {
445 continue;
446 }
447
448 foreach ( $column['elements'] as $element ) {
449 if ( empty( $element['type'] ) || ! is_string( $element['type'] ) ) {
450 continue;
451 }
452
453 $type = sanitize_key( $element['type'] );
454
455 if ( '' === $type ) {
456 continue;
457 }
458
459 $key = 'campaign_element_' . $type;
460 $counts[ $key ] = isset( $counts[ $key ] ) ? $counts[ $key ] + 1 : 1;
461 }
462 }
463 }
464
465 return $counts;
466 }
467
468 /**
469 * Subscription usage — both kinds, which are different systems.
470 *
471 * - `subscription_relations_{source}` counts rows in the e-commerce
472 * relation table, i.e. subscriptions owned by WooCommerce / FluentCart /
473 * SureCart that Better Payment is integrating with.
474 * - `subscriptions_{status}` counts Better Payment's OWN native
475 * WooCommerce recurring payments, which never write to that table
476 * (they live as parent-order meta).
477 * - `subscription_products` counts products opted into native recurring
478 * billing — configured intent, as opposed to the sales above.
479 *
480 * Counts only. No order ids, no customer ids, no amounts, no billing dates.
481 *
482 * @return array<string, int>
483 */
484 protected static function subscription_usage() {
485 global $wpdb;
486
487 $counts = array();
488
489 $table = $wpdb->prefix . 'better_payment_subscription_order';
490
491 // The table arrives via a version migration, so an install that has not
492 // migrated yet legitimately does not have it.
493 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
494 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) );
495
496 if ( $table_exists === $table ) {
497 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
498 $rows = $wpdb->get_results(
499 "SELECT source, type, COUNT(*) AS total FROM {$table} GROUP BY source, type",
500 ARRAY_A
501 );
502
503 if ( ! empty( $rows ) ) {
504 foreach ( $rows as $row ) {
505 $source = sanitize_key( $row['source'] );
506
507 if ( '' === $source ) {
508 continue;
509 }
510
511 $key = 'subscription_relations_' . $source;
512 $counts[ $key ] = isset( $counts[ $key ] ) ? $counts[ $key ] + (int) $row['total'] : (int) $row['total'];
513
514 if ( SubscriptionRelationModel::TYPE_RENEW === $row['type'] ) {
515 $renew_key = 'subscription_renewals_' . $source;
516 $counts[ $renew_key ] = isset( $counts[ $renew_key ] )
517 ? $counts[ $renew_key ] + (int) $row['total']
518 : (int) $row['total'];
519 }
520 }
521 }
522 }
523
524 return array_merge( $counts, self::native_subscription_usage() );
525 }
526
527 /**
528 * Better Payment's own WooCommerce recurring payments.
529 *
530 * Subscription state is parent-order meta, and orders may live in either the
531 * posts table or HPOS's own tables — so this goes through `wc_get_orders()`
532 * with `paginate`, which resolves to a COUNT against whichever store is
533 * active and returns `total` without hydrating a single order object.
534 *
535 * @return array<string, int>
536 */
537 protected static function native_subscription_usage() {
538 if ( ! function_exists( 'wc_get_orders' ) || ! class_exists( Subscriptions::class ) ) {
539 return array();
540 }
541
542 global $wpdb;
543
544 $counts = array();
545
546 foreach ( self::SUBSCRIPTION_STATUSES as $status ) {
547 $result = wc_get_orders(
548 array(
549 'type' => 'shop_order',
550 'limit' => 1,
551 'return' => 'ids',
552 'paginate' => true,
553 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- the status meta only exists on subscription parent orders; this is the intended lookup, run at most once a day behind opt-in.
554 array(
555 'key' => Subscriptions::STATUS_META,
556 'value' => $status,
557 'compare' => '=',
558 ),
559 ),
560 )
561 );
562
563 $total = is_object( $result ) && isset( $result->total ) ? (int) $result->total : 0;
564
565 if ( $total > 0 ) {
566 $counts[ 'subscriptions_' . $status ] = $total;
567 }
568 }
569
570 // Products opted into recurring billing. Products remain a CPT under
571 // HPOS, so this stays a plain post-meta count.
572 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
573 $products = (int) $wpdb->get_var(
574 $wpdb->prepare(
575 "SELECT COUNT(*)
576 FROM {$wpdb->postmeta} pm
577 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
578 WHERE pm.meta_key = %s
579 AND pm.meta_value = 'yes'
580 AND p.post_type IN ( 'product', 'product_variation' )
581 AND p.post_status NOT IN ( 'trash', 'auto-draft' )",
582 Subscriptions::PRODUCT_ENABLED_META
583 )
584 );
585
586 if ( $products > 0 ) {
587 $counts['subscription_products'] = $products;
588 }
589
590 return $counts;
591 }
592
593 /**
594 * Which features are switched on, and how the plugin is configured.
595 *
596 * This is the half that explains the counts above — a site with zero
597 * campaigns and `feature_fundraising_campaign` off is a very different
598 * data point from one that enabled the feature and never used it.
599 *
600 * Gateways and AI report **configured or not**, never the credential.
601 *
602 * @return array<string, int|string>
603 */
604 protected static function feature_configuration() {
605 $settings = DB::get_settings();
606
607 if ( ! is_array( $settings ) ) {
608 return array();
609 }
610
611 $data = array();
612
613 // Gateways: enabled in settings, and whether the site has left test mode.
614 // "Enabled but never went live" vs "live" is the single clearest signal
615 // of whether an install is actually in production.
616 foreach ( array( 'stripe', 'paypal', 'paystack' ) as $gateway ) {
617 $data[ 'gateway_' . $gateway ] = self::flag( $settings, 'better_payment_settings_general_general_' . $gateway );
618 $data[ 'gateway_' . $gateway . '_live' ] = self::flag( $settings, 'better_payment_settings_payment_' . $gateway . '_live_mode' );
619 }
620
621 $data['feature_user_dashboard'] = self::flag( $settings, 'better_payment_settings_general_general_user_dashboard' );
622 $data['feature_fundraising_campaign'] = self::flag( $settings, 'better_payment_settings_general_general_fundraising_campaign' );
623 $data['feature_email'] = self::flag( $settings, 'better_payment_settings_general_general_email' );
624 $data['feature_ai'] = self::flag( $settings, 'better_payment_settings_ai_enabled' );
625
626 // Provider slug only, and only when AI is actually on. The API key is
627 // never read here.
628 if ( 1 === $data['feature_ai'] && ! empty( $settings['better_payment_settings_ai_provider'] ) ) {
629 $data['ai_provider'] = sanitize_key( $settings['better_payment_settings_ai_provider'] );
630 }
631
632 // Currency drives which gateways/currencies to prioritise. It is a code
633 // from a fixed list, not an amount.
634 if ( ! empty( $settings['better_payment_settings_general_general_currency'] ) ) {
635 $data['currency'] = sanitize_text_field( $settings['better_payment_settings_general_general_currency'] );
636 }
637
638 // Subscription behaviour — only meaningful once the module is in use.
639 if ( ! empty( $settings['better_payment_settings_ecommerce_subscription_renewal_process'] ) ) {
640 $renewal = sanitize_key( $settings['better_payment_settings_ecommerce_subscription_renewal_process'] );
641 $data['subscription_renewal_process'] = in_array( $renewal, array( 'auto', 'manual' ), true ) ? $renewal : 'auto';
642 }
643
644 $data['subscription_auto_renew_toggle'] = self::flag( $settings, 'better_payment_settings_ecommerce_subscription_auto_renewal_toggle' );
645 $data['subscription_myaccount_tab'] = self::flag( $settings, 'better_payment_settings_ecommerce_subscription_myaccount_tab' );
646
647 return $data;
648 }
649
650 /**
651 * Read a settings value as a 1/0 flag.
652 *
653 * The settings array stores booleans as the string `'yes'` (on) and `''` or
654 * `'no'` (off) — see `Admin\DB::default_settings()`.
655 *
656 * @param array $settings Full settings array.
657 * @param string $key Settings key.
658 * @return int
659 */
660 protected static function flag( $settings, $key ) {
661 $value = isset( $settings[ $key ] ) ? $settings[ $key ] : '';
662
663 return ( 'yes' === $value || '1' === $value || 1 === $value || true === $value ) ? 1 : 0;
664 }
665
666 /**
667 * Block name without the `better-payment/` vendor prefix.
668 *
669 * @param string $block_name Full block name.
670 * @return string
671 */
672 protected static function short_block_name( $block_name ) {
673 $parts = explode( '/', $block_name );
674
675 return end( $parts );
676 }
677
678 /**
679 * A SQL-safe column alias for a block name.
680 *
681 * @param string $block_name Full block name.
682 * @return string
683 */
684 protected static function block_alias( $block_name ) {
685 return 'bp_' . str_replace( '-', '_', self::short_block_name( $block_name ) );
686 }
687
688 /**
689 * A SQL-safe column alias for an Elementor widget name.
690 *
691 * @param string $widget_name Widget name.
692 * @return string
693 */
694 protected static function widget_alias( $widget_name ) {
695 return 'bp_w_' . str_replace( '-', '_', $widget_name );
696 }
697 }
698