diff --git a/modules/billing-budget/README.md b/modules/billing-budget/README.md
new file mode 100644
index 00000000..2b1eaced
--- /dev/null
+++ b/modules/billing-budget/README.md
@@ -0,0 +1,88 @@
+# Google Cloud Billing Budget Module
+
+This module allows creating a Cloud Billing budget for a set of services and projects.
+
+To create billing budgets you need one of the following IAM roles on the target billing account:
+
+* Billing Account Administrator
+* Billing Account Costs Manager
+
+## Examples
+
+### Simple email notification
+
+Send a notification to an email when a set of projects reach $100 of spend.
+
+```hcl
+module "budget" {
+ source = "./modules/billing-budget"
+ billing_account = var.billing_account_id
+ name = "$100 budget"
+ amount = 100
+ thresholds = {
+ current = [0.5, 0.75, 1.0]
+ forecasted = [1.0]
+ }
+ projects = [
+ "projects/123456789000",
+ "projects/123456789111"
+ ]
+ email_recipients = {
+ project_id = "my-project"
+ emails = ["user@example.com"]
+ }
+}
+# tftest:modules=1:resources=2
+```
+
+### Pubsub notification
+
+Send a notification to a PubSub topic the total spend of a billing account reaches the previous month's spend.
+
+
+```hcl
+module "budget" {
+ source = "./modules/billing-budget"
+ billing_account = var.billing_account_id
+ name = "previous period budget"
+ amount = 0
+ thresholds = {
+ current = [1.0]
+ forecasted = []
+ }
+ pubsub_topic = module.pubsub.id
+}
+
+module "pubsub" {
+ source = "./modules/pubsub"
+ project_id = var.project_id
+ name = "budget-topic"
+}
+
+# tftest:modules=2:resources=2
+```
+
+
+## Variables
+
+| name | description | type | required | default |
+|---|---|:---: |:---:|:---:|
+| billing_account | Billing account id. | string
| ✓ | |
+| name | Budget name. | string
| ✓ | |
+| thresholds | None | object({...})
| ✓ | |
+| *amount* | Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend. | number
| | 0
|
+| *credit_treatment* | How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported | string
| | ...
|
+| *email_recipients* | Emails where budget notifications will be sent. Setting this will create a notification channel for each email in the specified project. | object({...})
| | null
|
+| *notification_channels* | Monitoring notification channels where to send updates. | list(string)
| | null
|
+| *notify_default_recipients* | Notify Billing Account Administrators and Billing Account Users IAM roles for the target account. | bool
| | false
|
+| *projects* | List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account. | list(string)
| | null
|
+| *pubsub_topic* | The ID of the Cloud Pub/Sub topic where budget related messages will be published. | string
| | null
|
+| *services* | List of services of the form services/{service_id}, specifying that usage from only this set of services should be included in the budget. Set to null to include usage for all services. | list(string)
| | null
|
+
+## Outputs
+
+| name | description | sensitive |
+|---|---|:---:|
+| budget | Budget resource. | |
+| id | Budget ID. | |
+
diff --git a/modules/billing-budget/main.tf b/modules/billing-budget/main.tf
new file mode 100644
index 00000000..739dcedd
--- /dev/null
+++ b/modules/billing-budget/main.tf
@@ -0,0 +1,95 @@
+/**
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+locals {
+ spend_basis = {
+ current = "CURRENT_SPEND"
+ forecasted = "FORECASTED_SPEND"
+ }
+ threshold_pairs = flatten([
+ for type, values in var.thresholds : [
+ for value in values : {
+ spend_basis = local.spend_basis[type]
+ threshold_percent = value
+ }
+ ]
+ ])
+
+ notification_channels = concat(
+ [for channel in google_monitoring_notification_channel.email_channels : channel.id],
+ coalesce(var.notification_channels, [])
+ )
+}
+
+resource "google_monitoring_notification_channel" "email_channels" {
+ for_each = toset(try(var.email_recipients.emails, []))
+ display_name = "${var.name} budget email notification (${each.value})"
+ type = "email"
+ project = var.email_recipients.project_id
+ labels = {
+ email_address = each.value
+ }
+ user_labels = {}
+}
+
+
+resource "google_billing_budget" "budget" {
+ billing_account = var.billing_account
+ display_name = var.name
+
+ budget_filter {
+ projects = var.projects
+ credit_types_treatment = var.credit_treatment
+ services = var.services
+ }
+
+ dynamic "amount" {
+ for_each = var.amount == 0 ? [1] : []
+ content {
+ last_period_amount = true
+ }
+ }
+
+ dynamic "amount" {
+ for_each = var.amount != 0 ? [1] : []
+ content {
+ dynamic "specified_amount" {
+ for_each = var.amount != 0 ? [1] : []
+ content {
+ units = var.amount
+ }
+ }
+ }
+ }
+
+ dynamic "threshold_rules" {
+ for_each = local.threshold_pairs
+ iterator = threshold
+ content {
+ threshold_percent = threshold.value.threshold_percent
+ spend_basis = threshold.value.spend_basis
+ }
+ }
+
+ all_updates_rule {
+ monitoring_notification_channels = local.notification_channels
+ pubsub_topic = var.pubsub_topic
+ # disable_default_iam_recipients can only be set if
+ # monitoring_notification_channels is nonempty
+ disable_default_iam_recipients = try(length(var.notification_channels), 0) > 0 && !var.notify_default_recipients
+ schema_version = "1.0"
+ }
+}
diff --git a/modules/billing-budget/outputs.tf b/modules/billing-budget/outputs.tf
new file mode 100644
index 00000000..9f2dd4ff
--- /dev/null
+++ b/modules/billing-budget/outputs.tf
@@ -0,0 +1,25 @@
+/**
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+output "budget" {
+ description = "Budget resource."
+ value = google_billing_budget.budget
+}
+
+output "id" {
+ description = "Budget ID."
+ value = google_billing_budget.budget.id
+}
diff --git a/modules/billing-budget/variables.tf b/modules/billing-budget/variables.tf
new file mode 100644
index 00000000..3125d37d
--- /dev/null
+++ b/modules/billing-budget/variables.tf
@@ -0,0 +1,94 @@
+/**
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+variable "amount" {
+ description = "Amount in the billing account's currency for the budget. Use 0 to set budget to 100% of last period's spend."
+ type = number
+ default = 0
+}
+
+variable "billing_account" {
+ description = "Billing account id."
+ type = string
+}
+
+variable "credit_treatment" {
+ description = "How credits should be treated when determining spend for threshold calculations. Only INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS are supported"
+ type = string
+ default = "INCLUDE_ALL_CREDITS"
+ validation {
+ condition = (
+ var.credit_treatment == "INCLUDE_ALL_CREDITS" ||
+ var.credit_treatment == "EXCLUDE_ALL_CREDITS"
+ )
+ error_message = "Argument credit_treatment must be INCLUDE_ALL_CREDITS or EXCLUDE_ALL_CREDITS."
+ }
+}
+
+variable "email_recipients" {
+ description = "Emails where budget notifications will be sent. Setting this will create a notification channel for each email in the specified project."
+ type = object({
+ project_id = string
+ emails = list(string)
+ })
+ default = null
+}
+
+variable "name" {
+ description = "Budget name."
+ type = string
+}
+
+variable "notification_channels" {
+ description = "Monitoring notification channels where to send updates."
+ type = list(string)
+ default = null
+}
+
+variable "notify_default_recipients" {
+ description = "Notify Billing Account Administrators and Billing Account Users IAM roles for the target account."
+ type = bool
+ default = false
+}
+
+variable "projects" {
+ description = "List of projects of the form projects/{project_number}, specifying that usage from only this set of projects should be included in the budget. Set to null to include all projects linked to the billing account."
+ type = list(string)
+ default = null
+}
+
+variable "pubsub_topic" {
+ description = "The ID of the Cloud Pub/Sub topic where budget related messages will be published."
+ type = string
+ default = null
+}
+
+variable "services" {
+ description = "List of services of the form services/{service_id}, specifying that usage from only this set of services should be included in the budget. Set to null to include usage for all services."
+ type = list(string)
+ default = null
+}
+
+variable "thresholds" {
+ type = object({
+ current = list(number)
+ forecasted = list(number)
+ })
+ validation {
+ condition = length(var.thresholds.current) > 0 || length(var.thresholds.forecasted) > 0
+ error_message = "Must specify at least one budget threshold."
+ }
+}
diff --git a/modules/billing-budget/versions.tf b/modules/billing-budget/versions.tf
new file mode 100644
index 00000000..968f411e
--- /dev/null
+++ b/modules/billing-budget/versions.tf
@@ -0,0 +1,23 @@
+/**
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+terraform {
+ required_version = ">= 0.13.0"
+ required_providers {
+ google = ">= 3.79.0"
+ google-beta = ">= 3.79.0"
+ }
+}
diff --git a/tests/modules/billing_budget/__init__.py b/tests/modules/billing_budget/__init__.py
new file mode 100644
index 00000000..d46dbae5
--- /dev/null
+++ b/tests/modules/billing_budget/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2021 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/tests/modules/billing_budget/fixture/main.tf b/tests/modules/billing_budget/fixture/main.tf
new file mode 100644
index 00000000..91c05e4a
--- /dev/null
+++ b/tests/modules/billing_budget/fixture/main.tf
@@ -0,0 +1,30 @@
+/**
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+module "budget" {
+ source = "../../../../modules/billing-budget"
+ billing_account = "123456-123456-123456"
+ name = "my budget"
+ projects = var.projects
+ services = var.services
+ notify_default_recipients = var.notify_default_recipients
+ amount = var.amount
+ credit_treatment = var.credit_treatment
+ pubsub_topic = var.pubsub_topic
+ notification_channels = var.notification_channels
+ thresholds = var.thresholds
+ email_recipients = var.email_recipients
+}
diff --git a/tests/modules/billing_budget/fixture/variables.tf b/tests/modules/billing_budget/fixture/variables.tf
new file mode 100644
index 00000000..6eb8e4e3
--- /dev/null
+++ b/tests/modules/billing_budget/fixture/variables.tf
@@ -0,0 +1,69 @@
+/**
+ * Copyright 2021 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+variable "amount" {
+ type = number
+ default = 0
+}
+
+variable "credit_treatment" {
+ type = string
+ default = "INCLUDE_ALL_CREDITS"
+}
+
+variable "email_recipients" {
+ type = object({
+ project_id = string
+ emails = list(string)
+ })
+ default = null
+}
+
+variable "notification_channels" {
+ type = list(string)
+ default = null
+}
+
+variable "notify_default_recipients" {
+ type = bool
+ default = false
+}
+
+variable "projects" {
+ type = list(string)
+ default = null
+}
+
+variable "pubsub_topic" {
+ type = string
+ default = null
+}
+
+variable "services" {
+ type = list(string)
+ default = null
+}
+
+variable "thresholds" {
+ type = object({
+ current = list(number)
+ forecasted = list(number)
+ })
+ default = {
+ current = [0.5, 1.0]
+ forecasted = [1.0]
+ }
+}
diff --git a/tests/modules/billing_budget/test_plan.py b/tests/modules/billing_budget/test_plan.py
new file mode 100644
index 00000000..5692bf0a
--- /dev/null
+++ b/tests/modules/billing_budget/test_plan.py
@@ -0,0 +1,69 @@
+# Copyright 2021 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import os
+import pytest
+
+
+FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixture')
+
+
+def test_pubsub(plan_runner):
+ "Test number of resources created."
+ _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic')
+ assert len(resources) == 1
+ resource = resources[0]
+ assert resource['values']['all_updates_rule'] == [
+ {'disable_default_iam_recipients': False,
+ 'monitoring_notification_channels': [],
+ 'pubsub_topic': 'topic',
+ 'schema_version': '1.0'}
+ ]
+
+def test_channel(plan_runner):
+ _, resources = plan_runner(FIXTURES_DIR, notification_channels='["channel"]')
+ assert len(resources) == 1
+ resource = resources[0]
+ assert resource['values']['all_updates_rule'] == [
+ {'disable_default_iam_recipients': True,
+ 'monitoring_notification_channels': ['channel'],
+ 'pubsub_topic': None,
+ 'schema_version': '1.0'}
+ ]
+
+def test_emails(plan_runner):
+ email_recipients = '{project_id = "project", emails = ["a@b.com", "c@d.com"]}'
+ _, resources = plan_runner(FIXTURES_DIR, email_recipients=email_recipients)
+ assert len(resources) == 3
+
+
+def test_absolute_amount(plan_runner):
+ "Test absolute amount budget."
+ _, resources = plan_runner(FIXTURES_DIR, pubsub_topic='topic', amount="100")
+ assert len(resources) == 1
+ resource = resources[0]
+
+ amount = resource['values']['amount'][0]
+ assert amount['last_period_amount'] is None
+ assert amount['specified_amount'] == [{'nanos': None, 'units': '100'}]
+
+ assert resource['values']['threshold_rules'] == [
+ {'spend_basis': 'CURRENT_SPEND',
+ 'threshold_percent': 0.5},
+ {'spend_basis': 'CURRENT_SPEND',
+ 'threshold_percent': 1},
+ {'spend_basis': 'FORECASTED_SPEND',
+ 'threshold_percent': 1}
+ ]