Arslan1997 commited on
Commit
811ee7a
·
1 Parent(s): bcef2c3

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
backend/alembic/versions/0003_billing_events.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """billing events: Stripe webhook deliveries already handled
2
+
3
+ Revision ID: 0003_billing_events
4
+ Revises: 0002_build_out
5
+ Create Date: 2026-09-27
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0003_billing_events"
11
+ down_revision = "0002_build_out"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the table.
18
+ if "billing_events" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "billing_events",
22
+ sa.Column("id", sa.String(100), primary_key=True),
23
+ sa.Column("type", sa.String(80), nullable=False),
24
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
25
+ )
26
+
27
+
28
+ def downgrade() -> None:
29
+ op.drop_table("billing_events")
backend/app/models/__init__.py CHANGED
@@ -21,11 +21,12 @@ from app.models.content import (
21
  VoiceProfile,
22
  )
23
  from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
24
- from app.models.user import AuthProvider, Subscription, User, Workspace, WorkspaceMember
25
 
26
  __all__ = [
27
  "Artifact",
28
  "AuthProvider",
 
29
  "CalendarItem",
30
  "Chat",
31
  "Citation",
 
21
  VoiceProfile,
22
  )
23
  from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
24
+ from app.models.user import AuthProvider, BillingEvent, Subscription, User, Workspace, WorkspaceMember
25
 
26
  __all__ = [
27
  "Artifact",
28
  "AuthProvider",
29
+ "BillingEvent",
30
  "CalendarItem",
31
  "Chat",
32
  "Citation",
backend/app/models/user.py CHANGED
@@ -70,3 +70,13 @@ class Subscription(IdMixin, TimestampMixin, Base):
70
  provider_customer_id: Mapped[str | None] = mapped_column(String(100))
71
  provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
72
  current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
 
 
 
 
 
 
 
 
 
 
 
70
  provider_customer_id: Mapped[str | None] = mapped_column(String(100))
71
  provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
72
  current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
73
+
74
+
75
+ class BillingEvent(Base):
76
+ """Stripe webhook events already handled, so a retried delivery never sends a second email."""
77
+
78
+ __tablename__ = "billing_events"
79
+
80
+ id: Mapped[str] = mapped_column(String(100), primary_key=True) # Stripe event id (evt_...)
81
+ type: Mapped[str] = mapped_column(String(80))
82
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
backend/app/routers/billing.py CHANGED
@@ -3,8 +3,11 @@ from typing import Literal
3
  from fastapi import APIRouter, Depends, HTTPException, Request
4
  from pydantic import BaseModel
5
 
 
 
6
  from app.auth import Ctx, get_ctx
7
  from app.config import settings
 
8
  from app.services.billing import billing_status, create_checkout_url, create_portal_url
9
  from app.services.plans import PLAN_ORDER, PLANS, effective_plan, plan_dict
10
 
@@ -50,10 +53,28 @@ def portal(ctx: Ctx = Depends(get_ctx)):
50
  return {"url": create_portal_url(ctx.db, ctx.workspace)}
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  @router.post("/webhook")
54
- async def webhook(request: Request):
55
- """TODO(stripe): verify the Stripe-Signature header against STRIPE_WEBHOOK_SECRET, then map events to
56
- services.billing.apply_subscription (workspace id from client_reference_id, plan from metadata)."""
57
  if not settings.billing_enabled:
58
  return {"ignored": True}
59
- raise HTTPException(501, "Webhook not wired yet")
 
 
 
 
3
  from fastapi import APIRouter, Depends, HTTPException, Request
4
  from pydantic import BaseModel
5
 
6
+ from sqlalchemy.orm import Session
7
+
8
  from app.auth import Ctx, get_ctx
9
  from app.config import settings
10
+ from app.db import get_db
11
  from app.services.billing import billing_status, create_checkout_url, create_portal_url
12
  from app.services.plans import PLAN_ORDER, PLANS, effective_plan, plan_dict
13
 
 
53
  return {"url": create_portal_url(ctx.db, ctx.workspace)}
54
 
55
 
56
+ class ConfirmIn(BaseModel):
57
+ session_id: str
58
+
59
+
60
+ @router.post("/confirm")
61
+ def confirm(body: ConfirmIn, ctx: Ctx = Depends(get_ctx)):
62
+ """Called when the app lands back from Checkout, so the new plan applies even if the webhook is late."""
63
+ if not settings.billing_enabled:
64
+ raise HTTPException(409, {"code": "billing_disabled", "message": "Billing is not available yet."})
65
+ from app.services import stripe_billing
66
+
67
+ stripe_billing.confirm_checkout(ctx.db, ctx.workspace, body.session_id)
68
+ return billing_status(ctx.db, ctx.workspace)
69
+
70
+
71
  @router.post("/webhook")
72
+ async def webhook(request: Request, db: Session = Depends(get_db)):
73
+ """Stripe events. The signature is checked against the raw body, so nothing in front of the API may
74
+ rewrite it. Point the Stripe endpoint at {API_URL}/api/billing/webhook."""
75
  if not settings.billing_enabled:
76
  return {"ignored": True}
77
+ from app.services import stripe_billing
78
+
79
+ event = stripe_billing.parse_event(await request.body(), request.headers.get("stripe-signature"))
80
+ return {"received": True, "result": stripe_billing.handle_event(db, event)}
backend/app/services/billing.py CHANGED
@@ -165,18 +165,16 @@ def return_urls() -> tuple[str, str]:
165
 
166
 
167
  def create_checkout_url(db: Session, user: User, workspace: Workspace, plan: Plan, cycle: str) -> str:
168
- """Return the hosted checkout URL for plan on the monthly or annual cycle.
 
 
169
 
170
- TODO(stripe): create a Checkout Session in subscription mode with the price for (plan.id, cycle),
171
- customer_email=user.email, client_reference_id=str(workspace.id), metadata={"plan": plan.id},
172
- success_url/cancel_url from return_urls(), and return session.url.
173
- """
174
- raise HTTPException(501, {"code": "checkout_not_wired", "message": "Checkout is almost ready. Check back soon."})
175
 
176
 
177
  def create_portal_url(db: Session, workspace: Workspace) -> str:
178
- """Return the customer portal URL for managing or cancelling a subscription.
 
179
 
180
- TODO(stripe): billing_portal.Session.create(customer=sub.provider_customer_id, return_url=.../app/settings).
181
- """
182
- raise HTTPException(501, {"code": "portal_not_wired", "message": "Billing management is almost ready."})
 
165
 
166
 
167
  def create_checkout_url(db: Session, user: User, workspace: Workspace, plan: Plan, cycle: str) -> str:
168
+ """Hosted Stripe Checkout for plan on the monthly or annual cycle, or an in place plan change (which may
169
+ return Stripe's invoice page when the bank wants 3D Secure). See services/stripe_billing.py."""
170
+ from app.services import stripe_billing
171
 
172
+ success, cancel = return_urls()
173
+ return stripe_billing.start_checkout(db, user, workspace, plan, cycle, success, cancel)
 
 
 
174
 
175
 
176
  def create_portal_url(db: Session, workspace: Workspace) -> str:
177
+ """Stripe customer portal: change card, switch or cancel the plan, download invoices."""
178
+ from app.services import stripe_billing
179
 
180
+ return stripe_billing.portal_url(db, workspace, f"{settings.frontend_url.rstrip('/')}/app/settings")
 
 
backend/app/services/email.py CHANGED
@@ -181,6 +181,29 @@ class EmailService:
181
  )
182
  )
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  # Internal alerts
185
 
186
  def send_alert(self, subject: str, text: str) -> bool:
 
181
  )
182
  )
183
 
184
+ # Billing
185
+
186
+ def send_payment_action(self, to: str, kind: str, url: str) -> bool:
187
+ """kind: 'confirm' (the bank wants 3D Secure for a renewal) or 'failed' (card declined; Stripe retries)."""
188
+ if kind == "confirm":
189
+ title, subject = "Confirm your payment", "Your bank needs you to confirm your Notestack payment"
190
+ body = ("Your bank asked for a quick confirmation (3D Secure) before your Notestack renewal can go "
191
+ "through. It takes a few seconds, and your plan stays active meanwhile.")
192
+ cta = "Confirm payment"
193
+ else:
194
+ title, subject = "Your payment did not go through", "Your Notestack payment failed"
195
+ body = ("We could not charge your card for Notestack. We will retry automatically over the next few "
196
+ "days; update your card to keep your plan without interruption.")
197
+ cta = "Update payment"
198
+ return self._send(
199
+ OutgoingEmail(
200
+ to=to,
201
+ subject=subject,
202
+ html=_layout(title, html.escape(body) + _button(cta, url)),
203
+ text=f"{body}\n\n{cta}: {url}",
204
+ )
205
+ )
206
+
207
  # Internal alerts
208
 
209
  def send_alert(self, subject: str, text: str) -> bool:
backend/app/services/plans.py CHANGED
@@ -124,7 +124,8 @@ def effective_plan(db: Session, workspace: Workspace) -> Plan:
124
  if not settings.billing_enabled:
125
  return PLANS["studio"]
126
  sub = db.query(Subscription).filter_by(workspace_id=workspace.id).one_or_none()
127
- if not sub or sub.status not in {"active", "trialing"}:
 
128
  return PLANS["free"]
129
  return PLANS.get(sub.plan, PLANS["free"])
130
 
 
124
  if not settings.billing_enabled:
125
  return PLANS["studio"]
126
  sub = db.query(Subscription).filter_by(workspace_id=workspace.id).one_or_none()
127
+ # past_due keeps the plan while Stripe retries the card (grace period); unpaid or canceled drop to Free.
128
+ if not sub or sub.status not in {"active", "trialing", "past_due"}:
129
  return PLANS["free"]
130
  return PLANS.get(sub.plan, PLANS["free"])
131
 
backend/app/services/stripe_billing.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stripe: hosted Checkout, the customer portal, plan changes and webhooks.
2
+
3
+ Hard parts handled here:
4
+ - 3D Secure / SCA. Checkout runs 3DS on the first payment. Renewals that need it arrive as
5
+ invoice.payment_action_required: the plan stays active and the owner gets the hosted invoice link to
6
+ confirm. Upgrades use payment_behavior=pending_if_incomplete, so the new plan applies only after the
7
+ payment succeeds; if the bank wants 3DS the user is sent to the invoice page to confirm.
8
+ - Failed payments. past_due keeps the plan (Stripe retries per the dashboard's dunning settings) and the
9
+ owner is emailed a link to fix the card; unpaid, canceled or incomplete_expired drop to Free.
10
+ - Webhooks. Signatures are verified against the raw body. Every handler re-reads the subscription from
11
+ Stripe instead of trusting the event payload, so retries and out of order deliveries converge on the
12
+ true state. Side effects (emails, alerts) run once per event id (billing_events table).
13
+ - Duplicates. One Stripe customer per workspace, created before checkout and reused; a workspace that
14
+ already has a live subscription changes plan in place instead of opening a second subscription.
15
+ - Redirect before webhook. The success URL carries the Checkout Session id and the app confirms it
16
+ directly (confirm_checkout), so the plan shows up even if the webhook is late.
17
+ - Prices. Found by lookup key notestack_<plan>_<cycle>, created from services/plans.py when missing
18
+ (so test and live mode both work without dashboard setup), or pinned with STRIPE_PRICE_* env vars.
19
+ """
20
+
21
+ import logging
22
+ import uuid
23
+ from datetime import UTC, datetime
24
+
25
+ import stripe
26
+ from fastapi import HTTPException
27
+ from sqlalchemy import select
28
+ from sqlalchemy.exc import IntegrityError
29
+ from sqlalchemy.orm import Session
30
+
31
+ from app.config import settings
32
+ from app.models import BillingEvent, Subscription, User, Workspace
33
+ from app.services.plans import PLANS, Plan, annual_prices
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ LIVE = {"active", "trialing", "past_due"} # statuses that keep the paid plan (past_due = grace while retrying)
38
+ _prices: dict[str, str] = {}
39
+
40
+
41
+ def client() -> stripe.StripeClient:
42
+ if not settings.stripe_secret_key:
43
+ raise HTTPException(503, {"code": "stripe_not_configured", "message": "Payments are not configured yet."})
44
+ return stripe.StripeClient(settings.stripe_secret_key, max_network_retries=2)
45
+
46
+
47
+ def _get(obj, key, default=None):
48
+ """Read a field from a StripeObject or a plain dict."""
49
+ try:
50
+ value = obj[key]
51
+ except (KeyError, TypeError, AttributeError):
52
+ return default
53
+ return default if value is None else value
54
+
55
+
56
+ def _ts(value) -> datetime | None:
57
+ return datetime.fromtimestamp(int(value), UTC) if value else None
58
+
59
+
60
+ # Prices
61
+
62
+
63
+ def lookup_key(plan_id: str, cycle: str) -> str:
64
+ return f"notestack_{plan_id}_{cycle}"
65
+
66
+
67
+ def price_id(sc: stripe.StripeClient, plan: Plan, cycle: str) -> str:
68
+ pinned = getattr(settings, f"stripe_price_{plan.id}_{cycle}", "")
69
+ if pinned:
70
+ return pinned
71
+ key = lookup_key(plan.id, cycle)
72
+ if key in _prices:
73
+ return _prices[key]
74
+ found = sc.v1.prices.list({"lookup_keys": [key], "active": True, "limit": 1}).data
75
+ if found:
76
+ _prices[key] = found[0].id
77
+ return found[0].id
78
+ product_id = f"notestack_{plan.id}"
79
+ try:
80
+ sc.v1.products.retrieve(product_id)
81
+ except stripe.InvalidRequestError:
82
+ sc.v1.products.create({"id": product_id, "name": f"Notestack {plan.name}", "metadata": {"plan": plan.id}})
83
+ amount = plan.price_monthly_usd if cycle == "monthly" else annual_prices(plan)[1]
84
+ price = sc.v1.prices.create({
85
+ "product": product_id,
86
+ "currency": "usd",
87
+ "unit_amount": round(amount * 100),
88
+ "recurring": {"interval": "month" if cycle == "monthly" else "year"},
89
+ "lookup_key": key,
90
+ "transfer_lookup_key": True,
91
+ "metadata": {"plan": plan.id, "cycle": cycle},
92
+ })
93
+ _prices[key] = price.id
94
+ return price.id
95
+
96
+
97
+ def plan_for_price(price) -> str | None:
98
+ """Plan id for a Stripe Price: pinned env ids, then lookup key, then metadata."""
99
+ pid = _get(price, "id")
100
+ for plan_id in ("writer", "studio"):
101
+ for cycle in ("monthly", "annual"):
102
+ if pid and getattr(settings, f"stripe_price_{plan_id}_{cycle}", "") == pid:
103
+ return plan_id
104
+ key = _get(price, "lookup_key") or ""
105
+ if key.startswith("notestack_"):
106
+ plan_id = key.split("_")[1]
107
+ if plan_id in PLANS:
108
+ return plan_id
109
+ plan_id = _get(_get(price, "metadata", {}), "plan")
110
+ return plan_id if plan_id in PLANS else None
111
+
112
+
113
+ # Customers and subscriptions
114
+
115
+
116
+ def _subscription_row(db: Session, workspace_id: uuid.UUID) -> Subscription:
117
+ sub = db.scalar(select(Subscription).where(Subscription.workspace_id == workspace_id))
118
+ if not sub:
119
+ sub = Subscription(workspace_id=workspace_id, plan="free", status="active")
120
+ db.add(sub)
121
+ db.flush()
122
+ return sub
123
+
124
+
125
+ def ensure_customer(sc: stripe.StripeClient, db: Session, user: User, workspace: Workspace) -> str:
126
+ sub = _subscription_row(db, workspace.id)
127
+ if sub.provider_customer_id:
128
+ return sub.provider_customer_id
129
+ customer = sc.v1.customers.create(
130
+ {"email": user.email, "name": user.name or None, "metadata": {"workspace_id": str(workspace.id)}},
131
+ {"idempotency_key": f"customer-{workspace.id}"},
132
+ )
133
+ sub.provider, sub.provider_customer_id = "stripe", customer.id
134
+ db.commit()
135
+ return customer.id
136
+
137
+
138
+ def sync_subscription(db: Session, workspace_id: uuid.UUID, stripe_sub) -> Subscription:
139
+ """Store the subscription's true state. Safe to call any number of times, in any order."""
140
+ items = _get(_get(stripe_sub, "items", {}), "data", [])
141
+ first = items[0] if items else {}
142
+ status = _get(stripe_sub, "status", "canceled")
143
+ plan_id = plan_for_price(_get(first, "price", {})) or _get(_get(stripe_sub, "metadata", {}), "plan")
144
+ period_end = _get(stripe_sub, "current_period_end") or _get(first, "current_period_end")
145
+ sub = _subscription_row(db, workspace_id)
146
+ sub.provider = "stripe"
147
+ sub.provider_customer_id = _get(stripe_sub, "customer") or sub.provider_customer_id
148
+ sub.provider_subscription_id = _get(stripe_sub, "id")
149
+ sub.status = status
150
+ sub.plan = plan_id if (plan_id in PLANS and status in LIVE) else "free"
151
+ sub.current_period_end = _ts(period_end)
152
+ db.commit()
153
+ return sub
154
+
155
+
156
+ def _workspace_for(db: Session, stripe_sub=None, customer_id: str | None = None) -> uuid.UUID | None:
157
+ ws = _get(_get(stripe_sub, "metadata", {}), "workspace_id") if stripe_sub is not None else None
158
+ if ws:
159
+ return uuid.UUID(ws)
160
+ sub_id = _get(stripe_sub, "id") if stripe_sub is not None else None
161
+ customer_id = customer_id or (_get(stripe_sub, "customer") if stripe_sub is not None else None)
162
+ row = None
163
+ if sub_id:
164
+ row = db.scalar(select(Subscription).where(Subscription.provider_subscription_id == sub_id))
165
+ if not row and customer_id:
166
+ row = db.scalar(select(Subscription).where(Subscription.provider_customer_id == customer_id))
167
+ return row.workspace_id if row else None
168
+
169
+
170
+ # Checkout, plan changes, portal
171
+
172
+
173
+ def start_checkout(db: Session, user: User, workspace: Workspace, plan: Plan, cycle: str,
174
+ success_url: str, cancel_url: str) -> str:
175
+ sc = client()
176
+ price = price_id(sc, plan, cycle)
177
+ row = _subscription_row(db, workspace.id)
178
+ if row.provider_subscription_id and row.status in LIVE:
179
+ return change_plan(sc, db, workspace, row, price, success_url)
180
+ customer = ensure_customer(sc, db, user, workspace)
181
+ meta = {"workspace_id": str(workspace.id), "plan": plan.id, "cycle": cycle}
182
+ session = sc.v1.checkout.sessions.create({
183
+ "mode": "subscription",
184
+ "customer": customer,
185
+ "client_reference_id": str(workspace.id),
186
+ "line_items": [{"price": price, "quantity": 1}],
187
+ "metadata": meta,
188
+ "subscription_data": {"metadata": meta},
189
+ "allow_promotion_codes": True,
190
+ "billing_address_collection": "auto",
191
+ "customer_update": {"address": "auto", "name": "auto"},
192
+ # 3D Secure: Checkout asks the bank whenever it wants it (SCA, Radar rules), nothing to force here.
193
+ "success_url": success_url + "&session_id={CHECKOUT_SESSION_ID}",
194
+ "cancel_url": cancel_url,
195
+ })
196
+ return session.url
197
+
198
+
199
+ def change_plan(sc: stripe.StripeClient, db: Session, workspace: Workspace, row: Subscription, price: str,
200
+ success_url: str) -> str:
201
+ """Move a live subscription to a new price, prorated. The change only sticks once paid; if the bank
202
+ wants 3D Secure, send the user to the invoice page to confirm it."""
203
+ current = sc.v1.subscriptions.retrieve(row.provider_subscription_id)
204
+ item = _get(current, "items")["data"][0]
205
+ updated = sc.v1.subscriptions.update(row.provider_subscription_id, {
206
+ "items": [{"id": item["id"], "price": price}],
207
+ "proration_behavior": "always_invoice",
208
+ "payment_behavior": "pending_if_incomplete",
209
+ "expand": ["latest_invoice"],
210
+ })
211
+ invoice = _get(updated, "latest_invoice")
212
+ if _get(updated, "pending_update") and _get(invoice, "status") == "open":
213
+ # Not paid yet: the bank wants 3D Secure, or the card was declined. Stripe's hosted invoice page
214
+ # handles both (confirm, or pay with another card); the plan switches when that payment succeeds.
215
+ hosted = _get(invoice, "hosted_invoice_url")
216
+ if hosted:
217
+ return hosted
218
+ raise HTTPException(402, {"code": "payment_needs_action",
219
+ "message": "The upgrade payment needs your attention. Check your email."})
220
+ sync_subscription(db, workspace.id, updated)
221
+ return success_url
222
+
223
+
224
+ def portal_url(db: Session, workspace: Workspace, return_url: str) -> str:
225
+ row = db.scalar(select(Subscription).where(Subscription.workspace_id == workspace.id))
226
+ if not row or not row.provider_customer_id:
227
+ raise HTTPException(404, {"code": "no_billing_account", "message": "There is no billing account yet."})
228
+ try:
229
+ session = client().v1.billing_portal.sessions.create({"customer": row.provider_customer_id, "return_url": return_url})
230
+ except stripe.InvalidRequestError as exc:
231
+ log.error("stripe portal failed: %s", exc)
232
+ raise HTTPException(503, {"code": "portal_unavailable",
233
+ "message": "Billing management is unavailable right now. Please try again soon."}) from exc
234
+ return session.url
235
+
236
+
237
+ def confirm_checkout(db: Session, workspace: Workspace, session_id: str) -> Subscription | None:
238
+ """Apply a finished Checkout right away, without waiting for the webhook."""
239
+ sc = client()
240
+ session = sc.v1.checkout.sessions.retrieve(session_id, {"expand": ["subscription"]})
241
+ if _get(session, "client_reference_id") != str(workspace.id):
242
+ raise HTTPException(404, "Checkout session not found")
243
+ stripe_sub = _get(session, "subscription")
244
+ if not stripe_sub or _get(session, "status") != "complete":
245
+ return None
246
+ if isinstance(stripe_sub, str):
247
+ stripe_sub = sc.v1.subscriptions.retrieve(stripe_sub)
248
+ return sync_subscription(db, workspace.id, stripe_sub)
249
+
250
+
251
+ # Webhooks
252
+
253
+ HANDLED = {
254
+ "checkout.session.completed",
255
+ "checkout.session.async_payment_succeeded",
256
+ "checkout.session.async_payment_failed",
257
+ "customer.subscription.created",
258
+ "customer.subscription.updated",
259
+ "customer.subscription.deleted",
260
+ "customer.subscription.paused",
261
+ "customer.subscription.resumed",
262
+ "invoice.paid",
263
+ "invoice.payment_failed",
264
+ "invoice.payment_action_required",
265
+ "charge.dispute.created",
266
+ }
267
+
268
+
269
+ def parse_event(payload: bytes, signature: str | None):
270
+ if not settings.stripe_webhook_secret:
271
+ raise HTTPException(503, "STRIPE_WEBHOOK_SECRET is not set")
272
+ try:
273
+ return stripe.Webhook.construct_event(payload, signature or "", settings.stripe_webhook_secret)
274
+ except (ValueError, stripe.SignatureVerificationError) as exc:
275
+ raise HTTPException(400, "Invalid Stripe signature") from exc
276
+
277
+
278
+ def _first_time(db: Session, event) -> bool:
279
+ """Record the event id; False when this delivery was already handled (Stripe retries)."""
280
+ db.add(BillingEvent(id=event["id"], type=event["type"], created_at=datetime.now(UTC)))
281
+ try:
282
+ db.commit()
283
+ return True
284
+ except IntegrityError:
285
+ db.rollback()
286
+ return False
287
+
288
+
289
+ def _owner_email(db: Session, workspace_id: uuid.UUID | None) -> str | None:
290
+ if not workspace_id:
291
+ return None
292
+ ws = db.get(Workspace, workspace_id)
293
+ owner = db.get(User, ws.owner_id) if ws else None
294
+ return owner.email if owner else None
295
+
296
+
297
+ def handle_event(db: Session, event) -> str:
298
+ from app.services.email import email_service
299
+
300
+ kind = event["type"]
301
+ if kind not in HANDLED:
302
+ return "ignored"
303
+ first = _first_time(db, event)
304
+ obj = event["data"]["object"]
305
+ sc = client()
306
+
307
+ if kind.startswith("checkout.session."):
308
+ if _get(obj, "mode") != "subscription" or not _get(obj, "subscription"):
309
+ return "skipped"
310
+ ws = _get(obj, "client_reference_id") or _get(_get(obj, "metadata", {}), "workspace_id")
311
+ if not ws:
312
+ return "no workspace"
313
+ sync_subscription(db, uuid.UUID(ws), sc.v1.subscriptions.retrieve(_get(obj, "subscription")))
314
+ return "synced"
315
+
316
+ if kind.startswith("customer.subscription."):
317
+ ws = _workspace_for(db, obj)
318
+ if not ws:
319
+ log.warning("stripe %s for unknown subscription %s", kind, _get(obj, "id"))
320
+ return "unknown"
321
+ # Re-read: the payload can be older than the current state when deliveries arrive out of order.
322
+ try:
323
+ fresh = sc.v1.subscriptions.retrieve(_get(obj, "id"))
324
+ except stripe.InvalidRequestError:
325
+ fresh = obj
326
+ sync_subscription(db, ws, fresh)
327
+ return "synced"
328
+
329
+ if kind.startswith("invoice."):
330
+ sub_id = _get(obj, "subscription") or _get(_get(_get(obj, "parent", {}), "subscription_details", {}), "subscription")
331
+ ws = _workspace_for(db, {"id": sub_id} if sub_id else None, customer_id=_get(obj, "customer"))
332
+ if ws and sub_id:
333
+ sync_subscription(db, ws, sc.v1.subscriptions.retrieve(sub_id))
334
+ if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
335
+ # The first invoice of a Checkout is handled on the Checkout page itself.
336
+ if _get(obj, "billing_reason") != "subscription_create":
337
+ to = _get(obj, "customer_email") or _owner_email(db, ws)
338
+ url = _get(obj, "hosted_invoice_url") or f"{settings.frontend_url.rstrip('/')}/app/settings"
339
+ if to:
340
+ email_service.send_payment_action(to, "confirm" if kind.endswith("action_required") else "failed", url)
341
+ return "synced" if ws else "unknown"
342
+
343
+ if kind == "charge.dispute.created" and first:
344
+ email_service.send_alert(
345
+ "Stripe dispute opened",
346
+ f"Dispute {_get(obj, 'id')} for {(_get(obj, 'amount', 0) or 0) / 100:.2f} "
347
+ f"{str(_get(obj, 'currency', '')).upper()}, reason: {_get(obj, 'reason')}. "
348
+ "Respond in the Stripe dashboard before the deadline.",
349
+ )
350
+ return "alerted"
351
+ return "ok"
frontend/index.html CHANGED
@@ -6,6 +6,23 @@
6
  <meta name="theme-color" content="#000000" />
7
  <meta name="description" content="Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice." />
8
  <link rel="icon" href="/logo.svg" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  <link rel="preconnect" href="https://fonts.googleapis.com" />
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
 
6
  <meta name="theme-color" content="#000000" />
7
  <meta name="description" content="Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice." />
8
  <link rel="icon" href="/logo.svg" />
9
+ <!-- Link previews (X, LinkedIn, Bluesky, Threads, Facebook, Slack, Discord, iMessage). %SITE_URL% is filled in at build. -->
10
+ <meta property="og:type" content="website" />
11
+ <meta property="og:site_name" content="Notestack" />
12
+ <meta property="og:title" content="Notestack: your knowledge, in orbit" />
13
+ <meta property="og:description" content="Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice." />
14
+ <meta property="og:url" content="%SITE_URL%/" />
15
+ <meta property="og:image" content="%SITE_URL%/og-image.jpg" />
16
+ <meta property="og:image:secure_url" content="%SITE_URL%/og-image.jpg" />
17
+ <meta property="og:image:type" content="image/jpeg" />
18
+ <meta property="og:image:width" content="1280" />
19
+ <meta property="og:image:height" content="720" />
20
+ <meta property="og:image:alt" content="Notestack: your knowledge, in orbit" />
21
+ <meta name="twitter:card" content="summary_large_image" />
22
+ <meta name="twitter:title" content="Notestack: your knowledge, in orbit" />
23
+ <meta name="twitter:description" content="Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice." />
24
+ <meta name="twitter:image" content="%SITE_URL%/og-image.jpg" />
25
+ <meta name="twitter:image:alt" content="Notestack: your knowledge, in orbit" />
26
  <link rel="preconnect" href="https://fonts.googleapis.com" />
27
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
28
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
frontend/vite.config.ts CHANGED
@@ -6,7 +6,12 @@ import { sitemapPlugin } from "./seo/sitemap";
6
  const SITE_URL = process.env.SITE_URL ?? "https://notestack.ai";
7
 
8
  export default defineConfig({
9
- plugins: [react(), sitemapPlugin(SITE_URL)],
 
 
 
 
 
10
  // Read the shared root .env. Only these prefixes reach the browser; the Google client ID is
11
  // public by design, so it can be shared with the backend under its plain name.
12
  envDir: "..",
 
6
  const SITE_URL = process.env.SITE_URL ?? "https://notestack.ai";
7
 
8
  export default defineConfig({
9
+ plugins: [
10
+ react(),
11
+ sitemapPlugin(SITE_URL),
12
+ // Link preview tags need absolute URLs: fill %SITE_URL% in index.html.
13
+ { name: "site-url", transformIndexHtml: (html) => html.replaceAll("%SITE_URL%", SITE_URL.replace(/\/$/, "")) },
14
+ ],
15
  // Read the shared root .env. Only these prefixes reach the browser; the Google client ID is
16
  // public by design, so it can be shared with the backend under its plain name.
17
  envDir: "..",