Integration example

Simple example on how do integration with NHP. Covers authentication, webhooks, pay-js-sdk and calling the api directly.

readme

Minimum example for Nordhealth Pay API / webhooks (Subhub) integration

If you want to see it running:
create & use virtual python environment: python -m venv .venv; source .venv/bin/activate
install dependencies: pip install -r requirements.txt
Check & modify the config.py file for configuration values
especially JWK_PASSPHRASE and SUBHUB_CURRENT_KEY and INTEGRATION_EXAMPLE_BASE_URL
Running the script first time will generate you RSA keypair (id_example) which will be used to sign JWTs

To run server to publish jwk and webhook receiver:
flask --app main run --host=0.0.0.0 --port=8000 --debug
Urls:
http://localhost:8000/etrust/jwks.json (jwk public key)
http://localhost:8000/incoming-webhooks/ (receive webhooks)
http://localhost:8000/example/ Call NH Pay api

To manage webhooks:
Run the script with following (optional) params (python main.py) for event subscription operations.
Specify ern, if you targeting speciric product/tenant (e.g. ern:dummypms/tenants/859c7f6b-90a4-43b4-a83c-24f9f8e2866d)
--subscribe [ern] 
--events [comma separated list of events to susbscribe to] (if omitted, uses default values)
--unsubscribe [ern] (unsubcribe from all events)
Running without --subscribe or --unsubcribe will list subscribed events

In order this to actually work, you need to configure the public key to NHP tenant authorizer with the help of NHP Team.

requirements.txt

Flask==3.0.0
jwcrypto==1.5.0
requests==2.31.0

main.py

from flask import Flask, request
import auth
import api
import events
import subhub
import pay_js_sdk
import helpers
import pprint
from config import config

app = Flask(__name__)

# Expose public JWK to public url so NH Pay tenant-authority can access it
@app.route("/etrust/jwks.json")
def get_public_key():
    return auth.get_public_key()

# Call NH Pay api
@app.route("/example/")
def get_info():
    return api.get_info()

# Receive events from SUBHUB via HTTP POST
@app.route("/incoming-webhooks/", methods=["POST"])
def incoming():
    return events.process_event()

# PAY-JS-SDK
@app.route("/etrust/tenant-token/", methods=["POST", "GET"])
def tenant_token():
    return pay_js_sdk.tenant_token_for_pay_js_sdk()

# PAY-JS-SDK
@app.route("/management/", methods=["GET"])
def management():
    return pay_js_sdk.management_page()

# PAY-JS-SDK
@app.route("/checkout/", methods=["GET"])
def checkout():
    return pay_js_sdk.checkout_page()


# Subhub subscription stuff
if __name__ == "__main__":
    pprint.pprint(config)
    command, cmd_events, ern = helpers.parse_command_line_arguments()
    if command == "subscribe":
        subhub.subscribe(cmd_events, ern)
    elif command == "unsubscribe":
        subhub.unsubscribe(ern)
    else:
        subhub.show_subscriptions(ern) 

auth.py

from jwcrypto.jwk import JWK
from jwcrypto.jwt import JWT

import pprint
import time

from config import config


# Try loading existing RSA keys from file, or generate new ones and save them. In prod, keys should be recycled regularly.
def _get_rsa_keypair():
    private_key_pem = None
    public_key_pem = None
    try:
        f = open("id_example", "r")
        private_key_pem = f.read()
        f.close()
        f = open("id_example.pub", "r")
        public_key_pem = f.read()
        f.close()
        print("Using existing keys")
    except:
        print("Private/public keys not found - generating new keypair")
        key = JWK.generate(kty="RSA", size=2048)
        passphrase = config["JWK_PASSPHRASE"].encode("utf-8")
        private_key_pem = key.export_to_pem(
            private_key=True, password=passphrase
        ).decode("utf-8")
        public_key_pem = key.export_to_pem(private_key=False).decode("utf-8")
        f = open("id_example", "w")
        f.write(private_key_pem)
        f.close()
        f = open("id_example.pub", "w")
        f.write(public_key_pem)
        f.close()
    return private_key_pem, public_key_pem


def _get_jwk_based_on_rsa_key():
    private_key_pem, _ = _get_rsa_keypair()
    jwk = JWK.from_pem(
        private_key_pem.encode("utf-8"), password=config["JWK_PASSPHRASE"].encode("utf-8")
    )
    print("JWK:")
    pprint.pprint(jwk)
    return jwk


def get_public_key():
    jwk = _get_jwk_based_on_rsa_key()
    jwk_public_key = jwk.export_public(as_dict=True)
    jwk_public_key.update({"use": "sig", "alg": "RS256"})  # Add needed metadata
    return jwk_public_key


def generate_tenant_token(
    aud=None,
    scope=None,
    tenant_ern=None,
    tenant_name=None,
    user_ern=None,
    validity=180,
    leeway=60,
):
    jwk = _get_jwk_based_on_rsa_key()
    iss = config["INTEGRATION_EXAMPLE_ISSUER"]
    header = {"typ": "JWT", "alg": "RS256", "kid": jwk.thumbprint()}
    claims = {
        "iss": iss,
        "sub": iss,
        "aud": aud,
        "iat": int(time.time()),
        "exp": int(time.time()) + validity,
        "nbf": int(time.time()) - leeway,
        "scope": scope,
        "tenant_name": tenant_name,
        "tenant_ern": tenant_ern,
        "user_ern": user_ern,
    }
    jwt_obj = JWT(header=header, claims=claims)
    print("Signing JWT {}.{}".format(header, claims))
    jwt_obj.make_signed_token(key=jwk)
    print("Token generated:")
    pprint.pprint(jwt_obj.serialize())
    return jwt_obj

events.py

import json
import hashlib
import binascii
import hmac
import pprint

from config import config

def process_event(request):
    pprint.pprint(request.headers)
    pprint.pprint(request.data)
    # Verify request signature, Subhub-Hmac header must be present
    subhub_hmac = request.headers.get("Subhub-Hmac")
    if not subhub_hmac:
        print(f"Unsigned POST to {request.path} from ")
        return "Unsigned POST"
    binary_key = binascii.unhexlify(config["SUBHUB_CURRENT_KEY"])
    sig = hmac.new(binary_key, request.data, hashlib.sha256).hexdigest()
    if sig != subhub_hmac:
        print(f"Invalid Subhub signature {sig}")
        return "Invalid Subhub signature"

    # Verify the publisher value is what we expect ourselves before processing the notification.
    event = json.loads(request.data)
    publisher = event["header"]["publisher"]
    if publisher != config["PAYMENT_GATEWAY_PRODUCT_AUD"]:
        print("Unknown publisher {}".format(event["header"]["publisher"]))
        return "Unknown publisher"

    # Process actual event
    pprint.pprint(event)
    return f'processed ok {event["header"]["event_id"]}'

subhub.py

from config import config
import pprint
import requests
import auth

def _get_auth_headers(tenant_ern):
    return auth.generate_tenant_token(
        aud=config["SUBHUB_PRODUCT_AUD"],
        scope="subhub:subscribe",
        tenant_ern=tenant_ern,
        user_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public",  # Act as a system
    ).serialize()

def subscribe(events, ern):
    assert config["INTEGRATION_EXAMPLE_BASE_URL"], "You need publicly accessible url to receive webhooks defined."
    subscription = {
        "publisher": config["PAYMENT_GATEWAY_PRODUCT_AUD"],
        "events": [
            "transaction.pending",
            "transaction.approved",
            "transaction.failed",
            "payment_link.created",
            "payment_link.expired",
            "payment_link.completed",
            "tokenization.succeeded",
            "tokenization.failed",
            "company_status.changed",
            "department_status.changed",
        ]
        if not events
        else events,
        "destination": f"{config['INTEGRATION_EXAMPLE_BASE_URL']}/incoming-webhooks/",
        "protocol": "http",
        "signing_key": config["SUBHUB_CURRENT_KEY"],
    }
    subscription_url = f"{config['SUBHUB_URL']}/my/subscriptions/{config['SUBHUB_SUBSCRIPTION_ID']}"
    pprint.pprint(subscription)
    resp = requests.put(subscription_url, json=subscription, headers=_get_auth_headers(ern))
    pprint.pprint(resp)  
      
def unsubscribe(ern):
    subscription_url = f"{config['SUBHUB_URL']}/my/subscriptions/{config['SUBHUB_SUBSCRIPTION_ID']}"
    resp = requests.delete(subscription_url, headers=_get_auth_headers(ern))
    pprint.pprint(resp)
  
def show_subscriptions(ern):
    print("Auth headers:")
    auth_headers = _get_auth_headers(ern)
    pprint.pprint(auth_headers)
    response = requests.get(f"{config['SUBHUB_URL']}/my/subscriptions", headers=auth_headers)
    print("Response:")
    pprint.pprint(response.json())
  

api.py

import auth
from config import config
import requests

def get_info():
    token = auth.generate_tenant_token(
        aud=config["PAYMENT_GATEWAY_PRODUCT_AUD"],
        scope="pay:processPayments",
        tenant_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public",
        user_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public",  # Act as a system
    )
    header = {"Authorization": f"Bearer {token.serialize()}"}
    resp = requests.get(f"{config['PAYMENT_GATEWAY_API_URL']}/api/v1/info/", headers=header)
    return resp.json()

pay_js_sdk.py

import auth
from config import config


def tenant_token_for_pay_js_sdk():
    # Define scope based on logged in user access rights. Only admin users should have access to manageIntegration (opening management-ui)
    scope = "pay:manageIntegration pay:processPayments pay:chargeToken",
    token = auth.generate_tenant_token(
        aud=config["PAYMENT_GATEWAY_PRODUCT_AUD"],
        scope=scope,
        tenant_ern=f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/1",
        tenant_name="integration example",
        user_ern="//users/1",  # authenticated user
    )
    return {"token": token.serialize(), "validity": 180}


def management_page():
    return """
    <html>
    <head>
      <script src="https://nordcdn-stage.net/pay/pay-js-sdk/2.0.x/pay-js-sdk.min.js"></script>
      <script>
          var PAYMENT_GATEWAY_FRONTEND_URL = 'https://pay.nordhealth-test.com';
          var PAYMENT_GATEWAY_API_URL = 'https://api.pay.nordhealth-test.app';
      </script>
    </head>
    <title>PMS page open management</title>
    <body>
      <div class="col-md mx-auto">
        <button id="payment-integration-settings" class="btn btn-lg btn-primary text-left w-50">
            Payment integration settings
        </button>
    </div>    
     <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
     <script>
        (function ($, et) {
            var auth = new et.Auth({tokenEndpoint: '/etrust/tenant-token/'});
            var organizationStructure = [{"name": "integration-test", "ern": "//companies/1", "reference": 1, "departments": [{"ern": "//departments/1", "reference": 1, "name": "test department", "active": true}], "country": "FI", "email": "joe@example.com", "mcc": "0742"}];
            console.log(organizationStructure)
            $('#payment-integration-settings').click(function () {
                (new eTrust.Pay.Management({
                    auth: auth,
                    apiUrl: PAYMENT_GATEWAY_API_URL,
                    baseUrl: PAYMENT_GATEWAY_FRONTEND_URL,
                    organizationStructure: organizationStructure
                })).openWindow();
            });            
        })(jQuery, eTrust);
    </script>
    </body>
    </html>
    """


def checkout_page():
    return """
    <html>
    <head>
      <script src="https://nordcdn-stage.net/pay/pay-js-sdk/2.0.x/pay-js-sdk.min.js"></script>
      <script>
          var PAYMENT_GATEWAY_FRONTEND_URL = 'https://pay.nordhealth-test.com';
          var PAYMENT_GATEWAY_API_URL = 'https://api.pay.nordhealth-test.app';
      </script>
    </head>
    <title>PMS page open checkout</title>
    <body>
        <div class="col-md mx-auto">
            <button id="payment-checkout" class="btn btn-lg btn-primary text-left w-50">
                Checkout
            </button>
        </div>    
          <div class="col-md mx-auto">
            <button id="payment-refund" class="btn btn-lg btn-primary text-left w-50">
                Refund
            </button>
        </div>    
          
     <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
     <script>
     (function ($, et) {
        function statusCallback(paymentStatus) {
            console.log('paymentStatus', paymentStatus)
            if (paymentStatus.notification) {
                console.log('notification', paymentStatus.notification);
            }
        }

        function openCheckout(data) {
            var auth = new et.Auth({
                tokenEndpoint: '/etrust/tenant-token/',
            });
            new et.Pay.Checkout({
                paymentStatusCallback: data.paymentStatusCallback,
                auth: auth,
                apiUrl: PAYMENT_GATEWAY_API_URL,
                baseUrl: PAYMENT_GATEWAY_FRONTEND_URL,
                locale: 'fi-FI',
                timezone: 'Europe/Helsinki',
                shopperErn: '//customers/1',
                storeErn: '//departments/1',
                shopperEmail: 'shopper@local.dev',
                shopperPhone: '+3584012344566',
                metaData: {
                    invoice_id: "234"
                },
            }).openModal(data.modalData);
        }

        $('#payment-checkout').click(function () {
            openCheckout({
                paymentStatusCallback: statusCallback,
                modalData: {
                    uiType: 'payment',
                    amount: "10.00",
                    currency: 'EUR',
                    onClose: function(data) {
                        console.log('payment onClose', data)
                        if (data.wasCharged)
                            window.location.reload()
                    },
                    tokenization: undefined,
                    paymentDetails: { 
                        lineItems:[
                            {
                                quantity: 1,
                                amountExcludingTax: '10.00',
                                taxPercentage: "0",
                                description: 'product description',
                                id: "1234-5678",
                                taxAmount: '0',
                                amountIncludingTax: '10.00',
                            }
                        ]
                    },
                }
            })
        });
        
        $('#payment-refund').click(function() {
            openCheckout({
                paymentStatusCallback: statusCallback,
                modalData: {
                    uiType: 'refund',
                    extReference: 'payment-reference',
                    currency: 'EUR',
                    onClose: function(data) {
                        console.log('refund onClose', data)
                        if (data.wasCharged)
                            window.location.reload()
                    }
                }
            })
        })
    })(jQuery, eTrust);
    </script>
    </body>
    </html>
    """

config.py

# These need to be configured to tenant-authority by NH Pay team along with public jwk (url)
# In order to acceess api / notifications for another product/tenant, it needs to be explicitly configured in tenant-authority
config = {
  "INTEGRATION_EXAMPLE_PRODUCT_NAME": "integrationexample-dev",
  "INTEGRATION_EXAMPLE_ISSUER": "https://integrationexample-dev.etrust.health",
  # This public url is used by subhub to deliver notifications and Payment gateway to fetch public jwk. Provide url (or the response) for tenant-authority
  "INTEGRATION_EXAMPLE_BASE_URL": "https://c88e-2a00-1190-c034-aba-00-1016.ngrok-free.app", #e.g. https://4ecc-195-181-204-114.ngrok-free.app
  # Secrets:
  # Generate unqiue values e.g. with:
  # binascii.hexlify(os.urandom(32)).decode("ascii")
  "JWK_PASSPHRASE": "121100b3e6ba1f5776a7de3bfcd3fcc8554074618b35c89503e125a02d9940cd",
  "SUBHUB_CURRENT_KEY": "74e567c8baedb91899c9bb59971fda93c108584e9323fa211d114e75e51bc807",
  # Target NH PAY environment and names
  "SUBHUB_URL": "https://subhub.nordhealth-test.app/v1",
  "PAYMENT_GATEWAY_API_URL": "https://api.pay.nordhealth-test.app",
  "SUBHUB_PRODUCT_AUD": "https://subhub.etrust.health",
  "PAYMENT_GATEWAY_PRODUCT_AUD": "https://pay.etrust.health",
}

# Other configurations
config["SUBHUB_SUBSCRIPTION_ID"] = f"pay_{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}"
assert config.get("JWK_PASSPHRASE")
assert config.get("SUBHUB_CURRENT_KEY")

helpers.py

import argparse
from config import config

def parse_command_line_arguments():
    parser = argparse.ArgumentParser(
        description="Example script to 1. generate JWK keys, 2. sign token to make api calls, 3. manage subhub subscriptions"
    )
    parser.add_argument(
        "--tenant",
        help="Target ern, e.g. ern:dummypms/tenants/859c7f6b-90a4-43b4-a83c-24f9f8e2866d",
    )
    parser.add_argument("--subscribe", action="store_true", default=False)
    parser.add_argument("--unsubscribe", action="store_true", default=False)
    parser.add_argument(
        "--events",
        help="Events to be subscribed to. Defaults to all if omitted. e.g. 'payment_link.created,payment_link.expired,payment_link.completed'",
    )
    args = parser.parse_args()
    ern = (
        args.tenant
        if args.tenant
        else f"ern:{config['INTEGRATION_EXAMPLE_PRODUCT_NAME']}/tenants/public"
    )  # target tenant or own system ern
    events = args.events.split(",") if args.events else []
    if args.subscribe:
        command = "subscribe"
    elif args.unsubscribe:
        command = "unsubsribe"
    else:
        command = "show"
   
    return command, events, ern