PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 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 All 68 releases
better-payment / includes / Classes / PaymentDateMigration.php

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

166 lines 4.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\Classes;
4
5 /**
6 * Exit if accessed directly
7 */
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * One-time fix for WooCommerce transaction rows stored in site time.
14 *
15 * `payment_date` is UTC: every form, block, Elementor and Paystack path writes
16 * it with `gmdate()` / `date()` (WordPress runs PHP in UTC), and every PHP
17 * reader converts it with `wp_date( …, strtotime( payment_date ) )`. The
18 * WooCommerce module (checkout rows and subscription renewal rows, both
19 * `referer = 'woocommerce'`) wrote `current_time( 'mysql' )` instead — site
20 * time. On a site east of UTC those rows sorted above newer payments in the
21 * admin list, and showed the wrong time in receipts, the user dashboard and
22 * the CSV export.
23 *
24 * The writers are fixed; this converts the rows they already wrote, once.
25 *
26 * - **Not tied to a version change.** Runs on the first `wp_loaded` that finds
27 * no flag, so a site already on the release that carries it is fixed too.
28 * - **Claimed atomically.** The flag is created with `INSERT IGNORE`; only the
29 * request whose insert lands converts. Two concurrent requests can never both
30 * shift a row.
31 * - **Bounded by id.** The highest row id is read *before* the claim, and only
32 * rows at or below it are converted, so a row written by the fixed code after
33 * the claim — already UTC — is never shifted.
34 * - **Per row, with DST.** `get_gmt_from_date()` converts each value with the
35 * site timezone as it was on that date. A site that changed its timezone since
36 * the row was written cannot be recovered exactly; it is converted with the
37 * current one.
38 *
39 * @since 2.4.1
40 */
41 class PaymentDateMigration {
42
43 /**
44 * Flag option. Its value is the highest row id the conversion covered.
45 */
46 const OPTION = 'better_payment_woo_payment_date_utc';
47
48 /**
49 * Rows read per batch.
50 */
51 const BATCH = 500;
52
53 /**
54 * Convert the rows, unless that already happened.
55 *
56 * @return int Rows converted (0 when already done or nothing to do).
57 */
58 public static function maybe_run() {
59 if ( false !== get_option( self::OPTION, false ) ) {
60 return 0;
61 }
62
63 global $wpdb;
64
65 $table = "{$wpdb->prefix}better_payment";
66
67 // No table yet (activation has not run): nothing to fix, and nothing written
68 // before the fix can appear later. Claim so the check is not repeated.
69 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
70 $max_id = $table_exists ? (int) $wpdb->get_var( "SELECT MAX(id) FROM {$table}" ) : 0;
71
72 if ( ! self::claim( $max_id ) ) {
73 return 0;
74 }
75
76 if ( $max_id <= 0 ) {
77 return 0;
78 }
79
80 $converted = 0;
81 $after_id = 0;
82
83 do {
84 $rows = $wpdb->get_results(
85 $wpdb->prepare(
86 "SELECT id, payment_date FROM {$table} WHERE referer = %s AND id > %d AND id <= %d ORDER BY id ASC LIMIT %d",
87 'woocommerce',
88 $after_id,
89 $max_id,
90 self::BATCH
91 )
92 );
93
94 foreach ( (array) $rows as $row ) {
95 $after_id = (int) $row->id;
96 $utc = self::to_utc( $row->payment_date );
97
98 if ( null === $utc || $utc === $row->payment_date ) {
99 continue;
100 }
101
102 $updated = $wpdb->update(
103 $table,
104 array( 'payment_date' => $utc ),
105 array( 'id' => (int) $row->id ),
106 array( '%s' ),
107 array( '%d' )
108 );
109
110 if ( $updated ) {
111 ++$converted;
112 }
113 }
114 } while ( is_array( $rows ) && count( $rows ) === self::BATCH );
115
116 return $converted;
117 }
118
119 /**
120 * A site-time `Y-m-d H:i:s` as UTC, or null when it is not a usable date.
121 *
122 * @param mixed $site_time Stored value.
123 * @return string|null
124 */
125 public static function to_utc( $site_time ) {
126 $site_time = is_string( $site_time ) ? trim( $site_time ) : '';
127
128 if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $site_time ) || 0 === strpos( $site_time, '0000-' ) ) {
129 return null;
130 }
131
132 $utc = get_gmt_from_date( $site_time, 'Y-m-d H:i:s' );
133
134 return '' !== $utc ? $utc : null;
135 }
136
137 /**
138 * Create the flag, atomically.
139 *
140 * `add_option()` is not a claim: it writes with `ON DUPLICATE KEY UPDATE`,
141 * so a second request with a different value succeeds too.
142 *
143 * @param int $max_id Highest row id covered.
144 * @return bool True for the one request that created it.
145 */
146 private static function claim( $max_id ) {
147 global $wpdb;
148
149 $inserted = $wpdb->query(
150 $wpdb->prepare(
151 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
152 self::OPTION,
153 (string) $max_id,
154 'yes'
155 )
156 );
157
158 // Keep the options cache in line with the row just written (or lost).
159 wp_cache_delete( 'notoptions', 'options' );
160 wp_cache_delete( 'alloptions', 'options' );
161 wp_cache_delete( self::OPTION, 'options' );
162
163 return 1 === (int) $inserted;
164 }
165 }
166