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.
@@ -0,0 +1,199 @@
1
+ ---
2
+ name: paystack-sdk-payments
3
+ description: 'Use when building or reviewing the hosted checkout flow with paystack_sdk: starting a payment with client.transactions.initiate, redirecting the payer to authorization_url, handling the callback_url return, confirming with transactions.verify and paid?, reading a verify status such as success, abandoned or failed, making confirmation safe when the callback and the webhook arrive together, deciding which transaction fields to store, or looking transactions up with fetch and list.'
4
+ ---
5
+
6
+ # Accepting a payment with the hosted checkout
7
+
8
+ The payer pays on Paystack's checkout page, not on yours. Your server starts the transaction, sends the payer to Paystack, and later asks Paystack what happened. Nothing the browser brings back counts as proof. Read [[paystack-sdk-overview]] first for the client, `Response` and error conventions.
9
+
10
+ ## The flow
11
+
12
+ 1. Create your own payment row with a new `reference`, an `amount` in pesewas and a `currency`, status `pending`. Save it **before** calling Paystack.
13
+ 2. `client.transactions.initiate(...)` with that reference. Redirect the payer to `response.authorization_url`.
14
+ 3. The payer comes back to your `callback_url`. Treat that request as "please check", nothing more.
15
+ 4. `client.transactions.verify(reference:)` and give value only if `paid?(amount: payment.amount, currency: payment.currency)` is true.
16
+ 5. Do the same in the `charge.success` webhook handler. Steps 4 and 5 run the same idempotent code (see "Confirming twice is normal").
17
+
18
+ ## Start the payment: `transactions.initiate`
19
+
20
+ | Keyword | What to pass |
21
+ |---|---|
22
+ | `email:` (required) | The payer's email. The SDK refuses a malformed one before sending |
23
+ | `amount:` (required) | A positive Integer in the subunit: `5000` is GHS 50.00. `0`, `50.0` and `"5000"` raise `PaystackSdk::InvalidValueError` before any request |
24
+ | `currency:` | Always pass it (`"GHS"`). The SDK accepts `GHS`, `KES`, `NGN`, `ZAR`, `USD`. Left out, Paystack uses the integration currency (docs); ours came back `GHS` |
25
+ | `reference:` | Yours, unique, stored first. The SDK accepts letters, digits and `-` `.` `=` `_`. Use letters, digits and `-` only: Paystack's docs list `-`, `.`, `=` and alphanumerics (an `_` was accepted on the test API, but it is not documented) |
26
+ | `callback_url:` | Where Paystack sends the payer after paying. It overrides the dashboard setting; with neither set, the payer is not sent back (docs) |
27
+ | `channels:` | An Array limiting the checkout, e.g. `["card", "mobile_money"]`. The docs list `card`, `bank`, `apple_pay`, `ussd`, `qr`, `mobile_money`, `bank_transfer`, `eft`, `capitec_pay`, `payattitude`. The SDK sends it unchecked |
28
+ | `metadata:` | A Hash (or a JSON string). The SDK sends it stringified, as Paystack documents it, so numbers keep their type (`{order_id: 42}` comes back from `verify` as `42`; sent as a plain object Paystack would store `"42"`: observed on the test API 2026-10-09). Look payments up by your reference, not by metadata |
29
+
30
+ The method also takes `plan:`, `invoice_limit:`, `split_code:`, `split:`, `subaccount:`, `transaction_charge:`, `bearer:` and `label:`. It has no `first_name:`, `last_name:` or `phone:` keyword.
31
+
32
+ A successful call returns `authorization_url`, `access_code` and your `reference`. `transactions.verify` does not return `authorization_url`, so if you lose it, you cannot get it back from verify.
33
+
34
+ ```ruby
35
+ # app/controllers/payments_controller.rb
36
+ def create
37
+ payment = Payment.create!(member: current_member, amount: 5000, currency: "GHS",
38
+ reference: "pay-#{SecureRandom.uuid}", status: "pending")
39
+
40
+ response = PaystackSdk::Client.new.transactions.initiate(
41
+ email: current_member.email, amount: payment.amount, currency: payment.currency,
42
+ reference: payment.reference, callback_url: payments_callback_url,
43
+ channels: ["card", "mobile_money"], metadata: {payment_id: payment.id}
44
+ )
45
+
46
+ if response.success?
47
+ redirect_to response.authorization_url, allow_other_host: true # else OpenRedirectError when raise_on_open_redirects is on
48
+ else
49
+ payment.update!(status: "failed", gateway_response: response.error_message)
50
+ redirect_to new_payment_path, alert: "We could not start the payment. Please try again."
51
+ end
52
+ end
53
+ ```
54
+
55
+ **One reference, one transaction, ever.** Paystack answered HTTP 400 "Duplicate Transaction Reference" (an unsuccessful `Response`, not an exception) to every reuse we tried: `initiate` again with the same reference (same or different amount), a card charge with a reference that `initiate` had already used, and `initiate` with a reference that was already paid. So a new attempt always needs a new reference and a new payment row.
56
+
57
+ **If `initiate` raises `PaystackSdk::TimeoutError` or `ServerError`**, the SDK does not retry it, and it may have reached Paystack. Call `verify(reference:)`: "Transaction reference not found." means Paystack never created it; a found transaction means it exists but you have no `authorization_url` for it. Either way, start again with a new reference and mark the old row `failed`.
58
+
59
+ ## When the payer comes back
60
+
61
+ Paystack's docs (Accept Payments, Redirect) say it appends the reference to your `callback_url` as `?reference=...`, and that a visit to the callback URL "doesn't prove that transaction was successful". So the callback only runs the confirmation:
62
+
63
+ ```ruby
64
+ # GET /payments/callback?reference=...
65
+ def callback
66
+ payment = ConfirmPayment.call(params[:reference].to_s)
67
+ return head(:not_found) if payment.nil?
68
+
69
+ redirect_to payment_path(payment) # shows paid, pending or failed from your own row
70
+ end
71
+ ```
72
+
73
+ ## What `verify` tells you
74
+
75
+ `verify` succeeds (`success?` true, HTTP 200) for any reference Paystack knows, paid or not. The payment outcome is in `data.status`, never in `success?`.
76
+
77
+ | `data.status` | What it means | What to do |
78
+ |---|---|---|
79
+ | `success` | Paid. `paid?(amount:, currency:)` also checks the amount and currency are the ones you asked for | Mark paid and give value once, if `paid?` is true. If the status is `success` but `paid?` is false, do not give value: flag it for a person |
80
+ | `abandoned` | Not paid (yet). Seen right after `initiate`, while a charge waited for a PIN, and after a card was declined at the first step (with the reason in `gateway_response`, e.g. "Transaction declined. Please use the test card.") | Keep the row `pending`. Do not call it failed: the docs define it as "the customer hasn't completed the transaction" |
81
+ | `failed` | The attempt failed. Seen after a wrong PIN, with `gateway_response` "Incorrect PIN" | Mark failed; a retry is a new reference |
82
+ | `ongoing`, `pending`, `processing`, `queued` | In progress (docs only, not seen here) | Keep `pending`, wait for the webhook or verify later |
83
+ | `reversed` | Refunded or charged back (docs only, not seen here) | Do not give value; if you already did, handle it as a reversal |
84
+ | anything else | Unknown | Keep `pending`, log the status and reference, do not guess |
85
+
86
+ A reference Paystack does not know: `verify` returns HTTP 400, `success?` false, `error_message` "Transaction reference not found." and no `data`. It does not raise. Never treat that as paid or as failed; it is not a Paystack transaction (or not one yet).
87
+
88
+ On a paid transaction, `verify` returned (test API): `id`, `status` `success`, `reference`, `amount` `100`, `currency` `GHS`, `paid_at`, `channel` `card`, `gateway_response` "Successful", your `metadata`, `customer` (`id`, `email`, `customer_code`) and `authorization` (`authorization_code`, `reusable` true, `channel`, plus card details: `bin`, `last4`, `exp_month`, `exp_year`, `brand`, `bank`, `signature`). On an unpaid one, `paid_at` was `null` and `authorization` was `{}`.
89
+
90
+ Dot access (`response.paid_at`) raises `NoMethodError` when Paystack leaves a key out. Read a field that may be missing with `response.dig(:paid_at)` (or `response.dig(:authorization, :authorization_code)`): it returns `nil` as soon as a key is missing.
91
+
92
+ ## Confirming twice is normal (design advice)
93
+
94
+ The callback and the `charge.success` webhook confirm the same payment, possibly at the same moment. Paystack's Verify Payments guide warns to "confirm that you haven't already delivered value for that transaction to avoid double fulfillments". This is **our design advice, not Paystack behaviour**: a unique `reference` column, one service both paths call, a row lock and a state check inside it.
95
+
96
+ ```ruby
97
+ # db/migrate/..._create_payments.rb
98
+ create_table :payments do |t|
99
+ t.references :member, null: false
100
+ t.string :reference, null: false
101
+ t.integer :amount, null: false # pesewas
102
+ t.string :currency, null: false
103
+ t.string :status, null: false # yours: pending, paid, failed, needs_review
104
+ t.string :paystack_status # the last data.status verify returned
105
+ t.bigint :paystack_id # data.id; the docs say treat it as an unsigned 64-bit integer
106
+ t.datetime :paid_at
107
+ t.string :gateway_response
108
+ t.string :authorization_code # only when reusable; `encrypts :authorization_code` in the model
109
+ t.timestamps
110
+ end
111
+ add_index :payments, :reference, unique: true
112
+ ```
113
+
114
+ ```ruby
115
+ # app/services/confirm_payment.rb: the callback and the webhook both call this
116
+ class ConfirmPayment
117
+ def self.call(reference, client: PaystackSdk::Client.new)
118
+ payment = Payment.find_by(reference: reference)
119
+ return nil if payment.nil? # not ours: never act on it
120
+ return payment if payment.status == "paid" # already done
121
+
122
+ response = client.transactions.verify(reference: reference)
123
+ return payment unless response.success? # unknown to Paystack: leave it pending
124
+
125
+ data = response.original_response["data"] # a plain Hash: a missing key is nil, not an error
126
+ payment.with_lock do # locks the row and reloads it
127
+ if payment.status == "paid"
128
+ # the other confirmation finished first: do nothing
129
+ elsif response.paid?(amount: payment.amount, currency: payment.currency)
130
+ authorization = data["authorization"] || {}
131
+ payment.update!(status: "paid", paystack_status: data["status"], paystack_id: data["id"],
132
+ paid_at: data["paid_at"], gateway_response: data["gateway_response"],
133
+ authorization_code: (authorization["authorization_code"] if authorization["reusable"]))
134
+ # record what the payment buys here, inside the lock, so it happens once
135
+ elsif response.status?(:success)
136
+ payment.update!(status: "needs_review", paystack_status: "success", gateway_response: data["gateway_response"])
137
+ else
138
+ payment.update!(status: response.status?(:failed) ? "failed" : "pending",
139
+ paystack_status: data["status"], gateway_response: data["gateway_response"])
140
+ end
141
+ end
142
+ payment
143
+ end
144
+ end
145
+ ```
146
+
147
+ The lock only serialises the two callers on a database with row locks (PostgreSQL, MySQL). The `status == "paid"` check inside the lock is what stops the second caller. Send emails and other side effects after the commit (for example from a job), not from inside the lock.
148
+
149
+ In the webhook, check the signature first, then call the same service with the event's reference:
150
+
151
+ ```ruby
152
+ event = PaystackSdk::Webhook.construct_event(payload: request.raw_post,
153
+ signature: request.headers["X-Paystack-Signature"], secret: ENV.fetch("PAYSTACK_SECRET_KEY"))
154
+ ConfirmPayment.call(event.data.reference) if event.event == "charge.success"
155
+ head :ok
156
+ ```
157
+
158
+ ## What to store
159
+
160
+ | Store | Why |
161
+ |---|---|
162
+ | `reference`, `amount`, `currency` | Yours, written before `initiate`; what `paid?` compares against |
163
+ | `status` (yours) and `paystack_status` | Your state machine, and what Paystack last said |
164
+ | `id` (as `paystack_id`, `bigint`) | `transactions.fetch(id:)` takes the numeric ID, not the reference |
165
+ | `paid_at`, `gateway_response` | Receipts and support ("Incorrect PIN", "Successful") |
166
+ | `authorization.authorization_code` | **Only when `authorization.reusable` is true**, encrypted, for later charges. Never overwrite a stored code with an empty one |
167
+
168
+ Never store card data: not `bin`, `last4`, `exp_month`, `exp_year`, `signature`, and never anything the payer typed. Keep the secret key out of logs.
169
+
170
+ ## Looking transactions up
171
+
172
+ ```ruby
173
+ client.transactions.fetch(id: payment.paystack_id) # unknown ID: HTTP 404, success? false, "Transaction not found"
174
+
175
+ page = client.transactions.list(per_page: 50, page: 1, status: "success",
176
+ from: Date.new(2026, 10, 1), to: Date.new(2026, 10, 31), customer_id: 407293981)
177
+ page.each { |transaction| puts transaction.reference }
178
+ page.meta.total # also page.meta.pageCount, page.meta.perPage
179
+ ```
180
+
181
+ - `per_page:` is sent as `perPage`; `customer_id:` is sent as `customer` and is Paystack's **numeric** customer ID (`data.customer.id` from verify). Paystack answers a `CUS_` code with `success?` true and no rows, which looks like "no payments", so the SDK refuses anything but a numeric ID (an Integer, or a string of digits) with `InvalidValueError` before sending.
182
+ - `status:` is checked by the SDK: `success`, `failed`, `abandoned` or `reversed`.
183
+ - `from:` and `to:` take a `Date`, a `Time` or an ISO 8601 String.
184
+
185
+ ## Rules
186
+
187
+ - **Your reference, stored first, used once.** A new attempt is a new reference.
188
+ - **The callback URL proves nothing.** Verify by reference, then `paid?(amount:, currency:)` with *your* stored amount and currency.
189
+ - **`success?` on verify is not "paid".** Read `data.status`; only `success` with `paid?` true is paid.
190
+ - **`abandoned` is not failed.** Keep the row pending.
191
+ - **Confirmation is idempotent**: one service, called by the callback and the webhook, with a lock and a state check.
192
+ - **Never retry `initiate` after a timeout with the same reference.** Verify it, then start again with a new one.
193
+ - **Store the authorization code only when reusable, encrypted. Never card data.**
194
+
195
+ ## Observed, and not verified
196
+
197
+ Observed on Paystack's test API on 2026-10-09 with `sandbox_only: true`: `initiate` with every keyword in the table (a 1-pesewa `initiate` and a 40-character `pay-<uuid>` reference were accepted too), `verify` of an unpaid, a paid, a declined, a wrong-PIN and an unknown reference, the duplicate-reference answers, `fetch` (known and unknown ID) and `list` with `per_page`, `status`, `from`, `to` and `customer_id`. The paid transaction was made with Paystack's no-validation test card through the Charge API (`4084 0840 8408 4081`, expiry `09/27`, CVV `408`) with our own reference, because the hosted checkout page cannot be driven from code.
198
+
199
+ Not verified: anything on the checkout page itself, including what the payer sees for each `channels` value, the exact query string Paystack adds to the `callback_url` (the docs show `reference`), and whether an `abandoned` transaction can still become `success` later. The statuses `ongoing`, `pending`, `processing`, `queued` and `reversed` come from Paystack's Verify Payments guide and were not produced here. The Rails code (the migration, the controller and `ConfirmPayment`) is design advice. The gem's specs run `ConfirmPayment` against a stand-in model; it was also run once with ActiveRecord 8.1 on SQLite against the test API (an abandoned payment stayed `pending`, a paid one confirmed from two threads ended `paid` once with its `AUTH_` code, an unknown reference returned `nil`). That run used SQLite, so the locking under real concurrent load on PostgreSQL or MySQL is not verified. Nothing here was run with a live key, and test mode does not model everything live mode does.
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: paystack-sdk-refunds
3
+ description: 'Use when code returns money to a customer for a Paystack payment with paystack_sdk (client.refunds): a full or partial refund, checking how much of a transaction is already refunded, reading a refund status (pending, processing, processed, failed, needs-attention), handling refund webhooks, recovering from a timeout on a refund request, or showing the payer what happened. Load it before writing any code that calls refunds.create.'
4
+ ---
5
+
6
+ # Refunds (`client.refunds`)
7
+
8
+ A refund returns money from a successful transaction to the payer. It is **asynchronous**: `refunds.create` queues it, and the refund is `pending` until Paystack and the payer's bank or wallet provider finish. Treat "created" as "requested", never as "money returned". Basics (client, `Response`, errors) are in [[paystack-sdk-overview]]. Each claim below is marked **observed** (Paystack test API, 2026-10-09, `sk_test_` key), **documented** (Paystack docs, page named), or **design advice**.
9
+
10
+ ## The calls
11
+
12
+ ```ruby
13
+ # Full refund: leave amount out. `transaction:` takes the transaction reference or its numeric id.
14
+ client.refunds.create(transaction: "order-1042")
15
+
16
+ # Partial refund: amount in pesewas (integer), currency optional
17
+ client.refunds.create(
18
+ transaction: "order-1042", amount: 200, currency: "GHS",
19
+ customer_note: "Event cancelled", merchant_note: "Refund approved by treasurer, ticket 77"
20
+ )
21
+
22
+ client.refunds.fetch(id: 18_633_077) # one refund, by the refund id
23
+ client.refunds.list(transaction_id: 6_641_908_848) # the refunds of one transaction: NUMERIC id
24
+ client.refunds.list(per_page: 20, page: 1, from: "2026-10-01", to: "2026-10-31")
25
+ ```
26
+
27
+ Keywords: `create(transaction:, amount:, currency:, customer_note:, merchant_note:)`, `fetch(id:)`, `list(per_page:, page:, from:, to:, transaction_id:)`, `retry_with_customer_details(id:, refund_account_details:)`. `amount` must be a positive integer (the SDK checks before sending); `currency` must be one of GHS, KES, NGN, USD, ZAR.
28
+
29
+ ## What was observed on the test API
30
+
31
+ | What I did | What came back |
32
+ |---|---|
33
+ | Refund by reference; refund by numeric id | Both accepted ("Refund has been queued for processing"). |
34
+ | `create` with a result | `data.status` is `pending`; `data.id` is the refund id; `data.amount`, `data.currency`; `refunded_by` was a short account name, not the email, so record your own requester. `customer_note`/`merchant_note` are echoed back; if you omit them Paystack fills them with "Refund for transaction <reference>" (and the account email in the merchant note). |
35
+ | Refund 50 pesewas of a 100-pesewa charge | **Rejected, HTTP 400: "The minimum amount you may send in a single refund at this time is: GHS1.00".** Over-refund of 60 gave the same minimum-amount message. After a full refund, 1 pesewa gave "Cannot refund less than GHS0.5". So a single refund below GHS 1.00 is refused in test mode (a limit "at this time"; check it for live). |
36
+ | 300-pesewa charge, refund 100 | Accepted, `pending`. |
37
+ | Then refund 250 (200 left), and `create` with `amount` omitted | **Both rejected: "Total refund amount cannot exceed original transaction amount".** With `amount` omitted Paystack asks for the whole original amount, not the remainder. After a partial refund, always pass the amount. |
38
+ | Then refund 100 (exactly part of the rest) | Accepted, `pending`. Two refunds on one transaction both appeared in `list(transaction_id:)`. |
39
+ | `currency: "USD"` on a GHS transaction | Rejected: "Transaction currency must match refund currency". |
40
+ | `list(transaction_id: <numeric id>)` | The transaction's refunds. Paystack answers a reference passed here with an empty list, not an error (observed on the test API), so the SDK refuses anything but a numeric ID (an Integer, or a string of digits) with `InvalidValueError` before sending. Find a refund by its transaction's numeric `data.id` from `verify`, not its reference. |
41
+ | `transactions.verify(reference:)` after a refund (full or partial) | `data.status` was `reversal-pending`, not `success`, so `paid?` returned `false`. A refunded payment no longer verifies as paid; do not re-verify an old payment and "un-grant" it because of that. |
42
+ | Refund status over the next minutes | Every refund stayed `pending` (`refunded_at` null). I did not see `processing`, `processed` or `failed`, nor `reversal-pending` change. |
43
+
44
+ Rejections are HTTP 400 and arrive as an unsuccessful `Response` (nothing raised): branch on `success?` and show `error_message` to staff.
45
+
46
+ ## Statuses (documented, not observed beyond `pending`)
47
+
48
+ From Paystack's Refunds guide (docs.../payments/refunds) and the webhooks page:
49
+
50
+ | Refund `status` | Meaning (Paystack's words, shortened) | Webhook event |
51
+ |---|---|---|
52
+ | `pending` | Initiated, waiting for the processor | `refund.pending` |
53
+ | `processing` | Received by the processor | `refund.processing` |
54
+ | `processed` | Successfully processed. The guide adds that customers may still wait up to 10 business days for the funds | `refund.processed` |
55
+ | `failed` | Could not be processed; the guide says your account reflects the credited amount again | `refund.failed` |
56
+ | `needs-attention` | You must provide the customer's bank details | the guide names `refund.needs-attention`, which is in `PaystackSdk::Webhook::EVENTS` (it is named in the Refunds guide but not on Paystack's Webhooks page) |
57
+
58
+ ## What to record and how to treat a refund (design advice)
59
+
60
+ Store a `Refund` row per request before calling Paystack: your own id, the payment's `reference` and Paystack transaction id, `amount` and `currency` (pesewas, integer), who requested and who approved it, `customer_note`/`merchant_note`, then after the call the Paystack refund `id` and `status`. Mark it `pending` until Paystack says `processed`. Reasons: a created refund can still fail (then the money stays with you), "processed" can still take days to reach the payer, and you can only tell the payer "refunded" once. Update the row from `refund.*` webhooks (verified with `Webhook.construct_event`; dedupe on event plus the refund id and status, there is no documented event id) and from `refunds.fetch(id:)`. Do not mark the member's payment as refunded in your ledger as final until `processed`. Keep the original payment record; a refund is its own record.
61
+
62
+ ## Never retry a create blindly
63
+
64
+ A timed-out `refunds.create` may have queued a refund (the SDK does not retry writes after a timeout). A second blind call could refund twice. First look:
65
+
66
+ ```ruby
67
+ # Returns the refunds Paystack already has for this payment (nil if the payment cannot be verified).
68
+ def refunds_for_payment(client, reference)
69
+ txn = client.transactions.verify(reference: reference)
70
+ return nil unless txn.success?
71
+
72
+ client.refunds.list(transaction_id: txn.data.id).original_response["data"]
73
+ end
74
+
75
+ # Pesewas already refunded or on their way (failed refunds do not count).
76
+ def refunded_amount(rows)
77
+ rows.reject { |row| row["status"] == "failed" }.sum { |row| row["amount"] }
78
+ end
79
+
80
+ # Safe to ask for `wanted` more pesewas?
81
+ def can_refund?(rows, payment_amount, wanted)
82
+ wanted >= 100 && refunded_amount(rows) + wanted <= payment_amount
83
+ end
84
+ ```
85
+
86
+ If a refund for that amount is listed, adopt its id; if the list is empty (with the numeric id), then create it. Persist your own refund row first so a crash leaves a trace. `wanted >= 100` is the GHS 1.00 minimum seen on the test API, not a documented number.
87
+
88
+ ## Webhooks
89
+
90
+ `refund.pending`, `refund.processing`, `refund.processed` and `refund.failed` are in `PaystackSdk::Webhook::EVENTS` (listed on Paystack's webhooks page). The refund webhook payload shape is **not** in the docs I had, and I did not receive one: read the fields you need from the event, and when it matters call `refunds.fetch(id:)` instead of trusting the payload. Return 200 quickly; Paystack retries unacknowledged events (documented).
91
+
92
+ ## Mobile money and bank transfer payments
93
+
94
+ Paystack's refunds guide says nothing about mobile money, minimum amounts, or how a refund reaches a wallet; its only account rule is the `needs-attention` flow where you supply a customer bank account. The channels page says a transfer that arrives with the wrong amount is refunded automatically. So for a mobile money or bank transfer payment, **not verified**: whether `refunds.create` works, how long it takes, and whether you will land in `needs-attention`. I only refunded card payments. Plan for a manual fallback (pay the member back yourself and record it).
95
+
96
+ ## `retry_with_customer_details` (documented only; never called)
97
+
98
+ ```ruby
99
+ client.refunds.retry_with_customer_details(
100
+ id: refund_id,
101
+ refund_account_details: {currency: "GHS", account_number: "0123456789", bank_id: "9"}
102
+ )
103
+ ```
104
+
105
+ Per the docs, use it only for a refund in `needs-attention` (after the `refund.needs-attention` event), with any valid bank account of the customer; `bank_id` comes from `banks.list`, `account_number` is a string, and `currency` must be the payment's. I did not call it.
106
+
107
+ ## Rules
108
+
109
+ - **Amounts are integer pesewas.** After any earlier refund on the transaction, pass `amount:` explicitly; omitting it asks for the full original amount and fails (observed).
110
+ - **Check `success?` on every refund call**; 400s do not raise.
111
+ - **A refund is pending until `processed`.** Do not tell the payer or finalise the books earlier.
112
+ - **After a timeout, list by the numeric transaction id before creating again.**
113
+ - **Only authorised staff may refund**; log who asked. Each refund moves real money in live mode.
114
+
115
+ ## Not verified
116
+
117
+ Any status beyond `pending`; how long a test refund stays pending; refund webhook payloads; the live minimum refund; mobile money and bank transfer refunds; `retry_with_customer_details`; whether a failed or reversed refund returns the transaction to `success`.
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: paystack-sdk-saved-card-renewals
3
+ description: 'Use when charging a returning customer saved card without checkout through paystack_sdk: renewals, dues or instalments your own app schedules, saving the reusable authorization from a first payment, calling transactions.charge_authorization or partial_debit, designing renewal references, handling a declined, rejected or timed-out renewal, dunning, or a lost authorization code. Not for Paystack Plans or Subscriptions.'
4
+ ---
5
+
6
+ # Saved-card renewals (your app schedules the charge)
7
+
8
+ The payer pays once through checkout (or the Charge API). Paystack returns an `authorization`. If it is reusable, you store its code and later charge the card yourself with `transactions.charge_authorization`, without the payer present. Your job owns the schedule. Read [[paystack-sdk-overview]] first; it covers `success?`, `paid?` and retries.
9
+
10
+ Paystack Plans and Subscriptions (`client.plans`, `client.subscriptions`) are a different feature, in which Paystack does the scheduling. They have their own method docs in the gem and are not covered or verified here.
11
+
12
+ ## 1. Get a reusable authorization from the first payment
13
+
14
+ After the first payment, verify it and read `data.authorization`. Save it only if `reusable` is true. Paystack's Recurring Charges guide says: "You should only attempt to use the authorization_code if this flag returns as true."
15
+
16
+ ```ruby
17
+ def reusable_card_authorization(verify_response, amount:, currency:)
18
+ return nil unless verify_response.paid?(amount: amount, currency: currency)
19
+
20
+ auth = verify_response.original_response.dig("data", "authorization") || {}
21
+ return nil unless auth["reusable"] == true && auth["channel"] == "card"
22
+
23
+ {
24
+ email: verify_response.original_response.dig("data", "customer", "email"), # the code belongs to THIS email
25
+ authorization_code: auth["authorization_code"], # secret: store it encrypted
26
+ signature: auth["signature"], # the same card gives the same signature
27
+ last4: auth["last4"], exp_month: auth["exp_month"], exp_year: auth["exp_year"],
28
+ bank: auth["bank"], card_type: auth["card_type"].to_s.strip # Paystack sends "visa " with a space
29
+ }
30
+ end
31
+ ```
32
+
33
+ | Field | What it is | Store it? |
34
+ |---|---|---|
35
+ | `authorization_code` | What you charge (`AUTH_...`) | Yes, **encrypted** (for example with Active Record encryption). Never log it |
36
+ | `customer.email` of that payment | The only email that can charge this code | Yes, next to the code. Do not take it from the user record later |
37
+ | `signature` | Identifies the card, not the customer | Yes: dedupe saved cards on email + signature |
38
+ | `last4`, `exp_month`, `exp_year`, `bank`, `card_type` | For "Visa ending 4081, expires 09/27" | Yes, plain |
39
+ | `reusable` | Whether you may charge it later | Check it. Do not save a code that is not `true` |
40
+ | Card number, CVV, PIN, OTP | You never receive them from Paystack | Never store or log what a payer typed |
41
+
42
+ Observed on the test API 2026-10-09: the customers `kwes@email.com` and `jdoe@email.com` hold different codes (`AUTH_50w0d0f5xo`, `AUTH_f6sm3o0m7w`) with the **same** signature, because they used the same test card. So a signature on its own is not a customer key. Paystack's guide also says new authorization codes are created each time a card is used, while the signature stays the same: dedupe on email + signature and keep the newest code.
43
+
44
+ ## 2. Charge a renewal
45
+
46
+ ```ruby
47
+ response = client.transactions.charge_authorization(
48
+ email: card.email, # the email the code was saved with
49
+ amount: 5000, # pesewas, an Integer (100.0 raises InvalidValueError before sending)
50
+ currency: "GHS", # always pass it
51
+ authorization_code: card.authorization_code,
52
+ reference: renewal_reference(membership.id, "2026-10", 1),
53
+ metadata: {membership_id: membership.id, period: "2026-10"} # a Hash is sent as a JSON string; verify returns it as a Hash
54
+ )
55
+ ```
56
+
57
+ Other keywords: `queue:` (Paystack's docs: for scheduled charges, so its processing system is not overloaded; with `queue: true` a test charge still answered `success` at once), `split_code:`, `split:`, `subaccount:`, `transaction_charge:`, `bearer:` (`"account"` or `"subaccount"`). Only `email`, `amount` and `authorization_code` are required by the method.
58
+
59
+ ## 3. What comes back (observed on the test API 2026-10-09)
60
+
61
+ | Situation | HTTP | `success?` | Message | Transaction created? |
62
+ |---|---|---|---|---|
63
+ | Good code, its own email | 200 | true | "Charge attempted", `data.status` `success` | Yes |
64
+ | Code that does not exist | 400 | false | "Authorization code is invalid" | No |
65
+ | Another customer's code (email mismatch) | 400 | false | "Email does not match Authorization code. Authorization may be inactive or belong to a different email. Please confirm." | No |
66
+ | A code you deactivated (`customers.deactivate_authorization`) | 400 | false | Same "Email does not match..." message | No |
67
+ | A mobile money code (`reusable: false`), its own email | 400 | false | Same "Email does not match..." message | No |
68
+ | Currency the account does not take (USD) | 400 | false | "Currency not supported by merchant" | No |
69
+ | A reference already used | 400 | false | "Duplicate Transaction Reference" | No new one |
70
+ | `verify` of a reference Paystack never saw | 400 | false | "Transaction reference not found." | n/a |
71
+
72
+ None of these raise: read `response.error_message`. A 400 on `charge_authorization` did not create a transaction, so the same reference stayed free: a valid charge on it afterwards succeeded. Three different causes share one message, so do not try to tell "wrong email", "deactivated" and "not reusable" apart from the text.
73
+
74
+ **Not observed: a declined card.** No test card here declines a saved-card charge, so a 200 with `data.status` `failed` (insufficient funds, expired card) was not seen. Handle every status that is not `success` as not paid. Paystack's guide also documents a response with `data.paused` true and `data.authorization_url` when a card is challenged for two-factor authentication (Nigerian integrations, on request): the payer must open that URL, and you verify the reference afterwards.
75
+
76
+ ## 4. References, lookup-first jobs and the timeout rule
77
+
78
+ One reference per **attempt**, built from your own record id and the billing period, and stored before the call. A retry of the same job (a crash, a timeout, a redeploy) reuses that reference and looks it up first. A deliberate new attempt after a decline gets the next attempt number.
79
+
80
+ ```ruby
81
+ def renewal_reference(record_id, period, attempt)
82
+ reference = "renewal-#{record_id}-#{period}-a#{attempt}"
83
+ raise ArgumentError, "unusable reference #{reference.inspect}" unless reference.match?(/\A[a-zA-Z0-9._=-]+\z/)
84
+
85
+ reference
86
+ end
87
+
88
+ # :paid, :failed, :pending (look again later), :check_amount (paid, but not what you asked for)
89
+ def renewal_outcome(response, amount:, currency:)
90
+ return :paid if response.paid?(amount: amount, currency: currency)
91
+ return :check_amount if response.status?(:success)
92
+ return :failed if response.status?(:failed)
93
+
94
+ :pending
95
+ end
96
+
97
+ # Returns [outcome, detail]. Outcomes: :paid, :failed, :pending, :check_amount,
98
+ # :rejected (400, nothing charged: detail is Paystack's message) and :unknown (verify this reference later).
99
+ def charge_renewal(client, card:, amount:, currency:, reference:)
100
+ existing = client.transactions.verify(reference: reference)
101
+ return [renewal_outcome(existing, amount: amount, currency: currency), nil] if existing.success?
102
+
103
+ response = client.transactions.charge_authorization(email: card.email, amount: amount, currency: currency,
104
+ authorization_code: card.authorization_code, reference: reference)
105
+ return [:unknown, response.error_message] if response.error_message.to_s.include?("Duplicate")
106
+ return [:rejected, response.error_message] unless response.success?
107
+
108
+ [renewal_outcome(client.transactions.verify(reference: reference), amount: amount, currency: currency), nil]
109
+ rescue PaystackSdk::TimeoutError, PaystackSdk::ConnectionError, PaystackSdk::ServerError => e
110
+ [:unknown, e.class.name] # the charge may have happened: never resend now, verify this reference on the next run
111
+ end
112
+ ```
113
+
114
+ - **Never retry a charge after a timeout, connection error or 5xx.** The SDK does not (writes retry only on 429). The charge may have gone through: verify the reference first. `charge_renewal` returns `:unknown` and the next run starts with `verify`.
115
+ - If you do send a reference twice, Paystack refuses the second with "Duplicate Transaction Reference" (observed), so the lookup-first job cannot double charge one attempt.
116
+ - Grant the period only on `:paid`, which is `paid?(amount:, currency:)` on a `verify` by reference.
117
+ - Run on the test API 2026-10-09: the first `charge_renewal` charged and returned `[:paid, nil]`; a second call with the same reference returned `[:paid, nil]` from the lookup without charging; another customer's code returned `[:rejected, "Email does not match..."]`.
118
+
119
+ ## 5. Partial debit
120
+
121
+ `transactions.partial_debit(email:, amount:, authorization_code:, currency:, at_least: nil, reference: nil)` charges a saved card for **whatever it can**, up to `amount`, when the full amount would fail for insufficient funds. Per Paystack's Partial Debits guide it is available only on request, only for Mastercard and Verve, and `verify` then returns `amount` (what was charged) and `requested_amount` (what you asked for). So `paid?(amount: requested, ...)` is false for a partial charge, and `renewal_outcome` returns `:check_amount`: record what was actually paid and bill the rest.
122
+
123
+ Observed on the test API 2026-10-09: `partial_debit` in GHS answered HTTP 400 "Currency GHS is not supported for Partial Debit", although the API reference lists NGN or GHS. **For a Ghana integration, treat partial debit as unavailable** unless Paystack enables it for you. No partial charge was observed.
124
+
125
+ ## 6. Two confirmations racing (reported by a consumer, not reproduced)
126
+
127
+ An app reported that a webhook and a return-URL confirmation for the same first card payment ran at the same time and the reusable authorization was lost; they now re-verify by reference whenever the token is missing. What was observed (test API 2026-10-09): `transactions.verify` on a paid reference returned the same authorization (code, `reusable: true`, signature) on two calls in a row, for a saved-card charge and for a first card charge. So re-verifying is a sound recovery. Design advice: save the authorization in one place, idempotently (a unique index on email + signature, then insert-or-update), and have both confirmation paths call it.
128
+
129
+ Also observed: after `customers.deactivate_authorization`, `verify` of the old reference still said `reusable: true`, while `customers.fetch` listed no authorizations. `reusable` is a snapshot of the payment; it does not tell you the code still works.
130
+
131
+ ## 7. Mobile money is not a saved card
132
+
133
+ A paid test mobile money charge (`0551234987`, `mtn`, observed 2026-10-09) returned an authorization with `reusable: false`, `signature: nil` and `channel: "mobile_money"`, and charging its code was refused (table above). There is no token to renew with: each mobile money renewal is a new payer-approved charge (see the [[paystack-sdk-mobile-money]] skill), or a payment link sent to the payer.
134
+
135
+ ## 8. Dunning (design advice, not Paystack behaviour)
136
+
137
+ - `:failed`: retry a few times on later days with a new attempt number each time, then stop and ask the payer to pay through checkout (which can save a new card).
138
+ - `:rejected`: the code is unusable (invalid, inactive, another email, or not reusable). Do not retry it. Mark the card unusable and ask the payer to pay and save a card again.
139
+ - `:unknown` and `:pending`: verify the same reference later; do not charge again.
140
+ - `:check_amount`: record the partial payment and do not grant the full period.
141
+ - Warn payers before `exp_month`/`exp_year` passes. What Paystack returns for an expired saved card was not observed.
142
+
143
+ ## Rules
144
+
145
+ - **Charge only codes saved with `reusable: true`, with the exact email they were saved with.**
146
+ - **Store the code encrypted and keyed to that email; never store card data or anything the payer typed.** Keep codes and keys out of logs.
147
+ - **One reference per attempt, stored before the call; a job retry looks it up first.**
148
+ - **Never resend after a timeout or 5xx; verify the reference.**
149
+ - **Grant the period only on `verify(reference:).paid?(amount:, currency:)`**, never on the charge response alone.
150
+ - **Amounts are Integers in pesewas, and always pass `currency: "GHS"`.**
151
+
152
+ ## Not verified
153
+
154
+ A declined saved-card charge, an expired card, the `paused` two-factor flow, `queue: true` in live mode, a successful partial debit, and anything in live mode. Test mode is lenient. The consumer-reported race was not reproduced.