Database Models
Il billing-service utilizza un database PostgreSQL dedicato (billing_db) con due tabelle principali.
📊 Schema ER​
Tabella: subscriptions​
Memorizza lo stato corrente di ogni abbonamento.
Campi​
| Campo | Tipo | Null | Descrizione |
|---|---|---|---|
id | SERIAL | No | Chiave primaria |
user_id | INTEGER | No | ID utente (riferimento esterno) |
provider | VARCHAR(20) | No | stripe, apple, google |
provider_subscription_id | VARCHAR(255) | No | ID subscription del provider |
provider_customer_id | VARCHAR(255) | Sì | Customer ID (Stripe) o originalTransactionId (Apple) |
plan_id | VARCHAR(100) | No | ID del piano/prodotto |
plan_name | VARCHAR(255) | Sì | Nome descrittivo del piano |
status | VARCHAR(20) | No | Stato normalizzato |
raw_status | VARCHAR(100) | Sì | Stato originale del provider |
started_at | TIMESTAMP | Sì | Data inizio abbonamento |
current_period_start | TIMESTAMP | Sì | Inizio periodo corrente |
current_period_end | TIMESTAMP | Sì | Fine periodo corrente |
canceled_at | TIMESTAMP | Sì | Data cancellazione |
created_at | TIMESTAMP | No | Data creazione record |
updated_at | TIMESTAMP | No | Ultimo aggiornamento |
Indici​
| Nome | Campi | Tipo |
|---|---|---|
idx_subscriptions_user_id | user_id | B-tree |
idx_subscriptions_provider_id | provider, provider_subscription_id | Unique |
Esempio Query​
-- Ottieni abbonamento attivo per utente
SELECT * FROM subscriptions
WHERE user_id = 123
AND status IN ('ACTIVE', 'GRACE_PERIOD')
ORDER BY current_period_end DESC
LIMIT 1;
Tabella: subscription_transactions​
Audit log di tutti gli eventi webhook processati. Serve anche per idempotenza.
Campi​
| Campo | Tipo | Null | Descrizione |
|---|---|---|---|
id | SERIAL | No | Chiave primaria |
subscription_id | INTEGER | Sì | FK a subscriptions |
provider | VARCHAR(20) | No | Provider dell'evento |
event_type | VARCHAR(100) | No | Tipo di evento |
event_id | VARCHAR(255) | No | ID univoco evento (dal provider) |
old_status | VARCHAR(20) | Sì | Stato prima dell'evento |
new_status | VARCHAR(20) | Sì | Stato dopo l'evento |
raw_event | JSONB | Sì | Payload completo per debug |
event_timestamp | TIMESTAMP | Sì | Quando è avvenuto l'evento |
processed_at | TIMESTAMP | No | Quando è stato processato |
Indici​
| Nome | Campi | Tipo |
|---|---|---|
idx_transactions_idempotency | provider, event_id | Unique |
idx_transactions_subscription | subscription_id | B-tree |
Come funziona l'idempotenza​
- Arriva un webhook con
event_id = "evt_123" - Prima di processare, controlliamo se esiste già in
subscription_transactions - Se esiste → restituiamo 200 OK senza ri-processare
- Se non esiste → processiamo e inseriamo il record
# utils/idempotency.py
def ensure_idempotent(db, provider, event_id):
existing = db.query(SubscriptionTransaction).filter(
SubscriptionTransaction.provider == provider,
SubscriptionTransaction.event_id == event_id
).first()
if existing:
raise DuplicateEventError(event_id, provider)
SQLAlchemy Models​
from sqlalchemy import String, Integer, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class Subscription(Base):
__tablename__ = 'subscriptions'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(20), nullable=False)
provider_subscription_id: Mapped[str] = mapped_column(String(255), nullable=False)
provider_customer_id: Mapped[str | None] = mapped_column(String(255))
plan_id: Mapped[str] = mapped_column(String(100), nullable=False)
plan_name: Mapped[str | None] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(20), nullable=False)
raw_status: Mapped[str | None] = mapped_column(String(100))
# ... timestamps
__table_args__ = (
Index('idx_subscriptions_provider_id', 'provider', 'provider_subscription_id', unique=True),
)
@property
def is_active(self) -> bool:
return self.status in ['ACTIVE', 'GRACE_PERIOD']
class SubscriptionTransaction(Base):
__tablename__ = 'subscription_transactions'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
subscription_id: Mapped[int | None] = mapped_column(ForeignKey('subscriptions.id'))
provider: Mapped[str] = mapped_column(String(20), nullable=False)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
event_id: Mapped[str] = mapped_column(String(255), nullable=False)
old_status: Mapped[str | None] = mapped_column(String(20))
new_status: Mapped[str | None] = mapped_column(String(20))
raw_event: Mapped[dict | None] = mapped_column(JSONB)
__table_args__ = (
Index('idx_transactions_idempotency', 'provider', 'event_id', unique=True),
)
Migrazione Iniziale​
Le tabelle vengono create automaticamente all'avvio del servizio:
# app.py
from db import Base, engine
Base.metadata.create_all(bind=engine)
Per ambienti di produzione, considera l'uso di Alembic per migration controllate.