diff options
6 files changed, 105 insertions, 31 deletions
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 index 39d974378b4..f8ef2958f63 100644 --- 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 @@ -6,6 +6,8 @@ import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.zone.ZoneId; import java.math.BigDecimal; +import java.time.Clock; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @@ -325,9 +327,10 @@ public class Invoice { this.history = history; } - public static StatusHistory open() { + public static StatusHistory open(Clock clock) { + var now = clock.instant().atZone(ZoneOffset.UTC); return new StatusHistory( - new TreeMap<>(Map.of(ZonedDateTime.now(), "OPEN")) + new TreeMap<>(Map.of(now, "OPEN")) ); } 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 ab2d8b8dce5..535f344d352 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 @@ -70,7 +70,7 @@ public class MockBillingController implements BillingController { .add(new Invoice( invoiceId, tenant, - Invoice.StatusHistory.open(), + Invoice.StatusHistory.open(clock), List.of(), startTime, endTime @@ -111,10 +111,11 @@ public class MockBillingController implements BillingController { @Override public void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status) { + var now = clock.instant().atZone(ZoneOffset.UTC); committedInvoices.values().stream() .flatMap(List::stream) .filter(invoice -> invoiceId.equals(invoice.id())) - .forEach(invoice -> invoice.statusHistory().history.put(ZonedDateTime.now(), status)); + .forEach(invoice -> invoice.statusHistory().history.put(now, status)); } @Override @@ -201,6 +202,6 @@ public class MockBillingController implements BillingController { private Invoice emptyInvoice() { var start = clock.instant().atZone(ZoneOffset.UTC); var end = clock.instant().atZone(ZoneOffset.UTC); - return new Invoice(Invoice.Id.of("empty"), TenantName.defaultName(), Invoice.StatusHistory.open(), List.of(), start, end); + return new Invoice(Invoice.Id.of("empty"), TenantName.defaultName(), Invoice.StatusHistory.open(clock), List.of(), start, end); } } 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 558beb20e66..9f6c0a79455 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 @@ -81,6 +81,8 @@ enum PathGroup { billing(Matcher.tenant, "/billing/v2/tenant/{tenant}/{*}"), + accountant("/billing/v2/accountant/{*}"), + applicationKeys(Matcher.tenant, Matcher.application, "/application/v4/tenant/{tenant}/application/{application}/key/"), 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 8bfb10c2e3e..ee5f1d806ab 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 @@ -190,7 +190,7 @@ enum Policy { /** Invoice management */ hostedAccountant(Privilege.grant(Action.all()) - .on(PathGroup.hostedAccountant) + .on(PathGroup.hostedAccountant, PathGroup.accountant) .in(SystemName.PublicCd, SystemName.Public)), /** Listing endpoint certificate request info */ 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 2ad0dece8d9..bfcefecba0c 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 @@ -1,14 +1,15 @@ package com.yahoo.vespa.hosted.controller.restapi.billing; 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.restapi.MessageResponse; import com.yahoo.restapi.RestApi; import com.yahoo.restapi.RestApiException; import com.yahoo.restapi.RestApiRequestHandler; import com.yahoo.restapi.SlimeJsonResponse; import com.yahoo.slime.Cursor; +import com.yahoo.slime.Inspector; import com.yahoo.slime.Slime; import com.yahoo.slime.SlimeUtils; import com.yahoo.slime.Type; @@ -22,18 +23,19 @@ 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.api.role.SecurityContext; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.Tenant; -import javax.ws.rs.ForbiddenException; +import javax.ws.rs.BadRequestException; import java.io.IOException; import java.math.BigDecimal; -import java.security.Principal; import java.time.Clock; +import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.Comparator; import java.util.Optional; import java.util.List; -import java.util.function.Function; /** * @author ogronnesby @@ -73,6 +75,9 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler */ .addRoute(RestApi.route("/billing/v2/accountant") .get(self::accountant)) + .addRoute(RestApi.route("/billing/v2/accountant/preview/tenant/{tenant}") + .get(self::previewBill) + .post(Slime.class, self::createBill)) /* * Utility - map Slime.class => SlimeJsonResponse */ @@ -179,10 +184,7 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler private Slime tenantUsage(RestApi.RequestContext requestContext) { var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); var tenant = tenants.require(tenantName, CloudTenant.class); - var untilAt = requestContext.queryParameters().getString("until") - .map(LocalDate::parse) - .map(date -> date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()) - .orElseGet(clock::instant); + var untilAt = untilParameter(requestContext); var usage = billing.createUncommittedInvoice(tenant.name(), untilAt.atZone(ZoneOffset.UTC).toLocalDate()); var slime = new Slime(); @@ -193,7 +195,52 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler // --------- ACCOUNTANT API ---------- private Slime accountant(RestApi.RequestContext requestContext) { - return null; + var untilAt = untilParameter(requestContext); + var usagePerTenant = billing.createUncommittedInvoices(untilAt.atZone(ZoneOffset.UTC).toLocalDate()); + + var response = new Slime(); + var tenantsResponse = response.setObject().setArray("tenants"); + tenants.asList().stream().sorted(Comparator.comparing(Tenant::name)).forEach(tenant -> { + var usage = Optional.ofNullable(usagePerTenant.get(tenant.name())); + var tenantResponse = tenantsResponse.addObject(); + tenantResponse.setString("tenant", tenant.name().value()); + tenantResponse.setString("plan", billing.getPlan(tenant.name()).value()); + tenantResponse.setString("collection", billing.getCollectionMethod(tenant.name()).name()); + tenantResponse.setString("lastBill", usage.map(Invoice::getStartTime).map(DateTimeFormatter.ISO_DATE::format).orElse(null)); + tenantResponse.setString("unbilled", usage.map(Invoice::sum).map(BigDecimal::toPlainString).orElse("0.00")); + }); + + return response; + } + + private Slime previewBill(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + var untilAt = untilParameter(requestContext); + + var usage = billing.createUncommittedInvoice(tenant.name(), untilAt.atZone(ZoneOffset.UTC).toLocalDate()); + + var slime = new Slime(); + toSlime(slime.setObject(), usage); + return slime; + } + + private HttpResponse createBill(RestApi.RequestContext requestContext, Slime slime) { + var body = slime.get(); + var security = requestContext.attributes().get(SecurityContext.ATTRIBUTE_NAME) + .map(SecurityContext.class::cast) + .orElseThrow(() -> new RestApiException.Forbidden("Must be logged in")); + + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var startAt = LocalDate.parse(getInspectorFieldOrThrow(body, "from")).atStartOfDay(ZoneOffset.UTC); + var endAt = LocalDate.parse(getInspectorFieldOrThrow(body, "to")).atStartOfDay(ZoneOffset.UTC); + + var invoiceId = billing.createInvoiceForPeriod(tenant.name(), startAt, endAt, security.principal().getName()); + + // TODO: Make a redirect to the bill itself + return new MessageResponse("Created bill " + invoiceId.value()); } @@ -277,10 +324,17 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler // ---------- END INVOICE RENDERING ---------- - 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 Instant untilParameter(RestApi.RequestContext ctx) { + return ctx.queryParameters().getString("until") + .map(LocalDate::parse) + .map(date -> date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()) + .orElseGet(clock::instant); + } + + 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 static Optional<Slime> slimeRequestMapper(RestApi.RequestContext requestContext) { @@ -291,20 +345,8 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler } } - private static <T> RestApi.Handler<T> withCloudTenant(TenantController ctl, FunctionWithCloudTenant<T> f) { - return (RestApi.RequestContext ctx) -> { - var tenantName = TenantName.from(ctx.pathParameters().getStringOrThrow("tenant")); - var tenant = ctl.require(tenantName, CloudTenant.class); - return f.applyWithTenant(ctx, tenant); - }; - } - private static HttpResponse slimeResponseMapper(RestApi.RequestContext ctx, Slime slime) { return new SlimeJsonResponse(slime); } - @FunctionalInterface - private interface FunctionWithCloudTenant<T> { - T applyWithTenant(RestApi.RequestContext ctx, CloudTenant tenant); - } } 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 8122c4c9679..43754261001 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 @@ -109,4 +109,30 @@ public class BillingApiHandlerV2Test extends ControllerContainerCloudTest { var singleRequest = request("/billing/v2/tenant/" + tenant + "/bill/id-1").roles(tenantReader); tester.assertResponse(singleRequest, "{\"id\":\"id-1\",\"from\":\"2020-05-23\",\"to\":\"2020-05-23\",\"total\":\"123.00\",\"status\":\"OPEN\",\"statusHistory\":[{\"at\":\"2020-05-23T00:00:00+02:00\",\"status\":\"OPEN\"}],\"items\":[{\"id\":\"some-id\",\"description\":\"description\",\"amount\":\"123.00\",\"plan\":\"some-plan\",\"planName\":\"Plan with id: some-plan\",\"cpu\":{},\"memory\":{},\"disk\":{}}]}"); } + + @Test + public void require_accountant_summary() { + var tenantRequest = request("/billing/v2/accountant").roles(tenantReader); + tester.assertResponse(tenantRequest, "{\n" + + " \"code\" : 403,\n" + + " \"message\" : \"Access denied\"\n" + + "}", 403); + + var accountantRequest = request("/billing/v2/accountant").roles(Role.hostedAccountant()); + tester.assertResponse(accountantRequest, "{\"tenants\":[{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"AUTO\",\"lastBill\":null,\"unbilled\":\"0.00\"}]}"); + } + + @Test + public void require_accountant_tenant_preview() { + var accountantRequest = request("/billing/v2/accountant/preview/tenant/tenant1").roles(Role.hostedAccountant()); + tester.assertResponse(accountantRequest, "{\"id\":\"empty\",\"from\":\"2021-04-13\",\"to\":\"2021-04-13\",\"total\":\"0.00\",\"status\":\"OPEN\",\"statusHistory\":[{\"at\":\"2021-04-13T00:00:00Z\",\"status\":\"OPEN\"}],\"items\":[]}"); + } + + @Test + public void require_accountant_tenant_bill() { + var accountantRequest = request("/billing/v2/accountant/preview/tenant/tenant1", Request.Method.POST) + .roles(Role.hostedAccountant()) + .data("{\"from\": \"2020-05-01\",\"to\": \"2020-06-01\"}"); + tester.assertResponse(accountantRequest, "{\"message\":\"Created bill id-123\"}"); + } } |