= test that would read a legacy growth * marker as solo. */ public static function tier(): string { if (!self::pro_unlocked()) { return 'free'; } $marker = (int) get_option('wpwand_pgc_limit', 10); if ($marker === self::UNLIMITED) { return 'agency'; } return in_array($marker, [15, 20], true) || $marker > 20 ? 'growth' : 'solo'; } /** * Effective monthly caps for the current tier, in RUNS. * * The numbers are the plan's — 100 bulk on Solo, 300 on Growth, automation twice that, no cap * on Agency — and since 2026-09-13 they count runs, not posts, the owner's call: the customer * writes on their own key, so a run of a hundred posts costs the plugin's owner nothing, and * what a paying customer wants to know is how many times, or on how many days, they can run. * Free was already runs. The marker only names the tier. * * @return array{bulk:int, automation:int} */ public static function limits(): array { switch (self::tier()) { case 'agency': return ['bulk' => self::UNLIMITED, 'automation' => self::UNLIMITED]; case 'growth': return ['bulk' => 30, 'automation' => 30]; case 'solo': return ['bulk' => 10, 'automation' => 10]; default: return ['bulk' => self::FREE_BULK, 'automation' => self::FREE_AUTOMATION]; } } /** * The plan this site is on, in words, or '' on free. * * The screens show a plan badge where a free site shows its remaining runs — a paying customer * has no counter worth watching, so the chip says what they bought instead. Derived from the * same marker `limits()` reads, so the two can never disagree. */ public static function plan_name(): string { switch (self::tier()) { case 'agency': return __('Agency plan', 'ai-content-generation'); case 'growth': return __('Growth plan', 'ai-content-generation'); case 'solo': return __('Solo plan', 'ai-content-generation'); default: return ''; } } /** Whether Pro is unlocked (safe when the Pro gate helper is absent). */ private static function pro_unlocked(): bool { return class_exists('WPWand\\Core\\Pro') && \WPWand\Core\Pro::unlocked(); } // ---- Bulk ------------------------------------------------------------------------------ public static function bulk_limit(): int { return self::limits()['bulk']; } public static function bulk_used(): int { self::maybe_reset(); return (int) get_option(self::bulk_option(), 0); } /** * Every tier counts runs in the run options. Pro used to keep counting posts in the legacy * totals; that stopped on 2026-09-13, and no migration was needed — the run options hold the * current period only, so a site that activates Pro mid-period carries at most its five free * runs into a cap of a hundred. */ private static function bulk_option(): string { return self::OPT_FREE_BULK_USED; } private static function auto_option(): string { return self::OPT_FREE_AUTO_USED; } /** How many more bulk runs may be started this period (PHP_INT_MAX when unlimited). */ public static function bulk_remaining(): int { $limit = self::bulk_limit(); if ($limit === self::UNLIMITED) { return PHP_INT_MAX; } return max(0, $limit - self::bulk_used()); } public static function can_bulk(int $need = 1): bool { return self::bulk_limit() === self::UNLIMITED || self::bulk_remaining() >= max(1, $need); } public static function consume_bulk(int $count): void { if (self::bulk_limit() === self::UNLIMITED || $count < 1) { return; } update_option(self::bulk_option(), self::bulk_used() + $count); } /** * Consume the bulk allowance for ONE bulk generation (one queue() call). * * A whole run costs exactly one unit however many posts it produces, on every tier. Free's * per-run post volume is gated separately by FREE_BULK_PER_RUN; Pro's is not gated. Pro was * charged per post until 2026-09-13. */ public static function consume_bulk_run(): void { self::consume_bulk(1); } /** * Give back bulk allowance that was charged for work which never arrived. * * The counter cannot go below zero: a refund with nothing left to give back is a no-op rather * than a negative balance that would silently hand out free allowance next period. * * THE PERIOD: both counters only ever hold the CURRENT 30-day period, so a refund lands in the * period it is applied in even when the charge happened in the one before. That is deliberate — * a closed period cannot be edited, and the alternative (skip the refund because the month * rolled over) leaves the user paying for a generation that never arrived. */ public static function refund_bulk(int $count): void { if (self::bulk_limit() === self::UNLIMITED || $count < 1) { return; } update_option(self::bulk_option(), max(0, self::bulk_used() - $count)); } /** * Give ONE run back — the mirror of {@see consume_bulk_run()}. * * The run was charged one unit whatever it produced, so the caller only reaches here once the * entire run has failed (JobRunner::refund_failure checks the siblings). */ public static function refund_bulk_run(): void { self::refund_bulk(1); } // ---- Automation ------------------------------------------------------------------------ public static function automation_limit(): int { return self::limits()['automation']; } public static function automation_used(): int { self::maybe_reset(); return (int) get_option(self::auto_option(), 0); } public static function automation_remaining(): int { $limit = self::automation_limit(); if ($limit === self::UNLIMITED) { return PHP_INT_MAX; } return max(0, $limit - self::automation_used()); } public static function can_automation(int $need = 1): bool { return self::automation_limit() === self::UNLIMITED || self::automation_remaining() >= max(1, $need); } public static function consume_automation(int $count): void { if (self::automation_limit() === self::UNLIMITED || $count < 1) { return; } update_option(self::auto_option(), self::automation_used() + $count); } /** * Consume the automation allowance for ONE scheduled run — the twin of {@see consume_bulk_run()}. * * A whole run costs exactly one unit however many posts it produces, on every tier. Charging * per post is what made a daily 5-post schedule die on day 1 and a 1-post-a-day schedule die * on day 5 — on free in 2026-08, and on Pro until 2026-09-13, where a 200-post cap read as * two hundred days of a one-post schedule or forty days of a five-post one. Per-run volume is * the schedule's own count, capped by {@see \WPWand\Automation\Schedules::max_count()}. */ public static function consume_automation_run(): void { self::consume_automation(1); } /** * Give back automation allowance that was charged for work which never arrived. * * The allowance is spent at queue time because that is where the count is known, so a * generation that then fails has been paid for and produced nothing. Two of this plugin's own * empty-response failures took nearly half a free month. * * The counter cannot go below zero, and — as with {@see refund_bulk()} — the refund lands in * the current period even when the charge happened in the previous one. Only the current * period is stored, and dropping the refund at a month boundary would keep the user's money * for work they never got. */ public static function refund_automation(int $count): void { if (self::automation_limit() === self::UNLIMITED || $count < 1) { return; } update_option(self::auto_option(), max(0, self::automation_used() - $count)); } /** * Give ONE scheduled run back — the mirror of {@see consume_automation_run()}. * * The run was charged one unit however many posts it queued, so the caller only reaches here * once the entire run has failed. */ public static function refund_automation_run(): void { self::refund_automation(1); } // ---- Monthly reset --------------------------------------------------------------------- /** * Reset both counters every 30 days while the license is active. No-op when the license isn't * active (Pro features don't run then anyway) so a lapsed license can't silently refill its quota. */ public static function maybe_reset(): void { // A lapsed licence must not silently refill the Pro tier caps — but the FREE counters are // not the licence's to hold. Someone who bought Pro and can't activate it was capped at 5 // forever, which is the opposite of what a stuck licence should cost them. $pro_frozen = class_exists('WPWand\\License\\LicenseService') && get_option(\WPWand\License\LicenseService::OPT_STATUS) !== 'activated'; $now = (int) current_time('timestamp'); $start = (int) get_option(self::OPT_PERIOD, 0); if ($start <= 0) { update_option(self::OPT_PERIOD, $now); // first run — start the clock return; } if ($now < $start + self::PERIOD) { return; } update_option(self::OPT_FREE_BULK_USED, 0); update_option(self::OPT_FREE_AUTO_USED, 0); if (!$pro_frozen) { update_option(self::OPT_BULK_USED, 0); update_option(self::OPT_AUTO_USED, 0); } update_option(self::OPT_PERIOD, $now); } /** Human label for the bulk counter, e.g. "12/100" or "Unlimited". */ public static function bulk_text(): string { $limit = self::bulk_limit(); return $limit === self::UNLIMITED ? __('Unlimited', 'ai-content-generation') : min(self::bulk_used(), $limit) . '/' . $limit; } /** Human label for the automation counter, e.g. "30/600" or "Unlimited". */ public static function automation_text(): string { $limit = self::automation_limit(); return $limit === self::UNLIMITED ? __('Unlimited', 'ai-content-generation') : min(self::automation_used(), $limit) . '/' . $limit; } /** * The date the current 30-day period rolls over, in the site's date format. * * The counters reset lazily in {@see maybe_reset()}, so this is when the next usage check hands * back a full allowance — the one thing our own limit messages have never told anyone. */ public static function reset_date(): string { $start = (int) get_option(self::OPT_PERIOD, 0); if ($start <= 0) { $start = (int) current_time('timestamp'); } return date_i18n((string) get_option('date_format', 'F j, Y'), $start + self::PERIOD); } /** * The message for running out of one of OUR OWN monthly meters. * * Bulk and automation both print this so the two read alike, and so both say the three things * the old copy left out: that the limit is WP Wand's (not the provider's — {@see * ErrorFormatter::humanize()} makes the same distinction from the other side), where the meter * stands, and the date it comes back. * * @param string $meter 'automation', or 'bulk' for anything else. */ public static function limit_reached_message(string $meter): string { if ($meter === 'automation') { /* translators: 1: usage so far, e.g. "5/5". 2: the date the allowance resets. */ $template = __('You\'ve used this month\'s automation allowance (%1$s). It resets on %2$s. Upgrade for a higher limit if you need more now.', 'ai-content-generation'); $used = self::automation_text(); } else { /* translators: 1: usage so far, e.g. "5/5". 2: the date the allowance resets. */ $template = __('You\'ve used this month\'s bulk allowance (%1$s). It resets on %2$s. Upgrade for a higher limit if you need more now.', 'ai-content-generation'); $used = self::bulk_text(); } return sprintf($template, $used, self::reset_date()); } }