Skip to main content

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:

ConcettoStripeAppleGoogle
"Abbonamento attivo"status: activeDID_RENEWnotificationType: 2
"Pagamento fallito"status: past_dueDID_FAIL_TO_RENEWnotificationType: 5
"Cancellato"status: canceledDID_CHANGE_RENEWAL_STATUSnotificationType: 3

La state machine normalizza tutto in 5 stati comprensibili.


📊 Stati Normalizzati

Definizione Stati

StatoCodiceAccessoDescrizione
ACTIVEACTIVE✅ SìAbbonamento attivo e pagato
GRACE_PERIODGRACE_PERIOD✅ SìProblema pagamento, retry in corso (7-30 giorni)
PAST_DUEPAST_DUE❌ NoPagamento fallito definitivamente
CANCELEDCANCELED✅ Fino a scadenzaUtente ha cancellato, ma ha ancora accesso
EXPIREDEXPIRED❌ NoAbbonamento 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

EventoQuandoAzione
customer.subscription.createdNuova subscriptionCrea record ACTIVE
customer.subscription.updatedCambio statoAggiorna stato
customer.subscription.deletedEliminataStato EXPIRED
invoice.paidPagamento OKStato ACTIVE
invoice.payment_failedPagamento fallitoStato 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

SubtypeSignificatoStato
AUTO_RENEW_ENABLEDUtente riattiva rinnovoACTIVE
AUTO_RENEW_DISABLEDUtente disattiva rinnovoCANCELED
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

TypeNomeDescrizione
1SUBSCRIPTION_RECOVEREDRecuperato da hold/grace period
2SUBSCRIPTION_RENEWEDRinnovato con successo
3SUBSCRIPTION_CANCELEDCancellato dall'utente
4SUBSCRIPTION_PURCHASEDNuova subscription
5SUBSCRIPTION_ON_HOLDAccount bloccato per pagamento
6SUBSCRIPTION_IN_GRACE_PERIODGrace period attivo
7SUBSCRIPTION_RESTARTEDRiattivata dopo pausa
12SUBSCRIPTION_REVOKEDRevocata (chargeback/refund)
13SUBSCRIPTION_EXPIREDScaduta

💻 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