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.
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/PKG-INFO +49 -1
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/README.md +48 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/PKG-INFO +49 -1
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/SOURCES.txt +10 -1
- paystack_webhook-0.2.0/paystack_webhook.egg-info/entry_points.txt +2 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/__init__.py +18 -4
- paystack_webhook-0.2.0/paystack_webhooks/cli.py +143 -0
- paystack_webhook-0.2.0/paystack_webhooks/reconcile.py +183 -0
- paystack_webhook-0.2.0/paystack_webhooks/replay.py +74 -0
- paystack_webhook-0.2.0/paystack_webhooks/storage.py +127 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/pyproject.toml +4 -1
- paystack_webhook-0.2.0/tests/test_cli.py +88 -0
- paystack_webhook-0.2.0/tests/test_reconcile.py +122 -0
- paystack_webhook-0.2.0/tests/test_replay.py +89 -0
- paystack_webhook-0.2.0/tests/test_storage.py +75 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/LICENSE +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/dependency_links.txt +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/requires.txt +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/top_level.txt +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/contrib/__init__.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/contrib/fastapi.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/contrib/flask.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/errors.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/event.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/flutterwave.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/idempotency.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/paystack.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhooks/signature.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/setup.cfg +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_adapters.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_event.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_flutterwave.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_idempotency.py +0 -0
- {paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/tests/test_paystack.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
30
|
+
tests/test_reconcile.py
|
|
31
|
+
tests/test_replay.py
|
|
32
|
+
tests/test_signature.py
|
|
33
|
+
tests/test_storage.py
|
|
@@ -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,
|
|
4
|
+
from paystack_webhooks import PaystackWebhook, SQLiteStore
|
|
4
5
|
|
|
5
|
-
|
|
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.
|
|
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.
|
|
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"
|
|
File without changes
|
{paystack_webhook-0.1.1 → paystack_webhook-0.2.0}/paystack_webhook.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|