Skip to content

Phase 4 — Portal + Emails + Read-only Banner

Phase 4 — Portal + Emails + Read-only Banner

Section titled “Phase 4 — Portal + Emails + Read-only Banner”

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

Section titled “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.

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.

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

Section titled “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

Section titled “billing-service — POST /subscriptions/portal”

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

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.

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

Section titled “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)

Section titled “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.

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:

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 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

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.

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

# 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.

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)

Section titled “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

Section titled “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.


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_duecanceled 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_duecanceled 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.


  • 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.
  • 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.
  • Phase 3 (Stripe Checkout, webhooks, Portal session target, processed_stripe_events for webhook dedup).
  • Phase 5 (trial-exhausted upgrade flow lands users in the same Checkout path; Portal available to them once they upgrade).