paystack_sdk 0.4.1 → 0.5.0
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 +33 -0
- data/README.md +68 -0
- data/exe/paystack_sdk +6 -0
- data/lib/generators/paystack_sdk/skills_generator.rb +27 -0
- data/lib/paystack_sdk/cli.rb +131 -0
- data/lib/paystack_sdk/client.rb +13 -0
- data/lib/paystack_sdk/request_helpers.rb +1 -0
- data/lib/paystack_sdk/resources/customers.rb +2 -1
- data/lib/paystack_sdk/resources/payment_requests.rb +2 -1
- data/lib/paystack_sdk/resources/refunds.rb +2 -1
- data/lib/paystack_sdk/resources/subscriptions.rb +3 -1
- data/lib/paystack_sdk/resources/transactions.rb +6 -4
- data/lib/paystack_sdk/resources/webhook_events.rb +174 -0
- data/lib/paystack_sdk/response.rb +25 -0
- data/lib/paystack_sdk/skills/paystack-sdk-charge-statuses/SKILL.md +85 -0
- data/lib/paystack_sdk/skills/paystack-sdk-mobile-money/SKILL.md +81 -0
- data/lib/paystack_sdk/skills/paystack-sdk-money-safety/SKILL.md +129 -0
- data/lib/paystack_sdk/skills/paystack-sdk-overview/SKILL.md +98 -0
- data/lib/paystack_sdk/skills/paystack-sdk-payments/SKILL.md +199 -0
- data/lib/paystack_sdk/skills/paystack-sdk-refunds/SKILL.md +117 -0
- data/lib/paystack_sdk/skills/paystack-sdk-saved-card-renewals/SKILL.md +154 -0
- data/lib/paystack_sdk/skills/paystack-sdk-testing/SKILL.md +227 -0
- data/lib/paystack_sdk/skills/paystack-sdk-webhooks/SKILL.md +250 -0
- data/lib/paystack_sdk/skills.rb +165 -0
- data/lib/paystack_sdk/validations.rb +17 -0
- data/lib/paystack_sdk/version.rb +1 -1
- data/lib/paystack_sdk/webhook.rb +11 -5
- metadata +17 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: paystack-sdk-testing
|
|
3
|
+
description: 'Use when writing or running tests for an app that calls Paystack through the paystack_sdk gem: choosing keys for test, staging and CI, stubbing Paystack HTTP with WebMock (success, 400, 401, 429, 500 and timeout bodies), writing an opt-in test-API suite, using Paystacks published test cards and test mobile money number, testing a webhook endpoint, or deciding what test mode can never prove.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing an app that uses paystack_sdk
|
|
7
|
+
|
|
8
|
+
Three layers, cheapest first: (1) **HTTP stubs** in your normal suite (fast, offline, cover every error path); (2) an **opt-in test-API suite** that talks to Paystack with an `sk_test_` key; (3) a **manual live check** for what test mode cannot prove (last section). Conventions the other skills assume are in [[paystack-sdk-overview]].
|
|
9
|
+
|
|
10
|
+
## Keys
|
|
11
|
+
|
|
12
|
+
| Where | Key | Client |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Unit tests | none needed; a dummy `sk_test_dummy` | `Client.new(secret_key: "sk_test_dummy")`, requests stubbed |
|
|
15
|
+
| Test-API suite, CI, staging, development | your Paystack **test** secret key (`sk_test_...`) | `Client.new(secret_key: key, sandbox_only: true)` |
|
|
16
|
+
| Production | the live key (`sk_live_...`) | no `sandbox_only` |
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
client = PaystackSdk::Client.new(secret_key: ENV.fetch("PAYSTACK_SECRET_KEY"), sandbox_only: true)
|
|
20
|
+
client.live? # => false for sk_test_ keys; true only for sk_live_ keys
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `sandbox_only: true` raises `ArgumentError` at construction unless the key starts with `sk_test_`. It is a **prefix check**. It stops a live key from being used by mistake. It cannot stop a test-key call that Paystack itself routes to live mode (publishing a storefront copies it to the live integration even with a test key), so keep tests off calls that publish, register, activate, notify or pay out.
|
|
24
|
+
- `client.live?` is true only for a key starting `sk_live_`. Assert `expect(client).not_to be_live` in the test-API suite as a tripwire.
|
|
25
|
+
- Test and live are separate worlds: customers, transactions, saved cards (authorization codes), plans and references made with a test key do not exist in live, and the reverse. Never copy a test authorization code into a live flow.
|
|
26
|
+
- Never commit a key or put it in a log. Read it from the environment (CI secret).
|
|
27
|
+
|
|
28
|
+
## Stub Paystack at the HTTP edge (WebMock)
|
|
29
|
+
|
|
30
|
+
Stub the request to `https://api.paystack.co/...` and let `PaystackSdk::Client` run for real. Do not replace `client.transactions` or `Client` with a double: the SDK validates keywords before sending, builds the request, and wraps the body in `Response` (`success?`, `paid?`, `error_message`, dot access). A double skips all of that, so the test passes on code the SDK would reject, with a response shape the SDK never returns.
|
|
31
|
+
|
|
32
|
+
Copy this into `spec/support/paystack_stubs.rb` (it needs `webmock/rspec`):
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
module PaystackStubs
|
|
36
|
+
PAYSTACK_API = "https://api.paystack.co"
|
|
37
|
+
|
|
38
|
+
# Paystack's success envelope: {status: true, message:, data: {...}}
|
|
39
|
+
def paystack_ok(data = {}, message: "OK", meta: nil)
|
|
40
|
+
body = {status: true, message: message, data: data}
|
|
41
|
+
body[:meta] = meta if meta
|
|
42
|
+
body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Paystack's error envelope: {status: false, message:, meta: {nextStep:}, type:, code:}
|
|
46
|
+
def paystack_error(message, code: nil, type: "validation_error", next_step: nil)
|
|
47
|
+
body = {status: false, message: message}
|
|
48
|
+
body[:meta] = {nextStep: next_step} if next_step
|
|
49
|
+
body[:type] = type
|
|
50
|
+
body[:code] = code if code
|
|
51
|
+
body
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A transaction as `transactions.verify` returns it. Override any field.
|
|
55
|
+
def paystack_transaction(overrides = {})
|
|
56
|
+
{
|
|
57
|
+
id: 1, reference: "order-1042", status: "success", amount: 5000, currency: "GHS",
|
|
58
|
+
channel: "card", paid_at: "2026-01-01T10:00:00.000Z",
|
|
59
|
+
customer: {email: "ama@example.com"},
|
|
60
|
+
authorization: {authorization_code: "AUTH_abc123", reusable: true}
|
|
61
|
+
}.merge(overrides)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A stubbed response. `path` includes the query string if the request has one.
|
|
65
|
+
def stub_paystack(verb, path, body, status: 200, headers: {})
|
|
66
|
+
stub_request(verb, "#{PAYSTACK_API}#{path}").to_return(
|
|
67
|
+
status: status, body: body.to_json,
|
|
68
|
+
headers: {"Content-Type" => "application/json"}.merge(headers)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stub_paystack_timeout(verb, path)
|
|
73
|
+
stub_request(verb, "#{PAYSTACK_API}#{path}").to_timeout
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add `config.include PaystackStubs` in `spec/spec_helper.rb` (or `rails_helper.rb`) and `WebMock.disable_net_connect!` (the default with `webmock/rspec`).
|
|
79
|
+
|
|
80
|
+
### Each case, as the SDK sees it
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
let(:client) { PaystackSdk::Client.new(secret_key: "sk_test_dummy", retry_interval: 0) }
|
|
84
|
+
|
|
85
|
+
# Success: initialize
|
|
86
|
+
stub_paystack(:post, "/transaction/initialize", paystack_ok(
|
|
87
|
+
{authorization_url: "https://checkout.paystack.com/abc", access_code: "abc", reference: "order-1042"},
|
|
88
|
+
message: "Authorization URL created"
|
|
89
|
+
))
|
|
90
|
+
response = client.transactions.initiate(email: "ama@example.com", amount: 5000, currency: "GHS", reference: "order-1042")
|
|
91
|
+
response.success? # => true
|
|
92
|
+
response.authorization_url # => "https://checkout.paystack.com/abc"
|
|
93
|
+
|
|
94
|
+
# Success: verify, then the check your app must make
|
|
95
|
+
stub_paystack(:get, "/transaction/verify/order-1042", paystack_ok(paystack_transaction, message: "Verification successful"))
|
|
96
|
+
client.transactions.verify(reference: "order-1042").paid?(amount: 5000, currency: "GHS") # => true
|
|
97
|
+
|
|
98
|
+
# 400 (and 404, 422): NOT raised, an unsuccessful Response
|
|
99
|
+
stub_paystack(:post, "/transaction/initialize", paystack_error("Duplicate Transaction Reference", code: "duplicate_reference"), status: 400)
|
|
100
|
+
response = client.transactions.initiate(email: "ama@example.com", amount: 5000, reference: "order-1042")
|
|
101
|
+
response.success? # => false
|
|
102
|
+
response.error_message # => "Duplicate Transaction Reference"
|
|
103
|
+
|
|
104
|
+
# 401: raises PaystackSdk::AuthenticationError ("Invalid key")
|
|
105
|
+
stub_paystack(:get, "/transaction/verify/x", paystack_error("Invalid key", code: "invalid_Key"), status: 401)
|
|
106
|
+
|
|
107
|
+
# 429: raises PaystackSdk::RateLimitError; retry_after comes from the x-ratelimit-reset header
|
|
108
|
+
stub_paystack(:get, "/transaction/verify/x", paystack_error("Rate limit exceeded"), status: 429, headers: {"x-ratelimit-reset" => "30"})
|
|
109
|
+
|
|
110
|
+
# 500: raises PaystackSdk::ServerError (status_code 500)
|
|
111
|
+
stub_paystack(:post, "/transaction/initialize", paystack_error("Internal server error"), status: 500)
|
|
112
|
+
|
|
113
|
+
# Timeout: raises PaystackSdk::TimeoutError
|
|
114
|
+
stub_paystack_timeout(:post, "/transaction/initialize")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Assert what you send, not only what you get back:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
stub = stub_paystack(:post, "/transaction/initialize", paystack_ok({reference: "order-1042"}))
|
|
121
|
+
client.transactions.initiate(email: "ama@example.com", amount: 5000, currency: "GHS", reference: "order-1042")
|
|
122
|
+
expect(stub.with(body: hash_including("amount" => 5000, "currency" => "GHS"))).to have_been_requested
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Shapes, and how they were established:
|
|
126
|
+
|
|
127
|
+
- **Observed on the test API 2026-10-09**: the 401, 400 and 404 bodies. 401 (bad key): `{"status":false,"message":"Invalid key","meta":{"nextStep":"Ensure that you provide the correct authorization key for the request"},"type":"validation_error","code":"invalid_Key"}`. 400 (`transaction/initialize` with email `not-an-email`): message `"Invalid Email Address Passed"`, `type` `"validation_error"`, `code` `"invalid_email_address"`. 404 (`transaction/verify` of an unknown reference): message `"Transaction reference not found."`, `code` `"transaction_not_found"`. The success shapes of initialize and verify were also observed; `paystack_transaction` is a trimmed version of the real verify `data`, not a full copy.
|
|
128
|
+
- **Not observed**: the real bodies of a 429 and a 500 (stubbed with the same envelope as the others, which is an assumption), and a real timeout. What the SDK does with them is checked by the gem's own specs; the status codes and the header are what matters.
|
|
129
|
+
- The SDK retries reads on network errors and 429/502/503/504, and writes only on 429. In tests pass `retry_interval: 0` (as above) so a stubbed 429 or timeout does not sleep. A 429 whose `x-ratelimit-reset` is more than 10 seconds is raised at once.
|
|
130
|
+
|
|
131
|
+
The SDK validates input before sending, so a stub cannot make it send a bad email or a missing field: `initiate(email: "x", ...)` raises `PaystackSdk::InvalidFormatError` and never reaches your stub. Stub a 400 for something only Paystack can know (a duplicate reference, an unknown customer).
|
|
132
|
+
|
|
133
|
+
### What to test in your own code
|
|
134
|
+
|
|
135
|
+
For each Paystack call your app makes, one spec per branch the app must handle: success; unsuccessful (`success?` false, 400); `AuthenticationError` (alert, do not retry); `RateLimitError`; `ServerError` and `TimeoutError` **on a write** (the app must verify by reference before retrying, never re-send); and "verify says not paid" (amount or currency mismatch: build it with `paystack_transaction(amount: 4000)`).
|
|
136
|
+
|
|
137
|
+
## An opt-in test-API suite
|
|
138
|
+
|
|
139
|
+
The gem's own `spec/sandbox/transactions_spec.rb` runs against Paystack's real test API only when `PAYSTACK_TEST_SECRET_KEY` is set, and is skipped otherwise. Do the same:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
RSpec.describe "Paystack test API", :paystack_sandbox do
|
|
143
|
+
key = ENV["PAYSTACK_TEST_SECRET_KEY"].to_s
|
|
144
|
+
|
|
145
|
+
before do
|
|
146
|
+
skip "set PAYSTACK_TEST_SECRET_KEY to an sk_test_ key" unless key.start_with?("sk_test_")
|
|
147
|
+
WebMock.allow_net_connect!
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
after { WebMock.disable_net_connect! }
|
|
151
|
+
|
|
152
|
+
let(:client) { PaystackSdk::Client.new(secret_key: key, sandbox_only: true) }
|
|
153
|
+
|
|
154
|
+
it "initializes and verifies a 100 pesewa payment" do
|
|
155
|
+
reference = "myapp-test-#{Time.now.to_i}-#{rand(100_000)}"
|
|
156
|
+
|
|
157
|
+
init = client.transactions.initiate(email: "test@example.com", amount: 100, currency: "GHS", reference: reference)
|
|
158
|
+
expect(init).to be_success
|
|
159
|
+
expect(init.reference).to eq(reference)
|
|
160
|
+
|
|
161
|
+
verified = client.transactions.verify(reference: reference)
|
|
162
|
+
expect(verified.status).to eq("abandoned") # nobody paid it
|
|
163
|
+
expect(verified.paid?(amount: 100, currency: "GHS")).to be(false)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- Skip when the key is unset or is not `sk_test_`; never fall back to `PAYSTACK_SECRET_KEY`, which on a developer machine may be live.
|
|
169
|
+
- Amounts of 100 pesewas (GHS 1.00). A **unique reference** per run: observed on the test API 2026-10-09, initializing a reference twice returns an unsuccessful Response, `Duplicate Transaction Reference` (`duplicate_reference`), the second time.
|
|
170
|
+
- Keep it out of the default run (tag it and exclude the tag, or run only `rspec spec/paystack_sandbox`). Each run leaves a test-mode transaction on the account (an abandoned one, in the example above).
|
|
171
|
+
- Do not call endpoints that publish, register, activate, notify or pay out from the suite (see Keys).
|
|
172
|
+
|
|
173
|
+
## Paystack's published test data
|
|
174
|
+
|
|
175
|
+
Documented on Paystack's Test Payments page (https://paystack.com/docs/payments/test-payments/), not observed by this gem except where marked. Expiry can be any future date (the page shows 09/27).
|
|
176
|
+
|
|
177
|
+
| Card | Number | CVV | Extra steps |
|
|
178
|
+
|---|---|---|---|
|
|
179
|
+
| No validation, reusable | 4084 0840 8408 4081 | 408 | none |
|
|
180
|
+
| PIN | 5078 5078 5078 5078 12 | 081 | PIN 1111 |
|
|
181
|
+
| PIN + OTP | 5060 6666 6666 6666 666 | 123 | PIN 1234, OTP 123456 |
|
|
182
|
+
| PIN + phone + OTP | 5078 5078 5078 5078 04 | 884 | PIN 0000, OTP 123456 |
|
|
183
|
+
|
|
184
|
+
The page also lists a declined card (4084 0800 0000 5408, CVV 001), a card where no token is generated (5078 5078 5078 5078 53, CVV 082, PIN 0000), and cards that decide how a **refund** ends (failed 4084 0800 0067 1803 CVV 180; needs attention 4084 0800 0067 1902 CVV 190). Not exercised by this gem.
|
|
185
|
+
|
|
186
|
+
- Cards are entered on Paystack's checkout page (from `initiate`'s `authorization_url`). Through the API a card goes through the raw connection (`client.connection.post("/charge", ...)` with a `card` object) because `charges.create` has no card keyword. A real person's card number never belongs in your app's servers or logs; test numbers are the only card numbers in tests.
|
|
187
|
+
- **Mobile money**: MTN test number `0551234987` (documented as "No PIN/OTP"). Observed on the test API 2026-10-09: `charges.mobile_money(... phone: "0551234987", provider: "mtn", currency: "GHS", amount: 100)` answered `Charge attempted` with status `success` at once, and `verify` then showed `success` and `paid?` true. This proves your code handles the happy path. **It proves nothing about live mobile money**, where the payer gets a prompt and the status is `pay_offline`/`send_pin`/`send_otp` for a while. See the [[paystack-sdk-mobile-money]] and [[paystack-sdk-charge-statuses]] skills, and drive those statuses with stubs.
|
|
188
|
+
- The page's bank accounts (Zenith, Kuda), EFT accounts, M-PESA and Orange numbers are for Nigeria, Kenya or Cote d'Ivoire; skip them for Ghana.
|
|
189
|
+
|
|
190
|
+
## Testing a webhook endpoint
|
|
191
|
+
|
|
192
|
+
Paystack signs the raw body with HMAC SHA512 of your secret key in `x-paystack-signature`. In a test, build a body, sign it with `PaystackSdk::Webhook.sign`, and post it to your endpoint with that header:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
secret = "sk_test_dummy"
|
|
196
|
+
body = {event: "charge.success", data: {id: 1, reference: "order-1042", status: "success", amount: 5000, currency: "GHS"}}.to_json
|
|
197
|
+
signature = PaystackSdk::Webhook.sign(body, secret)
|
|
198
|
+
|
|
199
|
+
event = PaystackSdk::Webhook.construct_event(payload: body, signature: signature, secret: secret)
|
|
200
|
+
event.event # => "charge.success"
|
|
201
|
+
event.data.reference # => "order-1042"
|
|
202
|
+
|
|
203
|
+
PaystackSdk::Webhook.valid_signature?(payload: body + " ", signature: signature, secret: secret) # => false: one changed byte
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Send exactly the string you signed (do not post a hash that the test framework re-serialises), assert a wrong or missing signature is rejected, and assert that the same event posted twice grants value once (Paystack retries events your server does not acknowledge, so the same event can arrive again; Paystack documents no event id, so dedupe on the event name plus `data.id` or `data.reference`). The endpoint should still **verify by reference** with the API rather than trust the body (stub `verify` with `stub_paystack`).
|
|
207
|
+
|
|
208
|
+
## What test mode cannot prove
|
|
209
|
+
|
|
210
|
+
| Not proven by test mode | What to do instead |
|
|
211
|
+
|---|---|
|
|
212
|
+
| Live mobile money: the prompt, the wait, the payer declining or timing out | Stub every non-final status; then one supervised live payment of the smallest real amount, with a refund |
|
|
213
|
+
| Webhooks actually arriving at your server (public URL, TLS, firewall, the three Paystack IPs, your response time) | Set the test webhook URL on the dashboard and make a test payment; or POST a signed body to your own endpoint |
|
|
214
|
+
| Settlements and payouts to a bank or wallet | Check the first live settlement by hand |
|
|
215
|
+
| Real declines, insufficient funds, fraud checks, bank 3DS | Stub the failure bodies; the docs list a declined test card, which is only a simulation |
|
|
216
|
+
| Live-only configuration (live key, live webhook URL, live callback URL, account activation) | Review by hand before launch; `sandbox_only: true` cannot catch a wrong key in production |
|
|
217
|
+
|
|
218
|
+
Not verified by this gem: any behaviour with a live key (it has never been exercised with one), the real 429 and 500 bodies, and the test-card flows through checkout. Say so rather than asserting them.
|
|
219
|
+
|
|
220
|
+
## Rules
|
|
221
|
+
|
|
222
|
+
1. Test and CI use `sk_test_` keys and `sandbox_only: true`; a live key never goes in a test environment.
|
|
223
|
+
2. Stub at HTTP with WebMock; do not mock `PaystackSdk::Client` or its resources.
|
|
224
|
+
3. Test the unhappy branches of every call: unsuccessful `Response`, 401, 429, 5xx, timeout, and "verify says not paid".
|
|
225
|
+
4. After a write times out or returns 5xx, the test expects a verify by reference, not a second write.
|
|
226
|
+
5. Test-API runs: opt in by environment variable, `sk_test_` only, 100 pesewas, unique references, nothing that publishes or pays out.
|
|
227
|
+
6. Passing test mode is not a launch check for mobile money, webhooks or settlements.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: paystack-sdk-webhooks
|
|
3
|
+
description: 'Use when building or reviewing the endpoint that receives Paystack webhooks (charge.success, refund.*, transfer.*, subscription.*, invoice.*, dispute.*) in a Rails or Rack app: verifying the x-paystack-signature header with PaystackSdk::Webhook, reading the raw request body, skipping CSRF, answering 200 fast and working in a background job, handling duplicate or out-of-order deliveries, and testing the endpoint with a signed request. Also use when checking whether Paystack delivered a webhook, or replaying one, with client.webhook_events.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Receiving Paystack webhooks
|
|
7
|
+
|
|
8
|
+
A webhook is Paystack POSTing a JSON event to a URL you register on the Paystack dashboard. `PaystackSdk::Webhook` checks the signature and parses the event. It does not know your framework, your database or your jobs.
|
|
9
|
+
|
|
10
|
+
What is executed and what is not: every Ruby block marked "runs in the spec" is run by `spec/skills_webhooks_spec.rb`. The Rails blocks are not run (the gem has no Rails dependency); they use ordinary Rails methods and are marked. Facts from Paystack's Webhooks page (payments/webhooks) are labelled "documented". The payload shapes below were observed in Paystack's own event log (the Webhook Events API) on a test integration: they are what Paystack recorded as sent. No webhook was received by code written for this skill: the test API cannot be made to send one to a URL we control.
|
|
11
|
+
|
|
12
|
+
## The signature rule (documented)
|
|
13
|
+
|
|
14
|
+
Paystack sends the header `x-paystack-signature`: the HMAC SHA512, as lowercase hex, of the event payload signed with your secret key. Verify it **before** doing anything with the event, over the **raw request body, byte for byte**. A body that was parsed and re-serialised does not match.
|
|
15
|
+
|
|
16
|
+
| Call | Returns | Raises |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `Webhook.valid_signature?(payload:, signature:, secret:)` | `true` / `false` (constant-time compare). A `nil`, empty, truncated or upper-case signature is `false` | `ArgumentError` if `payload` is not a String (a parsed Hash, for example) or `secret` is blank |
|
|
19
|
+
| `Webhook.verify!(payload:, signature:, secret:)` | `true` | `PaystackSdk::InvalidSignatureError`; also the `ArgumentError`s above |
|
|
20
|
+
| `Webhook.construct_event(payload:, signature:, secret:)` | a `Webhook::Event` (`event`, `payload`, `data`, `known?`) | `InvalidSignatureError` (nothing is parsed first), `InvalidPayloadError` if the signed body is not a JSON object with a non-empty string `"event"`; also the `ArgumentError`s |
|
|
21
|
+
| `Webhook.sign(payload, secret)` | the lowercase hex signature, for your own tests | |
|
|
22
|
+
|
|
23
|
+
`InvalidSignatureError` and `InvalidPayloadError` are both `PaystackSdk::WebhookError`, which is a `PaystackSdk::Error`. `event.data` is the event's `data` wrapped for dot access (`event.data.reference`, `event.data[:amount]`), or `nil` if the event carries none, or an array-like when `data` is an array (`subscription.expiring_cards`). `event.payload` is the string-keyed Hash.
|
|
24
|
+
|
|
25
|
+
Use `construct_event` unless you only need a yes/no. Rescue `PaystackSdk::WebhookError`, not `Error`, so a real SDK problem is not swallowed. The `ArgumentError`s are programming errors (blank secret): let them raise.
|
|
26
|
+
|
|
27
|
+
## Rails controller (not run in the spec; standard Rails methods)
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class PaystackWebhooksController < ApplicationController
|
|
31
|
+
# The request comes from Paystack's servers: there is no session and no CSRF token.
|
|
32
|
+
skip_before_action :verify_authenticity_token
|
|
33
|
+
# Also skip any login filter your ApplicationController adds (authenticate_user! and the like).
|
|
34
|
+
|
|
35
|
+
def create
|
|
36
|
+
event = PaystackSdk::Webhook.construct_event(
|
|
37
|
+
payload: request.raw_post, # the raw body, never params or request.body.read after parsing
|
|
38
|
+
signature: request.headers["X-Paystack-Signature"],
|
|
39
|
+
secret: Rails.application.credentials.paystack_secret_key
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Insert-or-ignore, then enqueue only the first time (see "Duplicates" below).
|
|
43
|
+
if PaystackEvent.claim(event)
|
|
44
|
+
HandlePaystackEventJob.perform_later(event.payload)
|
|
45
|
+
end
|
|
46
|
+
head :ok
|
|
47
|
+
rescue PaystackSdk::WebhookError
|
|
48
|
+
head :bad_request
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# config/routes.rb
|
|
55
|
+
post "/paystack/webhook", to: "paystack_webhooks#create"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Rules for this action: read `request.raw_post`; never pass `params` or a re-generated JSON string; keep the secret key in credentials or ENV and out of logs; do not log the whole payload at info level (it holds customer emails and card metadata). Use the secret key of the same Paystack environment that is calling: a test key verifies test events, a live key live events (the signature is made with the key of the account and mode that sent it; documented as "signed using your secret key"; which of your keys signs which mode was not tested here).
|
|
59
|
+
|
|
60
|
+
## A framework-free receiver (runs in the spec)
|
|
61
|
+
|
|
62
|
+
This is the same logic with no Rails: a receiver returning an HTTP status, and a Rack app around it. `claim` is your idempotency store (next section); here it is any object whose `claim(key)` returns true the first time it sees a key.
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class PaystackReceiver
|
|
66
|
+
def initialize(secret:, store:, enqueue:, allowed_ips: nil)
|
|
67
|
+
@secret, @store, @enqueue, @allowed_ips = secret, store, enqueue, allowed_ips
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def call(raw_body:, signature:, ip: nil)
|
|
71
|
+
return 403 if @allowed_ips && !@allowed_ips.include?(ip)
|
|
72
|
+
|
|
73
|
+
event = PaystackSdk::Webhook.construct_event(payload: raw_body, signature: signature, secret: @secret)
|
|
74
|
+
@enqueue.call(event.payload) if @store.claim(self.class.key(event))
|
|
75
|
+
200
|
|
76
|
+
rescue PaystackSdk::WebhookError
|
|
77
|
+
400
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# event name + data.id, else event name + data.reference
|
|
81
|
+
def self.key(event)
|
|
82
|
+
data = event.payload["data"]
|
|
83
|
+
data = data.first if data.is_a?(Array)
|
|
84
|
+
id = data.is_a?(Hash) ? (data["id"] || data["reference"]) : nil
|
|
85
|
+
[event.event, id].join(":")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class PaystackWebhookApp
|
|
90
|
+
def initialize(receiver)
|
|
91
|
+
@receiver = receiver
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def call(env)
|
|
95
|
+
input = env["rack.input"]
|
|
96
|
+
body = input.read
|
|
97
|
+
input.rewind
|
|
98
|
+
status = @receiver.call(raw_body: body, signature: env["HTTP_X_PAYSTACK_SIGNATURE"], ip: env["REMOTE_ADDR"])
|
|
99
|
+
[status, {"content-type" => "text/plain"}, [""]]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Pass `allowed_ips: PaystackSdk::Webhook::IP_ADDRESSES` to turn the IP check on. A bad signature gets 400 here (and in the gem's own example); 401 or 403 would also be reasonable, Paystack only needs a non-200 to know it was not accepted. Never answer 200 to an event you rejected.
|
|
105
|
+
|
|
106
|
+
## Answer fast, work later (documented)
|
|
107
|
+
|
|
108
|
+
Paystack: return `200 OK` immediately; long-running work in the webhook function leads to a timeout and an automatic error response, and without a 200 the event is retried. Documented retry policy: **live mode**, every 3 minutes for the first 4 tries, then hourly for the next 72 hours; **test mode**, hourly for 10 hours, with a 30 second timeout per attempt. A response that is not 200 (including a timeout) is "a failed attempt". So: verify the signature, store or enqueue, answer 200, and do the real work in a job (`HandlePaystackEventJob` above). Not observed here: the schedule was not seen in action.
|
|
109
|
+
|
|
110
|
+
Paystack also documents a Webhook Events API (list, look up and resend events to your webhook URL). `paystack_sdk` has no resource for it as of this version; call it through `client.connection` only after reading its docs page.
|
|
111
|
+
|
|
112
|
+
## Duplicates and out-of-order delivery (design advice, not Paystack behaviour)
|
|
113
|
+
|
|
114
|
+
Retries mean the same event can reach you more than once, and a retry of an old event can land after a newer one. The Webhooks page documents no event id. So:
|
|
115
|
+
|
|
116
|
+
- **Dedupe key**: event name + `data.id`. Observed (event log, test integration): every payload is `{"event": ..., "data": {...}}` with **no event id at the top level**, and `data.id` was present in every payload seen (`charge.success`, `refund.pending`, `refund.processed`, `product.create`, `product.update`, `product.delete`, `subscription.create`, `subscription.not_renew`). `data.reference` exists only on transaction events (`charge.success`); refund events carry `data.transaction_reference` and `data.refund_reference` instead. So a refund that goes `refund.pending` then `refund.processed` has the same `data.id` and two different event names: the name has to be in the key. Fall back to `data.reference` only for an event type you have checked, and an event with neither collapses to its name alone, so check the payload of each event type you handle (the Webhook Events API below shows exactly what Paystack sent).
|
|
117
|
+
- **Store the key with a unique index** and insert before you act:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# Rails migration and model: design advice, not run in the spec
|
|
121
|
+
class CreatePaystackEvents < ActiveRecord::Migration[7.1]
|
|
122
|
+
def change
|
|
123
|
+
create_table :paystack_events do |t|
|
|
124
|
+
t.string :key, null: false
|
|
125
|
+
t.string :event, null: false
|
|
126
|
+
t.datetime :processed_at
|
|
127
|
+
t.timestamps
|
|
128
|
+
end
|
|
129
|
+
add_index :paystack_events, :key, unique: true
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class PaystackEvent < ApplicationRecord
|
|
134
|
+
# true only for the delivery that created the row
|
|
135
|
+
def self.claim(event)
|
|
136
|
+
create!(key: PaystackReceiver.key(event), event: event.event)
|
|
137
|
+
true
|
|
138
|
+
rescue ActiveRecord::RecordNotUnique
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **The job must be safe to run twice** anyway: it re-reads the current state, does its change in a transaction, and sets `processed_at`. A crash between claim and enqueue loses nothing if the claim and the enqueue share a transaction (a database-backed queue) or the job also checks `processed_at`; with a Redis queue, choose which failure you prefer (a lost event, or a duplicate job) and make the job idempotent.
|
|
145
|
+
- **Out of order**: do not apply an event as a state change from the payload alone ("set status to what the event says"). Fetch the current state from Paystack and apply that (below), so an old event cannot overwrite a newer one.
|
|
146
|
+
|
|
147
|
+
## Never grant value from the payload alone
|
|
148
|
+
|
|
149
|
+
A valid signature proves the event came from Paystack. It does not prove the amount, the currency or that the payment is still good. For anything that moves value (marking an invoice paid, giving a membership, crediting a wallet), the job does what a return URL would do: verify by reference and compare to what you expected.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# inside HandlePaystackEventJob, for "charge.success"
|
|
153
|
+
reference = payload.dig("data", "reference")
|
|
154
|
+
response = client.transactions.verify(reference: reference)
|
|
155
|
+
if response.paid?(amount: expected_amount_in_pesewas, currency: "GHS")
|
|
156
|
+
# grant value once; look the order up by reference, not by anything in the payload
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`expected_amount_in_pesewas` comes from your own record of the order, not from the event. See [[paystack-sdk-payments]] (accepting a payment and verifying it), [[paystack-sdk-overview]] and [[paystack-sdk-charge-statuses]]. Unknown reference, `paid?` false, or a mismatched amount: log it and do not grant.
|
|
161
|
+
|
|
162
|
+
## Which events (documented list = `Webhook::EVENTS`)
|
|
163
|
+
|
|
164
|
+
`event.known?` is true for these 29. Paystack says it adds events over time, so an unknown name is still returned by `construct_event`: log it and answer 200.
|
|
165
|
+
|
|
166
|
+
| Group | Events |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Payments | `charge.success` (a successful charge was made) |
|
|
169
|
+
| Refunds | `refund.pending` (initiated, waiting for the processor), `refund.processing` (received by the processor), `refund.processed` (done), `refund.failed` (cannot be processed; your account is credited with the refund amount), `refund.needs-attention` (named in Paystack's Refunds guide, not on the Webhooks page; the retry endpoint is for this state) |
|
|
170
|
+
| Transfers | `transfer.success`, `transfer.failed`, `transfer.reversed` |
|
|
171
|
+
| Subscriptions | `subscription.create`, `subscription.disable`, `subscription.not_renew` (status changed to non-renewing; will not be charged on the next payment date), `subscription.expiring_cards` (all subscriptions with cards expiring that month; sent at the start of the month) |
|
|
172
|
+
| Invoices | `invoice.create` (usually 3 days before the subscription is due), `invoice.update` (usually means the customer was charged; inspect the invoice object), `invoice.payment_failed` |
|
|
173
|
+
| Disputes | `charge.dispute.create`, `charge.dispute.remind`, `charge.dispute.resolve` |
|
|
174
|
+
| Payment requests | `paymentrequest.pending`, `paymentrequest.success`, `paymentrequest.draft` (not on the Webhooks page; seen in an event log) |
|
|
175
|
+
| Products | `product.create`, `product.update`, `product.delete` (not on the Webhooks page; seen in an event log) |
|
|
176
|
+
| Customer identification | `customeridentification.success`, `customeridentification.failed` |
|
|
177
|
+
| Dedicated accounts | `dedicatedaccount.assign.success`, `dedicatedaccount.assign.failed` |
|
|
178
|
+
|
|
179
|
+
Payload shapes (observed in the event log of a test integration; `data` keys listed by event type):
|
|
180
|
+
|
|
181
|
+
| Event | `data` carries |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `charge.success` | `id`, `reference`, `status`, `amount`, `currency`, `paid_at`, `channel`, `gateway_response`, `metadata`, `fees`, `authorization` (the reusable `authorization_code` and card details), `customer`, `plan`, `subaccount`, `split` and more |
|
|
184
|
+
| `refund.pending`, `refund.processed` | `id` (the refund's), `status`, `transaction_reference`, `refund_reference`, `amount`, `currency`, `customer`, `integration`, `domain`, `customer_note`, `merchant_note` (no `reference`) |
|
|
185
|
+
| `subscription.create`, `subscription.not_renew` | `id`, `status`, `subscription_code`, `email_token`, `amount`, `next_payment_date`, `plan`, `authorization`, `customer` |
|
|
186
|
+
| `product.create`, `product.update`, `product.delete` | `id`, `product_code`, `name`, `price`, `currency`, `quantity`, `active` |
|
|
187
|
+
|
|
188
|
+
Everything above is a record of what Paystack sent to a test integration, not what your endpoint will see in live mode. Not observed: the payloads of the other events, and live payloads. The documented sample for `customeridentification.failed` has `customer_id`, `customer_code`, `email`, `identification` and `reason`. Still verify by reference instead of trusting any of these fields for money (below).
|
|
189
|
+
|
|
190
|
+
## See what Paystack sent, and replay it: `client.webhook_events`
|
|
191
|
+
|
|
192
|
+
The Webhook Events API (documented by Paystack; not in its OpenAPI spec) is the log of every webhook Paystack tried to send you. It answers "did my endpoint get it?" without guessing.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
client.webhook_events.list(status: "Failed", limit: 20) # Delivered, Pending or Failed
|
|
196
|
+
client.webhook_events.list(category: "transactions", event_type: "charge.success")
|
|
197
|
+
client.webhook_events.lookup(id: 6_641_907_106) # by a transaction id, or by an event _id
|
|
198
|
+
event = client.webhook_events.fetch(id: "6ac94b47fdad55440ef2066a")
|
|
199
|
+
event.status_detail # "retrying"
|
|
200
|
+
event.response_code # what your endpoint answered, e.g. 404
|
|
201
|
+
event.merchant_response_body # the body your endpoint answered with
|
|
202
|
+
event.event_payload[:data] # exactly what Paystack sent (brackets: Response#data is a method)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Observed on the test API: a list page holds 50 events (the docs say 20), newest first; `meta.next` is a cursor you pass back as `next_cursor:` (not together with `previous:`); an event is `Pending` with `status_detail` `retrying` while Paystack still retries, and `Failed` once it gave up; the event's `_id` is a MongoDB-style id, so it is the one id Paystack gives an event, although it is not in the payload you receive. A `Failed` list on a test integration whose URL returned 404 showed `response_code` 404 for every event, which is how you find a broken endpoint.
|
|
206
|
+
|
|
207
|
+
`resend(ids:)` and `resend_matching(preview:, filters:)` make Paystack deliver events again. **They were never called against the test API** (they would hit your endpoint), the docs say resending is not idempotent, and `resend_matching` has no default for `preview:` on purpose: call it with `preview: true` first, and make sure your endpoint dedupes before you resend anything.
|
|
208
|
+
|
|
209
|
+
## Test your endpoint locally
|
|
210
|
+
|
|
211
|
+
Paystack cannot reach localhost (documented: "localhost URLs can't receive events"), and this skill's authors could not make the Paystack test API send a real webhook. So test the endpoint yourself by signing a body with the same secret and posting it. This is what the spec runs, against the Rack app above:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
secret = "sk_test_example" # a placeholder, never a real key
|
|
215
|
+
body = {event: "charge.success", data: {id: 302961, reference: "ref-1"}}.to_json
|
|
216
|
+
signature = PaystackSdk::Webhook.sign(body, secret)
|
|
217
|
+
# then POST `body` (exactly this string) with the header X-Paystack-Signature: signature
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
In a Rails request spec (not run here) post the string, not a Hash, so the bytes are what you signed:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
post "/paystack/webhook", params: body, headers: {"CONTENT_TYPE" => "application/json", "X-Paystack-Signature" => signature}
|
|
224
|
+
expect(response).to have_http_status(:ok)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Cases to cover: valid signature (200 and a job enqueued), body changed after signing (400, no job), missing header (400), signature made with another key (400), the same delivery twice (200 both times, one job).
|
|
228
|
+
|
|
229
|
+
To try a real delivery you need a public HTTPS URL (a tunnel, or a staging host) registered as the webhook URL on the dashboard, and an action that fires an event. That was not done for this skill.
|
|
230
|
+
|
|
231
|
+
## Behind a proxy: the IP allow-list (design advice)
|
|
232
|
+
|
|
233
|
+
`Webhook.trusted_ip?(ip)` is true only for the three addresses Paystack documents for both test and live (`52.31.139.75`, `52.49.173.169`, `52.214.14.220`). Paystack lists signature validation and IP allow-listing as the two ways to verify origin. Treat the IP check as a second layer next to the signature, never instead of it.
|
|
234
|
+
|
|
235
|
+
Behind a load balancer or CDN the socket address (`REMOTE_ADDR`) is the proxy, not Paystack. In Rails `request.remote_ip` resolves the client address through the proxies Rails is configured to trust; if the trusted-proxy setting is wrong, either everything is rejected or a spoofed `X-Forwarded-For` is believed. Not tested here. If you cannot get the client IP right, skip the IP check and rely on the signature, or enforce the allow-list at the firewall/load balancer instead of in Ruby.
|
|
236
|
+
|
|
237
|
+
## Rules
|
|
238
|
+
|
|
239
|
+
- **Verify the signature on the raw body first.** `request.raw_post` in Rails, `rack.input` in Rack. Never `params`.
|
|
240
|
+
- **Skip CSRF and login filters on this one action**, and nowhere else.
|
|
241
|
+
- **Respond 200 quickly**; do the work in a background job.
|
|
242
|
+
- **Reject with a non-2xx** (400) when the signature or payload is bad.
|
|
243
|
+
- **Dedupe** on event name + `data.id` / `data.reference` with a unique index, and make the job idempotent.
|
|
244
|
+
- **Verify by reference and check `paid?(amount:, currency:)`** against your own record before granting value.
|
|
245
|
+
- **Handle unknown events** by logging and answering 200.
|
|
246
|
+
- **Keep the secret key out of code, logs and the repo.**
|
|
247
|
+
|
|
248
|
+
## Not verified
|
|
249
|
+
|
|
250
|
+
No webhook was received by code written for this skill. Unverified: that the signature is made with the key of the mode that sent the event, the payloads of every event not listed in the table above, live payloads, the live retry schedule in practice, how your proxy reports the client IP, and `resend` and `resend_matching` (never called). The retry schedule, header name, signature algorithm and IP list are as stated on Paystack's Webhooks documentation page.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
|
|
7
|
+
module PaystackSdk
|
|
8
|
+
# The AI skills the gem ships (Claude Code `SKILL.md` folders under `lib/paystack_sdk/skills`) and the
|
|
9
|
+
# installer that copies them into a project's `.claude/skills/`.
|
|
10
|
+
#
|
|
11
|
+
# Installing is safe to repeat and safe next to skills of your own:
|
|
12
|
+
#
|
|
13
|
+
# * Every folder it writes carries a `.paystack_sdk.json` marker. Only marked folders are ever updated or
|
|
14
|
+
# removed, so a gem upgrade changes the skills it installed and nothing else.
|
|
15
|
+
# * A folder with the same name that it did not install is never overwritten (it is reported as a
|
|
16
|
+
# `conflict`) unless you pass `force: true`.
|
|
17
|
+
# * Each installed `SKILL.md` is stamped with the gem version it was installed from.
|
|
18
|
+
module Skills
|
|
19
|
+
# Where the skills live inside the gem.
|
|
20
|
+
SOURCE_DIR = File.expand_path("skills", __dir__)
|
|
21
|
+
|
|
22
|
+
# Marker file written into every folder this gem installs.
|
|
23
|
+
MARKER = ".paystack_sdk.json"
|
|
24
|
+
|
|
25
|
+
# A skill shipped in the gem.
|
|
26
|
+
Skill = Struct.new(:name, :description, :path, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# What installing did to one folder: status is one of create, update, identical, remove, conflict, force.
|
|
29
|
+
Result = Struct.new(:status, :name, :note, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @return [Array<Skill>] the skills shipped in the gem, sorted by name
|
|
33
|
+
def all
|
|
34
|
+
Dir.children(SOURCE_DIR).sort.filter_map do |name|
|
|
35
|
+
path = File.join(SOURCE_DIR, name)
|
|
36
|
+
file = File.join(path, "SKILL.md")
|
|
37
|
+
next unless File.directory?(path) && File.file?(file)
|
|
38
|
+
|
|
39
|
+
Skill.new(name: name, description: frontmatter(File.read(file))["description"].to_s, path: path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Copies the skills into +dir+ (a `.claude/skills` directory).
|
|
44
|
+
#
|
|
45
|
+
# @param dir [String] the directory to install into; created if missing
|
|
46
|
+
# @param dry_run [Boolean] report what would happen and write nothing
|
|
47
|
+
# @param force [Boolean] replace a same-named folder that this gem did not install
|
|
48
|
+
# @return [Array<Result>]
|
|
49
|
+
def install(dir, dry_run: false, force: false)
|
|
50
|
+
skills = all
|
|
51
|
+
results = skills.map { |skill| install_skill(skill, dir, dry_run: dry_run, force: force) }
|
|
52
|
+
results + remove_stale(dir, skills.map(&:name), dry_run: dry_run)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Removes every folder this gem installed into +dir+, and nothing else.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Result>]
|
|
58
|
+
def uninstall(dir, dry_run: false)
|
|
59
|
+
owned_dirs(dir).map do |name|
|
|
60
|
+
FileUtils.rm_rf(File.join(dir, name)) unless dry_run
|
|
61
|
+
Result.new(status: "remove", name: name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The files a skill folder holds once installed: relative path => content. `SKILL.md` gets the gem
|
|
66
|
+
# version stamped into its frontmatter.
|
|
67
|
+
#
|
|
68
|
+
# @param skill [Skill]
|
|
69
|
+
# @return [Hash{String => String}]
|
|
70
|
+
def files_for(skill)
|
|
71
|
+
files = Dir.glob("**/*", base: skill.path).select { |f| File.file?(File.join(skill.path, f)) }.sort
|
|
72
|
+
content = files.to_h { |f| [f, File.read(File.join(skill.path, f))] }
|
|
73
|
+
content["SKILL.md"] = stamp(content.fetch("SKILL.md"))
|
|
74
|
+
content[MARKER] = marker_json
|
|
75
|
+
content
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parses the `key: value` lines of a SKILL.md's frontmatter (single-level; quotes are stripped).
|
|
79
|
+
#
|
|
80
|
+
# @param text [String]
|
|
81
|
+
# @return [Hash{String => String}]
|
|
82
|
+
def frontmatter(text)
|
|
83
|
+
block = text[/\A---\n(.*?)\n---\n/m, 1] or return {}
|
|
84
|
+
block.lines.each_with_object({}) do |line, found|
|
|
85
|
+
key, value = line.chomp.match(/\A([A-Za-z_][\w-]*):\s*(.*)\z/)&.captures
|
|
86
|
+
next unless key
|
|
87
|
+
|
|
88
|
+
value = value.strip
|
|
89
|
+
value = value[1..-2].gsub("''", "'") if value.match?(/\A'.*'\z/m)
|
|
90
|
+
value = value[1..-2] if value.match?(/\A".*"\z/m)
|
|
91
|
+
found[key] = value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def install_skill(skill, dir, dry_run:, force:)
|
|
98
|
+
dest = File.join(dir, skill.name)
|
|
99
|
+
wanted = files_for(skill)
|
|
100
|
+
|
|
101
|
+
if !File.exist?(dest)
|
|
102
|
+
write(dest, wanted) unless dry_run
|
|
103
|
+
Result.new(status: "create", name: skill.name)
|
|
104
|
+
elsif owned?(dest)
|
|
105
|
+
return Result.new(status: "identical", name: skill.name) if current_files(dest) == wanted
|
|
106
|
+
|
|
107
|
+
write(dest, wanted) unless dry_run
|
|
108
|
+
Result.new(status: "update", name: skill.name)
|
|
109
|
+
elsif force
|
|
110
|
+
write(dest, wanted) unless dry_run
|
|
111
|
+
Result.new(status: "force", name: skill.name, note: "replaced a folder paystack_sdk did not install")
|
|
112
|
+
else
|
|
113
|
+
Result.new(status: "conflict", name: skill.name,
|
|
114
|
+
note: "a folder with this name exists that paystack_sdk did not install; left alone (use --force to replace it)")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def remove_stale(dir, current, dry_run:)
|
|
119
|
+
(owned_dirs(dir) - current).map do |name|
|
|
120
|
+
FileUtils.rm_rf(File.join(dir, name)) unless dry_run
|
|
121
|
+
Result.new(status: "remove", name: name, note: "no longer shipped with this version")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def owned_dirs(dir)
|
|
126
|
+
return [] unless File.directory?(dir)
|
|
127
|
+
|
|
128
|
+
Dir.children(dir).sort.select { |name| owned?(File.join(dir, name)) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def owned?(path)
|
|
132
|
+
file = File.join(path, MARKER)
|
|
133
|
+
File.directory?(path) && File.file?(file) && JSON.parse(File.read(file))["gem"] == "paystack_sdk"
|
|
134
|
+
rescue JSON::ParserError
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def current_files(dest)
|
|
139
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: dest).select { |f| File.file?(File.join(dest, f)) }.sort
|
|
140
|
+
.to_h { |f| [f, File.read(File.join(dest, f))] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def write(dest, files)
|
|
144
|
+
FileUtils.rm_rf(dest)
|
|
145
|
+
files.each do |relative, content|
|
|
146
|
+
path = File.join(dest, relative)
|
|
147
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
148
|
+
File.write(path, content)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def marker_json
|
|
153
|
+
"#{JSON.pretty_generate("gem" => "paystack_sdk", "version" => VERSION)}\n"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Adds `metadata: gem / gem_version` to the frontmatter so an agent (and a person) can see which gem
|
|
157
|
+
# version the skill describes.
|
|
158
|
+
def stamp(text)
|
|
159
|
+
text.sub(/\A---\n(.*?)\n---\n/m) do
|
|
160
|
+
"---\n#{$1}\nmetadata:\n gem: paystack_sdk\n gem_version: \"#{VERSION}\"\n---\n"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|