State Machine
La State Machine è il cuore del billing-service: converte gli stati/eventi specifici di ogni provider in 5 stati normalizzati.
🎯 Perché una State Machine?
Ogni provider usa terminologie e concetti diversi:
| Concetto | Stripe | Apple | |
|---|---|---|---|
| "Abbonamento attivo" | status: active | DID_RENEW | notificationType: 2 |
| "Pagamento fallito" | status: past_due | DID_FAIL_TO_RENEW | notificationType: 5 |
| "Cancellato" | status: canceled | DID_CHANGE_RENEWAL_STATUS | notificationType: 3 |
La state machine normalizza tutto in 5 stati comprensibili.
📊 Stati Normalizzati
Definizione Stati
| Stato | Codice | Accesso | Descrizione |
|---|---|---|---|
| ACTIVE | ACTIVE | ✅ Sì | Abbonamento attivo e pagato |
| GRACE_PERIOD | GRACE_PERIOD | ✅ Sì | Problema pagamento, retry in corso (7-30 giorni) |
| PAST_DUE | PAST_DUE | ❌ No | Pagamento fallito definitivamente |
| CANCELED | CANCELED | ✅ Fino a scadenza | Utente ha cancellato, ma ha ancora accesso |
| EXPIRED | EXPIRED | ❌ No | Abbonamento terminato |
🔀 Mappatura Stripe
Stripe usa il campo status nella subscription:
STRIPE_STATUS_MAP = {
"active": SubscriptionStatus.ACTIVE,
"trialing": SubscriptionStatus.ACTIVE,
"past_due": SubscriptionStatus.PAST_DUE,
"canceled": SubscriptionStatus.CANCELED,
"unpaid": SubscriptionStatus.CANCELED,
"incomplete": SubscriptionStatus.PAST_DUE,
"incomplete_expired": SubscriptionStatus.EXPIRED,
"paused": SubscriptionStatus.CANCELED,
}
Eventi Stripe Gestiti
| Evento | Quando | Azione |
|---|---|---|
customer.subscription.created | Nuova subscription | Crea record ACTIVE |
customer.subscription.updated | Cambio stato | Aggiorna stato |
customer.subscription.deleted | Eliminata | Stato EXPIRED |
invoice.paid | Pagamento OK | Stato ACTIVE |
invoice.payment_failed | Pagamento fallito | Stato PAST_DUE |
🍎 Mappatura Apple v2
Apple v2 usa notificationType e opzionalmente subtype:
APPLE_EVENT_MAP = {
# Active states
"SUBSCRIBED": SubscriptionStatus.ACTIVE,
"DID_RENEW": SubscriptionStatus.ACTIVE,
"OFFER_REDEEMED": SubscriptionStatus.ACTIVE,
"RENEWAL_EXTENDED": SubscriptionStatus.ACTIVE,
# Grace period
"GRACE_PERIOD_EXPIRED": SubscriptionStatus.GRACE_PERIOD,
# Past due
"DID_FAIL_TO_RENEW": SubscriptionStatus.PAST_DUE,
# Expired
"EXPIRED": SubscriptionStatus.EXPIRED,
"REVOKE": SubscriptionStatus.EXPIRED,
"REFUND": SubscriptionStatus.EXPIRED,
# Canceled (still has access)
"DID_CHANGE_RENEWAL_STATUS": SubscriptionStatus.CANCELED,
}
Subtypes per DID_CHANGE_RENEWAL_STATUS
| Subtype | Significato | Stato |
|---|---|---|
AUTO_RENEW_ENABLED | Utente riattiva rinnovo | ACTIVE |
AUTO_RENEW_DISABLED | Utente disattiva rinnovo | CANCELED |
APPLE_SUBTYPE_OVERRIDES = {
("DID_CHANGE_RENEWAL_STATUS", "AUTO_RENEW_ENABLED"): SubscriptionStatus.ACTIVE,
("DID_CHANGE_RENEWAL_STATUS", "AUTO_RENEW_DISABLED"): SubscriptionStatus.CANCELED,
}
🤖 Mappatura Google
Google usa notificationType (integer 1-13):
GOOGLE_NOTIFICATION_MAP = {
1: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_RECOVERED
2: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_RENEWED
3: SubscriptionStatus.CANCELED, # SUBSCRIPTION_CANCELED
4: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_PURCHASED
5: SubscriptionStatus.PAST_DUE, # SUBSCRIPTION_ON_HOLD
6: SubscriptionStatus.GRACE_PERIOD,# SUBSCRIPTION_IN_GRACE_PERIOD
7: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_RESTARTED
8: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_PRICE_CHANGE_CONFIRMED
9: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_DEFERRED
10: SubscriptionStatus.CANCELED, # SUBSCRIPTION_PAUSED
11: SubscriptionStatus.ACTIVE, # SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED
12: SubscriptionStatus.EXPIRED, # SUBSCRIPTION_REVOKED
13: SubscriptionStatus.EXPIRED, # SUBSCRIPTION_EXPIRED
}
Reference Notification Types
| Type | Nome | Descrizione |
|---|---|---|
| 1 | SUBSCRIPTION_RECOVERED | Recuperato da hold/grace period |
| 2 | SUBSCRIPTION_RENEWED | Rinnovato con successo |
| 3 | SUBSCRIPTION_CANCELED | Cancellato dall'utente |
| 4 | SUBSCRIPTION_PURCHASED | Nuova subscription |
| 5 | SUBSCRIPTION_ON_HOLD | Account bloccato per pagamento |
| 6 | SUBSCRIPTION_IN_GRACE_PERIOD | Grace period attivo |
| 7 | SUBSCRIPTION_RESTARTED | Riattivata dopo pausa |
| 12 | SUBSCRIPTION_REVOKED | Revocata (chargeback/refund) |
| 13 | SUBSCRIPTION_EXPIRED | Scaduta |
💻 Utilizzo nel Codice
from utils.state_machine import StateMachine, SubscriptionStatus
# Stripe
status = StateMachine.normalize("stripe", raw_status="active")
# → SubscriptionStatus.ACTIVE
# Apple
status = StateMachine.normalize(
"apple",
event_type="DID_CHANGE_RENEWAL_STATUS",
subtype="AUTO_RENEW_DISABLED"
)
# → SubscriptionStatus.CANCELED
# Google
status = StateMachine.normalize("google", notification_type=5)
# → SubscriptionStatus.PAST_DUE
⚠️ Gestione Stati Sconosciuti
Se arriva uno stato non mappato, la state machine logga un warning e restituisce EXPIRED come fallback sicuro:
@classmethod
def _normalize_stripe(cls, status: Optional[str]) -> SubscriptionStatus:
normalized = cls.STRIPE_STATUS_MAP.get(status.lower())
if normalized is None:
logger.warning(f"Unknown Stripe status: {status}, defaulting to EXPIRED")
return SubscriptionStatus.EXPIRED
return normalized
Questo garantisce che l'utente non abbia mai accesso ingiustificato.
🧪 Testing
# tests/test_state_machine.py
def test_stripe_active():
status = StateMachine.normalize("stripe", raw_status="active")
assert status == SubscriptionStatus.ACTIVE
def test_apple_did_renew():
status = StateMachine.normalize("apple", event_type="DID_RENEW")
assert status == SubscriptionStatus.ACTIVE
def test_google_on_hold():
status = StateMachine.normalize("google", notification_type=5)
assert status == SubscriptionStatus.PAST_DUE