diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..cecbae69 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,138 @@ +# Architecture + +The Payment app provides an abstract _PaymentController_ and specific implementations for a growing number of gateways. +These implementations are located inside the _Payment Gateways_ module. + +Inside the _Payment_ module, an additional _Payment Gateway_ DocType serves as the link target to a reference DocType (RefDoc, see below) for the respective payment gateway controller and settings. For example, the _Payment Request_ DocType links to this _Payment Gateway_ in order to implement its payment flow. + +Furthermore, upon installation, the app adds custom fields to the Web Form for facilitating web form based payments as well as a reference to the _Payment Session Log_ to the _Payment Request_ and removes them upon uninstallation. + +## Relation between RefDoc and Payment Gateway Controller + +The reference document implements the surrounding business logic, links to the _Payment Gateway_, and calls out — typically on submit — to the specific _PaymentController_ for initiating and handling the transaction. + +After the transaction has been handled, the reference document may do post-processing in business logic via one of the available hook methods on the result and remit a specific payload (e.g., redirect link / success message) to the _PaymentController_. + +During the entire lifecycle of a payment, state is maintained on the _Payment Session Log_ DocType. It allows persisting free-form data and serves as a central log for interaction with remote systems. + +### TXData, TX Reference and Correlation ID + +The data is passed from the RefDoc to the _PaymentController_ via a standardized data structure called _TXData_. + +Then, all transaction lifecycle state is stored into a _Payment Session Log_. + +The _Name_ of the _Payment Session Log_ will be the system's unique transaction reference to identify a payment transaction across its lifecycle and needs to be always passed around between the server, the client and remote systems. It may be typically stored in a gateway's request metadata in such a way that it is always returned by the remote server in order to reliably identify the transaction. + +A payment gateway's _Correlation ID_, if available, is set as the _Payment Session Log_'s `correlation_id`. If a gateway uses it for fault-tolerant deduplication, a controller should send this ID to the remote server in any request throughout the remainder of a TX lifecycle. + +If the remote server only returns a _Correlation ID_ but is unable to carry the _Payment Session Log_ name, then implementations can work around and recover the _Payment Session Log_ name, which is required by the controller methods, by filtering payment session logs on `{"correlation_id": correlation_id}`. + +### RefDoc Flow + +1. Call the _PaymentController_'s `initiate` `staticmethod` with with `tx_data` and eventually any pre-selected _Payment Gateway_, store the returning _Payment Session Log_ for further reference. +2. If called with a pre-selected _Payment Gateway_, use the returned `controller` from the previous step to call its `is_user_flow_initiation_delegated` method with the name of the _Payment Session Log_. If returning `True`, it means that the controller takes over the flow, if `False` the business logic on the RefDoc is in charge of the next steps. +4. If the user flow initiation is not delegated (to the controller): initiate/continue user flow, e.g., via Email, SMS, WhatsApp, Phone Call, etc. +5. post-process the payment status change via `on_payment_{mandate_acquisition,mandated_charge,charge}_processed` with a two-fold goal: + - Continue business logic in the backend + - Optional: return business-logic-specific `{"message": _("..."), "action": {"href": "...", "label": _("...")}}` to the controller, where: + - `message` is shown to the used + - `action` is rendered into the call to action after completing the payment + - If nothing is returned, the gateway's (if implemented) or app's standard is used + +### PaymentController Flow + +The _PaymentController_ flow has knowledge of the following flow variants: + +- Charge: a single payment +- Mandate Acquisition: acquire a mandate for present or future mandated charges +- Mandated Charge: a single payment that requires little or no user interaction thanks to a mandate + +A mandate represents some sort of pre-authorization for a future (mandated) charge. It can be used in diverse use cases such as: + +- Subscription +- SEPA Mandate +- Preauthorized Charge ("hotel booking") +- Tokenized Charge ("one-click checkout") + +Taking the flow variants into account, the high level control flow looks like this: + +1. Eventually throw on `initiate` if `validate_tx_data` throws, if there's an issue. +2. Wait for the user GO signal (e.g., via link, call, SMS, click), then `proceed`. We delay remote interactions as much as possible in order to: + - Initialize timeouts as late as possible + - Offer the customer choices until the last possible moment (among others: mandate vs charge) + - The controller can change the _TX Data_ with the user input +3. Based on this most recent _TX Data_: decide whether to initiate a Charge, a Mandate Acquisition, or a Mandated Charge. Mandate Acquisition is typically implemented as a pre-processing step of a Mandated Charge. +4. Initiate the flow via the dedicated method `_initiate_*` method and the updated _TX Data_. +6. Manage the actual payment capture along the user flow in collaboration with the payment gateway. +7. Process the result via the dedicated method `_process_response_for_{charge,mandated_charge,mandate_acquisition}`: + - Validate the response payload via `_validate_response_payload`, for example, check the integrity of the message against a pre-shared key. +8. Invoke the RefDoc's `on_payment_{mandate_acquisition,mandated_charge,charge}_processed()` method and manage the finalization user flow (e.g., via message and redirect). + +### Schematic Overview + +![Schematic Overview](./overview.excalidraw.svg "Schematic Overview") + +### Sequence Diagram + +```mermaid +sequenceDiagram + participant RefDoc + participant PaymentController + actor Payer + actor Gateway + autonumber + + rect rgb(200, 150, 255) + RefDoc->>+PaymentController: initiate(txdata, payment_gateway) + Note over PaymentController: Status - "Created" + Note over RefDoc, Gateway: Payer is instructed to proceed + Payer ->> PaymentController: proceed(pslname, updated_txdata) + Note over PaymentController: Status - "Initiated" + PaymentController->>+Gateway: _initiate_*() + alt IPN + Gateway->>-PaymentController: process_reponse(pslname, payload) + else ClientFlow + Gateway-->>Payer: + Payer->>PaymentController: process_reponse(pslname, payload) + end + end + rect rgb(70, 200, 255) + PaymentController -->> PaymentController: _validate_response() + PaymentController -->> PaymentController: _process_response_for_*() + opt RefDoc implements hook + PaymentController ->> RefDoc: on_payment_*_processed(flags, state) + RefDoc-->>PaymentController: return_value + end + PaymentController -->> PaymentController: persist status + + Note over PaymentController: Status - "Authorized|Processing|Paid|Failed|Error|Error - RefDoc" + PaymentController -->- Payer: return data to caller for rendering + end +``` +> **Notes:** +> +> - A server-to-server response from the gateway and a signed payload via the client flow may occur in parallel. +> - Thus, the blue area is expected to be **idempotent** +> - A processing lock only ensures that no parallel processing would lead to a race + +### And my Payment URL? + +The payment URL is a well-known URL with a `s` query parameter and the page at that URL can be used for capturing the user's GO signal to the Payment controller flow. + +It is kept short, tidy, and gateway-agnostic in order to impress succinct trustworthiness on the user. + +Format: `https://my.site.tld/pay?s=`. + +## Other Folders + +All general utils are stored in the [utils](payments/utils) directory. The utils are written in [utils.py](payments/utils/utils.py) and then imported into the [`__init__.py`](payments/utils/__init__.py) file for easier importing/namespacing. + +The [overrides](payments/overrides) directory has all the overrides for overriding standard Frappe code. Currently, it overrides the WebForm DocType controller as well as a WebForm whitelisted method. + +The [templates](payments/templates) directory has all the payment gateways' custom checkout pages. + +The file [`payments/types.py`](`payments/types.py) define relevant types and dataclasses for ease of developing new integrations with an IDE. +The relevant exceptions of this app can be found in [`payments/exceptions.py`](payments/exceptions.py). + +The _Payment Controller_ base class is implemented in [`payments/controllers/payment_controller.py`](payments/controllers/payment_controller.py) and a unified checkout page is implemented in [`payments/www/pay.js`](payments/www/pay.js), [`payments/www/pay.css`](payments/www/pay.css), [`payments/www/pay.html`](payments/www/pay.html) and [`payments/www/pay.py`](payments/www/pay.py), respectively. + diff --git a/README.md b/README.md index 7772261f..6ed332e6 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,21 @@ A payments app for frappe. ``` $ bench --site install-app payments ``` + +## Supported Payment Gateways -## App Structure & Details -App has 2 modules - Payments and Payment Gateways. +- Razorpay +- Stripe +- Braintree +- Paypal +- PayTM +- Mpesa +- GoCardless -Payment Module contains the Payment Gateway DocType which creates links for the payment gateways and Payment Gateways Module contain all the Payment Gateway (Razorpay, Stripe, Braintree, Paypal, PayTM) DocTypes. +## Architecture -App adds custom fields to Web Form for facilitating payments upon installation and removes them upon uninstallation. +see [Architecture Document](./ARCHITECTURE.md) -All general utils are stored in [utils](payments/utils) directory. The utils are written in [utils.py](payments/utils/utils.py) and then imported into the [`__init__.py`](payments/utils/__init__.py) file for easier importing/namespacing. - -[overrides](payments/overrides) directory has all the overrides for overriding standard frappe code. Currently it overrides WebForm DocType controller as well as a WebForm whitelisted method. - -[templates](payments/templates) directory has all the payment gateways' custom checkout pages. - -# ## License MIT ([license.txt](license.txt)) diff --git a/overview.excalidraw.svg b/overview.excalidraw.svg new file mode 100644 index 00000000..cb0e19e7 --- /dev/null +++ b/overview.excalidraw.svg @@ -0,0 +1,21 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daVPjSpb9Xr/CUf2lO6JR5768mJhcdPZcdTAwMWRcZph1mCBkW8ZcdTAwMDZjXHUwMDFiL4DpeP99bppcdTAwMDJtKVuAXHUwMDA08qum3sNV1paS8py75r3//lEq/Vx1MDAxY4573s8/Sj+9p5rbbtX77uPPf5rvXHUwMDFmvP6g1e3AJjL596A76tcmezaHw97gj3/9yz/CqXXvXo7y2t6d11x1MDAxOVx1MDAwZWC//4V/l0r/nvxcdTAwMGVcXKfv1YZu57rtTVx1MDAwZZhsXG5cXEqw6Ld73c7kslhjQZDUSr/t0erUvSdzTlx1MDAxN/kna1xyVmBcdTAwMTBDr1x1MDAwZVx1MDAxYlx1MDAxYW574PlbzFc/y62lwVx1MDAxZENef72mT5ZHjV5fnt74hzda7fbRcNyejHXQhfvzt1xyhv3urXfaqlx1MDAwZpuwlUW+fzuq7lx1MDAwZZpe4LB+d3Td7HhcdTAwMDPzUPDbt92eW2tccsfmO4Tevn15Mn+U/G/MXHJS5l/MXHUwMDFjsUBF5OLL3Xa3by7+N6wlrlx1MDAxMf/qVbd2e1xyQ+jU3/ZcdTAwMTn23c6g5/bhRfn7Pf66LSr9aze91nVzaIaItf/YXHUwMDA33uThUsqExFxmk7dccuYyvc365OX/n/9E++6dt2mO6Iza7eBT6dR/PZXXSeJPXHUwMDEz+uubP/37MPuvRqdXcIqFptnQe/JvLvD6XHUwMDE311x1MDAxZo96y/eouiDLrYej8fHa6enzz7f9/vz1N3/4o17dfZlNXHUwMDE47lYySpUgWL1tb7c6t9F7a3drt/5cdTAwMDT8XHUwMDExuJFcdTAwMThcdTAwMWNC41xmIFx1MDAwMSOehFx1MDAwNCVcdTAwMTDmiFxualx1MDAwMVx1MDAwMk5cdTAwMGZcdTAwMDT7kyg4XHUwMDEwtHJcdTAwMTTXmjNF4e8xWODcYEFcdTAwMDRyiEJII0pcdKKY8DhKNIpiREosOaEyXHUwMDAzjIQ2xMCQ5Xz1R9XtXGaPWs9cdTAwMTM8itC3a+5dqz1cdTAwMGW9xslEhqd46DVWurXLzt+ro0HLjL3U7l63av/4XHUwMDE52nOx3bo20/xnXHLuwOuHXHUwMDEwMGyBXFx522HY7flba3BtXHUwMDE3ztrfTMPl3X7rutVx25XUQ4OH4228kZ5D+FTsTlx1MDAxN2WBR1x1MDAxNlx1MDAwMTBBSFKshPTfio9gklx1MDAxZcG7cnn3XHUwMDFhy6dcdTAwMDda7bOBXFyr7a5cdTAwMWbsXHUwMDE0XHUwMDFjwZL6wHnBLHJQ4Fx1MDAwN8tEXGKThvZcdTAwMTj7OISxXHUwMDE2NtAyXHUwMDE1XHUwMDFlQFx1MDAxNMOYaklcdGhcdTAwMWb+U5pcdTAwMDdBN+iL+/OVXHUwMDFiV3ebi6RDUf9B7q28S9BxXHUwMDAyz4ZcdTAwMDexkYugk8mCXHUwMDBlS1x0XHUwMDFhn1x1MDAxNSY0PUzsT6LYMMGCXHUwMDEyh2uYeiBpXGJjOFwi6VxiT4uaz1xuPiy0ozBBXFwyzTFcdTAwMGWIsjdcYjFcdTAwMWXFjMRwXGKISvF5yHxY7r17+n5O7i3D5n633Vx1MDAwZVxus+wk3Vxmqo9KOttgMpRtjJDot29mXHUwMDFhMCUhgenqQ5alh2z3fGntabSwfF33Rlx1MDAxN5UrUnvaev6QbkpcIt8nXHUwMDFklYVqSlx1MDAxY5h4RPGX38KH5ORcdTAwMDRcbjn+RvhcdTAwMWQlk1x1MDAwMGI98+dcdTAwMTOqqvTP7VtwXFw6gauTXHUwMDAwqb7KOaakYFx1MDAwMlxid67k3O7NTne797i8UmvxpcZBY3GxU79cdMi5f9pP+3Jw+/m8ukZcdTAwMGZ3d/DtLW/vb4zocFx1MDAxMLnK6/Xdfr/7mPa8t2unm3S4U23oo03ZrO4/37mHJN15Z8tlxrlAuVx1MDAxYqBcZifKZcypxppcdTAwMTOMLCjn79Bfra+u0ChnTDhCMSpcdTAwMTWgRVx1MDAxMoqjKOdfhXIshIO4VnBccm43R0lMLFx1MDAwMzMrqSlccqhcXF8vl1x1MDAxOVdIflYuXHUwMDEzXHUwMDE0+naKXFze7LSGLVx1MDAxOEGp7I7vQk8yO+k8Q1xcRaVz8pDeJ6NfqMOmU1x1MDAwM0UkgZcrrlx1MDAxNNdW7Ir02J3OnIXErlx1MDAxMDIkocPQXHUwMDA1VDhcZpRcXKE1wVrRXHUwMDAw/WVcdTAwMGVdhVx1MDAxZI440mDdXHUwMDAwzXLu348vr1x1MDAxMVxmllx1MDAxMFxuTK84Jlwi6PF9XHUwMDAxM+FMKeBcIv82Mlx1MDAxNNdktrieJaF0cFanhfhg6PaHSzAjW53r8MB+RVx1MDAxNtJcdTAwMDDOPFxut1x1MDAxN5wnXHUwMDEzmqiNzLhcdTAwMTeQg4h59lx1MDAxYyNFzTSQOnazXqc+e1x1MDAxMPq6VmW8S0fjXHUwMDE1cnjvNVx1MDAxN8r1013LIIiDmKKcXG5cdTAwMDWmnNC+6I6MiipcdTAwMDTKM4eduVx1MDAwNOMpPqq2O1x1MDAxOC537+5aQ3ja5W6rM4w+1cnjWzS00PTc2CuHu1xubovyR8+cMayJ+X8r+WCa/OPt7//3T+veyXN8sjU2u/3z/VxifiZRX73l3nU7dVx1MDAxYvmJXHUwMDAwXCJixomCKyNMbTEkmZ78pr/5QpKfXG5ojOZcYoZYyIOApkSUPmuPMOTo4I/NPJEx3zkmXGJcdTAwMWJrXHUwMDEyZaCsfIjg7Fxu/ix75Oa6fLtdbVx1MDAxMLahT043Tlx1MDAwNqqO6tdp7YY1tE7Wu4dcdTAwMGbrT+MncTRqXHUwMDFjn7tPT1x1MDAxOdgj98/y8GhA1tu7W5Xj0V6TPd1cdTAwMWOeZ3DeT9hPrzBPllwiX1x1MDAxM2hcdTAwMGJcdTAwMDaUo3QhXHUwMDE5R5RzZKNcdTAwMGKVni7sU6LYdKGIw1x1MDAxOFx1MDAwMllEsJSSht2PjFx1MDAxYnL/XHUwMDEy8tDYXHUwMDExWGt4XHLixdCJc4eM2zlcdTAwMTRcdTAwMGXRXHUwMDA06e+0czKJu6W3c46a3VG7XrrsNN1cdTAwMDdcdTAwMGY+7tyOXHUwMDE50P981N65a9XrQe9fxOSZIVx1MDAwMqMmz6/RTcZcdTAwMTZcdTAwMWZZpmG3ZOkvMYI5JJW/h1x1MDAwZmedXHUwMDFlzvjhaUe01tlcdTAwMWTZa3SGN9Xx9fnhbbHhjIOxRnOIIDnKe2xcdTAwMTHwOlx1MDAxZVfDIN8xJTpcdTAwMTdcdTAwMDPmw/7GvFx1MDAwNPEsveHk4um6UltcdTAwMDBcdH1w2uiNXHUwMDFm1+VFtZtWXHUwMDBlP5f799XjvVx1MDAxZHfzqlW+WcbH9ZpO6W+cet5cdTAwMWK8ufd4Vju4Ol7DgzPR3NtE7uJ8yXdCXHUwMDEzXSGCMCk0XGbCwlx1MDAwN4vp+cD+5orNXHUwMDA3XHUwMDEyaYdQhJQkYHVG5bvgOjKUXGadXHUwMDFmRnegXHUwMDFhbGFGdYJEt3gupVx1MDAxNGCpI5VcdTAwMDFZzItEf3NcdTAwMTMuN93+tZeTIJ8hzVx1MDAxMn2X0UFl5rpUKPr1m1x1MDAwMMdcZnMkXHUwMDE4sVx08KX0gJ1O7sVcdTAwMDQsXHUwMDAywHKJKFx1MDAwM8pcImBcdTAwMWGHxTln3OFEXHUwMDEwUNi1UDQ/9DKEXHUwMDFkqiQo/4JcdI219NVcYt+WJ8KhsFx1MDAxMThGYlx1MDAxNZTvb5JfcSQp2Fx1MDAxNXlI/tws++HVwWBn86rfpui+PV66LovzlYV8JV7gPabyi850Sb76XHUwMDFikVx1MDAwM2+GMY2UppJcbsz9qG/p1XFJqMNcdTAwMDSRSnNcdTAwMGUqXHUwMDFkQ0rFnmwqP2nzrHO0srHJXHUwMDFifFx1MDAxM/WPatu393uVZ9ugXHUwMDE2kEMxTFx1MDAxYqYkXHUwMDEzXHUwMDFjiMD3XHUwMDE0vVxyXG5cdTAwMGJHUkpMpFppxEAsxFx1MDAwNjVXblJcIlx1MDAxZEa04CZLVEmNRfBwwVx1MDAxY1x1MDAwNjjRmiOKXHUwMDA10nrW6ZLh+XKxXGIw/dP9XGJ+vlfFmlx1MDAxMihcdTAwMDaq0vC+bKnKy+lcdNuOvGJcdTAwMTM26DhAyVxibFx1MDAxY42A73g47ZGrKanKn7W/qKO5hnePQbdcdTAwMDWQiHRcdTAwMWGW5oTD7CC/kYa1181JqZqhYUSVquA4MtKj9Fx1MDAxNDWKMkSwVMqGypX0qJzuVC4kKuGpOIBGRVx1MDAxNVx1MDAwM3woXHUwMDE2iYlQ5DDgWq2JkFx1MDAwMpH8IFxumqwjOFaCYlx1MDAxMGaAO/9VvEHUJGwxSbimIKlcdTAwMTHnsYQtIFfNJJL+MOdBi3paXHUwMDFleSv7y4PFJXVzsr300HVcdTAwMGZ2cl6Ak5tcdTAwMTZcdTAwMDVcblx1MDAwYihR8Fx1MDAwNjRcYmfJMFxyye9fXHUwMDFhi3JcdTAwMTBcdTAwMDPNXXLj7Fx1MDAxMlLHXHUwMDE1llRa1Nb246i5evH4PGyPXHUwMDFmd8tLfO+80bWrdlxmLlxi05dyhbD5kLExYdC0KFx1MDAwNVx1MDAwNCBcdTAwMDVEXHUwMDAwqoKMXHJqnrQogIpWXHUwMDE4g1x1MDAxYVx1MDAwYrdcdTAwMGJKXHUwMDA3XHRcdTAwMWW9wEhcYtVIiVnnS4bn5IQxZGakR6nod6+MbZLAKaiJNsJeTU/YduhcdTAwMTWasLFcdTAwMTTc0cxkuEv4pbn/XGImapSUTn55OpQ4ZuGjYKBNXHUwMDAxwINpc1NcdTAwMTQpXHUwMDBlskXBXFz8jfSoc2+Qk1wiNUPHiCpSoYG8T5OaklFCdGK6u2DMJFx1MDAxMlx1MDAwNXzMPjLX0iNzOrlcdTAwMTdcdTAwMTOZ8Fx1MDAxM8JcIlFTXHUwMDE2oXzWpFx1MDAwMcs1lK3ie7imppBcdTAwMTjGXHUwMDEwYFj7UCiCipRXTsYs1WvP69fLJ0/06v6WMHd/XHUwMDAzr4xcdTAwMTcqaUNB9fZJu78jr5euyue4556s3lx1MDAxZa+yT1xy9+W8q1vtxlx1MDAxM188ZfWnweCqfNha6vbq6c6bg6r4IblNaHJcblx0Y1xmKyqELYVkPT1B2F9dwVx0QipcdTAwMDfrpFx1MDAxY1x1MDAxMsryo4tcdTAwMTRJI9xCXHUwMDE2glx1MDAxMCRcdTAwMDTKYJ3nvFxi7lxyd5B/qshcZuFcdTAwMTaV4WZMuaaI4IBhXHUwMDFmTY6XYKkxTWyK9kZ6tD5W2NnWwvPqlSe3j/vsZqPPV1HB0Spk2EPJXHUwMDAysyj7XGZcdTAwMTFLxruKuyRcdTAwMTlGoP8rmcEy0swzRLKXh7PEd71xurPLK5ucnS5cZjZ38cnT3jZLK2aX3fH9jWRdgbv9ylx1MDAxNj0uN0aoloH4btRcdTAwMWa2XHUwMDA3hzfqXHUwMDFhbTbQwVx1MDAwM1m54qOb+Vx1MDAxMt862VNqXFw6lFwibOODzfR8YH9zXHUwMDA151x1MDAwM8VcdTAwMWOqkZGKXGJEOfZcdTAwMTWYXHUwMDE3V2mehjfWxCFYSyW5XHUwMDA2XHUwMDAyXHUwMDEwttVtcVx1MDAwMa5Nhlxi+Z3E91syxu6LvDTpn7kmi8xcdTAwMTBsiclcIm/jyylrhCcvSFx1MDAxN5Rjoe1Jn1vvgPBUui8mhIlWjjYxSKJcdENKhn1njFCHMMUm60GFXHUwMDEy+anjVFBcdTAwMDcxqc1aIK5hWHE0M+ZIjbRAwvwvSHy5m1x1MDAxMnBcdTAwMWGueC7lxnKLdiytXHLun9vLz0TUbjfa9z12c7Y9Lla041x1MDAxZIFcdTAwMDWYRZiYuDLDIFx1MDAxZmQ81oGVg1x1MDAxNIVBY2zCXHUwMDBiRMXjXG6pglx1MDAxZNN5plx1MDAxNIrAYCGEJEZCYY7Mcr74qEho/Vx1MDAxMZiisVHNU7RD61x1MDAxMJqEXHUwMDBlXHUwMDFlzZAjucmSXHUwMDEzhE2iXHUwMDE3s86WXGLOydlisPRP9yP4+V6dK7E2XHUwMDE2SHtpcvRtObnb6enajrti0zVcdTAwMDethzLJKWBcdTAwMGJcdTAwMWV3YFHNxF+iXHUwMDExYIvAdoW4SZ+jkZF9d9xcdTAwMDNcdTAwMTMkXHUwMDE06Gq/k1x1MDAwMpZf4GOGvpFh4CNRqVxuLuGOZc5jrYDdbWGPnfQone77LSZKwVx1MDAxYXSQYExLzomiJIJSXGZsXG6zUGlcbkKJUZ6jkYQoiDVcdTAwMDFkLyhcXI7YvJymuFx1MDAxZVxibS1cdTAwMDRcdTAwMTMmkVx1MDAxMMdycVx04iDRSSalur5Qr7qS3kZ17+jibLx038Kdw53ecmVpTvUqk0VcIjgwv1x1MDAwNu1FXHUwMDExXCJYPO2VmPxuo90gXCJNhvdcdTAwMDeTSHa3T3i5Pb5+elq72u+c3PNm/6iVkFx1MDAxZsxcYjHLNDRcdTAwMTKgzFx0WyqudExcdTAwMDFcbk7NXHUwMDEyXHUwMDEyXHUwMDAyzD/falx1MDAxNVx1MDAwNTiZxb6EMYlBXHRhwaNcdTAwMTewXGLjms1OXCJJxOfkhHFoZqRaJWfjakyNXqFsrL2bnrXt2Cs2a1x1MDAwYlPuIDFcdTAwMWJcdTAwMTdcdTAwMTgwR2/Wx/JxXHSSwFBaM/9O/vLqVG75uDNcdTAwMTSN7PJxp1x1MDAwNp54ctVEJSlhxL6yaS89NFx1MDAwZp72KnfVXHUwMDA3ctfdLFdcdTAwMGWeNsX1WbPoK5uigSdC8rNsrEuTVay0PVx1MDAwNmJcdTAwMTZcXFFcXKzA08xcdTAwMDVKg62zPXKz2yrv19xBtSHW0Mb5XHUwMDE35GFMPe/BQ+3ioHd/jo/IWflxsUFFozvO4LzLq8uNu/WVR7z3cLZzuK9XK+3K6XxcdTAwMDWeXHUwMDAyJnwsRZ8qiTFcIra0kf30fGCfXHUwMDExXHUwMDA151x1MDAwM42dSeYy/KEmdybMXHUwMDBljORcdTAwMTl4UshRVEvzh1xuUGzjbFx1MDAxMY87SbBcdTAwMDKlyVx1MDAwMf99XHUwMDA0dTSwU7rsLNbuR61cdTAwMDF8XHJTO1x1MDAxZiE+Q7jNXG4+lawjzLT0SGLzXHUwMDFhrYzqp7TNq1lOXHUwMDBm5663Kq/Wxe7m+GRU2+63+6Nn5Fx1MDAxNVx1MDAxYs5cdTAwMDTTSJqoMWW/XcArxVx1MDAxNFx1MDAxMprMV03/heWlpYvOXvn+1qupllq/662dp85cdTAwMDDJS1x1MDAxMLuN9spGXHUwMDA1jZRcdTAwMTitPy2sLy5cdTAwMDJcdTAwMWSuZ3Dev0BmyZTaI1gyrSmmzCbhXHUwMDBm0lOCfUpcdTAwMTSdXHUwMDEylCN9W5yFXHUwMDBisVx1MDAxMpZnYolyZpnicVx1MDAwMW+aXHUwMDFmwKtcdTAwMTJZ5KHNi4Qv97s10yHHKuBLh96g1+1cZvJKM5kh56KSPjrW6SPNUOJTnqzBK1xm78y8Olx1MDAwYr5cdTAwMGbT4/u00+m5V1x1MDAxZHJ4s/5wqDzZvGn0io/vsMg3Xbu+WORjXHUwMDE2biuEXHUwMDAyPoXX1lxcSjMu58zErzWbtHf/WNne9Ta6q/1cdTAwMTba7e8/fEFcdTAwMGVoUSQqnVLcXHUwMDE3xFx0MVx0izYl+yg94uyPuOCIXHUwMDAzq1x1MDAxOFG4d841Ulx1MDAwNEUyvVx1MDAxOM01WVx1MDAxMzumUZFSXG6sdbMoNo1MxVx1MDAwNKaLwEz8Ru7tiJzyszXzlqczhMhcZnn6mrWZrywlLHlVhlDMLMW2XHUwMDAxu5JcdTAwMWXYo0av+dzf17uVwcrqxePu7Vx1MDAxZCp6y1x1MDAwMoJJWDlcdTAwMTY0Tyindo9TiVx1MDAwNOZzJjtcdTAwMTlcdTAwMWRcdTAwMWSdLF93RoPubnPBXVx1MDAxOVx1MDAxZC7i1e+usNleXHUwMDFmsNb6pnykjfPnU9mvdlx1MDAwZvD2fMnkKX4vrFx1MDAxNCdEIGRcdTAwMGI5XHUwMDFmp4eu/dVcdTAwMTVcdTAwMWO6nDqAXHUwMDEzpFx1MDAxOTGWvoiW2Mwvylx1MDAwNXJcdTAwMTWo2FTEYMiYremWP0rBqFx1MDAxNOh3NHJ/ybfLTs5yeIZcdTAwMDRKksNcdTAwMTmJ38REP8pcdTAwMTLdVCCAmGl5zG1cdTAwMDA+eUdgeqrzsaA9OOHGQ0akj6OXpFx1MDAxMexgwkG5XHUwMDE2yFTa08m9vlx1MDAxYUjVXHUwMDEw+oxcXKaRXGb2OKKRwyVcdTAwMDNcdTAwMWJIwmBcdTAwMTjjWsQsXmLaQuFM1jd/Yabf3THq11x1MDAxZTduOic7+1x1MDAxN/p+ba2zUWPFyvSbXHUwMDFlmCpcdTAwMDWz6pBJXHUwMDA242D1XHUwMDEws35CwZ/Abr/S6sKNWVxi+eBcdTAwMWGK6U60wKhM7yHGTaVcZpg8RDAp/Vx1MDAwNdKBUcVGMU/JfclcYppcdTAwMWNcdTAwMWXDjn++XHUwMDFmwc935+IlXHUwMDEyq2aKmmVpNpvmND2v2tFRaMVcYmulXHUwMDFjTE1cdTAwMGZ0zkyUPeysIEw4XHUwMDEyXHUwMDBiXHUwMDAxb1x1MDAwMbiVJJcy/iypYqJcdTAwMWNJNSio2uRAU2axfWx+XHUwMDBik6mrdFx1MDAwNsvQ5kVL6niPr1x1MDAwNVx1MDAxOS471/D70Vx1MDAxZJdcdTAwMWHtIFazjfNP11x1MDAxNaJaUorhZaQnseTeXCKTdqCMWOtAnaWH83RcdTAwMGZtQdUkZVx1MDAxNqQlqklMXHUwMDEwRypcdTAwMDSKXHUwMDEyV6ZTkE52X3xeTZIz1STi8F/d/7RcdTAwMTaYaVx1MDAxY1OTQFx1MDAxMGpTM1xuz9dK07PmXk3sXTSvt5c6293a4rB6ceRcdTAwMTVLT5q5qvOXqlx1MDAxMai2XHUwMDE4btuIQIxrM5nAqoVcdPUxvWi6MzSg8NhcdTAwMTU4wjGoXHTINFx1MDAwMMZcdTAwMWNxNt+LXHUwMDFlkiEz2Vx1MDAxYVx1MDAwM0tGelx1MDAxMUuOmzKlzIpcdTAwMTNmU4zO0zOpXHUwMDFkXHUwMDBlXHUwMDA1V4yEXHUwMDA02YSQxjyikLyU3FCOMitUqITHXHUwMDAz7yM/zUhcYodpU8RS6aTm0rYsicmqOMV+o/Wfd69cdTAwMTGS2sRF8yXq0VxmXHUwMDFkIapcdTAwMWWlXHUwMDFjYlaupORCXHUwMDFjXHUwMDE4ppXpJoFsXHUwMDE5XHUwMDExXHUwMDE36ZE93Vx1MDAxM19QXHUwMDFkSaPwXCJcdTAwMDchuVx1MDAwMyaQNqXYSZ5YXHUwMDBldoppXHUwMDA23q+pa8RcdTAwMDDaiFx1MDAxOZUt8ChelSBJNNU6n1x1MDAxME9uSlx1MDAxMFx1MDAxNqOlzVx1MDAwNVQ9qJ6OWGuxeb5wy46LpVx1MDAwNE3vt1x1MDAxNNY1tFx1MDAwMP7lXHUwMDEyXlx1MDAwM1x1MDAxNlx1MDAxNFtqi+O49pFKXHUwMDA3mu6IXHUwMDBlXHJcdTAwMDIjU1x1MDAwMVx1MDAxYmQy41hg8dfzXGZcdTAwMDX7cZuFnjFsZKTzTClcdTAwMTKKXHUwMDA1J0oyaWNGNz0z2md/wXVcdTAwMWUlpziDXHUwMDA001x1MDAwZeZamaxcdTAwMTZcdIp/fi72XHUwMDBmeoNAO/7dMkONv+VcdTAwMGLVnVx1MDAxOeLe5lxyylLT8drtVm9gzVjBMtGKXHUwMDAx/V1gXHUwMDE0zMPyXHUwMDAxXU1cdTAwMGbo6cv1i1x0aEai6z1U5OI+aElDe4x9XHUwMDAytDxsvWqLw8dSVlx1MDAxNKxcXJMll09Z0U9WXHUwMDA1z35cdTAwMTnmzIb1y8u9Ja/F7lx1MDAxYZ1cdTAwMDWuO/01dry091x1MDAwNaswp563Uq/23WGvcnO1ft6snFxmzy+qOylcdTAwMTer5KDjfUje4+SoOsYgYrAk1rSYWnp+sL+6gvNcdTAwMDNcdTAwMTeOQEpgkLiUxoI/3GFMgkFkmt7BI0o2jD7LXHUwMDFkoHcgrjUyyUkqddaqSURcdTAwMDS9MItqOfNcIu+PXHUwMDA3Xr902Zkko3j1vOpdzVx1MDAxMHVRMT9cdTAwMTlUfEhZlVx1MDAxM02uXGLMMVJJla/q6ZE7nTeLiVxcrlwiXHUwMDBitaag87MpbIFT+6GbKFx1MDAxYVx1MDAxOUVcdTAwMWN/X3OP7/RcIryjkFx1MDAxM1x1MDAxMtrwLGembTClXCJeXHUwMDFmUzlg5ZrOwVx1MDAxNFFcdHb+XHUwMDA3XHUwMDEzTt6TXHUwMDA2wyaJXHUwMDE2QmouQFx1MDAwZVxiXHUwMDFlXHUwMDFiXHUwMDEznu+OZFx1MDAxMlx1MDAwN3dfXGJU4fxcdTAwMTH8/IDVQVRinjzGlJuGvtrmR/DSk9P0hr3FJCdcdTAwMTZLlJ9W0umbXGZcdTAwMGaqOZ5cdTAwMTTVLJThMbNt0FWtvrm7s7mxd77TW9PD577gXCKtXCI/vVx1MDAxM2ro+u8yXHUwMDEwbvDm3uNZ7eDqeFxyXHUwMDBmzkRzb1x1MDAxM7mLXHUwMDE5nHfzaeNpQ1xmx88nJ1x1MDAwZlx1MDAwZuWVc9f1rtrzZXiQKXWmXHUwMDEwXCJMmVx1MDAwMsFcdTAwMTaGaKRnXGL7lCg4Q1xikmx4XGJOXHUwMDFkXHUwMDFkNDyKZXdwimFPnIWq81x1MDAxZrMjaHbMXHUwMDEwdV9sdkypXHUwMDBmR7iZnNZ00et3OFxmprJmMXFrymIrszCFYmlqc4ZxK7kjJKVcdTAwMTIxIbgk+TUxkMTBhGpcIlx1MDAxNJHc2rBcdTAwMTlcdTAwMDG9aG7KRpk04tAq3DfPI1xmlFx1MDAxM86/S1x1MDAwMfhOk2U61ErhVC9cdTAwMDbPXHTsbMqJ6TUgRbz6rDJVmIWW+KXXgPpgNljqcKxcdTAwMTlcdTAwMTVnmvHAb0tN3PlOXHUwMDA3S5rk5mchPr/90/1cYn4mXHUwMDExn6FJu6c0MTRKpOJU25f+Nt9cdTAwMTFcdTAwMTk9u1x1MDAxZpYrcplcdTAwMGWWtmD27d/Qzr2bKe/lkDPCXHUwMDAzqVx1MDAxYuZcdTAwMTBcdTAwMTlsipL1kkGL/Vx1MDAxMvDu/MrwXHUwMDAymjBz/7tcdTAwMTJjv4K9otBL2JIxqEPbMkV0yFx1MDAwZqHwO1x1MDAxMZtcdTAwMTjb0IlccjxcYoZcdTAwMTdi2s/bINtKXHUwMDBmWXZ78OiOcOWE1tfxuVfZv0OrjaJDlqpwmpfKsZbtpH+T1JxhhO0t0mTcXHUwMDAzgbhp4MN/p1qVnnPtlKrjUq3dqt1cdTAwMDKUS91OyS1cdTAwMWRcdTAwMWbuXFx2uv3SwFx1MDAxZJvvLn+OvcHlz8m2Tmnz5LBcdTAwMDRGRHuyx9txl1x1MDAxM+Oh1Vx1MDAxOXmwY6tTXHUwMDFhtMwkWOi5195CrenVbrujYYKZ0vZcdTAwMWHDKUbKsNtLslBCjyVqjnzTrWXaizU5XHUwMDEzXFxz0LGItto8d+mJpHq7ua7Xdlx1MDAxNiu7t7Q8XFx4Zut7g+2CXHUwMDEzXHTVYdFcdTAwMGY6V248QqTFlsGEhOtnxfqxXHUwMDExs1x1MDAxMk6E2o7MQ1x1MDAxMZCjQatWbt+OujtbO/KC1jb02t5aWufg7drpJlx1MDAxZO5UXHUwMDFi+mhTNqv7z3fuYVx1MDAxNkVAPuF0nHrev0BcdTAwMTaFSlxccqe51FQhbKOHTnp6sE+IYtNcdTAwMDMjalLuiyDFTIeuSGlcdTAwMTGFcq1cdTAwMTFEJuW+uDJZm8q6OMTWzUJrpEDv+I1cdTAwMTaHLFx1MDAwZW5LXHUwMDEzh+GwW+q9+Fxm82qxPkPGRfWG6SPLyptcdTAwMTl41LHit4hJSnmgeZaP3W567E7n42Jil1x1MDAxM+1IXCKV0pwwk4lcdTAwMTjCLmXSIYrDfP31OzdcdTAwMWNTh5lcdTAwMGV/1DSGRVhw5r9cdTAwMGJcdTAwMWbH0qzFZ29cdTAwMDNcbmRy/opLSGxWKORTRvtzXHUwMDFlXHUwMDAxXHUwMDE4lsAoOKkz9md2z5fWnkZcdTAwMGLL13VvdFG5XCK1p63nkD/zxSNcdTAwMTjop1x1MDAxOUyAQFx1MDAxMlx1MDAxZZzQXHUwMDE4aFRSRSmO3Wwq9+V04L9cckJZXHUwMDA2YVx1MDAxY5rIuFeF6bJgllx1MDAxYypcdTAwMTl/5PPkvUye0uYnNpn90/1cYn6+m+gwSyY6zqhcdTAwMTaKcJuS0ktPdNNcdTAwMTXETIiua15wlkQnpFx1MDAwZedIhJVcdTAwMTStXHUwMDFkJolAglxiXHJaXFx+RFx1MDAwN/ByqOlgXHUwMDBiXFxLiNaWamjMIabrLCdSKoEpj1c8XHUwMDE02nRcdTAwMDNcdTAwMTHkd8w1m8kxwVx1MDAxNWtcdTAwMThcdTAwMDObSSlcdTAwMThmjFx1MDAwN5dWvdLhx3juPcEjrlx1MDAxOVx1MDAwMu1cdTAwMTgpk+SvLaEjXGYyVlx1MDAwMttcdCm4KUM731x1MDAwYthcdTAwMTLnt/mJzeysSI+IxDQ0XHUwMDA285Ihe6z6Pj3nTTdei8p54eInXHUwMDEyIcf0QZfwglx1MDAxOFxintxITlx1MDAwNYbmd3+VKnT1eEBcdTAwMWFUXHUwMDEwykDsXHUwMDE3UH8rXHUwMDEyrVx1MDAwMUlIXHUwMDA0MKJcboxeaSGUj5Fa6iRe0yFcdTAwMTZMfdDUgFxcOcO2IWBcdTAwMDc2mKKfhFx1MDAwMtBcdTAwMDWf70A00+FVuYxEZvKsXHUwMDEzXHUwMDA0XHUwMDAwMTlBXHUwMDFjXHUwMDBi72TCpOC1SEzaYZJrrG2Zdv30LPh02+qP75/LO0ekeYRHj7dnJ62MNb/MTVxcXHUwMDFkycVlU9btfpb4LLTH402uMeLw6r9vxd9/XCLX5ud9XHUwMDE0XHUwMDEwxi/Pqj0yTa5WTZCWwK+Y2/LnXHUwMDA36TFb3u2uXHUwMDFj7ZZ3bpv6XvVP6J08PtFcdTAwMDXHrIpcdTAwMTQowVwiP2WFUDnpskjgXCJgXHUwMDFkS4tcdTAwMDdcdTAwMTlbYteKMEQxzlwieW5eXFzIXHUwMDE1mMyDUrfxWuBwUDIh3z8uO1x1MDAwYqWjUXVQ67d6psvR5N+r5cXXjlxy5t/lvueOhs1uXHUwMDFmrvnaweGyUyr9/fJnszv02qWBV1x1MDAxYvVhelxc/vyH2b9cdTAwMDKvuFx1MDAxM9+32/FewsSl1zgw7P+1Ue7CP4T3OcxcdTAwMTNcdTAwMTWJRC+SZlxmJJiNlIbpSel4XHUwMDEzN/Da3XnncHWjo+pXg4WHjIuD5OErXHUwMDBmK1x1MDAxMoE80K9QJEhMkVCaU1Ob7j9cdTAwMTlwc6pHkKzUXGLMXHUwMDEzc1Y5psxcdTAwMTSotPk/Ru/wf2h5vPiwdtPcKN/Wm4e9m1uxfFdwwDLCXHUwMDFjXCKxSSCW2qyTXHUwMDBlw1fn2NlcdTAwMWRhh4GhQVx1MDAxMlx1MDAxNFxuXHUwMDE2X8vPNFx1MDAxM4SJLNJZv1efXGJcdTAwMTRpm6FPnLqtYanR7ZdGJvrb6pS6/fokXGZ8XHQztO2OS95cdTAwMDNcZnrktkvD1p1cdTAwMDdy7sPrbj4q7D8zwlxm89J4cktcdTAwMWJcdTAwMDRmJMJU2uD9kFx1MDAxZd7q7Flccq7GXHUwMDE341q9+njaYaw32KlcdTAwMTZcdTAwMWPehFx1MDAxOTdTXGLThLDERLHMm7NbVtkyXHUwMDFkiTLFSlx1MDAxOU6WTlGSRVnSL8xTqyyK3d7e7tJcdTAwMDM7cVx1MDAxMdtw15bvq/vf3ZL5L9CsakqsXHUwMDE2TVwi8koqXHUwMDBisFx1MDAxZt8ht61vrujApsyhlCtOuVx1MDAxNEKEIM54flo36NJcdTAwMGUlimiimFx1MDAxMNK2jt5cIrlNhFx1MDAwZo6U3ym5OVVBXHUwMDFkMG/JfVxi+jLIwUNvMGpcdTAwMGYvO//eheG7194/S8uV9cU/c0ormyGjorI77Vx1MDAxOLPsXHUwMDFhiZLXzEqsiCTBXGZzXHUwMDFmz0/p8fxYZpt3j2q/fXXQ7i9ccnY6q1xu3Vx1MDAxN1x1MDAxY88s0Fx1MDAwNNdcdTAwMWOhp/RRySd/XFyH+y/H1pKZIFx1MDAxNctmRftcdTAwMTdK5fMtctxjdLmy8zSoPVXvn9Vp+bSAUi7YiCRcdTAwMWGVMqXniFxiTFxiXHUwMDFmXHUwMDE14/SosD+JgqPCWKdcdTAwMTiZMjmEU43CYk6raVx1MDAxNWM+jVx1MDAxMlx1MDAwMmdcdTAwMDf5JolmXHUwMDFhZoDNRo37vDlcdTAwMDIhw36rnozdztUvP+/Vf1xyhu5wNPjvy1x1MDAwZXz5UtQ18J0vXlx1MDAwZb16y0iJvMTgXGZcdFx1MDAxMFx1MDAxNYPZ3EFmXHUwMDA1+ZOIXHUwMDAw5lwiNlx1MDAwNp2FXHUwMDA3ntPzwHRTo5BcdTAwMTUlgFx1MDAxY2nUipXMYUyYJU1EKDWlleOnV21aWzeagqCEIT75XHUwMDBmx1x1MDAxN1uBbaIp2Cff1rvxO1x1MDAxM3VSNzI0te4kw1xcUmQqgkhcdTAwMWTMvX6tXHUwMDFhwWM3mypXZ7oqXHUwMDFjXHUwMDFhhNSWQv3xXHUwMDE0oblKx4m0S4zOV//4XHUwMDFmwc/3p1InOtVcdTAwMTXCXHUwMDEyISotynw1WPd9XHUwMDE2X013YVx1MDAxNJWvkFx1MDAxM21cdTAwMWaSI0PZXHUwMDFhhsRcYomDtc5hYP5Qf1x1MDAxZkJK3T3DLLGgVFxi4zJcdTAwMDZe4ibkXHUwMDEwL3Wpc+ckXHUwMDE4h1RcdTAwMThR4zahXHUwMDE0m84ucYaa84TBSFx1MDAxN493MlJSUJ7QxDRnTFxivFtl9S9UXHUwMDExTk9JO9vXg90nedDcXHUwMDAzQfdwdrS/c+ouXHUwMDE1PNFcdTAwMTmei4pwXHUwMDEy/cJIgDXbXHUwMDE58Vx1MDAxOZFcdTAwMDDGXHUwMDAxXHUwMDAyXFyQvzJn/dWi98HSwCVTPX7WXHUwMDAxXHUwMDBiNHREfFZkxFxmlCUzg9CSJvhYqoikZ4YjekbvXHUwMDE3XHUwMDFmnlx1MDAwZk+PN6737p/Pz9eed4rOXGZgU0aYIc+yfKCIWNyPRHNHIK6oJohcdTAwMTEpqaXBmVkoqLj6XHUwMDBmXHUwMDE5zFx1MDAwZlx1MDAxOSwoXHUwMDE23J9cYu2AXHUwMDE2ylx1MDAxMWHUNKyjs8khOF0mp4hNlHfSw9ToXHUwMDA0XHKImli0kVFTTU/50FxmcFx1MDAwNE3PXHUwMDExqNypjKW8XHUwMDFkl+nF8+79inbHXCLj8ETmXHUwMDFjQXWcXCLS6lx1MDAwZZ9uiEhcdTAwMDIuLz/IaCmVhYDCebBcYt88RCjaXHUwMDBiXHUwMDFi5d3Tepfs15e2nm8vXHUwMDBl1m4rK++KUDAlZCB3LadcYlx1MDAwNVx1MDAxNYlcdTAwMTXkTOFDLsFgYTZosHfY+tZnUWxoYIqoWbOJXHUwMDE1plowXHUwMDE1aVx1MDAwMizlV+GEKukwXHUwMDAxdClcdJLI3lx1MDAwNJjGUCONn9Is6P48aD5cdTAwMWOwmEzgXHUwMDBmVX5cYlx1MDAwNixU6NspXHUwMDAxi6MhXFz+j1LZXHUwMDFkm8GVjmD0MJlLO91AXeZMw1x1MDAxMTNcdTAwMTg/XHUwMDFhjkgzvoyCXHLBZrPRpFhcdTAwMDSqt1x1MDAxMNiqXHUwMDEy8/SYbtRcdTAwMWa2XHUwMDA3hzfqXHUwMDFhbTbQwVx1MDAwM1m54qObgmNcdTAwMWFUYlx1MDAxMTOW80uF5dpcIuBwsMLCL1x0R7BGXG7lJOFya0dBe9X6iLisR/XB4PHwcEyuym6xevymjlgsYOMgNFx1MDAxZEFcdTAwMTRcdTAwMDGJaKqDXHUwMDA39vpkyOKxws62XHUwMDE2nlevPLl93Gc3XHUwMDFifb6KXHUwMDEyRoFN41x1MDAwM0Y5U8D7SMd7XHUwMDA0YZuXMmtcdTAwMDfhsN+K6NLZmlx1MDAwZqDnXHUwMDA3XHUwMDBmYCmO8LFkflx1MDAwMEX+IT+Cn+/Wf6Z08ZFcdTAwMWNcdTAwMDRYQqhDpKdKO1CKTZWYK+zAzStqqr5LLsMrXGIoz482saBcdTAwMGXnhJpcdTAwMTZYRvGydeZcYqhcdTAwMTVvXHUwMDE5XHUwMDFhcFM0k1xuWPOyiODOXHUwMDFkl17SXHUwMDE12uPSa93YnHSdXHUwMDE54j6q68xcdTAwMThahomHjCZcdTAwMTemVKZcdTAwMDRcdTAwMGWyXHUwMDFh9jI9fFx1MDAwZlx1MDAwZbb22mqwS1eGXHUwMDE3g62jk9bNzs1W0TOsMHI0XHUwMDE2/k+8vSeA5a1cblx1MDAxN81xPVx1MDAxMEVOsI6etKBZYIdcdTAwMDWL7cWsXHUwMDE5oVx1MDAwNZfBQMI8eFx1MDAwME4uyjfu2sXN8cnR4VHz9LDFXHUwMDFhu9X3eVx1MDAwMIBcdTAwMDBcdTAwMDNlLfLyXHUwMDAwTGlkp6RRSFx1MDAwMn2QXHUwMDAyXHUwMDEwUukhZH9cdTAwMTRcdTAwMDWHXHUwMDEwpcapSanGSDGCw0mKhCNcdTAwMDeey9cst+FcdTAwMGWMQpo/ptBzulx1MDAwNrlcdTAwMTJj00FSfqtcdTAwMDPgvfPXJlx1MDAwZtNnLG50u7d/XFx2XHUwMDFlXFyYQCbpb/h0XHUwMDA1n25OXHUwMDEycYZYiErEmYNLkIk/fjHET7fXm3hcdTAwMTDeSOznQ8t7XFyKT6a/NSY/hmkmVGDg5U24789cdTAwMWZ//j+z0fGHIn0= + + + + + RefDoc(business logic)ControllerInitiate PaymentShould have mandate?Initiate ChargeNoYesHas mandate?Initiate Mandated ChargeYesNoInitiate Mandate AcquisitionProcess Mandate Acquisition ResponseProcess Mandated Charge ResponseProcess Charge Responsenew mandategateway flowmandated chargegateway flownew chargegateway flowUser ProceedsUser Proceedse.g. by clicking on a URLor saying "yes" on an IVR callor clicking "continue" in single-page-checkoutAsk User to proceed?Types of mandates e.g.:- Subscription- SEPA Mandate- Preauthorized Charge ("hotel security")- Tokenized Charge ("one click checkout")Wait for user in order todelay eventual timeoutsRender Result{Message, CTGA}on_mandate_<status>on_charge_<status>{Message, Redirect}State: Payment Session Logmay directly continueHook:validate_tx_data \ No newline at end of file diff --git a/payments/controllers/__init__.py b/payments/controllers/__init__.py new file mode 100644 index 00000000..d101171b --- /dev/null +++ b/payments/controllers/__init__.py @@ -0,0 +1 @@ +from .payment_controller import PaymentController, frontend_defaults diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py new file mode 100644 index 00000000..0a32709d --- /dev/null +++ b/payments/controllers/payment_controller.py @@ -0,0 +1,797 @@ +import json + +from urllib.parse import urlencode, quote + +from requests.exceptions import HTTPError + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.base_document import get_controller +from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.desk.form.load import get_document_email +from payments.payments.doctype.payment_session_log.payment_session_log import ( + create_log, +) +from frappe.utils import get_url + +from payments.utils import PAYMENT_SESSION_REF_KEY + +from payments.exceptions import ( + FailedToInitiateFlowError, + PayloadIntegrityError, + PaymentControllerProcessingError, + RefDocHookProcessingError, +) + +from payments.types import ( + Initiated, + TxData, + _Processed, + Processed, + PSLName, + PaymentUrl, + PaymentMandate, + SessionType, + Proceeded, + RemoteServerInitiationPayload, + GatewayProcessingResponse, + SessionStates, + FrontendDefaults, + ActionAfterProcessed, +) + +from typing import TYPE_CHECKING, Optional, overload +from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, +) + +if TYPE_CHECKING: + from payments.payments.doctype.payment_gateway.payment_gateway import PaymentGateway + + +def _error_value(error, flow): + return _( + "Our server had a problem processing your {0}. Please contact customer support mentioning: {1}" + ).format(flow, error) + + +def _help_me_develop(state): + from pprint import pprint + + print("self.state: ") + pprint(state) + + +class PaymentController(Document): + """This controller implemets the public API of payment gateway controllers.""" + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + frontend_defaults: FrontendDefaults + flowstates: SessionStates + + def __new__(cls, *args, **kwargs): + assert hasattr(cls, "flowstates") and isinstance( + cls.flowstates, SessionStates + ), """the controller must declare its flow states in `cls.flowstates` + and it must be an instance of payments.types.SessionStates + """ + assert hasattr(cls, "frontend_defaults") and isinstance( + cls.frontend_defaults, FrontendDefaults + ), """the controller must declare its flow states in `cls.frontend_defaults` + and it must be an instance of payments.types.FrontendDefaults + """ + return super().__new__(cls) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = frappe._dict() + + @overload + @staticmethod + def initiate( + tx_data: TxData, gateway: str, correlation_id: str | None, name: str | None + ) -> PSLName: + ... + + @staticmethod + def initiate( + tx_data: TxData, + gateway: "PaymentController" = None, + correlation_id: str = None, + name: str = None, + ) -> ("PaymentController", PSLName): + """Initiate a payment flow from Ref Doc with the given gateway. + + Inheriting methods can invoke super and then set e.g. correlation_id on self.state.psl to save + and early-obtained correlation id from the payment gateway or to initiate the user flow if delegated to + the controller (see: is_user_flow_initiation_delegated) + """ + if isinstance(gateway, str): + payment_gateway: PaymentGateway = frappe.get_cached_doc("Payment Gateway", gateway) + + if not payment_gateway.gateway_controller and not payment_gateway.gateway_settings: + frappe.throw( + _( + "{0} is not fully configured, both Gateway Settings and Gateway Controller need to be set" + ).format(gateway) + ) + + self = frappe.get_cached_doc( + payment_gateway.gateway_settings, + payment_gateway.gateway_controller or payment_gateway.gateway_settings, # may be a singleton + ) + else: + self = gateway + + # self.validate_tx_data(tx_data) # preflight check + + psl = create_log( + tx_data=tx_data, + controller=self, + status="Created", + ) + return self, psl.name + + @staticmethod + def get_payment_url(psl_name: PSLName) -> PaymentUrl | None: + """Use the payment url to initiate the user flow, for example via email or chat message. + + Beware, that the controller might not implement this and in that case return: None + """ + params = { + PAYMENT_SESSION_REF_KEY: psl_name, + } + return get_url(f"./pay?{urlencode(params)}") + + @staticmethod + def pre_data_capture_hook(psl_name: PSLName) -> dict: + """Call this before presenting the user with a form to capture additional data. + + Implementation is optional, but can be used to acquire any additonal data from the remote + gateway that should be present already during data capture. + """ + + psl: PaymentSessionLog = frappe.get_cached_doc("Payment Session Log", psl_name) + self: "PaymentController" = psl.get_controller() + data = self._pre_data_capture_hook() + psl.update_gateway_specific_state(data, "Data Capture") + return data + + @staticmethod + def proceed(psl_name: PSLName, updated_tx_data: TxData = None) -> Proceeded: + """Call this when the user agreed to proceed with the payment to initiate the capture with + the remote payment gateway. + + If the capture is initialized by the gatway, call this immediatly without waiting for the + user OK signal. + + updated_tx_data: + Pass any update to the inital transaction data; this can reflect later customer choices + and thereby modify the flow + + Example: + ```python + if controller.is_user_flow_initiation_delegated(): + controller.proceed() + else: + # example (depending on the doctype & business flow): + # 1. send email with payment link + # 2. let user open the link + # 3. upon rendering of the page: call proceed; potentially with tx updates + pass + ``` + """ + + psl: PaymentSessionLog = frappe.get_cached_doc("Payment Session Log", psl_name) + self: "PaymentController" = psl.get_controller() + + psl.update_tx_data(updated_tx_data or {}, "Started") # commits + + self.state = psl.load_state() + # controller specific temporary modifications + self.state.tx_data = self._patch_tx_data(self.state.tx_data) + self.state.mandate: PaymentMandate = self._get_mandate() + + try: + frappe.flags.integration_request_doc = psl # for linking error logs + + if self._should_have_mandate() and not self.mandate: + self.state.mandate = self._create_mandate() + initiated = self._initiate_mandate_acquisition() + psl.db_set( + { + "processing_response_payload": None, # in case of a reset + "flow_type": SessionType.mandate_acquisition, + "correlation_id": initiated.correlation_id, + "mandate": f"{self.state.mandate.doctype}[{self.state.mandate.name}]", + }, + # commit=True, + ) + psl.set_initiation_payload(initiated.payload, "Initiated") # commits + return Proceeded( + integration=self.doctype, + psltype=SessionType.mandate_acquisition, + mandate=self.state.mandate, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + elif self.state.mandate: + initiated = self._initiate_mandated_charge() + psl.db_set( + { + "processing_response_payload": None, # in case of a reset + "flow_type": SessionType.mandated_charge, + "correlation_id": initiated.correlation_id, + "mandate": f"{self.state.mandate.doctype}[{self.state.mandate.name}]", + }, + # commit=True, + ) + psl.set_initiation_payload(initiated.payload, "Initiated") # commits + return Proceeded( + integration=self.doctype, + psltype=SessionType.mandated_charge, + mandate=self.state.mandate, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + else: + initiated = self._initiate_charge() + psl.db_set( + { + "processing_response_payload": None, # in case of a reset + "flow_type": SessionType.charge, + "correlation_id": initiated.correlation_id, + }, + # commit=True, + ) + psl.set_initiation_payload(initiated.payload, "Initiated") # commits + return Proceeded( + integration=self.doctype, + psltype=SessionType.charge, + mandate=None, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + + # some gateways don't return HTTP errors ... + except FailedToInitiateFlowError as e: + psl.set_initiation_payload(e.data, "Error") + error = psl.log_error(title=e.message) + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("Please contact customer care mentioning: {0} and {1}").format(psl, error), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + + # ... yet others do ... + except HTTPError as e: + data = frappe.flags.integration_request.json() + psl.set_initiation_payload(data, "Error") + error = frappe.get_last_doc("Error Log") + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("Please contact customer care mentioning: {0} and {1}").format(psl, error), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + + except Exception as e: + error = psl.log_error(title="Unknown Initialization Failure") + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("Please contact customer care mentioning: {0}").format(error), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + + def __process_response( + self, + psl: PaymentSessionLog, + response: GatewayProcessingResponse, + ref_doc: Document, + callable, + hookmethod, + psltype, + ) -> Processed | None: + processed = None + try: + processed = callable() # idempotent on second run + except Exception: + raise PaymentControllerProcessingError(f"{callable} failed", psltype) + + assert self.flags.status_changed_to in ( + self.flowstates.success + + self.flowstates.pre_authorized + + self.flowstates.processing + + self.flowstates.declined + ), "self.flags.status_changed_to must be in the set of possible states for this controller:\n - {}".format( + "\n - ".join( + self.flowstates.success + + self.flowstates.pre_authorized + + self.flowstates.processing + + self.flowstates.declined + ) + ) + + ret = { + "status_changed_to": self.flags.status_changed_to, + "payload": response.payload, + } + + changed = False + is_success = self.flags.status_changed_to in self.flowstates.success + is_pre_authorized = self.flags.status_changed_to in self.flowstates.pre_authorized + is_processing = self.flags.status_changed_to in self.flowstates.processing + is_declined = self.flags.status_changed_to in self.flowstates.declined + + if is_success: + changed = "Paid" != psl.status + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, "Paid") # commits + ret["indicator_color"] = "green" + processed = processed or Processed( + message=_("{} succeeded").format(psltype.title()), + action=dict(href="/", label=_("Go to Homepage")), + **ret, + ) + elif is_pre_authorized: + changed = "Authorized" != psl.status + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, "Authorized") # commits + ret["indicator_color"] = "green" + processed = processed or Processed( + message=_("{} authorized").format(psltype.title()), + action=dict(href="/", label=_("Go to Homepage")), + **ret, + ) + elif is_processing: + changed = "Processing" != psl.status + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, "Processing") # commits + ret["indicator_color"] = "yellow" + processed = processed or Processed( + message=_("{} awaiting further processing by the bank").format(psltype.title()), + action=dict(href="/", label=_("Refresh")), + **ret, + ) + elif is_declined: + changed = "Declined" != psl.status + psl.db_set( + { + "decline_reason": self._render_failure_message(), + "button": None, # reset the button for another chance + } + ) + psl.set_processing_payload(response, "Declined") # commits + ret["indicator_color"] = "red" + + incoming_email = get_document_email( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + if not incoming_email: + email = EmailAccount.find_default_incoming() + incoming_email = email.email_id if email else None + + if incoming_email: + params = { + "subject": _("Help! Payment declined: {}, {}").format( + self.state.tx_data.reference_docname, psl.name + ), + "body": _("Please help me with:\n - PSL: {}\n - RefDoc: {}\n\nThank you!").format( + frappe.utils.get_url_to_form("Payment Session Log", psl.name), + frappe.utils.get_url_to_form( + self.state.tx_data.reference_doctype, self.state.tx_data.reference_docname + ), + ), + } + href = f"mailto:{incoming_email}?{urlencode(params, quote_via=quote)}" + action = dict(href=href, label=_("Email Us")) + else: + action = dict(href=self.get_payment_url(psl.name), label=_("Refresh")) + processed = processed or Processed( + message=_("{} declined").format(psltype.title()), + action=action, + **ret, + ) + + try: + ref_doc.flags.payment_session = frappe._dict( + changed=changed, + is_success=is_success, + is_pre_authorized=is_pre_authorized, + is_processing=is_processing, + is_declined=is_declined, + state=self.state, + psl=psl, + ) # when run as server script: can only set flags + res = ref_doc.run_method( + hookmethod, + status=frappe._dict( + changed=changed, + is_success=is_success, + is_pre_authorized=is_pre_authorized, + is_processing=is_processing, + is_declined=is_declined, + ), + state=self.state, + psl=psl, + ) + # result from server script run + res = ref_doc.flags.payment_session.result or res + if res: + # type check the result value on user implementations + res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ + _res = _Processed(**res) + processed = Processed(**(ret | _res.__dict__)) + except Exception as e: + # Ensure no details are leaked to the client + frappe.local.message_log = [ + { + "message": _("Server Processing Failure!"), + "subtitle": _("(during RefDoc processing)"), + "body": str(e), + "indicator": "red", + } + ] + raise RefDocHookProcessingError(psltype) from e + + return processed + + def _process_response( + self, psl: PaymentSessionLog, response: GatewayProcessingResponse, ref_doc: Document + ) -> Processed: + self._validate_response() + + match psl.flow_type: + case SessionType.mandate_acquisition: + self.state.mandate: PaymentMandate = self._get_mandate() + processed: Processed = self.__process_response( + psl=psl, + response=response, + ref_doc=ref_doc, + callable=self._process_response_for_mandate_acquisition, + hookmethod="on_payment_mandate_acquisition_processed", + psltype="mandate adquisition", + ) + case SessionType.mandated_charge: + self.state.mandate: PaymentMandate = self._get_mandate() + processed: Processed = self.__process_response( + psl=psl, + response=response, + ref_doc=ref_doc, + callable=self._process_response_for_mandated_charge, + hookmethod="on_payment_mandated_charge_processed", + psltype="mandated charge", + ) + case SessionType.charge: + processed: Processed = self.__process_response( + psl=psl, + response=response, + ref_doc=ref_doc, + callable=self._process_response_for_charge, + hookmethod="on_payment_charge_processed", + psltype="charge", + ) + + return processed + + @staticmethod + def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> Processed: + """Call this from the controlling business logic; either backend or frontend. + + It will recover the correct controller and dispatch the correct processing based on data that is at this + point already stored in the integration log + + payload: + this is a signed, sensitive response containing the payment status; the signature is validated prior + to processing by controller._validate_response + """ + + psl: PaymentSessionLog = frappe.get_cached_doc("Payment Session Log", psl_name) + self: "PaymentController" = psl.get_controller() + + # guard against already currently being processed payloads via another entrypoint + if psl.is_locked: + psl.lock(timeout=5) # allow ample 5 seconds to finish + psl.reload() + else: + psl.lock() + + self.state = psl.load_state() + self.state.response = response + + ref_doc = frappe.get_doc( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + + mute = self._is_server_to_server() + + def get_compensatatory_action(error_log): + + incoming_email = get_document_email( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + if not incoming_email: + email = EmailAccount.find_default_incoming() + incoming_email = email.email_id if email else None + + if incoming_email: + params = { + "subject": _("Payment Server Error: {}").format(error_log), + "body": _("Reference:\n\n - PSL: {}\n - Error Log: {}\n - RefDoc: {}\n\nThank you!").format( + frappe.utils.get_url_to_form("Payment Session Log", psl.name), + frappe.utils.get_url_to_form("Error Log", error_log.name), + frappe.utils.get_url_to_form( + self.state.tx_data.reference_doctype, self.state.tx_data.reference_docname + ), + ), + } + href = f"mailto:{incoming_email}?{urlencode(params, quote_via=quote)}" + action = dict(href=href, label=_("Email Us")) + else: + action = dict(href="/", label=_("Go to Homepage")) + return action + + try: + processed = self._process_response(psl, response, ref_doc) + if self.flags.status_changed_to in self.flowstates.declined: + try: + msg = self._render_failure_message() + ref_doc.flags.payment_failure_message = msg + ref_doc.run_method("on_payment_failed", msg) + except Exception: + # Ensure no details are leaked to the client + frappe.local.message_log = [] + psl.log_error("Setting failure message on ref doc failed") + + except PayloadIntegrityError: + error = psl.log_error("Response validation failure") + if not mute: + return Processed( + message=_("There's been an issue with your payment."), + action=get_compensatatory_action(error), + status_changed_to=_("Server Error"), + indicator_color="red", + payload={}, + ) + + except PaymentControllerProcessingError as e: + error = psl.log_error(f"Processing error ({e.psltype})") + psl.set_processing_payload(response, "Error") + if not mute: + return Processed( + message=_error_value(error, e.psltype), + action=get_compensatatory_action(error), + status_changed_to=_("Server Error"), + indicator_color="red", + payload={}, + ) + + except RefDocHookProcessingError as e: + error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)") + psl.set_processing_payload(response, "Error - RefDoc") + if not mute: + return Processed( + message=_error_value(error, f"{e.psltype} (via ref doc hook)"), + action=get_compensatatory_action(error), + status_changed_to=_("Server Error"), + indicator_color="red", + payload={}, + ) + else: + return processed + finally: + psl.unlock() + + # Lifecycle hooks (contracts) + # - imeplement them for your controller + # --------------------------------------- + + def validate_tx_data(self, tx_data: TxData) -> None: + """Invoked by the reference document for example in order to validate the transaction data. + + Should throw on error with an informative user facing message. + """ + raise NotImplementedError + + def is_user_flow_initiation_delegated(self, psl_name: PSLName) -> bool: + """If true, you should initiate the user flow from the Ref Doc. + + For example, by sending an email (with a payment url), letting the user make a phone call or initiating a factoring process. + + If false, the gateway initiates the user flow. + """ + return False + + # Concrete controller methods + # - imeplement them for your gateway + # --------------------------------------- + + def _patch_tx_data(self, tx_data: TxData) -> TxData: + """Optional: Implement tx_data preprocessing if required by the gateway. + For example in order to fix rounding or decimal accuracy. + """ + return tx_data + + def _pre_data_capture_hook(self) -> dict: + """Optional: Implement additional server side control flow prior to data capture. + For example in order to fetch additional data from the gateway that must be already present + during the data capture. + + This is NOT used in Buttons with the Third Party Widget implementation variant. + """ + return {} + + def _should_have_mandate(self) -> bool: + """Optional: Define here, if the TxData store in self.state.tx_data should have a mandate. + + If yes, and the controller hasn't yet found one from a call to self._get_mandate(), + it will initiate the adquisition of a new mandate in self._create_mandate(). + + You have read (!) access to: + - self.state.psl + - self.state.tx_data + """ + assert self.state.psl + assert self.state.tx_data + return False + + def _get_mandate(self) -> PaymentMandate: + """Optional: Define here, how to fetch this controller's mandate doctype instance. + + Since a mandate might be highly controller specific, this is its accessor. + + You have read (!) access to: + - self.state.psl + - self.state.tx_data + """ + assert self.state.psl + assert self.state.tx_data + return None + + def _create_mandate(self) -> PaymentMandate: + """Optional: Define here, how to create controller's mandate doctype instance. + + Since a mandate might be highly controller specific, this is its constructor. + + You have read (!) access to: + - self.state.psl + - self.state.tx_data + """ + assert self.state.psl + assert self.state.tx_data + _help_me_develop(self.state) + return None + + def _initiate_mandate_acquisition(self) -> Initiated: + """Invoked by proceed to initiate a mandate acquisiton flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _initiate_mandated_charge(self) -> Initiated: + """Invoked by proceed or after having aquired a mandate in order to initiate a mandated charge flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _initiate_charge(self) -> Initiated: + """Invoked by proceed in order to initiate a charge flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _validate_response(self) -> None: + """Implement how the validation of the response signature + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _process_response_for_mandate_acquisition(self) -> Processed | None: + """Implement how the controller should process mandate acquisition responses + + Needs to be idenmpotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _process_response_for_mandated_charge(self) -> Processed | None: + """Implement how the controller should process mandated charge responses + + Needs to be idenmpotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _process_response_for_charge(self) -> Processed | None: + """Implement how the controller should process charge responses + + Needs to be idenmpotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _render_failure_message(self) -> str: + """Extract a readable failure message out of the server response + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + - self.state.mandate; if mandate is involved + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _is_server_to_server(self) -> bool: + """If this is a server to server processing flow. + + In this case, no errors will be returned. + + Implementations can read: + - self.state.response + """ + _help_me_develop(self.state) + raise NotImplementedError + + +@frappe.whitelist() +def frontend_defaults(doctype): + c: PaymentController = get_controller(doctype) + if issubclass(c, PaymentController): + d: FrontendDefaults = c.frontend_defaults + return d.__dict__ diff --git a/payments/exceptions.py b/payments/exceptions.py new file mode 100644 index 00000000..a8395aad --- /dev/null +++ b/payments/exceptions.py @@ -0,0 +1,22 @@ +from frappe.exceptions import ValidationError + + +class FailedToInitiateFlowError(Exception): + def __init__(self, message, data): + self.message = message + self.data = data + + +class PayloadIntegrityError(ValidationError): + pass + + +class PaymentControllerProcessingError(Exception): + def __init__(self, message, psltype): + self.message = message + self.psltype = psltype + + +class RefDocHookProcessingError(Exception): + def __init__(self, psltype): + self.psltype = psltype diff --git a/payments/hooks.py b/payments/hooks.py index caa07d27..7d9b92dc 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -108,6 +108,10 @@ # } # } +ignore_links_on_delete = [ + "Payment Session Log", +] + # Scheduled Tasks # --------------- @@ -179,3 +183,5 @@ # Recommended only for DocTypes which have limited documents with untranslated names # For example: Role, Gender, etc. # translated_search_doctypes = [] + +export_python_type_annotations = True diff --git a/payments/overrides/payment_webform.py b/payments/overrides/payment_webform.py index 3b2d7afa..c99d0555 100644 --- a/payments/overrides/payment_webform.py +++ b/payments/overrides/payment_webform.py @@ -6,7 +6,7 @@ from frappe.utils import flt from frappe.website.doctype.web_form.web_form import WebForm -from payments.utils import get_payment_gateway_controller +from payments.utils import get_payment_controller class PaymentWebForm(WebForm): @@ -24,7 +24,7 @@ def validate_payment_amount(self): def get_payment_gateway_url(self, doc): if getattr(self, "accept_payment", False): - controller = get_payment_gateway_controller(self.payment_gateway) + controller = get_payment_controller(self.payment_gateway) title = f"Payment for {doc.doctype} {doc.name}" amount = self.amount diff --git a/payments/patches.txt b/payments/patches.txt index e69de29b..591f9eb7 100644 --- a/payments/patches.txt +++ b/payments/patches.txt @@ -0,0 +1,4 @@ +[pre_model_sync] +[post_model_sync] +execute:import payments.utils.utils; payments.utils.utils.make_custom_fields() +payments.patches.add_gateway_to_buttons diff --git a/payments/patches/add_gateway_to_buttons.py b/payments/patches/add_gateway_to_buttons.py new file mode 100644 index 00000000..e1963d83 --- /dev/null +++ b/payments/patches/add_gateway_to_buttons.py @@ -0,0 +1,25 @@ +import click +import frappe + + +def execute(): + for button in frappe.get_all( + "Payment Button", fields=["name", "gateway_settings", "gateway_controller"] + ): + gateways = frappe.get_all( + "Payment Gateway", + { + "gateway_settings": button.gateway_settings, + "gateway_controller": button.gateway_controller, + }, + pluck="name", + ) + if len(gateways) > 1: + click.secho( + f"{button} was not migrated: no unabiguous matching gateway found. Set gateway manually", + color="yellow", + ) + continue + button = frappe.get_doc("Payment Button", button.name) + button.payment_gateway = gateways[0] + button.save() diff --git a/payments/payment_gateways/doctype/payzen_settings/__init__.py b/payments/payment_gateways/doctype/payzen_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.js b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.js new file mode 100644 index 00000000..3b0c5449 --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.js @@ -0,0 +1,3 @@ +frappe.ui.form.on('Payzen Settings', { + +}); diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.json b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.json new file mode 100644 index 00000000..6a474a1f --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.json @@ -0,0 +1,151 @@ +{ + "actions": [], + "autoname": "field:gateway_name", + "creation": "2018-02-05 13:46:12.101852", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway_name", + "brand", + "shop_id", + "challenge_3ds", + "column_break_vtxq", + "use_sandbox", + "static_assets_url", + "api_url", + "section_break_2", + "test_password", + "test_hmac_key", + "test_public_key", + "column_break_xbif", + "production_password", + "production_hmac_key", + "production_public_key" + ], + "fields": [ + { + "fieldname": "gateway_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Payment Gateway Name", + "options": "Company", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "allow_in_quick_entry": 1, + "default": "0", + "fieldname": "use_sandbox", + "fieldtype": "Check", + "label": "Use Sandbox" + }, + { + "bold": 1, + "fieldname": "shop_id", + "fieldtype": "Data", + "label": "Shop ID", + "reqd": 1 + }, + { + "fieldname": "column_break_vtxq", + "fieldtype": "Column Break" + }, + { + "fieldname": "test_password", + "fieldtype": "Password", + "label": "Test Password", + "mandatory_depends_on": "use_sandbox" + }, + { + "fieldname": "column_break_xbif", + "fieldtype": "Column Break" + }, + { + "fieldname": "production_password", + "fieldtype": "Password", + "label": "Production Password", + "mandatory_depends_on": "eval:!doc.use_sandbox" + }, + { + "description": "Used to select api url and static assets url.", + "fieldname": "brand", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Brand", + "options": "Clic&Pay By groupe Cr\u00e9dit du Nord\nCobro Inmediato\nEpayNC\nLyra Collect\nMi Cuenta Web\nPayty\nPayZen India\nPayZen LATAM\nPayZen Brazil\nPayZen Europe\nScellius\nSogecommerce\nSystempay", + "reqd": 1 + }, + { + "fieldname": "static_assets_url", + "fieldtype": "Read Only", + "in_list_view": 1, + "label": "Static Assets URL" + }, + { + "fieldname": "api_url", + "fieldtype": "Read Only", + "in_list_view": 1, + "label": "API URL" + }, + { + "fieldname": "test_public_key", + "fieldtype": "Data", + "label": "Test Public Key", + "mandatory_depends_on": "use_sandbox" + }, + { + "fieldname": "production_public_key", + "fieldtype": "Data", + "label": "Production Public Key", + "mandatory_depends_on": "eval:!doc.use_sandbox" + }, + { + "fieldname": "test_hmac_key", + "fieldtype": "Password", + "label": "Test HMAC Key", + "mandatory_depends_on": "use_sandbox" + }, + { + "fieldname": "production_hmac_key", + "fieldtype": "Password", + "label": "Production HMAC Key", + "mandatory_depends_on": "eval:!doc.use_sandbox" + }, + { + "default": "NO_PREFERENCE", + "documentation_url": "https://docs.lyra.com/en/rest/V4.0/api/playground/Charge/CreatePayment#strongAuthentication", + "fieldname": "challenge_3ds", + "fieldtype": "Select", + "label": "3DS Challenge", + "options": "DISABLED\nCHALLENGE_REQUESTED\nCHALLENGE_MANDATE\nNO_PREFERENCE\nAUTO" + } + ], + "links": [], + "modified": "2024-04-12 16:40:50.615981", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "Payzen Settings", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py new file mode 100644 index 00000000..3c63b14e --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py @@ -0,0 +1,432 @@ +# Copyright (c) 2018, Frappe Technologies and contributors +# License: MIT. See LICENSE + +from urllib.parse import urlencode + +import hashlib +import hmac +import json + +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log, make_post_request +from frappe.utils import call_hook_method, get_url + +from requests.auth import HTTPBasicAuth + +from payments.controllers import PaymentController +from payments.utils import create_payment_gateway +from payments.exceptions import FailedToInitiateFlowError, PayloadIntegrityError + +from payments.types import ( + TxData, + Initiated, + GatewayProcessingResponse, + SessionStates, + FrontendDefaults, + Processed, +) + +gateway_css = """ +""" + +gateway_js = """ + +""" + +gateway_wrapper = """
+
+ +
+ +
+
+
""" + +data_capture = "" + + +class PayzenSettings(PaymentController): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_url: DF.ReadOnly | None + brand: DF.Literal[ + "Clic&Pay By groupe Cr\u00e9dit du Nord", + "Cobro Inmediato", + "EpayNC", + "Lyra Collect", + "Mi Cuenta Web", + "Payty", + "PayZen India", + "PayZen LATAM", + "PayZen Brazil", + "PayZen Europe", + "Scellius", + "Sogecommerce", + "Systempay", + ] + challenge_3ds: DF.Literal[ + "DISABLED", "CHALLENGE_REQUESTED", "CHALLENGE_MANDATE", "NO_PREFERENCE", "AUTO" + ] + gateway_name: DF.Data + production_hmac_key: DF.Password | None + production_password: DF.Password | None + production_public_key: DF.Data | None + shop_id: DF.Data + static_assets_url: DF.ReadOnly | None + test_hmac_key: DF.Password | None + test_password: DF.Password | None + test_public_key: DF.Data | None + use_sandbox: DF.Check + # end: auto-generated types + + supported_currencies = [ + "COP", + ] + flowstates = SessionStates( + success=["Paid"], + pre_authorized=[], + processing=["Running"], + declined=["Unpaid", "Abandoned by User", "Unknown - Not Paid"], + ) + frontend_defaults = FrontendDefaults( + gateway_css=gateway_css, + gateway_js=gateway_js, + gateway_wrapper=gateway_wrapper, + data_capture=data_capture, + ) + + # source: https://github.com/lyra/flask-embedded-form-examples/blob/master/.env.example + static_urls = { + "Clic&Pay By groupe Crédit du Nord": "https://api-clicandpay.groupecdn.fr/static/", + "Cobro Inmediato": "https://static.cobroinmediato.tech/static/", + "EpayNC": "https://epaync.nc/static/", + "Lyra Collect": "https://api.lyra.com/static/", + "Mi Cuenta Web": "https://static.micuentaweb.pe/static/", + "Payty": "https://static.payty.com/static/", + "PayZen India": "https://secure.payzen.co.in/static/", + "PayZen LATAM": "https://static.payzen.lat/static/", + "PayZen Brazil": "https://api.payzen.com.br/api-payment/", + "PayZen Europe": "https://static.payzen.eu/static/", + "Scellius": "https://api.scelliuspaiement.labanquepostale.fr/static/", + "Sogecommerce": "https://api-sogecommerce.societegenerale.eu/static/", + "Systempay": "https://api.systempay.fr/static/", + } + + # source: https://github.com/lyra/flask-embedded-form-examples/blob/master/.env.example + api_urls = { + "Clic&Pay By groupe Crédit du Nord": "https://api-clicandpay.groupecdn.fr/api-payment/", + "Cobro Inmediato": "https://api.cobroinmediato.tech/api-payment/", + "EpayNC": "https://epaync.nc/api-payment/", + "Lyra Collect": "https://api.lyra.com/api-payment/", + "Mi Cuenta Web": "https://api.micuentaweb.pe/api-payment/", + "Payty": "https://api.payty.com/api-payment/", + "PayZen India": "https://secure.payzen.co.in/api-payment/", + "PayZen LATAM": "https://api.payzen.lat/api-payment/", + "PayZen Brazil": "https://static.payzen.lat/static/", + "PayZen Europe": "https://api.payzen.eu/api-payment/", + "Scellius": "https://api.scelliuspaiement.labanquepostale.fr/api-payment/", + "Sogecommerce": "https://api-sogecommerce.societegenerale.eu/api-payment/", + "Systempay": "https://api.systempay.fr/api-payment/", + } + # Field Helper + + @property + def password(self): + return str.encode( + self.get_password( + fieldname="test_password" if self.use_sandbox else "production_password", + raise_exception=False, + ) + ) + + @property + def hmac_key(self): + return str.encode( + self.get_password( + fieldname="test_hmac_key" if self.use_sandbox else "production_hmac_key", + raise_exception=False, + ) + ) + + @property + def pub_key(self): + return ( + f"{self.shop_id}:{self.test_public_key if self.use_sandbox else self.production_public_key}" + ) + + # Frappe Hooks + + def before_validate(self): + self._set_read_only_fields() + + def validate(self): + self._validate_payzen_credentials() + + def on_update(self): + gateway = "Payzen-" + self.gateway_name + create_payment_gateway(gateway, settings="Payzen Settings", controller=self.gateway_name) + call_hook_method("payment_gateway_enabled", gateway=gateway) + + # Ref Doc Hooks + + def validate_tx_data(self, data): + self._validate_tx_data_amount(data.amount) + self._validate_tx_data_currency(data.currency) + + # Implementations + + def _set_read_only_fields(self): + self.api_url = self.api_urls.get(self.brand) + self.static_assets_url = self.static_urls.get(self.brand) + + def _validate_payzen_credentials(self): + def make_test_request(auth): + return frappe._dict( + make_post_request(url=f"{self.api_url}/V4/Charge/SDKTest", auth=auth, data={"value": "test"}) + ) + + if self.test_password: + try: + password = self.get_password(fieldname="test_password") + result = make_test_request(HTTPBasicAuth(self.shop_id, password)) + if result.status != "SUCCESS" or result.answer.get("value") != "test": + frappe.throw(_("Test credentials seem not valid.")) + except Exception: + frappe.throw(_("Could not validate test credentials.")) + + if self.production_password: + try: + password = self.get_password(fieldname="production_password") + result = make_test_request(HTTPBasicAuth(self.shop_id, password)) + if result.status != "SUCCESS" or result.answer.get("value") != "test": + frappe.throw(_("Production credentials seem not valid.")) + except Exception: + frappe.throw(_("Could not validate production credentials.")) + + def _validate_tx_data_amount(self, amount): + if not amount: + frappe.throw(_("Payment amount cannot be 0")) + + def _validate_tx_data_currency(self, currency): + if currency not in self.supported_currencies: + frappe.throw( + _( + "Please select another payment method. Payzen does not support transactions in currency '{0}'" + ).format(currency) + ) + + # Gateway Lifecyle Hooks + + ## Preflight + + def _patch_tx_data(self, tx_data: TxData) -> TxData: + # payzen requires this to be in the smallest denomination of a currency + # TODO: needs to be modified if other currencies are implemented + tx_data.amount = int(tx_data.amount * 100) # hardcoded: COP factor + return tx_data + + ## Initiation + def _initiate_charge(self) -> Initiated: + tx_data = self.state.tx_data + psl = self.state.psl + btn = frappe.get_cached_doc("Payment Button", psl.button) + + data = { + # payzen receives values in the currency's smallest denomination + "amount": tx_data.amount, + "currency": tx_data.currency, + "orderId": tx_data.reference_docname, + "customer": { + "reference": tx_data.payer_contact.get("full_name"), + }, + "strongAuthentication": self.challenge_3ds, + "contrib": f"ERPNext/{self.name}", + "ipnTargetUrl": get_url( + "./api/method/payments.payment_gateways.doctype.payzen_settings.payzen_settings.notification" + ), + "metadata": { + "psl": psl.name, + "reference_doctype": tx_data.reference_doctype, + "reference_docname": tx_data.reference_docname, + }, + } + if btn.extra_payload: + e = json.loads(btn.extra_payload) + if paymentMethods := e.get("paymentMethods"): + data["paymentMethods"] = paymentMethods + + if email_id := tx_data.payer_contact.get("email_id"): + data["customer"]["email"] = email_id + + res = make_post_request( + url=f"{self.api_url}/V4/Charge/CreatePayment", + auth=HTTPBasicAuth(self.shop_id, self.password), + json=data, + ) + if not res.get("status") == "SUCCESS": + raise FailedToInitiateFlowError( + _("didn't return SUCCESS", context="Payments Gateway Exception"), + data=res, + ) + return Initiated( + correlation_id=res["ticket"], + payload=res["answer"], # we're after its '.formToken' + ) + + ## Response Processing + + def _validate_response(self): + response: GatewayProcessingResponse = self.state.response + type = response.payload.get("type") + if type == "V4/Charge/ProcessPaymentAnswer": + key = self.hmac_key + elif type == "V4/Payment": + key = self.password + else: + raise PayloadIntegrityError() + signature = hmac.new( + key, + response.message, + hashlib.sha256, + ).hexdigest() + if response.hash != signature: + raise PayloadIntegrityError() + + def _process_response_for_charge(self): + psl, tx_data = self.state.psl, self.state.tx_data + response: GatewayProcessingResponse = self.state.response + data = response.payload.get("data") + + orderStatus = data.get("orderStatus") + + if orderStatus == "PAID": + self.flags.status_changed_to = "Paid" + elif orderStatus == "RUNNING": + self.flags.status_changed_to = "Running" + elif orderStatus == "UNPAID": + self.flags.status_changed_to = "Unpaid" + elif orderStatus == "ABANDONED": + self.flags.status_changed_to = "Abandoned by User" + else: + self.flags.status_changed_to = "Unknown - Not Paid" + + def _render_failure_message(self): + psl, tx_data = self.state.psl, self.state.tx_data + response: GatewayProcessingResponse = self.state.response + data = response.payload.get("data") + + txDetails = data["transactions"][0] + errcode = txDetails.get("detailedErrorCode", "NO ERROR CODE") + errdetail = txDetails.get("detailedErrorMessage", "no detail") + return ( + _("Payzen Error Code: {}").format(errcode) + "\n" + _("Error Detail: {}").format(errdetail) + ) + + def _is_server_to_server(self): + response: GatewayProcessingResponse = self.state.response + return "V4/Payment" == response.payload.get("type") + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) +def notification(**kwargs): + kr_hash = kwargs["kr-hash"] + kr_answer = kwargs["kr-answer"] + kr_answer_type = kwargs["kr-answer-type"] + + _kr_hash_key = kwargs["kr-hash-key"] + _kr_hash_algorithm = kwargs["kr-hash-algorithm"] + + if kr_answer_type not in [ + "V4/Payment", # IPN + "V4/Charge/ProcessPaymentAnswer", # Client Flow + ]: # TODO: implemet more + return + if not kr_answer: + return + + data = json.loads(kr_answer) + tx1 = data["transactions"][0] + psl_name = tx1["metadata"]["psl"] + + processed: Processed = PaymentController.process_response( + psl_name=psl_name, + response=GatewayProcessingResponse( + hash=kr_hash, + message=str.encode(kr_answer), + payload={ + "type": kr_answer_type, + "data": data, + }, + ), + ) + + if processed: + return processed.__dict__ diff --git a/payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py new file mode 100644 index 00000000..4993bf44 --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py @@ -0,0 +1,24 @@ +import unittest +import frappe + + +class TestPayzenSettings(unittest.TestCase): + pass + + +def create_payzen_settings(payment_gateway_name="Express"): + if frappe.db.exists("Payzen Settings", payment_gateway_name): + return frappe.get_doc("Payzen Settings", payment_gateway_name) + + doc = frappe.get_doc( + doctype="Payzen Settings", + sandbox=1, + payment_gateway_name=payment_gateway_name, + consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", + consumer_secret="VI1oS3oBGPJfh3JyvLHw", + online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd", + till_number="174379", + ) + + doc.insert(ignore_permissions=True) + return doc diff --git a/payments/payments/doctype/__init__.py b/payments/payments/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payments/doctype/payment_button/__init__.py b/payments/payments/doctype/payment_button/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payments/doctype/payment_button/payment_button.js b/payments/payments/doctype/payment_button/payment_button.js new file mode 100644 index 00000000..40b964c0 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.js @@ -0,0 +1,18 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Payment Button', { + gateway_settings: function(frm) { + const val = frm.get_field("gateway_settings").value; + if (val) { + frappe.call("payments.controllers.frontend_defaults", {doctype: val}, (r) => { + if (r.message) { + frm.set_value('gateway_css', r.message.gateway_css ) + frm.set_value('gateway_js', r.message.gateway_js) + frm.set_value('gateway_wrapper', r.message.gateway_wrapper) + frm.set_value('data_capture', r.message.data_capture) + } + }) + } + } +}); diff --git a/payments/payments/doctype/payment_button/payment_button.json b/payments/payments/doctype/payment_button/payment_button.json new file mode 100644 index 00000000..8ae4b835 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -0,0 +1,176 @@ +{ + "actions": [], + "autoname": "field:label", + "creation": "2022-01-24 21:09:47.229371", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "payment_gateway", + "gateway_settings", + "gateway_controller", + "implementation_variant", + "column_break_mjuo", + "label", + "enabled", + "priority", + "button_configuration_section", + "column_break_zwhf", + "icon", + "gateway_css", + "gateway_js", + "gateway_wrapper", + "data_capture", + "extra_payload" + ], + "fields": [ + { + "fetch_from": "payment_gateway.gateway_settings", + "fieldname": "gateway_settings", + "fieldtype": "Data", + "label": "Gateway Settings", + "read_only": 1 + }, + { + "fetch_from": "payment_gateway.gateway_controller", + "fieldname": "gateway_controller", + "fieldtype": "Data", + "label": "Gateway Controller", + "read_only": 1 + }, + { + "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", + "fieldname": "gateway_css", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Gateway CSS", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Third Party Widget\"", + "options": "HTML" + }, + { + "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", + "fieldname": "gateway_js", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Gateway JS", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Third Party Widget\"", + "options": "HTML" + }, + { + "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", + "fieldname": "gateway_wrapper", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Gateway Wrapper", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Third Party Widget\"", + "options": "HTML" + }, + { + "default": "
\n
\n
\n \n\n \n
\n
\n
\n \n
\n
\n
\n
\n\n\n", + "fieldname": "column_break_zwhf", + "fieldtype": "Column Break" + }, + { + "description": "The label to show on the payment button on checkout pages", + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_mjuo", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "description": "The svg icon to the left of the payment button label", + "fieldname": "icon", + "fieldtype": "Attach Image", + "label": "Icon" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.enabled", + "description": "The code fields of this section are HTML snippets templated via jinja.
\n
{\n  \"doc\":     <Instance of PaymentController>,\n  \"extra\":   <Dict of parsed Extra Payload Json>,\n  \"psl\":     <Dict of PSL>,\n  \"tx_data\": <TX Data>,\n  \"gateway_data\": <Dict of Gateway-specific Pre Data Capture State>, \n}
\nThis jinja context is specific to the gateway.", + "fieldname": "button_configuration_section", + "fieldtype": "Section Break", + "label": "Button Configuration" + }, + { + "default": "{}", + "description": "Add button-specific extra payload which can be used by the gateway implementation.", + "fieldname": "extra_payload", + "fieldtype": "Code", + "label": "Extra Payload", + "options": "JSON" + }, + { + "default": "Third Party Widget", + "fieldname": "implementation_variant", + "fieldtype": "Select", + "label": "Implementation Variant", + "options": "Third Party Widget\nData Capture", + "reqd": 1 + }, + { + "depends_on": "eval: doc.implementation_variant == \"Data Capture\"", + "fieldname": "data_capture", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Data Capture", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Data Capture\"", + "options": "HTML" + }, + { + "fieldname": "payment_gateway", + "fieldtype": "Link", + "label": "Payment Gateway", + "options": "Payment Gateway", + "reqd": 1 + }, + { + "fieldname": "priority", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Priority" + } + ], + "image_field": "icon", + "links": [], + "modified": "2024-07-10 05:38:57.786232", + "modified_by": "Administrator", + "module": "Payments", + "name": "Payment Button", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guest", + "select": 1, + "share": 1 + } + ], + "quick_entry": 1, + "sort_field": "priority", + "sort_order": "ASC", + "states": [] +} \ No newline at end of file diff --git a/payments/payments/doctype/payment_button/payment_button.py b/payments/payments/doctype/payment_button/payment_button.py new file mode 100644 index 00000000..4b42f84d --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -0,0 +1,76 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import frappe +import json +from frappe import _ +from frappe.model.document import Document +from payments.types import RemoteServerInitiationPayload, TxData +from payments.payments.doctype.payment_session_log.payment_session_log import PSLState + +Css = str +Js = str +Wrapper = str + + +class PaymentButton(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + data_capture: DF.Code | None + enabled: DF.Check + extra_payload: DF.Code | None + gateway_controller: DF.Data | None + gateway_css: DF.Code | None + gateway_js: DF.Code | None + gateway_settings: DF.Data | None + gateway_wrapper: DF.Code | None + icon: DF.AttachImage | None + implementation_variant: DF.Literal["Third Party Widget", "Data Capture"] + label: DF.Data + payment_gateway: DF.Link + priority: DF.Int + # end: auto-generated types + + # Frontend Assets (widget) + # - imeplement them for your controller + # - need to be fully rendered with + # --------------------------------------- + def get_widget_assets(self, payload: RemoteServerInitiationPayload) -> (Css, Js, Wrapper): + """Get the fully rendered frontend assets for this button.""" + context = { + "doc": frappe.get_cached_doc(self.gateway_settings, self.gateway_controller), + "payload": payload, + } + css = frappe.render_template(self.gateway_css, context) + js = frappe.render_template(self.gateway_js, context) + wrapper = frappe.render_template(self.gateway_wrapper, context) + return css, js, wrapper + + def get_data_capture_assets(self, state: PSLState) -> Wrapper: + """Get the fully rendered data capture form. + + The rendering context is updated with `state`. + """ + context = { + "doc": frappe.get_cached_doc(self.gateway_settings, self.gateway_controller), + "extra": frappe._dict(json.loads(self.extra_payload)), + } + context.update(state) + return frappe.render_template(self.data_capture, context) + + @property + def requires_data_catpure(self): + return self.implementation_variant == "Data Capture" + + def validate(self): + if self.extra_payload: + try: + json.loads(self.extra_payload) + except Exception: + frappe.throw(_("Extra Payload must be valid JSON.")) diff --git a/payments/payments/doctype/payment_button/test_payment_button.py b/payments/payments/doctype/payment_button/test_payment_button.py new file mode 100644 index 00000000..31b1c933 --- /dev/null +++ b/payments/payments/doctype/payment_button/test_payment_button.py @@ -0,0 +1,9 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE +import unittest + +# test_records = frappe.get_test_records('Payment Button') + + +class TestPaymentButton(unittest.TestCase): + pass diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.js b/payments/payments/doctype/payment_gateway/payment_gateway.js index 0eff5a56..6d40f6c3 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.js +++ b/payments/payments/doctype/payment_gateway/payment_gateway.js @@ -3,6 +3,24 @@ frappe.ui.form.on('Payment Gateway', { refresh: function(frm) { + }, + validate_company: (frm) => { + if (!frm.doc.company) { + frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") }); + } + }, + setup: function(frm) { + frm.set_query("payment_account", function () { + frm.events.validate_company(frm); - } + var account_types = ["Bank", "Cash"]; + return { + filters: { + account_type: ["in", account_types], + is_group: 0, + company: frm.doc.company, + }, + }; + }); + }, }); diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.json b/payments/payments/doctype/payment_gateway/payment_gateway.json index 2f80da6e..0fd4814e 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.json +++ b/payments/payments/doctype/payment_gateway/payment_gateway.json @@ -8,7 +8,9 @@ "field_order": [ "gateway", "gateway_settings", - "gateway_controller" + "gateway_controller", + "column_break_bkjg", + "company" ], "fields": [ { @@ -30,10 +32,20 @@ "fieldtype": "Dynamic Link", "label": "Gateway Controller", "options": "gateway_settings" + }, + { + "fieldname": "column_break_bkjg", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" } ], "links": [], - "modified": "2022-07-24 21:17:03.864719", + "modified": "2024-07-07 11:40:01.056073", "modified_by": "Administrator", "module": "Payments", "name": "Payment Gateway", diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.py b/payments/payments/doctype/payment_gateway/payment_gateway.py index 74306ae4..bccc5136 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.py +++ b/payments/payments/doctype/payment_gateway/payment_gateway.py @@ -5,4 +5,17 @@ class PaymentGateway(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + company: DF.Link | None + gateway: DF.Data + gateway_controller: DF.DynamicLink | None + gateway_settings: DF.Link | None + # end: auto-generated types pass diff --git a/payments/payments/doctype/payment_session_log/__init__.py b/payments/payments/doctype/payment_session_log/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.js b/payments/payments/doctype/payment_session_log/payment_session_log.js new file mode 100644 index 00000000..d2157e82 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.js @@ -0,0 +1,22 @@ +// Copyright (c) 2021, Frappe and contributors +// For license information, please see LICENSE + +frappe.ui.form.on('Payment Session Log', { + refresh: function(frm) { + if (frm.doc.request_data && ['Error', 'Error - RefDoc'].includes(frm.doc.status)){ + frm.add_custom_button(__('Retry'), function() { + frappe.call({ + method:"payments.payments.doctype.payment_session_log.payment_session_log.resync", + args:{ + method:frm.doc.method, + name: frm.doc.name, + request_data: frm.doc.request_data + }, + callback: function(r){ + frappe.msgprint(__("Reattempting to sync")) + } + }) + }).addClass('btn-primary'); + } + } +}); diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.json b/payments/payments/doctype/payment_session_log/payment_session_log.json new file mode 100644 index 00000000..1b7b7e02 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.json @@ -0,0 +1,156 @@ +{ + "actions": [], + "creation": "2021-04-15 12:29:03.541492", + "doctype": "DocType", + "document_type": "System", + "engine": "InnoDB", + "field_order": [ + "title", + "status", + "decline_reason", + "button", + "flow_type", + "gateway", + "requires_data_capture", + "mandate", + "correlation_id", + "tx_data", + "gateway_specific_state", + "initiation_response_payload", + "processing_response_payload" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "hidden": 1, + "label": "Title", + "read_only": 1 + }, + { + "default": "Queued", + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "read_only": 1 + }, + { + "fieldname": "gateway", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Gateway", + "read_only": 1 + }, + { + "fieldname": "tx_data", + "fieldtype": "Code", + "label": "Tx Data", + "read_only": 1 + }, + { + "fieldname": "correlation_id", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Correlation ID", + "read_only": 1 + }, + { + "fieldname": "flow_type", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Flow Type", + "read_only": 1 + }, + { + "fieldname": "mandate", + "fieldtype": "Data", + "label": "Mandate", + "read_only": 1 + }, + { + "fieldname": "button", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Button", + "read_only": 1 + }, + { + "fieldname": "initiation_response_payload", + "fieldtype": "Code", + "label": "Initiation Response Payload", + "read_only": 1 + }, + { + "fieldname": "processing_response_payload", + "fieldtype": "Code", + "label": "Processing Response Payload", + "read_only": 1 + }, + { + "fieldname": "decline_reason", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Decline Reason", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "requires_data_capture", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Requires Data Capture", + "read_only": 1 + }, + { + "fieldname": "gateway_specific_state", + "fieldtype": "Code", + "label": "Gateway Specific State", + "read_only": 1 + } + ], + "in_create": 1, + "links": [], + "modified": "2024-05-05 12:17:27.224568", + "modified_by": "Administrator", + "module": "Payments", + "name": "Payment Session Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title" +} \ No newline at end of file diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.py b/payments/payments/doctype/payment_session_log/payment_session_log.py new file mode 100644 index 00000000..24aedc55 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.py @@ -0,0 +1,250 @@ +# Copyright (c) 2021, Frappe and contributors +# For license information, please see LICENSE + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.query_builder import Interval +from frappe.query_builder.functions import Now +from frappe.utils import strip_html +from frappe.utils.data import cstr + +from payments.types import TxData, RemoteServerInitiationPayload, GatewayProcessingResponse +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from payments.controllers import PaymentController + from payments.payments.doctype.payment_gateway.payment_gateway import PaymentGateway + from payments.payments.doctype.payment_button.payment_button import PaymentButton + +import collections.abc + + +def update(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = update(d.get(k, {}), v) + else: + d[k] = v + return d + + +PSLState = dict + + +class PaymentSessionLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + button: DF.Data | None + correlation_id: DF.Data | None + decline_reason: DF.Data | None + flow_type: DF.Data | None + gateway: DF.Data | None + gateway_specific_state: DF.Code | None + initiation_response_payload: DF.Code | None + mandate: DF.Data | None + processing_response_payload: DF.Code | None + requires_data_capture: DF.Check + status: DF.Data | None + title: DF.Data | None + tx_data: DF.Code | None + # end: auto-generated types + def update_tx_data(self, tx_data: TxData, status: str) -> None: + d = json.loads(self.tx_data) + update(d, tx_data) + self.db_set( + { + "tx_data": frappe.as_json(d), + "status": status, + }, + commit=True, + ) + + def update_gateway_specific_state(self, data: dict, status: str) -> None: + d = json.loads(self.gateway_specific_state) if self.gateway_specific_state else {} + update(d, data) + self.db_set( + { + "gateway_specific_state": frappe.as_json(d), + "status": status, + }, + commit=True, + ) + + def set_initiation_payload( + self, initiation_payload: RemoteServerInitiationPayload, status: str + ) -> None: + self.db_set( + { + "initiation_response_payload": frappe.as_json(initiation_payload), + "status": status, + }, + commit=True, + ) + + def set_processing_payload( + self, processing_response: GatewayProcessingResponse, status: str + ) -> None: + self.db_set( + { + "processing_response_payload": frappe.as_json(processing_response.payload), + "status": status, + }, + commit=True, + ) + + def load_state(self) -> PSLState: + return frappe._dict( + psl=frappe._dict(self.as_dict()), + tx_data=TxData(**json.loads(self.tx_data)), + gateway_data=json.loads(self.gateway_specific_state) if self.gateway_specific_state else {}, + ) + + def get_controller(self) -> "PaymentController": + """ + Retrieves the payment controller associated with the current Payment Session Log's gateway. + + This method uses a JSON-encoded gateway value instead of a dynamic link + for performance optimization. The controller is only fetched when necessary. + + Returns: + PaymentController: The cached document of the payment controller. + + Raises: + frappe.ValidationError: If no gateway is selected for this Payment Session Log. + """ + if not self.gateway: + self.log_error("No gateway selected yet") + frappe.throw(_("No gateway selected for this payment session")) + d = json.loads(self.gateway) + doctype, docname = d["gateway_settings"], d["gateway_controller"] + return frappe.get_cached_doc(doctype, docname) + + def get_gateway(self) -> "PaymentGateway": + """ + Retrieves the Payment Gateway document associated with the current Payment Session Log. + + The 'gateway' attribute of the Payment Session Log serves a dual purpose, + acting as both a data store and a filter for recovering the Payment Gateway document. + + Returns: + Payment Gateway: The most recent Payment Gateway document matching the stored gateway data. + + Raises: + frappe.ValidationError: If no gateway is selected for this Payment Session Log. + """ + if not self.gateway: + self.log_error("No gateway selected yet") + frappe.throw(_("No gateway selected for this payment session")) + d = json.loads(self.gateway) + return frappe.get_cached_doc("Payment Gateway", d["payment_gateway"]) + + def get_button(self) -> "PaymentButton": + if not self.button: + self.log_error("No button selected yet") + frappe.throw(_("No button selected for this payment session")) + return frappe.get_cached_doc("Payment Button", self.button) + + @staticmethod + def clear_old_logs(days=90): + table = frappe.qb.DocType("Payment Session Log") + frappe.db.delete( + table, filters=(table.modified < (Now() - Interval(days=days))) & (table.status == "Success") + ) + + +@frappe.whitelist(allow_guest=True) +def select_button(pslName: str = None, buttonName: str = None) -> str: + try: + psl = frappe.get_cached_doc("Payment Session Log", pslName) + except Exception: + e = frappe.log_error("Payment Session Log not found", reference_doctype="Payment Session Log") + # Ensure no more details are leaked than the error log reference + frappe.local.message_log = [_("Server Failure!
{}").format(e)] + return + try: + btn: PaymentButton = frappe.get_cached_doc("Payment Button", buttonName) + except Exception: + e = frappe.log_error("Payment Button not found", reference_doctype="Payment Button") + # Ensure no more details are leaked than the error log reference + frappe.local.message_log = [_("Server Failure!
{}").format(e)] + return + + psl.db_set( + { + "button": buttonName, + "requires_data_capture": btn.requires_data_catpure, + "gateway": json.dumps( + { + "gateway_settings": btn.gateway_settings, + "gateway_controller": btn.gateway_controller, + "payment_gateway": btn.payment_gateway, + } + ), + } + ) + # once state set: reload the page to activate widget + return {"reload": True} + + +def create_log( + tx_data: TxData, + controller: "PaymentController" = None, + status: str = "Created", +) -> PaymentSessionLog: + + log = frappe.new_doc("Payment Session Log") + log.tx_data = frappe.as_json(tx_data) + log.status = status + if controller: + log.gateway = json.dumps( + { + "gateway_settings": controller.doctype, + "gateway_controller": controller.name, + } + ) + + log.insert(ignore_permissions=True) + return log + + +@frappe.whitelist() +def resync(method, name, request_data): + _retry_job(name) + + +def _retry_job(job: str): + frappe.only_for("System Manager") + + doc = frappe.get_doc("Payment Session Log", job) + if not doc.method.startswith("payments.payment_gateways.") or doc.status != "Error": + return + + doc.db_set("status", "Queued", update_modified=False) + doc.db_set("traceback", "", update_modified=False) + + frappe.enqueue( + method=doc.method, + queue="short", + timeout=300, + is_async=True, + payload=json.loads(doc.request_data), + request_id=doc.name, + enqueue_after_commit=True, + ) + + +@frappe.whitelist() +def bulk_retry(names): + if isinstance(names, str): + names = json.loads(names) + for name in names: + _retry_job(name) diff --git a/payments/payments/doctype/payment_session_log/payment_session_log_list.js b/payments/payments/doctype/payment_session_log/payment_session_log_list.js new file mode 100644 index 00000000..543f9e6a --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log_list.js @@ -0,0 +1,21 @@ +frappe.listview_settings["Payment Session Log"] = { + hide_name_column: true, + add_fields: ["status"], + get_indicator: function (doc) { + if (doc.status === "Success") { + return [__("Success"), "green", "status,=,Success"]; + } else if (doc.status === "Error") { + return [__("Error"), "red", "status,=,Error"]; + } else if (doc.status === "Queued") { + return [__("Queued"), "orange", "status,=,Queued"]; + } + }, + + onload: function (listview) { + listview.page.add_action_item(__("Retry"), () => { + listview.call_for_selected_items( + "payments.payments.doctype.payment_session_log.payment_session_log.bulk_retry" + ); + }); + }, +}; diff --git a/payments/payments/doctype/payment_session_log/test_payment_session_log.py b/payments/payments/doctype/payment_session_log/test_payment_session_log.py new file mode 100644 index 00000000..748c7c91 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/test_payment_session_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe and Contributors +# See LICENSE + +# import frappe +import unittest + + +class TestPaymentSessionLog(unittest.TestCase): + pass diff --git a/payments/types.py b/payments/types.py new file mode 100644 index 00000000..04d3091b --- /dev/null +++ b/payments/types.py @@ -0,0 +1,216 @@ +from dataclasses import dataclass +from typing import Optional, Dict, List + +from types import MappingProxyType + +from frappe.model.document import Document + +from enum import Enum + + +class SessionType(str, Enum): + """We distinguish three distinct flow types. + + They may be chained into a composed flow from the business logic, for example a + mandate_aquisition can precede a mandated_charge in a continuous flow. + """ + + charge = "charge" + mandated_charge = "mandated_charge" + mandate_acquisition = "mandate_acquisition" + + +@dataclass +class SessionStates: + """Define gateway states in their respective category""" + + success: list[str] + pre_authorized: list[str] + processing: list[str] + declined: list[str] + + +@dataclass +class FrontendDefaults: + """Define gateway widget frontend defaults for css, js and the wrapper components + or a data capture form. + + All four are html snippets and jinja templates rendered against this gateway's + PaymentController instance and its RemoteServerInitiationPayload. + + These are loaded into the Payment Button document and give users a starting point + to customize payment button(s). + + If the button implements a third party widget, then gateway_* are rendered into the checkout. + If the button implements first party data capture, then data_capture is rendered. + """ + + gateway_css: str + gateway_js: str + gateway_wrapper: str + data_capture: str + + +class RemoteServerInitiationPayload(dict): + """The remote server payload returned during flow initiation. + + Interface: Remote Server -> Concrete Gateway Implementation + Concrete Gateway Implementation -> Payment Gateway Controller + Payment Gateway Controller -> Payment Gateway Controller + """ + + pass + + +@dataclass(frozen=True) +class Initiated: + """The return data structure from a gateway flow initiation. + + Interface: Concrete Gateway Implementation -> Payment Gateway Controller + + correlation_id: + stored as request_id in the integration log to correlate + remote and local request + """ + + correlation_id: str + payload: RemoteServerInitiationPayload + + +@dataclass +class TxData: + """The main data interchange format between refdoc and controller. + + Interface: Ref Doc -> Payment Gateway Controller + + """ + + amount: float + currency: str + reference_doctype: str + reference_docname: str + payer_contact: dict # as: contact.as_dict() + payer_address: dict # as: address.as_dict() + # [Program, Points, Amount] + loyalty_points: list[str, float, float] | None # for display purpose only + discount_amount: float | None # for display purpose only + display_reference_doctype: str | None = None + display_reference_docname: str | None = None + # TODO: tx data for subscriptions, pre-authorized, require-mandate and other flows + + +@dataclass(frozen=True) +class GatewayProcessingResponse: + """The remote server payload returned during flow processing. + + Interface: Remote Server -> Concrete Gateway Implementation + Concrete Gateway Implementation -> Payment Gateway Controller + Payment Gateway Controller -> Payment Gateway Controller + """ + + hash: bytes | None + message: bytes | None + payload: dict + + +class PaymentMandate(Document): + """All payment mandate doctypes should inherit from this base class. + + Interface: Concrete Gateway Implementation -> Payment Gateway Controller + """ + + +@dataclass +class Proceeded: + """The return data structure from a call to proceed() which initiates the flow. + + Interface: Payment Gateway Controller -> calling control flow (backend or frontend) + + integration: + The name of the integration (gateway doctype). + Exposed so that the controlling business flow can case switch on it. + """ + + integration: str + psltype: SessionType + mandate: PaymentMandate | None # TODO: will this be serialized when called from the frontend? + txdata: TxData + payload: RemoteServerInitiationPayload + + +@dataclass +class ActionAfterProcessed: + href: str + label: str + redirect_after_milliseconds: int | None = None + + +@dataclass +class _Processed: + """The return data structure after processing gateway response (by a Ref Doc hook). + + Interface: Ref Doc -> Payment Gateway Controller + + Implementation Note: + If implemented via a server action you may aproximate by using frappe._dict. + + message: + a (translated) message to show to the user + action: + an action for the frontend to perfom + """ + + message: str + action: dict # checked against ActionAfterProcessed + + +@dataclass +class Processed(_Processed): + """The return data structure after processing gateway response. + + Interface: + Payment Gateway Controller -> Calling Buisness Flow (backend or frontend) + + Implementation Note: + If the Ref Doc exposes a hook method, this should return _Processed, instead. + + message: + a (translated) message to show to the user + action: + an action for the frontend to perfom + status_changed_to: + the new status of the payment session after processing + indicator_color: + the new indicator color for visual display of the new status + payload: + a gateway specific payload that is understood by a gateway-specific frontend + implementation + """ + + status_changed_to: str + indicator_color: str + payload: GatewayProcessingResponse + + +# for nicer DX using an LSP + + +class PSLName(str): + """The name of the primary local reference to identify an ongoing payment gateway flow. + + Interface: Payment Gateway Controller -> Ref Doc -> Payment Gateway Controller + Payment Gateway Controller -> Remote Server -> Payment Gateway Controller + Payment Gateway Controller -> Calling Buisness Flow -> Payment Gateway Controller + + It is first returned by a call to initiate and should be stored on + the Ref Doc for later reference. + """ + + +class PaymentUrl(str): + """The payment url in case the gateway implements it. + + Interface: Payment Gateway Controller -> Ref Doc + + It is rendered from the integration log reference and the URL of the current site. + """ diff --git a/payments/utils/__init__.py b/payments/utils/__init__.py index fb540bd5..aee76796 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -2,7 +2,11 @@ before_install, create_payment_gateway, delete_custom_fields, - get_payment_gateway_controller, + get_payment_controller, make_custom_fields, erpnext_app_import_guard, + PAYMENT_SESSION_REF_KEY, ) + +# compatibility with older erpnext versions <16 +from payments.utils.utils import get_payment_controller as get_payment_gateway_controller diff --git a/payments/utils/utils.py b/payments/utils/utils.py index e56a89c2..66bba282 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -1,11 +1,25 @@ import click import frappe + from frappe import _ from contextlib import contextmanager from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from payments.types import PSLName + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from payments.controllers import PaymentController + from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, + ) + +# Key used to identify the integration request on the frappe/erpnext side across its lifecycle +PAYMENT_SESSION_REF_KEY = "s" + -def get_payment_gateway_controller(payment_gateway): +def get_payment_controller(payment_gateway: str) -> "PaymentController": """Return payment gateway controller""" gateway = frappe.get_doc("Payment Gateway", payment_gateway) if gateway.gateway_controller is None: @@ -24,7 +38,7 @@ def get_payment_gateway_controller(payment_gateway): def get_checkout_url(**kwargs): try: if kwargs.get("payment_gateway"): - doc = frappe.get_doc("{} Settings".format(kwargs.get("payment_gateway"))) + doc = get_payment_controller(kwargs.get("payment_gateway")) return doc.get_payment_url(**kwargs) else: raise Exception @@ -149,7 +163,28 @@ def make_custom_fields(): "reqd": 1, "insert_after": "disabled", } - ] + ], + "Payment Request": [ + { + "fieldname": "payment_session_log", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Session Log", + "options": "Payment Session Log", + "read_only": 1, + "insert_after": "payment_url", + } + ], + "Payment Gateway": [ + { + "fieldname": "payment_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Account", + "options": "Account", + "insert_after": "company", + } + ], } create_custom_fields(custom_fields) @@ -177,6 +212,12 @@ def delete_custom_fields(): frappe.clear_cache(doctype="Web Form") + if "erpnext" in frappe.get_installed_apps(): + if frappe.get_meta("Payment Request").has_field("payment_session_log"): + frappe.db.delete("Custom Field", {"name": "Payment Request-payment_session_log"}) + if frappe.get_meta("Payment Gateway").has_field("payment_account"): + frappe.db.delete("Custom Field", {"name": "Payment Gateway-payment_account"}) + def before_install(): # TODO: remove this diff --git a/payments/www/__init__.py b/payments/www/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/www/pay.css b/payments/www/pay.css new file mode 100644 index 00000000..a71bfe79 --- /dev/null +++ b/payments/www/pay.css @@ -0,0 +1,8 @@ +.page-card { + min-width: 30%; +} + +.btn svg { + width: 20px; + height: 20px; +} \ No newline at end of file diff --git a/payments/www/pay.html b/payments/www/pay.html new file mode 100644 index 00000000..22f94718 --- /dev/null +++ b/payments/www/pay.html @@ -0,0 +1,180 @@ +{% extends "templates/base.html" %} + + +{% block title %}{{ title or _("Payment") }}{% endblock %} +{% block navbar %}{% endblock %} + +{%- block head_include %} + +{% if render_widget %} + {{ gateway_css }} +{% endif %} +{% endblock -%} + +{%- block content -%} +
+
+ + {{ tx_data.display_reference_docname or tx_data.reference_docname }} +
+
+ {% block reference_body %} + {% set payer = tx_data.payer_contact -%} + {% if has_discount -%} +
+

+
+ {% if tx_data.discount_amount -%} + {{ _("Discount") }}:
+ {%- endif %} + {% if tx_data.loyalty_points -%} + {{ _("Used Points") }}:
+ {{ _("Loyalty Benefit") }}:
+ {%- endif %} + {{ _("Amount") }}:
+

+

+ {{ frappe.utils.fmt_money(grand_total, currency=tx_data.currency) }}
+ {% if tx_data.discount_amount -%} + - {{ frappe.utils.fmt_money(tx_data.discount_amount, currency=tx_data.currency) }}
+ {%- endif %} + {% if tx_data.loyalty_points -%} + {{ tx_data.loyalty_points[0] }} {{ tx_data.loyalty_points[1] }}
+ - {{ frappe.utils.fmt_money(tx_data.loyalty_points[2], currency=tx_data.currency) }}
+ {%- endif %} + = {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}
+

+
+

+ {{ _("Document") }}: {{ _(tx_data.display_reference_doctype or tx_data.reference_doctype) }}
+ {{ _("Customer") }}: {{ payer.get("full_name") }}
+

+ {% else -%} +

+ {{ _("Amount") }}: {{ frappe.utils.fmt_money(grand_total, currency=tx_data.currency) }}
+ {{ _("Document") }}: {{ _(tx_data.display_reference_doctype or tx_data.reference_doctype) }}
+ {{ _("Customer") }}: {{ payer.get("full_name") }}
+

+ {%- endif %} + {% endblock %} +
+

+ + +

+ {% if debug %} +
+
Debug Information:
+

+ DOM Loaded: Not yet
+ Global Variables: Checking...
+

+
+ + {% endif %} + {% if logo %} + + {% endif %} + {% if render_capture %} +
+ {{ data_capture }} +
+ +
+
+ {% endif %} + {% if render_widget %} +
+ + + {{ gateway_wrapper }} +
+ {% endif %} + {% if render_buttons %} +
+ {% if render_widget or render_capture %} +
+
{{ _("or change payment method") }}:
+ {% endif %} + + {% set primary_button = payment_buttons[0] %} + {% set secondary_buttons = payment_buttons[1:] %} +
+
+ {% for secondary_button in secondary_buttons %} +
+ {% endfor %} +
+
+ {% endif %} +
+
+{% endblock %} + +{% block base_scripts %} +{% if render_widget %} + {{ gateway_js}} +{% endif %} + + +{{ include_script('website-core.bundle.js') }} + +{% endblock %} + +{% set web_include_js=[] %} diff --git a/payments/www/pay.js b/payments/www/pay.js new file mode 100644 index 00000000..7c914731 --- /dev/null +++ b/payments/www/pay.js @@ -0,0 +1,85 @@ +frappe.ready(function() { + + // Focus the first button + // document.getElementById("primary-button").focus(); + + // Get all button elements + const buttons = Array.from(document.getElementsByClassName('btn-pay')); + + // Get the error section + // const errors = document.getElementById("errors"); + + // Get the payment session log name + const urlParams = new URLSearchParams(window.location.search); + const pslName = urlParams.get('s'); + + // Loop through each button and add the onclick event listener + buttons.forEach((button) => { + // Get the data-button attribute value + const buttonData = button.getAttribute('data-button'); + + button.addEventListener('click', () => { + + // Make the Frappe call + frappe.call({ + method: "payments.payments.doctype.payment_session_log.payment_session_log.select_button", + args: { + pslName: pslName, + buttonName: buttonData, + }, + error_msg: "#select-button-errors", + callback: (r) => { + if (r.message.reload) { + window.location.reload(); + } + } + }); + }); + }); +}); + +$(document).on("payment-submitted", function (e) { + $("div#button-section").hide() +}) + +$(document).on("payment-processed", function (e, r) { + if (r.message.status_changed_to) { + const status = r.message.status_changed_to; + const color = r.message.indicator_color; + const pill = $("#status"); + pill.html(status); + pill.addClass(color) + $("#status-wrapper").toggle(true) + const indicator = $("#refdoc-indicator"); + indicator.removeClass(function(_, className) { + return className.match(/blue|red/g).join(" "); + }); + indicator.addClass(color); + } + if (r.message.message) { + $("#message").html(r.message.message).toggle(true) + $("#message-wrapper").toggle(true) + } + if (r.message.action) { + const cta = $("#action-processed"); + cta.html(r.message.action.label) + cta.attr("href", r.message.action.href) + cta.toggle(true) + cta.focus() + if (r.message.action.redirect_after_milliseconds) { + const message = $("#action-redirect-message"); + const secondsCounter = $("#action-redirect-message-seconds"); + let seconds = Math.floor(r.message.action.redirect_after_milliseconds / 1000); + secondsCounter.html(seconds); + function updateCounter() { + seconds--; + secondsCounter.html(seconds); + } + message.toggle(true) + setInterval(updateCounter, 1000); + setTimeout(function() { + window.location.href = r.message.action.href + }, r.message.action.redirect_after_milliseconds); + } + } +}) diff --git a/payments/www/pay.py b/payments/www/pay.py new file mode 100644 index 00000000..c06d181b --- /dev/null +++ b/payments/www/pay.py @@ -0,0 +1,134 @@ +import json + +import frappe +from frappe import _ +from frappe.utils.file_manager import get_file_path +from payments.utils import PAYMENT_SESSION_REF_KEY +from payments.controllers import PaymentController +from payments.types import Proceeded, TxData, RemoteServerInitiationPayload + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from payments.payments.doctype.payment_session_log.payment_session_log import PaymentSessionLog + from payments.payments.doctype.payment_button.payment_button import PaymentButton + +no_cache = 1 + + +def get_psl() -> "PaymentSessionLog": + try: + name = frappe.form_dict[PAYMENT_SESSION_REF_KEY] + psl: PaymentSessionLog = frappe.get_doc("Payment Session Log", name) + return psl + except (KeyError, frappe.exceptions.DoesNotExistError): + frappe.redirect_to_message( + _("Invalid Payment Link"), + _("This payment link is invalid!"), + http_status_code=400, + indicator_color="red", + ) + raise frappe.Redirect + + +default_icon = """ + + + +""" + + +def load_icon(icon_file): + return frappe.read_file(get_file_path(icon_file)) if icon_file else default_icon + + +def get_context(context): + + # always + + context.debug = frappe.form_dict.get('debug') == '1' + + psl: PaymentSessionLog = get_psl() + state = psl.load_state() + context.tx_data: TxData = state.tx_data + context.grand_total = state.tx_data.amount + (state.tx_data.discount_amount or 0) + if state.tx_data.loyalty_points: + context.grand_total += state.tx_data.loyalty_points[2] + context.has_discount = state.tx_data.discount_amount or state.tx_data.loyalty_points + + # keep in sync with payment_controller.py + terminal_states = { + "Paid": "green", + "Authorized": "green", + "Processing": "yellow", + "Error": "red", + "Error - RefDoc": "red", + } + + # Not reached a terminal state, yet + # A terminal error state would require operator intervention, first + if psl.status not in terminal_states.keys(): + # First Pass: chose payment button + # gateway was preselected; e.g. on the backend + filters = {"enabled": True} + if psl.gateway: + filters.update(json.loads(psl.gateway)) + + buttons = frappe.get_list( + "Payment Button", + fields=["name", "icon", "label"], + filters=filters, + order_by="priority", + ) + + context.payment_buttons = [ + (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) + for entry in frappe.get_list( + "Payment Button", + fields=["name", "icon", "label"], + filters=filters, + ) + ] + context.render_buttons = True + + if not psl.button: + context.render_widget = False + context.render_capture = False + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + + # Second Pass (Data Capture): capture additonal data if the button requires it + elif psl.requires_data_capture: + context.render_widget = False + context.render_capture = True + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + + proceeded: Proceeded = PaymentController.pre_data_capture_hook(psl.name) + # Display + button: PaymentButton = psl.get_button() + context.data_capture = button.get_data_capture_assets(state) + context.button_name = psl.button + + # Second Pass (Third Party Widget): let the third party widget manage data capture and flow + else: + context.render_widget = True + context.render_capture = False + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + + proceeded: Proceeded = PaymentController.proceed(psl.name) + + # Display + payload: RemoteServerInitiationPayload = proceeded.payload + button: PaymentButton = psl.get_button() + css, js, wrapper = button.get_widget_assets(payload) + context.gateway_css = css + context.gateway_js = js + context.gateway_wrapper = wrapper + + # Response processed already: show the result + else: + context.render_widget = False + context.render_buttons = False + context.render_capture = False + context.status = psl.status + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + context.indicator_color = terminal_states.get(psl.status, "gray")