EnCoach Odoo 19 custom modules
Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor
This commit is contained in:
4
encoach_subscription/services/__init__.py
Normal file
4
encoach_subscription/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import stripe_service
|
||||
from . import paypal_service
|
||||
from . import paymob_service
|
||||
from . import subscription_helper
|
||||
170
encoach_subscription/services/paymob_service.py
Normal file
170
encoach_subscription/services/paymob_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PAYMOB_BASE = "https://accept.paymob.com/api"
|
||||
|
||||
|
||||
class EncoachPaymobService:
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
params = env["ir.config_parameter"].sudo()
|
||||
self.api_key = params.get_param("encoach.paymob_api_key", "")
|
||||
self.hmac_secret = params.get_param("encoach.paymob_hmac_secret", "")
|
||||
|
||||
def _authenticate(self):
|
||||
resp = httpx.post(
|
||||
f"{PAYMOB_BASE}/auth/tokens",
|
||||
json={"api_key": self.api_key},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["token"]
|
||||
|
||||
def create_intention(self, user, package, discount_code=None):
|
||||
if not self.api_key:
|
||||
return {"error": "Paymob API key not configured"}
|
||||
|
||||
amount = package.price
|
||||
if discount_code:
|
||||
discount = self.env["encoach.discount"].sudo().search([
|
||||
("code", "=", discount_code),
|
||||
], limit=1)
|
||||
if discount and discount.is_valid():
|
||||
amount = amount * (1 - discount.percentage / 100.0)
|
||||
|
||||
amount_cents = int(round(amount * 100))
|
||||
currency = (package.currency or "EGP").upper()
|
||||
|
||||
try:
|
||||
auth_token = self._authenticate()
|
||||
|
||||
order_resp = httpx.post(
|
||||
f"{PAYMOB_BASE}/ecommerce/orders",
|
||||
json={
|
||||
"auth_token": auth_token,
|
||||
"delivery_needed": False,
|
||||
"amount_cents": amount_cents,
|
||||
"currency": currency,
|
||||
"merchant_order_id": f"encoach_{user.id}_{package.id}",
|
||||
"items": [{
|
||||
"name": package.name,
|
||||
"amount_cents": amount_cents,
|
||||
"quantity": 1,
|
||||
}],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
order_resp.raise_for_status()
|
||||
order = order_resp.json()
|
||||
|
||||
integration_id = self.env["ir.config_parameter"].sudo().get_param(
|
||||
"encoach.paymob_integration_id", ""
|
||||
)
|
||||
|
||||
key_resp = httpx.post(
|
||||
f"{PAYMOB_BASE}/acceptance/payment_keys",
|
||||
json={
|
||||
"auth_token": auth_token,
|
||||
"amount_cents": amount_cents,
|
||||
"expiration": 3600,
|
||||
"order_id": order["id"],
|
||||
"billing_data": {
|
||||
"email": user.login,
|
||||
"first_name": (user.name or "").split()[0] if user.name else "N/A",
|
||||
"last_name": (user.name or "").split()[-1] if user.name else "N/A",
|
||||
"phone_number": "N/A",
|
||||
"apartment": "N/A",
|
||||
"floor": "N/A",
|
||||
"street": "N/A",
|
||||
"building": "N/A",
|
||||
"shipping_method": "N/A",
|
||||
"postal_code": "N/A",
|
||||
"city": "N/A",
|
||||
"country": "N/A",
|
||||
"state": "N/A",
|
||||
},
|
||||
"currency": currency,
|
||||
"integration_id": int(integration_id) if integration_id else 0,
|
||||
"lock_order_when_paid": True,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
key_resp.raise_for_status()
|
||||
payment_key = key_resp.json()["token"]
|
||||
|
||||
return {
|
||||
"intentionId": str(order["id"]),
|
||||
"clientSecret": payment_key,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
_logger.error("Paymob API error: %s", e.response.text)
|
||||
return {"error": f"Paymob error: {e.response.status_code}"}
|
||||
except Exception as e:
|
||||
_logger.exception("Paymob create intention error")
|
||||
return {"error": str(e)}
|
||||
|
||||
def verify_transaction(self, payload, hmac_header):
|
||||
if not self.hmac_secret:
|
||||
_logger.warning("No Paymob HMAC secret configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
data = payload if isinstance(payload, dict) else json.loads(payload)
|
||||
obj = data.get("obj", {})
|
||||
concat_str = (
|
||||
str(obj.get("amount_cents", "")) +
|
||||
str(obj.get("created_at", "")) +
|
||||
str(obj.get("currency", "")) +
|
||||
str(obj.get("error_occured", "")).lower() +
|
||||
str(obj.get("has_parent_transaction", "")).lower() +
|
||||
str(obj.get("id", "")) +
|
||||
str(obj.get("integration_id", "")) +
|
||||
str(obj.get("is_3d_secure", "")).lower() +
|
||||
str(obj.get("is_auth", "")).lower() +
|
||||
str(obj.get("is_capture", "")).lower() +
|
||||
str(obj.get("is_refunded", "")).lower() +
|
||||
str(obj.get("is_standalone_payment", "")).lower() +
|
||||
str(obj.get("is_voided", "")).lower() +
|
||||
str(obj.get("order", {}).get("id", "")) +
|
||||
str(obj.get("owner", "")) +
|
||||
str(obj.get("pending", "")).lower() +
|
||||
str(obj.get("source_data", {}).get("pan", "")) +
|
||||
str(obj.get("source_data", {}).get("sub_type", "")) +
|
||||
str(obj.get("source_data", {}).get("type", "")) +
|
||||
str(obj.get("success", "")).lower()
|
||||
)
|
||||
|
||||
computed = hmac.new(
|
||||
self.hmac_secret.encode("utf-8"),
|
||||
concat_str.encode("utf-8"),
|
||||
hashlib.sha512,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed, hmac_header):
|
||||
return None
|
||||
|
||||
if obj.get("success") is True:
|
||||
merchant_order_id = str(obj.get("order", {}).get("merchant_order_id", ""))
|
||||
parts = merchant_order_id.replace("encoach_", "").split("_")
|
||||
user_id = int(parts[0]) if len(parts) >= 1 else 0
|
||||
package_id = int(parts[1]) if len(parts) >= 2 else 0
|
||||
amount = obj.get("amount_cents", 0) / 100.0
|
||||
currency = obj.get("currency", "EGP")
|
||||
transaction_id = str(obj.get("id", ""))
|
||||
|
||||
from .subscription_helper import extend_subscription
|
||||
extend_subscription(
|
||||
self.env, user_id, package_id, "paymob", transaction_id, amount, currency,
|
||||
)
|
||||
return {"ok": True}
|
||||
return {"error": "Transaction not successful"}
|
||||
except Exception:
|
||||
_logger.exception("Paymob verify transaction error")
|
||||
return None
|
||||
128
encoach_subscription/services/paypal_service.py
Normal file
128
encoach_subscription/services/paypal_service.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PAYPAL_SANDBOX_BASE = "https://api-m.sandbox.paypal.com"
|
||||
PAYPAL_LIVE_BASE = "https://api-m.paypal.com"
|
||||
|
||||
|
||||
class EncoachPaypalService:
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
params = env["ir.config_parameter"].sudo()
|
||||
self.client_id = params.get_param("encoach.paypal_client_id", "")
|
||||
self.client_secret = params.get_param("encoach.paypal_client_secret", "")
|
||||
is_live = params.get_param("encoach.paypal_live", "false").lower() == "true"
|
||||
self.base_url = PAYPAL_LIVE_BASE if is_live else PAYPAL_SANDBOX_BASE
|
||||
|
||||
def _get_access_token(self):
|
||||
resp = httpx.post(
|
||||
f"{self.base_url}/v1/oauth2/token",
|
||||
auth=(self.client_id, self.client_secret),
|
||||
data={"grant_type": "client_credentials"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def _headers(self, token):
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def create_order(self, user, package, discount_code=None):
|
||||
if not self.client_id or not self.client_secret:
|
||||
return {"error": "PayPal credentials not configured"}
|
||||
|
||||
amount = package.price
|
||||
if discount_code:
|
||||
discount = self.env["encoach.discount"].sudo().search([
|
||||
("code", "=", discount_code),
|
||||
], limit=1)
|
||||
if discount and discount.is_valid():
|
||||
amount = amount * (1 - discount.percentage / 100.0)
|
||||
|
||||
currency = (package.currency or "USD").upper()
|
||||
|
||||
try:
|
||||
token = self._get_access_token()
|
||||
order_data = {
|
||||
"intent": "CAPTURE",
|
||||
"purchase_units": [{
|
||||
"amount": {
|
||||
"currency_code": currency,
|
||||
"value": f"{amount:.2f}",
|
||||
},
|
||||
"description": package.name,
|
||||
"custom_id": f"{user.id}:{package.id}",
|
||||
}],
|
||||
"application_context": {
|
||||
"return_url": "https://encoach.ai/payment/success",
|
||||
"cancel_url": "https://encoach.ai/payment/cancel",
|
||||
},
|
||||
}
|
||||
|
||||
resp = httpx.post(
|
||||
f"{self.base_url}/v2/checkout/orders",
|
||||
headers=self._headers(token),
|
||||
json=order_data,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
order = resp.json()
|
||||
|
||||
approval_url = ""
|
||||
for link in order.get("links", []):
|
||||
if link["rel"] == "approve":
|
||||
approval_url = link["href"]
|
||||
break
|
||||
|
||||
return {"orderId": order["id"], "approvalUrl": approval_url}
|
||||
except httpx.HTTPStatusError as e:
|
||||
_logger.error("PayPal API error: %s", e.response.text)
|
||||
return {"error": f"PayPal error: {e.response.status_code}"}
|
||||
except Exception as e:
|
||||
_logger.exception("PayPal create order error")
|
||||
return {"error": str(e)}
|
||||
|
||||
def capture_order(self, user, order_id):
|
||||
if not self.client_id or not self.client_secret:
|
||||
return {"error": "PayPal credentials not configured"}
|
||||
|
||||
try:
|
||||
token = self._get_access_token()
|
||||
resp = httpx.post(
|
||||
f"{self.base_url}/v2/checkout/orders/{order_id}/capture",
|
||||
headers=self._headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
capture = resp.json()
|
||||
|
||||
if capture.get("status") == "COMPLETED":
|
||||
purchase_unit = capture.get("purchase_units", [{}])[0]
|
||||
custom_id = purchase_unit.get("payments", {}).get("captures", [{}])[0].get("custom_id", "")
|
||||
parts = custom_id.split(":")
|
||||
user_id = int(parts[0]) if len(parts) >= 1 else user.id
|
||||
package_id = int(parts[1]) if len(parts) >= 2 else 0
|
||||
cap_amount = purchase_unit.get("payments", {}).get("captures", [{}])[0]
|
||||
amount = float(cap_amount.get("amount", {}).get("value", 0))
|
||||
currency = cap_amount.get("amount", {}).get("currency_code", "USD")
|
||||
|
||||
from .subscription_helper import extend_subscription
|
||||
extend_subscription(
|
||||
self.env, user_id, package_id, "paypal", order_id, amount, currency,
|
||||
)
|
||||
return {"ok": True}
|
||||
else:
|
||||
return {"error": f"Order status: {capture.get('status')}"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
_logger.error("PayPal capture error: %s", e.response.text)
|
||||
return {"error": f"PayPal error: {e.response.status_code}"}
|
||||
except Exception as e:
|
||||
_logger.exception("PayPal capture error")
|
||||
return {"error": str(e)}
|
||||
133
encoach_subscription/services/stripe_service.py
Normal file
133
encoach_subscription/services/stripe_service.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||
|
||||
|
||||
class EncoachStripeService:
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
params = env["ir.config_parameter"].sudo()
|
||||
self.api_key = params.get_param("encoach.stripe_secret_key", "")
|
||||
self.webhook_secret = params.get_param("encoach.stripe_webhook_secret", "")
|
||||
|
||||
def _headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
def create_checkout_session(self, user, package, success_url, cancel_url, discount_code=None):
|
||||
if not self.api_key:
|
||||
return {"error": "Stripe secret key not configured"}
|
||||
|
||||
amount = package.price
|
||||
if discount_code:
|
||||
discount = self.env["encoach.discount"].sudo().search([
|
||||
("code", "=", discount_code),
|
||||
], limit=1)
|
||||
if discount and discount.is_valid():
|
||||
amount = amount * (1 - discount.percentage / 100.0)
|
||||
|
||||
amount_cents = int(round(amount * 100))
|
||||
|
||||
data = {
|
||||
"mode": "payment",
|
||||
"line_items[0][price_data][currency]": (package.currency or "usd").lower(),
|
||||
"line_items[0][price_data][unit_amount]": str(amount_cents),
|
||||
"line_items[0][price_data][product_data][name]": package.name,
|
||||
"line_items[0][quantity]": "1",
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
"client_reference_id": str(user.id),
|
||||
"customer_email": user.login,
|
||||
"metadata[package_id]": str(package.id),
|
||||
"metadata[user_id]": str(user.id),
|
||||
}
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{STRIPE_API_BASE}/checkout/sessions",
|
||||
headers=self._headers(),
|
||||
data=data,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
session = resp.json()
|
||||
return {"sessionId": session["id"], "url": session["url"]}
|
||||
except httpx.HTTPStatusError as e:
|
||||
_logger.error("Stripe API error: %s", e.response.text)
|
||||
return {"error": f"Stripe error: {e.response.status_code}"}
|
||||
except Exception as e:
|
||||
_logger.exception("Stripe checkout error")
|
||||
return {"error": str(e)}
|
||||
|
||||
def verify_webhook_signature(self, payload, sig_header):
|
||||
if not self.webhook_secret:
|
||||
_logger.warning("No Stripe webhook secret configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
elements = {k: v for k, v in (
|
||||
item.split("=", 1) for item in sig_header.split(",")
|
||||
)}
|
||||
timestamp = elements.get("t", "")
|
||||
signature = elements.get("v1", "")
|
||||
|
||||
signed_payload = f"{timestamp}.{payload}"
|
||||
expected = hmac.new(
|
||||
self.webhook_secret.encode("utf-8"),
|
||||
signed_payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
return False
|
||||
|
||||
if abs(time.time() - int(timestamp)) > 300:
|
||||
_logger.warning("Stripe webhook timestamp too old")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
_logger.exception("Stripe webhook signature verification failed")
|
||||
return False
|
||||
|
||||
def handle_webhook(self, payload, sig_header):
|
||||
if not self.verify_webhook_signature(payload, sig_header):
|
||||
return {"error": "Invalid signature"}
|
||||
|
||||
event = json.loads(payload)
|
||||
event_type = event.get("type")
|
||||
|
||||
if event_type == "checkout.session.completed":
|
||||
session = event["data"]["object"]
|
||||
user_id = int(session.get("client_reference_id", 0))
|
||||
package_id = int(session.get("metadata", {}).get("package_id", 0))
|
||||
transaction_id = session.get("payment_intent") or session.get("id")
|
||||
|
||||
if user_id and package_id:
|
||||
self._complete_payment(
|
||||
user_id=user_id,
|
||||
package_id=package_id,
|
||||
provider="stripe",
|
||||
transaction_id=transaction_id,
|
||||
amount=session.get("amount_total", 0) / 100.0,
|
||||
currency=(session.get("currency") or "usd").upper(),
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
def _complete_payment(self, user_id, package_id, provider, transaction_id, amount, currency):
|
||||
from .subscription_helper import extend_subscription
|
||||
extend_subscription(
|
||||
self.env, user_id, package_id, provider, transaction_id, amount, currency,
|
||||
)
|
||||
52
encoach_subscription/services/subscription_helper.py
Normal file
52
encoach_subscription/services/subscription_helper.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extend_subscription(env, user_id, package_id, provider, transaction_id, amount, currency):
|
||||
user = env["res.users"].sudo().browse(user_id)
|
||||
if not user.exists():
|
||||
_logger.error("extend_subscription: user %s not found", user_id)
|
||||
return
|
||||
|
||||
package = env["encoach.package"].sudo().browse(package_id)
|
||||
if not package.exists():
|
||||
_logger.error("extend_subscription: package %s not found", package_id)
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
current_exp = user.subscription_expiration
|
||||
if current_exp and current_exp > now:
|
||||
base = current_exp
|
||||
else:
|
||||
base = now
|
||||
new_exp = base + timedelta(days=package.duration_days or 30)
|
||||
user.sudo().write({"subscription_expiration": new_exp})
|
||||
|
||||
env["encoach.subscription.payment"].sudo().create({
|
||||
"user_id": user_id,
|
||||
"package_id": package_id,
|
||||
"provider": provider,
|
||||
"status": "completed",
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"transaction_id": transaction_id,
|
||||
})
|
||||
|
||||
if user.encoach_type in ("corporate", "mastercorporate"):
|
||||
rels = env["encoach.user.entity.rel"].sudo().search([
|
||||
("user_id", "=", user_id),
|
||||
])
|
||||
for rel in rels:
|
||||
members = env["encoach.user.entity.rel"].sudo().search([
|
||||
("entity_id", "=", rel.entity_id.id),
|
||||
("user_id", "!=", user_id),
|
||||
])
|
||||
for m in members:
|
||||
m.user_id.sudo().write({"subscription_expiration": new_exp})
|
||||
|
||||
_logger.info(
|
||||
"Subscription extended: user=%s package=%s provider=%s until=%s",
|
||||
user_id, package_id, provider, new_exp,
|
||||
)
|
||||
Reference in New Issue
Block a user