ai-lls-lib 1.0.0__py3-none-any.whl → 1.0.0rc1__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.

Potentially problematic release.


This version of ai-lls-lib might be problematic. Click here for more details.

@@ -0,0 +1,186 @@
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
+ raise RuntimeError("boto3 is required for CreditManager")
27
+
28
+ self.dynamodb = boto3.resource("dynamodb")
29
+ self.table_name = table_name if table_name else os.environ['CREDITS_TABLE']
30
+
31
+ try:
32
+ self.table = self.dynamodb.Table(self.table_name)
33
+ except Exception as e:
34
+ logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}")
35
+ self.table = None
36
+
37
+ def get_balance(self, user_id: str) -> int:
38
+ """Get current credit balance for a user."""
39
+ if not self.table:
40
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
41
+
42
+ try:
43
+ response = self.table.get_item(Key={"user_id": user_id})
44
+ if "Item" in response:
45
+ return int(response["Item"].get("credits", 0))
46
+ return 0
47
+ except ClientError as e:
48
+ logger.error(f"Error getting balance for {user_id}: {e}")
49
+ return 0
50
+
51
+ def add_credits(self, user_id: str, amount: int) -> int:
52
+ """Add credits to user balance and return new balance."""
53
+ if not self.table:
54
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
55
+
56
+ try:
57
+ response = self.table.update_item(
58
+ Key={"user_id": user_id},
59
+ UpdateExpression="ADD credits :amount SET updated_at = :now",
60
+ ExpressionAttributeValues={
61
+ ":amount": Decimal(amount),
62
+ ":now": datetime.utcnow().isoformat()
63
+ },
64
+ ReturnValues="ALL_NEW"
65
+ )
66
+ return int(response["Attributes"]["credits"])
67
+ except ClientError as e:
68
+ logger.error(f"Error adding credits for {user_id}: {e}")
69
+ raise
70
+
71
+ def deduct_credits(self, user_id: str, amount: int) -> bool:
72
+ """
73
+ Deduct credits from user balance.
74
+ Returns True if successful, False if insufficient balance.
75
+ """
76
+ if not self.table:
77
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
78
+
79
+ try:
80
+ # Conditional update - only deduct if balance >= amount
81
+ self.table.update_item(
82
+ Key={"user_id": user_id},
83
+ UpdateExpression="ADD credits :negative_amount SET updated_at = :now",
84
+ ConditionExpression="credits >= :amount",
85
+ ExpressionAttributeValues={
86
+ ":negative_amount": Decimal(-amount),
87
+ ":amount": Decimal(amount),
88
+ ":now": datetime.utcnow().isoformat()
89
+ }
90
+ )
91
+ return True
92
+ except ClientError as e:
93
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
94
+ logger.info(f"Insufficient credits for {user_id}")
95
+ return False
96
+ logger.error(f"Error deducting credits for {user_id}: {e}")
97
+ raise
98
+
99
+ def set_subscription_state(
100
+ self,
101
+ user_id: str,
102
+ status: str,
103
+ stripe_customer_id: Optional[str] = None,
104
+ stripe_subscription_id: Optional[str] = None
105
+ ) -> None:
106
+ """Update subscription state in CreditsTable."""
107
+ if not self.table:
108
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
109
+
110
+ try:
111
+ update_expr = "SET subscription_status = :status, updated_at = :now"
112
+ expr_values = {
113
+ ":status": status,
114
+ ":now": datetime.utcnow().isoformat()
115
+ }
116
+
117
+ if stripe_customer_id:
118
+ update_expr += ", stripe_customer_id = :customer_id"
119
+ expr_values[":customer_id"] = stripe_customer_id
120
+
121
+ if stripe_subscription_id:
122
+ update_expr += ", stripe_subscription_id = :subscription_id"
123
+ expr_values[":subscription_id"] = stripe_subscription_id
124
+
125
+ self.table.update_item(
126
+ Key={"user_id": user_id},
127
+ UpdateExpression=update_expr,
128
+ ExpressionAttributeValues=expr_values
129
+ )
130
+ except ClientError as e:
131
+ logger.error(f"Error updating subscription state for {user_id}: {e}")
132
+ raise
133
+
134
+ def get_user_payment_info(self, user_id: str) -> Dict[str, Any]:
135
+ """Get user's payment-related information."""
136
+ if not self.table:
137
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
138
+
139
+ try:
140
+ response = self.table.get_item(Key={"user_id": user_id})
141
+ if "Item" in response:
142
+ item = response["Item"]
143
+ return {
144
+ "credits": int(item.get("credits", 0)),
145
+ "stripe_customer_id": item.get("stripe_customer_id"),
146
+ "stripe_subscription_id": item.get("stripe_subscription_id"),
147
+ "subscription_status": item.get("subscription_status")
148
+ }
149
+ return {
150
+ "credits": 0,
151
+ "stripe_customer_id": None,
152
+ "stripe_subscription_id": None,
153
+ "subscription_status": None
154
+ }
155
+ except ClientError as e:
156
+ logger.error(f"Error getting payment info for {user_id}: {e}")
157
+ return {
158
+ "credits": 0,
159
+ "stripe_customer_id": None,
160
+ "stripe_subscription_id": None,
161
+ "subscription_status": None
162
+ }
163
+
164
+ def has_unlimited_access(self, user_id: str) -> bool:
165
+ """Check if user has unlimited access via active subscription."""
166
+ info = self.get_user_payment_info(user_id)
167
+ return info.get("subscription_status") == "active"
168
+
169
+ def set_stripe_customer_id(self, user_id: str, stripe_customer_id: str) -> None:
170
+ """Store Stripe customer ID for a user."""
171
+ if not self.table:
172
+ raise RuntimeError(f"DynamoDB table {self.table_name} not accessible")
173
+
174
+ try:
175
+ self.table.update_item(
176
+ Key={"user_id": user_id},
177
+ UpdateExpression="SET stripe_customer_id = :customer_id, updated_at = :now",
178
+ ExpressionAttributeValues={
179
+ ":customer_id": stripe_customer_id,
180
+ ":now": datetime.utcnow().isoformat()
181
+ }
182
+ )
183
+ logger.info(f"Stored Stripe customer ID {stripe_customer_id} for user {user_id}")
184
+ except ClientError as e:
185
+ logger.error(f"Error storing Stripe customer ID for {user_id}: {e}")
186
+ raise
@@ -0,0 +1,102 @@
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
+ result = {
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
+ # Add variable_amount flag for VARIABLE product
56
+ if self.plan_name == "VARIABLE":
57
+ result["variable_amount"] = True
58
+
59
+ return result
60
+
61
+ @classmethod
62
+ def from_stripe_price(cls, price: Dict[str, Any], product: Dict[str, Any]) -> "Plan":
63
+ """
64
+ Create Plan from Stripe Price and Product objects.
65
+ Maps Stripe metadata to legacy fields.
66
+ """
67
+ metadata = price.get("metadata", {})
68
+
69
+ # Determine plan type
70
+ if price.get("recurring"):
71
+ plan_type = "postpaid"
72
+ else:
73
+ plan_type = metadata.get("plan_type", "prepaid")
74
+
75
+ # Extract credits
76
+ credits_str = metadata.get("credits", "")
77
+ if credits_str.lower() == "unlimited":
78
+ plan_credits = None
79
+ plan_credits_text = "Unlimited"
80
+ elif credits_str:
81
+ try:
82
+ plan_credits = int(credits_str)
83
+ plan_credits_text = f"{plan_credits:,} credits"
84
+ except ValueError:
85
+ plan_credits = None
86
+ plan_credits_text = credits_str
87
+ else:
88
+ plan_credits = None
89
+ plan_credits_text = ""
90
+
91
+ return cls(
92
+ plan_reference=metadata.get("plan_reference", price["id"]),
93
+ plan_type=plan_type,
94
+ plan_name=metadata.get("tier", product.get("name", "")).upper(),
95
+ plan_subtitle=metadata.get("plan_subtitle", product.get("description", "")),
96
+ plan_amount=price["unit_amount"] / 100.0, # Convert cents to dollars
97
+ plan_credits=plan_credits,
98
+ plan_credits_text=metadata.get("plan_credits_text", plan_credits_text),
99
+ percent_off=metadata.get("percent_off", ""),
100
+ stripe_price_id=price["id"],
101
+ stripe_product_id=product["id"]
102
+ )