kybernus 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/templates/java-spring/clean/.gitignore.hbs +72 -0
- package/templates/java-spring/clean/docker-compose.yml.hbs +6 -3
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/application/usecase/PaymentUseCase.java.hbs +21 -17
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/persistence/entity/UserEntity.java.hbs +52 -0
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/persistence/repository/JpaUserRepository.java.hbs +12 -0
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/security/JwtAuthenticationFilter.java.hbs +64 -0
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/security/SecurityConfig.java.hbs +36 -0
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/stripe/StripeGateway.java.hbs +63 -0
- package/templates/java-spring/clean/src/main/resources/application.properties.hbs +6 -7
- package/templates/java-spring/hexagonal/.gitignore.hbs +72 -0
- package/templates/java-spring/hexagonal/docker-compose.yml.hbs +6 -3
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/outbound/security/JwtFilter.java.hbs +71 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/outbound/security/SecurityConfig.java.hbs +35 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/core/service/PaymentService.java.hbs +3 -3
- package/templates/java-spring/hexagonal/src/main/resources/application.properties.hbs +4 -4
- package/templates/java-spring/mvc/.gitignore.hbs +72 -0
- package/templates/java-spring/mvc/docker-compose.yml.hbs +6 -3
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/config/SecurityConfig.java.hbs +13 -12
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/controller/AuthController.java.hbs +9 -8
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/controller/PaymentsController.java.hbs +5 -6
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/service/StripeService.java.hbs +3 -3
- package/templates/java-spring/mvc/src/main/resources/application.yml.hbs +29 -26
- package/templates/nestjs/clean/.gitignore.hbs +42 -0
- package/templates/nestjs/clean/Dockerfile.hbs +6 -3
- package/templates/nestjs/clean/docker-compose.yml.hbs +1 -11
- package/templates/nestjs/clean/package.json.hbs +51 -41
- package/templates/nestjs/clean/src/app.module.ts.hbs +2 -1
- package/templates/nestjs/clean/src/application/payment.service.ts.hbs +72 -72
- package/templates/nestjs/clean/src/domain/entities/user.entity.ts.hbs +2 -2
- package/templates/nestjs/clean/src/domain/repositories/user.repository.ts.hbs +2 -2
- package/templates/nestjs/clean/src/infrastructure/database/repositories/prisma.user.repository.ts.hbs +18 -18
- package/templates/nestjs/clean/src/infrastructure/http/health.controller.ts.hbs +9 -0
- package/templates/nestjs/clean/src/main.ts.hbs +1 -4
- package/templates/nestjs/clean/src/payment.module.ts.hbs +12 -12
- package/templates/nestjs/hexagonal/.gitignore.hbs +42 -0
- package/templates/nestjs/hexagonal/Dockerfile.hbs +6 -3
- package/templates/nestjs/hexagonal/docker-compose.yml.hbs +1 -11
- package/templates/nestjs/hexagonal/package.json.hbs +51 -41
- package/templates/nestjs/hexagonal/src/adapters/inbound/health.controller.ts.hbs +9 -0
- package/templates/nestjs/hexagonal/src/app.module.ts.hbs +2 -1
- package/templates/nestjs/hexagonal/src/core/domain/user.entity.ts.hbs +6 -6
- package/templates/nestjs/hexagonal/src/core/ports/ports.ts.hbs +4 -4
- package/templates/nestjs/hexagonal/src/main.ts.hbs +1 -4
- package/templates/nestjs/mvc/.gitignore.hbs +42 -0
- package/templates/nestjs/mvc/Dockerfile.hbs +6 -3
- package/templates/nestjs/mvc/docker-compose.yml.hbs +1 -11
- package/templates/nestjs/mvc/package.json.hbs +49 -39
- package/templates/nestjs/mvc/src/auth/auth.controller.ts.hbs +11 -1
- package/templates/nestjs/mvc/src/auth/auth.service.ts.hbs +3 -1
- package/templates/nestjs/mvc/src/controllers/health.controller.ts.hbs +6 -6
- package/templates/nestjs/mvc/src/main.ts.hbs +1 -4
- package/templates/nestjs/mvc/src/models/create-item.dto.ts.hbs +5 -2
- package/templates/nestjs/mvc/src/prisma/prisma.service.ts.hbs +1 -0
- package/templates/nextjs/mvc/.gitignore.hbs +42 -0
- package/templates/nextjs/mvc/Dockerfile.hbs +23 -8
- package/templates/nextjs/mvc/docker-compose.yml.hbs +1 -1
- package/templates/nextjs/mvc/package.json.hbs +46 -36
- package/templates/nodejs-express/clean/.gitignore.hbs +42 -0
- package/templates/nodejs-express/clean/Dockerfile.hbs +6 -1
- package/templates/nodejs-express/clean/docker-compose.yml.hbs +2 -2
- package/templates/nodejs-express/clean/package.json.hbs +12 -2
- package/templates/nodejs-express/clean/src/config.ts.hbs +11 -0
- package/templates/nodejs-express/clean/src/domain/entities/User.ts.hbs +46 -8
- package/templates/nodejs-express/hexagonal/.gitignore.hbs +42 -0
- package/templates/nodejs-express/hexagonal/Dockerfile.hbs +1 -1
- package/templates/nodejs-express/hexagonal/docker-compose.yml.hbs +2 -2
- package/templates/nodejs-express/hexagonal/package.json.hbs +12 -2
- package/templates/nodejs-express/hexagonal/src/adapters/inbound/http/PaymentController.ts.hbs +21 -38
- package/templates/nodejs-express/hexagonal/src/adapters/outbound/persistence/prisma.ts.hbs +2 -0
- package/templates/nodejs-express/hexagonal/src/config.ts.hbs +9 -0
- package/templates/nodejs-express/hexagonal/src/core/AuthService.ts.hbs +5 -5
- package/templates/nodejs-express/hexagonal/src/core/PaymentService.ts.hbs +7 -22
- package/templates/nodejs-express/hexagonal/src/core/domain/entities/User.ts.hbs +24 -4
- package/templates/nodejs-express/mvc/.gitignore.hbs +42 -0
- package/templates/nodejs-express/mvc/package.json.hbs +12 -2
- package/templates/python-fastapi/clean/.gitignore.hbs +76 -0
- package/templates/python-fastapi/clean/app/application/services/payment_service.py.hbs +3 -3
- package/templates/python-fastapi/clean/app/config.py.hbs +6 -7
- package/templates/python-fastapi/clean/app/domain/usecases/login_user.py.hbs +15 -0
- package/templates/python-fastapi/clean/app/infrastructure/http/auth_controller.py.hbs +40 -6
- package/templates/python-fastapi/clean/app/infrastructure/http/payment_controller.py.hbs +5 -4
- package/templates/python-fastapi/clean/app/infrastructure/security/jwt.py.hbs +23 -0
- package/templates/python-fastapi/clean/app/main.py.hbs +3 -0
- package/templates/python-fastapi/clean/docker-compose.yml.hbs +5 -12
- package/templates/python-fastapi/clean/requirements.txt.hbs +3 -0
- package/templates/python-fastapi/hexagonal/.gitignore.hbs +76 -0
- package/templates/python-fastapi/hexagonal/app/adapters/inbound/http_adapter.py.hbs +6 -9
- package/templates/python-fastapi/hexagonal/app/adapters/inbound/payment_http_adapter.py.hbs +4 -3
- package/templates/python-fastapi/hexagonal/app/adapters/outbound/stripe_adapter.py.hbs +30 -19
- package/templates/python-fastapi/hexagonal/app/config.py.hbs +14 -4
- package/templates/python-fastapi/hexagonal/app/core/domain/user.py.hbs +3 -1
- package/templates/python-fastapi/hexagonal/app/core/payment_service.py.hbs +28 -18
- package/templates/python-fastapi/hexagonal/app/core/ports/__init__.py.hbs +3 -0
- package/templates/python-fastapi/hexagonal/app/core/ports/user_repository.py.hbs +15 -0
- package/templates/python-fastapi/hexagonal/app/infrastructure/database/session.py.hbs +7 -0
- package/templates/python-fastapi/hexagonal/app/infrastructure/database/user_repository.py.hbs +53 -0
- package/templates/python-fastapi/hexagonal/app/infrastructure/security/__init__.py.hbs +0 -0
- package/templates/python-fastapi/hexagonal/app/infrastructure/security/adapters.py.hbs +23 -0
- package/templates/python-fastapi/hexagonal/app/infrastructure/security/jwt.py.hbs +23 -0
- package/templates/python-fastapi/hexagonal/docker-compose.yml.hbs +5 -12
- package/templates/python-fastapi/hexagonal/requirements.txt.hbs +4 -0
- package/templates/python-fastapi/mvc/.gitignore.hbs +76 -0
- package/templates/python-fastapi/mvc/app/controllers/payments.py.hbs +3 -17
- package/templates/python-fastapi/mvc/app/middleware/security.py.hbs +24 -3
- package/templates/python-fastapi/mvc/app/schemas/item.py.hbs +3 -1
- package/templates/python-fastapi/mvc/docker-compose.yml.hbs +5 -12
- package/templates/python-fastapi/mvc/requirements.txt.hbs +3 -1
- package/templates/nodejs-express/hexagonal/src/adapters/outbound/persistence/prisma.ts +0 -5
|
@@ -5,7 +5,7 @@ from app.core.ports import IAuthPort
|
|
|
5
5
|
from app.core.domain.user import User as DomainUser
|
|
6
6
|
from app.infrastructure.database.session import get_db
|
|
7
7
|
from app.infrastructure.database.models import UserModel
|
|
8
|
-
from app.adapters.outbound.postgres_user_repository import PostgresUserRepository
|
|
8
|
+
from app.adapters.outbound.postgres_user_repository import PostgresUserRepository
|
|
9
9
|
from app.core.service import AuthService
|
|
10
10
|
from app.infrastructure.security.adapters import BcryptHasher, JwtTokenGenerator
|
|
11
11
|
|
|
@@ -16,6 +16,10 @@ class RegisterRequest(BaseModel):
|
|
|
16
16
|
name: str
|
|
17
17
|
password: str
|
|
18
18
|
|
|
19
|
+
class LoginRequest(BaseModel):
|
|
20
|
+
email: EmailStr
|
|
21
|
+
password: str
|
|
22
|
+
|
|
19
23
|
# Dependency Injection Factory
|
|
20
24
|
def get_auth_service(db: AsyncSession = Depends(get_db)) -> IAuthPort:
|
|
21
25
|
repo = PostgresUserRepository(db)
|
|
@@ -36,7 +40,7 @@ async def register(
|
|
|
36
40
|
|
|
37
41
|
@router.post("/login")
|
|
38
42
|
async def login(
|
|
39
|
-
req:
|
|
43
|
+
req: LoginRequest,
|
|
40
44
|
service: IAuthPort = Depends(get_auth_service)
|
|
41
45
|
):
|
|
42
46
|
try:
|
|
@@ -44,10 +48,3 @@ async def login(
|
|
|
44
48
|
return result
|
|
45
49
|
except ValueError as e:
|
|
46
50
|
raise HTTPException(status_code=401, detail=str(e))
|
|
47
|
-
|
|
48
|
-
# Setup function for backward compatibility or direct usage
|
|
49
|
-
# though router is preferred
|
|
50
|
-
def setup_auth_routes(auth_service: IAuthPort):
|
|
51
|
-
# This pattern is tricky with FastAPIs dependency injection system
|
|
52
|
-
# It's better to rely on Depends() as implemented above
|
|
53
|
-
return router
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
from fastapi import APIRouter, HTTPException, Header, Request, Depends
|
|
2
2
|
from pydantic import BaseModel
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
4
|
from app.core.payment_service import PaymentService
|
|
4
5
|
from app.adapters.outbound.stripe_adapter import StripeAdapter
|
|
5
|
-
from app.infrastructure.database.session import
|
|
6
|
+
from app.infrastructure.database.session import get_db
|
|
6
7
|
from app.infrastructure.database.user_repository import SQLAlchemyUserRepository
|
|
7
8
|
from app.infrastructure.security.jwt import get_current_user_id
|
|
8
9
|
|
|
9
10
|
router = APIRouter()
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def get_payment_service() -> PaymentService:
|
|
13
|
-
repo = SQLAlchemyUserRepository(
|
|
13
|
+
def get_payment_service(db: AsyncSession = Depends(get_db)) -> PaymentService:
|
|
14
|
+
repo = SQLAlchemyUserRepository(db)
|
|
14
15
|
adapter = StripeAdapter()
|
|
15
16
|
return PaymentService(user_repository=repo, stripe_adapter=adapter)
|
|
16
17
|
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import stripe
|
|
2
2
|
import os
|
|
3
3
|
|
|
4
|
-
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
|
5
4
|
stripe.api_version = "2026-02-25.clover"
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
class StripeAdapter:
|
|
9
8
|
"""Outbound adapter: wraps the Stripe SDK for use by the core domain."""
|
|
10
9
|
|
|
10
|
+
def __init__(self):
|
|
11
|
+
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
|
12
|
+
|
|
11
13
|
def create_customer(self, email: str, name: str | None = None, user_id: str | None = None):
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
try:
|
|
15
|
+
return stripe.Customer.create(
|
|
16
|
+
email=email,
|
|
17
|
+
name=name,
|
|
18
|
+
metadata={"userId": user_id} if user_id else {},
|
|
19
|
+
)
|
|
20
|
+
except stripe.StripeError as e:
|
|
21
|
+
raise ValueError(f"Stripe error creating customer: {e.user_message or str(e)}")
|
|
17
22
|
|
|
18
23
|
def create_checkout_session(
|
|
19
24
|
self,
|
|
@@ -23,21 +28,27 @@ class StripeAdapter:
|
|
|
23
28
|
success_url: str,
|
|
24
29
|
cancel_url: str,
|
|
25
30
|
):
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
try:
|
|
32
|
+
return stripe.checkout.Session.create(
|
|
33
|
+
mode="subscription",
|
|
34
|
+
payment_method_types=["card"],
|
|
35
|
+
line_items=[{"price": price_id, "quantity": 1}],
|
|
36
|
+
customer=customer_id,
|
|
37
|
+
success_url=success_url,
|
|
38
|
+
cancel_url=cancel_url,
|
|
39
|
+
client_reference_id=str(user_id),
|
|
40
|
+
)
|
|
41
|
+
except stripe.StripeError as e:
|
|
42
|
+
raise ValueError(f"Stripe error creating checkout session: {e.user_message or str(e)}")
|
|
35
43
|
|
|
36
44
|
def create_portal_session(self, customer_id: str, return_url: str):
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
try:
|
|
46
|
+
return stripe.billing_portal.Session.create(
|
|
47
|
+
customer=customer_id,
|
|
48
|
+
return_url=return_url,
|
|
49
|
+
)
|
|
50
|
+
except stripe.StripeError as e:
|
|
51
|
+
raise ValueError(f"Stripe error creating portal session: {e.user_message or str(e)}")
|
|
41
52
|
|
|
42
53
|
def construct_event(self, payload: bytes, sig_header: str):
|
|
43
54
|
webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
from dotenv import load_dotenv
|
|
2
|
+
load_dotenv()
|
|
3
|
+
|
|
1
4
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
5
|
from functools import lru_cache
|
|
3
6
|
|
|
4
7
|
class Settings(BaseSettings):
|
|
5
8
|
PROJECT_NAME: str = "{{projectName}}"
|
|
6
9
|
API_V1_STR: str = "/api/v1"
|
|
7
|
-
|
|
10
|
+
|
|
8
11
|
# Database
|
|
9
|
-
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/{{projectName}}
|
|
10
|
-
|
|
12
|
+
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/{{projectName}}"
|
|
13
|
+
|
|
11
14
|
# Security
|
|
12
15
|
SECRET_KEY: str = "change_this_to_a_secure_random_key"
|
|
13
16
|
ALGORITHM: str = "HS256"
|
|
14
17
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
# Stripe
|
|
20
|
+
STRIPE_SECRET_KEY: str = "sk_test_placeholder"
|
|
21
|
+
STRIPE_WEBHOOK_SECRET: str = "whsec_placeholder"
|
|
22
|
+
|
|
23
|
+
# Frontend
|
|
24
|
+
FRONTEND_URL: str = "http://localhost:3000"
|
|
25
|
+
|
|
26
|
+
model_config = SettingsConfigDict(env_file=".env", case_sensitive=True, extra="ignore")
|
|
17
27
|
|
|
18
28
|
@lru_cache
|
|
19
29
|
def get_settings():
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
from typing import Optional
|
|
3
4
|
|
|
4
5
|
@dataclass(frozen=True)
|
|
@@ -9,7 +10,8 @@ class User:
|
|
|
9
10
|
password: str
|
|
10
11
|
id: Optional[str] = None
|
|
11
12
|
stripe_customer_id: Optional[str] = None
|
|
12
|
-
|
|
13
|
+
created_at: Optional[datetime] = None
|
|
14
|
+
|
|
13
15
|
def __post_init__(self):
|
|
14
16
|
if "@" not in self.email:
|
|
15
17
|
raise ValueError("Invalid email")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import dataclasses
|
|
2
3
|
from app.core.ports.user_repository import UserRepository
|
|
3
4
|
from app.adapters.outbound.stripe_adapter import StripeAdapter
|
|
4
5
|
|
|
@@ -18,22 +19,28 @@ class PaymentService:
|
|
|
18
19
|
customer_id = user.stripe_customer_id
|
|
19
20
|
|
|
20
21
|
if not customer_id:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
try:
|
|
23
|
+
customer = self.stripe_adapter.create_customer(
|
|
24
|
+
email=user.email,
|
|
25
|
+
name=getattr(user, "name", None),
|
|
26
|
+
user_id=str(user.id),
|
|
27
|
+
)
|
|
28
|
+
except ValueError as e:
|
|
29
|
+
raise ValueError(f"Failed to create Stripe customer: {e}")
|
|
26
30
|
customer_id = customer.id
|
|
27
|
-
user.stripe_customer_id
|
|
31
|
+
user = dataclasses.replace(user, stripe_customer_id=customer_id)
|
|
28
32
|
await self.user_repository.save(user)
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
try:
|
|
35
|
+
session = self.stripe_adapter.create_checkout_session(
|
|
36
|
+
customer_id=customer_id,
|
|
37
|
+
price_id=price_id,
|
|
38
|
+
user_id=str(user_id),
|
|
39
|
+
success_url=f"{os.getenv('FRONTEND_URL')}/success?session_id={'{CHECKOUT_SESSION_ID}'}",
|
|
40
|
+
cancel_url=f"{os.getenv('FRONTEND_URL')}/cancel",
|
|
41
|
+
)
|
|
42
|
+
except ValueError as e:
|
|
43
|
+
raise ValueError(f"Failed to create checkout session: {e}")
|
|
37
44
|
return session.url
|
|
38
45
|
|
|
39
46
|
async def create_portal_session(self, user_id: str) -> str:
|
|
@@ -41,10 +48,13 @@ class PaymentService:
|
|
|
41
48
|
if not user or not user.stripe_customer_id:
|
|
42
49
|
raise ValueError("No Stripe customer found for this user")
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
try:
|
|
52
|
+
session = self.stripe_adapter.create_portal_session(
|
|
53
|
+
customer_id=user.stripe_customer_id,
|
|
54
|
+
return_url=f"{os.getenv('FRONTEND_URL')}/dashboard",
|
|
55
|
+
)
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
raise ValueError(f"Failed to create portal session: {e}")
|
|
48
58
|
return session.url
|
|
49
59
|
|
|
50
60
|
async def handle_webhook(self, payload: bytes, sig_header: str) -> dict:
|
|
@@ -62,7 +72,7 @@ class PaymentService:
|
|
|
62
72
|
if user_id and customer_id:
|
|
63
73
|
user = await self.user_repository.find_by_id(user_id)
|
|
64
74
|
if user:
|
|
65
|
-
user.stripe_customer_id
|
|
75
|
+
user = dataclasses.replace(user, stripe_customer_id=customer_id)
|
|
66
76
|
await self.user_repository.save(user)
|
|
67
77
|
print(f"Checkout completed for user: {user_id}")
|
|
68
78
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from app.core.domain.user import User
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UserRepository(ABC):
|
|
7
|
+
"""Repository port for payment-related user lookups."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def find_by_id(self, user_id: str) -> Optional[User]:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def save(self, user: User) -> User:
|
|
15
|
+
pass
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from app.core.ports.user_repository import UserRepository
|
|
5
|
+
from app.core.domain.user import User
|
|
6
|
+
from app.infrastructure.database.models import UserModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SQLAlchemyUserRepository(UserRepository):
|
|
10
|
+
"""Async SQLAlchemy implementation of UserRepository (payment context)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, session: AsyncSession):
|
|
13
|
+
self.session = session
|
|
14
|
+
|
|
15
|
+
def _to_entity(self, model: UserModel) -> User:
|
|
16
|
+
return User(
|
|
17
|
+
id=model.id,
|
|
18
|
+
email=model.email,
|
|
19
|
+
name=model.name,
|
|
20
|
+
password=model.password,
|
|
21
|
+
stripe_customer_id=model.stripe_customer_id,
|
|
22
|
+
created_at=model.created_at,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def find_by_id(self, user_id: str) -> Optional[User]:
|
|
26
|
+
result = await self.session.execute(
|
|
27
|
+
select(UserModel).where(UserModel.id == user_id)
|
|
28
|
+
)
|
|
29
|
+
model = result.scalars().first()
|
|
30
|
+
return self._to_entity(model) if model else None
|
|
31
|
+
|
|
32
|
+
async def save(self, user: User) -> User:
|
|
33
|
+
result = await self.session.execute(
|
|
34
|
+
select(UserModel).where(UserModel.id == user.id)
|
|
35
|
+
)
|
|
36
|
+
model = result.scalars().first()
|
|
37
|
+
|
|
38
|
+
if model:
|
|
39
|
+
model.stripe_customer_id = user.stripe_customer_id
|
|
40
|
+
else:
|
|
41
|
+
model = UserModel(
|
|
42
|
+
id=user.id,
|
|
43
|
+
email=user.email,
|
|
44
|
+
name=user.name,
|
|
45
|
+
password=user.password,
|
|
46
|
+
stripe_customer_id=user.stripe_customer_id,
|
|
47
|
+
created_at=user.created_at,
|
|
48
|
+
)
|
|
49
|
+
self.session.add(model)
|
|
50
|
+
|
|
51
|
+
await self.session.commit()
|
|
52
|
+
await self.session.refresh(model)
|
|
53
|
+
return self._to_entity(model)
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from passlib.context import CryptContext
|
|
2
|
+
from jose import jwt
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
SECRET_KEY = os.getenv("JWT_SECRET", "secret")
|
|
7
|
+
ALGORITHM = "HS256"
|
|
8
|
+
|
|
9
|
+
class BcryptHasher:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
12
|
+
|
|
13
|
+
def hash(self, password: str) -> str:
|
|
14
|
+
return self.pwd_context.hash(password)
|
|
15
|
+
|
|
16
|
+
def verify(self, password: str, hashed: str) -> bool:
|
|
17
|
+
return self.pwd_context.verify(password, hashed)
|
|
18
|
+
|
|
19
|
+
class JwtTokenGenerator:
|
|
20
|
+
def generate(self, user_id: str, email: str) -> str:
|
|
21
|
+
expire = datetime.utcnow() + timedelta(days=7)
|
|
22
|
+
to_encode = {"exp": expire, "sub": user_id, "email": email}
|
|
23
|
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fastapi import HTTPException, Security
|
|
2
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
3
|
+
from jose import jwt, JWTError
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
security = HTTPBearer()
|
|
7
|
+
|
|
8
|
+
SECRET_KEY = os.getenv("JWT_SECRET", "secret")
|
|
9
|
+
ALGORITHM = "HS256"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_current_user_id(
|
|
13
|
+
credentials: HTTPAuthorizationCredentials = Security(security),
|
|
14
|
+
) -> str:
|
|
15
|
+
try:
|
|
16
|
+
token = credentials.credentials
|
|
17
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
18
|
+
user_id: str = payload.get("sub")
|
|
19
|
+
if user_id is None:
|
|
20
|
+
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
|
21
|
+
return user_id
|
|
22
|
+
except JWTError:
|
|
23
|
+
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
version: '3.8'
|
|
2
|
-
|
|
3
1
|
services:
|
|
4
|
-
app:
|
|
5
|
-
build: .
|
|
6
|
-
container_name: {{kebabCase projectName}}-app
|
|
7
|
-
ports:
|
|
8
|
-
- "8000:8000"
|
|
9
|
-
environment:
|
|
10
|
-
- DATABASE_URL=postgresql://postgres:postgres@db:5432/{{snakeCase projectName}}
|
|
11
|
-
depends_on:
|
|
12
|
-
- db
|
|
13
|
-
|
|
14
2
|
db:
|
|
15
3
|
image: postgres:15-alpine
|
|
16
4
|
container_name: {{kebabCase projectName}}-db
|
|
@@ -23,6 +11,11 @@ services:
|
|
|
23
11
|
volumes:
|
|
24
12
|
- postgres_data:/var/lib/postgresql/data
|
|
25
13
|
restart: unless-stopped
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
16
|
+
interval: 10s
|
|
17
|
+
timeout: 5s
|
|
18
|
+
retries: 5
|
|
26
19
|
|
|
27
20
|
volumes:
|
|
28
21
|
postgres_data:
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
fastapi>=0.109.0
|
|
2
2
|
uvicorn[standard]>=0.27.0
|
|
3
3
|
pydantic>=2.5.0
|
|
4
|
+
pydantic[email]>=2.5.0
|
|
4
5
|
pydantic-settings>=2.1.0
|
|
5
6
|
python-dotenv>=1.0.0
|
|
6
7
|
python-jose[cryptography]>=3.3.0
|
|
7
8
|
passlib[bcrypt]>=1.7.4
|
|
9
|
+
bcrypt>=3.0.0,<4.0.0
|
|
10
|
+
email-validator>=2.1.0
|
|
11
|
+
greenlet>=3.0.0
|
|
8
12
|
stripe>=8.0.0
|
|
9
13
|
sqlalchemy>=2.0.0
|
|
10
14
|
alembic>=1.13.0
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.pyo
|
|
7
|
+
|
|
8
|
+
# Distribution / packaging
|
|
9
|
+
build/
|
|
10
|
+
develop-eggs/
|
|
11
|
+
dist/
|
|
12
|
+
downloads/
|
|
13
|
+
eggs/
|
|
14
|
+
.eggs/
|
|
15
|
+
lib/
|
|
16
|
+
lib64/
|
|
17
|
+
parts/
|
|
18
|
+
sdist/
|
|
19
|
+
var/
|
|
20
|
+
wheels/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.installed.cfg
|
|
23
|
+
*.egg
|
|
24
|
+
MANIFEST
|
|
25
|
+
|
|
26
|
+
# Virtual environments
|
|
27
|
+
.venv/
|
|
28
|
+
venv/
|
|
29
|
+
env/
|
|
30
|
+
ENV/
|
|
31
|
+
env.bak/
|
|
32
|
+
venv.bak/
|
|
33
|
+
.python-version
|
|
34
|
+
|
|
35
|
+
# Environment variables
|
|
36
|
+
.env
|
|
37
|
+
.env.local
|
|
38
|
+
.env.*.local
|
|
39
|
+
!.env.example
|
|
40
|
+
|
|
41
|
+
# Pytest
|
|
42
|
+
.pytest_cache/
|
|
43
|
+
pytest-cache/
|
|
44
|
+
.cache/
|
|
45
|
+
|
|
46
|
+
# Coverage
|
|
47
|
+
htmlcov/
|
|
48
|
+
.tox/
|
|
49
|
+
.coverage
|
|
50
|
+
.coverage.*
|
|
51
|
+
coverage.xml
|
|
52
|
+
*.cover
|
|
53
|
+
*.py,cover
|
|
54
|
+
|
|
55
|
+
# MyPy / Pyright
|
|
56
|
+
.mypy_cache/
|
|
57
|
+
.dmypy.json
|
|
58
|
+
dmypy.json
|
|
59
|
+
.pyright/
|
|
60
|
+
pyrightconfig.json
|
|
61
|
+
|
|
62
|
+
# Alembic — keep migrations, ignore autogenerated caches
|
|
63
|
+
# alembic/versions/ is intentionally tracked
|
|
64
|
+
|
|
65
|
+
# Jupyter
|
|
66
|
+
.ipynb_checkpoints
|
|
67
|
+
|
|
68
|
+
# OS
|
|
69
|
+
.DS_Store
|
|
70
|
+
Thumbs.db
|
|
71
|
+
|
|
72
|
+
# Editor
|
|
73
|
+
.vscode/
|
|
74
|
+
.idea/
|
|
75
|
+
*.swp
|
|
76
|
+
*.swo
|
|
@@ -5,7 +5,7 @@ import os
|
|
|
5
5
|
from app.database import get_db
|
|
6
6
|
from app.services.stripe_service import stripe_service
|
|
7
7
|
from app.models.user import User
|
|
8
|
-
from app.middleware.
|
|
8
|
+
from app.middleware.security import get_current_db_user
|
|
9
9
|
|
|
10
10
|
router = APIRouter()
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ class CheckoutRequest(BaseModel):
|
|
|
18
18
|
async def create_checkout(
|
|
19
19
|
data: CheckoutRequest,
|
|
20
20
|
db: Session = Depends(get_db),
|
|
21
|
-
current_user: User = Depends(
|
|
21
|
+
current_user: User = Depends(get_current_db_user),
|
|
22
22
|
):
|
|
23
23
|
"""Create a Stripe Checkout Session (authenticated)."""
|
|
24
24
|
customer_id = stripe_service.get_or_create_customer(db, current_user)
|
|
@@ -36,7 +36,7 @@ async def create_checkout(
|
|
|
36
36
|
@router.post("/portal")
|
|
37
37
|
async def create_portal(
|
|
38
38
|
db: Session = Depends(get_db),
|
|
39
|
-
current_user: User = Depends(
|
|
39
|
+
current_user: User = Depends(get_current_db_user),
|
|
40
40
|
):
|
|
41
41
|
"""Open Stripe Billing Portal (authenticated)."""
|
|
42
42
|
if not current_user.stripe_customer_id:
|
|
@@ -78,17 +78,3 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|
|
78
78
|
|
|
79
79
|
elif event_type == "customer.subscription.updated":
|
|
80
80
|
print(f"Subscription updated: {data_object.get('id')} | Status: {data_object.get('status')}")
|
|
81
|
-
# TODO: Update subscription status in DB
|
|
82
|
-
|
|
83
|
-
elif event_type == "customer.subscription.deleted":
|
|
84
|
-
print(f"Subscription deleted: {data_object.get('id')}")
|
|
85
|
-
# TODO: Mark user as unsubscribed in DB
|
|
86
|
-
|
|
87
|
-
elif event_type == "invoice.payment_failed":
|
|
88
|
-
print(f"Payment failed for invoice: {data_object.get('id')}")
|
|
89
|
-
# TODO: Notify user via email
|
|
90
|
-
|
|
91
|
-
else:
|
|
92
|
-
print(f"Unhandled Stripe event: {event_type}")
|
|
93
|
-
|
|
94
|
-
return {"received": True}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from passlib.context import CryptContext
|
|
2
2
|
from jose import jwt, JWTError
|
|
3
3
|
from datetime import datetime, timedelta
|
|
4
|
-
from fastapi import HTTPException, Security
|
|
4
|
+
from fastapi import HTTPException, Security, Depends
|
|
5
5
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
from app.database import get_db
|
|
8
|
+
from app.models.user import User as UserModel
|
|
6
9
|
import os
|
|
7
10
|
|
|
8
11
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
@@ -30,10 +33,28 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Security(securi
|
|
|
30
33
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
31
34
|
user_id = payload.get("sub")
|
|
32
35
|
email = payload.get("email")
|
|
33
|
-
|
|
36
|
+
|
|
34
37
|
if user_id is None:
|
|
35
38
|
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
|
36
|
-
|
|
39
|
+
|
|
37
40
|
return {"id": user_id, "email": email}
|
|
38
41
|
except JWTError:
|
|
39
42
|
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
|
43
|
+
|
|
44
|
+
def get_current_db_user(
|
|
45
|
+
credentials: HTTPAuthorizationCredentials = Security(security),
|
|
46
|
+
db: Session = Depends(get_db),
|
|
47
|
+
) -> UserModel:
|
|
48
|
+
try:
|
|
49
|
+
token = credentials.credentials
|
|
50
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
51
|
+
user_id = payload.get("sub")
|
|
52
|
+
if user_id is None:
|
|
53
|
+
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
|
54
|
+
except JWTError:
|
|
55
|
+
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
|
56
|
+
|
|
57
|
+
user = db.query(UserModel).filter(UserModel.id == user_id).first()
|
|
58
|
+
if user is None:
|
|
59
|
+
raise HTTPException(status_code=401, detail="User not found")
|
|
60
|
+
return user
|
|
@@ -5,12 +5,14 @@ from typing import Optional
|
|
|
5
5
|
class ItemCreate(BaseModel):
|
|
6
6
|
name: str
|
|
7
7
|
description: Optional[str] = None
|
|
8
|
+
price: Optional[float] = None
|
|
8
9
|
|
|
9
10
|
class ItemResponse(BaseModel):
|
|
10
11
|
id: str
|
|
11
12
|
name: str
|
|
12
13
|
description: Optional[str] = None
|
|
14
|
+
price: Optional[float] = None
|
|
13
15
|
created_at: datetime
|
|
14
|
-
|
|
16
|
+
|
|
15
17
|
class Config:
|
|
16
18
|
from_attributes = True
|
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
version: '3.8'
|
|
2
|
-
|
|
3
1
|
services:
|
|
4
|
-
app:
|
|
5
|
-
build: .
|
|
6
|
-
container_name: {{kebabCase projectName}}-app
|
|
7
|
-
ports:
|
|
8
|
-
- "8000:8000"
|
|
9
|
-
environment:
|
|
10
|
-
- DATABASE_URL=postgresql://postgres:postgres@db:5432/{{snakeCase projectName}}
|
|
11
|
-
depends_on:
|
|
12
|
-
- db
|
|
13
|
-
|
|
14
2
|
db:
|
|
15
3
|
image: postgres:15-alpine
|
|
16
4
|
container_name: {{kebabCase projectName}}-db
|
|
@@ -23,6 +11,11 @@ services:
|
|
|
23
11
|
volumes:
|
|
24
12
|
- postgres_data:/var/lib/postgresql/data
|
|
25
13
|
restart: unless-stopped
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
16
|
+
interval: 10s
|
|
17
|
+
timeout: 5s
|
|
18
|
+
retries: 5
|
|
26
19
|
|
|
27
20
|
volumes:
|
|
28
21
|
postgres_data:
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
fastapi>=0.109.0
|
|
2
2
|
uvicorn[standard]>=0.27.0
|
|
3
3
|
pydantic>=2.5.0
|
|
4
|
+
pydantic[email]>=2.5.0
|
|
4
5
|
python-dotenv>=1.0.0
|
|
5
6
|
python-jose[cryptography]>=3.3.0
|
|
6
7
|
passlib[bcrypt]>=1.7.4
|
|
8
|
+
bcrypt>=3.0.0,<4.0.0
|
|
7
9
|
stripe>=8.0.0
|
|
8
10
|
sqlalchemy>=2.0.0
|
|
9
11
|
alembic>=1.13.0
|
|
10
12
|
psycopg2-binary>=2.9.9
|
|
11
13
|
pytest>=8.0.0
|
|
12
|
-
httpx>=0.26.0
|
|
14
|
+
httpx>=0.26.0
|