Code Deep Dive: Implementing In-Skill Purchasing for Consumables with Node.js

Amit Jotwani Jul 08, 2019
Share:
Node.js Tutorial Make Money
Blog_Header_Post_Img

Editor's Note: In our code deep dive series, we provide an end-to-end walkthrough of how to implement in-skill purchasing (ISP) in your Alexa skill. We've previously used the Premium Hello World Skill (available on GitHub) to demonstrate the implementation of two ISP products - one-time purchases and subscriptions). In this post, we use a new sample skill - "Greeting Sender" (available on GitHub), to demonstrate how to use consumable in-skill products. This is the third in-skill product the sample skill offers via a “Sharing Pack” that allows the customer to share greetings with friends. We will explain each line of code as we walk through several scenarios that a skill with consumables should be able to handle. If you'd like, you can follow along by referencing the steps in the GitHub guide to set up the Greeting Sender sample skill on your developer account.

What Are Consumable In-Skill Products?

"Consumables" are an in-skill product that you can use in your Alexa skill to allow customers to access content or features that can be purchased, depleted, and purchased again. For example, you can leverage consumable in-skill products as hints for a game, in-game currency, or extra lives.

The Sharing Pack in our "Greeting Sender" sample skill is a consumable in-skill product that allows customers to buy "Sharing Coins" that they can use to share greetings with their friends.

Please note that in order to focus on the implementation of consumables, the "sharing" functionality is merely a simulation. You may extend this code to implement email/Twitter/sms sharing at your own convenience by extending the simulateSharing() method.

 

Scenarios

Here are the eight scenarios we will cover in this post as it relates to consumable in-skill products.

  • Scenario 1: Customer does NOT have any "sharing coins" available, and asks to "share this greeting"
  • Scenario 2: Customer has one or more "sharing coins" available, and asks to "share this greeting"
  • Scenario 3: Customer does NOT have any "sharing coins" available, and asks "how many sharing coins are remaining"
  • Scenario 4: Customer has one or more "sharing coins" available, and asks “how many sharing coins are remaining"
  • Scenario 5: Customer does NOT have any "sharing coins" available, and asks “what have I bought”
  • Scenario 6: Customer has one or more "sharing coins" available, and asks “what have I bought”
  • Scenario 7: Customer has bought the Sharing Pack, and requests a refund
  • Scenario 8: Customer has NOT bought the Sharing Pack, and requests a refund

Be sure to check the certification guidelines which are required for skill publication. In addition, check out our ISP best practices which, though not required, may help increase your chances of being featured by Amazon.

 

Scenario 1: Customer does NOT have any "sharing coins" available, and asks to "share this greeting"

In this scenario, the customer does not have any "sharing coins" available, and asks to share a greeting with a friend. This utterance is mapped to the intent `ShareGreetingIntent`, and the handler `ShareGreetingIntentHandler` gets triggered in our Lambda code.

Alexa Blog
Alexa Blog
with developer notes
Alexa Blog
Alexa Blog

You Are Responsible for Your Inventory

For consumables, the onus of maintaining the inventory is on the developer. In this skill, we do this through the updateInventory() helper function. We ensure that this method is called after each request by adding it as a request interceptor.

Copied to clipboard
.addRequestInterceptors(
	LoadGreetingRequestInterceptor,
	LogRequestInterceptor,
	UpdateInventoryInterceptor,
	recordLastIntentRequestInterceptor)

Inside the `updateInventory()` method, we first initiate `persistentAttributes`. Persistent attributes persist beyond the lifecycle of the current session, and hence are ideal to keep track of sharing coins that have been used, and also the last greeting that was sent back to the customer. You can learn more about the different types of attributes supported by the Alexa Skills Kit (ASK) Software Development Kit (SDK) here.

​Next, we call the inSkillProduct API to get a list of your products, and to determine how many times the customer has purchased your consumables. This is the number of times a customer bought each product, which is called the `activeEntitlementCount`. In this skill, when you buy the Sharing Pack, you get five coins for 0.99 cents. We then multiply the `activeEntitlementCount` by five to get the total number of coins a customer has purchased.

Copied to clipboard
async function updateInventory(handlerInput){
	const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
	const sessionAttributes = await handlerInput.attributesManager.getSessionAttributes();

	if (persistentAttributes.coinsUsed === undefined) persistentAttributes.coinsUsed = 0;
	if (persistentAttributes.coinsPurchased === undefined) persistentAttributes.coinsPurchased = 0;

	const locale = handlerInput.requestEnvelope.request.locale;
	const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();

	return monetizationClient.getInSkillProducts(locale).then(function(res){
		const sharingPackProduct = res.inSkillProducts.filter(
			record => record.referenceName === 'Sharing_Pack'
		);

		const coinsPurchased = (sharingPackProduct[0].activeEntitlementCount * 5);

		if (persistentAttributes.coinsPurchased > coinsPurchased) {
			// THIS CAN HAPPEN IF A CUSTOMER RETURNS AN ACCIDENTAL PURCHASE.
			// YOU SHOULD RESET THEIR TOTALS TO REFLECT THAT RETURN.
			persistentAttributes.coinsPurchased = coinsPurchased;

			if (persistentAttributes.coinsUsed > coinsPurchased) {
				// IF THE USER HAS USED MORE coins THAN THEY HAVE PURCHASED,
				// SET THEIR TOTAL "USED" TO THE TOTAL "PURCHASED."
				persistentAttributes.coinsUsed = coinsPurchased;
			}
		}
		else if (persistentAttributes.coinsPurchased < coinsPurchased) {
			// THIS SHOULDN'T HAPPEN UNLESS WE FORGOT TO MANAGE OUR INVENTORY PROPERLY.
			persistentAttributes.coinsPurchased = coinsPurchased;
		}
		persistentAttributes.coinsAvailable = persistentAttributes.coinsPurchased - persistentAttributes.coinsUsed;
		sessionAttributes.coinsAvailable = persistentAttributes.coinsAvailable;
		handlerInput.attributesManager.savePersistentAttributes();
		// handlerInput.attributesManager.saveSessionAttributes();
	});
}

Now, inside the `ShareGreetingIntentHandler`, we call the Alexa Monetization API, which returns the list of products available for the skill in the given locale. Next, we filter this list of products available for purchase to find the product with the reference name "SharingPack”, which is the name we gave to our Consumable ISP product when we created it in the Alexa Developer Console.

Copied to clipboard
//Respond to the utterance "share greeting"
const ShareGreetingIntentHandler = {
	canHandle(handlerInput) {
		return handlerInput.requestEnvelope.request.type === 'IntentRequest'
			&& handlerInput.requestEnvelope.request.intent.name === 'ShareGreetingIntent';
	},
	async handle(handlerInput) {
		const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
		const locale = handlerInput.requestEnvelope.request.locale;
		const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();

		return monetizationClient.getInSkillProducts(locale).then(function(res){
			// Filter the list of products available for purchase to find the product with the reference name "Greetings_Pack"
			const sharingPackProduct = res.inSkillProducts.filter(
				record => record.referenceName === 'Sharing_Pack'
			);
			const greeting = persistentAttributes.hasOwnProperty('greeting') ? persistentAttributes.greeting : { language: 'english', greeting: 'Good Morning' };

			if (persistentAttributes.coinsAvailable > 0){
				//Customer has enough coins available. 
			}
			else{
				//Customer is out of coins. Make upsell.
				const speechText = 'Darn it. Looks like you are out of coins';
				saveGreeting(handlerInput,greeting);
				return makeUpsell(speechText,sharingPackProduct,handlerInput);
			}
		});
	},
};

We save the greeting as a session attribute, and also persist it by saving it to our S3 storage by calling our helper function - `saveGreeting()`.

Copied to clipboard
async function saveGreeting(handlerInput,greeting) {
	const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
	const sessionAttributes = await handlerInput.attributesManager.getSessionAttributes();

	persistentAttributes.greeting = greeting;
	sessionAttributes.greeting = greeting;

	handlerInput.attributesManager.savePersistentAttributes();
}

Finally, we check if the customer has any coins available by tapping into `PersistentAttributes`. Since in this scenario, the customer does not have any coins available, we respond back with an upsell for “Sharing Pack”.

Copied to clipboard
function makeUpsell(preUpsellMessage,greetingsPackProduct,handlerInput){
	let upsellMessage = `${preUpsellMessage}. ${greetingsPackProduct[0].summary}. ${getRandomLearnMorePrompt()}`;

	return handlerInput.responseBuilder
		.addDirective({
			type: 'Connections.SendRequest',
			name: 'Upsell',
			payload: {
				InSkillProduct: {
					productId: greetingsPackProduct[0].productId
				},
				upsellMessage
			},
			token: 'correlationToken'
		})
		.getResponse();
}

Scenario 2: Customer has one or more "sharing coins" available, and asks to "share this greeting"

In this scenario, the customer does have one or more sharing coins, so we should simulate greeting share, and then update the inventory for the coins available. This is pretty straightforward because we just decrement this value each time that you give them a hint. We do this by calling our helper function - `useCoin()` from within the `ShareGreetingIntentHandler()`

2a developer notes
Copied to clipboard
//Respond to the utterance "share greeting"
const ShareGreetingIntentHandler = {
	canHandle(handlerInput) {
		return handlerInput.requestEnvelope.request.type === 'IntentRequest'
			&& handlerInput.requestEnvelope.request.intent.name === 'ShareGreetingIntent';
	},
	async handle(handlerInput) {
		const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
		const locale = handlerInput.requestEnvelope.request.locale;
		const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();

		return monetizationClient.getInSkillProducts(locale).then(function(res){
			// Filter the list of products available for purchase to find the product with the reference name "Greetings_Pack"
			const sharingPackProduct = res.inSkillProducts.filter(
				record => record.referenceName === 'Sharing_Pack'
			);
			const greeting = persistentAttributes.hasOwnProperty('greeting') ? persistentAttributes.greeting : { language: 'english', greeting: 'Good Morning' };

			if (persistentAttributes.coinsAvailable > 0){
				//Customer has enough coins available.
				//Simulate greeting share with friend,
				//and update coin count (useCoin)

				const speechText = simulateShareGreeting(greeting);
				const repromptOutput = `I have shared the greeting - ${greeting['greeting']}, which is hello in ${greeting['language']} with your favorite friend. ${getRandomYesNoQuestion()}`;
				const cardText = `${greeting['greeting']} - hello in ${greeting['language']} was shared with your favorite friend`;

				useCoin(handlerInput);

				return handlerInput.responseBuilder
					.speak(speechText)
					.reprompt(repromptOutput)
					.withSimpleCard(skillName, cardText)
					.getResponse();
			}
			else{
				//Customer is out of coins. Make upsell.
				const speechText = 'Darn it. Looks like you are out of coins';
				saveGreeting(handlerInput,greeting);
				return makeUpsell(speechText,sharingPackProduct,handlerInput);
			}
		});
	},
};
Copied to clipboard
function simulateShareGreeting(greeting){
	// TODO: You can replace this code with sharing service of your choice.
	return `Alright. I have shared the greeting - ${greeting['greeting']}, which is hello in ${greeting['language']} with your favorite friend.`;
}

It’s important to persist this value to an external database (like Amazon DynamoDB or an S3 storage) because you’ll need an accurate count every time that your customer starts the skill. In this skill, we use S3 storage, which is included automatically as part of an Alexa Hosted skill.

Copied to clipboard
async function useCoin(handlerInput) {
	const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
	const sessionAttributes = await handlerInput.attributesManager.getSessionAttributes();

	persistentAttributes.coinsAvailable -= 1;
	persistentAttributes.coinsUsed += 1;

	sessionAttributes.coinsAvailable = persistentAttributes.coinsAvailable;
	handlerInput.attributesManager.savePersistentAttributes();
}

Scenario 3: Customer does NOT have any "sharing coins" available, and asks "how many sharing coins are remaining?"

In this scenario, the customer does NOT have any "sharing coins" available, and asks "How many sharing coins are remaining". This utterance is mapped to the intent `CoinInventoryIntent`, and the handler `CoinInventoryHandler` gets triggered in our Lambda code.

scenario 3a
scenario 3b
scenario 3c

As in the previous scenarios, we first check if they have any coins available, and then make an upsell.

Copied to clipboard
//Respond to the utterance "how many coins remaining"
const CoinInventoryHandler = {
	canHandle(handlerInput) {
		return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'CoinInventoryIntent';
	},
	async handle(handlerInput) {
		const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
		if (persistentAttributes.coinsAvailable > 0) {
			//Customer has enough coins available.
		}
		else{
			//Customer is out of coins. Make upsell.
			const locale = handlerInput.requestEnvelope.request.locale;
			const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();

			return monetizationClient.getInSkillProducts(locale).then(function(res){
				// Filter the list of products available for purchase to find the product with the reference name "Greetings_Pack"
				const sharingPackProduct = res.inSkillProducts.filter(
					record => record.referenceName === 'Sharing_Pack'
				);
				const speechText = 'Darn it. Looks like you are out of coins';
				return makeUpsell(speechText,sharingPackProduct,handlerInput);
			});
		}
	},
};

Scenario 4: Cusomter has one or more "sharing coins" available, and asks "how many sharing coins are remaining?"

In this scenario, the customer does have one or more "sharing coins" available, and asks "How many sharing coins are remaining." Like the previous scenario, this utterance is mapped to the intent `CoinInventoryIntent` , and the handler `CoinInventoryHandler` gets triggered in our Lambda code.

scenario 4a

As in the previous scenarios, we first check if they have any coins available. We do this by accessing the `persistentAttributes` to get the number of `coinsAvailable`, and respond back to the customer with the number of coins available, and then check if they would like another greeting.

Copied to clipboard
//Respond to the utterance "how many coins remaining"
const CoinInventoryHandler = {
	canHandle(handlerInput) {
		return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'CoinInventoryIntent';
	},
	async handle(handlerInput) {
		const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();
		if (persistentAttributes.coinsAvailable > 0) {
			//Customer has enough coins available.
			const speechText = `You now have ${persistentAttributes.coinsAvailable} sharing coins available. ${getRandomYesNoQuestion()}`;
			const repromptOutput = `${getRandomYesNoQuestion()}`;
			return handlerInput.responseBuilder
				.speak(speechText)
				.reprompt(repromptOutput)
				.getResponse();
		}
		else{
			//Customer is out of coins. Make upsell.
			const locale = handlerInput.requestEnvelope.request.locale;
			const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();

			return monetizationClient.getInSkillProducts(locale).then(function(res){
				// Filter the list of products available for purchase to find the product with the reference name "Greetings_Pack"
				const sharingPackProduct = res.inSkillProducts.filter(
					record => record.referenceName === 'Sharing_Pack'
				);
				const speechText = 'Darn it. Looks like you are out of coins';
				return makeUpsell(speechText,sharingPackProduct,handlerInput);
			});
		}
	},
};

Scenario 5: Customer does NOT have any "sharing coins" available, and asks "what have I bought?"

In this scenario, the customer does NOT have any "sharing coins" available, and asks "what have I bought." This utterance is mapped to the intent `PurchaseHistoryIntent`, and the handler `PurchaseHistoryIntentHandler` gets triggered in our Lambda code.

scenario 5a
scenario 5b
scenario 5c

As in the previous scenarios, we first check if they ever bought the “Sharing Pack”, and then if they have any coins available. We then make an upsell to check if they would be interested in buying the “Sharing Pack”  again, which would give them an additional five sharing coins.

Copied to clipboard
//Respond to the utterance "what have I bought"
const PurchaseHistoryIntentHandler = {
	canHandle(handlerInput) {
		return (
			handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'PurchaseHistoryIntent'
		);
	},
	async handle(handlerInput) {
		const locale = handlerInput.requestEnvelope.request.locale;
		const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
		const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();

		return monetizationClient.getInSkillProducts(locale).then(function(res) {
			const entitledProducts = getAllEntitledProducts(res.inSkillProducts);
			const sharingPackProduct = res.inSkillProducts.filter(
				record => record.referenceName === 'Sharing_Pack'
			);
			if (entitledProducts && entitledProducts.length > 0) {
				if (persistentAttributes.coinsAvailable > 0){
					//Customer has coins available.
				}
				else{
					//Customer is out of coins.
					const speechText = `You bought the following items: ${getSpeakableListOfProducts(entitledProducts)}, but you're out of sharing coins.`;
					return makeUpsell(speechText,sharingPackProduct,handlerInput);
				}
			}
			else{
				const speechText = 'You haven\'t purchased anything yet. To learn more about the products you can buy, say - what can I buy. How can I help?';
				const repromptOutput = `You asked me for a what you've bought, but you haven't purchased anything yet. You can say - what can I buy, or say yes to get another greeting. ${getRandomYesNoQuestion()}`;

				return handlerInput.responseBuilder
					.speak(speechText)
					.reprompt(repromptOutput)
					.getResponse();
			}
		});
	}
};

Scenario 6: Customer has one or more "sharing coins" available, and asks "what have I bought?"

In this scenario, the customer does have one or more "sharing coins" available, and asks "what have I bought.” Like the previous scenario, this utterance is mapped to the intent `PurchaseHistoryIntent`, and the handler `PurchaseHistoryIntentHandler` gets triggered in our Lambda code.

scenario 6a

As in the previous scenarios, we first check if they have ever bought the “Sharing Pack”, and then if they have any coins available. Since the customer has one or more coins available, we access the “persistentAttributes” to get the number of “coinsAvailable”, and respond back to the customer with then number of coins available, and then check if they would like another greeting.

Copied to clipboard
//Respond to the utterance "what have I bought"
const PurchaseHistoryIntentHandler = {
	canHandle(handlerInput) {
		return (
			handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'PurchaseHistoryIntent'
		);
	},
	async handle(handlerInput) {
		const locale = handlerInput.requestEnvelope.request.locale;
		const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
		const persistentAttributes = await handlerInput.attributesManager.getPersistentAttributes();

		return monetizationClient.getInSkillProducts(locale).then(function(res) {
			const entitledProducts = getAllEntitledProducts(res.inSkillProducts);
			const sharingPackProduct = res.inSkillProducts.filter(
				record => record.referenceName === 'Sharing_Pack'
			);
			if (entitledProducts && entitledProducts.length > 0) {
				if (persistentAttributes.coinsAvailable > 0){
					//Customer has coins available.
					const speechText = `You bought the following items: ${getSpeakableListOfProducts(entitledProducts)}. You have ${getCoinsAvailable(handlerInput)} sharing coins available. ${getRandomYesNoQuestion()}`;
					const repromptOutput = `You asked me for a what you've bought, here's a list ${getSpeakableListOfProducts(entitledProducts)}. You have ${getCoinsAvailable(handlerInput)} sharing coins available. ${getRandomYesNoQuestion()}`;

					return handlerInput.responseBuilder
						.speak(speechText)
						.reprompt(repromptOutput)
						.getResponse();
				}
				else{
					//Customer is out of coins.
					const speechText = `You bought the following items: ${getSpeakableListOfProducts(entitledProducts)}, but you're out of sharing coins.`;
					return makeUpsell(speechText,sharingPackProduct,handlerInput);
				}
			}
			else{
				const speechText = 'You haven\'t purchased anything yet. To learn more about the products you can buy, say - what can I buy. How can I help?';
				const repromptOutput = `You asked me for a what you've bought, but you haven't purchased anything yet. You can say - what can I buy, or say yes to get another greeting. ${getRandomYesNoQuestion()}`;

				return handlerInput.responseBuilder
					.speak(speechText)
					.reprompt(repromptOutput)
					.getResponse();
			}
		});
	}
};

Scenario 7: Customer has bought the Sharing Pack, and requests a refund

In this scenario, the customer has bought the Sharing Pack (they may or may not have coins remaining), and would like to refund the “Sharing Pack”. This utterance is mapped to the intent `RefundSharingPackIntent`, and the handler ` RefundSharingPackIntentHandler` gets triggered in our Lambda code.

scenario 7a
Copied to clipboard
const RefundSharingPackIntentHandler = {
	canHandle(handlerInput) {
		return (
			handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'RefundSharingPackIntent'
		);
	},
	handle(handlerInput) {
		const locale = handlerInput.requestEnvelope.request.locale;
		const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();

		return monetizationClient.getInSkillProducts(locale).then(function(res) {
			const sharingPackProduct = res.inSkillProducts.filter(
				record => record.referenceName === 'Sharing_Pack'
			);
			if (isEntitled(sharingPackProduct)) {
				//Customer has bought the Sharing Pack at some point
				return handlerInput.responseBuilder
					.addDirective({
						type: 'Connections.SendRequest',
						name: 'Cancel',
						payload: {
							InSkillProduct: {
								productId: sharingPackProduct[0].productId
							}
						},
						token: 'correlationToken'
					})
					.getResponse();
			}
			else{
				//Customer has never bought the Sharing Pack
				const speechText = 'It looks like you haven\'t purchased the Sharing Pack yet. To learn more about the products you can buy, say - what can I buy. How can I help?';
				const repromptOutput = `${getRandomYesNoQuestion()}`;

				return handlerInput.responseBuilder
					.speak(speechText)
					.reprompt(repromptOutput)
					.getResponse();
			}
		});
	}
};

Scenario 8: Customer has NOT bought the Sharing Pack, and requests a refund

In this scenario, the customer has not bought the Sharing Pack, and has requested a refund for it. Like the last scenario, this utterance is mapped to the intent `RefundSharingPackIntent`, and the handler ` RefundSharingPackIntentHandler` gets triggered in our Lambda code.

scenario 8a

For this scenario, we first need to check if they previously bought the Sharing Pack. We do that by calling our `isEntitled()` helper function (see code in Scenario 7).

We hope you find this new series helpful as you embark on the journey to use ISP to sell premium content in your skills and enrich your Alexa skill experience. You can reach out to me on Twitter @amit. We can't wait to see what you build!

Resources and Related Content