├── .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