paystack-webhook 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (35) hide show
  1. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/PKG-INFO +49 -1
  2. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/README.md +48 -0
  3. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/PKG-INFO +49 -1
  4. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/SOURCES.txt +10 -1
  5. paystack_webhook-0.2.0/paystack_webhook.egg-info/entry_points.txt +2 -0
  6. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/__init__.py +18 -4
  7. paystack_webhook-0.2.0/paystack_webhooks/cli.py +143 -0
  8. paystack_webhook-0.2.0/paystack_webhooks/reconcile.py +183 -0
  9. paystack_webhook-0.2.0/paystack_webhooks/replay.py +74 -0
  10. paystack_webhook-0.2.0/paystack_webhooks/storage.py +127 -0
  11. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/pyproject.toml +4 -1
  12. paystack_webhook-0.2.0/tests/test_cli.py +88 -0
  13. paystack_webhook-0.2.0/tests/test_reconcile.py +122 -0
  14. paystack_webhook-0.2.0/tests/test_replay.py +89 -0
  15. paystack_webhook-0.2.0/tests/test_storage.py +75 -0
  16. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/LICENSE +0 -0
  17. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/dependency_links.txt +0 -0
  18. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/requires.txt +0 -0
  19. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/top_level.txt +0 -0
  20. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/contrib/__init__.py +0 -0
  21. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/contrib/fastapi.py +0 -0
  22. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/contrib/flask.py +0 -0
  23. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/errors.py +0 -0
  24. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/event.py +0 -0
  25. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/flutterwave.py +0 -0
  26. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/idempotency.py +0 -0
  27. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/paystack.py +0 -0
  28. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/signature.py +0 -0
  29. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/setup.cfg +0 -0
  30. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_adapters.py +0 -0
  31. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_event.py +0 -0
  32. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_flutterwave.py +0 -0
  33. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_idempotency.py +0 -0
  34. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_paystack.py +0 -0
  35. {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_signature.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paystack-webhook
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Verify Paystack and Flutterwave webhooks in Python: HMAC-SHA512, idempotency, replay-safe retries. Zero dependencies.
5
5
  Author: mgkvibez
6
6
  License: MIT
@@ -134,6 +134,54 @@ header, which proves the sender knows the hash and nothing else. A valid header
134
134
  accompany a tampered payload if the hash leaks. This library says so instead of
135
135
  implying parity with Paystack.
136
136
 
137
+ ## When a delivery never arrives
138
+
139
+ Verification only helps if the body shows up. The failure that costs money is the
140
+ opposite one: the provider says paid, your handler never ran, and nothing is reconciling
141
+ the two. This package now carries a store, a reconciliation job and a replay CLI for it.
142
+
143
+ Pass the store when you construct the webhook, so deliveries are recorded with their raw
144
+ bytes:
145
+
146
+ ```python
147
+ from paystack_webhooks import PaystackWebhook, SQLiteStore
148
+
149
+ store = SQLiteStore("deliveries.db")
150
+ paystack = PaystackWebhook("sk_live_...", store=store)
151
+ ```
152
+
153
+ Then compare the provider's ledger against what actually arrived:
154
+
155
+ ```bash
156
+ paystack-webhook reconcile --store deliveries.db --ledger transactions.json
157
+ ```
158
+
159
+ Each finding is a different bug with a different fix:
160
+
161
+ | Finding | What it means |
162
+ |---|---|
163
+ | `paid_no_delivery` | the ledger says paid and no webhook ever reached you |
164
+ | `delivery_no_ledger` | a delivery for a reference the provider does not know |
165
+ | `amount_mismatch` / `currency_mismatch` | the delivery disagrees with the ledger, in minor units |
166
+ | `duplicate_delivery` | more than one delivery for one reference |
167
+ | `ledger_state_mismatch` | the delivery says success, the ledger says otherwise |
168
+ | `unparseable_delivery` | the stored body is not a payload with a reference |
169
+
170
+ Exit codes are the contract: `0` clean, `1` findings, `2` usage error. `--json` for a
171
+ machine.
172
+
173
+ To resend a loss:
174
+
175
+ ```bash
176
+ paystack-webhook replay --store deliveries.db --url http://localhost:3000/webhook --reference T1234
177
+ ```
178
+
179
+ The body is reused byte for byte and the signature is recomputed over those bytes. A
180
+ re-serialised JSON body produces a signature that no longer matches, which is the bug
181
+ replay exists to avoid. Without `--secret` (or `$PAYSTACK_WEBHOOK_SECRET`) the stored
182
+ signature header is reused unchanged, which is only correct if your secret has not
183
+ rotated. `--dry-run` prints the plan and touches no network.
184
+
137
185
  ## The security model, stated plainly
138
186
 
139
187
  | | Paystack | Flutterwave |
@@ -105,6 +105,54 @@ header, which proves the sender knows the hash and nothing else. A valid header
105
105
  accompany a tampered payload if the hash leaks. This library says so instead of
106
106
  implying parity with Paystack.
107
107
 
108
+ ## When a delivery never arrives
109
+
110
+ Verification only helps if the body shows up. The failure that costs money is the
111
+ opposite one: the provider says paid, your handler never ran, and nothing is reconciling
112
+ the two. This package now carries a store, a reconciliation job and a replay CLI for it.
113
+
114
+ Pass the store when you construct the webhook, so deliveries are recorded with their raw
115
+ bytes:
116
+
117
+ ```python
118
+ from paystack_webhooks import PaystackWebhook, SQLiteStore
119
+
120
+ store = SQLiteStore("deliveries.db")
121
+ paystack = PaystackWebhook("sk_live_...", store=store)
122
+ ```
123
+
124
+ Then compare the provider's ledger against what actually arrived:
125
+
126
+ ```bash
127
+ paystack-webhook reconcile --store deliveries.db --ledger transactions.json
128
+ ```
129
+
130
+ Each finding is a different bug with a different fix:
131
+
132
+ | Finding | What it means |
133
+ |---|---|
134
+ | `paid_no_delivery` | the ledger says paid and no webhook ever reached you |
135
+ | `delivery_no_ledger` | a delivery for a reference the provider does not know |
136
+ | `amount_mismatch` / `currency_mismatch` | the delivery disagrees with the ledger, in minor units |
137
+ | `duplicate_delivery` | more than one delivery for one reference |
138
+ | `ledger_state_mismatch` | the delivery says success, the ledger says otherwise |
139
+ | `unparseable_delivery` | the stored body is not a payload with a reference |
140
+
141
+ Exit codes are the contract: `0` clean, `1` findings, `2` usage error. `--json` for a
142
+ machine.
143
+
144
+ To resend a loss:
145
+
146
+ ```bash
147
+ paystack-webhook replay --store deliveries.db --url http://localhost:3000/webhook --reference T1234
148
+ ```
149
+
150
+ The body is reused byte for byte and the signature is recomputed over those bytes. A
151
+ re-serialised JSON body produces a signature that no longer matches, which is the bug
152
+ replay exists to avoid. Without `--secret` (or `$PAYSTACK_WEBHOOK_SECRET`) the stored
153
+ signature header is reused unchanged, which is only correct if your secret has not
154
+ rotated. `--dry-run` prints the plan and touches no network.
155
+
108
156
  ## The security model, stated plainly
109
157
 
110
158
  | | Paystack | Flutterwave |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paystack-webhook
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Verify Paystack and Flutterwave webhooks in Python: HMAC-SHA512, idempotency, replay-safe retries. Zero dependencies.
5
5
  Author: mgkvibez
6
6
  License: MIT
@@ -134,6 +134,54 @@ header, which proves the sender knows the hash and nothing else. A valid header
134
134
  accompany a tampered payload if the hash leaks. This library says so instead of
135
135
  implying parity with Paystack.
136
136
 
137
+ ## When a delivery never arrives
138
+
139
+ Verification only helps if the body shows up. The failure that costs money is the
140
+ opposite one: the provider says paid, your handler never ran, and nothing is reconciling
141
+ the two. This package now carries a store, a reconciliation job and a replay CLI for it.
142
+
143
+ Pass the store when you construct the webhook, so deliveries are recorded with their raw
144
+ bytes:
145
+
146
+ ```python
147
+ from paystack_webhooks import PaystackWebhook, SQLiteStore
148
+
149
+ store = SQLiteStore("deliveries.db")
150
+ paystack = PaystackWebhook("sk_live_...", store=store)
151
+ ```
152
+
153
+ Then compare the provider's ledger against what actually arrived:
154
+
155
+ ```bash
156
+ paystack-webhook reconcile --store deliveries.db --ledger transactions.json
157
+ ```
158
+
159
+ Each finding is a different bug with a different fix:
160
+
161
+ | Finding | What it means |
162
+ |---|---|
163
+ | `paid_no_delivery` | the ledger says paid and no webhook ever reached you |
164
+ | `delivery_no_ledger` | a delivery for a reference the provider does not know |
165
+ | `amount_mismatch` / `currency_mismatch` | the delivery disagrees with the ledger, in minor units |
166
+ | `duplicate_delivery` | more than one delivery for one reference |
167
+ | `ledger_state_mismatch` | the delivery says success, the ledger says otherwise |
168
+ | `unparseable_delivery` | the stored body is not a payload with a reference |
169
+
170
+ Exit codes are the contract: `0` clean, `1` findings, `2` usage error. `--json` for a
171
+ machine.
172
+
173
+ To resend a loss:
174
+
175
+ ```bash
176
+ paystack-webhook replay --store deliveries.db --url http://localhost:3000/webhook --reference T1234
177
+ ```
178
+
179
+ The body is reused byte for byte and the signature is recomputed over those bytes. A
180
+ re-serialised JSON body produces a signature that no longer matches, which is the bug
181
+ replay exists to avoid. Without `--secret` (or `$PAYSTACK_WEBHOOK_SECRET`) the stored
182
+ signature header is reused unchanged, which is only correct if your secret has not
183
+ rotated. `--dry-run` prints the plan and touches no network.
184
+
137
185
  ## The security model, stated plainly
138
186
 
139
187
  | | Paystack | Flutterwave |
@@ -4,21 +4,30 @@ pyproject.toml
4
4
  paystack_webhook.egg-info/PKG-INFO
5
5
  paystack_webhook.egg-info/SOURCES.txt
6
6
  paystack_webhook.egg-info/dependency_links.txt
7
+ paystack_webhook.egg-info/entry_points.txt
7
8
  paystack_webhook.egg-info/requires.txt
8
9
  paystack_webhook.egg-info/top_level.txt
9
10
  paystack_webhooks/__init__.py
11
+ paystack_webhooks/cli.py
10
12
  paystack_webhooks/errors.py
11
13
  paystack_webhooks/event.py
12
14
  paystack_webhooks/flutterwave.py
13
15
  paystack_webhooks/idempotency.py
14
16
  paystack_webhooks/paystack.py
17
+ paystack_webhooks/reconcile.py
18
+ paystack_webhooks/replay.py
15
19
  paystack_webhooks/signature.py
20
+ paystack_webhooks/storage.py
16
21
  paystack_webhooks/contrib/__init__.py
17
22
  paystack_webhooks/contrib/fastapi.py
18
23
  paystack_webhooks/contrib/flask.py
19
24
  tests/test_adapters.py
25
+ tests/test_cli.py
20
26
  tests/test_event.py
21
27
  tests/test_flutterwave.py
22
28
  tests/test_idempotency.py
23
29
  tests/test_paystack.py
24
- tests/test_signature.py
30
+ tests/test_reconcile.py
31
+ tests/test_replay.py
32
+ tests/test_signature.py
33
+ tests/test_storage.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ paystack-webhook = paystack_webhooks.cli:main
@@ -1,8 +1,10 @@
1
- """Verify Paystack and Flutterwave webhooks without getting it wrong.
1
+ """Verify Paystack and Flutterwave webhooks without getting it wrong, then check up
2
+ on them when a delivery goes missing.
2
3
 
3
- from paystack_webhooks import PaystackWebhook, MemoryStore
4
+ from paystack_webhooks import PaystackWebhook, SQLiteStore
4
5
 
5
- paystack = PaystackWebhook("sk_live_...")
6
+ store = SQLiteStore("deliveries.db")
7
+ paystack = PaystackWebhook("sk_live_...", store=store)
6
8
  event = paystack.process(raw_body, headers) # None means already handled
7
9
  """
8
10
 
@@ -11,18 +13,30 @@ from .event import Event
11
13
  from .flutterwave import FlutterwaveWebhook
12
14
  from .idempotency import MemoryStore, Store
13
15
  from .paystack import PaystackWebhook
16
+ from .reconcile import Finding, LedgerEntry, load_ledger, reconcile
17
+ from .replay import ReplayResult, replay_deliveries, sign
18
+ from .storage import Delivery, SQLiteStore
14
19
 
15
- __version__ = "0.1.1"
20
+ __version__ = "0.2.0"
16
21
 
17
22
  __all__ = [
23
+ "Delivery",
18
24
  "Event",
25
+ "Finding",
19
26
  "FlutterwaveWebhook",
27
+ "LedgerEntry",
20
28
  "MalformedPayload",
21
29
  "MemoryStore",
22
30
  "PaystackWebhook",
23
31
  "RawBodyRequired",
32
+ "ReplayResult",
33
+ "SQLiteStore",
24
34
  "SignatureError",
25
35
  "Store",
26
36
  "WebhookError",
27
37
  "__version__",
38
+ "load_ledger",
39
+ "reconcile",
40
+ "replay_deliveries",
41
+ "sign",
28
42
  ]
@@ -0,0 +1,143 @@
1
+ """`paystack-webhook` - reconcile a ledger against deliveries, and replay them.
2
+
3
+ Exit codes are a contract, because this runs in cron and in CI:
4
+
5
+ 0 nothing wrong (no findings, or every replay was accepted)
6
+ 1 findings exist, or a replay was not accepted
7
+ 2 usage or configuration error (bad path, unreadable ledger, no url)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ from typing import List, Optional, Sequence
17
+
18
+ from . import __version__
19
+ from .reconcile import load_ledger, reconcile
20
+ from .replay import replay_deliveries
21
+ from .storage import SQLiteStore
22
+
23
+ EXIT_OK = 0
24
+ EXIT_FINDINGS = 1
25
+ EXIT_USAGE = 2
26
+
27
+ SECRET_ENV = "PAYSTACK_WEBHOOK_SECRET"
28
+
29
+
30
+ def _store(path: str) -> SQLiteStore:
31
+ if not path:
32
+ raise ValueError("--store is required")
33
+ if not os.path.exists(path):
34
+ raise ValueError("no store at %s (point it at the file your handler writes)" % path)
35
+ return SQLiteStore(path)
36
+
37
+
38
+ def _cmd_reconcile(args: argparse.Namespace) -> int:
39
+ store = _store(args.store)
40
+ try:
41
+ ledger = load_ledger(args.ledger)
42
+ n_deliveries = len(store)
43
+ findings = reconcile(store.deliveries(), ledger)
44
+ finally:
45
+ store.close()
46
+
47
+ if args.json:
48
+ print(json.dumps(
49
+ {"findings": [{"kind": f.kind, "reference": f.reference, "detail": f.detail}
50
+ for f in findings],
51
+ "evidence": {"deliveries": n_deliveries, "ledger_entries": len(ledger)}},
52
+ indent=2))
53
+ elif findings:
54
+ print("Reconciliation found %d problem(s):\n" % len(findings))
55
+ for f in findings:
56
+ print(" %-22s %-14s %s" % (f.kind, f.reference, f.detail))
57
+ else:
58
+ print("Reconciled %d ledger entries against %d deliveries, no discrepancies."
59
+ % (len(ledger), n_deliveries))
60
+ return EXIT_FINDINGS if findings else EXIT_OK
61
+
62
+
63
+ def _cmd_replay(args: argparse.Namespace) -> int:
64
+ store = _store(args.store)
65
+ try:
66
+ wanted: Optional[Sequence[str]] = args.reference or None
67
+ deliveries: List[object] = []
68
+ if wanted:
69
+ for reference in wanted:
70
+ found = store.deliveries(reference)
71
+ if not found:
72
+ raise ValueError("no stored delivery for reference %s" % reference)
73
+ deliveries.extend(found)
74
+ else:
75
+ deliveries = store.deliveries()
76
+ if not deliveries:
77
+ raise ValueError("the store holds no deliveries to replay")
78
+ secret = args.secret or os.environ.get(SECRET_ENV)
79
+
80
+ if args.dry_run:
81
+ for d in deliveries:
82
+ print("would replay %s to %s (%d bytes%s)"
83
+ % (d.reference or d.key, args.url, len(d.body),
84
+ ", re-signed" if secret else ", original signature kept"))
85
+ return EXIT_OK
86
+
87
+ results = replay_deliveries(deliveries, args.url, secret=secret)
88
+ finally:
89
+ store.close()
90
+
91
+ failed = 0
92
+ for r in results:
93
+ if r.ok:
94
+ print("replayed %-14s -> %s %s" % (r.reference or "-", r.status, r.url))
95
+ else:
96
+ failed += 1
97
+ print("FAILED %-14s -> %s %s"
98
+ % (r.reference or "-", r.status if r.status else r.error, r.url))
99
+ if failed:
100
+ print("\n%d of %d replays were not accepted" % (failed, len(results)))
101
+ return EXIT_FINDINGS
102
+ return EXIT_OK
103
+
104
+
105
+ def build_parser() -> argparse.ArgumentParser:
106
+ parser = argparse.ArgumentParser(
107
+ prog="paystack-webhook",
108
+ description="Reconcile a provider ledger against the webhooks that actually arrived.")
109
+ parser.add_argument("--version", action="version", version="paystack-webhook %s" % __version__)
110
+ sub = parser.add_subparsers(dest="command")
111
+
112
+ rec = sub.add_parser("reconcile", help="compare a ledger with recorded deliveries")
113
+ rec.add_argument("--store", required=True, help="SQLite file your handler writes")
114
+ rec.add_argument("--ledger", required=True, help="ledger JSON (list, or {data: [...]})")
115
+ rec.add_argument("--json", action="store_true", help="machine-readable output")
116
+ rec.set_defaults(func=_cmd_reconcile)
117
+
118
+ rep = sub.add_parser("replay", help="send recorded deliveries again")
119
+ rep.add_argument("--store", required=True, help="SQLite file your handler writes")
120
+ rep.add_argument("--url", required=True, help="where to send them")
121
+ rep.add_argument("--reference", action="append", help="repeatable; default is all")
122
+ rep.add_argument("--secret", default=None,
123
+ help="re-sign with this secret (default: $%s)" % SECRET_ENV)
124
+ rep.add_argument("--dry-run", action="store_true", help="show what would be sent")
125
+ rep.set_defaults(func=_cmd_replay)
126
+ return parser
127
+
128
+
129
+ def main(argv: Optional[Sequence[str]] = None) -> int:
130
+ parser = build_parser()
131
+ args = parser.parse_args(list(argv) if argv is not None else None)
132
+ if not getattr(args, "command", None):
133
+ parser.print_help()
134
+ return EXIT_USAGE
135
+ try:
136
+ return int(args.func(args))
137
+ except (ValueError, OSError) as exc:
138
+ print("paystack-webhook: %s" % exc, file=sys.stderr)
139
+ return EXIT_USAGE
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
@@ -0,0 +1,183 @@
1
+ """Compare what the provider says was paid against what actually reached you.
2
+
3
+ Every webhook integration fails the same way eventually: a delivery is lost, retried
4
+ past its window, or arrives twice, and the two systems quietly disagree about who paid
5
+ what. Verification cannot catch that, because there is nothing to verify when a body
6
+ never arrives.
7
+
8
+ The unit here is minor currency units, because that is what Paystack and Flutterwave
9
+ send (kobo, pesewas). Comparing floats to money is how you get a mismatch finding on
10
+ every single transaction.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import Any, Iterable, List, Mapping, Optional, Sequence, Union
18
+
19
+ PAID_NO_DELIVERY = "paid_no_delivery"
20
+ DELIVERY_NO_LEDGER = "delivery_no_ledger"
21
+ AMOUNT_MISMATCH = "amount_mismatch"
22
+ CURRENCY_MISMATCH = "currency_mismatch"
23
+ DUPLICATE_DELIVERY = "duplicate_delivery"
24
+ LEDGER_STATE_MISMATCH = "ledger_state_mismatch"
25
+ UNPARSEABLE_DELIVERY = "unparseable_delivery"
26
+
27
+ KINDS = (
28
+ PAID_NO_DELIVERY,
29
+ DELIVERY_NO_LEDGER,
30
+ AMOUNT_MISMATCH,
31
+ CURRENCY_MISMATCH,
32
+ DUPLICATE_DELIVERY,
33
+ LEDGER_STATE_MISMATCH,
34
+ UNPARSEABLE_DELIVERY,
35
+ )
36
+
37
+ _SUCCESS_STATUSES = {"success", "successful", "completed"}
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class LedgerEntry:
42
+ """One transaction as the provider's API reports it."""
43
+
44
+ reference: str
45
+ amount: Optional[int] = None
46
+ currency: Optional[str] = None
47
+ status: Optional[str] = None
48
+
49
+ @property
50
+ def paid(self) -> bool:
51
+ return (self.status or "").lower() in _SUCCESS_STATUSES
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Finding:
56
+ kind: str
57
+ reference: str
58
+ detail: str
59
+
60
+
61
+ def _as_amount(value: Any) -> Optional[int]:
62
+ if value is None or value == "":
63
+ return None
64
+ if isinstance(value, bool):
65
+ raise ValueError("an amount cannot be a boolean")
66
+ if isinstance(value, int):
67
+ return value
68
+ if isinstance(value, float):
69
+ if value != int(value):
70
+ raise ValueError("amounts are minor units; %r is not whole" % value)
71
+ return int(value)
72
+ text = str(value).strip()
73
+ if not text:
74
+ return None
75
+ return int(text)
76
+
77
+
78
+ def load_ledger(source: Union[str, Sequence[Mapping[str, Any]]]) -> List[LedgerEntry]:
79
+ """Read a ledger from a list of dicts or a JSON file.
80
+
81
+ Accepts a bare list, ``{"transactions": [...]}``, or the provider's own
82
+ ``{"data": [...]}`` envelope, because those are the three shapes you actually
83
+ end up holding.
84
+ """
85
+ if isinstance(source, str):
86
+ with open(source, "r", encoding="utf-8") as fh:
87
+ return load_ledger(json.load(fh))
88
+ if isinstance(source, Mapping):
89
+ for key in ("transactions", "data"):
90
+ if isinstance(source.get(key), list):
91
+ return load_ledger(source[key])
92
+ raise ValueError("ledger object has no transactions or data list")
93
+
94
+ out: List[LedgerEntry] = []
95
+ for raw in source:
96
+ if not isinstance(raw, Mapping):
97
+ raise ValueError("each ledger entry must be an object")
98
+ reference = raw.get("reference")
99
+ if not reference:
100
+ raise ValueError("a ledger entry is missing its reference")
101
+ currency = raw.get("currency")
102
+ out.append(
103
+ LedgerEntry(
104
+ reference=str(reference),
105
+ amount=_as_amount(raw.get("amount")),
106
+ currency=str(currency).upper() if currency else None,
107
+ status=str(raw["status"]) if raw.get("status") is not None else None,
108
+ )
109
+ )
110
+ return out
111
+
112
+
113
+ def _facts(body: bytes) -> tuple:
114
+ """(event name, reference, amount, currency, successful) from a raw body."""
115
+ payload = json.loads(body.decode("utf-8"))
116
+ if not isinstance(payload, Mapping):
117
+ raise ValueError("payload is not an object")
118
+ data = payload.get("data") or {}
119
+ if not isinstance(data, Mapping):
120
+ data = {}
121
+ event = str(payload.get("event") or "")
122
+ reference = data.get("reference") or data.get("tx_ref") or payload.get("reference")
123
+ status = str(data.get("status") or "").lower()
124
+ successful = status in _SUCCESS_STATUSES or event.lower().endswith(".success")
125
+ return (
126
+ event,
127
+ str(reference) if reference else None,
128
+ _as_amount(data.get("amount")),
129
+ (str(data.get("currency")).upper() if data.get("currency") else None),
130
+ successful,
131
+ )
132
+
133
+
134
+ def reconcile(deliveries: Iterable[Any], ledger: Iterable[LedgerEntry]) -> List[Finding]:
135
+ """Every way the two records can disagree, sorted so the output is stable."""
136
+ findings: List[Finding] = []
137
+ entries = {e.reference: e for e in ledger}
138
+ by_reference: dict = {}
139
+
140
+ for delivery in deliveries:
141
+ try:
142
+ event, reference, amount, currency, successful = _facts(delivery.body)
143
+ except (ValueError, UnicodeDecodeError) as exc:
144
+ findings.append(Finding(UNPARSEABLE_DELIVERY, delivery.key,
145
+ "body is not readable JSON: %s" % exc))
146
+ continue
147
+ if not reference:
148
+ findings.append(Finding(UNPARSEABLE_DELIVERY, delivery.key,
149
+ "body carries no reference"))
150
+ continue
151
+ by_reference.setdefault(reference, []).append(
152
+ {"event": event, "amount": amount, "currency": currency,
153
+ "successful": successful}
154
+ )
155
+
156
+ entry = entries.get(reference)
157
+ if entry is None:
158
+ findings.append(Finding(DELIVERY_NO_LEDGER, reference,
159
+ "delivery %s for an unknown reference" % event))
160
+ continue
161
+ if successful and not entry.paid:
162
+ findings.append(Finding(LEDGER_STATE_MISMATCH, reference,
163
+ "delivery says success, ledger says %s"
164
+ % (entry.status or "unknown")))
165
+ if amount is not None and entry.amount is not None and amount != entry.amount:
166
+ findings.append(Finding(AMOUNT_MISMATCH, reference,
167
+ "delivery %r, ledger %r (minor units)"
168
+ % (amount, entry.amount)))
169
+ if currency and entry.currency and currency != entry.currency:
170
+ findings.append(Finding(CURRENCY_MISMATCH, reference,
171
+ "delivery %s, ledger %s" % (currency, entry.currency)))
172
+
173
+ for reference, seen in by_reference.items():
174
+ if len(seen) > 1:
175
+ findings.append(Finding(DUPLICATE_DELIVERY, reference,
176
+ "%d deliveries for one reference" % len(seen)))
177
+
178
+ for reference, entry in entries.items():
179
+ if entry.paid and reference not in by_reference:
180
+ findings.append(Finding(PAID_NO_DELIVERY, reference,
181
+ "ledger says paid, no delivery was recorded"))
182
+
183
+ return sorted(findings, key=lambda f: (f.kind, f.reference, f.detail))
@@ -0,0 +1,74 @@
1
+ """Send a recorded delivery again, signed over the bytes that were actually received.
2
+
3
+ The failure this fixes: the provider retried three times, your handler 500ed, and the
4
+ body is gone. Re-posting a re-serialised dict produces a valid-looking payload whose
5
+ signature does not match, so the honest receiver rejects it and the debugging session
6
+ starts by blaming the wrong component.
7
+
8
+ So the body is reused byte for byte and the signature is recomputed over those bytes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from typing import Callable, Iterable, List, Mapping, Optional
19
+
20
+ SIGNATURE_HEADER = "X-Paystack-Signature"
21
+ CONTENT_TYPE = "application/json"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ReplayResult:
26
+ reference: Optional[str]
27
+ url: str
28
+ status: Optional[int]
29
+ error: Optional[str] = None
30
+
31
+ @property
32
+ def ok(self) -> bool:
33
+ return self.error is None and self.status is not None and 200 <= self.status < 300
34
+
35
+
36
+ def sign(body: bytes, secret: str) -> str:
37
+ return hmac.new(secret.encode("utf-8"), bytes(body), hashlib.sha512).hexdigest()
38
+
39
+
40
+ def _default_sender(url: str, body: bytes, headers: Mapping[str, str]) -> int:
41
+ request = urllib.request.Request(url, data=bytes(body), method="POST",
42
+ headers=dict(headers))
43
+ try:
44
+ with urllib.request.urlopen(request, timeout=30) as response:
45
+ return int(response.status)
46
+ except urllib.error.HTTPError as exc:
47
+ return int(exc.code)
48
+
49
+
50
+ def replay_deliveries(
51
+ deliveries: Iterable[object],
52
+ url: str,
53
+ *,
54
+ secret: Optional[str] = None,
55
+ sender: Optional[Callable[[str, bytes, Mapping[str, str]], int]] = None,
56
+ ) -> List[ReplayResult]:
57
+ """POST each delivery to ``url``. The caller decides what to do with the results."""
58
+ if not url:
59
+ raise ValueError("a replay needs a target url")
60
+ send = sender or _default_sender
61
+ out: List[ReplayResult] = []
62
+
63
+ for delivery in deliveries:
64
+ body = bytes(delivery.body)
65
+ headers = dict(getattr(delivery, "headers", {}) or {})
66
+ headers.setdefault("Content-Type", CONTENT_TYPE)
67
+ if secret:
68
+ headers[SIGNATURE_HEADER] = sign(body, secret)
69
+ reference = getattr(delivery, "reference", None)
70
+ try:
71
+ out.append(ReplayResult(reference, url, send(url, body, headers)))
72
+ except Exception as exc: # a single unreachable target must not abort the rest
73
+ out.append(ReplayResult(reference, url, None, "%s: %s" % (type(exc).__name__, exc)))
74
+ return out
@@ -0,0 +1,127 @@
1
+ """A store that keeps the delivery, not just the fact that it arrived.
2
+
3
+ ``MemoryStore`` answers one question: have I seen this key before? That is enough to
4
+ dedupe and nothing else. Reconciliation needs to know what was actually promised, and
5
+ replay needs the original raw body, because the signature is computed over exactly
6
+ those bytes. A re-serialised JSON body is not the same body and will fail verification
7
+ at the receiving end, which makes replay useless for the one job replay exists for.
8
+
9
+ SQLite because it is in the standard library, survives a restart, and needs no service
10
+ running beside it. A reconciliation job on a laptop wants a file, not a cluster.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sqlite3
17
+ import threading
18
+ from dataclasses import dataclass
19
+ from typing import List, Mapping, Optional
20
+
21
+ _SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS deliveries (
23
+ key TEXT PRIMARY KEY,
24
+ provider TEXT,
25
+ reference TEXT,
26
+ body BLOB NOT NULL,
27
+ headers TEXT NOT NULL,
28
+ received_at TEXT NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS deliveries_reference ON deliveries(reference);
31
+ """
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Delivery:
36
+ """One webhook that actually arrived, raw body included."""
37
+
38
+ key: str
39
+ provider: Optional[str]
40
+ reference: Optional[str]
41
+ body: bytes
42
+ headers: Mapping[str, str]
43
+ received_at: str
44
+
45
+
46
+ class SQLiteStore:
47
+ """Store-protocol compatible, plus the delivery log replay and reconcile need.
48
+
49
+ ``seen`` exists so this can stand in wherever a ``Store`` is expected, but it
50
+ records an empty body. New code should call ``accept`` and pass the body.
51
+ """
52
+
53
+ def __init__(self, path: str = ":memory:") -> None:
54
+ if not path:
55
+ raise ValueError("a store path is required")
56
+ self._lock = threading.Lock()
57
+ self._conn = sqlite3.connect(path, check_same_thread=False)
58
+ self._conn.row_factory = sqlite3.Row
59
+ # WAL keeps a reader (reconcile) from blocking the writer (a delivery).
60
+ if path != ":memory:":
61
+ self._conn.execute("PRAGMA journal_mode=WAL")
62
+ self._conn.executescript(_SCHEMA)
63
+ self._conn.commit()
64
+
65
+ # -- Store protocol ----------------------------------------------------
66
+ def seen(self, key: str) -> bool:
67
+ """True if the key was already present. Records it with an empty body."""
68
+ return not self.accept(key, body=b"")
69
+
70
+ # -- the real API ------------------------------------------------------
71
+ def accept(
72
+ self,
73
+ key: str,
74
+ *,
75
+ body: bytes = b"",
76
+ headers: Optional[Mapping[str, str]] = None,
77
+ provider: Optional[str] = None,
78
+ reference: Optional[str] = None,
79
+ received_at: str = "",
80
+ ) -> bool:
81
+ """Record the delivery. True if it is new, False if it is a duplicate.
82
+
83
+ One statement, so two concurrent deliveries of the same key cannot both be
84
+ told they are new. That race is a double charge.
85
+ """
86
+ if not key:
87
+ raise ValueError("an idempotency key is required")
88
+ with self._lock:
89
+ cur = self._conn.execute(
90
+ "INSERT OR IGNORE INTO deliveries"
91
+ " (key, provider, reference, body, headers, received_at)"
92
+ " VALUES (?, ?, ?, ?, ?, ?)",
93
+ (key, provider, reference, bytes(body),
94
+ json.dumps(dict(headers or {})), received_at),
95
+ )
96
+ inserted = cur.rowcount == 1
97
+ if inserted:
98
+ self._conn.commit()
99
+ return inserted
100
+
101
+ def deliveries(self, reference: Optional[str] = None) -> List[Delivery]:
102
+ """Every recorded delivery, oldest first, optionally for one reference."""
103
+ sql = "SELECT * FROM deliveries"
104
+ args: tuple = ()
105
+ if reference is not None:
106
+ sql += " WHERE reference = ?"
107
+ args = (reference,)
108
+ sql += " ORDER BY received_at, rowid"
109
+ rows = self._conn.execute(sql, args).fetchall()
110
+ return [
111
+ Delivery(
112
+ key=r["key"],
113
+ provider=r["provider"],
114
+ reference=r["reference"],
115
+ body=bytes(r["body"]),
116
+ headers=json.loads(r["headers"]),
117
+ received_at=r["received_at"],
118
+ )
119
+ for r in rows
120
+ ]
121
+
122
+ def __len__(self) -> int:
123
+ return self._conn.execute("SELECT COUNT(*) FROM deliveries").fetchone()[0]
124
+
125
+ def close(self) -> None:
126
+ with self._lock:
127
+ self._conn.close()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "paystack-webhook"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Verify Paystack and Flutterwave webhooks in Python: HMAC-SHA512, idempotency, replay-safe retries. Zero dependencies."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -26,6 +26,9 @@ dependencies = []
26
26
  [project.optional-dependencies]
27
27
  dev = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27", "flask>=3.0", "ruff>=0.4", "build>=1.0"]
28
28
 
29
+ [project.scripts]
30
+ paystack-webhook = "paystack_webhooks.cli:main"
31
+
29
32
  [project.urls]
30
33
  Homepage = "https://github.com/mgkvibez/paystack-webhooks"
31
34
  Issues = "https://github.com/mgkvibez/paystack-webhooks/issues"
@@ -0,0 +1,88 @@
1
+ import json
2
+
3
+ from paystack_webhooks.cli import EXIT_FINDINGS, EXIT_OK, EXIT_USAGE, main
4
+ from paystack_webhooks.storage import SQLiteStore
5
+
6
+
7
+ def _db(tmp_path, *references):
8
+ path = str(tmp_path / "d.db")
9
+ store = SQLiteStore(path)
10
+ for i, reference in enumerate(references):
11
+ store.accept("k%d" % i,
12
+ body=json.dumps({"event": "charge.success",
13
+ "data": {"reference": reference, "amount": 50000,
14
+ "currency": "NGN"}}).encode(),
15
+ reference=reference)
16
+ store.close()
17
+ return path
18
+
19
+
20
+ def _ledger(tmp_path, entries):
21
+ path = tmp_path / "ledger.json"
22
+ path.write_text(json.dumps(entries))
23
+ return str(path)
24
+
25
+
26
+ def test_version_prints_package_version(capsys):
27
+ import pytest
28
+ with pytest.raises(SystemExit) as exc:
29
+ main(["--version"])
30
+ assert exc.value.code == 0
31
+ assert capsys.readouterr().out.startswith("paystack-webhook ")
32
+
33
+
34
+ def test_no_command_is_a_usage_error():
35
+ assert main([]) == EXIT_USAGE
36
+
37
+
38
+ def test_missing_store_is_a_usage_error(tmp_path):
39
+ ledger = _ledger(tmp_path, [])
40
+ missing = str(tmp_path / "nope.db")
41
+ assert main(["reconcile", "--store", missing, "--ledger", ledger]) == EXIT_USAGE
42
+
43
+
44
+ def test_clean_reconciliation_exits_zero(tmp_path, capsys):
45
+ db = _db(tmp_path, "T1")
46
+ ledger = _ledger(tmp_path, [{"reference": "T1", "amount": 50000,
47
+ "currency": "NGN", "status": "success"}])
48
+ assert main(["reconcile", "--store", db, "--ledger", ledger]) == EXIT_OK
49
+ assert "no discrepancies" in capsys.readouterr().out
50
+
51
+
52
+ def test_findings_exit_one_and_are_printed(tmp_path, capsys):
53
+ db = _db(tmp_path, "T1")
54
+ ledger = _ledger(tmp_path, [{"reference": "T2", "amount": 1, "currency": "NGN",
55
+ "status": "success"}])
56
+ assert main(["reconcile", "--store", db, "--ledger", ledger]) == EXIT_FINDINGS
57
+ out = capsys.readouterr().out
58
+ assert "paid_no_delivery" in out and "delivery_no_ledger" in out
59
+
60
+
61
+ def test_json_output_is_machine_readable(tmp_path, capsys):
62
+ db = _db(tmp_path, "T1")
63
+ ledger = _ledger(tmp_path, [{"reference": "T2", "amount": 1, "currency": "NGN",
64
+ "status": "success"}])
65
+ assert main(["reconcile", "--store", db, "--ledger", ledger, "--json"]) == EXIT_FINDINGS
66
+ payload = json.loads(capsys.readouterr().out)
67
+ assert payload["evidence"]["deliveries"] == 1
68
+ assert {f["kind"] for f in payload["findings"]} == {"paid_no_delivery", "delivery_no_ledger"}
69
+
70
+
71
+ def test_replay_dry_run_sends_nothing(tmp_path, capsys):
72
+ db = _db(tmp_path, "T1")
73
+ args = ["replay", "--store", db, "--url", "http://127.0.0.1:9/never", "--dry-run"]
74
+ assert main(args) == EXIT_OK
75
+ assert "would replay T1" in capsys.readouterr().out
76
+
77
+
78
+ def test_replay_unknown_reference_is_a_usage_error(tmp_path):
79
+ db = _db(tmp_path, "T1")
80
+ assert main(["replay", "--store", db, "--url", "http://127.0.0.1:9/x",
81
+ "--reference", "ghost", "--dry-run"]) == EXIT_USAGE
82
+
83
+
84
+ def test_unreadable_ledger_is_a_usage_error(tmp_path):
85
+ db = _db(tmp_path, "T1")
86
+ bad = tmp_path / "bad.json"
87
+ bad.write_text("{not json")
88
+ assert main(["reconcile", "--store", db, "--ledger", str(bad)]) == EXIT_USAGE
@@ -0,0 +1,122 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from paystack_webhooks.reconcile import (
6
+ AMOUNT_MISMATCH,
7
+ CURRENCY_MISMATCH,
8
+ DELIVERY_NO_LEDGER,
9
+ DUPLICATE_DELIVERY,
10
+ LEDGER_STATE_MISMATCH,
11
+ PAID_NO_DELIVERY,
12
+ UNPARSEABLE_DELIVERY,
13
+ LedgerEntry,
14
+ load_ledger,
15
+ reconcile,
16
+ )
17
+
18
+
19
+ class FakeDelivery:
20
+ def __init__(self, key, body, reference=None):
21
+ self.key = key
22
+ self.body = body
23
+ self.reference = reference
24
+
25
+
26
+ def _d(key, reference="T1", amount=50000, currency="NGN", event="charge.success", ref=None):
27
+ return FakeDelivery(key, json.dumps(
28
+ {"event": event, "data": {"reference": reference, "amount": amount,
29
+ "currency": currency}}).encode())
30
+
31
+
32
+ def _ledger(*entries):
33
+ return [LedgerEntry(reference=r, amount=a, currency=c, status=s) for r, a, c, s in entries]
34
+
35
+
36
+ def test_agreement_is_silent():
37
+ findings = reconcile([_d("k1")], _ledger(("T1", 50000, "NGN", "success")))
38
+ assert findings == []
39
+
40
+
41
+ def test_paid_but_never_delivered():
42
+ findings = reconcile([], _ledger(("T1", 50000, "NGN", "success")))
43
+ assert [f.kind for f in findings] == [PAID_NO_DELIVERY]
44
+ assert findings[0].reference == "T1"
45
+
46
+
47
+ def test_pending_ledger_entry_is_not_a_finding():
48
+ assert reconcile([], _ledger(("T1", 50000, "NGN", "pending"))) == []
49
+
50
+
51
+ def test_delivery_for_unknown_reference():
52
+ findings = reconcile([_d("k1", reference="ghost")], _ledger(("T1", 50000, "NGN", "success")))
53
+ kinds = sorted(f.kind for f in findings)
54
+ assert DELIVERY_NO_LEDGER in kinds
55
+ assert PAID_NO_DELIVERY in kinds
56
+
57
+
58
+ def test_amount_and_currency_mismatch():
59
+ findings = reconcile([_d("k1", amount=49999, currency="GHS")],
60
+ _ledger(("T1", 50000, "NGN", "success")))
61
+ assert sorted(f.kind for f in findings) == [AMOUNT_MISMATCH, CURRENCY_MISMATCH]
62
+
63
+
64
+ def test_success_delivery_against_failed_ledger():
65
+ findings = reconcile([_d("k1")], _ledger(("T1", 50000, "NGN", "failed")))
66
+ assert [f.kind for f in findings] == [LEDGER_STATE_MISMATCH]
67
+
68
+
69
+ def test_duplicate_deliveries_are_one_finding():
70
+ findings = reconcile([_d("k1"), _d("k2")], _ledger(("T1", 50000, "NGN", "success")))
71
+ assert [f.kind for f in findings] == [DUPLICATE_DELIVERY]
72
+ assert "2 deliveries" in findings[0].detail
73
+
74
+
75
+ def test_unparseable_body_is_reported_not_raised():
76
+ broken = FakeDelivery("k1", b"not json")
77
+ findings = reconcile([broken], _ledger(("T1", 50000, "NGN", "success")))
78
+ kinds = sorted(f.kind for f in findings)
79
+ assert UNPARSEABLE_DELIVERY in kinds
80
+
81
+
82
+ def test_body_without_reference_is_reported():
83
+ body = json.dumps({"event": "charge.success", "data": {"amount": 5}}).encode()
84
+ findings = reconcile([FakeDelivery("k1", body)], [])
85
+ assert [f.kind for f in findings] == [UNPARSEABLE_DELIVERY]
86
+
87
+
88
+ def test_flutterwave_status_and_tx_ref():
89
+ body = json.dumps({"event": "charge.completed",
90
+ "data": {"tx_ref": "FLW1", "amount": 100, "currency": "NGN",
91
+ "status": "successful"}}).encode()
92
+ assert reconcile([FakeDelivery("k1", body)],
93
+ _ledger(("FLW1", 100, "NGN", "successful"))) == []
94
+
95
+
96
+ @pytest.mark.parametrize("payload", [
97
+ {"transactions": [{"reference": "T1", "amount": 1, "currency": "ngn", "status": "success"}]},
98
+ {"data": [{"reference": "T1", "amount": 1, "currency": "NGN", "status": "success"}]},
99
+ [{"reference": "T1", "amount": 1, "currency": "NGN", "status": "success"}],
100
+ ])
101
+ def test_ledger_shapes(payload):
102
+ entries = load_ledger(payload)
103
+ assert entries[0].reference == "T1"
104
+ assert entries[0].currency == "NGN"
105
+
106
+
107
+ def test_ledger_from_file(tmp_path):
108
+ path = tmp_path / "ledger.json"
109
+ path.write_text(json.dumps([{"reference": "T1", "amount": "50000",
110
+ "currency": "NGN", "status": "success"}]))
111
+ entries = load_ledger(str(path))
112
+ assert entries[0].amount == 50000
113
+
114
+
115
+ def test_ledger_rejects_missing_reference():
116
+ with pytest.raises(ValueError):
117
+ load_ledger([{"amount": 1}])
118
+
119
+
120
+ def test_ledger_rejects_fractional_minor_units():
121
+ with pytest.raises(ValueError):
122
+ load_ledger([{"reference": "T1", "amount": 1.5}])
@@ -0,0 +1,89 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from paystack_webhooks.replay import SIGNATURE_HEADER, replay_deliveries, sign
8
+ from paystack_webhooks.storage import SQLiteStore
9
+
10
+ SECRET = "sk_test_demo"
11
+
12
+
13
+ def _store(tmp_path, reference="T1", body=None):
14
+ store = SQLiteStore(str(tmp_path / "d.db"))
15
+ store.accept("%s-key" % reference,
16
+ body=body or json.dumps({"event": "charge.success",
17
+ "data": {"reference": reference,
18
+ "amount": 50000}}).encode(),
19
+ headers={"X-Paystack-Signature": "stale", "Content-Type": "application/json"},
20
+ reference=reference, provider="paystack")
21
+ return store
22
+
23
+
24
+ def test_resigned_over_the_original_bytes(tmp_path):
25
+ """The punchline of the whole module: signature must match what we send."""
26
+ store = _store(tmp_path)
27
+ captured = {}
28
+
29
+ def sender(url, body, headers):
30
+ captured["body"] = body
31
+ captured["headers"] = dict(headers)
32
+ return 200
33
+
34
+ results = replay_deliveries(store.deliveries(), "http://local/hook",
35
+ secret=SECRET, sender=sender)
36
+ assert results[0].ok
37
+ expected = hmac.new(SECRET.encode(), captured["body"], hashlib.sha512).hexdigest()
38
+ assert captured["headers"][SIGNATURE_HEADER] == expected
39
+ assert captured["body"] == store.deliveries()[0].body
40
+ store.close()
41
+
42
+
43
+ def test_original_signature_is_kept_without_a_secret(tmp_path):
44
+ store = _store(tmp_path)
45
+ captured = {}
46
+ replay_deliveries(store.deliveries(), "http://local/hook",
47
+ sender=lambda u, b, h: captured.update(h=h) or 200)
48
+ assert captured["h"][SIGNATURE_HEADER] == "stale"
49
+ store.close()
50
+
51
+
52
+ def test_sign_matches_the_packages_own_verifier():
53
+ """Replay signs with the same function the verifier checks against."""
54
+ from paystack_webhooks.signature import hmac_sha512_hex
55
+ body = b'{"a": 1}'
56
+ assert sign(body, SECRET) == hmac_sha512_hex(SECRET, body)
57
+
58
+
59
+ def test_one_bad_target_does_not_abort_the_run(tmp_path):
60
+ store = _store(tmp_path, "T1")
61
+ store.accept("t2-key", body=b'{"event":"charge.success","data":{"reference":"T2"}}',
62
+ reference="T2")
63
+ calls = {"n": 0}
64
+
65
+ def sender(url, body, headers):
66
+ calls["n"] += 1
67
+ if calls["n"] == 1:
68
+ raise ConnectionError("refused")
69
+ return 202
70
+
71
+ results = replay_deliveries(store.deliveries(), "http://local/hook", sender=sender)
72
+ assert [r.ok for r in results] == [False, True]
73
+ assert "ConnectionError" in results[0].error
74
+ store.close()
75
+
76
+
77
+ def test_non_2xx_is_not_ok(tmp_path):
78
+ store = _store(tmp_path)
79
+ results = replay_deliveries(store.deliveries(), "http://local/hook",
80
+ sender=lambda u, b, h: 500)
81
+ assert results[0].ok is False and results[0].status == 500
82
+ store.close()
83
+
84
+
85
+ def test_empty_url_is_refused(tmp_path):
86
+ store = _store(tmp_path)
87
+ with pytest.raises(ValueError):
88
+ replay_deliveries(store.deliveries(), "", sender=lambda u, b, h: 200)
89
+ store.close()
@@ -0,0 +1,75 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from paystack_webhooks.storage import Delivery, SQLiteStore
6
+
7
+
8
+ def _body(reference="T1", amount=50000):
9
+ return json.dumps({"event": "charge.success",
10
+ "data": {"reference": reference, "amount": amount,
11
+ "currency": "NGN"}}).encode()
12
+
13
+
14
+ def test_accept_reports_first_sight_only(tmp_path):
15
+ store = SQLiteStore(str(tmp_path / "d.db"))
16
+ assert store.accept("k1", body=_body()) is True
17
+ assert store.accept("k1", body=_body()) is False
18
+ assert len(store) == 1
19
+ store.close()
20
+
21
+
22
+ def test_raw_body_survives_byte_for_byte(tmp_path):
23
+ """Replay is only valid if the signature covers the same bytes we send back."""
24
+ exact = b'{"event": "charge.success", "data": {"reference": "T1"}}'
25
+ store = SQLiteStore(str(tmp_path / "d.db"))
26
+ store.accept("k1", body=exact, headers={"X-Paystack-Signature": "abc"})
27
+ got = store.deliveries()[0]
28
+ assert got.body == exact
29
+ assert got.headers == {"X-Paystack-Signature": "abc"}
30
+ store.close()
31
+
32
+
33
+ def test_it_persists_across_reopen(tmp_path):
34
+ path = str(tmp_path / "d.db")
35
+ first = SQLiteStore(path)
36
+ first.accept("k1", body=_body(), reference="T1", provider="paystack",
37
+ received_at="2026-10-31T20:00:00")
38
+ first.close()
39
+
40
+ second = SQLiteStore(path)
41
+ assert len(second) == 1
42
+ assert second.seen("k1") is True
43
+ assert second.deliveries("T1")[0].provider == "paystack"
44
+ second.close()
45
+
46
+
47
+ def test_deliveries_filter_and_order(tmp_path):
48
+ store = SQLiteStore(str(tmp_path / "d.db"))
49
+ store.accept("k1", body=_body("T1"), reference="T1", received_at="2026-10-31T20:00:00")
50
+ store.accept("k2", body=_body("T2"), reference="T2", received_at="2026-10-31T20:00:01")
51
+ store.accept("k3", body=_body("T1"), reference="T1", received_at="2026-10-31T20:00:02")
52
+ assert [d.key for d in store.deliveries()] == ["k1", "k2", "k3"]
53
+ assert [d.key for d in store.deliveries("T1")] == ["k1", "k3"]
54
+ assert store.deliveries("nope") == []
55
+ store.close()
56
+
57
+
58
+ def test_seen_is_the_store_protocol(tmp_path):
59
+ store = SQLiteStore(str(tmp_path / "d.db"))
60
+ assert store.seen("k1") is False
61
+ assert store.seen("k1") is True
62
+ store.close()
63
+
64
+
65
+ def test_empty_key_is_refused(tmp_path):
66
+ store = SQLiteStore(str(tmp_path / "d.db"))
67
+ with pytest.raises(ValueError):
68
+ store.accept("", body=_body())
69
+ store.close()
70
+
71
+
72
+ def test_delivery_is_immutable(tmp_path):
73
+ d = Delivery("k", None, "T1", b"{}", {}, "")
74
+ with pytest.raises(Exception):
75
+ d.key = "other"