openmeter / billing /httpdriver /invoice.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 2)
5a22efd verified
Raw
History Blame Contribute Delete
32.9 kB
package httpdriver
import (
"context"
"fmt"
"net/http"
"slices"
"github.com/samber/lo"
"github.com/samber/mo"
"github.com/openmeterio/openmeter/api"
apphttpdriver "github.com/openmeterio/openmeter/openmeter/app/httpdriver"
"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/openmeter/billing/models/externalid"
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
"github.com/openmeterio/openmeter/openmeter/customer"
customerhttpdriver "github.com/openmeterio/openmeter/openmeter/customer/httpdriver"
"github.com/openmeterio/openmeter/pkg/convert"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/filter"
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/pagination"
"github.com/openmeterio/openmeter/pkg/slicesx"
"github.com/openmeterio/openmeter/pkg/sortx"
"github.com/openmeterio/openmeter/pkg/timeutil"
)
var _ InvoiceHandler = (*handler)(nil)
type (
ListInvoicesRequest = billing.ListInvoicesInput
ListInvoicesResponse = api.InvoicePaginatedResponse
ListInvoicesParams = api.ListInvoicesParams
ListInvoicesHandler httptransport.HandlerWithArgs[ListInvoicesRequest, ListInvoicesResponse, ListInvoicesParams]
)
func (h *handler) ListInvoices() ListInvoicesHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, input ListInvoicesParams) (ListInvoicesRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return ListInvoicesRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
req := ListInvoicesRequest{
Namespaces: []string{ns},
Statuses: lo.Map(
lo.FromPtr(input.Statuses),
func(status api.InvoiceStatus, _ int) string {
return string(status)
},
),
ExtendedStatuses: lo.Map(
lo.FromPtr(input.ExtendedStatuses),
func(status string, _ int) billing.StandardInvoiceStatus {
return billing.StandardInvoiceStatus(status)
},
),
IssuedAt: filter.NewFilterTime(input.IssuedAfter, input.IssuedBefore),
PeriodStart: filter.NewFilterTime(input.PeriodStartAfter, input.PeriodStartBefore),
CreatedAt: filter.NewFilterTime(input.CreatedAfter, input.CreatedBefore),
Expand: mapInvoiceExpandsToEntity(lo.FromPtr(input.Expand)).
With(billing.InvoiceExpandCalculateGatheringInvoiceWithLiveData),
Order: sortx.Order(lo.FromPtrOr(input.Order, api.InvoiceOrderByOrderingOrder(sortx.OrderDefault))),
OrderBy: lo.FromPtr(input.OrderBy),
IncludeDeleted: lo.FromPtrOr(input.IncludeDeleted, false),
Page: pagination.Page{
PageSize: lo.FromPtrOr(input.PageSize, DefaultPageSize),
PageNumber: lo.FromPtrOr(input.Page, DefaultPageNumber),
},
}
if customers := lo.FromPtr(input.Customers); len(customers) > 0 {
req.CustomerID = &filter.FilterULID{FilterString: filter.FilterString{In: &customers}}
}
return req, nil
},
func(ctx context.Context, request ListInvoicesRequest) (ListInvoicesResponse, error) {
// Let's mandate properly set page size and page number.
if err := request.Page.Validate(); err != nil {
return ListInvoicesResponse{}, models.NewGenericValidationError(err)
}
invoices, err := h.service.ListInvoices(ctx, request)
if err != nil {
return ListInvoicesResponse{}, err
}
res := ListInvoicesResponse{
Items: make([]api.Invoice, 0, len(invoices.Items)),
Page: invoices.Page.PageNumber,
PageSize: invoices.Page.PageSize,
TotalCount: invoices.TotalCount,
}
for _, invoice := range invoices.Items {
invoice, err := h.MapInvoiceToAPI(ctx, invoice)
if err != nil {
return ListInvoicesResponse{}, err
}
res.Items = append(res.Items, invoice)
}
return res, nil
},
commonhttp.JSONResponseEncoderWithStatus[ListInvoicesResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("ListInvoices"),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
type (
InvoicePendingLinesActionRequest struct {
billing.InvoicePendingLinesInput
Options []billing.InvoicePendingLinesOption
}
InvoicePendingLinesActionResponse = []api.Invoice
InvoicePendingLinesActionHandler httptransport.Handler[InvoicePendingLinesActionRequest, InvoicePendingLinesActionResponse]
)
func (h *handler) InvoicePendingLinesAction() InvoicePendingLinesActionHandler {
return httptransport.NewHandler(
func(ctx context.Context, r *http.Request) (InvoicePendingLinesActionRequest, error) {
body := api.InvoicePendingLinesActionInput{}
if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
return InvoicePendingLinesActionRequest{}, err
}
ns, err := h.resolveNamespace(ctx)
if err != nil {
return InvoicePendingLinesActionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
pendingLinesFilter := mo.None[[]string]()
if body.Filters != nil {
pendingLinesFilter = mo.PointerToOption(body.Filters.LineIds)
}
req := InvoicePendingLinesActionRequest{
InvoicePendingLinesInput: billing.InvoicePendingLinesInput{
Customer: customer.CustomerID{
ID: body.CustomerId,
Namespace: ns,
},
IncludePendingLines: pendingLinesFilter,
AsOf: body.AsOf,
},
}
opts := make([]billing.InvoicePendingLinesOption, 0, 2)
opts = append(opts, billing.WithBypassCollectionAlignment())
if body.ProgressiveBillingOverride != nil {
if *body.ProgressiveBillingOverride {
opts = append(opts, billing.WithPartialInvoiceLinesEnabled())
} else {
opts = append(opts, billing.WithPartialInvoiceLinesDisabled())
}
}
req.Options = opts
return req, nil
},
func(ctx context.Context, request InvoicePendingLinesActionRequest) (InvoicePendingLinesActionResponse, error) {
invoices, err := h.service.InvoicePendingLines(ctx, request.InvoicePendingLinesInput, request.Options...)
if err != nil {
return nil, err
}
out := make([]api.Invoice, 0, len(invoices))
for _, invoice := range invoices {
invoice, err := MapStandardInvoiceToAPI(invoice)
if err != nil {
return nil, err
}
out = append(out, invoice)
}
return out, nil
},
commonhttp.JSONResponseEncoderWithStatus[InvoicePendingLinesActionResponse](http.StatusCreated),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("InvoicePendingLinesAction"),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
type (
GetInvoiceRequest = billing.GetInvoiceByIdInput
GetInvoiceResponse = api.Invoice
GetInvoiceParams struct {
InvoiceID string
Expand []api.InvoiceExpand
IncludeDeletedLines bool
}
GetInvoiceHandler httptransport.HandlerWithArgs[GetInvoiceRequest, GetInvoiceResponse, GetInvoiceParams]
)
func (h *handler) GetInvoice() GetInvoiceHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params GetInvoiceParams) (GetInvoiceRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return GetInvoiceRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
if len(params.Expand) == 0 {
params.Expand = []api.InvoiceExpand{api.InvoiceExpandLines}
}
return GetInvoiceRequest{
Invoice: billing.InvoiceID{
ID: params.InvoiceID,
Namespace: ns,
},
Expand: mapInvoiceExpandsToEntity(params.Expand).
SetOrUnsetIf(params.IncludeDeletedLines, billing.InvoiceExpandDeletedLines).
With(billing.InvoiceExpandCalculateGatheringInvoiceWithLiveData),
}, nil
},
func(ctx context.Context, request GetInvoiceRequest) (GetInvoiceResponse, error) {
invoice, err := h.service.GetInvoiceById(ctx, request)
if err != nil {
return GetInvoiceResponse{}, err
}
return h.MapInvoiceToAPI(ctx, invoice)
},
commonhttp.JSONResponseEncoderWithStatus[GetInvoiceResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("GetInvoice"),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
type ProgressAction string
const (
InvoiceProgressActionApprove ProgressAction = "approve"
InvoiceProgressActionRetry ProgressAction = "retry"
InvoiceProgressActionAdvance ProgressAction = "advance"
InvoiceProgressActionSnapshotQuantities ProgressAction = "snapshot_quantities"
)
var (
InvoiceProgressActions = []ProgressAction{
InvoiceProgressActionApprove,
InvoiceProgressActionRetry,
InvoiceProgressActionAdvance,
InvoiceProgressActionSnapshotQuantities,
}
invoiceProgressOperationNames = map[ProgressAction]string{
InvoiceProgressActionApprove: "ApproveInvoiceAction",
InvoiceProgressActionRetry: "RetryInvoiceAction",
InvoiceProgressActionAdvance: "AdvanceInvoiceAction",
InvoiceProgressActionSnapshotQuantities: "SnapshotQuantitiesAction",
}
)
type (
ProgressInvoiceRequest struct {
Invoice billing.InvoiceID
}
ProgressInvoiceResponse = api.Invoice
ProgressInvoiceParams struct {
InvoiceID string
}
ProgressInvoiceHandler httptransport.HandlerWithArgs[ProgressInvoiceRequest, ProgressInvoiceResponse, ProgressInvoiceParams]
)
func (h *handler) ProgressInvoice(action ProgressAction) ProgressInvoiceHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params ProgressInvoiceParams) (ProgressInvoiceRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return ProgressInvoiceRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
if !slices.Contains(InvoiceProgressActions, action) {
return ProgressInvoiceRequest{}, fmt.Errorf("invalid action: %s", action)
}
return ProgressInvoiceRequest{
Invoice: billing.InvoiceID{
ID: params.InvoiceID,
Namespace: ns,
},
}, nil
},
func(ctx context.Context, request ProgressInvoiceRequest) (ProgressInvoiceResponse, error) {
var invoice billing.StandardInvoice
var err error
switch action {
case InvoiceProgressActionApprove:
invoice, err = h.service.ApproveInvoice(ctx, request.Invoice)
case InvoiceProgressActionRetry:
invoice, err = h.service.RetryInvoice(ctx, request.Invoice)
case InvoiceProgressActionAdvance:
invoice, err = h.service.AdvanceInvoice(ctx, request.Invoice)
case InvoiceProgressActionSnapshotQuantities:
invoice, err = h.service.ForceCollectInvoice(ctx, request.Invoice)
default:
return ProgressInvoiceResponse{}, fmt.Errorf("invalid action: %s", action)
}
if err != nil {
return ProgressInvoiceResponse{}, err
}
return MapStandardInvoiceToAPI(invoice)
},
commonhttp.JSONResponseEncoderWithStatus[ProgressInvoiceResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName(invoiceProgressOperationNames[action]),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
type (
DeleteInvoiceRequest = billing.DeleteInvoiceInput
DeleteInvoiceResponse = struct{}
DeleteInvoiceParams struct {
InvoiceID string
}
DeleteInvoiceHandler httptransport.HandlerWithArgs[DeleteInvoiceRequest, DeleteInvoiceResponse, DeleteInvoiceParams]
)
func (h *handler) DeleteInvoice() DeleteInvoiceHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params DeleteInvoiceParams) (DeleteInvoiceRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return DeleteInvoiceRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
return billing.DeleteInvoiceInput{
Invoice: billing.InvoiceID{
ID: params.InvoiceID,
Namespace: ns,
},
DeletionSource: billing.ChangeSourceAPIRequest,
}, nil
},
func(ctx context.Context, request DeleteInvoiceRequest) (DeleteInvoiceResponse, error) {
invoice, err := h.service.GetInvoiceById(ctx, billing.GetInvoiceByIdInput{
Invoice: request.Invoice,
Expand: billing.InvoiceExpandAll,
})
if err != nil {
return DeleteInvoiceResponse{}, err
}
switch invoice.Type() {
case billing.InvoiceTypeGathering:
if err := billing.ValidateAPIInvoiceDeleteSupported(invoice); err != nil {
return DeleteInvoiceResponse{}, err
}
if _, err := h.service.DeleteGatheringInvoice(ctx, request); err != nil {
return DeleteInvoiceResponse{}, fmt.Errorf("deleting gathering invoice: %w", err)
}
case billing.InvoiceTypeStandard:
if err := h.deleteStandardInvoice(ctx, request); err != nil {
return DeleteInvoiceResponse{}, fmt.Errorf("deleting standard invoice: %w", err)
}
default:
return DeleteInvoiceResponse{}, models.NewNillableGenericValidationError(fmt.Errorf("invalid invoice type: %s", invoice.Type()))
}
return DeleteInvoiceResponse{}, nil
},
commonhttp.EmptyResponseEncoder[DeleteInvoiceResponse](http.StatusNoContent),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("DeleteInvoice"),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
func (h *handler) deleteStandardInvoice(ctx context.Context, request DeleteInvoiceRequest) error {
invoice, err := h.service.DeleteInvoice(ctx, request)
if err != nil {
return err
}
// Given we are doing background processing, we might be in any delete.* state, but in case we ended up in delete.failed let's have
// proper return code for the API (otherwise we would return 200)
if invoice.Status == billing.StandardInvoiceStatusDeleteFailed {
// If we have validation issues we return them as the deletion sync handler
// yields validation errors
if len(invoice.ValidationIssues) > 0 {
return billing.ValidationError{
Err: invoice.ValidationIssues.AsError(),
}
}
return billing.ValidationError{
Err: fmt.Errorf("%w [status=%s]", billing.ErrInvoiceDeleteFailed, invoice.Status),
}
}
return nil
}
type (
SimulateInvoiceRequest = billing.SimulateInvoiceInput
SimulateInvoiceResponse = api.Invoice
SimulateInvoiceParams struct {
CustomerID string
}
SimulateInvoiceHandler httptransport.HandlerWithArgs[SimulateInvoiceRequest, SimulateInvoiceResponse, SimulateInvoiceParams]
)
func (h *handler) SimulateInvoice() SimulateInvoiceHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params SimulateInvoiceParams) (SimulateInvoiceRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return SimulateInvoiceRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
body := api.InvoiceSimulationInput{}
if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
return SimulateInvoiceRequest{}, err
}
lines, err := slicesx.MapWithErr(body.Lines, mapSimulationLineToEntity)
if err != nil {
return SimulateInvoiceRequest{}, billing.ValidationError{
Err: fmt.Errorf("failed to map simulation lines to entity: %w", err),
}
}
return SimulateInvoiceRequest{
Namespace: ns,
CustomerID: &params.CustomerID,
Number: body.Number,
Currency: currencyx.Code(body.Currency),
Lines: billing.NewStandardInvoiceLines(lines),
}, nil
},
func(ctx context.Context, request SimulateInvoiceRequest) (SimulateInvoiceResponse, error) {
invoice, err := h.service.SimulateInvoice(ctx, request)
if err != nil {
return SimulateInvoiceResponse{}, err
}
return MapStandardInvoiceToAPI(invoice)
},
commonhttp.JSONResponseEncoderWithStatus[SimulateInvoiceResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("SimulateInvoice"),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
type (
UpdateInvoiceRequest struct {
InvoiceID billing.InvoiceID
Input api.InvoiceReplaceUpdate
}
UpdateInvoiceResponse = api.Invoice
UpdateInvoiceParams struct {
InvoiceID string
}
UpdateInvoiceHandler httptransport.HandlerWithArgs[UpdateInvoiceRequest, UpdateInvoiceResponse, UpdateInvoiceParams]
)
func (h *handler) UpdateInvoice() UpdateInvoiceHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params UpdateInvoiceParams) (UpdateInvoiceRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return UpdateInvoiceRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
}
body := api.InvoiceReplaceUpdate{}
if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
return UpdateInvoiceRequest{}, err
}
return UpdateInvoiceRequest{
InvoiceID: billing.InvoiceID{
ID: params.InvoiceID,
Namespace: ns,
},
Input: body,
}, nil
},
func(ctx context.Context, request UpdateInvoiceRequest) (UpdateInvoiceResponse, error) {
// This fetch is only used to dispatch by invoice type. We do not need a customer lock here:
// invoice customer IDs are immutable, and the typed update services lock and refetch before editing.
invoice, err := h.service.GetInvoiceById(ctx, billing.GetInvoiceByIdInput{
Invoice: request.InvoiceID,
})
if err != nil {
return UpdateInvoiceResponse{}, err
}
var updatedInvoice billing.Invoice
switch invoice.Type() {
case billing.InvoiceTypeGathering:
updatedInvoice, err = h.updateGatheringInvoice(ctx, invoice, request)
if err != nil {
h.logInvoiceEditRejected(ctx, invoice.Type(), request, err)
return UpdateInvoiceResponse{}, fmt.Errorf("updating gathering invoice: %w", err)
}
case billing.InvoiceTypeStandard:
updatedInvoice, err = h.updateStandardInvoice(ctx, invoice, request)
if err != nil {
h.logInvoiceEditRejected(ctx, invoice.Type(), request, err)
return UpdateInvoiceResponse{}, fmt.Errorf("updating standard invoice: %w", err)
}
default:
return UpdateInvoiceResponse{}, models.NewNillableGenericValidationError(fmt.Errorf("invalid invoice type: %s", invoice.Type()))
}
return h.MapInvoiceToAPI(ctx, updatedInvoice)
},
commonhttp.JSONResponseEncoderWithStatus[UpdateInvoiceResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("UpdateInvoice"),
httptransport.WithErrorEncoder(errorEncoder()),
)...,
)
}
func (h *handler) logInvoiceEditRejected(ctx context.Context, invoiceType billing.InvoiceType, request UpdateInvoiceRequest, err error) {
if h.logger == nil {
return
}
h.logger.WarnContext(ctx, "billing invoice edit rejected",
"namespace", request.InvoiceID.Namespace,
"invoiceID", request.InvoiceID.ID,
"invoiceType", invoiceType,
// Line edits contain billing configuration but no invoice, customer, or supplier PII.
"lines", request.Input.Lines,
"rejection", err.Error(),
)
}
func (h *handler) updateGatheringInvoice(ctx context.Context, invoice billing.Invoice, request UpdateInvoiceRequest) (billing.Invoice, error) {
gatheringInvoice, err := invoice.AsGatheringInvoice()
if err != nil {
return billing.Invoice{}, fmt.Errorf("converting invoice to gathering invoice: %w", err)
}
updatedGatheringInvoice, err := h.service.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
Invoice: gatheringInvoice.GetInvoiceID(),
ChangeSource: billing.ChangeSourceAPIRequest,
EditFn: func(invoice *billing.GatheringInvoice) error {
var err error
invoice.Lines, err = h.mergeGatheringInvoiceLinesFromAPI(ctx, invoice, request.Input.Lines)
if err != nil {
return fmt.Errorf("merging lines: %w", err)
}
return nil
},
})
if err != nil {
return billing.Invoice{}, err
}
return billing.NewInvoice(updatedGatheringInvoice), nil
}
func (h *handler) updateStandardInvoice(ctx context.Context, invoice billing.Invoice, request UpdateInvoiceRequest) (billing.Invoice, error) {
stdInvoice, err := invoice.AsStandardInvoice()
if err != nil {
return billing.Invoice{}, fmt.Errorf("converting invoice to standard invoice: %w", err)
}
updatedStandardInvoice, err := h.service.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
Invoice: stdInvoice.GetInvoiceID(),
ChangeSource: billing.ChangeSourceAPIRequest,
EditFn: func(invoice *billing.StandardInvoice) error {
invoice.Supplier = mergeInvoiceSupplierFromAPI(invoice.Supplier, request.Input.Supplier)
invoice.Customer = mergeInvoiceCustomerFromAPI(invoice.Customer, request.Input.Customer)
var err error
invoice.Workflow, err = mergeInvoiceWorkflowFromAPI(invoice.Workflow, request.Input.Workflow)
if err != nil {
return fmt.Errorf("merging workflow: %w", err)
}
invoice.Lines, err = h.mergeStandardInvoiceLinesFromAPI(ctx, invoice, request.Input.Lines)
if err != nil {
return fmt.Errorf("merging lines: %w", err)
}
// basic fields
invoice.Description = request.Input.Description
invoice.Metadata = lo.FromPtrOr(request.Input.Metadata, map[string]string{})
return nil
},
})
if err != nil {
return billing.Invoice{}, err
}
return billing.NewInvoice(updatedStandardInvoice), nil
}
func (h *handler) MapInvoiceToAPI(ctx context.Context, invoice billing.Invoice) (api.Invoice, error) {
switch invoice.Type() {
case billing.InvoiceTypeStandard:
standardInvoice, err := invoice.AsStandardInvoice()
if err != nil {
return api.Invoice{}, fmt.Errorf("converting invoice to standard invoice: %w", err)
}
return MapStandardInvoiceToAPI(standardInvoice)
case billing.InvoiceTypeGathering:
gatheringInvoice, err := invoice.AsGatheringInvoice()
if err != nil {
return api.Invoice{}, fmt.Errorf("converting invoice to gathering invoice: %w", err)
}
// TODO: For the V3 api let's make sure that we don't return gathering invoice customer data (or even gathering invoices)
mergedProfile, err := h.service.GetCustomerOverride(ctx, billing.GetCustomerOverrideInput{
Customer: gatheringInvoice.GetCustomerID(),
Expand: billing.CustomerOverrideExpand{
Customer: true,
Apps: true,
},
})
if err != nil {
return UpdateInvoiceResponse{}, fmt.Errorf("failed to get customer override: %w", err)
}
return MapGatheringInvoiceToAPI(gatheringInvoice, mergedProfile.Customer, mergedProfile.MergedProfile)
default:
return api.Invoice{}, fmt.Errorf("invalid invoice type: %s", invoice.Type())
}
}
func MapStandardInvoiceToAPI(invoice billing.StandardInvoice) (api.Invoice, error) {
var apps *api.BillingProfileAppsOrReference
var err error
collectionAt := invoice.CollectionAt
// Preserve the historic API shape for standard invoices by emulating collectionAt when the
// domain model intentionally leaves it nil (for example, flat-fee-only invoices).
if collectionAt == nil {
collectionAt = lo.ToPtr(invoice.CreatedAt)
}
// Sort the lines to make the response more consistent (internally we don't care about the order)
invoice.SortLines()
if invoice.Workflow.Apps != nil {
apps, err = mapProfileAppsToAPI(invoice.Workflow.Apps)
if err != nil {
return api.Invoice{}, fmt.Errorf("failed to map profile apps to API: %w", err)
}
} else {
apps, err = mapProfileAppReferencesToAPI(&invoice.Workflow.AppReferences)
if err != nil {
return api.Invoice{}, fmt.Errorf("failed to map profile app references to API: %w", err)
}
}
out := api.Invoice{
Id: invoice.ID,
CreatedAt: invoice.CreatedAt,
UpdatedAt: invoice.UpdatedAt,
DeletedAt: invoice.DeletedAt,
IssuedAt: invoice.IssuedAt,
VoidedAt: invoice.VoidedAt,
DueAt: invoice.DueAt,
CollectionAt: collectionAt,
DraftUntil: invoice.DraftUntil,
SentToCustomerAt: invoice.SentToCustomerAt,
QuantitySnapshotedAt: invoice.QuantitySnapshotedAt,
Period: mapPeriodToAPI(invoice.Period),
Currency: string(invoice.Currency),
Customer: mapInvoiceCustomerToAPI(invoice.Customer),
Number: invoice.Number,
Description: invoice.Description,
Metadata: convert.MapToPointer(invoice.Metadata),
Status: api.InvoiceStatus(invoice.Status.ShortStatus()),
StatusDetails: api.InvoiceStatusDetails{
Failed: invoice.StatusDetails.Failed,
Immutable: invoice.StatusDetails.Immutable,
ExtendedStatus: string(invoice.Status),
AvailableActions: mapInvoiceAvailableActionsToAPI(invoice.StatusDetails.AvailableActions),
},
Supplier: mapSupplierContactToAPI(invoice.Supplier),
Totals: mapTotalsToAPI(invoice.Totals),
// TODO[OM-943]: Implement
Payment: nil,
Type: api.InvoiceType(invoice.Type),
ValidationIssues: convert.SliceToPointer(
lo.Map(invoice.ValidationIssues, func(v billing.ValidationIssue, _ int) api.ValidationIssue {
return api.ValidationIssue{
Id: v.ID,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
DeletedAt: v.DeletedAt,
Severity: api.ValidationIssueSeverity(v.Severity),
Message: v.Message,
Code: lo.EmptyableToPtr(v.Code),
Component: string(v.Component),
Field: lo.EmptyableToPtr(v.Path),
}
})),
ExternalIds: mapInvoiceAppExternalIdsToAPI(invoice.ExternalIDs),
}
workflowConfig, err := mapWorkflowConfigSettingsToAPI(invoice.Workflow.Config)
if err != nil {
return api.Invoice{}, fmt.Errorf("failed to map workflow config to API: %w", err)
}
out.Workflow = api.InvoiceWorkflowSettings{
Apps: apps,
SourceBillingProfileId: invoice.Workflow.SourceBillingProfileID,
Workflow: workflowConfig,
}
outLines, err := slicesx.MapWithErr(invoice.Lines.OrEmpty(), func(line *billing.StandardLine) (api.InvoiceLine, error) {
mappedLine, err := mapInvoiceLineToAPI(line)
if err != nil {
return api.InvoiceLine{}, fmt.Errorf("failed to map billing line[%s] to API: %w", line.ID, err)
}
return mappedLine, nil
})
if err != nil {
return api.Invoice{}, err
}
if len(outLines) > 0 {
out.Lines = &outLines
}
return out, nil
}
func mapInvoiceAppExternalIdsToAPI(externalIds externalid.InvoiceExternalIDs) *api.InvoiceAppExternalIds {
if lo.IsEmpty(externalIds) {
return nil
}
return &api.InvoiceAppExternalIds{
Invoicing: lo.EmptyableToPtr(externalIds.Invoicing),
Payment: lo.EmptyableToPtr(externalIds.Payment),
}
}
func MapEventInvoiceToAPI(event billing.EventStandardInvoice) (api.Invoice, error) {
// Prefer the apps from the event
event.Invoice.Workflow.Apps = nil
invoice, err := MapStandardInvoiceToAPI(event.Invoice)
if err != nil {
return api.Invoice{}, err
}
// Let's map the apps, if there are no apps in the event, we will skip generating the profile apps
apps := api.BillingProfileApps{}
if event.Apps.Invoicing.Type != "" {
apps.Invoicing, err = apphttpdriver.MapEventAppToAPI(event.Apps.Invoicing)
if err != nil {
return api.Invoice{}, err
}
}
if event.Apps.Payment.Type != "" {
apps.Payment, err = apphttpdriver.MapEventAppToAPI(event.Apps.Payment)
if err != nil {
return api.Invoice{}, err
}
}
if event.Apps.Tax.Type != "" {
apps.Tax, err = apphttpdriver.MapEventAppToAPI(event.Apps.Tax)
if err != nil {
return api.Invoice{}, err
}
}
invoice.Workflow.Apps = &api.BillingProfileAppsOrReference{}
if err := invoice.Workflow.Apps.FromBillingProfileApps(apps); err != nil {
return api.Invoice{}, err
}
return invoice, nil
}
func mapPeriodToAPI(p *timeutil.ClosedPeriod) *api.Period {
if p == nil {
return nil
}
// TODO[later]: let's use a common model for this
return &api.Period{
From: p.From,
To: p.To,
}
}
func mapInvoiceCustomerToAPI(c billing.InvoiceCustomer) api.BillingInvoiceCustomerExtendedDetails {
a := c.BillingAddress
out := api.BillingInvoiceCustomerExtendedDetails{
Id: lo.ToPtr(c.CustomerID),
Key: c.Key,
Name: lo.EmptyableToPtr(c.Name),
}
if c.UsageAttribution != nil {
out.UsageAttribution = api.CustomerUsageAttribution{
SubjectKeys: c.UsageAttribution.SubjectKeys,
}
}
if a != nil && !lo.IsEmpty(*a) {
out.Addresses = lo.ToPtr([]api.Address{
{
Country: (*string)(a.Country),
PostalCode: a.PostalCode,
State: a.State,
City: a.City,
Line1: a.Line1,
Line2: a.Line2,
PhoneNumber: a.PhoneNumber,
},
})
}
return out
}
func mapInvoiceExpandsToEntity(expand []api.InvoiceExpand) billing.InvoiceExpands {
if len(expand) == 0 {
return nil
}
return billing.InvoiceExpands{}.
SetOrUnsetIf(slices.Contains(expand, api.InvoiceExpandLines), billing.InvoiceExpandLines)
}
func mapTotalsToAPI(t totals.Totals) api.InvoiceTotals {
return api.InvoiceTotals{
Amount: t.Amount.String(),
ChargesTotal: t.ChargesTotal.String(),
CreditsTotal: t.CreditsTotal.String(),
DiscountsTotal: t.DiscountsTotal.String(),
TaxesInclusiveTotal: t.TaxesInclusiveTotal.String(),
TaxesExclusiveTotal: t.TaxesExclusiveTotal.String(),
TaxesTotal: t.TaxesTotal.String(),
Total: t.Total.String(),
}
}
func mapInvoiceAvailableActionsToAPI(actions billing.StandardInvoiceAvailableActions) api.InvoiceAvailableActions {
return api.InvoiceAvailableActions{
Advance: mapInvoiceAvailableActionDetailsToAPI(actions.Advance),
Approve: mapInvoiceAvailableActionDetailsToAPI(actions.Approve),
Delete: mapInvoiceAvailableActionDetailsToAPI(actions.Delete),
Retry: mapInvoiceAvailableActionDetailsToAPI(actions.Retry),
Void: mapInvoiceAvailableActionDetailsToAPI(actions.Void),
SnapshotQuantities: mapInvoiceAvailableActionDetailsToAPI(actions.SnapshotQuantities),
Invoice: lo.If(actions.Invoice != nil, &api.InvoiceAvailableActionInvoiceDetails{}).Else(nil),
}
}
func mapInvoiceAvailableActionDetailsToAPI(actions *billing.StandardInvoiceAvailableActionDetails) *api.InvoiceAvailableActionDetails {
if actions == nil {
return nil
}
return &api.InvoiceAvailableActionDetails{
ResultingState: string(actions.ResultingState),
}
}
func mergeInvoiceSupplierFromAPI(existing billing.SupplierContact, updatedSupplier api.BillingPartyReplaceUpdate) billing.SupplierContact {
existing.Name = lo.FromPtr(updatedSupplier.Name)
if updatedSupplier.Addresses == nil || len(*updatedSupplier.Addresses) == 0 {
existing.Address = models.Address{}
} else {
mappedAddress := customerhttpdriver.MapAddress(&(*updatedSupplier.Addresses)[0])
existing.Address = *mappedAddress
}
if updatedSupplier.TaxId != nil {
existing.TaxCode = updatedSupplier.TaxId.Code
} else {
existing.TaxCode = nil
}
return existing
}
func mergeInvoiceCustomerFromAPI(existing billing.InvoiceCustomer, updatedCustomer api.BillingPartyReplaceUpdate) billing.InvoiceCustomer {
existing.Name = lo.FromPtr(updatedCustomer.Name)
if updatedCustomer.Addresses == nil || len(*updatedCustomer.Addresses) == 0 {
existing.BillingAddress = nil
} else {
mappedAddress := customerhttpdriver.MapAddress(&(*updatedCustomer.Addresses)[0])
existing.BillingAddress = mappedAddress
}
return existing
}
func mergeInvoiceWorkflowFromAPI(existing billing.InvoiceWorkflow, updatedWorkflow api.InvoiceWorkflowReplaceUpdate) (billing.InvoiceWorkflow, error) {
existing.Config.Invoicing.AutoAdvance = lo.FromPtrOr(
updatedWorkflow.Workflow.Invoicing.AutoAdvance,
billing.DefaultWorkflowConfig.Invoicing.AutoAdvance)
if updatedWorkflow.Workflow.Invoicing.DraftPeriod == nil {
existing.Config.Invoicing.DraftPeriod = billing.DefaultWorkflowConfig.Invoicing.DraftPeriod
} else {
period, err := datetime.ISODurationString(*updatedWorkflow.Workflow.Invoicing.DraftPeriod).Parse()
if err != nil {
return existing, billing.ValidationError{
Err: fmt.Errorf("failed to parse draft period: %w", err),
}
}
existing.Config.Invoicing.DraftPeriod = period
}
if updatedWorkflow.Workflow.Invoicing.DueAfter == nil {
existing.Config.Invoicing.DueAfter = billing.DefaultWorkflowConfig.Invoicing.DueAfter
} else {
period, err := datetime.ISODurationString(*updatedWorkflow.Workflow.Invoicing.DueAfter).Parse()
if err != nil {
return existing, billing.ValidationError{
Err: fmt.Errorf("failed to parse due after: %w", err),
}
}
existing.Config.Invoicing.DueAfter = period
}
if updatedWorkflow.Workflow.Payment.CollectionMethod != nil {
existing.Config.Payment.CollectionMethod = billing.CollectionMethod(*updatedWorkflow.Workflow.Payment.CollectionMethod)
} else {
existing.Config.Payment.CollectionMethod = billing.DefaultWorkflowConfig.Payment.CollectionMethod
}
return existing, nil
}