gimacpay 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.
- gimacpay/__init__.py +119 -0
- gimacpay/__main__.py +217 -0
- gimacpay/builders.py +339 -0
- gimacpay/client.py +512 -0
- gimacpay/errors.py +159 -0
- gimacpay/intents.py +205 -0
- gimacpay/models.py +282 -0
- gimacpay/money.py +164 -0
- gimacpay/py.typed +0 -0
- gimacpay-0.1.0.dist-info/METADATA +311 -0
- gimacpay-0.1.0.dist-info/RECORD +14 -0
- gimacpay-0.1.0.dist-info/WHEEL +4 -0
- gimacpay-0.1.0.dist-info/entry_points.txt +2 -0
- gimacpay-0.1.0.dist-info/licenses/LICENSE +202 -0
gimacpay/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""gimacpay -- a Python client for GIMACPAY, the CEMAC interbank payment network.
|
|
2
|
+
|
|
3
|
+
GIMACPAY is operated by GIMAC (Groupement Interbancaire Monétique de l'Afrique
|
|
4
|
+
Centrale) and connects 59 commercial banks, six national treasuries and the
|
|
5
|
+
mobile-money wallets of the six CEMAC member states -- Cameroun, Tchad,
|
|
6
|
+
République centrafricaine, Guinée équatoriale, Gabon and Congo. Since July 2026
|
|
7
|
+
it also carries the BEAC's common QR code, and since July 2026 it is connected
|
|
8
|
+
to PAPSS for continental cross-border settlement.
|
|
9
|
+
|
|
10
|
+
This library implements the participant API as published in
|
|
11
|
+
``GIMACPay Swagger for payments issuers`` version 1.8 (specification file
|
|
12
|
+
1.10).
|
|
13
|
+
|
|
14
|
+
Zero runtime dependencies. See ``SECURITY.md`` for why that is deliberate.
|
|
15
|
+
|
|
16
|
+
Quick start::
|
|
17
|
+
|
|
18
|
+
from gimacpay import GimacPayClient, wallet_to_wallet
|
|
19
|
+
|
|
20
|
+
client = GimacPayClient(
|
|
21
|
+
base_url="https://.../stdendpointfacade/v1/external",
|
|
22
|
+
client_id="...", client_secret="...",
|
|
23
|
+
username="...", password="...",
|
|
24
|
+
default_dial_code="237",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
payment = client.send_and_resolve(
|
|
28
|
+
wallet_to_wallet(
|
|
29
|
+
amount=500,
|
|
30
|
+
walletsource="237699947943",
|
|
31
|
+
walletdestination="2356789009",
|
|
32
|
+
tomember="22001",
|
|
33
|
+
issuertrxref="ORD-2026-0001",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
print(payment.state) # ACCEPTED, REJECTED, ... -- never a guess
|
|
37
|
+
|
|
38
|
+
The one rule this library is built around, taken from the specification's
|
|
39
|
+
description of ``/payment/send``::
|
|
40
|
+
|
|
41
|
+
Participants should not guess the state if it's not given by GIMACPay.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from .builders import (
|
|
47
|
+
account_inquiry,
|
|
48
|
+
bill_inquiry,
|
|
49
|
+
bill_payment,
|
|
50
|
+
build,
|
|
51
|
+
cash_deposit,
|
|
52
|
+
cash_in,
|
|
53
|
+
cash_out,
|
|
54
|
+
merchant_purchase,
|
|
55
|
+
mobile_reload,
|
|
56
|
+
request_to_pay,
|
|
57
|
+
wallet_to_wallet,
|
|
58
|
+
)
|
|
59
|
+
from .client import REFERENCE_SERVER, GimacPayClient
|
|
60
|
+
from .errors import (
|
|
61
|
+
GimacPayAuthError,
|
|
62
|
+
GimacPayConfigError,
|
|
63
|
+
GimacPayError,
|
|
64
|
+
GimacPayForbiddenError,
|
|
65
|
+
GimacPayProtocolError,
|
|
66
|
+
GimacPayTransportError,
|
|
67
|
+
PaymentRejected,
|
|
68
|
+
PaymentStateUnknown,
|
|
69
|
+
)
|
|
70
|
+
from .intents import ALL_INTENTS, Intent, is_known, required_fields
|
|
71
|
+
from .models import TERMINAL_STATES, AccountInfo, Bill, CustomerData, Payment, PaymentState
|
|
72
|
+
from .money import CEMAC_DIAL_CODES, CURRENCY_XAF, normalise_phone, validate_amount
|
|
73
|
+
|
|
74
|
+
__version__ = "0.1.0"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"__version__",
|
|
78
|
+
# client
|
|
79
|
+
"GimacPayClient",
|
|
80
|
+
"REFERENCE_SERVER",
|
|
81
|
+
# builders
|
|
82
|
+
"wallet_to_wallet",
|
|
83
|
+
"merchant_purchase",
|
|
84
|
+
"request_to_pay",
|
|
85
|
+
"cash_in",
|
|
86
|
+
"cash_deposit",
|
|
87
|
+
"cash_out",
|
|
88
|
+
"bill_inquiry",
|
|
89
|
+
"bill_payment",
|
|
90
|
+
"mobile_reload",
|
|
91
|
+
"account_inquiry",
|
|
92
|
+
"build",
|
|
93
|
+
# intents
|
|
94
|
+
"Intent",
|
|
95
|
+
"ALL_INTENTS",
|
|
96
|
+
"is_known",
|
|
97
|
+
"required_fields",
|
|
98
|
+
# models
|
|
99
|
+
"Payment",
|
|
100
|
+
"PaymentState",
|
|
101
|
+
"TERMINAL_STATES",
|
|
102
|
+
"AccountInfo",
|
|
103
|
+
"Bill",
|
|
104
|
+
"CustomerData",
|
|
105
|
+
# helpers
|
|
106
|
+
"CURRENCY_XAF",
|
|
107
|
+
"CEMAC_DIAL_CODES",
|
|
108
|
+
"normalise_phone",
|
|
109
|
+
"validate_amount",
|
|
110
|
+
# errors
|
|
111
|
+
"GimacPayError",
|
|
112
|
+
"GimacPayConfigError",
|
|
113
|
+
"GimacPayTransportError",
|
|
114
|
+
"GimacPayAuthError",
|
|
115
|
+
"GimacPayForbiddenError",
|
|
116
|
+
"PaymentRejected",
|
|
117
|
+
"PaymentStateUnknown",
|
|
118
|
+
"GimacPayProtocolError",
|
|
119
|
+
]
|
gimacpay/__main__.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Command line entry point: ``gimacpay``.
|
|
2
|
+
|
|
3
|
+
Deliberately conservative. Sending money from a shell is easy to do by
|
|
4
|
+
accident, so :command:`send` refuses to act without an explicit ``--confirm``,
|
|
5
|
+
and :command:`build` prints the payload without sending anything.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
gimacpay intents # what the specification defines
|
|
10
|
+
gimacpay intents mobile_transfer # required fields for one intent
|
|
11
|
+
gimacpay doctor # check config and authenticate
|
|
12
|
+
gimacpay build --intent mobile_transfer --amount 500 --walletsource ... \
|
|
13
|
+
--walletdestination ... --tomember 22001 --ref ORD-1
|
|
14
|
+
gimacpay send ... --confirm
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .builders import build as build_request
|
|
28
|
+
from .client import GimacPayClient
|
|
29
|
+
from .errors import GimacPayError, PaymentStateUnknown
|
|
30
|
+
from .intents import ALL_INTENTS, REQUIRED_FIELDS, required_fields
|
|
31
|
+
|
|
32
|
+
__all__ = ["main"]
|
|
33
|
+
|
|
34
|
+
_ENV_PREFIX = "GIMACPAY_"
|
|
35
|
+
|
|
36
|
+
#: Fields the CLI passes through as integers rather than strings.
|
|
37
|
+
_INT_FIELDS = frozenset({"amount", "validityduration", "pagesize", "firstelement"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="gimacpay",
|
|
43
|
+
description="Client for the GIMACPAY participant API (CEMAC).",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument("--version", action="version", version=f"gimacpay {__version__}")
|
|
46
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
|
|
48
|
+
intents = sub.add_parser("intents", help="list the intents in specification 1.10")
|
|
49
|
+
intents.add_argument("intent", nargs="?", help="show the required fields for one intent")
|
|
50
|
+
|
|
51
|
+
sub.add_parser("doctor", help="check configuration and authenticate")
|
|
52
|
+
|
|
53
|
+
for name, help_text in (
|
|
54
|
+
("build", "build a request and print it, without sending"),
|
|
55
|
+
("send", "send a request (requires --confirm)"),
|
|
56
|
+
):
|
|
57
|
+
cmd = sub.add_parser(name, help=help_text)
|
|
58
|
+
cmd.add_argument("--intent", required=True)
|
|
59
|
+
cmd.add_argument("--ref", required=True, dest="issuertrxref")
|
|
60
|
+
cmd.add_argument("--amount", type=int)
|
|
61
|
+
cmd.add_argument("--currency", default="950")
|
|
62
|
+
cmd.add_argument("--tomember")
|
|
63
|
+
cmd.add_argument("--walletsource")
|
|
64
|
+
cmd.add_argument("--walletdestination")
|
|
65
|
+
cmd.add_argument("--sendermobile")
|
|
66
|
+
cmd.add_argument("--receivermobile")
|
|
67
|
+
cmd.add_argument("--validityduration", type=int)
|
|
68
|
+
cmd.add_argument("--description")
|
|
69
|
+
cmd.add_argument("--extra", help="extra fields as a JSON object")
|
|
70
|
+
cmd.add_argument(
|
|
71
|
+
"--wait",
|
|
72
|
+
type=float,
|
|
73
|
+
default=0.0,
|
|
74
|
+
help="seconds to follow the payment to a terminal state",
|
|
75
|
+
)
|
|
76
|
+
if name == "send":
|
|
77
|
+
cmd.add_argument(
|
|
78
|
+
"--confirm",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="required: without it nothing is sent",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _client_from_env() -> GimacPayClient:
|
|
87
|
+
"""Build a client from ``GIMACPAY_*`` environment variables."""
|
|
88
|
+
missing = [
|
|
89
|
+
key
|
|
90
|
+
for key in ("CLIENT_ID", "CLIENT_SECRET", "USERNAME", "PASSWORD")
|
|
91
|
+
if not os.environ.get(_ENV_PREFIX + key)
|
|
92
|
+
]
|
|
93
|
+
if missing:
|
|
94
|
+
raise SystemExit(
|
|
95
|
+
"missing environment variables: " + ", ".join(_ENV_PREFIX + key for key in missing)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return GimacPayClient(
|
|
99
|
+
base_url=os.environ.get(_ENV_PREFIX + "BASE_URL", None) or None or "",
|
|
100
|
+
client_id=os.environ[_ENV_PREFIX + "CLIENT_ID"],
|
|
101
|
+
client_secret=os.environ[_ENV_PREFIX + "CLIENT_SECRET"],
|
|
102
|
+
username=os.environ[_ENV_PREFIX + "USERNAME"],
|
|
103
|
+
password=os.environ[_ENV_PREFIX + "PASSWORD"],
|
|
104
|
+
default_dial_code=os.environ.get(_ENV_PREFIX + "DIAL_CODE"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _payload(args: argparse.Namespace) -> dict[str, Any]:
|
|
109
|
+
fields: dict[str, Any] = {
|
|
110
|
+
"issuertrxref": args.issuertrxref,
|
|
111
|
+
"amount": args.amount,
|
|
112
|
+
"currency": args.currency,
|
|
113
|
+
"tomember": args.tomember,
|
|
114
|
+
"walletsource": args.walletsource,
|
|
115
|
+
"walletdestination": args.walletdestination,
|
|
116
|
+
"sendermobile": args.sendermobile,
|
|
117
|
+
"receivermobile": args.receivermobile,
|
|
118
|
+
"validityduration": args.validityduration,
|
|
119
|
+
"description": args.description,
|
|
120
|
+
}
|
|
121
|
+
if args.extra:
|
|
122
|
+
try:
|
|
123
|
+
extra = json.loads(args.extra)
|
|
124
|
+
except json.JSONDecodeError as exc:
|
|
125
|
+
raise SystemExit(f"--extra is not valid JSON: {exc}") from exc
|
|
126
|
+
if not isinstance(extra, dict):
|
|
127
|
+
raise SystemExit("--extra must be a JSON object")
|
|
128
|
+
fields.update(extra)
|
|
129
|
+
|
|
130
|
+
return build_request(args.intent, **{k: v for k, v in fields.items() if v is not None})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cmd_intents(intent: str | None) -> int:
|
|
134
|
+
if intent:
|
|
135
|
+
if intent not in ALL_INTENTS:
|
|
136
|
+
print(f"unknown intent {intent!r} in specification 1.10", file=sys.stderr)
|
|
137
|
+
print("known intents:", ", ".join(sorted(ALL_INTENTS)), file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
print(f"{intent}:")
|
|
140
|
+
for name in required_fields(intent):
|
|
141
|
+
print(f" - {name}")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
for name in sorted(ALL_INTENTS):
|
|
145
|
+
fields = REQUIRED_FIELDS.get(name)
|
|
146
|
+
suffix = f" ({len(fields)} required)" if fields else " (no builder; use build())"
|
|
147
|
+
print(f"{name}{suffix}")
|
|
148
|
+
print(f"\n{len(ALL_INTENTS)} intents, specification version 1.10", file=sys.stderr)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cmd_doctor() -> int:
|
|
153
|
+
client = _client_from_env()
|
|
154
|
+
print(f"base_url : {client.base_url}")
|
|
155
|
+
print(f"username : {client.username}")
|
|
156
|
+
print("authenticating...")
|
|
157
|
+
client.authenticate()
|
|
158
|
+
print("OK: authenticated")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cmd_build(args: argparse.Namespace) -> int:
|
|
163
|
+
print(json.dumps(_payload(args), indent=2, ensure_ascii=False))
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cmd_send(args: argparse.Namespace) -> int:
|
|
168
|
+
if not args.confirm:
|
|
169
|
+
print(
|
|
170
|
+
"refusing to send without --confirm.\n"
|
|
171
|
+
"Review the payload first with:\n"
|
|
172
|
+
" gimacpay build ...",
|
|
173
|
+
file=sys.stderr,
|
|
174
|
+
)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
client = _client_from_env()
|
|
178
|
+
payload = _payload(args)
|
|
179
|
+
|
|
180
|
+
if args.wait > 0:
|
|
181
|
+
payment = client.send_and_resolve(payload, timeout=args.wait)
|
|
182
|
+
else:
|
|
183
|
+
payment = client.send_payment(payload)
|
|
184
|
+
|
|
185
|
+
print(json.dumps(payment.raw, indent=2, ensure_ascii=False))
|
|
186
|
+
print(f"\nstate: {payment.state}", file=sys.stderr)
|
|
187
|
+
if not payment.is_terminal:
|
|
188
|
+
print(
|
|
189
|
+
f"note: not terminal. Inquire with:\n issuertrxref={payment.issuertrxref}",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
196
|
+
args = _parser().parse_args(argv)
|
|
197
|
+
|
|
198
|
+
handlers = {
|
|
199
|
+
"intents": lambda: _cmd_intents(getattr(args, "intent", None)),
|
|
200
|
+
"doctor": _cmd_doctor,
|
|
201
|
+
"build": lambda: _cmd_build(args),
|
|
202
|
+
"send": lambda: _cmd_send(args),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
return handlers[args.command]()
|
|
207
|
+
except PaymentStateUnknown as exc:
|
|
208
|
+
print(f"STATE UNKNOWN: {exc}", file=sys.stderr)
|
|
209
|
+
print("the payment is not failed -- inquire before acting", file=sys.stderr)
|
|
210
|
+
return 3
|
|
211
|
+
except GimacPayError as exc:
|
|
212
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__": # pragma: no cover
|
|
217
|
+
raise SystemExit(main())
|
gimacpay/builders.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Request builders -- one function per ``intent`` you are likely to need.
|
|
2
|
+
|
|
3
|
+
Why these exist: ``/payment/send`` accepts a ``oneOf`` of 24 schemas selected
|
|
4
|
+
by the ``intent`` field. Building that dictionary by hand means remembering a
|
|
5
|
+
discriminator string and a set of required fields, in a system where a typo
|
|
6
|
+
moves money. These helpers set ``intent`` themselves, validate the amount, and
|
|
7
|
+
normalise phone numbers.
|
|
8
|
+
|
|
9
|
+
Every builder returns a plain ``dict``. Nothing here is opaque: you can always
|
|
10
|
+
print the result, amend it, and pass it to
|
|
11
|
+
:meth:`~gimacpay.client.GimacPayClient.send_payment` yourself.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .intents import Intent
|
|
20
|
+
from .money import CURRENCY_XAF, normalise_phone, validate_amount
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"wallet_to_wallet",
|
|
24
|
+
"merchant_purchase",
|
|
25
|
+
"request_to_pay",
|
|
26
|
+
"cash_in",
|
|
27
|
+
"cash_deposit",
|
|
28
|
+
"cash_out",
|
|
29
|
+
"bill_inquiry",
|
|
30
|
+
"bill_payment",
|
|
31
|
+
"mobile_reload",
|
|
32
|
+
"account_inquiry",
|
|
33
|
+
"build",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _base(intent: str, issuertrxref: str, **rest: Any) -> dict[str, Any]:
|
|
38
|
+
"""Assemble a request, dropping ``None`` values.
|
|
39
|
+
|
|
40
|
+
``issuertrxref`` is your own reference and the only handle you have on the
|
|
41
|
+
payment afterwards -- :meth:`GimacPayClient.await_state` looks it up. It is
|
|
42
|
+
required on every request, so it is required here.
|
|
43
|
+
"""
|
|
44
|
+
if not issuertrxref:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"issuertrxref is required: it is the only reference you can use to "
|
|
47
|
+
"inquire about this payment later"
|
|
48
|
+
)
|
|
49
|
+
payload: dict[str, Any] = {"intent": intent, "issuertrxref": str(issuertrxref)}
|
|
50
|
+
payload.update({k: v for k, v in rest.items() if v is not None})
|
|
51
|
+
return payload
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _phone(value: str | None, field: str) -> str | None:
|
|
55
|
+
"""Normalise a phone number, refusing to guess a missing country code."""
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return normalise_phone(value)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise ValueError(f"{field}: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wallet_to_wallet(
|
|
65
|
+
*,
|
|
66
|
+
amount: object,
|
|
67
|
+
walletsource: str,
|
|
68
|
+
walletdestination: str,
|
|
69
|
+
tomember: str,
|
|
70
|
+
issuertrxref: str,
|
|
71
|
+
description: str | None = None,
|
|
72
|
+
currency: str = CURRENCY_XAF,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""Move money from one mobile wallet to another (``mobile_transfer``)."""
|
|
75
|
+
return _base(
|
|
76
|
+
Intent.MOBILE_TRANSFER,
|
|
77
|
+
issuertrxref,
|
|
78
|
+
amount=validate_amount(amount),
|
|
79
|
+
currency=currency,
|
|
80
|
+
walletsource=_phone(walletsource, "walletsource"),
|
|
81
|
+
walletdestination=_phone(walletdestination, "walletdestination"),
|
|
82
|
+
tomember=str(tomember),
|
|
83
|
+
description=description,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def merchant_purchase(
|
|
88
|
+
*,
|
|
89
|
+
amount: object,
|
|
90
|
+
walletsource: str,
|
|
91
|
+
walletdestination: str,
|
|
92
|
+
tomember: str,
|
|
93
|
+
issuertrxref: str,
|
|
94
|
+
description: str | None = None,
|
|
95
|
+
currency: str = CURRENCY_XAF,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Pay a merchant from a wallet (``merchant_purchase``).
|
|
98
|
+
|
|
99
|
+
``walletdestination`` is the merchant's wallet identifier, which in the
|
|
100
|
+
specification's example is a short numeric code rather than a phone number
|
|
101
|
+
-- so it is passed through untouched.
|
|
102
|
+
"""
|
|
103
|
+
return _base(
|
|
104
|
+
Intent.MERCHANT_PURCHASE,
|
|
105
|
+
issuertrxref,
|
|
106
|
+
amount=validate_amount(amount),
|
|
107
|
+
currency=currency,
|
|
108
|
+
walletsource=_phone(walletsource, "walletsource"),
|
|
109
|
+
walletdestination=str(walletdestination),
|
|
110
|
+
tomember=str(tomember),
|
|
111
|
+
description=description,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def request_to_pay(
|
|
116
|
+
*,
|
|
117
|
+
amount: object,
|
|
118
|
+
walletsource: str,
|
|
119
|
+
walletdestination: str,
|
|
120
|
+
tomember: str,
|
|
121
|
+
issuertrxref: str,
|
|
122
|
+
currency: str = CURRENCY_XAF,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Ask a payer to pay you (``request_to_pay``).
|
|
125
|
+
|
|
126
|
+
Note the direction: ``walletsource`` is *your* identifier and
|
|
127
|
+
``walletdestination`` is the person being asked to pay.
|
|
128
|
+
"""
|
|
129
|
+
return _base(
|
|
130
|
+
Intent.REQUEST_TO_PAY,
|
|
131
|
+
issuertrxref,
|
|
132
|
+
amount=validate_amount(amount),
|
|
133
|
+
currency=currency,
|
|
134
|
+
walletsource=str(walletsource),
|
|
135
|
+
walletdestination=_phone(walletdestination, "walletdestination"),
|
|
136
|
+
tomember=str(tomember),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cash_in(
|
|
141
|
+
*,
|
|
142
|
+
amount: object,
|
|
143
|
+
walletdestination: str,
|
|
144
|
+
tomember: str,
|
|
145
|
+
teller: str,
|
|
146
|
+
pointofservice: str,
|
|
147
|
+
issuertrxref: str,
|
|
148
|
+
currency: str = CURRENCY_XAF,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Credit a wallet with cash handed over at a counter (``cash_in``)."""
|
|
151
|
+
return _base(
|
|
152
|
+
Intent.CASH_IN,
|
|
153
|
+
issuertrxref,
|
|
154
|
+
amount=validate_amount(amount),
|
|
155
|
+
currency=currency,
|
|
156
|
+
walletdestination=_phone(walletdestination, "walletdestination"),
|
|
157
|
+
tomember=str(tomember),
|
|
158
|
+
teller=str(teller),
|
|
159
|
+
pointofservice=str(pointofservice),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cash_deposit(
|
|
164
|
+
*,
|
|
165
|
+
amount: object,
|
|
166
|
+
walletdestination: str,
|
|
167
|
+
tomember: str,
|
|
168
|
+
issuertrxref: str,
|
|
169
|
+
currency: str = CURRENCY_XAF,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Deposit cash into a wallet (``cash_deposit``)."""
|
|
172
|
+
return _base(
|
|
173
|
+
Intent.CASH_DEPOSIT,
|
|
174
|
+
issuertrxref,
|
|
175
|
+
amount=validate_amount(amount),
|
|
176
|
+
currency=currency,
|
|
177
|
+
walletdestination=_phone(walletdestination, "walletdestination"),
|
|
178
|
+
tomember=str(tomember),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cash_out(
|
|
183
|
+
*,
|
|
184
|
+
amount: object,
|
|
185
|
+
walletsource: str,
|
|
186
|
+
issuertrxref: str,
|
|
187
|
+
validityduration: int,
|
|
188
|
+
receiver_firstname: str,
|
|
189
|
+
receiver_secondname: str,
|
|
190
|
+
merchantid: str | None = None,
|
|
191
|
+
currency: str = CURRENCY_XAF,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Withdraw cash from a wallet (``cash_out``).
|
|
194
|
+
|
|
195
|
+
``validityduration`` is in seconds: how long the withdrawal voucher stays
|
|
196
|
+
usable. The specification's examples use 4500.
|
|
197
|
+
"""
|
|
198
|
+
receiver: dict[str, Any] = {
|
|
199
|
+
"firstname": receiver_firstname,
|
|
200
|
+
"secondname": receiver_secondname,
|
|
201
|
+
}
|
|
202
|
+
if merchantid is not None:
|
|
203
|
+
receiver["merchantid"] = str(merchantid)
|
|
204
|
+
|
|
205
|
+
return _base(
|
|
206
|
+
Intent.CASH_OUT,
|
|
207
|
+
issuertrxref,
|
|
208
|
+
amount=validate_amount(amount),
|
|
209
|
+
currency=currency,
|
|
210
|
+
walletsource=str(walletsource),
|
|
211
|
+
validityduration=int(validityduration),
|
|
212
|
+
receivercustomerdata=[receiver],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def bill_inquiry(
|
|
217
|
+
*,
|
|
218
|
+
walletsource: str,
|
|
219
|
+
tomember: str,
|
|
220
|
+
issuertrxref: str,
|
|
221
|
+
query_ref: str,
|
|
222
|
+
contract_ref: str,
|
|
223
|
+
service_ref: str,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Ask a biller what is owed, before paying (``bill_inquiry``).
|
|
226
|
+
|
|
227
|
+
No ``amount``: the point of an inquiry is to discover it.
|
|
228
|
+
"""
|
|
229
|
+
return _base(
|
|
230
|
+
Intent.BILL_INQUIRY,
|
|
231
|
+
issuertrxref,
|
|
232
|
+
tomember=str(tomember),
|
|
233
|
+
walletsource=str(walletsource),
|
|
234
|
+
billInquiryData={
|
|
235
|
+
"queryRef": str(query_ref),
|
|
236
|
+
"contractRef": str(contract_ref),
|
|
237
|
+
"serviceRef": str(service_ref),
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def bill_payment(
|
|
243
|
+
*,
|
|
244
|
+
amount: object,
|
|
245
|
+
walletsource: str,
|
|
246
|
+
tomember: str,
|
|
247
|
+
service: str,
|
|
248
|
+
bill_refs: Sequence[str],
|
|
249
|
+
issuertrxref: str,
|
|
250
|
+
currency: str = CURRENCY_XAF,
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
"""Pay one or more bills identified by reference (``bill_payment``).
|
|
253
|
+
|
|
254
|
+
The ``billRef`` values come from :func:`bill_inquiry` -- the specification's
|
|
255
|
+
example is an ENEO reference of the form
|
|
256
|
+
``S-112-950-ENEO-10039-<hash>``.
|
|
257
|
+
"""
|
|
258
|
+
refs: list[str] = [str(ref) for ref in bill_refs]
|
|
259
|
+
if not refs:
|
|
260
|
+
raise ValueError("bill_refs must contain at least one bill reference")
|
|
261
|
+
|
|
262
|
+
return _base(
|
|
263
|
+
Intent.BILL_PAYMENT,
|
|
264
|
+
issuertrxref,
|
|
265
|
+
amount=validate_amount(amount),
|
|
266
|
+
currency=currency,
|
|
267
|
+
walletsource=str(walletsource),
|
|
268
|
+
tomember=str(tomember),
|
|
269
|
+
service=str(service),
|
|
270
|
+
billList=[{"billRef": ref} for ref in refs],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def mobile_reload(
|
|
275
|
+
*,
|
|
276
|
+
amount: object,
|
|
277
|
+
sendermobile: str,
|
|
278
|
+
receivermobile: str,
|
|
279
|
+
tomember: str,
|
|
280
|
+
issuertrxref: str,
|
|
281
|
+
currency: str = CURRENCY_XAF,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Buy airtime for a number (``mobile_reload``)."""
|
|
284
|
+
return _base(
|
|
285
|
+
Intent.MOBILE_RELOAD,
|
|
286
|
+
issuertrxref,
|
|
287
|
+
amount=validate_amount(amount),
|
|
288
|
+
currency=currency,
|
|
289
|
+
sendermobile=_phone(sendermobile, "sendermobile"),
|
|
290
|
+
receivermobile=_phone(receivermobile, "receivermobile"),
|
|
291
|
+
tomember=str(tomember),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def account_inquiry(
|
|
296
|
+
*,
|
|
297
|
+
tomember: str,
|
|
298
|
+
issuertrxref: str,
|
|
299
|
+
walletdestination: str | None = None,
|
|
300
|
+
dstaccount: str | None = None,
|
|
301
|
+
dstaccount_type: str = "ACCOUNT",
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Resolve a wallet or account before sending to it (``account_inquiry``).
|
|
304
|
+
|
|
305
|
+
Pass exactly one of ``walletdestination`` or ``dstaccount``. The
|
|
306
|
+
specification has two worked examples for this intent, one for each.
|
|
307
|
+
"""
|
|
308
|
+
if (walletdestination is None) == (dstaccount is None):
|
|
309
|
+
raise ValueError("pass exactly one of walletdestination or dstaccount")
|
|
310
|
+
|
|
311
|
+
payload = _base(Intent.ACCOUNT_INQUIRY, issuertrxref, tomember=str(tomember))
|
|
312
|
+
if walletdestination is not None:
|
|
313
|
+
payload["walletdestination"] = _phone(walletdestination, "walletdestination")
|
|
314
|
+
else:
|
|
315
|
+
payload["dstaccounts"] = [{"iden": str(dstaccount), "type": dstaccount_type}]
|
|
316
|
+
return payload
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def build(intent: str, /, **fields: Any) -> dict[str, Any]:
|
|
320
|
+
"""Build a request for any intent, including ones this module does not cover.
|
|
321
|
+
|
|
322
|
+
Use this for the eight remittance intents and anything GIMACPay adds later.
|
|
323
|
+
It validates the amount if one is supplied, but it does not know the
|
|
324
|
+
required fields for an intent it has no builder for -- so it cannot protect
|
|
325
|
+
you from an incomplete request. Prefer the named builders.
|
|
326
|
+
|
|
327
|
+
Example::
|
|
328
|
+
|
|
329
|
+
build("inc_acc_remit",
|
|
330
|
+
issuertrxref="R-1", amount=750, tomember="10029",
|
|
331
|
+
sendermobile="+336575950505",
|
|
332
|
+
dstaccounts=[{"iden": "100291525478502", "type": "ACCOUNT"}])
|
|
333
|
+
"""
|
|
334
|
+
if not intent:
|
|
335
|
+
raise ValueError("intent is required")
|
|
336
|
+
if "amount" in fields:
|
|
337
|
+
fields["amount"] = validate_amount(fields["amount"])
|
|
338
|
+
fields.setdefault("currency", CURRENCY_XAF)
|
|
339
|
+
return _base(intent, fields.pop("issuertrxref", ""), **fields)
|