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 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
@@ -0,0 +1,6 @@
1
+ """``python -m bitsopay`` runs the command line interface."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ raise SystemExit(main())
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"&#8377;{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