feat: add invite rewards and redeem codes
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
from v1.auth.service import AuthService
|
||||
|
||||
|
||||
@@ -81,7 +81,9 @@ async def create_email_session(
|
||||
if profile is not None:
|
||||
settings: dict[str, object] = dict(profile.settings or {})
|
||||
prefs_raw = settings.get("preferences", {})
|
||||
preferences: dict[str, object] = dict(prefs_raw) if isinstance(prefs_raw, dict) else {}
|
||||
preferences: dict[str, object] = (
|
||||
dict(prefs_raw) if isinstance(prefs_raw, dict) else {}
|
||||
)
|
||||
if payload.language is not None:
|
||||
preferences["language"] = payload.language
|
||||
if payload.timezone is not None:
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.creem_transaction import CreemTransaction
|
||||
from models.invite_code import InviteCode
|
||||
from models.invite_referral import InviteReferral
|
||||
from models.profile import Profile
|
||||
from models.system_audit_log import SystemAuditLog
|
||||
|
||||
|
||||
class InviteCodeRepository:
|
||||
@@ -20,3 +26,118 @@ class InviteCodeRepository:
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_referral_by_invitee(
|
||||
self, *, invitee_user_id: UUID
|
||||
) -> InviteReferral | None:
|
||||
stmt = (
|
||||
select(InviteReferral)
|
||||
.where(InviteReferral.invitee_user_id == invitee_user_id)
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_legacy_binding_by_invitee(
|
||||
self, *, invitee_user_id: UUID
|
||||
) -> tuple[str, datetime | None] | None:
|
||||
stmt = (
|
||||
select(InviteCode.code, Profile.updated_at)
|
||||
.join(InviteCode, InviteCode.owner_id == Profile.referred_by)
|
||||
.where(Profile.id == invitee_user_id, Profile.referred_by.is_not(None))
|
||||
.order_by(InviteCode.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = (await self._session.execute(stmt)).first()
|
||||
if row is None:
|
||||
return None
|
||||
return (str(row[0]), row[1])
|
||||
|
||||
async def list_referrals_by_inviter(
|
||||
self, *, inviter_user_id: UUID
|
||||
) -> list[InviteReferral]:
|
||||
stmt = (
|
||||
select(InviteReferral)
|
||||
.where(InviteReferral.inviter_user_id == inviter_user_id)
|
||||
.order_by(InviteReferral.bound_at.desc())
|
||||
)
|
||||
return list((await self._session.execute(stmt)).scalars().all())
|
||||
|
||||
async def get_bindable_code_for_update(self, *, code: str) -> InviteCode | None:
|
||||
stmt = (
|
||||
select(InviteCode).where(InviteCode.code == code).limit(1).with_for_update()
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def has_completed_creem_payment(self, *, user_id: UUID) -> bool:
|
||||
stmt = (
|
||||
select(CreemTransaction.id)
|
||||
.where(
|
||||
CreemTransaction.user_id == user_id,
|
||||
CreemTransaction.status == "completed",
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none() is not None
|
||||
|
||||
async def insert_referral(
|
||||
self,
|
||||
*,
|
||||
inviter_user_id: UUID,
|
||||
invitee_user_id: UUID,
|
||||
invite_code_id: UUID,
|
||||
invite_code_snapshot: str,
|
||||
) -> UUID | None:
|
||||
stmt = (
|
||||
insert(InviteReferral)
|
||||
.values(
|
||||
inviter_user_id=inviter_user_id,
|
||||
invitee_user_id=invitee_user_id,
|
||||
invite_code_id=invite_code_id,
|
||||
invite_code_snapshot=invite_code_snapshot,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[InviteReferral.invitee_user_id])
|
||||
.returning(InviteReferral.id)
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def mark_profile_referred_by(
|
||||
self,
|
||||
*,
|
||||
invitee_user_id: UUID,
|
||||
inviter_user_id: UUID,
|
||||
) -> None:
|
||||
await self._session.execute(
|
||||
update(Profile)
|
||||
.where(Profile.id == invitee_user_id)
|
||||
.values(referred_by=inviter_user_id, updated_at=func.now())
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def append_system_audit_log(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: UUID | None,
|
||||
actor_user_id: UUID | None = None,
|
||||
target_user_id: UUID | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
self._session.add(
|
||||
SystemAuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
target_user_id=target_user_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self._session.commit()
|
||||
|
||||
@staticmethod
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -9,7 +9,7 @@ from v1.invite.dependencies import (
|
||||
get_current_user_for_invite,
|
||||
get_invite_code_service,
|
||||
)
|
||||
from v1.invite.schemas import MyInviteCodeResponse
|
||||
from v1.invite.schemas import InviteBindRequest, MyInviteCodeResponse
|
||||
from v1.invite.service import InviteCodeService
|
||||
|
||||
|
||||
@@ -22,3 +22,15 @@ async def get_my_invite_code(
|
||||
service: Annotated[InviteCodeService, Depends(get_invite_code_service)],
|
||||
) -> MyInviteCodeResponse:
|
||||
return await service.get_my_invite_code(user_id=current_user.id)
|
||||
|
||||
|
||||
@router.post("/bind", response_model=MyInviteCodeResponse)
|
||||
async def bind_invite_code(
|
||||
payload: InviteBindRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user_for_invite)],
|
||||
service: Annotated[InviteCodeService, Depends(get_invite_code_service)],
|
||||
) -> MyInviteCodeResponse:
|
||||
return await service.bind_invite_code(
|
||||
user_id=current_user.id,
|
||||
raw_code=payload.code,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InviteBindingInfo(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True, serialize_by_alias=True, extra="forbid"
|
||||
)
|
||||
|
||||
can_bind: bool = Field(alias="canBind")
|
||||
bound_invite_code: str | None = Field(alias="boundInviteCode", default=None)
|
||||
bound_at: str | None = Field(alias="boundAt", default=None)
|
||||
|
||||
|
||||
class InviteSummary(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True, serialize_by_alias=True, extra="forbid"
|
||||
)
|
||||
|
||||
reward_points: int = Field(alias="rewardPoints", ge=0)
|
||||
invited_count: int = Field(alias="invitedCount", ge=0)
|
||||
rewarded_count: int = Field(alias="rewardedCount", ge=0)
|
||||
pending_count: int = Field(alias="pendingCount", ge=0)
|
||||
rewarded_points: int = Field(alias="rewardedPoints", ge=0)
|
||||
total_potential_reward_points: int = Field(alias="totalPotentialRewardPoints", ge=0)
|
||||
|
||||
|
||||
class InviteReferralItem(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True, serialize_by_alias=True, extra="forbid"
|
||||
)
|
||||
|
||||
referral_id: str = Field(alias="referralId")
|
||||
invite_code: str = Field(alias="inviteCode")
|
||||
bound_at: str = Field(alias="boundAt")
|
||||
first_creem_paid_at: str | None = Field(alias="firstCreemPaidAt", default=None)
|
||||
reward_granted: bool = Field(alias="rewardGranted")
|
||||
reward_granted_at: str | None = Field(alias="rewardGrantedAt", default=None)
|
||||
|
||||
|
||||
class MyInviteCodeResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True, serialize_by_alias=True, extra="forbid"
|
||||
)
|
||||
|
||||
code: str
|
||||
used_count: int
|
||||
my_code: str = Field(alias="myCode")
|
||||
binding: InviteBindingInfo
|
||||
summary: InviteSummary
|
||||
items: list[InviteReferralItem]
|
||||
|
||||
|
||||
class InviteBindRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
|
||||
code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timezone
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from core.config.settings import config
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from v1.invite.repository import InviteCodeRepository
|
||||
from v1.invite.schemas import MyInviteCodeResponse
|
||||
from v1.invite.schemas import (
|
||||
InviteBindingInfo,
|
||||
InviteReferralItem,
|
||||
InviteSummary,
|
||||
MyInviteCodeResponse,
|
||||
)
|
||||
|
||||
INVITE_CODE_PATTERN = re.compile(r"^[A-Z0-9]{6}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -13,6 +23,124 @@ class InviteCodeService:
|
||||
repository: InviteCodeRepository
|
||||
|
||||
async def get_my_invite_code(self, *, user_id: UUID) -> MyInviteCodeResponse:
|
||||
return await self._build_overview(user_id=user_id)
|
||||
|
||||
async def bind_invite_code(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
raw_code: str,
|
||||
) -> MyInviteCodeResponse:
|
||||
code = raw_code.strip().upper()
|
||||
if not INVITE_CODE_PATTERN.fullmatch(code):
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="INVITE_CODE_INVALID",
|
||||
detail="Invite code format is invalid",
|
||||
),
|
||||
)
|
||||
|
||||
existing = await self.repository.get_referral_by_invitee(
|
||||
invitee_user_id=user_id
|
||||
)
|
||||
legacy_binding = await self.repository.get_legacy_binding_by_invitee(
|
||||
invitee_user_id=user_id
|
||||
)
|
||||
if existing is not None or legacy_binding is not None:
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="INVITE_ALREADY_BOUND",
|
||||
detail="Current user already bound an invite code",
|
||||
),
|
||||
)
|
||||
|
||||
invite_code = await self.repository.get_bindable_code_for_update(code=code)
|
||||
if invite_code is None:
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="INVITE_BIND_CODE_NOT_FOUND",
|
||||
detail="Invite code does not exist",
|
||||
),
|
||||
)
|
||||
if invite_code.owner_id is None or invite_code.status != "active":
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="INVITE_CODE_NOT_BINDABLE",
|
||||
detail="Invite code is not bindable",
|
||||
),
|
||||
)
|
||||
if invite_code.owner_id == user_id:
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="INVITE_SELF_BIND_FORBIDDEN",
|
||||
detail="Cannot bind your own invite code",
|
||||
),
|
||||
)
|
||||
now = self.repository.utcnow()
|
||||
if invite_code.expires_at is not None:
|
||||
expires_at = invite_code.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at <= now:
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="INVITE_CODE_NOT_BINDABLE",
|
||||
detail="Invite code is expired",
|
||||
),
|
||||
)
|
||||
if (
|
||||
invite_code.max_uses is not None
|
||||
and invite_code.used_count >= invite_code.max_uses
|
||||
):
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="INVITE_CODE_NOT_BINDABLE",
|
||||
detail="Invite code usage limit reached",
|
||||
),
|
||||
)
|
||||
|
||||
referral_id = await self.repository.insert_referral(
|
||||
inviter_user_id=invite_code.owner_id,
|
||||
invitee_user_id=user_id,
|
||||
invite_code_id=invite_code.id,
|
||||
invite_code_snapshot=invite_code.code,
|
||||
)
|
||||
if referral_id is None:
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="INVITE_ALREADY_BOUND",
|
||||
detail="Current user already bound an invite code",
|
||||
),
|
||||
)
|
||||
|
||||
invite_code.used_count = int(invite_code.used_count) + 1
|
||||
await self.repository.mark_profile_referred_by(
|
||||
invitee_user_id=user_id,
|
||||
inviter_user_id=invite_code.owner_id,
|
||||
)
|
||||
await self.repository.append_system_audit_log(
|
||||
action="invite.bind",
|
||||
entity_type="invite_referral",
|
||||
entity_id=referral_id,
|
||||
actor_user_id=user_id,
|
||||
target_user_id=invite_code.owner_id,
|
||||
metadata={
|
||||
"invite_code": invite_code.code,
|
||||
"invite_code_id": str(invite_code.id),
|
||||
},
|
||||
)
|
||||
await self.repository.commit()
|
||||
return await self._build_overview(user_id=user_id)
|
||||
|
||||
async def _build_overview(self, *, user_id: UUID) -> MyInviteCodeResponse:
|
||||
invite_code = await self.repository.get_by_owner_id(owner_id=user_id)
|
||||
if invite_code is None:
|
||||
raise ApiProblemError(
|
||||
@@ -22,7 +150,67 @@ class InviteCodeService:
|
||||
detail="Invite code not found for current user",
|
||||
),
|
||||
)
|
||||
return MyInviteCodeResponse(
|
||||
code=invite_code.code,
|
||||
used_count=invite_code.used_count,
|
||||
binding = await self.repository.get_referral_by_invitee(invitee_user_id=user_id)
|
||||
legacy_binding = None
|
||||
if binding is None:
|
||||
legacy_binding = await self.repository.get_legacy_binding_by_invitee(
|
||||
invitee_user_id=user_id
|
||||
)
|
||||
bound_invite_code = (
|
||||
binding.invite_code_snapshot
|
||||
if binding is not None
|
||||
else legacy_binding[0]
|
||||
if legacy_binding is not None
|
||||
else None
|
||||
)
|
||||
bound_at = (
|
||||
binding.bound_at
|
||||
if binding is not None
|
||||
else legacy_binding[1]
|
||||
if legacy_binding is not None
|
||||
else None
|
||||
)
|
||||
can_bind = bound_invite_code is None
|
||||
referrals = await self.repository.list_referrals_by_inviter(
|
||||
inviter_user_id=user_id
|
||||
)
|
||||
reward_points = int(config.points_policy.invite_reward_points)
|
||||
rewarded_count = sum(
|
||||
1 for row in referrals if row.inviter_reward_granted_at is not None
|
||||
)
|
||||
invited_count = len(referrals)
|
||||
return MyInviteCodeResponse(
|
||||
myCode=invite_code.code,
|
||||
binding=InviteBindingInfo(
|
||||
canBind=can_bind,
|
||||
boundInviteCode=bound_invite_code,
|
||||
boundAt=bound_at.isoformat() if bound_at else None,
|
||||
),
|
||||
summary=InviteSummary(
|
||||
rewardPoints=reward_points,
|
||||
invitedCount=invited_count,
|
||||
rewardedCount=rewarded_count,
|
||||
pendingCount=max(invited_count - rewarded_count, 0),
|
||||
rewardedPoints=rewarded_count * reward_points,
|
||||
totalPotentialRewardPoints=invited_count * reward_points,
|
||||
),
|
||||
items=[
|
||||
InviteReferralItem(
|
||||
referralId=str(row.id),
|
||||
inviteCode=row.invite_code_snapshot,
|
||||
boundAt=row.bound_at.isoformat(),
|
||||
firstCreemPaidAt=(
|
||||
row.first_creem_paid_at.isoformat()
|
||||
if row.first_creem_paid_at is not None
|
||||
else None
|
||||
),
|
||||
rewardGranted=row.inviter_reward_granted_at is not None,
|
||||
rewardGrantedAt=(
|
||||
row.inviter_reward_granted_at.isoformat()
|
||||
if row.inviter_reward_granted_at is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
for row in referrals
|
||||
],
|
||||
)
|
||||
|
||||
@@ -15,9 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey)
|
||||
|
||||
_APPLE_ROOT_CA_G3_FINGERPRINT = (
|
||||
"b52cb02fd567e0359fe8fa4d4c41037970fe01b0"
|
||||
)
|
||||
_APPLE_ROOT_CA_G3_FINGERPRINT = "b52cb02fd567e0359fe8fa4d4c41037970fe01b0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -30,7 +30,9 @@ class CreemCheckout:
|
||||
class CreemClient:
|
||||
def __init__(self) -> None:
|
||||
settings = config.creem
|
||||
self._api_key = settings.api_key.get_secret_value() if settings.api_key else None
|
||||
self._api_key = (
|
||||
settings.api_key.get_secret_value() if settings.api_key else None
|
||||
)
|
||||
self._base_url = settings.base_url.rstrip("/")
|
||||
self._timeout = httpx.Timeout(30.0, connect=5.0)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
@@ -21,6 +22,7 @@ from schemas.domain.points import (
|
||||
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
||||
from v1.payments.creem_client import CreemClient, CreemProduct
|
||||
from v1.payments.repository import PaymentRepository
|
||||
from v1.points.invite_rewards import grant_invite_rewards_for_creem_payment
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -44,8 +46,7 @@ def _load_creem_product_mappings() -> dict[str, CreemProductMapping]:
|
||||
return _creem_product_mappings_cache
|
||||
|
||||
mapping_path = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "core/config/static/packages/mapping.yaml"
|
||||
Path(__file__).parent.parent.parent / "core/config/static/packages/mapping.yaml"
|
||||
)
|
||||
with mapping_path.open("r", encoding="utf-8") as f:
|
||||
raw: Any = yaml.safe_load(f) or {}
|
||||
@@ -265,7 +266,9 @@ class CreemService:
|
||||
order_id = order_obj.get("id") if isinstance(order_obj, dict) else None
|
||||
customer_obj = obj.get("customer", {})
|
||||
customer_id = customer_obj.get("id") if isinstance(customer_obj, dict) else None
|
||||
metadata = obj.get("metadata", {})
|
||||
customer_email = (
|
||||
customer_obj.get("email", "") if isinstance(customer_obj, dict) else ""
|
||||
)
|
||||
|
||||
txn = await self._payment_repo.get_creem_transaction_by_checkout_id(
|
||||
checkout_id=checkout_id
|
||||
@@ -336,6 +339,7 @@ class CreemService:
|
||||
txn.status = "completed"
|
||||
txn.ledger_event_id = event_id
|
||||
txn.creem_payload = obj
|
||||
paid_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
"CREEM payment completed: user_id=%s checkout_id=%s credits=%d new_balance=%d",
|
||||
@@ -345,11 +349,18 @@ class CreemService:
|
||||
new_balance,
|
||||
)
|
||||
|
||||
await grant_invite_rewards_for_creem_payment(
|
||||
repository=self._points_repo,
|
||||
invitee_user_id=user_id,
|
||||
invitee_email=str(customer_email),
|
||||
creem_transaction_id=txn.id,
|
||||
paid_at=paid_at,
|
||||
)
|
||||
|
||||
mappings = _load_creem_product_mappings()
|
||||
mapping = mappings.get(txn.product_code)
|
||||
if mapping and mapping.type == "starter":
|
||||
user_email = obj.get("customer", {}).get("email", "")
|
||||
normalized_email = user_email.strip().lower()
|
||||
normalized_email = str(customer_email).strip().lower()
|
||||
if normalized_email:
|
||||
email_hash = self._build_email_hash(normalized_email)
|
||||
_ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack(
|
||||
|
||||
@@ -14,12 +14,8 @@ class VerifyTransactionRequest(BaseModel):
|
||||
alias="appStoreProductId", min_length=1, max_length=128
|
||||
)
|
||||
transaction_id: str = Field(alias="transactionId", min_length=1, max_length=64)
|
||||
signed_transaction_info: str = Field(
|
||||
alias="signedTransactionInfo", min_length=1
|
||||
)
|
||||
app_account_token: UUID | None = Field(
|
||||
alias="appAccountToken", default=None
|
||||
)
|
||||
signed_transaction_info: str = Field(alias="signedTransactionInfo", min_length=1)
|
||||
app_account_token: UUID | None = Field(alias="appAccountToken", default=None)
|
||||
|
||||
|
||||
class VerifyTransactionResponse(BaseModel):
|
||||
|
||||
@@ -49,8 +49,7 @@ def _load_product_mappings() -> dict[str, ProductMapping]:
|
||||
return _product_mappings_cache
|
||||
|
||||
mapping_path = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "core/config/static/packages/mapping.yaml"
|
||||
Path(__file__).parent.parent.parent / "core/config/static/packages/mapping.yaml"
|
||||
)
|
||||
with mapping_path.open("r", encoding="utf-8") as f:
|
||||
raw: Any = yaml.safe_load(f) or {}
|
||||
@@ -60,7 +59,9 @@ def _load_product_mappings() -> dict[str, ProductMapping]:
|
||||
for code, entry in product_mappings.items():
|
||||
mappings[str(code)] = ProductMapping(
|
||||
app_store_product_id=str(entry.get("app_store_product_id", "")),
|
||||
creem_product_id=str(entry["creem_product_id"]) if entry.get("creem_product_id") else None,
|
||||
creem_product_id=str(entry["creem_product_id"])
|
||||
if entry.get("creem_product_id")
|
||||
else None,
|
||||
credits=int(entry["credits"]),
|
||||
type=str(entry["type"]),
|
||||
sort_order=int(entry.get("sort_order", 0)),
|
||||
@@ -335,7 +336,9 @@ class PaymentService:
|
||||
transaction_id=transaction_id
|
||||
)
|
||||
if txn is None:
|
||||
logger.warning("Refund requested for unknown transaction: %s", transaction_id)
|
||||
logger.warning(
|
||||
"Refund requested for unknown transaction: %s", transaction_id
|
||||
)
|
||||
return
|
||||
|
||||
if txn.status not in ("granted",):
|
||||
@@ -466,7 +469,9 @@ class PaymentService:
|
||||
txn_data: Any = json.loads(txn_decoded)
|
||||
transaction_id = str(txn_data.get("transactionId", ""))
|
||||
except Exception:
|
||||
logger.exception("Failed to decode signed transaction from notification")
|
||||
logger.exception(
|
||||
"Failed to decode signed transaction from notification"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Apple notification received: type=%s subtype=%s transaction_id=%s",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from models.user_points import UserPoints
|
||||
from schemas.domain.points import (
|
||||
AppendAuditLedgerCommand,
|
||||
AdjustLedgerMetadata,
|
||||
ApplyPointsChangeCommand,
|
||||
AuditLedgerMetadata,
|
||||
)
|
||||
from schemas.enums import PointsChangeType, PointsOperatorType
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
|
||||
async def apply_adjust_points(
|
||||
*,
|
||||
repository: PointsRepository,
|
||||
user_id: UUID,
|
||||
user_email: str,
|
||||
event_id: str,
|
||||
amount: int,
|
||||
reason: str,
|
||||
audit_metadata: AuditLedgerMetadata,
|
||||
ledger_ext: dict[str, object],
|
||||
) -> UserPoints:
|
||||
if amount <= 0:
|
||||
raise ValueError("adjust amount must be positive")
|
||||
|
||||
account = await repository.get_or_create_user_points_for_update(user_id=user_id)
|
||||
account.balance = int(account.balance) + amount
|
||||
account.lifetime_earned = int(account.lifetime_earned) + amount
|
||||
account.version = int(account.version) + 1
|
||||
|
||||
metadata = AdjustLedgerMetadata(
|
||||
operator_type=PointsOperatorType.SYSTEM,
|
||||
run_id=event_id,
|
||||
ext={
|
||||
**ledger_ext,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
await repository.append_ledger(
|
||||
command=ApplyPointsChangeCommand(
|
||||
user_id=user_id,
|
||||
change_type=PointsChangeType.ADJUST,
|
||||
event_id=event_id,
|
||||
amount=amount,
|
||||
direction=1,
|
||||
operator_id=None,
|
||||
metadata=metadata,
|
||||
),
|
||||
balance_after=int(account.balance),
|
||||
)
|
||||
await repository.append_audit_ledger(
|
||||
command=AppendAuditLedgerCommand(
|
||||
event_id=event_id,
|
||||
user_id_snapshot=user_id,
|
||||
user_email_snapshot=(user_email or "").strip().lower() or None,
|
||||
change_type=PointsChangeType.ADJUST,
|
||||
direction=1,
|
||||
amount=amount,
|
||||
balance_after=int(account.balance),
|
||||
billed_to="user",
|
||||
run_id=event_id,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
cost=Decimal("0"),
|
||||
metadata=audit_metadata,
|
||||
)
|
||||
)
|
||||
return account
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from core.config.settings import config
|
||||
from schemas.domain.points import AuditLedgerMetadata
|
||||
from v1.points.adjustments import apply_adjust_points
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
|
||||
async def grant_invite_rewards_for_creem_payment(
|
||||
*,
|
||||
repository: PointsRepository,
|
||||
invitee_user_id: UUID,
|
||||
invitee_email: str,
|
||||
creem_transaction_id: UUID,
|
||||
paid_at: datetime,
|
||||
) -> None:
|
||||
reward_points = int(config.points_policy.invite_reward_points)
|
||||
if reward_points <= 0:
|
||||
return
|
||||
|
||||
referral = await repository.get_referral_by_invitee_for_update(
|
||||
invitee_user_id=invitee_user_id
|
||||
)
|
||||
if referral is None:
|
||||
return
|
||||
if (
|
||||
referral.inviter_reward_granted_at is not None
|
||||
or referral.invitee_reward_granted_at is not None
|
||||
):
|
||||
return
|
||||
|
||||
referral.first_creem_transaction_id = creem_transaction_id
|
||||
referral.first_creem_paid_at = paid_at
|
||||
|
||||
inviter_event_id = f"invite.reward.inviter:{referral.id}"
|
||||
invitee_event_id = f"invite.reward.invitee:{referral.id}"
|
||||
inviter_email = await repository.get_user_email(user_id=referral.inviter_user_id)
|
||||
|
||||
inviter_account = await apply_adjust_points(
|
||||
repository=repository,
|
||||
user_id=referral.inviter_user_id,
|
||||
user_email=inviter_email or "",
|
||||
event_id=inviter_event_id,
|
||||
amount=reward_points,
|
||||
reason="invite_reward_inviter",
|
||||
audit_metadata=AuditLedgerMetadata(
|
||||
source="invite_reward_inviter",
|
||||
referral_id=str(referral.id),
|
||||
transaction_id=str(creem_transaction_id),
|
||||
role="inviter",
|
||||
),
|
||||
ledger_ext={
|
||||
"reason": "invite_reward_inviter",
|
||||
"referral_id": str(referral.id),
|
||||
"invitee_user_id": str(invitee_user_id),
|
||||
"creem_transaction_id": str(creem_transaction_id),
|
||||
},
|
||||
)
|
||||
invitee_account = await apply_adjust_points(
|
||||
repository=repository,
|
||||
user_id=invitee_user_id,
|
||||
user_email=invitee_email,
|
||||
event_id=invitee_event_id,
|
||||
amount=reward_points,
|
||||
reason="invite_reward_invitee",
|
||||
audit_metadata=AuditLedgerMetadata(
|
||||
source="invite_reward_invitee",
|
||||
referral_id=str(referral.id),
|
||||
transaction_id=str(creem_transaction_id),
|
||||
role="invitee",
|
||||
),
|
||||
ledger_ext={
|
||||
"reason": "invite_reward_invitee",
|
||||
"referral_id": str(referral.id),
|
||||
"inviter_user_id": str(referral.inviter_user_id),
|
||||
"creem_transaction_id": str(creem_transaction_id),
|
||||
},
|
||||
)
|
||||
|
||||
referral.inviter_reward_event_id = inviter_event_id
|
||||
referral.invitee_reward_event_id = invitee_event_id
|
||||
referral.inviter_reward_granted_at = paid_at
|
||||
referral.invitee_reward_granted_at = paid_at
|
||||
await repository.append_system_audit_log(
|
||||
actor_user_id=None,
|
||||
target_user_id=invitee_user_id,
|
||||
action="invite.reward_grant",
|
||||
entity_type="invite_referral",
|
||||
entity_id=referral.id,
|
||||
metadata={
|
||||
"creem_transaction_id": str(creem_transaction_id),
|
||||
"reward_points": reward_points,
|
||||
"inviter_user_id": str(referral.inviter_user_id),
|
||||
"invitee_user_id": str(invitee_user_id),
|
||||
"inviter_balance_after": int(inviter_account.balance),
|
||||
"invitee_balance_after": int(invitee_account.balance),
|
||||
},
|
||||
)
|
||||
@@ -9,10 +9,14 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.agent_chat_message import AgentChatMessage
|
||||
from models.auth_user import AuthUser
|
||||
from models.invite_referral import InviteReferral
|
||||
from models.points_audit_ledger import PointsAuditLedger
|
||||
from models.points_ledger import PointsLedger
|
||||
from models.profile import Profile
|
||||
from models.register_bonus_claims import RegisterBonusClaims
|
||||
from models.redeem_code import RedeemCode
|
||||
from models.system_audit_log import SystemAuditLog
|
||||
from models.user_points import UserPoints
|
||||
from schemas.domain.points import (
|
||||
AppendAuditLedgerCommand,
|
||||
@@ -47,6 +51,24 @@ class PointsRepository:
|
||||
row = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
return row is not None
|
||||
|
||||
async def get_redeem_code_for_update(self, *, code: str) -> RedeemCode | None:
|
||||
stmt = select(RedeemCode).where(RedeemCode.code == code).with_for_update()
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_referral_by_invitee_for_update(
|
||||
self, *, invitee_user_id: UUID
|
||||
) -> InviteReferral | None:
|
||||
stmt = (
|
||||
select(InviteReferral)
|
||||
.where(InviteReferral.invitee_user_id == invitee_user_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_user_email(self, *, user_id: UUID) -> str | None:
|
||||
stmt = select(AuthUser.email).where(AuthUser.id == user_id).limit(1)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def append_ledger(
|
||||
self,
|
||||
*,
|
||||
@@ -97,6 +119,28 @@ class PointsRepository:
|
||||
self._session.add(entry)
|
||||
await self._session.flush()
|
||||
|
||||
async def append_system_audit_log(
|
||||
self,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
target_user_id: UUID | None,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: UUID | None,
|
||||
metadata: dict[str, object],
|
||||
) -> None:
|
||||
self._session.add(
|
||||
SystemAuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
target_user_id=target_user_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def get_run_usage_snapshot(
|
||||
self,
|
||||
*,
|
||||
@@ -226,3 +270,6 @@ class PointsRepository:
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return (items, has_more)
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self._session.commit()
|
||||
|
||||
@@ -14,6 +14,8 @@ from v1.points.schemas import (
|
||||
PointsBalanceResponse,
|
||||
LedgerListResponse,
|
||||
LedgerItem,
|
||||
RedeemCodeRequest,
|
||||
RedeemCodeResponse,
|
||||
)
|
||||
from v1.points.service import PointsService
|
||||
from v1.users.dependencies import get_current_user
|
||||
@@ -108,3 +110,23 @@ async def get_points_ledger(
|
||||
nextCursor=next_cursor,
|
||||
hasMore=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/redeem-codes/redeem", response_model=RedeemCodeResponse)
|
||||
async def redeem_code(
|
||||
payload: RedeemCodeRequest,
|
||||
service: Annotated[PointsService, Depends(get_points_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> RedeemCodeResponse:
|
||||
result = await service.redeem_code(
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email or "",
|
||||
raw_code=payload.code,
|
||||
)
|
||||
return RedeemCodeResponse(
|
||||
packageProductCode=result.package_product_code,
|
||||
packageName=result.package_name,
|
||||
credits=result.credits,
|
||||
balanceAfter=result.balance_after,
|
||||
redeemedAt=result.redeemed_at.isoformat(),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,9 @@ class PackageInfo(BaseModel):
|
||||
starter_eligible: bool = Field(alias="starterEligible")
|
||||
sort_order: int = Field(alias="sortOrder", ge=0)
|
||||
price_cents: int | None = Field(alias="priceCents", default=None, ge=0)
|
||||
currency: str | None = Field(alias="currency", default=None, min_length=3, max_length=8)
|
||||
currency: str | None = Field(
|
||||
alias="currency", default=None, min_length=3, max_length=8
|
||||
)
|
||||
|
||||
|
||||
class PackagesResponse(BaseModel):
|
||||
@@ -57,3 +59,19 @@ class LedgerListResponse(BaseModel):
|
||||
items: list[LedgerItem]
|
||||
next_cursor: str | None = Field(alias="nextCursor", default=None)
|
||||
has_more: bool = Field(alias="hasMore")
|
||||
|
||||
|
||||
class RedeemCodeRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class RedeemCodeResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
package_product_code: str = Field(alias="packageProductCode")
|
||||
package_name: str = Field(alias="packageName")
|
||||
credits: int = Field(ge=1)
|
||||
balance_after: int = Field(alias="balanceAfter", ge=0)
|
||||
redeemed_at: str = Field(alias="redeemedAt")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -22,6 +23,10 @@ from schemas.domain.points import ApplyPointsChangeCommand
|
||||
from v1.payments.service import _load_product_mappings
|
||||
from v1.payments.creem_service import _load_creem_product_mappings
|
||||
from v1.payments.creem_client import CreemClient
|
||||
from v1.points.adjustments import apply_adjust_points
|
||||
from v1.points.invite_rewards import (
|
||||
grant_invite_rewards_for_creem_payment as grant_invite_rewards_for_creem_payment_command,
|
||||
)
|
||||
from v1.points.repository import PointsRepository
|
||||
from v1.points.schemas import LedgerItem
|
||||
|
||||
@@ -29,6 +34,7 @@ if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
RUN_POINTS_COST = 20
|
||||
REDEEM_CODE_PATTERN = re.compile(r"^[A-Z0-9]{8,32}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -64,6 +70,15 @@ class RegisterBonusResult:
|
||||
is_first_registration: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RedeemCodeResult:
|
||||
package_product_code: str
|
||||
package_name: str
|
||||
credits: int
|
||||
balance_after: int
|
||||
redeemed_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageInfoResult:
|
||||
product_code: str
|
||||
@@ -431,6 +446,113 @@ class PointsService:
|
||||
cost=usage_snapshot.cost,
|
||||
)
|
||||
|
||||
async def redeem_code(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
user_email: str,
|
||||
raw_code: str,
|
||||
) -> RedeemCodeResult:
|
||||
code = raw_code.strip().upper()
|
||||
if not REDEEM_CODE_PATTERN.fullmatch(code):
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="REDEEM_CODE_INVALID_FORMAT",
|
||||
detail="Redeem code must be 8-32 uppercase letters or digits",
|
||||
),
|
||||
)
|
||||
|
||||
redeem_code = await self._repository.get_redeem_code_for_update(code=code)
|
||||
if redeem_code is None:
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="REDEEM_CODE_NOT_FOUND",
|
||||
detail="Redeem code not found",
|
||||
),
|
||||
)
|
||||
if redeem_code.status == "redeemed":
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="REDEEM_CODE_ALREADY_REDEEMED",
|
||||
detail="Redeem code has already been redeemed",
|
||||
),
|
||||
)
|
||||
if redeem_code.status != "active":
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="REDEEM_CODE_DISABLED",
|
||||
detail="Redeem code is not active",
|
||||
),
|
||||
)
|
||||
|
||||
event_hash = hashlib.sha1(code.encode("utf-8")).hexdigest()
|
||||
event_id = f"redeem.code:{event_hash}"
|
||||
redeemed_at = datetime.now(timezone.utc)
|
||||
account = await apply_adjust_points(
|
||||
repository=self._repository,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
event_id=event_id,
|
||||
amount=int(redeem_code.credits),
|
||||
reason="redeem_code_activation",
|
||||
audit_metadata=AuditLedgerMetadata(
|
||||
source="redeem_code_activation",
|
||||
redeem_code_id=str(redeem_code.id),
|
||||
package_product_code=redeem_code.package_product_code,
|
||||
),
|
||||
ledger_ext={
|
||||
"reason": "redeem_code_activation",
|
||||
"redeem_code_id": str(redeem_code.id),
|
||||
"package_product_code": redeem_code.package_product_code,
|
||||
"package_name": redeem_code.package_name_snapshot,
|
||||
},
|
||||
)
|
||||
|
||||
redeem_code.status = "redeemed"
|
||||
redeem_code.redeemed_by_user_id = user_id
|
||||
redeem_code.redeemed_at = redeemed_at
|
||||
redeem_code.redeem_event_id = event_id
|
||||
await self._repository.append_system_audit_log(
|
||||
actor_user_id=user_id,
|
||||
target_user_id=user_id,
|
||||
action="redeem_code.activate",
|
||||
entity_type="redeem_code",
|
||||
entity_id=redeem_code.id,
|
||||
metadata={
|
||||
"package_product_code": redeem_code.package_product_code,
|
||||
"credits": int(redeem_code.credits),
|
||||
"event_id": event_id,
|
||||
},
|
||||
)
|
||||
await self._repository.commit()
|
||||
return RedeemCodeResult(
|
||||
package_product_code=redeem_code.package_product_code,
|
||||
package_name=redeem_code.package_name_snapshot,
|
||||
credits=int(redeem_code.credits),
|
||||
balance_after=int(account.balance),
|
||||
redeemed_at=redeemed_at,
|
||||
)
|
||||
|
||||
async def grant_invite_rewards_for_creem_payment(
|
||||
self,
|
||||
*,
|
||||
invitee_user_id: UUID,
|
||||
invitee_email: str,
|
||||
creem_transaction_id: UUID,
|
||||
paid_at: datetime,
|
||||
) -> None:
|
||||
await grant_invite_rewards_for_creem_payment_command(
|
||||
repository=self._repository,
|
||||
invitee_user_id=invitee_user_id,
|
||||
invitee_email=invitee_email,
|
||||
creem_transaction_id=creem_transaction_id,
|
||||
paid_at=paid_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
Reference in New Issue
Block a user