cedikit 1.0.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.
cedikit/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """cedikit - a Python toolkit for Ghanaian phone numbers, cedi amounts and Mobile Money data.
2
+
3
+ Everything runs offline; no data leaves the device.
4
+ """
5
+
6
+ from cedikit import fees, fraud, ids, ledger, money, phone, sms
7
+ from cedikit.exceptions import (
8
+ CedikitError,
9
+ CediTypeError,
10
+ InvalidIdentifier,
11
+ InvalidPhoneNumber,
12
+ MoneyParseError,
13
+ TemplateError,
14
+ )
15
+ from cedikit.money import Cedi
16
+
17
+ __version__ = "1.0.0"
18
+
19
+ __all__ = [
20
+ "Cedi",
21
+ "CediTypeError",
22
+ "CedikitError",
23
+ "InvalidIdentifier",
24
+ "InvalidPhoneNumber",
25
+ "MoneyParseError",
26
+ "TemplateError",
27
+ "__version__",
28
+ "fees",
29
+ "fraud",
30
+ "ids",
31
+ "ledger",
32
+ "money",
33
+ "phone",
34
+ "sms",
35
+ ]
cedikit/cli.py ADDED
@@ -0,0 +1,280 @@
1
+ """The ``cedikit`` command-line tool.
2
+
3
+ Examples::
4
+
5
+ cedikit phone clean customers.csv --column phone --output cleaned.csv
6
+ cedikit sms parse inbox.txt --export xlsx
7
+ cedikit fraud check "You have received GHS 500..." --sender 0551234567
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import sys
14
+ from datetime import date
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Annotated, Optional
18
+
19
+ import typer
20
+
21
+ from cedikit import __version__, fees, fraud, money, phone
22
+ from cedikit.exceptions import CedikitError
23
+ from cedikit.fees import Kind
24
+ from cedikit.ids import ghana_card, gpgps
25
+ from cedikit.ledger import Ledger
26
+ from cedikit.sms.anonymise import anonymise_many
27
+
28
+ app = typer.Typer(help="Tools for Ghanaian phone numbers, cedi amounts and Mobile Money SMS.")
29
+ phone_app = typer.Typer(help="Clean, check and format phone numbers.")
30
+ money_app = typer.Typer(help="Parse, format and spell out cedi amounts.")
31
+ sms_app = typer.Typer(help="Parse and anonymise Mobile Money SMS.")
32
+ fraud_app = typer.Typer(help="Check payment SMS for signs of fraud.")
33
+ fees_app = typer.Typer(help="Estimate Mobile Money charges.")
34
+ ids_app = typer.Typer(help="Check Ghana Card numbers and digital addresses.")
35
+ for sub, name in [
36
+ (phone_app, "phone"),
37
+ (money_app, "money"),
38
+ (sms_app, "sms"),
39
+ (fraud_app, "fraud"),
40
+ (fees_app, "fees"),
41
+ (ids_app, "ids"),
42
+ ]:
43
+ app.add_typer(sub, name=name)
44
+
45
+
46
+ class ExportFormat(str, Enum):
47
+ csv = "csv"
48
+ xlsx = "xlsx"
49
+ json = "json"
50
+
51
+
52
+ class PhoneStyleOption(str, Enum):
53
+ e164 = "e164"
54
+ local = "local"
55
+ pretty = "pretty"
56
+ international = "international"
57
+
58
+
59
+ KindOption = Enum("KindOption", {k: k for k in Kind.__args__}, type=str) # type: ignore[misc]
60
+
61
+
62
+ def _fail(message: str) -> typer.Exit:
63
+ typer.secho(message, fg=typer.colors.RED, err=True)
64
+ return typer.Exit(1)
65
+
66
+
67
+ def _version(value: bool) -> None:
68
+ if value:
69
+ typer.echo(f"cedikit {__version__}")
70
+ raise typer.Exit()
71
+
72
+
73
+ @app.callback()
74
+ def main(
75
+ version: Annotated[
76
+ Optional[bool], # noqa: UP045 - typer needs Optional on Python 3.10
77
+ typer.Option("--version", callback=_version, is_eager=True, help="Show the version."),
78
+ ] = None,
79
+ ) -> None:
80
+ """cedikit: built in Ghana, for Ghana. Everything runs offline."""
81
+
82
+
83
+ # -- phone --------------------------------------------------------------------
84
+
85
+
86
+ @phone_app.command("clean")
87
+ def phone_clean(
88
+ input: Annotated[Path, typer.Argument(exists=True, dir_okay=False, help="CSV file.")],
89
+ column: Annotated[str, typer.Option(help="Column holding the phone numbers.")] = "phone",
90
+ output: Annotated[Optional[Path], typer.Option(help="Where to write the cleaned CSV.")] = None, # noqa: UP045
91
+ style: Annotated[PhoneStyleOption, typer.Option(help="Output format.")] = PhoneStyleOption.e164,
92
+ ) -> None:
93
+ """Normalise a column of phone numbers and report what was fixed or invalid."""
94
+ with input.open(newline="", encoding="utf-8-sig") as fh:
95
+ reader = csv.DictReader(fh)
96
+ rows = list(reader)
97
+ fields = list(reader.fieldnames or [])
98
+ if column not in fields:
99
+ raise _fail(f"No column {column!r}. Columns: {', '.join(fields)}")
100
+
101
+ report = phone.clean_column(row[column] for row in rows)
102
+ output = output or input.with_name(f"{input.stem}_cleaned.csv")
103
+ with output.open("w", newline="", encoding="utf-8") as fh:
104
+ writer = csv.DictWriter(fh, fieldnames=[*fields, f"{column}_status", f"{column}_note"])
105
+ writer.writeheader()
106
+ for row, result in zip(rows, report.results, strict=True):
107
+ if result.normalised:
108
+ row[column] = phone.format(result.normalised, style.value)
109
+ writer.writerow(
110
+ {**row, f"{column}_status": result.status, f"{column}_note": result.reason or ""}
111
+ )
112
+ typer.echo(str(report))
113
+ for bad in report.invalid[:10]:
114
+ typer.echo(f" invalid: {bad.original!r} - {bad.reason}")
115
+ if report.invalid_count > 10:
116
+ typer.echo(f" ... and {report.invalid_count - 10} more (see the _status column)")
117
+ typer.echo(f"Saved {output}")
118
+
119
+
120
+ @phone_app.command("check")
121
+ def phone_check(number: str) -> None:
122
+ """Validate one number and show its formats and likely network."""
123
+ try:
124
+ e164 = phone.normalise(number)
125
+ except CedikitError as exc:
126
+ raise _fail(str(exc)) from None
127
+ guess = phone.likely_network(e164)
128
+ typer.echo(f"E.164: {e164}")
129
+ typer.echo(f"Local: {phone.format(e164, 'local')}")
130
+ typer.echo(f"International: {phone.format(e164, 'international')}")
131
+ typer.echo(f"Network: {guess.network} (likely - {guess.note})")
132
+
133
+
134
+ # -- money --------------------------------------------------------------------
135
+
136
+
137
+ @money_app.command("parse")
138
+ def money_parse(text: str) -> None:
139
+ """Parse text such as "GH₵1.2k" into an exact amount."""
140
+ try:
141
+ typer.echo(money.parse(text))
142
+ except CedikitError as exc:
143
+ raise _fail(str(exc)) from None
144
+
145
+
146
+ @money_app.command("words")
147
+ def money_words(amount: str) -> None:
148
+ """Spell out an amount, as on a cheque."""
149
+ try:
150
+ typer.echo(money.to_words(amount))
151
+ except (CedikitError, ValueError) as exc:
152
+ raise _fail(str(exc)) from None
153
+
154
+
155
+ # -- sms ----------------------------------------------------------------------
156
+
157
+
158
+ def _read_messages(path: Path) -> list[dict[str, str]]:
159
+ """Messages separated by blank lines (.txt), or a CSV with a ``text`` column and
160
+ optional ``sender`` and ``received_at`` (ISO date-time) columns."""
161
+ if path.suffix.lower() == ".csv":
162
+ with path.open(newline="", encoding="utf-8-sig") as fh:
163
+ return [row for row in csv.DictReader(fh) if row.get("text")]
164
+ blocks = path.read_text(encoding="utf-8-sig").replace("\r\n", "\n").split("\n\n")
165
+ return [{"text": b.strip()} for b in blocks if b.strip()]
166
+
167
+
168
+ @sms_app.command("parse")
169
+ def sms_parse(
170
+ input: Annotated[
171
+ Path,
172
+ typer.Argument(
173
+ exists=True, dir_okay=False, help="Messages separated by blank lines, or a CSV."
174
+ ),
175
+ ],
176
+ sender: Annotated[Optional[str], typer.Option(help="Sender ID of the messages.")] = None, # noqa: UP045
177
+ export: Annotated[Optional[ExportFormat], typer.Option(help="Save the ledger.")] = None, # noqa: UP045
178
+ output: Annotated[Optional[Path], typer.Option(help="Output file.")] = None, # noqa: UP045
179
+ ) -> None:
180
+ """Turn a file of MoMo SMS into a ledger and print a summary."""
181
+ ledger = Ledger.from_messages(_read_messages(input), sender).categorise()
182
+ typer.echo(str(ledger.summary()))
183
+ if ledger.notices:
184
+ typer.echo(f"{len(ledger.notices)} notices (e.g. airtime received) were not counted.")
185
+ if ledger.unrecognised:
186
+ typer.secho(
187
+ f"{len(ledger.unrecognised)} messages were not recognised.", fg=typer.colors.YELLOW
188
+ )
189
+ for gap in ledger.balance_gaps():
190
+ typer.secho(
191
+ f"Balance gap before {gap.after.transaction_id or 'a transaction'}: expected "
192
+ f"{money.format(gap.expected)}, message says {money.format(gap.actual)} "
193
+ "(a message may be missing).",
194
+ fg=typer.colors.YELLOW,
195
+ )
196
+ if export or output:
197
+ fmt = export.value if export else (output.suffix.lstrip(".") if output else "csv")
198
+ path = output or input.with_name(f"{input.stem}_ledger.{fmt}")
199
+ try:
200
+ ledger.export(path, fmt) # type: ignore[arg-type] # validated by export()
201
+ except (ValueError, ImportError) as exc:
202
+ raise _fail(str(exc)) from None
203
+ typer.echo(f"Saved {path}")
204
+
205
+
206
+ @sms_app.command("anonymise")
207
+ def sms_anonymise(
208
+ input: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
209
+ seed: Annotated[Optional[int], typer.Option(help="Make the output repeatable.")] = None, # noqa: UP045
210
+ ) -> None:
211
+ """Replace names, numbers, IDs, amounts and dates (keeps balances consistent)."""
212
+ messages = [(m["text"], m.get("sender") or None) for m in _read_messages(input)]
213
+ for result in anonymise_many(messages, seed=seed):
214
+ if result.needs_review:
215
+ typer.secho("# CHECK BY HAND: " + " ".join(result.notes), fg=typer.colors.YELLOW)
216
+ typer.echo(result.text + "\n")
217
+
218
+
219
+ # -- fraud --------------------------------------------------------------------
220
+
221
+
222
+ @fraud_app.command("check")
223
+ def fraud_check(
224
+ message: Annotated[str, typer.Argument(help="The SMS text (use - to read from stdin).")],
225
+ sender: Annotated[Optional[str], typer.Option(help="Who sent it.")] = None, # noqa: UP045
226
+ ) -> None:
227
+ """Rate how likely a payment SMS is to be fake, and explain why."""
228
+ text = sys.stdin.read() if message == "-" else message
229
+ report = fraud.check(text, sender=sender)
230
+ colour = {"LOW": typer.colors.GREEN, "MEDIUM": typer.colors.YELLOW, "HIGH": typer.colors.RED}
231
+ lines = str(report).splitlines()
232
+ typer.secho(lines[0], fg=colour[report.risk], bold=True)
233
+ typer.echo("\n".join(lines[1:]))
234
+ if sender is None:
235
+ typer.echo("Tip: pass --sender; fake alerts almost always come from personal numbers.")
236
+
237
+
238
+ # -- fees ---------------------------------------------------------------------
239
+
240
+
241
+ @fees_app.command("estimate")
242
+ def fees_estimate(
243
+ network: str,
244
+ kind: Annotated[KindOption, typer.Argument()],
245
+ amount: str,
246
+ on: Annotated[
247
+ Optional[str], # noqa: UP045
248
+ typer.Option(help="Date YYYY-MM-DD (default today)."),
249
+ ] = None,
250
+ ) -> None:
251
+ """Estimate the fee and E-Levy for a transaction."""
252
+ try:
253
+ day = date.fromisoformat(on) if on else None
254
+ typer.echo(str(fees.estimate(network, kind.value, amount, day)))
255
+ except (CedikitError, ValueError) as exc:
256
+ raise _fail(str(exc)) from None
257
+
258
+
259
+ # -- ids ----------------------------------------------------------------------
260
+
261
+
262
+ @ids_app.command("check")
263
+ def ids_check(value: str) -> None:
264
+ """Check a Ghana Card number or GhanaPostGPS address (format only)."""
265
+ if ghana_card.is_valid_format(value):
266
+ typer.echo(
267
+ f"Ghana Card number, valid format: {ghana_card.normalise(value)} "
268
+ f"({ghana_card.card_type(value)})"
269
+ )
270
+ elif gpgps.is_valid_format(value):
271
+ address = gpgps.parse(value)
272
+ place = ", ".join(p for p in (address.district, address.region) if p)
273
+ typer.echo(f"GhanaPostGPS address, valid format: {address.code} ({place})")
274
+ else:
275
+ raise _fail(f"{value!r} is neither a Ghana Card number nor a GhanaPostGPS address.")
276
+ typer.echo("Format check only: this does not confirm that it exists.")
277
+
278
+
279
+ if __name__ == "__main__": # pragma: no cover
280
+ app()
@@ -0,0 +1,34 @@
1
+ # Ghanaian mobile number prefixes and the network each was ORIGINALLY assigned to.
2
+ #
3
+ # Because of mobile number portability, a prefix only tells you the *likely*
4
+ # network - the subscriber may have ported to another operator.
5
+ #
6
+ # Keys are the two national digits after the trunk "0" (e.g. 024 -> "24").
7
+ #
8
+ # Sources (checked 2026-09-26):
9
+ # NCA, "Numbering Plan for Ghana" (nca.org.gh, uploaded 2021): 9-digit national
10
+ # numbers; 20 & 50 Ghana Telecom (now Telecel); 23 Glo; 24, 54, 55 MTN;
11
+ # 26, 56 Airtel and 27, 57 Millicom/Tigo (now AT); 25 mobile broadband.
12
+ # The plan predates later assignments: MTN received 025 blocks in 2021
13
+ # (Graphic Online, "MTN releases new network codes") and uses 053 and 059,
14
+ # which the plan still shows as unassigned.
15
+ # Not included: 028 (Kasapa/Expresso, which has shut down) and 029 (National
16
+ # Security, not public numbers).
17
+
18
+ version: "2026-09-26"
19
+ country_code: "233"
20
+ national_number_length: 9
21
+
22
+ networks:
23
+ MTN:
24
+ display_name: MTN Ghana
25
+ prefixes: ["24", "25", "53", "54", "55", "59"]
26
+ TELECEL:
27
+ display_name: Telecel Ghana
28
+ prefixes: ["20", "50"]
29
+ AT:
30
+ display_name: AT Ghana
31
+ prefixes: ["26", "27", "56", "57"]
32
+ GLO:
33
+ display_name: Glo Mobile Ghana
34
+ prefixes: ["23"]
cedikit/evaluation.py ADDED
@@ -0,0 +1,197 @@
1
+ """Measure the parser and fraud checker against labelled, anonymised messages.
2
+
3
+ The file format is the one used by ``tests/fixtures/sample_messages``: a YAML
4
+ list of entries with ``text``, ``sender`` and (for parser checks) ``expected``
5
+ fields. Keep a held-out set that is never used to tune rules or train models.
6
+
7
+ Example::
8
+
9
+ from cedikit import evaluation
10
+ genuine = evaluation.load("genuine.yaml")
11
+ scam = evaluation.load("scam.yaml")
12
+ print(evaluation.evaluate_fraud(genuine, scam))
13
+ print(evaluation.evaluate_parser(genuine))
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+ from decimal import Decimal
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ import yaml
25
+
26
+ from cedikit import fraud, sms
27
+ from cedikit.fraud.rules import FraudReport, Risk
28
+
29
+ if TYPE_CHECKING:
30
+ from cedikit.fraud.classifier import ScamClassifier
31
+
32
+ __all__ = ["FraudEvaluation", "ParserEvaluation", "evaluate_fraud", "evaluate_parser", "load"]
33
+
34
+ RECALL_TARGET = 0.90
35
+ PRECISION_TARGET = 0.85
36
+ PARSER_TARGET = 0.95
37
+ _MONEY_FIELDS = {"amount", "fee", "tax", "balance", "available_balance"}
38
+ _ORDER: dict[str, int] = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
39
+
40
+
41
+ def load(path: str | Path) -> list[dict[str, Any]]:
42
+ """Load a labelled YAML file."""
43
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
44
+ if not isinstance(data, list):
45
+ raise ValueError(f"{path}: expected a list of messages")
46
+ return data
47
+
48
+
49
+ def _ratio(a: int, b: int) -> float:
50
+ return a / b if b else 0.0
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class FraudEvaluation:
55
+ true_positives: int
56
+ false_positives: int
57
+ true_negatives: int
58
+ false_negatives: int
59
+ flag_at: Risk
60
+ missed: list[tuple[dict[str, Any], FraudReport]] = field(default_factory=list)
61
+ false_alarms: list[tuple[dict[str, Any], FraudReport]] = field(default_factory=list)
62
+
63
+ @property
64
+ def recall(self) -> float:
65
+ """Share of scams that were flagged."""
66
+ return _ratio(self.true_positives, self.true_positives + self.false_negatives)
67
+
68
+ @property
69
+ def precision(self) -> float:
70
+ """Share of flagged messages that really were scams."""
71
+ return _ratio(self.true_positives, self.true_positives + self.false_positives)
72
+
73
+ @property
74
+ def f1(self) -> float:
75
+ p, r = self.precision, self.recall
76
+ return 2 * p * r / (p + r) if p + r else 0.0
77
+
78
+ def __str__(self) -> str:
79
+ total = sum(
80
+ (self.true_positives, self.false_positives, self.true_negatives, self.false_negatives)
81
+ )
82
+
83
+ def mark(value: float, target: float) -> str:
84
+ return "meets target" if value >= target else f"below target {target:.0%}"
85
+
86
+ lines = [
87
+ f"Fraud detection on {total} messages (flagged at {self.flag_at} or above):",
88
+ f" Recall: {self.recall:.1%} ({mark(self.recall, RECALL_TARGET)})",
89
+ f" Precision: {self.precision:.1%} ({mark(self.precision, PRECISION_TARGET)})",
90
+ f" F1: {self.f1:.3f}",
91
+ f" Scams caught {self.true_positives}, missed {self.false_negatives}; "
92
+ f"false alarms {self.false_positives}, correct passes {self.true_negatives}",
93
+ ]
94
+ lines += [f" MISSED: {m.get('id', m['text'][:50])}" for m, _ in self.missed]
95
+ lines += [f" FALSE ALARM: {m.get('id', m['text'][:50])}" for m, _ in self.false_alarms]
96
+ if total < 100:
97
+ lines.append(f" Note: {total} messages is too few for reliable percentages.")
98
+ return "\n".join(lines)
99
+
100
+
101
+ def evaluate_fraud(
102
+ genuine: Sequence[dict[str, Any]],
103
+ scam: Sequence[dict[str, Any]],
104
+ *,
105
+ flag_at: Risk = "MEDIUM",
106
+ use_sender: bool = True,
107
+ classifier: ScamClassifier | None = None,
108
+ ) -> FraudEvaluation:
109
+ """Count how many scams are flagged and how many genuine messages are wrongly flagged.
110
+
111
+ Args:
112
+ flag_at: The lowest risk level counted as "flagged".
113
+ use_sender: Pass each message's sender to the checker. Set False to
114
+ measure how well the text alone is judged.
115
+ """
116
+ tp = fp = tn = fn = 0
117
+ missed: list[tuple[dict[str, Any], FraudReport]] = []
118
+ alarms: list[tuple[dict[str, Any], FraudReport]] = []
119
+ for sample, is_scam in [(g, False) for g in genuine] + [(s, True) for s in scam]:
120
+ report = fraud.check(
121
+ sample["text"],
122
+ sender=sample.get("sender") if use_sender else None,
123
+ classifier=classifier,
124
+ )
125
+ flagged = _ORDER[report.risk] >= _ORDER[flag_at]
126
+ if is_scam and flagged:
127
+ tp += 1
128
+ elif is_scam:
129
+ fn += 1
130
+ missed.append((sample, report))
131
+ elif flagged:
132
+ fp += 1
133
+ alarms.append((sample, report))
134
+ else:
135
+ tn += 1
136
+ return FraudEvaluation(tp, fp, tn, fn, flag_at, missed, alarms)
137
+
138
+
139
+ def _actual(tx: sms.Transaction, name: str) -> Any:
140
+ if name.startswith("counterparty_"):
141
+ return getattr(tx.counterparty, name.removeprefix("counterparty_"), None)
142
+ if name == "timestamp":
143
+ return tx.timestamp.isoformat() if tx.timestamp else None
144
+ if name == "type":
145
+ return tx.type.value
146
+ return getattr(tx, name)
147
+
148
+
149
+ def field_mismatches(tx: sms.Transaction, expected: dict[str, Any]) -> list[str]:
150
+ """Fields of ``tx`` that differ from ``expected``, as readable strings."""
151
+ problems = []
152
+ for name, want in expected.items():
153
+ if name in _MONEY_FIELDS and want is not None:
154
+ want = Decimal(str(want))
155
+ got = _actual(tx, name)
156
+ if got != want:
157
+ problems.append(f"{name}: expected {want!r}, got {got!r}")
158
+ return problems
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class ParserEvaluation:
163
+ total: int
164
+ fully_correct: int
165
+ failures: dict[str, list[str]] = field(default_factory=dict)
166
+
167
+ @property
168
+ def accuracy(self) -> float:
169
+ return _ratio(self.fully_correct, self.total)
170
+
171
+ def __str__(self) -> str:
172
+ status = "meets" if self.accuracy >= PARSER_TARGET else "below"
173
+ lines = [
174
+ f"Parser: {self.fully_correct}/{self.total} messages with every expected field "
175
+ f"correct ({self.accuracy:.1%}, {status} target {PARSER_TARGET:.0%})"
176
+ ]
177
+ for sample_id, problems in self.failures.items():
178
+ lines.append(f" {sample_id}: " + "; ".join(problems))
179
+ return "\n".join(lines)
180
+
181
+
182
+ def evaluate_parser(samples: Sequence[dict[str, Any]]) -> ParserEvaluation:
183
+ """Check that each sample parses with all its ``expected`` fields correct."""
184
+ correct = 0
185
+ failures: dict[str, list[str]] = {}
186
+ for i, sample in enumerate(samples):
187
+ sample_id = str(sample.get("id", i))
188
+ result = sms.parse(sample["text"], sender=sample.get("sender"))
189
+ if result.transaction is None:
190
+ failures[sample_id] = ["not recognised"]
191
+ continue
192
+ problems = field_mismatches(result.transaction, sample.get("expected", {}))
193
+ if problems:
194
+ failures[sample_id] = problems
195
+ else:
196
+ correct += 1
197
+ return ParserEvaluation(len(samples), correct, failures)
cedikit/exceptions.py ADDED
@@ -0,0 +1,42 @@
1
+ """Exceptions raised by cedikit."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CedikitError(Exception):
7
+ """Base class for all cedikit errors."""
8
+
9
+
10
+ class InvalidPhoneNumber(CedikitError, ValueError):
11
+ """Raised when a phone number cannot be normalised to a valid Ghanaian mobile number."""
12
+
13
+ def __init__(self, value: object, reason: str) -> None:
14
+ self.value = value
15
+ self.reason = reason
16
+ super().__init__(f"Invalid Ghanaian phone number {value!r}: {reason}")
17
+
18
+
19
+ class CediTypeError(CedikitError, TypeError):
20
+ """Raised when a float (or other unsafe type) is used as a money value."""
21
+
22
+
23
+ class InvalidIdentifier(CedikitError, ValueError):
24
+ """Raised when a Ghana Card number or digital address has the wrong format."""
25
+
26
+ def __init__(self, value: object, reason: str) -> None:
27
+ self.value = value
28
+ self.reason = reason
29
+ super().__init__(f"Invalid identifier {value!r}: {reason}")
30
+
31
+
32
+ class TemplateError(CedikitError):
33
+ """Raised when an SMS template file is malformed."""
34
+
35
+
36
+ class MoneyParseError(CedikitError, ValueError):
37
+ """Raised when text cannot be parsed as a cedi amount."""
38
+
39
+ def __init__(self, value: object, reason: str) -> None:
40
+ self.value = value
41
+ self.reason = reason
42
+ super().__init__(f"Cannot parse {value!r} as a cedi amount: {reason}")
@@ -0,0 +1,15 @@
1
+ """Estimate Mobile Money fees and the E-Levy from dated, sourced tables.
2
+
3
+ Results are always *estimates*: charges change, and the tables record only
4
+ what has been observed or published. Unknown values are ``None``, never guessed.
5
+
6
+ Example:
7
+ >>> from datetime import date
8
+ >>> from cedikit import fees
9
+ >>> fees.estimate("MTN", "cash_out", "50.00").fee
10
+ Decimal('0.50')
11
+ """
12
+
13
+ from cedikit.fees.calculator import FeeEstimate, Kind, estimate
14
+
15
+ __all__ = ["FeeEstimate", "Kind", "estimate"]