aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java4
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java49
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java19
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java39
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java67
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java266
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java144
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java51
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java23
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java10
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java43
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java54
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java4
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java5
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java1
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java396
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java8
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java16
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java214
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants48
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response1
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list9
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view38
23 files changed, 1389 insertions, 120 deletions
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 0b5f2538892..a522e26a46d 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
@@ -4,7 +4,7 @@ package com.yahoo.vespa.hosted.controller.api.integration;
import com.yahoo.vespa.hosted.controller.api.integration.aws.ApplicationRoleService;
import com.yahoo.vespa.hosted.controller.api.integration.aws.AwsEventFetcher;
import com.yahoo.vespa.hosted.controller.api.integration.aws.ResourceTagger;
-import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController;
import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateProvider;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServer;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationStore;
@@ -77,6 +77,6 @@ public interface ServiceRegistry {
SystemMonitor systemMonitor();
- PlanController planController();
+ BillingController billingController();
}
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
new file mode 100644
index 00000000000..bd9568fe891
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java
@@ -0,0 +1,49 @@
+// Copyright Verizon Media. 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 com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public interface BillingController {
+
+ PlanId getPlan(TenantName tenant);
+
+ /**
+ * Returns true if plan was changed
+ */
+ boolean setPlan(TenantName tenant, PlanId planId, boolean hasApplications);
+
+ Invoice.Id createInvoiceForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent);
+
+ Invoice createUncommittedInvoice(TenantName tenant, LocalDate until);
+
+ Map<TenantName, Invoice> createUncommittedInvoices(LocalDate until);
+
+ List<Invoice.LineItem> getUnusedLineItems(TenantName tenant);
+
+ Optional<PaymentInstrument> getDefaultInstrument(TenantName tenant);
+
+ String createClientToken(String tenant, String userId);
+
+ boolean deleteInstrument(TenantName tenant, String userId, String instrumentId);
+
+ void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status);
+
+ void addLineItem(TenantName tenant, String description, BigDecimal amount, String agent);
+
+ void deleteLineItem(String lineItemId);
+
+ boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument);
+
+ InstrumentList listInstruments(TenantName tenant, String userId);
+
+ List<Invoice> getInvoices(TenantName tenant);
+
+} \ No newline at end of file
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
deleted file mode 100644
index 628beec8450..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright Verizon Media. 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 com.yahoo.config.provision.NodeResources;
-import com.yahoo.vespa.hosted.controller.api.integration.resource.CostInfo;
-
-
-/**
- * @author ogronnesby
- */
-public interface CostCalculator {
-
- /** Calculate the cost for the given usage */
- CostInfo calculate(ResourceUsage usage);
-
- /** Estimate the cost for the given resources */
- double calculate(NodeResources resources);
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java
new file mode 100644
index 00000000000..f26261cd157
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java
@@ -0,0 +1,39 @@
+// Copyright Verizon Media. 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.ArrayList;
+import java.util.List;
+
+/**
+ * @author olaa
+ */
+public class InstrumentList {
+
+ private String activeInstrumentId;
+ private List<PaymentInstrument> instruments;
+
+
+ public InstrumentList(List<PaymentInstrument> instruments) {
+ this.instruments = instruments;
+ }
+
+ public void setActiveInstrumentId(String activeInstrumentId) {
+ this.activeInstrumentId = activeInstrumentId;
+ }
+
+ public void addInstrument(PaymentInstrument instrument) {
+ instruments.add(instrument);
+ }
+
+ public void addInstruments(List<PaymentInstrument> instruments) {
+ instruments.addAll(instruments);
+ }
+
+ public String getActiveInstrumentId() {
+ return activeInstrumentId;
+ }
+
+ public List<PaymentInstrument> getInstruments() {
+ return instruments;
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java
new file mode 100644
index 00000000000..45e06b11b2a
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java
@@ -0,0 +1,67 @@
+// Copyright Verizon Media. 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 com.yahoo.config.provision.TenantName;
+
+import java.util.Objects;
+
+/**
+ * @author olaa
+ */
+public class InstrumentOwner {
+
+ private final TenantName tenantName;
+ private final String userId;
+ private final String paymentInstrumentId;
+ private final boolean isDefault;
+
+ public InstrumentOwner(TenantName tenantName, String userId, String paymentInstrumentId, boolean isDefault) {
+ this.tenantName = tenantName;
+ this.userId = userId;
+ this.paymentInstrumentId = paymentInstrumentId;
+ this.isDefault = isDefault;
+ }
+
+ public TenantName getTenantName() {
+ return tenantName;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public String getPaymentInstrumentId() {
+ return paymentInstrumentId;
+ }
+
+ public boolean isDefault() {
+ return isDefault;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "Tenant: %s\nCusomer ID: %s\nPayment Instrument ID: %s\nIs default: %s",
+ tenantName.value(),
+ userId,
+ paymentInstrumentId,
+ isDefault
+ );
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ InstrumentOwner other = (InstrumentOwner) o;
+ return this.tenantName.equals(other.getTenantName()) &&
+ this.userId.equals(other.getUserId()) &&
+ this.paymentInstrumentId.equals(other.getPaymentInstrumentId()) &&
+ this.isDefault() == other.isDefault();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(tenantName, userId, paymentInstrumentId, isDefault);
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java
new file mode 100644
index 00000000000..31388d24e2e
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java
@@ -0,0 +1,266 @@
+// Copyright Verizon Media. 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 com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.zone.ZoneId;
+
+import java.math.BigDecimal;
+import java.time.ZonedDateTime;
+import java.util.Collections;
+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;
+
+
+/**
+ * An Invoice is an identifier with a status (with history) and line items. A line item is the meat and
+ * potatoes of the content of the invoice, and are a history of items. Most line items are connected to
+ * a given deployment in Vespa Cloud, but they can also be manually added to e.g. give a discount or represent
+ * support.
+ * <p>
+ * All line items have a Plan associated with them - which was used to map from utilization to an actual price.
+ * <p>
+ * The invoice has a status history, but only the latest status is exposed through this API.
+ *
+ * @author ogronnesby
+ */
+public class Invoice {
+ private static final BigDecimal SCALED_ZERO = new BigDecimal("0.00");
+
+ private final Id id;
+ private final List<LineItem> lineItems;
+ private final StatusHistory statusHistory;
+ private final ZonedDateTime startTime;
+ private final ZonedDateTime endTime;
+
+ public Invoice(Id id, StatusHistory statusHistory, List<LineItem> lineItems, ZonedDateTime startTime, ZonedDateTime endTime) {
+ this.id = id;
+ this.lineItems = List.copyOf(lineItems);
+ this.statusHistory = statusHistory;
+ this.startTime = startTime;
+ this.endTime = endTime;
+ }
+
+ public Id id() {
+ return id;
+ }
+
+ public String status() {
+ return statusHistory.current();
+ }
+
+ public StatusHistory statusHistory() {
+ return statusHistory;
+ }
+
+ public List<LineItem> lineItems() {
+ return lineItems;
+ }
+
+ public ZonedDateTime getStartTime() {
+ return startTime;
+ }
+
+ public ZonedDateTime getEndTime() {
+ return endTime;
+ }
+
+ public BigDecimal sum() {
+ return lineItems.stream().map(LineItem::amount).reduce(SCALED_ZERO, BigDecimal::add);
+ }
+
+ public static final class Id {
+ private final String value;
+
+ public static Id of(String value) {
+ Objects.requireNonNull(value);
+ return new Id(value);
+ }
+
+ public static Id generate() {
+ var id = UUID.randomUUID().toString();
+ return new Id(id);
+ }
+
+ private Id(String value) {
+ this.value = value;
+ }
+
+ public String value() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Id invoiceId = (Id) o;
+ return value.equals(invoiceId.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value);
+ }
+
+ @Override
+ public String toString() {
+ return "InvoiceId{" +
+ "value='" + value + '\'' +
+ '}';
+ }
+ }
+
+ /**
+ * Represents a chargeable line on an invoice.
+ */
+ public static class LineItem {
+ private final String id;
+ private final String description;
+ private final BigDecimal amount;
+ private final String plan;
+ private final String agent;
+ private final ZonedDateTime addedAt;
+ private final Optional<ZonedDateTime> startedAt;
+ private final Optional<ZonedDateTime> endedAt;
+ private final Optional<ApplicationId> applicationId;
+ private final Optional<ZoneId> zoneId;
+
+ public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt, ZonedDateTime startedAt, ZonedDateTime endedAt, ApplicationId applicationId, ZoneId zoneId) {
+ this.id = id;
+ this.description = description;
+ this.amount = amount;
+ this.plan = plan;
+ this.agent = agent;
+ this.addedAt = addedAt;
+ this.startedAt = Optional.ofNullable(startedAt);
+ this.endedAt = Optional.ofNullable(endedAt);
+
+ if (applicationId == null && zoneId != null)
+ throw new IllegalArgumentException("Must supply applicationId if zoneId is supplied");
+
+ this.applicationId = Optional.ofNullable(applicationId);
+ this.zoneId = Optional.ofNullable(zoneId);
+ }
+
+ public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt) {
+ this(id, description, amount, plan, agent, addedAt, null, null, null, null);
+ }
+
+ /** The opaque ID of this */
+ public String id() {
+ return id;
+ }
+
+ /** The string description of this - used for display purposes */
+ public String description() {
+ return description;
+ }
+
+ /** The dollar amount of this */
+ public BigDecimal amount() {
+ return SCALED_ZERO.add(amount);
+ }
+
+ /** The plan used to calculate amount of this */
+ public String plan() {
+ return plan;
+ }
+
+ /** Who created this line item */
+ public String agent() {
+ return agent;
+ }
+
+ /** When was this line item added */
+ public ZonedDateTime addedAt() {
+ return addedAt;
+ }
+
+ /** What time period is this line item for - time start */
+ public Optional<ZonedDateTime> startedAt() {
+ return startedAt;
+ }
+
+ /** What time period is this line item for - time end */
+ public Optional<ZonedDateTime> endedAt() {
+ return endedAt;
+ }
+
+ /** Optionally - what application is this line item about */
+ public Optional<ApplicationId> applicationId() {
+ return applicationId;
+ }
+
+ /** Optionally - what zone deployment is this line item about */
+ public Optional<ZoneId> zoneId() {
+ return zoneId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ LineItem lineItem = (LineItem) o;
+ return id.equals(lineItem.id) &&
+ description.equals(lineItem.description) &&
+ amount.equals(lineItem.amount) &&
+ plan.equals(lineItem.plan) &&
+ agent.equals(lineItem.agent) &&
+ addedAt.equals(lineItem.addedAt) &&
+ startedAt.equals(lineItem.startedAt) &&
+ endedAt.equals(lineItem.endedAt) &&
+ applicationId.equals(lineItem.applicationId) &&
+ zoneId.equals(lineItem.zoneId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, description, amount, plan, agent, addedAt, startedAt, endedAt, applicationId, zoneId);
+ }
+
+ @Override
+ public String toString() {
+ return "LineItem{" +
+ "id='" + id + '\'' +
+ ", description='" + description + '\'' +
+ ", amount=" + amount +
+ ", plan='" + plan + '\'' +
+ ", agent='" + agent + '\'' +
+ ", addedAt=" + addedAt +
+ ", startedAt=" + startedAt +
+ ", endedAt=" + endedAt +
+ ", applicationId=" + applicationId +
+ ", zoneId=" + zoneId +
+ '}';
+ }
+ }
+
+ public static class StatusHistory {
+ SortedMap<ZonedDateTime, String> history;
+
+ public StatusHistory(SortedMap<ZonedDateTime, String> history) {
+ this.history = history;
+ }
+
+ public static StatusHistory open() {
+ return new StatusHistory(
+ new TreeMap<>(Map.of(ZonedDateTime.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/MockBillingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java
new file mode 100644
index 00000000000..a4c25e301ba
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java
@@ -0,0 +1,144 @@
+// Copyright Verizon Media. 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 com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * @author olaa
+ */
+public class MockBillingController implements BillingController {
+
+ Map<TenantName, PlanId> plans = new HashMap<>();
+ Map<TenantName, PaymentInstrument> activeInstruments = new HashMap<>();
+ Map<TenantName, List<Invoice>> committedInvoices = new HashMap<>();
+ Map<TenantName, Invoice> uncommittedInvoices = new HashMap<>();
+ Map<TenantName, List<Invoice.LineItem>> unusedLineItems = new HashMap<>();
+
+ @Override
+ public PlanId getPlan(TenantName tenant) {
+ return plans.get(tenant);
+ }
+
+ @Override
+ public boolean setPlan(TenantName tenant, PlanId planId, boolean hasApplications) {
+ plans.put(tenant, planId);
+ return true;
+ }
+
+ @Override
+ public Invoice.Id createInvoiceForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent) {
+ var invoiceId = Invoice.Id.of("id-123");
+ committedInvoices.computeIfAbsent(tenant, l -> new ArrayList<>())
+ .add(new Invoice(
+ invoiceId,
+ Invoice.StatusHistory.open(),
+ List.of(),
+ startTime,
+ endTime
+ ));
+ return invoiceId;
+ }
+
+ @Override
+ public Invoice createUncommittedInvoice(TenantName tenant, LocalDate until) {
+ return uncommittedInvoices.get(tenant);
+ }
+
+ @Override
+ public Map<TenantName, Invoice> createUncommittedInvoices(LocalDate until) {
+ return uncommittedInvoices;
+ }
+
+ @Override
+ public List<Invoice.LineItem> getUnusedLineItems(TenantName tenant) {
+ return unusedLineItems.getOrDefault(tenant, List.of());
+ }
+
+ @Override
+ public Optional<PaymentInstrument> getDefaultInstrument(TenantName tenant) {
+ return Optional.ofNullable(activeInstruments.get(tenant));
+ }
+
+ @Override
+ public String createClientToken(String tenant, String userId) {
+ return "some-token";
+ }
+
+ @Override
+ public boolean deleteInstrument(TenantName tenant, String userId, String instrumentId) {
+ activeInstruments.remove(tenant);
+ return true;
+ }
+
+ @Override
+ public void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status) {
+ committedInvoices.values().stream()
+ .flatMap(List::stream)
+ .filter(invoice -> invoiceId.equals(invoice.id()))
+ .forEach(invoice -> invoice.statusHistory().history.put(ZonedDateTime.now(), status));
+ }
+
+ @Override
+ public void addLineItem(TenantName tenant, String description, BigDecimal amount, String agent) {
+ unusedLineItems.computeIfAbsent(tenant, l -> new ArrayList<>())
+ .add(new Invoice.LineItem(
+ "line-item-id",
+ description,
+ amount,
+ "some-plan",
+ agent,
+ ZonedDateTime.now()
+ ));
+ }
+
+ @Override
+ public void deleteLineItem(String lineItemId) {
+
+ }
+
+ @Override
+ public boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument) {
+ var instrumentId = paymentInstrument.getPaymentInstrumentId();
+ activeInstruments.put(paymentInstrument.getTenantName(), createInstrument(instrumentId));
+ return true;
+ }
+
+ @Override
+ public InstrumentList listInstruments(TenantName tenant, String userId) {
+ return null;
+ }
+
+ @Override
+ public List<Invoice> getInvoices(TenantName tenant) {
+ return committedInvoices.getOrDefault(tenant, List.of());
+ }
+
+ private PaymentInstrument createInstrument(String id) {
+ return new PaymentInstrument(id,
+ "name",
+ "displayText",
+ "brand",
+ "type",
+ "endingWith",
+ "expiryDate"
+ );
+ }
+
+ public void addInvoice(TenantName tenantName, Invoice invoice, boolean committed) {
+ if (committed)
+ committedInvoices.computeIfAbsent(tenantName, i -> new ArrayList<>())
+ .add(invoice);
+ else
+ uncommittedInvoices.put(tenantName, invoice);
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java
new file mode 100644
index 00000000000..7b8d36f3d4f
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java
@@ -0,0 +1,51 @@
+// Copyright Verizon Media. 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;
+
+/**
+ * @author olaa
+ */
+public class PaymentInstrument {
+
+ private final String id;
+ private final String nameOnCard;
+ private final String displayText;
+ private final String brand;
+ private final String type;
+ private final String endingWith;
+ private final String expiryDate;
+
+
+ public PaymentInstrument(String id, String nameOnCard, String displayText, String brand, String type, String endingWith, String expiryDate) {
+ this.id = id;
+ this.nameOnCard = nameOnCard;
+ this.displayText = displayText;
+ this.brand = brand;
+ this.type = type;
+ this.endingWith = endingWith;
+ this.expiryDate = expiryDate;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getNameOnCard() {
+ return nameOnCard;
+ }
+
+ public String getDisplayText() {
+ return displayText;
+ }
+
+ public String getBrand() {
+ return brand;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getEndingWith() {
+ return endingWith;
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java
deleted file mode 100644
index 75a88136c45..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright Verizon Media. 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;
-
-/**
- * A Plan decides two different things:
- *
- * - How to map from usage to a sum of money that is owed.
- * - Limits on how much resources can be used.
- *
- * @author ogronnesby
- */
-public interface Plan {
-
- /** The ID of the plan as used in APIs and storage systems */
- String id();
-
- /** The calculator used to calculate a bill for usage */
- CostCalculator calculator();
-
- /** The quota limits associated with the plan */
- Object quota();
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java
deleted file mode 100644
index f13c251d212..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright Verizon Media. 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 com.yahoo.config.provision.TenantName;
-
-public interface PlanController {
-
- Plan getPlan(TenantName tenant);
-
-} \ No newline at end of file
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java
new file mode 100644
index 00000000000..68a897c904f
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java
@@ -0,0 +1,43 @@
+// Copyright Verizon Media. 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.Objects;
+
+/**
+ * @author olaa
+ */
+public class PlanId {
+
+ private final String value;
+
+ public PlanId(String value) {
+ if (value.isBlank())
+ throw new IllegalArgumentException("Id must be non-blank.");
+ this.value = value;
+ }
+
+ public static PlanId from(String value) {
+ return new PlanId(value);
+ }
+
+ public String value() { return value; }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PlanId id = (PlanId) o;
+ return Objects.equals(value, id.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value);
+ }
+
+ @Override
+ public String toString() {
+ return "plan '" + value + "'";
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java
deleted file mode 100644
index cbfd2b6ff50..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright Verizon Media. 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 com.yahoo.config.provision.ApplicationId;
-import com.yahoo.config.provision.zone.ZoneId;
-
-import java.math.BigDecimal;
-
-/**
- * @author olaa
- */
-public class ResourceUsage {
-
- private final ApplicationId applicationId;
- private final ZoneId zoneId;
- private final Plan plan;
- private final BigDecimal cpuMillis;
- private final BigDecimal memoryMillis;
- private final BigDecimal diskMillis;
-
- public ResourceUsage(ApplicationId applicationId, ZoneId zoneId, Plan plan,
- BigDecimal cpuMillis, BigDecimal memoryMillis, BigDecimal diskMillis) {
- this.applicationId = applicationId;
- this.zoneId = zoneId;
- this.cpuMillis = cpuMillis;
- this.memoryMillis = memoryMillis;
- this.diskMillis = diskMillis;
- this.plan = plan;
- }
-
- public ApplicationId getApplicationId() {
- return applicationId;
- }
-
- public ZoneId getZoneId() {
- return zoneId;
- }
-
- public BigDecimal getCpuMillis() {
- return cpuMillis;
- }
-
- public BigDecimal getMemoryMillis() {
- return memoryMillis;
- }
-
- public BigDecimal getDiskMillis() {
- return diskMillis;
- }
-
- public Plan getPlan() {
- return plan;
- }
-}
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 2fdf442dbe0..aaddd3811bc 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,6 +72,10 @@ enum PathGroup {
PathPrefix.api,
"/billing/v1/tenant/{tenant}/instrument/{*}"),
+ billingPlan(Matcher.tenant,
+ PathPrefix.api,
+ "/billing/v1/tenant/{tenant}/plan/{*}"),
+
billingList(Matcher.tenant,
PathPrefix.api,
"/billing/v1/tenant/{tenant}/billing/{*}"),
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 83adba6f59b..548ad0af484 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
@@ -158,6 +158,11 @@ enum Policy {
.on(PathGroup.billingToken)
.in(SystemName.PublicCd)),
+ /** Ability to update tenant payment instrument */
+ planUpdate(Privilege.grant(Action.update)
+ .on(PathGroup.billingPlan)
+ .in(SystemName.PublicCd)),
+
/** Read the generated bills */
billingInformationRead(Privilege.grant(Action.read)
.on(PathGroup.billingList)
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 b9d534019db..801661f454e 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
@@ -67,6 +67,7 @@ public enum RoleDefinition {
Policy.paymentInstrumentUpdate,
Policy.paymentInstrumentDelete,
Policy.paymentInstrumentCreate,
+ Policy.planUpdate,
Policy.billingInformationRead),
/** Headless — the application specific role identified by deployment keys for production */
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java
new file mode 100644
index 00000000000..ccbee15d2c5
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java
@@ -0,0 +1,396 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.billing;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.restapi.ErrorResponse;
+import com.yahoo.restapi.JacksonJsonResponse;
+import com.yahoo.restapi.MessageResponse;
+import com.yahoo.restapi.Path;
+import com.yahoo.restapi.SlimeJsonResponse;
+import com.yahoo.restapi.StringResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.ApplicationController;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.PaymentInstrument;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.InstrumentOwner;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId;
+import com.yahoo.yolean.Exceptions;
+
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.NotFoundException;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.security.Principal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * @author andreer
+ * @author olaa
+ */
+public class BillingApiHandler extends LoggingRequestHandler {
+
+ private static final String OPTIONAL_PREFIX = "/api";
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+
+ private final BillingController billingController;
+ private final ApplicationController applicationController;
+
+ public BillingApiHandler(Executor executor,
+ AccessLog accessLog,
+ Controller controller) {
+ super(executor, accessLog);
+ this.billingController = controller.serviceRegistry().billingController();
+ this.applicationController = controller.applications();
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ Path path = new Path(request.getUri(), OPTIONAL_PREFIX);
+ String userId = userIdOrThrow(request);
+ switch (request.getMethod()) {
+ case GET:
+ return handleGET(request, path, userId);
+ case PATCH:
+ return handlePATCH(request, path, userId);
+ case DELETE:
+ return handleDELETE(path, userId);
+ case POST:
+ return handlePOST(path, request, userId);
+ default:
+ return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ } catch (NotFoundException e) {
+ return ErrorResponse.notFoundError(Exceptions.toMessageString(e));
+ } catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ } catch (Exception e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ // Don't expose internal billing details in error message to user
+ return ErrorResponse.internalServerError("Internal problem while handling billing API request");
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request, Path path, String userId) {
+ if (path.matches("/billing/v1/tenant/{tenant}/token")) return getToken(path.get("tenant"), userId);
+ if (path.matches("/billing/v1/tenant/{tenant}/instrument")) return getInstruments(path.get("tenant"), userId);
+ if (path.matches("/billing/v1/tenant/{tenant}/billing")) return getBilling(path.get("tenant"), request.getProperty("until"));
+ if (path.matches("/billing/v1/tenant/{tenant}/plan")) return getPlan(path.get("tenant"));
+ if (path.matches("/billing/v1/billing")) return getBillingAllTenants(request.getProperty("until"));
+ if (path.matches("/billing/v1/invoice/tenant/{tenant}/line-item")) return getLineItems(path.get("tenant"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePATCH(HttpRequest request, Path path, String userId) {
+ if (path.matches("/billing/v1/tenant/{tenant}/instrument")) return patchActiveInstrument(request, path.get("tenant"), userId);
+ if (path.matches("/billing/v1/tenant/{tenant}/plan")) return patchPlan(request, path.get("tenant"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+
+ }
+
+ private HttpResponse handleDELETE(Path path, String userId) {
+ if (path.matches("/billing/v1/tenant/{tenant}/instrument/{instrument}")) return deleteInstrument(path.get("tenant"), userId, path.get("instrument"));
+ if (path.matches("/billing/v1/invoice/line-item/{line-item-id}")) return deleteLineItem(path.get("line-item-id"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+
+ }
+
+ private HttpResponse handlePOST(Path path, HttpRequest request, String userId) {
+ if (path.matches("/billing/v1/invoice")) return createInvoice(request, userId);
+ if (path.matches("/billing/v1/invoice/{invoice-id}/status")) return setInvoiceStatus(request, path.get("invoice-id"));
+ if (path.matches("/billing/v1/invoice/tenant/{tenant}/line-item")) return addLineItem(request, path.get("tenant"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+
+ }
+
+ private HttpResponse getPlan(String tenant) {
+ var plan = billingController.getPlan(TenantName.from(tenant));
+ var slime = new Slime();
+ var root = slime.setObject();
+ root.setString("tenant", tenant);
+ root.setString("plan", plan.value());
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse patchPlan(HttpRequest request, String tenant) {
+ var tenantName = TenantName.from(tenant);
+ var slime = inspectorOrThrow(request);
+ var planId = PlanId.from(slime.field("plan").asString());
+ var hasApplications = applicationController.asList(tenantName).size() > 0;
+
+ if (billingController.setPlan(tenantName, planId, hasApplications)) {
+ return new StringResponse("Plan: " + planId.value());
+ } else {
+ return ErrorResponse.forbidden("Invalid plan change with active deployments");
+ }
+
+ }
+
+ private HttpResponse getBillingAllTenants(String until) {
+ try {
+ var untilDate = untilParameter(until);
+ var uncommittedInvoices = billingController.createUncommittedInvoices(untilDate);
+
+ var slime = new Slime();
+ var root = slime.setObject();
+ root.setString("until", untilDate.format(DateTimeFormatter.ISO_DATE));
+ var tenants = root.setArray("tenants");
+
+ uncommittedInvoices.forEach((tenant, invoice) -> {
+ var tc = tenants.addObject();
+ tc.setString("tenant", tenant.value());
+ getPlanForTenant(tc, tenant);
+ renderCurrentUsage(tc.setObject("current"), invoice);
+ renderAdditionalItems(tc.setObject("additional").setArray("items"), billingController.getUnusedLineItems(tenant));
+
+ billingController.getDefaultInstrument(tenant).ifPresent(card ->
+ renderInstrument(tc.setObject("payment"), card)
+ );
+ });
+
+ return new SlimeJsonResponse(slime);
+ } catch (DateTimeParseException e) {
+ return ErrorResponse.badRequest("Could not parse date: " + until);
+ }
+ }
+
+ private HttpResponse addLineItem(HttpRequest request, String tenant) {
+ Inspector inspector = inspectorOrThrow(request);
+ billingController.addLineItem(
+ TenantName.from(tenant),
+ getInspectorFieldOrThrow(inspector, "description"),
+ new BigDecimal(getInspectorFieldOrThrow(inspector, "amount")),
+ userIdOrThrow(request));
+ return new MessageResponse("Added line item for tenant " + tenant);
+ }
+
+ private HttpResponse setInvoiceStatus(HttpRequest request, String invoiceId) {
+ Inspector inspector = inspectorOrThrow(request);
+ String status = getInspectorFieldOrThrow(inspector, "status");
+ billingController.updateInvoiceStatus(Invoice.Id.of(invoiceId), userIdOrThrow(request), status);
+ return new MessageResponse("Updated status of invoice " + invoiceId);
+ }
+
+ private HttpResponse createInvoice(HttpRequest request, String userId) {
+ Inspector inspector = inspectorOrThrow(request);
+ TenantName tenantName = TenantName.from(getInspectorFieldOrThrow(inspector, "tenant"));
+
+ LocalDate startDate = LocalDate.parse(getInspectorFieldOrThrow(inspector, "startTime"));
+ LocalDate endDate = LocalDate.parse(getInspectorFieldOrThrow(inspector, "endTime"));
+ ZonedDateTime startTime = startDate.atStartOfDay(ZoneId.of("UTC"));
+ ZonedDateTime endTime = endDate.atStartOfDay(ZoneId.of("UTC"));
+
+ var invoiceId = billingController.createInvoiceForPeriod(tenantName, startTime, endTime, userId);
+
+ return new MessageResponse("Created invoice with ID " + invoiceId.value());
+ }
+
+ private HttpResponse getInstruments(String tenant, String userId) {
+ var instrumentListResponse = billingController.listInstruments(TenantName.from(tenant), userId);
+ return new JacksonJsonResponse<>(200, instrumentListResponse);
+ }
+
+ private HttpResponse getToken(String tenant, String userId) {
+ return new StringResponse(billingController.createClientToken(tenant, userId));
+ }
+
+ private HttpResponse getBilling(String tenant, String until) {
+ try {
+ var untilDate = untilParameter(until);
+ var tenantId = TenantName.from(tenant);
+ var slimeResponse = new Slime();
+ var root = slimeResponse.setObject();
+
+ root.setString("until", untilDate.format(DateTimeFormatter.ISO_DATE));
+
+ getPlanForTenant(root, tenantId);
+ renderCurrentUsage(root.setObject("current"), getCurrentUsageForTenant(tenantId, untilDate));
+ renderAdditionalItems(root.setObject("additional").setArray("items"), billingController.getUnusedLineItems(tenantId));
+ renderInvoices(root.setArray("bills"), getInvoicesForTenant(tenantId));
+
+ billingController.getDefaultInstrument(tenantId).ifPresent( card ->
+ renderInstrument(root.setObject("payment"), card)
+ );
+
+ return new SlimeJsonResponse(slimeResponse);
+ } catch (DateTimeParseException e) {
+ return ErrorResponse.badRequest("Could not parse date: " + until);
+ }
+ }
+
+ private HttpResponse getLineItems(String tenant) {
+ var slimeResponse = new Slime();
+ var root = slimeResponse.setObject();
+ var lineItems = root.setArray("lineItems");
+
+ billingController.getUnusedLineItems(TenantName.from(tenant))
+ .forEach(lineItem -> {
+ var itemCursor = lineItems.addObject();
+ renderLineItemToCursor(itemCursor, lineItem);
+ });
+
+ return new SlimeJsonResponse(slimeResponse);
+ }
+
+ private void getPlanForTenant(Cursor cursor, TenantName tenant) {
+ cursor.setString("plan", billingController.getPlan(tenant).value());
+ }
+
+ private void renderInstrument(Cursor cursor, PaymentInstrument instrument) {
+ cursor.setString("type", instrument.getType());
+ cursor.setString("brand", instrument.getBrand());
+ cursor.setString("endingWith", instrument.getEndingWith());
+ }
+
+ private void renderCurrentUsage(Cursor cursor, Invoice currentUsage) {
+ cursor.setString("amount", currentUsage.sum().toPlainString());
+ cursor.setString("status", "accrued");
+ cursor.setString("from", currentUsage.getStartTime().format(DATE_TIME_FORMATTER));
+ var itemsCursor = cursor.setArray("items");
+ currentUsage.lineItems().forEach(lineItem -> {
+ var itemCursor = itemsCursor.addObject();
+ renderLineItemToCursor(itemCursor, lineItem);
+ });
+ }
+
+ private void renderAdditionalItems(Cursor cursor, List<Invoice.LineItem> items) {
+ items.forEach(item -> {
+ renderLineItemToCursor(cursor.addObject(), item);
+ });
+ }
+
+ private Invoice getCurrentUsageForTenant(TenantName tenant, LocalDate until) {
+ return billingController.createUncommittedInvoice(tenant, until);
+ }
+
+ private List<Invoice> getInvoicesForTenant(TenantName tenant) {
+ return billingController.getInvoices(tenant);
+ }
+
+ private void renderInvoices(Cursor cursor, List<Invoice> invoices) {
+ invoices.forEach(invoice -> {
+ var invoiceCursor = cursor.addObject();
+ renderInvoiceToCursor(invoiceCursor, invoice);
+ });
+ }
+
+ private void renderInvoiceToCursor(Cursor invoiceCursor, Invoice invoice) {
+ invoiceCursor.setString("id", invoice.id().value());
+ invoiceCursor.setString("from", invoice.getStartTime().format(DATE_TIME_FORMATTER));
+ invoiceCursor.setString("to", invoice.getEndTime().format(DATE_TIME_FORMATTER));
+
+ invoiceCursor.setString("amount", invoice.sum().toString());
+ invoiceCursor.setString("status", invoice.status());
+ var statusCursor = invoiceCursor.setArray("statusHistory");
+ renderStatusHistory(statusCursor, invoice.statusHistory());
+
+
+ var lineItemsCursor = invoiceCursor.setArray("items");
+ invoice.lineItems().forEach(lineItem -> {
+ var itemCursor = lineItemsCursor.addObject();
+ renderLineItemToCursor(itemCursor, lineItem);
+ });
+ }
+
+ private void renderStatusHistory(Cursor cursor, Invoice.StatusHistory statusHistory) {
+ statusHistory.getHistory()
+ .entrySet()
+ .stream()
+ .forEach(entry -> {
+ var c = cursor.addObject();
+ c.setString("at", entry.getKey().format(DATE_TIME_FORMATTER));
+ c.setString("status", entry.getValue());
+ });
+ }
+
+ private void renderLineItemToCursor(Cursor cursor, Invoice.LineItem lineItem) {
+ cursor.setString("id", lineItem.id());
+ cursor.setString("description", lineItem.description());
+ cursor.setString("amount", lineItem.amount().toString());
+ lineItem.applicationId().ifPresent(appId -> {
+ cursor.setString("application", appId.application().value());
+ });
+ }
+
+ private HttpResponse deleteInstrument(String tenant, String userId, String instrument) {
+ if (billingController.deleteInstrument(TenantName.from(tenant), userId, instrument)) {
+ return new StringResponse("OK");
+ } else {
+ return ErrorResponse.forbidden("Cannot delete payment instrument you don't own");
+ }
+ }
+
+ private HttpResponse deleteLineItem(String lineItemId) {
+ billingController.deleteLineItem(lineItemId);
+ return new MessageResponse("Succesfully deleted line item " + lineItemId);
+ }
+
+ private HttpResponse patchActiveInstrument(HttpRequest request, String tenant, String userId) {
+ var inspector = inspectorOrThrow(request);
+ String instrumentId = getInspectorFieldOrThrow(inspector, "active");
+ InstrumentOwner paymentInstrument = new InstrumentOwner(TenantName.from(tenant), userId, instrumentId, true);
+ boolean success = billingController.setActivePaymentInstrument(paymentInstrument);
+ return success ? new StringResponse("OK") : ErrorResponse.internalServerError("Failed to patch active instrument");
+ }
+
+ private Inspector inspectorOrThrow(HttpRequest request) {
+ try {
+ return SlimeUtils.jsonToSlime(request.getData().readAllBytes()).get();
+ } catch (IOException e) {
+ throw new BadRequestException("Failed to parse request body");
+ }
+ }
+
+ private static String userIdOrThrow(HttpRequest request) {
+ return Optional.ofNullable(request.getJDiscRequest().getUserPrincipal())
+ .map(Principal::getName)
+ .orElseThrow(() -> new ForbiddenException("Must be authenticated to use this API"));
+ }
+
+ private static String getInspectorFieldOrThrow(Inspector inspector, String field) {
+ if (!inspector.field(field).valid())
+ throw new BadRequestException("Field " + field + " cannot be null");
+ return inspector.field(field).asString();
+ }
+
+ private DeploymentId getDeploymentIdOrNull(Inspector inspector) {
+ if (inspector.field("applicationId").valid() != inspector.field("zoneId").valid() ) {
+ throw new BadRequestException("Either both application id and zone id should be set, or neither.");
+ }
+ if (inspector.field("applicationId").valid()) {
+ return new DeploymentId(
+ ApplicationId.fromSerializedForm(inspector.field("applicationId").asString()),
+ com.yahoo.config.provision.zone.ZoneId.from(inspector.field("zoneId").asString())
+ );
+ }
+ return null;
+ }
+
+ private LocalDate untilParameter(String until) {
+ if (until == null || until.isEmpty() || until.isBlank())
+ return LocalDate.now().plusDays(1);
+ return LocalDate.parse(until);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java
index bd0143ef879..13cf992cd52 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java
@@ -9,7 +9,7 @@ import com.yahoo.vespa.flags.FlagSource;
import com.yahoo.vespa.flags.Flags;
import com.yahoo.vespa.hosted.controller.Application;
import com.yahoo.vespa.hosted.controller.api.integration.ServiceRegistry;
-import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController;
import com.yahoo.vespa.hosted.controller.api.integration.organization.BillingInfo;
import com.yahoo.vespa.hosted.controller.api.integration.user.Roles;
import com.yahoo.vespa.hosted.controller.api.integration.user.UserId;
@@ -36,13 +36,13 @@ public class CloudAccessControl implements AccessControl {
private final UserManagement userManagement;
private final BooleanFlag enablePublicSignup;
- private final PlanController planController;
+ private final BillingController planController;
@Inject
public CloudAccessControl(UserManagement userManagement, FlagSource flagSource, ServiceRegistry serviceRegistry) {
this.userManagement = userManagement;
this.enablePublicSignup = Flags.ENABLE_PUBLIC_SIGNUP_FLOW.bindTo(flagSource);
- planController = serviceRegistry.planController();
+ planController = serviceRegistry.billingController();
}
@Override
@@ -109,7 +109,7 @@ public class CloudAccessControl implements AccessControl {
}
private boolean isTrial(TenantName tenant) {
- return planController.getPlan(tenant).id().equals("trial");
+ return planController.getPlan(tenant).value().equals("trial");
}
@Override
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java
index b7e7c9814e3..1b21f7db7c4 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java
@@ -12,14 +12,14 @@ import com.yahoo.vespa.hosted.controller.api.integration.aws.MockAwsEventFetcher
import com.yahoo.vespa.hosted.controller.api.integration.aws.MockResourceTagger;
import com.yahoo.vespa.hosted.controller.api.integration.aws.NoopApplicationRoleService;
import com.yahoo.vespa.hosted.controller.api.integration.aws.ResourceTagger;
-import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController;
import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateMock;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServer;
import com.yahoo.vespa.hosted.controller.api.integration.dns.MemoryNameService;
import com.yahoo.vespa.hosted.controller.api.integration.entity.MemoryEntityService;
import com.yahoo.vespa.hosted.controller.api.integration.organization.MockContactRetriever;
import com.yahoo.vespa.hosted.controller.api.integration.organization.MockIssueHandler;
-import com.yahoo.vespa.hosted.controller.api.integration.organization.SystemMonitor;
import com.yahoo.vespa.hosted.controller.api.integration.resource.CostReportConsumerMock;
import com.yahoo.vespa.hosted.controller.api.integration.routing.GlobalRoutingService;
import com.yahoo.vespa.hosted.controller.api.integration.routing.MemoryGlobalRoutingService;
@@ -60,7 +60,7 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg
private final MockRunDataStore mockRunDataStore = new MockRunDataStore();
private final MockResourceTagger mockResourceTagger = new MockResourceTagger();
private final ApplicationRoleService applicationRoleService = new NoopApplicationRoleService();
- private final PlanController planController = (tenantName) -> null;
+ private final BillingController billingController = new MockBillingController();
public ServiceRegistryMock(SystemName system) {
this.zoneRegistryMock = new ZoneRegistryMock(system);
@@ -187,6 +187,11 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg
return systemMonitor;
}
+ @Override
+ public BillingController billingController() {
+ return billingController;
+ }
+
public ConfigServerMock configServerMock() {
return configServerMock;
}
@@ -203,9 +208,4 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg
return endpointCertificateMock;
}
- @Override
- public PlanController planController() {
- return planController;
- }
-
}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java
new file mode 100644
index 00000000000..19cfa95c682
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java
@@ -0,0 +1,214 @@
+package com.yahoo.vespa.hosted.controller.restapi.billing;
+
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId;
+import com.yahoo.vespa.hosted.controller.api.role.Role;
+import com.yahoo.vespa.hosted.controller.restapi.ContainerTester;
+import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+import static com.yahoo.application.container.handler.Request.Method.*;
+import static org.junit.Assert.*;
+
+/**
+ * @author olaa
+ */
+public class BillingApiHandlerTest extends ControllerContainerCloudTest {
+
+ private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/";
+ private static final TenantName tenant = TenantName.from("tenant1");
+ private static final TenantName tenant2 = TenantName.from("tenant2");
+ private static final Set<Role> tenantRole = Set.of(Role.administrator(tenant));
+ private static final Set<Role> financeAdmin = Set.of(Role.hostedAccountant());
+ private MockBillingController billingController;
+
+ private ContainerTester tester;
+
+ @Before
+ public void setup() {
+ tester = new ContainerTester(container, responseFiles);
+ billingController = (MockBillingController) tester.serviceRegistry().billingController();
+ }
+
+ @Override
+ protected SystemName system() {
+ return SystemName.PublicCd;
+ }
+
+ @Override
+ protected String variablePartXml() {
+ return " <component id='com.yahoo.vespa.hosted.controller.security.CloudAccessControlRequests'/>\n" +
+ " <component id='com.yahoo.vespa.hosted.controller.security.CloudAccessControl'/>\n" +
+
+ " <handler id='com.yahoo.vespa.hosted.controller.restapi.billing.BillingApiHandler'>\n" +
+ " <binding>http://*/billing/v1/*</binding>\n" +
+ " </handler>\n" +
+
+ " <http>\n" +
+ " <server id='default' port='8080' />\n" +
+ " <filtering>\n" +
+ " <request-chain id='default'>\n" +
+ " <filter id='com.yahoo.vespa.hosted.controller.restapi.filter.ControllerAuthorizationFilter'/>\n" +
+ " <binding>http://*/*</binding>\n" +
+ " </request-chain>\n" +
+ " </filtering>\n" +
+ " </http>\n";
+ }
+
+ @Test
+ public void setting_and_deleting_instrument() {
+ assertTrue(billingController.getDefaultInstrument(tenant).isEmpty());
+
+ var instrumentRequest = request("/billing/v1/tenant/tenant1/instrument", PATCH)
+ .data("{\"active\": \"id-1\"}")
+ .roles(tenantRole);
+
+ tester.assertResponse(instrumentRequest,"OK");
+ assertEquals("id-1", billingController.getDefaultInstrument(tenant).get().getId());
+
+ var deleteInstrumentRequest = request("/billing/v1/tenant/tenant1/instrument/id-1", DELETE)
+ .roles(tenantRole);
+
+ tester.assertResponse(deleteInstrumentRequest,"OK");
+ assertTrue(billingController.getDefaultInstrument(tenant).isEmpty());
+ }
+
+ @Test
+ public void response_list_bills() {
+ var invoice = createInvoice();
+
+ billingController.addInvoice(tenant, invoice, true);
+ billingController.addInvoice(tenant, invoice, false);
+ billingController.setPlan(tenant, PlanId.from("some-plan"), true);
+
+ var request = request("/billing/v1/tenant/tenant1/billing?until=2020-05-28").roles(tenantRole);
+ tester.assertResponse(request, new File("tenant-billing-view"));
+
+ }
+
+ @Test
+ public void test_invoice_creation() {
+ var invoices = billingController.getInvoices(tenant);
+ assertEquals(0, invoices.size());
+
+ String requestBody = "{\"tenant\":\"tenant1\", \"startTime\":\"2020-04-20\", \"endTime\":\"2020-05-20\"}";
+ var request = request("/billing/v1/invoice", POST)
+ .data(requestBody)
+ .roles(tenantRole);
+
+ tester.assertResponse(request, accessDenied, 403);
+ request.roles(financeAdmin);
+ tester.assertResponse(request, new File("invoice-creation-response"));
+
+ invoices = billingController.getInvoices(tenant);
+ assertEquals(1, invoices.size());
+ Invoice invoice = invoices.get(0);
+ assertEquals(invoice.getStartTime().toString(), "2020-04-20T00:00Z[UTC]");
+ assertEquals(invoice.getEndTime().toString(), "2020-05-20T00:00Z[UTC]");
+ }
+
+ @Test
+ public void adding_and_listing_line_item() {
+
+ var requestBody = "{" +
+ "\"description\":\"some description\"," +
+ "\"amount\":\"123.45\" " +
+ "}";
+
+ var request = request("/billing/v1/invoice/tenant/tenant1/line-item", POST)
+ .data(requestBody)
+ .roles(financeAdmin);
+
+ tester.assertResponse(request, "{\"message\":\"Added line item for tenant tenant1\"}");
+
+ var lineItems = billingController.getUnusedLineItems(tenant);
+ Assert.assertEquals(1, lineItems.size());
+ Invoice.LineItem lineItem = lineItems.get(0);
+ assertEquals("some description", lineItem.description());
+ assertEquals(new BigDecimal("123.45"), lineItem.amount());
+
+ request = request("/billing/v1/invoice/tenant/tenant1/line-item")
+ .roles(financeAdmin);
+
+ tester.assertResponse(request, new File("line-item-list"));
+ }
+
+ @Test
+ public void adding_new_status() {
+ billingController.addInvoice(tenant, createInvoice(), true);
+
+ var requestBody = "{\"status\":\"DONE\"}";
+ var request = request("/billing/v1/invoice/id-1/status", POST)
+ .data(requestBody)
+ .roles(financeAdmin);
+ tester.assertResponse(request, "{\"message\":\"Updated status of invoice id-1\"}");
+
+ var invoice = billingController.getInvoices(tenant).get(0);
+ assertEquals("DONE", invoice.status());
+ }
+
+ @Test
+ public void list_all_uninvoiced_items() {
+ var invoice = createInvoice();
+ billingController.setPlan(tenant, PlanId.from("some-plan"), true);
+ billingController.setPlan(tenant2, PlanId.from("some-plan"), true);
+ billingController.addInvoice(tenant, invoice, false);
+ billingController.addLineItem(tenant, "support", new BigDecimal("42"), "Smith");
+ billingController.addInvoice(tenant2, invoice, false);
+
+
+ var request = request("/billing/v1/billing?until=2020-05-28").roles(financeAdmin);
+
+ tester.assertResponse(request, new File("billing-all-tenants"));
+ }
+
+ @Test
+ public void setting_plans() {
+ var planRequest = request("/billing/v1/tenant/tenant1/plan", PATCH)
+ .data("{\"plan\": \"new-plan\"}")
+ .roles(tenantRole);
+ tester.assertResponse(planRequest, "Plan: new-plan");
+ assertEquals("new-plan", billingController.getPlan(tenant).value());
+ }
+
+ private Invoice createInvoice() {
+ var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneId.systemDefault());
+ var end = start.plusDays(5);
+ var statusHistory = new Invoice.StatusHistory(new TreeMap<>(Map.of(start, "OPEN")));
+ return new Invoice(
+ Invoice.Id.of("id-1"),
+ statusHistory,
+ List.of(createLineItem(start)),
+ start,
+ end
+ );
+ }
+
+
+ private Invoice.LineItem createLineItem(ZonedDateTime addedAt) {
+ return new Invoice.LineItem(
+ "some-id",
+ "description",
+ new BigDecimal("123.00"),
+ "plan",
+ "Smith",
+ addedAt
+ );
+ }
+
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants
new file mode 100644
index 00000000000..c5bf0c88c2c
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants
@@ -0,0 +1,48 @@
+{
+ "until":"2020-05-28",
+ "tenants":[
+ {
+ "tenant":"tenant2",
+ "plan":"some-plan",
+ "current":{
+ "amount":"123.00",
+ "status":"accrued",
+ "from":"2020-05-23",
+ "items":[
+ {
+ "id":"some-id",
+ "description":"description",
+ "amount":"123.00"
+ }
+ ]
+ },
+ "additional":{"items":[]}
+ },
+ {
+ "tenant":"tenant1",
+ "plan":"some-plan",
+ "current":{
+ "amount":"123.00",
+ "status":"accrued",
+ "from":"2020-05-23",
+ "items":[
+ {
+ "id":"some-id",
+ "description":"description",
+ "amount":"123.00"
+ }
+ ]
+ },
+ "additional":
+ {
+ "items":[
+ {
+ "id":"line-item-id",
+ "description":"support",
+ "amount":"42.00"
+ }
+ ]
+ }
+ }
+ ]
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response
new file mode 100644
index 00000000000..0a92229025b
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response
@@ -0,0 +1 @@
+{"message":"Created invoice with ID id-123"} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list
new file mode 100644
index 00000000000..cd5aec2f8f4
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list
@@ -0,0 +1,9 @@
+{
+ "lineItems":[
+ {
+ "id":"line-item-id",
+ "description":"some description",
+ "amount":"123.45"
+ }
+ ]
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view
new file mode 100644
index 00000000000..8bc39771b31
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view
@@ -0,0 +1,38 @@
+{
+ "until":"2020-05-28",
+ "plan":"some-plan",
+ "current":{
+ "amount":"123.00",
+ "status":"accrued",
+ "from":"2020-05-23",
+ "items":[
+ {
+ "id":"some-id",
+ "description":"description",
+ "amount":"123.00"
+ }
+ ]
+ },
+ "additional":{"items":[]},
+ "bills":[
+ {
+ "id":"id-1",
+ "from":"2020-05-23",
+ "to":"2020-05-28","amount":"123.00",
+ "status":"OPEN",
+ "statusHistory":[
+ {
+ "at":"2020-05-23",
+ "status":"OPEN"
+ }
+ ],
+ "items":[
+ {
+ "id":"some-id",
+ "description":"description",
+ "amount":"123.00"
+ }
+ ]
+ }
+ ]
+} \ No newline at end of file