From 8e5634b81acadb23203cb7fb8474c96db3a406ee Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 6 Aug 2026 16:41:57 -0300 Subject: [PATCH 1/2] user_profile: add pro auto-renewing status (config key `A`) Clients sometimes need to know whether a Pro subscription is terminal or auto-renewing (e.g. "renews on X" vs "expires on X"). Store the backend's `auto_renewing` (from get_pro_status) as a presence-only config flag `A`: 1 when auto-renewing, absent otherwise (terminal / unknown / not Pro). Deliberately not tri-state: unlike blinded_msgreqs `M`, this is backend- derived fact, not a defaulted client preference, so there's no upgrade- default edge case that a distinct "unset" would guard. And no t/T bump -- it's synced pro state like E/I/R, not a user-initiated profile edit. Exposes get_/set_pro_auto_renewing (C++ bool; C 0/1) with unit + C-API coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/session/config/user_profile.h | 26 +++++++++++++++++++++++++ include/session/config/user_profile.hpp | 26 +++++++++++++++++++++++++ src/config/user_profile.cpp | 19 ++++++++++++++++++ tests/test_config_userprofile.cpp | 16 +++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 17e3a578..4729105f 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -399,6 +399,32 @@ LIBSESSION_EXPORT int64_t user_profile_get_pro_access_expiry(const config_object LIBSESSION_EXPORT void user_profile_set_pro_access_expiry( config_object* conf, int64_t access_expiry_ts); +/// API: user_profile/user_profile_get_pro_auto_renewing +/// +/// Returns whether the account's current Session Pro subscription is auto-renewing. Backend-derived +/// (the `auto_renewing` field on /get_pro_status); set alongside the access expiry. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int` -- 1 if the subscription is known to be auto-renewing, otherwise 0 (terminal, unknown, +/// or not Pro). +LIBSESSION_EXPORT int user_profile_get_pro_auto_renewing(const config_object* conf); + +/// API: user_profile/user_profile_set_pro_auto_renewing +/// +/// Records whether the current Session Pro subscription is auto-renewing: nonzero stores the flag, +/// 0 clears it (which is also how it is cleared when the subscription lapses). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `auto_renewing` -- [in] nonzero if auto-renewing, 0 to clear +/// +/// Outputs: +/// - `void` +LIBSESSION_EXPORT void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing); + /// API: user_profile/user_profile_get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index ea20306c..0fa2eb14 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -40,6 +40,10 @@ using namespace std::literals; /// flight"), so all the account's devices poll the backend to pull the entitlement through. /// Inserted only when not already pro; cleared automatically when entitlement lands; values /// more than a week in the past are ignored on read. +/// A - set to 1 when the current Session Pro subscription is auto-renewing; omitted when it is +/// terminal (will not renew), unknown, or the account isn't Pro. Backend-derived +/// (get_pro_status.auto_renewing) and synced across devices; the client sets it alongside `E` +/// and clears it (sets false) when the subscription lapses. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -339,6 +343,28 @@ class UserProfile : public ConfigBase { /// will expire, or nullopt to remove the value. void set_pro_access_expiry(std::optional access_expiry_ts); + /// API: user_profile/UserProfile::get_pro_auto_renewing + /// + /// Returns whether the account's current Session Pro subscription is auto-renewing (true) or + /// terminal/unknown (false). Backend-derived (the `auto_renewing` field on /get_pro_status); + /// the client sets it alongside `set_pro_access_expiry`. Only a `true` value is stored, so an + /// account that isn't Pro, or whose renewal status has not been learned, reads as false. + /// + /// Inputs: None + /// + /// Outputs: + /// - `bool` -- true iff the subscription is known to be auto-renewing. + bool get_pro_auto_renewing() const; + + /// API: user_profile/UserProfile::set_pro_auto_renewing + /// + /// Records whether the current Session Pro subscription is auto-renewing. `true` stores the + /// flag; `false` erases it -- which is also how it is cleared when the subscription lapses. + /// + /// Inputs: + /// - `auto_renewing` -- true if the subscription auto-renews; false to clear the flag. + void set_pro_auto_renewing(bool auto_renewing); + /// API: user_profile/UserProfile::get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 5ca98b05..8bf367ed 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -241,6 +241,17 @@ void UserProfile::set_pro_access_expiry(std::optional } } +bool UserProfile::get_pro_auto_renewing() const { + return data["A"].integer_or(0) != 0; +} + +void UserProfile::set_pro_auto_renewing(bool auto_renewing) { + // Presence-only: store 1 when auto-renewing, erase otherwise (absent == terminal/unknown). No + // t/T bump -- this is backend-derived pro state (like E/I/R), not a user-initiated profile + // edit. + set_nonzero_int(data["A"], auto_renewing); +} + std::optional UserProfile::get_refund_requested() const { if (auto* R = data["R"].integer()) { std::chrono::sys_seconds when{std::chrono::seconds{*R}}; @@ -541,6 +552,14 @@ LIBSESSION_C_API void user_profile_set_pro_access_expiry( unbox(conf)->set_pro_access_expiry(as_sys_seconds(access_expiry_ts)); } +LIBSESSION_C_API int user_profile_get_pro_auto_renewing(const config_object* conf) { + return unbox(conf)->get_pro_auto_renewing() ? 1 : 0; +} + +LIBSESSION_C_API void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing) { + unbox(conf)->set_pro_auto_renewing(auto_renewing != 0); +} + LIBSESSION_C_API int64_t user_profile_get_refund_requested(const config_object* conf) { if (auto when = unbox(conf)->get_refund_requested()) return epoch_seconds(*when); diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 856386b9..b3bb3b21 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -336,6 +336,12 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { CHECK(user_profile_get_blinded_msgreqs(conf2) == -1); user_profile_set_blinded_msgreqs(conf2, 1); CHECK(user_profile_get_blinded_msgreqs(conf2) == 1); + + CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); + user_profile_set_pro_auto_renewing(conf2, 1); + CHECK(user_profile_get_pro_auto_renewing(conf2) == 1); + user_profile_set_pro_auto_renewing(conf2, 0); + CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); UserProfileTester::set_profile_updated(conf2, std::chrono::sys_seconds{124s}); // Both have changes, so push need a push @@ -654,6 +660,16 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { profile.set_pro_access_expiry(access_expiry); CHECK(profile.get_pro_access_expiry() == access_expiry); + // Pro auto-renewing flag: presence-only, defaults to false, and (backend-derived state, not a + // user edit) does not stamp the profile-updated timestamp. + CHECK_FALSE(profile.get_pro_auto_renewing()); + UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); + profile.set_pro_auto_renewing(true); + CHECK(profile.get_pro_auto_renewing()); + CHECK(profile.get_profile_updated().time_since_epoch().count() == 456); + profile.set_pro_auto_renewing(false); + CHECK_FALSE(profile.get_pro_auto_renewing()); + // Refund-requested flag (synced via config, not the Pro backend) CHECK_FALSE(profile.get_refund_requested().has_value()); From 086a420e95904c9664386e42dfff826628682a8f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 16:48:38 +1000 Subject: [PATCH 2/2] user_profile: add the pro grace period, and distinguish an unset auto-renewing flag Completes the config side of #121 against the refresh spec, which asks for both `auto_renewing` and `grace` to be synced alongside `E`. - New key `G`: the account's grace period in seconds. The backend folds grace into the stored expiry for auto-renewing subscriptions, so `E` is the end of coverage rather than the date a renewal is due; `E - G` recovers the paid-through instant on any device. Clearing `E` clears `G` with it, since a grace that outlived its expiry would pair with whatever wrote `E` next. - `get_pro_auto_renewing_opt()` / `user_profile_has_pro_auto_renewing()`: the flag is presence-only, so the existing getter answers 0 both for "not auto-renewing" and for "never learned". A client deciding at startup whether to fetch `/get_pro_status` needs those apart, since an account entitled before the field existed reads as false forever otherwise. A separate predicate rather than a -1 sentinel on the getter: a negative int cast to an unsigned boolean is non-zero, so the natural JNI binding would silently read "unknown" as "auto-renewing". --- include/session/config/user_profile.h | 60 ++++++++++++++++++++++++ include/session/config/user_profile.hpp | 52 +++++++++++++++++++++ src/config/user_profile.cpp | 42 ++++++++++++++++- tests/test_config_userprofile.cpp | 61 +++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/include/session/config/user_profile.h b/include/session/config/user_profile.h index 4729105f..49ef95c9 100644 --- a/include/session/config/user_profile.h +++ b/include/session/config/user_profile.h @@ -412,6 +412,33 @@ LIBSESSION_EXPORT void user_profile_set_pro_access_expiry( /// or not Pro). LIBSESSION_EXPORT int user_profile_get_pro_auto_renewing(const config_object* conf); +/// API: user_profile/user_profile_has_pro_auto_renewing +/// +/// Returns whether the auto-renewing flag has ever been stored -- the distinction +/// `user_profile_get_pro_auto_renewing` cannot express, since the flag is presence-only and that +/// getter answers 0 both for "not auto-renewing" and for "never learned". A client needs it when +/// deciding at startup whether to fetch `/get_pro_status` at all: an account entitled before this +/// field existed reads as `false` forever otherwise, and the fetch that would populate it is the +/// one being skipped. Use as a pair -- `has_` first, then the getter for the value. +/// +/// Note the pair expresses *unknown vs true*, not three states: setting the flag false erases the +/// key, so a stored false and an absent one are the same state. Do not read a 0 here as "definitely +/// not auto-renewing". +/// +/// This is a separate 0/1 predicate rather than a -1 sentinel on the getter *deliberately*: with a +/// sentinel, the natural binding on some platforms converts "unknown" to `true` silently -- a +/// negative int cast to an unsigned boolean type is non-zero -- and that is the reading which +/// suppresses the very fetch that would resolve it. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int` -- 1 if the flag has been stored (read it with `user_profile_get_pro_auto_renewing`), 0 +/// if +/// it has never been stored. +LIBSESSION_EXPORT int user_profile_has_pro_auto_renewing(const config_object* conf); + /// API: user_profile/user_profile_set_pro_auto_renewing /// /// Records whether the current Session Pro subscription is auto-renewing: nonzero stores the flag, @@ -425,6 +452,39 @@ LIBSESSION_EXPORT int user_profile_get_pro_auto_renewing(const config_object* co /// - `void` LIBSESSION_EXPORT void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing); +/// API: user_profile/user_profile_get_pro_grace_period +/// +/// Returns the account's grace period in seconds (`get_pro_status.grace_period_duration`), or 0 if +/// none is stored. Backend-derived and synced alongside the access expiry, so any linked device can +/// derive the paid-through instant as `access_expiry - grace_period`: the backend folds the grace +/// period into the stored expiry for auto-renewing subscriptions, so the access expiry is the end +/// of coverage rather than the date the renewal is due. +/// +/// Unlike `user_profile_has_pro_auto_renewing` there is no companion presence check, and +/// deliberately so: the backend sends 0 whenever the subscription is not auto-renewing, so "unset" +/// and "zero" describe the same account and both give `expiry - 0 == expiry`. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- the grace period in seconds, or 0 if unset. +LIBSESSION_EXPORT int64_t user_profile_get_pro_grace_period(const config_object* conf); + +/// API: user_profile/user_profile_set_pro_grace_period +/// +/// Sets the account's grace period, in seconds. Set alongside `user_profile_set_pro_access_expiry` +/// from each `get_pro_status` response; 0 (or negative) clears it. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `grace_seconds` -- [in] the grace period in seconds, or 0 to clear +/// +/// Outputs: +/// - `void` +LIBSESSION_EXPORT void user_profile_set_pro_grace_period( + config_object* conf, int64_t grace_seconds); + /// API: user_profile/user_profile_get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/include/session/config/user_profile.hpp b/include/session/config/user_profile.hpp index 0fa2eb14..3b95c17d 100644 --- a/include/session/config/user_profile.hpp +++ b/include/session/config/user_profile.hpp @@ -44,6 +44,10 @@ using namespace std::literals; /// terminal (will not renew), unknown, or the account isn't Pro. Backend-derived /// (get_pro_status.auto_renewing) and synced across devices; the client sets it alongside `E` /// and clears it (sets false) when the subscription lapses. +/// G - the account's grace period, in seconds (get_pro_status.grace_period_duration), synced so any +/// device can derive the paid-through instant as `E - G`. Backend-derived and set alongside +/// `E`. Omitted when zero, which is also what the backend sends when the subscription is not +/// auto-renewing -- so an absent `G` and a zero `G` mean the same thing and `E - G == E`. /// P - user profile url after re-uploading (should take precedence over `p` when `T > t`). /// Q - user profile decryption key (binary) after re-uploading (should take precedence over `q` /// when `T > t`). @@ -356,6 +360,25 @@ class UserProfile : public ConfigBase { /// - `bool` -- true iff the subscription is known to be auto-renewing. bool get_pro_auto_renewing() const; + /// API: user_profile/UserProfile::get_pro_auto_renewing_opt + /// + /// As `get_pro_auto_renewing`, but distinguishes *not set* from *false*. Because the flag is + /// stored presence-only, the plain getter cannot tell "known not to be auto-renewing" from + /// "never learned"; a caller that must not act on an unpopulated value should use this and + /// treat `nullopt` as unknown. The motivating case is a client deciding at startup whether to + /// fetch + /// `/get_pro_status` at all: an account entitled before this field existed reads as `false` + /// forever otherwise, and the fetch that would populate it is the one being skipped. + /// + /// Note this is *unknown vs true*, not a three-state flag: `set_pro_auto_renewing(false)` + /// erases the key, so a stored false and an absent one are the same state on the wire. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` -- `nullopt` if the flag has never been stored; otherwise its value. + std::optional get_pro_auto_renewing_opt() const; + /// API: user_profile/UserProfile::set_pro_auto_renewing /// /// Records whether the current Session Pro subscription is auto-renewing. `true` stores the @@ -365,6 +388,35 @@ class UserProfile : public ConfigBase { /// - `auto_renewing` -- true if the subscription auto-renews; false to clear the flag. void set_pro_auto_renewing(bool auto_renewing); + /// API: user_profile/UserProfile::get_pro_grace_period + /// + /// Returns the account's grace period (`get_pro_status.grace_period_duration`), or zero if none + /// is stored. Backend-derived and synced alongside `E`, so any linked device can derive the + /// paid-through instant as `get_pro_access_expiry() - get_pro_grace_period()`: the backend + /// folds the grace period into the stored expiry for auto-renewing subscriptions, so `E` is the + /// end of coverage rather than the date the renewal is due. + /// + /// Note this deliberately returns a plain duration rather than an optional, unlike + /// `get_pro_auto_renewing_opt`: the backend sends zero when the subscription is not + /// auto-renewing, so "no grace stored" and "a grace of zero" describe the same account and both + /// give `E - 0 == E`. There is no state a caller could act on differently, so there is nothing + /// for a presence check to disambiguate. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::chrono::seconds` -- the grace period, or `0s` if unset. + std::chrono::seconds get_pro_grace_period() const; + + /// API: user_profile/UserProfile::set_pro_grace_period + /// + /// Records the account's grace period, in seconds. Set alongside `set_pro_access_expiry` from + /// each `get_pro_status` response; a zero (or negative) value erases the key. + /// + /// Inputs: + /// - `grace` -- the grace period; zero or negative clears it. + void set_pro_grace_period(std::chrono::seconds grace); + /// API: user_profile/UserProfile::get_refund_requested /// /// Retrieves the timestamp at which the user requested a refund of their current Session Pro diff --git a/src/config/user_profile.cpp b/src/config/user_profile.cpp index 8bf367ed..93173891 100644 --- a/src/config/user_profile.cpp +++ b/src/config/user_profile.cpp @@ -227,8 +227,16 @@ std::optional UserProfile::get_pro_access_expiry() con void UserProfile::set_pro_access_expiry(std::optional access_expiry_ts) { if (access_expiry_ts) data["E"] = epoch_seconds(*access_expiry_ts); - else + else { data["E"].erase(); + // `G` is only meaningful as `E - G`, so it must never outlive the `E` it was paired with: + // a stranded `G` would silently pair with whatever the *next* `E` write happens to be, and + // that next write is usually a proof outcome, which carries no grace of its own to correct + // it with. Enforced here rather than left to callers because clearing `E` is the common + // case (the proof-outcome clears), and a rule spread across every call site is one a new + // call site inherits wrongly. + data["G"].erase(); + } // Confirming a live entitlement means any in-flight purchase resolved, and any long-stale // refund request is moot -- opportunistically clear both (we're already writing E anyway). @@ -245,6 +253,14 @@ bool UserProfile::get_pro_auto_renewing() const { return data["A"].integer_or(0) != 0; } +std::optional UserProfile::get_pro_auto_renewing_opt() const { + // `integer()` yields nullptr for an absent key, which is exactly the distinction that + // `get_pro_auto_renewing`'s `integer_or(0)` discards. Same shape as `get_pro_access_expiry`. + if (auto* A = data["A"].integer()) + return *A != 0; + return std::nullopt; +} + void UserProfile::set_pro_auto_renewing(bool auto_renewing) { // Presence-only: store 1 when auto-renewing, erase otherwise (absent == terminal/unknown). No // t/T bump -- this is backend-derived pro state (like E/I/R), not a user-initiated profile @@ -252,6 +268,17 @@ void UserProfile::set_pro_auto_renewing(bool auto_renewing) { set_nonzero_int(data["A"], auto_renewing); } +std::chrono::seconds UserProfile::get_pro_grace_period() const { + return std::chrono::seconds{data["G"].integer_or(0)}; +} + +void UserProfile::set_pro_grace_period(std::chrono::seconds grace) { + // Omitted when zero: the backend sends 0 whenever the subscription isn't auto-renewing, and + // `E - 0 == E`, so an absent key and a stored zero describe the same account. Set alongside + // `E`; no t/T bump -- backend-derived pro state, like E/I/R/A. + set_nonzero_int(data["G"], grace.count() > 0 ? grace.count() : 0); +} + std::optional UserProfile::get_refund_requested() const { if (auto* R = data["R"].integer()) { std::chrono::sys_seconds when{std::chrono::seconds{*R}}; @@ -556,10 +583,23 @@ LIBSESSION_C_API int user_profile_get_pro_auto_renewing(const config_object* con return unbox(conf)->get_pro_auto_renewing() ? 1 : 0; } +LIBSESSION_C_API int user_profile_has_pro_auto_renewing(const config_object* conf) { + return unbox(conf)->get_pro_auto_renewing_opt().has_value() ? 1 : 0; +} + LIBSESSION_C_API void user_profile_set_pro_auto_renewing(config_object* conf, int auto_renewing) { unbox(conf)->set_pro_auto_renewing(auto_renewing != 0); } +LIBSESSION_C_API int64_t user_profile_get_pro_grace_period(const config_object* conf) { + return unbox(conf)->get_pro_grace_period().count(); +} + +LIBSESSION_C_API void user_profile_set_pro_grace_period( + config_object* conf, int64_t grace_seconds) { + unbox(conf)->set_pro_grace_period(std::chrono::seconds{grace_seconds}); +} + LIBSESSION_C_API int64_t user_profile_get_refund_requested(const config_object* conf) { if (auto when = unbox(conf)->get_refund_requested()) return epoch_seconds(*when); diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index b3bb3b21..d531d942 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -338,10 +338,29 @@ TEST_CASE("user profile C API", "[config][user_profile][c]") { CHECK(user_profile_get_blinded_msgreqs(conf2) == 1); CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); + // "never stored", which the plain getter reports as 0 -- the whole point of the has_ predicate. + CHECK(user_profile_has_pro_auto_renewing(conf2) == 0); user_profile_set_pro_auto_renewing(conf2, 1); CHECK(user_profile_get_pro_auto_renewing(conf2) == 1); + CHECK(user_profile_has_pro_auto_renewing(conf2) == 1); user_profile_set_pro_auto_renewing(conf2, 0); CHECK(user_profile_get_pro_auto_renewing(conf2) == 0); + // Back to "not stored": clearing erases the key rather than storing a 0, so `has_` goes to 0 + // too + // -- which is why a 0 here must not be read as "explicitly not auto-renewing". + CHECK(user_profile_has_pro_auto_renewing(conf2) == 0); + + CHECK(user_profile_get_pro_grace_period(conf2) == 0); + user_profile_set_pro_grace_period(conf2, 3600); + CHECK(user_profile_get_pro_grace_period(conf2) == 3600); + // Zero erases and reads back as 0 -- unset and zero are the same account state here, which is + // why (unlike `A`) there is deliberately no presence check to go with it. + user_profile_set_pro_grace_period(conf2, 0); + CHECK(user_profile_get_pro_grace_period(conf2) == 0); + // Negative clears rather than storing a negative duration. + user_profile_set_pro_grace_period(conf2, -5); + CHECK(user_profile_get_pro_grace_period(conf2) == 0); + UserProfileTester::set_profile_updated(conf2, std::chrono::sys_seconds{124s}); // Both have changes, so push need a push @@ -663,12 +682,54 @@ TEST_CASE("UserProfile Pro Storage", "[config][user_profile][pro]") { // Pro auto-renewing flag: presence-only, defaults to false, and (backend-derived state, not a // user edit) does not stamp the profile-updated timestamp. CHECK_FALSE(profile.get_pro_auto_renewing()); + // ...and `_opt` separates "never stored" from "stored false", which the getter above cannot -- + // both read as `false` there. + CHECK_FALSE(profile.get_pro_auto_renewing_opt().has_value()); UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); profile.set_pro_auto_renewing(true); CHECK(profile.get_pro_auto_renewing()); + CHECK(profile.get_pro_auto_renewing_opt() == std::optional{true}); CHECK(profile.get_profile_updated().time_since_epoch().count() == 456); profile.set_pro_auto_renewing(false); CHECK_FALSE(profile.get_pro_auto_renewing()); + // Setting false *erases* the key rather than storing a 0, so we return to "unset" rather than + // to an explicit false. This pins the presence-only encoding: `_opt` is unknown-vs-true, NOT a + // genuine tri-state, so a caller must not read `nullopt` as "definitely not renewing". + CHECK_FALSE(profile.get_pro_auto_renewing_opt().has_value()); + + // Grace period: synced so any device can derive the paid-through instant as `E - G`. The + // backend folds grace INTO the stored expiry for auto-renewing subscriptions, so `E` is the end + // of coverage rather than the renewal-due date -- deriving that is the whole reason this key + // exists. + CHECK(profile.get_pro_grace_period() == 0s); + UserProfileTester::set_profile_updated(profile, std::chrono::sys_seconds{456s}); + profile.set_pro_grace_period(1h); + CHECK(profile.get_pro_grace_period() == 1h); + // Backend-derived, like E/I/R/A: no profile-updated bump. + CHECK(profile.get_profile_updated().time_since_epoch().count() == 456); + // The property the key exists for: coverage end minus grace is the paid-through instant. + profile.set_pro_access_expiry(std::chrono::sys_seconds{5000s}); + CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == + std::chrono::sys_seconds{5000s} - 1h); + // Zero clears; unset and zero are indistinguishable *and* equivalent (`E - 0 == E`). + profile.set_pro_grace_period(0s); + CHECK(profile.get_pro_grace_period() == 0s); + CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == + std::chrono::sys_seconds{5000s}); + + // Clearing `E` also clears `G`: the pair is only meaningful as `E - G`, so a `G` that outlived + // its `E` would silently pair with the NEXT `E` write -- and that write is typically a proof + // outcome, which carries no grace to correct it with. Enforced in the setter, not at call + // sites. + profile.set_pro_grace_period(1h); + CHECK(profile.get_pro_grace_period() == 1h); + profile.set_pro_access_expiry(std::nullopt); + CHECK_FALSE(profile.get_pro_access_expiry().has_value()); + CHECK(profile.get_pro_grace_period() == 0s); + // ...and a later `E` write therefore cannot inherit the stale grace. + profile.set_pro_access_expiry(std::chrono::sys_seconds{9000s}); + CHECK(*profile.get_pro_access_expiry() - profile.get_pro_grace_period() == + std::chrono::sys_seconds{9000s}); // Refund-requested flag (synced via config, not the Pro backend) CHECK_FALSE(profile.get_refund_requested().has_value());