diff options
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); |