Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c5f13d6
Fix pydantic validation error
shahzaib-ali-khan Nov 30, 2025
8cc1633
Create OS independent temp file for AuditLogs
shahzaib-ali-khan Nov 30, 2025
e67da80
Fix typo in variables name
shahzaib-ali-khan Dec 1, 2025
8825c73
Fix Invoice field name
shahzaib-ali-khan Dec 1, 2025
2f02603
Revrt back last commit
shahzaib-ali-khan Dec 6, 2025
93823c8
Add invoice.paid Stripe webhook
shahzaib-ali-khan Dec 6, 2025
241ec53
Remove /webhooks/stripe from cache
shahzaib-ali-khan Dec 6, 2025
bedc6ec
Use BillingRecord class
shahzaib-ali-khan Dec 6, 2025
453ac68
Merge branch 'sajanv88:main' into feature/stripe-webhook
shahzaib-ali-khan Dec 6, 2025
54b2bf6
Merge branch 'sajanv88:main' into main
shahzaib-ali-khan Dec 6, 2025
d5309d5
Check scope early
shahzaib-ali-khan Dec 7, 2025
6d6d9ad
Remove tenant check inside billing_record_service
shahzaib-ali-khan Dec 10, 2025
3ad65f5
Move Webhook to stripe_endpoint file
shahzaib-ali-khan Dec 10, 2025
43acd1d
Add stripe_webhook_secret to config and check scope in /webhooks
shahzaib-ali-khan Dec 10, 2025
3846ba4
Add missing env varible in example file
shahzaib-ali-khan Dec 10, 2025
05ee1a9
Merge branch 'main' into feature/stripe-webhook
shahzaib-ali-khan Dec 12, 2025
5b33553
Remove stale import
shahzaib-ali-khan Dec 16, 2025
739c2bb
Update endpoint url
shahzaib-ali-khan Dec 16, 2025
5582720
Merge branch 'sajanv88:main' into feature/stripe-webhook
shahzaib-ali-khan Dec 19, 2025
86fcbdd
Merge branch 'main' into feature/stripe-webhook
shahzaib-ali-khan Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,14 @@ COOLIFY_API_URL="https://{replace_with_your_coolify_instance_endpoint}/api/v1"
COOLIFY_API_KEY="{replace_with_your_coolify_api_key}" # Read here https://coolify.io/docs/api-reference/authorization
COOLIFY_APPLICATION_ID="{replace_with_your_coolify_application_id}" # Read here https://coolify.io/docs/api-reference/api/operations/get-application-by-uuid


# Default aws s3 settings for file uploads this belongs to the Host
AWS_REGION="eu-central-1"
AWS_ACCESS_KEY_ID="Ab..."
AWS_SECRET_ACCESS_KEY="xxe..."
AWS_S3_BUCKET_NAME="fs.."

# Stripe credentials settings
STRIPE_API_KEY="fg_test..."
STRIPE_PUBLISHABLE_KEY="ks_test...."
STRIPE_SECRET_KEY="fs_test...."
STRIPE_WEBHOOK_SECRET="whsec_..."
27 changes: 22 additions & 5 deletions backend/api/common/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import logging
from fastapi import logger
from datetime import datetime, timezone, timedelta
import re
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional

from fastapi import logger

from api.common.exceptions import InvalidOperationException
from api.core.config import settings
from api.domain.dtos.audit_logs_dto import AuditLogDto
Expand Down Expand Up @@ -94,14 +97,28 @@ def is_subdomain(host: str) -> bool:
return host != main_domain and host.endswith(f".{main_domain}")


def create_temp_file(file_name: str) -> Path:
"""Create a temporary file and return its path."""

temp_dir = Path(tempfile.gettempdir())

path = temp_dir / file_name

# Ensure directory exists (temp directories always exist, but safe to include)
path.parent.mkdir(parents=True, exist_ok=True)

return path


async def capture_audit_log(
log_data: AuditLogDto
) -> None:
"""Capture an audit log entry."""
file_name = f"audit_logs_for{log_data.tenant_id if log_data.tenant_id else 'host'}_{get_utc_now().strftime('%Y%m%d')}.jsonl"

file_path = create_temp_file(file_name)

path = f"/tmp/audit_logs_for{log_data.tenant_id if log_data.tenant_id else 'host'}_{get_utc_now().strftime('%Y%m%d')}.jsonl"
with open(path, "a") as f:
with open(file_path, "a") as f:
f.write(log_data.model_dump_json() + "\n")


Expand All @@ -111,4 +128,4 @@ def get_sso_redirect_uri(provider_name: str, domain: Optional[str] = None) -> st
return f"https://{local_domain}/api/v1/account/sso/{provider_name}/callback"

return f"http://{local_domain}:3000/api/v1/account/sso/{provider_name}/callback"


1 change: 1 addition & 0 deletions backend/api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Settings(BaseSettings):
stripe_api_key: str
stripe_publishable_key: str
stripe_secret_key: str
stripe_webhook_secret: str

# Instantiate settings once
settings = Settings()
Expand Down
2 changes: 1 addition & 1 deletion backend/api/domain/dtos/billing_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class UpdatePlanDto(BaseModel):

class InvoiceDto(BaseModel):
id: str
amount_country: str
account_country: str
account_name: str
amount_due: int = 0
amount_paid: int = 0
Expand Down
4 changes: 4 additions & 0 deletions backend/api/domain/dtos/stripe_setting_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ class StripeSettingDto(BaseModel):
default_currency: str
mode: PaymentType
trial_period_days: int


class StripeSettingSecretDto(BaseModel):
stripe_webhook_secret: str
2 changes: 1 addition & 1 deletion backend/api/domain/entities/stripe_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
PaymentType = Literal["one_time", "recurring", "both"]
ScopeType = Literal["host", "tenant"]
StatusType = Literal[
"pending", "requires_payment_method", "requires_action",
"paid", "pending", "requires_payment_method", "requires_action",
"active", "succeeded", "payment_failed", "canceled", "incomplete"
]
ActorType = Literal["tenant", "end_user"] # who is being billed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ async def list (self, skip: int = 0, limit: int = 10) -> BillingRecordListDto:
skip=skip,
limit=limit,
total=total,
hasPrevious=skip > 0,
hasNext=skip + limit < total
has_previous=skip > 0,
has_next=skip + limit < total
)
return result

Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async def update(self, user_id: str, data: UpdateUserDto) -> Optional[User]:
await self.add_audit_log(AuditLogDto(
action="update",
entity="User",
user_id=user_id,
user_id=str(user_id),
changes={
"new": data.model_dump(exclude_unset=True, exclude={"email"}, exclude_none=True),
"old": UpdateUserDto(**await exising_user.to_serializable_dict()).model_dump(exclude_unset=True, exclude={"email"}, exclude_none=True)
Expand All @@ -76,7 +76,7 @@ async def update(self, user_id: str, data: UpdateUserDto) -> Optional[User]:
await self.add_audit_log(AuditLogDto(
action="update",
entity="User",
user_id=user_id,
user_id=str(user_id),
changes={"error": f"No user found for the given user id: {user_id}"},
tenant_id=str(data.tenant_id) if data.tenant_id else None
))
Expand Down
29 changes: 16 additions & 13 deletions backend/api/infrastructure/persistence/seeder.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
from api.common.utils import get_logger, get_utc_now, get_utc_now
import os

from faker import Faker
from api.core.config import settings
from api.core.container import get_role_service, get_user_service
from api.domain.dtos.user_dto import CreateUserDto, UpdateUserDto

from api.common.enums.gender import Gender
from api.common.security import hash_it
from api.common.seeder_utils import get_seed_roles
from api.common.utils import create_temp_file, get_logger, get_utc_now
from api.core.config import settings
from api.core.container import get_role_service, get_user_service
from api.domain.dtos.role_dto import CreateRoleDto, UpdateRoleDto
from api.domain.enum.role import RoleType
from api.common.security import hash_it
from api.domain.dtos.user_dto import CreateUserDto, UpdateUserDto
from api.domain.enum.permission import Permission
import os

from api.infrastructure.persistence.repositories.role_repository_impl import RoleRepository
from api.infrastructure.persistence.repositories.user_repository_impl import UserRepository
from api.domain.enum.role import RoleType
from api.infrastructure.persistence.repositories.role_repository_impl import \
RoleRepository
from api.infrastructure.persistence.repositories.user_repository_impl import \
UserRepository

fake = Faker()

logger = get_logger(__name__)
LOCK_FILE = "/tmp/seed.lock"
LOCK_FILE = "seed.lock"

async def seed_initial_data():
if settings.fastapi_env != "development":
Expand All @@ -27,7 +30,7 @@ async def seed_initial_data():
if settings.mongo_db_name == "myapp":
logger.warning("Using default database name 'myapp'. Please change it in production environments.")

if os.path.exists(LOCK_FILE):
if os.path.exists(create_temp_file(LOCK_FILE)):
logger.info("Seeding has already been completed. Skipping...")
return

Expand Down Expand Up @@ -99,5 +102,5 @@ async def seed_initial_data():
logger.info("Seeded fake users.")

# Create lock file to indicate seeding is done
with open(LOCK_FILE, "w") as f:
with open(create_temp_file(LOCK_FILE), "w") as f:
f.write("seeding completed")
144 changes: 131 additions & 13 deletions backend/api/interfaces/api_controllers/stripe_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,157 @@
from fastapi import APIRouter, Depends, status
from typing import Any

import stripe
from api.common.exceptions import InvalidOperationException
from api.core.container import get_stripe_setting_service
from api.common.utils import get_logger
from api.core.container import get_billing_record_service, get_stripe_setting_service
from api.domain.dtos.stripe_setting_dto import CreateStripeSettingDto, StripeSettingDto
from api.domain.enum.feature import Feature as FeatureEnum
from api.domain.enum.permission import Permission
from api.domain.security.feature_access_management import check_feature_access
from api.infrastructure.security.current_user import CurrentUser
from api.interfaces.security.role_checker import check_permissions_for_current_role
from api.usecases.billing_record_service import BillingRecordService
from api.usecases.stripe_setting_service import StripeSettingService
from api.domain.enum.feature import Feature as FeatureEnum
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from api.core.config import settings


logger = get_logger(__name__)

router = APIRouter(prefix="/configurations", dependencies=[
Depends(check_permissions_for_current_role(required_permissions=[Permission.MANAGE_PAYMENTS_SETTINGS])),
Depends(check_feature_access(FeatureEnum.STRIPE))
])

router = APIRouter(
prefix="/configurations",
dependencies=[
Depends(
check_permissions_for_current_role(
required_permissions=[Permission.MANAGE_PAYMENTS_SETTINGS]
)
),
Depends(check_feature_access(FeatureEnum.STRIPE)),
],
)
router.tags = ["Stripe"]


@router.post("/stripe", status_code=status.HTTP_201_CREATED)
async def configure_stripe_setting(
configuration: CreateStripeSettingDto,
current_user: CurrentUser,
stripe_setting_service: StripeSettingService = Depends(get_stripe_setting_service)
stripe_setting_service: StripeSettingService = Depends(get_stripe_setting_service),
):
if not current_user.tenant_id:
raise InvalidOperationException("Tenant context is required to configure Stripe settings.")
raise InvalidOperationException(
"Tenant context is required to configure Stripe settings."
)

await stripe_setting_service.configure_stripe_settings(settings=configuration, tenant_id=str(current_user.tenant_id))
await stripe_setting_service.configure_stripe_settings(
settings=configuration, tenant_id=str(current_user.tenant_id)
)
return status.HTTP_201_CREATED


@router.get("/stripe", response_model=StripeSettingDto, status_code=status.HTTP_200_OK)
async def get_stripe_settings(
current_user: CurrentUser,
stripe_setting_service: StripeSettingService = Depends(get_stripe_setting_service)
stripe_setting_service: StripeSettingService = Depends(get_stripe_setting_service),
):
if not current_user.tenant_id:
raise InvalidOperationException("Tenant context is required to get Stripe settings.")
return await stripe_setting_service.get_stripe_settings()
raise InvalidOperationException(
"Tenant context is required to get Stripe settings."
)
return await stripe_setting_service.get_stripe_settings()


SUPPORTED_EVENTS = {
"invoice.paid": lambda obj, svc: handle_invoice_paid(obj, svc),
}


@router.post("/stripe/webhooks", status_code=status.HTTP_200_OK)
async def stripe_webhook(
request: Request,
stripe_setting_service: StripeSettingService = Depends(get_stripe_setting_service),
billing_record_service: BillingRecordService = Depends(get_billing_record_service),
):
event = None
stripe_webhook_secret = None
payload = await request.body()
stripe_settings = await stripe_setting_service.get_stripe_secret()

# Check the request context for tenant or host. If tenant then get tenant webhook secret otherwise use host from env
if request.state.tenant_id:
# Tenant-scoped webhook
if not stripe_settings or not stripe_settings.stripe_webhook_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Tenant webhook secret not configured",
)
stripe_webhook_secret = stripe_settings.stripe_webhook_secret
else:
# Host-scoped webhook
stripe_webhook_secret = settings.stripe_webhook_secret

try:
sig_header = request.headers.get("stripe-signature")

event = stripe.Webhook.construct_event(
payload=payload,
sig_header=sig_header,
secret=stripe_webhook_secret,
)
except ValueError:
# Invalid payload
logger.error("Invalid payload")
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError as e:
logger.error(f"Webhook signature verification failed: {e}")
raise HTTPException(status_code=400, detail="Invalid signature")
except Exception as e:
logger.error(f"Unexpected error constructing event: {e}")
raise HTTPException(status_code=400, detail="Webhook error")

# Extract the event type and object
event_type = event["type"]
data_object = event["data"]["object"]

# ------------------------------------------------------------------
# Handle relevant Stripe events
# ------------------------------------------------------------------

if event_type not in SUPPORTED_EVENTS:
logger.error(f"Unhandled event type: {event_type}")
return JSONResponse(
status_code=404, content={"message": "Unhandled event type"}
)

await SUPPORTED_EVENTS.get(event_type, None)(data_object, billing_record_service)

return JSONResponse(status_code=200, content={"status": "success"})


async def handle_invoice_paid(
invoice: dict[str, Any],
billing_record_service: BillingRecordService,
) -> None:
tenant_id = invoice.get("metadata", {}).get("tenant_id")

billing_record = await billing_record_service.from_stripe_invoice_paid(
invoice=invoice, scope="tenant" if tenant_id else "host", tenant_id=tenant_id
)

if not billing_record:
logger.warning(
f"Could not convert invoice to BillingRecordDto: {invoice['id']}"
)
return

try:
await billing_record_service.create_billing_record(billing_record)
logger.info(
f"BillingRecord created for tenant {tenant_id} – invoice {invoice['id']}"
)
except Exception as e:
logger.exception(
f"Failed to save BillingRecord for invoice {invoice['id']}: {e}"
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"/api/v1/account/refresh_token",
"/api/v1/app_configuration/",
"/api/v1/security/passkeys",
"/api/v1/webhooks/stripe",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/api/v1/configurations/stripe/webhooks

]


Expand Down
Loading