summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java7
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java7
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java2
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java2
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java92
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java26
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\"}");
+ }
}