MotorSignal-App/lib/services/entitlements/entitlement_state.dart

333 lines
11 KiB
Dart

import 'package:flutter/foundation.dart';
/// Supported commercial product plans for MotorSignal.
enum MotorSignalPlan { free, proMonthly, proLifetime }
/// Lifecycle and billing entitlement states.
enum MotorSignalEntitlementStatus {
free,
proMonthlyActive,
proMonthlyGrace,
proMonthlyAccountHold,
proMonthlyExpired,
proLifetime,
unknown,
syncPending,
}
/// Verification / freshness state of store-asserted entitlement.
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
class EntitlementState {
final MotorSignalPlan plan;
final MotorSignalEntitlementStatus status;
final EntitlementVerificationStatus verificationStatus;
final PurchaseSource source;
final String? productId;
final String? purchaseTokenHash;
final DateTime? expiresAt;
final DateTime? lastVerifiedAt;
const EntitlementState({
this.plan = MotorSignalPlan.free,
this.status = MotorSignalEntitlementStatus.free,
this.verificationStatus = EntitlementVerificationStatus.unverified,
this.source = PurchaseSource.none,
this.productId,
this.purchaseTokenHash,
this.expiresAt,
this.lastVerifiedAt,
});
/// Factory creating normalized default Free tier.
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 canUnlimitedCloudHistory => isPro;
bool get canMultiDeviceRestore => isPro;
bool get canAdvancedTelemetry => isPro;
bool get canAdvancedAcceleration => isPro;
bool get canAdvancedBraking => isPro;
bool get canAdvancedLean => isPro;
bool get canAdvancedAnalytics => isPro;
bool get canUnlimitedHistory => isPro;
bool get canExportCsv => isPro;
bool get canExportPdf => isPro;
bool get canUseMultipleMotorcycles => isPro;
bool get canPlanTrips => isPro;
bool get canCreateGroupRides => isPro;
bool get canUseAdvancedGroupRideFeatures => isPro;
/// Free cloud allowance quota. null indicates unconstrained (Pro) or to be determined by backend.
int? get cloudRideAllowance => isPro ? null : null;
EntitlementState copyWith({
MotorSignalPlan? plan,
MotorSignalEntitlementStatus? status,
EntitlementVerificationStatus? verificationStatus,
PurchaseSource? source,
String? productId,
String? purchaseTokenHash,
DateTime? expiresAt,
DateTime? lastVerifiedAt,
}) => EntitlementState(
plan: plan ?? this.plan,
status: status ?? this.status,
verificationStatus: verificationStatus ?? this.verificationStatus,
source: source ?? this.source,
productId: productId ?? this.productId,
purchaseTokenHash: purchaseTokenHash ?? this.purchaseTokenHash,
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;
}