summaryrefslogtreecommitdiffstats
path: root/controller-api/src/main/java/com/yahoo
diff options
context:
space:
mode:
Diffstat (limited to 'controller-api/src/main/java/com/yahoo')
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/application/v4/model/DeploymentData.java54
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ConsoleUrls.java89
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java35
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java7
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/AcceptedCountries.java23
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java31
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillStatus.java33
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java13
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java2
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java27
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporter.java8
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporterMock.java26
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java13
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InvoiceUpdate.java45
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java36
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java4
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java20
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/StatusHistory.java61
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateValidatorImpl.java2
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentFailureMails.java12
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java9
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java18
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java10
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java5
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/zone/ZoneRegistry.java21
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java33
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java37
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java6
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/systemflags/v1/SystemFlagsDataArchive.java5
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java7
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java6
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java3
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PurchaseOrder.java21
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TaxId.java41
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java51
35 files changed, 565 insertions, 249 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/application/v4/model/DeploymentData.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/application/v4/model/DeploymentData.java
index f406095d579..fd4a34118c5 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/application/v4/model/DeploymentData.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/application/v4/model/DeploymentData.java
@@ -14,9 +14,12 @@ import com.yahoo.yolean.concurrent.Memoized;
import java.io.InputStream;
import java.security.cert.X509Certificate;
+import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import static java.util.Objects.requireNonNull;
@@ -28,6 +31,8 @@ import static java.util.Objects.requireNonNull;
*/
public class DeploymentData {
+ private static final Logger log = Logger.getLogger(DeploymentData.class.getName());
+
private final ApplicationId instance;
private final ZoneId zone;
private final Supplier<InputStream> applicationPackage;
@@ -56,14 +61,14 @@ public class DeploymentData {
this.zone = requireNonNull(zone);
this.applicationPackage = requireNonNull(applicationPackage);
this.platform = requireNonNull(platform);
- this.endpoints = new Memoized<>(requireNonNull(endpoints));
+ this.endpoints = wrap(requireNonNull(endpoints), Duration.ofSeconds(30), "deployment endpoints for " + instance + " in " + zone);
this.dockerImageRepo = requireNonNull(dockerImageRepo);
this.athenzDomain = athenzDomain;
- this.quota = new Memoized<>(requireNonNull(quota));
+ this.quota = wrap(requireNonNull(quota), Duration.ofSeconds(10), "quota for " + instance);
this.tenantSecretStores = List.copyOf(requireNonNull(tenantSecretStores));
this.operatorCertificates = List.copyOf(requireNonNull(operatorCertificates));
- this.cloudAccount = new Memoized<>(requireNonNull(cloudAccount));
- this.dataPlaneTokens = new Memoized<>(dataPlaneTokens);
+ this.cloudAccount = wrap(requireNonNull(cloudAccount), Duration.ofSeconds(5), "cloud account for " + instance + " in " + zone);
+ this.dataPlaneTokens = wrap(dataPlaneTokens, Duration.ofSeconds(5), "data plane tokens for " + instance + " in " + zone);
this.dryRun = dryRun;
}
@@ -83,8 +88,8 @@ public class DeploymentData {
return platform;
}
- public Supplier<DeploymentEndpoints> endpoints() {
- return endpoints;
+ public DeploymentEndpoints endpoints() {
+ return endpoints.get();
}
public Optional<DockerImage> dockerImageRepo() {
@@ -119,4 +124,41 @@ public class DeploymentData {
return dryRun;
}
+ private static <T> Supplier<T> wrap(Supplier<T> delegate, Duration timeout, String description) {
+ return new TimingSupplier<>(new Memoized<>(delegate), timeout, description);
+ }
+
+ public static class TimingSupplier<T> implements Supplier<T> {
+
+ private final Supplier<T> delegate;
+ private final Duration timeout;
+ private final String description;
+
+ public TimingSupplier(Supplier<T> delegate, Duration timeout, String description) {
+ this.delegate = delegate;
+ this.timeout = timeout;
+ this.description = description;
+ }
+
+ @Override
+ public T get() {
+ long startNanos = System.nanoTime();
+ Throwable thrown = null;
+ try {
+ return delegate.get();
+ }
+ catch (Throwable t) {
+ thrown = t;
+ throw t;
+ }
+ finally {
+ long durationNanos = System.nanoTime() - startNanos;
+ Level level = durationNanos > timeout.toNanos() ? Level.WARNING : Level.FINE;
+ String thrownMessage = thrown == null ? "" : " with exception " + thrown;
+ log.log(level, () -> String.format("Getting %s took %.6f seconds%s", description, durationNanos / 1e9, thrownMessage));
+ }
+ }
+
+ }
+
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ConsoleUrls.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ConsoleUrls.java
new file mode 100644
index 00000000000..82cddb46d9a
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ConsoleUrls.java
@@ -0,0 +1,89 @@
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.ClusterSpec;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.zone.ZoneId;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Generates URLs to various views in the Console. Prefer to create new methods and return
+ * String instead of URI to make it easier to track which views are linked from where.
+ *
+ * @author freva
+ */
+public class ConsoleUrls {
+ private final String root;
+ public ConsoleUrls(URI root) {
+ this.root = root.toString().replaceFirst("/$", ""); // Remove trailing slash
+ }
+
+ public String root() {
+ return root;
+ }
+
+ public String tenantOverview(TenantName tenantName) {
+ return "%s/tenant/%s".formatted(root, tenantName.value());
+ }
+
+ /** Returns URL to notification settings view for the given tenant */
+ public String tenantNotifications(TenantName tenantName) {
+ return "%s/tenant/%s/account/notifications".formatted(root, tenantName.value());
+ }
+
+ public String tenantBilling(TenantName t) { return "%s/tenant/%s/account/billing".formatted(root, t.value()); }
+
+ public String prodApplicationOverview(TenantName tenantName, ApplicationName applicationName) {
+ return "%s/tenant/%s/application/%s/prod/instance".formatted(root, tenantName.value(), applicationName.value());
+ }
+
+ public String instanceOverview(ApplicationId application, Environment environment) {
+ return "%s/tenant/%s/application/%s/%s/instance/%s".formatted(root,
+ application.tenant().value(),
+ application.application().value(),
+ environment.isManuallyDeployed() ? environment.value() : "prod",
+ application.instance().value());
+ }
+
+ public String clusterOverview(ApplicationId application, ZoneId zone, ClusterSpec.Id clusterId) {
+ return cluster(application, zone, clusterId, null);
+ }
+
+ public String clusterReindexing(ApplicationId application, ZoneId zone, ClusterSpec.Id clusterId) {
+ return cluster(application, zone, clusterId, "reindexing");
+ }
+
+ public String deploymentRun(RunId id) {
+ return "%s/job/%s/run/%s".formatted(
+ instanceOverview(id.application(), id.type().environment()), id.type().jobName(), id.number());
+ }
+
+ /** Returns URL used to request support from the Vespa team. */
+ public String support() {
+ return root + "/support";
+ }
+
+ /** Returns URL to verify an email address with the given verification code */
+ public String verifyEmail(String verifyCode) {
+ return "%s/verify?%s".formatted(root, queryParam("code", verifyCode));
+ }
+
+ public String termsOfService() { return root + "/terms-of-service-trial.html"; }
+
+ private String cluster(ApplicationId application, ZoneId zone, ClusterSpec.Id clusterId, String viewOrNull) {
+ return instanceOverview(application, zone.environment()) + '?' +
+ queryParam("%s.%s.%s".formatted(application.instance().value(), zone.environment().value(), zone.region().value()),
+ "clusters," + clusterId.value() + (viewOrNull == null ? "" : '=' + viewOrNull));
+ }
+
+ private static String queryParam(String key, String value) {
+ return URLEncoder.encode(key, StandardCharsets.UTF_8) + '=' + URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java
deleted file mode 100644
index f72f80155ed..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import com.yahoo.config.provision.ClusterResources;
-import com.yahoo.vespa.hosted.controller.api.integration.billing.Plan;
-import com.yahoo.vespa.hosted.controller.api.integration.pricing.PriceInformation;
-import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingController;
-import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-import static com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel.BASIC;
-
-public class MockPricingController implements PricingController {
-
- @Override
- public PriceInformation price(List<ClusterResources> clusterResources, PricingInfo pricingInfo, Plan plan) {
- BigDecimal listPrice = BigDecimal.valueOf(clusterResources.stream()
- .mapToDouble(resources -> resources.nodes() *
- (resources.nodeResources().vcpu() * 1000 +
- resources.nodeResources().memoryGb() * 100 +
- resources.nodeResources().diskGb() * 10))
- .sum());
-
- BigDecimal supportLevelCost = pricingInfo.supportLevel() == BASIC ? new BigDecimal("-160.00") : new BigDecimal("800.00");
- BigDecimal listPriceWithSupport = listPrice.add(supportLevelCost);
- BigDecimal enclaveDiscount = pricingInfo.enclave() ? new BigDecimal("-15.1234") : BigDecimal.ZERO;
- BigDecimal volumeDiscount = new BigDecimal("-5.64315634");
- BigDecimal committedAmountDiscount = new BigDecimal("-1.23");
- BigDecimal totalAmount = listPrice.add(supportLevelCost).add(enclaveDiscount).add(volumeDiscount).add(committedAmountDiscount);
- return new PriceInformation(listPriceWithSupport, volumeDiscount, committedAmountDiscount, enclaveDiscount, totalAmount);
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java
index 1c5f5f972cd..e39a8cf38b7 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java
@@ -30,11 +30,10 @@ import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueHandl
import com.yahoo.vespa.hosted.controller.api.integration.organization.Mailer;
import com.yahoo.vespa.hosted.controller.api.integration.organization.OwnershipIssues;
import com.yahoo.vespa.hosted.controller.api.integration.organization.SystemMonitor;
-import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingController;
import com.yahoo.vespa.hosted.controller.api.integration.resource.CostReportConsumer;
import com.yahoo.vespa.hosted.controller.api.integration.resource.ResourceDatabaseClient;
-import com.yahoo.vespa.hosted.controller.api.integration.secrets.GcpSecretStore;
import com.yahoo.vespa.hosted.controller.api.integration.secrets.EndpointSecretManager;
+import com.yahoo.vespa.hosted.controller.api.integration.secrets.GcpSecretStore;
import com.yahoo.vespa.hosted.controller.api.integration.secrets.TenantSecretService;
import com.yahoo.vespa.hosted.controller.api.integration.user.RoleMaintainer;
import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestClient;
@@ -91,6 +90,8 @@ public interface ServiceRegistry {
ZoneRegistry zoneRegistry();
+ ConsoleUrls consoleUrls();
+
ResourceTagger resourceTagger();
EnclaveAccessService enclaveAccessService();
@@ -127,6 +128,4 @@ public interface ServiceRegistry {
BillingReporter billingReporter();
- PricingController pricingController();
-
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/AcceptedCountries.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/AcceptedCountries.java
new file mode 100644
index 00000000000..c665b4fb7c2
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/AcceptedCountries.java
@@ -0,0 +1,23 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.vespa.hosted.controller.api.integration.billing;
+
+import java.util.List;
+
+/**
+ * @author bjorncs
+ */
+public record AcceptedCountries(List<Country> countries) {
+
+ public AcceptedCountries {
+ countries = List.copyOf(countries);
+ }
+
+ public record Country(String code, String displayName, List<TaxType> taxTypes) {
+ public Country {
+ taxTypes = List.copyOf(taxTypes);
+ }
+ }
+
+ public record TaxType(String id, String description, String pattern, String example) {}
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java
index 1acb4964ea6..e7959d2057a 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java
@@ -8,16 +8,11 @@ import com.yahoo.config.provision.TenantName;
import com.yahoo.config.provision.zone.ZoneId;
import java.math.BigDecimal;
-import java.time.Clock;
import java.time.LocalDate;
-import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
import java.util.Optional;
-import java.util.SortedMap;
-import java.util.TreeMap;
import java.util.UUID;
import java.util.function.Function;
@@ -69,7 +64,7 @@ public class Bill {
return tenant;
}
- public String status() {
+ public BillStatus status() {
return statusHistory.current();
}
@@ -389,28 +384,4 @@ public class Bill {
}
}
- public static class StatusHistory {
- SortedMap<ZonedDateTime, String> history;
-
- public StatusHistory(SortedMap<ZonedDateTime, String> history) {
- this.history = history;
- }
-
- public static StatusHistory open(Clock clock) {
- var now = clock.instant().atZone(ZoneOffset.UTC);
- return new StatusHistory(
- new TreeMap<>(Map.of(now, "OPEN"))
- );
- }
-
- public String current() {
- return history.get(history.lastKey());
- }
-
- public SortedMap<ZonedDateTime, String> getHistory() {
- return history;
- }
-
- }
-
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillStatus.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillStatus.java
new file mode 100644
index 00000000000..4f35b47219a
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillStatus.java
@@ -0,0 +1,33 @@
+package com.yahoo.vespa.hosted.controller.api.integration.billing;
+
+/**
+ * @author gjoranv
+ */
+public enum BillStatus {
+ OPEN, // All bills start in this state. The bill can be modified and exported/synced to external systems.
+ FROZEN, // Syncing to external systems is switched off. No changes can be made.
+ CLOSED, // End state for a valid bill.
+ VOID; // End state, indicating that the bill is not valid.
+
+ // Legacy states, used by historical bills
+ private static final String LEGACY_ISSUED = "ISSUED";
+ private static final String LEGACY_EXPORTED = "EXPORTED";
+ private static final String LEGACY_CANCELED = "CANCELED";
+
+ private final String value;
+
+ BillStatus() {
+ this.value = name();
+ }
+
+ public String value() {
+ return value;
+ }
+
+ public static BillStatus from(String status) {
+ if (LEGACY_ISSUED.equals(status) || LEGACY_EXPORTED.equals(status)) return OPEN;
+ if (LEGACY_CANCELED.equals(status)) return VOID;
+ return Enum.valueOf(BillStatus.class, status.toUpperCase());
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java
index 95b2ba9f8f8..8b48c72f88e 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java
@@ -2,7 +2,7 @@
package com.yahoo.vespa.hosted.controller.api.integration.billing;
import com.yahoo.config.provision.TenantName;
-import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import com.yahoo.vespa.hosted.controller.tenant.TaxId;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -90,7 +90,7 @@ public interface BillingController {
boolean deleteInstrument(TenantName tenant, String userId, String instrumentId);
/** Change the status of the given bill */
- void updateBillStatus(Bill.Id billId, String agent, String status);
+ void updateBillStatus(Bill.Id billId, String agent, BillStatus status);
/** Add a line item to the given bill */
void addLineItem(TenantName tenant, String description, BigDecimal amount, Optional<Bill.Id> billId, String agent);
@@ -130,7 +130,10 @@ public interface BillingController {
default void updateCache(List<TenantName> tenants) {}
- default String exportBill(Bill bill, String exportMethod, CloudTenant tenant) {
- return "NOT_IMPLEMENTED";
- }
+ /** Get the list of countries that are accepted */
+ AcceptedCountries getAcceptedCountries();
+
+ /** Validation of tax id */
+ void validateTaxId(TaxId id) throws IllegalArgumentException;
+
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java
index 3e24314ba5c..c5859cd7d2f 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java
@@ -69,7 +69,7 @@ public interface BillingDatabaseClient {
* @param agent The agent that added the status
* @param status The new status of the bill
*/
- void setStatus(Bill.Id billId, String agent, String status);
+ void setStatus(Bill.Id billId, String agent, BillStatus status);
List<Bill.LineItem> getUnusedLineItems(TenantName tenantName);
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java
index 300c1658c29..a6bcc9bf0ed 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java
@@ -26,9 +26,10 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
private final Map<Bill.Id, List<Bill.LineItem>> lineItems = new HashMap<>();
private final Map<TenantName, List<Bill.LineItem>> uncommittedLineItems = new HashMap<>();
- private final Map<Bill.Id, Bill.StatusHistory> statuses = new HashMap<>();
+ private final Map<Bill.Id, StatusHistory> statuses = new HashMap<>();
private final Map<Bill.Id, ZonedDateTime> startTimes = new HashMap<>();
private final Map<Bill.Id, ZonedDateTime> endTimes = new HashMap<>();
+ private final Map<Bill.Id, String> exportedInvoiceIds = new HashMap<>();
private final ZonedDateTime startTime = LocalDate.of(2020, 4, 1).atStartOfDay(ZoneId.of("UTC"));
private final ZonedDateTime endTime = LocalDate.of(2020, 5, 1).atStartOfDay(ZoneId.of("UTC"));
@@ -53,7 +54,7 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
.findFirst();
}
- public String getStatus(Bill.Id invoiceId) {
+ public BillStatus getStatus(Bill.Id invoiceId) {
return statuses.get(invoiceId).current();
}
@@ -61,7 +62,7 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
public Bill.Id createBill(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent) {
var invoiceId = Bill.Id.generate();
invoices.put(invoiceId, tenant);
- statuses.computeIfAbsent(invoiceId, l -> Bill.StatusHistory.open(clock));
+ statuses.computeIfAbsent(invoiceId, l -> StatusHistory.open(clock));
startTimes.put(invoiceId, startTime);
endTimes.put(invoiceId, endTime);
return invoiceId;
@@ -71,10 +72,11 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
public Optional<Bill> readBill(Bill.Id billId) {
var invoice = Optional.ofNullable(invoices.get(billId));
var lines = lineItems.getOrDefault(billId, List.of());
- var status = statuses.getOrDefault(billId, Bill.StatusHistory.open(clock));
+ var status = statuses.getOrDefault(billId, StatusHistory.open(clock));
var start = startTimes.getOrDefault(billId, startTime);
var end = endTimes.getOrDefault(billId, endTime);
- return invoice.map(tenant -> new Bill(billId, tenant, status, lines, start, end));
+ var exportedId = exportedInvoiceId(billId);
+ return invoice.map(tenant -> new Bill(billId, tenant, status, lines, start, end, exportedId));
}
@Override
@@ -88,8 +90,8 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
}
@Override
- public void setStatus(Bill.Id invoiceId, String agent, String status) {
- statuses.computeIfAbsent(invoiceId, k -> Bill.StatusHistory.open(clock))
+ public void setStatus(Bill.Id invoiceId, String agent, BillStatus status) {
+ statuses.computeIfAbsent(invoiceId, k -> StatusHistory.open(clock))
.getHistory()
.put(ZonedDateTime.now(), status);
}
@@ -157,7 +159,7 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
var status = statuses.get(invoiceId);
var start = startTimes.get(invoiceId);
var end = endTimes.get(invoiceId);
- return new Bill(invoiceId, tenant, status, items, start, end);
+ return new Bill(invoiceId, tenant, status, items, start, end, exportedInvoiceId(invoiceId));
})
.toList();
}
@@ -171,7 +173,7 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
var status = statuses.get(invoiceId);
var start = startTimes.get(invoiceId);
var end = endTimes.get(invoiceId);
- return new Bill(invoiceId, tenant, status, items, start, end);
+ return new Bill(invoiceId, tenant, status, items, start, end, exportedInvoiceId(invoiceId));
})
.toList();
}
@@ -180,9 +182,14 @@ public class BillingDatabaseClientMock implements BillingDatabaseClient {
public void maintain() {}
@Override
- public void setExportedInvoiceId(Bill.Id billId, String invoiceId) { }
+ public void setExportedInvoiceId(Bill.Id billId, String invoiceId) {
+ exportedInvoiceIds.put(billId, invoiceId);
+ }
@Override
public void setExportedInvoiceItemId(String lineItemId, String invoiceItemId) { }
+ private String exportedInvoiceId(Bill.Id billId) {
+ return exportedInvoiceIds.getOrDefault(billId, null);
+ }
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporter.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporter.java
index 7339555e578..676c29cec5d 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporter.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporter.java
@@ -6,4 +6,12 @@ import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
public interface BillingReporter {
BillingReference maintainTenant(CloudTenant tenant);
+
+ InvoiceUpdate maintainInvoice(Bill bill);
+
+ /** Export a bill to a payment service. Returns the invoice ID in the external system. */
+ default String exportBill(Bill bill, String exportMethod, CloudTenant tenant) {
+ return "NOT_IMPLEMENTED";
+ }
+
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporterMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporterMock.java
index 29c7fbbf410..689ecc356dc 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporterMock.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingReporterMock.java
@@ -4,18 +4,42 @@ package com.yahoo.vespa.hosted.controller.api.integration.billing;
import com.yahoo.vespa.hosted.controller.tenant.BillingReference;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import java.math.BigDecimal;
import java.time.Clock;
+import java.time.ZonedDateTime;
+import java.util.Optional;
import java.util.UUID;
public class BillingReporterMock implements BillingReporter {
private final Clock clock;
+ private final BillingDatabaseClient dbClient;
- public BillingReporterMock(Clock clock) {
+ public BillingReporterMock(Clock clock, BillingDatabaseClient dbClient) {
this.clock = clock;
+ this.dbClient = dbClient;
}
@Override
public BillingReference maintainTenant(CloudTenant tenant) {
return new BillingReference(UUID.randomUUID().toString(), clock.instant());
}
+
+ @Override
+ public InvoiceUpdate maintainInvoice(Bill bill) {
+ dbClient.addLineItem(bill.tenant(), maintainedMarkerItem(), Optional.of(bill.id()));
+ return new InvoiceUpdate(1,0,0);
+ }
+
+ @Override
+ public String exportBill(Bill bill, String exportMethod, CloudTenant tenant) {
+ // Replace bill with a copy with exportedId set
+ var exportedId = "EXT-ID-123";
+ dbClient.setExportedInvoiceId(bill.id(), exportedId);
+ return exportedId;
+ }
+
+ private static Bill.LineItem maintainedMarkerItem() {
+ return new Bill.LineItem("maintained", "", BigDecimal.valueOf(0.0), "", "", ZonedDateTime.now());
+ }
+
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java
index e7f87d3a628..ddcd5308986 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java
@@ -5,6 +5,8 @@ import com.yahoo.config.provision.NodeResources;
import com.yahoo.vespa.hosted.controller.api.integration.resource.CostInfo;
import com.yahoo.vespa.hosted.controller.api.integration.resource.ResourceUsage;
+import java.math.BigDecimal;
+
/**
* @author ogronnesby
*/
@@ -16,4 +18,15 @@ public interface CostCalculator {
/** Estimate the cost for the given resources */
double calculate(NodeResources resources);
+ /** CPU unit price */
+ BigDecimal getCpuPrice();
+
+ /** Memory unit price */
+ BigDecimal getMemoryPrice();
+
+ /** Disk unit price */
+ BigDecimal getDiskPrice();
+
+ /** GPU unit price */
+ BigDecimal getGpuPrice();
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InvoiceUpdate.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InvoiceUpdate.java
new file mode 100644
index 00000000000..6ca3cf6ebb1
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InvoiceUpdate.java
@@ -0,0 +1,45 @@
+package com.yahoo.vespa.hosted.controller.api.integration.billing;
+
+/**
+ * Helper to track changes to an invoice.
+ *
+ * @author gjoranv
+ */
+public record InvoiceUpdate(int itemsAdded, int itemsRemoved, int itemsModified) {
+ public boolean isEmpty() {
+ return itemsAdded == 0 && itemsRemoved == 0 && itemsModified == 0;
+ }
+
+ public static InvoiceUpdate empty() {
+ return new InvoiceUpdate(0, 0, 0);
+ }
+
+ public static class Counter {
+ private int itemsAdded = 0;
+ private int itemsRemoved = 0;
+ private int itemsModified = 0;
+
+ public void addedItem() {
+ itemsAdded++;
+ }
+
+ public void removedItem() {
+ itemsRemoved++;
+ }
+
+ public void modifiedItem() {
+ itemsModified++;
+ }
+
+ public void add(InvoiceUpdate other) {
+ itemsAdded += other.itemsAdded;
+ itemsRemoved += other.itemsRemoved;
+ itemsModified += other.itemsModified;
+ }
+
+ public InvoiceUpdate finish() {
+ return new InvoiceUpdate(itemsAdded, itemsRemoved, itemsModified);
+ }
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java
index 8ef14dd60ba..18dd339b4a1 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java
@@ -2,6 +2,7 @@
package com.yahoo.vespa.hosted.controller.api.integration.billing;
import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.tenant.TaxId;
import java.math.BigDecimal;
import java.time.Clock;
@@ -22,6 +23,7 @@ import java.util.stream.Stream;
public class MockBillingController implements BillingController {
private final Clock clock;
+ private final BillingDatabaseClient dbClient;
PlanId defaultPlan = PlanId.from("trial");
List<TenantName> tenants = new ArrayList<>();
@@ -32,8 +34,9 @@ public class MockBillingController implements BillingController {
Map<TenantName, List<Bill.LineItem>> unusedLineItems = new HashMap<>();
Map<TenantName, CollectionMethod> collectionMethod = new HashMap<>();
- public MockBillingController(Clock clock) {
+ public MockBillingController(Clock clock, BillingDatabaseClient dbClient) {
this.clock = clock;
+ this.dbClient = dbClient;
}
@Override
@@ -71,7 +74,7 @@ public class MockBillingController implements BillingController {
.add(new Bill(
billId,
tenant,
- Bill.StatusHistory.open(clock),
+ StatusHistory.open(clock),
List.of(),
startTime,
endTime
@@ -116,7 +119,7 @@ public class MockBillingController implements BillingController {
}
@Override
- public void updateBillStatus(Bill.Id billId, String agent, String status) {
+ public void updateBillStatus(Bill.Id billId, String agent, BillStatus status) {
var now = clock.instant().atZone(ZoneOffset.UTC);
committedBills.values().stream()
.flatMap(List::stream)
@@ -134,7 +137,7 @@ public class MockBillingController implements BillingController {
"line-item-id",
description,
amount,
- "some-plan",
+ "paid",
agent,
ZonedDateTime.now()));
}
@@ -203,6 +206,29 @@ public class MockBillingController implements BillingController {
return count < limit;
}
+ @Override
+ public AcceptedCountries getAcceptedCountries() {
+ return new AcceptedCountries(List.of(
+ new AcceptedCountries.Country(
+ "NO", "Norway",
+ List.of(new AcceptedCountries.TaxType("no_vat", "Norwegian VAT number", "[0-9]{9}MVA", "123456789MVA"))),
+ new AcceptedCountries.Country(
+ "CA", "Canada",
+ List.of(new AcceptedCountries.TaxType("ca_gst_hst", "Canadian GST/HST number", "([0-9]{9}) ?RT ?([0-9]{4})", "123456789RT0002"),
+ new AcceptedCountries.TaxType("ca_pst_bc", "Canadian PST number (British Columbia)", "PST-?([0-9]{4})-?([0-9]{4})", "PST-1234-5678")))
+ ));
+ }
+
+ @Override
+ public void validateTaxId(TaxId id) throws IllegalArgumentException {
+ if (id.isEmpty() || id.isLegacy()) return;
+ if (!List.of("eu_vat", "no_vat").contains(id.type().value()))
+ throw new IllegalArgumentException("Unknown tax id type '%s'".formatted(id.type().value()));
+ if (!id.code().value().matches("\\w+"))
+ throw new IllegalArgumentException("Invalid tax id code '%s'".formatted(id.code().value()));
+ }
+
+
public void setTenants(List<TenantName> tenants) {
this.tenants = tenants;
}
@@ -234,6 +260,6 @@ public class MockBillingController implements BillingController {
private Bill emptyBill() {
var start = clock.instant().atZone(ZoneOffset.UTC);
var end = clock.instant().atZone(ZoneOffset.UTC);
- return new Bill(Bill.Id.of("empty"), TenantName.defaultName(), Bill.StatusHistory.open(clock), List.of(), start, end);
+ return new Bill(Bill.Id.of("empty"), TenantName.defaultName(), StatusHistory.open(clock), List.of(), start, end);
}
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java
index 686a239a138..c0bd0dd29cd 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java
@@ -20,6 +20,10 @@ public interface PlanRegistry {
/** Get a set of all plans */
List<Plan> all();
+ default Plan require(String planId) {
+ return plan(planId).orElseThrow();
+ }
+
/** Get a plan give a plan ID */
default Optional<Plan> plan(String planId) {
if (planId == null || planId.isBlank())
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java
index 3ae2b0aa495..5af4d0cff29 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java
@@ -144,5 +144,25 @@ public class PlanRegistryMock implements PlanRegistry {
public double calculate(NodeResources resources) {
return resources.cost();
}
+
+ @Override
+ public BigDecimal getCpuPrice() {
+ return cpuHourCost;
+ }
+
+ @Override
+ public BigDecimal getMemoryPrice() {
+ return memHourCost;
+ }
+
+ @Override
+ public BigDecimal getDiskPrice() {
+ return dgbHourCost;
+ }
+
+ @Override
+ public BigDecimal getGpuPrice() {
+ return gpuHourCost;
+ }
}
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/StatusHistory.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/StatusHistory.java
new file mode 100644
index 00000000000..f0c7f806c8c
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/StatusHistory.java
@@ -0,0 +1,61 @@
+package com.yahoo.vespa.hosted.controller.api.integration.billing;
+
+import java.time.Clock;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * @author ogronnesby
+ */
+public class StatusHistory {
+ SortedMap<ZonedDateTime, BillStatus> history;
+
+ public StatusHistory(SortedMap<ZonedDateTime, BillStatus> history) {
+ // Validate the given history
+ var iter = history.values().iterator();
+ BillStatus next = iter.hasNext() ? iter.next() : null;
+ while (iter.hasNext()) {
+ var current = next;
+ next = iter.next();
+ if (! validateStatus(current, next)) {
+ throw new IllegalArgumentException("Invalid transition from " + current + " to " + next);
+ }
+ }
+
+ this.history = history;
+ }
+
+ public static StatusHistory open(Clock clock) {
+ var now = clock.instant().atZone(ZoneOffset.UTC);
+ return new StatusHistory(
+ new TreeMap<>(Map.of(now, BillStatus.OPEN))
+ );
+ }
+
+ public BillStatus current() {
+ return history.get(history.lastKey());
+ }
+
+ public SortedMap<ZonedDateTime, BillStatus> getHistory() {
+ return history;
+ }
+
+ public void checkValidTransition(BillStatus newStatus) {
+ if (! validateStatus(current(), newStatus)) {
+ throw new IllegalArgumentException("Invalid transition from " + current() + " to " + newStatus);
+ }
+ }
+
+ private static boolean validateStatus(BillStatus current, BillStatus newStatus) {
+ return switch(current) {
+ case OPEN -> true;
+ case FROZEN -> newStatus != BillStatus.OPEN; // This could be subject to change.
+ case CLOSED -> newStatus == BillStatus.CLOSED;
+ case VOID -> newStatus == BillStatus.VOID;
+ };
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateValidatorImpl.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateValidatorImpl.java
index 421ec99d6f2..13fa6c862a7 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateValidatorImpl.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/certificates/EndpointCertificateValidatorImpl.java
@@ -67,7 +67,7 @@ public class EndpointCertificateValidatorImpl implements EndpointCertificateVali
} catch (SecretNotFoundException s) {
// Normally because the cert is in the process of being provisioned - this will cause a retry in InternalStepRunner
- throw new EndpointCertificateException(EndpointCertificateException.Type.CERT_NOT_AVAILABLE, "Certificate not found in secret store");
+ throw new EndpointCertificateException(EndpointCertificateException.Type.CERT_NOT_AVAILABLE, "Certificate not found in secret store", s);
} catch (EndpointCertificateException e) {
if (!e.type().equals(EndpointCertificateException.Type.CERT_NOT_AVAILABLE)) { // such failures are normal and will be retried, it takes some time to show up in the secret store
log.log(Level.WARNING, "Certificate validation failure for " + serializedInstanceId, e);
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentFailureMails.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentFailureMails.java
index ef08c3a9adc..72728966dbc 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentFailureMails.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentFailureMails.java
@@ -1,9 +1,9 @@
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.api.integration.organization;
+import com.yahoo.vespa.hosted.controller.api.integration.ConsoleUrls;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
-import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry;
import java.util.Collection;
@@ -14,10 +14,10 @@ import java.util.Collection;
*/
public class DeploymentFailureMails {
- private final ZoneRegistry registry;
+ private final ConsoleUrls consoleUrls;
- public DeploymentFailureMails(ZoneRegistry registry) {
- this.registry = registry;
+ public DeploymentFailureMails(ConsoleUrls consoleUrls) {
+ this.consoleUrls = consoleUrls;
}
public Mail nodeAllocationFailure(RunId id, Collection<String> recipients) {
@@ -66,8 +66,8 @@ public class DeploymentFailureMails {
jobToString(id.type()),
id.application(),
messageDetail,
- registry.dashboardUrl(id),
- registry.supportUrl()));
+ consoleUrls.deploymentRun(id),
+ consoleUrls.support()));
}
private String jobToString(JobType type) {
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java
deleted file mode 100644
index 887741f9196..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.pricing;
-
-import java.math.BigDecimal;
-
-public record PriceInformation(BigDecimal listPriceWithSupport, BigDecimal volumeDiscount, BigDecimal committedAmountDiscount,
- BigDecimal enclaveDiscount, BigDecimal totalAmount) {
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java
deleted file mode 100644
index d8186f17796..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.pricing;
-
-import com.yahoo.config.provision.ClusterResources;
-import com.yahoo.vespa.hosted.controller.api.integration.billing.Plan;
-
-import java.util.List;
-
-/**
- * A service that calculates price information based on cluster resources, plan, service level etc.
- *
- * @author hmusum
- */
-public interface PricingController {
-
- PriceInformation price(List<ClusterResources> clusterResources, PricingInfo pricingInfo, Plan plan);
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java
deleted file mode 100644
index 938991e2ed7..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.pricing;
-
-public record PricingInfo(boolean enclave, SupportLevel supportLevel, double committedHourlyAmount) {
-
- public enum SupportLevel { BASIC, COMMERCIAL, ENTERPRISE }
-
- public static PricingInfo empty() { return new PricingInfo(false, SupportLevel.COMMERCIAL, 0); }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java
deleted file mode 100644
index 649ab2a80f4..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java
+++ /dev/null
@@ -1,5 +0,0 @@
-// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-@ExportPackage
-package com.yahoo.vespa.hosted.controller.api.integration.pricing;
-
-import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/zone/ZoneRegistry.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/zone/ZoneRegistry.java
index 747c6b72172..92c0a6b1fbb 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/zone/ZoneRegistry.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/zone/ZoneRegistry.java
@@ -1,8 +1,6 @@
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.api.integration.zone;
-import com.yahoo.config.provision.ApplicationId;
-import com.yahoo.config.provision.ApplicationName;
import com.yahoo.config.provision.AthenzDomain;
import com.yahoo.config.provision.CloudAccount;
import com.yahoo.config.provision.CloudName;
@@ -18,7 +16,6 @@ import com.yahoo.config.provision.zone.ZoneFilter;
import com.yahoo.config.provision.zone.ZoneId;
import com.yahoo.vespa.athenz.api.AthenzIdentity;
import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
-import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
import java.net.URI;
import java.time.Duration;
@@ -97,24 +94,6 @@ public interface ZoneRegistry {
/** Returns the routing method used by given zone */
RoutingMethod routingMethod(ZoneId zone);
- /** Returns a URL where an informative dashboard can be found. */
- URI dashboardUrl();
-
- /** Returns a URL which displays information about the given tenant. */
- URI dashboardUrl(TenantName id);
-
- /** Returns a URL which displays information about the given application. */
- URI dashboardUrl(TenantName tenantName, ApplicationName applicationName);
-
- /** Returns a URL which displays information about the given application instance. */
- URI dashboardUrl(ApplicationId id);
-
- /** Returns a URL which displays information about the given job run. */
- URI dashboardUrl(RunId id);
-
- /** Returns a URL used to request support from the Vespa team. */
- URI supportUrl();
-
/** Returns a URL to the controller's api endpoint */
URI apiUrl();
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
index 52900f83203..54f53d64f76 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
@@ -72,25 +72,11 @@ enum PathGroup {
"/application/v4/tenant/{tenant}/archive-access/aws",
"/application/v4/tenant/{tenant}/archive-access/gcp"),
-
- billingToken(Matcher.tenant,
- "/billing/v1/tenant/{tenant}/token"),
-
- billingInstrument(Matcher.tenant,
- "/billing/v1/tenant/{tenant}/instrument/{*}"),
-
- billingPlan(Matcher.tenant,
- "/billing/v1/tenant/{tenant}/plan/{*}"),
-
- billingCollection(Matcher.tenant,
- "/billing/v1/tenant/{tenant}/collection/{*}"),
-
- billingList(Matcher.tenant,
- "/billing/v1/tenant/{tenant}/billing/{*}"),
-
billing(Matcher.tenant,
"/billing/v2/tenant/{tenant}/{*}"),
+ billingAux("/billing/v2/countries"),
+
accountant("/billing/v2/accountant/{*}"),
userSearch("/user/v1/find"),
@@ -247,11 +233,6 @@ enum PathGroup {
/** Paths used for receiving payment callbacks */
paymentProcessor("/payment/notification"),
- /** Paths used for invoice management */
- hostedAccountant("/billing/v1/invoice/{*}",
- "/billing/v1/billing",
- "/billing/v1/plans"),
-
/** Path used for listing endpoint certificate request and re-requesting endpoint certificates */
endpointCertificates("/endpointcertificates/"),
@@ -322,20 +303,12 @@ enum PathGroup {
static Set<PathGroup> operatorRestrictedPaths() {
var paths = billingPathsNoToken();
- paths.add(PathGroup.billingToken);
paths.add(accessRequestApproval);
return paths;
}
static Set<PathGroup> billingPathsNoToken() {
- return EnumSet.of(
- PathGroup.billingCollection,
- PathGroup.billingInstrument,
- PathGroup.billingList,
- PathGroup.billingPlan,
- PathGroup.billing,
- PathGroup.hostedAccountant
- );
+ return EnumSet.of(PathGroup.billing, PathGroup.billingAux);
}
/** Returns whether this group matches path in given context */
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
index 6b5130cf2e5..d1a8b2ef0c3 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
@@ -26,10 +26,7 @@ enum Policy {
.in(SystemName.all()),
Privilege.grant(Action.read)
.on(PathGroup.billingPathsNoToken())
- .in(SystemName.all()),
- Privilege.grant(Action.read)
- .on(PathGroup.billingToken)
- .in(SystemName.PublicCd)),
+ .in(SystemName.all())),
/** Full access to everything. */
supporter(Privilege.grant(Action.read)
@@ -155,40 +152,14 @@ enum Policy {
.on(PathGroup.paymentProcessor)
.in(SystemName.PublicCd)),
- /** Read your own instrument information */
- paymentInstrumentRead(Privilege.grant(Action.read)
- .on(PathGroup.billingInstrument)
- .in(SystemName.PublicCd, SystemName.Public)),
-
- /** Ability to update tenant payment instrument */
- paymentInstrumentUpdate(Privilege.grant(Action.update)
- .on(PathGroup.billingInstrument)
- .in(SystemName.PublicCd, SystemName.Public)),
-
- /** Ability to remove your own payment instrument */
- paymentInstrumentDelete(Privilege.grant(Action.delete)
- .on(PathGroup.billingInstrument)
- .in(SystemName.PublicCd, SystemName.Public)),
-
- /** Get the token to view instrument form */
- paymentInstrumentCreate(Privilege.grant(Action.read)
- .on(PathGroup.billingToken)
- .in(SystemName.PublicCd, SystemName.Public)),
-
/** Ability to update tenant payment instrument */
planUpdate(Privilege.grant(Action.update)
- .on(PathGroup.billingPlan, PathGroup.billing)
- .in(SystemName.PublicCd, SystemName.Public)),
-
- /** Ability to update tenant collection method */
- collectionMethodUpdate(Privilege.grant(Action.update)
- .on(PathGroup.billingCollection)
+ .on(PathGroup.billing)
.in(SystemName.PublicCd, SystemName.Public)),
-
/** Read the generated bills */
billingInformationRead(Privilege.grant(Action.read)
- .on(PathGroup.billingList, PathGroup.billing)
+ .on(PathGroup.billing, PathGroup.billingAux)
.in(SystemName.PublicCd, SystemName.Public)),
accessRequests(Privilege.grant(Action.all())
@@ -197,7 +168,7 @@ enum Policy {
/** Invoice management */
hostedAccountant(Privilege.grant(Action.all())
- .on(PathGroup.hostedAccountant, PathGroup.accountant, PathGroup.userSearch)
+ .on(PathGroup.accountant, PathGroup.userSearch)
.in(SystemName.PublicCd, SystemName.Public)),
/** Listing endpoint certificates and re-requesting certificates */
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
index d57e38df239..31c8560c908 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
@@ -43,8 +43,6 @@ public enum RoleDefinition {
Policy.applicationRead,
Policy.deploymentRead,
Policy.publicRead,
- Policy.paymentInstrumentRead,
- Policy.paymentInstrumentDelete,
Policy.billingInformationRead,
Policy.horizonProxyOperations),
@@ -56,8 +54,6 @@ public enum RoleDefinition {
Policy.developmentDeployment,
Policy.keyManagement,
Policy.submission,
- Policy.paymentInstrumentRead,
- Policy.paymentInstrumentDelete,
Policy.billingInformationRead,
Policy.secretStoreOperations,
Policy.dataplaneToken),
@@ -72,7 +68,6 @@ public enum RoleDefinition {
Policy.tenantArchiveAccessManagement,
Policy.applicationManager,
Policy.keyRevokal,
- Policy.paymentInstrumentRead,
Policy.billingInformationRead,
Policy.accessRequests
),
@@ -99,7 +94,6 @@ public enum RoleDefinition {
paymentProcessor(Policy.paymentProcessor),
hostedAccountant(Policy.hostedAccountant,
- Policy.collectionMethodUpdate,
Policy.planUpdate,
Policy.tenantUpdate);
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/systemflags/v1/SystemFlagsDataArchive.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/systemflags/v1/SystemFlagsDataArchive.java
index b55157b90be..02bb669417c 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/systemflags/v1/SystemFlagsDataArchive.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/systemflags/v1/SystemFlagsDataArchive.java
@@ -5,7 +5,6 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.fasterxml.jackson.databind.node.TextNode;
import com.yahoo.component.Version;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.ApplicationName;
@@ -192,7 +191,7 @@ public class SystemFlagsDataArchive {
flagData.rules().forEach(rule -> rule.conditions().forEach(condition -> {
int force_switch_expression_dummy = switch (condition.type()) {
case RELATIONAL -> switch (condition.dimension()) {
- case APPLICATION_ID, CLOUD, CLOUD_ACCOUNT, CLUSTER_ID, CLUSTER_TYPE, CONSOLE_USER_EMAIL,
+ case APPLICATION, CLOUD, CLOUD_ACCOUNT, CLUSTER_ID, CLUSTER_TYPE, CONSOLE_USER_EMAIL,
ENVIRONMENT, HOSTNAME, INSTANCE_ID, NODE_TYPE, SYSTEM, TENANT_ID, ZONE_ID ->
throw new FlagValidationException(condition.type().toWire() + " " +
DimensionHelper.toWire(condition.dimension()) +
@@ -207,7 +206,7 @@ public class SystemFlagsDataArchive {
};
case WHITELIST, BLACKLIST -> switch (condition.dimension()) {
- case APPLICATION_ID -> validateConditionValues(condition, SystemFlagsDataArchive::validateTenantApplication);
+ case APPLICATION -> validateConditionValues(condition, SystemFlagsDataArchive::validateTenantApplication);
case CONSOLE_USER_EMAIL -> validateConditionValues(condition, email -> {
if (!email.contains("@"))
throw new FlagValidationException("Invalid email address: " + email);
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java
index 4a61ff30c25..9ceeba32061 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java
@@ -51,11 +51,16 @@ public class CloudTenant extends Tenant {
/** Creates a tenant with the given name, provided it passes validation. */
public static CloudTenant create(TenantName tenantName, Instant createdAt, Principal creator) {
+ // Initialize with creator as verified contact
+ var info = TenantInfo.empty().withContacts(new TenantContacts(List.of(
+ new TenantContacts.EmailContact(
+ List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS),
+ new Email(creator.getName(), true)))));
return new CloudTenant(requireName(tenantName),
createdAt,
LastLoginInfo.EMPTY,
Optional.ofNullable(creator).map(SimplePrincipal::of),
- ImmutableBiMap.of(), TenantInfo.empty(), List.of(), new ArchiveAccess(), Optional.empty(),
+ ImmutableBiMap.of(), info, List.of(), new ArchiveAccess(), Optional.empty(),
Instant.EPOCH, List.of(), Optional.empty(), PlanId.from("none"));
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java
index 995f1b1864f..702a183e7af 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java
@@ -25,7 +25,7 @@ public class Email {
}
public static Email empty() {
- return new Email("", true);
+ return new Email("", false);
}
public Email withEmailAddress(String emailAddress) {
@@ -36,6 +36,10 @@ public class Email {
return new Email(emailAddress, isVerified);
}
+ public boolean isBlank() {
+ return emailAddress.isBlank();
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java
index 057c8bad89b..9c4bbc88f1f 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java
@@ -76,6 +76,7 @@ public class PendingMailVerification {
public enum MailType {
TENANT_CONTACT,
- NOTIFICATIONS
+ NOTIFICATIONS,
+ BILLING
}
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PurchaseOrder.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PurchaseOrder.java
new file mode 100644
index 00000000000..d222864a388
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PurchaseOrder.java
@@ -0,0 +1,21 @@
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.tenant;
+
+import ai.vespa.validation.StringWrapper;
+
+import static ai.vespa.validation.Validation.requireLength;
+
+/**
+ * @author olaa
+ */
+public class PurchaseOrder extends StringWrapper<PurchaseOrder> {
+
+ public PurchaseOrder(String value) {
+ super(value);
+ requireLength(value, "purchase order length", 0, 64);
+ }
+
+ public static PurchaseOrder empty() {
+ return new PurchaseOrder("");
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TaxId.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TaxId.java
new file mode 100644
index 00000000000..99c2400c58c
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TaxId.java
@@ -0,0 +1,41 @@
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.tenant;
+
+import ai.vespa.validation.StringWrapper;
+
+import static ai.vespa.validation.Validation.requireLength;
+
+/**
+ * @author olaa
+ */
+public record TaxId(Type type, Code code) {
+
+ public TaxId(String type, String code) { this(new Type(type), new Code(code)); }
+
+ public static TaxId empty() { return new TaxId(Type.empty(), Code.empty()); }
+ public boolean isEmpty() { return type.isEmpty() && code.isEmpty(); }
+
+ // TODO(bjorncs) Remove legacy once no longer present in ZK
+ public static TaxId legacy(String code) { return new TaxId(Type.empty(), new Code(code)); }
+ public boolean isLegacy() { return type.isEmpty() && !code.isEmpty(); }
+
+ public static class Type extends StringWrapper<Type> {
+ public Type(String value) {
+ super(value);
+ requireLength(value, "tax code type length", 0, 16);
+ }
+
+ public static Type empty() { return new Type(""); }
+ public boolean isEmpty() { return value().isEmpty(); }
+ }
+
+ public static class Code extends StringWrapper<Code> {
+ public Code(String value) {
+ super(value);
+ requireLength(value, "tax code value length", 0, 64);
+ }
+
+ public static Code empty() { return new Code(""); }
+ public boolean isEmpty() { return value().isEmpty(); }
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java
index 5377b820e18..6e3b26661e5 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java
@@ -10,14 +10,20 @@ public class TenantBilling {
private final TenantContact contact;
private final TenantAddress address;
+ private final TaxId taxId;
+ private final PurchaseOrder purchaseOrder;
+ private final Email invoiceEmail;
- public TenantBilling(TenantContact contact, TenantAddress address) {
+ public TenantBilling(TenantContact contact, TenantAddress address, TaxId taxId, PurchaseOrder purchaseOrder, Email invoiceEmail) {
this.contact = Objects.requireNonNull(contact);
this.address = Objects.requireNonNull(address);
+ this.taxId = Objects.requireNonNull(taxId);
+ this.purchaseOrder = Objects.requireNonNull(purchaseOrder);
+ this.invoiceEmail = Objects.requireNonNull(invoiceEmail);
}
public static TenantBilling empty() {
- return new TenantBilling(TenantContact.empty(), TenantAddress.empty());
+ return new TenantBilling(TenantContact.empty(), TenantAddress.empty(), TaxId.empty(), PurchaseOrder.empty(), Email.empty());
}
public TenantContact contact() {
@@ -28,12 +34,36 @@ public class TenantBilling {
return address;
}
+ public TaxId getTaxId() {
+ return taxId;
+ }
+
+ public PurchaseOrder getPurchaseOrder() {
+ return purchaseOrder;
+ }
+
+ public Email getInvoiceEmail() {
+ return invoiceEmail;
+ }
+
public TenantBilling withContact(TenantContact updatedContact) {
- return new TenantBilling(updatedContact, this.address);
+ return new TenantBilling(updatedContact, this.address, this.taxId, this.purchaseOrder, this.invoiceEmail);
}
public TenantBilling withAddress(TenantAddress updatedAddress) {
- return new TenantBilling(this.contact, updatedAddress);
+ return new TenantBilling(this.contact, updatedAddress, this.taxId, this.purchaseOrder, this.invoiceEmail);
+ }
+
+ public TenantBilling withTaxId(TaxId updatedTaxId) {
+ return new TenantBilling(this.contact, this.address, updatedTaxId, this.purchaseOrder, this.invoiceEmail);
+ }
+
+ public TenantBilling withPurchaseOrder(PurchaseOrder updatedPurchaseOrder) {
+ return new TenantBilling(this.contact, this.address, this.taxId, updatedPurchaseOrder, this.invoiceEmail);
+ }
+
+ public TenantBilling withInvoiceEmail(Email updatedInvoiceEmail) {
+ return new TenantBilling(this.contact, this.address, this.taxId, this.purchaseOrder, updatedInvoiceEmail);
}
public boolean isEmpty() {
@@ -45,19 +75,26 @@ public class TenantBilling {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TenantBilling that = (TenantBilling) o;
- return Objects.equals(contact, that.contact) && Objects.equals(address, that.address);
+ return Objects.equals(contact, that.contact) &&
+ Objects.equals(address, that.address) &&
+ Objects.equals(taxId, that.taxId) &&
+ Objects.equals(purchaseOrder, that.purchaseOrder) &&
+ Objects.equals(invoiceEmail, that.invoiceEmail);
}
@Override
public int hashCode() {
- return Objects.hash(contact, address);
+ return Objects.hash(contact, address, taxId, purchaseOrder, invoiceEmail);
}
@Override
public String toString() {
- return "TenantInfoBillingContact{" +
+ return "TenantBilling{" +
"contact=" + contact +
", address=" + address +
+ ", taxId='" + taxId + '\'' +
+ ", purchaseOrder='" + purchaseOrder + '\'' +
+ ", invoiceEmail=" + invoiceEmail +
'}';
}
}