BWH Payments (Next)

BWH Payments (Next)

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Gateway contract

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 dict with the same keys keeps working
  • a result missing a required field fails at once, with a clear error, instead of saving a broken request

BWH Payments calls five methods on a gateway, and each returns a model.

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.99 means $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.throw with 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 Paid only when the money has actually been captured.
  • Only a request that is still Pending changes. 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 amount isn'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.

See Webhooks and security.


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.

Last updated 17 hours ago
Was this helpful?
Thanks!