diff --git a/flask_ask/__init__.py b/flask_ask/__init__.py index d878d12..a3cb90e 100644 --- a/flask_ask/__init__.py +++ b/flask_ask/__init__.py @@ -24,6 +24,8 @@ elicit_slot, confirm_slot, confirm_intent, + setup_payment, + charge_payment, buy, upsell, refund diff --git a/flask_ask/core.py b/flask_ask/core.py index bf006c1..def84fd 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -852,7 +852,6 @@ def _map_player_request_to_func(self, player_request_type): def _map_purchase_request_to_func(self, purchase_request_type): """Provides appropriate parameters to the on_purchase functions.""" - if purchase_request_type in self._intent_view_funcs: view_func = self._intent_view_funcs[purchase_request_type] else: @@ -862,7 +861,7 @@ def _map_purchase_request_to_func(self, purchase_request_type): arg_names = argspec.args arg_values = self._map_params_to_view_args(purchase_request_type, arg_names) - print('_map_purchase_request_to_func', arg_names, arg_values, view_func, purchase_request_type) + #print('_map_purchase_request_to_func', arg_names, arg_values, view_func, purchase_request_type) return partial(view_func, *arg_values) def _get_slot_value(self, slot_object): diff --git a/flask_ask/models.py b/flask_ask/models.py index d159f7b..ddc7606 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -201,6 +201,38 @@ def reprompt(self, reprompt): return self +class setup_payment(_Response): + + def __init__(self, setupPayload=None): + self._response = { + 'shouldEndSession': True, + 'directives': [{ + 'type': 'Connections.SendRequest', + 'name': 'Setup', + 'payload': { + 'SetupAmazonPay': setupPayload + }, + 'token': 'correlationToken' + }] + } + + +class charge_payment(_Response): + + def __init__(self, chargePayload=None): + self._response = { + 'shouldEndSession': True, + 'directives': [{ + 'type': 'Connections.SendRequest', + 'name': 'Charge', + 'payload': { + 'ChargeAmazonPay': chargePayload + }, + 'token': 'correlationToken' + }] + } + + class buy(_Response): def __init__(self, productId=None): diff --git a/samples/payment_integration/README.md b/samples/payment_integration/README.md new file mode 100644 index 0000000..ac1c1ef --- /dev/null +++ b/samples/payment_integration/README.md @@ -0,0 +1,13 @@ +Porting payment integration code in Amazon Alexa cookbook written in node.js (https://github.com/alexa/alexa-cookbook/tree/master/feature-demos/skill-demo-amazon-pay) to python. Made changes to suit the flask-ask library and fixed a few bugs. + +### Deployment Instruction +```bash +# From path_to/samples/payment_integration, run: +python index.py + +# If you are testing locally, install ngrok and in a new terminal window, do: +ngrok http -bind-tls=true 5000 +``` +In Alexa developer console, paste en-US.json to the json editor. Paste the ngrok forwarding url, which looks like https://xxxxxxxx.ngrok.io, to Alexa Endpoint. Enable Amazon payment in skill permissions. + +Then, you may ask alexa to "open payments demo" and say "yes" to the purchase confirmation. diff --git a/samples/payment_integration/config.py b/samples/payment_integration/config.py new file mode 100644 index 0000000..5dda020 --- /dev/null +++ b/samples/payment_integration/config.py @@ -0,0 +1,81 @@ +import utilities + +# TODO: +# 1. Fill in appID and sellerId +# 2. Fill in sandboxCustomerEmailId and set sandboxMode as True +# OR +# provide an empty string to sandboxCustomerEmailId and set sandboxMode as False + + +# GLOBAL +appID = 'amzn1.ask.skill.xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # Required; Alexa Skill ID +sellerId = '' # Required; Amazon Pay seller ID; Used for both Setup and Process Payment + +# DIRECTIVE CONFIG +connectionCharge = 'Charge' # Required; +connectionSetup = 'Setup' # Required; +directiveCharge = 'ChargeAmazonPay' # Required; +directiveSetup = 'SetupAmazonPay' # Required; +directiveType = 'Connections.SendRequest' # Required; +version = '1.0' # Required; + +# SETUP +checkoutLanguage= 'en_US' # Optional; US must be en_US +countryOfEstablishment= 'US' # Required; +ledgerCurrency= 'USD' # Required; This doesn't exist in web SDK; GBP and EUR +needAmazonShippingAddress= True # Optional; Must be boolean; +sandboxCustomerEmailId= 'test@example.com' # Optional; Required if sandboxMode equals true; Must setup Amazon Pay test account first; +sandboxMode= True # Optional; Must be false for certification; Must be boolean; + +# PROCESS PAYMENT +paymentAction= 'AuthorizeAndCapture' # Required; Authorize or AuthorizeAndCapture +providerAttributes= '' # Optional; Required if Solution Provider. +sellerOrderAttributes= '' # Optional; + +# AUTHORIZE ATTRIBUTES +authorizationReferenceId = utilities.generateRandomString( 32 ) # Required; Must be unique, max 32 chars +sellerAuthorizationNote = utilities.getSimulationString( '' ) # Optional; Max 255 chars +softDescriptor = '16charSoftDesc' # Optional; Max 16 chars +transactionTimeout = 0 # Optional; The default value for Alexa transactions is 0. + +# AUTHORIZE AMOUNT +amount = '0.99' # Required; Max $150,000 +currencyCode = 'USD' # Required; + +# SELLER ORDER ATTRIBUTES +customInformation = 'customInformation max 1024 chars' # Optional; Max 1024 chars +sellerNote = 'sellerNote max 1024 chars' # Optional; Max 1024 chars +sellerOrderId = 'Alexa unique sellerOrderId' # Optional; Merchant specific order ID +sellerStoreName = 'Blitz and Chips' # Optional; Documentation calls this out as storeName not sellerStoreName + +# ADDITIONAL ATTRIBUTES +platformId = '' # Optional; Used for Solution Providers +sellerBillingAgreementId = '' # Optional; The merchant-specified identifier of this billing agreement +storeName = sellerStoreName # Optional; Why is there 2 store names? + + +# The following strings DO NOT interact with Amazon Pay +# They are only here to augment the skill + +# INTENT RESPONSE STRINGS +launchRequestWelcomeResponse = 'Welcome to ' + sellerStoreName + '. ' # Optional; Used for demo only +launchRequestQuestionResponse = 'Would you like to buy one Plumbus for $'+ amount +'?' # Optional; Used for demo only +noIntentResponse = 'There is nothing else for sale. Goodbye.' # Optional; Used for demo only +noIntentMessage = 'Please visit us again' # Optional; Used for demo only +orderConfirmationResponse = 'Thank you for ordering from ' + sellerStoreName + '. Goodbye.' # Optional; Used for demo only +orderConfirmationTitle = 'Order Details for Testing' # Optional; Used for demo only +storeURL = 'blitzandchips.com' # Optional; Used for demo only + +# ERROR RESPONSE STRINGS +enablePermission = 'Please enable permission for Amazon Pay in your Alexa app.' # Optional; Used for demo only +scope = 'payments:autopay_consent' # Optional; Used for demo only +errorMessage = 'Merchant error occurred. ' # Optional; Used for demo only +errorUnknown = 'Unknown error occurred. ' +errorStatusCode = 'Status code: ' # Optional; Used for demo only +errorStatusMessage = ' Status message: ' # Optional; Used for demo only +errorPayloadMessage = ' Payload message: ' # Optional; Used for demo only +errorBillingAgreement = 'Billing agreement state is ' +errorBillingAgreementMessage= '. Reach out to the user to resolve this issue.' # Optional; Used for demo only +authorizationDeclineMessage = 'Your order was not placed and you have not been charged.' # Optional; Used for demo only +debug = 'debug' # Optional; Used for demo only + diff --git a/samples/payment_integration/en-US.json b/samples/payment_integration/en-US.json new file mode 100644 index 0000000..7a92865 --- /dev/null +++ b/samples/payment_integration/en-US.json @@ -0,0 +1,32 @@ +{ + "interactionModel": { + "languageModel": { + "invocationName": "payments demo", + "intents": [ + { + "name": "AMAZON.CancelIntent", + "samples": [] + }, + { + "name": "AMAZON.HelpIntent", + "samples": [ + "help" + ] + }, + { + "name": "AMAZON.StopIntent", + "samples": [] + }, + { + "name": "AMAZON.YesIntent", + "samples": [] + }, + { + "name": "AMAZON.NoIntent", + "samples": [] + } + ], + "types": [] + } + } +} \ No newline at end of file diff --git a/samples/payment_integration/error_handler.py b/samples/payment_integration/error_handler.py new file mode 100644 index 0000000..aca7dcb --- /dev/null +++ b/samples/payment_integration/error_handler.py @@ -0,0 +1,104 @@ +import config + +from flask_ask import statement + + +# These are errors that will not be handled by Amazon Pay; Merchant must handle +def handleErrors(request): + errorMessage = '' + permissionsError = False + actionResponseStatusCode = request['status']['code'] + actionResponseStatusMessage = request['status']['message'] + actionResponsePayloadMessage = '' + if 'errorMessage' in request['payload']: + actionResponsePayloadMessage = request['payload']['errorMessage'] + + knownPermissionError = { + # Permissions errors - These must be resolved before a user can use Amazon Pay + 'ACCESS_DENIED', + 'ACCESS_NOT_REQUESTED', + 'FORBIDDEN' + } + knownIntegrationOrRuntimeError = { + # Integration errors - These must be resolved before Amazon Pay can run + 'BuyerEqualsSeller', + 'InvalidParameterValue', + 'InvalidSandboxCustomerEmail', + 'InvalidSellerId', + 'UnauthorizedAccess', + 'UnsupportedCountryOfEstablishment', + 'UnsupportedCurrency', + + # Runtime errors - These must be resolved before a charge action can occur + 'DuplicateRequest', + 'InternalServerError', + 'InvalidAuthorizationAmount', + 'InvalidBillingAgreementId', + 'InvalidBillingAgreementStatus', + 'InvalidPaymentAction', + 'PeriodicAmountExceeded', + 'ProviderNotAuthorized', + 'ServiceUnavailable' + } + if actionResponseStatusMessage in knownPermissionError: + permissionsError = True + errorMessage = config.enablePermission + elif actionResponseStatusMessage in knownIntegrationOrRuntimeError: + errorMessage = config.errorMessage + config.errorStatusCode + actionResponseStatusCode + '.' + config.errorStatusMessage + actionResponseStatusMessage + '.' + config.errorPayloadMessage + actionResponsePayloadMessage + else: + errorMessage = config.errorUnknown + + debug('handleErrors', request) + + # If it is a permissions error send a permission consent card to the user, otherwise .speak() error to resolve during testing + if permissionsError: + return statement(errorMessage).consent_card(config.scope) + else: + return statement(errorMessage) + + +# If billing agreement equals any of these states, you need to get the user to update their payment method +# Once payment method is updated, billing agreement state will go back to OPEN and you can charge the payment method +def handleBillingAgreementState( billingAgreementStatus, request): + errorMessage = '' + + knownStatus = { + 'CANCELED', + 'CLOSED', + 'SUSPENDED' + } + if billingAgreementStatus in knownStatus: + errorMessage = config.errorBillingAgreement + billingAgreementStatus + config.errorBillingAgreementMessage + else: + errorMessage = config.errorUnknown + + debug('handleBillingAgreementState', request) + + return statement(errorMessage) + + +# Ideal scenario in authorization decline is that you save the session, allow the customer to fix their payment method, +# and allow customer to resume session. This is just a simple message to tell the user their order was not placed. +def handleAuthorizationDeclines( authorizationStatusReasonCode, request): + errorMessage = '' + + knownReasonCode = { + 'AmazonRejected', + 'InvalidPaymentMethod', + 'ProcessingFailure', + 'TransactionTimedOut' + } + if authorizationStatusReasonCode in knownReasonCode: + errorMessage = config.authorizationDeclineMessage + else: + errorMessage = config.errorUnknown + + debug('handleAuthorizationDeclines', request) + + return statement(errorMessage) + + +# Output object to console for debugging purposes +def debug(funcName, request ): + print('ERROR in %s --- %s\n' % (funcName, str(request))) + diff --git a/samples/payment_integration/handler.py b/samples/payment_integration/handler.py new file mode 100644 index 0000000..8121885 --- /dev/null +++ b/samples/payment_integration/handler.py @@ -0,0 +1,98 @@ +import logging +import os + +from flask import Flask #. /usr/local/opt/libpcap/bin:/usr/local/opt/gettext/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/go/bin:/Users/qiheng/Library/Python/2.7/bin +from flask_ask import Ask, request, context, session, question, statement + +import utilities +import config + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +@ask.launch +def launch(): + return question(config.launchRequestWelcomeResponse + config.launchRequestQuestionResponse).reprompt(config.launchRequestQuestionResponse) + + +# No, I do not want to buy something +@ask.intent('AMAZON.NoIntent') +def no(): + return question(config.noIntentResponse).simple_card(config.noIntentMessage, config.storeURL) + + +#Yes, I do want to buy something +@ask.intent('AMAZON.YesIntent') +def yes(): + # If you have a valid billing agreement from a previous session, skip the Setup action and call the Charge action instead + consentToken = utilities.getConsentToken(context) + token = utilities.generateRandomString(12) + + # If you do not have a billing agreement set the Setup payload in Skill Connections and send the request directive + setupPayload = payload.buildSetup( consentToken ); + + return directive.sendDirective(config.directiveType, config.connectionSetup, setupPayload, config.directiveSetup, token, context, False) + + +# You requested the Setup or Charge directive and are now receiving the Connections.Response +@ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'}) +def connectionResponse(): + consentToken = utilities.getConsentToken(context) + connectionName = request['name'] + connectionPayload = request['payload'] + connectionResponseStatusCode = request['status']['code'] + + if connectionResponseStatusCode != 200: + return error.handleErrors(request) + else: + #Receiving Setup Connections.Response + if connectionName == config.connectionSetup: + token = utilities.generateRandomString( 12 ) + + # Get the billingAgreementId and billingAgreementStatus from the Setup Connections.Response + billingAgreementId = connectionResponsePayload['billingAgreementDetails']['billingAgreementId'] + billingAgreementStatus = connectionResponsePayload['billingAgreementDetails']['billingAgreementStatus'] + + # If billingAgreementStatus is valid, Charge the payment method + if billingAgreementStatus == 'OPEN': + # Set the Charge payload in Skill Connections and send the request directive + chargePayload = payload.buildCharge( consentToken, billingAgreementId ) + return directive.sendDirective(config.directiveType, config.connectionCharge, chargePayload, config.directiveCharge, token, context, True) + + # If billingAgreementStatus is not valid, do not Charge the payment method + else: + return error.handleBillingAgreementState(billingAgreementStatus, request) + + # Receiving Charge Connections.Response + elif connectionName == config.connectionCharge: + authorizationStatusState = connectionResponsePayload['authorizationDetails']['state'] + + # Authorization is declined, tell the user their order was not placed + if authorizationStatusState == 'Declined': + authorizationStatusReasonCode = connectionResponsePayload['authorizationDetails']['reasonCode'] + return error.handleAuthorizationDeclines(authorizationStatusReasonCode, request) + + # Authorization is approved, tell the user their order was placed + else: + return statement(config.orderConfirmationResponse) + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +def lambda_handler(event, context): + return ask.run_aws_lambda(event) + +""" +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config.ASK_VERIFY_REQUESTS = False + app.run(debug=True) +""" \ No newline at end of file diff --git a/samples/payment_integration/index.py b/samples/payment_integration/index.py new file mode 100644 index 0000000..f1557f1 --- /dev/null +++ b/samples/payment_integration/index.py @@ -0,0 +1,94 @@ +import logging +import os + +from flask import Flask +from flask_ask import Ask, request, context, session, question, statement, setup_payment, charge_payment + +import utilities +import config +import payload +import error_handler + + +app = Flask(__name__) +ask = Ask(app, "/") +logging.getLogger('flask_ask').setLevel(logging.DEBUG) + + +@ask.launch +def launch(): + return question(config.launchRequestWelcomeResponse + config.launchRequestQuestionResponse).reprompt(config.launchRequestQuestionResponse) + + +# No, I do not want to buy something +@ask.intent('AMAZON.NoIntent') +def no(): + return question(config.noIntentResponse).simple_card(config.noIntentMessage, config.storeURL) + + +#Yes, I do want to buy something +@ask.intent('AMAZON.YesIntent') +def yes(): + # If you have a valid billing agreement from a previous session, skip the Setup action and call the Charge action instead + consentToken = utilities.getConsentToken(context) + token = utilities.generateRandomString(12) + + # If you do not have a billing agreement set the Setup payload in Skill Connections and send the request directive + setupPayload = payload.buildSetup(consentToken); + return setup_payment(setupPayload) + + +# You requested the Setup or Charge directive and are now receiving the Connections.Response +@ask.on_purchase_completed() +def connectionResponse(): + consentToken = utilities.getConsentToken(context) + connectionName = request['name'] + connectionResponsePayload = request['payload'] + connectionResponseStatusCode = request['status']['code'] + + if connectionResponseStatusCode != '200': + return error_handler.handleErrors(request) + else: + #Receiving Setup Connections.Response + if connectionName == config.connectionSetup: + token = utilities.generateRandomString( 12 ) + + # Get the billingAgreementId and billingAgreementStatus from the Setup Connections.Response + billingAgreementId = connectionResponsePayload['billingAgreementDetails']['billingAgreementId'] + billingAgreementStatus = connectionResponsePayload['billingAgreementDetails']['billingAgreementStatus'] + + # If billingAgreementStatus is valid, Charge the payment method + if billingAgreementStatus == 'OPEN': + # Set the Charge payload in Skill Connections and send the request directive + chargePayload = payload.buildCharge(consentToken, billingAgreementId) + return charge_payment(chargePayload) + + # If billingAgreementStatus is not valid, do not Charge the payment method + else: + return error.handleBillingAgreementState(billingAgreementStatus, request) + + # Receiving Charge Connections.Response + elif connectionName == config.connectionCharge: + authorizationStatusState = connectionResponsePayload['authorizationDetails']['authorizationStatus']['state'] + + # Authorization is declined, tell the user their order was not placed + if authorizationStatusState == 'Declined': + authorizationStatusReasonCode = connectionResponsePayload['authorizationDetails']['authorizationStatus']['reasonCode'] + return error.handleAuthorizationDeclines(authorizationStatusReasonCode, request) + + # Authorization is approved, tell the user their order was placed + else: + return statement(config.orderConfirmationResponse) + + +@ask.session_ended +def session_ended(): + return request, 200 + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config.ASK_VERIFY_REQUESTS = False + app.run(debug=True) diff --git a/samples/payment_integration/payload.py b/samples/payment_integration/payload.py new file mode 100644 index 0000000..6081e41 --- /dev/null +++ b/samples/payment_integration/payload.py @@ -0,0 +1,53 @@ +import config + +# Builds payload for Setup action +def buildSetup (consentToken): + payload = { + 'consentToken': consentToken, + 'sellerId': config.sellerId, + 'sandboxCustomerEmailId': config.sandboxCustomerEmailId, + 'sandboxMode': config.sandboxMode, + 'checkoutLanguage': config.checkoutLanguage, + 'countryOfEstablishment': config.countryOfEstablishment, + 'ledgerCurrency': config.ledgerCurrency, + 'billingAgreementAttributes': { + 'platformId': config.platformId, + 'sellerNote': config.sellerNote, + 'sellerBillingAgreementAttributes': { + 'sellerBillingAgreementId': config.sellerBillingAgreementId, + 'storeName': config.storeName, + 'customInformation': config.customInformation + } + }, + 'needAmazonShippingAddress': config.needAmazonShippingAddress + } + + return payload + + +# Builds payload for Charge action +def buildCharge (consentToken, billingAgreementId): + payload = { + 'consentToken': consentToken, + 'sellerId': config.sellerId, + 'billingAgreementId': billingAgreementId, + 'paymentAction': config.paymentAction, + 'authorizeAttributes': { + 'authorizationReferenceId': config.authorizationReferenceId, + 'authorizationAmount': { + 'amount': config.amount, + 'currencyCode': config.currencyCode + }, + 'transactionTimeout': config.transactionTimeout, + 'sellerAuthorizationNote': config.sellerAuthorizationNote, + 'softDescriptor': config.softDescriptor + }, + 'sellerOrderAttributes': { + 'sellerOrderId': config.sellerOrderId, + 'storeName': config.storeName, + 'customInformation': config.customInformation, + 'sellerNote': config.sellerNote + } + } + + return payload diff --git a/samples/payment_integration/utilities.py b/samples/payment_integration/utilities.py new file mode 100644 index 0000000..7ab28d2 --- /dev/null +++ b/samples/payment_integration/utilities.py @@ -0,0 +1,34 @@ +from random import randint + +# Used for testing simulation strings in sandbox mode +def getSimulationString( simuType ): + simulationString = '' + + if simuType == 'InvalidPaymentMethod': + # PaymentMethodUpdateTimeInMins only works with Async authorizations to change BA back to OPEN; Sync authorizations will not revert + simulationString = '{ "SandboxSimulation": { "State":"Declined", "ReasonCode":"InvalidPaymentMethod", "PaymentMethodUpdateTimeInMins":1, "SoftDecline":"true" } }' + elif simuType == 'AmazonRejected': + simulationString = '{ "SandboxSimulation": { "State":"Declined", "ReasonCode":"AmazonRejected" } }' + + elif simuType == 'TransactionTimedOut': + simulationString = '{ "SandboxSimulation": { "State":"Declined", "ReasonCode":"TransactionTimedOut" } }' + else: + simulationString = '' + + return simulationString + + +# Sometimes you just need a random string right? +def generateRandomString( length ): + randomString = '' + stringValues = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + + for i in range(length): + randomString += stringValues[ randint(0, len(stringValues)-1) ] + + return randomString + + +def getConsentToken( context ): + return context['System']['apiAccessToken'] +