Phase 4 — Portal + Emails + Read-only Banner
Phase 4 — Portal + Emails + Read-only Banner
Section titled “Phase 4 — Portal + Emails + Read-only Banner”Problem → Solution
Section titled “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
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.
Repos touched
Section titled “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
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.
api-backend — POST /billing/portal
Section titled “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
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.
New send functions
Section titled “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:
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_receiptpostmark_template_payment_failedpostmark_template_plan_changedpostmark_template_subscription_canceled
Trigger table
Section titled “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 |
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
Section titled “Wiring into webhook handlers”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.
Template rules
Section titled “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)
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:
-
Author four templates in the Postmark dashboard (same server as the existing balance-alert templates):
subscription-receipt— plan, period, amount, optional overage breakdown, Portal linkpayment-failed— invoice amount, retry date, Portal CTAplan-changed— old plan, new plan, effective date, next invoice datesubscription-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.
-
Capture template IDs into billing-service env (staging + production) via noxkey:
POSTMARK_TEMPLATE_SUBSCRIPTION_RECEIPTPOSTMARK_TEMPLATE_PAYMENT_FAILEDPOSTMARK_TEMPLATE_PLAN_CHANGEDPOSTMARK_TEMPLATE_SUBSCRIPTION_CANCELED
Added to
app/config.pySettings alongside the existingpostmark_template_balance_warning/postmark_template_balance_exhaustedentries. Reuses the existingPOSTMARK_SERVER_TOKEN— no new server, no new sender signature. -
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.
-
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.
Step 4 — Read-only account banner
Section titled “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 untilcurrent_period_end.canceled(post period end, status flipped bycustomer.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
Section titled “Testing”- Unit: each send function builds the correct template model from a webhook payload;
processed_stripe_eventsshort-circuit prevents double-send on webhook replay. - Stripe test-mode: upgrade via Customer Portal → webhook → subscription row updated →
plan_changedemail. Cancel →subscription_canceledemail. Payment failure →payment_failedemail +ReadOnlyBannerrenders.
Done when
Section titled “Done when”- Portal link from Billing page opens Stripe Customer Portal with upgrade/downgrade/cancel controls.
- All four email templates render correctly in staging.
ReadOnlyBannerrenders the correct copy + CTA for each non-active status.
Depends on
Section titled “Depends on”- Phase 3 (Stripe Checkout, webhooks, Portal session target,
processed_stripe_eventsfor webhook dedup).
- Phase 5 (trial-exhausted upgrade flow lands users in the same Checkout path; Portal available to them once they upgrade).
