Every gateway is a Single DocType whose controller extends both Document and PaymentGatewayBase:
from frappe.model.document import Document
from bwh_payments.base_class import CheckoutSession, PaymentGatewayBase, RefundResult, WebhookEvent
class AcmePayGatewaySettings(Document, PaymentGatewayBase): ...
Methods return pydantic models from bwh_payments.base_class. BWH Payments reads every result with <Model>.model_validate, so:
- a gateway that still returns a plain
dictwith the same keys keeps working - a result missing a required field fails at once, with a clear error, instead of saving a broken request
| Method | Required | Called when |
|---|---|---|
create_session |
✅ | A Gateway Payment Request is created |
get_payment_status |
✅ | An app checks a payment without waiting for a webhook |
refund_payment |
✅ | A refund is issued |
handle_webhook |
✅ | The gateway posts to the webhook endpoint |
cancel_session |
Your app calls release_if_unpaid() on an open checkout |
|
get_transaction_ref |
A payment becomes Paid | |
get_refund_status |
sync_refunds checks a Pending refund |
|
supported_currencies |
An app lists gateways for a currency, or checks one | |
get_gateway_name |
Never by BWH Payments. It's for your own use. |
The optional methods have defaults, so a gateway that does not define them keeps working.
Rules for every method
- Money is in major units.
49.99means $49.99. Convert with the currency helpers below, and use the same conversion for charges and refunds. - Statuses are BWH Payments statuses, already mapped from the gateway's own wording:
Pending,Paid,Not Paid,Cancelled,Expired. - To report failure, throw. Use
frappe.throwwith a message a person can act on.
create_session
def create_session(
self,
amount: float,
currency: str,
reference: str | None = None,
customer: dict | None = None,
return_url: str | None = None,
) -> CheckoutSession
Opens a hosted checkout. Runs when a Gateway Payment Request is saved without a gateway session, which normally means once, on insert.
Parameters
| Name | Type | Description |
|---|---|---|
amount |
float |
Amount to charge, in major units |
currency |
str |
ISO 4217 code, for example USD or KWD |
reference |
str |
The Gateway Payment Request name. Send it as the gateway's merchant reference. |
customer |
dict |
The shopper. See Customer below. Any field may be empty. |
return_url |
str |
Passed only when the app set redirect_to. Send the shopper here after a payment, a failure or a cancel, instead of your settings URLs. Do not add parameters to it. |
Returns a CheckoutSession:
| Field | Required | Description |
|---|---|---|
session_id |
✅ | The gateway's id for this checkout, stored as the request's order_ref. It must be the id the gateway's webhooks and status endpoint use. |
redirect_url |
✅ | The hosted checkout URL the shopper is sent to |
success_url |
Where the shopper returns after paying | |
cancel_url |
Where the shopper returns after cancelling | |
failure_url |
Where the shopper returns after a failed payment |
Throw when the currency isn't supported, a required setting is missing, or the gateway refuses to open a checkout. The request isn't saved, and your message is raised to the app that created it.
get_payment_status
def get_payment_status(self, session_id: str) -> str
Reads the current status from the gateway's API. It is called:
- when the shopper returns to the site
- by your own scheduled job, if you run one to settle Pending payments no one confirmed
- by
release_if_unpaid(), before an open checkout is cancelled
For gateways with no trustworthy webhook, this is the only source of truth.
Parameters
| Name | Type | Description |
|---|---|---|
session_id |
str |
The session_id your create_session returned |
Returns one of Pending, Paid, Not Paid, Cancelled, Expired.
- Map anything you don't recognise to
Pending. - Return
Paidonly when the money has actually been captured. - Only a request that is still
Pendingchanges. Once it's Paid, Cancelled, Not Paid or Expired, a later answer is ignored.
refund_payment
def refund_payment(self, session_id: str, amount: float, currency: str | None = None) -> RefundResult
Refunds part or all of a paid checkout. Before your method runs, BWH Payments has already:
- checked the payment is refundable
- checked
amountisn't more than what's left - skipped a refund Payment Entry (or an amendment of one) that was already refunded
Parameters
| Name | Type | Description |
|---|---|---|
session_id |
str |
The checkout to refund |
amount |
float |
Amount to refund, in major units |
currency |
str |
The payment's currency |
Returns a RefundResult. Every field is optional: it's read after the money has moved, so a thin answer from the gateway must not fail the refund.
| Field | Description |
|---|---|
refund_id |
The gateway's refund id. Every partial refund's id is kept for reconciliation. |
status |
The gateway's own refund status. succeeded, processed or no status mean done. pending or anything unknown means Pending. failed, canceled or cancelled mean Failed. |
amount |
Informational: the amount refunded, in major units. The refunded total is increased by the amount requested, not this value. |
Throw when the gateway refuses the refund. The Gateway Refund becomes Failed with your message, and the payment's refund total does not change.
handle_webhook
def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None
Verifies a delivery to the webhook endpoint and says what it means.
Parameters
| Name | Type | Description |
|---|---|---|
payload |
bytes |
The raw request body, exactly as received. Verify signatures against this. |
headers |
dict |
Request headers, with title-case names such as X-Acme-Signature |
Returns
| Return | Meaning | HTTP response |
|---|---|---|
| (throws) | Verification failed | 400 |
None |
Genuine, but not something to act on | 200 |
WebhookEvent(session_id, status, event_id) |
Apply status to the checkout with this session_id |
200 |
WebhookEvent(refund_id, status, refund_amount, transaction_ref, event_id) |
Apply status to the refund with this refund_id, or record it against the payment whose transaction_ref matches |
200 |
Anything that isn't a valid WebhookEvent |
A verified delivery you couldn't turn into an event | 400, so the gateway retries |
event_id must stay the same when the gateway retries a delivery, and differ between events. A repeated event_id is ignored.
cancel_session
def cancel_session(self, session_id: str) -> bool
Optional. The default returns False.
Cancels an open checkout. Called by release_if_unpaid(). Return True only when the gateway confirms the checkout can no longer be paid. On any gateway error, return False instead of throwing.
If this returns False, the payment stays Pending until the gateway settles it, and release_if_unpaid() returns False.
get_transaction_ref
def get_transaction_ref(self, session_id: str) -> str | None
Optional. The default returns None.
Returns the gateway's id for the settled payment, for example Stripe's payment_intent or Razorpay's pay_… id. BWH Payments stores it as the request's gateway_transaction_ref when the payment becomes Paid. A refund webhook for a refund raised on the gateway's dashboard finds the payment through it. If this method throws, the error is logged and the payment still becomes Paid.
get_refund_status
def get_refund_status(self, session_id: str, refund_id: str) -> RefundResult | None
Optional. The default returns None, which means the gateway cannot report a refund.
Reads a refund's current status from the gateway, for sync_refunds. Return a RefundResult with the gateway's refund status.
supported_currencies
def supported_currencies(self) -> tuple[str, ...] | None
Optional. The default returns None, which means any currency the site has.
Returns the ISO codes the gateway can charge in. get_gateway_options and validate_currency use it.
You can also set the class attributes logo and logo_dark to site paths of your gateway's logo, for example /assets/your_app/images/acme-pay.svg. get_gateway_options returns them.
get_gateway_name
def get_gateway_name(self) -> str
Optional. The default is the class name. Returns a human-readable name, for example "Acme Pay". BWH Payments doesn't call it. It's there for your own code.
Models
class CheckoutSession(BaseModel):
session_id: str
redirect_url: str
success_url: str | None = None
cancel_url: str | None = None
failure_url: str | None = None
class RefundResult(BaseModel):
refund_id: str | None = None
status: str | None = None
amount: float | None = None # major units
class WebhookEvent(BaseModel):
session_id: str | None = None # a payment event
status: str # a Gateway Payment Request status, or the gateway's refund status
event_id: str | None = None
refund_id: str | None = None # a refund event
refund_amount: float | None = None # major units
transaction_ref: str | None = None # the refunded payment's id
# A WebhookEvent needs session_id or refund_id.
Customer
The customer argument to create_session:
{
"ref": "CUST-0001", # linked customer id, or ""
"email": "shopper@example.com", # or ""
"phone": "+15550100", # or ""
"name": {"forenames": "Ada", "surname": "Lovelace"},
"address": {"line1": "1 Main St", "city": "Dubai", "country": "United Arab Emirates"}, # or None
}
Guest checkouts often leave fields empty. Drop the empty ones before you send the dict to the gateway.
Currency helpers
from bwh_payments.currency import (
from_minor_units,
get_minor_unit_exponent,
to_minor_units,
validate_transaction_currency,
)
| Helper | Example | Use it to |
|---|---|---|
to_minor_units(amount, currency) |
(49.99, "USD") → 4999, (12.345, "KWD") → 12345 |
Send amounts to gateways that bill in cents |
from_minor_units(amount, currency) |
(4999, "USD") → 49.99 |
Read the gateway's echoed amounts |
get_minor_unit_exponent(currency) |
"KWD" → 3, "JPY" → 0, "USD" → 2 |
Format major-unit strings, e.g. f"{amount:.{exponent}f}" |
validate_transaction_currency(currency) |
Throws for an unknown code | Check the currency exists on the site before calling out |
Exponents follow ISO 4217. They aren't read from the site's Currency settings.
URL helper
from bwh_payments.bwh_payments.utils import get_localised_url
get_localised_url(url) swaps a leading language segment, such as /en/, for the shopper's current language, so they return to the page in their own language. Use it on every return URL you send the gateway.