ai-lls-lib 1.1.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
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.
- ai_lls_lib/__init__.py +1 -1
- ai_lls_lib/auth/__init__.py +4 -0
- ai_lls_lib/auth/context_parser.py +68 -0
- ai_lls_lib/cli/__main__.py +2 -1
- ai_lls_lib/cli/commands/stripe.py +307 -0
- ai_lls_lib/cli/env_loader.py +122 -0
- ai_lls_lib/payment/__init__.py +13 -0
- ai_lls_lib/payment/credit_manager.py +174 -0
- ai_lls_lib/payment/models.py +96 -0
- ai_lls_lib/payment/stripe_manager.py +473 -0
- ai_lls_lib/payment/webhook_processor.py +163 -0
- {ai_lls_lib-1.1.0.dist-info → ai_lls_lib-1.2.0.dist-info}/METADATA +2 -1
- {ai_lls_lib-1.1.0.dist-info → ai_lls_lib-1.2.0.dist-info}/RECORD +15 -6
- {ai_lls_lib-1.1.0.dist-info → ai_lls_lib-1.2.0.dist-info}/WHEEL +0 -0
- {ai_lls_lib-1.1.0.dist-info → ai_lls_lib-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,174 @@
|
|
1
|
+
"""Credit balance management with DynamoDB."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from typing import Optional, Dict, Any
|
5
|
+
from decimal import Decimal
|
6
|
+
import logging
|
7
|
+
from datetime import datetime
|
8
|
+
|
9
|
+
try:
|
10
|
+
import boto3
|
11
|
+
from botocore.exceptions import ClientError
|
12
|
+
except ImportError:
|
13
|
+
boto3 = None # Handle gracefully for testing
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class CreditManager:
|
19
|
+
"""
|
20
|
+
Manages user credit balances in DynamoDB CreditsTable.
|
21
|
+
"""
|
22
|
+
|
23
|
+
def __init__(self, table_name: Optional[str] = None):
|
24
|
+
"""Initialize with DynamoDB table."""
|
25
|
+
if not boto3:
|
26
|
+
logger.warning("boto3 not installed, using mock credit manager")
|
27
|
+
self.table = None
|
28
|
+
return
|
29
|
+
|
30
|
+
self.dynamodb = boto3.resource("dynamodb")
|
31
|
+
self.table_name = table_name or os.environ.get("CREDITS_TABLE", "CreditsTable")
|
32
|
+
|
33
|
+
try:
|
34
|
+
self.table = self.dynamodb.Table(self.table_name)
|
35
|
+
except Exception as e:
|
36
|
+
logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}")
|
37
|
+
self.table = None
|
38
|
+
|
39
|
+
def get_balance(self, user_id: str) -> int:
|
40
|
+
"""Get current credit balance for a user."""
|
41
|
+
if not self.table:
|
42
|
+
return 1000 # Mock balance for testing
|
43
|
+
|
44
|
+
try:
|
45
|
+
response = self.table.get_item(Key={"user_id": user_id})
|
46
|
+
if "Item" in response:
|
47
|
+
return int(response["Item"].get("credits", 0))
|
48
|
+
return 0
|
49
|
+
except ClientError as e:
|
50
|
+
logger.error(f"Error getting balance for {user_id}: {e}")
|
51
|
+
return 0
|
52
|
+
|
53
|
+
def add_credits(self, user_id: str, amount: int) -> int:
|
54
|
+
"""Add credits to user balance and return new balance."""
|
55
|
+
if not self.table:
|
56
|
+
return 1000 + amount # Mock for testing
|
57
|
+
|
58
|
+
try:
|
59
|
+
response = self.table.update_item(
|
60
|
+
Key={"user_id": user_id},
|
61
|
+
UpdateExpression="ADD credits :amount SET updated_at = :now",
|
62
|
+
ExpressionAttributeValues={
|
63
|
+
":amount": Decimal(amount),
|
64
|
+
":now": datetime.utcnow().isoformat()
|
65
|
+
},
|
66
|
+
ReturnValues="ALL_NEW"
|
67
|
+
)
|
68
|
+
return int(response["Attributes"]["credits"])
|
69
|
+
except ClientError as e:
|
70
|
+
logger.error(f"Error adding credits for {user_id}: {e}")
|
71
|
+
raise
|
72
|
+
|
73
|
+
def deduct_credits(self, user_id: str, amount: int) -> bool:
|
74
|
+
"""
|
75
|
+
Deduct credits from user balance.
|
76
|
+
Returns True if successful, False if insufficient balance.
|
77
|
+
"""
|
78
|
+
if not self.table:
|
79
|
+
return True # Mock for testing
|
80
|
+
|
81
|
+
try:
|
82
|
+
# Conditional update - only deduct if balance >= amount
|
83
|
+
self.table.update_item(
|
84
|
+
Key={"user_id": user_id},
|
85
|
+
UpdateExpression="ADD credits :negative_amount SET updated_at = :now",
|
86
|
+
ConditionExpression="credits >= :amount",
|
87
|
+
ExpressionAttributeValues={
|
88
|
+
":negative_amount": Decimal(-amount),
|
89
|
+
":amount": Decimal(amount),
|
90
|
+
":now": datetime.utcnow().isoformat()
|
91
|
+
}
|
92
|
+
)
|
93
|
+
return True
|
94
|
+
except ClientError as e:
|
95
|
+
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
|
96
|
+
logger.info(f"Insufficient credits for {user_id}")
|
97
|
+
return False
|
98
|
+
logger.error(f"Error deducting credits for {user_id}: {e}")
|
99
|
+
raise
|
100
|
+
|
101
|
+
def set_subscription_state(
|
102
|
+
self,
|
103
|
+
user_id: str,
|
104
|
+
status: str,
|
105
|
+
stripe_customer_id: Optional[str] = None,
|
106
|
+
stripe_subscription_id: Optional[str] = None
|
107
|
+
) -> None:
|
108
|
+
"""Update subscription state in CreditsTable."""
|
109
|
+
if not self.table:
|
110
|
+
return # Mock for testing
|
111
|
+
|
112
|
+
try:
|
113
|
+
update_expr = "SET subscription_status = :status, updated_at = :now"
|
114
|
+
expr_values = {
|
115
|
+
":status": status,
|
116
|
+
":now": datetime.utcnow().isoformat()
|
117
|
+
}
|
118
|
+
|
119
|
+
if stripe_customer_id:
|
120
|
+
update_expr += ", stripe_customer_id = :customer_id"
|
121
|
+
expr_values[":customer_id"] = stripe_customer_id
|
122
|
+
|
123
|
+
if stripe_subscription_id:
|
124
|
+
update_expr += ", stripe_subscription_id = :subscription_id"
|
125
|
+
expr_values[":subscription_id"] = stripe_subscription_id
|
126
|
+
|
127
|
+
self.table.update_item(
|
128
|
+
Key={"user_id": user_id},
|
129
|
+
UpdateExpression=update_expr,
|
130
|
+
ExpressionAttributeValues=expr_values
|
131
|
+
)
|
132
|
+
except ClientError as e:
|
133
|
+
logger.error(f"Error updating subscription state for {user_id}: {e}")
|
134
|
+
raise
|
135
|
+
|
136
|
+
def get_user_payment_info(self, user_id: str) -> Dict[str, Any]:
|
137
|
+
"""Get user's payment-related information."""
|
138
|
+
if not self.table:
|
139
|
+
return {
|
140
|
+
"credits": 1000,
|
141
|
+
"stripe_customer_id": None,
|
142
|
+
"stripe_subscription_id": None,
|
143
|
+
"subscription_status": None
|
144
|
+
}
|
145
|
+
|
146
|
+
try:
|
147
|
+
response = self.table.get_item(Key={"user_id": user_id})
|
148
|
+
if "Item" in response:
|
149
|
+
item = response["Item"]
|
150
|
+
return {
|
151
|
+
"credits": int(item.get("credits", 0)),
|
152
|
+
"stripe_customer_id": item.get("stripe_customer_id"),
|
153
|
+
"stripe_subscription_id": item.get("stripe_subscription_id"),
|
154
|
+
"subscription_status": item.get("subscription_status")
|
155
|
+
}
|
156
|
+
return {
|
157
|
+
"credits": 0,
|
158
|
+
"stripe_customer_id": None,
|
159
|
+
"stripe_subscription_id": None,
|
160
|
+
"subscription_status": None
|
161
|
+
}
|
162
|
+
except ClientError as e:
|
163
|
+
logger.error(f"Error getting payment info for {user_id}: {e}")
|
164
|
+
return {
|
165
|
+
"credits": 0,
|
166
|
+
"stripe_customer_id": None,
|
167
|
+
"stripe_subscription_id": None,
|
168
|
+
"subscription_status": None
|
169
|
+
}
|
170
|
+
|
171
|
+
def has_unlimited_access(self, user_id: str) -> bool:
|
172
|
+
"""Check if user has unlimited access via active subscription."""
|
173
|
+
info = self.get_user_payment_info(user_id)
|
174
|
+
return info.get("subscription_status") == "active"
|
@@ -0,0 +1,96 @@
|
|
1
|
+
"""Payment data models with legacy shape compatibility."""
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import Optional, Dict, Any
|
5
|
+
from enum import Enum
|
6
|
+
|
7
|
+
|
8
|
+
class PlanType(Enum):
|
9
|
+
"""Plan types matching legacy frontend expectations."""
|
10
|
+
PREPAID = "prepaid"
|
11
|
+
POSTPAID = "postpaid"
|
12
|
+
INTRO = "intro"
|
13
|
+
|
14
|
+
|
15
|
+
class SubscriptionStatus(Enum):
|
16
|
+
"""Subscription statuses."""
|
17
|
+
ACTIVE = "active"
|
18
|
+
PAUSED = "paused"
|
19
|
+
CANCELLED = "cancelled"
|
20
|
+
PAST_DUE = "past_due"
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class Plan:
|
25
|
+
"""
|
26
|
+
Plan model matching legacy frontend data structure.
|
27
|
+
Maps from Stripe Price/Product to legacy fields.
|
28
|
+
"""
|
29
|
+
plan_reference: str # Stripe price ID or legacy reference
|
30
|
+
plan_type: str # prepaid, postpaid, intro
|
31
|
+
plan_name: str # STANDARD, POWER, ELITE, UNLIMITED
|
32
|
+
plan_subtitle: str
|
33
|
+
plan_amount: float # Price in USD
|
34
|
+
plan_credits: Optional[int] # Number of credits or None for unlimited
|
35
|
+
plan_credits_text: str # Display text like "5,000 credits"
|
36
|
+
percent_off: str # Discount percentage display text
|
37
|
+
|
38
|
+
# Additional fields for internal use
|
39
|
+
stripe_price_id: Optional[str] = None
|
40
|
+
stripe_product_id: Optional[str] = None
|
41
|
+
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
43
|
+
"""Convert to dictionary for JSON serialization."""
|
44
|
+
return {
|
45
|
+
"plan_reference": self.plan_reference,
|
46
|
+
"plan_type": self.plan_type,
|
47
|
+
"plan_name": self.plan_name,
|
48
|
+
"plan_subtitle": self.plan_subtitle,
|
49
|
+
"plan_amount": self.plan_amount,
|
50
|
+
"plan_credits": self.plan_credits,
|
51
|
+
"plan_credits_text": self.plan_credits_text,
|
52
|
+
"percent_off": self.percent_off
|
53
|
+
}
|
54
|
+
|
55
|
+
@classmethod
|
56
|
+
def from_stripe_price(cls, price: Dict[str, Any], product: Dict[str, Any]) -> "Plan":
|
57
|
+
"""
|
58
|
+
Create Plan from Stripe Price and Product objects.
|
59
|
+
Maps Stripe metadata to legacy fields.
|
60
|
+
"""
|
61
|
+
metadata = price.get("metadata", {})
|
62
|
+
|
63
|
+
# Determine plan type
|
64
|
+
if price.get("recurring"):
|
65
|
+
plan_type = "postpaid"
|
66
|
+
else:
|
67
|
+
plan_type = metadata.get("plan_type", "prepaid")
|
68
|
+
|
69
|
+
# Extract credits
|
70
|
+
credits_str = metadata.get("credits", "")
|
71
|
+
if credits_str.lower() == "unlimited":
|
72
|
+
plan_credits = None
|
73
|
+
plan_credits_text = "Unlimited"
|
74
|
+
elif credits_str:
|
75
|
+
try:
|
76
|
+
plan_credits = int(credits_str)
|
77
|
+
plan_credits_text = f"{plan_credits:,} credits"
|
78
|
+
except ValueError:
|
79
|
+
plan_credits = None
|
80
|
+
plan_credits_text = credits_str
|
81
|
+
else:
|
82
|
+
plan_credits = None
|
83
|
+
plan_credits_text = ""
|
84
|
+
|
85
|
+
return cls(
|
86
|
+
plan_reference=metadata.get("plan_reference", price["id"]),
|
87
|
+
plan_type=plan_type,
|
88
|
+
plan_name=metadata.get("tier", product.get("name", "")).upper(),
|
89
|
+
plan_subtitle=metadata.get("plan_subtitle", product.get("description", "")),
|
90
|
+
plan_amount=price["unit_amount"] / 100.0, # Convert cents to dollars
|
91
|
+
plan_credits=plan_credits,
|
92
|
+
plan_credits_text=metadata.get("plan_credits_text", plan_credits_text),
|
93
|
+
percent_off=metadata.get("percent_off", ""),
|
94
|
+
stripe_price_id=price["id"],
|
95
|
+
stripe_product_id=product["id"]
|
96
|
+
)
|