autumn-billing 0.10.0

Subscription billing plugin (Stripe checkout, portal, webhook mirror, dunning) for autumn-web applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
// autumn-panic-gate: request-path module — production code path must be panic-free.
// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
#![cfg_attr(
    not(test),
    deny(
        clippy::unwrap_used,
        clippy::expect_used,
        clippy::panic,
        clippy::unreachable,
        clippy::todo,
        clippy::unimplemented,
        clippy::indexing_slicing,
        clippy::string_slice,
        clippy::arithmetic_side_effects,
    )
)]
//! The plan gate: read local mirror state, default deny.
//!
//! A user is entitled when the mirror holds a subscription that is
//! `active` or `trialing` (`past_due` only with `allow_past_due`), whose
//! price maps to a catalog plan, and whose `current_period_end` plus the
//! grace period is not in the past. Without a known period end the grace
//! period counts from the last event applied. Every missing piece denies.
//!
//! The gate tests the rule against every subscription of the customer. At
//! least one entitled subscription that satisfies the rule gives access
//! (issue #3114).

use std::marker::PhantomData;
use std::sync::Arc;

use autumn_web::session::Session;
use autumn_web::{AppState, AutumnError};
use axum_core_reexport::FromRequestParts;
use serde::{Deserialize, Serialize};

use crate::BillingService;
use crate::error::BillingError;
use crate::model::{Subscription, SubscriptionStatus};
use crate::plan::{Plan, PlanCatalog, PlanId};

mod axum_core_reexport {
    pub use autumn_web::reexports::axum::extract::FromRequestParts;
}

/// What a route requires.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum PlanRule {
    /// Any entitled subscription on any catalog plan.
    AnyActive,
    /// An entitled subscription on this plan.
    Plan(PlanId),
    /// An entitled subscription on a plan that grants this entitlement.
    Entitlement(String),
}

impl PlanRule {
    /// Require a plan.
    #[must_use]
    pub fn plan(id: impl Into<PlanId>) -> Self {
        Self::Plan(id.into())
    }

    /// Require an entitlement.
    #[must_use]
    pub fn entitlement(name: impl Into<String>) -> Self {
        Self::Entitlement(name.into())
    }

    /// `true` when `plan` satisfies the rule.
    fn accepts(&self, plan: &Plan) -> bool {
        match self {
            Self::AnyActive => true,
            Self::Plan(id) => &plan.id == id,
            Self::Entitlement(name) => plan.grants(name),
        }
    }

    /// Text for the `Forbidden` error.
    fn describe(&self) -> String {
        match self {
            Self::AnyActive => "an active subscription".to_owned(),
            Self::Plan(id) => format!("plan {id}"),
            Self::Entitlement(name) => format!("entitlement {name}"),
        }
    }
}

/// A compile-time plan requirement for [`Entitled`].
pub trait PlanRequirement: Send + Sync + 'static {
    /// The rule.
    fn rule() -> PlanRule;
}

/// A subscription joined with its plan, from the local mirror.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SubscriptionView {
    /// The mirror row.
    pub subscription: Subscription,
    /// The catalog plan, when the price id is known.
    pub plan: Option<Plan>,
    /// `true` when the gate treats this subscription as entitled.
    pub entitled: bool,
}

impl SubscriptionView {
    /// Build a view. `entitled` is the caller's decision.
    #[must_use]
    pub const fn new(subscription: Subscription, plan: Option<Plan>, entitled: bool) -> Self {
        Self {
            subscription,
            plan,
            entitled,
        }
    }

    /// `true` when the view is entitled and its plan satisfies `rule`.
    fn satisfies(&self, rule: &PlanRule) -> bool {
        self.entitled && self.plan.as_ref().is_some_and(|plan| rule.accepts(plan))
    }
}

/// Service handle for handlers and policies.
#[derive(Clone)]
pub struct Billing {
    service: Arc<BillingService>,
    state: AppState,
}

impl std::fmt::Debug for Billing {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Billing").finish_non_exhaustive()
    }
}

impl Billing {
    /// Build from state. `None` when the plugin did not start.
    #[must_use]
    pub fn from_state(state: &AppState) -> Option<Self> {
        BillingService::from_state(state).map(|service| Self {
            service,
            state: state.clone(),
        })
    }

    /// The service.
    #[must_use]
    pub fn service(&self) -> &BillingService {
        &self.service
    }

    /// The app state.
    #[must_use]
    pub const fn state(&self) -> &AppState {
        &self.state
    }

    /// The logged-in user id in `session`, read with the configured auth
    /// session key.
    ///
    /// Under Autumn's tenancy feature, this is the same opaque, tenant-scoped
    /// identity every billing store lookup is keyed on — not the bare
    /// session value — since two different tenants' sessions can otherwise
    /// stringify to the identical id (see `docs/guide/sharding.md`). Recover
    /// the raw id with [`crate::gate::strip_tenant_scope`] if a caller needs
    /// it. `CustomerRequest.user_id` (what a [`BillingProvider`](crate::provider::BillingProvider)
    /// receives) is unaffected: `customer_for` recovers the raw id before
    /// building that request, so a custom provider sees the same value as
    /// before tenancy folded anything in here.
    ///
    /// # Errors
    ///
    /// Returns [`BillingError::Unauthenticated`] when no user is logged in.
    pub async fn current_user(&self, session: &Session) -> Result<String, BillingError> {
        user_id_in(session, &self.state).await
    }

    /// The user's current subscription from the mirror. No provider call.
    ///
    /// Picks an entitled subscription first, then a live one, then the
    /// latest `last_event_at` within that group, then the lowest `id`. An
    /// abandoned `incomplete` checkout never hides an active subscription.
    ///
    /// `user_id` is looked up against `Customer.user_id` verbatim — under
    /// Autumn's tenancy feature that is the tenant-scoped identity
    /// [`current_user`](Self::current_user)/[`session_user_id`] returns, not
    /// a bare application user id. A caller resolving the current user some
    /// other way (outside `SessionUser`/`Entitled<R>`, which already go
    /// through `session_user_id`) must pass that same scoped value here —
    /// build it explicitly with [`crate::gate::scope_identity`] if it is not
    /// already at hand — or this always misses and denies entitlement for an
    /// otherwise-paying tenant user. [`is_entitled`](Self::is_entitled) and
    /// [`require`](Self::require) share this contract.
    ///
    /// This method picks one subscription to show. It is not a gate: the
    /// gate tests every subscription (issue #3114).
    ///
    /// # Errors
    ///
    /// Returns the store error.
    pub async fn current_subscription(
        &self,
        user_id: &str,
    ) -> Result<Option<SubscriptionView>, BillingError> {
        let rank = |view: &SubscriptionView| {
            (
                view.entitled,
                view.subscription.status.is_live(),
                view.subscription.last_event_at,
            )
        };
        Ok(self.views(user_id).await?.into_iter().max_by(|a, b| {
            rank(a)
                .cmp(&rank(b))
                .then_with(|| b.subscription.id.cmp(&a.subscription.id))
        }))
    }

    /// `true` when at least one entitled subscription of `user_id` satisfies
    /// `rule`. Default deny.
    ///
    /// # Errors
    ///
    /// Returns the store error.
    pub async fn is_entitled(&self, user_id: &str, rule: &PlanRule) -> Result<bool, BillingError> {
        Ok(best_satisfying(self.views(user_id).await?, rule).is_some())
    }

    /// The entitled subscription that satisfies `rule` and has the latest
    /// `last_event_at`, or [`BillingError::Forbidden`].
    ///
    /// A tie on `last_event_at` goes to the lowest `id`. The result does not
    /// depend on the store row order.
    ///
    /// # Errors
    ///
    /// Returns [`BillingError::Forbidden`] when no subscription satisfies
    /// the rule, or the store error.
    pub async fn require(
        &self,
        user_id: &str,
        rule: &PlanRule,
    ) -> Result<SubscriptionView, BillingError> {
        best_satisfying(self.views(user_id).await?, rule)
            .ok_or_else(|| BillingError::Forbidden(rule.describe()))
    }

    /// Every subscription of the user, joined with its plan. Empty when the
    /// user has no customer row.
    async fn views(&self, user_id: &str) -> Result<Vec<SubscriptionView>, BillingError> {
        let store = self.service.store();
        let Some(customer) = store.customer_by_user(user_id).await? else {
            return Ok(Vec::new());
        };
        let rows = store.subscriptions_for_customer(&customer.id).await?;
        Ok(rows.into_iter().map(|row| self.view(row)).collect())
    }

    /// Join the plan and evaluate entitlement.
    fn view(&self, subscription: Subscription) -> SubscriptionView {
        let plan = resolve_plan(self.service.catalog(), &subscription).cloned();
        let entitled =
            plan.is_some() && self.status_ok(subscription.status) && self.in_period(&subscription);
        SubscriptionView::new(subscription, plan, entitled)
    }

    fn status_ok(&self, status: SubscriptionStatus) -> bool {
        match status {
            SubscriptionStatus::Active | SubscriptionStatus::Trialing => true,
            SubscriptionStatus::PastDue => self.service.config().allow_past_due,
            _ => false,
        }
    }

    /// `true` when `current_period_end + grace` is not in the past. Without
    /// a period end the deadline is `last_event_at + grace`: a mirror the
    /// provider stopped feeding lapses either way.
    fn in_period(&self, subscription: &Subscription) -> bool {
        let end = subscription
            .current_period_end
            .unwrap_or(subscription.last_event_at);
        let grace = chrono::Duration::from_std(self.service.config().grace_period)
            .unwrap_or(chrono::Duration::MAX);
        let now = self.state.clock().now();
        end.checked_add_signed(grace)
            .is_none_or(|deadline| deadline >= now)
    }
}

/// Picks the view that grants `rule`. It takes the satisfying view with the
/// latest `last_event_at`. A tie goes to the lowest `id`. `None` denies.
/// `verification/billing_gate.rs` models this choice.
fn best_satisfying(
    views: impl IntoIterator<Item = SubscriptionView>,
    rule: &PlanRule,
) -> Option<SubscriptionView> {
    views
        .into_iter()
        .filter(|view| view.satisfies(rule))
        .min_by(|a, b| {
            b.subscription
                .last_event_at
                .cmp(&a.subscription.last_event_at)
                .then_with(|| a.subscription.id.cmp(&b.subscription.id))
        })
}

/// The catalog plan for a mirror row: by price id first, then by stored plan id.
fn resolve_plan<'a>(catalog: &'a PlanCatalog, subscription: &Subscription) -> Option<&'a Plan> {
    subscription
        .provider_price_id
        .as_ref()
        .and_then(|price| catalog.by_price_id(price))
        .or_else(|| subscription.plan_id.as_ref().and_then(|id| catalog.get(id)))
}

impl FromRequestParts<AppState> for Billing {
    type Rejection = AutumnError;

    async fn from_request_parts(
        _parts: &mut autumn_web::reexports::http::request::Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        Self::from_state(state)
            .ok_or_else(|| AutumnError::service_unavailable_msg("billing plugin is not started"))
    }
}

/// Pre-body gate: 401 without a session user, 403 without an entitled
/// subscription that satisfies `R`.
pub struct Entitled<R: PlanRequirement> {
    /// The entitled subscription that satisfies `R` and has the latest
    /// `last_event_at`.
    pub view: SubscriptionView,
    _rule: PhantomData<R>,
}

impl<R: PlanRequirement> std::fmt::Debug for Entitled<R> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Entitled")
            .field("view", &self.view)
            .finish()
    }
}

impl<R: PlanRequirement> FromRequestParts<AppState> for Entitled<R> {
    type Rejection = AutumnError;

    async fn from_request_parts(
        parts: &mut autumn_web::reexports::http::request::Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let user_id = session_user_id(parts, state)
            .await
            .map_err(BillingError::into_autumn)?;
        let billing = Billing::from_request_parts(parts, state).await?;
        let view = billing
            .require(&user_id, &R::rule())
            .await
            .map_err(BillingError::into_autumn)?;
        Ok(Self {
            view,
            _rule: PhantomData,
        })
    }
}

/// Resolve the session user id with the configured auth key.
///
/// # Errors
///
/// Returns [`BillingError::Unauthenticated`] when no user is logged in.
pub async fn session_user_id(
    parts: &mut autumn_web::reexports::http::request::Parts,
    state: &AppState,
) -> Result<String, BillingError> {
    let session = match Session::from_request_parts(parts, state).await {
        Ok(session) => session,
        Err(never) => match never {},
    };
    user_id_in(&session, state).await
}

/// Marker byte opening a tenant-scoped billing identity
/// (`scope_identity_to_tenant`).
///
/// A C0 control character rather than a printable one (`:`, `/`, …): an
/// ordinary application user id written before tenancy existed can spell any
/// printable character, so a byte it is vanishingly unlikely to already
/// start with is what lets [`strip_tenant_scope`] tell a scoped identity
/// apart from a pre-existing bare one. It is NOT what separates the tenant
/// from the user id within a scoped identity — `autumn_web::tenancy`'s
/// `session` and `jwt` sources hand back whatever string the app's own
/// session value or JWT claim contains, with no character-set restriction,
/// so a tenant string can itself contain this same byte. Splitting on it
/// (as an earlier version of this fix did) is then not injective: tenant `a`
/// with user id `b{MARKER}c` and tenant `a{MARKER}b` with user id `c` fold
/// to the identical string, reopening the exact cross-tenant collision this
/// scoping exists to close. `scope_identity_to_tenant` instead
/// length-prefixes the tenant — the same reasoning `idempotency.rs`'s
/// length-prefixed key components exist for, kept unhashed here so
/// [`BillingHooks::recipient_for`](crate::hooks::BillingHooks::recipient_for)'s
/// default implementation can still recover the raw id.
pub(crate) const TENANT_IDENTITY_MARKER: char = '\u{1}';

/// The non-empty user id stored under the configured auth session key,
/// scoped to the ambient tenant when one is in scope.
///
/// A session's stored identity (whatever the app's login handler put under
/// `auth.session_key`) is only guaranteed unique WITHIN its own tenant: a
/// `#[repository(tenant_scoped)]` model's row id is a per-tenant sequence,
/// and a sharded deployment's shard-local `BIGSERIAL` starts over on every
/// shard (`docs/guide/sharding.md`: the destination's PK sequence is never
/// copied between shards), so two different tenants' principals routinely
/// stringify to the identical `user_id`. Every billing store lookup is keyed
/// on this string alone (`customer_by_user`, `Entitled<R>`), and
/// `BillingPlugin` always resolves the app's one primary connection pool —
/// never a per-shard one — so without folding the tenant in here, two
/// unrelated principals that happen to share a `user_id` would be treated as
/// one and the same billing customer: reading, and through the hosted
/// portal potentially managing, each other's subscription.
///
/// `None` (tenancy disabled, or a `[tenancy] public_paths` route the
/// middleware exempts before it scopes anything) folds in nothing, so a
/// non-tenant app's stored identity — and therefore its `billing_customers`
/// rows — is byte-identical to what it was before this existed. Mirrors the
/// tenant-folding already applied to `#[cached]`'s cache key and the
/// idempotency/rate-limit storage keys.
///
/// **Compatibility:** under tenancy, this — and therefore `Customer.user_id`
/// and whatever [`BillingHooks::recipient_for`](crate::hooks::BillingHooks::recipient_for)
/// receives — is now an opaque, tenant-scoped identity, not the bare session
/// id. Recover the raw id with [`strip_tenant_scope`]; a pre-existing
/// `billing_customers` row keyed by the old bare id needs
/// [`BillingStore::relink_customer`](crate::store::BillingStore::relink_customer)
/// to move it onto the new one (see the migration guide).
async fn user_id_in(session: &Session, state: &AppState) -> Result<String, BillingError> {
    let user_id = session
        .get(state.auth_session_key())
        .await
        .filter(|user_id| !user_id.is_empty())
        .ok_or(BillingError::Unauthenticated)?;
    Ok(scope_identity_to_tenant(user_id))
}

/// Build the tenant-scoped billing identity for `tenant`/`user_id` directly,
/// without reading the ambient [`CURRENT_TENANT`](autumn_web::tenancy::CURRENT_TENANT).
///
/// For operator tooling only — an offline migration script has no request to
/// resolve a tenant from. Request-handling code goes through
/// [`session_user_id`], which resolves `tenant` from `CURRENT_TENANT` itself.
#[must_use]
pub fn scope_identity(tenant: &str, user_id: impl Into<String>) -> String {
    encode_tenant_scope(tenant, &user_id.into())
}

/// Length-prefix `tenant` so the split point stays unambiguous no matter what
/// bytes `tenant` or `user_id` themselves contain: `{MARKER}{tenant.len()}:{tenant}{user_id}`.
/// [`strip_tenant_scope`] reads the decimal length up to the first `:` after
/// the marker, then skips exactly that many bytes — never the marker or a
/// `:` occurring anywhere inside `tenant` or `user_id`, since nothing before
/// that first `:` can come from either of them.
fn encode_tenant_scope(tenant: &str, user_id: &str) -> String {
    format!("{TENANT_IDENTITY_MARKER}{}:{tenant}{user_id}", tenant.len())
}

/// Recover the raw session user id from a billing identity
/// `scope_identity_to_tenant` may have tenant-scoped.
///
/// A no-op when tenancy is disabled, or for an identity written before this
/// existed, or for anything else that does not parse as this crate's own
/// encoding (no marker, no `:`-terminated length, or too short) — so it is
/// safe to call unconditionally, as
/// [`BillingHooks::recipient_for`](crate::hooks::BillingHooks::recipient_for)'s
/// default implementation and `routes.rs`'s `customer_for` both do. Neither
/// `TENANT_IDENTITY_MARKER` nor the wire format is public API: this function
/// is the stable surface a custom `recipient_for` override recovers the bare
/// id through.
///
/// **Known, accepted limitation:** detection is content-based, not
/// provenance-based — this function cannot tell "genuinely produced by
/// `scope_identity_to_tenant`" apart from "a bare session id that happens to
/// already start with `{MARKER}{digits}:`", because both are indistinguishable
/// byte-for-byte. Closing this completely would need either a typed identity
/// (carrying its own scoped/raw provenance rather than being a bare
/// `String`) or rejecting the marker byte from every session/tenant value at
/// the source — the former is a breaking API redesign this fix does not make
/// unilaterally, the latter cannot apply retroactively to a `user_id` a
/// pre-existing app already stored under `auth.session_key` before it ever
/// adopted this crate. Accepted because `TENANT_IDENTITY_MARKER` is a raw C0
/// control byte (`\u{1}`): every `user_id` shape Autumn's own examples and
/// `docs/guide/billing.md` produce — an integer primary key, a UUID — cannot
/// contain one, and an app whose session-stored identity can contain
/// arbitrary bytes (unusual) should not treat this string as opaque metadata
/// only, the same caveat `CustomerRequest.user_id` already carries.
#[must_use]
pub fn strip_tenant_scope(user_id: &str) -> &str {
    let Some(rest) = user_id.strip_prefix(TENANT_IDENTITY_MARKER) else {
        return user_id;
    };
    let Some((len, rest)) = rest.split_once(':') else {
        return user_id;
    };
    let Ok(tenant_len) = len.parse::<usize>() else {
        return user_id;
    };
    rest.get(tenant_len..).unwrap_or(user_id)
}

/// Fold the request's ambient `CURRENT_TENANT` into a billing identity.
fn scope_identity_to_tenant(user_id: String) -> String {
    let tenant = autumn_web::tenancy::CURRENT_TENANT
        .try_with(Clone::clone)
        .ok()
        .flatten();
    match tenant {
        Some(tenant) => encode_tenant_scope(&tenant, &user_id),
        None => user_id,
    }
}

#[cfg(test)]
mod tenant_scope_tests {
    use super::{scope_identity, strip_tenant_scope};

    /// `strip_tenant_scope` recovers exactly what `scope_identity` encoded.
    #[test]
    fn round_trips() {
        let scoped = scope_identity("acme", "7");
        assert_eq!(strip_tenant_scope(&scoped), "7");
    }

    /// A separator-based encoding would fold tenant `"a"`/user `"b\u{1}c"`
    /// and tenant `"a\u{1}b"`/user `"c"` to the identical string — Autumn's
    /// `session`/`jwt` tenancy sources place no character-set restriction on
    /// the resolved tenant, so this is not a hypothetical input. The
    /// length-prefixed encoding must tell the two apart.
    #[test]
    fn injective_even_when_a_component_contains_the_marker() {
        let a = scope_identity("a", "b\u{1}c");
        let b = scope_identity("a\u{1}b", "c");
        assert_ne!(a, b, "distinct (tenant, user_id) pairs must not collide");
        assert_eq!(strip_tenant_scope(&a), "b\u{1}c");
        assert_eq!(strip_tenant_scope(&b), "c");
    }

    /// A colon right after the length digits — inside the tenant, not as the
    /// length/tenant delimiter — must not confuse the parse.
    #[test]
    fn tenant_containing_a_colon_still_round_trips() {
        let scoped = scope_identity("ten:ant", "user:42");
        assert_eq!(strip_tenant_scope(&scoped), "user:42");
    }

    /// A bare id written before tenancy existed (no marker) passes through
    /// unchanged rather than being misparsed.
    #[test]
    fn legacy_bare_id_is_unaffected() {
        assert_eq!(strip_tenant_scope("42"), "42");
    }

    /// Malformed input that merely starts with the marker (truncated,
    /// non-numeric length, or a length longer than what follows) degrades to
    /// returning the input unchanged rather than panicking on a bad slice
    /// index.
    #[test]
    fn malformed_scoped_looking_input_does_not_panic() {
        assert_eq!(strip_tenant_scope("\u{1}"), "\u{1}");
        assert_eq!(strip_tenant_scope("\u{1}abc:x"), "\u{1}abc:x");
        assert_eq!(strip_tenant_scope("\u{1}999:short"), "\u{1}999:short");
        assert_eq!(strip_tenant_scope("\u{1}3:ab"), "\u{1}3:ab");
    }

    /// Known, accepted limitation (see `strip_tenant_scope`'s doc): a bare
    /// id that happens to be well-formed-looking scoped syntax is
    /// misclassified, because detection is content-based, not
    /// provenance-based. Documented here rather than left an undocumented
    /// surprise — this is exactly why `TENANT_IDENTITY_MARKER` is a raw C0
    /// control byte no realistic `user_id` (an integer id, a UUID) contains.
    #[test]
    fn known_limitation_a_bare_id_shaped_like_the_encoding_is_misclassified() {
        let coincidental_bare_id = "\u{1}1:a7";
        assert_eq!(strip_tenant_scope(coincidental_bare_id), "7");
        assert_eq!(scope_identity("a", "7"), coincidental_bare_id);
    }
}

#[cfg(test)]
mod best_satisfying_tests {
    use chrono::{DateTime, TimeZone, Utc};

    use super::{PlanRule, SubscriptionView, best_satisfying};
    use crate::model::{Subscription, SubscriptionStatus};
    use crate::money::{Currency, Money};
    use crate::plan::{BillingInterval, Plan};

    fn at(hour: u32) -> DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 9, 10, hour, 0, 0).unwrap()
    }

    fn view(id: &str, plan: &str, hour: u32, entitled: bool) -> SubscriptionView {
        let row = Subscription::new(
            id,
            "cust",
            format!("sub_{id}"),
            SubscriptionStatus::Active,
            at(hour),
            at(12),
        );
        let plan = Plan::new(
            plan,
            plan,
            format!("price_{plan}"),
            Money::from_minor(100, Currency::USD),
            BillingInterval::Month,
        );
        SubscriptionView::new(row, Some(plan), entitled)
    }

    /// Every permutation of `items`, by Heap's algorithm.
    fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
        fn heap<T: Clone>(k: usize, items: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
            if k <= 1 {
                out.push(items.clone());
                return;
            }
            for i in 0..k {
                heap(k - 1, items, out);
                let j = if k.is_multiple_of(2) { i } else { 0 };
                items.swap(j, k - 1);
            }
        }
        let mut items = items.to_vec();
        let mut out = Vec::new();
        heap(items.len(), &mut items, &mut out);
        out
    }

    fn pick(views: Vec<SubscriptionView>, rule: &PlanRule) -> Option<String> {
        best_satisfying(views, rule).map(|view| view.subscription.id)
    }

    /// The choice does not depend on the input order. A memory store and a
    /// SQL store can return tied rows in different orders.
    #[test]
    fn choice_is_independent_of_row_order() {
        let views = [
            view("b", "pro", 9, true),
            view("a", "pro", 9, true),
            view("c", "pro", 9, true),
            view("d", "pro", 8, true),
            view("e", "pro", 10, false),
            view("f", "team", 11, true),
        ];
        for order in permutations(&views) {
            assert_eq!(
                pick(order.clone(), &PlanRule::plan("pro")).as_deref(),
                Some("a")
            );
            assert_eq!(
                pick(order.clone(), &PlanRule::AnyActive).as_deref(),
                Some("f")
            );
            assert_eq!(pick(order, &PlanRule::plan("enterprise")), None);
        }
    }

    /// An empty input denies.
    #[test]
    fn no_views_denies() {
        assert_eq!(pick(Vec::new(), &PlanRule::AnyActive), None);
    }
}