---
title: Phase 4 — Portal + Emails + Read-only Banner
---

# Phase 4 — Portal + Emails + Read-only Banner

## Problem → Solution

**Problem:** users on any plan have no self-serve way to upgrade/downgrade/cancel, nobody receives transactional email for billing events, and there's no in-app explanation when a subscription goes read-only.
**Solution:** Stripe Customer Portal handles upgrades/downgrades/cancels/card updates. Four transactional emails (receipt, payment-failed, plan-changed, subscription-canceled) cover the billing-event surface. `ReadOnlyBanner` explains `past_due` / `canceled` / `incomplete` states with a one-click path back to Portal.

## What this phase adds to the user experience

- "Manage subscription" button on the billing page lands in Stripe Customer Portal to upgrade, downgrade, cancel, or update card.
- Receipt email after every successful invoice (with overage breakdown when present).
- Payment-failed email with a Portal CTA when a card declines.
- In-app banner when the account is read-only (payment failed, subscription canceled, or signup never finished) with a one-click path back to Portal or pick-a-plan.
- Confirmation email when the plan changes or the subscription is canceled.

## Goal

Stand up the two day-two operations that make the model sustainable (self-serve plan change + transactional email) and give read-only accounts a comprehensible in-app explanation.

## Repos touched

| Repo | Changes |
|---|---|
| **billing-service** | Contract F (portal session, N1-API-key only); four new send functions in `app/services/email_service.py` (reusing the existing Postmark `retry_http_post` wrapper); send wiring inside existing Stripe webhook handlers (dedup via Phase 3's `processed_stripe_events`) |
| **api-backend** | User-facing pass-through `POST /billing/portal` (Contract F) |
| **react-frontend** | "Manage subscription" button, `ReadOnlyBanner` for non-active statuses |
| **Postmark (runbook)** | Four new transactional templates authored in Postmark dashboard; template IDs added to billing-service env (`postmark_template_subscription_receipt`, `payment_failed`, `plan_changed`, `subscription_canceled`) |
---

## Step 1 — Contract F — Portal session via api-backend

**Problem:** users have no way to upgrade, downgrade, cancel, or update their payment method from inside the app. Frontend can't reach billing-service directly (gateway pattern).
**Solution:** two endpoints — api-backend fronts the user, billing-service owns the Stripe call.

### billing-service — `POST /subscriptions/portal`

Auth: N1 API key.
Request: `{ "user_id": "uuid" }`.
Response: `{ "url": "https://billing.stripe.com/session/...", "expires_at": "..." }`.

```python
session = stripe.billing_portal.Session.create(
    customer=sub.stripe_customer_id,
    return_url=settings.frontend_url + "/billing",
    timeout=5,
)
```

Stripe Portal is configured via Stripe Dashboard (runbook) to allow: upgrade (immediate, prorated), downgrade (scheduled to period end), cancel at period end, payment-method update. Webhook handling already covered in Phase 3.

**Quota on plan change — no new code.** The `customer.subscription.updated` handler (Phase 3 Step 5) writes the new `plan_id` and leaves `current_period_start` alone (Stripe doesn't roll the period on plan swap). Contract B already computes `remaining = plan.pages_quota - SUM(usage)`, so the new plan's quota applies against usage-so-far and the delta surfaces naturally. Example: Starter (500 pages) user has used 300, upgrades to Pro (2000 pages) → new remaining = 2000 − 300 = 1,700 for the rest of the current period. Downgrade below usage-so-far (e.g. Pro → Starter after 1,200 pages used) leaves `remaining = 0` and the next `/quota/charge` returns 429 — acceptable for a self-initiated downgrade; Portal is set to schedule downgrades for period end anyway, so mid-cycle over-usage is an edge case.

### api-backend — `POST /billing/portal`

Auth: user JWT. Empty body — resolves `user_id` from `jwt.sub`, forwards to billing-service, returns the Portal URL. On `BillingUnavailable` → 503.

---

## Step 2 — Email triggers on billing events

**Problem:** users receive no transactional email for subscription events — no receipts, no payment-failed alerts, no cancellation confirmations.
**Solution:** reuse the existing Postmark integration already wired for balance alerts (`billing-service/app/services/email_service.py` + the `retry_http_post` wrapper it already uses). Add four new send functions and four new Postmark template IDs (configured in the Postmark dashboard, referenced via env vars). All four are webhook-driven, so the Phase 3 `processed_stripe_events` table already provides the dedup guarantee — no new email-dedup table needed. Quota-threshold warnings are deliberately out of scope: the in-app `QuotaIndicator` + `ReadOnlyBanner` (for non-active subscriptions) are the user-facing surfaces for usage and billing-state problems.

### Sends that already exist (reference shape)

`alert_service.py:124` calls `asyncio.create_task(email_service.send_balance_alert_email(...))` after the alert-threshold DB write has committed. Inside `send_balance_alert_email`, the Postmark HTTP call is wrapped in `retry_http_post` (2 retries, 0.5s initial delay, `settings.postmark_timeout`). One layer of retry, fire-and-forget at the caller. Phase 4 follows the same pattern — no new retry or queue infrastructure.

### New send functions

In `email_service.py`, one async function per trigger, each a thin wrapper around the existing Postmark call shape with a different `template_id` and `template_model`:

```python
async def send_subscription_receipt_email(user_email, user_name, template_model): ...
async def send_payment_failed_email(user_email, user_name, template_model): ...
async def send_plan_changed_email(user_email, user_name, template_model): ...
async def send_subscription_canceled_email(user_email, user_name, template_model): ...
```

All four reuse the existing `settings.email_from`, `settings.email_product_name`, `settings.email_company_name`, `settings.email_company_address`, `settings.email_company_url`, `settings.email_logo_image_url` for the common template-model fields. Per-template variables (plan name, amount, overage breakdown, portal URL) are added per function.

New Postmark template IDs land in `settings` as env vars following the existing `postmark_template_balance_warning` / `postmark_template_balance_exhausted` naming:
- `postmark_template_subscription_receipt`
- `postmark_template_payment_failed`
- `postmark_template_plan_changed`
- `postmark_template_subscription_canceled`

### Trigger table

| Trigger | Send function |
|---|---|
| `invoice.paid` | `send_subscription_receipt_email` |
| `invoice.payment_failed` | `send_payment_failed_email` (fires once per Stripe event — Stripe naturally emits multiple events during the ~3-week dunning retry cycle) |
| `customer.subscription.updated` (plan changed) | `send_plan_changed_email` |
| `customer.subscription.deleted` | `send_subscription_canceled_email` |
### Dedup

Each send lives inside a Stripe webhook handler already protected by `processed_stripe_events` (Phase 3 Step 7). On webhook replay, the handler returns early — the send never fires a second time for the same `stripe_event_id`. No separate `sent_emails` table.

### Wiring into webhook handlers

Follows the existing `stripe_webhook.py:343-348` post-commit pattern:

```python
# inside the invoice.paid handler, after await db.commit()
asyncio.create_task(
    email_service.send_subscription_receipt_email(
        user_email=user.email,
        user_name=user.name,
        template_model={...},
    )
)
```

Webhook returns 200 to Stripe immediately after the task is scheduled; Postmark retry happens in the background via `retry_http_post`. Matches Stripe's documented best practice (fast 2xx + background delivery) and matches the pattern the codebase already uses for every other outbound call from the webhook path.

### Template rules

Receipts list plan + period + amount + overage breakdown if any. Payment-failed CTA → Customer Portal. Plan-changed shows old → new plan + effective date. Subscription-canceled shows cancellation date + access-until date + reactivation link. Reply-to `support@n1.care`. Styling reuses the existing balance-alert template's company header (logo, address, support link).

### Postmark runbook (off-code deliverables, must complete before code ships)

Templates must exist in Postmark before the handlers are enabled — Postmark returns 422 on unknown `TemplateId`, and with the `asyncio.create_task` pattern that failure would only surface in logs. Order of operations:

1. **Author four templates in the Postmark dashboard** (same server as the existing balance-alert templates):
   - `subscription-receipt` — plan, period, amount, optional overage breakdown, Portal link
   - `payment-failed` — invoice amount, retry date, Portal CTA
   - `plan-changed` — old plan, new plan, effective date, next invoice date
   - `subscription-canceled` — cancellation date, access-until date, reactivation CTA
   
   Copy authored against the template rules above; layout matches the balance-alert template's header/footer so the four new templates feel like one product.

2. **Capture template IDs into billing-service env** (staging + production) via noxkey:
   - `POSTMARK_TEMPLATE_SUBSCRIPTION_RECEIPT`
   - `POSTMARK_TEMPLATE_PAYMENT_FAILED`
   - `POSTMARK_TEMPLATE_PLAN_CHANGED`
   - `POSTMARK_TEMPLATE_SUBSCRIPTION_CANCELED`
   
   Added to `app/config.py` Settings alongside the existing `postmark_template_balance_warning` / `postmark_template_balance_exhausted` entries. Reuses the existing `POSTMARK_SERVER_TOKEN` — no new server, no new sender signature.

3. **Test-send each template from the Postmark dashboard** to a staging inbox before the handler code is enabled. Verifies template rendering in isolation from the wiring work.

4. **Staging smoke test** once handlers are wired: trigger each of the four paths in Stripe test mode / staging (paid invoice, failed invoice, plan change, cancel) and confirm one email per path, correct template, correct variables. Dedup verified by replaying the same Stripe webhook and confirming no second send.

No new Postmark account, no new server token, no sender-identity work — the existing `settings.email_from` is reused as-is.

---

## Step 3 — "Manage subscription" button

**Problem:** Contract F has no UI trigger — users can't reach their Stripe Portal session.
**Solution:** button on `BillingPage` below `OverageToggleCard`, visible when `stripe_customer_id` is present.

Calls `POST /billing/portal` (Contract F) → `window.location.href = response.url`.

---

## Step 4 — Read-only account banner

**Problem:** Phase 3 makes `/quota/charge` 402 immediately when `subscription.status != active` (declined payment, canceled subscription, incomplete signup). Users would hit cryptic errors on Extract / Generate without an in-app explanation or a clear path to fix it. Spec §11 calls for a banner with an update-payment CTA.
**Solution:** persistent `ReadOnlyBanner` in the app shell rendered when Contract B's `status` is anything other than `active`. CTA targets the Stripe Portal (Contract F) so the user can update card / reactivate without leaving the app.

Render rules (driven by Contract B `status`):
- `past_due` → "Your last payment failed. Update your payment method to continue uploading." → "Update payment method" → Portal.
- `canceled` (still in paid period) → no banner; access continues until `current_period_end`.
- `canceled` (post period end, status flipped by `customer.subscription.deleted`) → "Your subscription ended. Reactivate to continue, or download your data." → "Reactivate" → `PickPlanPage`; "Download data" → existing export.
- `incomplete` (Stripe Checkout never finished) → "Finish setting up your subscription." → resume Checkout via Contract D.

Dismissible via an X button. Dismiss state stored in localStorage only — never synced to the backend — keyed on `(n1_user_id, status)` so dismissal survives reloads but the banner reappears automatically if `subscription.status` changes (e.g. `past_due` → `canceled` during dunning). No route-change logic. If the user hasn't fixed the underlying state by the time they try to Extract or Generate, the 402 from `/quota/charge` still fails the action — the banner is a courtesy warning, not the enforcement surface.

`past_due` → `canceled` transition happens automatically via Stripe's dunning: after ~3 weeks of failed retries, Stripe fires `customer.subscription.deleted`, the Phase 3 webhook handler flips `status` to `canceled`, and the banner copy changes on the next quota-status refresh. No code change needed — behavior falls out of `status`-driven rendering.

---

## Testing

- Unit: each send function builds the correct template model from a webhook payload; `processed_stripe_events` short-circuit prevents double-send on webhook replay.
- Stripe test-mode: upgrade via Customer Portal → webhook → subscription row updated → `plan_changed` email. Cancel → `subscription_canceled` email. Payment failure → `payment_failed` email + `ReadOnlyBanner` renders.

## Done when

- Portal link from Billing page opens Stripe Customer Portal with upgrade/downgrade/cancel controls.
- All four email templates render correctly in staging.
- `ReadOnlyBanner` renders the correct copy + CTA for each non-active status.

## Depends on

- Phase 3 (Stripe Checkout, webhooks, Portal session target, `processed_stripe_events` for webhook dedup).

## Feeds

- Phase 5 (trial-exhausted upgrade flow lands users in the same Checkout path; Portal available to them once they upgrade).
