Billing & Entitlement
Part of: Pickatale Master Build Wiki | Version: v1.7 | Last Updated: 2026-04-18
29. Billing and Entitlement β Full Contracts
Source: (D) Sig direct instruction | Status: Confirmed as spec. All billing tables are MUST BUILD.
29.1 Subscription Types β Complete Definition
| Type | tier value |
state |
Who owns billing | Duration | Features |
|---|---|---|---|---|---|
| No subscription | free |
none |
β | Indefinite | Free tier only (50 books, no bot, no reports) |
| Trial | trial |
trialing |
Self | 14 days from registration | Full access |
| Teacher paid | teacher_paid |
active |
Teacher (teacher_id) β 1 license covers all their classes | Monthly or yearly (Stripe) | Full access for all students in teacher's classes (33 per class) |
| Sponsored / gifted | gifted |
active |
Platform admin | Defined expiry | Full access |
| Payment failed (grace) | teacher_paid |
past_due |
Same as active | 7-day grace period | Full access during grace |
| Cancelled (still active) | any | cancelled |
Same | Until current_period_end |
Full access until period end |
| Expired | any | expired |
β | β | Free tier only |
| Locked for non-payment | any | past_due after grace |
β | β | Free tier only (same as expired) |
29.2 Feature Access Rules by Tier
| Feature | Free | Full |
|---|---|---|
| Library β first 50 books | β | β |
| Library β all 9,089 books | β | β |
| Placement test | β | β |
| Comprehension quiz | β | β |
| Finger-Follow TTS | β | β |
| Karaoke TTS | β | β |
| Speedread RSVP | β | β (age/FK gate too) |
| Colour overlay / font choice | β | β |
| Miles tracking | β | β |
| Token unlock | β | β |
| Adaptive recommendations (Learner Bot) | β | β |
| Teacher: AI reports | β | β |
| Teacher: Intervention tools | β | β |
| Teacher: Attention list | β | β |
| Teacher: Assign books | β | β |
| Parent: Weekly digest | β | β |
| Parent: Progress view | β (basic) | β (full) |
| Fluency assessment | β | β |
| Nightly Learner Bot cycle | β | β |
29.3 checkEntitlement() β The Entitlement Middleware
Every protected endpoint must call this before serving content.
Function signature:
checkEntitlement(context: { user_id?: number, school_id?: number, student_id?: number }):
Promise<{ allowed: boolean, tier: 'free' | 'full', expires_at: Date | null }>
Resolution order (first match wins):
- Check
entitlement_grantsWHERE (user_id OR school_id) AND expires_at > now(). If found β{tier: 'full'}. - Check
subscriptionsWHERE user_id OR school_id. Match on state:trialingAND trial_ends_at > now() βfullactiveAND current_period_end > now() βfullcancelledAND current_period_end > now() βfullpast_dueAND grace_ends_at > now() βfull- anything else β
free
- Default β
{tier: 'free'}
For child accounts: resolve via students.school_id first, then students.parent_id (whichever returns full wins).
This function must be called server-side. Client-side entitlement hints are cosmetic only.
29.4 Entitlement API Endpoint
Endpoint: GET /api/billing/entitlement
Auth: X-Internal-Key (service-to-service) OR uc_session (self-check)
Query: ?user_id=INT OR ?school_id=INT OR ?student_id=INT
Output:
{
"tier": "full",
"state": "trialing",
"trial_ends_at": "2026-05-02T00:00:00Z",
"current_period_end": null,
"grace_ends_at": null,
"features": ["full_library", "learner_bot", "reports", "interventions", "fluency"]
}
29.5 Subscription Lifecycle β State Transitions
none β trialing (registration)
trialing β active (Stripe payment confirmed, or trial auto-convert)
trialing β expired (trial_ends_at passed, no payment)
active β past_due (Stripe payment_failed webhook)
active β cancelled (user cancels via portal)
past_due β active (payment succeeds within grace period)
past_due β expired (grace_ends_at passed)
cancelled β expired (current_period_end passed)
expired β active (renews via Stripe)
any β active (admin grants entitlement_grant)
29.6 Stripe Webhook Handler
Endpoint: POST /api/billing/webhook
Auth: Stripe-Signature header (Stripe secret verification β NOT uc_session)
Events handled:
| Stripe Event | Action |
|---|---|
checkout.session.completed |
UPDATE subscriptions.state='active', set stripe_subscription_id, stripe_customer_id, current_period_start/end |
invoice.payment_succeeded |
UPDATE subscriptions.state='active', update current_period_end, INSERT payments (amount, status='succeeded') |
invoice.payment_failed |
UPDATE subscriptions.state='past_due', set grace_ends_at=now()+7d. Send RENEWAL_FAILED email. INSERT payments (status='failed') |
customer.subscription.deleted |
UPDATE subscriptions.state='cancelled', set cancelled_at=now() |
customer.subscription.updated |
UPDATE current_period_start/end, tier if plan changed |
Audit Log: INSERT audit_log (action='stripe_webhook', metadata={event_type, subscription_id})
Response: Always 200 (Stripe requires this even for unhandled events)
29.7 What Happens When Subscription Expires
Trigger: Cron job runs every hour. Query: subscriptions WHERE state='past_due' AND grace_ends_at < now()
Actions:
- UPDATE
subscriptions.state='expired'. - For all students in teacher's classes: UPDATE
students.entitlement_tier='free'. - Learner Bot: mark learners inactive (
skip_nightly=true). Stop nightly cycle. - Teacher Portal: add banner "Subscription expired. Renew to restore access."
- Last cached reports remain visible (read-only).
- Send SUBSCRIPTION_EXPIRED email to teacher (action: teacher.email, metadata: {teacher_id, tier}).
DB Writes: subscriptions, students, email_log
Audit Log: INSERT audit_log (action='subscription_expired', metadata={teacher_id, tier})
29.8 Billing Tables β Full Schema
Table: subscriptions
CREATE TABLE subscriptions (
id INT PRIMARY KEY AUTO_INCREMENT,
teacher_id INT NOT NULL, -- teacher who holds this license (FK users)
school_id INT NOT NULL, -- school the teacher belongs to (FK schools)
state ENUM('none','trialing','active','past_due','cancelled','expired') NOT NULL DEFAULT 'none',
tier ENUM('free','trial','teacher_paid','enterprise','gifted') NOT NULL DEFAULT 'free', -- canonical v1 tiers
trial_ends_at DATETIME NULL,
current_period_start DATETIME NULL,
current_period_end DATETIME NULL,
grace_ends_at DATETIME NULL,
cancelled_at DATETIME NULL,
stripe_subscription_id VARCHAR(255) NULL,
stripe_customer_id VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW(),
INDEX(user_id), INDEX(school_id), INDEX(state)
);
Table: entitlement_grants
CREATE TABLE entitlement_grants (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NULL,
school_id INT NULL,
granted_by INT NOT NULL, -- platform admin user_id
tier ENUM('teacher_paid','enterprise','gifted') NOT NULL, -- external entitlements only; never free/trial
expires_at DATETIME NULL, -- NULL = permanent
reason VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT NOW()
);
Table: payments
CREATE TABLE payments (
id INT PRIMARY KEY AUTO_INCREMENT,
subscription_id INT NOT NULL,
amount_cents INT NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
status ENUM('succeeded','failed','refunded') NOT NULL,
stripe_payment_intent_id VARCHAR(255),
created_at DATETIME NOT NULL DEFAULT NOW()
);
40. Entitlement Execution Model
Source: (D) | Status: Confirmed as spec
40.2 Caching Strategy
- In-process cache only (no Redis required for v1). Use a simple Map with TTL.
- TTL: 60 seconds per key. Acceptable staleness β no billing decision is instant.
- Cache invalidation: On Stripe webhook processed (state change) β Account Center emits to all services via internal endpoint
POST /api/internal/invalidate-entitlement(X-Internal-Key, fire-and-forget). - Cache key format:
ent:user_{user_id}ORent:school_{school_id}ORent:student_{student_id}
40.3 Failure Fallback
| Failure condition | Fallback | Rationale |
|---|---|---|
| Account Center timeout (>2s) | tier: 'free' |
Fail-open β child can still read 50 free books |
| Account Center returns 5xx | tier: 'free' |
Same |
| Network error | tier: 'free' |
Same |
| Cache stale (service restart) | Force re-fetch | No stale-on-error for initial request |
Never return 500 to user because entitlement check failed. Always fall back to free tier and log the failure:
INSERT audit_log (action='entitlement_check_failed', metadata={error, fallback: 'free', endpoint})