├── .github └── workflows │ └── publish-docker-image.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── apiConfiguration ├── openApi │ ├── address.yaml │ └── billing.yaml └── policies │ ├── apis │ ├── address-validate_address.xml │ ├── billing-get_monetization_models.xml │ └── billing-get_products.xml │ ├── global.xml │ └── products │ ├── basic.xml │ ├── developer.xml │ ├── enterprise.xml │ ├── free.xml │ ├── payg.xml │ ├── pro.xml │ └── standard.xml ├── app ├── .dockerignore ├── .env.sample ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── constants.ts │ ├── index.ts │ ├── public │ │ └── css │ │ │ └── main.css │ ├── routes │ │ ├── adyen.ts │ │ ├── apim-delegation.ts │ │ ├── index.ts │ │ └── stripe.ts │ ├── services │ │ ├── adyenBillingService.ts │ │ ├── apimService.ts │ │ ├── billingService.ts │ │ ├── monetizationService.ts │ │ └── stripeBillingService.ts │ ├── utils.ts │ └── views │ │ ├── cancel.ejs │ │ ├── checkout-adyen.ejs │ │ ├── checkout-stripe.ejs │ │ ├── fail.ejs │ │ ├── layout.ejs │ │ ├── pending.ejs │ │ ├── sign-in.ejs │ │ ├── sign-up.ejs │ │ ├── subscribe.ejs │ │ ├── success.ejs │ │ └── unsubscribe.ejs ├── tools │ └── copyAssets.ts ├── tsconfig.json └── tslint.json ├── build.ps1 ├── deploy.ps1 ├── documentation ├── advanced-steps.md ├── adyen-deploy.md ├── adyen-details.md ├── architecture-adyen.png ├── architecture-stripe.png ├── deployment-details.md ├── stripe-deploy.md └── stripe-details.md ├── output ├── main.json └── main.parameters.template.json ├── payment ├── monetizationModels.json ├── notes.md └── stripeInitialisation.ps1 └── templates ├── apim-instance.bicep ├── apimmonetization-apis-address.bicep ├── apimmonetization-apis-billing.bicep ├── apimmonetization-globalServicePolicy.bicep ├── apimmonetization-namedValues.bicep ├── apimmonetization-productAPIs.bicep ├── apimmonetization-productGroups.bicep ├── apimmonetization-products.bicep ├── app-service-settings.bicep ├── app-service.bicep └── main.bicep /.github/workflows/publish-docker-image.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | containerImageTag: 7 | description: Tag for the container image 8 | required: true 9 | default: 'latest' 10 | 11 | jobs: 12 | push_to_registry: 13 | name: Push Docker image to GitHub Packages 14 | runs-on: ubuntu-latest 15 | permissions: 16 | packages: write 17 | contents: read 18 | 19 | steps: 20 | - name: Check out the repo 21 | uses: actions/checkout@v2 22 | 23 | - name: Validate container image tag 24 | run: | 25 | # Add your validation logic here 26 | # You can use regular expressions or custom checks 27 | if [[ ! "${{ github.event.inputs.containerImageTag }}" =~ ^[a-zA-Z0-9.-]+$ ]]; then 28 | echo "Invalid container image tag" 29 | exit 1 30 | fi 31 | 32 | - name: Login to Registry 33 | uses: docker/login-action@v1 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.repository_owner }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Build and Push to GitHub Packages 40 | uses: docker/build-push-action@v2 41 | with: 42 | context: ./app 43 | file: ./app/Dockerfile 44 | push: true 45 | tags: ghcr.io/${{ github.repository }}/app:${{ github.event.inputs.containerImageTag }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | build/ 353 | /tools/ 354 | output/*.parameters.json 355 | .env 356 | /app/dist/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure API Management - Monetization 2 | 3 | ## Purpose and scope 4 | 5 | This is a **demo project** providing two working examples of how to integrate Azure API Management (APIM) with payment providers - one based on integration with [Stripe](https://stripe.com/), the other with [Adyen](https://www.adyen.com/). 6 | 7 | The objective is to show how you can enable consumers to discover an API that you wish to make public, enter their payment details, activate their subscription and trigger automated payment based on their usage of the API. 8 | 9 | To use this demo, you will need to deploy the solution into your own Azure subscription and to set up your own Stripe / Adyen account. This is **not** a managed service - you will be responsible for managing the resources that are deployed on Azure, adapting the solution to meet your specific use case and keeping the solution up to date. 10 | 11 | ### Table of contents 12 | 13 | | Document | Purpose 14 | |---------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| 15 | | [Monetization with Azure API Management](https://docs.microsoft.com/azure/api-management/monetization-overview) | Makes recommendations about how to design a successful monetization strategy for your API. | 16 | | [How API Management supports monetization](https://docs.microsoft.com/azure/api-management/monetization-support) | Provides an overview of the API Management features that can be used to accelerate and de-risk API monetization. | 17 | | [How to implement monetization with Azure API Management and Stripe](./documentation/stripe-details.md) | Describes how the Stripe integration has been implemented and the user flow through the solution. | 18 | | [Deploy demo with Stripe](./documentation/stripe-deploy.md) | End to end deployment steps to implement the demo project with Stripe as payment provider. | 19 | | [How to implement monetization with Azure API Management and Adyen](./documentation/adyen-details.md) | Describes how the Adyen integration has been implemented and the user flow through the solution. | 20 | | [Deploy demo with Adyen](./documentation/adyen-deploy.md) | End to end deployment steps to implement the demo project with Adyen as payment provider. | 21 | | [Deployment details](./documentation/deployment-details.md) | Details the resources that are deployed and the approach taken to script the deployment. | 22 | | [Advanced steps](./documentation/advanced-steps.md) | Details of advanced steps to modify the infrastructure templates and run the billing app locally. | 23 | 24 | ## Steps to follow 25 | 26 | Follow these steps to implement the demo project: 27 | 28 | 1. Read [Monetization with Azure API Management](https://docs.microsoft.com/azure/api-management/monetization-overview) to get background about designing a successful monetization strategy. 29 | 30 | 1. Read [How API Management supports monetization](https://docs.microsoft.com/azure/api-management/monetization-support) to understand how APIM supports implementation of a monetization strategy. 31 | 32 | 1. Choose the payment provider you want to implement - either [Stripe](https://stripe.com/) or [Adyen](https://www.adyen.com/). 33 | 34 | 1. Read the overview: either [How to implement monetization with Azure API Management and Stripe](./documentation/stripe-details.md) or [How to implement monetization with Azure API Management and Adyen](./documentation/adyen-details.md) to understand more about Stripe / Adyen, how they integrate with APIM, the architecture adopted and the consumer flow through the solution. 35 | 36 | 1. Follow the deployment instructions in either [Deploy demo with Stripe](./documentation/stripe-deploy.md) or [Deploy demo with Adyen](./documentation/adyen-deploy.md) to set up the pre-requisites, deploy the resources onto Azure and complete remaining steps post deployment to implement the demo project. 37 | 38 | 1. Reference [Deployment details](./documentation/deployment-details.md) to get more detail about the resources that are being deployed and how this has been scripted. 39 | 40 | 1. Reference [Advanced steps](./documentation/advanced-steps.md) if you want to modify the infrastructure templates or run the billing app locally. 41 | 42 | ## Architecture 43 | 44 | The following diagram illustrates the high level architecture this demo has adopted to integrate API Management with a payment provider, showing the components of the solution across APIM, the Billing App (both hosted on Azure) and the payment provider. It also highlights the major integration flows between components, including the interactions between the API Consumer (both developer and application) and the solution: 45 | 46 | ![Monetization with Stripe](documentation/architecture-stripe.png) 47 | 48 | ## Contributing 49 | 50 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 51 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 52 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 53 | 54 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 55 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 56 | provided by the bot. You will only need to do this once across all repos using our CLA. 57 | 58 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 59 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 60 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 61 | 62 | ## Trademarks 63 | 64 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft 65 | trademarks or logos is subject to and must follow 66 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). 67 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. 68 | Any use of third-party trademarks or logos are subject to those third-party's policies. 69 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # TODO: The maintainer of this repo has not yet edited this file 2 | 3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? 4 | 5 | - **No CSS support:** Fill out this template with information about how to file issues and get help. 6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/spot](https://aka.ms/spot). CSS will work with/help you to determine next steps. More details also available at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). 7 | - **Not sure?** Fill out a SPOT intake as though the answer were "Yes". CSS will help you decide. 8 | 9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.* 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 17 | feature request as a new Issue. 18 | 19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 22 | 23 | ## Microsoft Support Policy 24 | 25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 26 | -------------------------------------------------------------------------------- /apiConfiguration/openApi/address.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Address API 5 | license: 6 | name: Microsoft 7 | paths: 8 | '/address/validate': 9 | post: 10 | summary: Validate Address 11 | operationId: validate_address 12 | requestBody: 13 | content: 14 | application/json: 15 | schema: 16 | $ref: '#/components/schemas/addressValidateRequest' 17 | responses: 18 | '201': 19 | description: OK 20 | content: 21 | application/json: 22 | schema: 23 | $ref: '#/components/schemas/addressValidateResponse' 24 | components: 25 | schemas: 26 | addressValidateRequest: 27 | type: object 28 | properties: 29 | address: 30 | type: string 31 | example: "One Microsoft Way, Redmond, WA 98052, United States" 32 | addressValidateResponse: 33 | type: object 34 | properties: 35 | valid: 36 | type: boolean 37 | example: true -------------------------------------------------------------------------------- /apiConfiguration/openApi/billing.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Billing API 5 | license: 6 | name: Microsoft 7 | paths: 8 | '/monetizationModels': 9 | get: 10 | summary: Gets a list of monetization models for products 11 | operationId: get_monetization_models 12 | responses: 13 | '200': 14 | description: OK 15 | content: 16 | application/json: 17 | schema: 18 | type: object 19 | properties: 20 | result: 21 | type: array 22 | items: 23 | $ref: "#/components/schemas/monetizationModel" 24 | '/products': 25 | get: 26 | summary: Gets a list of APIM products 27 | operationId: get_products 28 | responses: 29 | '200': 30 | description: OK 31 | content: 32 | application/json: 33 | schema: 34 | type: object 35 | properties: 36 | value: 37 | type: array 38 | items: 39 | $ref: "#/components/schemas/product" 40 | components: 41 | schemas: 42 | product: 43 | type: object 44 | properties: 45 | name: 46 | type: string 47 | properties: 48 | type: object 49 | properties: 50 | displayName: 51 | type: string 52 | description: 53 | type: string 54 | additionalProperties: true 55 | additionalProperties: true 56 | 57 | monetizationModel: 58 | type: object 59 | properties: 60 | id: 61 | type: string 62 | example: standard 63 | pricingModelType: 64 | type: string 65 | enum: 66 | - Metered 67 | - UnitAndMeteredOverage 68 | - Unit 69 | example: UnitAndMeteredOverage 70 | recurring: 71 | type: object 72 | properties: 73 | interval: 74 | type: string 75 | enum: 76 | - day 77 | - month 78 | - year 79 | example: month 80 | intervalCount: 81 | type: integer 82 | example: 1 83 | prices: 84 | type: object 85 | properties: 86 | unit: 87 | type: object 88 | properties: 89 | currency: 90 | type: string 91 | example: usd 92 | unitAmount: 93 | type: number 94 | example: 89.95 95 | quota: 96 | type: integer 97 | example: 50000 98 | maxUnits: 99 | type: integer 100 | example: 1 101 | metered: 102 | type: object 103 | properties: 104 | currency: 105 | type: string 106 | example: usd 107 | unitAmount: 108 | type: number 109 | example: 0.00095 -------------------------------------------------------------------------------- /apiConfiguration/policies/apis/address-validate_address.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apiConfiguration/policies/apis/billing-get_monetization_models.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{monetizationModelsUrl}} 6 | GET 7 | 8 | 9 | 10 | 11 | application/json 12 | 13 | @(((IResponse)context.Variables["monetizationModels"]).Body.As()) 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /apiConfiguration/policies/apis/billing-get_products.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 2019-12-01 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /apiConfiguration/policies/global.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | https://{{apimServiceName}}.developer.azure-api.net 6 | https://{{apimServiceName}}.azure-api.net 7 | https://{{appServiceName}}.azurewebsites.net 8 | 9 | 10 | * 11 | 12 | 13 |
*
14 |
15 | 16 |
*
17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 |
-------------------------------------------------------------------------------- /apiConfiguration/policies/products/basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apiConfiguration/policies/products/developer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apiConfiguration/policies/products/enterprise.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apiConfiguration/policies/products/free.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apiConfiguration/policies/products/payg.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apiConfiguration/policies/products/pro.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apiConfiguration/policies/products/standard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | dist -------------------------------------------------------------------------------- /app/.env.sample: -------------------------------------------------------------------------------- 1 | ## App configuration ## 2 | 3 | # Set to production when deploying to production 4 | NODE_ENV=development 5 | 6 | # Node.js server configuration 7 | SERVER_PORT=8000 8 | 9 | 10 | ## APIM configuration ## 11 | 12 | # The name of the API Management instance, e.g apimpaymentproviderdemo 13 | APIM_SERVICE_NAME= 14 | 15 | # Subscription ID for the Azure subscription the API Management instance resides in 16 | APIM_SERVICE_AZURE_SUBSCRIPTION_ID= 17 | 18 | # Name of the resource group the API Management instance resides in 19 | APIM_SERVICE_AZURE_RESOURCE_GROUP_NAME= 20 | 21 | # The management URL for the API Management instance, e.g. https://apimpaymentproviderdemo.management.azure-api.net 22 | APIM_MANAGEMENT_URL= 23 | 24 | # The gateway URL for the API Management instance, e.g. https://apimpaymentproviderdemo.azure-api.net 25 | APIM_GATEWAY_URL= 26 | 27 | # The developer URL for the API Management instance, e.g. https://apimpaymentproviderdemo.developer.azure-api.net 28 | APIM_DEVELOPER_PORTAL_URL= 29 | 30 | # The built-in all-access subscription key for the API Management instance 31 | APIM_ADMIN_SUBSCRIPTION_KEY= 32 | 33 | # The delegation validation key for the API Management instance 34 | APIM_DELEGATION_VALIDATION_KEY= 35 | 36 | 37 | 38 | ## Payment configuration ## 39 | 40 | PAYMENT_PROVIDER=Stripe 41 | 42 | 43 | ## Stripe configuration ## 44 | 45 | # Value of Stripe 'Publishable key' from Stripe standard keys 46 | STRIPE_PUBLIC_KEY= 47 | 48 | # Value of Stripe 'App Key' API key created as part of pre-requisites 49 | STRIPE_API_KEY= 50 | 51 | # Value of the signing secret for the Stripe webhook created as part of the stripeInitialisation.ps1 script 52 | STRIPE_WEBHOOK_SECRET= 53 | 54 | 55 | ## Adyen configuration ## 56 | 57 | # Value of Adyen API key retrieved as part of pre-requisites 58 | ADYEN_API_KEY= 59 | 60 | # Adyen merchant account name 61 | ADYEN_MERCHANT_ACCOUNT= 62 | 63 | # Value of Adyen client key retrieved as part of pre-requisites 64 | ADYEN_CLIENT_KEY= 65 | 66 | 67 | ## Azure AD Service Principal configuration ## 68 | 69 | # The app ID of the service principal created as part of pre-requisites 70 | AZURE_AD_SERVICE_PRINCIPAL_APP_ID= 71 | 72 | # The password for the service principal created as part of pre-requisites 73 | AZURE_AD_SERVICE_PRINCIPAL_PASSWORD= 74 | 75 | # The ID of the tenant that the service principal created as part of pre-requisites resides in 76 | AZURE_AD_SERVICE_PRINCIPAL_TENANT_ID= 77 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/azurelinux/base/nodejs:20 2 | WORKDIR /usr/app 3 | COPY package*.json ./ 4 | RUN npm install 5 | COPY . . 6 | RUN npm run build 7 | EXPOSE 8000 8 | CMD [ "node", "dist/index.js" ] -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portal", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "clean": "rimraf dist/*", 8 | "copy-assets": "ts-node tools/copyAssets", 9 | "lint": "tslint -c tslint.json -p tsconfig.json --fix", 10 | "tsc": "tsc", 11 | "build": "npm-run-all clean lint tsc copy-assets", 12 | "dev:start": "npm-run-all build start", 13 | "dev": "nodemon --watch src -e ts,ejs --exec npm run dev:start", 14 | "start": "node .", 15 | "test": "echo \"Error: no test specified\" && exit 1" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@adyen/api-library": "^7.0.0", 22 | "@azure/arm-apimanagement": "^7.2.0", 23 | "@azure/identity": "^4.0.0", 24 | "cron": "^1.8.2", 25 | "dotenv": "^8.6.0", 26 | "ejs": "^3.1.9", 27 | "express": "^4.18.2", 28 | "express-ejs-layouts": "^2.5.1", 29 | "js-guid": "^1.0.2", 30 | "node-fetch": "^2.7.0", 31 | "semver": "^7.6.3", 32 | "stripe": "^8.222.0" 33 | }, 34 | "devDependencies": { 35 | "@types/cron": "^1.7.3", 36 | "@types/dotenv": "^8.2.0", 37 | "@types/express": "^4.17.21", 38 | "@types/express-ejs-layouts": "^2.5.4", 39 | "@types/fs-extra": "^9.0.13", 40 | "@types/node": "^14.18.63", 41 | "@types/node-fetch": "^2.6.10", 42 | "@types/shelljs": "^0.8.15", 43 | "fs-extra": "^9.1.0", 44 | "nodemon": "^3.1.7", 45 | "npm-run-all": "^4.1.5", 46 | "rimraf": "^3.0.2", 47 | "shelljs": "^0.8.5", 48 | "ts-node": "^10.9.2", 49 | "tslint": "^6.1.3", 50 | "typescript": "^4.9.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ApimSubscriptionIdKey = "apim-subscription-id"; 2 | export const ApimProductIdKey = "apim-product-id"; 3 | export const ApimUserIdKey = "apim-user-id"; 4 | export const ApimSubscriptionNameKey = "apim-subscription-name"; 5 | export const LastUsageUpdateKey = "last-usage-update"; -------------------------------------------------------------------------------- /app/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import expressLayouts from "express-ejs-layouts"; 3 | import path from "path"; 4 | import dotenv from "dotenv"; 5 | import { CronJob } from "cron"; 6 | import * as routes from "./routes/index"; 7 | import { AdyenBillingService, Invoice } from "./services/adyenBillingService"; 8 | import { StripeBillingService } from "./services/stripeBillingService"; 9 | 10 | dotenv.config(); 11 | 12 | const app = express(); 13 | const paymentProvider = process.env.PAYMENT_PROVIDER 14 | 15 | app.use(express.urlencoded({ extended: true })); 16 | 17 | // Use JSON parser for all non-webhook routes 18 | app.use( 19 | ( 20 | req: express.Request, 21 | res: express.Response, 22 | next: express.NextFunction 23 | ): void => { 24 | if (req.originalUrl.startsWith('/webhook/')) { 25 | next(); 26 | } else { 27 | express.json()(req, res, next); 28 | } 29 | } 30 | ); 31 | 32 | const port = process.env.SERVER_PORT || 8080; 33 | 34 | app.use(expressLayouts) 35 | app.set("views", path.join(__dirname, "views")); 36 | app.set('layout', './layout'); 37 | app.set("view engine", "ejs"); 38 | 39 | app.use(express.static(path.join(__dirname, "public"))); 40 | 41 | routes.register(app); 42 | 43 | if (paymentProvider === "Stripe") { 44 | // Run 'report usage' function every day at midnight 45 | const job = new CronJob( 46 | '0 0 * * *', 47 | async () => { 48 | await StripeBillingService.reportUsageToStripe(); 49 | }, 50 | null, 51 | true, 52 | 'Europe/London' 53 | ); 54 | } 55 | 56 | if (paymentProvider === "Adyen") { 57 | // Run monthly billing for all subscriptions 58 | const job = new CronJob( 59 | '0 0 1 * *', 60 | async () => { 61 | const now = new Date(Date.now()); 62 | const invoices : Invoice[] = await AdyenBillingService.calculateInvoices(now.getMonth(), now.getFullYear()); 63 | 64 | await Promise.all(invoices.map(async invoice => { 65 | await AdyenBillingService.takePaymentFromUser(invoice); 66 | })) 67 | }, 68 | null, 69 | true, 70 | 'Europe/London' 71 | ); 72 | } 73 | 74 | app.listen(port, () => { 75 | // tslint:disable-next-line:no-console 76 | console.log(`server started at http://localhost:${port}`); 77 | }); -------------------------------------------------------------------------------- /app/src/public/css/main.css: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | html, 8 | body { 9 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Ubuntu, sans-serif; 10 | color: rgba(26,26,26,.9); 11 | background: #fff; 12 | -webkit-font-smoothing: antialiased; 13 | -webkit-box-direction: normal; 14 | } 15 | 16 | body { 17 | margin: 0; 18 | padding: 2rem; 19 | } 20 | 21 | h1 { 22 | line-height: 1.1; 23 | color: rgba(26, 26, 26, 0.9); 24 | box-sizing: border-box; 25 | font-weight: 600; 26 | font-variant-numeric: tabular-nums; 27 | letter-spacing: -0.03rem; 28 | margin: 0 0 10px; 29 | font-size: 36px; 30 | } 31 | 32 | /* Forms */ 33 | 34 | input[type='text'], 35 | input[type='password'], 36 | input[type='email'] { 37 | box-sizing: border-box; 38 | font-family: inherit; 39 | margin: 0; 40 | margin-bottom: 15px; 41 | overflow: visible; 42 | position: relative; 43 | width: 100%; 44 | padding: 8px 12px; 45 | color: rgba(26, 26, 26, 0.9); 46 | line-height: 1.5; 47 | border: 0; 48 | box-shadow: 0 0 0 1px #e0e0e0, 0 2px 4px 0 rgba(0, 0, 0, 0.07), 0 1px 1.5px 0 rgba(0, 0, 0, 0.05); 49 | transition: box-shadow 0.08s ease-in, color 0.08s ease-in, filter 50000s, -webkit-filter 50000s; 50 | background: #fff; 51 | appearance: none; 52 | height: 36px; 53 | font-size: 14px; 54 | border-radius: 6px; 55 | } 56 | @media only screen and (min-width: 640px) { 57 | input[type='text'], 58 | input[type='password'], 59 | input[type='email'] { 60 | width: auto; 61 | min-width: 300px; 62 | } 63 | } 64 | 65 | input[type='text']:focus, 66 | input[type='password']:focus, 67 | input[type='email']:focus { 68 | outline: none; 69 | box-shadow: 0 0 0 1px rgb(50 151 211 / 30%), 0 1px 1px 0 rgb(0 0 0 / 7%), 0 0 0 4px rgb(50 151 211 / 30%); 70 | } 71 | 72 | label { 73 | display: block; 74 | margin-top: 15px; 75 | margin-bottom: 5px; 76 | font-weight: 500; 77 | font-size: 13px; 78 | color: rgba(26, 26, 26, 0.7); 79 | } 80 | 81 | button, 82 | input[type='submit'] { 83 | display: block; 84 | font-family: inherit; 85 | font-size: 100%; 86 | line-height: 1.15; 87 | margin: 0; 88 | background-color: rgb(0, 116, 212); 89 | color: rgb(255, 255, 255); 90 | height: 44px; 91 | width: 100%; 92 | padding: 0 25px; 93 | color: #fff; 94 | box-shadow: inset 0 0 0 1px rgb(50 50 93 / 10%), 0 2px 5px 0 rgb(50 50 93 / 10%), 0 1px 1px 0 rgb(0 0 0 / 7%); 95 | border: 0; 96 | outline: none; 97 | border-radius: 6px; 98 | cursor: pointer; 99 | transition: all 0.2s ease, box-shadow 0.08s ease-in; 100 | } 101 | 102 | button:hover, 103 | input[type='submit']:hover { 104 | background-color: rgb(0, 116, 212); 105 | box-shadow: inset 0 0 0 1px rgb(50 50 93 / 10%), 0 6px 15px 0 rgb(50 50 93 / 20%), 0 2px 2px 0 rgb(0 0 0 / 10%); 106 | } 107 | 108 | @media only screen and (min-width: 640px) { 109 | button, 110 | input[type='submit'] { 111 | width: auto; 112 | } 113 | } 114 | 115 | /* Tables */ 116 | 117 | table { 118 | width: 100%; 119 | } 120 | @media only screen and (min-width: 640px) { 121 | table { 122 | width: auto; 123 | min-width: 300px; 124 | } 125 | } 126 | 127 | table th { 128 | padding: 5px 10px; 129 | font-weight: 500; 130 | text-align: left; 131 | color: rgba(26, 26, 26, 0.6); 132 | } 133 | table td { 134 | padding: 5px 10px; 135 | text-align: left; 136 | border-bottom: 1px solid rgba(26, 26, 26, 0.1); 137 | } 138 | 139 | table tr th:first-of-type, 140 | table tr td:first-of-type { 141 | padding-left: 0; 142 | } 143 | table tr th:last-of-type, 144 | table tr td:last-of-type { 145 | padding-right: 0; 146 | } -------------------------------------------------------------------------------- /app/src/routes/adyen.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { ApimService } from "../services/apimService"; 3 | import { env } from "shelljs"; 4 | import { AdyenBillingService } from "../services/adyenBillingService"; 5 | import { Guid } from "js-guid"; 6 | 7 | export const register = (app: express.Application) => { 8 | 9 | /** Retrieve the available payment methods from Adyen for the merchant. */ 10 | app.post("/api/getPaymentMethods", async (req, res) => { 11 | try { 12 | const checkout = AdyenBillingService.GetAdyenCheckout(); 13 | const response = await checkout.paymentMethods({ 14 | channel: "Web", 15 | merchantAccount: env.ADYEN_MERCHANT_ACCOUNT, 16 | }); 17 | res.json(response); 18 | } catch (err) { 19 | // tslint:disable-next-line:no-console 20 | console.error(`Error: ${err.message}, error code: ${err.errorCode}`); 21 | res.status(err.statusCode).json(err.message); 22 | } 23 | }); 24 | 25 | /** Save a user's card details for future payments */ 26 | app.post("/api/initiatePayment", async (req, res) => { 27 | try { 28 | const checkout = AdyenBillingService.GetAdyenCheckout(); 29 | 30 | // Use the Adyen checkout API to create a 0-amount payment, and save the consumer's card details 31 | const response = await checkout.payments({ 32 | amount: { currency: "USD", value: 0 }, 33 | storePaymentMethod: true, 34 | shopperInteraction: "Ecommerce", 35 | recurringProcessingModel: "CardOnFile", 36 | reference: `${req.body.userId}_${req.body.productId}_${req.body.subscriptionName}`, 37 | merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT, 38 | returnUrl: req.body.returnUrl, 39 | paymentMethod: req.body.paymentMethod, 40 | shopperReference: req.body.userId 41 | }); 42 | 43 | const apimUserId = req.body.userId; 44 | const apimProductId = req.body.productId; 45 | const apimSubscriptionName = req.body.subscriptionName; 46 | const apimService = new ApimService(); 47 | 48 | // Create a new APIM subscription for the user 49 | await apimService.createSubscription(Guid.newGuid().toString(), apimUserId, apimProductId, apimSubscriptionName); 50 | 51 | res.json(response); 52 | } catch (err) { 53 | // tslint:disable-next-line:no-console 54 | console.error(`Error: ${err.message}, error code: ${err.errorCode}`); 55 | res.status(err.statusCode).json(err.message); 56 | } 57 | }); 58 | 59 | /** Used to submit additional payment details */ 60 | app.post("/api/submitAdditionalDetails", async (req, res) => { 61 | // Create the payload for submitting payment details 62 | const payload = { 63 | details: req.body.details, 64 | paymentData: req.body.paymentData, 65 | }; 66 | 67 | try { 68 | const checkout = AdyenBillingService.GetAdyenCheckout(); 69 | // Return the response back to client 70 | // (for further action handling or presenting result to shopper) 71 | const response = await checkout.paymentsDetails(payload); 72 | 73 | res.json(response); 74 | } catch (err) { 75 | // tslint:disable-next-line:no-console 76 | console.error(`Error: ${err.message}, error code: ${err.errorCode}`); 77 | res.status(err.statusCode).json(err.message); 78 | } 79 | }); 80 | }; -------------------------------------------------------------------------------- /app/src/routes/apim-delegation.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import crypto from "crypto"; 3 | import querystring from "querystring"; 4 | import { ApimService } from "../services/apimService"; 5 | import { BillingService } from "../services/billingService"; 6 | 7 | export const register = (app: express.Application, billingService: BillingService) => { 8 | const apimService = new ApimService(); 9 | 10 | /** Delegated sign up/in, sign out and subscription creation for APIM */ 11 | app.get("/apim-delegation", async (req, res) => { 12 | const operation = req.query.operation as string; 13 | const errorMessage = req.query.errorMessage as string; 14 | 15 | let isValid: boolean; 16 | switch (operation) { 17 | case "SignUp": 18 | case "SignIn": 19 | const signUpSignInRequest: SignUpSignInRequest = { 20 | operation, 21 | returnUrl: req.query.returnUrl as string, 22 | salt: req.query.salt as string, 23 | sig: req.query.sig as string, 24 | } 25 | 26 | isValid = validateSignUpSignInRequest(signUpSignInRequest); 27 | 28 | if (!isValid) { 29 | res.status(401); 30 | return; 31 | } 32 | 33 | if (operation === "SignIn"){ 34 | res.render("sign-in", { title: 'Sign in', signUpSignInRequest, errorMessage }); 35 | } else { 36 | res.render("sign-up", { title: 'Sign up', signUpSignInRequest, errorMessage }); 37 | } 38 | break; 39 | case "SignOut": 40 | const returnUrl = req.query.returnUrl as string; 41 | const redirectUrl = process.env.APIM_DEVELOPER_PORTAL_URL + returnUrl; 42 | res.redirect(redirectUrl); 43 | break; 44 | case "Subscribe": 45 | const subscribeRequest: SubscriptionRequest = { 46 | operation, 47 | productId: req.query.productId as string, 48 | userId: req.query.userId as string, 49 | salt: req.query.salt as string, 50 | sig: req.query.sig as string, 51 | }; 52 | 53 | isValid = validateSubscribeRequest(subscribeRequest); 54 | 55 | if (!isValid) { 56 | res.status(401); 57 | return; 58 | } 59 | 60 | const product = await apimService.getProduct(subscribeRequest.productId); 61 | 62 | res.render("subscribe", { subscribeRequest, product, title: "Subscribe" }); 63 | break; 64 | case "Unsubscribe": 65 | const unsubscribeRequest: SubscriptionRequest = { 66 | operation, 67 | subscriptionId: req.query.subscriptionId as string, 68 | salt: req.query.salt as string, 69 | sig: req.query.sig as string, 70 | }; 71 | 72 | isValid = validateUnsubscribeRequest(unsubscribeRequest); 73 | 74 | if (!isValid) { 75 | res.status(401); 76 | return; 77 | } 78 | 79 | await apimService.updateSubscriptionState(unsubscribeRequest.subscriptionId, "cancelled"); 80 | await billingService.unsubscribe(unsubscribeRequest.subscriptionId); 81 | 82 | res.render("unsubscribe", { unsubscribeRequest, title: "Unsubscribe" }); 83 | case "ChangePassword": 84 | case "ChangeProfile": 85 | case "CloseAccount": 86 | case "Renew": 87 | // Not implemented 88 | res.status(501); 89 | break; 90 | default: 91 | res.status(400); 92 | break; 93 | } 94 | }); 95 | 96 | /** Create a subscription for the user. This includes validating the request, retrieving the user, and redirecting them to checkout. On successful checkout, the subscription will be created. */ 97 | app.post("/subscribe", async (req, res) => { 98 | const subscribeRequest: SubscriptionRequest = { 99 | operation: req.body.operation as string, 100 | productId: req.body.productId as string, 101 | userId: req.body.userId as string, 102 | salt: req.body.salt as string, 103 | sig: req.body.sig as string, 104 | }; 105 | 106 | const isValid = validateSubscribeRequest(subscribeRequest); 107 | 108 | if (!isValid) { 109 | res.status(401); 110 | return; 111 | } 112 | 113 | const subscriptionName = req.body.subscriptionName as string; 114 | 115 | const user = await apimService.getUser(subscribeRequest.userId); 116 | 117 | const redirectQuery = querystring.stringify({ 118 | operation: subscribeRequest.operation, 119 | userId: subscribeRequest.userId, 120 | productId: subscribeRequest.productId, 121 | subscriptionName, 122 | salt: subscribeRequest.salt, 123 | sig: subscribeRequest.sig, 124 | userEmail: user.email 125 | }); 126 | 127 | res.redirect("/checkout?" + redirectQuery); 128 | }); 129 | 130 | /** Checkout, using redirect to specific payment provider view */ 131 | app.get("/checkout", async (req, res) => { 132 | const operation = req.query.operation as string; 133 | const userId = req.query.userId as string; 134 | const productId = req.query.productId as string; 135 | const subscriptionName = req.query.subscriptionName as string; 136 | const salt = req.query.salt as string; 137 | const sig = req.query.sig as string; 138 | const userEmail = req.query.userEmail as string; 139 | 140 | const subscribeRequest: SubscriptionRequest = { 141 | operation, 142 | productId, 143 | userId, 144 | salt, 145 | sig 146 | } 147 | 148 | const isValid = validateSubscribeRequest(subscribeRequest); 149 | 150 | if (!isValid) { 151 | res.status(401); 152 | return; 153 | } 154 | 155 | res.render(`checkout-${process.env.PAYMENT_PROVIDER.toLowerCase()}`, { subscribeRequest, subscriptionName, userEmail, title: "Checkout" }); 156 | }); 157 | 158 | /** Sign in user using APIM authentication service */ 159 | app.post("/signIn", async (req, res) => { 160 | const email = req.body.email as string; 161 | const password = req.body.password as string; 162 | 163 | const signUpSignInRequest: SignUpSignInRequest = { 164 | operation: req.body.operation as string, 165 | returnUrl: req.body.returnUrl as string, 166 | salt: req.body.salt as string, 167 | sig: req.body.sig as string, 168 | } 169 | 170 | const { authenticated, userId } = await ApimService.authenticateUser(email, password); 171 | 172 | if (!authenticated) { 173 | const query = querystring.stringify({ 174 | errorMessage: "Invalid credentials", 175 | returnUrl: signUpSignInRequest.returnUrl, 176 | operation: signUpSignInRequest.operation, 177 | salt: signUpSignInRequest.salt, 178 | sig: signUpSignInRequest.sig 179 | }); 180 | res.redirect('/apim-delegation?' + query); 181 | return; 182 | } 183 | 184 | const { value: token } = await apimService.getSharedAccessToken(userId); 185 | 186 | const redirectQuery = querystring.stringify({ 187 | token, 188 | returnUrl: signUpSignInRequest.returnUrl 189 | }); 190 | 191 | const redirectUrl = process.env.APIM_DEVELOPER_PORTAL_URL + "/signin-sso?" + redirectQuery; 192 | 193 | res.redirect(redirectUrl); 194 | }); 195 | 196 | /** Sign up user by creating a new user via the APIM service */ 197 | app.post("/signUp", async (req, res) => { 198 | const email = req.body.email as string; 199 | const password = req.body.password as string; 200 | const firstName = req.body.firstName as string; 201 | const lastName = req.body.lastName as string; 202 | 203 | const signUpSignInRequest: SignUpSignInRequest = { 204 | operation: req.body.operation as string, 205 | returnUrl: req.body.returnUrl as string, 206 | salt: req.body.salt as string, 207 | sig: req.body.sig as string, 208 | } 209 | 210 | try { 211 | await apimService.createUser(email, password, firstName, lastName); 212 | } 213 | catch (error) { 214 | const query = querystring.stringify({ 215 | errorMessage: "Invalid credentials", 216 | returnUrl: signUpSignInRequest.returnUrl, 217 | operation: signUpSignInRequest.operation, 218 | salt: signUpSignInRequest.salt, 219 | sig: signUpSignInRequest.sig 220 | }); 221 | res.redirect('/apim-delegation?' + query); 222 | return; 223 | } 224 | 225 | const { userId } = await ApimService.authenticateUser(email, password); 226 | const { value: token } = await apimService.getSharedAccessToken(userId); 227 | 228 | const redirectQuery = querystring.stringify({ 229 | token, 230 | returnUrl: signUpSignInRequest.returnUrl 231 | }) 232 | 233 | const redirectUrl = process.env.APIM_DEVELOPER_PORTAL_URL + "/signin-sso?" + redirectQuery; 234 | 235 | res.redirect(redirectUrl); 236 | }); 237 | 238 | app.get("/success", (req, res) => { 239 | res.render("success", { title: 'Payment Succeeded' }); 240 | }); 241 | 242 | app.get("/fail", (req, res) => { 243 | const subscribeRequest: SubscriptionRequest = { 244 | operation: req.query.operation as string, 245 | productId: req.query.productId as string, 246 | userId: req.query.userId as string, 247 | salt: req.query.salt as string, 248 | sig: req.query.sig as string, 249 | }; 250 | 251 | const subscriptionName = req.query.subscriptionName as string; 252 | const userEmail = req.query.userEmail as string; 253 | 254 | const checkoutQuery = querystring.stringify({ 255 | operation: subscribeRequest.operation, 256 | userId: subscribeRequest.userId, 257 | productId: subscribeRequest.productId, 258 | salt: subscribeRequest.salt, 259 | sig: subscribeRequest.sig, 260 | subscriptionName, 261 | userEmail 262 | }); 263 | 264 | const checkoutUrl = "/checkout?" + checkoutQuery; 265 | 266 | res.render("fail", { title: 'Payment Failed', checkoutUrl }); 267 | }); 268 | 269 | /** Checkout cancelled, redirect to payment cancelled view */ 270 | app.get("/cancel", (req, res) => { 271 | const subscribeRequest: SubscriptionRequest = { 272 | operation: req.query.operation as string, 273 | productId: req.query.productId as string, 274 | userId: req.query.userId as string, 275 | salt: req.query.salt as string, 276 | sig: req.query.sig as string, 277 | }; 278 | 279 | const subscriptionName = req.query.subscriptionName as string; 280 | const userEmail = req.query.userEmail as string; 281 | 282 | const checkoutQuery = querystring.stringify({ 283 | operation: subscribeRequest.operation, 284 | userId: subscribeRequest.userId, 285 | productId: subscribeRequest.productId, 286 | salt: subscribeRequest.salt, 287 | sig: subscribeRequest.sig, 288 | subscriptionName, 289 | userEmail 290 | }); 291 | 292 | const checkoutUrl = "/checkout?" + checkoutQuery; 293 | 294 | res.render("cancel", { title: 'Payment Cancelled', checkoutUrl }); 295 | }); 296 | } 297 | 298 | function validateSignUpSignInRequest(signUpSignInRequest: SignUpSignInRequest): boolean { 299 | return validateRequest([signUpSignInRequest.salt, signUpSignInRequest.returnUrl], signUpSignInRequest.sig) 300 | } 301 | 302 | function validateSubscribeRequest(subscriptionRequest: SubscriptionRequest): boolean { 303 | return validateRequest([subscriptionRequest.salt, subscriptionRequest.productId, subscriptionRequest.userId], subscriptionRequest.sig) 304 | } 305 | 306 | function validateUnsubscribeRequest(subscriptionRequest: SubscriptionRequest): boolean { 307 | return validateRequest([subscriptionRequest.salt, subscriptionRequest.subscriptionId], subscriptionRequest.sig) 308 | } 309 | 310 | function validateRequest(queryParams: string[], expectedSignature: string): boolean { 311 | const key = process.env.APIM_DELEGATION_VALIDATION_KEY; 312 | const hmac = crypto.createHmac('sha512', Buffer.from(key, 'base64')); 313 | 314 | const input = queryParams.join('\n'); 315 | const digest = hmac.update(input).digest(); 316 | 317 | const calculatedSignature = digest.toString('base64'); 318 | 319 | return calculatedSignature === expectedSignature; 320 | } 321 | 322 | interface SignUpSignInRequest { 323 | operation: string; 324 | returnUrl: string; 325 | salt: string; 326 | sig: string; 327 | } 328 | 329 | interface SubscriptionRequest { 330 | operation: string; 331 | productId?: string; 332 | subscriptionId?: string; 333 | userId?: string; 334 | salt: string; 335 | sig: string; 336 | } -------------------------------------------------------------------------------- /app/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import * as apimDelegationRoutes from "./apim-delegation"; 3 | import * as stripeRoutes from "./stripe"; 4 | import * as adyenRoutes from "./adyen"; 5 | import { BillingService } from "../services/billingService"; 6 | import { AdyenBillingService } from "../services/adyenBillingService"; 7 | import { StripeBillingService } from "../services/stripeBillingService"; 8 | 9 | export const register = (app: express.Application) => { 10 | 11 | const paymentProvider = process.env.PAYMENT_PROVIDER 12 | 13 | let billingService: BillingService; 14 | 15 | // Register the adyen specific routes 16 | if (paymentProvider === "Adyen") { 17 | adyenRoutes.register(app); 18 | billingService = new AdyenBillingService(); 19 | } 20 | 21 | // Register the Stripe specific routes 22 | if (paymentProvider === "Stripe") { 23 | stripeRoutes.register(app); 24 | billingService = new StripeBillingService(); 25 | } 26 | 27 | apimDelegationRoutes.register(app, billingService); 28 | }; -------------------------------------------------------------------------------- /app/src/routes/stripe.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { ApimProductIdKey, ApimSubscriptionIdKey, ApimSubscriptionNameKey, ApimUserIdKey } from "../constants" 3 | import Stripe from 'stripe'; 4 | import { ApimService } from "../services/apimService"; 5 | import querystring from "querystring"; 6 | import { MonetizationService } from "../services/monetizationService"; 7 | 8 | export const register = (app: express.Application) => { 9 | 10 | /** Create a new Stripe checkout session for the user */ 11 | app.post(`/api/checkout/session`, async (req: any, res) => { 12 | const userEmail = req.body.userEmail; 13 | const apimUserId = req.body.apimUserId; 14 | const apimProductId = req.body.apimProductId; 15 | const apimSubscriptionName = req.body.apimSubscriptionName; 16 | const returnUrlBase = req.body.returnUrlBase 17 | const salt = req.body.salt 18 | const sig = req.body.sig 19 | const operation = req.body.operation 20 | 21 | // Get the APIM products 22 | const apimService = new ApimService(); 23 | const apimProduct = await apimService.getProduct(apimProductId); 24 | 25 | // Get the monetization model relating to that product 26 | const monetizationModel = await MonetizationService.getMonetizationModelFromProduct(apimProduct); 27 | 28 | const stripe = new Stripe(process.env.STRIPE_API_KEY, { 29 | apiVersion: '2020-08-27', 30 | }); 31 | 32 | 33 | // List the prices for that product in Stripe 34 | const prices = (await stripe.prices.list({ product: apimProduct.name, active: true })).data; 35 | 36 | const pricingModelType = monetizationModel.pricingModelType; 37 | 38 | const cancelUrlQuery = querystring.stringify({ 39 | operation, 40 | userId: apimUserId, 41 | productId: apimProductId, 42 | subscriptionName: apimSubscriptionName, 43 | salt, 44 | sig, 45 | userEmail 46 | }); 47 | 48 | const cancelUrl = returnUrlBase + "/cancel?" + cancelUrlQuery; 49 | const successUrl = returnUrlBase + "/success"; 50 | 51 | const session: Stripe.Checkout.Session = await createCheckoutSession(userEmail, cancelUrl, successUrl, apimUserId, apimProductId, apimSubscriptionName, prices[0], pricingModelType, stripe); 52 | 53 | res.json({ id: session.id }); 54 | }); 55 | 56 | /** Listens for Stripe events for successful subscription creation, update or deletion */ 57 | app.post( 58 | '/webhook/stripe', 59 | // Stripe requires the raw body to construct the event 60 | express.raw({ type: 'application/json' }), 61 | async (req: express.Request, res: express.Response): Promise => { 62 | const stripe = new Stripe(process.env.STRIPE_API_KEY, { 63 | apiVersion: '2020-08-27', 64 | }); 65 | 66 | const sig = req.headers['stripe-signature']; 67 | 68 | let event: Stripe.Event; 69 | 70 | try { 71 | event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET); 72 | } catch (err) { 73 | // On error, log and return the error message 74 | // tslint:disable-next-line:no-console 75 | console.log(`Error message: ${err.message}`); 76 | res.status(400).send(`Webhook Error: ${err.message}`); 77 | return; 78 | } 79 | 80 | // Successfully constructed event 81 | // tslint:disable-next-line:no-console 82 | console.log('✅ Success:', event.id); 83 | 84 | let stripeSubscription : Stripe.Subscription; 85 | 86 | switch (event.type) { 87 | case 'customer.subscription.created': 88 | // Create a new APIM subscription for the user when checkout is successful 89 | stripeSubscription = event.data.object as Stripe.Subscription; 90 | 91 | const apimProductId = stripeSubscription.metadata[ApimProductIdKey]; 92 | const apimUserId = stripeSubscription.metadata[ApimUserIdKey]; 93 | const apimSubscriptionName = stripeSubscription.metadata[ApimSubscriptionNameKey]; 94 | 95 | const createSubscriptionResponse = await createApimSubscription(stripeSubscription.id, apimUserId, apimProductId, apimSubscriptionName); 96 | 97 | // Add APIM subscription ID to Stripe subscription metadata 98 | stripe.subscriptions.update(stripeSubscription.id, { metadata: {[ApimSubscriptionIdKey]: createSubscriptionResponse.name }}) 99 | 100 | break; 101 | 102 | case 'customer.subscription.updated': 103 | case 'customer.subscription.deleted': 104 | // Deactivate APIM subscription if Stripe subscription is updated / deleted (as payment may no longer be valid) 105 | stripeSubscription = event.data.object as Stripe.Subscription; 106 | 107 | if (stripeSubscription.status === 'canceled' || stripeSubscription.status === 'unpaid'){ 108 | const apimSubscriptionId = stripeSubscription.metadata[ApimSubscriptionIdKey]; 109 | 110 | await deactivateApimSubscription(apimSubscriptionId); 111 | } 112 | 113 | break; 114 | 115 | default: 116 | // tslint:disable-next-line:no-console 117 | console.warn(`Unhandled event type: ${event.type}`); 118 | break; 119 | } 120 | 121 | // Return a response to acknowledge receipt of the event 122 | res.json({ received: true }); 123 | } 124 | ); 125 | }; 126 | 127 | async function createCheckoutSession( 128 | userEmail: string, 129 | cancelUrl: string, 130 | successUrl: string, 131 | apimUserId: string, 132 | apimProductId: string, 133 | apimSubscriptionName: string, 134 | price: Stripe.Price, 135 | pricingModelType: string, 136 | stripe: Stripe 137 | ) { 138 | let session: Stripe.Checkout.Session; 139 | 140 | const createSessionParameters: Stripe.Checkout.SessionCreateParams = { 141 | cancel_url: cancelUrl, 142 | success_url: successUrl, 143 | payment_method_types: ["card"], 144 | mode: 'subscription', 145 | customer_email: userEmail, 146 | subscription_data: { 147 | metadata: { 148 | [ApimUserIdKey]: apimUserId, 149 | [ApimProductIdKey]: apimProductId, 150 | [ApimSubscriptionNameKey]: apimSubscriptionName 151 | } 152 | } 153 | }; 154 | 155 | let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; 156 | 157 | switch (pricingModelType) { 158 | case "Tier": 159 | lineItems = [ 160 | { price: price.id, quantity: 1 } 161 | ]; 162 | break; 163 | default: 164 | lineItems = [ 165 | { price: price.id } 166 | ]; 167 | break; 168 | } 169 | 170 | createSessionParameters.line_items = lineItems; 171 | session = await stripe.checkout.sessions.create(createSessionParameters); 172 | 173 | return session; 174 | } 175 | 176 | async function createApimSubscription(apimSubscriptionId: string, apimUserId: string, apimProductId: string, apimSubscriptionName: string) { 177 | const apimService = new ApimService(); 178 | return await apimService.createSubscription(apimSubscriptionId, apimUserId, apimProductId, apimSubscriptionName); 179 | } 180 | 181 | async function deactivateApimSubscription(apimSubscriptionId: string) { 182 | const apimService = new ApimService(); 183 | const subscription = await apimService.getSubscription(apimSubscriptionId); 184 | 185 | if (subscription.state === "active" || subscription.state === "submitted") { 186 | await apimService.updateSubscriptionState(apimSubscriptionId, 'suspended'); 187 | } 188 | } -------------------------------------------------------------------------------- /app/src/services/adyenBillingService.ts: -------------------------------------------------------------------------------- 1 | import { ApimService } from "./apimService"; 2 | import { CheckoutAPI, Client, Config } from "@adyen/api-library"; 3 | import { MonetizationService } from "./monetizationService"; 4 | import { BillingService } from "./billingService"; 5 | 6 | export interface Invoice { 7 | amount: number, 8 | subscriptionId: string, 9 | userId: string, 10 | month: number, 11 | year: number, 12 | currency: string 13 | } 14 | 15 | /** Contains functionality relating to Adyen billing - invoices and taking payment via users' saved payment methods */ 16 | export class AdyenBillingService implements BillingService { 17 | public async unsubscribe(apimSubscriptionId: string) { 18 | /* 19 | We only charge for active subscriptions, so not necessary to update any state here. 20 | You can add logic in here to e.g. bill the customer for a pro-rated month, or remove their saved payment details 21 | */ 22 | } 23 | 24 | /** From the usage, calculate the invoices for all subscriptions for the past month */ 25 | public static async calculateInvoices(month: number, year: number): Promise { 26 | const startDate = new Date(year, month, 1); 27 | let endDate = new Date(startDate); 28 | endDate = new Date(endDate.setMonth(startDate.getMonth() + 1)); 29 | 30 | const invoices: Invoice[] = [] 31 | 32 | const apimService = new ApimService(); 33 | const subscriptions = await apimService.getSubscriptions(); 34 | 35 | await Promise.all(subscriptions.filter(sub => sub.state === "active").map(async sub => { 36 | // Retrieve the usage report for the period from APIM 37 | const usageReport = await apimService.getUsage(sub.name, endDate, startDate); 38 | 39 | const subscriptionId = sub.name; 40 | const userId = sub.ownerId.replace('/users/', ''); 41 | 42 | // Retrieve the product, which is stored in the scope of the subscription 43 | const product = await apimService.getProduct(sub.scope.split("/").slice(-1)[0]); 44 | 45 | if (product) { 46 | const monetizationModel = await MonetizationService.getMonetizationModelFromProduct(product); 47 | 48 | if (monetizationModel) { 49 | let amount: number; 50 | let currency: string; 51 | 52 | let invoice: Invoice; 53 | 54 | if (usageReport) { 55 | const usageUnits = usageReport.callCountTotal / 100; 56 | 57 | // Calculate the amount owing based on the pricing model and usage 58 | switch (monetizationModel.pricingModelType) { 59 | case "Free": 60 | amount = 0; 61 | currency = ""; 62 | break; 63 | case "Freemium": 64 | case "TierWithOverage": 65 | // We floor this calculation as consumers only pay for full units used 66 | let usageOverage = Math.floor(usageUnits - monetizationModel.prices.unit.quota); 67 | 68 | if (usageOverage < 0) { 69 | usageOverage = 0; 70 | } 71 | 72 | amount = monetizationModel.prices.unit.unitAmount + usageOverage * monetizationModel.prices.metered.unitAmount; 73 | currency = monetizationModel.prices.metered.currency; 74 | break; 75 | case "Tier": 76 | amount = monetizationModel.prices.unit.unitAmount; 77 | currency = monetizationModel.prices.unit.currency; 78 | break; 79 | case "Metered": 80 | // We floor this calculation as consumers only pay for full units used 81 | amount = Math.floor(usageUnits) * monetizationModel.prices.metered.unitAmount; 82 | currency = monetizationModel.prices.metered.currency; 83 | break; 84 | case "Unit": 85 | // We ceiling this calculation as for "Unit" prices, you buy full units at a time 86 | let numberOfUnits = Math.ceil(usageUnits / monetizationModel.prices.unit.quota); 87 | 88 | // The minimum units that someone pays for is 1 89 | if (numberOfUnits <= 0) { 90 | numberOfUnits = 1; 91 | } 92 | 93 | amount = numberOfUnits * monetizationModel.prices.unit.unitAmount; 94 | currency = monetizationModel.prices.unit.currency; 95 | break; 96 | default: 97 | break; 98 | } 99 | 100 | invoice = { 101 | amount, 102 | month, 103 | year, 104 | subscriptionId, 105 | userId, 106 | currency: currency.toUpperCase() 107 | } 108 | } 109 | else { 110 | invoice = { 111 | amount: 0, 112 | month, 113 | year, 114 | subscriptionId: sub.name, 115 | userId, 116 | currency: currency.toUpperCase() 117 | } 118 | } 119 | 120 | invoices.push(invoice); 121 | } 122 | } 123 | })); 124 | 125 | return invoices; 126 | } 127 | 128 | /** Use Adyen and the consumer's stored card details to take payment for an invoice */ 129 | public static async takePaymentFromUser(invoice: Invoice) { 130 | const checkout = this.GetAdyenCheckout(); 131 | 132 | const apimService = new ApimService(); 133 | 134 | if (invoice.amount > 0) { 135 | // Retrieve the stored card details for the subscription 136 | const paymentMethodsResponse = await checkout.paymentMethods( 137 | { 138 | merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT, 139 | countryCode: "GB", 140 | shopperLocale: "en-GB", 141 | shopperReference: invoice.userId, 142 | amount: { 143 | currency: invoice.currency, 144 | value: invoice.amount 145 | } 146 | }) 147 | if (paymentMethodsResponse.storedPaymentMethods) { 148 | // Create a new payment using the stored method, for the amount on the invoice 149 | const paymentResponse = await checkout.payments({ 150 | amount: { currency: invoice.currency, value: invoice.amount }, 151 | paymentMethod: { 152 | type: 'scheme', 153 | storedPaymentMethodId: paymentMethodsResponse.storedPaymentMethods[0].id 154 | }, 155 | reference: `${invoice.subscriptionId}-${invoice.month}-${invoice.year}`, 156 | merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT, 157 | shopperInteraction: "ContAuth", 158 | shopperReference: invoice.userId, 159 | recurringProcessingModel: "CardOnFile", 160 | returnUrl: "/" 161 | }) 162 | 163 | // If there is an error in payment, deactivate the subscription 164 | if (paymentResponse.resultCode === "Error" || paymentResponse.resultCode === "Cancelled" || paymentResponse.resultCode === "Refused") { 165 | await apimService.updateSubscriptionState(invoice.subscriptionId, 'suspended'); 166 | } 167 | } else { 168 | // If there are no stored payment details for the subscription, deactivate it 169 | await apimService.updateSubscriptionState(invoice.subscriptionId, 'suspended'); 170 | } 171 | 172 | } 173 | } 174 | 175 | /** Retrieve Adyen checkout API access client */ 176 | public static GetAdyenCheckout() { 177 | const config = new Config(); 178 | // Set your X-API-KEY with the API key from the Customer Area. 179 | config.apiKey = process.env.ADYEN_API_KEY; 180 | config.merchantAccount = process.env.ADYEN_MERCHANT_ACCOUNT; 181 | const client = new Client({ config }); 182 | client.setEnvironment("TEST"); 183 | const checkout = new CheckoutAPI(client); 184 | return checkout; 185 | } 186 | } -------------------------------------------------------------------------------- /app/src/services/apimService.ts: -------------------------------------------------------------------------------- 1 | import { ClientSecretCredential } from "@azure/identity"; 2 | import { ApiManagementClient } from "@azure/arm-apimanagement"; 3 | import { ProductGetResponse, ProductListByServiceResponse, ReportRecordContract, SubscriptionCreateOrUpdateResponse, SubscriptionGetResponse, SubscriptionListResponse, SubscriptionState, UserCreateOrUpdateResponse, UserGetResponse, UserGetSharedAccessTokenResponse } from "@azure/arm-apimanagement/esm/models"; 4 | import { Utils } from "../utils"; 5 | import fetch from "node-fetch"; 6 | import { Guid } from 'js-guid'; 7 | 8 | export class ApimService { 9 | 10 | initialized = false; 11 | managementClient: ApiManagementClient; 12 | 13 | subscriptionId : string = process.env.APIM_SERVICE_AZURE_SUBSCRIPTION_ID; 14 | resourceGroupName : string = process.env.APIM_SERVICE_AZURE_RESOURCE_GROUP_NAME; 15 | serviceName : string = process.env.APIM_SERVICE_NAME; 16 | 17 | /** Get the list of products for the APIM service */ 18 | public async getProducts() : Promise { 19 | await this.initialize(); 20 | 21 | return await this.managementClient.product.listByService(this.resourceGroupName, this.serviceName); 22 | } 23 | 24 | /** Get the list of subscriptions for the APIM service */ 25 | public async getSubscriptions() : Promise { 26 | await this.initialize(); 27 | 28 | return await this.managementClient.subscription.list(this.resourceGroupName, this.serviceName); 29 | } 30 | 31 | /** Get a single product by product ID for the APIM service */ 32 | public async getProduct(productId: string) : Promise { 33 | await this.initialize(); 34 | 35 | return await this.managementClient.product.get(this.resourceGroupName, this.serviceName, productId); 36 | } 37 | 38 | /** Get a user by ID */ 39 | public async getUser(userId: string) : Promise { 40 | await this.initialize(); 41 | 42 | return await this.managementClient.user.get(this.resourceGroupName, this.serviceName, userId); 43 | } 44 | 45 | /** Get a subscription by ID */ 46 | public async getSubscription(sid: string) : Promise { 47 | await this.initialize(); 48 | 49 | return await this.managementClient.subscription.get(this.resourceGroupName, this.serviceName, sid); 50 | } 51 | 52 | /** Create a new subscription to a product for a user */ 53 | public async createSubscription(sid: string, userId: string, productId: string, subscriptionName: string) : Promise { 54 | await this.initialize(); 55 | 56 | return await this.managementClient.subscription.createOrUpdate( 57 | this.resourceGroupName, 58 | this.serviceName, 59 | Guid.newGuid().toString(), 60 | { 61 | displayName: subscriptionName, 62 | scope: `/products/${productId}`, 63 | ownerId: `/users/${userId}`, 64 | state: "active" 65 | } 66 | ); 67 | } 68 | 69 | /** Get the usage for a particular subscription between two dates */ 70 | public async getUsage(sid: string, to: Date, from?: Date) : Promise { 71 | await this.initialize(); 72 | const filter = `timestamp ge datetime'${from ? from.toISOString() : new Date('2000-01-01').toISOString()}' and timestamp le datetime'${to.toISOString()}' and subscriptionId eq '${sid}'`; 73 | const report = await this.managementClient.reports.listBySubscription(this.resourceGroupName, this.serviceName, filter); 74 | 75 | return report[0]; 76 | } 77 | 78 | /** List the subscriptions for a user */ 79 | public async getUserSubscriptions(userId: string) : Promise { 80 | await this.initialize(); 81 | 82 | return await this.managementClient.userSubscription.list(this.resourceGroupName, this.serviceName, userId); 83 | } 84 | 85 | /** Update the state of a subscription (for the API keys related to a subscription to work, that subscription must be in an active state) */ 86 | public async updateSubscriptionState(sid: string, state : SubscriptionState) { 87 | await this.initialize(); 88 | 89 | return await this.managementClient.subscription.update(this.resourceGroupName, this.serviceName, sid, { state }, "*"); 90 | } 91 | 92 | /** Get a shared access token for the user */ 93 | public async getSharedAccessToken(userId: string) : Promise { 94 | await this.initialize(); 95 | 96 | const expiry = new Date(); 97 | expiry.setDate(expiry.getDate() + 1); 98 | 99 | return await this.managementClient.user.getSharedAccessToken(this.resourceGroupName, this.serviceName, userId, { expiry, keyType: "primary" }); 100 | } 101 | 102 | /** Create a new user */ 103 | public async createUser(email: string, password: string, firstName: string, lastName: string) : Promise { 104 | await this.initialize(); 105 | 106 | return await this.managementClient.user.createOrUpdate( 107 | this.resourceGroupName, 108 | this.serviceName, 109 | Guid.newGuid().toString(), 110 | { 111 | email, 112 | firstName, 113 | lastName, 114 | password 115 | } 116 | ); 117 | } 118 | 119 | /** Authenticate a user using basic authentication */ 120 | public static async authenticateUser(email: string, password: string): Promise { 121 | try { 122 | 123 | const credentials = `Basic ${Buffer.from(`${email}:${password}`).toString('base64')}`; 124 | const managementApiUrl = Utils.ensureUrlArmified(process.env.APIM_MANAGEMENT_URL) 125 | const url = `${managementApiUrl}/identity?api-version=2019-12-01` 126 | const response = await fetch(url, { method: "GET", headers: { Authorization: credentials } }); 127 | const sasToken = response.headers.get("Ocp-Apim-Sas-Token"); 128 | const identity = await response.json() as { id: string }; 129 | 130 | return { 131 | authenticated: true, 132 | sasToken, 133 | userId: identity.id 134 | }; 135 | } 136 | catch (error) { 137 | return { authenticated: false }; 138 | } 139 | } 140 | 141 | private async initialize() { 142 | if (!this.initialized) { 143 | const credentials = new ClientSecretCredential( 144 | process.env.AZURE_AD_SERVICE_PRINCIPAL_TENANT_ID, 145 | process.env.AZURE_AD_SERVICE_PRINCIPAL_APP_ID, 146 | process.env.AZURE_AD_SERVICE_PRINCIPAL_PASSWORD, 147 | ) 148 | 149 | const client = new ApiManagementClient(credentials, this.subscriptionId); 150 | 151 | this.managementClient = client 152 | 153 | this.initialized = true; 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /app/src/services/billingService.ts: -------------------------------------------------------------------------------- 1 | export interface BillingService { 2 | unsubscribe: (apimSubscriptionId: string) => Promise; 3 | } -------------------------------------------------------------------------------- /app/src/services/monetizationService.ts: -------------------------------------------------------------------------------- 1 | import { ProductContract } from "@azure/arm-apimanagement/esm/models"; 2 | import fetch from "node-fetch"; 3 | 4 | export interface MonetizationModel { 5 | id: string, 6 | prices: any; 7 | pricingModelType: string 8 | recurring: any; 9 | } 10 | 11 | export class MonetizationService { 12 | /** Retrieve our defined monetization model for a given APIM product */ 13 | public static async getMonetizationModelFromProduct(product: ProductContract): Promise { 14 | 15 | const monetizationModelsUrl = `${process.env.APIM_GATEWAY_URL}/billing/monetizationModels`; 16 | 17 | const monetizationModelsResponse = await fetch(monetizationModelsUrl, { method: "GET", headers: { "Ocp-Apim-Subscription-Key": process.env.APIM_ADMIN_SUBSCRIPTION_KEY } }); 18 | 19 | const monetizationModels = (await monetizationModelsResponse.json()) as MonetizationModel[]; 20 | 21 | const monetizationModel = monetizationModels.find((x: { id: any; }) => x.id === product.name); 22 | 23 | return monetizationModel; 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/services/stripeBillingService.ts: -------------------------------------------------------------------------------- 1 | import { LastUsageUpdateKey, ApimSubscriptionIdKey } from "../constants"; 2 | import Stripe from "stripe"; 3 | import { ApimService } from "./apimService"; 4 | import { BillingService } from "./billingService"; 5 | 6 | /** Containing functionality relating to Stripe billing - updating usage records */ 7 | export class StripeBillingService implements BillingService { 8 | 9 | /** When unsubscribing from APIM product, we cancel the Stripe subscription to stop the consumer being billed. */ 10 | public async unsubscribe(apimSubscriptionId: string) { 11 | const stripe = new Stripe(process.env.STRIPE_API_KEY, { 12 | apiVersion: '2020-08-27', 13 | }); 14 | 15 | let stripeSubscriptionListResponse: Stripe.Response>; 16 | let startingAfter: string; 17 | let stripeSubscription: Stripe.Subscription 18 | do { 19 | stripeSubscriptionListResponse = await stripe.subscriptions.list({ starting_after: startingAfter }); 20 | stripeSubscription = stripeSubscriptionListResponse.data.find(sub => sub.metadata[ApimSubscriptionIdKey] === apimSubscriptionId); 21 | startingAfter = stripeSubscriptionListResponse.data.slice(-1)[0].id; 22 | } 23 | while(!stripeSubscription && stripeSubscriptionListResponse.has_more); 24 | 25 | await stripe.subscriptions.del(stripeSubscription.id); 26 | } 27 | 28 | /** Stripe automatically bills consumers monthly according to their usage. To support this, we report usage to Stripe throughout the month. */ 29 | public static async reportUsageToStripe(): Promise { 30 | 31 | const apimService = new ApimService(); 32 | const now = new Date(); 33 | 34 | const stripe = new Stripe(process.env.STRIPE_API_KEY, { 35 | apiVersion: '2020-08-27', 36 | }); 37 | 38 | let stripeSubscriptionListResponse: Stripe.Response>; 39 | let startingAfter: string; 40 | 41 | do { 42 | stripeSubscriptionListResponse = await stripe.subscriptions.list({ starting_after: startingAfter }); 43 | 44 | // For each Stripe subscription 45 | stripeSubscriptionListResponse.data.forEach(async stripeSubscription => { 46 | 47 | startingAfter = stripeSubscription.id; 48 | 49 | const subscriptionItem = stripeSubscription.items.data[0]; 50 | 51 | // Find the subscriptions with a metered usage type, as these are the only ones which depend on usage 52 | if (subscriptionItem.price.recurring.usage_type !== "metered") { 53 | return; 54 | } 55 | 56 | const apimSubscriptionId = stripeSubscription.metadata[ApimSubscriptionIdKey]; 57 | 58 | if (!apimSubscriptionId) { 59 | return; 60 | } 61 | 62 | const lastUsageUpdate = stripeSubscription.metadata[LastUsageUpdateKey]; 63 | 64 | // Get all usage since the start of the current billing period. 65 | // Unless the last time we did a usage update was in the last period, 66 | // in which case we need to create an additional usage record for the usage between the previous update, and the start of this billing period 67 | // This is to ensure that no usage is missed 68 | 69 | const startofBillingPeriod = new Date(stripeSubscription.current_period_start * 1000); 70 | 71 | if (lastUsageUpdate) { 72 | const lastUsageUpdateDate = new Date(lastUsageUpdate); 73 | if (lastUsageUpdateDate < startofBillingPeriod) { 74 | const priorUsageReport = await apimService.getUsage(apimSubscriptionId, startofBillingPeriod, lastUsageUpdateDate); 75 | 76 | const totalPriorCalls = priorUsageReport.callCountTotal; 77 | const priorUsageUnits = Math.floor(totalPriorCalls / 100); // We bill in units of 100 calls 78 | 79 | if (priorUsageUnits !== 0) { 80 | // Create a usage record in Stripe for the period between the previous usage record and the start of this billing period 81 | await stripe.subscriptionItems.createUsageRecord(subscriptionItem.id, { quantity: priorUsageUnits, timestamp: stripeSubscription.current_period_start + 1, action: "set" }); 82 | 83 | // Update Stripe's record for when the usage was last updated to the start of the billing period 84 | await stripe.subscriptions.update(stripeSubscription.id, { metadata: { [LastUsageUpdateKey]: startofBillingPeriod.toISOString() } }); 85 | } 86 | } 87 | } 88 | 89 | // Retrieve the usage report for the start of the billing period to now 90 | const usageReport = await apimService.getUsage(apimSubscriptionId, now, startofBillingPeriod); 91 | 92 | const totalCalls = usageReport.callCountTotal; 93 | const usageUnits = Math.floor(totalCalls / 100); // We bill in units of 100 calls 94 | 95 | if (usageUnits === 0) { 96 | return; 97 | } 98 | 99 | // Create a usage record in Stripe for the start of the billing period to now 100 | await stripe.subscriptionItems.createUsageRecord(subscriptionItem.id, { quantity: usageUnits, timestamp: stripeSubscription.current_period_start, action: "set" }); 101 | 102 | // Update Stripe's record for when the usage was last updated to now 103 | await stripe.subscriptions.update(stripeSubscription.id, { metadata: { [LastUsageUpdateKey]: now.toISOString() } }); 104 | 105 | }); 106 | } 107 | while (stripeSubscriptionListResponse.has_more) 108 | 109 | return 0; 110 | } 111 | } -------------------------------------------------------------------------------- /app/src/utils.ts: -------------------------------------------------------------------------------- 1 | export class Utils { 2 | 3 | /** This ensures that URLs for contacting the APIM service contain the Azure subscription, resource group and service name */ 4 | public static ensureUrlArmified(resourceUrl: string): string { 5 | const regex = /subscriptions\/.*\/resourceGroups\/.*\/providers\/microsoft.ApiManagement\/service/i; 6 | const isArmUrl = regex.test(resourceUrl); 7 | 8 | if (isArmUrl) { 9 | return resourceUrl; 10 | } 11 | 12 | const url = new URL(resourceUrl); 13 | const protocol = url.protocol; 14 | const hostname = url.hostname; 15 | const pathname = url.pathname.endsWith("/") 16 | ? url.pathname.substring(0, url.pathname.length - 1) 17 | : url.pathname; 18 | 19 | resourceUrl = `${protocol}//${hostname}/subscriptions/sid/resourceGroups/rgid/providers/Microsoft.ApiManagement/service/sid${pathname}`; 20 | 21 | return resourceUrl; 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/views/cancel.ejs: -------------------------------------------------------------------------------- 1 |

Subscription payment cancelled.

2 |

Please try again.

-------------------------------------------------------------------------------- /app/src/views/checkout-adyen.ejs: -------------------------------------------------------------------------------- 1 | 3 | 5 | 93 | 94 |

Checkout

95 |
96 |
97 |
98 |
99 |
100 |
101 |
-------------------------------------------------------------------------------- /app/src/views/checkout-stripe.ejs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/views/fail.ejs: -------------------------------------------------------------------------------- 1 |

Subscription payment failed.

2 |

Please try again.

-------------------------------------------------------------------------------- /app/src/views/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%- title %> 7 | 8 | 9 | 10 | <%- body %> 11 | 12 | -------------------------------------------------------------------------------- /app/src/views/pending.ejs: -------------------------------------------------------------------------------- 1 |

Subscription payment pending.

-------------------------------------------------------------------------------- /app/src/views/sign-in.ejs: -------------------------------------------------------------------------------- 1 |

APIM Sign In

2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

<%= errorMessage %>

-------------------------------------------------------------------------------- /app/src/views/sign-up.ejs: -------------------------------------------------------------------------------- 1 |

APIM Sign Up

2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

<%= errorMessage %>

-------------------------------------------------------------------------------- /app/src/views/subscribe.ejs: -------------------------------------------------------------------------------- 1 |

Subscribe

2 | 3 |
Product: <%= product.name %>
4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
-------------------------------------------------------------------------------- /app/src/views/success.ejs: -------------------------------------------------------------------------------- 1 |

Subscription payment accepted

2 |

Your subscription will shortly be activated.

3 |

Back to Developer Portal

-------------------------------------------------------------------------------- /app/src/views/unsubscribe.ejs: -------------------------------------------------------------------------------- 1 |

Subscription cancelled.

2 |

Back to Developer Portal

-------------------------------------------------------------------------------- /app/tools/copyAssets.ts: -------------------------------------------------------------------------------- 1 | import * as shell from "shelljs"; 2 | 3 | // Copy all the view templates 4 | shell.cp( "-R", "src/views", "dist/" ); 5 | 6 | // Copy all the public files 7 | shell.cp( "-R", "src/public", "dist/" ); -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "baseUrl": ".", 11 | "paths": { 12 | "*": [ 13 | "node_modules/*" 14 | ] 15 | } 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | ], 20 | "exclude": [ 21 | "src/public" 22 | ] 23 | } -------------------------------------------------------------------------------- /app/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "trailing-comma": [ false ] 9 | }, 10 | "rulesDirectory": [] 11 | } -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | $here = Split-Path -Parent $PSCommandPath 3 | $toolsDir = "$here/tools" 4 | 5 | if ($IsWindows) { 6 | $os = "win" 7 | } 8 | elseif ($IsLinux) { 9 | $os = "linux" 10 | } 11 | elseif ($IsMacOS) { 12 | $os = "osx" 13 | } 14 | else { 15 | throw "Unsupported OS" 16 | } 17 | 18 | function installBicep { 19 | $version = "v0.3.126" 20 | $extension = $IsWindows ? ".exe" : "" 21 | $bicepUri = "https://github.com/Azure/bicep/releases/download/$version/bicep-$os-x64$extension" 22 | 23 | $dir = New-Item -Path $toolsDir/bicep/$version -ItemType Directory -Force 24 | $bicep = "$dir\bicep$extension" 25 | 26 | if (Test-Path $dir/* -Filter bicep*) { 27 | Write-Host "Bicep already installed." 28 | } 29 | else { 30 | (New-Object Net.WebClient).DownloadFile($bicepUri, $bicep) 31 | } 32 | 33 | return $bicep 34 | } 35 | 36 | try { 37 | Write-Host "Installing Bicep..." 38 | $bicep = installBicep 39 | 40 | Write-Host "Building main Bicep template..." 41 | . $bicep build $here/templates/main.bicep --outdir $here/output 42 | } 43 | catch { 44 | Write-Warning $_.ScriptStackTrace 45 | Write-Warning $_.InvocationInfo.PositionMessage 46 | Write-Error ("Build error: `n{0}" -f $_.Exception.Message) 47 | } -------------------------------------------------------------------------------- /deploy.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter(Mandatory=$true)] 4 | [string] 5 | $TenantId, 6 | 7 | [Parameter(Mandatory=$true)] 8 | [string] 9 | $SubscriptionId, 10 | 11 | [Parameter(Mandatory=$true)] 12 | [string] 13 | $ResourceGroupName, 14 | 15 | [Parameter(Mandatory=$true)] 16 | [string] 17 | $ResourceGroupLocation, 18 | 19 | [Parameter(Mandatory=$true)] 20 | [string] 21 | $ArtifactStorageAccountName, 22 | 23 | [Parameter()] 24 | [string] 25 | $ParameterFilePath = "$(Split-Path -Parent $PSCommandPath)/output/main.parameters.json" 26 | ) 27 | 28 | $ErrorActionPreference = "Stop" 29 | $here = Split-Path -Parent $PSCommandPath 30 | 31 | $deploymentName = "apim-monetization-{0}" -f (Get-Date).ToString("yyyyMMddHHmmss") 32 | 33 | $account = (az account show) | ConvertFrom-Json 34 | 35 | if (!$account -or $account.tenantId -ne $TenantId) { 36 | az login --tenant $TenantId 37 | } 38 | 39 | if ($account.id -ne $SubscriptionId) { 40 | az account set --subscription $SubscriptionId 41 | } 42 | 43 | az group create --name $ResourceGroupName --location $ResourceGroupLocation 44 | 45 | az storage account create -n $ArtifactStorageAccountName -g $ResourceGroupName -l $ResourceGroupLocation 46 | az storage container create -n default --account-name $ArtifactStorageAccountName --public-access blob 47 | az storage blob directory upload -c default --account-name $ArtifactStorageAccountName -s "$here/apiConfiguration/*" -d "/apiConfiguration" --recursive 48 | az storage blob directory upload -c default --account-name $ArtifactStorageAccountName -s "$here/payment/*" -d "/payment" --recursive 49 | $artifactsBaseUrl = "https://$ArtifactStorageAccountName.blob.core.windows.net/default" 50 | 51 | az deployment group create --resource-group $ResourceGroupName --name $deploymentName --template-file "$here/output/main.json" --parameters (Resolve-Path $ParameterFilePath) --parameters artifactsBaseUrl=$artifactsBaseUrl -------------------------------------------------------------------------------- /documentation/advanced-steps.md: -------------------------------------------------------------------------------- 1 | # Advanced steps 2 | 3 | This article takes you beyond initial Adyen or Stripe deployment and integration with API Management. 4 | 5 | ## Running the build script 6 | 7 | To modify the infrastructure templates, you'll run a build script to generate a new Azure Resource Manager template output for deployment. 8 | 9 | 1. Run the `build.ps1` PowerShell script at the root of the repo. 10 | 11 | 1. The build script generates deployment scripts for all Azure resources required to support the demo project. 12 | * See the [deployment details](./deployment-details.md) for more details. 13 | 14 | 1. The build script executes the following steps: 15 | * Installs the Bicep CLI into the `build` folder. 16 | * Transpiles the `main.bicep` template into a single `main.json` Azure Resource Manager template in the `output` folder. 17 | 18 | ## Running the billing portal app locally 19 | 20 | To modify the billing portal app and run the app locally: 21 | 22 | 1. Install [NodeJS](https://nodejs.org/en/download/) (version 20.10 or later). 23 | 1. Deploy an instance of the API Management infrastructure, following the instructions in either: 24 | * [Deploy with Stripe](./stripe-deploy.md), or 25 | * [Deploy with Adyen](./adyen-deploy.md) documents). 26 | 1. Make a copy of `.env.sample` in `/app`, rename to `.env`, and fill in the variables as per your environment. 27 | 1. Use a tunneling app such as [ngrok](https://ngrok.com/) to create a public URL forwarding port 8080. If using ngrok: 28 | * [Download ngrok](https://ngrok.com/download). 29 | * Unzip the executable. 30 | * From the command line, run the ngrok executable to start an HTTP tunnel, using port 8080: `./ngrok http 8080`. 31 | * Continue with the following steps, replacing `` with the output URL (for example, https://\.ngrok.io). 32 | 1. Update the API Management delegation URL via the Azure portal to point to `/apim-delegation`. 33 | * For Stripe, update the Stripe webhook endpoint URL to `/webhook/stripe`. 34 | * For Adyen, update the allowed origins to include ``. 35 | 1. Run `npm run dev` from the `/app` folder to start the app. 36 | 1. If you're running from VS Code, enable debugging by: 37 | * Opening the command palette. 38 | * Selecting **Debug: Toggle Auto Attach**. 39 | * Setting to **Smart**. 40 | 41 | ## Re-building the docker image 42 | 43 | If you make code changes to the custom billing portal app, you will need to: 44 | 45 | 1. Re-build the docker image from `app/Dockerfile`. 46 | 1. Publish the docker image to a publicly accessible container registry. 47 | 1. When deploying, set the value of the `appServiceContainerImage` parameter to the URL for your published container image. 48 | -------------------------------------------------------------------------------- /documentation/adyen-deploy.md: -------------------------------------------------------------------------------- 1 | # Deploy demo with Adyen 2 | 3 | In this tutorial, you'll deploy the demo Adyen account and learn how to: 4 | 5 | > [!div class="checklist"] 6 | > * Set up an Adyen account, the required PowerShell and `az cli` tools, an Azure subscription, and a service principal on Azure. 7 | > * Deploy the Azure resources using either Azure portal or PowerShell. 8 | > * Make your deployment visible to consumers by publishing the Azure developer portal. 9 | 10 | ## Pre-requisites 11 | 12 | To prepare for this demo, you'll need to: 13 | 14 | > [!div class="checklist"] 15 | > * Create an Adyen test account. 16 | > * Install and set up the required PowerShell and Azure CLI tools. 17 | > * Set up an Azure subscription. 18 | > * Set up a service principal in Azure. 19 | 20 | ### [Create an Adyen test account](https://www.adyen.com/signup) 21 | 22 | From within an Adyen test account: 23 | 1. Expand the **Accounts** tab on the pages panel on the left side of the Adyen test account homepage, select the **Merchant accounts** option. 24 | 1. If you do not have one already, create a **merchant account** for an **ecommerce sales channel**. 25 | 26 | Three values should be copied from the Adyen test account that are required as input parameters during the deployment process: 27 | 1. Copy the **Merchant account name** which is the displayed in the top left corner of the Adyen test account homepage. 28 | 1. Expand the **Developers** tab on the pages panel on the left side of the Adyen test account homepage, select the **API Credentials** option. 29 | 1. Select the **ws** (Web Service) API. 30 | 1. Generate and copy an **API key** and copy the **Client key**. 31 | 32 | After the deployment of the Azure resources (see below) has completed, you should return to the Adyen test account homepage to: 33 | 1. Expand the **Developers** tab on the pages panel on the left side of the Adyen test account homepage, select the **API Credentials** option. 34 | 1. Select the **ws** (Web Service) API. 35 | 1. Add a new origin to the list of allowed origins which is the URL of the App Service that has been deployed. 36 | 37 | ### Install and set up the required tools 38 | 39 | - Version 7.1 or later of [PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.1). 40 | - Version 2.21.0 or later of [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli). 41 | 42 | ### Set up an Azure subscription with admin access 43 | 44 | For this sample project, you will need admin access in order to deploy all the included artifacts to Azure. If you do not have an Azure subscription, set up a [free trial](https://azure.microsoft.com/). 45 | 46 | ### Set up a service principal on Azure 47 | 48 | For the solution to work, the Web App component needs a privileged credential on your Azure subscription with the scope to execute `read` operations on API Management (get products, subscriptions, etc.). 49 | 50 | Before deploying the resources, set up the service principal in the Azure Active Directory (AAD) tenant used by the Web App to update the status of API Management subscriptions. 51 | 52 | The simplest method is using the Azure CLI. 53 | 54 | 1. [Sign in with Azure CLI](https://docs.microsoft.com/cli/azure/authenticate-azure-cli#sign-in-interactively): 55 | 56 | ```azurecli-interactive 57 | az login 58 | ``` 59 | 2. [Create an Azure service principal with the Azure CLI](https://docs.microsoft.com/cli/azure/create-an-azure-service-principal-azure-cli#password-based-authentication): 60 | 61 | ```azurecli-interactive 62 | az ad sp create-for-rbac --name --skip-assignment 63 | ``` 64 | 65 | 3. Take note of the `name` (ID), `appId` (client ID) and `password` (client secret), as you will need to pass these values as deployment parameters. 66 | 67 | 4. Retrieve the **object ID** of your new service principal for deployment: 68 | 69 | ```azurecli-interactive 70 | az ad sp show --id "" 71 | ``` 72 | 73 | The correct role assignments for the service principal will be assigned as part of the deployment. 74 | 75 | ## Deploy the Azure monetization resources 76 | 77 | You can deploy the monetization resource via either Azure portal or PowerShell script. 78 | 79 | >[!NOTE] 80 | > For both options, when filling in parameters, leave the `stripe*` parameters blank. 81 | 82 | ### Azure portal 83 | 84 | Click the button below to deploy the example to Azure and fill in the required parameters in the Azure portal. 85 | 86 | [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fazure-api-management-monetization%2Fmain%2Foutput%2Fmain.json) 87 | 88 | ### PowerShell script 89 | 90 | You can deploy by running the `deploy.ps1` PowerShell script at the root of the repo. 91 | 92 | 1. Provide a parameters file for the `main.json` ARM template. 93 | * Find a template for the parameters file provider in `output/main.parameters.template.json`. 94 | * Rename this JSON file to `output/main.parameters.json` and update the values as necessary. 95 | 96 | 2. Execute the `deploy.ps1` script: 97 | 98 | ```powershell 99 | deploy.ps1 ` 100 | -TenantId "" ` 101 | -SubscriptionId "" ` 102 | -ResourceGroupName "apimmonetization" ` 103 | -ResourceGroupLocation "uksouth" ` 104 | -ArtifactStorageAccountName "" 105 | ``` 106 | 107 | ## Publish the API Management developer portal 108 | 109 | This example project uses the hosted [API Management developer portal](https://docs.microsoft.com/azure/api-management/api-management-howto-developer-portal). 110 | 111 | You are required to complete a manual step to publish and make the resources visible to customers. See the [Publish the portal](https://docs.microsoft.com/azure/api-management/api-management-howto-developer-portal-customize#publish) for instructions. 112 | 113 | ## Next Steps 114 | * Learn more about [deploying API Management monetization with Adyen](adyen-details.md). 115 | * Learn about the [Stripe deployment](stripe-details.md) option. -------------------------------------------------------------------------------- /documentation/adyen-details.md: -------------------------------------------------------------------------------- 1 | # How to implement monetization with Azure API Management and Adyen 2 | 3 | You can configure the API Management and Adyen to implement products defined in the revenue model (Free, Developer, PAYG, Basic, Standard, Pro, Enterprise). Once implemented, API consumers can browse, select, and subscribe to products via the developer portal. 4 | 5 | To deliver a consistent end-to-end API consumer experience, you'll synchronize the API Management product policies and the Adyen configuration using a shared configuration file [payment/monetizationModels.json](../payment/monetizationModels.json). 6 | 7 | In this demo project, we'll implement the example revenue model defined in [the monetization overview](https://docs.microsoft.com/azure/api-management/monetization-overview#step-4---design-the-revenue-model) to demonstrate integrating Azure API Management with Adyen. 8 | 9 | ## Adyen 10 | 11 | [Adyen](https://adyen.com/) is a payment provider with which you can securely take payment from consumers. 12 | 13 | With Adyen, you can [tokenize](https://docs.adyen.com/online-payments/tokenization) a consumer's card details to be: 14 | 15 | * Securely stored by Adyen. 16 | * Used to authorize recurring transactions. 17 | 18 | ## Architecture 19 | 20 | The following diagram illustrates: 21 | * The components of the solution across API Management, the billing app, and Adyen. 22 | * The major integration flows between components, including the interactions between the API consumer (both developer and application) and the solution. 23 | 24 | ![Adyen architecture overview](architecture-adyen.png) 25 | 26 | ## API consumer flow 27 | 28 | The API consumer flow describes the end-to-end user journey supported by the solution. Typically, the API consumer is a developer tasked with integrating their organization's own application with your API. The API consumer flow aims to support bringing the user from API discovery, through API consumption, to paying for API usage. 29 | 30 | ### API consumer flow 31 | 32 | 1. Consumer selects **Sign up** in the API Management developer portal. 33 | 2. Developer portal redirects consumer to the billing portal app to register their account via [API Management delegation](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation). 34 | 3. Upon successful registration, consumer is authenticated and returned back to the developer portal. 35 | 4. Consumer selects a product to subscribe to in the developer portal. 36 | 5. Developer portal redirects consumer to the billing portal app via delegation. 37 | 6. Consumer enters a display name for their subscription and selects checkout. 38 | 7. Billing portal app redirects consumer to an embedded Adyen checkout page to enter their payment details. 39 | 8. Adyen saves their payment details. 40 | 9. Consumer's API Management subscription is created. 41 | 10. Usage calculates the invoice monthly, which is then charged to the consumer. 42 | 43 | ### Steps 1 - 3: Register an account 44 | 45 | 1. Find the developer portal for an API Management service at `https://{ApimServiceName}.developer.azure-api.net`. 46 | 1. Select **Sign Up** to be redirected to the billing portal app. 47 | 1. On the billing portal app, register for an account. 48 | * This is handled via [user registration delegation](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation#-delegating-developer-sign-in-and-sign-up). 49 | 1. Upon successful account creation, the consumer is authenticated and redirected to the developer portal. 50 | 51 | Once the consumer creates an account, they'll only need to sign into the existing account to browse APIs and products from the developer portal. 52 | 53 | ### Steps 4 - 5: Subscribe to products and retrieve API keys 54 | 55 | 1. Log into the developer portal account. 56 | 1. Search for a product and select **Subscribe** to begin a new subscription. 57 | 1. Consumer will be redirected to the billing portal app. 58 | * This is handled via [product subscription delegation](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation#-delegating-product-subscription). 59 | 60 | ### Steps 5 - 6: Billing portal 61 | 62 | 1. Once redirected to the billing portal, enter a display name for the subscription. 63 | 1. Select **Checkout** to be redirected to the Adyen checkout page. 64 | 65 | ### Steps 7 - 8: Adyen checkout 66 | 67 | 1. On the [Adyen checkout page](../app/src/views/checkout-adyen.ejs), enter the payment details. 68 | * This page collects the consumer's payment details using an Adyen plugin. 69 | 1. Once the card details are confirmed, Adyen sends the request through to the [API for initiating payments](../app/src/routes/adyen.ts). 70 | 1. Create a 0-amount payment for the consumer, passing in: 71 | * A flag to store the payment method and keep the card on file. 72 | * The API Management user ID as the shopper reference. 73 | * A combination of the API Management user ID, API Management product ID, and subscription name as payment reference. 74 | 75 | The saved card details will be used for future transactions related to this subscription. 76 | 77 | ### Step 9: API Management subscription created 78 | 79 | Once the consumer's payment details have been successfully tokenized, we create their API Management subscription so they can use their API keys and access the APIs provided under the subscribed product. 80 | 81 | Unlike the [Stripe](./stripe-deploy.md) implementation, Adyen's subscription creation is done as part of the same API call, with no need for a callback/webhook. 82 | 83 | ### Step 10: Billing 84 | 85 | On the first of each month, a [CRON job](https://www.npmjs.com/package/node-cron) is run. The CRON job is defined in the main [index.ts file](../app/src/index.ts) for the app, and: 86 | * Calls into the [calculateInvoices method on the AdyenBillingService](../app/src/services/adyenBillingService.ts) to calculate the invoices for all API Management subscriptions. 87 | * Charges the consumer's cards. 88 | 89 | This method contains the logic for calculating the payment amount, using: 90 | * The product to which the consumer has subscribed. 91 | * The usage data from API Management. 92 | 93 | 1. The CRON job calculates `UsageUnits` by dividing the total number of API calls by 100 (since our pricing model works in units of 100 calls). 94 | 95 | 1. The following logic is used to calculate the amount, based on the pricing model for the product: 96 | 97 | ```ts 98 | // Calculate the amount owing based on the pricing model and usage 99 | switch (monetizationModel.pricingModelType) { 100 | case "Free": 101 | amount = 0; 102 | currency = ""; 103 | break; 104 | case "Freemium": 105 | case "TierWithOverage": 106 | // We floor this calculation as consumers only pay for full units used 107 | let usageOverage = Math.floor(usageUnits - monetizationModel.prices.unit.quota); 108 | 109 | if (usageOverage < 0) { 110 | usageOverage = 0; 111 | } 112 | 113 | amount = monetizationModel.prices.unit.unitAmount + usageOverage * monetizationModel.prices.metered.unitAmount; 114 | currency = monetizationModel.prices.metered.currency; 115 | break; 116 | case "Tier": 117 | amount = monetizationModel.prices.unit.unitAmount; 118 | currency = monetizationModel.prices.unit.currency; 119 | break; 120 | case "Metered": 121 | // We floor this calculation as consumers only pay for full units used 122 | amount = Math.floor(usageUnits) * monetizationModel.prices.metered.unitAmount; 123 | currency = monetizationModel.prices.metered.currency; 124 | break; 125 | case "Unit": 126 | // We ceiling this calculation as for "Unit" prices, you buy full units at a time 127 | let numberOfUnits = Math.ceil(usageUnits / monetizationModel.prices.unit.quota); 128 | 129 | // The minimum units that someone pays for is 1 130 | if (numberOfUnits <= 0) { 131 | numberOfUnits = 1; 132 | } 133 | 134 | amount = numberOfUnits * monetizationModel.prices.unit.unitAmount; 135 | currency = monetizationModel.prices.unit.currency; 136 | break; 137 | default: 138 | break; 139 | } 140 | } 141 | 142 | ``` 143 | 144 | 1. The invoices are passed to the `takePaymentViaAdyen` function. 145 | * This uses the API Management user ID to retrieve the stored payment methods for the subscription. 146 | 1. We use the ID of the stored payment method to authorize a payment for the amount on the invoice. 147 | 148 | ### Step 11: Subscription suspended 149 | 150 | If the payment fails, the API Management subscription is placed in a suspended state. 151 | 152 | ## Next steps 153 | 154 | * Follow [Deploy demo with Adyen](adyen-deploy.md) to deploy the solution described in this document. 155 | * See if [deploying with Stripe](stripe-details.md) is a better method for you. -------------------------------------------------------------------------------- /documentation/architecture-adyen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-api-management-monetization/6398ea1cad3de0e6c1d13b2000459852729dd02e/documentation/architecture-adyen.png -------------------------------------------------------------------------------- /documentation/architecture-stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/azure-api-management-monetization/6398ea1cad3de0e6c1d13b2000459852729dd02e/documentation/architecture-stripe.png -------------------------------------------------------------------------------- /documentation/deployment-details.md: -------------------------------------------------------------------------------- 1 | # Azure API Management monetization strategy deployment details 2 | 3 | In this article, you will learn about the technology, resources, and tools that API Management uses to deploy your monetization strategy. Using the provided demo, you will deploy a monetization strategy and define a set of products, APIs, and named values. 4 | 5 | As part of the demo, you'll deploy the following resources: 6 | - An [API Management service](https://azure.microsoft.com/services/api-management/), with the API Management resources required to support the demo project (APIs, Products, Policies, Named Values). 7 | - An [App Service plan](https://docs.microsoft.com/azure/app-service/overview). 8 | - A [Web App for containers](https://azure.microsoft.com/services/app-service/containers/), using the billing portal app container image. 9 | - A [Service Principal role-based access control assignment](https://docs.microsoft.com/azure/role-based-access-control/overview). 10 | 11 | ## Bicep templates 12 | 13 | This project is currently using [Bicep](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/overview?tabs=bicep) for local development and deployment. Bicep is a templating language for declaratively deploying Azure resources. Currently, the **Deploy to Azure** button does not support Bicep. 14 | 15 | * Prior to deployment, Bicep must be decompiled into an Azure Resource Manager template, which happens when the solution is built by running the [build.ps1](../build.ps1) script. 16 | * You can find the Azure Resource Manager template generated on build in the [/output](../output/) folder. 17 | * You can run the deployment using the [deploy.ps1](../deploy.ps1) script. Follow the steps in the [README](../README.md) file. 18 | 19 | ## API Management Service 20 | 21 | With the demo, you deploy an instance of Azure API Management service. As part of this deployment, we define a set of products, APIs (with accompanying policies), and named values. 22 | 23 | ### Products 24 | 25 | The API Management instance products are defined as resources as part of the Bicep templates in [templates/apimmonetization-products.bicep](../templates/apimmonetization-products.bicep). 26 | 27 | In the following example product definition, we: 28 | * Define the basic product. 29 | * Define a name, display name, and description. 30 | * Require subscription in order to use this product. 31 | * Require approval before activating the subscription. 32 | 33 | ```bicep 34 | resource ApimServiceName_basic 'Microsoft.ApiManagement/service/products@2019-01-01' = { 35 | properties: { 36 | description: 'Basic tier with a monthly quota of 50,000 calls.' 37 | terms: 'Terms here' 38 | subscriptionRequired: true 39 | approvalRequired: true 40 | state: 'published' 41 | displayName: 'Basic' 42 | } 43 | name: '${ApimServiceName}/basic' 44 | dependsOn: [] 45 | } 46 | ``` 47 | 48 | We link a separate resource to a policy file for that product. 49 | 50 | ```bicep 51 | resource ApimServiceName_basic_policy 'Microsoft.ApiManagement/service/products/policies@2019-01-01' = { 52 | properties: { 53 | value: concat(artifactsBaseUrl, '/apiConfiguration/policies/products/basic.xml') 54 | format: 'xml-link' 55 | } 56 | name: '${ApimServiceName_basic.name}/policy' 57 | } 58 | ``` 59 | 60 | In the following policy file for the basic product, we define an inbound policy that: 61 | * Limits a single subscription to 50,000 calls/month. 62 | * Adds a rate limiting policy, ensuring a single subscription is limited to 100 calls/minute. 63 | 64 | ```xml 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ``` 82 | 83 | We also define who can access each product in the [/templates/apimmonetization-productGroups.bicep](../templates/apimmonetization-productGroups.bicep) Bicep file. Developers and guests are able to sign up to the following products: 84 | 85 | - Free 86 | - Developer 87 | - PAYG 88 | - Basic 89 | - Standard 90 | - Pro 91 | - Enterprise 92 | 93 | Since the *Admin* product is only used internally for communication between services, we don't want to expose it as a product available for consumer sign-up and use. 94 | 95 | ### APIs 96 | 97 | Two APIs are defined as part of the API Management instance deployment: 98 | * Address API (external) 99 | * Billing API (internal) 100 | 101 | Resources of these APIs are defined in the following Bicep templates: 102 | * [templates/apimmonetization-apis-billing.bicep](../templates/apimmonetization-apis-billing.bicep) 103 | * [templates/apimmonetization-apis-address.bicep](../templates/apimmonetization-apis-address.bicep) 104 | 105 | Links between APIs and products are defined in [templates/apimmonetization-productAPIs.bicep](../templates/apimmonetization-productAPIs.bicep). 106 | 107 | Each of these APIs and products: 108 | * Are defined in the Bicep templates. 109 | * Has a set of policies defined. 110 | * Has an Open API definition attached. 111 | 112 | #### Address API 113 | 114 | Address APIs are external APIs. Consumers can only subscribe to address APIs, using a set of differently priced products. 115 | 116 | We provided consumers access to an example address API via API Management and Stripe. In your own solution, replace the example API with the APIs to which your consumers will subscribe. In our example, a subscriber can post an address to the API, which will then be validated. 117 | 118 | See [apiConfiguration/openApi/address.yaml](../apiConfiguration/openApi/address.yaml) for the example address API definition. 119 | 120 | See in [templates/apimmonetization-productAPIs.bicep](../templates/apimmonetization-productAPIs.bicep) how the example API can be accessed using a range of products: 121 | 122 | * Free 123 | * Developer 124 | * PAYG 125 | * Basic 126 | * Standard 127 | * Pro 128 | * Enterprise 129 | 130 | Anyone signing up to any of the above products can access this API using their API key. However, they may be limited to a specific number of calls/month or rate of calls/minute, depending on the product. 131 | 132 | #### Billing API 133 | 134 | Billing APIs are internal APIs with two endpoints defined: 135 | 136 | * **`monetizationModels`** endpoint. 137 | Used to retrieve the monetization models defined in the [payment/monetizationModels.json](../payment/monetizationModels.json) file. 138 | * **`products`** endpoint. 139 | Incoming requests are redirected to the management API. Used to retrieve the products defined on the API Management instance. 140 | 141 | See [apiConfiguration/openApi/billing.yaml](../apiConfiguration/openApi/billing.yaml) for the billing API definition. 142 | 143 | >[!NOTE] 144 | > The billing API is only exposed under the `Admin` product and will not be exposed to consumers. 145 | 146 | ### Named Values 147 | 148 | API Management also provides a concept of named values as part of deployment. Named values allow you to define specific terms, which will be replaced within your policies. For example: 149 | 150 | ```xml 151 | 152 | 153 | 154 | 155 | ``` 156 | In the above policy definition, certain values will need to be replaced. We can define named values on the API Management instance and thus automatically override these values. 157 | 158 | Named values are defined in the [templates/apimmonetization-namedValues](../templates/apimmonetization-namedValues.bicep) file. In this file, we set up: 159 | * A list of values 160 | * What the values should be replaced by in the deployed instance. 161 | 162 | ## Billing portal 163 | 164 | Aside from API Management, the deployment script also deploys the billing portal resource. The billing portal is a `Node.js` app. Consumers directed to the billing portal to activate their subscriptions. You can handle the integration between API Management and the billing portal with the [user registration and product subscription delegation features in API Management](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation). 165 | 166 | As part of the main deployment, the billing portal app is deployed to [Azure Web App for Containers](https://azure.microsoft.com/services/app-service/containers/). The container image is pulled from the [GitHub Container Registry](https://docs.github.com/en/packages/guides/about-github-container-registry) associated with this repo. 167 | 168 | You can also add configuration to the app as part of the payment provider initialization (Adyen and Stripe). 169 | 170 | ## Next Steps 171 | * Learn more about configuring and initializing both [Stripe](stripe-details.md) and [Adyen](adyen-details.md) in more detail. 172 | * Understand how [API Management supports monetization](https://docs.microsoft.com/azure/api-management/monetization-support). -------------------------------------------------------------------------------- /documentation/stripe-deploy.md: -------------------------------------------------------------------------------- 1 | # Deploy demo with Stripe 2 | 3 | In this tutorial, you'll deploy the demo Stripe account and learn how to: 4 | 5 | > [!div class="checklist"] 6 | > * Set up a Stripe account, the required PowerShell and `az cli` tools, an Azure subscription, and a service principal on Azure. 7 | > * Deploy the Azure resources using either Azure portal or PowerShell. 8 | > * Make your deployment visible to consumers by publishing the Azure developer portal. 9 | > * Initialize the Stripe products and prices. 10 | 11 | ## Pre-requisites 12 | 13 | To prepare for this demo, you'll need to: 14 | 15 | > [!div class="checklist"] 16 | > * Create a Stripe account. 17 | > * Install and set up the required PowerShell and Azure CLI tools. 18 | > * Set up an Azure subscription. 19 | > * Set up a service principal in Azure. 20 | 21 | ### [Create a Stripe account](https://dashboard.stripe.com/register) 22 | 23 | 1. Once you've [created a Stripe account](https://dashboard.stripe.com/register), navigate to the **Developers** tab in the Stripe dashboard. 24 | 1. Use the **API keys** menu to create the following two API keys with specific permissions on different APIs. 25 | 26 | | Key name | Description | Permissions | 27 | |------------------------|--------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| 28 | | **Initialization key** | Use to initialize Stripe with products, prices, and webhooks |
  • Products: `write`
  • Prices: `write`
  • Webhook endpoints: `write`
| 29 | | **App key** | Used by application to create checkout sessions, subscriptions, and payments for consumers |
  • Checkout sessions: `write`
  • Subscriptions: `write`
  • Usage records: `write`
  • Prices: `read`
  • Products: `read`
| 30 | 31 | ### Install and set up the required tools 32 | 33 | - Version 7.1 or later of [PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.1). 34 | - Version 2.21.0 or later of [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli). 35 | 36 | ### Set up an Azure subscription with admin access 37 | 38 | For this sample project, you will need admin access in order to deploy all the included artifacts to Azure. If you do not have an Azure subscription, set up a [free trial](https://azure.microsoft.com/). 39 | 40 | ### Set up a service principal on Azure 41 | 42 | For the solution to work, the Web App component needs a privileged credential on your Azure subscription with the scope to execute `read` operations on API Management (get products, subscriptions, etc.). 43 | 44 | Before deploying the resources, set up the service principal in the Azure Active Directory (AAD) tenant used by the Web App to update the status of API Management subscriptions. 45 | 46 | The simplest method is using the Azure CLI. 47 | 48 | 1. [Sign in with Azure CLI](https://docs.microsoft.com/cli/azure/authenticate-azure-cli#sign-in-interactively): 49 | 50 | ```azurecli-interactive 51 | az login 52 | ``` 53 | 2. [Create an Azure service principal with the Azure CLI](https://docs.microsoft.com/cli/azure/create-an-azure-service-principal-azure-cli#password-based-authentication): 54 | 55 | ```azurecli-interactive 56 | az ad sp create-for-rbac --name --skip-assignment 57 | ``` 58 | 59 | 3. Take note of the `name` (ID), `appId` (client ID) and `password` (client secret), as you will need to pass these values as deployment parameters. 60 | 61 | 4. Retrieve the **object ID** of your new service principal for deployment: 62 | 63 | ```azurecli-interactive 64 | az ad sp show --id --query '{displayName: displayName, appId: appId, objectId: id}' 65 | ``` 66 | 67 | The correct role assignments for the service principal will be assigned as part of the deployment. 68 | 69 | ## Deploy the Azure monetization resources 70 | 71 | You can deploy the monetization resource via either Azure portal or PowerShell script. 72 | 73 | >[!NOTE] 74 | > For both options, when filling in parameters, leave the `adyen*` parameters blank. 75 | 76 | ### Azure portal 77 | 78 | Click the button below to deploy the example to Azure and fill in the required parameters in the Azure portal. 79 | 80 | [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fazure-api-management-monetization%2Fmain%2Foutput%2Fmain.json) 81 | 82 | ### PowerShell script 83 | 84 | You can deploy by running the `deploy.ps1` PowerShell script at the root of the repo. 85 | 86 | 1. Provide a parameters file for the `main.json` ARM template. 87 | * Find a template for the parameters file provider in `output/main.parameters.template.json`. 88 | * Rename this JSON file to `output/main.parameters.json` and update the values as necessary. 89 | 90 | 2. Execute the `deploy.ps1` script: 91 | 92 | ```powershell 93 | deploy.ps1 ` 94 | -TenantId "" ` 95 | -SubscriptionId "" ` 96 | -ResourceGroupName "apimmonetization" ` 97 | -ResourceGroupLocation "uksouth" ` 98 | -ArtifactStorageAccountName "" 99 | ``` 100 | 101 | ## Publish the API Management developer portal 102 | 103 | This example project uses the hosted [API Management developer portal](https://docs.microsoft.com/azure/api-management/api-management-howto-developer-portal). 104 | 105 | You are required to complete a manual step to publish and make the resources visible to customers. See the [Publish the portal](https://docs.microsoft.com/en-us/azure/api-management/api-management-howto-developer-portal-customize#publish) for instructions. 106 | 107 | ## Initialize Stripe products and prices 108 | 109 | Once you've deployed the billing portal, the API Management service, and the products defined within API Management, you'll need to initialize the products in Stripe. Use [the Stripe initialization PowerShell script](../payment/stripeInitialisation.ps1). 110 | 111 | 1. Run the script using the following parameters: 112 | 113 | ```powershell 114 | ./payment/stripeInitialisation.ps1 ` 115 | -StripeApiKey "" ` 116 | -ApimGatewayUrl "" ` 117 | -ApimSubscriptionKey "" ` 118 | -StripeWebhookUrl "https:///webhook/stripe" ` 119 | -AppServiceResourceGroup "" ` 120 | -AppServiceName "" 121 | ``` 122 | 123 | 1. The script makes two API calls: 124 | 125 | * To retrieve the API Management products. 126 | * To retrieve the monetization model definitions. 127 | 128 | 1. For each of the monetization models defined, the script: 129 | 130 | * Finds the corresponding APIM product. 131 | * Uses the Stripe CLI to create a Stripe product. 132 | * For that Stripe product, creates the corresponding price for the model. 133 | 134 | 1. The script: 135 | 136 | * Creates a webhook in Stripe to listen for: 137 | * Stripe subscription created events (to create API Management subscriptions when a consumer completes checkout). 138 | * Failed/cancelled Stripe subscription events (to deactivate API Management subscriptions when consumers cease payment). 139 | * Adds the secret for webhook connection to the billing portal app settings, so that the app can attach listeners and handle these events. 140 | 141 | ## Next Steps 142 | 143 | * Learn more about [deploying API Management monetization with Stripe](stripe-details.md). 144 | * Learn about the [Adyen deployment](adyen-details.md) option. 145 | -------------------------------------------------------------------------------- /documentation/stripe-details.md: -------------------------------------------------------------------------------- 1 | # How to implement monetization with Azure API Management and Stripe 2 | 3 | You can configure the API Management and Stripe to implement products defined in the revenue model (Free, Developer, PAYG, Basic, Standard, Pro, Enterprise). Once implemented, API consumers can browse, select, and subscribe to products via the developer portal. 4 | 5 | To deliver a consistent end-to-end API consumer experience, you'll synchronize the API Management product policies and the Stripe configuration using a shared configuration file [payment/monetizationModels.json](../payment/monetizationModels.json). 6 | 7 | In this demo project, we'll implement the example revenue model defined in [the monetization overview](https://docs.microsoft.com/azure/api-management/monetization-overview#step-4---design-the-revenue-model) to demonstrate integrating Azure API Management with Stripe. 8 | 9 | ## Stripe 10 | 11 | As a tech company, [Stripe](https://stripe.com/) builds economic infrastructure for the internet. Stripe provides a fully featured payment platform. With Stripe's platform, you can monetize your APIs hosted in Azure API Management to API consumers. 12 | 13 | Both API Management and Stripe define the concept of products and subscriptions, but only Stripe has the notion of pricing. 14 | 15 | In Stripe, you can define one or more associated prices against a product. Recurring prices (billed more than once) can be `Licensed` or `Metered`. 16 | 17 | | Recurring prices | Description | 18 | | ---------------- | ----------- | 19 | | `Licensed` | Billed automatically at the given interval. In the example, it's set to monthly. | 20 | | `Metered` | Calculates the monthly cost based on
  • Usage records
  • The set price per unit
| 21 | 22 | The following table builds on the conceptual revenue model in [the monetization overview](https://docs.microsoft.com/azure/api-management/monetization-overview) and provides more detail about implementing using API Management and Stripe: 23 | 24 | | Products implemented in both API Management and Stripe | Pricing model | Stripe configuration | Quality of service (API Management product policies) | 25 | |------------------------------------------------|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| 26 | | Free | `Free` | No configuration required. | Quota set to limit the consumer to 100 calls/month. | 27 | | Developer | `Freemium ` | Metered, graduated tiers:
  • First tier flat amount is $0.
  • Next tiers per unit amount charge set to charge $0.20/100 calls.
| No quota set. Consumer can continue to make and pay for calls with a rate limit of 100 calls/minute. | 28 | | PAYG | `Metered` | Metered. Price set to charge consumer $0.15/100 calls. | No quota set. Consumer can continue to make and pay for calls with a rate limit of 200 calls/minute. | 29 | | Basic | `Tier` | Licensed. Price set to charge consumer $14.95/month. | Quota set to limit the consumer to 50,000 calls/month with a rate limit of 100 calls/minute. | 30 | | Standard | `Tier + Overage` | Metered, graduated tiers:
  • First tier flat amount is $89.95/month for first 100,000 calls.
  • Next tiers per unit amount charge set to charge $0.10/100 calls.
| No quota set. Consumer can continue to make and pay for extra calls with a rate limit of 100 calls/minute. | 31 | | Pro | `Tier + Overage` | Metered, graduated tiers:
  • First tier flat amount is $449.95/month for first 500,000 calls.
  • Next tiers per unit amount charge set to charge $0.06/100 calls.
| No quota set. Consumer can continue to make and pay for extra calls with a rate limit of 1,200 calls/minute. | 32 | | Enterprise | `Unit` | Metered, graduated tiers. Every tier flat amount is $749.95/month for 1,500,000 calls. | No quota set. Consumer can continue to make and pay for extra calls with a rate limit of 3,500 calls/minute. | 33 | 34 | ## Architecture 35 | 36 | The following diagram illustrates: 37 | * The components of the solution across API Management, the billing app, and Stripe. 38 | * The major integration flows between components, including the interactions between the API consumer (both developer and application) and the solution. 39 | 40 | ![Stripe architecture overview](architecture-stripe.png) 41 | 42 | 43 | ## API consumer flow 44 | 45 | The API consumer flow describes the end-to-end user journey supported by the solution. Typically, the API consumer is a developer tasked with integrating their organization's own application with your API. The API consumer flow aims to support bringing the user from API discovery, through API consumption, to paying for API usage. 46 | 47 | ### API consumer flow 48 | 49 | 1. Consumer selects **Sign up** in the API Management developer portal. 50 | 2. Developer portal redirects consumer to the billing portal app to register their account via [API Management delegation](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation). 51 | 3. Upon successful registration, consumer is authenticated and returned back to the developer portal. 52 | 4. Consumer selects a product to subscribe to in the developer portal. 53 | 5. Developer portal redirects consumer to the billing portal app via delegation. 54 | 6. Consumer enters a display name for their subscription and selects checkout. 55 | 7. Stripe checkout session starts, using the product definition to retrieve the product prices. 56 | 8. Consumer inputs credit card details into Stripe checkout session. 57 | 9. On successful checkout, the API Management subscription is created and enabled. 58 | 10. Based on the product and usage amount, consumer is billed monthly. 59 | 11. If payment fails, subscription is suspended. 60 | 61 | ### Steps 1 - 3: Register an account 62 | 63 | 1. Find the developer portal for an API Management service at `https://{ApimServiceName}.developer.azure-api.net`. 64 | 1. Select **Sign Up** to be redirected to the billing portal app. 65 | 1. On the billing portal app, register for an account. 66 | * Handled via [user registration delegation](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation#-delegating-developer-sign-in-and-sign-up). 67 | 1. Upon successful account creation, the consumer is authenticated and redirected to the developer portal. 68 | 69 | Once the consumer creates an account, they'll only need to sign into the existing account to browse APIs and products from the developer portal. 70 | 71 | ### Steps 4 - 5: Subscribes to products and retrieve API keys 72 | 73 | 1. Log into the developer portal. 74 | 1. Search for a product and select **Subscribe** to begin a new subscription. 75 | 1. Consumer will be redirected to the billing portal app. 76 | * This is handled via [product subscription delegation](https://docs.microsoft.com/azure/api-management/api-management-howto-setup-delegation#-delegating-product-subscription). 77 | 78 | ### Steps 5 - 6: Billing portal 79 | 80 | 1. Once redirected to the billing portal, enter a display name for the subscription. 81 | 1. Select **Checkout** to be redirected to the Stripe checkout page. 82 | 83 | ### Steps 7 - 8: Stripe checkout session 84 | 85 | 1. From the Stripe checkout page, [create a new Stripe checkout session](https://stripe.com/docs/api/checkout/sessions) using a [checkout session API](../app/src/routes/stripe.ts) defined within the application. 86 | 1. Pass into this API: 87 | * The API Management user ID. 88 | * The API Management product ID you wish to activate. 89 | * The URL to return to on checkout completion. 90 | 1. With the product name, retrieve the list of prices linked to that product within Stripe using the [Stripe Node SDK](https://stripe.com/docs/api?lang=node). 91 | * You can also use the monetization model API to retrieve the registered product type using the product name (`Freemium`, `Metered`, `Tier`, `TierWithOverage`, `Unit`, or `MeteredUnit`). 92 | 1. Create a Stripe checkout session using following parameters: 93 | 94 | | Parameter | Description | 95 | | --------- | ----------- | 96 | | **Success url** | The URL consumers are redirected to if the checkout is successful. Hosted within the web application. | 97 | | **Cancel url** | The URL consumers are redirected to if the checkout is canceled. Hosted within the web application. | 98 | | **Payment method types** | Set to "card". | 99 | | **Mode** | Set to "subscription". Consumer will receive recurring charges. | 100 | | **Metadata** | Pass the API Management user ID, product ID, and subscription name.
  • Retrieve this metadata in the event raised by creating a Stripe subscription.
  • Use within event listener to create associated API Management subscription.
| 101 | | **Line items** | Set up a line item for the price associated with the product. | 102 | 103 | 1. Return the session ID from our API. 104 | 1. Once back in checkout view, use the `stripe.redirectToCheckout` function to: 105 | * Redirect the consumer to the checkout session. 106 | * Ask the consumer to enter their card details and authorize monthly payment at either set price or based on usage. 107 | 108 | 1. Once complete, the Consumer is redirected to the **Success URL** you passed into the Stripe checkout session. 109 | 110 | ### Step 9: API Management subscription created 111 | 112 | 1. Once you've successfully completed a checkout session and create a Stripe subscription, a `customer.subscription.created` event is raised within Stripe. 113 | * As part of the Stripe initialization, a webhook was defined to listen for this event. 114 | 1. Within our web application, add a [listener for these events](../app/src/routes/stripe.ts). 115 | 1. The event data attached to these events contains all the data defined on the `StripeCheckoutSession`. When a new event is raised, retrieve the metadata attached to the checkout session. 116 | * Earlier, when setting up the session, you set this as the API Management user ID, product ID, and subscription name. 117 | 1. Within the listener, create the API Management subscription via the API Management service management API. 118 | * The web app authenticates to the API Management management API using a service principal. The service principal credentials are available via the app settings. 119 | 1. Consumer's paid subscription is created. 120 | 1. Consumer starts using API keys to access the APIs provided by subscription. 121 | 122 | ### Step 10: Billing 123 | 124 | #### Non-metered prices 125 | 126 | Stripe will automatically charge the consumer each billing period by their fixed amount. 127 | 128 | #### Metered prices 129 | 130 | [Report the consumer's usage to Stripe](https://stripe.com/docs/billing/subscriptions/metered-billing#reporting-usage) using the logic in the [StripeBillingService](../app/src/services/stripeBillingService.ts). Once you've reported usage, Stripe calculates the amount to charge. 131 | 132 | 1. Register a daily CRON job to: 133 | * Run a function for querying usage from API Management using the API Management management API. 134 | * Posts the number of units of usage to Stripe. 135 | 1. At the end of each billing period, Stripe will automatically calculate the amount to charge based on the reported usage. 136 | 137 | ### Step 11: Subscription suspended 138 | 139 | Along with the `customer.subscription.created` event, the webhook listener also listens for the `customer.subscription.updated` and `customer.subscription.deleted` events. 140 | 141 | If the subscription is canceled or moves into an unpaid state, update the API Management subscription into a suspended state so that the consumer can no longer access the APIs. 142 | 143 | ## Next steps 144 | 145 | * [Deploy demo with Stripe](stripe-deploy.md) as described in this document. 146 | * Learn about how [API Management integrates with the Adyen](adyen-details.md) payment option. -------------------------------------------------------------------------------- /output/main.parameters.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "apimServiceName": { 6 | "value": "apimpaymentproviderdemo" // Change this to a globally unique value 7 | }, 8 | "apimPublisherEmail": { 9 | "value": "foo@contoso.com" // Change this to your desired email address for API Management publisher 10 | }, 11 | "apimPublisherName": { 12 | "value": "Contoso" // Change this to your desired API Management publisher name 13 | }, 14 | "appServiceHostingPlanName": { 15 | "value": "apimpaymentproviderdemo" // Change this to a globally unique value 16 | }, 17 | "appServiceName": { 18 | "value": "apimpaymentproviderdemo" // Change this to a globally unique value 19 | }, 20 | "servicePrincipalAppId": { 21 | "value": "" 22 | }, 23 | "servicePrincipalPassword": { 24 | "value": "" 25 | }, 26 | "servicePrincipalObjectId": { 27 | "value": "" 28 | }, 29 | "servicePrincipalTenantId": { 30 | "value": "" 31 | }, 32 | "paymentProvider":{ 33 | "value": "Stripe" // Value should be Adyen or Stripe 34 | }, 35 | "stripeApiKey": { 36 | "value": "" // Required if using Stripe 37 | }, 38 | "stripePublicKey": { 39 | "value": "" // Required if using Stripe 40 | }, 41 | "adyenApiKey": { 42 | "value": "" // Required if using Adyen 43 | }, 44 | "adyenClientKey": { 45 | "value": "" // Required if using Adyen 46 | }, 47 | "adyenMerchantAccount":{ 48 | "value": "" // Required if using Adyen 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /payment/monetizationModels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "payg", 4 | "pricingModelType": "Metered", 5 | "recurring": { 6 | "interval": "month", 7 | "intervalCount": 1 8 | }, 9 | "prices": { 10 | "metered": { 11 | "currency": "usd", 12 | "unitAmount": 15 13 | } 14 | } 15 | }, 16 | { 17 | "id": "developer", 18 | "pricingModelType": "Freemium", 19 | "recurring": { 20 | "interval": "month", 21 | "intervalCount": 1 22 | }, 23 | "prices": { 24 | "unit": { 25 | "currency": "usd", 26 | "unitAmount": 0, 27 | "quota": 1 28 | }, 29 | "metered": { 30 | "currency": "usd", 31 | "unitAmount": 20 32 | } 33 | } 34 | }, 35 | { 36 | "id": "basic", 37 | "pricingModelType": "Tier", 38 | "recurring": { 39 | "interval": "month", 40 | "intervalCount": 1 41 | }, 42 | "prices": { 43 | "unit": { 44 | "currency": "usd", 45 | "unitAmount": 1495, 46 | "quota": 500 47 | } 48 | } 49 | }, 50 | { 51 | "id": "standard", 52 | "pricingModelType": "TierWithOverage", 53 | "recurring": { 54 | "interval": "month", 55 | "intervalCount": 1 56 | }, 57 | "prices": { 58 | "unit": { 59 | "currency": "usd", 60 | "unitAmount": 8995, 61 | "quota": 1000 62 | }, 63 | "metered": { 64 | "currency": "usd", 65 | "unitAmount": 10 66 | } 67 | } 68 | }, 69 | { 70 | "id": "pro", 71 | "pricingModelType": "TierWithOverage", 72 | "recurring": { 73 | "interval": "month", 74 | "intervalCount": 1 75 | }, 76 | "prices": { 77 | "unit": { 78 | "currency": "usd", 79 | "unitAmount": 44900, 80 | "quota": 5000, 81 | "maxUnits": 1 82 | }, 83 | "metered": { 84 | "currency": "usd", 85 | "unitAmount": 6 86 | } 87 | } 88 | }, 89 | { 90 | "id": "enterprise", 91 | "pricingModelType": "Unit", 92 | "recurring": { 93 | "interval": "month", 94 | "intervalCount": 1 95 | }, 96 | "prices": { 97 | "unit": { 98 | "currency": "usd", 99 | "unitAmount": 74900, 100 | "quota": 15000 101 | } 102 | } 103 | } 104 | ] -------------------------------------------------------------------------------- /payment/notes.md: -------------------------------------------------------------------------------- 1 | For the monetization model: 2 | - 1 unit == 100 calls 3 | - Quotas are in units (i.e. quota of 1 means a quota of 100 calls) 4 | - Unit amounts are in cents (i.e. cost in cents per 100 calls) -------------------------------------------------------------------------------- /payment/stripeInitialisation.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter(Mandatory=$true)] 4 | [String] 5 | $StripeApiKey, 6 | 7 | [Parameter(Mandatory=$true)] 8 | [String] 9 | $ApimGatewayUrl, 10 | 11 | [Parameter(Mandatory=$true)] 12 | [String] 13 | $ApimSubscriptionKey, 14 | 15 | [Parameter(Mandatory=$true)] 16 | [String] 17 | $StripeWebhookUrl, 18 | 19 | [Parameter(Mandatory=$true)] 20 | [String] 21 | $AppServiceResourceGroup, 22 | 23 | [Parameter(Mandatory=$true)] 24 | [String] 25 | $AppServiceName 26 | ) 27 | 28 | $ErrorActionPreference = "Stop" 29 | $here = Split-Path -Parent $PSCommandPath 30 | $toolsDir = "$here/../tools" 31 | 32 | if ($IsWindows) { 33 | $osPath = "windows_x86_64.zip" 34 | } 35 | elseif ($IsLinux) { 36 | $osPath = "linux_x86_64.tar.gz" 37 | } 38 | elseif ($IsMacOS) { 39 | $osPath = "mac-os_x86_64.tar.gz" 40 | } 41 | else { 42 | throw "Unsupported OS" 43 | } 44 | 45 | function installStripe { 46 | $version = "1.5.13" 47 | $stripeUri = "https://github.com/stripe/stripe-cli/releases/download/v$version/stripe_$($version)_$osPath" 48 | 49 | $dir = New-Item -Path $toolsDir/stripe/$version -ItemType Directory -Force 50 | $stripe = "$dir/stripe" 51 | if (Test-Path $dir/* -Filter stripe*) { 52 | Write-Host "Stripe CLI already installed." 53 | } 54 | else { 55 | if ($IsWindows) { 56 | $stripeZipPath = "$dir/stripe.zip" 57 | Invoke-WebRequest -Uri $stripeUri -OutFile $stripeZipPath | Out-Null 58 | Expand-Archive -Path $stripeZipPath -DestinationPath $dir -Force | Out-Null 59 | Remove-Item $stripeZipPath -Force | Out-Null 60 | } 61 | else { 62 | $stripeTarGzPath = "$dir/stripe.tar.gz" 63 | Invoke-WebRequest -Uri $stripeUri -OutFile $stripeTarGzPath | Out-Null 64 | tar -xvf $stripeTarGzPath -C $dir 65 | Remove-Item $stripeTarGzPath -Force | Out-Null 66 | } 67 | } 68 | 69 | return $stripe 70 | } 71 | 72 | $stripe = installStripe 73 | 74 | $env:STRIPE_API_KEY = $StripeApiKey 75 | 76 | $monetizationModels = Invoke-WebRequest -Uri $ApimGatewayUrl/billing/monetizationModels -Headers @{"Ocp-Apim-Subscription-Key"=$ApimSubscriptionKey} | ConvertFrom-Json 77 | $apimProducts = Invoke-WebRequest -Uri $ApimGatewayUrl/billing/products -Headers @{"Ocp-Apim-Subscription-Key"=$ApimSubscriptionKey} | ConvertFrom-Json 78 | 79 | foreach ($model in $monetizationModels) { 80 | $apimProduct = $apimProducts.value | Where-Object { $_.name -eq $model.id } 81 | 82 | Write-Host "Creating product and prices for model $($model.id) ($($apimProduct.name))..." 83 | 84 | . $stripe post /v1/products ` 85 | -d "name=$($apimProduct.properties.displayName)" ` 86 | -d "id=$($apimProduct.name)" ` 87 | -d "description=$($apimProduct.properties.description)" 88 | 89 | $prices = . $stripe get /v1/prices ` 90 | -d "product=$($model.id)" ` 91 | -d "active=true" | ConvertFrom-Json 92 | 93 | if (!($prices.data[0])) { 94 | if ($model.pricingModelType -eq "Metered") { 95 | . $stripe post /v1/prices ` 96 | -d "product=$($model.id)" ` 97 | -d "currency=$($model.prices.metered.currency)" ` 98 | -d "unit_amount_decimal=$($model.prices.metered.unitAmount)" ` 99 | -d "recurring[interval]=$($model.recurring.interval)" ` 100 | -d "recurring[interval_count]=$($model.recurring.intervalCount)" ` 101 | -d "recurring[usage_type]=metered" 102 | } 103 | if (($model.pricingModelType -eq "Freemium") -or ($model.pricingModelType -eq "TierWithOverage")) { 104 | . $stripe post /v1/prices ` 105 | -d "product=$($model.id)" ` 106 | -d "currency=$($model.prices.metered.currency)" ` 107 | -d "recurring[interval]=$($model.recurring.interval)" ` 108 | -d "recurring[interval_count]=$($model.recurring.intervalCount)" ` 109 | -d "recurring[usage_type]=metered" ` 110 | -d "billing_scheme=tiered" ` 111 | -d "tiers_mode=graduated" ` 112 | -d "tiers[0][up_to]=$($model.prices.unit.quota)" ` 113 | -d "tiers[0][flat_amount]=$($model.prices.unit.unitAmount)" ` 114 | -d "tiers[1][up_to]=inf" ` 115 | -d "tiers[1][unit_amount_decimal]=$($model.prices.metered.unitAmount)" 116 | } 117 | if (($model.pricingModelType -eq "Tier")) { 118 | . $stripe post /v1/prices ` 119 | -d "product=$($model.id)" ` 120 | -d "currency=$($model.prices.unit.currency)" ` 121 | -d "unit_amount_decimal=$($model.prices.unit.unitAmount)" ` 122 | -d "recurring[interval]=$($model.recurring.interval)" ` 123 | -d "recurring[interval_count]=$($model.recurring.intervalCount)" ` 124 | -d "recurring[usage_type]=licensed" 125 | } 126 | if (($model.pricingModelType -eq "Unit")) { 127 | # NOTE: we're only going up to 5 tiers for this example 128 | . $stripe post /v1/prices ` 129 | -d "product=$($model.id)" ` 130 | -d "currency=$($model.prices.unit.currency)" ` 131 | -d "recurring[interval]=$($model.recurring.interval)" ` 132 | -d "recurring[interval_count]=$($model.recurring.intervalCount)" ` 133 | -d "recurring[usage_type]=metered" ` 134 | -d "billing_scheme=tiered" ` 135 | -d "tiers_mode=graduated" ` 136 | -d "tiers[0][up_to]=$($model.prices.unit.quota)" ` 137 | -d "tiers[0][flat_amount]=$($model.prices.unit.unitAmount)" ` 138 | -d "tiers[1][up_to]=$(($model.prices.unit.quota) * 2)" ` 139 | -d "tiers[1][flat_amount]=$($model.prices.unit.unitAmount)" ` 140 | -d "tiers[2][up_to]=$(($model.prices.unit.quota) * 3)" ` 141 | -d "tiers[2][flat_amount]=$($model.prices.unit.unitAmount)" ` 142 | -d "tiers[3][up_to]=$(($model.prices.unit.quota) * 4)" ` 143 | -d "tiers[3][flat_amount]=$($model.prices.unit.unitAmount)" ` 144 | -d "tiers[4][up_to]=$(($model.prices.unit.quota) * 5)" ` 145 | -d "tiers[4][flat_amount]=$($model.prices.unit.unitAmount)" ` 146 | -d "tiers[5][up_to]=inf" ` 147 | -d "tiers[5][flat_amount]=$($model.prices.unit.unitAmount)" 148 | } 149 | } 150 | 151 | 152 | } 153 | 154 | $webhookEndpoints = (. $stripe get /v1/webhook_endpoints) | ConvertFrom-Json 155 | 156 | $webhookEndpoint = $webhookEndpoints.data | Where-Object { ($_.url -eq $StripeWebhookUrl) -and ($_.enabled_events -contains 'charge.failed') -and ($_.enabled_events -contains 'checkout.session.completed')} 157 | 158 | if ($webhookEndpoint) { 159 | Write-Host "Found existing webhook endpoint. Deleting..." 160 | . $stripe delete /v1/webhook_endpoints/$($webhookEndpoint.id) --confirm 161 | } 162 | 163 | Write-Host "Creating new webhook endpoint..." 164 | 165 | $webhookEndpoint = (. $stripe post /v1/webhook_endpoints ` 166 | -d "enabled_events[]=customer.subscription.created" ` 167 | -d "enabled_events[]=customer.subscription.updated" ` 168 | -d "enabled_events[]=customer.subscription.deleted" ` 169 | -d "url=$StripeWebhookUrl") | ConvertFrom-Json 170 | 171 | $webhookSecret = $webhookEndpoint.secret 172 | 173 | Write-Host "Updating appsettings with webhook secret..." 174 | 175 | az webapp config appsettings set -g $AppServiceResourceGroup -n $AppServiceName --settings STRIPE_WEBHOOK_SECRET=$webhookSecret | Out-Null -------------------------------------------------------------------------------- /templates/apim-instance.bicep: -------------------------------------------------------------------------------- 1 | @description('The API Management instance service name') 2 | param serviceName string 3 | 4 | @description('The email address of the owner of the service') 5 | param publisherEmail string 6 | 7 | @description('The name of the owner of the service') 8 | param publisherName string 9 | 10 | @description('The pricing tier of this API Management service') 11 | @allowed([ 12 | 'Developer' 13 | 'Standard' 14 | 'Premium' 15 | ]) 16 | param sku string = 'Developer' 17 | 18 | @description('The instance size of this API Management service.') 19 | param skuCount int = 1 20 | 21 | @description('Location for all resources.') 22 | param location string = resourceGroup().location 23 | 24 | @description('The URL for delegation requests from APIM') 25 | param delegationUrl string 26 | 27 | @description('The validation key used for delegation requests from APIM. Default value will generate a new GUID. If deploying to production, consider setting this parameter explicitly to avoid the value being regenerated for new deployments.') 28 | param delegationValidationKeyRaw string = newGuid() 29 | 30 | var readerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7') 31 | 32 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' = { 33 | name: serviceName 34 | location: location 35 | sku: { 36 | name: sku 37 | capacity: skuCount 38 | } 39 | identity: { 40 | type: 'SystemAssigned' 41 | } 42 | properties: { 43 | publisherEmail: publisherEmail 44 | publisherName: publisherName 45 | } 46 | 47 | resource masterSubscription 'subscriptions' existing = { 48 | name: 'master' 49 | } 50 | } 51 | 52 | resource apiManagementServiceDelegation 'Microsoft.ApiManagement/service/portalsettings@2021-01-01-preview' = { 53 | parent: apiManagementService 54 | name: 'delegation' 55 | properties: { 56 | url: delegationUrl 57 | validationKey: base64(delegationValidationKeyRaw) 58 | subscriptions: { 59 | enabled: true 60 | } 61 | userRegistration: { 62 | enabled: true 63 | } 64 | } 65 | } 66 | 67 | resource apimManagedIdentityReaderRole 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { 68 | scope: resourceGroup() 69 | name: guid(apiManagementService.id, readerRoleId) 70 | properties: { 71 | roleDefinitionId: readerRoleId 72 | principalId: apiManagementService.identity.principalId 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /templates/apimmonetization-apis-address.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | param serviceUrl object 3 | param artifactsBaseUrl string 4 | 5 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 6 | name: apimServiceName 7 | } 8 | 9 | resource addressApi 'Microsoft.ApiManagement/service/apis@2019-01-01' = { 10 | parent: apiManagementService 11 | name: 'address' 12 | properties: { 13 | isCurrent: false 14 | subscriptionRequired: true 15 | displayName: 'address' 16 | serviceUrl: serviceUrl.address 17 | path: 'address' 18 | protocols: [ 19 | 'https' 20 | ] 21 | value: '${artifactsBaseUrl}/apiConfiguration/openApi/address.yaml' 22 | format: 'openapi-link' 23 | } 24 | 25 | resource validateAddressOperation 'operations' existing = { 26 | name: 'validate_address' 27 | 28 | resource policy 'policies' = { 29 | name: 'policy' 30 | properties: { 31 | value: '${artifactsBaseUrl}/apiConfiguration/policies/apis/address-validate_address.xml' 32 | format: 'xml-link' 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /templates/apimmonetization-apis-billing.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | param serviceUrl object 3 | param artifactsBaseUrl string 4 | 5 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 6 | name: apimServiceName 7 | } 8 | 9 | resource billingApi 'Microsoft.ApiManagement/service/apis@2019-01-01' = { 10 | parent: apiManagementService 11 | name: 'billing' 12 | properties: { 13 | isCurrent: false 14 | subscriptionRequired: true 15 | displayName: 'billing' 16 | serviceUrl: serviceUrl.billing 17 | path: 'billing' 18 | protocols: [ 19 | 'https' 20 | ] 21 | value: '${artifactsBaseUrl}/apiConfiguration/openApi/billing.yaml' 22 | format: 'openapi-link' 23 | } 24 | 25 | resource getMonetizationModelsOperation 'operations' existing = { 26 | name: 'get_monetization_models' 27 | 28 | resource policy 'policies' = { 29 | name: 'policy' 30 | properties: { 31 | value: '${artifactsBaseUrl}/apiConfiguration/policies/apis/billing-get_monetization_models.xml' 32 | format: 'rawxml-link' 33 | } 34 | } 35 | } 36 | 37 | resource getProductsOperation 'operations' existing = { 38 | name: 'get_products' 39 | 40 | resource policy 'policies' = { 41 | name: 'policy' 42 | properties: { 43 | value: '${artifactsBaseUrl}/apiConfiguration/policies/apis/billing-get_products.xml' 44 | format: 'xml-link' 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /templates/apimmonetization-globalServicePolicy.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | param artifactsBaseUrl string 3 | 4 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 5 | name: apimServiceName 6 | } 7 | 8 | resource apiManagementServicePolicy 'Microsoft.ApiManagement/service/policies@2019-01-01' = { 9 | parent: apiManagementService 10 | name: 'policy' 11 | properties: { 12 | value: '${artifactsBaseUrl}/apiConfiguration/policies/global.xml' 13 | format: 'xml-link' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /templates/apimmonetization-namedValues.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | param subscriptionId string 3 | param resourceGroupName string 4 | param appServiceName string 5 | param artifactsBaseUrl string 6 | 7 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 8 | name: apimServiceName 9 | } 10 | 11 | resource subscriptionIDProperty 'Microsoft.ApiManagement/service/properties@2019-01-01' = { 12 | parent: apiManagementService 13 | name: 'subscriptionId' 14 | properties: { 15 | secret: false 16 | displayName: 'subscriptionId' 17 | value: subscriptionId 18 | } 19 | } 20 | 21 | resource resourceGroupNameProperty 'Microsoft.ApiManagement/service/properties@2019-01-01' = { 22 | parent: apiManagementService 23 | name: 'resourceGroupName' 24 | properties: { 25 | secret: false 26 | displayName: 'resourceGroupName' 27 | value: resourceGroupName 28 | } 29 | } 30 | 31 | resource apimServiceNameProperty 'Microsoft.ApiManagement/service/properties@2019-01-01' = { 32 | parent: apiManagementService 33 | name: 'apimServiceName' 34 | properties: { 35 | secret: false 36 | displayName: 'apimServiceName' 37 | value: apimServiceName 38 | } 39 | } 40 | 41 | resource appServiceNameProperty 'Microsoft.ApiManagement/service/properties@2019-01-01' = { 42 | parent: apiManagementService 43 | name: 'appServiceName' 44 | properties: { 45 | secret: false 46 | displayName: 'appServiceName' 47 | value: appServiceName 48 | } 49 | } 50 | 51 | resource monetizationModelsUrlProperty 'Microsoft.ApiManagement/service/properties@2019-01-01' = { 52 | parent: apiManagementService 53 | name: 'monetizationModelsUrl' 54 | properties: { 55 | secret: false 56 | displayName: 'monetizationModelsUrl' 57 | value: '${artifactsBaseUrl}/payment/monetizationModels.json' 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /templates/apimmonetization-productAPIs.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | 3 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 4 | name: apimServiceName 5 | 6 | resource freeProduct 'products' existing = { 7 | name: 'free' 8 | 9 | resource addressApi 'apis' = { 10 | name: 'address' 11 | } 12 | } 13 | 14 | resource developerProduct 'products' existing = { 15 | name: 'developer' 16 | 17 | resource addressApi 'apis' = { 18 | name: 'address' 19 | } 20 | } 21 | 22 | resource paygProduct 'products' existing = { 23 | name: 'payg' 24 | 25 | resource addressApi 'apis' = { 26 | name: 'address' 27 | } 28 | } 29 | 30 | resource basicProduct 'products' existing = { 31 | name: 'basic' 32 | 33 | resource addressApi 'apis' = { 34 | name: 'address' 35 | } 36 | } 37 | 38 | resource standardProduct 'products' existing = { 39 | name: 'standard' 40 | 41 | resource addressApi 'apis' = { 42 | name: 'address' 43 | } 44 | } 45 | 46 | resource proProduct 'products' existing = { 47 | name: 'pro' 48 | 49 | resource addressApi 'apis' = { 50 | name: 'address' 51 | } 52 | } 53 | 54 | resource enterpriseProduct 'products' existing = { 55 | name: 'enterprise' 56 | 57 | resource addressApi 'apis' = { 58 | name: 'address' 59 | } 60 | } 61 | 62 | resource adminProduct 'products' existing = { 63 | name: 'admin' 64 | 65 | resource addressApi 'apis' = { 66 | name: 'address' 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /templates/apimmonetization-productGroups.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | 3 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 4 | name: apimServiceName 5 | 6 | resource freeProduct 'products' existing = { 7 | name: 'free' 8 | 9 | resource guestsGroup 'groups' = { 10 | name: 'guests' 11 | } 12 | 13 | resource developersGroup 'groups' = { 14 | name: 'developers' 15 | } 16 | } 17 | 18 | resource developerProduct 'products' existing = { 19 | name: 'developer' 20 | 21 | resource guestsGroup 'groups' = { 22 | name: 'guests' 23 | } 24 | 25 | resource developersGroup 'groups' = { 26 | name: 'developers' 27 | } 28 | } 29 | 30 | resource paygProduct 'products' existing = { 31 | name: 'payg' 32 | 33 | resource guestsGroup 'groups' = { 34 | name: 'guests' 35 | } 36 | 37 | resource developersGroup 'groups' = { 38 | name: 'developers' 39 | } 40 | } 41 | 42 | resource basicProduct 'products' existing = { 43 | name: 'basic' 44 | 45 | resource guestsGroup 'groups' = { 46 | name: 'guests' 47 | } 48 | 49 | resource developersGroup 'groups' = { 50 | name: 'developers' 51 | } 52 | } 53 | 54 | resource standardProduct 'products' existing = { 55 | name: 'standard' 56 | 57 | resource guestsGroup 'groups' = { 58 | name: 'guests' 59 | } 60 | 61 | resource developersGroup 'groups' = { 62 | name: 'developers' 63 | } 64 | } 65 | 66 | resource proProduct 'products' existing = { 67 | name: 'pro' 68 | 69 | resource guestsGroup 'groups' = { 70 | name: 'guests' 71 | } 72 | 73 | resource developersGroup 'groups' = { 74 | name: 'developers' 75 | } 76 | } 77 | 78 | resource enterpriseProduct 'products' existing = { 79 | name: 'enterprise' 80 | 81 | resource guestsGroup 'groups' = { 82 | name: 'guests' 83 | } 84 | 85 | resource developersGroup 'groups' = { 86 | name: 'developers' 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /templates/apimmonetization-products.bicep: -------------------------------------------------------------------------------- 1 | param apimServiceName string 2 | param artifactsBaseUrl string 3 | 4 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 5 | name: apimServiceName 6 | } 7 | 8 | resource adminProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 9 | parent: apiManagementService 10 | name: 'admin' 11 | properties: { 12 | description: 'Admin' 13 | terms: 'Terms here' 14 | subscriptionRequired: true 15 | approvalRequired: true 16 | subscriptionsLimit: 1 17 | state: 'published' 18 | displayName: 'Admin' 19 | } 20 | } 21 | 22 | resource freeProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 23 | parent: apiManagementService 24 | name: 'free' 25 | properties: { 26 | description: 'Free tier with a monthly quota of 100 calls.' 27 | terms: 'Terms here' 28 | subscriptionRequired: true 29 | approvalRequired: false 30 | subscriptionsLimit: 1 31 | state: 'published' 32 | displayName: 'Free' 33 | } 34 | 35 | resource policy 'policies' = { 36 | name: 'policy' 37 | properties: { 38 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/free.xml' 39 | format: 'xml-link' 40 | } 41 | } 42 | } 43 | 44 | resource developerProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 45 | parent: apiManagementService 46 | name: 'developer' 47 | properties: { 48 | description: 'Developer tier with a free monthly quota of 100 calls and charges for overage.' 49 | terms: 'Terms here' 50 | subscriptionRequired: true 51 | approvalRequired: true 52 | state: 'published' 53 | displayName: 'Developer' 54 | } 55 | 56 | resource policy 'policies' = { 57 | name: 'policy' 58 | properties: { 59 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/developer.xml' 60 | format: 'xml-link' 61 | } 62 | } 63 | } 64 | 65 | resource paygProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 66 | parent: apiManagementService 67 | name: 'payg' 68 | properties: { 69 | description: 'Pay-as-you-go tier.' 70 | terms: 'Terms here' 71 | subscriptionRequired: true 72 | approvalRequired: true 73 | state: 'published' 74 | displayName: 'PAYG' 75 | } 76 | 77 | resource policy 'policies' = { 78 | name: 'policy' 79 | properties: { 80 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/payg.xml' 81 | format: 'xml-link' 82 | } 83 | } 84 | } 85 | 86 | resource basicProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 87 | parent: apiManagementService 88 | name: 'basic' 89 | properties: { 90 | description: 'Basic tier with a monthly quota of 50,000 calls.' 91 | terms: 'Terms here' 92 | subscriptionRequired: true 93 | approvalRequired: true 94 | state: 'published' 95 | displayName: 'Basic' 96 | } 97 | 98 | resource policy 'policies' = { 99 | name: 'policy' 100 | properties: { 101 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/basic.xml' 102 | format: 'xml-link' 103 | } 104 | } 105 | } 106 | 107 | resource standardProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 108 | parent: apiManagementService 109 | name: 'standard' 110 | properties: { 111 | description: 'Standard tier with a monthly quota of 100,000 calls and charges for overage.' 112 | terms: 'Terms here' 113 | subscriptionRequired: true 114 | approvalRequired: true 115 | state: 'published' 116 | displayName: 'Standard' 117 | } 118 | 119 | resource policy 'policies' = { 120 | name: 'policy' 121 | properties: { 122 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/standard.xml' 123 | format: 'xml-link' 124 | } 125 | } 126 | } 127 | 128 | resource proProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 129 | parent: apiManagementService 130 | name: 'pro' 131 | properties: { 132 | description: 'Pro tier with a monthly quota of 500,000 calls and charges for overage.' 133 | terms: 'Terms here' 134 | subscriptionRequired: true 135 | approvalRequired: true 136 | state: 'published' 137 | displayName: 'Pro' 138 | } 139 | 140 | resource policy 'policies' = { 141 | name: 'policy' 142 | properties: { 143 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/pro.xml' 144 | format: 'xml-link' 145 | } 146 | } 147 | } 148 | 149 | resource enterpriseProduct 'Microsoft.ApiManagement/service/products@2019-01-01' = { 150 | parent: apiManagementService 151 | name: 'enterprise' 152 | properties: { 153 | description: 'Enterprise tier with a monthly quota of 1,500,000 calls. Overage is charged in units of 1,500,000 calls.' 154 | terms: 'Terms here' 155 | subscriptionRequired: true 156 | approvalRequired: true 157 | state: 'published' 158 | displayName: 'Enterprise' 159 | } 160 | 161 | resource policy 'policies' = { 162 | name: 'policy' 163 | properties: { 164 | value: '${artifactsBaseUrl}/apiConfiguration/policies/products/enterprise.xml' 165 | format: 'xml-link' 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /templates/app-service-settings.bicep: -------------------------------------------------------------------------------- 1 | param webSiteName string 2 | 3 | param apimServiceName string 4 | 5 | param paymentProvider string 6 | 7 | // Required for Stripe 8 | param stripePublicKey string = '' 9 | param stripeApiKey string = '' 10 | 11 | // Required for Adyen 12 | param adyenApiKey string = '' 13 | param adyenClientKey string = '' 14 | param adyenMerchantAccount string = '' 15 | 16 | param containerPort int 17 | 18 | param servicePrincipalAppId string 19 | 20 | @secure() 21 | param servicePrincipalPassword string 22 | param servicePrincipalTenantId string 23 | 24 | resource apiManagementService 'Microsoft.ApiManagement/service@2020-12-01' existing = { 25 | name: apimServiceName 26 | 27 | resource masterSubscription 'subscriptions@2019-01-01' existing = { 28 | name: 'master' 29 | } 30 | 31 | resource serviceDelegation 'portalsettings@2018-01-01' existing = { 32 | name: 'delegation' 33 | } 34 | } 35 | 36 | resource webSite 'Microsoft.Web/sites@2018-11-01' existing = { 37 | name: webSiteName 38 | } 39 | 40 | resource webSiteAppSettings 'Microsoft.Web/sites/config@2020-06-01' = { 41 | parent: webSite 42 | name: 'appsettings' 43 | properties: { 44 | NODE_ENV: 'production' 45 | SERVER_PORT: '8000' 46 | APIM_MANAGEMENT_URL: apiManagementService.properties.managementApiUrl 47 | APIM_GATEWAY_URL: apiManagementService.properties.gatewayUrl 48 | APIM_DEVELOPER_PORTAL_URL: apiManagementService.properties.developerPortalUrl 49 | APIM_ADMIN_SUBSCRIPTION_KEY: apiManagementService::masterSubscription.properties.primaryKey 50 | STRIPE_PUBLIC_KEY: stripePublicKey 51 | STRIPE_API_KEY: stripeApiKey 52 | WEBSITES_PORT: string(containerPort) 53 | WEBSITES_ENABLE_APP_SERVICE_STORAGE: 'false' 54 | APIM_SERVICE_NAME: apimServiceName 55 | APIM_SERVICE_AZURE_SUBSCRIPTION_ID: subscription().subscriptionId 56 | APIM_SERVICE_AZURE_RESOURCE_GROUP_NAME: resourceGroup().name 57 | APIM_DELEGATION_VALIDATION_KEY: apiManagementService::serviceDelegation.properties.validationKey 58 | AZURE_AD_SERVICE_PRINCIPAL_APP_ID: servicePrincipalAppId 59 | AZURE_AD_SERVICE_PRINCIPAL_PASSWORD: servicePrincipalPassword 60 | AZURE_AD_SERVICE_PRINCIPAL_TENANT_ID: servicePrincipalTenantId 61 | PAYMENT_PROVIDER: paymentProvider 62 | ADYEN_MERCHANT_ACCOUNT: adyenMerchantAccount 63 | ADYEN_CLIENT_KEY: adyenClientKey 64 | ADYEN_API_KEY: adyenApiKey 65 | } 66 | } 67 | 68 | output webSiteUrl string = webSite.properties.defaultHostName 69 | -------------------------------------------------------------------------------- /templates/app-service.bicep: -------------------------------------------------------------------------------- 1 | param location string = resourceGroup().location 2 | 3 | @allowed([ 4 | 'B1' 5 | 'B2' 6 | 'B3' 7 | 'S1' 8 | 'S2' 9 | 'S3' 10 | 'P1' 11 | 'P2' 12 | 'P3' 13 | 'P1V2' 14 | 'P2V2' 15 | 'P3V2' 16 | ]) 17 | param skuName string = 'B1' 18 | 19 | @minValue(1) 20 | param skuCapacity int = 1 21 | 22 | param hostingPlanName string 23 | param webSiteName string 24 | 25 | param containerImage string 26 | 27 | var linuxFxVersion = 'DOCKER|${containerImage}' 28 | 29 | resource hostingPlan 'Microsoft.Web/serverfarms@2020-12-01' = { 30 | name: hostingPlanName 31 | location: location 32 | kind: 'linux' 33 | properties: { 34 | reserved: true 35 | } 36 | sku: { 37 | name: skuName 38 | capacity: skuCapacity 39 | } 40 | } 41 | 42 | resource webSite 'Microsoft.Web/sites@2018-11-01' = { 43 | name: webSiteName 44 | location: location 45 | properties: { 46 | siteConfig: { 47 | linuxFxVersion: linuxFxVersion 48 | alwaysOn: true 49 | } 50 | serverFarmId: hostingPlan.id 51 | clientAffinityEnabled: false 52 | } 53 | } 54 | 55 | output webSiteUrl string = webSite.properties.defaultHostName 56 | -------------------------------------------------------------------------------- /templates/main.bicep: -------------------------------------------------------------------------------- 1 | @description('The API Management instance service name') 2 | param apimServiceName string 3 | 4 | @description('The email address of the owner of the APIM service') 5 | param apimPublisherEmail string 6 | 7 | @description('The name of the owner of the APIM service') 8 | param apimPublisherName string 9 | 10 | @description('The pricing tier of this API Management service') 11 | @allowed([ 12 | 'Developer' 13 | 'Standard' 14 | 'Premium' 15 | ]) 16 | param apimSku string = 'Developer' 17 | 18 | @description('The instance size of this API Management service.') 19 | @maxValue(2) 20 | param apimSkuCount int = 1 21 | 22 | @description('The App Service hosting plan name') 23 | param appServiceHostingPlanName string 24 | 25 | @description('The App Service name') 26 | param appServiceName string 27 | 28 | @allowed([ 29 | 'B1' 30 | 'B2' 31 | 'B3' 32 | 'S1' 33 | 'S2' 34 | 'S3' 35 | 'P1' 36 | 'P2' 37 | 'P3' 38 | 'P1V2' 39 | 'P2V2' 40 | 'P3V2' 41 | ]) 42 | @description('The pricing tier of the App Service plan to deploy, defaults to Basic') 43 | param appServiceSkuName string = 'B1' 44 | 45 | @minValue(1) 46 | param appServiceSkuCapacity int = 1 47 | 48 | @description('The payment provider - Adyen or Stripe') 49 | @allowed([ 50 | 'Stripe' 51 | 'Adyen' 52 | ]) 53 | param paymentProvider string 54 | 55 | @secure() 56 | @description('The Stripe secret API key - required if using Stripe') 57 | param stripeApiKey string = '' 58 | 59 | @description('The Stripe publishable key - required if using Stripe') 60 | param stripePublicKey string = '' 61 | 62 | @secure() 63 | @description('The Adyen API key - required if using Adyen') 64 | param adyenApiKey string = '' 65 | 66 | @description('The Adyen client key - required if using Adyen') 67 | param adyenClientKey string = '' 68 | 69 | @description('The Adyen merchant account ID - required if using Adyen') 70 | param adyenMerchantAccount string = '' 71 | 72 | @description('The container image to deploy to the app service. By default is retrieved from Github') 73 | param appServiceContainerImage string = 'mcr.microsoft.com/azure-api-management/samples/monetization:0.3.0' 74 | 75 | @description('Port for the App Service container') 76 | param appServiceContainerPort int = 8000 77 | 78 | @description('The app ID of the service principal that the Web App uses to manage APIM') 79 | param servicePrincipalAppId string 80 | 81 | @description('The object ID of the service principal that the Web App uses to manage APIM') 82 | param servicePrincipalObjectId string 83 | 84 | @secure() 85 | @description('The password for the service principal') 86 | param servicePrincipalPassword string 87 | 88 | @description('The AAD tenant in which the service principal resides') 89 | param servicePrincipalTenantId string 90 | 91 | @description('Location for all resources.') 92 | param location string = resourceGroup().location 93 | 94 | @description('The base URL for artifacts used in deployment.') 95 | param artifactsBaseUrl string = 'https://raw.githubusercontent.com/microsoft/azure-api-management-monetization/main' 96 | 97 | var contributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions/', 'b24988ac-6180-42a0-ab88-20f7382dd24c') 98 | 99 | module appService 'app-service.bicep' = { 100 | name: 'appServiceDeploy' 101 | params: { 102 | hostingPlanName: appServiceHostingPlanName 103 | webSiteName: appServiceName 104 | skuName: appServiceSkuName 105 | skuCapacity: appServiceSkuCapacity 106 | containerImage: appServiceContainerImage 107 | } 108 | } 109 | 110 | module apimInstance './apim-instance.bicep' = { 111 | name: 'apimInstanceDeploy' 112 | params: { 113 | serviceName: apimServiceName 114 | publisherEmail: apimPublisherEmail 115 | publisherName: apimPublisherName 116 | sku: apimSku 117 | skuCount: apimSkuCount 118 | location: location 119 | delegationUrl: uri('https://${appService.outputs.webSiteUrl}', 'apim-delegation') 120 | } 121 | } 122 | 123 | module apimAddressApi './apimmonetization-apis-address.bicep' = { 124 | name: 'apimAddressApiDeploy' 125 | params: { 126 | apimServiceName: apimServiceName 127 | serviceUrl: { 128 | address: 'https://api.microsoft.com/address' 129 | } 130 | artifactsBaseUrl: artifactsBaseUrl 131 | } 132 | dependsOn: [ 133 | apimInstance 134 | ] 135 | } 136 | 137 | module apimBillingApi './apimmonetization-apis-billing.bicep' = { 138 | name: 'apimBillingApiDeploy' 139 | params: { 140 | apimServiceName: apimServiceName 141 | serviceUrl: { 142 | billing: 'https://api.microsoft.com/billing' 143 | } 144 | artifactsBaseUrl: artifactsBaseUrl 145 | } 146 | dependsOn: [ 147 | apimInstance 148 | apimInstanceNamedValues 149 | ] 150 | } 151 | 152 | module apimProducts './apimmonetization-products.bicep' = { 153 | name: 'apimProductsDeploy' 154 | params: { 155 | apimServiceName: apimServiceName 156 | artifactsBaseUrl: artifactsBaseUrl 157 | } 158 | dependsOn: [ 159 | apimAddressApi 160 | apimBillingApi 161 | ] 162 | } 163 | 164 | module apimProductsApis './apimmonetization-productAPIs.bicep' = { 165 | name: 'apimProductsApisDeploy' 166 | params: { 167 | apimServiceName: apimServiceName 168 | } 169 | dependsOn: [ 170 | apimProducts 171 | ] 172 | } 173 | 174 | module apimProductsGroups './apimmonetization-productGroups.bicep' = { 175 | name: 'apimProductsGroupsDeploy' 176 | params: { 177 | apimServiceName: apimServiceName 178 | } 179 | dependsOn: [ 180 | apimProducts 181 | ] 182 | } 183 | 184 | module apimInstanceNamedValues './apimmonetization-namedValues.bicep' = { 185 | name: 'apimInstanceNamedValuesDeploy' 186 | params: { 187 | subscriptionId: subscription().subscriptionId 188 | resourceGroupName: resourceGroup().name 189 | apimServiceName: apimServiceName 190 | appServiceName: appServiceName 191 | artifactsBaseUrl: artifactsBaseUrl 192 | } 193 | dependsOn: [ 194 | apimInstance 195 | ] 196 | } 197 | 198 | module apimGlobalServicePolicy './apimmonetization-globalServicePolicy.bicep' = { 199 | name: 'apimGlobalServicePolicyDeploy' 200 | params: { 201 | apimServiceName: apimServiceName 202 | artifactsBaseUrl: artifactsBaseUrl 203 | } 204 | dependsOn: [ 205 | apimInstance 206 | apimInstanceNamedValues 207 | ] 208 | } 209 | 210 | module appServiceSettings 'app-service-settings.bicep' = { 211 | name: 'appServiceSettingsDeploy' 212 | params: { 213 | webSiteName: appServiceName 214 | apimServiceName: apimServiceName 215 | stripeApiKey: stripeApiKey 216 | stripePublicKey: stripePublicKey 217 | containerPort: appServiceContainerPort 218 | servicePrincipalAppId: servicePrincipalAppId 219 | servicePrincipalPassword: servicePrincipalPassword 220 | servicePrincipalTenantId: servicePrincipalTenantId 221 | paymentProvider: paymentProvider 222 | adyenApiKey: adyenApiKey 223 | adyenClientKey: adyenClientKey 224 | adyenMerchantAccount: adyenMerchantAccount 225 | } 226 | dependsOn: [ 227 | apimInstance 228 | ] 229 | } 230 | 231 | resource servicePrincipalContributorRole 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { 232 | name: guid(resourceGroup().id, servicePrincipalObjectId, contributorRoleId) 233 | scope: resourceGroup() 234 | properties: { 235 | roleDefinitionId: contributorRoleId 236 | principalId: servicePrincipalObjectId 237 | } 238 | } 239 | --------------------------------------------------------------------------------