summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java87
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java59
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java8
3 files changed, 154 insertions, 0 deletions
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 e7959d2057a..45e643ca54c 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
@@ -10,11 +10,18 @@ import com.yahoo.config.provision.zone.ZoneId;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.EnumMap;
+import java.util.EnumSet;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
import java.util.UUID;
import java.util.function.Function;
+import java.util.stream.Collectors;
/**
@@ -344,6 +351,14 @@ public class Bill {
return Optional.ofNullable(exportedId);
}
+ public boolean isAdditional() {
+ return cpuCost != null && diskCost != null && memoryCost != null && gpuCost != null;
+ }
+
+ public boolean isResource() {
+ return ! isAdditional();
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -384,4 +399,76 @@ public class Bill {
}
}
+ public enum ItemKeyType {
+ plan(LineItem::plan),
+ version(LineItem::getMajorVersion),
+ account(LineItem::getCloudAccount),
+ architecture(LineItem::getArchitecture);
+
+ private final Function<LineItem, Object> extractor;
+
+ ItemKeyType(Function<LineItem, Object> extractor) {
+ this.extractor = extractor;
+ }
+
+ public Function<LineItem, Object> extractor() {
+ return extractor;
+ }
+ }
+
+ public record ItemKey(EnumMap<ItemKeyType, Object> keys) {}
+
+ public record ItemRequest(TreeSet<ItemKeyType> keyTypes) {
+ public static ItemRequest of(Collection<ItemKeyType> keyTypes) {
+ return new ItemRequest(new TreeSet<>(keyTypes));
+ }
+ }
+ public record ItemSummary(
+ BigDecimal cpuUsage,
+ BigDecimal ramUsage,
+ BigDecimal diskUsage,
+ BigDecimal gpuUsage,
+ BigDecimal cpuCost,
+ BigDecimal ramCost,
+ BigDecimal diskCost,
+ BigDecimal gpuCost) {
+
+ static ItemSummary from(List<LineItem> items) {
+ return new ItemSummary(
+ sum(items, LineItem::getCpuHours),
+ sum(items, LineItem::getMemoryHours),
+ sum(items, LineItem::getDiskHours),
+ sum(items, LineItem::getGpuHours),
+ sum(items, LineItem::getCpuCost),
+ sum(items, LineItem::getMemoryCost),
+ sum(items, LineItem::getDiskCost),
+ sum(items, LineItem::getGpuCost));
+ }
+
+ private static BigDecimal sum(List<LineItem> items, Function<LineItem, Optional<BigDecimal>> mapper) {
+ return items.stream().map(mapper).map(o -> o.orElse(BigDecimal.ZERO)).reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+ }
+
+ public Map<ItemKey, ItemSummary> summarizeBy(ItemRequest request) {
+ var itemsByKey = this.lineItems.stream()
+ .filter(LineItem::isResource)
+ .collect(
+ Collectors.groupingBy(
+ (LineItem item) -> createKeyFromItem(request, item),
+ Collectors.toList()));
+
+ return itemsByKey.entrySet().stream()
+ .map(item -> Map.entry(item.getKey(), ItemSummary.from(item.getValue())))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ private static ItemKey createKeyFromItem(ItemRequest request, LineItem item) {
+ var key = new EnumMap<>(ItemKeyType.class);
+ for (var keyType : request.keyTypes()) {
+ key.put(keyType, keyType.extractor().apply(item));
+ }
+ return new ItemKey(key);
+ }
+
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java
index 93c13e6ed4c..7a7ef080e17 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java
@@ -40,10 +40,14 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
import java.util.Comparator;
+import java.util.EnumSet;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.logging.Logger;
+import java.util.stream.Collectors;
/**
* @author ogronnesby
@@ -114,6 +118,8 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler
.addRoute(RestApi.route("/billing/v2/accountant/tenant/{tenant}/collection")
.get(self::accountantTenantCollection)
.post(Slime.class, self::setAccountantTenantCollection))
+ .addRoute(RestApi.route("/billing/v2/accountant/bill/{invoice}/summary")
+ .get(self::accountantInvoiceSummary))
.addRoute(RestApi.route("/billing/v2/accountant/bill/{invoice}/export")
.put(Slime.class, self::putAccountantInvoiceExport))
.addRoute(RestApi.route("/billing/v2/accountant/plans")
@@ -432,6 +438,22 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler
return slime;
}
+ private Slime accountantInvoiceSummary(RestApi.RequestContext requestContext) {
+ var billId = requestContext.pathParameters().getString("invoice").map(Bill.Id::of).orElseThrow(RestApiException.NotFound::new);
+ var requestParam = requestContext.queryParameters().getString("keys").stream()
+ .flatMap(s -> Arrays.stream(s.split(",")))
+ .map(Bill.ItemKeyType::valueOf)
+ .toList();
+
+ var requestKeys = Bill.ItemRequest.of(requestParam);
+ var bill = billing.getBill(billId);
+ var response = bill.summarizeBy(requestKeys);
+
+ var slime = new Slime();
+ toSlime(slime.setObject(), bill, response);
+ return slime;
+ }
+
private MessageResponse setAccountantTenantCollection(RestApi.RequestContext requestContext, Slime body) {
var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
var tenant = tenants.require(tenantName, CloudTenant.class);
@@ -574,6 +596,43 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler
}
}
+ private void toSlime(Cursor slime, Bill bill, Map<Bill.ItemKey, Bill.ItemSummary> summaries) {
+ slime.setString("id", bill.id().toString());
+ var summaryCursor = slime.setArray("summary");
+ summaries.forEach((key, summary) -> {
+ toSlime(summaryCursor.addObject(), key, summary);
+ });
+ }
+
+ private void toSlime(Cursor slime, Bill.ItemKey key, Bill.ItemSummary summary) {
+ toSlime(slime.setObject("key"), key);
+ toSlime(slime.setObject("summary"), summary);
+ }
+
+ private void toSlime(Cursor slime, Bill.ItemKey key) {
+ key.keys().forEach((keyType, keyValue) -> {
+ slime.setString(keyType.name(), keyValue.toString());
+ });
+ }
+
+ private void toSlime(Cursor slime, Bill.ItemSummary summary) {
+ var cpu = slime.setObject("cpu");
+ cpu.setString("cost", summary.cpuCost().toPlainString());
+ cpu.setString("hours", summary.cpuUsage().toPlainString());
+
+ var ram = slime.setObject("memory");
+ ram.setString("cost", summary.ramCost().toPlainString());
+ ram.setString("hours", summary.ramUsage().toPlainString());
+
+ var disk = slime.setObject("disk");
+ disk.setString("cost", summary.diskCost().toPlainString());
+ disk.setString("hours", summary.diskUsage().toPlainString());
+
+ var gpu = slime.setObject("gpu");
+ gpu.setString("cost", summary.gpuCost().toPlainString());
+ gpu.setString("hours", summary.gpuUsage().toPlainString());
+ }
+
private List<Object[]> toCsv(Bill bill) {
return List.<Object[]>of(new Object[]{
bill.id().value(), bill.tenant().value(),
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java
index 356076a8d00..8729410d877 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java
@@ -258,6 +258,14 @@ public class BillingApiHandlerV2Test extends ControllerContainerCloudTest {
tester.assertJsonResponse(req, new File("accepted-countries.json"));
}
+ @Test
+ void summarize_bill() {
+ var req = request("/billing/v2/accountant/bill/id-1/summary?keys=plan,architecture")
+ .roles(Role.hostedAccountant());
+ tester.assertResponse(req, """
+ {"id":"BillId{value='id-1'}","summary":[{"key":{"plan":"paid","architecture":"Optional.empty"},"summary":{"cpu":{"cost":"0","hours":"0"},"memory":{"cost":"0","hours":"0"},"disk":{"cost":"0","hours":"0"},"gpu":{"cost":"0","hours":"0"}}}]}""");
+ }
+
private static Bill createBill() {
var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneOffset.UTC);
var end = start.toLocalDate().plusDays(6).atStartOfDay(ZoneOffset.UTC);