bitsopay 0.1.0__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.
- bitsopay/__init__.py +64 -0
- bitsopay/__main__.py +6 -0
- bitsopay/cli.py +245 -0
- bitsopay/exceptions.py +31 -0
- bitsopay/link.py +122 -0
- bitsopay/models.py +57 -0
- bitsopay/page.py +133 -0
- bitsopay/py.typed +0 -0
- bitsopay/qr.py +87 -0
- bitsopay/ratelimit.py +69 -0
- bitsopay/server.py +520 -0
- bitsopay/session.py +67 -0
- bitsopay/shortlink.py +172 -0
- bitsopay/stateless.py +181 -0
- bitsopay/store.py +277 -0
- bitsopay/upi.py +47 -0
- bitsopay/validators.py +86 -0
- bitsopay-0.1.0.dist-info/METADATA +234 -0
- bitsopay-0.1.0.dist-info/RECORD +23 -0
- bitsopay-0.1.0.dist-info/WHEEL +5 -0
- bitsopay-0.1.0.dist-info/entry_points.txt +2 -0
- bitsopay-0.1.0.dist-info/licenses/LICENSE +21 -0
- bitsopay-0.1.0.dist-info/top_level.txt +1 -0
bitsopay/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Bitsopay: dynamic UPI QR codes and signed payment links.
|
|
2
|
+
|
|
3
|
+
>>> from bitsopay import UPIPayment, qr_png, create_payment_link
|
|
4
|
+
>>> pay = UPIPayment(upi_id="shop@okhdfcbank", payee_name="My Shop", amount=499)
|
|
5
|
+
>>> pay.uri
|
|
6
|
+
'upi://pay?pa=shop@okhdfcbank&pn=My%20Shop&am=499.00&cu=INR'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
BitsoPayError,
|
|
15
|
+
DuplicateReferenceError,
|
|
16
|
+
InvalidStateError,
|
|
17
|
+
LinkExpiredError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
SignatureError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
)
|
|
22
|
+
from .link import create_payment_link, parse_payment_link, verify_payment_link
|
|
23
|
+
from .models import UPIPayment
|
|
24
|
+
from .page import render_payment_page
|
|
25
|
+
from .qr import qr_data_uri, qr_png, qr_svg, save_qr
|
|
26
|
+
from .session import SessionQR, create_session_qr
|
|
27
|
+
from .shortlink import PaymentLink, PaymentLinks
|
|
28
|
+
from .stateless import StatelessLink, StatelessLinks
|
|
29
|
+
from .store import MemoryStore, PaymentRecord, PaymentStore, SQLiteStore
|
|
30
|
+
from .upi import build_app_uri, build_upi_uri
|
|
31
|
+
from .validators import new_reference
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BitsoPayError",
|
|
35
|
+
"DuplicateReferenceError",
|
|
36
|
+
"InvalidStateError",
|
|
37
|
+
"LinkExpiredError",
|
|
38
|
+
"MemoryStore",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"PaymentLink",
|
|
41
|
+
"PaymentLinks",
|
|
42
|
+
"PaymentRecord",
|
|
43
|
+
"PaymentStore",
|
|
44
|
+
"SQLiteStore",
|
|
45
|
+
"SessionQR",
|
|
46
|
+
"SignatureError",
|
|
47
|
+
"StatelessLink",
|
|
48
|
+
"StatelessLinks",
|
|
49
|
+
"UPIPayment",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"__version__",
|
|
52
|
+
"build_app_uri",
|
|
53
|
+
"build_upi_uri",
|
|
54
|
+
"create_payment_link",
|
|
55
|
+
"create_session_qr",
|
|
56
|
+
"new_reference",
|
|
57
|
+
"parse_payment_link",
|
|
58
|
+
"qr_data_uri",
|
|
59
|
+
"qr_png",
|
|
60
|
+
"qr_svg",
|
|
61
|
+
"render_payment_page",
|
|
62
|
+
"save_qr",
|
|
63
|
+
"verify_payment_link",
|
|
64
|
+
]
|
bitsopay/__main__.py
ADDED
bitsopay/cli.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
``bitsopay qr|uri|link|session-qr|page`` stateless helpers
|
|
4
|
+
``bitsopay create-link|history|show|mark-paid|cancel`` short links + history (SQLite)
|
|
5
|
+
``bitsopay serve`` run the payment server
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .exceptions import BitsoPayError
|
|
18
|
+
from .link import create_payment_link
|
|
19
|
+
from .models import UPIPayment
|
|
20
|
+
from .page import render_payment_page
|
|
21
|
+
from .qr import save_qr
|
|
22
|
+
from .session import create_session_qr
|
|
23
|
+
from .shortlink import PaymentLinks
|
|
24
|
+
from .stateless import StatelessLinks
|
|
25
|
+
from .store import SQLiteStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_payment_args(p: argparse.ArgumentParser, ref: bool = True) -> None:
|
|
29
|
+
p.add_argument("--upi-id", required=True, help="Merchant UPI ID, e.g. shop@okhdfcbank")
|
|
30
|
+
p.add_argument("--name", required=True, help="Payee / merchant name")
|
|
31
|
+
p.add_argument("--amount", help="Amount in INR (omit for open amount)")
|
|
32
|
+
p.add_argument("--note", help="Transaction note (max 80 chars)")
|
|
33
|
+
if ref:
|
|
34
|
+
p.add_argument("--ref", help="Transaction reference / order id")
|
|
35
|
+
p.add_argument("--mcc", help="4-digit merchant category code")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _payment(a: argparse.Namespace) -> UPIPayment:
|
|
39
|
+
return UPIPayment(
|
|
40
|
+
upi_id=a.upi_id,
|
|
41
|
+
payee_name=a.name,
|
|
42
|
+
amount=a.amount,
|
|
43
|
+
note=a.note,
|
|
44
|
+
reference=getattr(a, "ref", None),
|
|
45
|
+
merchant_code=a.mcc,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _secret(a: argparse.Namespace) -> str:
|
|
50
|
+
secret = a.secret or os.environ.get("BITSOPAY_SECRET")
|
|
51
|
+
if not secret:
|
|
52
|
+
raise BitsoPayError("Provide --secret or set BITSOPAY_SECRET")
|
|
53
|
+
return secret
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _db_arg(p: argparse.ArgumentParser) -> None:
|
|
57
|
+
p.add_argument("--db", help="SQLite file (or $BITSOPAY_DB)")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _links(a: argparse.Namespace, opened: List[SQLiteStore]) -> PaymentLinks:
|
|
61
|
+
path = a.db or os.environ.get("BITSOPAY_DB")
|
|
62
|
+
if not path:
|
|
63
|
+
raise BitsoPayError("Provide --db or set BITSOPAY_DB")
|
|
64
|
+
store = SQLiteStore(path)
|
|
65
|
+
opened.append(store) # closed by main() when the command finishes
|
|
66
|
+
return PaymentLinks(store, getattr(a, "base_url", None) or os.environ.get("BITSOPAY_BASE_URL"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fmt_time(ts: Optional[int]) -> str:
|
|
70
|
+
return "-" if ts is None else datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
74
|
+
parser = argparse.ArgumentParser(prog="bitsopay", description="UPI QR codes and payment links")
|
|
75
|
+
parser.add_argument("--version", action="version", version=f"bitsopay {__version__}")
|
|
76
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
77
|
+
|
|
78
|
+
qr = sub.add_parser("qr", help="Write a QR code image (.png or .svg)")
|
|
79
|
+
_add_payment_args(qr)
|
|
80
|
+
qr.add_argument("-o", "--output", required=True)
|
|
81
|
+
|
|
82
|
+
uri = sub.add_parser("uri", help="Print the upi:// deep link")
|
|
83
|
+
_add_payment_args(uri)
|
|
84
|
+
|
|
85
|
+
link = sub.add_parser("link", help="Create a signed https payment link (data in URL)")
|
|
86
|
+
_add_payment_args(link)
|
|
87
|
+
link.add_argument("--base-url", required=True, help="e.g. https://pay.example.com/pay")
|
|
88
|
+
link.add_argument("--secret", help="Signing secret (or $BITSOPAY_SECRET)")
|
|
89
|
+
link.add_argument("--expires-in", type=int, default=86400, help="Seconds until expiry (0 = never)")
|
|
90
|
+
|
|
91
|
+
sess = sub.add_parser("session-qr", help="Write an expiring QR using a signed link")
|
|
92
|
+
_add_payment_args(sess)
|
|
93
|
+
sess.add_argument("--base-url", required=True, help="e.g. https://pay.example.com/pay")
|
|
94
|
+
sess.add_argument("--secret", help="Signing secret (or $BITSOPAY_SECRET)")
|
|
95
|
+
sess.add_argument("--expires-in", type=int, default=300, help="Session length in seconds (default 300)")
|
|
96
|
+
sess.add_argument("-o", "--output", required=True)
|
|
97
|
+
|
|
98
|
+
page = sub.add_parser("page", help="Write a standalone HTML payment page")
|
|
99
|
+
_add_payment_args(page)
|
|
100
|
+
page.add_argument("-o", "--output", required=True)
|
|
101
|
+
|
|
102
|
+
# -- secure short links ------------------------------------------------
|
|
103
|
+
cl = sub.add_parser("create-link", help="Create a secure short link (reference code only in URL)")
|
|
104
|
+
_add_payment_args(cl, ref=False)
|
|
105
|
+
_db_arg(cl)
|
|
106
|
+
cl.add_argument("--base-url", help="Public server root, e.g. https://pay.example.com (or $BITSOPAY_BASE_URL)")
|
|
107
|
+
cl.add_argument("--expires-in", type=int, default=86400, help="Seconds until expiry (0 = never)")
|
|
108
|
+
cl.add_argument("-o", "--output", help="Also write a QR of the short link (.png / .svg)")
|
|
109
|
+
|
|
110
|
+
ql = sub.add_parser("quick-link", help="Create a database-free short link (no storage needed)")
|
|
111
|
+
ql.add_argument("--upi-id", required=True)
|
|
112
|
+
ql.add_argument("--name", required=True)
|
|
113
|
+
ql.add_argument("--amount", help="Amount in INR (omit for open amount)")
|
|
114
|
+
ql.add_argument("--base-url", required=True, help="Public server root, e.g. https://pay.example.com")
|
|
115
|
+
ql.add_argument("--secret", help="Signing secret (or $BITSOPAY_SECRET)")
|
|
116
|
+
ql.add_argument("--expires-in", type=int, default=86400, help="Seconds until expiry (0 = never)")
|
|
117
|
+
ql.add_argument("-o", "--output", help="Also write a QR of the link (.png / .svg)")
|
|
118
|
+
|
|
119
|
+
hist = sub.add_parser("history", help="List payment history")
|
|
120
|
+
_db_arg(hist)
|
|
121
|
+
hist.add_argument("--status", choices=["pending", "paid", "cancelled", "expired"])
|
|
122
|
+
hist.add_argument("--limit", type=int, default=20)
|
|
123
|
+
|
|
124
|
+
show = sub.add_parser("show", help="Show one payment")
|
|
125
|
+
_db_arg(show)
|
|
126
|
+
show.add_argument("reference")
|
|
127
|
+
|
|
128
|
+
paid = sub.add_parser("mark-paid", help="Mark a payment as paid after verifying it manually")
|
|
129
|
+
_db_arg(paid)
|
|
130
|
+
paid.add_argument("reference")
|
|
131
|
+
paid.add_argument("--note", help="e.g. the UPI transaction id from your bank statement")
|
|
132
|
+
|
|
133
|
+
cancel = sub.add_parser("cancel", help="Cancel a pending payment link")
|
|
134
|
+
_db_arg(cancel)
|
|
135
|
+
cancel.add_argument("reference")
|
|
136
|
+
|
|
137
|
+
serve = sub.add_parser("serve", help="Run a dev server for payment links")
|
|
138
|
+
serve.add_argument("--secret", help="Signing secret for legacy signed links (or $BITSOPAY_SECRET)")
|
|
139
|
+
_db_arg(serve)
|
|
140
|
+
serve.add_argument("--upi-id", help="Merchant UPI ID for database-free links (or $BITSOPAY_UPI_ID)")
|
|
141
|
+
serve.add_argument("--name", help="Merchant name for database-free links (or $BITSOPAY_PAYEE_NAME)")
|
|
142
|
+
serve.add_argument("--admin-token", help="Merchant API token (or $BITSOPAY_ADMIN_TOKEN)")
|
|
143
|
+
serve.add_argument("--base-url", help="Public root URL (or $BITSOPAY_BASE_URL)")
|
|
144
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
145
|
+
serve.add_argument("--port", type=int, default=8080)
|
|
146
|
+
return parser
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
150
|
+
args = build_parser().parse_args(argv)
|
|
151
|
+
opened: List[SQLiteStore] = []
|
|
152
|
+
try:
|
|
153
|
+
if args.cmd == "qr":
|
|
154
|
+
print(save_qr(_payment(args), args.output))
|
|
155
|
+
elif args.cmd == "uri":
|
|
156
|
+
print(_payment(args).uri)
|
|
157
|
+
elif args.cmd == "link":
|
|
158
|
+
exp = args.expires_in or None
|
|
159
|
+
print(create_payment_link(args.base_url, _payment(args), _secret(args), expires_in=exp))
|
|
160
|
+
elif args.cmd == "session-qr":
|
|
161
|
+
sq = create_session_qr(args.base_url, _payment(args), _secret(args), expires_in=args.expires_in)
|
|
162
|
+
sq.save(args.output)
|
|
163
|
+
print(f"{args.output} (valid for {args.expires_in}s)\n{sq.url}")
|
|
164
|
+
elif args.cmd == "page":
|
|
165
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
166
|
+
fh.write(render_payment_page(_payment(args)))
|
|
167
|
+
print(args.output)
|
|
168
|
+
elif args.cmd == "create-link":
|
|
169
|
+
links = _links(args, opened)
|
|
170
|
+
new = links.create(
|
|
171
|
+
args.upi_id,
|
|
172
|
+
args.name,
|
|
173
|
+
args.amount,
|
|
174
|
+
note=args.note,
|
|
175
|
+
merchant_code=args.mcc,
|
|
176
|
+
expires_in=args.expires_in or None,
|
|
177
|
+
)
|
|
178
|
+
print(f"reference: {new.reference}\nurl: {new.url}\nexpires: {_fmt_time(new.expires_at)}")
|
|
179
|
+
if args.output:
|
|
180
|
+
print(f"qr: {new.save(args.output)}")
|
|
181
|
+
elif args.cmd == "quick-link":
|
|
182
|
+
sl = StatelessLinks(args.upi_id, args.name, _secret(args), args.base_url).create(
|
|
183
|
+
args.amount, expires_in=args.expires_in or None
|
|
184
|
+
)
|
|
185
|
+
print(f"reference: {sl.reference}\nurl: {sl.url}\nexpires: {_fmt_time(sl.expires_at)}")
|
|
186
|
+
if args.output:
|
|
187
|
+
print(f"qr: {sl.save(args.output)}")
|
|
188
|
+
elif args.cmd == "history":
|
|
189
|
+
links = _links(args, opened)
|
|
190
|
+
rows = links.history(args.status, args.limit)
|
|
191
|
+
if not rows:
|
|
192
|
+
print("No payments found.")
|
|
193
|
+
now = links.clock()
|
|
194
|
+
for r in rows:
|
|
195
|
+
amt = "open" if r.amount is None else f"{r.amount:.2f}"
|
|
196
|
+
print(
|
|
197
|
+
f"{r.reference} {r.effective_status(now):<9} {amt:>10} {r.payee_name} "
|
|
198
|
+
f"created {_fmt_time(r.created_at)} views {r.views}"
|
|
199
|
+
)
|
|
200
|
+
elif args.cmd == "show":
|
|
201
|
+
links = _links(args, opened)
|
|
202
|
+
rec = links.get(args.reference)
|
|
203
|
+
if rec is None:
|
|
204
|
+
raise BitsoPayError(f"No payment with reference {args.reference}")
|
|
205
|
+
for k, v in rec.to_dict(links.clock()).items():
|
|
206
|
+
print(f"{k:<14} {v}")
|
|
207
|
+
elif args.cmd == "mark-paid":
|
|
208
|
+
rec = _links(args, opened).mark_paid(args.reference, args.note)
|
|
209
|
+
print(f"{rec.reference} marked paid at {_fmt_time(rec.paid_at)}")
|
|
210
|
+
elif args.cmd == "cancel":
|
|
211
|
+
rec = _links(args, opened).cancel(args.reference)
|
|
212
|
+
print(f"{rec.reference} cancelled")
|
|
213
|
+
elif args.cmd == "serve":
|
|
214
|
+
from .server import serve
|
|
215
|
+
|
|
216
|
+
path = args.db or os.environ.get("BITSOPAY_DB")
|
|
217
|
+
secret = args.secret or os.environ.get("BITSOPAY_SECRET")
|
|
218
|
+
upi = args.upi_id or os.environ.get("BITSOPAY_UPI_ID")
|
|
219
|
+
pname = args.name or os.environ.get("BITSOPAY_PAYEE_NAME")
|
|
220
|
+
if not path and not secret:
|
|
221
|
+
raise BitsoPayError("Provide --db (short links) and/or --secret (signed / database-free links)")
|
|
222
|
+
stateless = StatelessLinks(upi, pname, secret) if (upi and pname and secret) else None
|
|
223
|
+
store = SQLiteStore(path) if path else None
|
|
224
|
+
if store is not None:
|
|
225
|
+
opened.append(store) # closed when the server stops
|
|
226
|
+
serve(
|
|
227
|
+
secret,
|
|
228
|
+
args.host,
|
|
229
|
+
args.port,
|
|
230
|
+
store=store,
|
|
231
|
+
admin_token=args.admin_token or os.environ.get("BITSOPAY_ADMIN_TOKEN"),
|
|
232
|
+
base_url=args.base_url or os.environ.get("BITSOPAY_BASE_URL"),
|
|
233
|
+
stateless=stateless,
|
|
234
|
+
)
|
|
235
|
+
except BitsoPayError as exc:
|
|
236
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
237
|
+
return 2
|
|
238
|
+
finally:
|
|
239
|
+
for opened_store in opened:
|
|
240
|
+
opened_store.close()
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__": # pragma: no cover
|
|
245
|
+
raise SystemExit(main())
|
bitsopay/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exception hierarchy for bitsopay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BitsoPayError(Exception):
|
|
7
|
+
"""Base class for all bitsopay errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ValidationError(BitsoPayError, ValueError):
|
|
11
|
+
"""Raised when a UPI ID, amount or other field is invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SignatureError(BitsoPayError):
|
|
15
|
+
"""Raised when a signed payment link has a missing or invalid signature."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LinkExpiredError(BitsoPayError):
|
|
19
|
+
"""Raised when a signed payment link is past its expiry time."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotFoundError(BitsoPayError):
|
|
23
|
+
"""Raised when a payment reference does not exist."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidStateError(BitsoPayError):
|
|
27
|
+
"""Raised for a status change that is not allowed (e.g. cancelling a paid payment)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DuplicateReferenceError(BitsoPayError):
|
|
31
|
+
"""Raised by a store when a reference already exists."""
|
bitsopay/link.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Signed, tamper-proof web payment links.
|
|
2
|
+
|
|
3
|
+
A *payment link* is a normal HTTPS URL the merchant shares (WhatsApp, SMS, email).
|
|
4
|
+
It carries the UPI ID, amount, etc. plus an HMAC-SHA256 signature and an expiry, so
|
|
5
|
+
a customer cannot edit the amount in the URL. Open on a phone, the page offers a
|
|
6
|
+
"Pay with UPI app" button; on desktop it shows a QR code to scan.
|
|
7
|
+
|
|
8
|
+
Serve the links with :mod:`bitsopay.server` (a ready WSGI app) or render the page
|
|
9
|
+
yourself with :func:`bitsopay.page.render_payment_page`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import time
|
|
17
|
+
from typing import Dict, List, Mapping, Optional, Tuple, Union
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
19
|
+
|
|
20
|
+
from .exceptions import LinkExpiredError, SignatureError, ValidationError
|
|
21
|
+
from .models import UPIPayment
|
|
22
|
+
|
|
23
|
+
_SIGNED_KEYS = ("pa", "pn", "am", "tn", "tr", "mc", "cu", "exp")
|
|
24
|
+
DEFAULT_EXPIRY_SECONDS = 24 * 60 * 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _secret_bytes(secret: Union[str, bytes]) -> bytes:
|
|
28
|
+
key = secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
29
|
+
if len(key) < 16:
|
|
30
|
+
raise ValidationError("Signing secret must be at least 16 bytes long")
|
|
31
|
+
return key
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fields(payment: UPIPayment, exp: Optional[int]) -> Dict[str, str]:
|
|
35
|
+
data: Dict[str, str] = {"pa": payment.upi_id, "pn": payment.payee_name, "cu": payment.currency}
|
|
36
|
+
if payment.amount is not None:
|
|
37
|
+
data["am"] = f"{payment.amount:.2f}"
|
|
38
|
+
if payment.note:
|
|
39
|
+
data["tn"] = payment.note
|
|
40
|
+
if payment.reference:
|
|
41
|
+
data["tr"] = payment.reference
|
|
42
|
+
if payment.merchant_code:
|
|
43
|
+
data["mc"] = payment.merchant_code
|
|
44
|
+
if exp is not None:
|
|
45
|
+
data["exp"] = str(exp)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sign(fields: Mapping[str, str], secret: Union[str, bytes]) -> str:
|
|
50
|
+
message = "\n".join(f"{k}={fields[k]}" for k in _SIGNED_KEYS if k in fields)
|
|
51
|
+
return hmac.new(_secret_bytes(secret), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_payment_link(
|
|
55
|
+
base_url: str,
|
|
56
|
+
payment: UPIPayment,
|
|
57
|
+
secret: Union[str, bytes],
|
|
58
|
+
*,
|
|
59
|
+
expires_in: Optional[int] = DEFAULT_EXPIRY_SECONDS,
|
|
60
|
+
now: Optional[float] = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Return ``base_url?...&sig=...`` for ``payment``.
|
|
63
|
+
|
|
64
|
+
``base_url`` is the full URL of your payment page, e.g. ``https://pay.example.com/pay``.
|
|
65
|
+
Pass ``expires_in=None`` for a link that never expires.
|
|
66
|
+
"""
|
|
67
|
+
if not base_url.lower().startswith(("https://", "http://")):
|
|
68
|
+
raise ValidationError("base_url must start with http:// or https://")
|
|
69
|
+
exp = None if expires_in is None else int((now if now is not None else time.time()) + expires_in)
|
|
70
|
+
fields = _fields(payment, exp)
|
|
71
|
+
fields["sig"] = _sign(fields, secret)
|
|
72
|
+
sep = "&" if "?" in base_url else "?"
|
|
73
|
+
return f"{base_url}{sep}{urlencode(fields)}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verify_payment_link(
|
|
77
|
+
link_or_query: str,
|
|
78
|
+
secret: Union[str, bytes],
|
|
79
|
+
*,
|
|
80
|
+
now: Optional[float] = None,
|
|
81
|
+
) -> Tuple[UPIPayment, Optional[int]]:
|
|
82
|
+
"""Verify signature and expiry; return ``(payment, expires_at_unix_or_None)``.
|
|
83
|
+
|
|
84
|
+
Accepts a full URL or just the query string. Raises :class:`SignatureError`
|
|
85
|
+
or :class:`LinkExpiredError`.
|
|
86
|
+
"""
|
|
87
|
+
query = urlparse(link_or_query).query if "?" in link_or_query or "://" in link_or_query else link_or_query
|
|
88
|
+
raw: Dict[str, List[str]] = parse_qs(query, keep_blank_values=False)
|
|
89
|
+
fields = {k: vals[0] for k, vals in raw.items() if k in _SIGNED_KEYS and vals}
|
|
90
|
+
sig = (raw.get("sig") or [""])[0]
|
|
91
|
+
if not sig or "pa" not in fields:
|
|
92
|
+
raise SignatureError("Missing signature")
|
|
93
|
+
if not hmac.compare_digest(sig, _sign(fields, secret)):
|
|
94
|
+
raise SignatureError("Invalid signature")
|
|
95
|
+
exp: Optional[int] = None
|
|
96
|
+
if "exp" in fields:
|
|
97
|
+
try:
|
|
98
|
+
exp = int(fields["exp"])
|
|
99
|
+
except ValueError:
|
|
100
|
+
raise SignatureError("Invalid expiry") from None
|
|
101
|
+
if (now if now is not None else time.time()) > exp:
|
|
102
|
+
raise LinkExpiredError("This payment link has expired")
|
|
103
|
+
payment = UPIPayment(
|
|
104
|
+
upi_id=fields["pa"],
|
|
105
|
+
payee_name=fields.get("pn", ""),
|
|
106
|
+
amount=fields.get("am"),
|
|
107
|
+
note=fields.get("tn"),
|
|
108
|
+
reference=fields.get("tr"),
|
|
109
|
+
merchant_code=fields.get("mc"),
|
|
110
|
+
currency=fields.get("cu", "INR"),
|
|
111
|
+
)
|
|
112
|
+
return payment, exp
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_payment_link(
|
|
116
|
+
link_or_query: str,
|
|
117
|
+
secret: Union[str, bytes],
|
|
118
|
+
*,
|
|
119
|
+
now: Optional[float] = None,
|
|
120
|
+
) -> UPIPayment:
|
|
121
|
+
"""Like :func:`verify_payment_link` but returns only the :class:`UPIPayment`."""
|
|
122
|
+
return verify_payment_link(link_or_query, secret, now=now)[0]
|
bitsopay/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The :class:`UPIPayment` value object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from . import validators as v
|
|
10
|
+
from .exceptions import ValidationError
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_AMOUNT = Decimal("10000000.00") # sanity cap: 1 crore INR
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class UPIPayment:
|
|
17
|
+
"""A validated request to pay ``amount`` INR to ``upi_id``.
|
|
18
|
+
|
|
19
|
+
``amount=None`` produces an *open amount* request where the payer types the amount.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
upi_id: str
|
|
23
|
+
payee_name: str
|
|
24
|
+
amount: Optional[v.Number] = None
|
|
25
|
+
note: Optional[str] = None
|
|
26
|
+
reference: Optional[str] = None
|
|
27
|
+
merchant_code: Optional[str] = None
|
|
28
|
+
currency: str = "INR"
|
|
29
|
+
max_amount: Decimal = DEFAULT_MAX_AMOUNT
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
name = v.validate_text(self.payee_name, "payee_name", v.MAX_NAME_LEN)
|
|
33
|
+
if not name:
|
|
34
|
+
raise ValidationError("payee_name is required")
|
|
35
|
+
if self.currency != "INR":
|
|
36
|
+
raise ValidationError("Only INR is supported by UPI")
|
|
37
|
+
max_amount = Decimal(str(self.max_amount))
|
|
38
|
+
object.__setattr__(self, "max_amount", max_amount)
|
|
39
|
+
object.__setattr__(self, "upi_id", v.validate_vpa(self.upi_id))
|
|
40
|
+
object.__setattr__(self, "payee_name", name)
|
|
41
|
+
object.__setattr__(self, "amount", v.validate_amount(self.amount, max_amount))
|
|
42
|
+
object.__setattr__(self, "note", v.validate_text(self.note, "note", v.MAX_NOTE_LEN))
|
|
43
|
+
object.__setattr__(self, "reference", v.validate_reference(self.reference))
|
|
44
|
+
object.__setattr__(self, "merchant_code", v.validate_mcc(self.merchant_code))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def uri(self) -> str:
|
|
48
|
+
"""The ``upi://pay`` deep link / QR payload."""
|
|
49
|
+
from .upi import build_upi_uri
|
|
50
|
+
|
|
51
|
+
return build_upi_uri(self)
|
|
52
|
+
|
|
53
|
+
def with_reference(self, reference: Optional[str] = None) -> UPIPayment:
|
|
54
|
+
"""Return a copy with a (generated, if omitted) transaction reference."""
|
|
55
|
+
from dataclasses import replace
|
|
56
|
+
|
|
57
|
+
return replace(self, reference=reference or v.new_reference())
|
bitsopay/page.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Self-contained HTML payment page (QR for desktop, app buttons for mobile)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import UPIPayment
|
|
9
|
+
from .qr import qr_data_uri
|
|
10
|
+
from .upi import APP_SCHEMES, build_app_uri
|
|
11
|
+
|
|
12
|
+
_APP_LABELS = {"gpay": "Google Pay", "phonepe": "PhonePe", "paytm": "Paytm"}
|
|
13
|
+
|
|
14
|
+
_TEMPLATE = """<!doctype html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8">
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
19
|
+
<meta name="robots" content="noindex, nofollow">
|
|
20
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
21
|
+
<title>Pay {payee}</title>
|
|
22
|
+
<style>
|
|
23
|
+
:root {{ color-scheme: light dark; --fg:#111827; --muted:#6b7280; --bg:#f3f4f6; --card:#fff; --accent:#4f46e5; }}
|
|
24
|
+
@media (prefers-color-scheme: dark) {{ :root {{ --fg:#f9fafb; --muted:#9ca3af; --bg:#111827; --card:#1f2937; }} }}
|
|
25
|
+
* {{ box-sizing: border-box; }}
|
|
26
|
+
body {{ margin:0; min-height:100vh; display:grid; place-items:center; padding:16px;
|
|
27
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background:var(--bg); color:var(--fg); }}
|
|
28
|
+
.card {{ width:100%; max-width:380px; background:var(--card); border-radius:16px; padding:24px; text-align:center;
|
|
29
|
+
box-shadow:0 8px 30px rgba(0,0,0,.12); }}
|
|
30
|
+
.payee {{ font-size:14px; color:var(--muted); margin:0; }}
|
|
31
|
+
.name {{ font-size:20px; font-weight:600; margin:2px 0 12px; word-break:break-word; }}
|
|
32
|
+
.amount {{ font-size:36px; font-weight:700; margin:4px 0 2px; }}
|
|
33
|
+
.note {{ color:var(--muted); font-size:14px; margin:0 0 16px; word-break:break-word; }}
|
|
34
|
+
.qr {{ width:240px; max-width:100%; height:auto; border-radius:8px; background:#fff; padding:8px; }}
|
|
35
|
+
.btn {{ display:block; margin:10px 0 0; padding:14px; border-radius:12px; text-decoration:none; font-weight:600;
|
|
36
|
+
background:var(--accent); color:#fff; }}
|
|
37
|
+
.btn.alt {{ background:transparent; color:var(--accent); border:1px solid var(--accent); }}
|
|
38
|
+
.upi {{ font-size:13px; color:var(--muted); margin-top:16px; word-break:break-all; }}
|
|
39
|
+
.hint {{ font-size:12px; color:var(--muted); margin-top:12px; }}
|
|
40
|
+
.timer {{ font-size:13px; color:var(--muted); margin:0 0 12px; }}
|
|
41
|
+
.expired .pay {{ display:none; }}
|
|
42
|
+
.expired-msg {{ display:none; color:#dc2626; font-weight:600; margin:16px 0; }}
|
|
43
|
+
.expired .expired-msg {{ display:block; }}
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<main class="card" id="card">
|
|
48
|
+
<p class="payee">Pay to</p>
|
|
49
|
+
<h1 class="name">{payee}</h1>
|
|
50
|
+
<div class="amount">{amount}</div>
|
|
51
|
+
{note}
|
|
52
|
+
{timer}
|
|
53
|
+
<div class="pay">
|
|
54
|
+
<img class="qr" src="{qr}" alt="UPI QR code for {payee}" width="240" height="240">
|
|
55
|
+
<a class="btn" href="{uri}">Pay with any UPI app</a>
|
|
56
|
+
{app_buttons}
|
|
57
|
+
<div class="upi">UPI ID: {upi_id}</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="expired-msg">This payment session has ended. Please request a new link.</div>
|
|
60
|
+
{ref}
|
|
61
|
+
<div class="hint">Scan the QR with any UPI app, or tap a button on your phone.</div>
|
|
62
|
+
</main>
|
|
63
|
+
{script}
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_SCRIPT = """<script>
|
|
70
|
+
(function(){{
|
|
71
|
+
var end = {exp} * 1000, card = document.getElementById("card"), t = document.getElementById("timer");
|
|
72
|
+
function tick(){{
|
|
73
|
+
var left = Math.floor((end - Date.now()) / 1000);
|
|
74
|
+
if (left <= 0) {{ card.className += " expired"; if (t) t.textContent = "Session ended"; return; }}
|
|
75
|
+
if (t) t.textContent = "Session ends in " + Math.floor(left/60) + ":" + ("0"+(left%60)).slice(-2);
|
|
76
|
+
setTimeout(tick, 1000);
|
|
77
|
+
}}
|
|
78
|
+
tick();
|
|
79
|
+
}})();
|
|
80
|
+
</script>"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_payment_page(payment: UPIPayment, expires_at: Optional[int] = None) -> str:
|
|
84
|
+
"""Return a complete, escaped HTML page for ``payment``.
|
|
85
|
+
|
|
86
|
+
If ``expires_at`` (unix seconds) is given, the page shows a countdown and hides the
|
|
87
|
+
QR and pay buttons when the session ends.
|
|
88
|
+
"""
|
|
89
|
+
amount = f"₹{payment.amount:,.2f}" if payment.amount is not None else "Enter amount in app"
|
|
90
|
+
apps = "\n ".join(
|
|
91
|
+
f'<a class="btn alt" href="{escape(build_app_uri(payment, app), quote=True)}">{escape(_APP_LABELS[app])}</a>'
|
|
92
|
+
for app in APP_SCHEMES
|
|
93
|
+
)
|
|
94
|
+
return _TEMPLATE.format(
|
|
95
|
+
payee=escape(payment.payee_name),
|
|
96
|
+
amount=amount,
|
|
97
|
+
note=f'<p class="note">{escape(payment.note)}</p>' if payment.note else "",
|
|
98
|
+
qr=qr_data_uri(payment),
|
|
99
|
+
uri=escape(payment.uri, quote=True),
|
|
100
|
+
app_buttons=apps,
|
|
101
|
+
upi_id=escape(payment.upi_id),
|
|
102
|
+
timer='<p class="timer" id="timer"></p>' if expires_at is not None else "",
|
|
103
|
+
script=_SCRIPT.format(exp=int(expires_at)) if expires_at is not None else "",
|
|
104
|
+
ref=f'<div class="upi">Ref: {escape(payment.reference)}</div>' if payment.reference else "",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_MESSAGE = """<!doctype html>
|
|
109
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
110
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
111
|
+
<meta name="robots" content="noindex, nofollow">
|
|
112
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
|
|
113
|
+
<title>{title}</title>
|
|
114
|
+
<style>
|
|
115
|
+
:root {{ color-scheme: light dark; }}
|
|
116
|
+
body {{ margin:0; min-height:100vh; display:grid; place-items:center; padding:16px;
|
|
117
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background:#f3f4f6; color:#111827; }}
|
|
118
|
+
@media (prefers-color-scheme: dark) {{ body {{ background:#111827; color:#f9fafb; }} .card {{ background:#1f2937 !important; }} }}
|
|
119
|
+
.card {{ max-width:380px; width:100%; background:#fff; border-radius:16px; padding:28px; text-align:center;
|
|
120
|
+
box-shadow:0 8px 30px rgba(0,0,0,.12); }}
|
|
121
|
+
h1 {{ font-size:20px; margin:0 0 8px; }} p {{ margin:6px 0; color:#6b7280; }}
|
|
122
|
+
</style></head>
|
|
123
|
+
<body><main class="card"><h1>{title}</h1><p>{message}</p>{ref}</main></body></html>
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_message_page(title: str, message: str, reference: Optional[str] = None) -> str:
|
|
128
|
+
"""A small status page (paid / expired / cancelled / not found)."""
|
|
129
|
+
return _MESSAGE.format(
|
|
130
|
+
title=escape(title),
|
|
131
|
+
message=escape(message),
|
|
132
|
+
ref=f"<p>Ref: {escape(reference)}</p>" if reference else "",
|
|
133
|
+
)
|
bitsopay/py.typed
ADDED
|
File without changes
|