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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +219 -204
  4. data/Rakefile +11 -0
  5. data/docs/README.md +15 -0
  6. data/docs/_config.yml +11 -0
  7. data/docs/_layouts/default.html +37 -0
  8. data/docs/assets/css/style.css +8 -0
  9. data/docs/capabilities.md +116 -0
  10. data/docs/gateways.md +158 -0
  11. data/docs/getting-started.md +64 -0
  12. data/docs/index.md +46 -0
  13. data/docs/reliability.md +50 -0
  14. data/docs/webhooks.md +72 -0
  15. data/lib/lipwa/auth_strategies/api_key.rb +28 -0
  16. data/lib/lipwa/auth_strategies/base.rb +4 -0
  17. data/lib/lipwa/auth_strategies/bearer_token.rb +5 -0
  18. data/lib/lipwa/auth_strategies/jenga.rb +58 -0
  19. data/lib/lipwa/auth_strategies.rb +2 -0
  20. data/lib/lipwa/capabilities/authorize.rb +26 -0
  21. data/lib/lipwa/capabilities/bank_transfer.rb +66 -0
  22. data/lib/lipwa/capabilities/c2b.rb +11 -10
  23. data/lib/lipwa/capabilities/capture.rb +25 -0
  24. data/lib/lipwa/capabilities/disbursement.rb +16 -17
  25. data/lib/lipwa/capabilities/lightning_invoice.rb +75 -0
  26. data/lib/lipwa/capabilities/lightning_payment.rb +71 -0
  27. data/lib/lipwa/capabilities/purchase.rb +26 -0
  28. data/lib/lipwa/capabilities/refund.rb +14 -14
  29. data/lib/lipwa/capabilities/status_query.rb +21 -12
  30. data/lib/lipwa/capabilities/stk_push.rb +8 -5
  31. data/lib/lipwa/capabilities/void.rb +25 -0
  32. data/lib/lipwa/configuration_snapshot.rb +50 -0
  33. data/lib/lipwa/context.rb +60 -0
  34. data/lib/lipwa/contracts/bank_transfer_contract.rb +61 -0
  35. data/lib/lipwa/contracts/card_payment_contract.rb +59 -0
  36. data/lib/lipwa/contracts/card_transaction_contract.rb +21 -0
  37. data/lib/lipwa/contracts/lightning_invoice_check_contract.rb +18 -0
  38. data/lib/lipwa/contracts/lightning_invoice_contract.rb +27 -0
  39. data/lib/lipwa/contracts/lightning_payment_check_contract.rb +18 -0
  40. data/lib/lipwa/contracts/lightning_payment_contract.rb +14 -0
  41. data/lib/lipwa/contracts/status_query_contract.rb +29 -6
  42. data/lib/lipwa/errors.rb +4 -2
  43. data/lib/lipwa/gateway.rb +24 -5
  44. data/lib/lipwa/gateways/coop_bank/auth.rb +68 -0
  45. data/lib/lipwa/gateways/coop_bank.rb +115 -0
  46. data/lib/lipwa/gateways/flutterwave.rb +160 -0
  47. data/lib/lipwa/gateways/jenga/auth.rb +76 -0
  48. data/lib/lipwa/gateways/jenga.rb +282 -0
  49. data/lib/lipwa/gateways/lnbits.rb +67 -0
  50. data/lib/lipwa/gateways/mpesa/auth.rb +18 -2
  51. data/lib/lipwa/gateways/mpesa.rb +16 -11
  52. data/lib/lipwa/gateways/paystack.rb +119 -0
  53. data/lib/lipwa/gateways/pesapal/auth.rb +72 -0
  54. data/lib/lipwa/gateways/pesapal.rb +123 -0
  55. data/lib/lipwa/gateways.rb +7 -1
  56. data/lib/lipwa/http_adapter.rb +56 -17
  57. data/lib/lipwa/logging.rb +100 -0
  58. data/lib/lipwa/money.rb +33 -4
  59. data/lib/lipwa/types.rb +3 -3
  60. data/lib/lipwa/version.rb +1 -1
  61. data/lib/lipwa/webhooks/coop_bank.rb +28 -0
  62. data/lib/lipwa/webhooks/jenga.rb +55 -0
  63. data/lib/lipwa/webhooks/lnbits.rb +46 -0
  64. data/lib/lipwa.rb +29 -0
  65. metadata +42 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afc2872c6fa2654eb15ce20c62a1a6027e93f9d7a3b4aceaa0c6420ac96b29d3
4
- data.tar.gz: a58735e4ccecf1e745d8287db798b9b6993b0da08cff0511c7927d182c239a42
3
+ metadata.gz: 5ed1d0107830b6fafe1757cc253a820b57fe62c99f38ec78e1d2adde62e10eaa
4
+ data.tar.gz: 85994d9fe4d49e276892154fc600ae06f420740a55c5580f9bbac6cfbcc3d14b
5
5
  SHA512:
6
- metadata.gz: e6abf4adc039b3930c73663e1dfb6888b2a3b13846a3637fd17a13ff6909776b28e0ac340ab6bc9f8db78f56b2a3eea5d41bd9f9ec5fb5a466282480c849c044
7
- data.tar.gz: 57b7f247579e9d6c810f29fe0998b2bac9c1d027c9cd39780ae64d567a9813b0b34cd7020a7fd0e5f558559600563becdebfcb3cd7f77fc43c6694e9384e861e
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 unified Ruby gem for accepting and disbursing payments across
4
- African payment providers — mobile money, bank APIs, and (eventually) card
5
- rails behind one consistent, capability-based interface. It currently
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
- ## Installation
7
+ ```ruby
8
+ gateway = Lipwa.gateway(:mpesa)
9
+ gateway.capability?(:stk_push) # => true
10
+ gateway.capability?(:bank_transfer) # => false
11
+ ```
16
12
 
17
- Install the gem and add it to the application's Gemfile by executing:
13
+ ## What it provides
18
14
 
19
- ```bash
20
- bundle add lipwa
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
- If bundler is not being used to manage dependencies, install the gem by
24
- executing:
21
+ ## Installation
25
22
 
26
23
  ```bash
27
- gem install lipwa
24
+ bundle add lipwa
28
25
  ```
29
26
 
30
- ## Configuration
27
+ Lipwa supports Ruby 3.2 and newer.
31
28
 
32
- Set gem-wide defaults once (logger, default timeout, Faraday adapter — all
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
- Then configure each gateway you use. For M-Pesa:
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::Gateways::Mpesa.configure do |c|
46
- c.env = :sandbox # or :production
47
- c.consumer_key = ENV["MPESA_CONSUMER_KEY"]
48
- c.consumer_secret = ENV["MPESA_CONSUMER_SECRET"]
49
- c.shortcode = ENV["MPESA_SHORTCODE"]
50
- c.passkey = ENV["MPESA_PASSKEY"] # STK Push only
51
-
52
- # B2C/B2B disbursements only:
53
- c.initiator_name = ENV["MPESA_INITIATOR_NAME"]
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
- `consumer_key`/`consumer_secret` authenticate every Daraja call (OAuth2
60
- client-credentials, cached and auto-refreshed). `shortcode`/`passkey` are
61
- only needed for STK Push and C2B. `initiator_name`/`initiator_password`/
62
- `security_credential_cert` are only needed if you call `#disburse` see
63
- [Disbursement](#disbursement-b2c--b2b) for what the cert is and where to
64
- get it. Configuring only what you actually use is fine; each capability
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
- Fetch a configured gateway by name instead of referencing the class
69
- directly:
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
- ```ruby
72
- Lipwa.gateway(:mpesa).stk_push(...)
73
- ```
102
+ ## Lightning Network (LNbits)
74
103
 
75
- ## Usage
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
- ### STK Push (Lipa Na M-Pesa Online)
108
+ ### Configuration
78
109
 
79
- Pushes a payment prompt to the payer's phone. The actual result of the
80
- payment arrives later at `callback_url` a successful call here only
81
- confirms Daraja *accepted* the request, not that the customer paid.
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
- result = Lipwa.gateway(:mpesa).stk_push(
85
- amount: Lipwa::Money.new(amount: 100, currency: "KES"), # 100 = 1.00 KES, minor units
86
- phone_number: "254712345678",
87
- account_reference: "ORDER-123",
88
- callback_url: "https://example.com/webhooks/mpesa/stk"
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
- ### C2B (Customer to Business)
123
+ ### Usage
98
124
 
99
- Registers the validation/confirmation webhook URLs Daraja calls when a
100
- customer pays your paybill/till directly (outside STK Push), and — sandbox
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(:mpesa).register_urls(
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
- # Sandbox only:
111
- Lipwa.gateway(:mpesa).simulate(
112
- amount: Lipwa::Money.new(amount: 100, currency: "KES"),
113
- phone_number: "254712345678",
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
- `#disburse` sends money out from your shortcode — to a customer's phone
121
- (B2C: salaries, promotions, business payments) or to another business
122
- (B2B: paybill/till settlement) driven by `command_id` rather than two
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
- ```ruby
126
- # B2C — pay out to a customer's phone
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
- # B2B — settle with another business shortcode
138
- result = Lipwa.gateway(:mpesa).disburse(
139
- command_id: "BusinessPayBill", # or "BusinessBuyGoods" / "MerchantToMerchantTransfer"
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
- Like STK Push, `#disburse` only confirms Daraja *accepted* the request
150
- (`ConversationID`) the outcome (success or failure of the actual
151
- payout) arrives later at `result_url`.
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
- **About `security_credential_cert`**: Daraja requires every B2C/B2B
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
- ### Refund
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
- result = Lipwa.gateway(:mpesa).refund(
172
- transaction_id: "OEI2AK4Q16",
173
- amount: Lipwa::Money.new(amount: 100_00, currency: "KES"),
174
- remarks: "Missing item",
175
- result_url: "https://example.com/webhooks/mpesa/reversal/result",
176
- queue_timeout_url: "https://example.com/webhooks/mpesa/reversal/timeout",
177
- occasion: "Customer complaint"
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
- Like `#disburse`, `#refund` only confirms Daraja *accepted* the reversal
182
- request the outcome arrives later at `result_url`. It requires the same
183
- `security_credential_cert` as `#disburse` see
184
- [Disbursement](#disbursement-b2c--b2b) for what the cert is and where to
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
- ### Webhook handling
178
+ ### Webhooks
188
179
 
189
- Daraja delivers STK Push results and C2B validation/confirmation as
190
- inbound HTTP callbacks. Parse and (for M-Pesa) verify them with
191
- `Lipwa::Webhook`:
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
- # in your webhook controller
195
- result = Lipwa::Webhook.parse_webhook(provider: :mpesa, body: request.body.read, headers: request.headers)
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
- result.either(
198
- lambda do |event|
199
- if event.verify_signature(source_ip: request.remote_ip)
200
- # event.event_type => :stk_callback or :c2b
201
- # event.success? => whether the STK push succeeded (always true for C2B —
202
- # C2B callbacks only fire for an already-completed payment)
203
- # event.provider_reference => CheckoutRequestID (STK) or TransID (C2B)
204
- handle(event)
205
- else
206
- head :forbidden
207
- end
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
- M-Pesa doesn't cryptographically sign callbacks, so `verify_signature`
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
- ### `Lipwa::Money`
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
- Amounts are always a `Lipwa::Money` — an immutable value object storing
220
- minor currency units (e.g. `100` = KES 1.00) plus an ISO 4217 currency
221
- code (defaults to `"KES"`):
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
- ## Error handling
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
- Gateway methods (`#stk_push`, `#register_urls`, `#simulate`, `#disburse`)
230
- never raise for expected failure modes they return a
231
- `Dry::Monads::Result`:
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
- - `Success(Lipwa::Response)` — `#success?`, `#provider_reference`,
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
- `Lipwa::ConfigurationError` and `Lipwa::UnsupportedCapabilityError` are
244
- raised, not wrapped — they represent programmer/ops mistakes (missing
245
- credentials, calling a capability a gateway doesn't include) that should
246
- fail loudly at call time rather than be routed through error-handling
247
- code.
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
- After checking out the repo, run `bin/setup` to install dependencies.
252
- Then, run `rake test` to run the tests. You can also run `bin/console` for
253
- an interactive prompt that will allow you to experiment.
278
+ ```bash
279
+ bin/setup
280
+ bundle exec rake
281
+ bin/console
282
+ ```
254
283
 
255
- Tests run against hand-authored VCR cassettes
256
- (`test/fixtures/vcr_cassettes/mpesa/`) with fake sandbox credentials —
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
- ## Contributing
273
-
274
- Bug reports and pull requests are welcome on GitHub at
275
- https://github.com/kamalogudah/lipwa. This project is intended to be a
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,11 @@
1
+ title: Lipwa
2
+ description: Capability-based payment APIs for Ruby
3
+ url: https://kamalogudah.github.io
4
+ baseurl: /lipwa
5
+ permalink: pretty
6
+ markdown: kramdown
7
+ defaults:
8
+ - scope:
9
+ path: ""
10
+ values:
11
+ layout: default
@@ -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}}