payplus-python 0.1.2__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.
- examples/basic_payment.py +29 -0
- examples/fastapi_webhooks.py +130 -0
- examples/subscription_saas.py +206 -0
- payplus/__init__.py +30 -0
- payplus/api/__init__.py +15 -0
- payplus/api/base.py +37 -0
- payplus/api/payment_pages.py +176 -0
- payplus/api/payments.py +117 -0
- payplus/api/recurring.py +216 -0
- payplus/api/transactions.py +203 -0
- payplus/client.py +211 -0
- payplus/exceptions.py +57 -0
- payplus/models/__init__.py +23 -0
- payplus/models/customer.py +136 -0
- payplus/models/invoice.py +242 -0
- payplus/models/payment.py +179 -0
- payplus/models/subscription.py +193 -0
- payplus/models/tier.py +226 -0
- payplus/subscriptions/__init__.py +11 -0
- payplus/subscriptions/billing.py +231 -0
- payplus/subscriptions/manager.py +600 -0
- payplus/subscriptions/storage.py +571 -0
- payplus/webhooks/__init__.py +10 -0
- payplus/webhooks/handler.py +370 -0
- payplus_python-0.1.2.dist-info/METADATA +446 -0
- payplus_python-0.1.2.dist-info/RECORD +31 -0
- payplus_python-0.1.2.dist-info/WHEEL +5 -0
- payplus_python-0.1.2.dist-info/licenses/LICENSE +21 -0
- payplus_python-0.1.2.dist-info/top_level.txt +3 -0
- tests/__init__.py +1 -0
- tests/test_models.py +348 -0
payplus/client.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PayPlus API Client - Core HTTP client for PayPlus payment gateway.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from payplus.api.payments import PaymentsAPI
|
|
15
|
+
from payplus.api.recurring import RecurringAPI
|
|
16
|
+
from payplus.api.transactions import TransactionsAPI
|
|
17
|
+
from payplus.api.payment_pages import PaymentPagesAPI
|
|
18
|
+
from payplus.exceptions import PayPlusError, PayPlusAPIError, PayPlusAuthError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PayPlus:
|
|
22
|
+
"""
|
|
23
|
+
PayPlus API client.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
client = PayPlus(
|
|
27
|
+
api_key="your_api_key",
|
|
28
|
+
secret_key="your_secret_key",
|
|
29
|
+
terminal_uid="your_terminal_uid"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Generate a payment link
|
|
33
|
+
link = client.payment_pages.generate_link(
|
|
34
|
+
amount=100.00,
|
|
35
|
+
currency="ILS",
|
|
36
|
+
description="Monthly subscription"
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
BASE_URL = "https://restapi.payplus.co.il/api/v1.0"
|
|
41
|
+
SANDBOX_URL = "https://restapidev.payplus.co.il/api/v1.0"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
secret_key: str,
|
|
47
|
+
terminal_uid: Optional[str] = None,
|
|
48
|
+
sandbox: bool = False,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Initialize PayPlus client.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
api_key: PayPlus API key
|
|
56
|
+
secret_key: PayPlus secret key
|
|
57
|
+
terminal_uid: Terminal UID (optional, uses default if not provided)
|
|
58
|
+
sandbox: Use sandbox environment
|
|
59
|
+
timeout: Request timeout in seconds
|
|
60
|
+
"""
|
|
61
|
+
self.api_key = api_key
|
|
62
|
+
self.secret_key = secret_key
|
|
63
|
+
self.terminal_uid = terminal_uid
|
|
64
|
+
self.sandbox = sandbox
|
|
65
|
+
self.base_url = self.SANDBOX_URL if sandbox else self.BASE_URL
|
|
66
|
+
|
|
67
|
+
self._client = httpx.Client(
|
|
68
|
+
timeout=timeout,
|
|
69
|
+
headers=self._get_headers(),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._async_client: Optional[httpx.AsyncClient] = None
|
|
73
|
+
|
|
74
|
+
# API endpoints
|
|
75
|
+
self.payments = PaymentsAPI(self)
|
|
76
|
+
self.recurring = RecurringAPI(self)
|
|
77
|
+
self.transactions = TransactionsAPI(self)
|
|
78
|
+
self.payment_pages = PaymentPagesAPI(self)
|
|
79
|
+
|
|
80
|
+
def _get_headers(self) -> dict[str, str]:
|
|
81
|
+
"""Get default headers for API requests."""
|
|
82
|
+
return {
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"Authorization": json.dumps({
|
|
85
|
+
"api_key": self.api_key,
|
|
86
|
+
"secret_key": self.secret_key,
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def async_client(self) -> httpx.AsyncClient:
|
|
92
|
+
"""Get or create async HTTP client."""
|
|
93
|
+
if self._async_client is None:
|
|
94
|
+
self._async_client = httpx.AsyncClient(
|
|
95
|
+
timeout=30.0,
|
|
96
|
+
headers=self._get_headers(),
|
|
97
|
+
)
|
|
98
|
+
return self._async_client
|
|
99
|
+
|
|
100
|
+
def _request(
|
|
101
|
+
self,
|
|
102
|
+
method: str,
|
|
103
|
+
endpoint: str,
|
|
104
|
+
data: Optional[dict[str, Any]] = None,
|
|
105
|
+
params: Optional[dict[str, Any]] = None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Make a synchronous API request."""
|
|
108
|
+
url = f"{self.base_url}/{endpoint}"
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
response = self._client.request(
|
|
112
|
+
method=method,
|
|
113
|
+
url=url,
|
|
114
|
+
json=data,
|
|
115
|
+
params=params,
|
|
116
|
+
)
|
|
117
|
+
return self._handle_response(response)
|
|
118
|
+
except httpx.HTTPError as e:
|
|
119
|
+
raise PayPlusError(f"HTTP error: {e}") from e
|
|
120
|
+
|
|
121
|
+
async def _async_request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
endpoint: str,
|
|
125
|
+
data: Optional[dict[str, Any]] = None,
|
|
126
|
+
params: Optional[dict[str, Any]] = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Make an asynchronous API request."""
|
|
129
|
+
url = f"{self.base_url}/{endpoint}"
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
response = await self.async_client.request(
|
|
133
|
+
method=method,
|
|
134
|
+
url=url,
|
|
135
|
+
json=data,
|
|
136
|
+
params=params,
|
|
137
|
+
)
|
|
138
|
+
return self._handle_response(response)
|
|
139
|
+
except httpx.HTTPError as e:
|
|
140
|
+
raise PayPlusError(f"HTTP error: {e}") from e
|
|
141
|
+
|
|
142
|
+
def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
143
|
+
"""Handle API response."""
|
|
144
|
+
try:
|
|
145
|
+
data = response.json()
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
raise PayPlusError(f"Invalid JSON response: {response.text}")
|
|
148
|
+
|
|
149
|
+
if response.status_code == 401:
|
|
150
|
+
raise PayPlusAuthError("Authentication failed. Check your API credentials.")
|
|
151
|
+
|
|
152
|
+
if response.status_code >= 400:
|
|
153
|
+
error_msg = data.get("message") or data.get("error") or str(data)
|
|
154
|
+
raise PayPlusAPIError(
|
|
155
|
+
message=error_msg,
|
|
156
|
+
status_code=response.status_code,
|
|
157
|
+
response=data,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# PayPlus specific error handling
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
results = data.get("results", {})
|
|
163
|
+
if isinstance(results, dict) and results.get("status") == "error":
|
|
164
|
+
raise PayPlusAPIError(
|
|
165
|
+
message=results.get("description", "Unknown error"),
|
|
166
|
+
status_code=response.status_code,
|
|
167
|
+
response=data,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
def verify_webhook_signature(
|
|
173
|
+
self,
|
|
174
|
+
payload: bytes,
|
|
175
|
+
signature: str,
|
|
176
|
+
) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
Verify webhook/IPN signature.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
payload: Raw request body
|
|
182
|
+
signature: Signature from X-PayPlus-Signature header
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if signature is valid
|
|
186
|
+
"""
|
|
187
|
+
expected = hmac.new(
|
|
188
|
+
self.secret_key.encode(),
|
|
189
|
+
payload,
|
|
190
|
+
hashlib.sha256,
|
|
191
|
+
).hexdigest()
|
|
192
|
+
return hmac.compare_digest(expected, signature)
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
"""Close HTTP clients."""
|
|
196
|
+
self._client.close()
|
|
197
|
+
if self._async_client:
|
|
198
|
+
# Note: for async client, use await client.aclose() in async context
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
async def aclose(self) -> None:
|
|
202
|
+
"""Close async HTTP client."""
|
|
203
|
+
if self._async_client:
|
|
204
|
+
await self._async_client.aclose()
|
|
205
|
+
self._async_client = None
|
|
206
|
+
|
|
207
|
+
def __enter__(self) -> "PayPlus":
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
def __exit__(self, *args: Any) -> None:
|
|
211
|
+
self.close()
|
payplus/exceptions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PayPlus SDK Exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PayPlusError(Exception):
|
|
9
|
+
"""Base exception for PayPlus SDK."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PayPlusAPIError(PayPlusError):
|
|
14
|
+
"""Exception raised for API errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
status_code: Optional[int] = None,
|
|
20
|
+
response: Optional[dict[str, Any]] = None,
|
|
21
|
+
):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.response = response or {}
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
if self.status_code:
|
|
29
|
+
return f"[{self.status_code}] {self.message}"
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PayPlusAuthError(PayPlusAPIError):
|
|
34
|
+
"""Exception raised for authentication errors."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str = "Authentication failed"):
|
|
37
|
+
super().__init__(message, status_code=401)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PayPlusValidationError(PayPlusError):
|
|
41
|
+
"""Exception raised for validation errors."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SubscriptionError(PayPlusError):
|
|
46
|
+
"""Exception raised for subscription-related errors."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WebhookError(PayPlusError):
|
|
51
|
+
"""Exception raised for webhook-related errors."""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WebhookSignatureError(WebhookError):
|
|
56
|
+
"""Exception raised when webhook signature verification fails."""
|
|
57
|
+
pass
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PayPlus SDK Models for subscription management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from payplus.models.customer import Customer
|
|
6
|
+
from payplus.models.subscription import Subscription, SubscriptionStatus, BillingCycle
|
|
7
|
+
from payplus.models.payment import Payment, PaymentStatus
|
|
8
|
+
from payplus.models.invoice import Invoice, InvoiceStatus, InvoiceItem
|
|
9
|
+
from payplus.models.tier import Tier, TierFeature
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Customer",
|
|
13
|
+
"Subscription",
|
|
14
|
+
"SubscriptionStatus",
|
|
15
|
+
"BillingCycle",
|
|
16
|
+
"Payment",
|
|
17
|
+
"PaymentStatus",
|
|
18
|
+
"Invoice",
|
|
19
|
+
"InvoiceStatus",
|
|
20
|
+
"InvoiceItem",
|
|
21
|
+
"Tier",
|
|
22
|
+
"TierFeature",
|
|
23
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Customer model for subscription management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, EmailStr
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomerStatus(str, Enum):
|
|
15
|
+
"""Customer status."""
|
|
16
|
+
ACTIVE = "active"
|
|
17
|
+
INACTIVE = "inactive"
|
|
18
|
+
SUSPENDED = "suspended"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaymentMethod(BaseModel):
|
|
22
|
+
"""Stored payment method (tokenized card)."""
|
|
23
|
+
|
|
24
|
+
id: str = Field(..., description="Payment method ID")
|
|
25
|
+
token: str = Field(..., description="PayPlus card token (card_uid)")
|
|
26
|
+
card_brand: Optional[str] = Field(None, description="Card brand (Visa, Mastercard, etc.)")
|
|
27
|
+
last_four: Optional[str] = Field(None, description="Last 4 digits of card")
|
|
28
|
+
expiry_month: Optional[str] = Field(None, description="Card expiry month")
|
|
29
|
+
expiry_year: Optional[str] = Field(None, description="Card expiry year")
|
|
30
|
+
holder_name: Optional[str] = Field(None, description="Card holder name")
|
|
31
|
+
is_default: bool = Field(default=False, description="Default payment method")
|
|
32
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
33
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Customer(BaseModel):
|
|
37
|
+
"""
|
|
38
|
+
Customer model for subscription management.
|
|
39
|
+
|
|
40
|
+
This model tracks customer information, payment methods, and links to
|
|
41
|
+
subscriptions and invoices.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
id: str = Field(..., description="Unique customer ID")
|
|
45
|
+
email: EmailStr = Field(..., description="Customer email")
|
|
46
|
+
name: Optional[str] = Field(None, description="Customer full name")
|
|
47
|
+
phone: Optional[str] = Field(None, description="Customer phone")
|
|
48
|
+
company: Optional[str] = Field(None, description="Company name")
|
|
49
|
+
|
|
50
|
+
# PayPlus integration
|
|
51
|
+
payplus_customer_uid: Optional[str] = Field(None, description="PayPlus customer UID")
|
|
52
|
+
|
|
53
|
+
# Payment methods
|
|
54
|
+
payment_methods: list[PaymentMethod] = Field(default_factory=list)
|
|
55
|
+
default_payment_method_id: Optional[str] = Field(None)
|
|
56
|
+
|
|
57
|
+
# Status
|
|
58
|
+
status: CustomerStatus = Field(default=CustomerStatus.ACTIVE)
|
|
59
|
+
|
|
60
|
+
# Address
|
|
61
|
+
address_line1: Optional[str] = None
|
|
62
|
+
address_line2: Optional[str] = None
|
|
63
|
+
city: Optional[str] = None
|
|
64
|
+
state: Optional[str] = None
|
|
65
|
+
postal_code: Optional[str] = None
|
|
66
|
+
country: Optional[str] = Field(default="IL")
|
|
67
|
+
|
|
68
|
+
# Tax
|
|
69
|
+
tax_id: Optional[str] = Field(None, description="Tax ID / VAT number")
|
|
70
|
+
|
|
71
|
+
# Metadata
|
|
72
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
# Timestamps
|
|
75
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
76
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
77
|
+
|
|
78
|
+
def get_default_payment_method(self) -> Optional[PaymentMethod]:
|
|
79
|
+
"""Get the default payment method."""
|
|
80
|
+
if self.default_payment_method_id:
|
|
81
|
+
for pm in self.payment_methods:
|
|
82
|
+
if pm.id == self.default_payment_method_id:
|
|
83
|
+
return pm
|
|
84
|
+
# Return first payment method if no default set
|
|
85
|
+
if self.payment_methods:
|
|
86
|
+
return self.payment_methods[0]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def add_payment_method(
|
|
90
|
+
self,
|
|
91
|
+
token: str,
|
|
92
|
+
card_brand: Optional[str] = None,
|
|
93
|
+
last_four: Optional[str] = None,
|
|
94
|
+
expiry_month: Optional[str] = None,
|
|
95
|
+
expiry_year: Optional[str] = None,
|
|
96
|
+
holder_name: Optional[str] = None,
|
|
97
|
+
set_default: bool = True,
|
|
98
|
+
) -> PaymentMethod:
|
|
99
|
+
"""Add a new payment method."""
|
|
100
|
+
import uuid
|
|
101
|
+
|
|
102
|
+
pm = PaymentMethod(
|
|
103
|
+
id=str(uuid.uuid4()),
|
|
104
|
+
token=token,
|
|
105
|
+
card_brand=card_brand,
|
|
106
|
+
last_four=last_four,
|
|
107
|
+
expiry_month=expiry_month,
|
|
108
|
+
expiry_year=expiry_year,
|
|
109
|
+
holder_name=holder_name,
|
|
110
|
+
is_default=set_default,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if set_default:
|
|
114
|
+
for existing in self.payment_methods:
|
|
115
|
+
existing.is_default = False
|
|
116
|
+
self.default_payment_method_id = pm.id
|
|
117
|
+
|
|
118
|
+
self.payment_methods.append(pm)
|
|
119
|
+
self.updated_at = datetime.utcnow()
|
|
120
|
+
return pm
|
|
121
|
+
|
|
122
|
+
def remove_payment_method(self, payment_method_id: str) -> bool:
|
|
123
|
+
"""Remove a payment method."""
|
|
124
|
+
for i, pm in enumerate(self.payment_methods):
|
|
125
|
+
if pm.id == payment_method_id:
|
|
126
|
+
self.payment_methods.pop(i)
|
|
127
|
+
if self.default_payment_method_id == payment_method_id:
|
|
128
|
+
self.default_payment_method_id = (
|
|
129
|
+
self.payment_methods[0].id if self.payment_methods else None
|
|
130
|
+
)
|
|
131
|
+
self.updated_at = datetime.utcnow()
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
class Config:
|
|
136
|
+
use_enum_values = True
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Invoice model for billing and record-keeping.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, date
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvoiceStatus(str, Enum):
|
|
16
|
+
"""Invoice status."""
|
|
17
|
+
DRAFT = "draft"
|
|
18
|
+
OPEN = "open"
|
|
19
|
+
PAID = "paid"
|
|
20
|
+
VOID = "void"
|
|
21
|
+
UNCOLLECTIBLE = "uncollectible"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvoiceItem(BaseModel):
|
|
25
|
+
"""Line item on an invoice."""
|
|
26
|
+
|
|
27
|
+
id: str = Field(..., description="Line item ID")
|
|
28
|
+
description: str = Field(..., description="Item description")
|
|
29
|
+
quantity: int = Field(default=1)
|
|
30
|
+
unit_amount: Decimal = Field(..., description="Price per unit")
|
|
31
|
+
amount: Decimal = Field(..., description="Total line amount")
|
|
32
|
+
currency: str = Field(default="ILS")
|
|
33
|
+
|
|
34
|
+
# Period for subscription items
|
|
35
|
+
period_start: Optional[datetime] = None
|
|
36
|
+
period_end: Optional[datetime] = None
|
|
37
|
+
|
|
38
|
+
# References
|
|
39
|
+
subscription_id: Optional[str] = None
|
|
40
|
+
tier_id: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
# Proration
|
|
43
|
+
proration: bool = Field(default=False)
|
|
44
|
+
|
|
45
|
+
# Metadata
|
|
46
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvoiceDiscount(BaseModel):
|
|
50
|
+
"""Discount applied to an invoice."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
amount: Decimal
|
|
55
|
+
percent_off: Optional[Decimal] = None
|
|
56
|
+
coupon_code: Optional[str] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Invoice(BaseModel):
|
|
60
|
+
"""
|
|
61
|
+
Invoice model for billing and record-keeping.
|
|
62
|
+
|
|
63
|
+
Invoices are generated for subscription renewals and one-time charges.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
id: str = Field(..., description="Unique invoice ID")
|
|
67
|
+
number: Optional[str] = Field(None, description="Invoice number for display")
|
|
68
|
+
customer_id: str = Field(..., description="Customer ID")
|
|
69
|
+
subscription_id: Optional[str] = Field(None, description="Subscription ID if recurring")
|
|
70
|
+
|
|
71
|
+
# Status
|
|
72
|
+
status: InvoiceStatus = Field(default=InvoiceStatus.DRAFT)
|
|
73
|
+
|
|
74
|
+
# Line items
|
|
75
|
+
items: list[InvoiceItem] = Field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
# Amounts
|
|
78
|
+
subtotal: Decimal = Field(default=Decimal("0"))
|
|
79
|
+
tax: Decimal = Field(default=Decimal("0"))
|
|
80
|
+
tax_percent: Optional[Decimal] = Field(None, description="Tax percentage")
|
|
81
|
+
total: Decimal = Field(default=Decimal("0"))
|
|
82
|
+
amount_due: Decimal = Field(default=Decimal("0"))
|
|
83
|
+
amount_paid: Decimal = Field(default=Decimal("0"))
|
|
84
|
+
amount_remaining: Decimal = Field(default=Decimal("0"))
|
|
85
|
+
currency: str = Field(default="ILS")
|
|
86
|
+
|
|
87
|
+
# Discounts
|
|
88
|
+
discounts: list[InvoiceDiscount] = Field(default_factory=list)
|
|
89
|
+
total_discount: Decimal = Field(default=Decimal("0"))
|
|
90
|
+
|
|
91
|
+
# Payment
|
|
92
|
+
payment_id: Optional[str] = Field(None, description="Payment ID when paid")
|
|
93
|
+
payment_intent: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
# Billing
|
|
96
|
+
billing_reason: Optional[str] = Field(None, description="subscription_create, subscription_cycle, etc.")
|
|
97
|
+
|
|
98
|
+
# Period
|
|
99
|
+
period_start: Optional[datetime] = None
|
|
100
|
+
period_end: Optional[datetime] = None
|
|
101
|
+
|
|
102
|
+
# Due date
|
|
103
|
+
due_date: Optional[date] = None
|
|
104
|
+
|
|
105
|
+
# Customer details snapshot
|
|
106
|
+
customer_email: Optional[str] = None
|
|
107
|
+
customer_name: Optional[str] = None
|
|
108
|
+
customer_address: Optional[dict[str, str]] = None
|
|
109
|
+
customer_tax_id: Optional[str] = None
|
|
110
|
+
|
|
111
|
+
# URLs
|
|
112
|
+
hosted_invoice_url: Optional[str] = None
|
|
113
|
+
pdf_url: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
# Collection
|
|
116
|
+
collection_method: str = Field(default="charge_automatically")
|
|
117
|
+
auto_advance: bool = Field(default=True)
|
|
118
|
+
attempt_count: int = Field(default=0)
|
|
119
|
+
next_payment_attempt: Optional[datetime] = None
|
|
120
|
+
|
|
121
|
+
# Metadata
|
|
122
|
+
memo: Optional[str] = None
|
|
123
|
+
footer: Optional[str] = None
|
|
124
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
125
|
+
|
|
126
|
+
# Timestamps
|
|
127
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
128
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
129
|
+
finalized_at: Optional[datetime] = None
|
|
130
|
+
paid_at: Optional[datetime] = None
|
|
131
|
+
voided_at: Optional[datetime] = None
|
|
132
|
+
|
|
133
|
+
def add_item(
|
|
134
|
+
self,
|
|
135
|
+
item_id: str,
|
|
136
|
+
description: str,
|
|
137
|
+
unit_amount: Decimal,
|
|
138
|
+
quantity: int = 1,
|
|
139
|
+
**kwargs: Any,
|
|
140
|
+
) -> InvoiceItem:
|
|
141
|
+
"""Add a line item to the invoice."""
|
|
142
|
+
amount = unit_amount * quantity
|
|
143
|
+
|
|
144
|
+
item = InvoiceItem(
|
|
145
|
+
id=item_id,
|
|
146
|
+
description=description,
|
|
147
|
+
quantity=quantity,
|
|
148
|
+
unit_amount=unit_amount,
|
|
149
|
+
amount=amount,
|
|
150
|
+
currency=self.currency,
|
|
151
|
+
**kwargs,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self.items.append(item)
|
|
155
|
+
self._recalculate_totals()
|
|
156
|
+
return item
|
|
157
|
+
|
|
158
|
+
def add_discount(
|
|
159
|
+
self,
|
|
160
|
+
discount_id: str,
|
|
161
|
+
name: str,
|
|
162
|
+
amount: Optional[Decimal] = None,
|
|
163
|
+
percent_off: Optional[Decimal] = None,
|
|
164
|
+
coupon_code: Optional[str] = None,
|
|
165
|
+
) -> InvoiceDiscount:
|
|
166
|
+
"""Add a discount to the invoice."""
|
|
167
|
+
if percent_off:
|
|
168
|
+
calculated_amount = self.subtotal * (percent_off / 100)
|
|
169
|
+
elif amount:
|
|
170
|
+
calculated_amount = amount
|
|
171
|
+
else:
|
|
172
|
+
calculated_amount = Decimal("0")
|
|
173
|
+
|
|
174
|
+
discount = InvoiceDiscount(
|
|
175
|
+
id=discount_id,
|
|
176
|
+
name=name,
|
|
177
|
+
amount=calculated_amount,
|
|
178
|
+
percent_off=percent_off,
|
|
179
|
+
coupon_code=coupon_code,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
self.discounts.append(discount)
|
|
183
|
+
self._recalculate_totals()
|
|
184
|
+
return discount
|
|
185
|
+
|
|
186
|
+
def _recalculate_totals(self) -> None:
|
|
187
|
+
"""Recalculate invoice totals."""
|
|
188
|
+
self.subtotal = sum(item.amount for item in self.items)
|
|
189
|
+
self.total_discount = sum(d.amount for d in self.discounts)
|
|
190
|
+
|
|
191
|
+
taxable = self.subtotal - self.total_discount
|
|
192
|
+
if self.tax_percent:
|
|
193
|
+
self.tax = taxable * (self.tax_percent / 100)
|
|
194
|
+
|
|
195
|
+
self.total = taxable + self.tax
|
|
196
|
+
self.amount_remaining = self.total - self.amount_paid
|
|
197
|
+
self.amount_due = self.amount_remaining
|
|
198
|
+
self.updated_at = datetime.utcnow()
|
|
199
|
+
|
|
200
|
+
def finalize(self) -> None:
|
|
201
|
+
"""Finalize the invoice (make it ready for payment)."""
|
|
202
|
+
if self.status != InvoiceStatus.DRAFT:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
self._recalculate_totals()
|
|
206
|
+
self.status = InvoiceStatus.OPEN
|
|
207
|
+
self.finalized_at = datetime.utcnow()
|
|
208
|
+
self.updated_at = datetime.utcnow()
|
|
209
|
+
|
|
210
|
+
def mark_paid(self, payment_id: str) -> None:
|
|
211
|
+
"""Mark the invoice as paid."""
|
|
212
|
+
self.status = InvoiceStatus.PAID
|
|
213
|
+
self.payment_id = payment_id
|
|
214
|
+
self.amount_paid = self.total
|
|
215
|
+
self.amount_remaining = Decimal("0")
|
|
216
|
+
self.amount_due = Decimal("0")
|
|
217
|
+
self.paid_at = datetime.utcnow()
|
|
218
|
+
self.updated_at = datetime.utcnow()
|
|
219
|
+
|
|
220
|
+
def void(self) -> None:
|
|
221
|
+
"""Void the invoice."""
|
|
222
|
+
self.status = InvoiceStatus.VOID
|
|
223
|
+
self.voided_at = datetime.utcnow()
|
|
224
|
+
self.updated_at = datetime.utcnow()
|
|
225
|
+
|
|
226
|
+
def mark_uncollectible(self) -> None:
|
|
227
|
+
"""Mark invoice as uncollectible."""
|
|
228
|
+
self.status = InvoiceStatus.UNCOLLECTIBLE
|
|
229
|
+
self.updated_at = datetime.utcnow()
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def is_paid(self) -> bool:
|
|
233
|
+
"""Check if invoice is paid."""
|
|
234
|
+
return self.status == InvoiceStatus.PAID
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def is_open(self) -> bool:
|
|
238
|
+
"""Check if invoice is open for payment."""
|
|
239
|
+
return self.status == InvoiceStatus.OPEN
|
|
240
|
+
|
|
241
|
+
class Config:
|
|
242
|
+
use_enum_values = True
|