├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── pom.xml ├── src ├── main │ ├── java │ │ └── com │ │ │ └── amazonaws │ │ │ └── partners │ │ │ └── saasfactory │ │ │ └── metering │ │ │ ├── aggregation │ │ │ ├── BillingEventAggregation.java │ │ │ └── StripeBillingPublish.java │ │ │ ├── billing │ │ │ └── ProcessBillingEvent.java │ │ │ ├── common │ │ │ ├── AggregationEntry.java │ │ │ ├── BillingEvent.java │ │ │ ├── BillingProviderConfiguration.java │ │ │ ├── Constants.java │ │ │ ├── EventBridgeBillingEvent.java │ │ │ ├── EventBridgeBillingEventDetail.java │ │ │ ├── EventBridgeEvent.java │ │ │ ├── EventBridgeOnboardTenantEvent.java │ │ │ ├── EventBridgeOnboardTenantEventDetail.java │ │ │ ├── OnboardingEvent.java │ │ │ ├── ProcessBillingEventException.java │ │ │ ├── TableConfiguration.java │ │ │ ├── TenantConfiguration.java │ │ │ ├── TenantNotFoundException.java │ │ │ └── TenantOnboardingException.java │ │ │ └── onboarding │ │ │ └── OnboardNewTenant.java │ └── resources │ │ └── log4j2.xml └── test │ ├── java │ ├── aggregation │ │ ├── BillingEventAggregationTest.java │ │ └── StripeBillingPublishTest.java │ ├── billing │ │ └── ProcessBillingEventTest.java │ ├── common │ │ ├── ConstantsTest.java │ │ └── TenantConfigurationTest.java │ └── onboarding │ │ └── OnboardNewTenantTest.java │ └── resources │ └── log4j2-test.xml └── template.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .aws-sam/ 3 | .idea/ 4 | target/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reference Billing/Metering Service 2 | 3 | This project contains a reference implementation for a serverless billing/metering service. 4 | 5 | ## Requirements 6 | 7 | * Java, version 11 or higher 8 | * Maven 9 | * AWS SAM (https://aws.amazon.com/serverless/sam/) 10 | 11 | ## Security 12 | 13 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 14 | 15 | ## License 16 | 17 | This library is licensed under the MIT-0 License. See the LICENSE file. 18 | 19 | ## Building 20 | 21 | Build the SAM application by running the following command: 22 | 23 | ``` 24 | $ sam build 25 | ``` 26 | 27 | You can ignore the warnings about the JVM using a major version higher than 11 if you are 28 | using a Java compiler version higher than 11. The pom file tells the compiler to build for a version 29 | of Java that is compatible with Lambda. 30 | 31 | ## Testing 32 | 33 | There is Maven lifecycle that defines tests of major functionality locally. In order to 34 | test the OnboardNewTenant, ProcessBillingEvent, BillingEventAggregation and StripeBillingPublish, the 35 | [DynamoDB Local](https://hub.docker.com/r/amazon/dynamodb-local/) container needs to be running in Docker 36 | and listening on port 8000: 37 | 38 | ```shell script 39 | $ docker run -p 8000:8000 amazon/dynamodb-local:latest 40 | ``` 41 | 42 | For the StripeBillingPublish test, the [stripe-mock container](https://hub.docker.com/r/stripemock/stripe-mock) needs 43 | to be run according to the Docker usage instructions [here](https://github.com/stripe/stripe-mock) 44 | 45 | The tests for TenantConfiguration and Constants do not require any external dependencies. 46 | 47 | ## Deploying 48 | 49 | In order to deploy the application, run the following command: 50 | 51 | ```shell script 52 | $ sam deploy --guided 53 | ``` 54 | 55 | ## Initial Configuration 56 | 57 | ### Tenant Configuration 58 | 59 | Tenants are configured in two places: within Stripe and within this application. Tenant configuration in Stripe must 60 | happen before configuring a tenant in this application. There are several components that need to be created within 61 | Stripe: 62 | * Stripe Product (https://stripe.com/docs/api/products) 63 | * Stripe Price (https://stripe.com/docs/api/prices) 64 | * Stripe Customer (https://stripe.com/docs/api/customers) 65 | * Stripe Subscription (https://stripe.com/docs/api/subscriptions) 66 | 67 | The connection between the String Customer and Stripe Subscription creates the subscription item ID, an identifier 68 | prefixed with "si\_". Please refer to the Stripe documentation for more information about how to create 69 | these resources. 70 | 71 | Once the subscription item ID exists in Stripe, the tenant can be onboarded into this application. This is done 72 | through an event on the associated EventBridge. See the "Usage and Function" section below. 73 | 74 | ### Stripe API Key 75 | 76 | **Do not use a production API key.** 77 | 78 | The Stripe API key is stored within Secrets Manager. The Cloudformation stack creates an empty secret. 79 | Access the secret through the Secrets Manager console and paste in a **restricted testing API key** with 80 | the following permissions: 81 | 82 | * Invoices - Read 83 | * Subscriptions - Read 84 | * Usage Records - Write 85 | 86 | More information about how to create a restricted key can be found [here](https://stripe.com/docs/keys#limit-access). 87 | 88 | Do not paste it in JSON format; the application expects to find a single string with the API key. 89 | 90 | **Do not use a production API key.** 91 | 92 | ## Usage and Function 93 | 94 | First, tenants need to be onboarded to this system. This is done by placing an event onto the EventBridge. The 95 | event is in the following format: 96 | 97 | ```json 98 | { 99 | "TenantID": "Tenant0", 100 | "ExternalSubscriptionIdentifier": "si_00000000" 101 | } 102 | ``` 103 | 104 | Where "TenantID" is some way to identify a tenant, and where "ExternalSubscriptionIdentifier" is the identifier 105 | associated with the subscription in the billing provider. In the case of Stripe Billing, this identifier will be 106 | the subscription ID, prefixed with "si\_". 107 | 108 | This can be placed onto the EventBridge with the AWS CLI or with one of the AWS SDKs. Here is an example using the 109 | AWS CLI: 110 | 111 | ```shell script 112 | $ aws events put-events --entries file://exampleOnboardingEvent.json 113 | ``` 114 | 115 | The contents of exampleOnboardingEvent.json should be similar to the following: 116 | ```json 117 | [ 118 | { 119 | "Detail": "{ \"TenantID\": \"Tenant0\", \"ExternalSubscriptionIdentifier\": \"si_00000000\" }", 120 | "DetailType": "ONBOARD", 121 | "EventBusName": "BillingEventBridge", 122 | "Source": "command-line-test" 123 | } 124 | ] 125 | ``` 126 | 127 | Where the value of the EventBusName key is the name of the EventBridge associated with the SAM application. This is 128 | BillingEventBridge by default. 129 | 130 | Once a tenant is onboarded, place billing events onto the same EventBridge. The event is in the following format: 131 | 132 | ```json 133 | { 134 | "TenantID": "Tenant0", 135 | "Quantity": 5 136 | } 137 | ``` 138 | 139 | The billing event is in the following format: 140 | * TenantID: the ID of the tenant that owns the billing event 141 | * Quantity: the number of billing events that occurred 142 | 143 | This can be placed onto the EventBridge with the AWS CLI or with one of the AWS SDKs. Here is an example using the 144 | AWS CLI: 145 | 146 | ```shell script 147 | $ aws events put-events --entries file://exampleBillingEvents.json 148 | ``` 149 | 150 | The contents of exampleBillingEvents.json should be similar to the following: 151 | ```json 152 | [ 153 | { 154 | "Detail": "{ \"TenantID\": \"Tenant0\", \"Quantity\": 5 }", 155 | "DetailType": "BILLING", 156 | "EventBusName": "BillingEventBridge", 157 | "Source": "command-line-test" 158 | }, 159 | { 160 | "Detail": "{ \"TenantID\": \"Tenant0\", \"Quantity\": 10 }", 161 | "DetailType": "BILLING", 162 | "EventBusName": "BillingEventBridge", 163 | "Source": "command-line-test" 164 | } 165 | ] 166 | ``` 167 | 168 | After placing the event onto the EventBridge, a Lambda function processes the event and places it into a DynamoDB 169 | table. 170 | 171 | Where the value of the EventBusName key is the name of the EventBridge associated with the SAM application. This is 172 | BillingEventBridge by default. 173 | 174 | At a rate set by the user of the application, a Cloudwatch Scheduled Event runs a Step Function State Machine that 175 | aggregates the events in the DynamoDB table and publishes them to Stripe Billing. 176 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 17 | 19 | 4.0.0 20 | com.amazonaws.partners.saasfactory.metering 21 | saas-factory-billing-metering 22 | 1.0 23 | jar 24 | A reference architecture for billing and metering 25 | 26 | 11 27 | 11 28 | 1.2.0 29 | 1.2.0 30 | 2.13.68 31 | 2.17.1 32 | 20.29.0 33 | 2.8.9 34 | 5.4.0 35 | 3.2.0 36 | UTF-8 37 | 38 | 39 | 40 | 41 | software.amazon.awssdk 42 | dynamodb 43 | ${aws.java.sdk.version} 44 | 45 | 46 | software.amazon.awssdk 47 | url-connection-client 48 | ${aws.java.sdk.version} 49 | 50 | 51 | software.amazon.awssdk 52 | secretsmanager 53 | ${aws.java.sdk.version} 54 | 55 | 56 | com.amazonaws 57 | aws-lambda-java-core 58 | ${aws.lambda.java.core.version} 59 | 60 | 61 | com.stripe 62 | stripe-java 63 | ${stripe.java.version} 64 | 65 | 66 | com.google.code.gson 67 | gson 68 | ${google.code.gson.version} 69 | 70 | 71 | com.amazonaws 72 | aws-lambda-java-log4j2 73 | ${aws.lambda.java.log4j.version} 74 | 75 | 76 | org.apache.logging.log4j 77 | log4j-api 78 | ${apache.log4j.version} 79 | 80 | 81 | org.apache.logging.log4j 82 | log4j-core 83 | ${apache.log4j.version} 84 | 85 | 86 | org.apache.logging.log4j 87 | log4j-slf4j-impl 88 | ${apache.log4j.version} 89 | 90 | 91 | org.junit.jupiter 92 | junit-jupiter-engine 93 | ${junit.version} 94 | test 95 | 96 | 97 | 98 | 99 | 100 | 101 | org.apache.maven.plugins 102 | maven-shade-plugin 103 | ${maven.shade.plugin.version} 104 | 105 | 106 | package 107 | 108 | shade 109 | 110 | 111 | 112 | 113 | 114 | 115 | org.apache.maven.plugins 116 | maven-surefire-plugin 117 | 2.22.0 118 | 119 | 120 | org.apache.maven.plugins 121 | maven-failsafe-plugin 122 | 2.22.0 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/aggregation/BillingEventAggregation.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.aggregation; 18 | 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import com.amazonaws.services.lambda.runtime.Context; 23 | import com.amazonaws.services.lambda.runtime.RequestStreamHandler; 24 | 25 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 26 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 27 | import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; 28 | import software.amazon.awssdk.services.dynamodb.model.Delete; 29 | import software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException; 30 | import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; 31 | import software.amazon.awssdk.services.dynamodb.model.QueryRequest; 32 | import software.amazon.awssdk.services.dynamodb.model.QueryResponse; 33 | import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; 34 | import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; 35 | import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest; 36 | import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; 37 | import software.amazon.awssdk.services.dynamodb.model.Update; 38 | 39 | import com.amazonaws.partners.saasfactory.metering.common.BillingEvent; 40 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 41 | import com.amazonaws.partners.saasfactory.metering.common.TenantConfiguration; 42 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.ADD_TO_AGGREGATION_EXPRESSION_VALUE; 43 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.ATTRIBUTE_DELIMITER; 44 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EVENT_PREFIX; 45 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EVENT_PREFIX_ATTRIBUTE_VALUE; 46 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EVENT_TIME_ARRAY_INDEX; 47 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.IDEMPOTENTCY_KEY_ATTRIBUTE_NAME; 48 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.MAXIMUM_BATCH_SIZE; 49 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.NONCE_ARRAY_INDEX; 50 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_EXPRESSION_NAME; 51 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_NAME; 52 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.QUANTITY_ATTRIBUTE_NAME; 53 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.QUANTITY_EXPRESSION_NAME; 54 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SELECTED_UUID_INDEX; 55 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_EXPRESSION_NAME; 56 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_NAME; 57 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SUBMITTED_KEY_ATTRIBUTE_NAME; 58 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.TENANT_ID_EXPRESSION_VALUE; 59 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.TRUNCATION_UNIT; 60 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.UUID_DELIMITER; 61 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.formatAggregationEntry; 62 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeDynamoDBClient; 63 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeTableConfiguration; 64 | 65 | import java.io.InputStream; 66 | import java.io.OutputStream; 67 | import java.time.Instant; 68 | import java.time.ZoneId; 69 | import java.time.ZonedDateTime; 70 | import java.util.ArrayList; 71 | import java.util.Collections; 72 | import java.util.HashMap; 73 | import java.util.List; 74 | import java.util.Map.Entry; 75 | import java.util.Map; 76 | import java.util.UUID; 77 | import java.util.stream.Collectors; 78 | 79 | public class BillingEventAggregation implements RequestStreamHandler { 80 | 81 | private final DynamoDbClient ddb; 82 | private final Logger logger; 83 | private final TableConfiguration tableConfig; 84 | 85 | public BillingEventAggregation() { 86 | this.ddb = initializeDynamoDBClient(); 87 | this.logger = LoggerFactory.getLogger(BillingEventAggregation.class); 88 | this.tableConfig = initializeTableConfiguration(this.logger); 89 | } 90 | 91 | // This is for testing 92 | public BillingEventAggregation(DynamoDbClient ddb, TableConfiguration tableConfig) { 93 | this.ddb = ddb; 94 | this.logger = LoggerFactory.getLogger(BillingEventAggregation.class); 95 | this.tableConfig = tableConfig; 96 | } 97 | 98 | private List getBillingEventsForTenant(String tenantID) { 99 | HashMap expressionNames = new HashMap<>(); 100 | expressionNames.put(PRIMARY_KEY_EXPRESSION_NAME, PRIMARY_KEY_NAME); 101 | expressionNames.put(SORT_KEY_EXPRESSION_NAME, SORT_KEY_NAME); 102 | 103 | HashMap expressionValues = new HashMap<>(); 104 | 105 | AttributeValue tenantIDValue = AttributeValue.builder() 106 | .s(tenantID) 107 | .build(); 108 | 109 | AttributeValue eventPrefixValue = AttributeValue.builder() 110 | .s(EVENT_PREFIX) 111 | .build(); 112 | 113 | expressionValues.put(TENANT_ID_EXPRESSION_VALUE, tenantIDValue); 114 | expressionValues.put(EVENT_PREFIX_ATTRIBUTE_VALUE, eventPrefixValue); 115 | 116 | QueryResponse result = null; 117 | List billingEvents = new ArrayList<>(); 118 | do { 119 | QueryRequest request = QueryRequest.builder() 120 | .tableName(this.tableConfig.getTableName()) 121 | .keyConditionExpression(String.format("%s = %s and begins_with(%s, %s)", 122 | PRIMARY_KEY_EXPRESSION_NAME, 123 | TENANT_ID_EXPRESSION_VALUE, 124 | SORT_KEY_EXPRESSION_NAME, 125 | EVENT_PREFIX_ATTRIBUTE_VALUE)) 126 | .expressionAttributeNames(expressionNames) 127 | .expressionAttributeValues(expressionValues) 128 | .build(); 129 | if (result != null && !result.lastEvaluatedKey().isEmpty()) { 130 | request = request.toBuilder() 131 | .exclusiveStartKey(result.lastEvaluatedKey()) 132 | .build(); 133 | } 134 | try { 135 | result = this.ddb.query(request); 136 | } catch (ResourceNotFoundException e) { 137 | this.logger.error("Table {} does not exist", this.tableConfig.getTableName()); 138 | } catch (InternalServerErrorException e) { 139 | this.logger.error(e.getMessage()); 140 | // if there's a failure, return an empty array list rather than a partial array list 141 | return new ArrayList<>(); 142 | } 143 | 144 | if (result == null) { 145 | return new ArrayList<>(); 146 | } 147 | 148 | for (Map item : result.items()) { 149 | String eventEntry = item.get(SORT_KEY_NAME).s(); 150 | long eventTimeInMilliseconds = Long.parseLong(eventEntry.split(ATTRIBUTE_DELIMITER)[EVENT_TIME_ARRAY_INDEX]); 151 | String nonce = eventEntry.split(ATTRIBUTE_DELIMITER)[NONCE_ARRAY_INDEX]; 152 | Instant eventTime = Instant.ofEpochMilli(eventTimeInMilliseconds); 153 | Long quantity = Long.valueOf(item.get(QUANTITY_ATTRIBUTE_NAME).n()); 154 | BillingEvent billingEvent = BillingEvent.createBillingEvent(tenantID, eventTime, quantity, nonce); 155 | billingEvents.add(billingEvent); 156 | } 157 | } while (!result.lastEvaluatedKey().isEmpty()); 158 | return billingEvents; 159 | } 160 | 161 | private Map> categorizeEvents(TenantConfiguration tenant, List billingEvents) { 162 | if (billingEvents.isEmpty()) { 163 | return new HashMap<>(); 164 | } 165 | // Figure out the lowest and highest date (the range) 166 | Instant earliestBillingEvent = Collections.min(billingEvents).getEventTime(); 167 | this.logger.info("Earliest event for tenant {} at {}", 168 | tenant.getTenantID(), 169 | earliestBillingEvent); 170 | Instant latestBillingEvent = Collections.max(billingEvents).getEventTime(); 171 | this.logger.info("Latest event for tenant {} at {}", 172 | tenant.getTenantID(), 173 | latestBillingEvent); 174 | // Create a map with each element as a key based on the frequency (e.g. a day for a key with frequency for a day) 175 | Map> eventCounts = new HashMap<>(); 176 | for (BillingEvent event : billingEvents) { 177 | ZonedDateTime eventTime = event.getEventTime().atZone(ZoneId.of("UTC")); 178 | ZonedDateTime startOfEventTimePeriod = eventTime.truncatedTo(TRUNCATION_UNIT); 179 | ZonedDateTime startOfCurrentTimePeriod = Instant.now().atZone(ZoneId.of("UTC")).truncatedTo(TRUNCATION_UNIT); 180 | // Skip over this time period and future time period events because there may eventually be more events 181 | if (!(startOfCurrentTimePeriod.compareTo(startOfEventTimePeriod) <= 0)) { 182 | List eventList = eventCounts.getOrDefault(startOfEventTimePeriod, new ArrayList<>()); 183 | eventList.add(event); 184 | eventCounts.put(startOfEventTimePeriod, eventList); 185 | } 186 | } 187 | return eventCounts; 188 | } 189 | 190 | private void initializeItem(Map compositeKey, ZonedDateTime time) { 191 | // Format the statements 192 | AttributeValue idempotencyKeyValue = AttributeValue.builder() 193 | .s(UUID.randomUUID().toString().split(UUID_DELIMITER)[SELECTED_UUID_INDEX]) 194 | .build(); 195 | compositeKey.put(IDEMPOTENTCY_KEY_ATTRIBUTE_NAME, idempotencyKeyValue); 196 | 197 | AttributeValue submittedValue = AttributeValue.builder() 198 | .bool(false) 199 | .build(); 200 | compositeKey.put(SUBMITTED_KEY_ATTRIBUTE_NAME, submittedValue); 201 | 202 | String conditionalStatement = String.format("attribute_not_exists(%s)", QUANTITY_ATTRIBUTE_NAME); 203 | 204 | PutItemRequest putItemRequest = PutItemRequest.builder() 205 | .tableName(this.tableConfig.getTableName()) 206 | .item(compositeKey) 207 | .conditionExpression(conditionalStatement) 208 | .build(); 209 | 210 | try { 211 | ddb.putItem(putItemRequest); 212 | } catch (ResourceNotFoundException|InternalServerErrorException e) { 213 | this.logger.error("{}", e.toString()); 214 | } catch (ConditionalCheckFailedException e) { 215 | // Repeat the transaction and see if it works 216 | this.logger.error("Entry at {} already exists", 217 | time.toInstant()); 218 | } 219 | } 220 | 221 | private void putRequestsAsTransaction(Update updateRequest, List deleteRequests) { 222 | List transaction = new ArrayList<>(); 223 | TransactWriteItem updateTransactionItem = TransactWriteItem.builder() 224 | .update(updateRequest) 225 | .build(); 226 | transaction.add(updateTransactionItem); 227 | List deleteRequestItems = deleteRequests.stream().map(deleteRequest -> TransactWriteItem.builder() 228 | .delete(deleteRequest) 229 | .build()).collect(Collectors.toList()); 230 | transaction.addAll(deleteRequestItems); 231 | this.logger.info("Transaction contains {} actions", transaction.size()); 232 | 233 | TransactWriteItemsRequest transactWriteItemsRequest = TransactWriteItemsRequest.builder() 234 | .transactItems(transaction) 235 | .build(); 236 | 237 | try { 238 | ddb.transactWriteItems(transactWriteItemsRequest); 239 | } catch (ResourceNotFoundException|InternalServerErrorException|TransactionCanceledException e) { 240 | this.logger.error("{}", e.toString()); 241 | } 242 | } 243 | 244 | private Long countEvents(List billingEvents) { 245 | this.logger.info("Counting events"); 246 | Long eventCount = 0L; 247 | for (BillingEvent event : billingEvents) { 248 | eventCount += event.getQuantity(); 249 | } 250 | return eventCount; 251 | } 252 | 253 | private Update buildUpdate(Long eventCount, Map compositeKey) { 254 | List updateStatements = new ArrayList<>(); 255 | Map expressionAttributeNames = new HashMap<>(); 256 | expressionAttributeNames.put(QUANTITY_EXPRESSION_NAME, QUANTITY_ATTRIBUTE_NAME); 257 | Map expressionAttributeValues = new HashMap<>(); 258 | AttributeValue countByProductionCodeValue = AttributeValue.builder() 259 | .n(eventCount.toString()) 260 | .build(); 261 | expressionAttributeValues.put(ADD_TO_AGGREGATION_EXPRESSION_VALUE, countByProductionCodeValue); 262 | 263 | this.logger.info("Count is {}", eventCount); 264 | // Appended to the ADD_TO_AGGREGATION_ATTRIBUTE_VALUE for identification in the expression 265 | // attribute names/values. There could be more than one product code to aggregate 266 | String updateStatement = String.format("ADD %s %s", 267 | QUANTITY_EXPRESSION_NAME, 268 | ADD_TO_AGGREGATION_EXPRESSION_VALUE); 269 | updateStatements.add(updateStatement); 270 | 271 | return Update.builder() 272 | .tableName(this.tableConfig.getTableName()) 273 | .key(compositeKey) 274 | .updateExpression(String.join(",", updateStatements)) 275 | .expressionAttributeNames(expressionAttributeNames) 276 | .expressionAttributeValues(expressionAttributeValues) 277 | .build(); 278 | 279 | } 280 | 281 | private List buildDeletes(List billingEvents, TenantConfiguration tenant) { 282 | List deleteRequests = new ArrayList<>(); 283 | for (BillingEvent event : billingEvents) { 284 | Map keyToDelete = new HashMap<>(); 285 | Long eventTime = event.getEventTime().toEpochMilli(); 286 | String nonce = event.getNonce(); 287 | AttributeValue tenantIDValue = AttributeValue.builder() 288 | .s(tenant.getTenantID()) 289 | .build(); 290 | keyToDelete.put(PRIMARY_KEY_NAME, tenantIDValue); 291 | 292 | AttributeValue eventValue = AttributeValue.builder() 293 | .s(String.format("%s%s%d%s%s", 294 | EVENT_PREFIX, 295 | ATTRIBUTE_DELIMITER, 296 | eventTime, 297 | ATTRIBUTE_DELIMITER, 298 | nonce)) 299 | .build(); 300 | 301 | keyToDelete.put(SORT_KEY_NAME, eventValue); 302 | 303 | Delete delete = Delete.builder() 304 | .tableName(this.tableConfig.getTableName()) 305 | .key(keyToDelete) 306 | .build(); 307 | 308 | deleteRequests.add(delete); 309 | } 310 | return deleteRequests; 311 | } 312 | 313 | private void performTransaction(List billingEvents, Map compositeKey, ZonedDateTime time, TenantConfiguration tenant) { 314 | Update updateRequest = null; 315 | List deleteRequests = null; 316 | 317 | Long eventCount = countEvents(billingEvents); 318 | // Initialize the item for this time slot if necessary 319 | this.logger.debug("Initializing item for tenant {} at time {}", 320 | tenant.getTenantID(), 321 | time.toInstant()); 322 | // This doesn't have to be done each time; keep track of what is already initialized 323 | // Pass in a copy of compositeKey because initializeItem makes modifications to it 324 | initializeItem(new HashMap<>(compositeKey), time); 325 | this.logger.debug("Batched {} events, performing transaction", billingEvents.size()); 326 | updateRequest = buildUpdate(eventCount, compositeKey); 327 | deleteRequests = buildDeletes(billingEvents, tenant); 328 | putRequestsAsTransaction(updateRequest, deleteRequests); 329 | } 330 | 331 | private void aggregateEntries(Map> eventsByDate, TenantConfiguration tenant) { 332 | Map compositeKey = new HashMap<>(); 333 | List eventsToCount = new ArrayList<>(); 334 | 335 | for (Entry> entry : eventsByDate.entrySet()) { 336 | ZonedDateTime time = entry.getKey(); 337 | AttributeValue tenantIDValue = AttributeValue.builder() 338 | .s(tenant.getTenantID()) 339 | .build(); 340 | compositeKey.put(PRIMARY_KEY_NAME, tenantIDValue); 341 | 342 | AttributeValue aggregationEntryValue = AttributeValue.builder() 343 | .s(formatAggregationEntry(time.toInstant().toEpochMilli())) 344 | .build(); 345 | compositeKey.put(SORT_KEY_NAME, aggregationEntryValue); 346 | 347 | List billingEvents = eventsByDate.get(time); 348 | 349 | for (BillingEvent event : billingEvents) { 350 | eventsToCount.add(event); 351 | // 24 purges, 1 update 352 | // Minus one because I need to leave room for the update statement 353 | if (eventsToCount.size() == MAXIMUM_BATCH_SIZE - 1) { 354 | performTransaction(eventsToCount, compositeKey, time, tenant); 355 | eventsToCount.clear(); 356 | } 357 | } 358 | // Submit the last batch of items 359 | // If the number of requests lands on an increment of 25, need to make sure that no attempt is made to put 360 | // an empty list, which is why I'm confirming that the list isn't empty. If it isn't empty, put the remaining 361 | // events in the database 362 | if (!eventsToCount.isEmpty()) { 363 | performTransaction(eventsToCount, compositeKey, time, tenant); 364 | eventsToCount.clear(); 365 | } 366 | } 367 | } 368 | 369 | @Override 370 | public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) { 371 | if (this.tableConfig.getTableName().isEmpty() || this.tableConfig.getIndexName().isEmpty()) { 372 | return; 373 | } 374 | 375 | this.logger.info("Resolving tenant IDs in table {}", this.tableConfig.getTableName()); 376 | List tenants = TenantConfiguration.getTenantConfigurations(this.tableConfig, this.ddb, this.logger); 377 | this.logger.info("Resolved tenant IDs in table {}", this.tableConfig.getTableName()); 378 | if (tenants.isEmpty()) { 379 | this.logger.info("No tenants found"); 380 | return; 381 | } 382 | for (TenantConfiguration tenant : tenants) { 383 | List billingEvents = getBillingEventsForTenant(tenant.getTenantID()); 384 | if (billingEvents.isEmpty()) { 385 | this.logger.info("No events for {}", tenant.getTenantID()); 386 | continue; 387 | } 388 | // Count the number of events - this step is necessary to make the transactions work; they need 389 | // to be grouped together 390 | Map> categorizedEvents = categorizeEvents(tenant, billingEvents); 391 | if (categorizedEvents.isEmpty()) { 392 | this.logger.info("No aggregation entries for {}", tenant.getTenantID()); 393 | } else { 394 | // Put those results back into the table 395 | aggregateEntries(categorizedEvents, tenant); 396 | } 397 | } 398 | } 399 | } -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/aggregation/StripeBillingPublish.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.aggregation; 18 | 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import com.amazonaws.services.lambda.runtime.Context; 23 | import com.amazonaws.services.lambda.runtime.RequestStreamHandler; 24 | 25 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 26 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 27 | import software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException; 28 | import software.amazon.awssdk.services.dynamodb.model.QueryRequest; 29 | import software.amazon.awssdk.services.dynamodb.model.QueryResponse; 30 | import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; 31 | import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; 32 | import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; 33 | 34 | import com.amazonaws.partners.saasfactory.metering.common.AggregationEntry; 35 | import com.amazonaws.partners.saasfactory.metering.common.BillingProviderConfiguration; 36 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 37 | import com.amazonaws.partners.saasfactory.metering.common.TenantConfiguration; 38 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.AGGREGATION_ENTRY_PREFIX; 39 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.AGGREGATION_EXPRESSION_VALUE; 40 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.ATTRIBUTE_DELIMITER; 41 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CLOSING_INVOICE_TIME_ATTRIBUTE_NAME; 42 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CLOSING_INVOICE_TIME_EXPRESSION_NAME; 43 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CLOSING_INVOICE_TIME_EXPRESSION_VALUE; 44 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CONFIG_SORT_KEY_VALUE; 45 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.IDEMPOTENTCY_KEY_ATTRIBUTE_NAME; 46 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.KEY_SUBMITTED_EXPRESSION_VALUE; 47 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PERIOD_START_ARRAY_LOCATION; 48 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_EXPRESSION_NAME; 49 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_NAME; 50 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.QUANTITY_ATTRIBUTE_NAME; 51 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_EXPRESSION_NAME; 52 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_NAME; 53 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.STRIPE_IDEMPOTENCY_REPLAYED; 54 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SUBMITTED_KEY_ATTRIBUTE_NAME; 55 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SUBMITTED_KEY_EXPRESSION_NAME; 56 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.TENANT_ID_EXPRESSION_VALUE; 57 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.formatAggregationEntry; 58 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeBillingProviderConfiguration; 59 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeDynamoDBClient; 60 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeTableConfiguration; 61 | 62 | import com.stripe.Stripe; 63 | import com.stripe.exception.StripeException; 64 | import com.stripe.model.Invoice; 65 | import com.stripe.model.SubscriptionItem; 66 | import com.stripe.model.UsageRecord; 67 | import com.stripe.net.RequestOptions; 68 | import com.stripe.param.InvoiceUpcomingParams; 69 | import com.stripe.param.UsageRecordCreateOnSubscriptionItemParams; 70 | 71 | import java.io.InputStream; 72 | import java.io.OutputStream; 73 | import java.time.Instant; 74 | import java.time.temporal.ChronoUnit; 75 | import java.util.ArrayList; 76 | import java.util.HashMap; 77 | import java.util.List; 78 | import java.util.Map; 79 | 80 | public class StripeBillingPublish implements RequestStreamHandler { 81 | 82 | private final DynamoDbClient ddb; 83 | private final Logger logger; 84 | private final TableConfiguration tableConfig; 85 | private final BillingProviderConfiguration billingConfig; 86 | 87 | public StripeBillingPublish() { 88 | this.ddb = initializeDynamoDBClient(); 89 | this.logger = LoggerFactory.getLogger(StripeBillingPublish.class); 90 | this.tableConfig = initializeTableConfiguration(this.logger); 91 | this.billingConfig = initializeBillingProviderConfiguration(this.logger); 92 | Stripe.apiKey = this.billingConfig.getApiKey(); 93 | } 94 | 95 | public StripeBillingPublish(DynamoDbClient ddb, 96 | TableConfiguration tableConfig, 97 | BillingProviderConfiguration billingConfig, 98 | String stripeOverrideUrl) { 99 | this.ddb = ddb; 100 | this.logger = LoggerFactory.getLogger(StripeBillingPublish.class); 101 | this.tableConfig = tableConfig; 102 | this.billingConfig = billingConfig; 103 | Stripe.apiKey = this.billingConfig.getApiKey(); 104 | Stripe.overrideApiBase(stripeOverrideUrl); 105 | } 106 | 107 | private List getAggregationEntries(String tenantID) { 108 | HashMap expressionNames = new HashMap<>(); 109 | expressionNames.put(PRIMARY_KEY_EXPRESSION_NAME, PRIMARY_KEY_NAME); 110 | expressionNames.put(SORT_KEY_EXPRESSION_NAME, SORT_KEY_NAME); 111 | expressionNames.put(SUBMITTED_KEY_EXPRESSION_NAME, SUBMITTED_KEY_ATTRIBUTE_NAME); 112 | 113 | HashMap expressionValues = new HashMap<>(); 114 | AttributeValue tenantIDValue = AttributeValue.builder() 115 | .s(tenantID) 116 | .build(); 117 | expressionValues.put(TENANT_ID_EXPRESSION_VALUE, tenantIDValue); 118 | 119 | AttributeValue aggregationEntryPrefixValue = AttributeValue.builder() 120 | .s(AGGREGATION_ENTRY_PREFIX) 121 | .build(); 122 | expressionValues.put(AGGREGATION_EXPRESSION_VALUE, aggregationEntryPrefixValue); 123 | 124 | // Filter for those entries that have not yet been submitted to the billing provider 125 | AttributeValue keySubmittedValue = AttributeValue.builder() 126 | .bool(false) 127 | .build(); 128 | expressionValues.put(KEY_SUBMITTED_EXPRESSION_VALUE, keySubmittedValue); 129 | 130 | QueryResponse result = null; 131 | List aggregationEntries = new ArrayList<>(); 132 | do { 133 | QueryRequest request = QueryRequest.builder() 134 | .tableName(this.tableConfig.getTableName()) 135 | .keyConditionExpression(String.format("%s = %s and begins_with(%s, %s)", 136 | PRIMARY_KEY_EXPRESSION_NAME, 137 | TENANT_ID_EXPRESSION_VALUE, 138 | SORT_KEY_EXPRESSION_NAME, 139 | AGGREGATION_EXPRESSION_VALUE)) 140 | .filterExpression(String.format("%s = %s", SUBMITTED_KEY_EXPRESSION_NAME, KEY_SUBMITTED_EXPRESSION_VALUE)) 141 | .expressionAttributeNames(expressionNames) 142 | .expressionAttributeValues(expressionValues) 143 | .build(); 144 | if (result != null && !result.lastEvaluatedKey().isEmpty()) { 145 | request = request.toBuilder() 146 | .exclusiveStartKey(result.lastEvaluatedKey()) 147 | .build(); 148 | } 149 | try { 150 | result = this.ddb.query(request); 151 | } catch (ResourceNotFoundException e) { 152 | this.logger.error("Table {} does not exist", this.tableConfig.getTableName()); 153 | return new ArrayList<>(); 154 | } catch (InternalServerErrorException e) { 155 | this.logger.error(e.getMessage()); 156 | return new ArrayList<>(); 157 | } 158 | for (Map item : result.items()) { 159 | String[] aggregationInformation = item.get(SORT_KEY_NAME).s().split(ATTRIBUTE_DELIMITER); 160 | Instant periodStart = Instant.ofEpochMilli(Long.parseLong(aggregationInformation[PERIOD_START_ARRAY_LOCATION])); 161 | Long quantity = Long.valueOf(item.get(QUANTITY_ATTRIBUTE_NAME).n()); 162 | String idempotencyKey = item.get(IDEMPOTENTCY_KEY_ATTRIBUTE_NAME).s(); 163 | AggregationEntry entry = new AggregationEntry(tenantID, 164 | periodStart, 165 | quantity, 166 | idempotencyKey); 167 | aggregationEntries.add(entry); 168 | } 169 | } while (!result.lastEvaluatedKey().isEmpty()); 170 | return aggregationEntries; 171 | } 172 | 173 | private void addUsageToSubscriptionItem(String subscriptionItemId, AggregationEntry aggregationEntry) { 174 | UsageRecord usageRecord = null; 175 | 176 | UsageRecordCreateOnSubscriptionItemParams params = 177 | UsageRecordCreateOnSubscriptionItemParams.builder() 178 | .setQuantity(aggregationEntry.getQuantity()) 179 | .setTimestamp(aggregationEntry.getPeriodStart().truncatedTo(ChronoUnit.SECONDS).getEpochSecond()) 180 | .build(); 181 | 182 | RequestOptions requestOptions = RequestOptions 183 | .builder() 184 | .setIdempotencyKey(aggregationEntry.getIdempotencyKey()) 185 | .build(); 186 | 187 | try { 188 | usageRecord = UsageRecord.createOnSubscriptionItem(subscriptionItemId, params, requestOptions); 189 | } catch(StripeException e) { 190 | this.logger.error("Stripe exception:\n{}", e.getMessage()); 191 | this.logger.error("Timestamp: {}", aggregationEntry.getPeriodStart()); 192 | return; 193 | } 194 | Map> responseHeaders = usageRecord.getLastResponse().headers().map(); 195 | // Check for idempotency key in use; if it is, then this is likely a situation where the 196 | // item was already submitted, but not marked as published 197 | if (responseHeaders.containsKey(STRIPE_IDEMPOTENCY_REPLAYED)) { 198 | String aggregationEntryString = formatAggregationEntry(aggregationEntry.getPeriodStart().toEpochMilli()); 199 | this.logger.info("Aggregation entry {} for tenant {} already published; marking as published", 200 | aggregationEntryString, 201 | aggregationEntry.getTenantID()); 202 | } 203 | markAggregationRecordAsSubmitted(aggregationEntry); 204 | } 205 | 206 | private void markAggregationRecordAsSubmitted(AggregationEntry aggregationEntry) { 207 | // Update the attribute that marks an item as submitted 208 | Map aggregationEntryKey = new HashMap<>(); 209 | AttributeValue tenantIDValue = AttributeValue.builder() 210 | .s(aggregationEntry.getTenantID()) 211 | .build(); 212 | aggregationEntryKey.put(PRIMARY_KEY_NAME, tenantIDValue); 213 | 214 | AttributeValue aggregationStringValue = AttributeValue.builder() 215 | .s(formatAggregationEntry(aggregationEntry.getPeriodStart().toEpochMilli())) 216 | .build(); 217 | aggregationEntryKey.put(SORT_KEY_NAME, aggregationStringValue); 218 | 219 | Map expressionAttributeNames = new HashMap<>(); 220 | expressionAttributeNames.put(SUBMITTED_KEY_EXPRESSION_NAME, SUBMITTED_KEY_ATTRIBUTE_NAME); 221 | 222 | Map expressionAttributeValues = new HashMap<>(); 223 | 224 | AttributeValue keySubmittedValue = AttributeValue.builder() 225 | .bool(true) 226 | .build(); 227 | expressionAttributeValues.put(KEY_SUBMITTED_EXPRESSION_VALUE, keySubmittedValue); 228 | 229 | String updateExpression = String.format("SET %s = %s", 230 | SUBMITTED_KEY_EXPRESSION_NAME, 231 | KEY_SUBMITTED_EXPRESSION_VALUE); 232 | 233 | UpdateItemRequest updateRequest = UpdateItemRequest.builder() 234 | .tableName(this.tableConfig.getTableName()) 235 | .key(aggregationEntryKey) 236 | .updateExpression(updateExpression) 237 | .expressionAttributeNames(expressionAttributeNames) 238 | .expressionAttributeValues(expressionAttributeValues) 239 | .build(); 240 | 241 | try { 242 | ddb.updateItem(updateRequest); 243 | } catch (ResourceNotFoundException|InternalServerErrorException|TransactionCanceledException e) { 244 | this.logger.error(e.getMessage()); 245 | } 246 | 247 | String aggregationEntryString = formatAggregationEntry(aggregationEntry.getPeriodStart().toEpochMilli()); 248 | this.logger.info("Marked aggregation record {} for tenant {} as published", 249 | aggregationEntry.getTenantID(), 250 | aggregationEntryString 251 | ); 252 | } 253 | 254 | private String getSubscription(TenantConfiguration tenantConfiguration) { 255 | SubscriptionItem subscriptionItem = null; 256 | try { 257 | subscriptionItem = SubscriptionItem.retrieve(tenantConfiguration.getExternalSubscriptionIdentifier()); 258 | } catch (StripeException e) { 259 | this.logger.error("Error retrieving subscription for tenant {}", tenantConfiguration.getTenantID()); 260 | this.logger.error("Stripe exception:\n{}", e.getMessage()); 261 | return ""; 262 | } 263 | return subscriptionItem.getSubscription(); 264 | } 265 | 266 | private Instant getUpcomingInvoiceExpirationDate(TenantConfiguration tenantConfiguration) { 267 | // Need to first retrieve the subscription ID with the subscription item ID 268 | String subscription = getSubscription(tenantConfiguration); 269 | if (subscription.isEmpty()) { 270 | return null; 271 | } 272 | InvoiceUpcomingParams invoiceUpcomingParams = InvoiceUpcomingParams.builder() 273 | .setSubscription(subscription) 274 | .build(); 275 | Invoice upcomingInvoice = null; 276 | try { 277 | upcomingInvoice = Invoice.upcoming(invoiceUpcomingParams); 278 | } catch (StripeException e) { 279 | this.logger.error("Error retrieving upcoming invoice for tenant {}", tenantConfiguration.getTenantID()); 280 | this.logger.error("Stripe exception:\n{}", e.getMessage()); 281 | return null; 282 | } 283 | Instant invoiceExpiration = Instant.ofEpochSecond(upcomingInvoice.getPeriodEnd()); 284 | this.logger.info( 285 | "Closing time for tenant {} is {}", 286 | tenantConfiguration.getTenantID(), 287 | invoiceExpiration); 288 | return invoiceExpiration; 289 | } 290 | 291 | private void updateInvoiceExpirationTimeInTable(TenantConfiguration tenantConfiguration, Instant invoiceClosingTime) { 292 | Map key = new HashMap<>(); 293 | AttributeValue primaryKeyValue = AttributeValue.builder() 294 | .s(tenantConfiguration.getTenantID()) 295 | .build(); 296 | key.put(PRIMARY_KEY_NAME, primaryKeyValue); 297 | AttributeValue sortKeyValue = AttributeValue.builder() 298 | .s(CONFIG_SORT_KEY_VALUE) 299 | .build(); 300 | key.put(SORT_KEY_NAME, sortKeyValue); 301 | 302 | Map expressionAttributeNames = new HashMap<>(); 303 | expressionAttributeNames.put(CLOSING_INVOICE_TIME_EXPRESSION_NAME, CLOSING_INVOICE_TIME_ATTRIBUTE_NAME); 304 | 305 | Map expressionAttributeValues = new HashMap<>(); 306 | expressionAttributeValues.put(CLOSING_INVOICE_TIME_EXPRESSION_VALUE, AttributeValue.builder() 307 | .s(invoiceClosingTime.toString()) 308 | .build()); 309 | 310 | String updateExpression = String.format( 311 | "SET %s = %s", 312 | CLOSING_INVOICE_TIME_EXPRESSION_NAME, 313 | CLOSING_INVOICE_TIME_EXPRESSION_VALUE); 314 | 315 | UpdateItemRequest updateItemRequest = UpdateItemRequest.builder() 316 | .tableName(tableConfig.getTableName()) 317 | .key(key) 318 | .expressionAttributeNames(expressionAttributeNames) 319 | .expressionAttributeValues(expressionAttributeValues) 320 | .updateExpression(updateExpression) 321 | .build(); 322 | this.ddb.updateItem(updateItemRequest); 323 | } 324 | 325 | private Instant updateInvoice(TenantConfiguration tenantConfiguration) { 326 | Instant invoiceExpiration = getUpcomingInvoiceExpirationDate(tenantConfiguration); 327 | // Couldn't retrieve the invoice expiration, return false 328 | if (invoiceExpiration == null) { 329 | return null; 330 | } 331 | // update the configuration in DynamoDB 332 | updateInvoiceExpirationTimeInTable(tenantConfiguration, invoiceExpiration); 333 | return invoiceExpiration; 334 | } 335 | 336 | @Override 337 | public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) { 338 | if (this.tableConfig.getTableName().isEmpty() || this.tableConfig.getIndexName().isEmpty()) { 339 | return; 340 | } 341 | 342 | if (this.billingConfig.getApiKey().isEmpty()) { 343 | return; 344 | } 345 | this.logger.info("Fetching tenant IDs in table {}", this.tableConfig.getTableName()); 346 | List tenantConfigurations = TenantConfiguration.getTenantConfigurations(this.tableConfig, ddb, this.logger); 347 | if (tenantConfigurations.isEmpty()) { 348 | this.logger.info("No tenant configurations found in table {}", 349 | this.tableConfig.getTableName()); 350 | return; 351 | } 352 | this.logger.info("Resolved tenant IDs in table {}", this.tableConfig.getTableName()); 353 | for (TenantConfiguration tenant: tenantConfigurations) { 354 | // Check for the existence of the invoice expiration time or if it is expired 355 | // If it doesn't exist or is expired, retrieve and store it 356 | if (tenant.getInvoiceClosingTime() == null) { 357 | this.logger.info("No invoice closing time found for tenant {}", tenant.getTenantID()); 358 | Instant invoiceClosingTime = updateInvoice(tenant); 359 | if (invoiceClosingTime == null) { 360 | this.logger.info("Unable to update invoice closing time for tenant {}", tenant.getTenantID()); 361 | continue; 362 | } 363 | tenant.setInvoiceClosingTime(invoiceClosingTime); 364 | } 365 | 366 | if (!tenant.isInvoiceClosed()) { 367 | this.logger.info("Invoice for tenant {} is still open", tenant.getTenantID()); 368 | continue; 369 | } 370 | this.logger.info("Invoice closed for tenant {}", tenant.getTenantID()); 371 | 372 | List aggregationEntries = getAggregationEntries(tenant.getTenantID()); 373 | if (aggregationEntries.isEmpty()) { 374 | this.logger.info("No unpublished aggregation entries found for tenant {}", 375 | tenant.getTenantID()); 376 | } else { 377 | if (aggregationEntries.size() == 1) { 378 | this.logger.info("Found {} an unpublished aggregation entry for tenant {}", 379 | aggregationEntries.size(), 380 | tenant.getTenantID()); 381 | } else{ 382 | this.logger.info("Found {} unpublished aggregation entries for tenant {}", 383 | aggregationEntries.size(), 384 | tenant.getTenantID()); 385 | } 386 | for (AggregationEntry entry : aggregationEntries) { 387 | String subscriptionID = tenant.getExternalSubscriptionIdentifier(); 388 | if (subscriptionID == null) { 389 | this.logger.error("No subscription ID found associated with tenant {}", 390 | tenant.getTenantID()); 391 | String aggregationEntryString = formatAggregationEntry(entry.getPeriodStart().toEpochMilli()); 392 | this.logger.error("Unable to publish aggregation entry {} associated with tenant {}", 393 | aggregationEntryString, 394 | tenant.getTenantID()); 395 | continue; 396 | } 397 | addUsageToSubscriptionItem(tenant.getExternalSubscriptionIdentifier(), entry); 398 | } 399 | } 400 | } 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/billing/ProcessBillingEvent.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.billing; 18 | 19 | import com.amazonaws.partners.saasfactory.metering.common.TenantNotFoundException; 20 | import com.google.gson.JsonSyntaxException; 21 | import org.slf4j.Logger; 22 | import org.slf4j.LoggerFactory; 23 | 24 | import com.amazonaws.services.lambda.runtime.Context; 25 | import com.amazonaws.services.lambda.runtime.RequestStreamHandler; 26 | 27 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 28 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 29 | import software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException; 30 | import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; 31 | import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; 32 | 33 | import com.amazonaws.partners.saasfactory.metering.common.BillingEvent; 34 | import com.amazonaws.partners.saasfactory.metering.common.EventBridgeBillingEvent; 35 | import com.amazonaws.partners.saasfactory.metering.common.ProcessBillingEventException; 36 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 37 | import com.amazonaws.partners.saasfactory.metering.common.TenantConfiguration; 38 | 39 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CONFIG_INDEX_NAME_ENV_VARIABLE; 40 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_NAME; 41 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.QUANTITY_ATTRIBUTE_NAME; 42 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_NAME; 43 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.TABLE_ENV_VARIABLE; 44 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.formatEventEntry; 45 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.formatTenantEntry; 46 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeDynamoDBClient; 47 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeTableConfiguration; 48 | 49 | import com.google.gson.Gson; 50 | import com.google.gson.GsonBuilder; 51 | 52 | import java.io.InputStream; 53 | import java.io.OutputStream; 54 | import java.io.BufferedReader; 55 | import java.io.InputStreamReader; 56 | import java.nio.charset.StandardCharsets; 57 | import java.util.HashMap; 58 | 59 | public class ProcessBillingEvent implements RequestStreamHandler { 60 | 61 | private final DynamoDbClient ddb; 62 | private final Gson gson; 63 | private final Logger logger; 64 | private final TableConfiguration tableConfig; 65 | 66 | public ProcessBillingEvent() { 67 | this.ddb = initializeDynamoDBClient(); 68 | this.gson = new GsonBuilder().setPrettyPrinting().create(); 69 | this.logger = LoggerFactory.getLogger(ProcessBillingEvent.class); 70 | this.tableConfig = initializeTableConfiguration(this.logger); 71 | } 72 | 73 | // This is for testing 74 | public ProcessBillingEvent(DynamoDbClient ddb, TableConfiguration tableConfig) { 75 | this.ddb = ddb; 76 | this.gson = new GsonBuilder().setPrettyPrinting().create(); 77 | this.logger = LoggerFactory.getLogger(ProcessBillingEvent.class); 78 | this.tableConfig = tableConfig; 79 | } 80 | 81 | private boolean putEvent(BillingEvent billingEvent) { 82 | HashMap item= new HashMap<>(); 83 | 84 | AttributeValue primaryKeyValue = AttributeValue.builder() 85 | .s(formatTenantEntry(billingEvent.getTenantID())) 86 | .build(); 87 | 88 | AttributeValue sortKeyValue = AttributeValue.builder() 89 | .s(formatEventEntry(billingEvent.getEventTime())) 90 | .build(); 91 | 92 | AttributeValue quantityAttributeValue = AttributeValue.builder() 93 | .n(billingEvent.getQuantity().toString()) 94 | .build(); 95 | 96 | item.put(PRIMARY_KEY_NAME, primaryKeyValue); 97 | item.put(SORT_KEY_NAME, sortKeyValue); 98 | item.put(QUANTITY_ATTRIBUTE_NAME, quantityAttributeValue); 99 | 100 | PutItemRequest request = PutItemRequest.builder() 101 | .tableName(this.tableConfig.getTableName()) 102 | .item(item) 103 | .build(); 104 | 105 | try { 106 | this.ddb.putItem(request); 107 | } catch (ResourceNotFoundException e) { 108 | this.logger.error("Table {} does not exist", this.tableConfig.getTableName()); 109 | return false; 110 | } catch (InternalServerErrorException e) { 111 | this.logger.error(e.getMessage()); 112 | return false; 113 | } 114 | return true; 115 | } 116 | 117 | private boolean validateEventFields(EventBridgeBillingEvent event) { 118 | if (event.getDetail().getTenantID() == null) { 119 | return false; 120 | } 121 | if (event.getDetail().getQuantity() == null) { 122 | return false; 123 | } 124 | return true; 125 | } 126 | 127 | @Override 128 | public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) { 129 | if (this.tableConfig.getTableName().isEmpty() || this.tableConfig.getIndexName().isEmpty()) { 130 | this.logger.error("{} or {} environment variable not set", TABLE_ENV_VARIABLE,CONFIG_INDEX_NAME_ENV_VARIABLE); 131 | return; 132 | } 133 | 134 | BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); 135 | EventBridgeBillingEvent event = null; 136 | try { 137 | event = gson.fromJson(reader, EventBridgeBillingEvent.class); 138 | } catch (JsonSyntaxException e) { 139 | this.logger.error("Unable to parse JSON input"); 140 | throw e; 141 | } 142 | 143 | if (!validateEventFields(event)) { 144 | this.logger.error("The fields associated with the billing event are not valid"); 145 | throw new ProcessBillingEventException("TenantID or Quantity key not found in event"); 146 | } 147 | 148 | // Verify the existence of the tenant ID 149 | TenantConfiguration tenant = TenantConfiguration.getTenantConfiguration( 150 | event.getDetail().getTenantID(), 151 | this.tableConfig, 152 | this.ddb, 153 | this.logger); 154 | 155 | if (tenant.isEmpty()) { 156 | throw new TenantNotFoundException(String.format( 157 | "Tenant with ID %s not found", 158 | event.getDetail().getTenantID())); 159 | } 160 | 161 | this.logger.info("Found tenant ID {}", event.getDetail().getTenantID()); 162 | 163 | BillingEvent billingEvent = BillingEvent.createBillingEvent(event); 164 | if (billingEvent == null) { 165 | this.logger.error("Billing event not created because a component of the billing event was missing."); 166 | throw new ProcessBillingEventException("Billing event not created because a component of the billing event was missing."); 167 | } 168 | this.logger.debug("Billing event time is: {}", event.getTime()); 169 | boolean result = putEvent(billingEvent); 170 | if (result) { 171 | this.logger.info("{} | {} | {}", 172 | billingEvent.getTenantID(), 173 | billingEvent.getEventTime(), 174 | billingEvent.getQuantity()); 175 | } else { 176 | this.logger.error("{} | {} | {}", 177 | billingEvent.getTenantID(), 178 | billingEvent.getEventTime(), 179 | billingEvent.getQuantity()); 180 | throw new ProcessBillingEventException("Failure to put item into DyanmoDB"); 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/AggregationEntry.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.AGGREGATION_ENTRY_PREFIX; 20 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.ATTRIBUTE_DELIMITER; 21 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.TRUNCATION_UNIT; 22 | 23 | import java.time.Instant; 24 | 25 | public class AggregationEntry { 26 | 27 | private final String tenantID; 28 | private final Instant periodStart; 29 | private final Long quantity; 30 | private final String idempotencyKey; 31 | 32 | public AggregationEntry(String tenantID, Instant periodStart, Long quantity, String idempotencyKey) { 33 | this.tenantID = tenantID; 34 | this.periodStart = periodStart; 35 | this.quantity = quantity; 36 | this.idempotencyKey = idempotencyKey; 37 | } 38 | 39 | public String getTenantID() { return tenantID; } 40 | 41 | public Instant getPeriodStart() { return periodStart; } 42 | 43 | public Long getQuantity() { return quantity; } 44 | 45 | public String getIdempotencyKey() { return idempotencyKey; } 46 | 47 | public String toString() { 48 | return String.format("%s%s%s%s%d", 49 | AGGREGATION_ENTRY_PREFIX, 50 | ATTRIBUTE_DELIMITER, 51 | TRUNCATION_UNIT.toString().toUpperCase(), 52 | ATTRIBUTE_DELIMITER, 53 | periodStart.toEpochMilli()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/BillingEvent.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import java.time.Instant; 20 | 21 | public class BillingEvent implements Comparable { 22 | 23 | private final Instant eventTime; 24 | private final String tenantID; 25 | private final Long quantity; 26 | private final String nonce; 27 | 28 | private BillingEvent(String tenantID, 29 | Instant eventTime, 30 | Long quantity, 31 | String nonce) { 32 | this.eventTime = eventTime; 33 | this.tenantID = tenantID; 34 | this.quantity = quantity; 35 | this.nonce = nonce; 36 | } 37 | 38 | public static BillingEvent createBillingEvent(String tenantID, Instant eventTime, Long quantity) { 39 | return new BillingEvent(tenantID, 40 | eventTime, 41 | quantity, 42 | ""); 43 | } 44 | 45 | public static BillingEvent createBillingEvent(String tenantID, Instant eventTime, Long quantity, String nonce) { 46 | return new BillingEvent(tenantID, 47 | eventTime, 48 | quantity, 49 | nonce); 50 | } 51 | 52 | public static BillingEvent createBillingEvent(EventBridgeBillingEvent event) { 53 | String tenantID = event.getDetail().getTenantID(); 54 | if (tenantID == null || tenantID.isEmpty()) { 55 | return null; 56 | } 57 | Long quantity = event.getDetail().getQuantity(); 58 | if (quantity == null || quantity <= 0) { 59 | return null; 60 | } 61 | Instant timestamp = Instant.now(); 62 | 63 | return new BillingEvent(tenantID, 64 | timestamp, 65 | quantity, 66 | ""); 67 | } 68 | 69 | public Instant getEventTime() { return this.eventTime; } 70 | 71 | public String getTenantID() { return this.tenantID; } 72 | 73 | public Long getQuantity() { return this.quantity; } 74 | 75 | public String getNonce() { return this.nonce; } 76 | 77 | @Override 78 | public int compareTo(BillingEvent event) { 79 | return this.eventTime.compareTo(event.eventTime); 80 | } 81 | 82 | @Override 83 | public boolean equals(Object o) { 84 | if (o == null) { return false; } 85 | 86 | if (o.getClass() != this.getClass()) { return false; } 87 | 88 | if (o == this) { return true; } 89 | 90 | BillingEvent event = (BillingEvent) o; 91 | 92 | return this.eventTime.equals(event.eventTime) && 93 | this.tenantID.equals(event.tenantID) && 94 | this.quantity.equals(event.quantity) && 95 | this.nonce.equals(event.nonce); 96 | } 97 | 98 | @Override 99 | public int hashCode() { 100 | return this.eventTime.hashCode() + 101 | this.tenantID.hashCode() + 102 | this.quantity.hashCode() + 103 | this.nonce.hashCode(); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/BillingProviderConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class BillingProviderConfiguration { 20 | 21 | private final String apiKey; 22 | 23 | public BillingProviderConfiguration() { 24 | this.apiKey = ""; 25 | } 26 | 27 | public BillingProviderConfiguration(String apiKey) { 28 | this.apiKey = apiKey; 29 | } 30 | 31 | public String getApiKey() { 32 | return apiKey; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/Constants.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import org.slf4j.Logger; 20 | 21 | import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; 22 | import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; 23 | import software.amazon.awssdk.regions.Region; 24 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 25 | import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; 26 | import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; 27 | import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; 28 | import software.amazon.awssdk.services.secretsmanager.model.InvalidParameterException; 29 | import software.amazon.awssdk.services.secretsmanager.model.InvalidRequestException; 30 | import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException; 31 | 32 | import java.time.Instant; 33 | import java.time.temporal.ChronoUnit; 34 | import java.util.UUID; 35 | 36 | public final class Constants { 37 | 38 | private Constants() {} 39 | 40 | public static final ChronoUnit TRUNCATION_UNIT = ChronoUnit.MINUTES; 41 | public static final Integer EVENT_TIME_ARRAY_INDEX = 1; 42 | public static final Integer MAXIMUM_BATCH_SIZE = 25; 43 | public static final Integer NONCE_ARRAY_INDEX = 2; 44 | public static final Integer PERIOD_START_ARRAY_LOCATION = 2; 45 | public static final Integer SELECTED_UUID_INDEX = 4; 46 | public static final String ADD_TO_AGGREGATION_EXPRESSION_VALUE = ":aggregationValue"; 47 | public static final String AGGREGATION_ENTRY_PREFIX = "AGGREGATE"; 48 | public static final String AGGREGATION_EXPRESSION_VALUE = ":aggregate"; 49 | public static final String ATTRIBUTE_DELIMITER = "#"; 50 | public static final String CLOSING_INVOICE_TIME_ATTRIBUTE_NAME = "closing_invoice_time"; 51 | public static final String CLOSING_INVOICE_TIME_EXPRESSION_NAME = "#closing_invoice_time"; 52 | public static final String CLOSING_INVOICE_TIME_EXPRESSION_VALUE = ":closingInvoiceTime"; 53 | public static final String CONFIG_EXPRESSION_NAME = "#configurationAttributeName"; 54 | public static final String CONFIG_EXPRESSION_VALUE = ":config"; 55 | public static final String CONFIG_INDEX_NAME_ENV_VARIABLE = "DYNAMODB_CONFIG_INDEX_NAME"; 56 | public static final String CONFIG_SORT_KEY_VALUE = "CONFIG"; 57 | public static final String EVENT_PREFIX = "EVENT"; 58 | public static final String EVENT_PREFIX_ATTRIBUTE_VALUE = ":event"; 59 | public static final String IDEMPOTENTCY_KEY_ATTRIBUTE_NAME = "idempotency_key"; 60 | public static final String KEY_SUBMITTED_EXPRESSION_VALUE = ":confirmPublished"; 61 | public static final String PRIMARY_KEY_EXPRESSION_NAME = "#datatype"; 62 | public static final String PRIMARY_KEY_NAME = "data_type"; 63 | public static final String QUANTITY_ATTRIBUTE_NAME = "quantity"; 64 | public static final String QUANTITY_EXPRESSION_NAME = "#quantityName"; 65 | public static final String SORT_KEY_EXPRESSION_NAME = "#subtype"; 66 | public static final String SORT_KEY_NAME = "sub_type"; 67 | public static final String STRIPE_IDEMPOTENCY_REPLAYED = "idempotent-replayed"; 68 | public static final String STRIPE_SECRET_ARN_ENV_VARIABLE = "STRIPE_SECRET_ARN"; 69 | public static final String SUBMITTED_KEY_ATTRIBUTE_NAME = "published_to_billing_provider"; 70 | public static final String SUBMITTED_KEY_EXPRESSION_NAME = "#publishName"; 71 | public static final String EXTERNAL_SUBSCRIPTION_IDENTIFIER_ATTRIBUTE_NAME = "external_subscription_identifier"; 72 | public static final String EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_NAME = "#external_subscription_identifier"; 73 | public static final String EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_VALUE = ":externalSubscriptionIdentifier"; 74 | public static final String TABLE_ENV_VARIABLE = "DYNAMODB_TABLE_NAME"; 75 | public static final String TENANT_ID_EXPRESSION_VALUE = ":tenantID"; 76 | public static final String TENANT_PREFIX = "TENANT"; 77 | public static final String UUID_DELIMITER = "-"; 78 | 79 | public static String getEnvVariable(String envVariableName, Logger logger) { 80 | String envVariableValue = System.getenv(envVariableName); 81 | if (envVariableValue == null) { 82 | logger.error("Environment variable {} not present", envVariableName); 83 | return ""; 84 | } 85 | logger.debug("Resolved {} to {}", envVariableName, envVariableValue); 86 | return envVariableValue; 87 | } 88 | 89 | public static String formatAggregationEntry(long aggregationTime) { 90 | return String.format("%s%s%s%s%d", 91 | AGGREGATION_ENTRY_PREFIX, 92 | ATTRIBUTE_DELIMITER, 93 | TRUNCATION_UNIT.toString().toUpperCase(), 94 | ATTRIBUTE_DELIMITER, 95 | aggregationTime); 96 | } 97 | 98 | public static String formatTenantEntry(String tenantID) { 99 | return String.format("%s%s%s", 100 | TENANT_PREFIX, 101 | ATTRIBUTE_DELIMITER, 102 | tenantID); 103 | } 104 | 105 | public static String formatEventEntry(Instant timeOfEvent) { 106 | return String.format("%s%s%d%s%s", 107 | EVENT_PREFIX, 108 | ATTRIBUTE_DELIMITER, 109 | timeOfEvent.toEpochMilli(), 110 | ATTRIBUTE_DELIMITER, 111 | UUID.randomUUID().toString().split("-")[SELECTED_UUID_INDEX]); 112 | } 113 | 114 | public static DynamoDbClient initializeDynamoDBClient() { 115 | return DynamoDbClient.builder() 116 | .region(Region.of(System.getenv("AWS_REGION"))) 117 | .credentialsProvider(EnvironmentVariableCredentialsProvider.create()) 118 | .httpClient(UrlConnectionHttpClient.builder().build()) 119 | .build(); 120 | } 121 | 122 | public static TableConfiguration initializeTableConfiguration(Logger logger) { 123 | return new TableConfiguration( 124 | getEnvVariable(TABLE_ENV_VARIABLE, logger), 125 | getEnvVariable(CONFIG_INDEX_NAME_ENV_VARIABLE, logger) 126 | ); 127 | } 128 | 129 | public static BillingProviderConfiguration initializeBillingProviderConfiguration(Logger logger) { 130 | SecretsManagerClient sm = SecretsManagerClient.builder().build(); 131 | String secretArn = getEnvVariable(STRIPE_SECRET_ARN_ENV_VARIABLE, logger); 132 | 133 | GetSecretValueRequest request = GetSecretValueRequest.builder() 134 | .secretId(secretArn) 135 | .build(); 136 | GetSecretValueResponse result = null; 137 | try { 138 | result = sm.getSecretValue(request); 139 | } catch (ResourceNotFoundException |InvalidRequestException|InvalidParameterException e) { 140 | logger.error(e.getMessage()); 141 | } 142 | if (result == null) { 143 | return new BillingProviderConfiguration(); 144 | } 145 | 146 | return new BillingProviderConfiguration( 147 | result.secretString() 148 | ); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/EventBridgeBillingEvent.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class EventBridgeBillingEvent extends EventBridgeEvent { 20 | 21 | private EventBridgeBillingEventDetail detail; 22 | 23 | public EventBridgeBillingEvent() { 24 | // This constructor is empty because this class is a container for an event 25 | // from EventBridge. This class is used for transforming JSON into a 26 | // Java object 27 | } 28 | 29 | public EventBridgeBillingEventDetail getDetail() { 30 | return detail; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/EventBridgeBillingEventDetail.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import com.google.gson.annotations.SerializedName; 20 | 21 | public class EventBridgeBillingEventDetail { 22 | 23 | @SerializedName("TenantID") 24 | private String tenantID; 25 | @SerializedName("Quantity") 26 | private Long quantity; 27 | 28 | public String getTenantID() { 29 | return tenantID; 30 | } 31 | 32 | public Long getQuantity() { 33 | return quantity; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/EventBridgeEvent.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import com.google.gson.annotations.SerializedName; 20 | 21 | public class EventBridgeEvent { 22 | 23 | private String version; 24 | private String id; 25 | @SerializedName("detail-type") 26 | private String detail_type; 27 | private String source; 28 | private String account; 29 | private String time; 30 | private String region; 31 | private String[] resources; 32 | 33 | public EventBridgeEvent() { 34 | // This constructor is empty because this class is a container for an event 35 | // from EventBridge. This class is used for transforming JSON into a 36 | // Java object 37 | } 38 | 39 | public String getVersion() { 40 | return version; 41 | } 42 | 43 | public String getId() { 44 | return id; 45 | } 46 | 47 | public String getDetail_type() { 48 | return detail_type; 49 | } 50 | 51 | public String getSource() { 52 | return source; 53 | } 54 | 55 | public String getAccount() { 56 | return account; 57 | } 58 | 59 | public String getTime() { 60 | return time; 61 | } 62 | 63 | public String getRegion() { 64 | return region; 65 | } 66 | 67 | public String[] getResources() { 68 | return resources; 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/EventBridgeOnboardTenantEvent.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class EventBridgeOnboardTenantEvent extends EventBridgeEvent { 20 | 21 | private EventBridgeOnboardTenantEventDetail detail; 22 | 23 | public EventBridgeOnboardTenantEvent() { 24 | // This constructor is empty because this class is a container for an event 25 | // from EventBridge. This class is used for transforming JSON into a 26 | // Java object 27 | } 28 | 29 | public EventBridgeOnboardTenantEventDetail getDetail() { 30 | return detail; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/EventBridgeOnboardTenantEventDetail.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import com.google.gson.annotations.SerializedName; 20 | 21 | public class EventBridgeOnboardTenantEventDetail { 22 | 23 | @SerializedName("TenantID") 24 | private String tenantID; 25 | @SerializedName("ExternalSubscriptionIdentifier") 26 | private String externalSubscriptionIdentifier; 27 | 28 | public String getTenantID() { 29 | return this.tenantID; 30 | } 31 | 32 | public String getExternalSubscriptionIdentifier() { 33 | return this.externalSubscriptionIdentifier; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/OnboardingEvent.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class OnboardingEvent { 20 | 21 | private final String tenantID; 22 | private final String externalSubscriptionIdentifier; 23 | 24 | private OnboardingEvent(String tenantID, 25 | String externalSubscriptionIdentifier) { 26 | this.tenantID = tenantID; 27 | this.externalSubscriptionIdentifier = externalSubscriptionIdentifier; 28 | } 29 | 30 | public static OnboardingEvent createOnboardingEvent(EventBridgeOnboardTenantEvent event) { 31 | String tenantID = event.getDetail().getTenantID(); 32 | if (tenantID == null) { 33 | return null; 34 | } 35 | 36 | String externalSubscriptionIdentifier = event.getDetail().getExternalSubscriptionIdentifier(); 37 | if (externalSubscriptionIdentifier == null) { 38 | return null; 39 | } 40 | 41 | return new OnboardingEvent(tenantID, 42 | externalSubscriptionIdentifier); 43 | 44 | } 45 | 46 | public String getTenantID() { 47 | return this.tenantID; 48 | } 49 | 50 | public String getExternalSubscriptionIdentifier() { 51 | return this.externalSubscriptionIdentifier; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/ProcessBillingEventException.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class ProcessBillingEventException extends RuntimeException { 20 | 21 | public ProcessBillingEventException(String errorMessage) { 22 | super(errorMessage); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/TableConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class TableConfiguration { 20 | 21 | private final String tableName; 22 | private final String indexName; 23 | 24 | public TableConfiguration(String tableName, String indexName) { 25 | this.tableName = tableName; 26 | this.indexName = indexName; 27 | } 28 | 29 | public String getIndexName() { 30 | return indexName; 31 | } 32 | 33 | public String getTableName() { 34 | return tableName; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/TenantConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | import org.slf4j.Logger; 20 | 21 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 22 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 23 | import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; 24 | import software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException; 25 | import software.amazon.awssdk.services.dynamodb.model.QueryRequest; 26 | import software.amazon.awssdk.services.dynamodb.model.QueryResponse; 27 | import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; 28 | 29 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CLOSING_INVOICE_TIME_ATTRIBUTE_NAME; 30 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CONFIG_EXPRESSION_NAME; 31 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CONFIG_EXPRESSION_VALUE; 32 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CONFIG_SORT_KEY_VALUE; 33 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EXTERNAL_SUBSCRIPTION_IDENTIFIER_ATTRIBUTE_NAME; 34 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_NAME; 35 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_NAME; 36 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.formatTenantEntry; 37 | 38 | import java.time.Instant; 39 | import java.time.format.DateTimeParseException; 40 | import java.util.ArrayList; 41 | import java.util.HashMap; 42 | import java.util.List; 43 | import java.util.Map; 44 | 45 | public class TenantConfiguration { 46 | 47 | private final String tenantID; 48 | private final String externalSubscriptionIdentifier; 49 | private Instant invoiceClosingTime; 50 | 51 | private TenantConfiguration(String tenantID, 52 | String externalSubscriptionIdentifier, 53 | String invoiceClosingTime) { 54 | this.tenantID = tenantID; 55 | this.externalSubscriptionIdentifier = externalSubscriptionIdentifier; 56 | if (invoiceClosingTime == null) { 57 | this.invoiceClosingTime = null; 58 | } else { 59 | this.invoiceClosingTime = Instant.parse(invoiceClosingTime); 60 | } 61 | } 62 | 63 | private TenantConfiguration() { 64 | this.tenantID = ""; 65 | this.externalSubscriptionIdentifier = ""; 66 | this.invoiceClosingTime = null; 67 | } 68 | 69 | public String getTenantID() { return tenantID; } 70 | 71 | public String getExternalSubscriptionIdentifier() { return externalSubscriptionIdentifier; } 72 | 73 | public Instant getInvoiceClosingTime() { return this.invoiceClosingTime; } 74 | 75 | public boolean isEmpty() { 76 | return this.tenantID.isEmpty() && 77 | this.externalSubscriptionIdentifier.isEmpty() && 78 | this.invoiceClosingTime == null; 79 | } 80 | 81 | public boolean isInvoiceClosed() { 82 | // Is the invoice time older than the current time? 83 | return this.invoiceClosingTime.compareTo(Instant.now()) < 0; 84 | } 85 | 86 | public void setInvoiceClosingTime(Instant newClosingTime) { 87 | this.invoiceClosingTime = newClosingTime; 88 | } 89 | 90 | public static List getTenantConfigurations(TableConfiguration tableConfig, DynamoDbClient ddb, Logger logger) { 91 | // Add support for the invoice end field here 92 | // https://stripe.com/docs/api/invoices/upcoming 93 | // When the application retrieves the tenant configuration, check for this attribute. 94 | // if it exists, move on. If it doesn't, retrieve it from Stripe, add it to the returned 95 | // tenant configuration and put it back into DDB. The retrieval part should be part of 96 | // the Lambda function that integrates Stripe 97 | List tenantIDs = new ArrayList<>(); 98 | 99 | HashMap expressionNames = new HashMap<>(); 100 | expressionNames.put(CONFIG_EXPRESSION_NAME, SORT_KEY_NAME); 101 | 102 | HashMap expressionValues = new HashMap<>(); 103 | AttributeValue sortKeyValue = AttributeValue.builder() 104 | .s(CONFIG_SORT_KEY_VALUE) 105 | .build(); 106 | expressionValues.put(CONFIG_EXPRESSION_VALUE, sortKeyValue); 107 | QueryResponse result = null; 108 | do { 109 | QueryRequest request = QueryRequest.builder() 110 | .tableName(tableConfig.getTableName()) 111 | .indexName(tableConfig.getIndexName()) 112 | .keyConditionExpression(String.format("%s = %s", CONFIG_EXPRESSION_NAME, CONFIG_EXPRESSION_VALUE)) 113 | .expressionAttributeNames(expressionNames) 114 | .expressionAttributeValues(expressionValues) 115 | .build(); 116 | if (result != null && !result.lastEvaluatedKey().isEmpty()) { 117 | request = request.toBuilder() 118 | .exclusiveStartKey(result.lastEvaluatedKey()) 119 | .build(); 120 | } 121 | try { 122 | result = ddb.query(request); 123 | } catch (ResourceNotFoundException e) { 124 | logger.error("Table {} does not exist", tableConfig.getTableName()); 125 | return new ArrayList<>(); 126 | } catch (InternalServerErrorException e) { 127 | logger.error(e.getMessage()); 128 | return new ArrayList<>(); 129 | } 130 | for (Map item : result.items()) { 131 | String tenantID = item.get(PRIMARY_KEY_NAME).s(); 132 | String externalProductCode = item.get(EXTERNAL_SUBSCRIPTION_IDENTIFIER_ATTRIBUTE_NAME).s(); 133 | String invoiceClosingTime = item.getOrDefault( 134 | CLOSING_INVOICE_TIME_ATTRIBUTE_NAME, 135 | AttributeValue.builder() 136 | .nul(true) 137 | .build()) 138 | .s(); 139 | TenantConfiguration tenant; 140 | try { 141 | tenant = new TenantConfiguration( 142 | tenantID, 143 | externalProductCode, 144 | invoiceClosingTime); 145 | } catch (DateTimeParseException e) { 146 | logger.error("Could not parse the invoice closing date for tenant {}", tenantID); 147 | continue; 148 | } 149 | logger.info("Found tenant ID {}", tenantID); 150 | tenantIDs.add(tenant); 151 | } 152 | } while (!result.lastEvaluatedKey().isEmpty()); 153 | return tenantIDs; 154 | } 155 | 156 | public static TenantConfiguration getTenantConfiguration(String tenantID, TableConfiguration tableConfig, DynamoDbClient ddb, Logger logger) { 157 | 158 | Map compositeKey = new HashMap<>(); 159 | AttributeValue primaryKeyValue = AttributeValue.builder() 160 | .s(formatTenantEntry(tenantID)) 161 | .build(); 162 | compositeKey.put(PRIMARY_KEY_NAME, primaryKeyValue); 163 | AttributeValue sortKeyValue = AttributeValue.builder() 164 | .s(CONFIG_SORT_KEY_VALUE) 165 | .build(); 166 | compositeKey.put(SORT_KEY_NAME, sortKeyValue); 167 | 168 | GetItemRequest request = GetItemRequest.builder() 169 | .tableName(tableConfig.getTableName()) 170 | .key(compositeKey) 171 | .build(); 172 | 173 | Map item; 174 | try { 175 | item = ddb.getItem(request).item(); 176 | } catch (ResourceNotFoundException e) { 177 | logger.error("Table {} does not exist", tableConfig.getTableName()); 178 | return new TenantConfiguration(); 179 | } catch (InternalServerErrorException e) { 180 | logger.error(e.getMessage()); 181 | return new TenantConfiguration(); 182 | } 183 | 184 | TenantConfiguration tenant; 185 | if (!item.isEmpty()) { 186 | String externalSubscriptionIdentifier = item.get(EXTERNAL_SUBSCRIPTION_IDENTIFIER_ATTRIBUTE_NAME).s(); 187 | String invoiceClosingTime = item.getOrDefault( 188 | CLOSING_INVOICE_TIME_ATTRIBUTE_NAME, 189 | AttributeValue.builder() 190 | .nul(true) 191 | .build()) 192 | .s(); 193 | try { 194 | tenant = new TenantConfiguration( 195 | tenantID, 196 | externalSubscriptionIdentifier, 197 | invoiceClosingTime); 198 | } catch (DateTimeParseException e) { 199 | logger.error("Could not parse the invoice closing date for tenant {}", tenantID); 200 | return new TenantConfiguration(); 201 | } 202 | } else { 203 | return new TenantConfiguration(); 204 | } 205 | 206 | return tenant; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/TenantNotFoundException.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class TenantNotFoundException extends RuntimeException { 20 | 21 | public TenantNotFoundException(String errorMessage) { 22 | super(errorMessage); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/common/TenantOnboardingException.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.common; 18 | 19 | public class TenantOnboardingException extends RuntimeException { 20 | 21 | public TenantOnboardingException(String errorMessage) { 22 | super(errorMessage); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/amazonaws/partners/saasfactory/metering/onboarding/OnboardNewTenant.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazonaws.partners.saasfactory.metering.onboarding; 18 | 19 | import com.amazonaws.partners.saasfactory.metering.common.TenantOnboardingException; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | import com.amazonaws.services.lambda.runtime.Context; 24 | import com.amazonaws.services.lambda.runtime.RequestStreamHandler; 25 | 26 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 27 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 28 | import software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException; 29 | import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; 30 | import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; 31 | import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; 32 | 33 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 34 | import com.amazonaws.partners.saasfactory.metering.common.OnboardingEvent; 35 | import com.amazonaws.partners.saasfactory.metering.common.EventBridgeOnboardTenantEvent; 36 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.ATTRIBUTE_DELIMITER; 37 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.CONFIG_SORT_KEY_VALUE; 38 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EXTERNAL_SUBSCRIPTION_IDENTIFIER_ATTRIBUTE_NAME; 39 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_NAME; 40 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_VALUE; 41 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.PRIMARY_KEY_NAME; 42 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.SORT_KEY_NAME; 43 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.TENANT_PREFIX; 44 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeDynamoDBClient; 45 | import static com.amazonaws.partners.saasfactory.metering.common.Constants.initializeTableConfiguration; 46 | 47 | import com.google.gson.Gson; 48 | import com.google.gson.GsonBuilder; 49 | 50 | import java.io.BufferedReader; 51 | import java.io.InputStream; 52 | import java.io.InputStreamReader; 53 | import java.io.OutputStream; 54 | import java.nio.charset.StandardCharsets; 55 | import java.util.HashMap; 56 | 57 | public class OnboardNewTenant implements RequestStreamHandler { 58 | 59 | private final DynamoDbClient ddb; 60 | private final Gson gson; 61 | private final Logger logger; 62 | private final TableConfiguration tableConfig; 63 | 64 | public OnboardNewTenant() { 65 | this.ddb = initializeDynamoDBClient(); 66 | this.logger = LoggerFactory.getLogger(OnboardNewTenant.class); 67 | this.gson = new GsonBuilder().setPrettyPrinting().create(); 68 | this.tableConfig = initializeTableConfiguration(this.logger); 69 | } 70 | 71 | // Used for testing, need to inject the mock DDB client 72 | public OnboardNewTenant(DynamoDbClient ddb, TableConfiguration tableConfig) { 73 | this.ddb = ddb; 74 | this.logger = LoggerFactory.getLogger(OnboardNewTenant.class); 75 | this.gson = new GsonBuilder().setPrettyPrinting().create(); 76 | this.tableConfig = tableConfig; 77 | } 78 | 79 | private UpdateItemRequest buildUpdateStatement(OnboardingEvent onboardingEvent) { 80 | HashMap compositeKey = new HashMap<>(); 81 | 82 | AttributeValue primaryKeyValue = AttributeValue.builder() 83 | .s(String.format("%s%s%s", 84 | TENANT_PREFIX, 85 | ATTRIBUTE_DELIMITER, 86 | onboardingEvent.getTenantID())) 87 | .build(); 88 | 89 | compositeKey.put(PRIMARY_KEY_NAME, primaryKeyValue); 90 | 91 | AttributeValue sortKeyValue = AttributeValue.builder() 92 | .s(CONFIG_SORT_KEY_VALUE) 93 | .build(); 94 | compositeKey.put(SORT_KEY_NAME, sortKeyValue); 95 | 96 | HashMap expressionAttributeNames = new HashMap<>(); 97 | expressionAttributeNames.put(EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_NAME, EXTERNAL_SUBSCRIPTION_IDENTIFIER_ATTRIBUTE_NAME); 98 | 99 | HashMap expressionAttributeValues = new HashMap<>(); 100 | AttributeValue externalProductCodeValue = AttributeValue.builder() 101 | .s(onboardingEvent.getExternalSubscriptionIdentifier()) 102 | .build(); 103 | 104 | expressionAttributeValues.put(EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_VALUE, externalProductCodeValue); 105 | 106 | String updateStatement = String.format("SET %s = %s", 107 | EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_NAME, 108 | EXTERNAL_SUBSCRIPTION_IDENTIFIER_EXPRESSION_VALUE); 109 | 110 | return UpdateItemRequest.builder() 111 | .tableName(this.tableConfig.getTableName()) 112 | .key(compositeKey) 113 | .updateExpression(updateStatement) 114 | .expressionAttributeNames(expressionAttributeNames) 115 | .expressionAttributeValues(expressionAttributeValues) 116 | .build(); 117 | 118 | } 119 | 120 | private void putTenant(OnboardingEvent onboardingEvent) { 121 | // This needs to be an update; there may already be an existing value in the subscription mapping attribute 122 | UpdateItemRequest subscriptionMappingUpdate = buildUpdateStatement(onboardingEvent); 123 | 124 | try { 125 | this.ddb.updateItem(subscriptionMappingUpdate); 126 | } catch (ResourceNotFoundException|InternalServerErrorException|TransactionCanceledException e) { 127 | this.logger.error("{}", e.toString()); 128 | throw e; 129 | } 130 | } 131 | 132 | @Override 133 | public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) { 134 | if (this.tableConfig.getTableName().isEmpty() || this.tableConfig.getIndexName().isEmpty()) { 135 | return; 136 | } 137 | 138 | BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); 139 | EventBridgeOnboardTenantEvent event = gson.fromJson(reader, EventBridgeOnboardTenantEvent.class); 140 | 141 | // Create onboarding event 142 | OnboardingEvent onboardingEvent = OnboardingEvent.createOnboardingEvent(event); 143 | if (onboardingEvent == null) { 144 | if (event.getDetail().getTenantID() == null) { 145 | this.logger.error("Failed to create a new tenant because tenant ID unspecified"); 146 | throw new TenantOnboardingException("Failed to create a new tenant because tenant ID unspecified"); 147 | } 148 | if (event.getDetail().getExternalSubscriptionIdentifier() == null) { 149 | this.logger.error("Failed to create a new tenant because external subscription identifier unspecified"); 150 | throw new TenantOnboardingException("Failed to create a new tenant because external subscription identifier unspecified"); 151 | } 152 | return; 153 | } 154 | 155 | // Put the onboarding event into DynamoDB 156 | putTenant(onboardingEvent); 157 | this.logger.info("Created tenant with ID {}", onboardingEvent.getTenantID()); 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/test/java/aggregation/BillingEventAggregationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package aggregation; 18 | 19 | import org.junit.jupiter.api.AfterAll; 20 | import org.junit.jupiter.api.BeforeAll; 21 | import org.junit.jupiter.api.Test; 22 | import static org.junit.jupiter.api.Assertions.assertEquals; 23 | import static org.junit.jupiter.api.Assertions.assertFalse; 24 | import static org.junit.jupiter.api.Assertions.assertTrue; 25 | 26 | import org.slf4j.Logger; 27 | import org.slf4j.LoggerFactory; 28 | 29 | import com.amazonaws.services.lambda.runtime.Context; 30 | 31 | import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; 32 | import software.amazon.awssdk.regions.Region; 33 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 34 | import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; 35 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 36 | import software.amazon.awssdk.services.dynamodb.model.BillingMode; 37 | import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; 38 | import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; 39 | import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; 40 | import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; 41 | import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; 42 | import software.amazon.awssdk.services.dynamodb.model.KeyType; 43 | import software.amazon.awssdk.services.dynamodb.model.Projection; 44 | import software.amazon.awssdk.services.dynamodb.model.ProjectionType; 45 | import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; 46 | import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; 47 | import software.amazon.awssdk.services.dynamodb.model.QueryRequest; 48 | import software.amazon.awssdk.services.dynamodb.model.QueryResponse; 49 | import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; 50 | 51 | import com.amazonaws.partners.saasfactory.metering.aggregation.BillingEventAggregation; 52 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 53 | 54 | import java.io.ByteArrayInputStream; 55 | import java.io.ByteArrayOutputStream; 56 | import java.io.InputStream; 57 | import java.io.OutputStream; 58 | import java.net.URI; 59 | import java.util.ArrayList; 60 | import java.util.HashMap; 61 | import java.util.List; 62 | import java.util.Map; 63 | import java.util.UUID; 64 | import java.util.regex.Matcher; 65 | import java.util.regex.Pattern; 66 | 67 | class BillingEventAggregationTest { 68 | private static DynamoDbClient client; 69 | private static final String tableName = "TestBillingAggregationTable"; 70 | private static final String indexName = "TestBillingAggregationIndex"; 71 | private static final String external_subscription_identifier = "si_000000000000"; 72 | // This variable is required to run the tests even if it isn't used within the test 73 | private static Logger logger; 74 | 75 | @BeforeAll 76 | public static void initDynamoDBLocal() { 77 | client = DynamoDbClient.builder() 78 | .endpointOverride(URI.create("http://localhost:8000")) 79 | .httpClient(UrlConnectionHttpClient.builder().build()) 80 | .region(Region.US_WEST_2) 81 | .build(); 82 | 83 | CreateTableRequest request = CreateTableRequest.builder() 84 | .tableName(tableName) 85 | .keySchema( 86 | KeySchemaElement.builder() 87 | .attributeName("data_type") 88 | .keyType(KeyType.HASH) 89 | .build(), 90 | KeySchemaElement.builder() 91 | .attributeName("sub_type") 92 | .keyType(KeyType.RANGE) 93 | .build()) 94 | .attributeDefinitions( 95 | AttributeDefinition.builder() 96 | .attributeName("data_type") 97 | .attributeType(ScalarAttributeType.S) 98 | .build(), 99 | AttributeDefinition.builder() 100 | .attributeName("sub_type") 101 | .attributeType(ScalarAttributeType.S) 102 | .build()) 103 | .billingMode(BillingMode.PAY_PER_REQUEST) 104 | .globalSecondaryIndexes( 105 | GlobalSecondaryIndex.builder() 106 | .indexName(indexName) 107 | .keySchema( 108 | KeySchemaElement.builder() 109 | .attributeName("sub_type") 110 | .keyType(KeyType.HASH) 111 | .build(), 112 | KeySchemaElement.builder() 113 | .attributeName("data_type") 114 | .keyType(KeyType.RANGE) 115 | .build()) 116 | .projection( 117 | Projection.builder() 118 | .projectionType(ProjectionType.ALL) 119 | .build()) 120 | .provisionedThroughput( 121 | ProvisionedThroughput.builder() 122 | .readCapacityUnits((long) 0) 123 | .writeCapacityUnits((long) 0) 124 | .build()) 125 | .build()) 126 | .build(); 127 | 128 | CreateTableResponse response = client.createTable(request); 129 | 130 | // Create a sample tenant 131 | HashMap item = new HashMap<>(); 132 | item.put("data_type", AttributeValue.builder() 133 | .s(String.format("TENANT#Tenant%d", 0)) 134 | .build()); 135 | item.put("sub_type", AttributeValue.builder() 136 | .s("CONFIG") 137 | .build()); 138 | item.put("external_subscription_identifier", AttributeValue.builder() 139 | .s(external_subscription_identifier) 140 | .build()); 141 | PutItemRequest tenantRequest = PutItemRequest.builder() 142 | .tableName(tableName) 143 | .item(item) 144 | .build(); 145 | client.putItem(tenantRequest); 146 | 147 | logger = LoggerFactory.getLogger(BillingEventAggregationTest.class); 148 | 149 | } 150 | 151 | @Test 152 | void shouldAggregateBillingEvents() { 153 | // Why start with this time rather than the current time? There's logic in 154 | // the aggregation algorithm that ignores events that occur in the current minute. 155 | // If you used Instant.now, these events would be missed and the test would fail. 156 | long aggregationTime = 1577836800000L; 157 | // Create sample events 158 | for (int i = 0; i < 10; i++) { 159 | HashMap item = new HashMap<>(); 160 | item.put("data_type", AttributeValue.builder() 161 | .s(String.format("TENANT#Tenant%d", 0)) 162 | .build()); 163 | item.put("sub_type", AttributeValue.builder() 164 | .s(String.format("EVENT#%d#%s", 165 | aggregationTime, 166 | UUID.randomUUID().toString().split("-")[4])) 167 | .build()); 168 | item.put("quantity", AttributeValue.builder() 169 | .n("1") 170 | .build()); 171 | 172 | PutItemRequest tenantRequest = PutItemRequest.builder() 173 | .tableName(tableName) 174 | .item(item) 175 | .build(); 176 | client.putItem(tenantRequest); 177 | aggregationTime += 1; 178 | } 179 | 180 | InputStream inputStream = new ByteArrayInputStream("".getBytes()); 181 | OutputStream outputStream = new ByteArrayOutputStream(); 182 | 183 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 184 | BillingEventAggregation billingAggregator = new BillingEventAggregation(client, tableConfig); 185 | Context context = null; 186 | billingAggregator.handleRequest(inputStream, outputStream, context); 187 | 188 | HashMap expressionNames = new HashMap<>(); 189 | String dataTypeExpressionName = "#dataTypeColumn"; 190 | expressionNames.put(dataTypeExpressionName, "data_type"); 191 | String subTypeExpressionName = "#subTypeColumn"; 192 | expressionNames.put(subTypeExpressionName, "sub_type"); 193 | 194 | HashMap expressionValues = new HashMap<>(); 195 | String tenantIDExpressionValue = ":tenantID"; 196 | String tenantID = "TENANT#Tenant0"; 197 | expressionValues.put(tenantIDExpressionValue, 198 | AttributeValue.builder() 199 | .s(tenantID) 200 | .build()); 201 | String aggregatePrefixExpressionValue = ":aggregate"; 202 | String aggregatePrefix = "AGGREGATE"; 203 | expressionValues.put(aggregatePrefixExpressionValue, 204 | AttributeValue.builder() 205 | .s(aggregatePrefix) 206 | .build()); 207 | 208 | QueryResponse response = null; 209 | List> items = new ArrayList<>(); 210 | do { 211 | QueryRequest request = QueryRequest.builder() 212 | .tableName(tableName) 213 | .keyConditionExpression( 214 | String.format("%s = %s and begins_with(%s, %s)", 215 | dataTypeExpressionName, 216 | tenantIDExpressionValue, 217 | subTypeExpressionName, 218 | aggregatePrefixExpressionValue 219 | )) 220 | .expressionAttributeNames(expressionNames) 221 | .expressionAttributeValues(expressionValues) 222 | .build(); 223 | 224 | if (response != null && !response.lastEvaluatedKey().isEmpty()) { 225 | request = request.toBuilder() 226 | .exclusiveStartKey(response.lastEvaluatedKey()) 227 | .build(); 228 | } 229 | 230 | response = client.query(request); 231 | items.addAll(response.items()); 232 | 233 | } while (!response.lastEvaluatedKey().isEmpty()); 234 | 235 | assertFalse(items.isEmpty()); 236 | 237 | // There could be more than one entry if the events were put in when a minute cut over; verify both 238 | int totalQuantity = 0; 239 | for (Map item : response.items()) { 240 | assertEquals(item.get("data_type").s(), tenantID); 241 | 242 | String expectedEventEntry = String.format("%s%s%s%s%s", 243 | aggregatePrefix, 244 | "#", 245 | "MINUTES", 246 | "#", 247 | "[a-z0-9]{12}"); 248 | Pattern expectedEventEntryPattern = Pattern.compile(expectedEventEntry); 249 | Matcher entryMatch = expectedEventEntryPattern.matcher(item.get("sub_type").s()); 250 | assertTrue(entryMatch.find()); 251 | 252 | totalQuantity += Integer.parseInt(item.get("quantity").n()); 253 | } 254 | assertEquals(totalQuantity, 10); 255 | } 256 | 257 | 258 | @AfterAll 259 | public static void cleanUpDynamoDB() { 260 | DeleteTableRequest request = DeleteTableRequest.builder() 261 | .tableName(tableName) 262 | .build(); 263 | 264 | client.deleteTable(request); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/test/java/aggregation/StripeBillingPublishTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package aggregation; 18 | 19 | import org.junit.jupiter.api.AfterAll; 20 | import org.junit.jupiter.api.BeforeAll; 21 | import org.junit.jupiter.api.Test; 22 | import static org.junit.jupiter.api.Assertions.assertTrue; 23 | 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | 27 | import com.amazonaws.services.lambda.runtime.Context; 28 | 29 | import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; 30 | import software.amazon.awssdk.regions.Region; 31 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 32 | import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; 33 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 34 | import software.amazon.awssdk.services.dynamodb.model.BillingMode; 35 | import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; 36 | import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; 37 | import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; 38 | import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; 39 | import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; 40 | import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; 41 | import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; 42 | import software.amazon.awssdk.services.dynamodb.model.KeyType; 43 | import software.amazon.awssdk.services.dynamodb.model.Projection; 44 | import software.amazon.awssdk.services.dynamodb.model.ProjectionType; 45 | import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; 46 | import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; 47 | import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; 48 | 49 | import com.amazonaws.partners.saasfactory.metering.aggregation.StripeBillingPublish; 50 | import com.amazonaws.partners.saasfactory.metering.common.BillingProviderConfiguration; 51 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 52 | 53 | import java.io.ByteArrayInputStream; 54 | import java.io.ByteArrayOutputStream; 55 | import java.io.InputStream; 56 | import java.io.OutputStream; 57 | import java.net.URI; 58 | import java.util.HashMap; 59 | import java.util.UUID; 60 | 61 | class StripeBillingPublishTest { 62 | 63 | private static DynamoDbClient client; 64 | private static final String tableName = "TestStripeBillingPublishTable"; 65 | private static final String indexName = "TestStripeBillingPublishIndex"; 66 | private static final String external_subscription_identifier = "si_000000000000"; 67 | // This variable is required to run the tests even if it isn't used within the test 68 | private static Logger logger; 69 | 70 | @BeforeAll 71 | public static void initDynamoDBLocal() { 72 | client = DynamoDbClient.builder() 73 | .endpointOverride(URI.create("http://localhost:8000")) 74 | .httpClient(UrlConnectionHttpClient.builder().build()) 75 | .region(Region.US_WEST_2) 76 | .build(); 77 | 78 | CreateTableRequest request = CreateTableRequest.builder() 79 | .tableName(tableName) 80 | .keySchema( 81 | KeySchemaElement.builder() 82 | .attributeName("data_type") 83 | .keyType(KeyType.HASH) 84 | .build(), 85 | KeySchemaElement.builder() 86 | .attributeName("sub_type") 87 | .keyType(KeyType.RANGE) 88 | .build()) 89 | .attributeDefinitions( 90 | AttributeDefinition.builder() 91 | .attributeName("data_type") 92 | .attributeType(ScalarAttributeType.S) 93 | .build(), 94 | AttributeDefinition.builder() 95 | .attributeName("sub_type") 96 | .attributeType(ScalarAttributeType.S) 97 | .build()) 98 | .billingMode(BillingMode.PAY_PER_REQUEST) 99 | .globalSecondaryIndexes( 100 | GlobalSecondaryIndex.builder() 101 | .indexName(indexName) 102 | .keySchema( 103 | KeySchemaElement.builder() 104 | .attributeName("sub_type") 105 | .keyType(KeyType.HASH) 106 | .build(), 107 | KeySchemaElement.builder() 108 | .attributeName("data_type") 109 | .keyType(KeyType.RANGE) 110 | .build()) 111 | .projection( 112 | Projection.builder() 113 | .projectionType(ProjectionType.ALL) 114 | .build()) 115 | .provisionedThroughput( 116 | ProvisionedThroughput.builder() 117 | .readCapacityUnits((long) 0) 118 | .writeCapacityUnits((long) 0) 119 | .build()) 120 | .build()) 121 | .build(); 122 | 123 | CreateTableResponse response = client.createTable(request); 124 | 125 | // Create a sample tenant 126 | HashMap item = new HashMap<>(); 127 | item.put("data_type", AttributeValue.builder() 128 | .s(String.format("TENANT#Tenant%d", 0)) 129 | .build()); 130 | item.put("sub_type", AttributeValue.builder() 131 | .s("CONFIG") 132 | .build()); 133 | item.put("external_subscription_identifier", AttributeValue.builder() 134 | .s(external_subscription_identifier) 135 | .build()); 136 | PutItemRequest tenantRequest = PutItemRequest.builder() 137 | .tableName(tableName) 138 | .item(item) 139 | .build(); 140 | client.putItem(tenantRequest); 141 | 142 | logger = LoggerFactory.getLogger(BillingEventAggregationTest.class); 143 | 144 | } 145 | 146 | @Test 147 | void shouldPublishAggregateEntries() { 148 | // Put a sample aggregation event 149 | long aggregationTimePeriod = 1577836800000L; 150 | HashMap item = new HashMap<>(); 151 | item.put("data_type", AttributeValue.builder() 152 | .s(String.format("TENANT#Tenant%d", 0)) 153 | .build()); 154 | item.put("sub_type", AttributeValue.builder() 155 | .s(String.format("AGGREGATE#MINUTES#%d", 156 | aggregationTimePeriod)) 157 | .build()); 158 | item.put("quantity", AttributeValue.builder() 159 | .n("10") 160 | .build()); 161 | item.put("published_to_billing_provider", AttributeValue.builder() 162 | .bool(false) 163 | .build()); 164 | item.put("idempotency_key", AttributeValue.builder() 165 | .s(UUID.randomUUID().toString().split("-")[4]) 166 | .build()); 167 | 168 | PutItemRequest aggregationRequest = PutItemRequest.builder() 169 | .tableName(tableName) 170 | .item(item) 171 | .build(); 172 | client.putItem(aggregationRequest); 173 | 174 | InputStream inputStream = new ByteArrayInputStream("".getBytes()); 175 | OutputStream outputStream = new ByteArrayOutputStream(); 176 | 177 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 178 | String testApiKey = "sk_test_123"; 179 | String stripeOverrideUrl = "http://localhost:12111/"; 180 | BillingProviderConfiguration billingConfig = new BillingProviderConfiguration(testApiKey); 181 | StripeBillingPublish stripeBillingPublish = new StripeBillingPublish(client, 182 | tableConfig, 183 | billingConfig, 184 | stripeOverrideUrl); 185 | Context context = null; 186 | stripeBillingPublish.handleRequest(inputStream, outputStream, context); 187 | 188 | // Not much more can be done beyond checking that the published_to_billing_provider 189 | // attribute is set to true because the stripe-mock doesn't retain state 190 | 191 | HashMap key = new HashMap<>(); 192 | 193 | key.put("data_type", AttributeValue.builder() 194 | .s(String.format("TENANT#Tenant%d", 0)) 195 | .build()); 196 | key.put("sub_type", AttributeValue.builder() 197 | .s(String.format("AGGREGATE#MINUTES#%d", 198 | aggregationTimePeriod)) 199 | .build()); 200 | 201 | GetItemRequest getItemRequest = GetItemRequest.builder() 202 | .key(key) 203 | .tableName(tableName) 204 | .build(); 205 | 206 | GetItemResponse getItemResponse = client.getItem(getItemRequest); 207 | 208 | assertTrue(getItemResponse.item().get("published_to_billing_provider").bool()); 209 | 210 | } 211 | 212 | @AfterAll 213 | public static void cleanUpDynamoDB() { 214 | DeleteTableRequest request = DeleteTableRequest.builder() 215 | .tableName(tableName) 216 | .build(); 217 | 218 | client.deleteTable(request); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/test/java/billing/ProcessBillingEventTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package billing; 18 | 19 | import com.amazonaws.partners.saasfactory.metering.common.ProcessBillingEventException; 20 | import com.google.gson.JsonSyntaxException; 21 | import org.junit.jupiter.api.AfterAll; 22 | import org.junit.jupiter.api.BeforeAll; 23 | import org.junit.jupiter.api.Test; 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | import static org.junit.jupiter.api.Assertions.assertThrows; 26 | 27 | import org.slf4j.Logger; 28 | import org.slf4j.LoggerFactory; 29 | 30 | import com.amazonaws.services.lambda.runtime.Context; 31 | 32 | import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; 33 | import software.amazon.awssdk.regions.Region; 34 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 35 | import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; 36 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 37 | import software.amazon.awssdk.services.dynamodb.model.BillingMode; 38 | import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; 39 | import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; 40 | import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; 41 | import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; 42 | import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; 43 | import software.amazon.awssdk.services.dynamodb.model.KeyType; 44 | import software.amazon.awssdk.services.dynamodb.model.Projection; 45 | import software.amazon.awssdk.services.dynamodb.model.ProjectionType; 46 | import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; 47 | import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; 48 | import software.amazon.awssdk.services.dynamodb.model.QueryRequest; 49 | import software.amazon.awssdk.services.dynamodb.model.QueryResponse; 50 | import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; 51 | 52 | import com.amazonaws.partners.saasfactory.metering.billing.ProcessBillingEvent; 53 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 54 | 55 | import java.io.ByteArrayInputStream; 56 | import java.io.ByteArrayOutputStream; 57 | import java.io.InputStream; 58 | import java.io.OutputStream; 59 | import java.net.URI; 60 | import java.nio.charset.StandardCharsets; 61 | import java.time.Instant; 62 | import java.util.HashMap; 63 | import java.util.regex.Matcher; 64 | import java.util.regex.Pattern; 65 | 66 | class ProcessBillingEventTest { 67 | 68 | private static DynamoDbClient client; 69 | private static final String tableName = "TestTenantConfigurationTable"; 70 | private static final String indexName = "TestTenantConfigurationIndex"; 71 | private static final String external_subscription_identifier = "si_000000000000"; 72 | // This variable is required to run the tests even if it isn't used within the test 73 | private static Logger logger; 74 | 75 | @BeforeAll 76 | public static void initDynamoDBLocal() { 77 | client = DynamoDbClient.builder() 78 | .endpointOverride(URI.create("http://localhost:8000")) 79 | .httpClient(UrlConnectionHttpClient.builder().build()) 80 | .region(Region.US_WEST_2) 81 | .build(); 82 | 83 | CreateTableRequest request = CreateTableRequest.builder() 84 | .tableName(tableName) 85 | .keySchema( 86 | KeySchemaElement.builder() 87 | .attributeName("data_type") 88 | .keyType(KeyType.HASH) 89 | .build(), 90 | KeySchemaElement.builder() 91 | .attributeName("sub_type") 92 | .keyType(KeyType.RANGE) 93 | .build()) 94 | .attributeDefinitions( 95 | AttributeDefinition.builder() 96 | .attributeName("data_type") 97 | .attributeType(ScalarAttributeType.S) 98 | .build(), 99 | AttributeDefinition.builder() 100 | .attributeName("sub_type") 101 | .attributeType(ScalarAttributeType.S) 102 | .build()) 103 | .billingMode(BillingMode.PAY_PER_REQUEST) 104 | .globalSecondaryIndexes( 105 | GlobalSecondaryIndex.builder() 106 | .indexName(indexName) 107 | .keySchema( 108 | KeySchemaElement.builder() 109 | .attributeName("sub_type") 110 | .keyType(KeyType.HASH) 111 | .build(), 112 | KeySchemaElement.builder() 113 | .attributeName("data_type") 114 | .keyType(KeyType.RANGE) 115 | .build()) 116 | .projection( 117 | Projection.builder() 118 | .projectionType(ProjectionType.ALL) 119 | .build()) 120 | .provisionedThroughput( 121 | ProvisionedThroughput.builder() 122 | .readCapacityUnits((long) 0) 123 | .writeCapacityUnits((long) 0) 124 | .build()) 125 | .build()) 126 | .build(); 127 | 128 | CreateTableResponse response = client.createTable(request); 129 | 130 | // Create a sample tenant 131 | HashMap item = new HashMap<>(); 132 | item.put("data_type", AttributeValue.builder() 133 | .s(String.format("TENANT#Tenant%d", 0)) 134 | .build()); 135 | item.put("sub_type", AttributeValue.builder() 136 | .s("CONFIG") 137 | .build()); 138 | item.put("external_subscription_identifier", AttributeValue.builder() 139 | .s(external_subscription_identifier) 140 | .build()); 141 | item.put("closing_invoice_time", AttributeValue.builder() 142 | .s(Instant.now().toString()) 143 | .build()); 144 | PutItemRequest tenantRequest = PutItemRequest.builder() 145 | .tableName(tableName) 146 | .item(item) 147 | .build(); 148 | client.putItem(tenantRequest); 149 | 150 | logger = LoggerFactory.getLogger(ProcessBillingEventTest.class); 151 | } 152 | 153 | @Test 154 | public void shouldProcessBillingEvent() { 155 | String onboardingJSON = "{ \"detail\": { \"TenantID\": \"Tenant0\", \"Quantity\": 5 }}"; 156 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 157 | OutputStream outputStream = new ByteArrayOutputStream(); 158 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 159 | ProcessBillingEvent processBillingEvent = new ProcessBillingEvent(client, tableConfig); 160 | Context context = null; 161 | processBillingEvent.handleRequest(inputStream, outputStream, context); 162 | 163 | HashMap expressionNames = new HashMap<>(); 164 | String dataTypeExpressionName = "#dataTypeColumn"; 165 | expressionNames.put(dataTypeExpressionName, "data_type"); 166 | String subTypeExpressionName = "#subTypeColumn"; 167 | expressionNames.put(subTypeExpressionName, "sub_type"); 168 | 169 | HashMap expressionValues = new HashMap<>(); 170 | String tenantIDExpressionValue = ":tenantID"; 171 | String tenantID = "TENANT#Tenant0"; 172 | expressionValues.put(tenantIDExpressionValue, 173 | AttributeValue.builder() 174 | .s(tenantID) 175 | .build()); 176 | String eventPrefixExpressionValue = ":event"; 177 | String eventPrefix = "EVENT"; 178 | expressionValues.put(eventPrefixExpressionValue, 179 | AttributeValue.builder() 180 | .s(eventPrefix) 181 | .build()); 182 | 183 | QueryResponse response = null; 184 | do { 185 | QueryRequest request = QueryRequest.builder() 186 | .tableName(tableName) 187 | .keyConditionExpression( 188 | String.format("%s = %s and begins_with(%s, %s)", 189 | dataTypeExpressionName, 190 | tenantIDExpressionValue, 191 | subTypeExpressionName, 192 | eventPrefixExpressionValue 193 | )) 194 | .expressionAttributeNames(expressionNames) 195 | .expressionAttributeValues(expressionValues) 196 | .build(); 197 | 198 | if (response != null && !response.lastEvaluatedKey().isEmpty()) { 199 | request = request.toBuilder() 200 | .exclusiveStartKey(response.lastEvaluatedKey()) 201 | .build(); 202 | } 203 | 204 | response = client.query(request); 205 | 206 | } while (!response.lastEvaluatedKey().isEmpty()); 207 | 208 | // There should only be one result here. 209 | assertEquals(1, response.items().size()); 210 | assertEquals(response.items().get(0).get("data_type").s(), tenantID); 211 | 212 | String expectedEventEntry = String.format("%s%s%s%s%s", 213 | "EVENT", 214 | "#", 215 | "[0-9]+", 216 | "#", 217 | "[a-z0-9]{12}"); 218 | Pattern expectedEventEntryPattern = Pattern.compile(expectedEventEntry); 219 | Matcher entryMatch = expectedEventEntryPattern.matcher(response.items().get(0).get("sub_type").s()); 220 | assertEquals(entryMatch.find(), true); 221 | 222 | assertEquals(response.items().get(0).get("quantity").n(), "5"); 223 | } 224 | 225 | @Test 226 | void shouldThrowProcessBillingEventExceptionOnBadQuantityKey() { 227 | String onboardingJSON = "{ \"detail\": { \"TenantID\": \"Tenant0\", \"invalid_key\": 5 }}"; 228 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 229 | OutputStream outputStream = new ByteArrayOutputStream(); 230 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 231 | ProcessBillingEvent processBillingEvent = new ProcessBillingEvent(client, tableConfig); 232 | Context context = null; 233 | assertThrows(ProcessBillingEventException.class, () -> { 234 | processBillingEvent.handleRequest(inputStream, outputStream, context); 235 | }); 236 | } 237 | 238 | @Test 239 | void shouldThrowProcessBillingEventExceptionOnBadTenantIDKey() { 240 | String onboardingJSON = "{ \"detail\": { \"invalid_key\": \"Tenant0\", \"Quantity\": 5 }}"; 241 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 242 | OutputStream outputStream = new ByteArrayOutputStream(); 243 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 244 | ProcessBillingEvent processBillingEvent = new ProcessBillingEvent(client, tableConfig); 245 | Context context = null; 246 | assertThrows(ProcessBillingEventException.class, () -> { 247 | processBillingEvent.handleRequest(inputStream, outputStream, context); 248 | }); 249 | } 250 | 251 | @Test 252 | void shouldThrowJsonSyntaxExceptionExceptionOnBadQuantityValue() { 253 | String onboardingJSON = "{ \"detail\": { \"TenantID\": \"Tenant0\", \"Quantity\": \"Five\" }}"; 254 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 255 | OutputStream outputStream = new ByteArrayOutputStream(); 256 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 257 | ProcessBillingEvent processBillingEvent = new ProcessBillingEvent(client, tableConfig); 258 | Context context = null; 259 | assertThrows(JsonSyntaxException.class, () -> { 260 | processBillingEvent.handleRequest(inputStream, outputStream, context); 261 | }); 262 | } 263 | 264 | @Test 265 | void shouldThrowJsonSyntaxExceptionOnInvalidJson() { 266 | String onboardingJSON = "invalid_json"; 267 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 268 | OutputStream outputStream = new ByteArrayOutputStream(); 269 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 270 | ProcessBillingEvent processBillingEvent = new ProcessBillingEvent(client, tableConfig); 271 | Context context = null; 272 | assertThrows(JsonSyntaxException.class, () -> { 273 | processBillingEvent.handleRequest(inputStream, outputStream, context); 274 | }); 275 | } 276 | 277 | @AfterAll 278 | public static void cleanUpDynamoDB() { 279 | DeleteTableRequest request = DeleteTableRequest.builder() 280 | .tableName(tableName) 281 | .build(); 282 | 283 | client.deleteTable(request); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/test/java/common/ConstantsTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package common; 18 | 19 | import org.junit.jupiter.api.Test; 20 | import static org.junit.jupiter.api.Assertions.assertEquals; 21 | import static org.junit.jupiter.api.Assertions.assertTrue; 22 | 23 | import com.amazonaws.partners.saasfactory.metering.common.Constants; 24 | 25 | import java.util.regex.Matcher; 26 | import java.util.regex.Pattern; 27 | import java.time.Instant; 28 | 29 | class ConstantsTest { 30 | 31 | @Test 32 | void aggregationEntryIsValid() { 33 | long aggregationTime = 1577836800000L; 34 | String aggregationEntry = Constants.formatAggregationEntry(aggregationTime); 35 | String expectedAggregationEntry = String.format("%s%s%s%s%d", 36 | "AGGREGATE", 37 | "#", 38 | "MINUTES", 39 | "#", 40 | aggregationTime); 41 | assertEquals(aggregationEntry, expectedAggregationEntry); 42 | } 43 | 44 | @Test 45 | void tenantEntryIsValid() { 46 | String tenantID = "example-tenant-id"; 47 | String tenantEntry = Constants.formatTenantEntry(tenantID); 48 | String expectedTenantEntry = String.format("%s%s%s", 49 | "TENANT", 50 | "#", 51 | tenantID 52 | ); 53 | assertEquals(tenantEntry, expectedTenantEntry); 54 | } 55 | 56 | @Test 57 | void eventEntryIsValid() { 58 | Instant eventTime = Instant.ofEpochMilli(1577836800000L); 59 | String eventEntry = Constants.formatEventEntry(eventTime); 60 | String expectedEventEntry = String.format("%s%s%d%s%s", 61 | "EVENT", 62 | "#", 63 | eventTime.toEpochMilli(), 64 | "#", 65 | "[a-z0-9]{12}"); 66 | Pattern expectedEventEntryPattern = Pattern.compile(expectedEventEntry); 67 | Matcher entryMatch = expectedEventEntryPattern.matcher(eventEntry); 68 | assertTrue(entryMatch.find()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/test/java/common/TenantConfigurationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package common; 18 | 19 | import org.junit.jupiter.api.AfterAll; 20 | import org.junit.jupiter.api.BeforeAll; 21 | import org.junit.jupiter.api.Test; 22 | import static org.junit.jupiter.api.Assertions.assertEquals; 23 | import static org.junit.jupiter.api.Assertions.assertNull; 24 | import static org.junit.jupiter.api.Assertions.assertTrue; 25 | 26 | import org.slf4j.Logger; 27 | import org.slf4j.LoggerFactory; 28 | 29 | import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; 30 | import software.amazon.awssdk.regions.Region; 31 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 32 | import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; 33 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 34 | import software.amazon.awssdk.services.dynamodb.model.BillingMode; 35 | import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; 36 | import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; 37 | import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; 38 | import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse; 39 | import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; 40 | import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; 41 | import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; 42 | import software.amazon.awssdk.services.dynamodb.model.KeyType; 43 | import software.amazon.awssdk.services.dynamodb.model.Projection; 44 | import software.amazon.awssdk.services.dynamodb.model.ProjectionType; 45 | import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; 46 | import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; 47 | import software.amazon.awssdk.services.dynamodb.model.PutItemResponse; 48 | import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; 49 | 50 | import com.amazonaws.partners.saasfactory.metering.common.TenantConfiguration; 51 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 52 | 53 | import java.net.URI; 54 | import java.time.Instant; 55 | import java.util.List; 56 | import java.util.HashMap; 57 | 58 | class TenantConfigurationTest { 59 | 60 | private static DynamoDbClient client; 61 | private static final String tableName = "TestTenantConfigurationTable"; 62 | private static final String indexName = "TestTenantConfigurationIndex"; 63 | private static final String external_subscription_identifier = "si_000000000000"; 64 | // This variable is required to run the tests even if it isn't used within the test 65 | private static Logger logger; 66 | 67 | @BeforeAll 68 | public static void initDynamoDBLocal() { 69 | client = DynamoDbClient.builder() 70 | .endpointOverride(URI.create("http://localhost:8000")) 71 | .httpClient(UrlConnectionHttpClient.builder().build()) 72 | .region(Region.US_WEST_2) 73 | .build(); 74 | 75 | CreateTableRequest request = CreateTableRequest.builder() 76 | .tableName(tableName) 77 | .keySchema( 78 | KeySchemaElement.builder() 79 | .attributeName("data_type") 80 | .keyType(KeyType.HASH) 81 | .build(), 82 | KeySchemaElement.builder() 83 | .attributeName("sub_type") 84 | .keyType(KeyType.RANGE) 85 | .build()) 86 | .attributeDefinitions( 87 | AttributeDefinition.builder() 88 | .attributeName("data_type") 89 | .attributeType(ScalarAttributeType.S) 90 | .build(), 91 | AttributeDefinition.builder() 92 | .attributeName("sub_type") 93 | .attributeType(ScalarAttributeType.S) 94 | .build()) 95 | .billingMode(BillingMode.PAY_PER_REQUEST) 96 | .globalSecondaryIndexes( 97 | GlobalSecondaryIndex.builder() 98 | .indexName(indexName) 99 | .keySchema( 100 | KeySchemaElement.builder() 101 | .attributeName("sub_type") 102 | .keyType(KeyType.HASH) 103 | .build(), 104 | KeySchemaElement.builder() 105 | .attributeName("data_type") 106 | .keyType(KeyType.RANGE) 107 | .build()) 108 | .projection( 109 | Projection.builder() 110 | .projectionType(ProjectionType.ALL) 111 | .build()) 112 | .provisionedThroughput( 113 | ProvisionedThroughput.builder() 114 | .readCapacityUnits((long) 0) 115 | .writeCapacityUnits((long) 0) 116 | .build()) 117 | .build()) 118 | .build(); 119 | 120 | CreateTableResponse response = client.createTable(request); 121 | 122 | logger = LoggerFactory.getLogger(TenantConfigurationTest.class); 123 | } 124 | 125 | public void loadRandomTenants(int numberOfTenants) { 126 | for (int i = 0; i < numberOfTenants; i++) { 127 | HashMap item = new HashMap<>(); 128 | item.put("data_type", AttributeValue.builder() 129 | .s(String.format("TENANT#Tenant%d", i)) 130 | .build()); 131 | item.put("sub_type", AttributeValue.builder() 132 | .s("CONFIG") 133 | .build()); 134 | item.put("external_subscription_identifier", AttributeValue.builder() 135 | .s(external_subscription_identifier) 136 | .build()); 137 | item.put("closing_invoice_time", AttributeValue.builder() 138 | .s(Instant.now().toString()) 139 | .build()); 140 | PutItemRequest request = PutItemRequest.builder() 141 | .tableName(tableName) 142 | .item(item) 143 | .build(); 144 | PutItemResponse response = client.putItem(request); 145 | } 146 | } 147 | 148 | public void loadDeterministicTenant(String tenantID, Instant time) { 149 | HashMap item = new HashMap<>(); 150 | item.put("data_type", AttributeValue.builder() 151 | .s(String.format("TENANT#%s", tenantID)) 152 | .build()); 153 | item.put("sub_type", AttributeValue.builder() 154 | .s("CONFIG") 155 | .build()); 156 | item.put("external_subscription_identifier", AttributeValue.builder() 157 | .s(external_subscription_identifier) 158 | .build()); 159 | item.put("closing_invoice_time", AttributeValue.builder() 160 | .s(time.toString()) 161 | .build()); 162 | PutItemRequest request = PutItemRequest.builder() 163 | .tableName(tableName) 164 | .item(item) 165 | .build(); 166 | client.putItem(request); 167 | } 168 | 169 | public void loadDeterministicTenantWithoutClosingInvoiceTime(String tenantID) { 170 | HashMap item = new HashMap<>(); 171 | item.put("data_type", AttributeValue.builder() 172 | .s(String.format("TENANT#%s", tenantID)) 173 | .build()); 174 | item.put("sub_type", AttributeValue.builder() 175 | .s("CONFIG") 176 | .build()); 177 | item.put("external_subscription_identifier", AttributeValue.builder() 178 | .s(external_subscription_identifier) 179 | .build()); 180 | PutItemRequest request = PutItemRequest.builder() 181 | .tableName(tableName) 182 | .item(item) 183 | .build(); 184 | client.putItem(request); 185 | } 186 | 187 | public void loadDeterministicTenantWithInvalidClosingInvoiceTime(String tenantID) { 188 | HashMap item = new HashMap<>(); 189 | item.put("data_type", AttributeValue.builder() 190 | .s(String.format("TENANT#%s", tenantID)) 191 | .build()); 192 | item.put("sub_type", AttributeValue.builder() 193 | .s("CONFIG") 194 | .build()); 195 | item.put("external_subscription_identifier", AttributeValue.builder() 196 | .s(external_subscription_identifier) 197 | .build()); 198 | item.put("closing_invoice_time", AttributeValue.builder() 199 | .s("invalid_closing_date") 200 | .build()); 201 | PutItemRequest request = PutItemRequest.builder() 202 | .tableName(tableName) 203 | .item(item) 204 | .build(); 205 | client.putItem(request); 206 | } 207 | 208 | public void deleteTenants(int numberOfTenants) { 209 | for (int i = 0; i < numberOfTenants; i++) { 210 | HashMap key = new HashMap<>(); 211 | key.put("data_type", AttributeValue.builder() 212 | .s(String.format("TENANT#Tenant%d", i)) 213 | .build()); 214 | key.put("sub_type", AttributeValue.builder() 215 | .s("CONFIG") 216 | .build()); 217 | DeleteItemRequest request = DeleteItemRequest.builder() 218 | .tableName(tableName) 219 | .key(key) 220 | .build(); 221 | DeleteItemResponse response = client.deleteItem(request); 222 | } 223 | 224 | } 225 | 226 | @Test 227 | void shouldReturnTenantConfigurationsLessThan25() { 228 | int numberOfTenants = 25; 229 | loadRandomTenants(numberOfTenants); 230 | List tenants = TenantConfiguration.getTenantConfigurations( 231 | new TableConfiguration(tableName, indexName), 232 | client, 233 | logger); 234 | assertEquals(tenants.size(), numberOfTenants); 235 | } 236 | 237 | @Test 238 | void shouldReturnTenantConfigurationsMoreThan25() { 239 | int numberOfTenants = 100; 240 | loadRandomTenants(numberOfTenants); 241 | List tenants = TenantConfiguration.getTenantConfigurations( 242 | new TableConfiguration(tableName, indexName), 243 | client, 244 | logger); 245 | assertEquals(tenants.size(), numberOfTenants); 246 | } 247 | 248 | @Test 249 | void shouldReturnTenantConfiguration() { 250 | String tenantIdentifier = "Tenant0"; 251 | Instant currentTime = Instant.now(); 252 | loadDeterministicTenant(tenantIdentifier, currentTime); 253 | TenantConfiguration tenant = TenantConfiguration.getTenantConfiguration( 254 | tenantIdentifier, 255 | new TableConfiguration(tableName, indexName), 256 | client, 257 | logger); 258 | assertEquals(currentTime, tenant.getInvoiceClosingTime()); 259 | assertEquals(external_subscription_identifier, tenant.getExternalSubscriptionIdentifier()); 260 | assertEquals(tenantIdentifier, tenant.getTenantID()); 261 | } 262 | 263 | @Test 264 | void shouldReturnTenantConfigurationWithEmptyClosingInvoiceTime() { 265 | String tenantIdentifier = "Tenant0"; 266 | loadDeterministicTenantWithoutClosingInvoiceTime(tenantIdentifier); 267 | TenantConfiguration tenant = TenantConfiguration.getTenantConfiguration( 268 | tenantIdentifier, 269 | new TableConfiguration(tableName, indexName), 270 | client, 271 | logger); 272 | assertNull(tenant.getInvoiceClosingTime()); 273 | assertEquals(external_subscription_identifier, tenant.getExternalSubscriptionIdentifier()); 274 | assertEquals(tenantIdentifier, tenant.getTenantID()); 275 | } 276 | 277 | @Test 278 | void shouldReturnEmptyTenantWithInvalidClosingInvoiceTime() { 279 | String tenantIdentifier = "Tenant0"; 280 | loadDeterministicTenantWithInvalidClosingInvoiceTime(tenantIdentifier); 281 | TenantConfiguration tenant = TenantConfiguration.getTenantConfiguration( 282 | tenantIdentifier, 283 | new TableConfiguration(tableName, indexName), 284 | client, 285 | logger); 286 | assertTrue(tenant.isEmpty()); 287 | } 288 | 289 | @AfterAll 290 | public static void cleanUpDynamoDB() { 291 | DeleteTableRequest request = DeleteTableRequest.builder() 292 | .tableName(tableName) 293 | .build(); 294 | 295 | client.deleteTable(request); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/test/java/onboarding/OnboardNewTenantTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | software and associated documentation files (the "Software"), to deal in the Software 6 | without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package onboarding; 18 | 19 | import com.amazonaws.partners.saasfactory.metering.common.TenantOnboardingException; 20 | import com.google.gson.JsonSyntaxException; 21 | import org.junit.jupiter.api.AfterAll; 22 | import org.junit.jupiter.api.BeforeAll; 23 | import org.junit.jupiter.api.Test; 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | import static org.junit.jupiter.api.Assertions.assertThrows; 26 | 27 | import org.slf4j.Logger; 28 | import org.slf4j.LoggerFactory; 29 | 30 | import com.amazonaws.services.lambda.runtime.Context; 31 | 32 | import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; 33 | import software.amazon.awssdk.regions.Region; 34 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient; 35 | import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; 36 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue; 37 | import software.amazon.awssdk.services.dynamodb.model.BillingMode; 38 | import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; 39 | import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; 40 | import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; 41 | import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; 42 | import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; 43 | import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; 44 | import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; 45 | import software.amazon.awssdk.services.dynamodb.model.KeyType; 46 | import software.amazon.awssdk.services.dynamodb.model.Projection; 47 | import software.amazon.awssdk.services.dynamodb.model.ProjectionType; 48 | import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; 49 | import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; 50 | 51 | import com.amazonaws.partners.saasfactory.metering.onboarding.OnboardNewTenant; 52 | import com.amazonaws.partners.saasfactory.metering.common.TableConfiguration; 53 | 54 | import java.io.ByteArrayInputStream; 55 | import java.io.ByteArrayOutputStream; 56 | import java.io.InputStream; 57 | import java.io.OutputStream; 58 | import java.net.URI; 59 | import java.nio.charset.StandardCharsets; 60 | import java.util.HashMap; 61 | 62 | class OnboardNewTenantTest { 63 | 64 | private static DynamoDbClient client; 65 | private static final String tableName = "TestTenantOnboardingTable"; 66 | private static final String indexName = "TestTenantOnboardingIndex"; 67 | private static final String tenantID = "Tenant0"; 68 | private static final String external_subscription_identifier = "si_000000000000"; 69 | // This variable is required to run the tests even if it isn't used within the test 70 | private static Logger logger; 71 | 72 | @BeforeAll 73 | public static void initDynamoDBLocal() { 74 | client = DynamoDbClient.builder() 75 | .endpointOverride(URI.create("http://localhost:8000")) 76 | .httpClient(UrlConnectionHttpClient.builder().build()) 77 | .region(Region.US_WEST_2) 78 | .build(); 79 | 80 | CreateTableRequest request = CreateTableRequest.builder() 81 | .tableName(tableName) 82 | .keySchema( 83 | KeySchemaElement.builder() 84 | .attributeName("data_type") 85 | .keyType(KeyType.HASH) 86 | .build(), 87 | KeySchemaElement.builder() 88 | .attributeName("sub_type") 89 | .keyType(KeyType.RANGE) 90 | .build()) 91 | .attributeDefinitions( 92 | AttributeDefinition.builder() 93 | .attributeName("data_type") 94 | .attributeType(ScalarAttributeType.S) 95 | .build(), 96 | AttributeDefinition.builder() 97 | .attributeName("sub_type") 98 | .attributeType(ScalarAttributeType.S) 99 | .build()) 100 | .billingMode(BillingMode.PAY_PER_REQUEST) 101 | .globalSecondaryIndexes( 102 | GlobalSecondaryIndex.builder() 103 | .indexName(indexName) 104 | .keySchema( 105 | KeySchemaElement.builder() 106 | .attributeName("sub_type") 107 | .keyType(KeyType.HASH) 108 | .build(), 109 | KeySchemaElement.builder() 110 | .attributeName("data_type") 111 | .keyType(KeyType.RANGE) 112 | .build()) 113 | .projection( 114 | Projection.builder() 115 | .projectionType(ProjectionType.ALL) 116 | .build()) 117 | .provisionedThroughput( 118 | ProvisionedThroughput.builder() 119 | .readCapacityUnits((long) 0) 120 | .writeCapacityUnits((long) 0) 121 | .build()) 122 | .build()) 123 | .build(); 124 | 125 | CreateTableResponse response = client.createTable(request); 126 | 127 | logger = LoggerFactory.getLogger(OnboardNewTenantTest.class); 128 | } 129 | 130 | @Test 131 | void shouldAddNewTenant() { 132 | String onboardingJSON = String.format( 133 | "{ \"detail\": { \"TenantID\": \"%s\", \"ExternalSubscriptionIdentifier\": \"%s\" }}", 134 | tenantID, 135 | external_subscription_identifier); 136 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 137 | OutputStream outputStream = new ByteArrayOutputStream(); 138 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 139 | OnboardNewTenant onboardNewTenant = new OnboardNewTenant(client, tableConfig); 140 | Context context = null; 141 | onboardNewTenant.handleRequest(inputStream, outputStream, context); 142 | 143 | HashMap key = new HashMap<>(); 144 | String data_type = "TENANT#Tenant0"; 145 | String sub_type = "CONFIG"; 146 | 147 | key.put("data_type", AttributeValue.builder() 148 | .s(data_type) 149 | .build()); 150 | key.put("sub_type", AttributeValue.builder() 151 | .s(sub_type) 152 | .build()); 153 | 154 | GetItemRequest request = GetItemRequest.builder() 155 | .tableName(tableName) 156 | .key(key) 157 | .build(); 158 | 159 | GetItemResponse response = client.getItem(request); 160 | 161 | assertEquals(data_type, response.item().get("data_type").s()); 162 | assertEquals(sub_type, response.item().get("sub_type").s()); 163 | assertEquals(external_subscription_identifier, response.item().get("external_subscription_identifier").s()); 164 | } 165 | 166 | @Test 167 | void shouldThrowOnboardingExceptionOnBadTenantIDKey() { 168 | String onboardingJSON = String.format( 169 | "{ \"detail\": { \"invalid_key\": \"%s\", \"ExternalSubscriptionIdentifier\": \"%s\" }}", 170 | tenantID, 171 | external_subscription_identifier); 172 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 173 | OutputStream outputStream = new ByteArrayOutputStream(); 174 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 175 | OnboardNewTenant onboardNewTenant = new OnboardNewTenant(client, tableConfig); 176 | Context context = null; 177 | assertThrows(TenantOnboardingException.class, () -> { 178 | onboardNewTenant.handleRequest(inputStream, outputStream, context); 179 | }); 180 | } 181 | 182 | @Test 183 | void shouldThrowOnboardingExceptionOnBadExternalIdentifierKey() { 184 | String onboardingJSON = String.format( 185 | "{ \"detail\": { \"TenantID\": \"%s\", \"invalid_key\": \"%s\" }}", 186 | tenantID, 187 | external_subscription_identifier); 188 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 189 | OutputStream outputStream = new ByteArrayOutputStream(); 190 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 191 | OnboardNewTenant onboardNewTenant = new OnboardNewTenant(client, tableConfig); 192 | Context context = null; 193 | assertThrows(TenantOnboardingException.class, () -> { 194 | onboardNewTenant.handleRequest(inputStream, outputStream, context); 195 | }); 196 | } 197 | 198 | @Test 199 | void shouldJsonSyntaxExceptionOnInvalidJson() { 200 | String onboardingJSON = "invalid_json"; 201 | InputStream inputStream = new ByteArrayInputStream(onboardingJSON.getBytes(StandardCharsets.UTF_8)); 202 | OutputStream outputStream = new ByteArrayOutputStream(); 203 | TableConfiguration tableConfig = new TableConfiguration(tableName, indexName); 204 | OnboardNewTenant onboardNewTenant = new OnboardNewTenant(client, tableConfig); 205 | Context context = null; 206 | assertThrows(JsonSyntaxException.class, () -> { 207 | onboardNewTenant.handleRequest(inputStream, outputStream, context); 208 | }); 209 | } 210 | 211 | @AfterAll 212 | public static void cleanUpDynamoDB() { 213 | DeleteTableRequest request = DeleteTableRequest.builder() 214 | .tableName(tableName) 215 | .build(); 216 | 217 | client.deleteTable(request); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/test/resources/log4j2-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | AWSTemplateFormatVersion: '2010-09-09' 16 | Transform: AWS::Serverless-2016-10-31 17 | Description: Reference implementation for a billing and metering service 18 | 19 | Globals: 20 | Function: 21 | Runtime: java11 22 | MemorySize: 1024 23 | 24 | Parameters: 25 | TenantConfigurationIndexName: 26 | Type: String 27 | Default: sub-type-data-type-index 28 | 29 | BillingEventBridgeName: 30 | Type: String 31 | Default: BillingEventBridge 32 | 33 | Resources: 34 | 35 | # EventBridge resources 36 | BillingEventBridge: 37 | Type: AWS::Events::EventBus 38 | Properties: 39 | Name: !Ref BillingEventBridgeName 40 | ##### 41 | 42 | # Tenant onboarding resources 43 | OnboardTenantEventRule: 44 | Type: AWS::Events::Rule 45 | Properties: 46 | Description: A filter for onboarding tenant events 47 | EventBusName: !Ref BillingEventBridge 48 | EventPattern: 49 | detail-type: 50 | - "ONBOARD" 51 | State: ENABLED 52 | Targets: 53 | - Arn: !GetAtt OnboardNewTenantFunction.Arn 54 | Id: OnboardNewTenantFunction 55 | 56 | OnboardNewTenantFunction: 57 | Type: AWS::Serverless::Function 58 | Properties: 59 | CodeUri: . 60 | Environment: 61 | Variables: 62 | DYNAMODB_TABLE_NAME: !Ref BillingAndMeteringTable 63 | DYNAMODB_CONFIG_INDEX_NAME: !Ref TenantConfigurationIndexName 64 | Handler: com.amazonaws.partners.saasfactory.metering.onboarding.OnboardNewTenant::handleRequest 65 | Role: !GetAtt OnboardNewTenantFunctionRole.Arn 66 | Timeout: 30 67 | 68 | OnboardNewTenantFunctionPermission: 69 | Type: AWS::Lambda::Permission 70 | Properties: 71 | Action: 'lambda:InvokeFunction' 72 | FunctionName: !GetAtt OnboardNewTenantFunction.Arn 73 | Principal: 'events.amazonaws.com' 74 | SourceArn: !GetAtt OnboardTenantEventRule.Arn 75 | 76 | OnboardNewTenantFunctionRole: 77 | Type: AWS::IAM::Role 78 | Properties: 79 | AssumeRolePolicyDocument: 80 | Version: 2012-10-17 81 | Statement: 82 | - Effect: Allow 83 | Principal: 84 | Service: 85 | - lambda.amazonaws.com 86 | Action: 87 | - 'sts:AssumeRole' 88 | Policies: 89 | - PolicyName: OnboardNewTenantFunction-Policy 90 | PolicyDocument: 91 | Version: 2012-10-17 92 | Statement: 93 | - Effect: Allow 94 | Action: 95 | - 'dynamodb:UpdateItem' 96 | - 'dynamodb:PutItem' 97 | Resource: !GetAtt BillingAndMeteringTable.Arn 98 | - Effect: Allow 99 | Action: 100 | - logs:PutLogEvents 101 | Resource: 102 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:* 103 | - Effect: Allow 104 | Action: 105 | - logs:CreateLogStream 106 | - logs:DescribeLogStreams 107 | - logs:CreateLogGroup 108 | Resource: 109 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 110 | ##### 111 | 112 | # Process billing event resources 113 | BillingEventRule: 114 | Type: AWS::Events::Rule 115 | Properties: 116 | Description: A filter for billing events on the billing event bridge 117 | EventBusName: !Ref BillingEventBridge 118 | EventPattern: 119 | detail-type: 120 | - "BILLING" 121 | State: ENABLED 122 | Targets: 123 | - Arn: !GetAtt ProcessBillingEventFunction.Arn 124 | Id: ProcessBillingEventFunction 125 | 126 | ProcessBillingEventFunction: 127 | Type: AWS::Serverless::Function 128 | Properties: 129 | CodeUri: . 130 | Environment: 131 | Variables: 132 | DYNAMODB_TABLE_NAME: !Ref BillingAndMeteringTable 133 | DYNAMODB_CONFIG_INDEX_NAME: !Ref TenantConfigurationIndexName 134 | Handler: com.amazonaws.partners.saasfactory.metering.billing.ProcessBillingEvent::handleRequest 135 | Role: !GetAtt ProcessBillingEventFunctionRole.Arn 136 | Timeout: 30 137 | 138 | ProcessBillingEventFunctionEventsPermission: 139 | Type: AWS::Lambda::Permission 140 | Properties: 141 | Action: 'lambda:InvokeFunction' 142 | FunctionName: !GetAtt ProcessBillingEventFunction.Arn 143 | Principal: 'events.amazonaws.com' 144 | SourceArn: !GetAtt BillingEventRule.Arn 145 | 146 | ProcessBillingEventFunctionRole: 147 | Type: AWS::IAM::Role 148 | Properties: 149 | AssumeRolePolicyDocument: 150 | Version: 2012-10-17 151 | Statement: 152 | - Effect: Allow 153 | Principal: 154 | Service: 155 | - lambda.amazonaws.com 156 | Action: 157 | - 'sts:AssumeRole' 158 | Policies: 159 | - PolicyName: ProcessBillingEventFunction-Policy 160 | PolicyDocument: 161 | Version: 2012-10-17 162 | Statement: 163 | - Effect: Allow 164 | Action: 165 | - 'dynamodb:GetItem' 166 | - 'dynamodb:PutItem' 167 | Resource: !GetAtt BillingAndMeteringTable.Arn 168 | - Effect: Allow 169 | Action: 170 | - 'logs:PutLogEvents' 171 | Resource: 172 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:* 173 | - Effect: Allow 174 | Action: 175 | - 'logs:CreateLogStream' 176 | - 'logs:DescribeLogStreams' 177 | - 'logs:CreateLogGroup' 178 | Resource: 179 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 180 | ##### 181 | 182 | # Aggregation resources 183 | BillingEventAggregationFunction: 184 | Type: AWS::Serverless::Function 185 | Properties: 186 | CodeUri: . 187 | Environment: 188 | Variables: 189 | DYNAMODB_TABLE_NAME: !Ref BillingAndMeteringTable 190 | DYNAMODB_CONFIG_INDEX_NAME: !Ref TenantConfigurationIndexName 191 | Handler: com.amazonaws.partners.saasfactory.metering.aggregation.BillingEventAggregation::handleRequest 192 | Role: !GetAtt BillingEventAggregationFunctionRole.Arn 193 | Timeout: 900 194 | 195 | BillingEventAggregationFunctionRole: 196 | Type: AWS::IAM::Role 197 | Properties: 198 | AssumeRolePolicyDocument: 199 | Version: 2012-10-17 200 | Statement: 201 | - Effect: Allow 202 | Principal: 203 | Service: 204 | - lambda.amazonaws.com 205 | Action: 206 | - 'sts:AssumeRole' 207 | Policies: 208 | - PolicyName: BillingEventAggregation-Policy 209 | PolicyDocument: 210 | Version: 2012-10-17 211 | Statement: 212 | - Effect: Allow 213 | Action: 214 | - 'dynamodb:Query' 215 | Resource: 216 | - !GetAtt BillingAndMeteringTable.Arn 217 | - !Join 218 | - '' 219 | - - !GetAtt BillingAndMeteringTable.Arn 220 | - '/index/' 221 | - !Ref TenantConfigurationIndexName 222 | - Effect: Allow 223 | Action: 224 | - 'dynamodb:ConditionCheckItem' 225 | - 'dynamodb:PutItem' 226 | - 'dynamodb:UpdateItem' 227 | - 'dynamodb:DeleteItem' 228 | - 'dynamodb:GetItem' 229 | Resource: 230 | - !GetAtt BillingAndMeteringTable.Arn 231 | Condition: 232 | ForAnyValue:StringEquals: 233 | dynamodb:EnclosingOperation: 234 | - TransactWriteItems 235 | - TransactGetItems 236 | - Effect: Allow 237 | Action: 238 | - 'dynamodb:PutItem' 239 | Resource: 240 | - !GetAtt BillingAndMeteringTable.Arn 241 | - Effect: Allow 242 | Action: 243 | - logs:PutLogEvents 244 | Resource: 245 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:* 246 | - Effect: Allow 247 | Action: 248 | - logs:CreateLogStream 249 | - logs:DescribeLogStreams 250 | - logs:CreateLogGroup 251 | Resource: 252 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 253 | ##### 254 | 255 | # Publish billing data to Stripe resources 256 | PublishToStripeEventRule: 257 | Type: AWS::Events::Rule 258 | Properties: 259 | Description: A scheduled task to publish billing data to Stripe 260 | # Run this on the top of the hour 261 | ScheduleExpression: "cron(* * * * ? *)" 262 | State: ENABLED 263 | Targets: 264 | - Arn: !Ref PublishToStripeStepFunction 265 | Id: PublishToStripeStepFunction 266 | RoleArn: !GetAtt PublishToStripeEventRuleRole.Arn 267 | 268 | PublishToStripeEventRuleRole: 269 | Type: AWS::IAM::Role 270 | Properties: 271 | AssumeRolePolicyDocument: 272 | Version: 2012-10-17 273 | Statement: 274 | - Effect: Allow 275 | Principal: 276 | Service: 277 | - events.amazonaws.com 278 | Action: 279 | - 'sts:AssumeRole' 280 | Policies: 281 | - PolicyName: InvokeStepFunction 282 | PolicyDocument: 283 | Version: 2012-10-17 284 | Statement: 285 | - Effect: Allow 286 | Action: 'states:StartExecution' 287 | Resource: !Ref PublishToStripeStepFunction 288 | 289 | PublishToStripeLogGroup: 290 | Type: AWS::Logs::LogGroup 291 | Properties: 292 | RetentionInDays: 7 293 | 294 | PublishToStripeStepFunction: 295 | Type: AWS::StepFunctions::StateMachine 296 | Properties: 297 | DefinitionString: 298 | !Sub 299 | - |- 300 | { 301 | "StartAt": "AggregateEntries", 302 | "States" : { 303 | "AggregateEntries": { 304 | "Type": "Task", 305 | "Resource": "${BillingAggregationArn}", 306 | "Next": "PutEvents" 307 | }, 308 | "PutEvents" : { 309 | "Type": "Task", 310 | "Resource": "${StripeAggregationArn}", 311 | "End": true 312 | } 313 | } 314 | } 315 | - BillingAggregationArn: !GetAtt [BillingEventAggregationFunction, Arn] 316 | StripeAggregationArn: !GetAtt [StripeBillingPublishFunction, Arn] 317 | RoleArn: !GetAtt PublishToStripeStateMachineRole.Arn 318 | StateMachineType: EXPRESS 319 | LoggingConfiguration: 320 | Destinations: 321 | - CloudWatchLogsLogGroup: 322 | LogGroupArn: !GetAtt PublishToStripeLogGroup.Arn 323 | Level: ALL 324 | 325 | PublishToStripeStateMachineRole: 326 | Type: AWS::IAM::Role 327 | Properties: 328 | AssumeRolePolicyDocument: 329 | Version: 2012-10-17 330 | Statement: 331 | - Effect: Allow 332 | Principal: 333 | Service: 334 | - states.amazonaws.com 335 | Action: 336 | - 'sts:AssumeRole' 337 | Policies: 338 | - PolicyName: StepFunctionPermissions 339 | PolicyDocument: 340 | Version: 2012-10-17 341 | Statement: 342 | - Effect: Allow 343 | Action: 'lambda:InvokeFunction' 344 | Resource: 345 | - !GetAtt BillingEventAggregationFunction.Arn 346 | - !GetAtt StripeBillingPublishFunction.Arn 347 | - Effect: Allow 348 | Action: 349 | - 'logs:CreateLogDelivery' 350 | - 'logs:GetLogDelivery' 351 | - 'logs:UpdateLogDelivery' 352 | - 'logs:DeleteLogDelivery' 353 | - 'logs:ListLogDeliveries' 354 | - 'logs:PutResourcePolicy' 355 | - 'logs:DescribeResourcePolicies' 356 | Resource: '*' 357 | - Effect: Allow 358 | Action: 359 | - 'logs:DescribeLogGroups' 360 | Resource: 361 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 362 | 363 | StripeBillingPublishFunction: 364 | Type: AWS::Serverless::Function 365 | Properties: 366 | CodeUri: . 367 | Environment: 368 | Variables: 369 | DYNAMODB_TABLE_NAME: !Ref BillingAndMeteringTable 370 | DYNAMODB_CONFIG_INDEX_NAME: !Ref TenantConfigurationIndexName 371 | STRIPE_SECRET_ARN: !Ref StripeApiKeySecret 372 | Handler: com.amazonaws.partners.saasfactory.metering.aggregation.StripeBillingPublish::handleRequest 373 | Role: !GetAtt StripeBillingPublishFunctionRole.Arn 374 | Timeout: 300 375 | 376 | StripeBillingPublishFunctionRole: 377 | Type: AWS::IAM::Role 378 | Properties: 379 | AssumeRolePolicyDocument: 380 | Version: 2012-10-17 381 | Statement: 382 | - Effect: Allow 383 | Principal: 384 | Service: 385 | - lambda.amazonaws.com 386 | Action: 387 | - 'sts:AssumeRole' 388 | Policies: 389 | - PolicyName: DDBPermissions 390 | PolicyDocument: 391 | Version: 2012-10-17 392 | Statement: 393 | - Effect: Allow 394 | Action: 395 | - 'dynamodb:Query' 396 | Resource: 397 | - !GetAtt BillingAndMeteringTable.Arn 398 | - !Join 399 | - '' 400 | - - !GetAtt BillingAndMeteringTable.Arn 401 | - '/index/' 402 | - !Ref TenantConfigurationIndexName 403 | - Effect: Allow 404 | Action: 405 | - 'dynamodb:BatchWriteItem' 406 | - 'dynamodb:UpdateItem' 407 | Resource: 408 | - !GetAtt BillingAndMeteringTable.Arn 409 | - Effect: Allow 410 | Action: 411 | - 'secretsmanager:GetSecretValue' 412 | Resource: 413 | - !Ref StripeApiKeySecret 414 | - Effect: Allow 415 | Action: 416 | - logs:PutLogEvents 417 | Resource: 418 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:* 419 | - Effect: Allow 420 | Action: 421 | - logs:CreateLogStream 422 | - logs:DescribeLogStreams 423 | - logs:CreateLogGroup 424 | Resource: 425 | - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 426 | ##### 427 | 428 | # Data persistence resources 429 | BillingAndMeteringTable: 430 | Type: AWS::DynamoDB::Table 431 | Properties: 432 | AttributeDefinitions: 433 | - AttributeName: data_type 434 | AttributeType: S 435 | - AttributeName: sub_type 436 | AttributeType: S 437 | BillingMode: PAY_PER_REQUEST 438 | GlobalSecondaryIndexes: 439 | - IndexName: !Ref TenantConfigurationIndexName 440 | KeySchema: 441 | - AttributeName: sub_type 442 | KeyType: HASH 443 | - AttributeName: data_type 444 | KeyType: RANGE 445 | Projection: 446 | ProjectionType: ALL 447 | ProvisionedThroughput: 448 | ReadCapacityUnits: 0 449 | WriteCapacityUnits: 0 450 | KeySchema: 451 | - AttributeName: data_type 452 | KeyType: HASH 453 | - AttributeName: sub_type 454 | KeyType: RANGE 455 | PointInTimeRecoverySpecification: 456 | PointInTimeRecoveryEnabled: true 457 | SSESpecification: 458 | SSEEnabled: true 459 | 460 | StripeApiKeySecret: 461 | Type: AWS::SecretsManager::Secret 462 | Properties: 463 | Description: The API key used to access Stripe 464 | ##### 465 | 466 | Outputs: 467 | StripeSecretKeyArn: 468 | Description: The secret key ARN created for Stripe's API key 469 | Value: !Ref StripeApiKeySecret 470 | 471 | --------------------------------------------------------------------------------