diff options
23 files changed, 1389 insertions, 120 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java index 0b5f2538892..a522e26a46d 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java @@ -4,7 +4,7 @@ package com.yahoo.vespa.hosted.controller.api.integration; import com.yahoo.vespa.hosted.controller.api.integration.aws.ApplicationRoleService; import com.yahoo.vespa.hosted.controller.api.integration.aws.AwsEventFetcher; 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.certificates.EndpointCertificateProvider; import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServer; import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationStore; @@ -77,6 +77,6 @@ public interface ServiceRegistry { SystemMonitor systemMonitor(); - PlanController planController(); + BillingController billingController(); } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java new file mode 100644 index 00000000000..bd9568fe891 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java @@ -0,0 +1,49 @@ +// 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.api.integration.billing; + +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface BillingController { + + PlanId getPlan(TenantName tenant); + + /** + * Returns true if plan was changed + */ + boolean setPlan(TenantName tenant, PlanId planId, boolean hasApplications); + + Invoice.Id createInvoiceForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent); + + Invoice createUncommittedInvoice(TenantName tenant, LocalDate until); + + Map<TenantName, Invoice> createUncommittedInvoices(LocalDate until); + + List<Invoice.LineItem> getUnusedLineItems(TenantName tenant); + + Optional<PaymentInstrument> getDefaultInstrument(TenantName tenant); + + String createClientToken(String tenant, String userId); + + boolean deleteInstrument(TenantName tenant, String userId, String instrumentId); + + void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status); + + void addLineItem(TenantName tenant, String description, BigDecimal amount, String agent); + + void deleteLineItem(String lineItemId); + + boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument); + + InstrumentList listInstruments(TenantName tenant, String userId); + + List<Invoice> getInvoices(TenantName tenant); + +}
\ No newline at end of file diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java deleted file mode 100644 index 628beec8450..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/CostCalculator.java +++ /dev/null @@ -1,19 +0,0 @@ -// 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.api.integration.billing; - -import com.yahoo.config.provision.NodeResources; -import com.yahoo.vespa.hosted.controller.api.integration.resource.CostInfo; - - -/** - * @author ogronnesby - */ -public interface CostCalculator { - - /** Calculate the cost for the given usage */ - CostInfo calculate(ResourceUsage usage); - - /** Estimate the cost for the given resources */ - double calculate(NodeResources resources); - -} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java new file mode 100644 index 00000000000..f26261cd157 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentList.java @@ -0,0 +1,39 @@ +// 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.api.integration.billing; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author olaa + */ +public class InstrumentList { + + private String activeInstrumentId; + private List<PaymentInstrument> instruments; + + + public InstrumentList(List<PaymentInstrument> instruments) { + this.instruments = instruments; + } + + public void setActiveInstrumentId(String activeInstrumentId) { + this.activeInstrumentId = activeInstrumentId; + } + + public void addInstrument(PaymentInstrument instrument) { + instruments.add(instrument); + } + + public void addInstruments(List<PaymentInstrument> instruments) { + instruments.addAll(instruments); + } + + public String getActiveInstrumentId() { + return activeInstrumentId; + } + + public List<PaymentInstrument> getInstruments() { + return instruments; + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java new file mode 100644 index 00000000000..45e06b11b2a --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/InstrumentOwner.java @@ -0,0 +1,67 @@ +// 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.api.integration.billing; + +import com.yahoo.config.provision.TenantName; + +import java.util.Objects; + +/** + * @author olaa + */ +public class InstrumentOwner { + + private final TenantName tenantName; + private final String userId; + private final String paymentInstrumentId; + private final boolean isDefault; + + public InstrumentOwner(TenantName tenantName, String userId, String paymentInstrumentId, boolean isDefault) { + this.tenantName = tenantName; + this.userId = userId; + this.paymentInstrumentId = paymentInstrumentId; + this.isDefault = isDefault; + } + + public TenantName getTenantName() { + return tenantName; + } + + public String getUserId() { + return userId; + } + + public String getPaymentInstrumentId() { + return paymentInstrumentId; + } + + public boolean isDefault() { + return isDefault; + } + + @Override + public String toString() { + return String.format( + "Tenant: %s\nCusomer ID: %s\nPayment Instrument ID: %s\nIs default: %s", + tenantName.value(), + userId, + paymentInstrumentId, + isDefault + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InstrumentOwner other = (InstrumentOwner) o; + return this.tenantName.equals(other.getTenantName()) && + this.userId.equals(other.getUserId()) && + this.paymentInstrumentId.equals(other.getPaymentInstrumentId()) && + this.isDefault() == other.isDefault(); + } + + @Override + public int hashCode() { + return Objects.hash(tenantName, userId, paymentInstrumentId, isDefault); + } +} 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 new file mode 100644 index 00000000000..31388d24e2e --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java @@ -0,0 +1,266 @@ +// 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.api.integration.billing; + +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.zone.ZoneId; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.UUID; + + +/** + * An Invoice is an identifier with a status (with history) and line items. A line item is the meat and + * potatoes of the content of the invoice, and are a history of items. Most line items are connected to + * a given deployment in Vespa Cloud, but they can also be manually added to e.g. give a discount or represent + * support. + * <p> + * All line items have a Plan associated with them - which was used to map from utilization to an actual price. + * <p> + * The invoice has a status history, but only the latest status is exposed through this API. + * + * @author ogronnesby + */ +public class Invoice { + private static final BigDecimal SCALED_ZERO = new BigDecimal("0.00"); + + private final Id id; + private final List<LineItem> lineItems; + private final StatusHistory statusHistory; + private final ZonedDateTime startTime; + private final ZonedDateTime endTime; + + public Invoice(Id id, StatusHistory statusHistory, List<LineItem> lineItems, ZonedDateTime startTime, ZonedDateTime endTime) { + this.id = id; + this.lineItems = List.copyOf(lineItems); + this.statusHistory = statusHistory; + this.startTime = startTime; + this.endTime = endTime; + } + + public Id id() { + return id; + } + + public String status() { + return statusHistory.current(); + } + + public StatusHistory statusHistory() { + return statusHistory; + } + + public List<LineItem> lineItems() { + return lineItems; + } + + public ZonedDateTime getStartTime() { + return startTime; + } + + public ZonedDateTime getEndTime() { + return endTime; + } + + public BigDecimal sum() { + return lineItems.stream().map(LineItem::amount).reduce(SCALED_ZERO, BigDecimal::add); + } + + public static final class Id { + private final String value; + + public static Id of(String value) { + Objects.requireNonNull(value); + return new Id(value); + } + + public static Id generate() { + var id = UUID.randomUUID().toString(); + return new Id(id); + } + + private Id(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Id invoiceId = (Id) o; + return value.equals(invoiceId.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "InvoiceId{" + + "value='" + value + '\'' + + '}'; + } + } + + /** + * Represents a chargeable line on an invoice. + */ + public static class LineItem { + private final String id; + private final String description; + private final BigDecimal amount; + private final String plan; + private final String agent; + private final ZonedDateTime addedAt; + private final Optional<ZonedDateTime> startedAt; + private final Optional<ZonedDateTime> endedAt; + private final Optional<ApplicationId> applicationId; + private final Optional<ZoneId> zoneId; + + public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt, ZonedDateTime startedAt, ZonedDateTime endedAt, ApplicationId applicationId, ZoneId zoneId) { + this.id = id; + this.description = description; + this.amount = amount; + this.plan = plan; + this.agent = agent; + this.addedAt = addedAt; + this.startedAt = Optional.ofNullable(startedAt); + this.endedAt = Optional.ofNullable(endedAt); + + if (applicationId == null && zoneId != null) + throw new IllegalArgumentException("Must supply applicationId if zoneId is supplied"); + + this.applicationId = Optional.ofNullable(applicationId); + this.zoneId = Optional.ofNullable(zoneId); + } + + public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt) { + this(id, description, amount, plan, agent, addedAt, null, null, null, null); + } + + /** The opaque ID of this */ + public String id() { + return id; + } + + /** The string description of this - used for display purposes */ + public String description() { + return description; + } + + /** The dollar amount of this */ + public BigDecimal amount() { + return SCALED_ZERO.add(amount); + } + + /** The plan used to calculate amount of this */ + public String plan() { + return plan; + } + + /** Who created this line item */ + public String agent() { + return agent; + } + + /** When was this line item added */ + public ZonedDateTime addedAt() { + return addedAt; + } + + /** What time period is this line item for - time start */ + public Optional<ZonedDateTime> startedAt() { + return startedAt; + } + + /** What time period is this line item for - time end */ + public Optional<ZonedDateTime> endedAt() { + return endedAt; + } + + /** Optionally - what application is this line item about */ + public Optional<ApplicationId> applicationId() { + return applicationId; + } + + /** Optionally - what zone deployment is this line item about */ + public Optional<ZoneId> zoneId() { + return zoneId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LineItem lineItem = (LineItem) o; + return id.equals(lineItem.id) && + description.equals(lineItem.description) && + amount.equals(lineItem.amount) && + plan.equals(lineItem.plan) && + agent.equals(lineItem.agent) && + addedAt.equals(lineItem.addedAt) && + startedAt.equals(lineItem.startedAt) && + endedAt.equals(lineItem.endedAt) && + applicationId.equals(lineItem.applicationId) && + zoneId.equals(lineItem.zoneId); + } + + @Override + public int hashCode() { + return Objects.hash(id, description, amount, plan, agent, addedAt, startedAt, endedAt, applicationId, zoneId); + } + + @Override + public String toString() { + return "LineItem{" + + "id='" + id + '\'' + + ", description='" + description + '\'' + + ", amount=" + amount + + ", plan='" + plan + '\'' + + ", agent='" + agent + '\'' + + ", addedAt=" + addedAt + + ", startedAt=" + startedAt + + ", endedAt=" + endedAt + + ", applicationId=" + applicationId + + ", zoneId=" + zoneId + + '}'; + } + } + + public static class StatusHistory { + SortedMap<ZonedDateTime, String> history; + + public StatusHistory(SortedMap<ZonedDateTime, String> history) { + this.history = history; + } + + public static StatusHistory open() { + return new StatusHistory( + new TreeMap<>(Map.of(ZonedDateTime.now(), "OPEN")) + ); + } + + public String current() { + return history.get(history.lastKey()); + } + + public SortedMap<ZonedDateTime, String> getHistory() { + return history; + } + + } + +} 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 new file mode 100644 index 00000000000..a4c25e301ba --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java @@ -0,0 +1,144 @@ +// 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.api.integration.billing; + +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * @author olaa + */ +public class MockBillingController implements BillingController { + + Map<TenantName, PlanId> plans = new HashMap<>(); + Map<TenantName, PaymentInstrument> activeInstruments = new HashMap<>(); + Map<TenantName, List<Invoice>> committedInvoices = new HashMap<>(); + Map<TenantName, Invoice> uncommittedInvoices = new HashMap<>(); + Map<TenantName, List<Invoice.LineItem>> unusedLineItems = new HashMap<>(); + + @Override + public PlanId getPlan(TenantName tenant) { + return plans.get(tenant); + } + + @Override + public boolean setPlan(TenantName tenant, PlanId planId, boolean hasApplications) { + plans.put(tenant, planId); + return true; + } + + @Override + public Invoice.Id createInvoiceForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent) { + var invoiceId = Invoice.Id.of("id-123"); + committedInvoices.computeIfAbsent(tenant, l -> new ArrayList<>()) + .add(new Invoice( + invoiceId, + Invoice.StatusHistory.open(), + List.of(), + startTime, + endTime + )); + return invoiceId; + } + + @Override + public Invoice createUncommittedInvoice(TenantName tenant, LocalDate until) { + return uncommittedInvoices.get(tenant); + } + + @Override + public Map<TenantName, Invoice> createUncommittedInvoices(LocalDate until) { + return uncommittedInvoices; + } + + @Override + public List<Invoice.LineItem> getUnusedLineItems(TenantName tenant) { + return unusedLineItems.getOrDefault(tenant, List.of()); + } + + @Override + public Optional<PaymentInstrument> getDefaultInstrument(TenantName tenant) { + return Optional.ofNullable(activeInstruments.get(tenant)); + } + + @Override + public String createClientToken(String tenant, String userId) { + return "some-token"; + } + + @Override + public boolean deleteInstrument(TenantName tenant, String userId, String instrumentId) { + activeInstruments.remove(tenant); + return true; + } + + @Override + public void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status) { + committedInvoices.values().stream() + .flatMap(List::stream) + .filter(invoice -> invoiceId.equals(invoice.id())) + .forEach(invoice -> invoice.statusHistory().history.put(ZonedDateTime.now(), status)); + } + + @Override + public void addLineItem(TenantName tenant, String description, BigDecimal amount, String agent) { + unusedLineItems.computeIfAbsent(tenant, l -> new ArrayList<>()) + .add(new Invoice.LineItem( + "line-item-id", + description, + amount, + "some-plan", + agent, + ZonedDateTime.now() + )); + } + + @Override + public void deleteLineItem(String lineItemId) { + + } + + @Override + public boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument) { + var instrumentId = paymentInstrument.getPaymentInstrumentId(); + activeInstruments.put(paymentInstrument.getTenantName(), createInstrument(instrumentId)); + return true; + } + + @Override + public InstrumentList listInstruments(TenantName tenant, String userId) { + return null; + } + + @Override + public List<Invoice> getInvoices(TenantName tenant) { + return committedInvoices.getOrDefault(tenant, List.of()); + } + + private PaymentInstrument createInstrument(String id) { + return new PaymentInstrument(id, + "name", + "displayText", + "brand", + "type", + "endingWith", + "expiryDate" + ); + } + + public void addInvoice(TenantName tenantName, Invoice invoice, boolean committed) { + if (committed) + committedInvoices.computeIfAbsent(tenantName, i -> new ArrayList<>()) + .add(invoice); + else + uncommittedInvoices.put(tenantName, invoice); + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java new file mode 100644 index 00000000000..7b8d36f3d4f --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PaymentInstrument.java @@ -0,0 +1,51 @@ +// 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.api.integration.billing; + +/** + * @author olaa + */ +public class PaymentInstrument { + + private final String id; + private final String nameOnCard; + private final String displayText; + private final String brand; + private final String type; + private final String endingWith; + private final String expiryDate; + + + public PaymentInstrument(String id, String nameOnCard, String displayText, String brand, String type, String endingWith, String expiryDate) { + this.id = id; + this.nameOnCard = nameOnCard; + this.displayText = displayText; + this.brand = brand; + this.type = type; + this.endingWith = endingWith; + this.expiryDate = expiryDate; + } + + public String getId() { + return id; + } + + public String getNameOnCard() { + return nameOnCard; + } + + public String getDisplayText() { + return displayText; + } + + public String getBrand() { + return brand; + } + + public String getType() { + return type; + } + + public String getEndingWith() { + return endingWith; + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java deleted file mode 100644 index 75a88136c45..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Plan.java +++ /dev/null @@ -1,23 +0,0 @@ -// 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.api.integration.billing; - -/** - * A Plan decides two different things: - * - * - How to map from usage to a sum of money that is owed. - * - Limits on how much resources can be used. - * - * @author ogronnesby - */ -public interface Plan { - - /** The ID of the plan as used in APIs and storage systems */ - String id(); - - /** The calculator used to calculate a bill for usage */ - CostCalculator calculator(); - - /** The quota limits associated with the plan */ - Object quota(); - -} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java deleted file mode 100644 index f13c251d212..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanController.java +++ /dev/null @@ -1,10 +0,0 @@ -// 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.api.integration.billing; - -import com.yahoo.config.provision.TenantName; - -public interface PlanController { - - Plan getPlan(TenantName tenant); - -}
\ No newline at end of file diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java new file mode 100644 index 00000000000..68a897c904f --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanId.java @@ -0,0 +1,43 @@ +// 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.api.integration.billing; + +import java.util.Objects; + +/** + * @author olaa + */ +public class PlanId { + + private final String value; + + public PlanId(String value) { + if (value.isBlank()) + throw new IllegalArgumentException("Id must be non-blank."); + this.value = value; + } + + public static PlanId from(String value) { + return new PlanId(value); + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PlanId id = (PlanId) o; + return Objects.equals(value, id.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "plan '" + value + "'"; + } + +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java deleted file mode 100644 index cbfd2b6ff50..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/ResourceUsage.java +++ /dev/null @@ -1,54 +0,0 @@ -// 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.api.integration.billing; - -import com.yahoo.config.provision.ApplicationId; -import com.yahoo.config.provision.zone.ZoneId; - -import java.math.BigDecimal; - -/** - * @author olaa - */ -public class ResourceUsage { - - private final ApplicationId applicationId; - private final ZoneId zoneId; - private final Plan plan; - private final BigDecimal cpuMillis; - private final BigDecimal memoryMillis; - private final BigDecimal diskMillis; - - public ResourceUsage(ApplicationId applicationId, ZoneId zoneId, Plan plan, - BigDecimal cpuMillis, BigDecimal memoryMillis, BigDecimal diskMillis) { - this.applicationId = applicationId; - this.zoneId = zoneId; - this.cpuMillis = cpuMillis; - this.memoryMillis = memoryMillis; - this.diskMillis = diskMillis; - this.plan = plan; - } - - public ApplicationId getApplicationId() { - return applicationId; - } - - public ZoneId getZoneId() { - return zoneId; - } - - public BigDecimal getCpuMillis() { - return cpuMillis; - } - - public BigDecimal getMemoryMillis() { - return memoryMillis; - } - - public BigDecimal getDiskMillis() { - return diskMillis; - } - - public Plan getPlan() { - return plan; - } -} 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 2fdf442dbe0..aaddd3811bc 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 @@ -72,6 +72,10 @@ enum PathGroup { PathPrefix.api, "/billing/v1/tenant/{tenant}/instrument/{*}"), + billingPlan(Matcher.tenant, + PathPrefix.api, + "/billing/v1/tenant/{tenant}/plan/{*}"), + billingList(Matcher.tenant, PathPrefix.api, "/billing/v1/tenant/{tenant}/billing/{*}"), 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 83adba6f59b..548ad0af484 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 @@ -158,6 +158,11 @@ enum Policy { .on(PathGroup.billingToken) .in(SystemName.PublicCd)), + /** Ability to update tenant payment instrument */ + planUpdate(Privilege.grant(Action.update) + .on(PathGroup.billingPlan) + .in(SystemName.PublicCd)), + /** Read the generated bills */ billingInformationRead(Privilege.grant(Action.read) .on(PathGroup.billingList) diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java index b9d534019db..801661f454e 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java @@ -67,6 +67,7 @@ public enum RoleDefinition { Policy.paymentInstrumentUpdate, Policy.paymentInstrumentDelete, Policy.paymentInstrumentCreate, + Policy.planUpdate, Policy.billingInformationRead), /** Headless — the application specific role identified by deployment keys for production */ 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 |