lipwa 0.1.2 → 0.1.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +219 -204
- data/Rakefile +11 -0
- data/docs/README.md +15 -0
- data/docs/_config.yml +11 -0
- data/docs/_layouts/default.html +37 -0
- data/docs/assets/css/style.css +8 -0
- data/docs/capabilities.md +116 -0
- data/docs/gateways.md +158 -0
- data/docs/getting-started.md +64 -0
- data/docs/index.md +46 -0
- data/docs/reliability.md +50 -0
- data/docs/webhooks.md +72 -0
- data/lib/lipwa/auth_strategies/api_key.rb +28 -0
- data/lib/lipwa/auth_strategies/base.rb +4 -0
- data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
- data/lib/lipwa/auth_strategies/jenga.rb +58 -0
- data/lib/lipwa/auth_strategies.rb +2 -0
- data/lib/lipwa/capabilities/authorize.rb +26 -0
- data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
- data/lib/lipwa/capabilities/c2b.rb +11 -10
- data/lib/lipwa/capabilities/capture.rb +25 -0
- data/lib/lipwa/capabilities/disbursement.rb +16 -17
- data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
- data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
- data/lib/lipwa/capabilities/purchase.rb +26 -0
- data/lib/lipwa/capabilities/refund.rb +14 -14
- data/lib/lipwa/capabilities/status_query.rb +21 -12
- data/lib/lipwa/capabilities/stk_push.rb +8 -5
- data/lib/lipwa/capabilities/void.rb +25 -0
- data/lib/lipwa/configuration_snapshot.rb +50 -0
- data/lib/lipwa/context.rb +60 -0
- data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
- data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
- data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
- data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
- data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
- data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
- data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
- data/lib/lipwa/contracts/status_query_contract.rb +29 -6
- data/lib/lipwa/errors.rb +4 -2
- data/lib/lipwa/gateway.rb +24 -5
- data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
- data/lib/lipwa/gateways/coop_bank.rb +115 -0
- data/lib/lipwa/gateways/flutterwave.rb +160 -0
- data/lib/lipwa/gateways/jenga/auth.rb +76 -0
- data/lib/lipwa/gateways/jenga.rb +282 -0
- data/lib/lipwa/gateways/lnbits.rb +67 -0
- data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
- data/lib/lipwa/gateways/mpesa.rb +16 -11
- data/lib/lipwa/gateways/paystack.rb +119 -0
- data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
- data/lib/lipwa/gateways/pesapal.rb +123 -0
- data/lib/lipwa/gateways.rb +7 -1
- data/lib/lipwa/http_adapter.rb +56 -17
- data/lib/lipwa/logging.rb +100 -0
- data/lib/lipwa/money.rb +33 -4
- data/lib/lipwa/types.rb +3 -3
- data/lib/lipwa/version.rb +1 -1
- data/lib/lipwa/webhooks/coop_bank.rb +28 -0
- data/lib/lipwa/webhooks/jenga.rb +55 -0
- data/lib/lipwa/webhooks/lnbits.rb +46 -0
- data/lib/lipwa.rb +29 -0
- metadata +42 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ed1d0107830b6fafe1757cc253a820b57fe62c99f38ec78e1d2adde62e10eaa
|
|
4
|
+
data.tar.gz: 85994d9fe4d49e276892154fc600ae06f420740a55c5580f9bbac6cfbcc3d14b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37ebc5f8bff81213d7949b33c984ac4f18bf32b6386b25323665fcf86f8f6a1d838759cbcb4c49fba3f0f4274d1e3a6bdabcd8b78600bf24ad23554e26adc253
|
|
7
|
+
data.tar.gz: 83d40bb47c71c2fb3f98ba51021b3bcbee44d91610d8407fa44975add4261f894c999f5cb109a370af1bae301fee906ef0f0e89de75993799e3846f7085eec89
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## [0.1.3] - 2026-09-06
|
|
2
|
+
|
|
3
|
+
- Add Co-op Bank support for bank transfers, balance and statement inquiries,
|
|
4
|
+
transaction status queries, OAuth authentication, and normalized callbacks
|
|
5
|
+
- Add Jenga HQ support for bank transfers, mobile-money disbursements, account
|
|
6
|
+
inquiries, forex rates, OAuth/RSA request signing, and verified payment IPNs
|
|
7
|
+
- Add card-payment capabilities for purchase, authorization, capture, and void,
|
|
8
|
+
with Flutterwave, Paystack, and Pesapal gateway implementations
|
|
9
|
+
- Add Lightning Network support through LNbits, including separate invoice and
|
|
10
|
+
admin API keys, invoice creation/status checks, outbound payments and
|
|
11
|
+
reconciliation, and verified webhook parsing
|
|
12
|
+
- Add immutable `Lipwa::Context` configuration snapshots with isolated,
|
|
13
|
+
memoized gateway instances for multi-tenant and concurrent applications
|
|
14
|
+
- Add a capability-focused documentation site and rewrite the project README
|
|
15
|
+
- Add constrained decimal amounts and currency-safe arithmetic to `Lipwa::Money`
|
|
16
|
+
- Formalize transient HTTP retries with bounded exponential backoff and idempotency-safe writes
|
|
17
|
+
- Add structured HTTP logging with recursive credential and secret redaction
|
|
18
|
+
- Add `idempotency_key:` support across gateway calls via the `Idempotency-Key` header
|
|
19
|
+
|
|
20
|
+
## [0.1.2] - 2026-08-29
|
|
21
|
+
|
|
22
|
+
- Add a Rails API example covering M-Pesa collection, disbursement, refunds,
|
|
23
|
+
and webhook handling
|
|
24
|
+
- Refresh gem metadata and exclude the internal project plan from packaged gems
|
|
25
|
+
|
|
1
26
|
## [0.1.1]
|
|
2
27
|
|
|
3
28
|
- Add `Lipwa::HttpAdapter`, a Faraday-based HTTP wrapper
|
data/README.md
CHANGED
|
@@ -1,262 +1,290 @@
|
|
|
1
1
|
# Lipwa
|
|
2
2
|
|
|
3
|
-
Lipwa is a
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
ships a full integration with Safaricom's **M-Pesa Daraja API**: STK Push,
|
|
7
|
-
C2B, B2C/B2B disbursements, and inbound webhook handling.
|
|
8
|
-
|
|
9
|
-
Every gateway call returns a `Dry::Monads::Result`
|
|
10
|
-
(`Success(Lipwa::Response)` / `Failure(Lipwa::Error)`) instead of raising —
|
|
11
|
-
see [Error handling](#error-handling) below. Only genuine programmer
|
|
12
|
-
mistakes (bad config, calling a capability the gateway doesn't support) are
|
|
13
|
-
raised as exceptions.
|
|
3
|
+
Lipwa is a capability-based Ruby toolkit for African payment APIs. Mobile
|
|
4
|
+
money, bank transfers, payouts, and callbacks do not share one card-shaped
|
|
5
|
+
interface; each gateway exposes only the flows it supports.
|
|
14
6
|
|
|
15
|
-
|
|
7
|
+
```ruby
|
|
8
|
+
gateway = Lipwa.gateway(:mpesa)
|
|
9
|
+
gateway.capability?(:stk_push) # => true
|
|
10
|
+
gateway.capability?(:bank_transfer) # => false
|
|
11
|
+
```
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
## What it provides
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
- Explicit, composable gateway capabilities
|
|
16
|
+
- Validated inputs and immutable decimal money values
|
|
17
|
+
- `Dry::Monads::Result` for expected validation and network failures
|
|
18
|
+
- Normalized responses and webhook events
|
|
19
|
+
- OAuth/signing strategies, timeouts, safe retries, and redacted logs
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
executing:
|
|
21
|
+
## Installation
|
|
25
22
|
|
|
26
23
|
```bash
|
|
27
|
-
|
|
24
|
+
bundle add lipwa
|
|
28
25
|
```
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
Lipwa supports Ruby 3.2 and newer.
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
optional):
|
|
29
|
+
## Quick start
|
|
34
30
|
|
|
35
31
|
```ruby
|
|
32
|
+
require "lipwa"
|
|
33
|
+
|
|
36
34
|
Lipwa.configure do |config|
|
|
37
35
|
config.logger = Rails.logger
|
|
38
36
|
config.default_timeout = 10
|
|
39
37
|
end
|
|
38
|
+
|
|
39
|
+
Lipwa::Gateways::Mpesa.configure do |config|
|
|
40
|
+
config.env = :sandbox
|
|
41
|
+
config.consumer_key = ENV.fetch("MPESA_CONSUMER_KEY")
|
|
42
|
+
config.consumer_secret = ENV.fetch("MPESA_CONSUMER_SECRET")
|
|
43
|
+
config.shortcode = ENV.fetch("MPESA_SHORTCODE")
|
|
44
|
+
config.passkey = ENV.fetch("MPESA_PASSKEY")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result = Lipwa.gateway(:mpesa).stk_push(
|
|
48
|
+
amount: Lipwa::Money.new(amount: "100.00", currency: "KES"),
|
|
49
|
+
phone_number: "254712345678",
|
|
50
|
+
account_reference: "ORDER-123",
|
|
51
|
+
callback_url: "https://payments.example.com/webhooks/mpesa",
|
|
52
|
+
idempotency_key: "stk-order-123"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
result.either(
|
|
56
|
+
->(response) { puts response.provider_reference },
|
|
57
|
+
->(error) { warn error.message }
|
|
58
|
+
)
|
|
40
59
|
```
|
|
41
60
|
|
|
42
|
-
|
|
61
|
+
A successful STK response means M-Pesa accepted the request. The final payment
|
|
62
|
+
outcome arrives asynchronously at `callback_url`.
|
|
63
|
+
|
|
64
|
+
## Isolated configuration contexts
|
|
65
|
+
|
|
66
|
+
Use an explicit context for each merchant or tenant. It snapshots the current
|
|
67
|
+
`Lipwa.config` and all registered gateway configurations before applying overrides.
|
|
43
68
|
|
|
44
69
|
```ruby
|
|
45
|
-
Lipwa
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
c.initiator_password = ENV["MPESA_INITIATOR_PASSWORD"]
|
|
55
|
-
c.security_credential_cert = File.read(ENV["MPESA_CERT_PATH"])
|
|
70
|
+
tenant_context = Lipwa.context do |config|
|
|
71
|
+
config.default_timeout = 20
|
|
72
|
+
|
|
73
|
+
config.gateway(:mpesa) do |mpesa|
|
|
74
|
+
mpesa.consumer_key = tenant.mpesa_consumer_key
|
|
75
|
+
mpesa.consumer_secret = tenant.mpesa_consumer_secret
|
|
76
|
+
mpesa.shortcode = tenant.mpesa_shortcode
|
|
77
|
+
mpesa.passkey = tenant.mpesa_passkey
|
|
78
|
+
end
|
|
56
79
|
end
|
|
80
|
+
|
|
81
|
+
gateway = tenant_context.gateway(:mpesa)
|
|
82
|
+
gateway.equal?(tenant_context.gateway(:mpesa)) # => true (memoized per context)
|
|
83
|
+
gateway.equal?(Lipwa.gateway(:mpesa)) # => false (process-wide gateway)
|
|
57
84
|
```
|
|
58
85
|
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
raises `Lipwa::ConfigurationError` at call time if something it needs is
|
|
66
|
-
missing, not at load time.
|
|
86
|
+
`Lipwa::Context.new` accepts the same block. Settings are frozen when the block
|
|
87
|
+
finishes, and typed overrides are validated during construction. Later global
|
|
88
|
+
reconfiguration does not affect an existing context. Each context owns its gateway
|
|
89
|
+
instances; pass the context explicitly to your application code. There is no
|
|
90
|
+
thread-local tenant state. Logger and other service objects retain their identity;
|
|
91
|
+
configuration strings, arrays, and hashes are copied and frozen.
|
|
67
92
|
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
`Lipwa.configure`, gateway-class `.configure`, and `Lipwa.gateway(:mpesa)`
|
|
94
|
+
remain supported and backward compatible for process-wide configuration.
|
|
95
|
+
Configure defaults at boot; do not repeatedly mutate gateway-class configuration
|
|
96
|
+
per request to switch tenants. Load tenant secrets when constructing the context,
|
|
97
|
+
then retain one context per tenant. Lipwa's HTTP log redaction also applies to
|
|
98
|
+
context gateways; avoid logging raw credentials or configuration.
|
|
99
|
+
See [contexts and the tenant service example](docs/gateways.md#contexts) for
|
|
100
|
+
inheritance, snapshot timing, and credential rotation.
|
|
70
101
|
|
|
71
|
-
|
|
72
|
-
Lipwa.gateway(:mpesa).stk_push(...)
|
|
73
|
-
```
|
|
102
|
+
## Lightning Network (LNbits)
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
LNbits is the only Lightning backend supported today. The authentication
|
|
105
|
+
strategy and capability-based gateway shape allow other REST-based Lightning
|
|
106
|
+
backends to register their own gateway class later without changing this API.
|
|
76
107
|
|
|
77
|
-
###
|
|
108
|
+
### Configuration
|
|
78
109
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
110
|
+
Configure the URL of your hosted or self-hosted LNbits instance and its scoped
|
|
111
|
+
invoice/read key. Receiving payments only requires this less-privileged key.
|
|
112
|
+
Configure the separate admin key only when the application sends payments.
|
|
82
113
|
|
|
83
114
|
```ruby
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
result.either(
|
|
92
|
-
->(response) { response.provider_reference }, # CheckoutRequestID
|
|
93
|
-
->(error) { logger.error(error.message) }
|
|
94
|
-
)
|
|
115
|
+
Lipwa::Gateways::Lnbits.configure do |config|
|
|
116
|
+
config.base_url = ENV.fetch("LNBITS_BASE_URL")
|
|
117
|
+
config.invoice_key = ENV.fetch("LNBITS_INVOICE_KEY")
|
|
118
|
+
# Optional and deliberately separate: only configure this when sending.
|
|
119
|
+
config.admin_key = ENV["LNBITS_ADMIN_KEY"]
|
|
120
|
+
end
|
|
95
121
|
```
|
|
96
122
|
|
|
97
|
-
###
|
|
123
|
+
### Usage
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
only — simulates such a payment so you can exercise those URLs without a
|
|
102
|
-
real transaction.
|
|
125
|
+
Lightning invoice amounts are positive integer satoshi amounts, rather than
|
|
126
|
+
`Lipwa::Money` values:
|
|
103
127
|
|
|
104
128
|
```ruby
|
|
105
|
-
Lipwa.gateway(:
|
|
106
|
-
validation_url: "https://example.com/webhooks/mpesa/validate",
|
|
107
|
-
confirmation_url: "https://example.com/webhooks/mpesa/confirm"
|
|
108
|
-
)
|
|
129
|
+
lnbits = Lipwa.gateway(:lnbits)
|
|
109
130
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
bill_ref_number: "ORDER-123"
|
|
131
|
+
invoice_result = lnbits.create_invoice(
|
|
132
|
+
amount_sats: 2_100,
|
|
133
|
+
memo: "Order ORDER-123",
|
|
134
|
+
expiry: 900
|
|
115
135
|
)
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Disbursement (B2C / B2B)
|
|
119
136
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
separate methods:
|
|
137
|
+
invoice = invoice_result.value!
|
|
138
|
+
invoice.provider_reference # payment_hash, used by #check_invoice
|
|
139
|
+
invoice.raw["payment_request"] # BOLT11 string; render this as a QR code
|
|
124
140
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
result = Lipwa.gateway(:mpesa).disburse(
|
|
128
|
-
command_id: "SalaryPayment", # or "BusinessPayment" / "PromotionPayment"
|
|
129
|
-
amount: Lipwa::Money.new(amount: 5_000_00, currency: "KES"),
|
|
130
|
-
party_b: "254712345678", # payee MSISDN
|
|
131
|
-
remarks: "August salary",
|
|
132
|
-
result_url: "https://example.com/webhooks/mpesa/b2c/result",
|
|
133
|
-
queue_timeout_url: "https://example.com/webhooks/mpesa/b2c/timeout",
|
|
134
|
-
occasion: "August payroll"
|
|
141
|
+
status_result = lnbits.check_invoice(
|
|
142
|
+
payment_hash: invoice.provider_reference
|
|
135
143
|
)
|
|
136
144
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
amount: Lipwa::Money.new(amount: 10_000_00, currency: "KES"),
|
|
141
|
-
party_b: "600000", # payee business shortcode
|
|
142
|
-
remarks: "Supplier settlement",
|
|
143
|
-
result_url: "https://example.com/webhooks/mpesa/b2b/result",
|
|
144
|
-
queue_timeout_url: "https://example.com/webhooks/mpesa/b2b/timeout",
|
|
145
|
-
account_reference: "INV-2026-08-001" # required for B2B command IDs
|
|
146
|
-
)
|
|
145
|
+
status = status_result.value!
|
|
146
|
+
status.success? # true only when LNbits reports paid == true
|
|
147
|
+
status.raw["paid"] # the provider's raw payment status
|
|
147
148
|
```
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
An unpaid invoice check is still a successful `Dry::Monads::Result`; inspect
|
|
151
|
+
`Lipwa::Response#success?` to distinguish paid from unpaid. `Failure` is
|
|
152
|
+
reserved for validation and transport errors.
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
request to carry a `SecurityCredential` — your initiator password,
|
|
155
|
-
RSA-encrypted with Safaricom's public certificate. Lipwa does this
|
|
156
|
-
encryption for you (see `Lipwa::Gateways::Mpesa::SecurityCredential`); you
|
|
157
|
-
just need to supply the certificate itself as PEM/DER content via
|
|
158
|
-
`security_credential_cert`. Download it from the Daraja developer
|
|
159
|
-
portal — the **Test Credentials** page for sandbox, or your app's
|
|
160
|
-
production certificate for production — since sandbox and production use
|
|
161
|
-
different certificates and mixing them up causes every B2C/B2B request to
|
|
162
|
-
fail. Don't hardcode certificate content in source; load it from a file or
|
|
163
|
-
secret store, e.g. `c.security_credential_cert = File.read("certs/mpesa_production.cer")`.
|
|
154
|
+
### Outbound payments
|
|
164
155
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`#refund` reverses a completed M-Pesa transaction by its `TransactionID`
|
|
168
|
-
(Daraja's Transaction Reversal API):
|
|
156
|
+
Paying a BOLT11 invoice requires the LNbits wallet admin key. This credential
|
|
157
|
+
can spend the wallet balance and is never used as a fallback for `invoice_key`:
|
|
169
158
|
|
|
170
159
|
```ruby
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
160
|
+
payment_result = lnbits.pay_invoice(bolt11: "lnbc...")
|
|
161
|
+
payment = payment_result.value!
|
|
162
|
+
|
|
163
|
+
if payment.success?
|
|
164
|
+
puts "settled: #{payment.provider_reference}"
|
|
165
|
+
elsif payment.raw["status"] == "pending"
|
|
166
|
+
# Reconcile later; do not submit the invoice again.
|
|
167
|
+
payment = lnbits.check_payment(
|
|
168
|
+
payment_hash: payment.provider_reference
|
|
169
|
+
).value!
|
|
170
|
+
end
|
|
179
171
|
```
|
|
180
172
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
get it.
|
|
173
|
+
`pay_invoice` is a synchronous submission, but settlement can remain pending
|
|
174
|
+
or become indeterminate if the request times out. A timeout is not proof of
|
|
175
|
+
failure and must not trigger an automatic retry. Reconcile the original
|
|
176
|
+
payment with `check_payment` before taking further action.
|
|
186
177
|
|
|
187
|
-
###
|
|
178
|
+
### Webhooks
|
|
188
179
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
180
|
+
LNbits does not sign payment webhooks. Mint a strong per-invoice token, embed
|
|
181
|
+
it in the HTTPS webhook URL, and store it with the returned payment hash. A
|
|
182
|
+
valid token makes the callback a useful low-latency notification, but it is
|
|
183
|
+
not proof of payment: always call `check_invoice` before crediting the payer.
|
|
192
184
|
|
|
193
185
|
```ruby
|
|
194
|
-
|
|
195
|
-
|
|
186
|
+
require "securerandom"
|
|
187
|
+
require "uri"
|
|
188
|
+
|
|
189
|
+
# When creating the invoice:
|
|
190
|
+
webhook_token = SecureRandom.hex(32)
|
|
191
|
+
webhook_url = "https://payments.example.com/webhooks/lnbits?#{
|
|
192
|
+
URI.encode_www_form(token: webhook_token)
|
|
193
|
+
}"
|
|
194
|
+
|
|
195
|
+
invoice_result = lnbits.create_invoice(
|
|
196
|
+
amount_sats: 2_100,
|
|
197
|
+
memo: "Order ORDER-123",
|
|
198
|
+
webhook_url: webhook_url
|
|
199
|
+
)
|
|
200
|
+
invoice = invoice_result.value!
|
|
196
201
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
end,
|
|
209
|
-
->(error) { logger.error(error.message) }
|
|
202
|
+
# Persist both values against the order. Never put an LNbits API key in the URL.
|
|
203
|
+
order.update!(
|
|
204
|
+
lightning_payment_hash: invoice.provider_reference,
|
|
205
|
+
lightning_webhook_token: webhook_token
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# In the webhook controller/handler:
|
|
209
|
+
event_result = Lipwa::Webhook.parse_webhook(
|
|
210
|
+
provider: :lnbits,
|
|
211
|
+
body: request.body.read,
|
|
212
|
+
headers: request.headers
|
|
210
213
|
)
|
|
214
|
+
event = event_result.value!
|
|
215
|
+
order = Order.find_by!(lightning_payment_hash: event.provider_reference)
|
|
216
|
+
|
|
217
|
+
verified = event.verify_signature(
|
|
218
|
+
expected_token: order.lightning_webhook_token,
|
|
219
|
+
provided_token: request.params["token"]
|
|
220
|
+
)
|
|
221
|
+
head :unauthorized and return unless verified
|
|
222
|
+
|
|
223
|
+
# Token verification alone is insufficient: ask LNbits for authoritative state.
|
|
224
|
+
status_result = lnbits.check_invoice(payment_hash: event.provider_reference)
|
|
225
|
+
status = status_result.value!
|
|
226
|
+
head :unprocessable_entity and return unless status.success?
|
|
227
|
+
|
|
228
|
+
order.credit_once!
|
|
229
|
+
head :ok
|
|
211
230
|
```
|
|
212
231
|
|
|
213
|
-
|
|
214
|
-
checks the request's source IP against Safaricom's published callback IP
|
|
215
|
-
ranges instead — always call it before trusting a callback's contents.
|
|
232
|
+
## Capability matrix
|
|
216
233
|
|
|
217
|
-
|
|
234
|
+
| Gateway | Capabilities |
|
|
235
|
+
| --- | --- |
|
|
236
|
+
| `:mpesa` | `stk_push`, `c2b`, `disbursement`, `status_query`, `refund` |
|
|
237
|
+
| `:coop_bank` | `bank_transfer`: transfer, balance, statement |
|
|
238
|
+
| `:jenga` | `bank_transfer`, `disbursement`, plus `forex_rates` |
|
|
239
|
+
| `:lnbits` | `lightning_invoice`: receive; `lightning_payment`: pay and reconcile |
|
|
218
240
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
241
|
+
## Money
|
|
242
|
+
|
|
243
|
+
`Lipwa::Money` stores non-negative amounts as constrained `BigDecimal` values.
|
|
244
|
+
Addition, subtraction, and comparison require matching currencies;
|
|
245
|
+
multiplication and division accept numeric scalars.
|
|
222
246
|
|
|
223
247
|
```ruby
|
|
224
|
-
Lipwa::Money.new(amount: 100, currency: "KES")
|
|
248
|
+
price = Lipwa::Money.new(amount: "100.25", currency: "KES")
|
|
249
|
+
tax = Lipwa::Money.new(amount: "16.04", currency: "KES")
|
|
250
|
+
|
|
251
|
+
(price + tax).to_s # => "116.29 KES"
|
|
252
|
+
(price * 2).to_s # => "200.5 KES"
|
|
225
253
|
```
|
|
226
254
|
|
|
227
|
-
|
|
255
|
+
Currency conversion is never implicit.
|
|
256
|
+
|
|
257
|
+
## Results and errors
|
|
258
|
+
|
|
259
|
+
Operations return `Success(Lipwa::Response)`, `Failure(Lipwa::ValidationError)`,
|
|
260
|
+
or `Failure(Lipwa::GatewayError)`. Configuration and unsupported-provider
|
|
261
|
+
errors are raised because they are programmer or deployment mistakes.
|
|
228
262
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
263
|
+
For asynchronous operations, persist `response.provider_reference` and
|
|
264
|
+
correlate it with a verified webhook. Never treat request acknowledgement as
|
|
265
|
+
the final transaction outcome.
|
|
232
266
|
|
|
233
|
-
|
|
234
|
-
`#message`, `#code`, `#raw` (the parsed provider response). Note a
|
|
235
|
-
`Success` can still wrap `response.success? == false` — Daraja
|
|
236
|
-
synchronous validation errors (bad shortcode, malformed request) come
|
|
237
|
-
back as a normal 200 response with a non-zero `ResponseCode`.
|
|
238
|
-
- `Failure(Lipwa::ValidationError)` — your params failed contract
|
|
239
|
-
validation before any network call was made.
|
|
240
|
-
- `Failure(Lipwa::GatewayError)` — the HTTP call itself failed (timeout,
|
|
241
|
-
connection error) or the provider returned an HTTP error status.
|
|
267
|
+
## Documentation
|
|
242
268
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
269
|
+
- [Documentation home](docs/index.md)
|
|
270
|
+
- [Getting started](docs/getting-started.md)
|
|
271
|
+
- [Capabilities](docs/capabilities.md)
|
|
272
|
+
- [Gateway configuration](docs/gateways.md)
|
|
273
|
+
- [Webhooks](docs/webhooks.md)
|
|
274
|
+
- [Reliability and errors](docs/reliability.md)
|
|
248
275
|
|
|
249
276
|
## Development
|
|
250
277
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
278
|
+
```bash
|
|
279
|
+
bin/setup
|
|
280
|
+
bundle exec rake
|
|
281
|
+
bin/console
|
|
282
|
+
```
|
|
254
283
|
|
|
255
|
-
Tests
|
|
256
|
-
|
|
257
|
-
they never hit Safaricom's real sandbox, so no network access or real
|
|
258
|
-
credentials are needed to run the suite.
|
|
284
|
+
Tests use WebMock and sanitized VCR fixtures; they do not contact live provider
|
|
285
|
+
sandboxes.
|
|
259
286
|
|
|
287
|
+
## Contributing and license
|
|
260
288
|
`examples/rails_api` is a small Rails API app that exercises every
|
|
261
289
|
capability against a real (sandbox) Daraja account — STK Push, C2B,
|
|
262
290
|
disbursement, refund, and inbound webhooks — useful both for evaluating
|
|
@@ -269,20 +297,7 @@ install`. To release a new version, update the version number in
|
|
|
269
297
|
git tag for the version, push git commits and the created tag, and push
|
|
270
298
|
the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
271
299
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
safe, welcoming space for collaboration, and contributors are expected to
|
|
277
|
-
adhere to the [code of conduct](https://github.com/kamalogudah/lipwa/blob/master/CODE_OF_CONDUCT.md).
|
|
278
|
-
|
|
279
|
-
## License
|
|
280
|
-
|
|
281
|
-
The gem is available as open source under the terms of the
|
|
282
|
-
[MIT License](https://opensource.org/licenses/MIT).
|
|
283
|
-
|
|
284
|
-
## Code of Conduct
|
|
285
|
-
|
|
286
|
-
Everyone interacting in the Lipwa project's codebases, issue trackers,
|
|
287
|
-
chat rooms and mailing lists is expected to follow the
|
|
288
|
-
[code of conduct](https://github.com/kamalogudah/lipwa/blob/master/CODE_OF_CONDUCT.md).
|
|
300
|
+
Bug reports and pull requests are welcome on
|
|
301
|
+
[GitHub](https://github.com/kamalogudah/lipwa). Please follow the
|
|
302
|
+
[code of conduct](CODE_OF_CONDUCT.md). Lipwa is available under the
|
|
303
|
+
[MIT License](LICENSE.txt).
|
data/Rakefile
CHANGED
|
@@ -9,4 +9,15 @@ require "rubocop/rake_task"
|
|
|
9
9
|
|
|
10
10
|
RuboCop::RakeTask.new
|
|
11
11
|
|
|
12
|
+
namespace :docs do
|
|
13
|
+
desc "Build the documentation site"
|
|
14
|
+
task :build do
|
|
15
|
+
require "jekyll"
|
|
16
|
+
Jekyll::Commands::Build.process(
|
|
17
|
+
source: File.expand_path("docs", __dir__),
|
|
18
|
+
destination: File.expand_path("_site", __dir__)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
12
23
|
task default: %i[test rubocop]
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Lipwa documentation site
|
|
2
|
+
|
|
3
|
+
This directory is the source for the Lipwa documentation site. It uses Jekyll
|
|
4
|
+
with a custom layout and has no theme or plugin dependency.
|
|
5
|
+
|
|
6
|
+
From the repository root:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bundle install
|
|
10
|
+
bundle exec rake docs:build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The generated site is written to `_site/`. GitHub Pages can publish directly
|
|
14
|
+
from the `docs/` directory on the main branch; `baseurl` in `_config.yml` is
|
|
15
|
+
already set for the `/lipwa` project path.
|
data/docs/_config.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="theme-color" content="#102a25">
|
|
7
|
+
<meta name="description" content="{{ page.description | default: site.description }}">
|
|
8
|
+
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
|
9
|
+
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<a class="skip-link" href="#content">Skip to content</a>
|
|
13
|
+
<header class="site-header">
|
|
14
|
+
<a class="brand" href="{{ '/' | relative_url }}"><span class="brand-mark">L</span> Lipwa</a>
|
|
15
|
+
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav">Menu</button>
|
|
16
|
+
<nav id="site-nav" class="site-nav" aria-label="Primary">
|
|
17
|
+
<a href="{{ '/getting-started/' | relative_url }}">Start</a>
|
|
18
|
+
<a href="{{ '/capabilities/' | relative_url }}">Capabilities</a>
|
|
19
|
+
<a href="{{ '/gateways/' | relative_url }}">Gateways</a>
|
|
20
|
+
<a href="{{ '/webhooks/' | relative_url }}">Webhooks</a>
|
|
21
|
+
<a href="{{ '/reliability/' | relative_url }}">Reliability</a>
|
|
22
|
+
<a href="https://github.com/kamalogudah/lipwa">GitHub ↗</a>
|
|
23
|
+
</nav>
|
|
24
|
+
</header>
|
|
25
|
+
<main id="content" class="page-shell">{{ content }}</main>
|
|
26
|
+
<footer class="site-footer"><span>Lipwa · payment flows, honestly modeled</span><a href="https://github.com/kamalogudah/lipwa/blob/master/LICENSE.txt">MIT licensed</a></footer>
|
|
27
|
+
<script>
|
|
28
|
+
const button = document.querySelector(".nav-toggle");
|
|
29
|
+
const nav = document.querySelector(".site-nav");
|
|
30
|
+
button.addEventListener("click", () => {
|
|
31
|
+
const open = button.getAttribute("aria-expanded") === "true";
|
|
32
|
+
button.setAttribute("aria-expanded", String(!open));
|
|
33
|
+
nav.classList.toggle("is-open", !open);
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
:root{--ink:#15231f;--muted:#586b65;--paper:#fbfaf5;--panel:#f0f4ed;--line:#d7dfd7;--green:#16735d;--dark:#102a25;--lime:#d8f06d;--code:#11231f;--max:1120px}
|
|
2
|
+
*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:6rem}body{margin:0;color:var(--ink);background:radial-gradient(circle at 88% 2%,rgba(216,240,109,.24),transparent 24rem),var(--paper);font:17px/1.7 Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}a{color:var(--green);text-underline-offset:.2em}.skip-link{position:fixed;top:-4rem;left:1rem;z-index:10;padding:.65rem 1rem;color:white;background:var(--dark)}.skip-link:focus{top:1rem}
|
|
3
|
+
.site-header{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:0 max(24px,calc((100vw - var(--max))/2));border-bottom:1px solid rgba(21,35,31,.12);background:rgba(251,250,245,.94);backdrop-filter:blur(16px)}.brand{display:flex;align-items:center;gap:.65rem;color:var(--dark);font-weight:800;text-decoration:none}.brand-mark{display:grid;width:34px;height:34px;place-items:center;border-radius:9px;background:var(--lime);font-family:ui-monospace,monospace}.site-nav{display:flex;gap:1.35rem}.site-nav a{color:var(--ink);font-size:.9rem;font-weight:650;text-decoration:none}.site-nav a:hover{color:var(--green)}.nav-toggle{display:none}
|
|
4
|
+
.page-shell{width:min(calc(100% - 48px),var(--max));min-height:calc(100vh - 160px);margin:auto;padding:4.5rem 0 6rem}.hero{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(2rem,6vw,6rem);align-items:center;min-height:560px;padding:2rem 0 5rem}.eyebrow{margin:0 0 .7rem;color:var(--green);font-size:.78rem;font-weight:800;letter-spacing:.13em;text-transform:uppercase}h1,h2,h3{color:var(--dark);font-weight:800;line-height:1.12;letter-spacing:-.035em}h1{max-width:850px;margin:0 0 1.25rem;font-size:clamp(2.65rem,7vw,5.6rem)}h2{margin:3.5rem 0 1rem;font-size:clamp(1.65rem,4vw,2.6rem)}h3{margin:2.2rem 0 .5rem;font-size:1.25rem}p,ul,ol{max-width:780px}.lead{color:var(--muted);font-size:clamp(1.08rem,2vw,1.35rem)}
|
|
5
|
+
.actions{display:flex;gap:.75rem;margin-top:2rem}.button{padding:.72rem 1.05rem;border:1px solid var(--dark);border-radius:8px;color:white;background:var(--dark);font-weight:750;text-decoration:none}.button.secondary{color:var(--dark);background:transparent}.terminal{overflow:hidden;border:1px solid #29443c;border-radius:14px;box-shadow:0 24px 80px rgba(16,42,37,.18);background:var(--code)}.terminal-bar{display:flex;gap:6px;padding:14px 16px;border-bottom:1px solid #29443c}.terminal-dot{width:9px;height:9px;border-radius:50%;background:#547269}.terminal-dot:last-child{background:var(--lime)}
|
|
6
|
+
pre{overflow-x:auto;padding:1.25rem 1.35rem;border-radius:10px;color:#e7f1ec;background:var(--code);font:.87rem/1.65 ui-monospace,monospace}.terminal pre{margin:0;border-radius:0}code{padding:.12em .35em;border-radius:4px;color:#165b4c;background:#e7eee7;font-size:.88em}pre code{padding:0;color:inherit;background:transparent;font-size:inherit}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin:2rem 0}.card{padding:1.5rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.6)}.card h3{margin-top:0}.card p{margin-bottom:0;color:var(--muted);font-size:.95rem}.number{color:var(--green);font:700 .8rem ui-monospace,monospace}.callout{margin:2rem 0;padding:1rem 1.25rem;border-left:4px solid var(--green);background:var(--panel)}
|
|
7
|
+
table{width:100%;margin:1.5rem 0 2.5rem;border-collapse:collapse;font-size:.94rem}th,td{padding:.8rem .9rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--dark);background:var(--panel);font-size:.78rem;text-transform:uppercase}.page-title{padding-bottom:2rem;border-bottom:1px solid var(--line)}.page-title h1{font-size:clamp(2.5rem,6vw,4.5rem)}.doc-nav{display:flex;justify-content:space-between;margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--line)}.site-footer{display:flex;justify-content:space-between;padding:2rem max(24px,calc((100vw - var(--max))/2));color:var(--muted);border-top:1px solid var(--line);font-size:.82rem}
|
|
8
|
+
@media(max-width:800px){.hero{grid-template-columns:1fr;min-height:auto}.grid{grid-template-columns:1fr}.nav-toggle{display:block;padding:.45rem .7rem;border:1px solid var(--line);border-radius:6px;background:transparent}.site-nav{position:absolute;top:71px;right:0;left:0;display:none;flex-direction:column;padding:.5rem 24px 1rem;background:var(--paper)}.site-nav.is-open{display:flex}.site-nav a{padding:.6rem 0}.page-shell{width:min(calc(100% - 32px),var(--max));padding-top:3rem}.site-footer{flex-direction:column}table{display:block;overflow-x:auto}}
|