Skip to content

feat: add PayPal gateway - #7

Merged
harshtandiya merged 9 commits into
bwhtech:v1-stablefrom
ishad05:feat/paypal-gateway
Oct 15, 2026
Merged

harshtandiya merged 9 commits into
bwhtech:v1-stablefrom
ishad05:feat/paypal-gateway

Conversation

@ishad05

@ishad05 ishad05 commented Sep 22, 2026 •

Copy link
Copy Markdown

Summary

Changes

  • PayPal Gateway Settings DocType: Sandbox/Live credentials, webhook ID, redirect URLs
  • services/paypal/{base,live,stub}.py service client, with stub-driven tests
  • Order creation, capture, webhook handling, full and partial refunds
  • OAuth token cached per mode and client id
  • Refund webhooks, so refunds made on the PayPal dashboard are recorded
  • WebhookEvent.sync_status: a webhook can ask the request to sync with the gateway
  • reusable_checkout = True: an approval link stays payable until the order is captured or expires

Worth a look

  • Capture runs through the shared webhook pipeline. The approval webhook only queues a status sync. It never captures in the guest request, and it never captures an order that isn't ours.
  • Return page and webhook can capture at the same time. Capture sends a PayPal-Request-Id, and ORDER_ALREADY_CAPTURED counts as success.
  • Paid only once the capture settles. A pending capture stays Pending. A declined or denied one is Not Paid.
  • Only Pending requests sync, because a sync may capture money.
  • PayPal unreachable (5xx, 429, dropped connection): Frappe retries the job. A real decline is logged as Failed and can be replayed with retry_webhook.
  • Webhook verification calls PayPal's verify-webhook-signature API. This is a deliberate exception to local signature checks, recorded in AGENTS.md.
  • Refunds send no PayPal-Request-Id, so a second partial refund isn't replayed as the first. The row lock and refund ledger prevent double refunds.
  • PAYMENT.CAPTURE.REVERSED is ignored, because PayPal can reinstate the money without sending a webhook.
  • Logs keep only endpoints, IDs and statuses.

Testing

  • Full suite passes locally (237 tests).
  • Tested against PayPal Sandbox with a real webhook:
    • USD payment creates one capture, Sales Order, Sales Invoice and Payment Entry
    • Return page and webhook arriving together give one capture and one order
    • With the browser closed after payment, the webhook alone places the order
    • A repeated webhook is ignored
    • Partial refund, then the remaining balance, both reach PayPal; a further refund is refused
    • An unsigned webhook is rejected
    • Re-run in Sandbox after the review changes

Setup

  1. Fill in PayPal Gateway Settings.
  2. Register a webhook at /api/method/bwh_payments.bwh_payments.webhook.handle?gateway=PayPal for CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.REFUNDED, PAYMENT.REFUND.PENDING and PAYMENT.REFUND.FAILED, and save the webhook ID.
  3. Enable a Payment Gateway Profile named PayPal.
  4. Give the PayPal Mode of Payment a default account for each company.
IMG_5680.MOV

🤖 Generated with Claude Code

@ishad05
ishad05 marked this pull request as draft October 11, 2026 09:04
@ishad05
ishad05 marked this pull request as ready for review October 11, 2026 09:41
@ishad05
ishad05 changed the base branch from develop to v1-stable October 11, 2026 10:37
@ishad05 ishad05 mentioned this pull request Oct 12, 2026
6 tasks done
@ishad05
ishad05 marked this pull request as draft October 12, 2026 06:46
@ishad05
ishad05 force-pushed the feat/paypal-gateway branch 2 times, most recently from cf28761 to fc1fa2d Compare October 12, 2026 15:47
A consumer building a gateway settings screen had to keep its own list of
gateways, with each one's description, docs link and settings DocType, so
every new gateway meant a change in each consumer.

- api.get_gateways() returns a GatewayInfo (name, settings_doctype,
  description, docs_url, capabilities, logos) for every gateway Settings
  Single on the site, enabled or not, including another app's gateways.
- The contract gains description, docs_url and capabilities class
  attributes. REUSABLE_CHECKOUT says the checkout link stays payable after
  the shopper leaves it.
- Razorpay, Stripe, Telr and Tabby describe themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ishad05
ishad05 force-pushed the feat/paypal-gateway branch from fc1fa2d to 633479e Compare October 12, 2026 17:10

@harshtandiya harshtandiya left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a few comments. the two that need fixing before merge are the capture inside the webhook and the token cache key, rest is smaller stuff.

also:

  • this is a new doctype + api going into v1-stable. shouldn't it go to main and get backported?
  • title says "per-gateway currency support" but supported_currencies is already on v1-stable. also get_gateways and paypal are two separate things, would be cleaner as two PRs
  • docs on docs.bwh.tech need updating for get_gateways and paypal

Comment thread bwh_payments/services/paypal/live.py Outdated
Comment thread bwh_payments/services/paypal/live.py Outdated
Comment thread bwh_payments/base_class.py Outdated
A tuple of capability strings and a constant carried one flag that nothing
in this app reads. reusable_checkout: bool says the same on the contract
and on GatewayInfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ishad05
ishad05 force-pushed the feat/paypal-gateway branch from 633479e to a666a77 Compare October 14, 2026 09:17
@ishad05 ishad05 changed the title feat: add PayPal gateway and per-gateway currency support feat: add PayPal gateway Oct 14, 2026
ishad05 and others added 5 commits October 14, 2026 15:30
get_gateways imported every `% Gateway Settings` controller, so one
DocType left behind by a removed app, or by code on another branch,
raised ImportError and broke every screen that lists gateways, such as
a storefront checkout. It is now skipped and logged to the file log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PayPal Orders v2 behind the gateway contract, on the base/live/stub
service-client layout:

- PayPal Gateway Settings (Sandbox/Live client credentials, webhook ID,
  return URLs) and services/paypal/{base,live,stub}.py.
- Checkout creates an order and sends the shopper to PayPal's approval
  link; the return page and the CHECKOUT.ORDER.APPROVED webhook both
  capture, and losing that race (ORDER_ALREADY_CAPTURED) counts as paid.
- Webhooks are verified by calling PayPal back with the webhook ID.
- supported_currencies() leaves out what PayPal cannot settle (SAR, AED,
  KWD, BHD, QAR, INR); HUF, JPY and TWD are charged in whole units.
- Refunds go against the completed capture and report processed or
  pending; get_refund_status settles a pending one, get_transaction_ref
  returns the capture, and create_session honours return_url.
- get_gateways() lists PayPal with REUSABLE_CHECKOUT: an approval link
  stays payable until the order is captured or expires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for the PayPal gateway.

- CHECKOUT.ORDER.APPROVED no longer captures inside the guest webhook.
  It queues sync_status, as Administrator, for our own Pending request
  with that order_ref and returns None. An order that is not ours is never
  captured, and a declined capture fails the job, not the delivery.
- The cached access token is keyed on mode and client id, so new
  credentials never create orders in the old account.
- Refund webhooks: PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED,
  PAYMENT.REFUND.PENDING and PAYMENT.REFUND.FAILED. A dashboard refund is
  found through the capture id in the refund's `up` link.
- Webhook verification stays a call back to PayPal, documented as the one
  deliberate exception to local signature checks.
- Smaller: only COMPLETED is mapped for refunds, the `approve` link
  fallback is gone, supports_currency replaces validate_paypal_currency,
  and the live client reads the error detail once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up review fixes.

- WebhookEvent gains `sync_status`: the request asks the gateway instead
  of applying `status`. CHECKOUT.ORDER.APPROVED returns one, so the capture
  runs in process_webhook with the gateway check, event id dedupe and
  Integration Request log. A failed capture is a Failed request that
  retry_webhook can replay, instead of a lost background job.
- A request that is no longer Pending is not synced, so a closed request
  never captures.
- A PayPal refund with no status stays pending instead of reading as
  Processed.
- PAYMENT.CAPTURE.REVERSED is no longer recorded as a refund. PayPal can
  reinstate a disputed capture with no webhook, and a settled Gateway
  Refund never changes.
- The PayPal-only sync_payment_request job is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review round.

- PayPal completes an order once captured, even while the capture waits
  for an eCheck or review, or was declined. get_payment_status now reads
  the capture: settled is Paid, DECLINED or FAILED is Not Paid, anything
  else stays Pending. PAYMENT.CAPTURE.DENIED syncs the request, and
  PAYMENT.CAPTURE.COMPLETED still marks it paid.
- sync_status asks the gateway only while the request is Pending. Asking
  may capture, so the Sync Status button or another app calling it can
  no longer take money for a closed request and drop the answer.
- services.GatewayUnavailableError marks a 5xx, a 429 or a dropped
  connection. process_webhook logs it as Failed, commits and raises
  RetryBackgroundJobError, so Frappe retries a capture PayPal could not
  answer and only a real decline waits for retry_webhook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ishad05
ishad05 force-pushed the feat/paypal-gateway branch from 05c6458 to 23feae1 Compare October 14, 2026 10:01
ishad05 and others added 2 commits October 14, 2026 16:20
AGENTS.md and README.md both gained a gateway on each side; kept both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PayU arrived from v1-stable without the description and docs_url that
get_gateways() shows for every other gateway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@harshtandiya
harshtandiya marked this pull request as ready for review October 15, 2026 08:11
@harshtandiya
harshtandiya merged commit cbde244 into bwhtech:v1-stable Oct 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants