Plugin Directory

Changeset 3776217


Ignore:
Timestamp:
10/31/2026 12:28:20 AM (5 hours ago)
Author:
avakodeforge
Message:

Release 0.4.0 (trunk + tag; see readme changelog)

Location:
renewforge
Files:
2 added
34 edited
1 copied

Legend:

Unmodified
Added
Removed
  • renewforge/tags/0.4.0/readme.txt

    r3774319 r3776217  
    55Tested up to: 7.1
    66Requires PHP: 8.2
    7 Stable tag: 0.3.1
     7Stable tag: 0.4.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    116116== Changelog ==
    117117
     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
    118123= 0.3.1 =
    119124* 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.
    … …  
    149154== Upgrade Notice ==
    150155
     156= 0.4.0 =
     157Improves 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
    151159= 0.3.1 =
    152160Adds 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  
    44 * Plugin URI:        https://renewforgewp.com
    55 * 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.1
     6 * Version:           0.4.0
    77 * Requires at least: 6.5
    88 * Requires PHP:      8.2
    … …  
    3131}
    3232
    33 define( 'RNWF_VERSION', '0.3.1' );
     33define( 'RNWF_VERSION', '0.4.0' );
    3434
    3535/*
    … …  
    193193        require_once RNWF_DIR . 'vendor/autoload.php';
    194194
     195        \RenewForge\Infra\Store\SettingsRepository::install();
    195196        \RenewForge\Infra\Store\Schema::install();
    196197}
  • renewforge/tags/0.4.0/src/Admin/ComplianceScreen.php

    r3733429 r3776217  
    9898        }
    9999
    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>';
    101101
    102102        // 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  
    652652            EventType::CancelWithdrawn  => __('Cancellation withdrawn (reactivated)', 'renewforge'),
    653653            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'),
    654655            EventType::EndNoticeRetried => __('Confirmation email retried', 'renewforge'),
    655656            EventType::CheckRun         => __('Compliance check recorded', 'renewforge'),
  • renewforge/tags/0.4.0/src/Cancel/CancelService.php

    r3733429 r3776217  
    4747final class CancelService
    4848{
     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
    4963    /** @var array<int,true> Subscriptions this request is cancelling through the button right now. */
    5064    private array $inFlight = [];
    5165
     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
    5269    /** @var callable():?TermEndPolicy */
    5370    private $termEnd;
    5471
    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     */
    5679    public function __construct(
    5780        private readonly SubscriptionsGateway $gateway,
    … …  
    6184        private readonly SubscriptionLock $lock,
    6285        private readonly Escalation $escalation,
    63         ?callable $termEnd = null
     86        ?callable $termEnd = null,
     87        ?callable $unscheduleEnding = null
    6488    ) {
    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        };
    6692    }
    6793
    … …  
    134160        }
    135161
     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
    136166        $now     = $this->clock->now();
    137167        $outcome = new CancelOutcome($statusBefore, $subscription->status, $now, $subscription->endAt ?? $now);
    … …  
    158188        if ($subscription === null) {
    159189            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);
    160199        }
    161200
    … …  
    166205            $channel,
    167206            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'),
    171211            ], static fn ($v): bool => $v !== null),
    172212            $fingerprint,
    … …  
    215255
    216256    /**
    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.
    221278     */
    222279    public function endAtTermEnd(int $subscriptionId): ?CancelOutcome
    … …  
    229286        }
    230287        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;
    232293        }
    233294
    … …  
    235296
    236297        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);
    238316        } 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.
    239319            $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);
    241323
    242324            return null;
    … …  
    246328        }
    247329
     330        $this->forgetTermEndAlerts($subscriptionId); // ended: the shop has nothing more to be told
     331
    248332        if (!$outcome->alreadyCancelled) {
    249333            $this->confirm($subscription, $outcome, Channel::System, Fingerprint::none());
    … …  
    251335
    252336        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        }
    253456    }
    254457
  • renewforge/tags/0.4.0/src/Cancel/Escalation.php

    r3733429 r3776217  
    4545
    4646    /**
     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    /**
    4792     * The confirmation email did not go out on attempt $attempt. Schedules
    4893     * the next attempt while there are any left; tells the shop on the first
    … …  
    65110    }
    66111
    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     */
    68117    public function endNotScheduled(int $subscriptionId, \DateTimeImmutable $at): void
    69118    {
    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            );
    75130
    76131        $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true);
  • renewforge/tags/0.4.0/src/Domain/Journal/EventType.php

    r3733429 r3776217  
    2222    /** The customer asked to cancel under a minimum term: the subscription ends at the end of it, payments until then due. */
    2323    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';
    2426    case EndNoticeRetried = 'end_notice_retried';
    2527    case CheckRun         = 'check_run';
  • renewforge/tags/0.4.0/src/Domain/Money/RecordedAmount.php

    r3736193 r3776217  
    88/**
    99 * 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.
    1821 *
    1922 * 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  
    1313{
    1414    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    }
    1535
    1636    public function values(): SettingsValues
  • renewforge/tags/0.4.0/src/Infra/Subscriptions/SubscriptionsGateway.php

    r3736193 r3776217  
    8585    public function saveCancelAtTermEnd(int $id, ?DateTimeImmutable $at): void;
    8686
     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
    87116    public function nextPaymentAt(int $id): ?DateTimeImmutable;
    88117
  • renewforge/tags/0.4.0/src/Infra/Subscriptions/WcsGateway.php

    r3736193 r3776217  
    5050    public const CANCEL_AT_TERM_END_META = 'rnwf_cancel_at_term_end';
    5151
     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
    5258    public function isAvailable(): bool
    5359    {
    … …  
    340346                $items    = $kind === PaidOrder::PARENT ? self::shareOfParent($subscription, $order, $unit) : self::allItems($order, $unit);
    341347                // 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);
    343349                $refunded = self::refundedTotal($order, $unit);
    344350            } catch (UnreadableOrderAmount $e) {
    … …  
    373379     * or more when what the order charged or refunded was recorded with more places — an order
    374380     * 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.
    379388     */
    380389    private static function orderUnit(\WC_Order $order): int
    381390    {
    382391        $charged = self::refundAmounts($order) ?? [self::recorded($order->get_total_refunded())];
    383         $charged[] = self::recorded($order->get_total());
     392        $charged[] = self::recorded(self::recordedTotal($order));
    384393
    385394        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();
    386404    }
    387405
    … …  
    680698
    681699        $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).
    682705        $query = [
    683706            '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,
    686709            'orderby'                => 'ID',
    687710            'order'                  => 'ASC',
    … …  
    706729        }
    707730
     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.
    708733        return Pence::fromDecimalString(self::scalarString($order->get_total()), function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2);
    709734    }
    … …  
    717742        }
    718743
    719         $total    = self::scalarString($order->get_total());
     744        $total    = self::scalarString(self::recordedTotal($order));
    720745        $decimals = RecordedAmount::scaleFor([$total], function_exists('wc_get_price_decimals') ? (int) wc_get_price_decimals() : 2);
    721746        $pence    = RecordedAmount::minor($total, $decimals);
    … …  
    769794        $share    = array_sum(array_map(static fn (PaidOrderItem $i): int => $i->grossPence(), self::shareOfParent($subscription, $parent, $decimals)));
    770795        $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);
    772799
    773800        return $full > 0 && $share - $fee < $full;
    … …  
    966993    }
    967994
     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
    9681065    public function clearEndNoticeState(int $id): void
    9691066    {
    … …  
    11611258            self::date($subscription->get_date('next_payment')),
    11621259            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.
    11631262            Pence::fromDecimalString(self::scalarString($subscription->get_total()), $decimals),
    11641263            (string) $subscription->get_currency(),
  • renewforge/tags/0.4.0/src/Plugin.php

    r3736193 r3776217  
    7575    }
    7676
     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
    7785    /** For other plugins built on this one: whether WooCommerce Subscriptions is usable. */
    7886    public function subscriptionsActive(): bool
    … …  
    8391    private function registerHooks(): void
    8492    {
     93        SettingsRepository::migrate();
    8594        // Schema first: screens and the cancel flow expect the journal table,
    8695        // and an update by copying files never runs the activation hook. A missing
    … …  
    102111            return $policy instanceof TermEndPolicy ? $policy : null;
    103112        };
    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        );
    105123        $button   = new CancelButton($settings);
    106124
  • renewforge/tags/0.4.0/uninstall.php

    r3736193 r3776217  
    1919}
    2020
    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 
    2721global $wpdb;
    2822
    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        }
    3249
    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() );
    4251
    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
     91if ( 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();
    45100}
    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  
    2020require_once __DIR__ . '/composer/autoload_real.php';
    2121
    22 return ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea::getLoader();
     22return ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188::getLoader();
  • renewforge/tags/0.4.0/vendor/composer/autoload_classmap.php

    r3736193 r3776217  
    102102    'RenewForge\\Infra\\Store\\WpdbJournal' => $baseDir . '/src/Infra/Store/WpdbJournal.php',
    103103    'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => $baseDir . '/src/Infra/Subscriptions/CancellationRefused.php',
     104    'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => $baseDir . '/src/Infra/Subscriptions/RecordedTotal.php',
    104105    'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => $baseDir . '/src/Infra/Subscriptions/SubscriptionNotFound.php',
    105106    'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => $baseDir . '/src/Infra/Subscriptions/SubscriptionsGateway.php',
  • renewforge/tags/0.4.0/vendor/composer/autoload_real.php

    r3736193 r3776217  
    33// autoload_real.php @generated by Composer
    44
    5 class ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea
     5class ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188
    66{
    77    private static $loader;
    … …  
    2525        require __DIR__ . '/platform_check.php';
    2626
    27         spl_autoload_register(array('ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'), true, true);
     27        spl_autoload_register(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader'), true, true);
    2828        self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
    29         spl_autoload_unregister(array('ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'));
     29        spl_autoload_unregister(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader'));
    3030
    3131        require __DIR__ . '/autoload_static.php';
    32         call_user_func(\Composer\Autoload\ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::getInitializer($loader));
     32        call_user_func(\Composer\Autoload\ComposerStaticInit77f5333bb03402da1b55b612e00f9188::getInitializer($loader));
    3333
    3434        $loader->setClassMapAuthoritative(true);
  • renewforge/tags/0.4.0/vendor/composer/autoload_static.php

    r3736193 r3776217  
    55namespace Composer\Autoload;
    66
    7 class ComposerStaticInit70f6e8361c89b458beb2df62131a16ea
     7class ComposerStaticInit77f5333bb03402da1b55b612e00f9188
    88{
    99    public static $prefixLengthsPsr4 = array (
    … …  
    117117        'RenewForge\\Infra\\Store\\WpdbJournal' => __DIR__ . '/../..' . '/src/Infra/Store/WpdbJournal.php',
    118118        'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/CancellationRefused.php',
     119        'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/RecordedTotal.php',
    119120        'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionNotFound.php',
    120121        'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionsGateway.php',
    … …  
    127128    {
    128129        return \Closure::bind(function () use ($loader) {
    129             $loader->prefixLengthsPsr4 = ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::$prefixLengthsPsr4;
    130             $loader->prefixDirsPsr4 = ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::$prefixDirsPsr4;
    131             $loader->classMap = ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::$classMap;
     130            $loader->prefixLengthsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixLengthsPsr4;
     131            $loader->prefixDirsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixDirsPsr4;
     132            $loader->classMap = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$classMap;
    132133
    133134        }, null, ClassLoader::class);
  • renewforge/trunk/readme.txt

    r3774319 r3776217  
    55Tested up to: 7.1
    66Requires PHP: 8.2
    7 Stable tag: 0.3.1
     7Stable tag: 0.4.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    116116== Changelog ==
    117117
     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
    118123= 0.3.1 =
    119124* 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.
    … …  
    149154== Upgrade Notice ==
    150155
     156= 0.4.0 =
     157Improves 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
    151159= 0.3.1 =
    152160Adds an index on the order number to the journal table. Nothing to set up after updating.
  • renewforge/trunk/renewforge.php

    r3736193 r3776217  
    44 * Plugin URI:        https://renewforgewp.com
    55 * 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.1
     6 * Version:           0.4.0
    77 * Requires at least: 6.5
    88 * Requires PHP:      8.2
    … …  
    3131}
    3232
    33 define( 'RNWF_VERSION', '0.3.1' );
     33define( 'RNWF_VERSION', '0.4.0' );
    3434
    3535/*
    … …  
    193193        require_once RNWF_DIR . 'vendor/autoload.php';
    194194
     195        \RenewForge\Infra\Store\SettingsRepository::install();
    195196        \RenewForge\Infra\Store\Schema::install();
    196197}
  • renewforge/trunk/src/Admin/ComplianceScreen.php

    r3733429 r3776217  
    9898        }
    9999
    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>';
    101101
    102102        // The headline, and the button that prints the screen as a report (shown by the script).
  • renewforge/trunk/src/Admin/JournalScreen.php

    r3733429 r3776217  
    652652            EventType::CancelWithdrawn  => __('Cancellation withdrawn (reactivated)', 'renewforge'),
    653653            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'),
    654655            EventType::EndNoticeRetried => __('Confirmation email retried', 'renewforge'),
    655656            EventType::CheckRun         => __('Compliance check recorded', 'renewforge'),
  • renewforge/trunk/src/Cancel/CancelService.php

    r3733429 r3776217  
    4747final class CancelService
    4848{
     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
    4963    /** @var array<int,true> Subscriptions this request is cancelling through the button right now. */
    5064    private array $inFlight = [];
    5165
     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
    5269    /** @var callable():?TermEndPolicy */
    5370    private $termEnd;
    5471
    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     */
    5679    public function __construct(
    5780        private readonly SubscriptionsGateway $gateway,
    … …  
    6184        private readonly SubscriptionLock $lock,
    6285        private readonly Escalation $escalation,
    63         ?callable $termEnd = null
     86        ?callable $termEnd = null,
     87        ?callable $unscheduleEnding = null
    6488    ) {
    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        };
    6692    }
    6793
    … …  
    134160        }
    135161
     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
    136166        $now     = $this->clock->now();
    137167        $outcome = new CancelOutcome($statusBefore, $subscription->status, $now, $subscription->endAt ?? $now);
    … …  
    158188        if ($subscription === null) {
    159189            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);
    160199        }
    161200
    … …  
    166205            $channel,
    167206            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'),
    171211            ], static fn ($v): bool => $v !== null),
    172212            $fingerprint,
    … …  
    215255
    216256    /**
    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.
    221278     */
    222279    public function endAtTermEnd(int $subscriptionId): ?CancelOutcome
    … …  
    229286        }
    230287        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;
    232293        }
    233294
    … …  
    235296
    236297        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);
    238316        } 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.
    239319            $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);
    241323
    242324            return null;
    … …  
    246328        }
    247329
     330        $this->forgetTermEndAlerts($subscriptionId); // ended: the shop has nothing more to be told
     331
    248332        if (!$outcome->alreadyCancelled) {
    249333            $this->confirm($subscription, $outcome, Channel::System, Fingerprint::none());
    … …  
    251335
    252336        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        }
    253456    }
    254457
  • renewforge/trunk/src/Cancel/Escalation.php

    r3733429 r3776217  
    4545
    4646    /**
     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    /**
    4792     * The confirmation email did not go out on attempt $attempt. Schedules
    4893     * the next attempt while there are any left; tells the shop on the first
    … …  
    65110    }
    66111
    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     */
    68117    public function endNotScheduled(int $subscriptionId, \DateTimeImmutable $at): void
    69118    {
    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            );
    75130
    76131        $this->raise(Incident::CANCEL_FAILED, $subscriptionId, $message, true);
  • renewforge/trunk/src/Domain/Journal/EventType.php

    r3733429 r3776217  
    2222    /** The customer asked to cancel under a minimum term: the subscription ends at the end of it, payments until then due. */
    2323    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';
    2426    case EndNoticeRetried = 'end_notice_retried';
    2527    case CheckRun         = 'check_run';
  • renewforge/trunk/src/Domain/Money/RecordedAmount.php

    r3736193 r3776217  
    88/**
    99 * 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.
    1821 *
    1922 * No table of currencies: the unit comes from the strings themselves, with the
  • renewforge/trunk/src/Infra/Store/SettingsRepository.php

    r3733429 r3776217  
    1313{
    1414    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    }
    1535
    1636    public function values(): SettingsValues
  • renewforge/trunk/src/Infra/Subscriptions/SubscriptionsGateway.php

    r3736193 r3776217  
    8585    public function saveCancelAtTermEnd(int $id, ?DateTimeImmutable $at): void;
    8686
     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
    87116    public function nextPaymentAt(int $id): ?DateTimeImmutable;
    88117
  • renewforge/trunk/src/Infra/Subscriptions/WcsGateway.php

    r3736193 r3776217  
    5050    public const CANCEL_AT_TERM_END_META = 'rnwf_cancel_at_term_end';
    5151
     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
    5258    public function isAvailable(): bool
    5359    {
    … …  
    340346                $items    = $kind === PaidOrder::PARENT ? self::shareOfParent($subscription, $order, $unit) : self::allItems($order, $unit);
    341347                // 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);
    343349                $refunded = self::refundedTotal($order, $unit);
    344350            } catch (UnreadableOrderAmount $e) {
    … …  
    373379     * or more when what the order charged or refunded was recorded with more places — an order
    374380     * 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.
    379388     */
    380389    private static function orderUnit(\WC_Order $order): int
    381390    {
    382391        $charged = self::refundAmounts($order) ?? [self::recorded($order->get_total_refunded())];
    383         $charged[] = self::recorded($order->get_total());
     392        $charged[] = self::recorded(self::recordedTotal($order));
    384393
    385394        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();
    386404    }
    387405
    … …  
    680698
    681699        $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).
    682705        $query = [
    683706            '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,
    686709            'orderby'                => 'ID',
    687710            'order'                  => 'ASC',
    … …  
    706729        }
    707730
     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.
    708733        return Pence::fromDecimalString(self::scalarString($order->get_total()), function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2);
    709734    }
    … …  
    717742        }
    718743
    719         $total    = self::scalarString($order->get_total());
     744        $total    = self::scalarString(self::recordedTotal($order));
    720745        $decimals = RecordedAmount::scaleFor([$total], function_exists('wc_get_price_decimals') ? (int) wc_get_price_decimals() : 2);
    721746        $pence    = RecordedAmount::minor($total, $decimals);
    … …  
    769794        $share    = array_sum(array_map(static fn (PaidOrderItem $i): int => $i->grossPence(), self::shareOfParent($subscription, $parent, $decimals)));
    770795        $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);
    772799
    773800        return $full > 0 && $share - $fee < $full;
    … …  
    966993    }
    967994
     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
    9681065    public function clearEndNoticeState(int $id): void
    9691066    {
    … …  
    11611258            self::date($subscription->get_date('next_payment')),
    11621259            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.
    11631262            Pence::fromDecimalString(self::scalarString($subscription->get_total()), $decimals),
    11641263            (string) $subscription->get_currency(),
  • renewforge/trunk/src/Plugin.php

    r3736193 r3776217  
    7575    }
    7676
     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
    7785    /** For other plugins built on this one: whether WooCommerce Subscriptions is usable. */
    7886    public function subscriptionsActive(): bool
    … …  
    8391    private function registerHooks(): void
    8492    {
     93        SettingsRepository::migrate();
    8594        // Schema first: screens and the cancel flow expect the journal table,
    8695        // and an update by copying files never runs the activation hook. A missing
    … …  
    102111            return $policy instanceof TermEndPolicy ? $policy : null;
    103112        };
    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        );
    105123        $button   = new CancelButton($settings);
    106124
  • renewforge/trunk/uninstall.php

    r3736193 r3776217  
    1919}
    2020
    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 
    2721global $wpdb;
    2822
    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        }
    3249
    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() );
    4251
    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
     91if ( 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();
    45100}
    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  
    2020require_once __DIR__ . '/composer/autoload_real.php';
    2121
    22 return ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea::getLoader();
     22return ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188::getLoader();
  • renewforge/trunk/vendor/composer/autoload_classmap.php

    r3736193 r3776217  
    102102    'RenewForge\\Infra\\Store\\WpdbJournal' => $baseDir . '/src/Infra/Store/WpdbJournal.php',
    103103    'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => $baseDir . '/src/Infra/Subscriptions/CancellationRefused.php',
     104    'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => $baseDir . '/src/Infra/Subscriptions/RecordedTotal.php',
    104105    'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => $baseDir . '/src/Infra/Subscriptions/SubscriptionNotFound.php',
    105106    'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => $baseDir . '/src/Infra/Subscriptions/SubscriptionsGateway.php',
  • renewforge/trunk/vendor/composer/autoload_real.php

    r3736193 r3776217  
    33// autoload_real.php @generated by Composer
    44
    5 class ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea
     5class ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188
    66{
    77    private static $loader;
    … …  
    2525        require __DIR__ . '/platform_check.php';
    2626
    27         spl_autoload_register(array('ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'), true, true);
     27        spl_autoload_register(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader'), true, true);
    2828        self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
    29         spl_autoload_unregister(array('ComposerAutoloaderInit70f6e8361c89b458beb2df62131a16ea', 'loadClassLoader'));
     29        spl_autoload_unregister(array('ComposerAutoloaderInit77f5333bb03402da1b55b612e00f9188', 'loadClassLoader'));
    3030
    3131        require __DIR__ . '/autoload_static.php';
    32         call_user_func(\Composer\Autoload\ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::getInitializer($loader));
     32        call_user_func(\Composer\Autoload\ComposerStaticInit77f5333bb03402da1b55b612e00f9188::getInitializer($loader));
    3333
    3434        $loader->setClassMapAuthoritative(true);
  • renewforge/trunk/vendor/composer/autoload_static.php

    r3736193 r3776217  
    55namespace Composer\Autoload;
    66
    7 class ComposerStaticInit70f6e8361c89b458beb2df62131a16ea
     7class ComposerStaticInit77f5333bb03402da1b55b612e00f9188
    88{
    99    public static $prefixLengthsPsr4 = array (
    … …  
    117117        'RenewForge\\Infra\\Store\\WpdbJournal' => __DIR__ . '/../..' . '/src/Infra/Store/WpdbJournal.php',
    118118        'RenewForge\\Infra\\Subscriptions\\CancellationRefused' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/CancellationRefused.php',
     119        'RenewForge\\Infra\\Subscriptions\\RecordedTotal' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/RecordedTotal.php',
    119120        'RenewForge\\Infra\\Subscriptions\\SubscriptionNotFound' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionNotFound.php',
    120121        'RenewForge\\Infra\\Subscriptions\\SubscriptionsGateway' => __DIR__ . '/../..' . '/src/Infra/Subscriptions/SubscriptionsGateway.php',
    … …  
    127128    {
    128129        return \Closure::bind(function () use ($loader) {
    129             $loader->prefixLengthsPsr4 = ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::$prefixLengthsPsr4;
    130             $loader->prefixDirsPsr4 = ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::$prefixDirsPsr4;
    131             $loader->classMap = ComposerStaticInit70f6e8361c89b458beb2df62131a16ea::$classMap;
     130            $loader->prefixLengthsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixLengthsPsr4;
     131            $loader->prefixDirsPsr4 = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$prefixDirsPsr4;
     132            $loader->classMap = ComposerStaticInit77f5333bb03402da1b55b612e00f9188::$classMap;
    132133
    133134        }, null, ClassLoader::class);
Note: See TracChangeset for help on using the changeset viewer.