feat: establish entitlement domain and feature access foundation
This commit is contained in:
parent
885d562690
commit
ee5fc42b37
280
AGENTS.md
280
AGENTS.md
|
|
@ -1,78 +1,152 @@
|
||||||
# MotorSignal — Agent Development Rules
|
# MotorSignal — Agent Development Rules
|
||||||
|
|
||||||
## Project Identity
|
## Project Identity
|
||||||
|
|
||||||
MotorSignal is a Flutter motorcycle telemetry and ride-analysis application.
|
MotorSignal is a Flutter motorcycle telemetry, ride-recording, performance-analysis, and cloud-synchronization application.
|
||||||
|
|
||||||
Before modifying code, agents MUST inspect the relevant existing architecture and
|
Before modifying code, agents MUST inspect the relevant existing architecture, providers, services, persistence paths, and tests and understand the current implementation.
|
||||||
tests and understand the current implementation.
|
|
||||||
|
Do not infer architecture from filenames or documentation alone when the production code can be inspected directly.
|
||||||
|
|
||||||
## Required Development Workflow
|
## Required Development Workflow
|
||||||
|
|
||||||
Before making changes:
|
Before making changes:
|
||||||
|
|
||||||
1. Inspect the relevant source files.
|
1. Confirm the current Git branch and working-tree state.
|
||||||
2. Identify existing services, providers, models, repositories, and tests.
|
2. Identify any pre-existing modified/untracked files and do not alter them unless they are explicitly part of the task.
|
||||||
3. Do not duplicate existing functionality.
|
3. Inspect the relevant source files.
|
||||||
4. Make the smallest safe change necessary.
|
4. Identify existing services, providers, models, repositories, database helpers, and tests.
|
||||||
5. Preserve existing public interfaces unless the task explicitly requires change.
|
5. Search for all production callers before replacing or removing an implementation.
|
||||||
6. Add or update tests for behavioral changes.
|
6. Do not duplicate existing functionality.
|
||||||
7. Run static analysis.
|
7. Make the smallest safe change necessary.
|
||||||
8. Run the relevant test suite.
|
8. Preserve existing public interfaces unless the task explicitly requires a change.
|
||||||
9. Run the complete test suite before declaring the task complete.
|
9. Add or update tests for behavioral changes.
|
||||||
10. Report exactly what changed and what verification passed.
|
10. Run the relevant focused tests first when appropriate.
|
||||||
|
11. Run the complete test suite.
|
||||||
|
12. Run static analysis.
|
||||||
|
13. Build the debug APK for changes that can affect production compilation/runtime wiring.
|
||||||
|
14. Report exactly what changed and what verification passed.
|
||||||
|
15. Do not commit unless explicitly authorized.
|
||||||
|
|
||||||
|
If the requested change conflicts with an existing architectural contract, stop and report the conflict before broadening scope.
|
||||||
|
|
||||||
## Telemetry Freeze Boundary
|
## Telemetry Freeze Boundary
|
||||||
|
|
||||||
The following subsystem is FROZEN unless the task explicitly requires
|
The following subsystem is FROZEN unless the task explicitly requires modification and the user authorizes that change:
|
||||||
modification and the user approves the architectural change:
|
|
||||||
|
|
||||||
- TelemetryService
|
* TelemetryService
|
||||||
- SensorFusionService
|
* SensorFusionService
|
||||||
- RideStateMachine
|
* RideStateMachine telemetry/motion semantics
|
||||||
|
|
||||||
|
Changes to RideStateMachine that are unrelated to telemetry semantics may be permitted when explicitly required by the task, but existing ride lifecycle behavior must be preserved unless the task specifically authorizes otherwise.
|
||||||
|
|
||||||
### TelemetryService
|
### TelemetryService
|
||||||
|
|
||||||
Authoritative telemetry acquisition runs at 20 Hz / 50 ms.
|
Authoritative telemetry acquisition runs at 20 Hz / 50 ms.
|
||||||
|
|
||||||
Do not:
|
Do not:
|
||||||
- change acquisition cadence
|
|
||||||
- introduce competing telemetry clocks
|
* change acquisition cadence
|
||||||
- duplicate sensor acquisition
|
* introduce competing telemetry clocks
|
||||||
- bypass the authoritative telemetry pipeline
|
* duplicate sensor acquisition
|
||||||
- introduce UI-driven polling of sensors
|
* bypass the authoritative telemetry pipeline
|
||||||
|
* introduce UI-driven polling of sensors
|
||||||
|
|
||||||
All consumers must use the existing telemetry architecture.
|
All consumers must use the existing telemetry architecture.
|
||||||
|
|
||||||
### SensorFusionService
|
### SensorFusionService
|
||||||
|
|
||||||
Sensor fusion is authoritative for derived orientation/lean data.
|
Sensor fusion is authoritative for derived orientation and lean data.
|
||||||
|
|
||||||
Do not introduce an independent lean-angle calculation without explicit
|
Do not introduce an independent lean-angle calculation without explicit architectural justification and regression tests.
|
||||||
architectural justification and regression tests.
|
|
||||||
|
|
||||||
### RideStateMachine
|
### RideStateMachine
|
||||||
|
|
||||||
Preserve existing movement/crash/recovery semantics.
|
Preserve existing movement, crash, lifecycle, interrupted-ride recovery, and state-transition semantics.
|
||||||
|
|
||||||
The 3.0 MPH moving gate and kickstand isolation behavior are architectural
|
The 3.0 MPH moving gate and kickstand isolation behavior are architectural contracts unless explicitly changed by the user.
|
||||||
contracts unless explicitly changed by the user.
|
|
||||||
|
Ride lifecycle changes must include regression coverage.
|
||||||
|
|
||||||
## Database Freeze Boundary
|
## Database Freeze Boundary
|
||||||
|
|
||||||
AppDatabase is currently Schema 5.
|
AppDatabase is currently Schema 7.
|
||||||
|
|
||||||
Do not modify database schema, migrations, event semantics, or persistence
|
Do not modify database schema, migrations, event semantics, or persistence behavior casually.
|
||||||
behavior casually.
|
|
||||||
|
|
||||||
Any schema change MUST include:
|
Any schema change MUST include:
|
||||||
|
|
||||||
- migration
|
* an explicit schema-version decision
|
||||||
- migration tests
|
* migration logic where required
|
||||||
- backward compatibility consideration
|
* migration tests
|
||||||
- verification against existing ride datasets
|
* backward-compatibility consideration
|
||||||
|
* verification against existing ride datasets
|
||||||
|
* consideration for legacy/null data
|
||||||
|
|
||||||
Never delete or rewrite historical ride data during feature development.
|
Never delete, destructively migrate, or rewrite historical ride data during normal feature development.
|
||||||
|
|
||||||
|
Use existing database helpers where available instead of duplicating raw insert/update behavior.
|
||||||
|
|
||||||
|
Stable ride identity (`cloudRideId`) is part of the persistence contract. Do not regenerate or replace an existing ride identity.
|
||||||
|
|
||||||
|
## Cloud / Offline Sync Architecture
|
||||||
|
|
||||||
|
MotorSignal uses an offline-first cloud synchronization architecture.
|
||||||
|
|
||||||
|
The authoritative automatic completed-ride synchronization path is:
|
||||||
|
|
||||||
|
RideStateMachine
|
||||||
|
→ syncObserverProvider
|
||||||
|
→ SyncManager
|
||||||
|
→ SyncOutbox
|
||||||
|
→ FirebaseSyncProvider
|
||||||
|
→ Cloudflare R2
|
||||||
|
→ Firestore
|
||||||
|
|
||||||
|
Do not introduce another automatic ride-upload queue or bypass SyncManager/SyncOutbox for normal completed-ride synchronization.
|
||||||
|
|
||||||
|
### SyncManager / SyncOutbox
|
||||||
|
|
||||||
|
SyncManager and SyncOutbox are authoritative for durable automatic cloud synchronization.
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
* durable queued work
|
||||||
|
* retry behavior
|
||||||
|
* authentication-required state
|
||||||
|
* restart recovery
|
||||||
|
* duplicate suppression
|
||||||
|
* account scoping
|
||||||
|
* offline operation
|
||||||
|
|
||||||
|
Do not replace durable queueing with an in-memory-only queue.
|
||||||
|
|
||||||
|
### FirebaseSyncProvider
|
||||||
|
|
||||||
|
FirebaseSyncProvider is the authoritative MotorCloud upload implementation.
|
||||||
|
|
||||||
|
Normal cloud synchronization must preserve:
|
||||||
|
|
||||||
|
* stable cloudRideId
|
||||||
|
* R2 telemetry-bundle identity
|
||||||
|
* Firestore ride identity
|
||||||
|
* retry/idempotency behavior
|
||||||
|
|
||||||
|
Legacy compatibility or explicit developer-export services must not become competing automatic synchronization paths.
|
||||||
|
|
||||||
|
### GitHub Sync / Export
|
||||||
|
|
||||||
|
GitHub-related upload/export functionality may remain for explicit developer or compatibility workflows.
|
||||||
|
|
||||||
|
Do not route normal automatic completed-ride synchronization through a legacy GitHubSyncService queue.
|
||||||
|
|
||||||
|
## Performance Data
|
||||||
|
|
||||||
|
Performance timing data such as 0–60 and 0–100 results must remain associated with the ride that produced it when recorded during an active ride.
|
||||||
|
|
||||||
|
Do not silently discard or detach ride-specific performance data from cloud/export payloads.
|
||||||
|
|
||||||
|
Legacy standalone performance runs with no ride association must remain valid where supported by the existing design.
|
||||||
|
|
||||||
## Forensic / Export Freeze Boundary
|
## Forensic / Export Freeze Boundary
|
||||||
|
|
||||||
|
|
@ -80,14 +154,30 @@ FieldTestExportService is authoritative for forensic exports.
|
||||||
|
|
||||||
Preserve:
|
Preserve:
|
||||||
|
|
||||||
- CSV schema
|
* CSV schema
|
||||||
- JSON session schema 1
|
* JSON session schema 1
|
||||||
- GPX export
|
* GPX export
|
||||||
- timestamp semantics
|
* timestamp semantics
|
||||||
- event ordering
|
* event ordering
|
||||||
- sub-second telemetry fidelity
|
* sub-second telemetry fidelity
|
||||||
|
|
||||||
Feature/UI work must not silently alter exported historical data.
|
Feature or UI work must not silently alter exported historical data.
|
||||||
|
|
||||||
|
Any intentional export-schema change must be explicitly authorized and versioned appropriately.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Authentication must not block local ride recording.
|
||||||
|
|
||||||
|
Preserve offline/local functionality when:
|
||||||
|
|
||||||
|
* signed out
|
||||||
|
* Firebase is unavailable
|
||||||
|
* network is unavailable
|
||||||
|
|
||||||
|
Do not make cloud authentication a prerequisite for collecting or storing local ride data unless explicitly required.
|
||||||
|
|
||||||
|
Anonymous/authentication transition behavior must not cause ride loss.
|
||||||
|
|
||||||
## State Management
|
## State Management
|
||||||
|
|
||||||
|
|
@ -95,37 +185,102 @@ Follow the project's existing Riverpod architecture.
|
||||||
|
|
||||||
Do not introduce another state-management framework.
|
Do not introduce another state-management framework.
|
||||||
|
|
||||||
Prefer existing providers/services over creating parallel state containers.
|
Prefer existing providers and services over creating parallel state containers.
|
||||||
|
|
||||||
|
Be careful with provider lifecycle and `autoDispose` behavior for long-lived services, observers, synchronization, and ride-state listeners.
|
||||||
|
|
||||||
## UI Development
|
## UI Development
|
||||||
|
|
||||||
UI features must consume existing domain/application services.
|
UI features must consume existing domain/application services.
|
||||||
|
|
||||||
Do not place telemetry acquisition, persistence, sensor fusion, or business
|
Do not place telemetry acquisition, persistence, sensor fusion, synchronization, or substantial business logic directly inside widgets.
|
||||||
logic directly inside widgets.
|
|
||||||
|
|
||||||
Support both portrait and landscape layouts where applicable.
|
Support both portrait and landscape layouts where applicable.
|
||||||
|
|
||||||
|
Do not make UI refresh frequency drive telemetry or persistence frequency.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Every behavioral change requires appropriate automated coverage.
|
Every behavioral change requires appropriate automated coverage.
|
||||||
|
|
||||||
Before completion:
|
Prefer behavioral tests over source-text assertions when practical.
|
||||||
|
|
||||||
dart analyze lib test
|
Before completion, run:
|
||||||
flutter test
|
|
||||||
|
|
||||||
A task is NOT considered complete if tests fail, analysis reports new issues,
|
```text
|
||||||
or existing regression datasets change unexpectedly.
|
flutter test
|
||||||
|
flutter analyze
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Focused tests may be run first during development.
|
||||||
|
|
||||||
|
A task is NOT considered complete if:
|
||||||
|
|
||||||
|
* tests fail
|
||||||
|
* analysis introduces new errors
|
||||||
|
* new warnings are introduced without justification
|
||||||
|
* the debug APK no longer builds when the task can affect compilation/runtime wiring
|
||||||
|
* existing regression datasets change unexpectedly
|
||||||
|
|
||||||
|
Pre-existing known warnings/info may remain if they are outside task scope, but they must be reported accurately.
|
||||||
|
|
||||||
|
Never claim physical motorcycle/device validation was performed unless it actually occurred.
|
||||||
|
|
||||||
## Git Safety
|
## Git Safety
|
||||||
|
|
||||||
Never reset, force-push, rebase, delete branches, delete tags, or rewrite
|
Never:
|
||||||
history unless explicitly requested.
|
|
||||||
|
|
||||||
Create a checkpoint commit before significant architectural changes.
|
* reset unrelated work
|
||||||
|
* force-push
|
||||||
|
* rewrite history
|
||||||
|
* rebase unless explicitly requested
|
||||||
|
* delete branches
|
||||||
|
* delete tags
|
||||||
|
* discard pre-existing user changes
|
||||||
|
* stage unrelated files
|
||||||
|
* commit unless explicitly authorized
|
||||||
|
|
||||||
Do not modify unrelated files.
|
Create or verify a checkpoint before significant architectural changes.
|
||||||
|
|
||||||
|
If the working tree is dirty before a task:
|
||||||
|
|
||||||
|
1. identify the pre-existing files
|
||||||
|
2. preserve them
|
||||||
|
3. do not include them in task changes
|
||||||
|
4. clearly separate them in the final report
|
||||||
|
|
||||||
|
Use per-command Git safe-directory overrides when necessary rather than changing global Git configuration unless explicitly requested.
|
||||||
|
|
||||||
|
## Dependency / Toolchain Safety
|
||||||
|
|
||||||
|
Do not perform broad dependency or toolchain upgrades to solve a narrow problem.
|
||||||
|
|
||||||
|
Prefer the smallest compatible dependency change.
|
||||||
|
|
||||||
|
Do not run broad package upgrades such as `flutter pub upgrade` unless explicitly required.
|
||||||
|
|
||||||
|
When changing dependencies:
|
||||||
|
|
||||||
|
* identify the exact reason
|
||||||
|
* inspect lockfile impact
|
||||||
|
* report exact version changes
|
||||||
|
* verify tests, analysis, and APK build
|
||||||
|
|
||||||
|
Do not raise Android compileSdk, targetSdk, AGP, Gradle, Kotlin, or Java versions merely to satisfy one package without first investigating a narrower compatible solution.
|
||||||
|
|
||||||
|
## Documentation Accuracy
|
||||||
|
|
||||||
|
Treat documentation as guidance, not proof of current production behavior.
|
||||||
|
|
||||||
|
When documentation conflicts with production code:
|
||||||
|
|
||||||
|
1. verify behavior from source/tests
|
||||||
|
2. preserve the correct production behavior
|
||||||
|
3. report stale documentation
|
||||||
|
4. update documentation only if it is within task scope
|
||||||
|
|
||||||
|
Do not silently rely on stale schema versions, timing values, provider wiring, or architecture descriptions.
|
||||||
|
|
||||||
## Agent Behavior
|
## Agent Behavior
|
||||||
|
|
||||||
|
|
@ -135,5 +290,12 @@ Prefer surgical patches over broad rewrites.
|
||||||
|
|
||||||
Do not "fix" unrelated problems discovered during a task.
|
Do not "fix" unrelated problems discovered during a task.
|
||||||
|
|
||||||
If an architectural conflict is discovered, stop and report it rather than
|
Do not silently expand scope.
|
||||||
silently changing a frozen subsystem.
|
|
||||||
|
Do not claim validation that was not actually performed.
|
||||||
|
|
||||||
|
Separate confirmed defects from suspected risks.
|
||||||
|
|
||||||
|
If an architectural conflict is discovered, stop and report it rather than silently changing a frozen subsystem.
|
||||||
|
|
||||||
|
When a task explicitly says STOP after reporting, do not proceed into the next stage.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'entitlement_state.dart';
|
import 'entitlement_state.dart';
|
||||||
|
import 'entitlement_storage.dart';
|
||||||
|
import 'feature_access_service.dart';
|
||||||
|
|
||||||
/// Temporary launch-safe entitlement provider. It defaults to Free until the
|
/// Provider for the physical entitlement storage contract.
|
||||||
/// Google Play Billing implementation is wired in. Keeping this boundary now
|
/// Can be overridden in tests with [InMemoryEntitlementStorage].
|
||||||
/// prevents future feature code from depending directly on billing SDK types.
|
final entitlementStorageProvider = Provider<EntitlementStorage>((ref) {
|
||||||
final entitlementProvider = Provider<EntitlementState>((ref) => const EntitlementState());
|
return const SecureEntitlementStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Authoritative entitlement state provider.
|
||||||
|
/// Defaults to [EntitlementState.free] until the billing coordinator or cache loader runs.
|
||||||
|
/// In Stage 4B-2, this will be wired to the billing lifecycle coordinator.
|
||||||
|
final entitlementProvider = StateProvider<EntitlementState>((ref) {
|
||||||
|
return const EntitlementState.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Centralized provider for checking feature accessibility.
|
||||||
|
/// Consumers watch this provider and call [FeatureAccessService.canAccess].
|
||||||
|
final featureAccessProvider = Provider<FeatureAccessService>((ref) {
|
||||||
|
final state = ref.watch(entitlementProvider);
|
||||||
|
return FeatureAccessService(state);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
enum MotorSignalPlan {
|
/// Supported commercial product plans for MotorSignal.
|
||||||
free,
|
enum MotorSignalPlan { free, proMonthly, proLifetime }
|
||||||
proMonthly,
|
|
||||||
proLifetime,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Lifecycle and billing entitlement states.
|
||||||
enum MotorSignalEntitlementStatus {
|
enum MotorSignalEntitlementStatus {
|
||||||
free,
|
free,
|
||||||
proMonthlyActive,
|
proMonthlyActive,
|
||||||
|
|
@ -17,23 +15,198 @@ enum MotorSignalEntitlementStatus {
|
||||||
syncPending,
|
syncPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Central capability model. UI and services should depend on capabilities,
|
/// Verification / freshness state of store-asserted entitlement.
|
||||||
/// not Google Play product IDs or scattered `isPro` checks.
|
enum EntitlementVerificationStatus {
|
||||||
|
/// Unverified or default state before store inquiry.
|
||||||
|
unverified,
|
||||||
|
|
||||||
|
/// Confirmed valid by app store or server verification.
|
||||||
|
verified,
|
||||||
|
|
||||||
|
/// Validated locally under defined offline grace rules.
|
||||||
|
offlineGrace,
|
||||||
|
|
||||||
|
/// Explicitly expired subscription.
|
||||||
|
expired,
|
||||||
|
|
||||||
|
/// Revoked or refunded by store/issuer.
|
||||||
|
revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origin of purchase assertion.
|
||||||
|
enum PurchaseSource { none, googlePlay, offlineCache }
|
||||||
|
|
||||||
|
/// Central capability model and immutable entitlement state.
|
||||||
|
/// UI and services depend on capabilities, not store product IDs or scattered checks.
|
||||||
@immutable
|
@immutable
|
||||||
class EntitlementState {
|
class EntitlementState {
|
||||||
final MotorSignalPlan plan;
|
final MotorSignalPlan plan;
|
||||||
final MotorSignalEntitlementStatus status;
|
final MotorSignalEntitlementStatus status;
|
||||||
|
final EntitlementVerificationStatus verificationStatus;
|
||||||
|
final PurchaseSource source;
|
||||||
|
final String? productId;
|
||||||
|
final String? purchaseTokenHash;
|
||||||
final DateTime? expiresAt;
|
final DateTime? expiresAt;
|
||||||
|
final DateTime? lastVerifiedAt;
|
||||||
|
|
||||||
const EntitlementState({
|
const EntitlementState({
|
||||||
this.plan = MotorSignalPlan.free,
|
this.plan = MotorSignalPlan.free,
|
||||||
this.status = MotorSignalEntitlementStatus.free,
|
this.status = MotorSignalEntitlementStatus.free,
|
||||||
|
this.verificationStatus = EntitlementVerificationStatus.unverified,
|
||||||
|
this.source = PurchaseSource.none,
|
||||||
|
this.productId,
|
||||||
|
this.purchaseTokenHash,
|
||||||
this.expiresAt,
|
this.expiresAt,
|
||||||
|
this.lastVerifiedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isPro => plan != MotorSignalPlan.free &&
|
/// Factory creating normalized default Free tier.
|
||||||
status != MotorSignalEntitlementStatus.proMonthlyExpired;
|
const EntitlementState.free()
|
||||||
|
: plan = MotorSignalPlan.free,
|
||||||
|
status = MotorSignalEntitlementStatus.free,
|
||||||
|
verificationStatus = EntitlementVerificationStatus.verified,
|
||||||
|
source = PurchaseSource.none,
|
||||||
|
productId = null,
|
||||||
|
purchaseTokenHash = null,
|
||||||
|
expiresAt = null,
|
||||||
|
lastVerifiedAt = null;
|
||||||
|
|
||||||
|
/// Factory creating normalized active Pro Monthly tier.
|
||||||
|
EntitlementState.proMonthlyActive({
|
||||||
|
this.productId,
|
||||||
|
this.purchaseTokenHash,
|
||||||
|
this.expiresAt,
|
||||||
|
DateTime? lastVerifiedAt,
|
||||||
|
this.source = PurchaseSource.googlePlay,
|
||||||
|
}) : plan = MotorSignalPlan.proMonthly,
|
||||||
|
status = MotorSignalEntitlementStatus.proMonthlyActive,
|
||||||
|
verificationStatus = EntitlementVerificationStatus.verified,
|
||||||
|
lastVerifiedAt = lastVerifiedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
/// Factory creating normalized active Pro Lifetime tier.
|
||||||
|
EntitlementState.proLifetime({
|
||||||
|
this.productId,
|
||||||
|
this.purchaseTokenHash,
|
||||||
|
DateTime? lastVerifiedAt,
|
||||||
|
this.source = PurchaseSource.googlePlay,
|
||||||
|
}) : plan = MotorSignalPlan.proLifetime,
|
||||||
|
status = MotorSignalEntitlementStatus.proLifetime,
|
||||||
|
verificationStatus = EntitlementVerificationStatus.verified,
|
||||||
|
expiresAt = null,
|
||||||
|
lastVerifiedAt = lastVerifiedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
/// Factory normalizing raw inputs to prevent invalid or contradictory combinations.
|
||||||
|
factory EntitlementState.normalized({
|
||||||
|
required MotorSignalPlan plan,
|
||||||
|
required MotorSignalEntitlementStatus status,
|
||||||
|
EntitlementVerificationStatus verificationStatus =
|
||||||
|
EntitlementVerificationStatus.unverified,
|
||||||
|
PurchaseSource source = PurchaseSource.none,
|
||||||
|
String? productId,
|
||||||
|
String? purchaseTokenHash,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
DateTime? lastVerifiedAt,
|
||||||
|
}) {
|
||||||
|
// Normalization rules:
|
||||||
|
// 1. Free plan must not possess Pro status, active expiration, or Pro product IDs.
|
||||||
|
if (plan == MotorSignalPlan.free) {
|
||||||
|
return EntitlementState(
|
||||||
|
plan: MotorSignalPlan.free,
|
||||||
|
status: MotorSignalEntitlementStatus.free,
|
||||||
|
verificationStatus:
|
||||||
|
verificationStatus == EntitlementVerificationStatus.revoked
|
||||||
|
? EntitlementVerificationStatus.revoked
|
||||||
|
: EntitlementVerificationStatus.verified,
|
||||||
|
source: PurchaseSource.none,
|
||||||
|
productId: null,
|
||||||
|
purchaseTokenHash: null,
|
||||||
|
expiresAt: null,
|
||||||
|
lastVerifiedAt: lastVerifiedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Revoked verification forces expired/revoked standing regardless of plan.
|
||||||
|
if (verificationStatus == EntitlementVerificationStatus.revoked) {
|
||||||
|
return EntitlementState(
|
||||||
|
plan: plan,
|
||||||
|
status: plan == MotorSignalPlan.proLifetime
|
||||||
|
? MotorSignalEntitlementStatus.unknown
|
||||||
|
: MotorSignalEntitlementStatus.proMonthlyExpired,
|
||||||
|
verificationStatus: EntitlementVerificationStatus.revoked,
|
||||||
|
source: source,
|
||||||
|
productId: productId,
|
||||||
|
purchaseTokenHash: purchaseTokenHash,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
lastVerifiedAt: lastVerifiedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lifetime plan normalization: status must be proLifetime, expiresAt is null.
|
||||||
|
if (plan == MotorSignalPlan.proLifetime) {
|
||||||
|
final isSyncPending = status == MotorSignalEntitlementStatus.syncPending;
|
||||||
|
return EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proLifetime,
|
||||||
|
status: isSyncPending
|
||||||
|
? MotorSignalEntitlementStatus.syncPending
|
||||||
|
: MotorSignalEntitlementStatus.proLifetime,
|
||||||
|
verificationStatus: verificationStatus,
|
||||||
|
source: source,
|
||||||
|
productId: productId,
|
||||||
|
purchaseTokenHash: purchaseTokenHash,
|
||||||
|
expiresAt: null,
|
||||||
|
lastVerifiedAt: lastVerifiedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Pro Monthly: check explicit expiration if past expiresAt.
|
||||||
|
final now = DateTime.now();
|
||||||
|
var resolvedStatus = status;
|
||||||
|
var resolvedVerification = verificationStatus;
|
||||||
|
|
||||||
|
if (expiresAt != null && now.isAfter(expiresAt)) {
|
||||||
|
if (status == MotorSignalEntitlementStatus.proMonthlyActive) {
|
||||||
|
// Expired active subscription
|
||||||
|
resolvedStatus = MotorSignalEntitlementStatus.proMonthlyExpired;
|
||||||
|
resolvedVerification = EntitlementVerificationStatus.expired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: resolvedStatus,
|
||||||
|
verificationStatus: resolvedVerification,
|
||||||
|
source: source,
|
||||||
|
productId: productId,
|
||||||
|
purchaseTokenHash: purchaseTokenHash,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
lastVerifiedAt: lastVerifiedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether user currently holds effective Pro entitlement.
|
||||||
|
/// Free plan, expired subscription, revoked verification, or account hold do NOT grant Pro access.
|
||||||
|
bool get isPro {
|
||||||
|
if (plan == MotorSignalPlan.free) return false;
|
||||||
|
if (verificationStatus == EntitlementVerificationStatus.revoked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (verificationStatus == EntitlementVerificationStatus.expired) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status == MotorSignalEntitlementStatus.proMonthlyExpired) return false;
|
||||||
|
if (status == MotorSignalEntitlementStatus.proMonthlyAccountHold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status == MotorSignalEntitlementStatus.free) return false;
|
||||||
|
if (status == MotorSignalEntitlementStatus.unknown) return false;
|
||||||
|
|
||||||
|
// Active or grace period allows effective access
|
||||||
|
return status == MotorSignalEntitlementStatus.proMonthlyActive ||
|
||||||
|
status == MotorSignalEntitlementStatus.proMonthlyGrace ||
|
||||||
|
status == MotorSignalEntitlementStatus.proLifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability mapping conforming to MOTORSIGNAL_FREE_PRO_SPEC.md
|
||||||
bool get canCloudBackup => true;
|
bool get canCloudBackup => true;
|
||||||
bool get canUnlimitedCloudHistory => isPro;
|
bool get canUnlimitedCloudHistory => isPro;
|
||||||
bool get canMultiDeviceRestore => isPro;
|
bool get canMultiDeviceRestore => isPro;
|
||||||
|
|
@ -50,17 +223,110 @@ class EntitlementState {
|
||||||
bool get canCreateGroupRides => isPro;
|
bool get canCreateGroupRides => isPro;
|
||||||
bool get canUseAdvancedGroupRideFeatures => isPro;
|
bool get canUseAdvancedGroupRideFeatures => isPro;
|
||||||
|
|
||||||
/// The Free cloud allowance is intentionally not hard-coded here. Product
|
/// Free cloud allowance quota. null indicates unconstrained (Pro) or to be determined by backend.
|
||||||
/// economics will define the exact quota before launch.
|
|
||||||
int? get cloudRideAllowance => isPro ? null : null;
|
int? get cloudRideAllowance => isPro ? null : null;
|
||||||
|
|
||||||
EntitlementState copyWith({
|
EntitlementState copyWith({
|
||||||
MotorSignalPlan? plan,
|
MotorSignalPlan? plan,
|
||||||
MotorSignalEntitlementStatus? status,
|
MotorSignalEntitlementStatus? status,
|
||||||
|
EntitlementVerificationStatus? verificationStatus,
|
||||||
|
PurchaseSource? source,
|
||||||
|
String? productId,
|
||||||
|
String? purchaseTokenHash,
|
||||||
DateTime? expiresAt,
|
DateTime? expiresAt,
|
||||||
|
DateTime? lastVerifiedAt,
|
||||||
}) => EntitlementState(
|
}) => EntitlementState(
|
||||||
plan: plan ?? this.plan,
|
plan: plan ?? this.plan,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
|
verificationStatus: verificationStatus ?? this.verificationStatus,
|
||||||
|
source: source ?? this.source,
|
||||||
|
productId: productId ?? this.productId,
|
||||||
|
purchaseTokenHash: purchaseTokenHash ?? this.purchaseTokenHash,
|
||||||
expiresAt: expiresAt ?? this.expiresAt,
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
lastVerifiedAt: lastVerifiedAt ?? this.lastVerifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'version': 1,
|
||||||
|
'plan': plan.name,
|
||||||
|
'status': status.name,
|
||||||
|
'verificationStatus': verificationStatus.name,
|
||||||
|
'source': source.name,
|
||||||
|
'productId': productId,
|
||||||
|
'purchaseTokenHash': purchaseTokenHash,
|
||||||
|
'expiresAt': expiresAt?.toIso8601String(),
|
||||||
|
'lastVerifiedAt': lastVerifiedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory EntitlementState.fromJson(Map<String, dynamic> json) {
|
||||||
|
final version = json['version'] as int? ?? 1;
|
||||||
|
if (version > 1) {
|
||||||
|
// Forward-compatibility safeguard for future cache schema versions
|
||||||
|
throw const FormatException('Unsupported entitlement cache version');
|
||||||
|
}
|
||||||
|
|
||||||
|
final plan = MotorSignalPlan.values.firstWhere(
|
||||||
|
(e) => e.name == json['plan'],
|
||||||
|
orElse: () => MotorSignalPlan.free,
|
||||||
|
);
|
||||||
|
|
||||||
|
final status = MotorSignalEntitlementStatus.values.firstWhere(
|
||||||
|
(e) => e.name == json['status'],
|
||||||
|
orElse: () => MotorSignalEntitlementStatus.free,
|
||||||
|
);
|
||||||
|
|
||||||
|
final verificationStatus = EntitlementVerificationStatus.values.firstWhere(
|
||||||
|
(e) => e.name == json['verificationStatus'],
|
||||||
|
orElse: () => EntitlementVerificationStatus.unverified,
|
||||||
|
);
|
||||||
|
|
||||||
|
final source = PurchaseSource.values.firstWhere(
|
||||||
|
(e) => e.name == json['source'],
|
||||||
|
orElse: () => PurchaseSource.none,
|
||||||
|
);
|
||||||
|
|
||||||
|
final expiresAt = json['expiresAt'] != null
|
||||||
|
? DateTime.tryParse(json['expiresAt'] as String)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final lastVerifiedAt = json['lastVerifiedAt'] != null
|
||||||
|
? DateTime.tryParse(json['lastVerifiedAt'] as String)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return EntitlementState.normalized(
|
||||||
|
plan: plan,
|
||||||
|
status: status,
|
||||||
|
verificationStatus: verificationStatus,
|
||||||
|
source: source,
|
||||||
|
productId: json['productId'] as String?,
|
||||||
|
purchaseTokenHash: json['purchaseTokenHash'] as String?,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
lastVerifiedAt: lastVerifiedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is EntitlementState &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
plan == other.plan &&
|
||||||
|
status == other.status &&
|
||||||
|
verificationStatus == other.verificationStatus &&
|
||||||
|
source == other.source &&
|
||||||
|
productId == other.productId &&
|
||||||
|
purchaseTokenHash == other.purchaseTokenHash &&
|
||||||
|
expiresAt == other.expiresAt &&
|
||||||
|
lastVerifiedAt == other.lastVerifiedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
plan.hashCode ^
|
||||||
|
status.hashCode ^
|
||||||
|
verificationStatus.hashCode ^
|
||||||
|
source.hashCode ^
|
||||||
|
productId.hashCode ^
|
||||||
|
purchaseTokenHash.hashCode ^
|
||||||
|
expiresAt.hashCode ^
|
||||||
|
lastVerifiedAt.hashCode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'entitlement_state.dart';
|
||||||
|
|
||||||
|
/// Contract defining persistence operations for cached entitlement state.
|
||||||
|
/// This abstraction isolates the billing coordinator and domain from physical storage.
|
||||||
|
abstract class EntitlementStorage {
|
||||||
|
/// Reads cached entitlement state, returning [EntitlementState.free] if none exists or invalid.
|
||||||
|
Future<EntitlementState> read();
|
||||||
|
|
||||||
|
/// Persists [state] to cache.
|
||||||
|
Future<void> write(EntitlementState state);
|
||||||
|
|
||||||
|
/// Clears cached entitlement, reverting cache to default.
|
||||||
|
Future<void> clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory implementation of [EntitlementStorage] for unit testing.
|
||||||
|
class InMemoryEntitlementStorage implements EntitlementStorage {
|
||||||
|
EntitlementState _state;
|
||||||
|
|
||||||
|
InMemoryEntitlementStorage([this._state = const EntitlementState.free()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<EntitlementState> read() async => _state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(EntitlementState state) async {
|
||||||
|
_state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
_state = const EntitlementState.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production implementation backed by [FlutterSecureStorage].
|
||||||
|
/// Stores normalized JSON with versioning. Never stores raw Google Play purchase tokens.
|
||||||
|
class SecureEntitlementStorage implements EntitlementStorage {
|
||||||
|
static const String storageKey = 'motorsignal_entitlement_v1';
|
||||||
|
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
const SecureEntitlementStorage({FlutterSecureStorage? storage})
|
||||||
|
: _storage = storage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<EntitlementState> read() async {
|
||||||
|
try {
|
||||||
|
final raw = await _storage.read(key: storageKey);
|
||||||
|
if (raw == null || raw.trim().isEmpty) {
|
||||||
|
return const EntitlementState.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
debugPrint('[EntitlementStorage] Cache payload not a valid JSON map');
|
||||||
|
return const EntitlementState.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntitlementState.fromJson(decoded);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'[EntitlementStorage] Error reading cached entitlement: $e\n$st',
|
||||||
|
);
|
||||||
|
return const EntitlementState.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(EntitlementState state) async {
|
||||||
|
try {
|
||||||
|
final payload = jsonEncode(state.toJson());
|
||||||
|
await _storage.write(key: storageKey, value: payload);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'[EntitlementStorage] Error writing cached entitlement: $e\n$st',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
try {
|
||||||
|
await _storage.delete(key: storageKey);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'[EntitlementStorage] Error clearing cached entitlement: $e\n$st',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'entitlement_state.dart';
|
||||||
|
|
||||||
|
/// Strongly typed identifiers for all capabilities that can be gated or regulated.
|
||||||
|
enum MotorSignalFeature {
|
||||||
|
cloudBackup,
|
||||||
|
unlimitedCloudHistory,
|
||||||
|
multiDeviceRestore,
|
||||||
|
advancedTelemetry,
|
||||||
|
advancedAcceleration,
|
||||||
|
advancedBraking,
|
||||||
|
advancedLean,
|
||||||
|
advancedAnalytics,
|
||||||
|
unlimitedHistory,
|
||||||
|
csvExport,
|
||||||
|
pdfExport,
|
||||||
|
useMultipleMotorcycles,
|
||||||
|
planTrips,
|
||||||
|
createGroupRides,
|
||||||
|
useAdvancedGroupRideFeatures,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centralized domain service determining access to MotorSignal features.
|
||||||
|
/// UI and background operations check [canAccess] rather than inspecting raw plan or store fields.
|
||||||
|
class FeatureAccessService {
|
||||||
|
final EntitlementState _state;
|
||||||
|
|
||||||
|
const FeatureAccessService(this._state);
|
||||||
|
|
||||||
|
/// Current underlying entitlement state.
|
||||||
|
EntitlementState get state => _state;
|
||||||
|
|
||||||
|
/// Returns true if the requested [feature] is permitted under current entitlement.
|
||||||
|
bool canAccess(MotorSignalFeature feature) {
|
||||||
|
switch (feature) {
|
||||||
|
case MotorSignalFeature.cloudBackup:
|
||||||
|
return _state.canCloudBackup;
|
||||||
|
case MotorSignalFeature.unlimitedCloudHistory:
|
||||||
|
return _state.canUnlimitedCloudHistory;
|
||||||
|
case MotorSignalFeature.multiDeviceRestore:
|
||||||
|
return _state.canMultiDeviceRestore;
|
||||||
|
case MotorSignalFeature.advancedTelemetry:
|
||||||
|
return _state.canAdvancedTelemetry;
|
||||||
|
case MotorSignalFeature.advancedAcceleration:
|
||||||
|
return _state.canAdvancedAcceleration;
|
||||||
|
case MotorSignalFeature.advancedBraking:
|
||||||
|
return _state.canAdvancedBraking;
|
||||||
|
case MotorSignalFeature.advancedLean:
|
||||||
|
return _state.canAdvancedLean;
|
||||||
|
case MotorSignalFeature.advancedAnalytics:
|
||||||
|
return _state.canAdvancedAnalytics;
|
||||||
|
case MotorSignalFeature.unlimitedHistory:
|
||||||
|
return _state.canUnlimitedHistory;
|
||||||
|
case MotorSignalFeature.csvExport:
|
||||||
|
return _state.canExportCsv;
|
||||||
|
case MotorSignalFeature.pdfExport:
|
||||||
|
return _state.canExportPdf;
|
||||||
|
case MotorSignalFeature.useMultipleMotorcycles:
|
||||||
|
return _state.canUseMultipleMotorcycles;
|
||||||
|
case MotorSignalFeature.planTrips:
|
||||||
|
return _state.canPlanTrips;
|
||||||
|
case MotorSignalFeature.createGroupRides:
|
||||||
|
return _state.canCreateGroupRides;
|
||||||
|
case MotorSignalFeature.useAdvancedGroupRideFeatures:
|
||||||
|
return _state.canUseAdvancedGroupRideFeatures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('EntitlementState domain rules', () {
|
||||||
|
test('default constructor creates free tier', () {
|
||||||
|
const state = EntitlementState();
|
||||||
|
expect(state.plan, MotorSignalPlan.free);
|
||||||
|
expect(state.status, MotorSignalEntitlementStatus.free);
|
||||||
|
expect(state.isPro, isFalse);
|
||||||
|
expect(state.canCloudBackup, isTrue);
|
||||||
|
expect(state.canUnlimitedCloudHistory, isFalse);
|
||||||
|
expect(state.canAdvancedTelemetry, isFalse);
|
||||||
|
expect(state.canExportCsv, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EntitlementState.free factory normalizes to valid free tier', () {
|
||||||
|
const state = EntitlementState.free();
|
||||||
|
expect(state.plan, MotorSignalPlan.free);
|
||||||
|
expect(state.status, MotorSignalEntitlementStatus.free);
|
||||||
|
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
|
||||||
|
expect(state.isPro, isFalse);
|
||||||
|
expect(state.productId, isNull);
|
||||||
|
expect(state.expiresAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pro Monthly active exposes complete premium capability surface', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final expiry = now.add(const Duration(days: 30));
|
||||||
|
final state = EntitlementState.proMonthlyActive(
|
||||||
|
productId: 'motorsignal_pro_monthly',
|
||||||
|
purchaseTokenHash: 'abc123hash',
|
||||||
|
expiresAt: expiry,
|
||||||
|
lastVerifiedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.isPro, isTrue);
|
||||||
|
expect(state.plan, MotorSignalPlan.proMonthly);
|
||||||
|
expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive);
|
||||||
|
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
|
||||||
|
expect(state.source, PurchaseSource.googlePlay);
|
||||||
|
expect(state.productId, 'motorsignal_pro_monthly');
|
||||||
|
expect(state.purchaseTokenHash, 'abc123hash');
|
||||||
|
expect(state.expiresAt, expiry);
|
||||||
|
|
||||||
|
// Verify all Pro capabilities
|
||||||
|
expect(state.canCloudBackup, isTrue);
|
||||||
|
expect(state.canUnlimitedCloudHistory, isTrue);
|
||||||
|
expect(state.canMultiDeviceRestore, isTrue);
|
||||||
|
expect(state.canAdvancedTelemetry, isTrue);
|
||||||
|
expect(state.canAdvancedAcceleration, isTrue);
|
||||||
|
expect(state.canAdvancedBraking, isTrue);
|
||||||
|
expect(state.canAdvancedLean, isTrue);
|
||||||
|
expect(state.canAdvancedAnalytics, isTrue);
|
||||||
|
expect(state.canUnlimitedHistory, isTrue);
|
||||||
|
expect(state.canExportCsv, isTrue);
|
||||||
|
expect(state.canExportPdf, isTrue);
|
||||||
|
expect(state.canUseMultipleMotorcycles, isTrue);
|
||||||
|
expect(state.canPlanTrips, isTrue);
|
||||||
|
expect(state.canCreateGroupRides, isTrue);
|
||||||
|
expect(state.canUseAdvancedGroupRideFeatures, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pro Monthly Grace provides effective premium access', () {
|
||||||
|
final state = EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyGrace,
|
||||||
|
verificationStatus: EntitlementVerificationStatus.offlineGrace,
|
||||||
|
source: PurchaseSource.offlineCache,
|
||||||
|
productId: 'motorsignal_pro_monthly',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.isPro, isTrue);
|
||||||
|
expect(state.canAdvancedTelemetry, isTrue);
|
||||||
|
expect(state.canExportCsv, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pro Monthly Expired denies premium access', () {
|
||||||
|
final state = EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyExpired,
|
||||||
|
verificationStatus: EntitlementVerificationStatus.expired,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.isPro, isFalse);
|
||||||
|
expect(state.canCloudBackup, isTrue);
|
||||||
|
expect(state.canAdvancedTelemetry, isFalse);
|
||||||
|
expect(state.canExportCsv, isFalse);
|
||||||
|
expect(state.canUnlimitedHistory, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pro Monthly Account Hold denies premium access', () {
|
||||||
|
final state = EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyAccountHold,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.isPro, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pro Lifetime exposes full capabilities without expiration', () {
|
||||||
|
final state = EntitlementState.proLifetime(
|
||||||
|
productId: 'motorsignal_pro_lifetime',
|
||||||
|
purchaseTokenHash: 'lifetime_hash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.isPro, isTrue);
|
||||||
|
expect(state.plan, MotorSignalPlan.proLifetime);
|
||||||
|
expect(state.status, MotorSignalEntitlementStatus.proLifetime);
|
||||||
|
expect(state.expiresAt, isNull);
|
||||||
|
expect(state.canUnlimitedCloudHistory, isTrue);
|
||||||
|
expect(state.canAdvancedLean, isTrue);
|
||||||
|
expect(state.canPlanTrips, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unknown or sync pending statuses do not grant Pro access', () {
|
||||||
|
const unknownState = EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.unknown,
|
||||||
|
);
|
||||||
|
expect(unknownState.isPro, isFalse);
|
||||||
|
|
||||||
|
const pendingState = EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.syncPending,
|
||||||
|
);
|
||||||
|
expect(pendingState.isPro, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('EntitlementState normalization & invalid state prevention', () {
|
||||||
|
test('normalizes contradictory free plan with pro status to pure free', () {
|
||||||
|
final normalized = EntitlementState.normalized(
|
||||||
|
plan: MotorSignalPlan.free,
|
||||||
|
status: MotorSignalEntitlementStatus.proLifetime,
|
||||||
|
productId: 'some_product',
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.plan, MotorSignalPlan.free);
|
||||||
|
expect(normalized.status, MotorSignalEntitlementStatus.free);
|
||||||
|
expect(normalized.isPro, isFalse);
|
||||||
|
expect(normalized.productId, isNull);
|
||||||
|
expect(normalized.expiresAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes revoked verification status to non-pro status', () {
|
||||||
|
final normalized = EntitlementState.normalized(
|
||||||
|
plan: MotorSignalPlan.proLifetime,
|
||||||
|
status: MotorSignalEntitlementStatus.proLifetime,
|
||||||
|
verificationStatus: EntitlementVerificationStatus.revoked,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalized.verificationStatus,
|
||||||
|
EntitlementVerificationStatus.revoked,
|
||||||
|
);
|
||||||
|
expect(normalized.isPro, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes expired subscription with past expiresAt date', () {
|
||||||
|
final pastDate = DateTime.now().subtract(const Duration(days: 2));
|
||||||
|
final normalized = EntitlementState.normalized(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyActive,
|
||||||
|
expiresAt: pastDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.status, MotorSignalEntitlementStatus.proMonthlyExpired);
|
||||||
|
expect(
|
||||||
|
normalized.verificationStatus,
|
||||||
|
EntitlementVerificationStatus.expired,
|
||||||
|
);
|
||||||
|
expect(normalized.isPro, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Lifetime plan ensuring null expiration', () {
|
||||||
|
final normalized = EntitlementState.normalized(
|
||||||
|
plan: MotorSignalPlan.proLifetime,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyActive,
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized.plan, MotorSignalPlan.proLifetime);
|
||||||
|
expect(normalized.status, MotorSignalEntitlementStatus.proLifetime);
|
||||||
|
expect(normalized.expiresAt, isNull);
|
||||||
|
expect(normalized.isPro, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FeatureAccessService exhaustive mapping', () {
|
||||||
|
test('every MotorSignalFeature enum entry maps to explicit capability', () {
|
||||||
|
const freeService = FeatureAccessService(EntitlementState.free());
|
||||||
|
final proService = FeatureAccessService(EntitlementState.proLifetime());
|
||||||
|
|
||||||
|
for (final feature in MotorSignalFeature.values) {
|
||||||
|
// Must execute cleanly without exception
|
||||||
|
final freeResult = freeService.canAccess(feature);
|
||||||
|
final proResult = proService.canAccess(feature);
|
||||||
|
|
||||||
|
if (feature == MotorSignalFeature.cloudBackup) {
|
||||||
|
expect(
|
||||||
|
freeResult,
|
||||||
|
isTrue,
|
||||||
|
reason: 'Cloud backup included in free tier',
|
||||||
|
);
|
||||||
|
expect(proResult, isTrue);
|
||||||
|
} else {
|
||||||
|
expect(
|
||||||
|
freeResult,
|
||||||
|
isFalse,
|
||||||
|
reason: '$feature should be denied on Free tier',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
proResult,
|
||||||
|
isTrue,
|
||||||
|
reason: '$feature should be granted on Pro tier',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Entitlement serialization & storage contract', () {
|
||||||
|
test('toJson and fromJson perform lossless round trip', () {
|
||||||
|
final original = EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyActive,
|
||||||
|
verificationStatus: EntitlementVerificationStatus.verified,
|
||||||
|
source: PurchaseSource.googlePlay,
|
||||||
|
productId: 'motorsignal_pro_monthly',
|
||||||
|
purchaseTokenHash: 'token_sha256_hash',
|
||||||
|
expiresAt: DateTime.utc(2026, 10, 1),
|
||||||
|
lastVerifiedAt: DateTime.utc(2026, 9, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = original.toJson();
|
||||||
|
final restored = EntitlementState.fromJson(json);
|
||||||
|
|
||||||
|
expect(restored.plan, original.plan);
|
||||||
|
expect(restored.status, original.status);
|
||||||
|
expect(restored.verificationStatus, original.verificationStatus);
|
||||||
|
expect(restored.source, original.source);
|
||||||
|
expect(restored.productId, original.productId);
|
||||||
|
expect(restored.purchaseTokenHash, original.purchaseTokenHash);
|
||||||
|
expect(restored.expiresAt, original.expiresAt);
|
||||||
|
expect(restored.lastVerifiedAt, original.lastVerifiedAt);
|
||||||
|
expect(restored.isPro, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromJson throws on unsupported future version', () {
|
||||||
|
final invalidVersionJson = {'version': 99, 'plan': 'proMonthly'};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => EntitlementState.fromJson(invalidVersionJson),
|
||||||
|
throwsA(isA<FormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('InMemoryEntitlementStorage behaves according to contract', () async {
|
||||||
|
final storage = InMemoryEntitlementStorage();
|
||||||
|
|
||||||
|
// Missing cache defaults to Free
|
||||||
|
final initial = await storage.read();
|
||||||
|
expect(initial.plan, MotorSignalPlan.free);
|
||||||
|
expect(initial.isPro, isFalse);
|
||||||
|
|
||||||
|
// Write Pro Lifetime
|
||||||
|
final proLifetime = EntitlementState.proLifetime(productId: 'lifetime');
|
||||||
|
await storage.write(proLifetime);
|
||||||
|
|
||||||
|
final cached = await storage.read();
|
||||||
|
expect(cached.plan, MotorSignalPlan.proLifetime);
|
||||||
|
expect(cached.isPro, isTrue);
|
||||||
|
|
||||||
|
// Clear reverts to Free
|
||||||
|
await storage.clear();
|
||||||
|
final cleared = await storage.read();
|
||||||
|
expect(cleared.plan, MotorSignalPlan.free);
|
||||||
|
expect(cleared.isPro, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Riverpod Provider integration', () {
|
||||||
|
test('featureAccessProvider reacts to entitlementProvider updates', () {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
entitlementStorageProvider.overrideWithValue(
|
||||||
|
InMemoryEntitlementStorage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
// Default is Free
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(featureAccessProvider)
|
||||||
|
.canAccess(MotorSignalFeature.csvExport),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update to Pro Lifetime
|
||||||
|
container.read(entitlementProvider.notifier).state =
|
||||||
|
EntitlementState.proLifetime();
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(featureAccessProvider)
|
||||||
|
.canAccess(MotorSignalFeature.csvExport),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Downgrade to Expired
|
||||||
|
container
|
||||||
|
.read(entitlementProvider.notifier)
|
||||||
|
.state = const EntitlementState(
|
||||||
|
plan: MotorSignalPlan.proMonthly,
|
||||||
|
status: MotorSignalEntitlementStatus.proMonthlyExpired,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(featureAccessProvider)
|
||||||
|
.canAccess(MotorSignalFeature.csvExport),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Architectural boundaries & Rider safety invariant', () {
|
||||||
|
test('entitlement domain requires zero Firebase or auth dependencies', () {
|
||||||
|
// Constructs, normalizes, serializes, queries access with no Firebase context
|
||||||
|
const state = EntitlementState.free();
|
||||||
|
const service = FeatureAccessService(state);
|
||||||
|
expect(service.canAccess(MotorSignalFeature.cloudBackup), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue