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.
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