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
@@ -0,0 +1,116 @@
1
+ ---
2
+ title: Capabilities
3
+ description: Reference for Lipwa payment operations.
4
+ ---
5
+
6
+ <header class="page-title"><p class="eyebrow">Guide 02</p><h1>Capabilities</h1><p class="lead">A capability describes a payment flow. Gateways include only the modules they support.</p></header>
7
+
8
+ {% highlight ruby %}gateway = Lipwa.gateway(:jenga)
9
+ gateway.capability?(:bank_transfer) # => true{% endhighlight %}
10
+
11
+ | Capability | Methods | Gateways |
12
+ | --- | --- | --- |
13
+ | `stk_push` | `stk_push` | M-Pesa |
14
+ | `c2b` | `register_urls`, `simulate` | M-Pesa |
15
+ | `disbursement` | `disburse` | M-Pesa, Jenga |
16
+ | `status_query` | `status` | M-Pesa |
17
+ | `refund` | `refund` | M-Pesa |
18
+ | `bank_transfer` | `transfer`, `balance`, `statement` | Co-op, Jenga |
19
+ | `lightning_invoice` | `create_invoice`, `check_invoice` | LNbits-backed gateways |
20
+ | `lightning_payment` | `pay_invoice`, `check_payment` | LNbits-backed gateways |
21
+
22
+ Lightning invoice amounts are positive integer satoshis (`amount_sats`), not
23
+ `Lipwa::Money` values. A created invoice's BOLT11 string is stored in
24
+ `response.raw["payment_request"]`; its payment hash is
25
+ `response.provider_reference`. Checking an unpaid invoice still returns
26
+ `Success(Response)`, with `response.success? == false`.
27
+
28
+ Outbound Lightning payments use the separate `lightning_payment` capability
29
+ and require an explicitly configured wallet admin key. That key can spend the
30
+ wallet balance; it never replaces the receive-only invoice key. A payment may
31
+ remain `pending`, and a transport timeout leaves its outcome unknown. Inspect
32
+ `response.raw["status"]` and call `check_payment` to reconcile it; never retry
33
+ an indeterminate payment automatically.
34
+
35
+ {% highlight ruby %}lnbits.pay_invoice(bolt11: "lnbc...")
36
+ lnbits.check_payment(payment_hash: "..."){% endhighlight %}
37
+
38
+
39
+ ## Collection
40
+
41
+ {% highlight ruby %}mpesa = Lipwa.gateway(:mpesa)
42
+
43
+ mpesa.stk_push(
44
+ amount: Lipwa::Money.new(amount: 100, currency: "KES"),
45
+ phone_number: "254712345678",
46
+ account_reference: "ORDER-123",
47
+ callback_url: "https://example.com/webhooks/mpesa"
48
+ )
49
+
50
+ mpesa.register_urls(
51
+ validation_url: "https://example.com/mpesa/validation",
52
+ confirmation_url: "https://example.com/mpesa/confirmation"
53
+ )
54
+
55
+ mpesa.simulate(
56
+ amount: Lipwa::Money.new(amount: 100, currency: "KES"),
57
+ phone_number: "254712345678",
58
+ bill_ref_number: "ORDER-123"
59
+ ){% endhighlight %}
60
+
61
+ `simulate` is for M-Pesa sandbox testing.
62
+
63
+ ## Disbursement, status, and refund
64
+
65
+ {% highlight ruby %}mpesa.disburse(
66
+ command_id: "BusinessPayment",
67
+ amount: Lipwa::Money.new(amount: 500, currency: "KES"),
68
+ party_b: "254712345678",
69
+ remarks: "Supplier payout",
70
+ result_url: "https://example.com/mpesa/payout/result",
71
+ queue_timeout_url: "https://example.com/mpesa/payout/timeout"
72
+ )
73
+
74
+ mpesa.status(
75
+ transaction_id: "OEI2AK4Q16",
76
+ remarks: "Reconcile order",
77
+ result_url: "https://example.com/mpesa/status/result",
78
+ queue_timeout_url: "https://example.com/mpesa/status/timeout"
79
+ )
80
+
81
+ mpesa.refund(
82
+ transaction_id: "OEI2AK4Q16",
83
+ amount: Lipwa::Money.new(amount: 100, currency: "KES"),
84
+ remarks: "Customer refund",
85
+ result_url: "https://example.com/mpesa/refund/result",
86
+ queue_timeout_url: "https://example.com/mpesa/refund/timeout"
87
+ ){% endhighlight %}
88
+
89
+ M-Pesa B2C command IDs are `SalaryPayment`, `BusinessPayment`, and
90
+ `PromotionPayment`. B2B supports `BusinessPayBill`, `BusinessBuyGoods`, and
91
+ `MerchantToMerchantTransfer` and requires `account_reference`.
92
+
93
+ ## Bank operations
94
+
95
+ {% highlight ruby %}bank = Lipwa.gateway(:jenga)
96
+
97
+ bank.transfer(
98
+ rail: :pesalink,
99
+ amount: Lipwa::Money.new(amount: "2500.00", currency: "KES"),
100
+ source_account: "00123456789",
101
+ destination_account: "00987654321",
102
+ destination_name: "Amina N.",
103
+ reference: "INV-1042"
104
+ )
105
+
106
+ bank.balance(account_number: "00123456789")
107
+ bank.statement(account_number: "00123456789",
108
+ from_date: Date.new(2026, 8, 1),
109
+ to_date: Date.new(2026, 8, 31)){% endhighlight %}
110
+
111
+ Rails are `internal`, `rtgs`, `pesalink`, `swift`, and `bill_payment`,
112
+ subject to provider availability. RTGS/SWIFT require
113
+ `destination_bank_code`; bill payment requires `biller_code`. Jenga also
114
+ exposes `forex_rates`.
115
+
116
+ <nav class="doc-nav"><a href="{{ '/getting-started/' | relative_url }}">← Getting started</a><a href="{{ '/gateways/' | relative_url }}">Gateways →</a></nav>
data/docs/gateways.md ADDED
@@ -0,0 +1,158 @@
1
+ ---
2
+ title: Gateways
3
+ description: Configure M-Pesa, Co-op Bank, and Jenga HQ.
4
+ ---
5
+
6
+ <header class="page-title"><p class="eyebrow">Guide 03</p><h1>Gateways</h1><p class="lead">Configure process-wide defaults or isolate each tenant's credentials and HTTP settings in a context.</p></header>
7
+
8
+ ## Process-wide defaults
9
+
10
+ `Lipwa.configure` sets shared `logger`, `default_timeout`, and Faraday `adapter`
11
+ defaults. Gateway-class `.configure` sets provider credentials, environment, and
12
+ HTTP overrides, as shown below. These APIs and `Lipwa.gateway(:mpesa)` remain
13
+ supported and backward compatible; existing single-tenant applications do not
14
+ need to adopt contexts.
15
+
16
+ Configure defaults at boot before resolving gateways or constructing contexts.
17
+ `Lipwa.gateway(:mpesa)` lazily constructs and memoizes one process-wide gateway.
18
+ Its provider and shared settings are captured on the first lookup; later
19
+ configuration changes do not update that instance. Direct construction with
20
+ `Lipwa::Gateways::Mpesa.new` captures defaults at construction time instead.
21
+
22
+ ## M-Pesa
23
+
24
+ {% highlight ruby %}Lipwa::Gateways::Mpesa.configure do |c|
25
+ c.env = :sandbox
26
+ c.consumer_key = ENV.fetch("MPESA_CONSUMER_KEY")
27
+ c.consumer_secret = ENV.fetch("MPESA_CONSUMER_SECRET")
28
+ c.shortcode = ENV.fetch("MPESA_SHORTCODE")
29
+ c.passkey = ENV.fetch("MPESA_PASSKEY")
30
+ end{% endhighlight %}
31
+
32
+ Lipwa chooses the Daraja host and manages OAuth tokens.
33
+
34
+ ## Co-op Bank
35
+
36
+ {% highlight ruby %}Lipwa::Gateways::CoopBank.configure do |c|
37
+ c.env = :sandbox
38
+ c.api_key = ENV.fetch("COOP_API_KEY")
39
+ c.api_secret = ENV.fetch("COOP_API_SECRET")
40
+ c.base_url = ENV["COOP_BASE_URL"] # optional override
41
+ c.token_url = ENV["COOP_TOKEN_URL"] # optional override
42
+ end{% endhighlight %}
43
+
44
+ `client_id` and `client_secret` are aliases for `api_key` and `api_secret`.
45
+
46
+ ## Jenga HQ
47
+
48
+ {% highlight ruby %}Lipwa::Gateways::Jenga.configure do |c|
49
+ c.env = :sandbox
50
+ c.api_key = ENV.fetch("JENGA_API_KEY")
51
+ c.merchant_code = ENV.fetch("JENGA_MERCHANT_CODE")
52
+ c.consumer_secret = ENV.fetch("JENGA_CONSUMER_SECRET")
53
+ c.private_key = File.read(ENV.fetch("JENGA_PRIVATE_KEY_PATH"))
54
+ c.source_account = ENV.fetch("JENGA_SOURCE_ACCOUNT")
55
+ c.source_name = ENV.fetch("JENGA_SOURCE_NAME")
56
+ c.country_code = "KE"
57
+ c.partner_id = ENV["JENGA_PARTNER_ID"]
58
+ end{% endhighlight %}
59
+
60
+ Lipwa obtains bearer tokens and signs provider requests.
61
+
62
+ ## Per-gateway HTTP settings
63
+
64
+ {% highlight ruby %}Lipwa::Gateways::Jenga.configure do |c|
65
+ c.timeout = 20
66
+ c.open_timeout = 5
67
+ c.logger = MyStructuredLogger.new
68
+ end{% endhighlight %}
69
+
70
+ Logs recursively redact authorization, credentials, cookies, passwords,
71
+ tokens, and secret-like fields.
72
+
73
+ ## Contexts
74
+
75
+ `Lipwa.context { |config| ... }` (or `Lipwa::Context.new` with the same block)
76
+ captures `Lipwa.config` and every currently registered gateway class's settings
77
+ before running the builder block. Unspecified settings inherit those captured
78
+ defaults. `config.gateway(:mpesa) { |mpesa| ... }` overrides only that context's
79
+ M-Pesa settings. Provider `timeout` and `logger` take precedence over shared
80
+ `default_timeout` and `logger`; otherwise the shared defaults apply.
81
+
82
+ The snapshot happens when the context is constructed, even if its first gateway
83
+ lookup is much later. Subsequent global changes and registrations do not alter
84
+ an existing context. Overrides do not change process-wide defaults or other
85
+ contexts. Typed overrides are validated during construction.
86
+
87
+ When the block finishes, configuration is immutable: configuration strings,
88
+ arrays, and hashes are copied and frozen. Logger and other service objects retain
89
+ their identity rather than being deeply copied or frozen. Gateway runtime state,
90
+ such as HTTP clients and OAuth token caches, can still change.
91
+
92
+ Each context lazily constructs and memoizes its own gateway instances. Repeated
93
+ `tenant_context.gateway(:mpesa)` calls return the same instance, distinct from
94
+ another context's gateway and from `Lipwa.gateway(:mpesa)`. Built-in provider HTTP
95
+ clients, authentication, and token caches belong to those gateway instances.
96
+ Pass contexts explicitly; there is no thread-local current tenant.
97
+
98
+ ### Retain one context per tenant
99
+
100
+ Construct one service object per tenant and retain it across payment calls. Here
101
+ `secrets_for` is an application-supplied callable that loads that tenant's secrets
102
+ from your credential store during context construction:
103
+
104
+ {% highlight ruby %}class TenantPayments
105
+ attr_reader :context
106
+
107
+ def initialize(tenant_id:, secrets_for:)
108
+ @context = Lipwa.context do |config|
109
+ secrets = secrets_for.call(tenant_id)
110
+ config.default_timeout = 20
111
+ config.gateway(:mpesa) do |mpesa|
112
+ mpesa.env = :production
113
+ mpesa.consumer_key = secrets.fetch(:consumer_key)
114
+ mpesa.consumer_secret = secrets.fetch(:consumer_secret)
115
+ mpesa.shortcode = secrets.fetch(:shortcode)
116
+ mpesa.passkey = secrets.fetch(:passkey)
117
+ end
118
+ end
119
+ end
120
+
121
+ def stk_push(**params)
122
+ context.gateway(:mpesa).stk_push(**params)
123
+ end
124
+ end
125
+
126
+ # Retain these services in your application's tenant service registry.
127
+ payments_by_tenant = tenant_ids.to_h do |tenant_id|
128
+ [tenant_id, TenantPayments.new(tenant_id: tenant_id, secrets_for: secrets_for)]
129
+ end
130
+
131
+ tenant_context = payments_by_tenant.fetch(tenant_id).context
132
+ tenant_context.gateway(:mpesa) # This tenant's memoized gateway
133
+ Lipwa.gateway(:mpesa) # Separate process-wide gateway using boot defaults
134
+ {% endhighlight %}
135
+
136
+ Do not repeatedly mutate `Lipwa::Gateways::Mpesa.configure` per request to switch
137
+ tenants: those defaults are shared across requests, and memoized gateways retain
138
+ their earlier snapshots. For credential rotation, construct a replacement service
139
+ and context with fresh secrets and use it for subsequent calls. Existing contexts
140
+ continue using their captured credentials.
141
+
142
+ Tenant secrets loaded during construction remain covered by Lipwa's HTTP log
143
+ redaction for authorization, credentials, tokens, and secret-like fields. This
144
+ does not sanitize arbitrary application logs: do not log raw secret hashes or
145
+ inspect configuration objects containing credentials.
146
+
147
+ ### Explicit gateway construction and custom authentication
148
+
149
+ Explicit `config:` and `global_config:` gateway constructor snapshots replace
150
+ their respective defaults and are copied and finalized by the gateway.
151
+
152
+ Custom `auth_strategy` objects are duplicated for each gateway. Stateful custom
153
+ strategies should implement `initialize_copy` to isolate nested mutable state,
154
+ or supply a zero-argument factory that creates a fresh strategy and token source.
155
+ Callable token providers and clocks must not close over shared mutable token
156
+ caches. Built-in OAuth clients reset their caches when copied.
157
+
158
+ <nav class="doc-nav"><a href="{{ '/capabilities/' | relative_url }}">← Capabilities</a><a href="{{ '/webhooks/' | relative_url }}">Webhooks →</a></nav>
@@ -0,0 +1,64 @@
1
+ ---
2
+ title: Getting started
3
+ description: Install Lipwa, configure a gateway, and make your first request.
4
+ ---
5
+
6
+ <header class="page-title"><p class="eyebrow">Guide 01</p><h1>Getting started</h1><p class="lead">Install the gem, configure one gateway, and handle the result explicitly.</p></header>
7
+
8
+ ## Install and configure
9
+
10
+ {% highlight bash %}bundle add lipwa{% endhighlight %}
11
+
12
+ {% highlight ruby %}require "lipwa"
13
+
14
+ Lipwa.configure do |config|
15
+ config.logger = Rails.logger
16
+ config.default_timeout = 10
17
+ end
18
+
19
+ Lipwa::Gateways::Mpesa.configure do |config|
20
+ config.env = :sandbox
21
+ config.consumer_key = ENV.fetch("MPESA_CONSUMER_KEY")
22
+ config.consumer_secret = ENV.fetch("MPESA_CONSUMER_SECRET")
23
+ config.shortcode = ENV.fetch("MPESA_SHORTCODE")
24
+ config.passkey = ENV.fetch("MPESA_PASSKEY")
25
+ end{% endhighlight %}
26
+
27
+ ## Make a request
28
+
29
+ {% highlight ruby %}result = Lipwa.gateway(:mpesa).stk_push(
30
+ amount: Lipwa::Money.new(amount: "100.00", currency: "KES"),
31
+ phone_number: "254712345678",
32
+ account_reference: "ORDER-123",
33
+ callback_url: "https://payments.example.com/webhooks/mpesa",
34
+ idempotency_key: "stk-order-123"
35
+ )
36
+
37
+ result.either(
38
+ ->(response) { Payment.update!(provider_reference: response.provider_reference) },
39
+ ->(error) { logger.error(error.message) }
40
+ ){% endhighlight %}
41
+
42
+ <div class="callout"><strong>Acceptance is not settlement.</strong> STK Push, disbursement, refund, and status calls are asynchronous. Process the later verified callback for the final result.</div>
43
+
44
+ ## Initiator credentials
45
+
46
+ Disbursement, status, and refund also require:
47
+
48
+ {% highlight ruby %}Lipwa::Gateways::Mpesa.configure do |config|
49
+ config.initiator_name = ENV.fetch("MPESA_INITIATOR_NAME")
50
+ config.initiator_password = ENV.fetch("MPESA_INITIATOR_PASSWORD")
51
+ config.security_credential_cert =
52
+ File.read(ENV.fetch("MPESA_CERTIFICATE_PATH"))
53
+ end{% endhighlight %}
54
+
55
+ Sandbox and production certificates differ. Keep certificate content outside
56
+ source control.
57
+
58
+ ## Idempotency
59
+
60
+ Reuse one stable `idempotency_key` when retrying one logical write. Lipwa
61
+ retries safe reads automatically, but writes only when this key is present.
62
+ The provider controls key retention and duplicate detection.
63
+
64
+ <nav class="doc-nav"><a href="{{ '/' | relative_url }}">← Home</a><a href="{{ '/capabilities/' | relative_url }}">Capabilities →</a></nav>
data/docs/index.md ADDED
@@ -0,0 +1,46 @@
1
+ ---
2
+ title: Home
3
+ description: Unified, capability-based African payment APIs for Ruby.
4
+ ---
5
+
6
+ <section class="hero"><div>
7
+ <p class="eyebrow">Payment flows, honestly modeled</p>
8
+ <h1>One Ruby API. Only the capabilities you need.</h1>
9
+ <p class="lead">Lipwa unifies mobile money, bank transfers, payouts, and asynchronous callbacks without pretending every provider behaves like a card gateway.</p>
10
+ <div class="actions"><a class="button" href="{{ '/getting-started/' | relative_url }}">Get started</a><a class="button secondary" href="{{ '/capabilities/' | relative_url }}">Explore capabilities</a></div>
11
+ </div><div class="terminal"><div class="terminal-bar"><span class="terminal-dot"></span><span class="terminal-dot"></span><span class="terminal-dot"></span></div>
12
+ {% highlight ruby %}gateway = Lipwa.gateway(:mpesa)
13
+
14
+ result = gateway.stk_push(
15
+ amount: Lipwa::Money.new(
16
+ amount: "100.00",
17
+ currency: "KES"
18
+ ),
19
+ phone_number: "254712345678",
20
+ account_reference: "ORDER-123",
21
+ callback_url: callback_url,
22
+ idempotency_key: "stk-order-123"
23
+ )
24
+
25
+ result.fmap(&:provider_reference){% endhighlight %}
26
+ </div></section>
27
+
28
+ ## Designed around real payment flows
29
+
30
+ <div class="grid">
31
+ <article class="card"><span class="number">01</span><h3>Capability first</h3><p>Gateways expose operations they truly implement. Inspect support with <code>capability?</code> and route deliberately.</p></article>
32
+ <article class="card"><span class="number">02</span><h3>Failure explicit</h3><p>Validation and provider failures travel through <code>Dry::Monads::Result</code>.</p></article>
33
+ <article class="card"><span class="number">03</span><h3>Async aware</h3><p>Request acknowledgement and final outcome stay distinct. Normalized webhook events complete the flow.</p></article>
34
+ </div>
35
+
36
+ ## Supported gateways
37
+
38
+ | Gateway | What it can do |
39
+ | --- | --- |
40
+ | M-Pesa | STK Push, C2B, B2C/B2B, status, refunds |
41
+ | Co-op Bank | Transfers, balances, and statements |
42
+ | Jenga HQ | Bank/mobile transfers, account queries, forex |
43
+
44
+ <div class="callout"><strong>Start with a flow, not a provider.</strong> Choose the capability your product needs, then select a gateway that advertises it.</div>
45
+
46
+ <nav class="doc-nav"><span></span><a href="{{ '/getting-started/' | relative_url }}">Getting started →</a></nav>
@@ -0,0 +1,50 @@
1
+ ---
2
+ title: Reliability
3
+ description: Results, retries, idempotency, and money safety.
4
+ ---
5
+
6
+ <header class="page-title"><p class="eyebrow">Guide 05</p><h1>Reliability</h1><p class="lead">Expected failures are explicit, and automatic recovery is limited to operations safe to repeat.</p></header>
7
+
8
+ ## Results and errors
9
+
10
+ | Type | Delivery | Meaning |
11
+ | --- | --- | --- |
12
+ | `ValidationError` | `Failure` | Input failed a capability contract |
13
+ | `GatewayError` | `Failure` | Network or provider HTTP failure |
14
+ | `WebhookParseError` | `Failure` | Invalid inbound JSON |
15
+ | `ConfigurationError` | raised | Missing or invalid setup |
16
+ | `UnsupportedProviderError` | raised | No webhook parser registered |
17
+
18
+ `Lipwa::Response` exposes `success?`, `provider_reference`, `message`,
19
+ `code`, and `raw`.
20
+
21
+ ## Retry policy
22
+
23
+ Lipwa retries transient network exceptions and HTTP 429, 500, 502, 503, and
24
+ 504 at most twice. Backoff begins at 0.5 seconds, doubles with up to 50%
25
+ jitter, and caps waits at 5 seconds. Server `Retry-After` and
26
+ `RateLimit-Reset` values are honored within that cap.
27
+
28
+ Safe reads retry automatically. Writes retry only with `Idempotency-Key`.
29
+
30
+ ## Decimal money
31
+
32
+ {% highlight ruby %}subtotal = Lipwa::Money.new(amount: "199.95", currency: "KES")
33
+ fee = Lipwa::Money.new(amount: "5.05", currency: "KES")
34
+
35
+ total = subtotal + fee
36
+ total.to_s # => "205.0 KES"
37
+ total > subtotal # => true{% endhighlight %}
38
+
39
+ Amounts and results are non-negative. Addition, subtraction, and comparison
40
+ across currencies raise `ArgumentError`. Currency conversion is never hidden.
41
+
42
+ ## Async reconciliation
43
+
44
+ 1. Persist a pending operation and idempotency key before calling the API.
45
+ 2. Store the synchronous provider reference.
46
+ 3. Verify and process callbacks idempotently.
47
+ 4. Use status query where supported to reconcile missing callbacks.
48
+ 5. Treat acknowledgements and callbacks as repeatable messages.
49
+
50
+ <nav class="doc-nav"><a href="{{ '/webhooks/' | relative_url }}">← Webhooks</a><a href="{{ '/' | relative_url }}">Home →</a></nav>
data/docs/webhooks.md ADDED
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: Webhooks
3
+ description: Parse and verify M-Pesa, Jenga, and LNbits callbacks.
4
+ ---
5
+
6
+ <header class="page-title"><p class="eyebrow">Guide 04</p><h1>Webhooks</h1><p class="lead">Normalize provider payloads, then perform the provider-specific trust check before changing financial state.</p></header>
7
+
8
+ ## Parse
9
+
10
+ {% highlight ruby %}result = Lipwa::Webhook.parse_webhook(
11
+ provider: :mpesa,
12
+ body: request.body.read,
13
+ headers: request.headers.to_h
14
+ )
15
+
16
+ result.either(
17
+ ->(event) { process_event(event) },
18
+ ->(error) { head :bad_request }
19
+ ){% endhighlight %}
20
+
21
+ `WebhookEvent` exposes `provider`, `event_type`, `success?`,
22
+ `provider_reference`, `message`, `raw`, and `verify_signature`.
23
+
24
+ ## Verify M-Pesa
25
+
26
+ Daraja does not cryptographically sign callbacks. Lipwa checks the remote
27
+ address against Safaricom's published callback IP list:
28
+
29
+ {% highlight ruby %}return head :forbidden unless event.verify_signature(
30
+ source_ip: request.remote_ip
31
+ ){% endhighlight %}
32
+
33
+ Configure trusted proxies carefully so clients cannot spoof forwarded IPs.
34
+ Event types are `stk_callback`, `c2b`, `transaction_status`,
35
+ `disbursement`, and `refund`.
36
+
37
+ ## Verify Jenga
38
+
39
+ Jenga receive-payment IPNs normalize to `receive_payment`:
40
+
41
+ {% highlight ruby %}return head :forbidden unless event.verify_signature(
42
+ username: ENV.fetch("JENGA_WEBHOOK_USERNAME"),
43
+ password: ENV.fetch("JENGA_WEBHOOK_PASSWORD")
44
+ ){% endhighlight %}
45
+
46
+ ## Verify LNbits
47
+
48
+ LNbits does not HMAC-sign webhooks by default. Give each invoice a strong,
49
+ random token in its webhook URL, persist that token alongside the payment hash,
50
+ and compare the URL parameter when the callback arrives:
51
+
52
+ {% highlight ruby %}return head :forbidden unless event.verify_signature(
53
+ expected_token: invoice.webhook_token,
54
+ provided_token: params[:token]
55
+ ){% endhighlight %}
56
+
57
+ This is a shared-secret URL check, not proof that the webhook body came from
58
+ LNbits. Anyone who learns the token can forge a payload. Always call
59
+ `check_invoice(payment_hash: event.provider_reference)` and confirm its status
60
+ before crediting funds; use the webhook only as a low-latency trigger.
61
+
62
+ ## Processing checklist
63
+
64
+ 1. Retain the raw body and parse it through Lipwa.
65
+ 2. Verify using provider-specific options.
66
+ 3. Correlate by `provider_reference`.
67
+ 4. Apply the transition idempotently inside a transaction.
68
+ 5. Return quickly and defer slow work to a job.
69
+
70
+ <div class="callout"><strong>Never credit from an unverified callback.</strong> Parsing normalizes data; verification determines trust.</div>
71
+
72
+ <nav class="doc-nav"><a href="{{ '/gateways/' | relative_url }}">← Gateways</a><a href="{{ '/reliability/' | relative_url }}">Reliability →</a></nav>
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Lipwa
6
+ module AuthStrategies
7
+ # Sets a static API key header on every request.
8
+ class ApiKey < Base
9
+ def initialize(key, header: "X-Api-Key")
10
+ raise ArgumentError, "key must not be blank" if key.nil? || key.to_s.strip.empty?
11
+
12
+ super()
13
+ @key = key
14
+ @header = header
15
+ end
16
+
17
+ def initialize_copy(source)
18
+ super
19
+ @key = @key.dup
20
+ @header = @header.dup
21
+ end
22
+
23
+ def apply(env)
24
+ env.request_headers[@header] = @key
25
+ end
26
+ end
27
+ end
28
+ end
@@ -8,6 +8,10 @@ module Lipwa
8
8
  # timestamp, whatever the provider needs) without HttpAdapter knowing
9
9
  # which kind of auth it's dealing with.
10
10
  class Base
11
+ def inspect
12
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
13
+ end
14
+
11
15
  # env is a Faraday::Env — mutate env.request_headers / env.body
12
16
  # in place. Must be implemented by subclasses.
13
17
  def apply(env)
@@ -19,6 +19,11 @@ module Lipwa
19
19
  @token_provider = token_provider
20
20
  end
21
21
 
22
+ def initialize_copy(source)
23
+ super
24
+ @token_provider = @token_provider.dup
25
+ end
26
+
22
27
  def apply(env)
23
28
  env.request_headers["Authorization"] = "Bearer #{@token_provider.call}"
24
29
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require_relative "base"
6
+
7
+ module Lipwa
8
+ module AuthStrategies
9
+ # Adds Jenga HQ's OAuth2 bearer token and RSA-SHA256 request signature.
10
+ #
11
+ # Jenga defines a different signature formula for each endpoint. The
12
+ # signature_payload callable receives the Faraday::Env for the outgoing
13
+ # request and must return the exact concatenated String required by that
14
+ # endpoint (without separators). It is evaluated for every request.
15
+ class Jenga < Base
16
+ def initialize(token_provider:, private_key:, signature_payload:)
17
+ super()
18
+ validate_callable!(token_provider, :token_provider)
19
+ validate_callable!(signature_payload, :signature_payload)
20
+
21
+ @token_provider = token_provider
22
+ @private_key = parse_private_key(private_key)
23
+ @signature_payload = signature_payload
24
+ end
25
+
26
+ def initialize_copy(source)
27
+ super
28
+ @token_provider = @token_provider.dup
29
+ end
30
+
31
+ def apply(env)
32
+ payload = @signature_payload.call(env)
33
+ raise ArgumentError, "signature_payload must return a String" unless payload.is_a?(String)
34
+
35
+ signature = @private_key.sign(OpenSSL::Digest.new("SHA256"), payload)
36
+ env.request_headers["Authorization"] = "Bearer #{@token_provider.call}"
37
+ env.request_headers["Signature"] = Base64.strict_encode64(signature)
38
+ end
39
+
40
+ private
41
+
42
+ def validate_callable!(value, name)
43
+ raise ArgumentError, "#{name} must respond to #call" unless value.respond_to?(:call)
44
+ end
45
+
46
+ def parse_private_key(private_key)
47
+ key = private_key.is_a?(OpenSSL::PKey::RSA) ? private_key : OpenSSL::PKey::RSA.new(private_key)
48
+ raise ArgumentError, "private_key must contain an RSA private key" unless key.private?
49
+
50
+ key
51
+ rescue OpenSSL::PKey::PKeyError, TypeError
52
+ raise ArgumentError, "private_key must be an RSA private key or PEM encoded RSA private key"
53
+ end
54
+ end
55
+
56
+ JengaHQ = Jenga
57
+ end
58
+ end
@@ -3,3 +3,5 @@
3
3
  require_relative "auth_strategies/base"
4
4
  require_relative "auth_strategies/none"
5
5
  require_relative "auth_strategies/bearer_token"
6
+ require_relative "auth_strategies/api_key"
7
+ require_relative "auth_strategies/jenga"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require_relative "../capability"
5
+ require_relative "../contracts/card_payment_contract"
6
+
7
+ module Lipwa
8
+ module Capabilities
9
+ # Creates a provider authorization flow without exposing card data.
10
+ module Authorize
11
+ extend Lipwa::Capability
12
+ include Dry::Monads[:result]
13
+ self.capability_name = :authorize
14
+
15
+ CONTRACT = Lipwa::Contracts::CardPaymentContract.new
16
+
17
+ def authorize(**params)
18
+ idempotency_key = params.delete(:idempotency_key)
19
+ validation = CONTRACT.call(params)
20
+ return Failure(Lipwa::ValidationError.new(validation)) if validation.failure?
21
+
22
+ perform_authorize(validation.to_h, idempotency_key)
23
+ end
24
+ end
25
+ end
26
+ end