feat: add invite rewards and redeem codes

This commit is contained in:
zl-q
2026-05-21 16:26:58 +08:00
parent d712645754
commit 673f8fed30
67 changed files with 3813 additions and 265 deletions
-1
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from v1.auth.gateway import SupabaseAuthGateway
from v1.auth.service import AuthService
+3 -1
View File
@@ -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:
+122 -1
View File
@@ -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)
+13 -1
View File
@@ -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,
)
+50 -4
View File
@@ -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)
+192 -4
View File
@@ -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
],
)
+1 -3
View File
@@ -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)
+3 -1
View File
@@ -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)
+16 -5
View File
@@ -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(
+2 -6
View File
@@ -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):
+10 -5
View File
@@ -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",
+73
View File
@@ -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
+101
View File
@@ -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),
},
)
+47
View File
@@ -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()
+22
View File
@@ -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(),
)
+19 -1
View File
@@ -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")
+123 -1
View File
@@ -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()