mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-08 07:12:39 +02:00
Merge branch 'dev' into feature/multi-agent
This commit is contained in:
commit
e74fce4166
17 changed files with 583 additions and 78 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.0.21
|
0.0.22
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from sqlalchemy import select
|
||||||
|
|
||||||
from app.agents.new_chat.tools.update_memory import _save_memory
|
from app.agents.new_chat.tools.update_memory import _save_memory
|
||||||
from app.db import SearchSpace, User, shielded_async_session
|
from app.db import SearchSpace, User, shielded_async_session
|
||||||
|
from app.utils.content_utils import extract_text_content
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -144,11 +145,7 @@ async def extract_and_save_memory(
|
||||||
[HumanMessage(content=prompt)],
|
[HumanMessage(content=prompt)],
|
||||||
config={"tags": ["surfsense:internal", "memory-extraction"]},
|
config={"tags": ["surfsense:internal", "memory-extraction"]},
|
||||||
)
|
)
|
||||||
text = (
|
text = extract_text_content(response.content).strip()
|
||||||
response.content
|
|
||||||
if isinstance(response.content, str)
|
|
||||||
else str(response.content)
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
if text == "NO_UPDATE" or not text:
|
if text == "NO_UPDATE" or not text:
|
||||||
logger.debug("Memory extraction: no update needed (user %s)", uid)
|
logger.debug("Memory extraction: no update needed (user %s)", uid)
|
||||||
|
|
@ -207,11 +204,7 @@ async def extract_and_save_team_memory(
|
||||||
[HumanMessage(content=prompt)],
|
[HumanMessage(content=prompt)],
|
||||||
config={"tags": ["surfsense:internal", "team-memory-extraction"]},
|
config={"tags": ["surfsense:internal", "team-memory-extraction"]},
|
||||||
)
|
)
|
||||||
text = (
|
text = extract_text_content(response.content).strip()
|
||||||
response.content
|
|
||||||
if isinstance(response.content, str)
|
|
||||||
else str(response.content)
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
if text == "NO_UPDATE" or not text:
|
if text == "NO_UPDATE" or not text:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import SearchSpace, User, async_session_maker
|
from app.db import SearchSpace, User, async_session_maker
|
||||||
|
from app.utils.content_utils import extract_text_content
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -188,12 +189,11 @@ async def _forced_rewrite(content: str, llm: Any) -> str | None:
|
||||||
[HumanMessage(content=prompt)],
|
[HumanMessage(content=prompt)],
|
||||||
config={"tags": ["surfsense:internal"]},
|
config={"tags": ["surfsense:internal"]},
|
||||||
)
|
)
|
||||||
text = (
|
text = extract_text_content(response.content).strip()
|
||||||
response.content
|
if not text:
|
||||||
if isinstance(response.content, str)
|
logger.warning("Forced rewrite returned empty text; aborting rewrite")
|
||||||
else str(response.content)
|
return None
|
||||||
)
|
return text
|
||||||
return text.strip()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Forced rewrite LLM call failed")
|
logger.exception("Forced rewrite LLM call failed")
|
||||||
return None
|
return None
|
||||||
|
|
@ -235,6 +235,16 @@ async def _save_memory(
|
||||||
label : str
|
label : str
|
||||||
Human label for log messages (e.g. "user memory", "team memory").
|
Human label for log messages (e.g. "user memory", "team memory").
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(updated_memory, str):
|
||||||
|
logger.warning(
|
||||||
|
"Refusing non-string memory payload (type=%s)",
|
||||||
|
type(updated_memory).__name__,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Internal error: memory payload must be a string.",
|
||||||
|
}
|
||||||
|
|
||||||
content = updated_memory
|
content = updated_memory
|
||||||
|
|
||||||
# --- forced rewrite if over the hard limit ---
|
# --- forced rewrite if over the hard limit ---
|
||||||
|
|
|
||||||
|
|
@ -754,6 +754,12 @@ app.add_middleware(
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"], # Allows all methods
|
allow_methods=["*"], # Allows all methods
|
||||||
allow_headers=["*"], # Allows all headers
|
allow_headers=["*"], # Allows all headers
|
||||||
|
# Cache CORS preflight (OPTIONS) responses for 24h. Browsers clamp:
|
||||||
|
# Chrome/Edge cap at 7200s, Firefox honours up to 86400s. Setting the
|
||||||
|
# higher value lets each browser cache for as long as it allows. This
|
||||||
|
# eliminates an OPTIONS round-trip on every non-simple request from
|
||||||
|
# FRONTEND_URL to BACKEND_URL.
|
||||||
|
max_age=86400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password / email-based auth routers are only mounted when not running in
|
# Password / email-based auth routers are only mounted when not running in
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from app.agents.new_chat.llm_config import (
|
||||||
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, _save_memory
|
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, _save_memory
|
||||||
from app.db import User, get_async_session
|
from app.db import User, get_async_session
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
from app.utils.content_utils import extract_text_content
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -123,11 +124,7 @@ async def edit_user_memory(
|
||||||
[HumanMessage(content=prompt)],
|
[HumanMessage(content=prompt)],
|
||||||
config={"tags": ["surfsense:internal", "memory-edit"]},
|
config={"tags": ["surfsense:internal", "memory-edit"]},
|
||||||
)
|
)
|
||||||
updated = (
|
updated = extract_text_content(response.content).strip()
|
||||||
response.content
|
|
||||||
if isinstance(response.content, str)
|
|
||||||
else str(response.content)
|
|
||||||
).strip()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Memory edit LLM call failed: %s", e)
|
logger.exception("Memory edit LLM call failed: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Memory edit failed.") from e
|
raise HTTPException(status_code=500, detail="Memory edit failed.") from e
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from app.schemas import (
|
||||||
SearchSpaceWithStats,
|
SearchSpaceWithStats,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
from app.utils.content_utils import extract_text_content
|
||||||
from app.utils.rbac import check_permission, check_search_space_access
|
from app.utils.rbac import check_permission, check_search_space_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -356,11 +357,7 @@ async def edit_team_memory(
|
||||||
[HumanMessage(content=prompt)],
|
[HumanMessage(content=prompt)],
|
||||||
config={"tags": ["surfsense:internal", "memory-edit"]},
|
config={"tags": ["surfsense:internal", "memory-edit"]},
|
||||||
)
|
)
|
||||||
updated = (
|
updated = extract_text_content(response.content).strip()
|
||||||
response.content
|
|
||||||
if isinstance(response.content, str)
|
|
||||||
else str(response.content)
|
|
||||||
).strip()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Team memory edit LLM call failed: %s", e)
|
logger.exception("Team memory edit LLM call failed: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Team memory edit failed.") from e
|
raise HTTPException(status_code=500, detail="Team memory edit failed.") from e
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from app.schemas.stripe import (
|
||||||
CreateCheckoutSessionResponse,
|
CreateCheckoutSessionResponse,
|
||||||
CreateTokenCheckoutSessionRequest,
|
CreateTokenCheckoutSessionRequest,
|
||||||
CreateTokenCheckoutSessionResponse,
|
CreateTokenCheckoutSessionResponse,
|
||||||
|
FinalizeCheckoutResponse,
|
||||||
PagePurchaseHistoryResponse,
|
PagePurchaseHistoryResponse,
|
||||||
StripeStatusResponse,
|
StripeStatusResponse,
|
||||||
StripeWebhookResponse,
|
StripeWebhookResponse,
|
||||||
|
|
@ -65,7 +66,15 @@ def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
|
||||||
)
|
)
|
||||||
|
|
||||||
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
|
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
|
||||||
success_url = f"{base_url}/dashboard/{search_space_id}/purchase-success"
|
# Stripe substitutes ``{CHECKOUT_SESSION_ID}`` with the actual session id
|
||||||
|
# at redirect time. The frontend uses it to call /stripe/finalize-checkout
|
||||||
|
# which fulfils synchronously without waiting for the webhook — fixing the
|
||||||
|
# webhook-vs-redirect race where users land on /purchase-success before
|
||||||
|
# checkout.session.completed has been delivered.
|
||||||
|
success_url = (
|
||||||
|
f"{base_url}/dashboard/{search_space_id}/purchase-success"
|
||||||
|
f"?session_id={{CHECKOUT_SESSION_ID}}"
|
||||||
|
)
|
||||||
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
|
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
|
||||||
return success_url, cancel_url
|
return success_url, cancel_url
|
||||||
|
|
||||||
|
|
@ -88,10 +97,62 @@ def _normalize_optional_string(value: Any) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _get_metadata(checkout_session: Any) -> dict[str, str]:
|
def _get_metadata(checkout_session: Any) -> dict[str, str]:
|
||||||
metadata = getattr(checkout_session, "metadata", None) or {}
|
"""Extract checkout session metadata as a plain ``str -> str`` dict.
|
||||||
|
|
||||||
|
In ``stripe>=15.0`` ``StripeObject`` is no longer a ``dict`` subclass
|
||||||
|
and exposes neither ``items()`` nor ``__iter__`` nor ``keys()``.
|
||||||
|
``dict(obj)`` falls into the sequence protocol and raises
|
||||||
|
``KeyError: 0``; ``obj.items()`` raises ``AttributeError``. The
|
||||||
|
supported way to materialize a ``StripeObject`` as a plain dict is
|
||||||
|
its ``to_dict()`` method (added in stripe-python 8.x, present in 15.x).
|
||||||
|
"""
|
||||||
|
metadata = getattr(checkout_session, "metadata", None)
|
||||||
|
if metadata is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 1. Plain dict (older SDKs that subclassed dict, JSON-decoded events
|
||||||
|
# in tests, etc.).
|
||||||
if isinstance(metadata, dict):
|
if isinstance(metadata, dict):
|
||||||
return {str(key): str(value) for key, value in metadata.items()}
|
return {str(k): str(v) for k, v in metadata.items()}
|
||||||
return dict(metadata)
|
|
||||||
|
# 2. Modern Stripe SDK: every ``StripeObject`` has ``to_dict()``.
|
||||||
|
# ``recursive=False`` is correct because Stripe metadata values
|
||||||
|
# are always primitive strings.
|
||||||
|
to_dict = getattr(metadata, "to_dict", None)
|
||||||
|
if callable(to_dict):
|
||||||
|
try:
|
||||||
|
d = to_dict(recursive=False)
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return {str(k): str(v) for k, v in d.items()}
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Stripe metadata.to_dict() failed for session %s",
|
||||||
|
getattr(checkout_session, "id", "?"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Last-resort: read the SDK's private ``_data`` backing dict.
|
||||||
|
# Stable across stripe-python 6.x -> 15.x.
|
||||||
|
inner = getattr(metadata, "_data", None)
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
return {str(k): str(v) for k, v in inner.items()}
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Could not extract metadata from checkout session %s (metadata type=%s)",
|
||||||
|
getattr(checkout_session, "id", "?"),
|
||||||
|
type(metadata).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical purchase_type metadata values. ``premium_credit`` was emitted
|
||||||
|
# by an earlier release of ``create_token_checkout_session`` so it's still
|
||||||
|
# accepted on the read side for backward compat with in-flight sessions.
|
||||||
|
_PURCHASE_TYPE_TOKEN_VALUES = frozenset({"premium_tokens", "premium_credit"})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_token_purchase(metadata: dict[str, str]) -> bool:
|
||||||
|
"""Return True for premium-credit (a.k.a. premium_token) purchases."""
|
||||||
|
return metadata.get("purchase_type", "page_packs") in _PURCHASE_TYPE_TOKEN_VALUES
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_purchase_from_checkout_session(
|
async def _get_or_create_purchase_from_checkout_session(
|
||||||
|
|
@ -439,45 +500,217 @@ async def stripe_webhook(
|
||||||
detail="Invalid Stripe webhook signature.",
|
detail="Invalid Stripe webhook signature.",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
if event.type in {
|
try:
|
||||||
"checkout.session.completed",
|
if event.type in {
|
||||||
"checkout.session.async_payment_succeeded",
|
"checkout.session.completed",
|
||||||
}:
|
"checkout.session.async_payment_succeeded",
|
||||||
checkout_session = event.data.object
|
|
||||||
payment_status = getattr(checkout_session, "payment_status", None)
|
|
||||||
|
|
||||||
if event.type == "checkout.session.completed" and payment_status not in {
|
|
||||||
"paid",
|
|
||||||
"no_payment_required",
|
|
||||||
}:
|
}:
|
||||||
logger.info(
|
checkout_session = event.data.object
|
||||||
"Received checkout.session.completed for unpaid session %s; waiting for async success.",
|
payment_status = getattr(checkout_session, "payment_status", None)
|
||||||
checkout_session.id,
|
|
||||||
)
|
|
||||||
return StripeWebhookResponse()
|
|
||||||
|
|
||||||
metadata = _get_metadata(checkout_session)
|
if event.type == "checkout.session.completed" and payment_status not in {
|
||||||
purchase_type = metadata.get("purchase_type", "page_packs")
|
"paid",
|
||||||
if purchase_type == "premium_tokens":
|
"no_payment_required",
|
||||||
return await _fulfill_completed_token_purchase(db_session, checkout_session)
|
}:
|
||||||
return await _fulfill_completed_purchase(db_session, checkout_session)
|
logger.info(
|
||||||
|
"Received checkout.session.completed for unpaid session %s; waiting for async success.",
|
||||||
|
checkout_session.id,
|
||||||
|
)
|
||||||
|
return StripeWebhookResponse()
|
||||||
|
|
||||||
if event.type in {
|
metadata = _get_metadata(checkout_session)
|
||||||
"checkout.session.async_payment_failed",
|
if _is_token_purchase(metadata):
|
||||||
"checkout.session.expired",
|
return await _fulfill_completed_token_purchase(
|
||||||
}:
|
db_session, checkout_session
|
||||||
checkout_session = event.data.object
|
)
|
||||||
metadata = _get_metadata(checkout_session)
|
return await _fulfill_completed_purchase(db_session, checkout_session)
|
||||||
purchase_type = metadata.get("purchase_type", "page_packs")
|
|
||||||
if purchase_type == "premium_tokens":
|
if event.type in {
|
||||||
return await _mark_token_purchase_failed(
|
"checkout.session.async_payment_failed",
|
||||||
db_session, str(checkout_session.id)
|
"checkout.session.expired",
|
||||||
)
|
}:
|
||||||
return await _mark_purchase_failed(db_session, str(checkout_session.id))
|
checkout_session = event.data.object
|
||||||
|
metadata = _get_metadata(checkout_session)
|
||||||
|
if _is_token_purchase(metadata):
|
||||||
|
return await _mark_token_purchase_failed(
|
||||||
|
db_session, str(checkout_session.id)
|
||||||
|
)
|
||||||
|
return await _mark_purchase_failed(db_session, str(checkout_session.id))
|
||||||
|
except Exception:
|
||||||
|
# Re-raise so FastAPI returns 500 and Stripe retries this delivery.
|
||||||
|
# Logging here gives us a structured trail with event id + type so
|
||||||
|
# future webhook bugs surface immediately in the logs without
|
||||||
|
# having to grep by request_id.
|
||||||
|
logger.exception(
|
||||||
|
"Stripe webhook handler failed for event id=%s type=%s — Stripe will retry",
|
||||||
|
getattr(event, "id", "?"),
|
||||||
|
getattr(event, "type", "?"),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
return StripeWebhookResponse()
|
return StripeWebhookResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/finalize-checkout", response_model=FinalizeCheckoutResponse)
|
||||||
|
async def finalize_checkout(
|
||||||
|
session_id: str,
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
db_session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> FinalizeCheckoutResponse:
|
||||||
|
"""Synchronously fulfil a checkout session from the success page.
|
||||||
|
|
||||||
|
Solves the webhook-vs-redirect race: the user lands on
|
||||||
|
``/dashboard/<id>/purchase-success?session_id=cs_...`` typically a
|
||||||
|
few hundred ms after paying, but Stripe's
|
||||||
|
``checkout.session.completed`` webhook can take 5-30s+ to arrive.
|
||||||
|
Calling this endpoint on success-page mount fulfils the purchase
|
||||||
|
immediately by retrieving the session from Stripe's API and
|
||||||
|
invoking the same idempotent helpers the webhook uses.
|
||||||
|
|
||||||
|
Idempotency: if the webhook has already fulfilled this purchase
|
||||||
|
(status=COMPLETED), the helpers short-circuit and we just return
|
||||||
|
the latest balance. Concurrent webhook + finalize calls are safe
|
||||||
|
because both acquire ``SELECT ... FOR UPDATE`` on the purchase row.
|
||||||
|
|
||||||
|
Authorization: the session's ``client_reference_id`` must match the
|
||||||
|
authenticated user's id. This prevents a user from finalising
|
||||||
|
someone else's checkout session if they happen to know the id.
|
||||||
|
"""
|
||||||
|
stripe_client = get_stripe_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
checkout_session = stripe_client.v1.checkout.sessions.retrieve(session_id)
|
||||||
|
except StripeError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"finalize_checkout: stripe lookup failed for session=%s user=%s: %s",
|
||||||
|
session_id,
|
||||||
|
user.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Checkout session not found.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Authorization check: the user finalising must be the user who
|
||||||
|
# initiated the checkout. ``client_reference_id`` is set in
|
||||||
|
# ``create_checkout_session`` / ``create_token_checkout_session``.
|
||||||
|
client_reference_id = getattr(checkout_session, "client_reference_id", None)
|
||||||
|
if client_reference_id != str(user.id):
|
||||||
|
logger.warning(
|
||||||
|
"finalize_checkout: ownership mismatch session=%s client_ref=%s user=%s",
|
||||||
|
session_id,
|
||||||
|
client_reference_id,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="This checkout session does not belong to you.",
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = _get_metadata(checkout_session)
|
||||||
|
is_token = _is_token_purchase(metadata)
|
||||||
|
payment_status = getattr(checkout_session, "payment_status", None)
|
||||||
|
session_status = getattr(checkout_session, "status", None)
|
||||||
|
|
||||||
|
# Defensive fallback: if metadata can't be read for any reason
|
||||||
|
# (extraction failure, manually-created session in Stripe dashboard,
|
||||||
|
# SDK upgrade breaking ``to_dict``, etc.) we'd otherwise route every
|
||||||
|
# purchase to the page_packs handler and get stuck. Resolve the
|
||||||
|
# purchase_type by checking which table actually has the row keyed
|
||||||
|
# by this Stripe session id.
|
||||||
|
if not metadata:
|
||||||
|
existing_token_purchase = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PremiumTokenPurchase.id).where(
|
||||||
|
PremiumTokenPurchase.stripe_checkout_session_id
|
||||||
|
== str(checkout_session.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing_token_purchase is not None:
|
||||||
|
is_token = True
|
||||||
|
else:
|
||||||
|
existing_page_purchase = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PagePurchase.id).where(
|
||||||
|
PagePurchase.stripe_checkout_session_id
|
||||||
|
== str(checkout_session.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing_page_purchase is None:
|
||||||
|
logger.error(
|
||||||
|
"finalize_checkout: no purchase row in either table "
|
||||||
|
"and metadata is empty for session=%s user=%s",
|
||||||
|
session_id,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
# Fall through; downstream path will short-circuit on
|
||||||
|
# missing-row + empty-metadata.
|
||||||
|
logger.info(
|
||||||
|
"finalize_checkout: recovered purchase_type=%s for session=%s "
|
||||||
|
"via DB fallback (metadata was empty)",
|
||||||
|
"premium_tokens" if is_token else "page_packs",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_paid = payment_status in {"paid", "no_payment_required"}
|
||||||
|
is_expired = session_status == "expired"
|
||||||
|
|
||||||
|
if is_paid:
|
||||||
|
if is_token:
|
||||||
|
await _fulfill_completed_token_purchase(db_session, checkout_session)
|
||||||
|
else:
|
||||||
|
await _fulfill_completed_purchase(db_session, checkout_session)
|
||||||
|
elif is_expired:
|
||||||
|
if is_token:
|
||||||
|
await _mark_token_purchase_failed(db_session, str(checkout_session.id))
|
||||||
|
else:
|
||||||
|
await _mark_purchase_failed(db_session, str(checkout_session.id))
|
||||||
|
# Otherwise (e.g. payment_status="unpaid", session_status="open"),
|
||||||
|
# leave the purchase row alone — frontend will keep polling and the
|
||||||
|
# webhook will eventually win the race.
|
||||||
|
|
||||||
|
# Refresh the user row so the response reflects any update applied
|
||||||
|
# by the fulfilment helpers in this same session.
|
||||||
|
await db_session.refresh(user)
|
||||||
|
|
||||||
|
if is_token:
|
||||||
|
purchase = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PremiumTokenPurchase).where(
|
||||||
|
PremiumTokenPurchase.stripe_checkout_session_id
|
||||||
|
== str(checkout_session.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return FinalizeCheckoutResponse(
|
||||||
|
purchase_type="premium_tokens",
|
||||||
|
status=purchase.status.value if purchase else "pending",
|
||||||
|
premium_credit_micros_limit=user.premium_credit_micros_limit,
|
||||||
|
premium_credit_micros_used=user.premium_credit_micros_used,
|
||||||
|
premium_credit_micros_granted=(
|
||||||
|
purchase.credit_micros_granted if purchase else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
purchase = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PagePurchase).where(
|
||||||
|
PagePurchase.stripe_checkout_session_id == str(checkout_session.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return FinalizeCheckoutResponse(
|
||||||
|
purchase_type="page_packs",
|
||||||
|
status=purchase.status.value if purchase else "pending",
|
||||||
|
pages_limit=user.pages_limit,
|
||||||
|
pages_used=user.pages_used,
|
||||||
|
pages_granted=purchase.pages_granted if purchase else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/purchases", response_model=PagePurchaseHistoryResponse)
|
@router.get("/purchases", response_model=PagePurchaseHistoryResponse)
|
||||||
async def get_page_purchases(
|
async def get_page_purchases(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
|
|
@ -524,7 +757,11 @@ def _get_token_checkout_urls(search_space_id: int) -> tuple[str, str]:
|
||||||
detail="NEXT_FRONTEND_URL is not configured.",
|
detail="NEXT_FRONTEND_URL is not configured.",
|
||||||
)
|
)
|
||||||
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
|
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
|
||||||
success_url = f"{base_url}/dashboard/{search_space_id}/purchase-success"
|
# See ``_get_checkout_urls`` for why session_id is appended.
|
||||||
|
success_url = (
|
||||||
|
f"{base_url}/dashboard/{search_space_id}/purchase-success"
|
||||||
|
f"?session_id={{CHECKOUT_SESSION_ID}}"
|
||||||
|
)
|
||||||
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
|
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
|
||||||
return success_url, cancel_url
|
return success_url, cancel_url
|
||||||
|
|
||||||
|
|
@ -575,7 +812,11 @@ async def create_token_checkout_session(
|
||||||
"user_id": str(user.id),
|
"user_id": str(user.id),
|
||||||
"quantity": str(body.quantity),
|
"quantity": str(body.quantity),
|
||||||
"credit_micros_per_unit": str(config.STRIPE_CREDIT_MICROS_PER_UNIT),
|
"credit_micros_per_unit": str(config.STRIPE_CREDIT_MICROS_PER_UNIT),
|
||||||
"purchase_type": "premium_credit",
|
# Canonical value matched by ``_is_token_purchase``.
|
||||||
|
# The legacy ``"premium_credit"`` is still accepted on
|
||||||
|
# the read side for any in-flight sessions started
|
||||||
|
# before this rename.
|
||||||
|
"purchase_type": "premium_tokens",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,26 @@ class StripeWebhookResponse(BaseModel):
|
||||||
received: bool = True
|
received: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class FinalizeCheckoutResponse(BaseModel):
|
||||||
|
"""Response from /stripe/finalize-checkout.
|
||||||
|
|
||||||
|
Returned by the success page so the UI can show the post-purchase
|
||||||
|
balance immediately, even when the Stripe webhook hasn't been
|
||||||
|
delivered yet. ``status`` mirrors the underlying purchase row
|
||||||
|
(``pending`` / ``completed`` / ``failed``); the FE polls this
|
||||||
|
endpoint until it sees ``completed`` or a final ``failed``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
purchase_type: str # "page_packs" | "premium_tokens"
|
||||||
|
status: str # PagePurchaseStatus / PremiumTokenPurchaseStatus value
|
||||||
|
pages_limit: int | None = None
|
||||||
|
pages_used: int | None = None
|
||||||
|
pages_granted: int | None = None
|
||||||
|
premium_credit_micros_limit: int | None = None
|
||||||
|
premium_credit_micros_used: int | None = None
|
||||||
|
premium_credit_micros_granted: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateTokenCheckoutSessionRequest(BaseModel):
|
class CreateTokenCheckoutSessionRequest(BaseModel):
|
||||||
"""Request body for creating a premium token purchase checkout session."""
|
"""Request body for creating a premium token purchase checkout session."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.21"
|
version = "0.0.22"
|
||||||
description = "SurfSense Backend"
|
description = "SurfSense Backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Unit tests for extracting text from LLM memory responses."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agents.new_chat.tools.update_memory import _save_memory
|
||||||
|
from app.utils.content_utils import extract_text_content
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
class _Recorder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.applied_content: str | None = None
|
||||||
|
self.commit_calls = 0
|
||||||
|
self.rollback_calls = 0
|
||||||
|
|
||||||
|
def apply(self, content: str) -> None:
|
||||||
|
self.applied_content = content
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
self.commit_calls += 1
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
self.rollback_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None:
|
||||||
|
content = [
|
||||||
|
{"type": "thinking", "thinking": "No"},
|
||||||
|
{"type": "thinking", "thinking": " memorizable info."},
|
||||||
|
"NO_UPDATE",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert extract_text_content(content).strip() == "NO_UPDATE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_content_ignores_thinking_blocks_and_keeps_markdown_text() -> None:
|
||||||
|
markdown = (
|
||||||
|
"## Work Context\n"
|
||||||
|
"- (2026-05-02) [fact] Anish is hardening SurfSense memory extraction.\n"
|
||||||
|
)
|
||||||
|
content = [
|
||||||
|
{"type": "thinking", "thinking": "This is durable context."},
|
||||||
|
{"type": "text", "text": markdown},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert extract_text_content(content).strip() == markdown.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_content_returns_empty_when_only_thinking_blocks_are_present() -> None:
|
||||||
|
content = [
|
||||||
|
{"type": "thinking", "thinking": "No durable fact."},
|
||||||
|
{"type": "thinking", "thinking": "Return no update."},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert extract_text_content(content) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_text_content_preserves_plain_string_responses() -> None:
|
||||||
|
markdown = (
|
||||||
|
"## Preferences\n"
|
||||||
|
"- (2026-05-02) [pref] Anish prefers no regex for memory validation.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert extract_text_content(markdown) == markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_rejects_non_string_payload_before_commit() -> None:
|
||||||
|
recorder = _Recorder()
|
||||||
|
|
||||||
|
result = await _save_memory(
|
||||||
|
updated_memory=["NO_UPDATE"], # type: ignore[arg-type]
|
||||||
|
old_memory=None,
|
||||||
|
llm=None,
|
||||||
|
apply_fn=recorder.apply,
|
||||||
|
commit_fn=recorder.commit,
|
||||||
|
rollback_fn=recorder.rollback,
|
||||||
|
label="memory",
|
||||||
|
scope="user",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "must be a string" in result["message"]
|
||||||
|
assert recorder.applied_content is None
|
||||||
|
assert recorder.commit_calls == 0
|
||||||
|
assert recorder.rollback_calls == 0
|
||||||
2
surfsense_backend/uv.lock
generated
2
surfsense_backend/uv.lock
generated
|
|
@ -7947,7 +7947,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.21"
|
version = "0.0.22"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_browser_extension",
|
"name": "surfsense_browser_extension",
|
||||||
"displayName": "Surfsense Browser Extension",
|
"displayName": "Surfsense Browser Extension",
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"description": "Extension to collect Browsing History for SurfSense.",
|
"description": "Extension to collect Browsing History for SurfSense.",
|
||||||
"author": "https://github.com/MODSetter",
|
"author": "https://github.com/MODSetter",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense-desktop",
|
"name": "surfsense-desktop",
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"description": "SurfSense Desktop App",
|
"description": "SurfSense Desktop App",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -12,23 +13,133 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import type { FinalizeCheckoutResponse } from "@/contracts/types/stripe.types";
|
||||||
|
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||||
|
|
||||||
|
type FinalizeState =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "completed"; data: FinalizeCheckoutResponse }
|
||||||
|
| { kind: "pending"; data: FinalizeCheckoutResponse }
|
||||||
|
| { kind: "still_pending"; data: FinalizeCheckoutResponse }
|
||||||
|
| { kind: "failed"; data: FinalizeCheckoutResponse }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
| { kind: "no_session" };
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
const MAX_POLL_ATTEMPTS = 15; // ~30s total before falling back to the still_pending state
|
||||||
|
|
||||||
export default function PurchaseSuccessPage() {
|
export default function PurchaseSuccessPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const searchSpaceId = String(params.search_space_id ?? "");
|
const searchSpaceId = String(params.search_space_id ?? "");
|
||||||
|
const sessionId = searchParams.get("session_id");
|
||||||
|
|
||||||
|
const [state, setState] = useState<FinalizeState>(
|
||||||
|
sessionId ? { kind: "loading" } : { kind: "no_session" }
|
||||||
|
);
|
||||||
|
// Tracks active polling so component unmount cancels it
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
const poll = async (attempt: number): Promise<void> => {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
try {
|
||||||
|
const data = await stripeApiService.finalizeCheckout(sessionId);
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
if (data.status === "completed") {
|
||||||
|
setState({ kind: "completed", data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === "failed") {
|
||||||
|
setState({ kind: "failed", data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is "pending" - either the user paid via async
|
||||||
|
// payment method (Klarna, ACH) or webhook + finalize both
|
||||||
|
// raced and lost. Keep polling up to MAX_POLL_ATTEMPTS,
|
||||||
|
// then fall back to a friendlier message that explains
|
||||||
|
// fulfilment may complete asynchronously.
|
||||||
|
if (attempt < MAX_POLL_ATTEMPTS) {
|
||||||
|
setState({ kind: "pending", data });
|
||||||
|
setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
setState({ kind: "still_pending", data });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
const message = err instanceof Error ? err.message : "Unable to finalize checkout.";
|
||||||
|
setState({ kind: "error", message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll(1);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||||
<Card className="w-full max-w-lg">
|
<Card className="w-full max-w-lg">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
{state.kind === "loading" || state.kind === "pending" ? (
|
||||||
<CardTitle className="text-2xl">Purchase complete</CardTitle>
|
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
||||||
<CardDescription>Your purchase is being applied to your account now.</CardDescription>
|
) : state.kind === "completed" ? (
|
||||||
|
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="mx-auto h-10 w-10 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-2xl">
|
||||||
|
{state.kind === "loading" && "Confirming payment…"}
|
||||||
|
{state.kind === "pending" && "Processing your payment…"}
|
||||||
|
{state.kind === "still_pending" && "Payment still processing"}
|
||||||
|
{state.kind === "completed" && "Purchase complete"}
|
||||||
|
{state.kind === "failed" && "Purchase failed"}
|
||||||
|
{state.kind === "error" && "Couldn't confirm payment"}
|
||||||
|
{state.kind === "no_session" && "Purchase complete"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{state.kind === "loading" && "We're verifying your payment with Stripe."}
|
||||||
|
{state.kind === "pending" &&
|
||||||
|
"Your bank is taking a moment to confirm. This usually takes 5–30 seconds."}
|
||||||
|
{state.kind === "still_pending" &&
|
||||||
|
"Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
|
||||||
|
{state.kind === "completed" &&
|
||||||
|
(state.data.purchase_type === "page_packs"
|
||||||
|
? `Added ${formatNumber(state.data.pages_granted ?? 0)} pages to your account.`
|
||||||
|
: `Added ${formatCredit(state.data.premium_credit_micros_granted ?? 0)} of premium credit to your account.`)}
|
||||||
|
{state.kind === "failed" &&
|
||||||
|
"Stripe reported the checkout as failed or expired. Your card was not charged."}
|
||||||
|
{state.kind === "error" &&
|
||||||
|
"Don't worry — if your card was charged, your purchase will still apply within a minute or two."}
|
||||||
|
{state.kind === "no_session" &&
|
||||||
|
"Your purchase is being applied to your account."}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-center">
|
<CardContent className="space-y-3 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
{state.kind === "completed" && state.data.purchase_type === "page_packs" && (
|
||||||
Your usage meters should refresh automatically in a moment.
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
New balance: {formatNumber(state.data.pages_limit ?? 0)} total pages
|
||||||
|
{typeof state.data.pages_used === "number"
|
||||||
|
? ` (${formatNumber((state.data.pages_limit ?? 0) - state.data.pages_used)} remaining)`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
New premium credit balance: {formatCredit(state.data.premium_credit_micros_limit ?? 0)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.kind === "error" && (
|
||||||
|
<p className="text-sm text-muted-foreground">{state.message}</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-2">
|
<CardFooter className="flex flex-col gap-2">
|
||||||
<Button asChild className="w-full">
|
<Button asChild className="w-full">
|
||||||
|
|
@ -42,3 +153,16 @@ export default function PurchaseSuccessPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return new Intl.NumberFormat("en-US").format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCredit(micros: number): string {
|
||||||
|
const dollars = micros / 1_000_000;
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(dollars);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,19 @@ export const getTokenPurchasesResponse = z.object({
|
||||||
purchases: z.array(tokenPurchase),
|
purchases: z.array(tokenPurchase),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Response from /stripe/finalize-checkout. Either page or token fields
|
||||||
|
// are populated depending on purchase_type.
|
||||||
|
export const finalizeCheckoutResponse = z.object({
|
||||||
|
purchase_type: z.enum(["page_packs", "premium_tokens"]),
|
||||||
|
status: pagePurchaseStatusEnum,
|
||||||
|
pages_limit: z.number().nullable().optional(),
|
||||||
|
pages_used: z.number().nullable().optional(),
|
||||||
|
pages_granted: z.number().nullable().optional(),
|
||||||
|
premium_credit_micros_limit: z.number().nullable().optional(),
|
||||||
|
premium_credit_micros_used: z.number().nullable().optional(),
|
||||||
|
premium_credit_micros_granted: z.number().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
|
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
|
||||||
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
|
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
|
||||||
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
|
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
|
||||||
|
|
@ -85,3 +98,4 @@ export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse
|
||||||
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||||
|
export type FinalizeCheckoutResponse = z.infer<typeof finalizeCheckoutResponse>;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import {
|
||||||
type CreateTokenCheckoutSessionResponse,
|
type CreateTokenCheckoutSessionResponse,
|
||||||
createCheckoutSessionResponse,
|
createCheckoutSessionResponse,
|
||||||
createTokenCheckoutSessionResponse,
|
createTokenCheckoutSessionResponse,
|
||||||
|
type FinalizeCheckoutResponse,
|
||||||
|
finalizeCheckoutResponse,
|
||||||
type GetPagePurchasesResponse,
|
type GetPagePurchasesResponse,
|
||||||
type GetTokenPurchasesResponse,
|
type GetTokenPurchasesResponse,
|
||||||
getPagePurchasesResponse,
|
getPagePurchasesResponse,
|
||||||
|
|
@ -54,6 +56,20 @@ class StripeApiService {
|
||||||
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
|
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
|
||||||
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
|
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously fulfil a checkout session from the success page.
|
||||||
|
*
|
||||||
|
* Solves the race where the user lands on /purchase-success before
|
||||||
|
* Stripe's checkout.session.completed webhook arrives. Idempotent —
|
||||||
|
* safe to call concurrently with the webhook.
|
||||||
|
*/
|
||||||
|
finalizeCheckout = async (sessionId: string): Promise<FinalizeCheckoutResponse> => {
|
||||||
|
return baseApiService.get(
|
||||||
|
`/api/v1/stripe/finalize-checkout?session_id=${encodeURIComponent(sessionId)}`,
|
||||||
|
finalizeCheckoutResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stripeApiService = new StripeApiService();
|
export const stripeApiService = new StripeApiService();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_web",
|
"name": "surfsense_web",
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "SurfSense Frontend",
|
"description": "SurfSense Frontend",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue