From 78af7ceae19e9cea463d2fc4c04537cfd1e49494 Mon Sep 17 00:00:00 2001 From: Shahin Shayandeh Date: Wed, 10 May 2017 08:40:33 -0700 Subject: [PATCH] Add payment sample --- CSharp/sample-payments/.gitignore | 239 +++++++++++++++++ CSharp/sample-payments/PaymentsBot.sln | 22 ++ .../ActivityControllerDispatcher.cs | 47 ++++ .../PaymentsBot/AppSettings.cs | 39 +++ .../PaymentsBot/App_Start/WebApiConfig.cs | 33 +++ .../Controllers/MessagesController.cs | 217 +++++++++++++++ .../PaymentsBot/Dialogs/RootDialog.cs | 252 ++++++++++++++++++ .../sample-payments/PaymentsBot/Global.asax | 1 + .../PaymentsBot/Global.asax.cs | 12 + .../PaymentsBot/Helpers/Base64Url.cs | 46 ++++ .../Helpers/PaymentAddressExtensions.cs | 15 ++ .../PaymentsBot/Helpers/PaymentItemBuilder.cs | 26 ++ .../PaymentsBot/Models/CatalogItem.cs | 19 ++ .../PaymentsBot/Models/PaymentException.cs | 15 ++ .../PaymentsBot/Models/PaymentRecord.cs | 27 ++ .../PaymentsBot/Models/PaymentToken.cs | 112 ++++++++ .../PaymentsBot/Models/PaymentTokenFormat.cs | 24 ++ .../PaymentsBot/Models/PaymentTokenHeader.cs | 71 +++++ .../PaymentsBot/PaymentsBot.csproj | 236 ++++++++++++++++ .../PaymentsBot/Properties/AssemblyInfo.cs | 35 +++ .../Properties/Resources.Designer.cs | 173 ++++++++++++ .../PaymentsBot/Properties/Resources.resx | 159 +++++++++++ .../PaymentsBot/Services/CatalogService.cs | 35 +++ .../PaymentsBot/Services/PaymentService.cs | 134 ++++++++++ .../PaymentsBot/Services/ShippingService.cs | 118 ++++++++ .../PaymentsBot/Web.Debug.config | 30 +++ .../PaymentsBot/Web.Release.config | 31 +++ CSharp/sample-payments/PaymentsBot/Web.config | 87 ++++++ .../sample-payments/PaymentsBot/default.htm | 12 + .../PaymentsBot/packages.config | 20 ++ CSharp/sample-payments/README.md | 45 ++++ CSharp/sample-payments/Settings.StyleCop | 219 +++++++++++++++ Node/sample-payments/.env | 8 + Node/sample-payments/.gitignore | 3 + Node/sample-payments/app.js | 242 +++++++++++++++++ Node/sample-payments/checkout.js | 217 +++++++++++++++ Node/sample-payments/package.json | 33 +++ Node/sample-payments/payment-token.js | 42 +++ Node/sample-payments/payments.js | 15 ++ Node/sample-payments/services/catalog.js | 24 ++ 40 files changed, 3135 insertions(+) create mode 100644 CSharp/sample-payments/.gitignore create mode 100644 CSharp/sample-payments/PaymentsBot.sln create mode 100644 CSharp/sample-payments/PaymentsBot/Activities/ActivityControllerDispatcher.cs create mode 100644 CSharp/sample-payments/PaymentsBot/AppSettings.cs create mode 100644 CSharp/sample-payments/PaymentsBot/App_Start/WebApiConfig.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Controllers/MessagesController.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Dialogs/RootDialog.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Global.asax create mode 100644 CSharp/sample-payments/PaymentsBot/Global.asax.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Helpers/Base64Url.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Helpers/PaymentAddressExtensions.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Helpers/PaymentItemBuilder.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Models/CatalogItem.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Models/PaymentException.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Models/PaymentRecord.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Models/PaymentToken.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Models/PaymentTokenFormat.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Models/PaymentTokenHeader.cs create mode 100644 CSharp/sample-payments/PaymentsBot/PaymentsBot.csproj create mode 100644 CSharp/sample-payments/PaymentsBot/Properties/AssemblyInfo.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Properties/Resources.Designer.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Properties/Resources.resx create mode 100644 CSharp/sample-payments/PaymentsBot/Services/CatalogService.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Services/PaymentService.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Services/ShippingService.cs create mode 100644 CSharp/sample-payments/PaymentsBot/Web.Debug.config create mode 100644 CSharp/sample-payments/PaymentsBot/Web.Release.config create mode 100644 CSharp/sample-payments/PaymentsBot/Web.config create mode 100644 CSharp/sample-payments/PaymentsBot/default.htm create mode 100644 CSharp/sample-payments/PaymentsBot/packages.config create mode 100644 CSharp/sample-payments/README.md create mode 100644 CSharp/sample-payments/Settings.StyleCop create mode 100644 Node/sample-payments/.env create mode 100644 Node/sample-payments/.gitignore create mode 100644 Node/sample-payments/app.js create mode 100644 Node/sample-payments/checkout.js create mode 100644 Node/sample-payments/package.json create mode 100644 Node/sample-payments/payment-token.js create mode 100644 Node/sample-payments/payments.js create mode 100644 Node/sample-payments/services/catalog.js diff --git a/CSharp/sample-payments/.gitignore b/CSharp/sample-payments/.gitignore new file mode 100644 index 0000000000..fba4c25f31 --- /dev/null +++ b/CSharp/sample-payments/.gitignore @@ -0,0 +1,239 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +ecf/ +rcf/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +*.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +## ignore imporeted publish xml files +*intercom-botdirectory-scratch\ -\ FTP.pubxml +*intercom-botdirectory-scratch\ -\ Web\ Deploy.pubxml + +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +/Microsoft.CodeDom.Providers.DotNetCompilerPlatform.dll +/Microsoft.CodeDom.Providers.DotNetCompilerPlatform.xml +/PublishScripts/Scripts/Deploy-AzureResourceGroup-5.ps1 +/PublishScripts + + + + + + + + + +# User-specific files +Documentation/Doxygen_warnings.txt + +# Build results +Documentation/docs/ + +# Azure publish profiles +*.pubxml +PublishProfiles/ diff --git a/CSharp/sample-payments/PaymentsBot.sln b/CSharp/sample-payments/PaymentsBot.sln new file mode 100644 index 0000000000..a1e105bc2e --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25420.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaymentsBot", "PaymentsBot\PaymentsBot.csproj", "{A8BA1066-5695-4D71-ABB4-65E5A5E0C3D4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A8BA1066-5695-4D71-ABB4-65E5A5E0C3D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8BA1066-5695-4D71-ABB4-65E5A5E0C3D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8BA1066-5695-4D71-ABB4-65E5A5E0C3D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8BA1066-5695-4D71-ABB4-65E5A5E0C3D4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/CSharp/sample-payments/PaymentsBot/Activities/ActivityControllerDispatcher.cs b/CSharp/sample-payments/PaymentsBot/Activities/ActivityControllerDispatcher.cs new file mode 100644 index 0000000000..4454db1ec7 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Activities/ActivityControllerDispatcher.cs @@ -0,0 +1,47 @@ +namespace PaymentsBot.Activities +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Web.Http; + using Microsoft.Bot.Builder.Internals.Fibers; + using Microsoft.Bot.Builder.Scorables; + using Microsoft.Bot.Connector; + + public sealed class ActivityControllerDispatcher : Dispatcher + { + private readonly ApiController controller; + private readonly Activity activity; + private readonly HttpResponseMessage response; + + public ActivityControllerDispatcher(ApiController controller, Activity activity, HttpResponseMessage response) + { + SetField.NotNull(out this.controller, nameof(controller), controller); + SetField.NotNull(out this.activity, nameof(activity), activity); + SetField.NotNull(out this.response, nameof(response), response); + } + + protected override Type MakeType() + { + return this.controller.GetType(); + } + + protected override IReadOnlyList MakeServices() + { + var credentials = new MicrosoftAppCredentials(); + var connector = new ConnectorClient(new Uri(this.activity.ServiceUrl), credentials); + + StateClient storage; + if (this.activity.ChannelId == "emulator") + { + storage = new StateClient(new Uri(this.activity.ServiceUrl), credentials); + } + else + { + storage = new StateClient(credentials); + } + + return new object[] { this.controller, this.activity, this.response, connector, storage }; + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/AppSettings.cs b/CSharp/sample-payments/PaymentsBot/AppSettings.cs new file mode 100644 index 0000000000..c39fd03891 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/AppSettings.cs @@ -0,0 +1,39 @@ +namespace PaymentsBot +{ + using System; + using System.Configuration; + + /// + /// App Settings + /// + public static class AppSettings + { + private static Lazy invalidShippingCountry = new Lazy(() => ConfigurationManager.AppSettings["InvalidShippingCountry"]); + + private static Lazy liveMode = new Lazy(() => bool.TrueString.Equals(ConfigurationManager.AppSettings["LiveMode"], StringComparison.OrdinalIgnoreCase)); + + private static Lazy merchantId = new Lazy(() => ConfigurationManager.AppSettings["MerchantId"]); + + private static Lazy stripeApiKey = new Lazy(() => ConfigurationManager.AppSettings["StripeApiKey"]); + + /// + /// Invalid shipping country + /// + public static string InvalidShippingCountry => invalidShippingCountry.Value; + + /// + /// Live Mode value + /// + public static bool LiveMode => liveMode.Value; + + /// + /// Merchant Id + /// + public static string MerchantId => merchantId.Value; + + /// + /// Stripe Api Key + /// + public static string StripeApiKey => stripeApiKey.Value; + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/App_Start/WebApiConfig.cs b/CSharp/sample-payments/PaymentsBot/App_Start/WebApiConfig.cs new file mode 100644 index 0000000000..b49eb6e3de --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/App_Start/WebApiConfig.cs @@ -0,0 +1,33 @@ +namespace PaymentsBot +{ + using System.Web.Http; + using Newtonsoft.Json; + using Newtonsoft.Json.Serialization; + + public static class WebApiConfig + { + public static void Register(HttpConfiguration config) + { + // Json settings + config.Formatters.JsonFormatter.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + config.Formatters.JsonFormatter.SerializerSettings.Formatting = Formatting.Indented; + JsonConvert.DefaultSettings = () => new JsonSerializerSettings() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Formatting = Newtonsoft.Json.Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + }; + + // Web API configuration and services + + // Web API routes + config.MapHttpAttributeRoutes(); + + config.Routes.MapHttpRoute( + name: "DefaultApi", + routeTemplate: "api/{controller}/{id}", + defaults: new { id = RouteParameter.Optional }); + } + } +} diff --git a/CSharp/sample-payments/PaymentsBot/Controllers/MessagesController.cs b/CSharp/sample-payments/PaymentsBot/Controllers/MessagesController.cs new file mode 100644 index 0000000000..9fb87c3d4a --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Controllers/MessagesController.cs @@ -0,0 +1,217 @@ +namespace PaymentsBot +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Net.Http.Formatting; + using System.Threading; + using System.Threading.Tasks; + using System.Web.Http; + using Activities; + using Dialogs; + using Microsoft.Bot.Builder.Dialogs; + using Microsoft.Bot.Builder.Scorables; + using Microsoft.Bot.Connector; + using Microsoft.Bot.Connector.Payments; + using Models; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + using Services; + + [BotAuthentication] + public class MessagesController : ApiController + { + private readonly CatalogService catalogService; + private readonly ShippingService shippingService; + private readonly PaymentService paymentService; + + public MessagesController() + { + this.shippingService = new ShippingService(); + this.paymentService = new PaymentService(AppSettings.StripeApiKey); + this.catalogService = new CatalogService(); + } + + private enum ShippingUpdateKind + { + Address, + Options, + Both + } + + /// + /// POST: api/Messages + /// Receive a message from a user and reply to it + /// + public async Task Post([FromBody]Activity activity, CancellationToken token) + { + var response = new HttpResponseMessage(HttpStatusCode.Accepted); + + // dispatch based on the activity to the private methods below + IDispatcher dispatcher = new ActivityControllerDispatcher(this, activity, response); + await dispatcher.TryPostAsync(token); + + return response; + } + + /// + /// Handle recieved messages + /// + [MethodBind] + [ScorableGroup(1)] + private async Task OnMessageActivity(IMessageActivity activity, CancellationToken token) + { + await Conversation.SendAsync(activity, () => new RootDialog(), token); + } + + /// + /// Handle Payment calls + /// + [MethodBind] + [ScorableGroup(1)] + private async Task OnInvoke(IInvokeActivity invoke, IConnectorClient connectorClient, IStateClient stateClient, HttpResponseMessage response, CancellationToken token) + { + MicrosoftAppCredentials.TrustServiceUrl(invoke.RelatesTo.ServiceUrl); + + var jobject = invoke.Value as JObject; + if (jobject == null) + { + throw new ArgumentException("Request payload must be a valid json object."); + } + + // This is a temporary workaround for the issue that the channelId for "webchat" is mapped to "directline" in the incoming RelatesTo object + invoke.RelatesTo.ChannelId = (invoke.RelatesTo.ChannelId == "directline") ? "webchat" : invoke.RelatesTo.ChannelId; + + if (invoke.RelatesTo.User == null) + { + // Bot keeps the userId in context.ConversationData[cartId] + var conversationData = await stateClient.BotState.GetConversationDataAsync(invoke.RelatesTo.ChannelId, invoke.RelatesTo.Conversation.Id, token); + var cartId = conversationData.GetProperty(RootDialog.CARTKEY); + + if (!string.IsNullOrEmpty(cartId)) + { + invoke.RelatesTo.User = new ChannelAccount + { + Id = conversationData.GetProperty(cartId) + }; + } + } + + var updateResponse = default(object); + switch (invoke.Name) + { + case PaymentOperations.UpdateShippingAddressOperationName: + updateResponse = await this.ProcessShippingUpdate(jobject.ToObject(), ShippingUpdateKind.Address, token); + break; + + case PaymentOperations.UpdateShippingOptionOperationName: + updateResponse = await this.ProcessShippingUpdate(jobject.ToObject(), ShippingUpdateKind.Options, token); + break; + + case PaymentOperations.PaymentCompleteOperationName: + updateResponse = await this.ProcessPaymentComplete(invoke, jobject.ToObject(), token); + break; + + default: + throw new ArgumentException("Invoke activity name is not a supported request type."); + } + + response.Content = new ObjectContent( + updateResponse, + this.Configuration.Formatters.JsonFormatter, + JsonMediaTypeFormatter.DefaultMediaType); + + response.StatusCode = HttpStatusCode.OK; + } + + private async Task ProcessShippingUpdate(PaymentRequestUpdate paymentRequestUpdate, ShippingUpdateKind updateKind, CancellationToken token = default(CancellationToken)) + { + var catalogItem = await this.catalogService.GetItemByIdAsync(Guid.Parse(paymentRequestUpdate.Id)); + if (catalogItem == null) + { + throw new ArgumentException("Invalid cart identifier within payment request provided."); + } + + var result = new PaymentRequestUpdateResult(paymentRequestUpdate.Details); + if (ShippingUpdateKind.Both.Equals(updateKind) || ShippingUpdateKind.Address.Equals(updateKind)) + { + result.Details.ShippingOptions = (await this.shippingService.GetShippingOptionsAsync(catalogItem, paymentRequestUpdate.ShippingAddress)).ToList(); + } + + if (ShippingUpdateKind.Both.Equals(updateKind) || ShippingUpdateKind.Options.Equals(updateKind)) + { + foreach (var shippingOption in result.Details.ShippingOptions) + { + shippingOption.Selected = shippingOption.Id.Equals(paymentRequestUpdate.ShippingOption, StringComparison.OrdinalIgnoreCase); + } + } + + if (result.Details.ShippingOptions.Count(option => option.Selected.HasValue && option.Selected.Value) != 1) + { + throw new ArgumentException("Expected exactly zero or one selected shipping option."); + } + + // update payment details after shipping changed + await this.shippingService.UpdatePaymentDetailsAsync(result.Details, paymentRequestUpdate.ShippingAddress, catalogItem); + + return result; + } + + private async Task ProcessPaymentComplete(IInvokeActivity invoke, PaymentRequestComplete paymentRequestComplete, CancellationToken token = default(CancellationToken)) + { + var paymentRequest = paymentRequestComplete.PaymentRequest; + var paymentResponse = paymentRequestComplete.PaymentResponse; + + paymentRequest.Details = (await this.ProcessShippingUpdate( + new PaymentRequestUpdate() + { + Id = paymentRequest.Id, + Details = paymentRequest.Details, + ShippingAddress = paymentResponse.ShippingAddress, + ShippingOption = paymentResponse.ShippingOption + }, + ShippingUpdateKind.Both, + token)).Details; + + PaymentRecord paymentRecord = null; + PaymentRequestCompleteResult result = null; + Exception paymentProcessingException = null; + try + { + paymentRecord = await this.paymentService.ProcessPaymentAsync(paymentRequest, paymentResponse); + result = new PaymentRequestCompleteResult("success"); + } + catch (Exception ex) + { + paymentProcessingException = ex; + // TODO: If payment is captured but not charged this would be considered "unknown" (charge the captured amount after shipping scenario). + result = new PaymentRequestCompleteResult("failure"); + } + + try + { + var message = invoke.RelatesTo.GetPostToBotMessage(); + if (result.Result == "success") + { + // Resume the conversation with the receipt to user + message.Text = paymentRequestComplete.Id; + message.Value = paymentRecord; + } + else + { + // Resume the conversation with error message + message.Text = $"Failed to process payment with error: {paymentProcessingException?.Message}"; + } + await Conversation.ResumeAsync(invoke.RelatesTo, message, token); + } + catch (Exception ex) + { + Trace.TraceError($"Failed to resume the conversation using ConversationReference: {JsonConvert.SerializeObject(invoke.RelatesTo)} and exception: {ex.Message}"); + } + + return result; + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Dialogs/RootDialog.cs b/CSharp/sample-payments/PaymentsBot/Dialogs/RootDialog.cs new file mode 100644 index 0000000000..0dbabd56d3 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Dialogs/RootDialog.cs @@ -0,0 +1,252 @@ +namespace PaymentsBot.Dialogs +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + using Helpers; + using Microsoft.Bot.Builder.Dialogs; + using Microsoft.Bot.Connector; + using Microsoft.Bot.Connector.Payments; + using Models; + using Properties; + using Services; + + [Serializable] + public class RootDialog : IDialog + { + public const string CARTKEY = "CART_ID"; + + public async Task StartAsync(IDialogContext context) + { + await Task.FromResult(true); + context.Wait(this.MessageReceivedAsync); + } + + public async Task MessageReceivedAsync(IDialogContext context, IAwaitable argument) + { + await this.WelcomeMessageAsync(context, argument); + } + + public async Task WelcomeMessageAsync(IDialogContext context, IAwaitable argument) + { + var reply = context.MakeMessage(); + + reply.Text = string.Format( + CultureInfo.CurrentCulture, + Resources.RootDialog_Welcome_Msg, + context.Activity.From.Name); + + await context.PostAsync(reply); + + var replyMessage = context.MakeMessage(); + + replyMessage.Attachments = new List(); + + var catalogItem = await new CatalogService().GetRandomItemAsync(); + + // store cartId and userId in the conversationData + var cartId = catalogItem.Id.ToString(); + context.ConversationData.SetValue(CARTKEY, cartId); + context.ConversationData.SetValue(cartId, context.Activity.From.Id); + + replyMessage.Attachments.Add(await BuildBuyCardAsync(cartId, catalogItem)); + + await context.PostAsync(replyMessage); + + context.Wait(this.AfterPurchaseAsync); + } + + private static PaymentRequest BuildPaymentRequest(string cartId, CatalogItem item, MicrosoftPayMethodData methodData) + { + return new PaymentRequest + { + Id = cartId, + Expires = TimeSpan.FromDays(1).ToString(), + MethodData = new List + { + methodData.ToPaymentMethodData() + }, + Details = new PaymentDetails + { + Total = new PaymentItem + { + Label = Resources.Wallet_Label_Total, + Amount = new PaymentCurrencyAmount + { + Currency = item.Currency, + Value = Convert.ToString(item.Price, CultureInfo.InvariantCulture) + }, + Pending = true + }, + DisplayItems = new List + { + new PaymentItem + { + Label = item.Title, + Amount = new PaymentCurrencyAmount + { + Currency = item.Currency, + Value = item.Price.ToString(CultureInfo.InvariantCulture) + } + }, + new PaymentItem + { + Label = Resources.Wallet_Label_Shipping, + Amount = new PaymentCurrencyAmount + { + Currency = item.Currency, + Value = "0.00" + }, + Pending = true + }, + new PaymentItem + { + Label = Resources.Wallet_Label_Tax, + Amount = new PaymentCurrencyAmount + { + Currency = item.Currency, + Value = "0.00" + }, + Pending = true + } + } + }, + Options = new PaymentOptions + { + RequestShipping = true, + RequestPayerEmail = true, + RequestPayerName = true, + RequestPayerPhone = true, + ShippingType = PaymentShippingTypes.Shipping + } + }; + } + + private static Task BuildBuyCardAsync(string cartId, CatalogItem item) + { + var heroCard = new HeroCard + { + Title = item.Title, + Subtitle = $"{item.Currency} {item.Price.ToString("F")}", + Text = item.Description, + Images = new List + { + new CardImage + { + Url = item.ImageUrl + } + }, + Buttons = new List + { + new CardAction + { + Title = "Buy", + Type = PaymentRequest.PaymentActionType, + Value = BuildPaymentRequest(cartId, item, PaymentService.GetAllowedPaymentMethods()) + } + } + }; + + return Task.FromResult(heroCard.ToAttachment()); + } + + private static ReceiptItem BuildReceiptItem(string title, string subtitle, string price, string imageUrl) + { + return new ReceiptItem( + title: title, + subtitle: subtitle, + price: price, + image: new CardImage(imageUrl)); + } + + private static async Task BuildReceiptCardAsync(PaymentRecord paymentRecord) + { + var shippingOption = await new ShippingService().GetShippingOptionAsync(paymentRecord.ShippingOption); + + var catalogItem = await new CatalogService().GetItemByIdAsync(paymentRecord.OrderId); + + var receiptItems = new List(); + + receiptItems.AddRange(paymentRecord.Items.Select(item => + { + if (catalogItem.Title.Equals(item.Label)) + { + return RootDialog.BuildReceiptItem( + catalogItem.Title, + catalogItem.Description, + $"{catalogItem.Currency} {catalogItem.Price.ToString("F")}", + catalogItem.ImageUrl); + } + else + { + return RootDialog.BuildReceiptItem( + item.Label, + null, + $"{item.Amount.Currency} {item.Amount.Value}", + null); + } + })); + + var receiptCard = new ReceiptCard + { + Title = Resources.RootDialog_Receipt_Title, + Facts = new List + { + new Fact(Resources.RootDialog_Receipt_OrderID, paymentRecord.OrderId.ToString()), + new Fact(Resources.RootDialog_Receipt_PaymentMethod, paymentRecord.MethodName), + new Fact(Resources.RootDialog_Shipping_Address, paymentRecord.ShippingAddress.FullInline()), + new Fact(Resources.RootDialog_Shipping_Option, shippingOption != null ? shippingOption.Label : "N/A") + }, + Items = receiptItems, + Tax = null, // Sales Tax is a displayed line item, leave this blank + Total = $"{paymentRecord.Total.Amount.Currency} {paymentRecord.Total.Amount.Value}" + }; + + return receiptCard.ToAttachment(); + } + + private async Task AfterPurchaseAsync(IDialogContext context, IAwaitable argument) + { + // clean up state store after completion + var cartId = context.ConversationData.GetValue(CARTKEY); + context.ConversationData.RemoveValue(CARTKEY); + context.ConversationData.RemoveValue(cartId); + + var activity = await argument as Activity; + var paymentRecord = activity?.Value as PaymentRecord; + + if (paymentRecord == null) + { + // show error + var errorMessage = activity.Text; + var message = context.MakeMessage(); + message.Text = errorMessage; + + await this.StartOverAsync(context, argument, message); + } + else + { + // show receipt + var message = context.MakeMessage(); + message.Text = string.Format( + CultureInfo.CurrentCulture, + Resources.RootDialog_Receipt_Text, + paymentRecord.OrderId, + paymentRecord.PaymentProcessor); + + message.Attachments.Add(await BuildReceiptCardAsync(paymentRecord)); + + await this.StartOverAsync(context, argument, message); + } + } + + private async Task StartOverAsync(IDialogContext context, IAwaitable argument, IMessageActivity message) + { + await context.PostAsync(message); + + context.Wait(this.MessageReceivedAsync); + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Global.asax b/CSharp/sample-payments/PaymentsBot/Global.asax new file mode 100644 index 0000000000..29ad22414b --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Global.asax @@ -0,0 +1 @@ +<%@ Application Codebehind="Global.asax.cs" Inherits="PaymentsBot.WebApiApplication" Language="C#" %> diff --git a/CSharp/sample-payments/PaymentsBot/Global.asax.cs b/CSharp/sample-payments/PaymentsBot/Global.asax.cs new file mode 100644 index 0000000000..d353c35b6c --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Global.asax.cs @@ -0,0 +1,12 @@ +namespace PaymentsBot +{ + using System.Web.Http; + + public class WebApiApplication : System.Web.HttpApplication + { + protected void Application_Start() + { + GlobalConfiguration.Configure(WebApiConfig.Register); + } + } +} diff --git a/CSharp/sample-payments/PaymentsBot/Helpers/Base64Url.cs b/CSharp/sample-payments/PaymentsBot/Helpers/Base64Url.cs new file mode 100644 index 0000000000..5c0c56c296 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Helpers/Base64Url.cs @@ -0,0 +1,46 @@ +namespace PaymentsBot.Helpers +{ + using System; + + internal static class Base64Url + { + public static string Encode(byte[] arg) + { + string s = Convert.ToBase64String(arg); // Standard base64 encoder + + s = s.Split('=')[0]; // Remove any trailing '='s + s = s.Replace('+', '-'); // 62nd char of encoding + s = s.Replace('/', '_'); // 63rd char of encoding + + return s; + } + + public static byte[] Decode(string arg) + { + string s = arg; + s = s.Replace('-', '+'); // 62nd char of encoding + s = s.Replace('_', '/'); // 63rd char of encoding + + // Pad with trailing '='s + switch (s.Length % 4) + { + case 0: + // No pad chars in this case + break; + + case 2: + s += "=="; // Two pad chars + break; + + case 3: + s += "="; // One pad char + break; + + default: + throw new Exception("Illegal base64url string!"); + } + + return Convert.FromBase64String(s); // Standard base64 decoder + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Helpers/PaymentAddressExtensions.cs b/CSharp/sample-payments/PaymentsBot/Helpers/PaymentAddressExtensions.cs new file mode 100644 index 0000000000..b7b19ac84b --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Helpers/PaymentAddressExtensions.cs @@ -0,0 +1,15 @@ +namespace PaymentsBot.Helpers +{ + using System.Linq; + using Microsoft.Bot.Connector.Payments; + + public static class PaymentAddressExtensions + { + public static string FullInline(this PaymentAddress address) + { + var addressLine = address.AddressLine.Count > 0 ? address.AddressLine.First() : "Address not provided"; + + return $"{addressLine}, {address.City} {address.Region}, {address.Country}\n{address.PostalCode}"; + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Helpers/PaymentItemBuilder.cs b/CSharp/sample-payments/PaymentsBot/Helpers/PaymentItemBuilder.cs new file mode 100644 index 0000000000..d68428702b --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Helpers/PaymentItemBuilder.cs @@ -0,0 +1,26 @@ +using Microsoft.Bot.Connector.Payments; + +namespace PaymentsBot.Helpers +{ + public class PaymentItemBuilder + { + public static PaymentCurrencyAmount BuildPaymentAmount(double amount) + { + return new PaymentCurrencyAmount + { + Currency = "USD", + Value = amount.ToString("F") + }; + } + + public static PaymentItem BuildPaymentItem(string label, double amount, bool pending = false) + { + return new PaymentItem + { + Amount = BuildPaymentAmount(amount), + Label = label, + Pending = pending + }; + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Models/CatalogItem.cs b/CSharp/sample-payments/PaymentsBot/Models/CatalogItem.cs new file mode 100644 index 0000000000..7c067888c0 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Models/CatalogItem.cs @@ -0,0 +1,19 @@ +namespace PaymentsBot.Models +{ + using System; + + public class CatalogItem + { + public string Currency { get; set; } + + public string Description { get; set; } + + public Guid Id { get; set; } + + public string ImageUrl { get; set; } + + public double Price { get; set; } + + public string Title { get; set; } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Models/PaymentException.cs b/CSharp/sample-payments/PaymentsBot/Models/PaymentException.cs new file mode 100644 index 0000000000..64d4e7a40c --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Models/PaymentException.cs @@ -0,0 +1,15 @@ +namespace PaymentsBot.Models +{ + using System; + + public class PaymentException : ApplicationException + { + public PaymentException(string message) : base(message) + { + } + + public PaymentException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Models/PaymentRecord.cs b/CSharp/sample-payments/PaymentsBot/Models/PaymentRecord.cs new file mode 100644 index 0000000000..8343d64d9d --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Models/PaymentRecord.cs @@ -0,0 +1,27 @@ +namespace PaymentsBot.Models +{ + using System; + using System.Collections.Generic; + using Microsoft.Bot.Connector.Payments; + + public class PaymentRecord + { + public Guid OrderId { get; set; } + + public Guid TransactionId { get; set; } + + public string MethodName { get; set; } + + public string PaymentProcessor { get; set; } + + public PaymentAddress ShippingAddress { get; set; } + + public string ShippingOption { get; set; } + + public IEnumerable Items { get; set; } + + public PaymentItem Total { get; set; } + + public bool LiveMode { get; set; } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Models/PaymentToken.cs b/CSharp/sample-payments/PaymentsBot/Models/PaymentToken.cs new file mode 100644 index 0000000000..dbde7e5b68 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Models/PaymentToken.cs @@ -0,0 +1,112 @@ +namespace PaymentsBot.Models +{ + using System; + using System.Text; + using Helpers; + using Microsoft.Bot.Connector.Payments; + using Newtonsoft.Json; + + public class PaymentToken + { + internal static readonly JsonSerializerSettings DefaultJsonSerializerSettings = new JsonSerializerSettings + { + DateFormatHandling = DateFormatHandling.IsoDateFormat, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + NullValueHandling = NullValueHandling.Ignore + }; + + private const string MsPayEmulatedStripeTokenSource = "tok_18yWDMKVgMv7trmwyE21VqO"; + + private readonly PaymentTokenHeader header; + private readonly string source; + private readonly byte[] signature; + + private PaymentToken(PaymentTokenHeader header, string source, byte[] signature) + { + this.header = header; + this.source = source; + this.signature = signature; + } + + /// + /// Token Format + /// + public PaymentTokenFormat Format => this.header.Format; + + /// + /// Merchant Id + /// + public string MerchantId => this.header.MerchantId; + + /// + /// Payment Request Id + /// + public string PaymentRequestId => this.header.PaymentRequestId; + + /// + /// Amount + /// + public PaymentCurrencyAmount Amount => this.header.Amount; + + /// + /// Expiry + /// + public DateTime Expiry => this.header.Expiry; + + /// + /// Timestamp + /// + public DateTime Timestamp => this.header.Timestamp; + + /// + /// Is Emulated + /// + public bool IsEmulated => this.Format == PaymentTokenFormat.Stripe && MsPayEmulatedStripeTokenSource.Equals(this.Source, StringComparison.OrdinalIgnoreCase); + + /// + /// Token Source + /// + public string Source => this.source; + + /// + /// Parse the token string + /// + /// token string + /// payment token + public static PaymentToken Parse(string tokenstring) + { + if (string.IsNullOrWhiteSpace(tokenstring)) + { + throw new ArgumentNullException(nameof(tokenstring)); + } + + var tokenParts = tokenstring.Split('.'); + + if (tokenParts.Length != 3) + { + throw new ArgumentException("Invalid payment token."); + } + + return new PaymentToken(ParseHeader(tokenParts[0]), ParseSource(tokenParts[1]), ParseSignature(tokenParts[2])); + } + + private static PaymentTokenHeader ParseHeader(string headerString) + { + var headerBytes = Base64Url.Decode(headerString); + var headerJson = Encoding.UTF8.GetString(headerBytes); + return new PaymentTokenHeader(headerJson); + } + + private static string ParseSource(string sourceString) + { + var sourceBytes = Base64Url.Decode(sourceString); + var sourceText = Encoding.UTF8.GetString(sourceBytes); + return sourceText; + } + + private static byte[] ParseSignature(string signatureString) + { + return Base64Url.Decode(signatureString); + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Models/PaymentTokenFormat.cs b/CSharp/sample-payments/PaymentsBot/Models/PaymentTokenFormat.cs new file mode 100644 index 0000000000..65c63516de --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Models/PaymentTokenFormat.cs @@ -0,0 +1,24 @@ +namespace PaymentsBot.Models +{ + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + [JsonConverter(typeof(StringEnumConverter))] + public enum PaymentTokenFormat + { + /// + /// Invalid + /// + Invalid = 0, + + /// + /// Error + /// + Error, + + /// + /// Stripe + /// + Stripe, + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Models/PaymentTokenHeader.cs b/CSharp/sample-payments/PaymentsBot/Models/PaymentTokenHeader.cs new file mode 100644 index 0000000000..626a1b6007 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Models/PaymentTokenHeader.cs @@ -0,0 +1,71 @@ +namespace PaymentsBot.Models +{ + using System; + using Microsoft.Bot.Connector.Payments; + using Newtonsoft.Json; + + public class PaymentTokenHeader + { + /// + /// Parse from header json + /// + /// header json + internal PaymentTokenHeader(string headerJson) + { + JsonConvert.PopulateObject(headerJson, this, PaymentToken.DefaultJsonSerializerSettings); + } + + /// + /// Payment Token Format + /// + [JsonProperty(PropertyName = "format")] + public PaymentTokenFormat Format { get; set; } + + /// + /// Merchant Id + /// + [JsonProperty(PropertyName = "merchantId")] + public string MerchantId { get; set; } + + /// + /// Payment Request Id + /// + public string PaymentRequestId { get; set; } + + /// + /// Amount + /// + [JsonProperty(PropertyName = "amount")] + public PaymentCurrencyAmount Amount { get; set; } + + /// + /// Expiry + /// + [JsonProperty(PropertyName = "expiry")] + public DateTime Expiry { get; set; } + + /// + /// TimeStamp + /// + [JsonProperty(PropertyName = "timeStamp")] + public DateTime Timestamp { get; set; } + + /// + /// Error Code + /// + [JsonProperty(PropertyName = "errorCode")] + public string ErrorCode { get; set; } + + /// + /// Error Text + /// + [JsonProperty(PropertyName = "errorText")] + public string ErrorText { get; set; } + + /// + /// Error Source + /// + [JsonProperty(PropertyName = "errorSource")] + public string ErrorSource { get; set; } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/PaymentsBot.csproj b/CSharp/sample-payments/PaymentsBot/PaymentsBot.csproj new file mode 100644 index 0000000000..c3c9fa522d --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/PaymentsBot.csproj @@ -0,0 +1,236 @@ + + + + + Debug + AnyCPU + + + 2.0 + {A8BA1066-5695-4D71-ABB4-65E5A5E0C3D4} + {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} + Library + Properties + PaymentsBot + Bot Application1 + v4.6 + true + 44346 + + + + + + + + + + true + full + false + bin\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\ + TRACE + prompt + 4 + + + + ..\packages\Autofac.4.3.0\lib\net45\Autofac.dll + True + + + ..\packages\Microsoft.Bot.Builder.3.8.0\lib\net46\Microsoft.Bot.Builder.dll + True + + + ..\packages\Microsoft.Bot.Builder.3.8.0\lib\net46\Microsoft.Bot.Builder.Autofac.dll + True + + + ..\packages\Microsoft.Bot.Builder.3.8.0\lib\net46\Microsoft.Bot.Connector.dll + True + + + + ..\packages\Microsoft.IdentityModel.Protocol.Extensions.1.0.4.403061554\lib\net45\Microsoft.IdentityModel.Protocol.Extensions.dll + True + + + ..\packages\Microsoft.Rest.ClientRuntime.2.3.4\lib\net45\Microsoft.Rest.ClientRuntime.dll + True + + + ..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll + True + + + ..\packages\Microsoft.WindowsAzure.ConfigurationManager.3.2.3\lib\net40\Microsoft.WindowsAzure.Configuration.dll + True + + + ..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll + True + + + ..\packages\Stripe.net.7.2.0\lib\net45\Stripe.net.dll + True + + + + + ..\packages\System.IdentityModel.Tokens.Jwt.4.0.4.403061554\lib\net45\System.IdentityModel.Tokens.Jwt.dll + True + + + + + ..\packages\Microsoft.AspNet.WebApi.Client.5.2.3\lib\net45\System.Net.Http.Formatting.dll + True + + + + + + + + + + + + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.Helpers.dll + True + + + ..\packages\Microsoft.AspNet.WebApi.Core.5.2.3\lib\net45\System.Web.Http.dll + True + + + ..\packages\Microsoft.AspNet.WebApi.WebHost.5.2.3\lib\net45\System.Web.Http.WebHost.dll + True + + + ..\packages\Microsoft.AspNet.Mvc.5.2.3\lib\net45\System.Web.Mvc.dll + True + + + ..\packages\Microsoft.AspNet.Razor.3.2.3\lib\net45\System.Web.Razor.dll + True + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.dll + True + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Deployment.dll + True + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Razor.dll + True + + + + + + + + + + + + Designer + + + + + + + + + Global.asax + + + + + + + + + + + + True + True + Resources.resx + + + + + + + + + + Designer + + + Web.config + Designer + + + Web.config + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + true + + + + + + + + + True + True + 3979 + / + http://localhost:3979/ + False + False + + + False + + + + + + \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Properties/AssemblyInfo.cs b/CSharp/sample-payments/PaymentsBot/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7c7fa2841a --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("PaymentsBot")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("PaymentsBot")] +[assembly: AssemblyCopyright("Copyright © 2016")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("a8ba1066-5695-4d71-abb4-65e5a5e0c3d4")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Revision and Build Numbers +// by using the '*' as shown below: +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/CSharp/sample-payments/PaymentsBot/Properties/Resources.Designer.cs b/CSharp/sample-payments/PaymentsBot/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..481089406d --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Properties/Resources.Designer.cs @@ -0,0 +1,173 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PaymentsBot.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PaymentsBot.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Sorry, this item can be shipped to your selected address.. + /// + internal static string RootDialog_Invalid_Address { + get { + return ResourceManager.GetString("RootDialog_Invalid_Address", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid purchase, please try again... + /// + internal static string RootDialog_Invalid_Purchase { + get { + return ResourceManager.GetString("RootDialog_Invalid_Purchase", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Order ID. + /// + internal static string RootDialog_Receipt_OrderID { + get { + return ResourceManager.GetString("RootDialog_Receipt_OrderID", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Payment Method. + /// + internal static string RootDialog_Receipt_PaymentMethod { + get { + return ResourceManager.GetString("RootDialog_Receipt_PaymentMethod", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your order **#{0}** has been processed! Your payment has been confirmed by **{1}**. + ///Thank you for using Contoso Red T-Shirts. + ///Here is your receipt:. + /// + internal static string RootDialog_Receipt_Text { + get { + return ResourceManager.GetString("RootDialog_Receipt_Text", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Contoso Red T-Shirts Receipt. + /// + internal static string RootDialog_Receipt_Title { + get { + return ResourceManager.GetString("RootDialog_Receipt_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shipping Address. + /// + internal static string RootDialog_Shipping_Address { + get { + return ResourceManager.GetString("RootDialog_Shipping_Address", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shipping Option. + /// + internal static string RootDialog_Shipping_Option { + get { + return ResourceManager.GetString("RootDialog_Shipping_Option", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Welcome **{0}** to Contoso Red T-Shirts, here you can only buy Scott Gu - Favorite Shirts. + /// + internal static string RootDialog_Welcome_Msg { + get { + return ResourceManager.GetString("RootDialog_Welcome_Msg", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shipping. + /// + internal static string Wallet_Label_Shipping { + get { + return ResourceManager.GetString("Wallet_Label_Shipping", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sales Tax. + /// + internal static string Wallet_Label_Tax { + get { + return ResourceManager.GetString("Wallet_Label_Tax", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Total. + /// + internal static string Wallet_Label_Total { + get { + return ResourceManager.GetString("Wallet_Label_Total", resourceCulture); + } + } + } +} diff --git a/CSharp/sample-payments/PaymentsBot/Properties/Resources.resx b/CSharp/sample-payments/PaymentsBot/Properties/Resources.resx new file mode 100644 index 0000000000..a64f4fb79d --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Properties/Resources.resx @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Sorry, this item can be shipped to your selected address. + + + Invalid purchase, please try again.. + + + Order ID + + + Payment Method + + + Your order **#{0}** has been processed! Your payment has been confirmed by **{1}**. +Thank you for using Contoso Red T-Shirts. +Here is your receipt: + 0 orderId, 1 payment processor info, 2 delivery address, 3 delivery option + + + Contoso Red T-Shirts Receipt + + + Shipping Address + + + Shipping Option + + + Welcome **{0}** to Contoso Red T-Shirts, here you can only buy Scott Gu - Favorite Shirts + + + Shipping + + + Sales Tax + + + Total + + \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Services/CatalogService.cs b/CSharp/sample-payments/PaymentsBot/Services/CatalogService.cs new file mode 100644 index 0000000000..863f907578 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Services/CatalogService.cs @@ -0,0 +1,35 @@ +namespace PaymentsBot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Models; + + public class CatalogService + { + private static readonly IEnumerable FakeCatalogRepository = new List + { + new CatalogItem + { + Currency = "USD", + Description = "Shiny red, ready to rock on Keynotes", + Id = new Guid("bc861179-46a5-4645-a249-7eba2a4d9846"), + ImageUrl = "https://pbs.twimg.com/profile_images/565139568/redshirt_400x400.jpg", + Price = 1.99, + Title = "Scott Gu - Favorite Shirt" + } + }; + + public Task GetItemByIdAsync(Guid itemId) + { + return Task.FromResult(FakeCatalogRepository.FirstOrDefault(o => o.Id.Equals(itemId))); + } + + public Task GetRandomItemAsync() + { + // getting a random item - currently we have only one choice :p + return Task.FromResult(FakeCatalogRepository.First()); + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Services/PaymentService.cs b/CSharp/sample-payments/PaymentsBot/Services/PaymentService.cs new file mode 100644 index 0000000000..4230c77c95 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Services/PaymentService.cs @@ -0,0 +1,134 @@ +namespace PaymentsBot.Services +{ + using System; + using System.Diagnostics; + using System.Threading.Tasks; + using Microsoft.Bot.Connector.Payments; + using Models; + using Newtonsoft.Json.Linq; + using Stripe; + + public class PaymentService + { + private static readonly MicrosoftPayMethodData MethodData = new MicrosoftPayMethodData(testMode: !AppSettings.LiveMode) + { + MerchantId = AppSettings.MerchantId, + SupportedNetworks = new[] { "visa", "mastercard" }, + SupportedTypes = new[] { "credit" } + }; + + private readonly StripeChargeService chargeService; + + public PaymentService(string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentNullException(nameof(apiKey)); + } + + this.chargeService = new StripeChargeService(apiKey); + } + + public static MicrosoftPayMethodData GetAllowedPaymentMethods() + { + return MethodData; + } + + public async Task ProcessPaymentAsync(PaymentRequest paymentRequest, PaymentResponse paymentResponse) + { + if (paymentRequest == null) + { + throw new ArgumentNullException(nameof(paymentRequest)); + } + + if (paymentResponse == null) + { + throw new ArgumentNullException(nameof(paymentResponse)); + } + + if (!MicrosoftPayMethodData.MethodName.Equals(paymentResponse.MethodName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentOutOfRangeException("Payment method is not supported."); + } + + var details = JObject.FromObject(paymentResponse.Details); + + var paymentToken = PaymentToken.Parse(details.GetValue("paymentToken").Value()); + + if (string.IsNullOrWhiteSpace(paymentToken.Source)) + { + throw new ArgumentNullException("Payment token source is empty."); + } + + if (paymentToken.Format != PaymentTokenFormat.Stripe) + { + throw new ArgumentOutOfRangeException("Payment token format is not Stripe."); + } + + if (!AppSettings.MerchantId.Equals(paymentToken.MerchantId, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentOutOfRangeException("Merchant id is not supported."); + } + + if (!paymentToken.Amount.Currency.Equals(paymentRequest.Details.Total.Amount.Currency)) + { + throw new ArgumentOutOfRangeException("Payment token amount currency mismatch."); + } + + if (!paymentToken.Amount.Value.Equals(paymentRequest.Details.Total.Amount.Value)) + { + throw new ArgumentOutOfRangeException("Payment token amount value mismatch."); + } + + var result = new PaymentRecord() + { + OrderId = Guid.Parse(paymentRequest.Id), + TransactionId = Guid.NewGuid(), + MethodName = paymentResponse.MethodName, + PaymentProcessor = paymentToken.Format.ToString(), + ShippingAddress = paymentResponse.ShippingAddress, + ShippingOption = paymentResponse.ShippingOption, + Items = paymentRequest.Details.DisplayItems, + Total = paymentRequest.Details.Total, + LiveMode = !paymentToken.IsEmulated, + }; + + // If the payment token is microsoft emulated do not charge (as it will fail) + if (paymentToken.IsEmulated) + { + return result; + } + + var chargeOptions = new StripeChargeCreateOptions() + { + Currency = paymentToken.Amount.Currency, + Amount = (int)(double.Parse(paymentToken.Amount.Value) * 100), // Amount in cents + Description = paymentRequest.Id, + SourceTokenOrExistingSourceId = paymentToken.Source, + }; + + try + { + var charge = await this.chargeService.CreateAsync(chargeOptions); + + if (charge.Status.Equals("succeeded", StringComparison.OrdinalIgnoreCase) && charge.Captured == true) + { + // Charge succeeded, return payment paymentRecord + // Ideally, you should register the transaction using the PaymentRecord and charge.Id + return result; + } + + // Other statuses may include processing "pending" or "success" with non captured funds. It is up to the merchant how to handle these cases. + // If payment is captured but not charged this would be considered "unknown" (charge the captured amount after shipping scenario) + // Merchant might choose to handle "pending" and "failed" status or handle "success" status with funds captured null or false + // More information @ https://stripe.com/docs/api#charge_object-captured + throw new PaymentException($"Could not process charge using Stripe with charge.status: {charge.Status} and charge.captured: {charge.Captured}"); + } + catch (StripeException ex) + { + Debug.Write($"Error processing payment with Stripe: {ex.Message}"); + throw new PaymentException("Error processing payment with Stripe.", ex); + } + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Services/ShippingService.cs b/CSharp/sample-payments/PaymentsBot/Services/ShippingService.cs new file mode 100644 index 0000000000..789e6bac23 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Services/ShippingService.cs @@ -0,0 +1,118 @@ +namespace PaymentsBot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Helpers; + using Microsoft.Bot.Connector.Payments; + using Models; + using Properties; + + public class ShippingService + { + private static readonly IEnumerable FakeShippingOptionsRepository = new List() + { + new PaymentShippingOption + { + Id = "STANDARD", + Label = "Standard - (5-6 Business days)", + Amount = PaymentItemBuilder.BuildPaymentAmount(0), + Selected = true + }, + new PaymentShippingOption + { + Id = "EXPEDITED", + Label = "Expedited - (2 Business days)", + Amount = PaymentItemBuilder.BuildPaymentAmount(2.50) + }, + new PaymentShippingOption + { + Id = "INTERNATIONAL", + Label = "International", + Amount = PaymentItemBuilder.BuildPaymentAmount(25) + } + }; + + + private static readonly IEnumerable FakeUSShippingOptions = FakeShippingOptionsRepository.Take(2); + + private static readonly IEnumerable FakeInternationalShippingOptions = FakeShippingOptionsRepository.Skip(2); + + public async Task GetShippingOptionAsync(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentNullException(nameof(id)); + } + + return await Task.FromResult(FakeShippingOptionsRepository.FirstOrDefault(option => id.Equals(option.Id, StringComparison.OrdinalIgnoreCase))); + } + + public async Task> GetShippingOptionsAsync(CatalogItem item, PaymentAddress address) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (string.IsNullOrWhiteSpace(address.Country)) + { + throw new ArgumentNullException(nameof(address.Country)); + } + + var shippingOptions = new List(); + + // if shipping country is not supported then throw + if (AppSettings.InvalidShippingCountry.Equals(address.Country)) + { + throw new Exception(Resources.RootDialog_Invalid_Address); + } + + // shipping options within US are first two + if (address.Country == "US") + { + shippingOptions.AddRange(FakeUSShippingOptions); + } + else + { + shippingOptions.AddRange(FakeInternationalShippingOptions); + } + + return await Task.FromResult(shippingOptions); + } + + public Task UpdatePaymentDetailsAsync(PaymentDetails details, PaymentAddress address, CatalogItem item) + { + var shippingOption = details.ShippingOptions.FirstOrDefault(o => o.Selected.HasValue && o.Selected.Value); + var shippingTotal = shippingOption != null ? Convert.ToDouble(shippingOption.Amount.Value) : 0; + + var taxTotal = (item.Price + shippingTotal) * ShippingService.GetTaxPercentage(address); + + // update items + var pending = shippingOption == null; + details.DisplayItems = new List + { + PaymentItemBuilder.BuildPaymentItem(item.Title, item.Price), + PaymentItemBuilder.BuildPaymentItem(Resources.Wallet_Label_Shipping, shippingTotal, pending), + PaymentItemBuilder.BuildPaymentItem(Resources.Wallet_Label_Tax, taxTotal, pending) + }; + + // update total + var total = item.Price + shippingTotal + taxTotal; + details.Total = PaymentItemBuilder.BuildPaymentItem(Resources.Wallet_Label_Total, total, pending); + + return Task.FromResult(true); + } + + private static double GetTaxPercentage(PaymentAddress address) + { + return address.Country == "US" ? 0.085 : 0; + } + } +} \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Web.Debug.config b/CSharp/sample-payments/PaymentsBot/Web.Debug.config new file mode 100644 index 0000000000..2e302f9f95 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Web.Debug.config @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Web.Release.config b/CSharp/sample-payments/PaymentsBot/Web.Release.config new file mode 100644 index 0000000000..c35844462b --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Web.Release.config @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/Web.config b/CSharp/sample-payments/PaymentsBot/Web.config new file mode 100644 index 0000000000..ab08cfbedc --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/Web.config @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CSharp/sample-payments/PaymentsBot/default.htm b/CSharp/sample-payments/PaymentsBot/default.htm new file mode 100644 index 0000000000..3e7cb63305 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/default.htm @@ -0,0 +1,12 @@ + + + + + + + +

PaymentsBot

+

Describe your bot here and your terms of use etc.

+

Visit Bot Framework to register your bot. When you register it, remember to set your bot's endpoint to

https://your_bots_hostname/api/messages

+ + diff --git a/CSharp/sample-payments/PaymentsBot/packages.config b/CSharp/sample-payments/PaymentsBot/packages.config new file mode 100644 index 0000000000..c15b623ee2 --- /dev/null +++ b/CSharp/sample-payments/PaymentsBot/packages.config @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CSharp/sample-payments/README.md b/CSharp/sample-payments/README.md new file mode 100644 index 0000000000..1283016d76 --- /dev/null +++ b/CSharp/sample-payments/README.md @@ -0,0 +1,45 @@ +# Payment Bot Sample + +A sample bot showing how to integrate with Microsoft Seller Center for payment processing. + +### Prerequisites + +The minimum prerequisites to run this sample are: +* The latest update of Visual Studio 2015. You can download the community version [here](http://www.visualstudio.com) for free. +* Register your bot with the Microsoft Bot Framework. Please refer to [this](https://docs.botframework.com/en-us/csharp/builder/sdkreference/gettingstarted.html#registering) for the instructions. Once you complete the registration, update the [Bot's Web.config](PaymentsBot/Web.config#L9-L11) file with the registered config values (MicrosoftAppId and MicrosoftAppPassword). + +#### Microsoft Bot Builder + +This sample has been developed based on Microsoft Bot Builder Dialog system. You can follow the following [sample](https://github.com/Microsoft/BotBuilder-Samples/tree/master/CSharp/core-MultiDialogs) to become familiar with different kind of dialogs and dialog stack in Bot Builder. + +#### Microsoft Seller Center + +1. Create and activate a Stripe account if you don't have one already. + +2. Sign in to Seller Center with your Microsoft account. + +3. Within Seller Center, connect your account with Stripe. + +4. Within Seller Center, navigate to the Dashboard and copy the value of **MerchantID**. + +5. Update your bot's **web.config** file to set `MerchantId` to the value that you copied from the Seller Center Dashboard. + +#### Publish +Also, in order to be able to run and test this sample you must [publish your bot, for example to Azure](https://docs.botframework.com/en-us/csharp/builder/sdkreference/gettingstarted.html#publishing). Alternatively, you can use [Ngrok to interact with your local bot in the cloud](https://blogs.msdn.microsoft.com/jamiedalton/2016/07/29/ms-bot-framework-ngrok/). + +### Outcome + +To run the sample, you'll need to publish Bot to Azure or use [Ngrok to interact with your local bot in the cloud](https://blogs.msdn.microsoft.com/jamiedalton/2016/07/29/ms-bot-framework-ngrok/). +* Running Bot app + 1. In the Visual Studio Solution Explorer window, right click on the **PaymentsBot** project. + 2. In the contextual menu, select Debug, then Start New Instance and wait for the _Web application_ to start. + +You can use the webchat control in bot framework developer portal to interact with your bot. + +### More Information + +To get more information about how to get started in Bot Builder for .NET and Conversations please review the following resources: +* [Bot Builder for .NET](https://docs.botframework.com/en-us/csharp/builder/sdkreference/index.html) +* [Bot Framework FAQ](https://docs.botframework.com/en-us/faq/#i-have-a-communication-channel-id-like-to-be-configurable-with-bot-framework-can-i-work-with-microsoft-to-do-that) +* [Bot Builder samples](https://github.com/microsoft/botbuilder-samples) +* [Bot Framework Emulator](https://github.com/microsoft/botframework-emulator/wiki/Getting-Started) diff --git a/CSharp/sample-payments/Settings.StyleCop b/CSharp/sample-payments/Settings.StyleCop new file mode 100644 index 0000000000..b49c38ebd5 --- /dev/null +++ b/CSharp/sample-payments/Settings.StyleCop @@ -0,0 +1,219 @@ + + + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + + + \ No newline at end of file diff --git a/Node/sample-payments/.env b/Node/sample-payments/.env new file mode 100644 index 0000000000..76e4ef20f0 --- /dev/null +++ b/Node/sample-payments/.env @@ -0,0 +1,8 @@ +# Bot Framework Credentials + +MICROSOFT_APP_ID= +MICROSOFT_APP_PASSWORD= + +PAYMENTS_LIVEMODE=false +PAYMENTS_MERCHANT_ID=merk_123 +PAYMENTS_STRIPE_API_KEY=stripe_123 \ No newline at end of file diff --git a/Node/sample-payments/.gitignore b/Node/sample-payments/.gitignore new file mode 100644 index 0000000000..805e724b14 --- /dev/null +++ b/Node/sample-payments/.gitignore @@ -0,0 +1,3 @@ +/**/node_modules +/**/.vscode +/**/*.VC.db \ No newline at end of file diff --git a/Node/sample-payments/app.js b/Node/sample-payments/app.js new file mode 100644 index 0000000000..1fcfb1e07f --- /dev/null +++ b/Node/sample-payments/app.js @@ -0,0 +1,242 @@ +// This loads the environment variables from the .env file +require('dotenv-extended').load(); + +var util = require('util'); +var builder = require('botbuilder'); +var restify = require('restify'); +var payments = require('./payments'); +var checkout = require('./checkout'); +var catalog = require('./services/catalog'); + +var connector = new builder.ChatConnector({ + appId: process.env.MICROSOFT_APP_ID, + appPassword: process.env.MICROSOFT_APP_PASSWORD +}); + +var server = restify.createServer(); +server.listen(process.env.port || process.env.PORT || 3978, function () { + console.log('%s listening to %s', server.name, server.url); +}); +server.post('/api/messages', connector.listen()); + +var CartIdKey = 'CardId'; + +var bot = new builder.UniversalBot(connector, (session) => { + + catalog.getPromotedItem().then(product => { + + // Store userId for later, when reading relatedTo to resume dialog with the receipt + var cartId = product.id; + session.conversationData[CartIdKey] = cartId; + session.conversationData[cartId] = session.message.address.user.id; + + // Create PaymentRequest obj based on product information + var paymentRequest = createPaymentRequest(cartId, product); + + var buyCard = new builder.HeroCard(session) + .title(product.name) + .subtitle(util.format('%s %s', product.currency, product.price)) + .text(product.description) + .images([ + new builder.CardImage(session).url(product.imageUrl) + ]) + .buttons([ + new builder.CardAction(session) + .title('Buy') + .type(payments.PaymentActionType) + .value(paymentRequest) + ]); + + session.send(new builder.Message(session) + .addAttachment(buyCard)); + }); +}); + +bot.set('persistConversationData', true); + +connector.onInvoke((invoke, callback) => { + console.log('onInvoke', invoke); + + // This is a temporary workaround for the issue that the channelId for "webchat" is mapped to "directline" in the incoming RelatesTo object + invoke.relatesTo.channelId = invoke.relatesTo.channelId === 'directline' ? 'webchat' : invoke.relatesTo.channelId; + + var storageCtx = { + address: invoke.relatesTo, + persistConversationData: true, + conversationId: invoke.relatesTo.conversation.id + }; + + connector.getData(storageCtx, (err, data) => { + var cartId = data.conversationData[CartIdKey]; + if (!invoke.relatesTo.user && cartId) { + // Bot keeps the userId in context.ConversationData[cartId] + var userId = data.conversationData[cartId]; + invoke.relatesTo.useAuth = true; + invoke.relatesTo.user = { id: userId }; + } + + // Continue based on PaymentRequest event + var paymentRequest = null; + switch (invoke.name) { + case payments.Operations.UpdateShippingAddressOperation: + case payments.Operations.UpdateShippingOptionOperation: + paymentRequest = invoke.value; + + // Validate address AND shipping method (if selected) + checkout + .validateAndCalculateDetails(paymentRequest, paymentRequest.shippingAddress, paymentRequest.shippingOption) + .then(updatedPaymentRequest => { + // return new paymentRequest with updated details + callback(null, updatedPaymentRequest, 200); + }).catch(err => { + // return error to onInvoke handler + callback(err); + // send error message back to user + bot.beginDialog(invoke.relatesTo, 'checkout_failed', { + errorMessage: err.message + }); + }); + + break; + + case payments.Operations.PaymentCompleteOperation: + var paymentRequestComplete = invoke.value; + paymentRequest = paymentRequestComplete.paymentRequest; + var paymentResponse = paymentRequestComplete.paymentResponse; + + // Validate address AND shipping method + checkout + .validateAndCalculateDetails(paymentRequest, paymentResponse.shippingAddress, paymentResponse.shippingOption) + .then(updatedPaymentRequest => + // Process Payment + checkout + .processPayment(updatedPaymentRequest, paymentResponse) + .then(chargeResult => { + // return success + callback(null, { result: "success" }, 200); + // send receipt to user + bot.beginDialog(invoke.relatesTo, 'checkout_receipt', { + paymentRequest: updatedPaymentRequest, + chargeResult: chargeResult + }); + }) + ).catch(err => { + // return error to onInvoke handler + callback(err); + // send error message back to user + bot.beginDialog(invoke.relatesTo, 'checkout_failed', { + errorMessage: err.message + }); + }); + + break; + } + + }); +}); + +bot.dialog('checkout_receipt', function (session, args) { + console.log('checkout_receipt', args); + + cleanupConversationData(session); + + var paymentRequest = args.paymentRequest; + var chargeResult = args.chargeResult; + var shippingAddress = chargeResult.shippingAddress; + var shippingOption = chargeResult.shippingOption; + var orderId = chargeResult.orderId; + + // send receipt card + var items = paymentRequest.details.displayItems + .map(o => builder.ReceiptItem.create(session, o.amount.currency + ' ' + o.amount.value, o.label)); + + var receiptCard = new builder.ReceiptCard(session) + .title('Contoso Order Receipt') + .facts([ + builder.Fact.create(session, orderId, 'Order ID'), + builder.Fact.create(session, chargeResult.methodName, 'Payment Method'), + builder.Fact.create(session, [shippingAddress.addressLine, shippingAddress.city, shippingAddress.region, shippingAddress.country].join(', '), 'Shipping Address'), + builder.Fact.create(session, shippingOption, 'Shipping Option') + ]) + .items(items) + .total(paymentRequest.details.total.amount.currency + ' ' + paymentRequest.details.total.amount.value); + + session.endDialog( + new builder.Message(session) + .addAttachment(receiptCard)); +}); + +bot.dialog('checkout_failed', function (session, args) { + cleanupConversationData(session); + session.endDialog('Could not process your payment: %s', args.errorMessage); +}); + +// PaymentRequest with default options +function createPaymentRequest(cartId, product) { + if (!cartId) { + throw new Error('cartId is missing'); + } + + if (!product) { + throw new Error('product is missing'); + } + + // PaymentMethodData[] + var paymentMethods = [{ + supportedMethods: [payments.MicrosoftPayMethodName], + data: { + mode: process.env.PAYMENTS_LIVEMODE === 'true' ? null : 'TEST', + merchantId: process.env.PAYMENTS_MERCHANT_ID, + supportedNetworks: ['visa', 'mastercard'], + supportedTypes: ['credit'] + } + }]; + + // PaymentDetails + var paymentDetails = { + total: { + label: 'Total', + amount: { currency: product.currency, value: product.price.toFixed(2) }, + pending: true + }, + displayItems: [ + { + label: product.name, + amount: { currency: product.currency, value: product.price.toFixed(2) } + }, { + label: 'Shipping', + amount: { currency: product.currency, value: '0.00' }, + pending: true + }, { + label: 'Sales Tax', + amount: { currency: product.currency, value: '0.00' }, + pending: true + }], + // until a shipping address is selected, we can't offer shipping options or calculate taxes or shipping costs + shippingOptions: [] + }; + + // PaymentOptions + var paymentOptions = { + requestPayerName: true, + requestPayerEmail: true, + requestPayerPhone: true, + requestShipping: true, + shippingType: 'shipping' + }; + + // PaymentRequest + return { + id: cartId, + expires: '1.00:00:00', // 1 day + methodData: paymentMethods, // paymethodMethods: paymentMethods, + details: paymentDetails, // paymentDetails: paymentDetails, + options: paymentOptions // paymentOptions: paymentOptions + }; +} + +function cleanupConversationData(session) { + var cartId = session.conversationData[CartIdKey]; + delete session.conversationData[CartIdKey]; + delete session.conversationData[cartId]; +} diff --git a/Node/sample-payments/checkout.js b/Node/sample-payments/checkout.js new file mode 100644 index 0000000000..f6f3ae5993 --- /dev/null +++ b/Node/sample-payments/checkout.js @@ -0,0 +1,217 @@ +var _ = require('lodash'); +var uuid = require('uuid'); +var util = require('util'); +var Promise = require('bluebird'); +var Stripe = require('stripe')(process.env.PAYMENTS_STRIPE_API_KEY); +var PaymentToken = require('./payment-token'); +var payments = require('./payments'); +var catalog = require('./services/catalog'); + +function validateAndCalculateDetails(paymentRequest, shippingAddress, selectedShippingOption) { + // selectedShippingOption may not be set the first time + + var productIds = [paymentRequest.id]; + + // get product items + return Promise + .all(productIds.map(id => catalog.getItemById(id))) + .then(products => { + if (products.length === 0) { + throw new Error('Product not available'); + } + + // get available shipping methods for address and validate + var shippingMethods = getShippingMethodsForAddress(shippingAddress); + var selectedShippingMethod = shippingMethods.find(o => o.id === selectedShippingOption); + if (!selectedShippingMethod && shippingMethods.length > 0) { + // preselect first option if none is found + selectedShippingMethod = shippingMethods[0]; + } + + if (selectedShippingMethod) { + selectedShippingMethod.selected = true; + } + + // details + var paymentDetails = { + total: { + label: 'Total', + amount: { currency: 'USD', value: '0.00' } + }, + displayItems: products.map(asDisplayItem) + .concat([ + { + label: 'Shipping', + amount: { currency: 'USD', value: '0.00' }, + pending: true + }, { + label: 'Sales Tax', + amount: { currency: 'USD', value: '0.00' }, + pending: true + }]), + shippingOptions: shippingMethods + }; + + // calculate tax, shipping cost and total + recalculateTaxAndTotal(paymentDetails, shippingAddress, selectedShippingMethod); + + return _.assign({}, paymentRequest, { details: paymentDetails }) + }); +} + +function processPayment(paymentRequest, paymentResponse) { + try { + // sanity checks + checkParam(paymentRequest, 'paymentRequest'); + checkParam(paymentResponse, 'paymentResponse'); + if (paymentResponse.methodName !== payments.MicrosoftPayMethodName) { + throw new Error('Payment method is not supported.'); + } + + checkParam(paymentResponse.details, 'paymentResponse.details'); + checkParam(paymentResponse.details.paymentToken, 'paymentResponse.details.paymentToken'); + + var paymentToken = PaymentToken.parse(paymentResponse.details.paymentToken); + checkParam(paymentToken, 'parsed paymentToken'); + checkParam(paymentToken.source, 'Payment token source is empty.'); + if (paymentToken.header.Format !== 'Stripe') { + throw new Error('Payment token format is not Stripe.'); + } + + if (paymentToken.header.MerchantId !== process.env.PAYMENTS_MERCHANT_ID) { + throw new Error('MerchantId is not supported.'); + } + + if (paymentToken.header.Amount.currency !== paymentRequest.details.total.amount.currency || + paymentToken.header.Amount.value !== paymentRequest.details.total.amount.value) { + throw new Error('Payment token amount currency/amount mismatch.'); + } + + var paymentRecord = { + orderId: paymentRequest.id, + transactionId: uuid.v1(), + methodName: paymentResponse.methodName, + paymentProcessor: paymentToken.header.Format, + shippingAddress: paymentResponse.shippingAddress, + shippingOption: paymentResponse.shippingOption, + items: paymentRequest.details.displayItems, + total: paymentRequest.details.total, + liveMode: !paymentToken.isEmulated + }; + + // If the payment token is microsoft emulated do not charge (as it will fail) + if (paymentToken.isEmulated) { + return Promise.resolve(paymentRecord); + } else { + // Charge using Stripe + var chargeOptions = { + amount: Math.floor(parseFloat(paymentRequest.details.total.amount.value) * 100), // Amount in cents + currency: paymentRequest.details.total.amount.currency, + description: paymentRequest.id, + source: paymentToken.source + }; + + return Stripe.charges + .create(chargeOptions) + .then((charge) => { + console.log('Strige.charge.result', charge); + if (charge.status === 'succeeded' && charge.captured) { + // Charge succeeded, return payment paymentRecord + // Ideally, you should register the transaction using the paymentRecord and charge.id + return paymentRecord; + } + + // Other statuses may include processing "pending" or "success" with non captured funds. It is up to the merchant how to handle these cases. + // If payment is captured but not charged this would be considered "unknown" (charge the captured amount after shipping scenario) + // Merchant might choose to handle "pending" and "failed" status or handle "success" status with funds captured null or false + // More information @ https://stripe.com/docs/api#charge_object-captured + throw new Error(util.format('Could not process charge using Stripe with charge.status: %s and charge.captured: %s', change.status, change.captured)); + }); + } + + } catch (err) { + return Promise.reject(err); + } +} + +module.exports = { + validateAndCalculateDetails: validateAndCalculateDetails, + processPayment: processPayment +}; + +// Available Shipping Methods +function getShippingMethodsForAddress(shippingAddress) { + console.log('getAvailableShippingMethods', shippingAddress.country); + + if (shippingAddress.country.toLowerCase() === 'zw') { + throw new Error('ZW country not supported'); + } + + if (shippingAddress.country.toLowerCase() === 'us') { + return [{ + id: 'STANDARD', + label: 'Standard - (5-6 Business days)', + amount: { currency: 'USD', value: '0.00' } + }, { + id: 'EXPEDITED', + label: 'Expedited - (2 Business days)', + amount: { currency: 'USD', value: '2.50' } + }]; + } else { + return [{ + id: 'INTERNATIONAL', + label: 'International', + amount: { currency: 'USD', value: '25.00' } + }]; + } +} + +function recalculateTaxAndTotal(details, shippingAddress, shippingOption) { + console.log('updateTotalWithShipping', details); + + // shipping price + if (shippingOption) { + var shippingItem = details.displayItems.find(i => i.label === 'Shipping'); + shippingItem.amount = shippingOption.amount; + shippingItem.pending = false; + } + + // tax + var subTotal = details.displayItems + .filter(i => i.label !== 'Sales Tax') + .reduce((a, b) => a + parseFloat(b.amount.value), 0); + var taxItem = details.displayItems.find(i => i.label === 'Sales Tax'); + + // only for US + var tax = shippingAddress.country.toLowerCase() === 'us' ? subTotal * 0.085 : 0; + taxItem.amount.value = tax.toFixed(2); + taxItem.pending = false; + + // new total + details.total.amount.value = details.displayItems + .reduce((a, b) => a + parseFloat(b.amount.value), 0) + .toFixed(2); +} + +function asDisplayItem(product) { + return { + label: product.name, + amount: { + currency: product.currency, + value: product.price.toFixed(2) + } + }; +} + +// helpers +function checkParam(param, name) { + if (param === undefined) { + throw new Error('Mising Parameter: \'' + name + '\''); + } +} + +function checkType(param, expectedType, name) { + if (!(param instanceof expectedType)) { + throw new Error('Expected type \'' + expectedType.name + '\' for parameter \'' + name + '\''); + } +} diff --git a/Node/sample-payments/package.json b/Node/sample-payments/package.json new file mode 100644 index 0000000000..67994d6923 --- /dev/null +++ b/Node/sample-payments/package.json @@ -0,0 +1,33 @@ +{ + "name": "botbuilder-sample-payments", + "version": "1.0.0", + "description": "Bot Builder Sample - Payments", + "scripts": { + "start": "node app.js" + }, + "author": "Microsoft Corp.", + "license": "MIT", + "keywords": [ + "botbuilder", + "bots", + "chatbots", + "botbuilder-samples" + ], + "bugs": { + "url": "https://github.com/Microsoft/BotBuilder-Samples/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/BotBuilder-Samples.git" + }, + "dependencies": { + "base64url": "^2.0.0", + "bluebird": "^3.5.0", + "botbuilder": "^3.8.0", + "dotenv-extended": "^1.0.4", + "lodash": "^4.17.4", + "restify": "^4.3.0", + "stripe": "^4.15.1", + "uuid": "^3.0.1" + } +} diff --git a/Node/sample-payments/payment-token.js b/Node/sample-payments/payment-token.js new file mode 100644 index 0000000000..28367336f5 --- /dev/null +++ b/Node/sample-payments/payment-token.js @@ -0,0 +1,42 @@ +var base64url = require('base64url'); + +var MsPayEmulatedStripeTokenSource = 'tok_18yWDMKVgMv7trmwyE21VqO'; + +function parse(tokenString) { + if (!tokenString) { + throw new Error('PaymentToken string expected'); + } + + var tokenParts = tokenString.split('.'); + if (tokenParts.length !== 3) { + throw new Error('Invalid PaymentToken'); + } + + var header = parseHeader(tokenParts[0]); + var source = parseSource(tokenParts[1]); + var signature = parseSignature(tokenParts[2]); + + return { + header, + source, + signature, + isEmulated: MsPayEmulatedStripeTokenSource === source + }; +} + +function parseHeader(headerString) { + var json = base64url.decode(headerString); + return JSON.parse(json); +} + +function parseSource(sourceString) { + return base64url.decode(sourceString); +} + +function parseSignature(signatureString) { + return base64url.toBuffer(signatureString); +} + +module.exports = { + parse: parse +}; \ No newline at end of file diff --git a/Node/sample-payments/payments.js b/Node/sample-payments/payments.js new file mode 100644 index 0000000000..920c441ed6 --- /dev/null +++ b/Node/sample-payments/payments.js @@ -0,0 +1,15 @@ +const Operations = { + UpdateShippingAddressOperation: 'payments/update/shippingAddress', + UpdateShippingOptionOperation: 'payments/update/shippingOption', + PaymentCompleteOperation: 'payments/complete' +}; + +const PaymentActionType = 'payment'; + +const MicrosoftPayMethodName = 'https://pay.microsoft.com/microsoftpay'; + +module.exports = { + Operations: Operations, + PaymentActionType: PaymentActionType, + MicrosoftPayMethodName: MicrosoftPayMethodName +}; \ No newline at end of file diff --git a/Node/sample-payments/services/catalog.js b/Node/sample-payments/services/catalog.js new file mode 100644 index 0000000000..598cb30b79 --- /dev/null +++ b/Node/sample-payments/services/catalog.js @@ -0,0 +1,24 @@ +var Promise = require('bluebird'); + +const catalogItems = [ + { + id: 'bc861179-46a5-4645-a249-7eba2a4d9846', + name: 'Scott Gu - Favorite Shirt', + description: 'Shiny red, ready to rock on Keynotes', + price: 1.99, + currency: 'USD', + imageUrl: 'https://pbs.twimg.com/profile_images/565139568/redshirt_400x400.jpg' + } +]; + +const catalog = { + + getItemById: (id) => Promise.resolve( + catalogItems.find((e) => e.id.toLowerCase() === id.toLowerCase())), + + getPromotedItem: () => Promise.resolve( + catalogItems[0]) + +}; + +module.exports = catalog; \ No newline at end of file