diff options
author | Øyvind Grønnesby <oyving@verizonmedia.com> | 2021-05-10 11:54:24 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-10 11:54:24 +0200 |
commit | 257e0e2fca7b0ff0a15210909116162c925a11b7 (patch) | |
tree | 6ca33a074cd23221e0d9908764b9065115732d79 /controller-server | |
parent | 0e795977b2072d5504b1a45adffa7d2bdfed7fc2 (diff) | |
parent | 93cbea53ec4aac9861d1f64182d04f3e6d4ee2f2 (diff) |
Merge pull request #17748 from vespa-engine/ogronnesby/billing-api-v2
Some API changes for Billing API
Diffstat (limited to 'controller-server')
6 files changed, 528 insertions, 30 deletions
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 index c56c2e93f65..daa84f4700c 100644 --- 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 @@ -26,14 +26,11 @@ import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingControll import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.yolean.Exceptions; -import org.apache.commons.csv.CSVFormat; import javax.ws.rs.BadRequestException; import javax.ws.rs.ForbiddenException; import javax.ws.rs.NotFoundException; import java.io.IOException; -import java.io.OutputStream; -import java.io.OutputStreamWriter; import java.math.BigDecimal; import java.security.Principal; import java.time.LocalDate; @@ -482,27 +479,4 @@ public class BillingApiHandler extends LoggingRequestHandler { .count() > 0; } - private static class CsvResponse extends HttpResponse { - private final String[] header; - private final List<Object[]> rows; - - CsvResponse(String[] header, List<Object[]> rows) { - super(200); - this.header = header; - this.rows = rows; - } - - @Override - public void render(OutputStream outputStream) throws IOException { - var writer = new OutputStreamWriter(outputStream); - var printer = CSVFormat.DEFAULT.withRecordSeparator('\n').withHeader(this.header).print(writer); - for (var row : this.rows) printer.printRecord(row); - printer.flush(); - } - - @Override - public String getContentType() { - return "text/csv; encoding=utf-8"; - } - } } 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 new file mode 100644 index 00000000000..bfcefecba0c --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java @@ -0,0 +1,352 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.config.provision.TenantName; +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; +import com.yahoo.vespa.hosted.controller.ApplicationController; +import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.TenantController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.CollectionMethod; +import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice; +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.BadRequestException; +import java.io.IOException; +import java.math.BigDecimal; +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; + +/** + * @author ogronnesby + */ +public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandlerV2> { + private static final String[] CSV_INVOICE_HEADER = new String[]{ "ID", "Tenant", "From", "To", "CpuHours", "MemoryHours", "DiskHours", "Cpu", "Memory", "Disk", "Additional" }; + + private final ApplicationController applications; + private final TenantController tenants; + private final BillingController billing; + private final Clock clock; + + public BillingApiHandlerV2(LoggingRequestHandler.Context context, Controller controller) { + super(context, BillingApiHandlerV2::createRestApi); + this.applications = controller.applications(); + this.tenants = controller.tenants(); + this.billing = controller.serviceRegistry().billingController(); + this.clock = controller.serviceRegistry().clock(); + } + + private static RestApi createRestApi(BillingApiHandlerV2 self) { + return RestApi.builder() + /* + * This is the API that is available to tenants to view their status + */ + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}") + .get(self::tenant) + .patch(Slime.class, self::patchTenant)) + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/usage") + .get(self::tenantUsage)) + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/bill") + .get(self::tenantInvoiceList)) + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/bill/{invoice}") + .get(self::tenantInvoice)) + /* + * This is the API that is created for accountant role in Vespa Cloud + */ + .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 + */ + .addRequestMapper(Slime.class, BillingApiHandlerV2::slimeRequestMapper) + .addResponseMapper(Slime.class, BillingApiHandlerV2::slimeResponseMapper) + .build(); + } + + // ---------- TENANT API ---------- + + private Slime tenant(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var plan = billing.getPlan(tenant.name()); + var collectionMethod = billing.getCollectionMethod(tenant.name()); + + var response = new Slime(); + var cursor = response.setObject(); + cursor.setString("tenant", tenant.name().value()); + cursor.setString("plan", plan.value()); + cursor.setString("collection", collectionMethod.name()); + return response; + } + + private Slime patchTenant(RestApi.RequestContext requestContext, Slime body) { + 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 newPlan = body.get().field("plan"); + var newCollection = body.get().field("collection"); + + if (newPlan.valid() && newPlan.type() == Type.STRING) { + var planId = PlanId.from(newPlan.asString()); + var hasDeployments = tenantHasDeployments(tenant.name()); + var result = billing.setPlan(tenant.name(), planId, hasDeployments); + if (! result.isSuccess()) { + throw new RestApiException.Forbidden(result.getErrorMessage().get()); + } + } + + if (newCollection.valid() && newCollection.type() == Type.STRING) { + if (security.roles().contains(Role.hostedAccountant())) { + var collection = CollectionMethod.valueOf(newCollection.asString()); + billing.setCollectionMethod(tenant.name(), collection); + } else { + throw new RestApiException.Forbidden("Only accountant can change billing method"); + } + } + + var response = new Slime(); + var cursor = response.setObject(); + cursor.setString("tenant", tenant.name().value()); + cursor.setString("plan", billing.getPlan(tenant.name()).value()); + cursor.setString("collection", billing.getCollectionMethod(tenant.name()).name()); + return response; + } + + private Slime tenantInvoiceList(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var slime = new Slime(); + invoicesSummaryToSlime(slime.setObject().setArray("invoices"), billing.getInvoicesForTenant(tenant.name())); + return slime; + } + + private HttpResponse tenantInvoice(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + var invoiceId = requestContext.pathParameters().getStringOrThrow("invoice"); + var format = requestContext.queryParameters().getString("format").orElse("json"); + + var invoice = billing.getInvoicesForTenant(tenant.name()).stream() + .filter(inv -> inv.id().value().equals(invoiceId)) + .findAny() + .orElseThrow(RestApiException.NotFound::new); + + if (format.equals("json")) { + var slime = new Slime(); + toSlime(slime.setObject(), invoice); + return new SlimeJsonResponse(slime); + } + + if (format.equals("csv")) { + var csv = toCsv(invoice); + return new CsvResponse(CSV_INVOICE_HEADER, csv); + } + + throw new RestApiException.BadRequest("Unknown format: " + format); + } + + private boolean tenantHasDeployments(TenantName tenant) { + return applications.asList(tenant).stream() + .flatMap(app -> app.instances().values().stream()) + .mapToLong(instance -> instance.deployments().size()) + .sum() > 0; + } + + private Slime tenantUsage(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(); + usageToSlime(slime.setObject(), usage); + return slime; + } + + // --------- ACCOUNTANT API ---------- + + private Slime accountant(RestApi.RequestContext requestContext) { + 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()); + } + + + // --------- INVOICE RENDERING ---------- + + private void invoicesSummaryToSlime(Cursor slime, List<Invoice> invoices) { + invoices.forEach(invoice -> invoiceSummaryToSlime(slime.addObject(), invoice)); + } + + private void invoiceSummaryToSlime(Cursor slime, Invoice invoice) { + slime.setString("id", invoice.id().value()); + slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("total", invoice.sum().toString()); + slime.setString("status", invoice.status()); + } + + private void usageToSlime(Cursor slime, Invoice invoice) { + slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("total", invoice.sum().toString()); + toSlime(slime.setArray("items"), invoice.lineItems()); + } + + private void toSlime(Cursor slime, Invoice invoice) { + slime.setString("id", invoice.id().value()); + slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("total", invoice.sum().toString()); + slime.setString("status", invoice.status()); + toSlime(slime.setArray("statusHistory"), invoice.statusHistory()); + toSlime(slime.setArray("items"), invoice.lineItems()); + } + + private void toSlime(Cursor slime, Invoice.StatusHistory history) { + history.getHistory().forEach((key, value) -> { + var c = slime.addObject(); + c.setString("at", key.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + c.setString("status", value); + }); + } + + private void toSlime(Cursor slime, List<Invoice.LineItem> items) { + items.forEach(item -> toSlime(slime.addObject(), item)); + } + + private void toSlime(Cursor slime, Invoice.LineItem item) { + slime.setString("id", item.id()); + slime.setString("description", item.description()); + slime.setString("amount",item.amount().toString()); + slime.setString("plan", item.plan()); + slime.setString("planName", billing.getPlanDisplayName(PlanId.from(item.plan()))); + + item.applicationId().ifPresent(appId -> { + slime.setString("application", appId.application().value()); + slime.setString("instance", appId.instance().value()); + }); + + item.zoneId().ifPresent(z -> slime.setString("zone", z.value())); + + toSlime(slime.setObject("cpu"), item.getCpuHours(), item.getCpuCost()); + toSlime(slime.setObject("memory"), item.getMemoryHours(), item.getMemoryCost()); + toSlime(slime.setObject("disk"), item.getDiskHours(), item.getDiskCost()); + } + + private void toSlime(Cursor slime, Optional<BigDecimal> hours, Optional<BigDecimal> cost) { + hours.ifPresent(h -> slime.setString("hours", h.toString())); + cost.ifPresent(c -> slime.setString("cost", c.toString())); + } + + private List<Object[]> toCsv(Invoice invoice) { + return List.<Object[]>of(new Object[]{ + invoice.id().value(), invoice.tenant().value(), + invoice.getStartTime().format(DateTimeFormatter.ISO_DATE), + invoice.getEndTime().format(DateTimeFormatter.ISO_DATE), + invoice.sumCpuHours(), invoice.sumMemoryHours(), invoice.sumDiskHours(), + invoice.sumCpuCost(), invoice.sumMemoryCost(), invoice.sumDiskCost(), + invoice.sumAdditionalCost() + }); + } + + // ---------- END INVOICE RENDERING ---------- + + 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) { + try { + return Optional.of(SlimeUtils.jsonToSlime(requestContext.requestContentOrThrow().content().readAllBytes())); + } catch (IOException e) { + throw new IllegalArgumentException("Could not parse JSON input"); + } + } + + private static HttpResponse slimeResponseMapper(RestApi.RequestContext ctx, Slime slime) { + return new SlimeJsonResponse(slime); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java new file mode 100644 index 00000000000..5aa993f2727 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java @@ -0,0 +1,33 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.container.jdisc.HttpResponse; +import org.apache.commons.csv.CSVFormat; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.List; + +class CsvResponse extends HttpResponse { + private final String[] header; + private final List<Object[]> rows; + + CsvResponse(String[] header, List<Object[]> rows) { + super(200); + this.header = header; + this.rows = rows; + } + + @Override + public void render(OutputStream outputStream) throws IOException { + var writer = new OutputStreamWriter(outputStream); + var printer = CSVFormat.DEFAULT.withRecordSeparator('\n').withHeader(this.header).print(writer); + for (var row : this.rows) printer.printRecord(row); + printer.flush(); + } + + @Override + public String getContentType() { + return "text/csv; encoding=utf-8"; + } +} 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 c11c6ba155a..702ce83d116 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 @@ -68,7 +68,7 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg private final MockRunDataStore mockRunDataStore = new MockRunDataStore(); private final MockResourceTagger mockResourceTagger = new MockResourceTagger(); private final RoleService roleService = new MockRoleService(); - private final BillingController billingController = new MockBillingController(); + private final BillingController billingController = new MockBillingController(clock); private final ContainerRegistryMock containerRegistry = new ContainerRegistryMock(); private final NoopTenantSecretService tenantSecretService = new NoopTenantSecretService(); private final ArchiveService archiveService = new MockArchiveService(); 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 index b88715efcc4..88b2b939c48 100644 --- 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 @@ -20,6 +20,7 @@ import java.io.File; import java.math.BigDecimal; import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @@ -221,8 +222,8 @@ public class BillingApiHandlerTest extends ControllerContainerCloudTest { assertEquals(CollectionMethod.INVOICE, billingController.getCollectionMethod(tenant)); } - private Invoice createInvoice() { - var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneId.systemDefault()); + static Invoice createInvoice() { + var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneOffset.UTC); var end = start.plusDays(5); var statusHistory = new Invoice.StatusHistory(new TreeMap<>(Map.of(start, "OPEN"))); return new Invoice( @@ -235,7 +236,7 @@ public class BillingApiHandlerTest extends ControllerContainerCloudTest { ); } - private Invoice.LineItem createLineItem(ZonedDateTime addedAt) { + static Invoice.LineItem createLineItem(ZonedDateTime addedAt) { return new Invoice.LineItem( "some-id", "description", 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 new file mode 100644 index 00000000000..e733f8e27d6 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java @@ -0,0 +1,138 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.config.provision.TenantName; +import com.yahoo.test.ManualClock; +import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController; +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 com.yahoo.vespa.hosted.controller.security.Auth0Credentials; +import com.yahoo.vespa.hosted.controller.security.CloudTenantSpec; +import org.junit.Before; +import org.junit.Test; + +import java.time.Instant; +import java.util.Set; + +/** + * @author ogronnesby + */ +public class BillingApiHandlerV2Test 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> tenantReader = Set.of(Role.reader(tenant)); + private static final Set<Role> tenantAdmin = Set.of(Role.administrator(tenant)); + private static final Set<Role> financeAdmin = Set.of(Role.hostedAccountant()); + + private static final String ACCESS_DENIED = "{\n" + + " \"code\" : 403,\n" + + " \"message\" : \"Access denied\"\n" + + "}"; + + private MockBillingController billingController; + private ContainerTester tester; + + @Before + public void before() { + tester = new ContainerTester(container, responseFiles); + tester.controller().tenants().create(new CloudTenantSpec(tenant, ""), new Auth0Credentials(() -> "foo", Set.of(Role.hostedOperator()))); + var clock = (ManualClock) tester.controller().serviceRegistry().clock(); + clock.setInstant(Instant.parse("2021-04-13T00:00:00Z")); + billingController = (MockBillingController) tester.serviceRegistry().billingController(); + billingController.addInvoice(tenant, BillingApiHandlerTest.createInvoice(), true); + } + + @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.BillingApiHandlerV2'>\n" + + " <binding>http://*/billing/v2/*</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 require_tenant_info() { + var request = request("/billing/v2/tenant/" + tenant.value()).roles(tenantReader); + tester.assertResponse(request, "{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"AUTO\"}"); + } + + @Test + public void require_admin_for_update_plan() { + var request = request("/billing/v2/tenant/" + tenant.value(), Request.Method.PATCH) + .data("{\"plan\": \"pay-as-you-go\"}"); + + var forbidden = request.roles(tenantReader); + tester.assertResponse(forbidden, ACCESS_DENIED, 403); + var success = request.roles(tenantAdmin); + tester.assertResponse(success, "{\"tenant\":\"tenant1\",\"plan\":\"pay-as-you-go\",\"collection\":\"AUTO\"}"); + } + + @Test + public void require_accountant_for_update_collection() { + var request = request("/billing/v2/tenant/" + tenant.value(), Request.Method.PATCH) + .data("{\"collection\": \"INVOICE\"}"); + + var forbidden = request.roles(tenantAdmin); + tester.assertResponse(forbidden, "{\"error-code\":\"FORBIDDEN\",\"message\":\"Only accountant can change billing method\"}", 403); + + var success = request.roles(financeAdmin); + tester.assertResponse(success, "{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"INVOICE\"}"); + } + + @Test + public void require_tenant_usage() { + var request = request("/billing/v2/tenant/" + tenant + "/usage").roles(tenantReader); + tester.assertResponse(request, "{\"from\":\"2021-04-13\",\"to\":\"2021-04-13\",\"total\":\"0.00\",\"items\":[]}"); + } + + @Test + public void require_tenant_invoice() { + var listRequest = request("/billing/v2/tenant/" + tenant + "/bill").roles(tenantReader); + tester.assertResponse(listRequest, "{\"invoices\":[{\"id\":\"id-1\",\"from\":\"2020-05-23\",\"to\":\"2020-05-23\",\"total\":\"123.00\",\"status\":\"OPEN\"}]}"); + + 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:00Z\",\"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\"}"); + } +} |