payrekha 0.1.0a1__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: payrekha
3
+ Version: 0.1.0a1
4
+ Summary: Sign Payrekha payout intents from your LOS/LMS or settlement engine.
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.9
7
+ Provides-Extra: ed25519
8
+ Requires-Dist: cryptography>=42; extra == "ed25519"
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kapil — People of Product
3
+ """Payrekha intent signing for systems of record (LOS/LMS, settlement engines).
4
+
5
+ Standard library only for HMAC; Ed25519 signing uses the optional
6
+ ``cryptography`` package. Byte-for-byte compatible with @payrekha/core.
7
+ """
8
+ from .client import DEFAULT_ON_UNAVAILABLE, PayrekhaClient
9
+ from .intent import (
10
+ INTENT_DOMAIN,
11
+ INTENT_KINDS,
12
+ canonical_json,
13
+ hash_beneficiary,
14
+ intent_signing_payload,
15
+ sign_intent_ed25519,
16
+ sign_intent_hmac,
17
+ )
18
+
19
+ __all__ = [
20
+ "DEFAULT_ON_UNAVAILABLE",
21
+ "PayrekhaClient",
22
+ "INTENT_DOMAIN",
23
+ "INTENT_KINDS",
24
+ "canonical_json",
25
+ "hash_beneficiary",
26
+ "intent_signing_payload",
27
+ "sign_intent_ed25519",
28
+ "sign_intent_hmac",
29
+ ]
30
+ __version__ = "0.1.0a0"
@@ -0,0 +1,71 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kapil — People of Product
3
+ """Stdlib-only client for the Payrekha gateway, with an explicit fail mode."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import urllib.error
9
+ import urllib.request
10
+ from typing import Any, Dict, Optional
11
+
12
+ DEFAULT_ON_UNAVAILABLE = "hold-above:5000000" # ₹50,000
13
+
14
+
15
+ def _threshold(on_unavailable: str) -> Optional[int]:
16
+ """None = allow all, -1 = hold all, n = hold above n paise."""
17
+ if on_unavailable == "allow":
18
+ return None
19
+ if on_unavailable == "hold":
20
+ return -1
21
+ if on_unavailable.startswith("hold-above:"):
22
+ n = on_unavailable.split(":", 1)[1]
23
+ if n.isdigit():
24
+ return int(n)
25
+ raise ValueError("on_unavailable must be 'allow', 'hold' or 'hold-above:<paise>'")
26
+
27
+
28
+ class PayrekhaClient:
29
+ def __init__(self, base_url: str, api_key: str, on_unavailable: str = DEFAULT_ON_UNAVAILABLE, timeout_s: float = 0.5) -> None:
30
+ self.base_url = base_url.rstrip("/")
31
+ self.api_key = api_key
32
+ self.timeout_s = timeout_s
33
+ self._threshold = _threshold(on_unavailable)
34
+ self.on_unavailable = on_unavailable
35
+
36
+ def _call(self, method: str, path: str, body: Any = None) -> Any:
37
+ data = None if body is None else json.dumps(body).encode()
38
+ req = urllib.request.Request(self.base_url + path, data=data, method=method, headers={"authorization": f"Bearer {self.api_key}", "content-type": "application/json"})
39
+ try:
40
+ with urllib.request.urlopen(req, timeout=self.timeout_s) as r:
41
+ return json.loads(r.read() or b"null")
42
+ except urllib.error.HTTPError as e:
43
+ payload = e.read()
44
+ try:
45
+ return json.loads(payload)
46
+ except ValueError:
47
+ raise
48
+ # URLError / timeouts propagate to the caller of _call
49
+
50
+ def evaluate(self, payout: Dict[str, Any]) -> Dict[str, Any]:
51
+ """Evaluate a payout. If the gateway is unreachable, apply the fail mode — never silently allow above it."""
52
+ try:
53
+ return self._call("POST", "/v1/evaluate", payout)
54
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
55
+ amount = payout.get("amountPaise")
56
+ hold = self._threshold is not None and (self._threshold < 0 or not isinstance(amount, int) or amount > self._threshold)
57
+ return {
58
+ "verdict": "REVIEW" if hold else "ALLOW",
59
+ "effectiveAction": "HOLD" if hold else "ALLOW",
60
+ "degraded": True,
61
+ "unavailable": True,
62
+ "onUnavailable": self.on_unavailable,
63
+ "hits": [],
64
+ "error": type(e).__name__,
65
+ }
66
+
67
+ def register_intent(self, intent: Dict[str, Any]) -> Dict[str, Any]:
68
+ return self._call("POST", "/v1/intents", intent)
69
+
70
+ def report_outcome(self, payout_id: str, outcome: str) -> Dict[str, Any]:
71
+ return self._call("POST", "/v1/outcomes", {"payoutId": payout_id, "outcome": outcome})
@@ -0,0 +1,96 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Kapil — People of Product
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import re
8
+ from typing import Any, Dict, Mapping, Optional, Union
9
+
10
+ INTENT_DOMAIN = "payrekha-intent-v1\n"
11
+ INTENT_KINDS = ("LOAN_DISBURSAL", "PA_SETTLEMENT", "REFUND", "VDA_WITHDRAWAL", "VENDOR_PAYOUT", "VERIFICATION")
12
+ _MAX_SAFE = 2**53 - 1
13
+ _MOBILE = re.compile(r"^(?:\+?91|0)?([6-9]\d{9})$")
14
+
15
+
16
+ def _b64url(b: bytes) -> str:
17
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
18
+
19
+
20
+ def _check(value: Any) -> Any:
21
+ if isinstance(value, bool) or value is None or isinstance(value, str):
22
+ return value
23
+ if isinstance(value, int):
24
+ if abs(value) > _MAX_SAFE:
25
+ raise ValueError("integers must be JS-safe")
26
+ return value
27
+ if isinstance(value, float):
28
+ raise ValueError("floats are not allowed; use integer paise / epoch ms")
29
+ if isinstance(value, (list, tuple)):
30
+ return [_check(v) for v in value]
31
+ if isinstance(value, Mapping):
32
+ return {str(k): _check(v) for k, v in value.items() if v is not None}
33
+ raise TypeError(f"unsupported type {type(value).__name__}")
34
+
35
+
36
+ def canonical_json(value: Any) -> str:
37
+ """Sorted keys, no whitespace, integers only, None fields dropped — same as canonicalJson()."""
38
+ return json.dumps(_check(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
39
+
40
+
41
+ def intent_signing_payload(intent: Mapping[str, Any]) -> bytes:
42
+ body = {k: v for k, v in intent.items() if k != "signature"}
43
+ if body.get("kind") not in INTENT_KINDS:
44
+ raise ValueError("unknown intent kind")
45
+ return (INTENT_DOMAIN + canonical_json(body)).encode("utf-8")
46
+
47
+
48
+ def sign_intent_hmac(intent: Mapping[str, Any], secret: str) -> Dict[str, Any]:
49
+ """HMAC-SHA256. Anyone holding the secret can mint intents — prefer Ed25519."""
50
+ if len(secret) < 32:
51
+ raise ValueError("HMAC secret must be at least 32 characters")
52
+ sig = hmac.new(secret.encode("utf-8"), intent_signing_payload(intent), hashlib.sha256).digest()
53
+ return {**intent, "signature": _b64url(sig)}
54
+
55
+
56
+ def sign_intent_ed25519(intent: Mapping[str, Any], private_key: Union[bytes, Any]) -> Dict[str, Any]:
57
+ """Ed25519 (default). ``private_key`` is a 32-byte seed or a cryptography Ed25519PrivateKey."""
58
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
59
+
60
+ key = Ed25519PrivateKey.from_private_bytes(private_key) if isinstance(private_key, (bytes, bytearray)) else private_key
61
+ return {**intent, "signature": _b64url(key.sign(intent_signing_payload(intent)))}
62
+
63
+
64
+ def hash_beneficiary(
65
+ key: Union[str, bytes],
66
+ *,
67
+ account: Optional[str] = None,
68
+ ifsc: Optional[str] = None,
69
+ vpa: Optional[str] = None,
70
+ mobile: Optional[str] = None,
71
+ ) -> str:
72
+ """Keyed beneficiary ref (``hb1_…``), identical to hashBeneficiary() in @payrekha/core."""
73
+ k = key.encode("utf-8") if isinstance(key, str) else bytes(key)
74
+ if len(k) < 16:
75
+ raise ValueError("hash key must be at least 16 bytes")
76
+ if account is not None:
77
+ acct = re.sub(r"[\s-]", "", account)
78
+ code = (ifsc or "").strip().upper()
79
+ if not re.fullmatch(r"\d{6,20}", acct):
80
+ raise ValueError("account number must be 6-20 digits")
81
+ if not re.fullmatch(r"[A-Z]{4}0[A-Z0-9]{6}", code):
82
+ raise ValueError("IFSC must look like ABCD0123456")
83
+ pre = f"acct:{acct}|{code}"
84
+ elif vpa is not None:
85
+ v = vpa.strip().lower()
86
+ if not re.fullmatch(r"[a-z0-9.\-_]{2,256}@[a-z][a-z0-9.\-]{1,64}", v):
87
+ raise ValueError("VPA must look like name@handle")
88
+ pre = f"vpa:{v}"
89
+ elif mobile is not None:
90
+ m = _MOBILE.match(re.sub(r"[\s-]", "", mobile))
91
+ if m is None:
92
+ raise ValueError("mobile must be a 10-digit Indian number")
93
+ pre = f"mob:{m.group(1)}"
94
+ else:
95
+ raise ValueError("give account+ifsc, vpa or mobile")
96
+ return "hb1_" + _b64url(hmac.new(k, pre.encode("utf-8"), hashlib.sha256).digest())
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: payrekha
3
+ Version: 0.1.0a1
4
+ Summary: Sign Payrekha payout intents from your LOS/LMS or settlement engine.
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.9
7
+ Provides-Extra: ed25519
8
+ Requires-Dist: cryptography>=42; extra == "ed25519"
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ payrekha/__init__.py
3
+ payrekha/client.py
4
+ payrekha/intent.py
5
+ payrekha.egg-info/PKG-INFO
6
+ payrekha.egg-info/SOURCES.txt
7
+ payrekha.egg-info/dependency_links.txt
8
+ payrekha.egg-info/requires.txt
9
+ payrekha.egg-info/top_level.txt
10
+ tests/test_intent.py
@@ -0,0 +1,3 @@
1
+
2
+ [ed25519]
3
+ cryptography>=42
@@ -0,0 +1 @@
1
+ payrekha
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "payrekha"
3
+ version = "0.1.0a1"
4
+ description = "Sign Payrekha payout intents from your LOS/LMS or settlement engine."
5
+ license = { text = "Apache-2.0" }
6
+ requires-python = ">=3.9"
7
+ dependencies = []
8
+
9
+ [project.optional-dependencies]
10
+ ed25519 = ["cryptography>=42"]
11
+
12
+ [build-system]
13
+ requires = ["setuptools>=68"]
14
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ import json
3
+ import pathlib
4
+ import unittest
5
+
6
+ from payrekha import canonical_json, hash_beneficiary, intent_signing_payload, sign_intent_ed25519, sign_intent_hmac
7
+
8
+ VEC = json.loads((pathlib.Path(__file__).resolve().parents[2] / "vectors" / "intent-v1.json").read_text("utf-8"))
9
+
10
+
11
+ class IntentVectorTest(unittest.TestCase):
12
+ def test_beneficiary_ref(self):
13
+ b = VEC["beneficiary"]
14
+ self.assertEqual(hash_beneficiary(VEC["hashKey"], account=b["account"], ifsc=b["ifsc"]), VEC["intent"]["beneficiaryRef"])
15
+
16
+ def test_payload(self):
17
+ self.assertEqual(intent_signing_payload(VEC["intent"]).decode("utf-8"), VEC["payload"])
18
+
19
+ def test_hmac(self):
20
+ self.assertEqual(sign_intent_hmac(VEC["intent"], VEC["hmacSecret"])["signature"], VEC["hmacSignature"])
21
+
22
+ def test_ed25519(self):
23
+ try:
24
+ import cryptography # noqa: F401
25
+ except ImportError:
26
+ self.skipTest("cryptography not installed")
27
+ signed = sign_intent_ed25519(VEC["intent"], bytes.fromhex(VEC["ed25519SeedHex"]))
28
+ self.assertEqual(signed["signature"], VEC["ed25519Signature"])
29
+
30
+ def test_rejects_floats_and_short_secrets(self):
31
+ with self.assertRaises(ValueError):
32
+ canonical_json({"a": 1.5})
33
+ with self.assertRaises(ValueError):
34
+ sign_intent_hmac(VEC["intent"], "short")
35
+
36
+ def test_mobile_normalisation(self):
37
+ self.assertEqual(hash_beneficiary(VEC["hashKey"], mobile="+91 98765 43210"), hash_beneficiary(VEC["hashKey"], mobile="9876543210"))
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()