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 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)