Skip to main content

BackdateGuard

\Benecaster\Token\BackdateGuard

Class Premium

Validates a backdated created_at before it reaches a token row. If you are writing an importer, this is the class you call, and you call it before you write anything.

The two-method split is the whole point. reason() never throws and returns a stable code; normalize() throws. An importer walking 400 CSV rows should run reason() over the entire file first, show the operator one list of every bad row, and only then start calling TokenManager::generate() per good row. Catching an exception per row instead means aborting partway through an import with some rows written and no clean way to resume — and, because generate() discards the plaintext token along with the exception, no way to re-mint the same feed URL for the row that failed.

normalize() is the last line of defence rather than the intended entry point. TokenRepository::insert() calls it on every backdated write, so no path can skip it; reaching that exception means the pre-pass was not done.

It never clamps. A rejected date is reported and the row is not written. A migration that quietly rewrote 400 join dates to the same floor would be indistinguishable from one that worked — the podcaster would find out months later, from a subscriber, with no way to tell which rows were real. Rejection is loud on the day of the import, while the CSV is still on the operator’s desk.

Construct it directly; in production it takes no arguments. The one optional constructor parameter is a UTC “now” string, and exists so a test can pin the future-date boundary.

Methods

Method Visibility Since Description
reason( string $created_at ): ?string Public Why this value is unacceptable, or null when it is fine. Non-throwing on purpose, and returns one of the REASON_* codes rather than a sentence so the caller can group and translate its own report. This is the method to loop over a file with.
normalize( string $created_at ): string Public Returns the value as a canonical Y-m-d H:i:s, or throws InvalidArgumentException with a message naming the offending value and the rule it broke. Ask reason() first if you want to report rather than abort.

Constants

Name Value Description
FLOOR '2004-01-01 00:00:00' The earliest created_at a token may claim, UTC. A floor read on the wrong clock is a few hours wrong on every site that is not on UTC. The floor is not "the earliest plausible customer" — it is the earliest date at which the relationship being imported could have existed at all. A podcast subscriber cannot predate podcasting, and 2004 is the year the medium acquired its name and its first clients. Nothing being imported — a Patreon pledge, a Supercast subscription, a membership plugin's join date — can honestly be older. It is set generously on purpose, because the failure mode has to be "lets a strange but genuine date through" rather than "refuses a podcaster's oldest supporter". What it actually catches is the real failure mode: a mis-parsed CSV cell. 0000-00-00, a Unix epoch 1970-01-01 standing in for an empty field, a two-digit year widened to 0022, a day/month swap landing in the wrong millennium. Every one of those produces a 10-Year Member badge, and every one is below this line.
REASON_UNPARSEABLE 'unparseable' Not a date this guard knows how to read. Only Y-m-d H:i:s and bare Y-m-d are accepted — no other format is guessed at.
REASON_FUTURE 'future' A real date, but later than now. Tenure is time elapsed since the date, and there is no honest badge for a negative one.
REASON_BEFORE_FLOOR 'before_floor' A real date, but older than FLOOR.

Notes

## Accepted formats

Y-m-d H:i:s and bare Y-m-d, which widens to midnight UTC. The bare form is accepted because it is what a CSV export actually contains — a Patreon join-date column reads 2022-11-01 — and refusing it would fail the exact caller this exists for. ⚠ Do not hard-code that column's name: Patreon has shipped it as Join Date, Patron Since and Patronage Since Date, and the header drifts between releases, so an importer must match an alias list rather than a literal. The widening is documented rather than silent, and it is the only interpretation the guard performs.

Impossible dates are refused rather than rolled forward. PHP's date parsing turns 2022-02-30 into 2 March; this guard re-formats what it parsed and compares it against the input, so a value MySQL would refuse is refused here too.

## UTC, not site-local

The comparison is made against the UTC clock, and so is the floor.

The argument for reading it site-local is worth stating, because it is a plausible mistake to make twice: the column's own CURRENT_TIMESTAMP default is the database server's local time, and the rest of the availability stack is site-local, so a backdated value must be measured beside the values it sits with. Both clauses are true. The conclusion does not follow, because they describe two different clocks being read as one.

"The database server's local time" is the MySQL session time_zone, which defaults to SYSTEM — the database host's OS timezone, which on managed WordPress hosting is UTC essentially without exception. "Site-local" is current_time( 'mysql' ), which is UTC plus the WordPress gmt_offset option — a PHP-side value the database has never been told. The two coincide only on a site already set to UTC. So reading the column as site-local does not put a backdated value on the same ruler as the default-inserted ones; it puts it on a third ruler.

The availability half of the argument is a false neighbour. benecaster_availability.available_datetime is site-local because it is a human-authored wall-clock intention — "release this at 9am" means 9am where the podcaster lives. created_at is a machine event stamp, and the table's other machine stamps were already UTC: the manual-subscriber REST controller treats datetimes on this table as UTC, the GDPR purger compares created_at against a gmdate() cutoff, and TenureBadgeCalculator reads it as UTC. The guard was the only site-local reader in the codebase, so it moved rather than the four things it disagreed with.

TokenRepository::insert() now writes the non-backdated value explicitly as UTC rather than leaning on the column default, so this is enforced rather than assumed.

## There is no filter on the floor, deliberately

It is a correctness bound, not a preference, and a test pins its absence. The plugin has been here before: the unlicensed follower cap shipped with a filter in front of it and the filter had to be removed, precisely because an exposed knob turns a guard into configuration. A site owner who genuinely needs a different floor is editing a GPL plugin, which is their right and is not the same thing as the plugin offering the option.

If you are here to move the floor, the question to answer first is not "is 2004 too early" but "which real customer is this refusing".