Initial commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
BIN
app/api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/payme.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/payme.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/shopify.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/shopify.cpython-312.pyc
Normal file
Binary file not shown.
0
app/api/health.py
Normal file
0
app/api/health.py
Normal file
45
app/api/payme.py
Normal file
45
app/api/payme.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.session import get_db
|
||||
from app.core.security import verify_payme_auth
|
||||
from app.services.payme_service import (
|
||||
check_perform_transaction,
|
||||
create_transaction_handler,
|
||||
perform_transaction_handler,
|
||||
cancel_transaction_handler,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/payme")
|
||||
async def payme_webhook(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_: None = Depends(verify_payme_auth),
|
||||
):
|
||||
body = await request.json()
|
||||
method = body.get("method")
|
||||
params = body.get("params", {})
|
||||
request_id = body.get("id")
|
||||
|
||||
if method == "CheckPerformTransaction":
|
||||
result = await check_perform_transaction(params)
|
||||
elif method == "CreateTransaction":
|
||||
result = await create_transaction_handler(db, params)
|
||||
elif method == "PerformTransaction":
|
||||
result = await perform_transaction_handler(db, params)
|
||||
elif method == "CancelTransaction":
|
||||
result = await cancel_transaction_handler(db, params)
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": -32601, "message": "Method not found"},
|
||||
}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": result,
|
||||
}
|
||||
78
app/api/shopify.py
Normal file
78
app/api/shopify.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# app/api/shopify.py
|
||||
import json
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def create_payme_payment_link(order_id: str, amount_uzs: float) -> str:
|
||||
"""Generate a Payme checkout URL"""
|
||||
# Amount must be in tiyin (1 UZS = 100 tiyin)
|
||||
amount_tiyin = int(amount_uzs * 100)
|
||||
|
||||
# Payme requires account params to be base64-encoded
|
||||
import json
|
||||
account = json.dumps({"order": str(order_id)})
|
||||
account_encoded = base64.b64encode(account.encode()).decode()
|
||||
|
||||
merchant_id = settings.PAYME_MERCHANT_ID
|
||||
|
||||
# Payme checkout URL format
|
||||
params = f"m={merchant_id};ac.order={order_id};a={amount_tiyin}"
|
||||
params_encoded = base64.b64encode(params.encode()).decode()
|
||||
|
||||
return f"https://checkout.paycom.uz/{params_encoded}"
|
||||
|
||||
|
||||
@router.post("/shopify/order-created")
|
||||
async def order_created(request: Request):
|
||||
raw_body = await request.body()
|
||||
|
||||
# Verify Shopify webhook signature
|
||||
hmac_header = request.headers.get("X-Shopify-Hmac-Sha256")
|
||||
computed_hmac = base64.b64encode(
|
||||
hmac.new(
|
||||
settings.SHOPIFY_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
if not hmac.compare_digest(computed_hmac, hmac_header or ""):
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook")
|
||||
|
||||
data = json.loads(raw_body)
|
||||
|
||||
order_id = str(data["id"])
|
||||
total_price = float(data["total_price"])
|
||||
customer_email = data.get("email", "")
|
||||
|
||||
# Generate Payme payment link
|
||||
payment_link = create_payme_payment_link(order_id, total_price)
|
||||
|
||||
# Add the link as an order note in Shopify so customer can see it
|
||||
await add_payment_link_to_order(order_id, payment_link)
|
||||
|
||||
return {"status": "payment_link_created", "link": payment_link}
|
||||
|
||||
|
||||
async def add_payment_link_to_order(order_id: str, payment_link: str):
|
||||
"""Add payment link as a note on the Shopify order"""
|
||||
import httpx
|
||||
url = f"https://{settings.SHOPIFY_STORE_URL}/admin/api/2024-01/orders/{order_id}.json"
|
||||
headers = {
|
||||
"X-Shopify-Access-Token": settings.SHOPIFY_ACCESS_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"order": {
|
||||
"id": order_id,
|
||||
"note": f"Payme payment link: {payment_link}"
|
||||
}
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.put(url, json=payload, headers=headers)
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
BIN
app/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/constants.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/constants.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/security.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
18
app/core/config.py
Normal file
18
app/core/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PAYME_MERCHANT_ID: str
|
||||
PAYME_SECRET_KEY: str
|
||||
|
||||
SHOPIFY_ACCESS_TOKEN: str
|
||||
SHOPIFY_STORE_URL: str
|
||||
SHOPIFY_WEBHOOK_SECRET: str
|
||||
|
||||
DATABASE_URL: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
10
app/core/constants.py
Normal file
10
app/core/constants.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# Payme states
|
||||
STATE_NEW = 0
|
||||
STATE_CREATED = 1
|
||||
STATE_COMPLETED = 2
|
||||
STATE_CANCELLED = -1
|
||||
|
||||
# Payme error codes
|
||||
ERROR_INVALID_AMOUNT = -31001
|
||||
ERROR_TRANSACTION_NOT_FOUND = -31003
|
||||
ERROR_CANNOT_PERFORM = -31008
|
||||
18
app/core/security.py
Normal file
18
app/core/security.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import base64
|
||||
from fastapi import Request, HTTPException
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def verify_payme_auth(request: Request):
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
raise HTTPException(status_code=401, detail="Missing auth")
|
||||
|
||||
encoded = auth_header.split(" ")[1]
|
||||
decoded = base64.b64decode(encoded).decode()
|
||||
|
||||
merchant_id, secret = decoded.split(":")
|
||||
|
||||
if merchant_id != settings.PAYME_MERCHANT_ID or secret != settings.PAYME_SECRET_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid Payme credentials")
|
||||
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
BIN
app/db/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/db/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/base.cpython-312.pyc
Normal file
BIN
app/db/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/session.cpython-312.pyc
Normal file
BIN
app/db/__pycache__/session.cpython-312.pyc
Normal file
Binary file not shown.
5
app/db/base.py
Normal file
5
app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
13
app/db/session.py
Normal file
13
app/db/session.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
13
app/main.py
Normal file
13
app/main.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import FastAPI
|
||||
from app.api.shopify import router as shopify_router
|
||||
from app.api.payme import router as payme_router
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
app = FastAPI()
|
||||
app.include_router(shopify_router)
|
||||
app.include_router(payme_router)
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Service running"}
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
BIN
app/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/transaction.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/transaction.cpython-312.pyc
Normal file
Binary file not shown.
16
app/models/transaction.py
Normal file
16
app/models/transaction.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, BigInteger, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(String, nullable=False)
|
||||
payme_transaction_id = Column(String, unique=True)
|
||||
amount = Column(BigInteger, nullable=False)
|
||||
state = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
0
app/schemas/__init__.py
Normal file
0
app/schemas/__init__.py
Normal file
0
app/schemas/payme.py
Normal file
0
app/schemas/payme.py
Normal file
0
app/schemas/transaction.py
Normal file
0
app/schemas/transaction.py
Normal file
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/payme_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/payme_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/shopify_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/shopify_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/transaction_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/transaction_service.cpython-312.pyc
Normal file
Binary file not shown.
15
app/services/payme_checkout.py
Normal file
15
app/services/payme_checkout.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import base64
|
||||
import json
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def generate_payme_checkout_url(order_id: int, amount: str):
|
||||
data = {
|
||||
"m": settings.PAYME_MERCHANT_ID,
|
||||
"ac.order": str(order_id),
|
||||
"a": int(float(amount) * 100),
|
||||
}
|
||||
|
||||
encoded = base64.b64encode(json.dumps(data).encode()).decode()
|
||||
|
||||
return f"https://checkout.paycom.uz/{encoded}"
|
||||
110
app/services/payme_service.py
Normal file
110
app/services/payme_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import time
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.transaction_service import (
|
||||
get_transaction_by_payme_id,
|
||||
create_transaction,
|
||||
update_transaction_state,
|
||||
)
|
||||
from app.services.shopify_service import mark_order_paid, get_shopify_order
|
||||
from app.core.constants import (
|
||||
STATE_CREATED,
|
||||
STATE_COMPLETED,
|
||||
STATE_CANCELLED,
|
||||
ERROR_TRANSACTION_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
async def check_perform_transaction(params: dict) -> dict:
|
||||
order_id = params["account"]["order"]
|
||||
payme_amount = params["amount"] # tiyin
|
||||
|
||||
order = await get_shopify_order(order_id)
|
||||
|
||||
if not order:
|
||||
return {"allow": False}
|
||||
|
||||
# Draft orders use "total_price", completed orders too — same field name
|
||||
shopify_amount = int(float(order["total_price"]) * 100)
|
||||
|
||||
if shopify_amount != payme_amount:
|
||||
return {"allow": False}
|
||||
|
||||
# Draft: status field is "status" not "financial_status"
|
||||
if order.get("_is_draft"):
|
||||
already_paid = order.get("status") == "completed"
|
||||
else:
|
||||
already_paid = order.get("financial_status") == "paid"
|
||||
|
||||
if already_paid:
|
||||
return {"allow": False}
|
||||
|
||||
return {"allow": True}
|
||||
|
||||
|
||||
async def create_transaction_handler(db: Session, params: dict) -> dict:
|
||||
payme_id = params["id"]
|
||||
order_id = params["account"]["order"]
|
||||
amount = params["amount"]
|
||||
|
||||
existing = get_transaction_by_payme_id(db, payme_id)
|
||||
if existing:
|
||||
return {
|
||||
"create_time": int(time.time() * 1000),
|
||||
"transaction": payme_id,
|
||||
"state": existing.state,
|
||||
}
|
||||
|
||||
create_transaction(db, order_id, payme_id, amount)
|
||||
|
||||
return {
|
||||
"create_time": int(time.time() * 1000),
|
||||
"transaction": payme_id,
|
||||
"state": STATE_CREATED,
|
||||
}
|
||||
|
||||
|
||||
async def perform_transaction_handler(db: Session, params: dict) -> dict | None:
|
||||
payme_id = params["id"]
|
||||
|
||||
transaction = get_transaction_by_payme_id(db, payme_id)
|
||||
if not transaction:
|
||||
return None
|
||||
|
||||
if transaction.state == STATE_COMPLETED:
|
||||
return {
|
||||
"perform_time": int(time.time() * 1000),
|
||||
"transaction": payme_id,
|
||||
"state": STATE_COMPLETED,
|
||||
}
|
||||
|
||||
update_transaction_state(db, transaction, STATE_COMPLETED)
|
||||
|
||||
# Fetch order to know if it's a draft
|
||||
order = await get_shopify_order(transaction.order_id)
|
||||
is_draft = order.get("_is_draft", False) if order else False
|
||||
|
||||
amount_uzs = str(transaction.amount / 100)
|
||||
await mark_order_paid(transaction.order_id, amount_uzs, is_draft=is_draft)
|
||||
|
||||
return {
|
||||
"perform_time": int(time.time() * 1000),
|
||||
"transaction": payme_id,
|
||||
"state": STATE_COMPLETED,
|
||||
}
|
||||
|
||||
|
||||
async def cancel_transaction_handler(db: Session, params: dict) -> dict | None:
|
||||
payme_id = params["id"]
|
||||
|
||||
transaction = get_transaction_by_payme_id(db, payme_id)
|
||||
if not transaction:
|
||||
return None
|
||||
|
||||
update_transaction_state(db, transaction, STATE_CANCELLED)
|
||||
|
||||
return {
|
||||
"cancel_time": int(time.time() * 1000),
|
||||
"transaction": payme_id,
|
||||
"state": STATE_CANCELLED,
|
||||
}
|
||||
63
app/services/shopify_service.py
Normal file
63
app/services/shopify_service.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
SHOPIFY_API = f"https://{settings.SHOPIFY_STORE_URL}/admin/api/2024-01"
|
||||
HEADERS = {
|
||||
"X-Shopify-Access-Token": settings.SHOPIFY_ACCESS_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def get_shopify_order(order_id: str) -> dict | None:
|
||||
"""
|
||||
Tries regular orders first, falls back to draft orders.
|
||||
Returns the order dict or None.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# 1. Try regular order
|
||||
r = await client.get(f"{SHOPIFY_API}/orders/{order_id}.json", headers=HEADERS)
|
||||
if r.status_code == 200:
|
||||
order = r.json().get("order")
|
||||
if order:
|
||||
order["_is_draft"] = False
|
||||
return order
|
||||
|
||||
# 2. Try draft order
|
||||
r = await client.get(f"{SHOPIFY_API}/draft_orders/{order_id}.json", headers=HEADERS)
|
||||
if r.status_code == 200:
|
||||
order = r.json().get("draft_order")
|
||||
if order:
|
||||
order["_is_draft"] = True
|
||||
return order
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def mark_order_paid(order_id: str, amount: str, is_draft: bool = False) -> dict:
|
||||
"""
|
||||
For draft orders → complete the draft (converts it to a real order, marks paid).
|
||||
For real orders → post a capture transaction.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
if is_draft:
|
||||
# Complete the draft order — this marks it as paid in Shopify
|
||||
r = await client.post(
|
||||
f"{SHOPIFY_API}/draft_orders/{order_id}/complete.json",
|
||||
headers=HEADERS,
|
||||
params={"payment_pending": False}, # False = mark as paid immediately
|
||||
)
|
||||
return r.json()
|
||||
else:
|
||||
# Regular order — post a capture transaction
|
||||
r = await client.post(
|
||||
f"{SHOPIFY_API}/orders/{order_id}/transactions.json",
|
||||
headers=HEADERS,
|
||||
json={
|
||||
"transaction": {
|
||||
"kind": "capture",
|
||||
"status": "success",
|
||||
"amount": amount,
|
||||
}
|
||||
},
|
||||
)
|
||||
return r.json()
|
||||
29
app/services/transaction_service.py
Normal file
29
app/services/transaction_service.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.transaction import Transaction
|
||||
from app.core.constants import *
|
||||
|
||||
|
||||
def get_transaction_by_payme_id(db: Session, payme_id: str):
|
||||
return db.query(Transaction).filter(
|
||||
Transaction.payme_transaction_id == payme_id
|
||||
).first()
|
||||
|
||||
|
||||
def create_transaction(db: Session, order_id: str, payme_id: str, amount: int):
|
||||
transaction = Transaction(
|
||||
order_id=order_id,
|
||||
payme_transaction_id=payme_id,
|
||||
amount=amount,
|
||||
state=STATE_CREATED,
|
||||
)
|
||||
db.add(transaction)
|
||||
db.commit()
|
||||
db.refresh(transaction)
|
||||
return transaction
|
||||
|
||||
|
||||
def update_transaction_state(db: Session, transaction: Transaction, state: int):
|
||||
transaction.state = state
|
||||
db.commit()
|
||||
db.refresh(transaction)
|
||||
return transaction
|
||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
30
app/utils/get_token.py
Normal file
30
app/utils/get_token.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import webbrowser
|
||||
import httpx
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
CLIENT_ID = "5811038f7c170b60b93fca3727545e6f"
|
||||
CLIENT_SECRET = input("Paste your Client Secret: ").strip()
|
||||
STORE = "cx0du9-sq.myshopify.com"
|
||||
|
||||
auth_url = (
|
||||
f"https://{STORE}/admin/oauth/authorize"
|
||||
f"?client_id={CLIENT_ID}"
|
||||
f"&scope=read_orders,write_orders,read_draft_orders,write_draft_orders,read_customers"
|
||||
f"&redirect_uri=https://example.com"
|
||||
f"&state=abc123"
|
||||
)
|
||||
|
||||
print("\nOpening browser — click Allow...")
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
redirect_url = input("\nPaste the full redirect URL: ").strip()
|
||||
code = parse_qs(urlparse(redirect_url).query)["code"][0]
|
||||
|
||||
response = httpx.post(
|
||||
f"https://{STORE}/admin/oauth/access_token",
|
||||
json={"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code": code},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
print("\n✅ Update your .env with this:")
|
||||
print(f"SHOPIFY_ACCESS_TOKEN={data['access_token']}")
|
||||
0
app/utils/helpers.py
Normal file
0
app/utils/helpers.py
Normal file
0
app/utils/logger.py
Normal file
0
app/utils/logger.py
Normal file
Reference in New Issue
Block a user