Changeset 3776217
- Timestamp:
- 10/31/2026 12:28:20 AM (5 hours ago)
- Location:
- renewforge
- Files:
-
- 2 added
- 34 edited
- 1 copied
-
tags/0.4.0 (copied) (copied from renewforge/trunk )
-
tags/0.4.0/readme.txt (modified) (3 diffs)
-
tags/0.4.0/renewforge.php (modified) (3 diffs)
-
tags/0.4.0/src/Admin/ComplianceScreen.php (modified) (1 diff)
-
tags/0.4.0/src/Admin/JournalScreen.php (modified) (1 diff)
-
tags/0.4.0/src/Cancel/CancelService.php (modified) (10 diffs)
-
tags/0.4.0/src/Cancel/Escalation.php (modified) (2 diffs)
-
tags/0.4.0/src/Domain/Journal/EventType.php (modified) (1 diff)
-
tags/0.4.0/src/Domain/Money/RecordedAmount.php (modified) (1 diff)
-
tags/0.4.0/src/Infra/Store/SettingsRepository.php (modified) (1 diff)
-
tags/0.4.0/src/Infra/Subscriptions/RecordedTotal.php (added)
-
tags/0.4.0/src/Infra/Subscriptions/SubscriptionsGateway.php (modified) (1 diff)
-
tags/0.4.0/src/Infra/Subscriptions/WcsGateway.php (modified) (9 diffs)
-
tags/0.4.0/src/Plugin.php (modified) (3 diffs)
-
tags/0.4.0/uninstall.php (modified) (1 diff)
-
tags/0.4.0/vendor/autoload.php (modified) (1 diff)
-
tags/0.4.0/vendor/composer/autoload_classmap.php (modified) (1 diff)
-
tags/0.4.0/vendor/composer/autoload_real.php (modified) (2 diffs)
-
tags/0.4.0/vendor/composer/autoload_static.php (modified) (3 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/renewforge.php (modified) (3 diffs)
-
trunk/src/Admin/ComplianceScreen.php (modified) (1 diff)
-
trunk/src/Admin/JournalScreen.php (modified) (1 diff)
-
trunk/src/Cancel/CancelService.php (modified) (10 diffs)
-
trunk/src/Cancel/Escalation.php (modified) (2 diffs)
-
trunk/src/Domain/Journal/EventType.php (modified) (1 diff)
-
trunk/src/Domain/Money/RecordedAmount.php (modified) (1 diff)
-
trunk/src/Infra/Store/SettingsRepository.php (modified) (1 diff)
-
trunk/src/Infra/Subscriptions/RecordedTotal.php (added)
-
trunk/src/Infra/Subscriptions/SubscriptionsGateway.php (modified) (1 diff)
-
trunk/src/Infra/Subscriptions/WcsGateway.php (modified) (9 diffs)
-
trunk/src/Plugin.php (modified) (3 diffs)
-
trunk/uninstall.php (modified) (1 diff)
-
trunk/vendor/autoload.php (modified) (1 diff)
-
trunk/vendor/composer/autoload_classmap.php (modified) (1 diff)
-
trunk/vendor/composer/autoload_real.php (modified) (2 diffs)
-
trunk/vendor/composer/autoload_static.php (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
renewforge/tags/0.4.0/readme.txt
r3774319 r3776217 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.2 7 Stable tag: 0. 3.17 Stable tag: 0.4.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 116 116 == Changelog == 117 117 118 = 0.4.0 = 119 * Changed: deleting the plugin follows "Delete the journal and the Renew Forge settings when the plugin is deleted from the Plugins screen." Unticked, the journal and settings stay; pending term-end jobs are always removed. 120 * Fixed: cancellations at the end of a minimum term survive delayed or locked jobs and moved end dates, with retries and alerts when an ending cannot be completed. 121 * Fixed: subscription amounts use the total recorded on the order, including its original decimal places. 122 118 123 = 0.3.1 = 119 124 * Changed: the journal table gets an index on the order number. A table from an earlier version is brought up to date in wp-admin, by WP-Cron or when the plugin is activated, one request at a time and never on a visitor's request; a table that is missing is created at once. No record is changed. … … 149 154 == Upgrade Notice == 150 155 156 = 0.4.0 = 157 Improves cancellation retries at the end of a minimum term. Deleting the plugin keeps your journal and settings unless you tick the delete-data checkbox. 158 151 159 = 0.3.1 = 152 160 Adds an index on the order number to the journal table. Nothing to set up after updating. -
renewforge/tags/0.4.0/renewforge.php
r3736193 r3776217 4 4 * Plugin URI: https://renewforgewp.com 5 5 * Description: Helps WooCommerce Subscriptions stores prepare for the UK subscription rules: a compliance check, one-click cancellation with a confirmation email, an evidence journal and a pre-contract summary on product pages. 6 * Version: 0. 3.16 * Version: 0.4.0 7 7 * Requires at least: 6.5 8 8 * Requires PHP: 8.2 … … 31 31 } 32 32 33 define( 'RNWF_VERSION', '0. 3.1' );33 define( 'RNWF_VERSION', '0.4.0' ); 34 34 35 35 /* … … 193 193 require_once RNWF_DIR . 'vendor/autoload.php'; 194 194 195 \RenewForge\Infra\Store\SettingsRepository::install(); 195 196 \RenewForge\Infra\Store\Schema::install(); 196 197 } -
renewforge/tags/0.4.0/src/Admin/ComplianceScreen.php
r3733429 r3776217 98 98 } 99 99 100 echo '<p class="rnwf-intro">' . esc_html__('How this store lines up with the subscription rules of the UK Digital Markets, Competition and Consumers Act 2024 (Part 4, Chapter 2). The UK government announced in August 2026 that the rules come into force in January 2027 and has not yet published the regulations that fill in the details, so treat this as a working list, not legal advice.', 'renewforge') . '</p>';100 echo '<p class="rnwf-intro">' . esc_html__('How this store lines up with the subscription rules of the UK Digital Markets, Competition and Consumers Act 2024 (Part 4, Chapter 2). The UK government announced in August 2026 that the rules come into force in January 2027. The draft regulations that fill in the details — The Consumer Subscription Contracts (Cancellation and Information) Regulations 2026 — were laid before Parliament in October 2026 and are due to apply from 1 January 2027; they are not yet made, so the wording may still change. Treat this as a working list, not legal advice.', 'renewforge') . '</p>'; 101 101 102 102 // The headline, and the button that prints the screen as a report (shown by the script). -
renewforge/tags/0.4.0/src/Admin/JournalScreen.php
r3733429 r3776217 652 652 EventType::CancelWithdrawn => __('Cancellation withdrawn (reactivated)', 'renewforge'), 653 653 EventType::CancelScheduled => __('Cancellation requested: ends at the end of the minimum term', 'renewforge'), 654 EventType::TermEndMoved => __('End of the minimum term moved (the subscription now runs longer)', 'renewforge'), 654 655 EventType::EndNoticeRetried => __('Confirmation email retried', 'renewforge'), 655 656 EventType::CheckRun => __('Compliance check recorded', 'renewforge'), -
renewforge/tags/0.4.0/src/Cancel/CancelService.php
r3733429 r3776217 47 47 final class CancelService 48 48 { 49 /** How long after a scheduled ending found the subscription locked it is queued again (ISO 8601 interval). */ 50 private const END_RETRY_INTERVAL = 'PT10M'; 51 52 /** The shop is emailed about the same subscription's failed scheduled ending at most this often (ISO 8601 interval). */ 53 private const ALERT_EVERY = 'P7D'; 54 55 /** 56 * A charge due within this long (or already overdue) is blocked by ending the subscription on the spot (ISO 8601 57 * interval). Two periods of the Pro add-on's daily check (2 * DAY_IN_SECONDS): while Pro runs, the check comes back 58 * to a refused ending every day, so one late check still finds the charge ahead of it. A charge slipping through is worse 59 * than losing up to two days of access. 60 */ 61 private const CHARGE_NEAR = 'PT48H'; 62 49 63 /** @var array<int,true> Subscriptions this request is cancelling through the button right now. */ 50 64 private array $inFlight = []; 51 65 66 /** @var array<int,\DateTimeImmutable> Subscriptions whose charge, due soon, neither way of ending could stop in this request: the date of that charge. */ 67 private array $chargeNotStopped = []; 68 52 69 /** @var callable():?TermEndPolicy */ 53 70 private $termEnd; 54 71 55 /** @param (callable():?TermEndPolicy)|null $termEnd the add-on's minimum-term policy, read at each request */ 72 /** @var callable(int):void */ 73 private $unscheduleEnding; 74 75 /** 76 * @param (callable():?TermEndPolicy)|null $termEnd the add-on's minimum-term policy, read at each request 77 * @param (callable(int):void)|null $unscheduleEnding takes a subscription's scheduled term-end ending off the queue 78 */ 56 79 public function __construct( 57 80 private readonly SubscriptionsGateway $gateway, … … 61 84 private readonly SubscriptionLock $lock, 62 85 private readonly Escalation $escalation, 63 ?callable $termEnd = null 86 ?callable $termEnd = null, 87 ?callable $unscheduleEnding = null 64 88 ) { 65 $this->termEnd = $termEnd ?? static fn (): ?TermEndPolicy => null; 89 $this->termEnd = $termEnd ?? static fn (): ?TermEndPolicy => null; 90 $this->unscheduleEnding = $unscheduleEnding ?? static function (int $subscriptionId): void { 91 }; 66 92 } 67 93 … … 134 160 } 135 161 162 // Ended by someone else (the shop, the customer in the core's own screen): what the shop was told about a 163 // scheduled ending that could not be carried out is out of date, whether or not the notice goes out below. 164 $this->forgetTermEndAlerts($subscription->id); 165 136 166 $now = $this->clock->now(); 137 167 $outcome = new CancelOutcome($statusBefore, $subscription->status, $now, $subscription->endAt ?? $now); … … 158 188 if ($subscription === null) { 159 189 return false; 190 } 191 192 // A request to end at the end of a minimum term goes with the cancellation: the customer has 193 // taken the subscription back, so the ending is off and its job is taken off the queue. 194 $asked = $this->gateway->cancelAtTermEnd($subscription->id); 195 if ($asked !== null) { 196 $this->gateway->saveCancelAtTermEnd($subscription->id, null); 197 $this->forgetTermEndAlerts($subscription->id); 198 ($this->unscheduleEnding)($subscription->id); 160 199 } 161 200 … … 166 205 $channel, 167 206 array_filter([ 168 'status_before' => 'pending-cancel', 169 'status_after' => $subscription->status, 170 'next_payment_at' => $subscription->nextPaymentAt?->format('Y-m-d H:i:s'), 207 'status_before' => 'pending-cancel', 208 'status_after' => $subscription->status, 209 'next_payment_at' => $subscription->nextPaymentAt?->format('Y-m-d H:i:s'), 210 'term_end_request_cleared' => $asked?->format('Y-m-d H:i:s'), 171 211 ], static fn ($v): bool => $v !== null), 172 212 $fingerprint, … … 215 255 216 256 /** 217 * The scheduled ending at the end of a minimum term (TermEndPolicy::HOOK): the 218 * subscription is cancelled now, if it still runs — WooCommerce Subscriptions 219 * ends a fixed-length one at its end date by itself, and then there is nothing 220 * to do — and the customer gets the notice that it has ended. 257 * The scheduled ending at the end of a minimum term (TermEndPolicy::HOOK), if the 258 * subscription still runs — WooCommerce Subscriptions ends a fixed-length one at its 259 * end date by itself, and then there is nothing to do. The customer was told "ends on 260 * X, payments until then remain due" and must pay nothing after X, so: 261 * - its end date is X or earlier, or already behind us: cancelled now; 262 * - its end date moved past X (a plan change) and a payment still falls before it: 263 * an ordinary cancellation ("pending-cancel"), the job is not moved; WooCommerce 264 * Subscriptions ends it at the end of the paid period and charges no more; 265 * - its end date moved past X and nothing is left to pay before it: the request and 266 * the job move to that date (journaled as term_end_moved), no email; 267 * - no end date at all: the same ordinary cancellation, so a period paid a moment 268 * ago is not lost. 269 * Where WooCommerce Subscriptions refuses the ordinary cancellation, the subscription is 270 * ended on the spot only if its next charge is due within two days (or is overdue); otherwise 271 * the shop is told (a journal line every attempt, an email at most once a week per 272 * subscription, and out of turn, once per charge date, when a charge due within two 273 * days could not be stopped by either way). The retry is Pro's daily check 274 * (Pro\Cancel\TermEndSweep): it runs while Pro runs, whatever the state of its key. Without 275 * Pro active nothing comes back to it: the refusal gives that one email and the 276 * subscription stays as it is until the shop ends it by hand. 277 * The customer gets the notice of the real end date, except when the job only moves. 221 278 */ 222 279 public function endAtTermEnd(int $subscriptionId): ?CancelOutcome … … 229 286 } 230 287 if (!$this->lock->acquire($subscriptionId)) { 231 return null; // another request has it; the ending is not lost: the daily run of the queue tries again 288 // Another request has the subscription. Action Scheduler counts this run as done, so nothing 289 // would run the ending again: queue it once more, a little later. 290 $this->endAgainSoon($subscriptionId, $at, $now); 291 292 return null; 232 293 } 233 294 … … 235 296 236 297 try { 237 $outcome = $this->gateway->cancelNow($subscriptionId, __('Ended at the end of the minimum term, as the customer asked (Renew Forge).', 'renewforge')); 298 $subscriptionEnd = $subscription->endAt; 299 $runsPast = $subscriptionEnd !== null && $subscriptionEnd > $at && $subscriptionEnd > $now; 300 $paymentsDue = $runsPast && $subscription->nextPaymentAt !== null && $subscription->nextPaymentAt < $subscriptionEnd; 301 if ($runsPast && !$paymentsDue) { 302 // The subscription now runs past the date the customer was told (a plan change moved its end date) 303 // and the customer has paid beyond that date: what was paid is WooCommerce Subscriptions' to say, 304 // so the ending follows its date. 305 $this->moveTermEnd($subscription, $at, $subscriptionEnd); 306 307 return null; 308 } 309 310 // No end date at all, or one past the date told while payments still fall before it: nothing may be 311 // charged after the date told, and what the customer has already paid stays theirs. An ordinary 312 // cancellation ("pending-cancel") does both: WooCommerce Subscriptions ends the subscription at the end of the 313 // paid period and takes no more. Ending it on the spot would lose a period paid a moment ago. 314 $note = __('Ended at the end of the minimum term, as the customer asked (Renew Forge).', 'renewforge'); 315 $outcome = $subscriptionEnd === null || $runsPast ? $this->endAtEndOfPaidPeriod($subscriptionId, $note, $now) : $this->gateway->cancelNow($subscriptionId, $note); 238 316 } catch (SubscriptionNotFound | CancellationRefused $e) { 317 // A line in the journal every attempt; the shop is emailed once a week (or out of turn for a charge not stopped). 318 // Only Pro's daily check makes more attempts than the job: without Pro active this is the one and only. 239 319 $this->record(Event::now(EventType::CancelFailed, $subscriptionId, $subscription->customerId, Channel::System, ['status_before' => $subscription->status, 'error' => $e->getMessage(), 'term_end' => $at->format('Y-m-d H:i:s')], Fingerprint::none(), $now)); 240 $this->escalation->cancelFailed($subscriptionId, $e->getMessage()); 320 $chargeAt = $this->chargeNotStopped[$subscriptionId] ?? null; 321 unset($this->chargeNotStopped[$subscriptionId]); 322 $this->alertShopOfFailedEnding($subscriptionId, $e->getMessage(), $now, $at, $chargeAt); 241 323 242 324 return null; … … 246 328 } 247 329 330 $this->forgetTermEndAlerts($subscriptionId); // ended: the shop has nothing more to be told 331 248 332 if (!$outcome->alreadyCancelled) { 249 333 $this->confirm($subscription, $outcome, Channel::System, Fingerprint::none()); … … 251 335 252 336 return $outcome; 337 } 338 339 /** 340 * An ordinary cancellation: "pending-cancel" while time is paid for, so the subscription ends at the end of 341 * the paid period and is charged no more. Where WooCommerce Subscriptions will not set that (it refuses the 342 * change) the customer keeps the period paid for and the shop is told (Pro's daily check tries again while Pro is active; 343 * without it this is the only attempt) — unless 344 * the next charge is due within two days or is overdue and the subscription still runs: nothing may be charged 345 * after the date the customer was told, so it is ended on the spot, which blocks the charge. With no next 346 * charge date nothing says a charge is near, and it is left to the shop (and to Pro's next daily check, if Pro runs). 347 * 348 * @throws SubscriptionNotFound 349 * @throws CancellationRefused when neither could be done 350 */ 351 private function endAtEndOfPaidPeriod(int $subscriptionId, string $note, \DateTimeImmutable $now): CancelOutcome 352 { 353 try { 354 return $this->gateway->cancel($subscriptionId, $note); 355 } catch (CancellationRefused $refused) { 356 $next = $this->gateway->nextPaymentAt($subscriptionId); 357 if ($next === null || $next >= $now->add(new \DateInterval(self::CHARGE_NEAR))) { 358 throw $refused; 359 } 360 361 try { 362 $outcome = $this->gateway->cancelNow($subscriptionId, $note); 363 } catch (CancellationRefused $refusedNow) { 364 // Both ways are refused and a charge is close: the shop is told out of turn. 365 $this->chargeNotStopped[$subscriptionId] = $next; 366 367 throw $refusedNow; 368 } 369 $this->gateway->addNote($subscriptionId, sprintf( 370 /* translators: %s: date (UTC) */ 371 __('Renew Forge: WooCommerce Subscriptions would not set the cancellation to take effect at the end of the paid period, and the next payment was due at %s (UTC), within two days, so the subscription was ended now to stop the charge.', 'renewforge'), 372 $next->format('Y-m-d H:i') 373 )); 374 375 return $outcome; 376 } 377 } 378 379 /** What the shop was told about a failed ending is out of date (the subscription ended, was taken back or moved on): forget it. */ 380 private function forgetTermEndAlerts(int $subscriptionId): void 381 { 382 if ($this->gateway->termEndAlertedAt($subscriptionId) !== null) { 383 $this->gateway->saveTermEndAlertedAt($subscriptionId, null); 384 } 385 if ($this->gateway->termEndChargeAlertedFor($subscriptionId) !== null) { 386 $this->gateway->saveTermEndChargeAlertedFor($subscriptionId, null); 387 } 388 } 389 390 /** The scheduled ending failed: the shop is emailed (with an incident and a note) the first time, then at most once a week for this subscription. */ 391 private function alertShopOfFailedEnding(int $subscriptionId, string $error, \DateTimeImmutable $now, \DateTimeImmutable $termEnd, ?\DateTimeImmutable $chargeAt): void 392 { 393 // A charge due within two days that neither way of ending stopped: one email out of turn for each charge date, 394 // whatever the weekly limit says. It says everything the ordinary one does, so it stands for it (and starts the week). 395 $told = $this->gateway->termEndChargeAlertedFor($subscriptionId); 396 if ($chargeAt !== null && ($told === null || $told->getTimestamp() !== $chargeAt->getTimestamp())) { 397 $this->escalation->chargeNotStopped($subscriptionId, $error, $termEnd, $chargeAt); 398 $this->gateway->saveTermEndChargeAlertedFor($subscriptionId, $chargeAt); 399 $this->gateway->saveTermEndAlertedAt($subscriptionId, $now); 400 401 return; 402 } 403 404 $last = $this->gateway->termEndAlertedAt($subscriptionId); 405 if ($last !== null && $now < $last->add(new \DateInterval(self::ALERT_EVERY))) { 406 return; 407 } 408 409 $this->escalation->termEndFailed($subscriptionId, $error, $termEnd, $this->gateway->nextPaymentAt($subscriptionId)); 410 $this->gateway->saveTermEndAlertedAt($subscriptionId, $now); 411 } 412 413 /** The subscription's own end date is later than the one promised: move the request and its job to it, and journal it. */ 414 private function moveTermEnd(SubscriptionRecord $subscription, \DateTimeImmutable $from, \DateTimeImmutable $to): void 415 { 416 $this->forgetTermEndAlerts($subscription->id); // a refusal on the old date does not carry over to the new one 417 $this->record(Event::now( 418 EventType::TermEndMoved, 419 $subscription->id, 420 $subscription->customerId, 421 Channel::System, 422 ['reason' => 'subscription_end_date_later', 'previous_ends_at' => $from->format('Y-m-d H:i:s'), 'ends_at' => $to->format('Y-m-d H:i:s')], 423 Fingerprint::none(), 424 $this->clock->now() 425 )); 426 427 $this->gateway->saveCancelAtTermEnd($subscription->id, $to); 428 try { 429 $scheduled = $this->termEndPolicy()?->schedule($subscription->id, $to) ?? false; 430 } catch (Throwable) { 431 $scheduled = false; // an add-on's queue failing is the shop's to hear about, not a fatal error in the ending 432 } 433 $this->gateway->addNote($subscription->id, sprintf( 434 /* translators: 1: date (UTC), 2: date (UTC) */ 435 __('Renew Forge: the subscription now runs longer than the minimum term (a plan change?). The ending the customer asked for moved from %1$s to %2$s (UTC).', 'renewforge'), 436 $from->format('Y-m-d H:i'), 437 $to->format('Y-m-d H:i') 438 )); 439 if (!$scheduled) { 440 $this->escalation->endNotScheduled($subscription->id, $to); 441 } 442 } 443 444 /** The scheduled ending found the subscription locked: queue it again shortly, or tell the shop that it could not be. */ 445 private function endAgainSoon(int $subscriptionId, \DateTimeImmutable $termEnd, \DateTimeImmutable $now): void 446 { 447 try { 448 $queued = $this->termEndPolicy()?->schedule($subscriptionId, $now->add(new \DateInterval(self::END_RETRY_INTERVAL))) ?? false; 449 } catch (Throwable) { 450 $queued = false; 451 } 452 453 if (!$queued) { 454 $this->escalation->endNotScheduled($subscriptionId, $termEnd); 455 } 253 456 } 254 457 -
renewforge/tags/0.4.0/src/Cancel/Escalation.php
r3733429 r3776217 45 45 46 46 /** 47 * The ending the customer asked for at the end of a minimum term could not be carried out: WooCommerce 48 * Subscriptions refused to end the subscription. Not the text of a press of the cancel button (cancelFailed): 49 * the press was answered long ago; what the shop has to do now is end the subscription before it is charged 50 * again, and it needs the end of the term and the date of the next charge for that. 51 */ 52 public function termEndFailed(int $subscriptionId, string $error, \DateTimeImmutable $termEnd, ?\DateTimeImmutable $nextPayment): void 53 { 54 $message = $nextPayment === null 55 ? sprintf( 56 /* translators: 1: date (UTC), 2: error message from WooCommerce Subscriptions */ 57 __('The customer asked to cancel at the end of their minimum term, %1$s (UTC), but WooCommerce Subscriptions refused to end the subscription: %2$s. The subscription is still active and has no next payment date. End it by hand.', 'renewforge'), 58 $termEnd->format('Y-m-d H:i'), 59 self::clause($error) 60 ) 61 : sprintf( 62 /* translators: 1: date (UTC), 2: error message from WooCommerce Subscriptions, 3: date (UTC) */ 63 __('The customer asked to cancel at the end of their minimum term, %1$s (UTC), but WooCommerce Subscriptions refused to end the subscription: %2$s. The subscription is still active and its next payment is due on %3$s (UTC). End it by hand before then.', 'renewforge'), 64 $termEnd->format('Y-m-d H:i'), 65 self::clause($error), 66 $nextPayment->format('Y-m-d H:i') 67 ); 68 69 $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true); 70 } 71 72 /** 73 * A charge due within two days could not be stopped: WooCommerce Subscriptions refused both the cancellation 74 * that takes effect at the end of the paid period and ending the subscription now, and the customer asked to 75 * end it at the end of their minimum term. Sent out of turn (once per charge date), and it says what is left 76 * to do: the subscription's payment is taken by the payment gateway, so cancel it on the gateway's side. 77 */ 78 public function chargeNotStopped(int $subscriptionId, string $error, \DateTimeImmutable $termEnd, \DateTimeImmutable $chargeAt): void 79 { 80 $message = sprintf( 81 /* translators: 1: date (UTC), 2: date (UTC), 3: error message from WooCommerce Subscriptions */ 82 __('The next payment of this subscription, due on %1$s (UTC), could not be stopped. The customer asked to cancel at the end of their minimum term, %2$s (UTC), but WooCommerce Subscriptions refused both the cancellation and ending the subscription now: %3$s. Cancel the subscription on the payment gateway side, so that the customer is not charged, and end it by hand.', 'renewforge'), 83 $chargeAt->format('Y-m-d H:i'), 84 $termEnd->format('Y-m-d H:i'), 85 self::clause($error) 86 ); 87 88 $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true); 89 } 90 91 /** 47 92 * The confirmation email did not go out on attempt $attempt. Schedules 48 93 * the next attempt while there are any left; tells the shop on the first … … 65 110 } 66 111 67 /** A cancellation under a minimum term could not be scheduled for the end of the term. */ 112 /** 113 * A cancellation under a minimum term could not be scheduled for the end of the term. Told at the press and 114 * when the date moves, the date is still ahead; a scheduled run that found the subscription locked and could 115 * not queue itself again tells it when the date has passed: "on that date" would send the shop to a date gone by. 116 */ 68 117 public function endNotScheduled(int $subscriptionId, \DateTimeImmutable $at): void 69 118 { 70 $message = sprintf( 71 /* translators: %s: date (UTC) */ 72 __('The customer asked to cancel at the end of their minimum term, %s (UTC), but the ending could not be scheduled. The request is recorded; end the subscription by hand on that date.', 'renewforge'), 73 $at->format('Y-m-d H:i') 74 ); 119 $message = $at > $this->clock->now() 120 ? sprintf( 121 /* translators: %s: date (UTC) */ 122 __('The customer asked to cancel at the end of their minimum term, %s (UTC), but the ending could not be scheduled. The request is recorded; end the subscription by hand on that date.', 'renewforge'), 123 $at->format('Y-m-d H:i') 124 ) 125 : sprintf( 126 /* translators: %s: date (UTC) */ 127 __('The customer asked to cancel at the end of their minimum term, %s (UTC), and the subscription has not ended: the scheduled ending could not run or be queued again. The request is recorded; end the subscription by hand now.', 'renewforge'), 128 $at->format('Y-m-d H:i') 129 ); 75 130 76 131 $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true); -
renewforge/tags/0.4.0/src/Domain/Journal/EventType.php
r3733429 r3776217 22 22 /** The customer asked to cancel under a minimum term: the subscription ends at the end of it, payments until then due. */ 23 23 case CancelScheduled = 'cancel_scheduled'; 24 /** The end of a minimum term the customer asked to cancel at was moved later: the subscription's own end date moved (a plan change). */ 25 case TermEndMoved = 'term_end_moved'; 24 26 case EndNoticeRetried = 'end_notice_retried'; 25 27 case CheckRun = 'check_run'; -
renewforge/tags/0.4.0/src/Domain/Money/RecordedAmount.php
r3736193 r3776217 8 8 /** 9 9 * An amount the store recorded, read by what the string says and not by the 10 * precision the shop displays prices in. WooCommerce rounds an order total only 11 * when it is set; a shop that later shows whole units, a multi-currency plugin, 12 * or a refund written with more places than the shop shows all leave strings 13 * such as "99.50" in a shop whose price precision is 0. Reading that through the 14 * display precision turns 99.50 into 100: a figure the store never charged, and a 15 * refund the payment gateway (which counts cents whatever the display says) turns 16 * down. The dispute dossier (Pro) and the refunds of an order count in the unit the 17 * strings need; an amount past what Pence holds is not recorded, never rounded. 10 * precision the shop displays prices in. A shop that later shows whole units, a 11 * multi-currency plugin, or a refund written with more places than the shop shows 12 * all leave strings such as "99.50" in the database of a shop whose price precision 13 * is 0. Reading that through the display precision turns 99.50 into 100: a figure 14 * the store never charged, and a refund the payment gateway (which counts cents 15 * whatever the display says) turns down. WooCommerce itself hands out the order 16 * total already rounded to today's precision (set_total runs on every read), so 17 * the total comes from the database (Infra\Subscriptions\RecordedTotal); the 18 * refunds' amounts are not rounded on read. The dispute dossier (Pro) and the 19 * refunds of an order count in the unit the strings need; an amount past what 20 * Pence holds is not recorded, never rounded. 18 21 * 19 22 * No table of currencies: the unit comes from the strings themselves, with the -
renewforge/tags/0.4.0/src/Infra/Store/SettingsRepository.php
r3733429 r3776217 13 13 { 14 14 public const OPTION = 'rnwf_settings'; 15 16 private const OPTION_VERSION = 'rnwf_settings_version'; 17 18 /** Activation: preserve the owner's choice, including an explicit request to delete. */ 19 public static function install(): void 20 { 21 // Pro 0.3.1 treats a missing row as consent to delete. Store only the opt-out: 22 // every other setting must keep following its current, unresolved default. 23 add_option(self::OPTION, [SettingsSchema::DELETE_DATA_KEY => false]); 24 // Autoloaded: migrate() reads it on every request, so it must not cost a query. 25 update_option(self::OPTION_VERSION, RNWF_VERSION, true); 26 } 27 28 /** File updates do not activate the plugin; run this once per Free version, apart from DB migrations. */ 29 public static function migrate(): void 30 { 31 if (get_option(self::OPTION_VERSION, '') !== RNWF_VERSION) { 32 self::install(); 33 } 34 } 15 35 16 36 public function values(): SettingsValues -
renewforge/tags/0.4.0/src/Infra/Subscriptions/SubscriptionsGateway.php
r3736193 r3776217 85 85 public function saveCancelAtTermEnd(int $id, ?DateTimeImmutable $at): void; 86 86 87 /** 88 * When the shop was last emailed that this subscription's scheduled ending could not be carried out (meta 89 * rnwf_term_end_alerted_at, UTC); null when it was not, or the subscription has been ended since. 90 */ 91 public function termEndAlertedAt(int $id): ?DateTimeImmutable; 92 93 /** Remember (or, with null, forget) the time of that email. */ 94 public function saveTermEndAlertedAt(int $id, ?DateTimeImmutable $at): void; 95 96 /** 97 * The next-payment date for which the shop was told, out of turn, that a charge due within two days could not 98 * be stopped (meta rnwf_term_end_charge_alerted_for, UTC); null when it was not, or the subscription has been 99 * ended or its ending moved since. One such email per date. 100 */ 101 public function termEndChargeAlertedFor(int $id): ?DateTimeImmutable; 102 103 /** Remember (or, with null, forget) that date. */ 104 public function saveTermEndChargeAlertedFor(int $id, ?DateTimeImmutable $at): void; 105 106 /** 107 * Subscriptions that are still charged (active or on hold) and carry a request to end at the end of 108 * a minimum term whose date is $now or earlier (the ones whose scheduled ending is due), lowest id first, 109 * at most $limit. The date is part of the query itself: a request still ahead must not take a place in 110 * the limited list, or an overdue one behind a long line of them would never be reached. 111 * 112 * @return list<int> 113 */ 114 public function subscriptionsAskedToEnd(int $limit, DateTimeImmutable $now): array; 115 87 116 public function nextPaymentAt(int $id): ?DateTimeImmutable; 88 117 -
renewforge/tags/0.4.0/src/Infra/Subscriptions/WcsGateway.php
r3736193 r3776217 50 50 public const CANCEL_AT_TERM_END_META = 'rnwf_cancel_at_term_end'; 51 51 52 /** When the shop was last emailed that the scheduled ending of this subscription could not be carried out (UTC). */ 53 public const TERM_END_ALERT_META = 'rnwf_term_end_alerted_at'; 54 55 /** The next-payment date the shop was told, out of turn, could not be stopped (UTC). */ 56 public const TERM_END_CHARGE_ALERT_META = 'rnwf_term_end_charge_alerted_for'; 57 52 58 public function isAvailable(): bool 53 59 { … … 340 346 $items = $kind === PaidOrder::PARENT ? self::shareOfParent($subscription, $order, $unit) : self::allItems($order, $unit); 341 347 // The whole order and every refund on it, lines or not (review B6, R13). 342 $total = self::exact( $order->get_total(), $unit);348 $total = self::exact(self::recordedTotal($order), $unit); 343 349 $refunded = self::refundedTotal($order, $unit); 344 350 } catch (UnreadableOrderAmount $e) { … … 373 379 * or more when what the order charged or refunded was recorded with more places — an order 374 380 * recorded at 99.50 in a shop that now shows whole units, a currency of three places beside a 375 * shop of two. WooCommerce keeps those strings as written, and the payment gateway refunds in 376 * the currency's own unit whatever the display says. Line amounts do not set it: WooCommerce may 377 * keep them to more places than it charged (tax rounded at subtotal), and they only split a 378 * refund over the lines, at this unit. 381 * shop of two. The database keeps those strings as written, and the payment gateway refunds in 382 * the currency's own unit whatever the display says. WooCommerce does not hand the total out as 383 * written: get_total() is rounded to the shop's price decimals of today on every read (11.1.2, 384 * WC_Abstract_Order::set_total), so the total is read from where it is recorded (recordedTotal()). 385 * The refunds' amounts are kept as written by WooCommerce. Line amounts do not set the unit: 386 * WooCommerce may keep them to more places than it charged (tax rounded at subtotal), and they 387 * only split a refund over the lines, at this unit. 379 388 */ 380 389 private static function orderUnit(\WC_Order $order): int 381 390 { 382 391 $charged = self::refundAmounts($order) ?? [self::recorded($order->get_total_refunded())]; 383 $charged[] = self::recorded( $order->get_total());392 $charged[] = self::recorded(self::recordedTotal($order)); 384 393 385 394 return RecordedAmount::scaleFor($charged, function_exists('wc_get_price_decimals') ? (int) wc_get_price_decimals() : 2); 395 } 396 397 /** 398 * The total of an order as it was recorded, not as WooCommerce rounds it for the shop of today 399 * (RecordedTotal); the figure WooCommerce hands out only for an order the database does not hold. 400 */ 401 private static function recordedTotal(\WC_Order|WC_Subscription $order): mixed 402 { 403 return RecordedTotal::of($order) ?? $order->get_total(); 386 404 } 387 405 … … 680 698 681 699 $ids = []; 700 $perPage = max(1, $perPage); 701 // `offset`, not `paged`: wcs_get_subscriptions() (WCS 8.2.0) builds 702 // its order query from `limit` and `offset` and ignores `paged`, so 703 // every page came back as the first one and the planning chain never 704 // ended (BUG-RENEW-REMIND-01). 682 705 $query = [ 683 706 'subscription_status' => ['active'], 684 'subscriptions_per_page' => max(1, $perPage),685 ' paged' => max(1, $page),707 'subscriptions_per_page' => $perPage, 708 'offset' => (max(1, $page) - 1) * $perPage, 686 709 'orderby' => 'ID', 687 710 'order' => 'ASC', … … 706 729 } 707 730 731 // The rounded reading on purpose (get_total(), not recordedTotal()): this is the price of the shop of today, in its decimals, 732 // as the reminder compares it with the current price; lastPaidRenewalExact() has what the customer was charged. 708 733 return Pence::fromDecimalString(self::scalarString($order->get_total()), function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2); 709 734 } … … 717 742 } 718 743 719 $total = self::scalarString( $order->get_total());744 $total = self::scalarString(self::recordedTotal($order)); 720 745 $decimals = RecordedAmount::scaleFor([$total], function_exists('wc_get_price_decimals') ? (int) wc_get_price_decimals() : 2); 721 746 $pence = RecordedAmount::minor($total, $decimals); … … 769 794 $share = array_sum(array_map(static fn (PaidOrderItem $i): int => $i->grossPence(), self::shareOfParent($subscription, $parent, $decimals))); 770 795 $fee = self::pence($subscription->get_sign_up_fee(), $decimals); 771 $full = Pence::fromDecimalString(self::scalarString($subscription->get_total()), $decimals); 796 // The full price as the subscription was recorded (in the unit of the parent order, like the share it is compared with), 797 // not rounded to the shop's decimals of today: 99.50 would read 100 and a full first period would look like a concession. 798 $full = Pence::fromDecimalString(self::scalarString(self::recordedTotal($subscription)), $decimals); 772 799 773 800 return $full > 0 && $share - $fee < $full; … … 966 993 } 967 994 995 public function termEndAlertedAt(int $id): ?DateTimeImmutable 996 { 997 $subscription = $this->load($id); 998 999 return $subscription === null ? null : self::gmtString($subscription->get_meta(self::TERM_END_ALERT_META, true)); 1000 } 1001 1002 public function saveTermEndAlertedAt(int $id, ?DateTimeImmutable $at): void 1003 { 1004 $subscription = $this->load($id); 1005 if ($subscription === null) { 1006 return; 1007 } 1008 1009 if ($at === null) { 1010 $subscription->delete_meta_data(self::TERM_END_ALERT_META); 1011 } else { 1012 $subscription->update_meta_data(self::TERM_END_ALERT_META, $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s')); 1013 } 1014 $subscription->save(); 1015 } 1016 1017 public function termEndChargeAlertedFor(int $id): ?DateTimeImmutable 1018 { 1019 $subscription = $this->load($id); 1020 1021 return $subscription === null ? null : self::gmtString($subscription->get_meta(self::TERM_END_CHARGE_ALERT_META, true)); 1022 } 1023 1024 public function saveTermEndChargeAlertedFor(int $id, ?DateTimeImmutable $at): void 1025 { 1026 $subscription = $this->load($id); 1027 if ($subscription === null) { 1028 return; 1029 } 1030 1031 if ($at === null) { 1032 $subscription->delete_meta_data(self::TERM_END_CHARGE_ALERT_META); 1033 } else { 1034 $subscription->update_meta_data(self::TERM_END_CHARGE_ALERT_META, $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s')); 1035 } 1036 $subscription->save(); 1037 } 1038 1039 public function subscriptionsAskedToEnd(int $limit, DateTimeImmutable $now): array 1040 { 1041 if (!$this->isAvailable() || !function_exists('wcs_get_subscriptions')) { 1042 return []; 1043 } 1044 1045 $ids = []; 1046 $query = [ 1047 'subscription_status' => ['active', 'on-hold'], 1048 'subscriptions_per_page' => max(1, $limit), 1049 'orderby' => 'ID', 1050 'order' => 'ASC', 1051 // The meta holds the date as 'Y-m-d H:i:s' in UTC (saveCancelAtTermEnd), which compares as a DATETIME. 1052 'meta_query' => [['key' => self::CANCEL_AT_TERM_END_META, 'value' => $now->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), 'compare' => '<=', 'type' => 'DATETIME']], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- the daily check of the few subscriptions that carry a request. 1053 ]; 1054 foreach (wcs_get_subscriptions($query) as $key => $subscription) { 1055 if ($subscription instanceof WC_Subscription) { 1056 $ids[] = (int) $subscription->get_id(); 1057 } elseif (is_numeric($key)) { 1058 $ids[] = (int) $key; 1059 } 1060 } 1061 1062 return $ids; 1063 } 1064 968 1065 public function clearEndNoticeState(int $id): void 969 1066 { … … 1161 1258 self::date($subscription->get_date('next_payment')), 1162 1259 self::date($subscription->get_date('end')), 1260 // The recurring amount in the shop's decimals of today, on purpose (get_total(), not recordedTotal()): it is shown and 1261 // compared with prices in that precision, as lastPaidRenewalPence() is. 1163 1262 Pence::fromDecimalString(self::scalarString($subscription->get_total()), $decimals), 1164 1263 (string) $subscription->get_currency(), -
renewforge/tags/0.4.0/src/Plugin.php
r3736193 r3776217 75 75 } 76 76 77 /** Takes one subscription's scheduled term-end ending off the queue (a cancellation the customer withdrew). */ 78 public static function unscheduleTermEnding(int $subscriptionId): void 79 { 80 if (function_exists('as_unschedule_all_actions')) { 81 as_unschedule_all_actions(TermEndPolicy::HOOK, [$subscriptionId], TermEndPolicy::GROUP); 82 } 83 } 84 77 85 /** For other plugins built on this one: whether WooCommerce Subscriptions is usable. */ 78 86 public function subscriptionsActive(): bool … … 83 91 private function registerHooks(): void 84 92 { 93 SettingsRepository::migrate(); 85 94 // Schema first: screens and the cancel flow expect the journal table, 86 95 // and an update by copying files never runs the activation hook. A missing … … 102 111 return $policy instanceof TermEndPolicy ? $policy : null; 103 112 }; 104 $service = new CancelService($gateway, $journal, new WcMailerNotifier(), $clock, new MysqlSubscriptionLock(), $escalation, $termEnd); 113 $service = new CancelService( 114 $gateway, 115 $journal, 116 new WcMailerNotifier(), 117 $clock, 118 new MysqlSubscriptionLock(), 119 $escalation, 120 $termEnd, 121 [self::class, 'unscheduleTermEnding'] 122 ); 105 123 $button = new CancelButton($settings); 106 124 -
renewforge/tags/0.4.0/uninstall.php
r3736193 r3776217 19 19 } 20 20 21 $rnwf_settings = get_option( 'rnwf_settings', array() );22 23 if ( ! is_array( $rnwf_settings ) || empty( $rnwf_settings['delete_data_on_uninstall'] ) ) {24 return;25 }26 27 21 global $wpdb; 28 22 29 // The plugin's own table; the name is built from the WordPress table prefix, and 30 // identifiers cannot be passed through prepare(). 31 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}rnwf_events" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange 23 /** 24 * Clears one site: the current one, or the one switched to. Each site's content follows its own 25 * "delete the data" tick; scheduled jobs of the removed plugin are always cancelled. 26 */ 27 $rnwf_uninstall_site = static function () use ( $wpdb ): void { 28 // Jobs of a deleted plugin have no callback; unlike customer content they go even when the data stays. 29 if ( version_compare( PHP_VERSION, '8.2', '>=' ) && is_readable( __DIR__ . '/vendor/autoload.php' ) ) { 30 require_once __DIR__ . '/vendor/autoload.php'; 31 \RenewForge\Infra\Queue\ActionSchedulerRetryQueue::clear(); 32 if ( function_exists( 'as_get_scheduled_actions' ) && function_exists( 'as_unschedule_action' ) ) { 33 // Hook + group + [] in as_unschedule_all_actions matches only empty args. Fetch every pending 34 // ending in this hook/group, then cancel each actual argument list, without the default five-item limit. 35 /** @var \ActionScheduler_Action[] $rnwf_actions */ 36 $rnwf_actions = as_get_scheduled_actions( 37 array( 38 'hook' => \RenewForge\Cancel\TermEndPolicy::HOOK, 39 'group' => \RenewForge\Cancel\TermEndPolicy::GROUP, 40 'status' => 'pending', 41 'per_page' => -1, 42 ) 43 ); 44 foreach ( $rnwf_actions as $rnwf_action ) { 45 as_unschedule_action( \RenewForge\Cancel\TermEndPolicy::HOOK, $rnwf_action->get_args(), \RenewForge\Cancel\TermEndPolicy::GROUP ); 46 } 47 } 48 } 32 49 33 $rnwf_options = array( 34 'rnwf_settings', 35 'rnwf_db_version', 36 'rnwf_db_migrating', 37 'rnwf_incidents', 38 // Settings of the plugin's two emails (WooCommerce → Settings → Emails). 39 'woocommerce_rnwf_cancellation_confirmation_settings', 40 'woocommerce_rnwf_admin_alert_settings', 41 ); 50 $rnwf_settings = get_option( 'rnwf_settings', array() ); 42 51 43 foreach ( $rnwf_options as $rnwf_option ) { 44 delete_option( $rnwf_option ); 52 if ( ! is_array( $rnwf_settings ) || empty( $rnwf_settings['delete_data_on_uninstall'] ) ) { 53 return; 54 } 55 56 // The plugin's own table; the name is built from the WordPress table prefix, and 57 // identifiers cannot be passed through prepare(). 58 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}rnwf_events" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange 59 60 $rnwf_options = array( 61 'rnwf_settings', 62 'rnwf_settings_version', 63 'rnwf_db_version', 64 'rnwf_db_migrating', 65 'rnwf_incidents', 66 // Settings of the plugin's two emails (WooCommerce → Settings → Emails). 67 'woocommerce_rnwf_cancellation_confirmation_settings', 68 'woocommerce_rnwf_admin_alert_settings', 69 ); 70 71 foreach ( $rnwf_options as $rnwf_option ) { 72 delete_option( $rnwf_option ); 73 } 74 75 // Four markers on subscriptions, each one exact meta key: "which ending was confirmed", and, under a minimum term, 76 // "the customer asked to cancel at the end of the term" (the date the ending was set for), "the shop was last 77 // emailed that the ending could not be carried out" (a time) and "the shop was told that the charge due on this 78 // date could not be stopped" (a date). Posts table (classic storage) and HPOS orders meta table. 79 $rnwf_meta_keys = array( '_rnwf_end_notice', 'rnwf_cancel_at_term_end', 'rnwf_term_end_alerted_at', 'rnwf_term_end_charge_alerted_for' ); 80 81 foreach ( $rnwf_meta_keys as $rnwf_meta_key ) { 82 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => $rnwf_meta_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall. 83 } 84 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'wc_orders_meta' ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- existence check before cleanup. 85 foreach ( $rnwf_meta_keys as $rnwf_meta_key ) { 86 $wpdb->delete( $wpdb->prefix . 'wc_orders_meta', array( 'meta_key' => $rnwf_meta_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall. 87 } 88 } 89 }; 90 91 if ( is_multisite() ) { 92 // WordPress runs this file once for the whole network: every site is entered in turn. 93 foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $rnwf_site_id ) { 94 switch_to_blog( (int) $rnwf_site_id ); 95 $rnwf_uninstall_site(); 96 restore_current_blog(); 97 } 98 } else { 99 $rnwf_uninstall_site(); 45 100 } 46 47 // The "which ending was confirmed" marker on subscriptions: one exact meta key,48 // in the posts table (classic storage) and in the HPOS orders meta table.49 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_rnwf_end_notice' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall.50 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'wc_orders_meta' ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- existence check before cleanup.51 $wpdb->delete( $wpdb->prefix . 'wc_orders_meta', array( 'meta_key' => '_rnwf_end_notice' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall.52 }53 54 // A pending retry of a confirmation email, if any (below PHP 8.2 the plugin never ran, so none).55 if ( version_compare( PHP_VERSION, '8.2', '>=' ) && is_readable( __DIR__ . '/vendor/autoload.php' ) ) {56 require_once __DIR__ . '/vendor/autoload.php';57 \RenewForge\Infra\Queue\ActionSchedulerRetryQueue::clear();58 } -
renewforge/tags/0.4.0/vendor/autoload.php
r3736193 r3776217 20 20 require_once __DIR__ . '/composer/autoload_real.php'; 21 21 22 return ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea::getLoader();22 return ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188::getLoader(); -
renewforge/tags/0.4.0/vendor/composer/autoload_classmap.php
r3736193 r3776217 102 102 'RenewForge\\Infra\\Store\\WpdbJournal' => $baseDir . '/src/Infra/Store/WpdbJournal.php', 103 103 'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => $baseDir . '/src/Infra/Subscriptions/CancellationRefused.php', 104 'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => $baseDir . '/src/Infra/Subscriptions/RecordedTotal.php', 104 105 'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => $baseDir . '/src/Infra/Subscriptions/SubscriptionNotFound.php', 105 106 'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => $baseDir . '/src/Infra/Subscriptions/SubscriptionsGateway.php', -
renewforge/tags/0.4.0/vendor/composer/autoload_real.php
r3736193 r3776217 3 3 // autoload_real.php @generated by Composer 4 4 5 class ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea5 class ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188 6 6 { 7 7 private static $loader; … … 25 25 require __DIR__ . '/platform_check.php'; 26 26 27 spl_autoload_register(array('ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'), true, true);27 spl_autoload_register(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader'), true, true); 28 28 self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__)); 29 spl_autoload_unregister(array('ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'));29 spl_autoload_unregister(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader')); 30 30 31 31 require __DIR__ . '/autoload_static.php'; 32 call_user_func(\Composer\Autoload\ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::getInitializer($loader));32 call_user_func(\Composer\Autoload\ComposerStaticInit77f5333bb03402da1b55b612e00f9188::getInitializer($loader)); 33 33 34 34 $loader->setClassMapAuthoritative(true); -
renewforge/tags/0.4.0/vendor/composer/autoload_static.php
r3736193 r3776217 5 5 namespace Composer\Autoload; 6 6 7 class ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea7 class ComposerStaticInit77f5333bb03402da1b55b612e00f9188 8 8 { 9 9 public static $prefixLengthsPsr4 = array ( … … 117 117 'RenewForge\\Infra\\Store\\WpdbJournal' => __DIR__ . '/../..' . '/src/Infra/Store/WpdbJournal.php', 118 118 'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/CancellationRefused.php', 119 'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/RecordedTotal.php', 119 120 'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionNotFound.php', 120 121 'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionsGateway.php', … … 127 128 { 128 129 return \Closure::bind(function () use ($loader) { 129 $loader->prefixLengthsPsr4 = ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::$prefixLengthsPsr4;130 $loader->prefixDirsPsr4 = ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::$prefixDirsPsr4;131 $loader->classMap = ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::$classMap;130 $loader->prefixLengthsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixLengthsPsr4; 131 $loader->prefixDirsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixDirsPsr4; 132 $loader->classMap = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$classMap; 132 133 133 134 }, null, ClassLoader::class); -
renewforge/trunk/readme.txt
r3774319 r3776217 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.2 7 Stable tag: 0. 3.17 Stable tag: 0.4.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 116 116 == Changelog == 117 117 118 = 0.4.0 = 119 * Changed: deleting the plugin follows "Delete the journal and the Renew Forge settings when the plugin is deleted from the Plugins screen." Unticked, the journal and settings stay; pending term-end jobs are always removed. 120 * Fixed: cancellations at the end of a minimum term survive delayed or locked jobs and moved end dates, with retries and alerts when an ending cannot be completed. 121 * Fixed: subscription amounts use the total recorded on the order, including its original decimal places. 122 118 123 = 0.3.1 = 119 124 * Changed: the journal table gets an index on the order number. A table from an earlier version is brought up to date in wp-admin, by WP-Cron or when the plugin is activated, one request at a time and never on a visitor's request; a table that is missing is created at once. No record is changed. … … 149 154 == Upgrade Notice == 150 155 156 = 0.4.0 = 157 Improves cancellation retries at the end of a minimum term. Deleting the plugin keeps your journal and settings unless you tick the delete-data checkbox. 158 151 159 = 0.3.1 = 152 160 Adds an index on the order number to the journal table. Nothing to set up after updating. -
renewforge/trunk/renewforge.php
r3736193 r3776217 4 4 * Plugin URI: https://renewforgewp.com 5 5 * Description: Helps WooCommerce Subscriptions stores prepare for the UK subscription rules: a compliance check, one-click cancellation with a confirmation email, an evidence journal and a pre-contract summary on product pages. 6 * Version: 0. 3.16 * Version: 0.4.0 7 7 * Requires at least: 6.5 8 8 * Requires PHP: 8.2 … … 31 31 } 32 32 33 define( 'RNWF_VERSION', '0. 3.1' );33 define( 'RNWF_VERSION', '0.4.0' ); 34 34 35 35 /* … … 193 193 require_once RNWF_DIR . 'vendor/autoload.php'; 194 194 195 \RenewForge\Infra\Store\SettingsRepository::install(); 195 196 \RenewForge\Infra\Store\Schema::install(); 196 197 } -
renewforge/trunk/src/Admin/ComplianceScreen.php
r3733429 r3776217 98 98 } 99 99 100 echo '<p class="rnwf-intro">' . esc_html__('How this store lines up with the subscription rules of the UK Digital Markets, Competition and Consumers Act 2024 (Part 4, Chapter 2). The UK government announced in August 2026 that the rules come into force in January 2027 and has not yet published the regulations that fill in the details, so treat this as a working list, not legal advice.', 'renewforge') . '</p>';100 echo '<p class="rnwf-intro">' . esc_html__('How this store lines up with the subscription rules of the UK Digital Markets, Competition and Consumers Act 2024 (Part 4, Chapter 2). The UK government announced in August 2026 that the rules come into force in January 2027. The draft regulations that fill in the details — The Consumer Subscription Contracts (Cancellation and Information) Regulations 2026 — were laid before Parliament in October 2026 and are due to apply from 1 January 2027; they are not yet made, so the wording may still change. Treat this as a working list, not legal advice.', 'renewforge') . '</p>'; 101 101 102 102 // The headline, and the button that prints the screen as a report (shown by the script). -
renewforge/trunk/src/Admin/JournalScreen.php
r3733429 r3776217 652 652 EventType::CancelWithdrawn => __('Cancellation withdrawn (reactivated)', 'renewforge'), 653 653 EventType::CancelScheduled => __('Cancellation requested: ends at the end of the minimum term', 'renewforge'), 654 EventType::TermEndMoved => __('End of the minimum term moved (the subscription now runs longer)', 'renewforge'), 654 655 EventType::EndNoticeRetried => __('Confirmation email retried', 'renewforge'), 655 656 EventType::CheckRun => __('Compliance check recorded', 'renewforge'), -
renewforge/trunk/src/Cancel/CancelService.php
r3733429 r3776217 47 47 final class CancelService 48 48 { 49 /** How long after a scheduled ending found the subscription locked it is queued again (ISO 8601 interval). */ 50 private const END_RETRY_INTERVAL = 'PT10M'; 51 52 /** The shop is emailed about the same subscription's failed scheduled ending at most this often (ISO 8601 interval). */ 53 private const ALERT_EVERY = 'P7D'; 54 55 /** 56 * A charge due within this long (or already overdue) is blocked by ending the subscription on the spot (ISO 8601 57 * interval). Two periods of the Pro add-on's daily check (2 * DAY_IN_SECONDS): while Pro runs, the check comes back 58 * to a refused ending every day, so one late check still finds the charge ahead of it. A charge slipping through is worse 59 * than losing up to two days of access. 60 */ 61 private const CHARGE_NEAR = 'PT48H'; 62 49 63 /** @var array<int,true> Subscriptions this request is cancelling through the button right now. */ 50 64 private array $inFlight = []; 51 65 66 /** @var array<int,\DateTimeImmutable> Subscriptions whose charge, due soon, neither way of ending could stop in this request: the date of that charge. */ 67 private array $chargeNotStopped = []; 68 52 69 /** @var callable():?TermEndPolicy */ 53 70 private $termEnd; 54 71 55 /** @param (callable():?TermEndPolicy)|null $termEnd the add-on's minimum-term policy, read at each request */ 72 /** @var callable(int):void */ 73 private $unscheduleEnding; 74 75 /** 76 * @param (callable():?TermEndPolicy)|null $termEnd the add-on's minimum-term policy, read at each request 77 * @param (callable(int):void)|null $unscheduleEnding takes a subscription's scheduled term-end ending off the queue 78 */ 56 79 public function __construct( 57 80 private readonly SubscriptionsGateway $gateway, … … 61 84 private readonly SubscriptionLock $lock, 62 85 private readonly Escalation $escalation, 63 ?callable $termEnd = null 86 ?callable $termEnd = null, 87 ?callable $unscheduleEnding = null 64 88 ) { 65 $this->termEnd = $termEnd ?? static fn (): ?TermEndPolicy => null; 89 $this->termEnd = $termEnd ?? static fn (): ?TermEndPolicy => null; 90 $this->unscheduleEnding = $unscheduleEnding ?? static function (int $subscriptionId): void { 91 }; 66 92 } 67 93 … … 134 160 } 135 161 162 // Ended by someone else (the shop, the customer in the core's own screen): what the shop was told about a 163 // scheduled ending that could not be carried out is out of date, whether or not the notice goes out below. 164 $this->forgetTermEndAlerts($subscription->id); 165 136 166 $now = $this->clock->now(); 137 167 $outcome = new CancelOutcome($statusBefore, $subscription->status, $now, $subscription->endAt ?? $now); … … 158 188 if ($subscription === null) { 159 189 return false; 190 } 191 192 // A request to end at the end of a minimum term goes with the cancellation: the customer has 193 // taken the subscription back, so the ending is off and its job is taken off the queue. 194 $asked = $this->gateway->cancelAtTermEnd($subscription->id); 195 if ($asked !== null) { 196 $this->gateway->saveCancelAtTermEnd($subscription->id, null); 197 $this->forgetTermEndAlerts($subscription->id); 198 ($this->unscheduleEnding)($subscription->id); 160 199 } 161 200 … … 166 205 $channel, 167 206 array_filter([ 168 'status_before' => 'pending-cancel', 169 'status_after' => $subscription->status, 170 'next_payment_at' => $subscription->nextPaymentAt?->format('Y-m-d H:i:s'), 207 'status_before' => 'pending-cancel', 208 'status_after' => $subscription->status, 209 'next_payment_at' => $subscription->nextPaymentAt?->format('Y-m-d H:i:s'), 210 'term_end_request_cleared' => $asked?->format('Y-m-d H:i:s'), 171 211 ], static fn ($v): bool => $v !== null), 172 212 $fingerprint, … … 215 255 216 256 /** 217 * The scheduled ending at the end of a minimum term (TermEndPolicy::HOOK): the 218 * subscription is cancelled now, if it still runs — WooCommerce Subscriptions 219 * ends a fixed-length one at its end date by itself, and then there is nothing 220 * to do — and the customer gets the notice that it has ended. 257 * The scheduled ending at the end of a minimum term (TermEndPolicy::HOOK), if the 258 * subscription still runs — WooCommerce Subscriptions ends a fixed-length one at its 259 * end date by itself, and then there is nothing to do. The customer was told "ends on 260 * X, payments until then remain due" and must pay nothing after X, so: 261 * - its end date is X or earlier, or already behind us: cancelled now; 262 * - its end date moved past X (a plan change) and a payment still falls before it: 263 * an ordinary cancellation ("pending-cancel"), the job is not moved; WooCommerce 264 * Subscriptions ends it at the end of the paid period and charges no more; 265 * - its end date moved past X and nothing is left to pay before it: the request and 266 * the job move to that date (journaled as term_end_moved), no email; 267 * - no end date at all: the same ordinary cancellation, so a period paid a moment 268 * ago is not lost. 269 * Where WooCommerce Subscriptions refuses the ordinary cancellation, the subscription is 270 * ended on the spot only if its next charge is due within two days (or is overdue); otherwise 271 * the shop is told (a journal line every attempt, an email at most once a week per 272 * subscription, and out of turn, once per charge date, when a charge due within two 273 * days could not be stopped by either way). The retry is Pro's daily check 274 * (Pro\Cancel\TermEndSweep): it runs while Pro runs, whatever the state of its key. Without 275 * Pro active nothing comes back to it: the refusal gives that one email and the 276 * subscription stays as it is until the shop ends it by hand. 277 * The customer gets the notice of the real end date, except when the job only moves. 221 278 */ 222 279 public function endAtTermEnd(int $subscriptionId): ?CancelOutcome … … 229 286 } 230 287 if (!$this->lock->acquire($subscriptionId)) { 231 return null; // another request has it; the ending is not lost: the daily run of the queue tries again 288 // Another request has the subscription. Action Scheduler counts this run as done, so nothing 289 // would run the ending again: queue it once more, a little later. 290 $this->endAgainSoon($subscriptionId, $at, $now); 291 292 return null; 232 293 } 233 294 … … 235 296 236 297 try { 237 $outcome = $this->gateway->cancelNow($subscriptionId, __('Ended at the end of the minimum term, as the customer asked (Renew Forge).', 'renewforge')); 298 $subscriptionEnd = $subscription->endAt; 299 $runsPast = $subscriptionEnd !== null && $subscriptionEnd > $at && $subscriptionEnd > $now; 300 $paymentsDue = $runsPast && $subscription->nextPaymentAt !== null && $subscription->nextPaymentAt < $subscriptionEnd; 301 if ($runsPast && !$paymentsDue) { 302 // The subscription now runs past the date the customer was told (a plan change moved its end date) 303 // and the customer has paid beyond that date: what was paid is WooCommerce Subscriptions' to say, 304 // so the ending follows its date. 305 $this->moveTermEnd($subscription, $at, $subscriptionEnd); 306 307 return null; 308 } 309 310 // No end date at all, or one past the date told while payments still fall before it: nothing may be 311 // charged after the date told, and what the customer has already paid stays theirs. An ordinary 312 // cancellation ("pending-cancel") does both: WooCommerce Subscriptions ends the subscription at the end of the 313 // paid period and takes no more. Ending it on the spot would lose a period paid a moment ago. 314 $note = __('Ended at the end of the minimum term, as the customer asked (Renew Forge).', 'renewforge'); 315 $outcome = $subscriptionEnd === null || $runsPast ? $this->endAtEndOfPaidPeriod($subscriptionId, $note, $now) : $this->gateway->cancelNow($subscriptionId, $note); 238 316 } catch (SubscriptionNotFound | CancellationRefused $e) { 317 // A line in the journal every attempt; the shop is emailed once a week (or out of turn for a charge not stopped). 318 // Only Pro's daily check makes more attempts than the job: without Pro active this is the one and only. 239 319 $this->record(Event::now(EventType::CancelFailed, $subscriptionId, $subscription->customerId, Channel::System, ['status_before' => $subscription->status, 'error' => $e->getMessage(), 'term_end' => $at->format('Y-m-d H:i:s')], Fingerprint::none(), $now)); 240 $this->escalation->cancelFailed($subscriptionId, $e->getMessage()); 320 $chargeAt = $this->chargeNotStopped[$subscriptionId] ?? null; 321 unset($this->chargeNotStopped[$subscriptionId]); 322 $this->alertShopOfFailedEnding($subscriptionId, $e->getMessage(), $now, $at, $chargeAt); 241 323 242 324 return null; … … 246 328 } 247 329 330 $this->forgetTermEndAlerts($subscriptionId); // ended: the shop has nothing more to be told 331 248 332 if (!$outcome->alreadyCancelled) { 249 333 $this->confirm($subscription, $outcome, Channel::System, Fingerprint::none()); … … 251 335 252 336 return $outcome; 337 } 338 339 /** 340 * An ordinary cancellation: "pending-cancel" while time is paid for, so the subscription ends at the end of 341 * the paid period and is charged no more. Where WooCommerce Subscriptions will not set that (it refuses the 342 * change) the customer keeps the period paid for and the shop is told (Pro's daily check tries again while Pro is active; 343 * without it this is the only attempt) — unless 344 * the next charge is due within two days or is overdue and the subscription still runs: nothing may be charged 345 * after the date the customer was told, so it is ended on the spot, which blocks the charge. With no next 346 * charge date nothing says a charge is near, and it is left to the shop (and to Pro's next daily check, if Pro runs). 347 * 348 * @throws SubscriptionNotFound 349 * @throws CancellationRefused when neither could be done 350 */ 351 private function endAtEndOfPaidPeriod(int $subscriptionId, string $note, \DateTimeImmutable $now): CancelOutcome 352 { 353 try { 354 return $this->gateway->cancel($subscriptionId, $note); 355 } catch (CancellationRefused $refused) { 356 $next = $this->gateway->nextPaymentAt($subscriptionId); 357 if ($next === null || $next >= $now->add(new \DateInterval(self::CHARGE_NEAR))) { 358 throw $refused; 359 } 360 361 try { 362 $outcome = $this->gateway->cancelNow($subscriptionId, $note); 363 } catch (CancellationRefused $refusedNow) { 364 // Both ways are refused and a charge is close: the shop is told out of turn. 365 $this->chargeNotStopped[$subscriptionId] = $next; 366 367 throw $refusedNow; 368 } 369 $this->gateway->addNote($subscriptionId, sprintf( 370 /* translators: %s: date (UTC) */ 371 __('Renew Forge: WooCommerce Subscriptions would not set the cancellation to take effect at the end of the paid period, and the next payment was due at %s (UTC), within two days, so the subscription was ended now to stop the charge.', 'renewforge'), 372 $next->format('Y-m-d H:i') 373 )); 374 375 return $outcome; 376 } 377 } 378 379 /** What the shop was told about a failed ending is out of date (the subscription ended, was taken back or moved on): forget it. */ 380 private function forgetTermEndAlerts(int $subscriptionId): void 381 { 382 if ($this->gateway->termEndAlertedAt($subscriptionId) !== null) { 383 $this->gateway->saveTermEndAlertedAt($subscriptionId, null); 384 } 385 if ($this->gateway->termEndChargeAlertedFor($subscriptionId) !== null) { 386 $this->gateway->saveTermEndChargeAlertedFor($subscriptionId, null); 387 } 388 } 389 390 /** The scheduled ending failed: the shop is emailed (with an incident and a note) the first time, then at most once a week for this subscription. */ 391 private function alertShopOfFailedEnding(int $subscriptionId, string $error, \DateTimeImmutable $now, \DateTimeImmutable $termEnd, ?\DateTimeImmutable $chargeAt): void 392 { 393 // A charge due within two days that neither way of ending stopped: one email out of turn for each charge date, 394 // whatever the weekly limit says. It says everything the ordinary one does, so it stands for it (and starts the week). 395 $told = $this->gateway->termEndChargeAlertedFor($subscriptionId); 396 if ($chargeAt !== null && ($told === null || $told->getTimestamp() !== $chargeAt->getTimestamp())) { 397 $this->escalation->chargeNotStopped($subscriptionId, $error, $termEnd, $chargeAt); 398 $this->gateway->saveTermEndChargeAlertedFor($subscriptionId, $chargeAt); 399 $this->gateway->saveTermEndAlertedAt($subscriptionId, $now); 400 401 return; 402 } 403 404 $last = $this->gateway->termEndAlertedAt($subscriptionId); 405 if ($last !== null && $now < $last->add(new \DateInterval(self::ALERT_EVERY))) { 406 return; 407 } 408 409 $this->escalation->termEndFailed($subscriptionId, $error, $termEnd, $this->gateway->nextPaymentAt($subscriptionId)); 410 $this->gateway->saveTermEndAlertedAt($subscriptionId, $now); 411 } 412 413 /** The subscription's own end date is later than the one promised: move the request and its job to it, and journal it. */ 414 private function moveTermEnd(SubscriptionRecord $subscription, \DateTimeImmutable $from, \DateTimeImmutable $to): void 415 { 416 $this->forgetTermEndAlerts($subscription->id); // a refusal on the old date does not carry over to the new one 417 $this->record(Event::now( 418 EventType::TermEndMoved, 419 $subscription->id, 420 $subscription->customerId, 421 Channel::System, 422 ['reason' => 'subscription_end_date_later', 'previous_ends_at' => $from->format('Y-m-d H:i:s'), 'ends_at' => $to->format('Y-m-d H:i:s')], 423 Fingerprint::none(), 424 $this->clock->now() 425 )); 426 427 $this->gateway->saveCancelAtTermEnd($subscription->id, $to); 428 try { 429 $scheduled = $this->termEndPolicy()?->schedule($subscription->id, $to) ?? false; 430 } catch (Throwable) { 431 $scheduled = false; // an add-on's queue failing is the shop's to hear about, not a fatal error in the ending 432 } 433 $this->gateway->addNote($subscription->id, sprintf( 434 /* translators: 1: date (UTC), 2: date (UTC) */ 435 __('Renew Forge: the subscription now runs longer than the minimum term (a plan change?). The ending the customer asked for moved from %1$s to %2$s (UTC).', 'renewforge'), 436 $from->format('Y-m-d H:i'), 437 $to->format('Y-m-d H:i') 438 )); 439 if (!$scheduled) { 440 $this->escalation->endNotScheduled($subscription->id, $to); 441 } 442 } 443 444 /** The scheduled ending found the subscription locked: queue it again shortly, or tell the shop that it could not be. */ 445 private function endAgainSoon(int $subscriptionId, \DateTimeImmutable $termEnd, \DateTimeImmutable $now): void 446 { 447 try { 448 $queued = $this->termEndPolicy()?->schedule($subscriptionId, $now->add(new \DateInterval(self::END_RETRY_INTERVAL))) ?? false; 449 } catch (Throwable) { 450 $queued = false; 451 } 452 453 if (!$queued) { 454 $this->escalation->endNotScheduled($subscriptionId, $termEnd); 455 } 253 456 } 254 457 -
renewforge/trunk/src/Cancel/Escalation.php
r3733429 r3776217 45 45 46 46 /** 47 * The ending the customer asked for at the end of a minimum term could not be carried out: WooCommerce 48 * Subscriptions refused to end the subscription. Not the text of a press of the cancel button (cancelFailed): 49 * the press was answered long ago; what the shop has to do now is end the subscription before it is charged 50 * again, and it needs the end of the term and the date of the next charge for that. 51 */ 52 public function termEndFailed(int $subscriptionId, string $error, \DateTimeImmutable $termEnd, ?\DateTimeImmutable $nextPayment): void 53 { 54 $message = $nextPayment === null 55 ? sprintf( 56 /* translators: 1: date (UTC), 2: error message from WooCommerce Subscriptions */ 57 __('The customer asked to cancel at the end of their minimum term, %1$s (UTC), but WooCommerce Subscriptions refused to end the subscription: %2$s. The subscription is still active and has no next payment date. End it by hand.', 'renewforge'), 58 $termEnd->format('Y-m-d H:i'), 59 self::clause($error) 60 ) 61 : sprintf( 62 /* translators: 1: date (UTC), 2: error message from WooCommerce Subscriptions, 3: date (UTC) */ 63 __('The customer asked to cancel at the end of their minimum term, %1$s (UTC), but WooCommerce Subscriptions refused to end the subscription: %2$s. The subscription is still active and its next payment is due on %3$s (UTC). End it by hand before then.', 'renewforge'), 64 $termEnd->format('Y-m-d H:i'), 65 self::clause($error), 66 $nextPayment->format('Y-m-d H:i') 67 ); 68 69 $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true); 70 } 71 72 /** 73 * A charge due within two days could not be stopped: WooCommerce Subscriptions refused both the cancellation 74 * that takes effect at the end of the paid period and ending the subscription now, and the customer asked to 75 * end it at the end of their minimum term. Sent out of turn (once per charge date), and it says what is left 76 * to do: the subscription's payment is taken by the payment gateway, so cancel it on the gateway's side. 77 */ 78 public function chargeNotStopped(int $subscriptionId, string $error, \DateTimeImmutable $termEnd, \DateTimeImmutable $chargeAt): void 79 { 80 $message = sprintf( 81 /* translators: 1: date (UTC), 2: date (UTC), 3: error message from WooCommerce Subscriptions */ 82 __('The next payment of this subscription, due on %1$s (UTC), could not be stopped. The customer asked to cancel at the end of their minimum term, %2$s (UTC), but WooCommerce Subscriptions refused both the cancellation and ending the subscription now: %3$s. Cancel the subscription on the payment gateway side, so that the customer is not charged, and end it by hand.', 'renewforge'), 83 $chargeAt->format('Y-m-d H:i'), 84 $termEnd->format('Y-m-d H:i'), 85 self::clause($error) 86 ); 87 88 $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true); 89 } 90 91 /** 47 92 * The confirmation email did not go out on attempt $attempt. Schedules 48 93 * the next attempt while there are any left; tells the shop on the first … … 65 110 } 66 111 67 /** A cancellation under a minimum term could not be scheduled for the end of the term. */ 112 /** 113 * A cancellation under a minimum term could not be scheduled for the end of the term. Told at the press and 114 * when the date moves, the date is still ahead; a scheduled run that found the subscription locked and could 115 * not queue itself again tells it when the date has passed: "on that date" would send the shop to a date gone by. 116 */ 68 117 public function endNotScheduled(int $subscriptionId, \DateTimeImmutable $at): void 69 118 { 70 $message = sprintf( 71 /* translators: %s: date (UTC) */ 72 __('The customer asked to cancel at the end of their minimum term, %s (UTC), but the ending could not be scheduled. The request is recorded; end the subscription by hand on that date.', 'renewforge'), 73 $at->format('Y-m-d H:i') 74 ); 119 $message = $at > $this->clock->now() 120 ? sprintf( 121 /* translators: %s: date (UTC) */ 122 __('The customer asked to cancel at the end of their minimum term, %s (UTC), but the ending could not be scheduled. The request is recorded; end the subscription by hand on that date.', 'renewforge'), 123 $at->format('Y-m-d H:i') 124 ) 125 : sprintf( 126 /* translators: %s: date (UTC) */ 127 __('The customer asked to cancel at the end of their minimum term, %s (UTC), and the subscription has not ended: the scheduled ending could not run or be queued again. The request is recorded; end the subscription by hand now.', 'renewforge'), 128 $at->format('Y-m-d H:i') 129 ); 75 130 76 131 $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true); -
renewforge/trunk/src/Domain/Journal/EventType.php
r3733429 r3776217 22 22 /** The customer asked to cancel under a minimum term: the subscription ends at the end of it, payments until then due. */ 23 23 case CancelScheduled = 'cancel_scheduled'; 24 /** The end of a minimum term the customer asked to cancel at was moved later: the subscription's own end date moved (a plan change). */ 25 case TermEndMoved = 'term_end_moved'; 24 26 case EndNoticeRetried = 'end_notice_retried'; 25 27 case CheckRun = 'check_run'; -
renewforge/trunk/src/Domain/Money/RecordedAmount.php
r3736193 r3776217 8 8 /** 9 9 * An amount the store recorded, read by what the string says and not by the 10 * precision the shop displays prices in. WooCommerce rounds an order total only 11 * when it is set; a shop that later shows whole units, a multi-currency plugin, 12 * or a refund written with more places than the shop shows all leave strings 13 * such as "99.50" in a shop whose price precision is 0. Reading that through the 14 * display precision turns 99.50 into 100: a figure the store never charged, and a 15 * refund the payment gateway (which counts cents whatever the display says) turns 16 * down. The dispute dossier (Pro) and the refunds of an order count in the unit the 17 * strings need; an amount past what Pence holds is not recorded, never rounded. 10 * precision the shop displays prices in. A shop that later shows whole units, a 11 * multi-currency plugin, or a refund written with more places than the shop shows 12 * all leave strings such as "99.50" in the database of a shop whose price precision 13 * is 0. Reading that through the display precision turns 99.50 into 100: a figure 14 * the store never charged, and a refund the payment gateway (which counts cents 15 * whatever the display says) turns down. WooCommerce itself hands out the order 16 * total already rounded to today's precision (set_total runs on every read), so 17 * the total comes from the database (Infra\Subscriptions\RecordedTotal); the 18 * refunds' amounts are not rounded on read. The dispute dossier (Pro) and the 19 * refunds of an order count in the unit the strings need; an amount past what 20 * Pence holds is not recorded, never rounded. 18 21 * 19 22 * No table of currencies: the unit comes from the strings themselves, with the -
renewforge/trunk/src/Infra/Store/SettingsRepository.php
r3733429 r3776217 13 13 { 14 14 public const OPTION = 'rnwf_settings'; 15 16 private const OPTION_VERSION = 'rnwf_settings_version'; 17 18 /** Activation: preserve the owner's choice, including an explicit request to delete. */ 19 public static function install(): void 20 { 21 // Pro 0.3.1 treats a missing row as consent to delete. Store only the opt-out: 22 // every other setting must keep following its current, unresolved default. 23 add_option(self::OPTION, [SettingsSchema::DELETE_DATA_KEY => false]); 24 // Autoloaded: migrate() reads it on every request, so it must not cost a query. 25 update_option(self::OPTION_VERSION, RNWF_VERSION, true); 26 } 27 28 /** File updates do not activate the plugin; run this once per Free version, apart from DB migrations. */ 29 public static function migrate(): void 30 { 31 if (get_option(self::OPTION_VERSION, '') !== RNWF_VERSION) { 32 self::install(); 33 } 34 } 15 35 16 36 public function values(): SettingsValues -
renewforge/trunk/src/Infra/Subscriptions/SubscriptionsGateway.php
r3736193 r3776217 85 85 public function saveCancelAtTermEnd(int $id, ?DateTimeImmutable $at): void; 86 86 87 /** 88 * When the shop was last emailed that this subscription's scheduled ending could not be carried out (meta 89 * rnwf_term_end_alerted_at, UTC); null when it was not, or the subscription has been ended since. 90 */ 91 public function termEndAlertedAt(int $id): ?DateTimeImmutable; 92 93 /** Remember (or, with null, forget) the time of that email. */ 94 public function saveTermEndAlertedAt(int $id, ?DateTimeImmutable $at): void; 95 96 /** 97 * The next-payment date for which the shop was told, out of turn, that a charge due within two days could not 98 * be stopped (meta rnwf_term_end_charge_alerted_for, UTC); null when it was not, or the subscription has been 99 * ended or its ending moved since. One such email per date. 100 */ 101 public function termEndChargeAlertedFor(int $id): ?DateTimeImmutable; 102 103 /** Remember (or, with null, forget) that date. */ 104 public function saveTermEndChargeAlertedFor(int $id, ?DateTimeImmutable $at): void; 105 106 /** 107 * Subscriptions that are still charged (active or on hold) and carry a request to end at the end of 108 * a minimum term whose date is $now or earlier (the ones whose scheduled ending is due), lowest id first, 109 * at most $limit. The date is part of the query itself: a request still ahead must not take a place in 110 * the limited list, or an overdue one behind a long line of them would never be reached. 111 * 112 * @return list<int> 113 */ 114 public function subscriptionsAskedToEnd(int $limit, DateTimeImmutable $now): array; 115 87 116 public function nextPaymentAt(int $id): ?DateTimeImmutable; 88 117 -
renewforge/trunk/src/Infra/Subscriptions/WcsGateway.php
r3736193 r3776217 50 50 public const CANCEL_AT_TERM_END_META = 'rnwf_cancel_at_term_end'; 51 51 52 /** When the shop was last emailed that the scheduled ending of this subscription could not be carried out (UTC). */ 53 public const TERM_END_ALERT_META = 'rnwf_term_end_alerted_at'; 54 55 /** The next-payment date the shop was told, out of turn, could not be stopped (UTC). */ 56 public const TERM_END_CHARGE_ALERT_META = 'rnwf_term_end_charge_alerted_for'; 57 52 58 public function isAvailable(): bool 53 59 { … … 340 346 $items = $kind === PaidOrder::PARENT ? self::shareOfParent($subscription, $order, $unit) : self::allItems($order, $unit); 341 347 // The whole order and every refund on it, lines or not (review B6, R13). 342 $total = self::exact( $order->get_total(), $unit);348 $total = self::exact(self::recordedTotal($order), $unit); 343 349 $refunded = self::refundedTotal($order, $unit); 344 350 } catch (UnreadableOrderAmount $e) { … … 373 379 * or more when what the order charged or refunded was recorded with more places — an order 374 380 * recorded at 99.50 in a shop that now shows whole units, a currency of three places beside a 375 * shop of two. WooCommerce keeps those strings as written, and the payment gateway refunds in 376 * the currency's own unit whatever the display says. Line amounts do not set it: WooCommerce may 377 * keep them to more places than it charged (tax rounded at subtotal), and they only split a 378 * refund over the lines, at this unit. 381 * shop of two. The database keeps those strings as written, and the payment gateway refunds in 382 * the currency's own unit whatever the display says. WooCommerce does not hand the total out as 383 * written: get_total() is rounded to the shop's price decimals of today on every read (11.1.2, 384 * WC_Abstract_Order::set_total), so the total is read from where it is recorded (recordedTotal()). 385 * The refunds' amounts are kept as written by WooCommerce. Line amounts do not set the unit: 386 * WooCommerce may keep them to more places than it charged (tax rounded at subtotal), and they 387 * only split a refund over the lines, at this unit. 379 388 */ 380 389 private static function orderUnit(\WC_Order $order): int 381 390 { 382 391 $charged = self::refundAmounts($order) ?? [self::recorded($order->get_total_refunded())]; 383 $charged[] = self::recorded( $order->get_total());392 $charged[] = self::recorded(self::recordedTotal($order)); 384 393 385 394 return RecordedAmount::scaleFor($charged, function_exists('wc_get_price_decimals') ? (int) wc_get_price_decimals() : 2); 395 } 396 397 /** 398 * The total of an order as it was recorded, not as WooCommerce rounds it for the shop of today 399 * (RecordedTotal); the figure WooCommerce hands out only for an order the database does not hold. 400 */ 401 private static function recordedTotal(\WC_Order|WC_Subscription $order): mixed 402 { 403 return RecordedTotal::of($order) ?? $order->get_total(); 386 404 } 387 405 … … 680 698 681 699 $ids = []; 700 $perPage = max(1, $perPage); 701 // `offset`, not `paged`: wcs_get_subscriptions() (WCS 8.2.0) builds 702 // its order query from `limit` and `offset` and ignores `paged`, so 703 // every page came back as the first one and the planning chain never 704 // ended (BUG-RENEW-REMIND-01). 682 705 $query = [ 683 706 'subscription_status' => ['active'], 684 'subscriptions_per_page' => max(1, $perPage),685 ' paged' => max(1, $page),707 'subscriptions_per_page' => $perPage, 708 'offset' => (max(1, $page) - 1) * $perPage, 686 709 'orderby' => 'ID', 687 710 'order' => 'ASC', … … 706 729 } 707 730 731 // The rounded reading on purpose (get_total(), not recordedTotal()): this is the price of the shop of today, in its decimals, 732 // as the reminder compares it with the current price; lastPaidRenewalExact() has what the customer was charged. 708 733 return Pence::fromDecimalString(self::scalarString($order->get_total()), function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2); 709 734 } … … 717 742 } 718 743 719 $total = self::scalarString( $order->get_total());744 $total = self::scalarString(self::recordedTotal($order)); 720 745 $decimals = RecordedAmount::scaleFor([$total], function_exists('wc_get_price_decimals') ? (int) wc_get_price_decimals() : 2); 721 746 $pence = RecordedAmount::minor($total, $decimals); … … 769 794 $share = array_sum(array_map(static fn (PaidOrderItem $i): int => $i->grossPence(), self::shareOfParent($subscription, $parent, $decimals))); 770 795 $fee = self::pence($subscription->get_sign_up_fee(), $decimals); 771 $full = Pence::fromDecimalString(self::scalarString($subscription->get_total()), $decimals); 796 // The full price as the subscription was recorded (in the unit of the parent order, like the share it is compared with), 797 // not rounded to the shop's decimals of today: 99.50 would read 100 and a full first period would look like a concession. 798 $full = Pence::fromDecimalString(self::scalarString(self::recordedTotal($subscription)), $decimals); 772 799 773 800 return $full > 0 && $share - $fee < $full; … … 966 993 } 967 994 995 public function termEndAlertedAt(int $id): ?DateTimeImmutable 996 { 997 $subscription = $this->load($id); 998 999 return $subscription === null ? null : self::gmtString($subscription->get_meta(self::TERM_END_ALERT_META, true)); 1000 } 1001 1002 public function saveTermEndAlertedAt(int $id, ?DateTimeImmutable $at): void 1003 { 1004 $subscription = $this->load($id); 1005 if ($subscription === null) { 1006 return; 1007 } 1008 1009 if ($at === null) { 1010 $subscription->delete_meta_data(self::TERM_END_ALERT_META); 1011 } else { 1012 $subscription->update_meta_data(self::TERM_END_ALERT_META, $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s')); 1013 } 1014 $subscription->save(); 1015 } 1016 1017 public function termEndChargeAlertedFor(int $id): ?DateTimeImmutable 1018 { 1019 $subscription = $this->load($id); 1020 1021 return $subscription === null ? null : self::gmtString($subscription->get_meta(self::TERM_END_CHARGE_ALERT_META, true)); 1022 } 1023 1024 public function saveTermEndChargeAlertedFor(int $id, ?DateTimeImmutable $at): void 1025 { 1026 $subscription = $this->load($id); 1027 if ($subscription === null) { 1028 return; 1029 } 1030 1031 if ($at === null) { 1032 $subscription->delete_meta_data(self::TERM_END_CHARGE_ALERT_META); 1033 } else { 1034 $subscription->update_meta_data(self::TERM_END_CHARGE_ALERT_META, $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s')); 1035 } 1036 $subscription->save(); 1037 } 1038 1039 public function subscriptionsAskedToEnd(int $limit, DateTimeImmutable $now): array 1040 { 1041 if (!$this->isAvailable() || !function_exists('wcs_get_subscriptions')) { 1042 return []; 1043 } 1044 1045 $ids = []; 1046 $query = [ 1047 'subscription_status' => ['active', 'on-hold'], 1048 'subscriptions_per_page' => max(1, $limit), 1049 'orderby' => 'ID', 1050 'order' => 'ASC', 1051 // The meta holds the date as 'Y-m-d H:i:s' in UTC (saveCancelAtTermEnd), which compares as a DATETIME. 1052 'meta_query' => [['key' => self::CANCEL_AT_TERM_END_META, 'value' => $now->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), 'compare' => '<=', 'type' => 'DATETIME']], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- the daily check of the few subscriptions that carry a request. 1053 ]; 1054 foreach (wcs_get_subscriptions($query) as $key => $subscription) { 1055 if ($subscription instanceof WC_Subscription) { 1056 $ids[] = (int) $subscription->get_id(); 1057 } elseif (is_numeric($key)) { 1058 $ids[] = (int) $key; 1059 } 1060 } 1061 1062 return $ids; 1063 } 1064 968 1065 public function clearEndNoticeState(int $id): void 969 1066 { … … 1161 1258 self::date($subscription->get_date('next_payment')), 1162 1259 self::date($subscription->get_date('end')), 1260 // The recurring amount in the shop's decimals of today, on purpose (get_total(), not recordedTotal()): it is shown and 1261 // compared with prices in that precision, as lastPaidRenewalPence() is. 1163 1262 Pence::fromDecimalString(self::scalarString($subscription->get_total()), $decimals), 1164 1263 (string) $subscription->get_currency(), -
renewforge/trunk/src/Plugin.php
r3736193 r3776217 75 75 } 76 76 77 /** Takes one subscription's scheduled term-end ending off the queue (a cancellation the customer withdrew). */ 78 public static function unscheduleTermEnding(int $subscriptionId): void 79 { 80 if (function_exists('as_unschedule_all_actions')) { 81 as_unschedule_all_actions(TermEndPolicy::HOOK, [$subscriptionId], TermEndPolicy::GROUP); 82 } 83 } 84 77 85 /** For other plugins built on this one: whether WooCommerce Subscriptions is usable. */ 78 86 public function subscriptionsActive(): bool … … 83 91 private function registerHooks(): void 84 92 { 93 SettingsRepository::migrate(); 85 94 // Schema first: screens and the cancel flow expect the journal table, 86 95 // and an update by copying files never runs the activation hook. A missing … … 102 111 return $policy instanceof TermEndPolicy ? $policy : null; 103 112 }; 104 $service = new CancelService($gateway, $journal, new WcMailerNotifier(), $clock, new MysqlSubscriptionLock(), $escalation, $termEnd); 113 $service = new CancelService( 114 $gateway, 115 $journal, 116 new WcMailerNotifier(), 117 $clock, 118 new MysqlSubscriptionLock(), 119 $escalation, 120 $termEnd, 121 [self::class, 'unscheduleTermEnding'] 122 ); 105 123 $button = new CancelButton($settings); 106 124 -
renewforge/trunk/uninstall.php
r3736193 r3776217 19 19 } 20 20 21 $rnwf_settings = get_option( 'rnwf_settings', array() );22 23 if ( ! is_array( $rnwf_settings ) || empty( $rnwf_settings['delete_data_on_uninstall'] ) ) {24 return;25 }26 27 21 global $wpdb; 28 22 29 // The plugin's own table; the name is built from the WordPress table prefix, and 30 // identifiers cannot be passed through prepare(). 31 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}rnwf_events" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange 23 /** 24 * Clears one site: the current one, or the one switched to. Each site's content follows its own 25 * "delete the data" tick; scheduled jobs of the removed plugin are always cancelled. 26 */ 27 $rnwf_uninstall_site = static function () use ( $wpdb ): void { 28 // Jobs of a deleted plugin have no callback; unlike customer content they go even when the data stays. 29 if ( version_compare( PHP_VERSION, '8.2', '>=' ) && is_readable( __DIR__ . '/vendor/autoload.php' ) ) { 30 require_once __DIR__ . '/vendor/autoload.php'; 31 \RenewForge\Infra\Queue\ActionSchedulerRetryQueue::clear(); 32 if ( function_exists( 'as_get_scheduled_actions' ) && function_exists( 'as_unschedule_action' ) ) { 33 // Hook + group + [] in as_unschedule_all_actions matches only empty args. Fetch every pending 34 // ending in this hook/group, then cancel each actual argument list, without the default five-item limit. 35 /** @var \ActionScheduler_Action[] $rnwf_actions */ 36 $rnwf_actions = as_get_scheduled_actions( 37 array( 38 'hook' => \RenewForge\Cancel\TermEndPolicy::HOOK, 39 'group' => \RenewForge\Cancel\TermEndPolicy::GROUP, 40 'status' => 'pending', 41 'per_page' => -1, 42 ) 43 ); 44 foreach ( $rnwf_actions as $rnwf_action ) { 45 as_unschedule_action( \RenewForge\Cancel\TermEndPolicy::HOOK, $rnwf_action->get_args(), \RenewForge\Cancel\TermEndPolicy::GROUP ); 46 } 47 } 48 } 32 49 33 $rnwf_options = array( 34 'rnwf_settings', 35 'rnwf_db_version', 36 'rnwf_db_migrating', 37 'rnwf_incidents', 38 // Settings of the plugin's two emails (WooCommerce → Settings → Emails). 39 'woocommerce_rnwf_cancellation_confirmation_settings', 40 'woocommerce_rnwf_admin_alert_settings', 41 ); 50 $rnwf_settings = get_option( 'rnwf_settings', array() ); 42 51 43 foreach ( $rnwf_options as $rnwf_option ) { 44 delete_option( $rnwf_option ); 52 if ( ! is_array( $rnwf_settings ) || empty( $rnwf_settings['delete_data_on_uninstall'] ) ) { 53 return; 54 } 55 56 // The plugin's own table; the name is built from the WordPress table prefix, and 57 // identifiers cannot be passed through prepare(). 58 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}rnwf_events" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange 59 60 $rnwf_options = array( 61 'rnwf_settings', 62 'rnwf_settings_version', 63 'rnwf_db_version', 64 'rnwf_db_migrating', 65 'rnwf_incidents', 66 // Settings of the plugin's two emails (WooCommerce → Settings → Emails). 67 'woocommerce_rnwf_cancellation_confirmation_settings', 68 'woocommerce_rnwf_admin_alert_settings', 69 ); 70 71 foreach ( $rnwf_options as $rnwf_option ) { 72 delete_option( $rnwf_option ); 73 } 74 75 // Four markers on subscriptions, each one exact meta key: "which ending was confirmed", and, under a minimum term, 76 // "the customer asked to cancel at the end of the term" (the date the ending was set for), "the shop was last 77 // emailed that the ending could not be carried out" (a time) and "the shop was told that the charge due on this 78 // date could not be stopped" (a date). Posts table (classic storage) and HPOS orders meta table. 79 $rnwf_meta_keys = array( '_rnwf_end_notice', 'rnwf_cancel_at_term_end', 'rnwf_term_end_alerted_at', 'rnwf_term_end_charge_alerted_for' ); 80 81 foreach ( $rnwf_meta_keys as $rnwf_meta_key ) { 82 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => $rnwf_meta_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall. 83 } 84 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'wc_orders_meta' ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- existence check before cleanup. 85 foreach ( $rnwf_meta_keys as $rnwf_meta_key ) { 86 $wpdb->delete( $wpdb->prefix . 'wc_orders_meta', array( 'meta_key' => $rnwf_meta_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall. 87 } 88 } 89 }; 90 91 if ( is_multisite() ) { 92 // WordPress runs this file once for the whole network: every site is entered in turn. 93 foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $rnwf_site_id ) { 94 switch_to_blog( (int) $rnwf_site_id ); 95 $rnwf_uninstall_site(); 96 restore_current_blog(); 97 } 98 } else { 99 $rnwf_uninstall_site(); 45 100 } 46 47 // The "which ending was confirmed" marker on subscriptions: one exact meta key,48 // in the posts table (classic storage) and in the HPOS orders meta table.49 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_rnwf_end_notice' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall.50 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'wc_orders_meta' ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- existence check before cleanup.51 $wpdb->delete( $wpdb->prefix . 'wc_orders_meta', array( 'meta_key' => '_rnwf_end_notice' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- one-off cleanup on uninstall.52 }53 54 // A pending retry of a confirmation email, if any (below PHP 8.2 the plugin never ran, so none).55 if ( version_compare( PHP_VERSION, '8.2', '>=' ) && is_readable( __DIR__ . '/vendor/autoload.php' ) ) {56 require_once __DIR__ . '/vendor/autoload.php';57 \RenewForge\Infra\Queue\ActionSchedulerRetryQueue::clear();58 } -
renewforge/trunk/vendor/autoload.php
r3736193 r3776217 20 20 require_once __DIR__ . '/composer/autoload_real.php'; 21 21 22 return ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea::getLoader();22 return ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188::getLoader(); -
renewforge/trunk/vendor/composer/autoload_classmap.php
r3736193 r3776217 102 102 'RenewForge\\Infra\\Store\\WpdbJournal' => $baseDir . '/src/Infra/Store/WpdbJournal.php', 103 103 'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => $baseDir . '/src/Infra/Subscriptions/CancellationRefused.php', 104 'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => $baseDir . '/src/Infra/Subscriptions/RecordedTotal.php', 104 105 'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => $baseDir . '/src/Infra/Subscriptions/SubscriptionNotFound.php', 105 106 'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => $baseDir . '/src/Infra/Subscriptions/SubscriptionsGateway.php', -
renewforge/trunk/vendor/composer/autoload_real.php
r3736193 r3776217 3 3 // autoload_real.php @generated by Composer 4 4 5 class ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea5 class ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188 6 6 { 7 7 private static $loader; … … 25 25 require __DIR__ . '/platform_check.php'; 26 26 27 spl_autoload_register(array('ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'), true, true);27 spl_autoload_register(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader'), true, true); 28 28 self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__)); 29 spl_autoload_unregister(array('ComposerAutoloaderInit7 0f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'));29 spl_autoload_unregister(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader')); 30 30 31 31 require __DIR__ . '/autoload_static.php'; 32 call_user_func(\Composer\Autoload\ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::getInitializer($loader));32 call_user_func(\Composer\Autoload\ComposerStaticInit77f5333bb03402da1b55b612e00f9188::getInitializer($loader)); 33 33 34 34 $loader->setClassMapAuthoritative(true); -
renewforge/trunk/vendor/composer/autoload_static.php
r3736193 r3776217 5 5 namespace Composer\Autoload; 6 6 7 class ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea7 class ComposerStaticInit77f5333bb03402da1b55b612e00f9188 8 8 { 9 9 public static $prefixLengthsPsr4 = array ( … … 117 117 'RenewForge\\Infra\\Store\\WpdbJournal' => __DIR__ . '/../..' . '/src/Infra/Store/WpdbJournal.php', 118 118 'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/CancellationRefused.php', 119 'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/RecordedTotal.php', 119 120 'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionNotFound.php', 120 121 'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionsGateway.php', … … 127 128 { 128 129 return \Closure::bind(function () use ($loader) { 129 $loader->prefixLengthsPsr4 = ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::$prefixLengthsPsr4;130 $loader->prefixDirsPsr4 = ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::$prefixDirsPsr4;131 $loader->classMap = ComposerStaticInit7 0f6e8361c89b458beb2df62131a16ea::$classMap;130 $loader->prefixLengthsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixLengthsPsr4; 131 $loader->prefixDirsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixDirsPsr4; 132 $loader->classMap = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$classMap; 132 133 133 134 }, null, ClassLoader::class);
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)