diff options
author | Ola Aunrønning <olaa@verizonmedia.com> | 2020-06-29 10:26:28 +0200 |
---|---|---|
committer | Ola Aunrønning <olaa@verizonmedia.com> | 2020-06-29 10:26:28 +0200 |
commit | 71e6628cf3aaa58d8a4dfd8325da5f3e841866a2 (patch) | |
tree | 149ad36bf5300bdd96d3f90b239b1e1691c99d19 /controller-server | |
parent | 0dbbd3c3be2870681480e73f9cc491e349b06610 (diff) |
Revert "Merge pull request #13726 from vespa-engine/revert-13715-olaa/billing-api-handler"
This reverts commit 0dbbd3c3be2870681480e73f9cc491e349b06610, reversing
changes made to 0a8b5894dfc442d661836fce4ddb6c870bcc0ec0.
Diffstat (limited to 'controller-server')
8 files changed, 718 insertions, 12 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 new file mode 100644 index 00000000000..ccbee15d2c5 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java @@ -0,0 +1,396 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.config.provision.ApplicationId; +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.container.logging.AccessLog; +import com.yahoo.restapi.ErrorResponse; +import com.yahoo.restapi.JacksonJsonResponse; +import com.yahoo.restapi.MessageResponse; +import com.yahoo.restapi.Path; +import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.restapi.StringResponse; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.vespa.hosted.controller.ApplicationController; +import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; +import com.yahoo.vespa.hosted.controller.api.integration.billing.PaymentInstrument; +import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice; +import com.yahoo.vespa.hosted.controller.api.integration.billing.InstrumentOwner; +import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId; +import com.yahoo.yolean.Exceptions; + +import javax.ws.rs.BadRequestException; +import javax.ws.rs.ForbiddenException; +import javax.ws.rs.NotFoundException; +import java.io.IOException; +import java.math.BigDecimal; +import java.security.Principal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.logging.Level; + +/** + * @author andreer + * @author olaa + */ +public class BillingApiHandler extends LoggingRequestHandler { + + private static final String OPTIONAL_PREFIX = "/api"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + + private final BillingController billingController; + private final ApplicationController applicationController; + + public BillingApiHandler(Executor executor, + AccessLog accessLog, + Controller controller) { + super(executor, accessLog); + this.billingController = controller.serviceRegistry().billingController(); + this.applicationController = controller.applications(); + } + + @Override + public HttpResponse handle(HttpRequest request) { + try { + Path path = new Path(request.getUri(), OPTIONAL_PREFIX); + String userId = userIdOrThrow(request); + switch (request.getMethod()) { + case GET: + return handleGET(request, path, userId); + case PATCH: + return handlePATCH(request, path, userId); + case DELETE: + return handleDELETE(path, userId); + case POST: + return handlePOST(path, request, userId); + default: + return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported"); + } + } catch (NotFoundException e) { + return ErrorResponse.notFoundError(Exceptions.toMessageString(e)); + } catch (IllegalArgumentException e) { + return ErrorResponse.badRequest(Exceptions.toMessageString(e)); + } catch (Exception e) { + log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e); + // Don't expose internal billing details in error message to user + return ErrorResponse.internalServerError("Internal problem while handling billing API request"); + } + } + + private HttpResponse handleGET(HttpRequest request, Path path, String userId) { + if (path.matches("/billing/v1/tenant/{tenant}/token")) return getToken(path.get("tenant"), userId); + if (path.matches("/billing/v1/tenant/{tenant}/instrument")) return getInstruments(path.get("tenant"), userId); + if (path.matches("/billing/v1/tenant/{tenant}/billing")) return getBilling(path.get("tenant"), request.getProperty("until")); + if (path.matches("/billing/v1/tenant/{tenant}/plan")) return getPlan(path.get("tenant")); + if (path.matches("/billing/v1/billing")) return getBillingAllTenants(request.getProperty("until")); + if (path.matches("/billing/v1/invoice/tenant/{tenant}/line-item")) return getLineItems(path.get("tenant")); + return ErrorResponse.notFoundError("Nothing at " + path); + } + + private HttpResponse handlePATCH(HttpRequest request, Path path, String userId) { + if (path.matches("/billing/v1/tenant/{tenant}/instrument")) return patchActiveInstrument(request, path.get("tenant"), userId); + if (path.matches("/billing/v1/tenant/{tenant}/plan")) return patchPlan(request, path.get("tenant")); + return ErrorResponse.notFoundError("Nothing at " + path); + + } + + private HttpResponse handleDELETE(Path path, String userId) { + if (path.matches("/billing/v1/tenant/{tenant}/instrument/{instrument}")) return deleteInstrument(path.get("tenant"), userId, path.get("instrument")); + if (path.matches("/billing/v1/invoice/line-item/{line-item-id}")) return deleteLineItem(path.get("line-item-id")); + return ErrorResponse.notFoundError("Nothing at " + path); + + } + + private HttpResponse handlePOST(Path path, HttpRequest request, String userId) { + if (path.matches("/billing/v1/invoice")) return createInvoice(request, userId); + if (path.matches("/billing/v1/invoice/{invoice-id}/status")) return setInvoiceStatus(request, path.get("invoice-id")); + if (path.matches("/billing/v1/invoice/tenant/{tenant}/line-item")) return addLineItem(request, path.get("tenant")); + return ErrorResponse.notFoundError("Nothing at " + path); + + } + + private HttpResponse getPlan(String tenant) { + var plan = billingController.getPlan(TenantName.from(tenant)); + var slime = new Slime(); + var root = slime.setObject(); + root.setString("tenant", tenant); + root.setString("plan", plan.value()); + return new SlimeJsonResponse(slime); + } + + private HttpResponse patchPlan(HttpRequest request, String tenant) { + var tenantName = TenantName.from(tenant); + var slime = inspectorOrThrow(request); + var planId = PlanId.from(slime.field("plan").asString()); + var hasApplications = applicationController.asList(tenantName).size() > 0; + + if (billingController.setPlan(tenantName, planId, hasApplications)) { + return new StringResponse("Plan: " + planId.value()); + } else { + return ErrorResponse.forbidden("Invalid plan change with active deployments"); + } + + } + + private HttpResponse getBillingAllTenants(String until) { + try { + var untilDate = untilParameter(until); + var uncommittedInvoices = billingController.createUncommittedInvoices(untilDate); + + var slime = new Slime(); + var root = slime.setObject(); + root.setString("until", untilDate.format(DateTimeFormatter.ISO_DATE)); + var tenants = root.setArray("tenants"); + + uncommittedInvoices.forEach((tenant, invoice) -> { + var tc = tenants.addObject(); + tc.setString("tenant", tenant.value()); + getPlanForTenant(tc, tenant); + renderCurrentUsage(tc.setObject("current"), invoice); + renderAdditionalItems(tc.setObject("additional").setArray("items"), billingController.getUnusedLineItems(tenant)); + + billingController.getDefaultInstrument(tenant).ifPresent(card -> + renderInstrument(tc.setObject("payment"), card) + ); + }); + + return new SlimeJsonResponse(slime); + } catch (DateTimeParseException e) { + return ErrorResponse.badRequest("Could not parse date: " + until); + } + } + + private HttpResponse addLineItem(HttpRequest request, String tenant) { + Inspector inspector = inspectorOrThrow(request); + billingController.addLineItem( + TenantName.from(tenant), + getInspectorFieldOrThrow(inspector, "description"), + new BigDecimal(getInspectorFieldOrThrow(inspector, "amount")), + userIdOrThrow(request)); + return new MessageResponse("Added line item for tenant " + tenant); + } + + private HttpResponse setInvoiceStatus(HttpRequest request, String invoiceId) { + Inspector inspector = inspectorOrThrow(request); + String status = getInspectorFieldOrThrow(inspector, "status"); + billingController.updateInvoiceStatus(Invoice.Id.of(invoiceId), userIdOrThrow(request), status); + return new MessageResponse("Updated status of invoice " + invoiceId); + } + + private HttpResponse createInvoice(HttpRequest request, String userId) { + Inspector inspector = inspectorOrThrow(request); + TenantName tenantName = TenantName.from(getInspectorFieldOrThrow(inspector, "tenant")); + + LocalDate startDate = LocalDate.parse(getInspectorFieldOrThrow(inspector, "startTime")); + LocalDate endDate = LocalDate.parse(getInspectorFieldOrThrow(inspector, "endTime")); + ZonedDateTime startTime = startDate.atStartOfDay(ZoneId.of("UTC")); + ZonedDateTime endTime = endDate.atStartOfDay(ZoneId.of("UTC")); + + var invoiceId = billingController.createInvoiceForPeriod(tenantName, startTime, endTime, userId); + + return new MessageResponse("Created invoice with ID " + invoiceId.value()); + } + + private HttpResponse getInstruments(String tenant, String userId) { + var instrumentListResponse = billingController.listInstruments(TenantName.from(tenant), userId); + return new JacksonJsonResponse<>(200, instrumentListResponse); + } + + private HttpResponse getToken(String tenant, String userId) { + return new StringResponse(billingController.createClientToken(tenant, userId)); + } + + private HttpResponse getBilling(String tenant, String until) { + try { + var untilDate = untilParameter(until); + var tenantId = TenantName.from(tenant); + var slimeResponse = new Slime(); + var root = slimeResponse.setObject(); + + root.setString("until", untilDate.format(DateTimeFormatter.ISO_DATE)); + + getPlanForTenant(root, tenantId); + renderCurrentUsage(root.setObject("current"), getCurrentUsageForTenant(tenantId, untilDate)); + renderAdditionalItems(root.setObject("additional").setArray("items"), billingController.getUnusedLineItems(tenantId)); + renderInvoices(root.setArray("bills"), getInvoicesForTenant(tenantId)); + + billingController.getDefaultInstrument(tenantId).ifPresent( card -> + renderInstrument(root.setObject("payment"), card) + ); + + return new SlimeJsonResponse(slimeResponse); + } catch (DateTimeParseException e) { + return ErrorResponse.badRequest("Could not parse date: " + until); + } + } + + private HttpResponse getLineItems(String tenant) { + var slimeResponse = new Slime(); + var root = slimeResponse.setObject(); + var lineItems = root.setArray("lineItems"); + + billingController.getUnusedLineItems(TenantName.from(tenant)) + .forEach(lineItem -> { + var itemCursor = lineItems.addObject(); + renderLineItemToCursor(itemCursor, lineItem); + }); + + return new SlimeJsonResponse(slimeResponse); + } + + private void getPlanForTenant(Cursor cursor, TenantName tenant) { + cursor.setString("plan", billingController.getPlan(tenant).value()); + } + + private void renderInstrument(Cursor cursor, PaymentInstrument instrument) { + cursor.setString("type", instrument.getType()); + cursor.setString("brand", instrument.getBrand()); + cursor.setString("endingWith", instrument.getEndingWith()); + } + + private void renderCurrentUsage(Cursor cursor, Invoice currentUsage) { + cursor.setString("amount", currentUsage.sum().toPlainString()); + cursor.setString("status", "accrued"); + cursor.setString("from", currentUsage.getStartTime().format(DATE_TIME_FORMATTER)); + var itemsCursor = cursor.setArray("items"); + currentUsage.lineItems().forEach(lineItem -> { + var itemCursor = itemsCursor.addObject(); + renderLineItemToCursor(itemCursor, lineItem); + }); + } + + private void renderAdditionalItems(Cursor cursor, List<Invoice.LineItem> items) { + items.forEach(item -> { + renderLineItemToCursor(cursor.addObject(), item); + }); + } + + private Invoice getCurrentUsageForTenant(TenantName tenant, LocalDate until) { + return billingController.createUncommittedInvoice(tenant, until); + } + + private List<Invoice> getInvoicesForTenant(TenantName tenant) { + return billingController.getInvoices(tenant); + } + + private void renderInvoices(Cursor cursor, List<Invoice> invoices) { + invoices.forEach(invoice -> { + var invoiceCursor = cursor.addObject(); + renderInvoiceToCursor(invoiceCursor, invoice); + }); + } + + private void renderInvoiceToCursor(Cursor invoiceCursor, Invoice invoice) { + invoiceCursor.setString("id", invoice.id().value()); + invoiceCursor.setString("from", invoice.getStartTime().format(DATE_TIME_FORMATTER)); + invoiceCursor.setString("to", invoice.getEndTime().format(DATE_TIME_FORMATTER)); + + invoiceCursor.setString("amount", invoice.sum().toString()); + invoiceCursor.setString("status", invoice.status()); + var statusCursor = invoiceCursor.setArray("statusHistory"); + renderStatusHistory(statusCursor, invoice.statusHistory()); + + + var lineItemsCursor = invoiceCursor.setArray("items"); + invoice.lineItems().forEach(lineItem -> { + var itemCursor = lineItemsCursor.addObject(); + renderLineItemToCursor(itemCursor, lineItem); + }); + } + + private void renderStatusHistory(Cursor cursor, Invoice.StatusHistory statusHistory) { + statusHistory.getHistory() + .entrySet() + .stream() + .forEach(entry -> { + var c = cursor.addObject(); + c.setString("at", entry.getKey().format(DATE_TIME_FORMATTER)); + c.setString("status", entry.getValue()); + }); + } + + private void renderLineItemToCursor(Cursor cursor, Invoice.LineItem lineItem) { + cursor.setString("id", lineItem.id()); + cursor.setString("description", lineItem.description()); + cursor.setString("amount", lineItem.amount().toString()); + lineItem.applicationId().ifPresent(appId -> { + cursor.setString("application", appId.application().value()); + }); + } + + private HttpResponse deleteInstrument(String tenant, String userId, String instrument) { + if (billingController.deleteInstrument(TenantName.from(tenant), userId, instrument)) { + return new StringResponse("OK"); + } else { + return ErrorResponse.forbidden("Cannot delete payment instrument you don't own"); + } + } + + private HttpResponse deleteLineItem(String lineItemId) { + billingController.deleteLineItem(lineItemId); + return new MessageResponse("Succesfully deleted line item " + lineItemId); + } + + private HttpResponse patchActiveInstrument(HttpRequest request, String tenant, String userId) { + var inspector = inspectorOrThrow(request); + String instrumentId = getInspectorFieldOrThrow(inspector, "active"); + InstrumentOwner paymentInstrument = new InstrumentOwner(TenantName.from(tenant), userId, instrumentId, true); + boolean success = billingController.setActivePaymentInstrument(paymentInstrument); + return success ? new StringResponse("OK") : ErrorResponse.internalServerError("Failed to patch active instrument"); + } + + private Inspector inspectorOrThrow(HttpRequest request) { + try { + return SlimeUtils.jsonToSlime(request.getData().readAllBytes()).get(); + } catch (IOException e) { + throw new BadRequestException("Failed to parse request body"); + } + } + + 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 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 DeploymentId getDeploymentIdOrNull(Inspector inspector) { + if (inspector.field("applicationId").valid() != inspector.field("zoneId").valid() ) { + throw new BadRequestException("Either both application id and zone id should be set, or neither."); + } + if (inspector.field("applicationId").valid()) { + return new DeploymentId( + ApplicationId.fromSerializedForm(inspector.field("applicationId").asString()), + com.yahoo.config.provision.zone.ZoneId.from(inspector.field("zoneId").asString()) + ); + } + return null; + } + + private LocalDate untilParameter(String until) { + if (until == null || until.isEmpty() || until.isBlank()) + return LocalDate.now().plusDays(1); + return LocalDate.parse(until); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java index bd0143ef879..13cf992cd52 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/security/CloudAccessControl.java @@ -9,7 +9,7 @@ import com.yahoo.vespa.flags.FlagSource; import com.yahoo.vespa.flags.Flags; import com.yahoo.vespa.hosted.controller.Application; import com.yahoo.vespa.hosted.controller.api.integration.ServiceRegistry; -import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; import com.yahoo.vespa.hosted.controller.api.integration.organization.BillingInfo; import com.yahoo.vespa.hosted.controller.api.integration.user.Roles; import com.yahoo.vespa.hosted.controller.api.integration.user.UserId; @@ -36,13 +36,13 @@ public class CloudAccessControl implements AccessControl { private final UserManagement userManagement; private final BooleanFlag enablePublicSignup; - private final PlanController planController; + private final BillingController planController; @Inject public CloudAccessControl(UserManagement userManagement, FlagSource flagSource, ServiceRegistry serviceRegistry) { this.userManagement = userManagement; this.enablePublicSignup = Flags.ENABLE_PUBLIC_SIGNUP_FLOW.bindTo(flagSource); - planController = serviceRegistry.planController(); + planController = serviceRegistry.billingController(); } @Override @@ -109,7 +109,7 @@ public class CloudAccessControl implements AccessControl { } private boolean isTrial(TenantName tenant) { - return planController.getPlan(tenant).id().equals("trial"); + return planController.getPlan(tenant).value().equals("trial"); } @Override 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 b7e7c9814e3..1b21f7db7c4 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 @@ -12,14 +12,14 @@ import com.yahoo.vespa.hosted.controller.api.integration.aws.MockAwsEventFetcher import com.yahoo.vespa.hosted.controller.api.integration.aws.MockResourceTagger; import com.yahoo.vespa.hosted.controller.api.integration.aws.NoopApplicationRoleService; import com.yahoo.vespa.hosted.controller.api.integration.aws.ResourceTagger; -import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController; import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateMock; import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServer; import com.yahoo.vespa.hosted.controller.api.integration.dns.MemoryNameService; import com.yahoo.vespa.hosted.controller.api.integration.entity.MemoryEntityService; import com.yahoo.vespa.hosted.controller.api.integration.organization.MockContactRetriever; import com.yahoo.vespa.hosted.controller.api.integration.organization.MockIssueHandler; -import com.yahoo.vespa.hosted.controller.api.integration.organization.SystemMonitor; import com.yahoo.vespa.hosted.controller.api.integration.resource.CostReportConsumerMock; import com.yahoo.vespa.hosted.controller.api.integration.routing.GlobalRoutingService; import com.yahoo.vespa.hosted.controller.api.integration.routing.MemoryGlobalRoutingService; @@ -60,7 +60,7 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg private final MockRunDataStore mockRunDataStore = new MockRunDataStore(); private final MockResourceTagger mockResourceTagger = new MockResourceTagger(); private final ApplicationRoleService applicationRoleService = new NoopApplicationRoleService(); - private final PlanController planController = (tenantName) -> null; + private final BillingController billingController = new MockBillingController(); public ServiceRegistryMock(SystemName system) { this.zoneRegistryMock = new ZoneRegistryMock(system); @@ -187,6 +187,11 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg return systemMonitor; } + @Override + public BillingController billingController() { + return billingController; + } + public ConfigServerMock configServerMock() { return configServerMock; } @@ -203,9 +208,4 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg return endpointCertificateMock; } - @Override - public PlanController planController() { - return planController; - } - } 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 new file mode 100644 index 00000000000..19cfa95c682 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java @@ -0,0 +1,214 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice; +import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController; +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.restapi.ContainerTester; +import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static com.yahoo.application.container.handler.Request.Method.*; +import static org.junit.Assert.*; + +/** + * @author olaa + */ +public class BillingApiHandlerTest 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> tenantRole = Set.of(Role.administrator(tenant)); + private static final Set<Role> financeAdmin = Set.of(Role.hostedAccountant()); + private MockBillingController billingController; + + private ContainerTester tester; + + @Before + public void setup() { + tester = new ContainerTester(container, responseFiles); + billingController = (MockBillingController) tester.serviceRegistry().billingController(); + } + + @Override + protected SystemName system() { + return SystemName.PublicCd; + } + + @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.BillingApiHandler'>\n" + + " <binding>http://*/billing/v1/*</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 setting_and_deleting_instrument() { + assertTrue(billingController.getDefaultInstrument(tenant).isEmpty()); + + var instrumentRequest = request("/billing/v1/tenant/tenant1/instrument", PATCH) + .data("{\"active\": \"id-1\"}") + .roles(tenantRole); + + tester.assertResponse(instrumentRequest,"OK"); + assertEquals("id-1", billingController.getDefaultInstrument(tenant).get().getId()); + + var deleteInstrumentRequest = request("/billing/v1/tenant/tenant1/instrument/id-1", DELETE) + .roles(tenantRole); + + tester.assertResponse(deleteInstrumentRequest,"OK"); + assertTrue(billingController.getDefaultInstrument(tenant).isEmpty()); + } + + @Test + public void response_list_bills() { + var invoice = createInvoice(); + + billingController.addInvoice(tenant, invoice, true); + billingController.addInvoice(tenant, invoice, false); + billingController.setPlan(tenant, PlanId.from("some-plan"), true); + + var request = request("/billing/v1/tenant/tenant1/billing?until=2020-05-28").roles(tenantRole); + tester.assertResponse(request, new File("tenant-billing-view")); + + } + + @Test + public void test_invoice_creation() { + var invoices = billingController.getInvoices(tenant); + assertEquals(0, invoices.size()); + + String requestBody = "{\"tenant\":\"tenant1\", \"startTime\":\"2020-04-20\", \"endTime\":\"2020-05-20\"}"; + var request = request("/billing/v1/invoice", POST) + .data(requestBody) + .roles(tenantRole); + + tester.assertResponse(request, accessDenied, 403); + request.roles(financeAdmin); + tester.assertResponse(request, new File("invoice-creation-response")); + + invoices = billingController.getInvoices(tenant); + assertEquals(1, invoices.size()); + Invoice invoice = invoices.get(0); + assertEquals(invoice.getStartTime().toString(), "2020-04-20T00:00Z[UTC]"); + assertEquals(invoice.getEndTime().toString(), "2020-05-20T00:00Z[UTC]"); + } + + @Test + public void adding_and_listing_line_item() { + + var requestBody = "{" + + "\"description\":\"some description\"," + + "\"amount\":\"123.45\" " + + "}"; + + var request = request("/billing/v1/invoice/tenant/tenant1/line-item", POST) + .data(requestBody) + .roles(financeAdmin); + + tester.assertResponse(request, "{\"message\":\"Added line item for tenant tenant1\"}"); + + var lineItems = billingController.getUnusedLineItems(tenant); + Assert.assertEquals(1, lineItems.size()); + Invoice.LineItem lineItem = lineItems.get(0); + assertEquals("some description", lineItem.description()); + assertEquals(new BigDecimal("123.45"), lineItem.amount()); + + request = request("/billing/v1/invoice/tenant/tenant1/line-item") + .roles(financeAdmin); + + tester.assertResponse(request, new File("line-item-list")); + } + + @Test + public void adding_new_status() { + billingController.addInvoice(tenant, createInvoice(), true); + + var requestBody = "{\"status\":\"DONE\"}"; + var request = request("/billing/v1/invoice/id-1/status", POST) + .data(requestBody) + .roles(financeAdmin); + tester.assertResponse(request, "{\"message\":\"Updated status of invoice id-1\"}"); + + var invoice = billingController.getInvoices(tenant).get(0); + assertEquals("DONE", invoice.status()); + } + + @Test + public void list_all_uninvoiced_items() { + var invoice = createInvoice(); + billingController.setPlan(tenant, PlanId.from("some-plan"), true); + billingController.setPlan(tenant2, PlanId.from("some-plan"), true); + billingController.addInvoice(tenant, invoice, false); + billingController.addLineItem(tenant, "support", new BigDecimal("42"), "Smith"); + billingController.addInvoice(tenant2, invoice, false); + + + var request = request("/billing/v1/billing?until=2020-05-28").roles(financeAdmin); + + tester.assertResponse(request, new File("billing-all-tenants")); + } + + @Test + public void setting_plans() { + var planRequest = request("/billing/v1/tenant/tenant1/plan", PATCH) + .data("{\"plan\": \"new-plan\"}") + .roles(tenantRole); + tester.assertResponse(planRequest, "Plan: new-plan"); + assertEquals("new-plan", billingController.getPlan(tenant).value()); + } + + private Invoice createInvoice() { + var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneId.systemDefault()); + var end = start.plusDays(5); + var statusHistory = new Invoice.StatusHistory(new TreeMap<>(Map.of(start, "OPEN"))); + return new Invoice( + Invoice.Id.of("id-1"), + statusHistory, + List.of(createLineItem(start)), + start, + end + ); + } + + + private Invoice.LineItem createLineItem(ZonedDateTime addedAt) { + return new Invoice.LineItem( + "some-id", + "description", + new BigDecimal("123.00"), + "plan", + "Smith", + addedAt + ); + } + +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants new file mode 100644 index 00000000000..c5bf0c88c2c --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/billing-all-tenants @@ -0,0 +1,48 @@ +{ + "until":"2020-05-28", + "tenants":[ + { + "tenant":"tenant2", + "plan":"some-plan", + "current":{ + "amount":"123.00", + "status":"accrued", + "from":"2020-05-23", + "items":[ + { + "id":"some-id", + "description":"description", + "amount":"123.00" + } + ] + }, + "additional":{"items":[]} + }, + { + "tenant":"tenant1", + "plan":"some-plan", + "current":{ + "amount":"123.00", + "status":"accrued", + "from":"2020-05-23", + "items":[ + { + "id":"some-id", + "description":"description", + "amount":"123.00" + } + ] + }, + "additional": + { + "items":[ + { + "id":"line-item-id", + "description":"support", + "amount":"42.00" + } + ] + } + } + ] +}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response new file mode 100644 index 00000000000..0a92229025b --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/invoice-creation-response @@ -0,0 +1 @@ +{"message":"Created invoice with ID id-123"}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list new file mode 100644 index 00000000000..cd5aec2f8f4 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/line-item-list @@ -0,0 +1,9 @@ +{ + "lineItems":[ + { + "id":"line-item-id", + "description":"some description", + "amount":"123.45" + } + ] +}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view new file mode 100644 index 00000000000..8bc39771b31 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/tenant-billing-view @@ -0,0 +1,38 @@ +{ + "until":"2020-05-28", + "plan":"some-plan", + "current":{ + "amount":"123.00", + "status":"accrued", + "from":"2020-05-23", + "items":[ + { + "id":"some-id", + "description":"description", + "amount":"123.00" + } + ] + }, + "additional":{"items":[]}, + "bills":[ + { + "id":"id-1", + "from":"2020-05-23", + "to":"2020-05-28","amount":"123.00", + "status":"OPEN", + "statusHistory":[ + { + "at":"2020-05-23", + "status":"OPEN" + } + ], + "items":[ + { + "id":"some-id", + "description":"description", + "amount":"123.00" + } + ] + } + ] +}
\ No newline at end of file |