Tryb tylko do odczytu zamiast blokady konta w SaaS
Wygasła subskrypcja w BusiKM odcina zapis, a nie dostęp do danych. Jak działa tryb tylko do odczytu w Django i ile kosztuje lista wyjątków.
Opublikowano: 7 min czytania
Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Spis treści
Gdy firmie w BusiKM kończy się subskrypcja, system przechodzi w tryb tylko do odczytu. Właściciel, księgowa i kierowcy nadal logują się, przeglądają trasy i eksportują dane, ale nie mogą niczego dodać ani zmienić. Wybrałem ten wariant zamiast typowej blokady konta, bo w tej branży dane po wygaśnięciu abonamentu dalej są klientowi potrzebne, i to nie z wygody.
Dlaczego nie zwykła blokada konta
Najprostsza wersja paywalla wygląda tak, że po wygaśnięciu subskrypcji użytkownik po zalogowaniu widzi tylko ekran płatności. W większości aplikacji to wystarcza. W BusiKM jest inaczej, bo ewidencja przebiegu pojazdu jest dokumentem księgowym, który firma musi przechowywać przez pięć lat. Zapisałem to w dokumentacji modelu biznesowego jako zasadę: odcięcie dostępu do własnej ewidencji za nieopłaconą fakturę byłoby dla klienta problemem wobec urzędu skarbowego, a nie tylko niedogodnością. Do tego dane dotyczą okresów, za które klient zapłacił.
Z tego wyszła jedna reguła dla całego systemu: wygasła subskrypcja odcina zapis, a odczyt i eksport zostają. To samo zachowanie dostało konto demo, które ma działać tylko do odczytu na stałe.
Jedna bramka zamiast sprawdzania w każdym widoku
Backend BusiKM to Django z DRF i ponad 30 aplikacji z własnymi widokami. Gdybym sprawdzał subskrypcję w każdym widoku zapisu osobno, każdy nowy endpoint musiałby pamiętać o dekoratorze, a pierwszy zapomniany stałby się dziurą. Dlatego blokada siedzi w jednym middleware, SubscriptionWriteGateMiddleware, który widzi każde żądanie do API. Kryterium jest metoda HTTP: GET przechodzi zawsze, a POST, PUT, PATCH i DELETE tylko wtedy, gdy firma ma ważną subskrypcję albo ścieżka jest na liście wyjątków.

class SubscriptionWriteGateMiddleware:
WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# Paths that must work exactly when the subscription has expired:
# log in, pay, take your own data out.
ALWAYS_ALLOWED_PREFIXES = (
"/api/v1/accounts/auth/",
"/api/v1/billing/",
"/api/v1/users/me/data-export/", # GDPR export
"/api/v1/integrations/exports/", # accounting exports
"/api/v1/documents/reports/", # PDF reports
"/api/v1/reports/", # POST, but read-only
# ...
)
def _blocked_reason(self, request):
if request.method not in self.WRITE_METHODS:
return None
if request.path.startswith(self.ALWAYS_ALLOWED_PREFIXES):
return None
user = self._resolve_user(request) # reads the JWT itself
if user is None or user.is_superuser:
return None
company = getattr(user, "company", None)
if company is None:
return None
if company.is_demo:
return _read_only_response("demo", "...")
subscription = getattr(company, "subscription", None)
if subscription is None or not subscription.is_valid:
return _read_only_response(subscription.plan if subscription else "no_subscription", "...")
return NoneLista wyjątków jest najważniejszą częścią tego kodu. Bez logowania, płatności i eksportu tryb tylko do odczytu byłby pułapką bez wyjścia: klient nie mógłby nawet zapłacić, żeby wrócić do normalnej pracy. Endpointy płatności muszą działać właśnie wtedy, gdy subskrypcja wygasła.
Przy wdrożeniu wyszedł szczegół, o którym łatwo zapomnieć. DRF uwierzytelnia token JWT dopiero w widoku, więc w middleware request.user jest jeszcze anonimowy. Bramka, która ufa request.user, nie zablokowałaby niczego, bo każde żądanie z aplikacji wyglądałoby dla niej na anonimowe. Dlatego _resolve_user sam odczytuje token przez JWTAuthentication. Jeśli token jest nieprawidłowy, bramka nic nie robi, a o dostępie rozstrzyga zwykłe uwierzytelnianie w widoku.
Odpowiedź przy blokadzie to 402 z polem feature: "write_access". Ten sam kod 402 zwracają też bramki planu dla pojedynczych funkcji, na przykład eksportu do systemu księgowego w niższym planie. Pole feature pozwala klientom odróżnić „ta funkcja nie jest w Twoim planie” od „cała firma jest w trybie tylko do odczytu”.
Test, który sprawdza, czy chronione ścieżki w ogóle istnieją
Lista prefiksów w middleware ma słabe miejsce: literówka w prefiksie nie powoduje żadnego błędu. Tak było w poprzednim middleware do bramkowania funkcji. Przez cały czas pilnował trzech tras, których nie było w config/urls.py. Testy przechodziły, bo middleware odpowiadał przed routingiem, więc sprawdzały same siebie, a nie to, czy chroniona ścieżka istnieje. Ten sam problem był w zwolnieniach z limitu zapytań: /api/v1/health/ nie istniało (prawdziwa ścieżka to /api/v1/monitoring/health/), a /metrics/ z ukośnikiem na końcu nie pasowało do /metrics. Health check i metryki podlegały więc limitowi, choć miały być z niego wyjęte.
Teraz osobny test przechodzi po wszystkich trasach zarejestrowanych w resolverze Django i sprawdza, czy każdy prefiks z listy wyjątków prowadzi do istniejącej trasy. Literówka w prefiksie oznacza albo dziurę w bramce, albo klienta, który nie może zapłacić, i oba przypadki test wyłapie w CI.
Dwie bramki, które się nie zgadzały
7 października poprawiłem błąd, który dobrze pokazuje koszt warstwowego sprawdzania. Middleware przepuszczał eksport do systemu księgowego i raporty PDF, bo ich ścieżki są na liście wyjątków. Te same widoki mają jednak dodatkowo dekorator plan_gate, który sprawdza, czy plan firmy zawiera daną funkcję. I ten dekorator przy wygasłej subskrypcji zwracał 402 dla wszystkiego. Pierwsza bramka mówiła „wolno”, druga „nie wolno”, a klient po wygaśnięciu abonamentu nie mógł wyjąć swojej ewidencji, czyli dokładnie tego, czemu cały tryb tylko do odczytu miał zapobiec.
Drugi przypadek był prostszy. KPiR i zestawienie kosztów idą metodą POST, bo parametry okresu są w ciele żądania, ale niczego nie zapisują. Ich prefiksu /api/v1/reports/ nie było na liście, więc middleware blokował je jako zapis.
# Features that only take the customer's own data out.
# An expired subscription does not close them.
DATA_RETRIEVAL_FEATURES = frozenset({"fk_export", "pdf_reports"})
def check_plan_or_402(request, *, feature, min_plan, require_feature_flag=True):
_, sub = _resolve_subscription(request)
if sub is None:
return plan_gate_response(feature=feature, min_plan=min_plan, current_plan="no_subscription")
if sub.is_expired and feature not in DATA_RETRIEVAL_FEATURES:
return plan_gate_response(feature=feature, min_plan=min_plan, current_plan="expired")
if not _plan_allows(sub.plan, min_plan):
return plan_gate_response(feature=feature, min_plan=min_plan, current_plan=sub.plan)
# ...Ważne jest, czego ta poprawka nie zmienia. Ranga planu i flaga funkcji nadal obowiązują, więc firma, która nigdy nie miała planu z eksportem do systemu księgowego, nie dostanie go przez samo wygaśnięcie subskrypcji. Wygaśnięcie przestaje tylko odbierać to, co klient już miał.
Web i aplikacja kierowcy reagują inaczej
Backend jest źródłem prawdy, ale użytkownik nie powinien dowiadywać się o blokadzie z błędu po kliknięciu „Zapisz”. Obaj klienci reagują na nią, każdy w inny sposób.

Panel web wie z góry, że subskrypcja wygasła, bo czyta jej status z API. Pokazuje trwały baner z informacją, że dane są dostępne do przeglądania i eksportu, a właściciel ma przycisk wznowienia subskrypcji. Przyciski „Dodaj”, „Nowe” i „Zaproś” na listach są nieaktywne z podpowiedzią, zamiast prowadzić do formularza, którego nie da się zapisać. Interceptor axios ma kopię listy wyjątków z backendu i zapisu, który backend by odrzucił, w ogóle nie wysyła. Zwraca 402 o tym samym kształcie, więc reszta aplikacji obsługuje go tak samo jak odpowiedź z serwera.
Aplikacja kierowcy działa offline i ma własne kolejki: statusy zleceń, zdjęcia licznika, wydatki, wiadomości. Tu kopii listy nie ma. Pierwsza odpowiedź 402 z write_access zamraża wysyłkę wszystkich kolejek. Wpisy zostają w telefonie ze statusem „do wysłania”, a licznik prób się cofa, bo blokada subskrypcji nie jest nieudaną próbą. Gdyby się nie cofał, po kilku próbach wpis trafiłby do nieudanych i kierowca musiałby go ponawiać ręcznie.
// API client interceptor: a 402 on a write freezes the queues,
// any other response to a write (the first one after access returns) unfreezes them.
export const observeWriteOutcome = (method: string | undefined, error?: unknown): void => {
if (!isWriteMethod(method)) return;
if (error !== undefined && isWriteBlockedError(error)) {
freezeWrites(blockReasonOf(error));
return;
}
if (error === undefined || httpStatusOf(error) !== undefined) unfreezeWrites();
};Przy zamrożonych kolejkach pojawił się problem, który wyszedł dopiero w kodzie. Odmrożenie miało następować po pierwszej udanej odpowiedzi na zapis, ale przy blokadzie kolejki nic nie wysyłają, więc takiej odpowiedzi nigdy nie będzie. Dlatego aplikacja co pięć minut czyta profil firmy (odczyt działa) i sama odmraża kolejki, gdy subskrypcja znów jest ważna. Wtedy wszystko, co kierowca zapisał w czasie blokady, wychodzi do serwera bez jego udziału.
Ile to kosztuje
Głównym kosztem jest lista wyjątków, która żyje w dwóch miejscach: w middleware i w interceptorze panelu web. Kopia w webie daje lepszy UX, bo przyciski są wyłączone, zanim użytkownik coś kliknie. Ale kopia może się rozjechać, i już się rozjechała. Ta sama poprawka z 7 października dodała /api/v1/reports/ w backendzie, a w webie tego prefiksu jeszcze nie ma. W trybie tylko do odczytu panel blokuje więc u siebie raporty, które backend już przepuszcza. Poprawka jest prosta, ale pokazuje, że test rozwiązywalności ścieżek pilnuje tylko backendu.
Drugi koszt to niepełne wyłączenie akcji w webie. Przyciski dodawania są nieaktywne, ale edycja, usuwanie i akceptacje nadal są klikalne i blokuje je dopiero zapis. Mam to w backlogu jako osobne zadanie.
Aplikacja kierowcy pokazuje drugą drogę: zero kopii, tylko reakcja na 402. Jest prostsza w utrzymaniu, ale użytkownik dowiaduje się o blokadzie dopiero przy pierwszej próbie zapisu. Nie mam jeszcze odpowiedzi, która droga jest lepsza dla panelu web. Najbardziej sensowne wydaje mi się, żeby backend sam wystawiał listę wyjątków w odpowiedzi ze statusem subskrypcji, a front tylko z niej korzystał. Wtedy źródło prawdy zostaje jedno.
Jeśli budujesz SaaS, w którym klient przechowuje u Ciebie dokumenty z obowiązkiem archiwizacji, zacznij od pytania, co klient musi móc zrobić, kiedy przestanie płacić. U mnie odpowiedź zmieniła paywall w bramkę zapisu. Chętnie porozmawiam o podobnych decyzjach w Twoim projekcie, napisz do mnie.
