Skip to content

Commit

Permalink
Merge pull request #182 from appuio/feat/sales-order-creation
Browse files Browse the repository at this point in the history
Reconcile organizations to create sales orders where needed
HappyTetrahedron authored Dec 7, 2023
2 parents 8e0ae6d + 25af579 commit 5c79acf
Showing 11 changed files with 859 additions and 5 deletions.
59 changes: 58 additions & 1 deletion apis/organization/v1/organization_types.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
package v1

import (
"encoding/json"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/apiserver-runtime/pkg/builder/resource"
)

const (
// SaleOrderCreated is set when the Sale Order has been created
ConditionSaleOrderCreated = "SaleOrderCreated"

// SaleOrderNameUpdated is set when the Sale Order's name has been added to the Status
ConditionSaleOrderNameUpdated = "SaleOrderNameUpdated"

ConditionReasonCreateFailed = "CreateFailed"

ConditionReasonGetNameFailed = "GetNameFailed"
)

// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;delete;update

var (
@@ -21,6 +35,12 @@ var (
BillingEntityRefKey = "organization.appuio.io/billing-entity-ref"
// BillingEntityNameKey is the annotation key that stores the billing entity name
BillingEntityNameKey = "status.organization.appuio.io/billing-entity-name"
// SaleOrderIdKey is the annotation key that stores the sale order ID
SaleOrderIdKey = "status.organization.appuio.io/sale-order-id"
// SaleOrderNameKey is the annotation key that stores the sale order name
SaleOrderNameKey = "status.organization.appuio.io/sale-order-name"
// StatusConditionsKey is the annotation key that stores the serialized status conditions
StatusConditionsKey = "status.organization.appuio.io/conditions"
)

// NewOrganizationFromNS returns an Organization based on the given namespace
@@ -29,11 +49,19 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization {
if ns == nil || ns.Labels == nil || ns.Labels[TypeKey] != OrgType {
return nil
}
var displayName, billingEntityRef, billingEntityName string
var displayName, billingEntityRef, billingEntityName, saleOrderId, saleOrderName, statusConditionsString string
if ns.Annotations != nil {
displayName = ns.Annotations[DisplayNameKey]
billingEntityRef = ns.Annotations[BillingEntityRefKey]
billingEntityName = ns.Annotations[BillingEntityNameKey]
statusConditionsString = ns.Annotations[StatusConditionsKey]
saleOrderId = ns.Annotations[SaleOrderIdKey]
saleOrderName = ns.Annotations[SaleOrderNameKey]
}
var conditions []metav1.Condition
err := json.Unmarshal([]byte(statusConditionsString), &conditions)
if err != nil {
conditions = nil
}
org := &Organization{
ObjectMeta: *ns.ObjectMeta.DeepCopy(),
@@ -43,6 +71,9 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization {
},
Status: OrganizationStatus{
BillingEntityName: billingEntityName,
SaleOrderID: saleOrderId,
SaleOrderName: saleOrderName,
Conditions: conditions,
},
}
if org.Annotations != nil {
@@ -79,6 +110,15 @@ type OrganizationSpec struct {
type OrganizationStatus struct {
// BillingEntityName is the name of the billing entity
BillingEntityName string `json:"billingEntityName,omitempty"`

// SaleOrderID is the ID of the sale order
SaleOrderID string `json:"saleOrderId,omitempty"`

// SaleOrderName is the name of the sale order
SaleOrderName string `json:"saleOrderName,omitempty"`

// Conditions is a list of conditions for the invitation
Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// Organization needs to implement the builder resource interface
@@ -149,10 +189,27 @@ func (o *Organization) ToNamespace() *corev1.Namespace {
if ns.Annotations == nil {
ns.Annotations = map[string]string{}
}
var statusString string
if o.Status.Conditions != nil {
statusBytes, err := json.Marshal(o.Status.Conditions)
if err == nil {
statusString = string(statusBytes)
}
}

ns.Labels[TypeKey] = OrgType
ns.Annotations[DisplayNameKey] = o.Spec.DisplayName
ns.Annotations[BillingEntityRefKey] = o.Spec.BillingEntityRef
ns.Annotations[BillingEntityNameKey] = o.Status.BillingEntityName
if o.Status.SaleOrderID != "" {
ns.Annotations[SaleOrderIdKey] = o.Status.SaleOrderID
}
if o.Status.SaleOrderName != "" {
ns.Annotations[SaleOrderNameKey] = o.Status.SaleOrderName
}
if statusString != "" {
ns.Annotations[StatusConditionsKey] = statusString
}
return ns
}

10 changes: 9 additions & 1 deletion apis/organization/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions config/rbac/controller/role.yaml
Original file line number Diff line number Diff line change
@@ -226,3 +226,19 @@ rules:
- get
- patch
- update
- apiGroups:
- user.appuio.io
resources:
- organizations
verbs:
- get
- list
- watch
- apiGroups:
- user.appuio.io
resources:
- organizations/status
verbs:
- get
- patch
- update
37 changes: 37 additions & 0 deletions controller.go
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ import (
orgv1 "github.com/appuio/control-api/apis/organization/v1"
userv1 "github.com/appuio/control-api/apis/user/v1"
controlv1 "github.com/appuio/control-api/apis/v1"
"github.com/appuio/control-api/controllers/saleorder"
"github.com/appuio/control-api/mailsenders"

"github.com/appuio/control-api/controllers"
@@ -66,6 +67,7 @@ func ControllerCommand() *cobra.Command {

zapfs := flag.NewFlagSet("zap", flag.ExitOnError)
opts := zap.Options{}
oc := saleorder.Odoo16Credentials{}
opts.BindFlags(zapfs)
cmd.Flags().AddGoFlagSet(zapfs)

@@ -102,6 +104,14 @@ func ControllerCommand() *cobra.Command {
billingEntityEmailSubject := cmd.Flags().String("billingentity-email-subject", "An APPUiO Billing Entity has been updated", "Subject for billing entity modification update mails")
billingEntityCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1h", "Cron interval for how frequently billing entity update e-mails are sent")

saleOrderStorage := cmd.Flags().String("sale-order-storage", "none", "Type of sale order storage to use. Valid values are `none` and `odoo16`")
saleOrderClientReference := cmd.Flags().String("sale-order-client-reference", "APPUiO Cloud", "Default client reference to add to newly created sales orders.")
saleOrderInternalNote := cmd.Flags().String("sale-order-internal-note", "auto-generated by APPUiO Cloud Control API", "Default internal note to add to newly created sales orders.")
cmd.Flags().StringVar(&oc.URL, "sale-order-odoo16-url", "http://localhost:8069", "URL of the Odoo instance to use for sale orders")
cmd.Flags().StringVar(&oc.Database, "sale-order-odoo16-db", "odooDB", "Database of the Odoo instance to use for sale orders")
cmd.Flags().StringVar(&oc.Admin, "sale-order-odoo16-account", "Admin", "Odoo Account name to use for sale orders")
cmd.Flags().StringVar(&oc.Password, "sale-order-odoo16-password", "superSecret1238", "Odoo Account password to use for sale orders")

cmd.Run = func(*cobra.Command, []string) {
scheme := runtime.NewScheme()
setupLog := ctrl.Log.WithName("setup")
@@ -182,6 +192,10 @@ func ControllerCommand() *cobra.Command {
*redeemedInvitationTTL,
*invEmailBaseRetryDelay,
invMailSender,
*saleOrderStorage,
*saleOrderClientReference,
*saleOrderInternalNote,
oc,
ctrl.Options{
Scheme: scheme,
MetricsBindAddress: *metricsAddr,
@@ -228,6 +242,10 @@ func setupManager(
redeemedInvitationTTL time.Duration,
invEmailBaseRetryDelay time.Duration,
mailSender mailsenders.MailSender,
saleOrderStorage string,
saleOrderClientReference string,
saleOrderInternalNote string,
odooCredentials saleorder.Odoo16Credentials,
opt ctrl.Options,
) (ctrl.Manager, error) {
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt)
@@ -320,6 +338,25 @@ func setupManager(
return nil, err
}

if saleOrderStorage == "odoo16" {
storage, err := saleorder.NewOdoo16Storage(&odooCredentials, &saleorder.Odoo16Options{
SaleOrderClientReferencePrefix: saleOrderClientReference,
SaleOrderInternalNote: saleOrderInternalNote,
})
if err != nil {
return nil, err
}
saleorder := &controllers.SaleOrderReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("sale-order-controller"),
SaleOrderStorage: storage,
}
if err = saleorder.SetupWithManager(mgr); err != nil {
return nil, err
}
}

metrics.Registry.MustRegister(invmail.GetMetrics())

mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{
102 changes: 102 additions & 0 deletions controllers/sale_order_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package controllers

import (
"context"
"fmt"

"go.uber.org/multierr"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"

organizationv1 "github.com/appuio/control-api/apis/organization/v1"
"github.com/appuio/control-api/controllers/saleorder"
)

// SaleOrderReconciler reconciles invitations and adds a token to the status if required.
type SaleOrderReconciler struct {
client.Client

Recorder record.EventRecorder
Scheme *runtime.Scheme

SaleOrderStorage saleorder.SaleOrderStorage
}

//+kubebuilder:rbac:groups="rbac.appuio.io",resources=organizations,verbs=get;list;watch
//+kubebuilder:rbac:groups="user.appuio.io",resources=organizations,verbs=get;list;watch
//+kubebuilder:rbac:groups="rbac.appuio.io",resources=organizations/status,verbs=get;update;patch
//+kubebuilder:rbac:groups="user.appuio.io",resources=organizations/status,verbs=get;update;patch

// Reconcile reacts to Organizations and creates Sale Orders if necessary
func (r *SaleOrderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
log.V(1).WithValues("request", req).Info("Reconciling")

org := organizationv1.Organization{}
if err := r.Get(ctx, req.NamespacedName, &org); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

if org.Spec.BillingEntityRef == "" {
return ctrl.Result{}, nil
}

if org.Status.SaleOrderName != "" {
return ctrl.Result{}, nil
}

if org.Status.SaleOrderID != "" {
// ID is present, but Name is not. Update name.
soName, err := r.SaleOrderStorage.GetSaleOrderName(org)
if err != nil {
log.V(0).Error(err, "Error getting sale order name")
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
Type: organizationv1.ConditionSaleOrderNameUpdated,
Status: metav1.ConditionFalse,
Reason: organizationv1.ConditionReasonGetNameFailed,
Message: err.Error(),
})
return ctrl.Result{}, multierr.Append(err, r.Client.Status().Update(ctx, &org))
}
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
Type: organizationv1.ConditionSaleOrderNameUpdated,
Status: metav1.ConditionTrue,
})
org.Status.SaleOrderName = soName
return ctrl.Result{}, r.Client.Status().Update(ctx, &org)
}

// Neither ID nor Name is present. Create new SO.
soId, err := r.SaleOrderStorage.CreateSaleOrder(org)

if err != nil {
log.V(0).Error(err, "Error creating sale order")
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
Type: organizationv1.ConditionSaleOrderCreated,
Status: metav1.ConditionFalse,
Reason: organizationv1.ConditionReasonCreateFailed,
Message: err.Error(),
})
return ctrl.Result{}, multierr.Append(err, r.Client.Status().Update(ctx, &org))
}

apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
Type: organizationv1.ConditionSaleOrderCreated,
Status: metav1.ConditionTrue,
})

org.Status.SaleOrderID = fmt.Sprint(soId)
return ctrl.Result{}, r.Client.Status().Update(ctx, &org)
}

// SetupWithManager sets up the controller with the Manager.
func (r *SaleOrderReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&organizationv1.Organization{}).
Complete(r)
}
243 changes: 243 additions & 0 deletions controllers/sale_order_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package controllers_test

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"

organizationv1 "github.com/appuio/control-api/apis/organization/v1"
. "github.com/appuio/control-api/controllers"
"github.com/appuio/control-api/controllers/saleorder/mock_saleorder"
)

func Test_SaleOrderReconciler_Reconcile_Create_Success(t *testing.T) {
ctx := context.Background()
mctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockSaleOrderStorage(mctrl)

subject := organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "subject",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-0000",
},
}
c := prepareTest(t, &subject)

gomock.InOrder(
mock.EXPECT().CreateSaleOrder(gomock.Any()).Return("123", nil),
)

_, err := (&SaleOrderReconciler{
Client: c,
Scheme: c.Scheme(),
Recorder: record.NewFakeRecorder(3),
SaleOrderStorage: mock,
}).Reconcile(ctx, ctrl.Request{
NamespacedName: types.NamespacedName{
Name: subject.Name,
},
})

require.NoError(t, err)
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject))
require.Equal(t, "123", subject.Status.SaleOrderID)
cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderCreated)
require.Equal(t, metav1.ConditionTrue, cond.Status)
}

func Test_SaleOrderReconciler_Reconcile_UpdateName_Success(t *testing.T) {
ctx := context.Background()
mctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockSaleOrderStorage(mctrl)

subject := organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "subject",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-0000",
},
Status: organizationv1.OrganizationStatus{
SaleOrderID: "123",
},
}
c := prepareTest(t, &subject)

gomock.InOrder(
mock.EXPECT().GetSaleOrderName(gomock.Any()).Return("SO123", nil),
)

_, err := (&SaleOrderReconciler{
Client: c,
Scheme: c.Scheme(),
Recorder: record.NewFakeRecorder(3),
SaleOrderStorage: mock,
}).Reconcile(ctx, ctrl.Request{
NamespacedName: types.NamespacedName{
Name: subject.Name,
},
})

require.NoError(t, err)
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject))
require.Equal(t, "123", subject.Status.SaleOrderID)
require.Equal(t, "SO123", subject.Status.SaleOrderName)
cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderNameUpdated)
require.Equal(t, metav1.ConditionTrue, cond.Status)
}

func Test_SaleOrderReconciler_Reconcile_NoAction_Success(t *testing.T) {
ctx := context.Background()
mctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockSaleOrderStorage(mctrl)

subject := organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "subject",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-0000",
},
Status: organizationv1.OrganizationStatus{
SaleOrderID: "123",
SaleOrderName: "SO123",
},
}
c := prepareTest(t, &subject)

mock.EXPECT().CreateSaleOrder(gomock.Any()).Times(0)
mock.EXPECT().GetSaleOrderName(gomock.Any()).Times(0)

_, err := (&SaleOrderReconciler{
Client: c,
Scheme: c.Scheme(),
Recorder: record.NewFakeRecorder(3),
SaleOrderStorage: mock,
}).Reconcile(ctx, ctrl.Request{
NamespacedName: types.NamespacedName{
Name: subject.Name,
},
})

require.NoError(t, err)
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject))
}

func Test_SaleOrderReconciler_Reconcile_NoBillingEntity_Success(t *testing.T) {
ctx := context.Background()
mctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockSaleOrderStorage(mctrl)

subject := organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "subject",
},
}
c := prepareTest(t, &subject)

mock.EXPECT().CreateSaleOrder(gomock.Any()).Times(0)
mock.EXPECT().GetSaleOrderName(gomock.Any()).Times(0)

_, err := (&SaleOrderReconciler{
Client: c,
Scheme: c.Scheme(),
Recorder: record.NewFakeRecorder(3),
SaleOrderStorage: mock,
}).Reconcile(ctx, ctrl.Request{
NamespacedName: types.NamespacedName{
Name: subject.Name,
},
})

require.NoError(t, err)
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject))
}

func Test_SaleOrderReconciler_Create_Error(t *testing.T) {
ctx := context.Background()
mctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockSaleOrderStorage(mctrl)

subject := organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "subject",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-0000",
},
}
c := prepareTest(t, &subject)

gomock.InOrder(
mock.EXPECT().CreateSaleOrder(gomock.Any()).Return("", errors.New("An unanticipated fault has come to pass.")),
)

_, err := (&SaleOrderReconciler{
Client: c,
Scheme: c.Scheme(),
Recorder: record.NewFakeRecorder(3),
SaleOrderStorage: mock,
}).Reconcile(ctx, ctrl.Request{
NamespacedName: types.NamespacedName{
Name: subject.Name,
},
})

require.Error(t, err)
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject))
cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderCreated)
require.Equal(t, metav1.ConditionFalse, cond.Status)
require.Equal(t, organizationv1.ConditionReasonCreateFailed, cond.Reason)
require.Equal(t, "An unanticipated fault has come to pass.", cond.Message)
}

func Test_SaleOrderReconciler_UpdateName_Error(t *testing.T) {
ctx := context.Background()
mctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockSaleOrderStorage(mctrl)

subject := organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "subject",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-0000",
},
Status: organizationv1.OrganizationStatus{
SaleOrderID: "123",
},
}
c := prepareTest(t, &subject)

gomock.InOrder(
mock.EXPECT().GetSaleOrderName(gomock.Any()).Return("", errors.New("An unanticipated fault has come to pass.")),
)

_, err := (&SaleOrderReconciler{
Client: c,
Scheme: c.Scheme(),
Recorder: record.NewFakeRecorder(3),
SaleOrderStorage: mock,
}).Reconcile(ctx, ctrl.Request{
NamespacedName: types.NamespacedName{
Name: subject.Name,
},
})

require.Error(t, err)
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject))
cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderNameUpdated)
require.Equal(t, metav1.ConditionFalse, cond.Status)
require.Equal(t, organizationv1.ConditionReasonGetNameFailed, cond.Reason)
require.Equal(t, "An unanticipated fault has come to pass.", cond.Message)
}
122 changes: 122 additions & 0 deletions controllers/saleorder/mock_saleorder/mock.go
129 changes: 129 additions & 0 deletions controllers/saleorder/saleorder_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package saleorder

import (
"fmt"
"strconv"
"strings"

organizationv1 "github.com/appuio/control-api/apis/organization/v1"
odooclient "github.com/appuio/go-odoo"
)

type Odoo16Credentials = odooclient.ClientConfig

type Odoo16Options struct {
SaleOrderClientReferencePrefix string
SaleOrderInternalNote string
}

const defaultSaleOrderState = "sale"

type SaleOrderStorage interface {
CreateSaleOrder(organizationv1.Organization) (string, error)
GetSaleOrderName(organizationv1.Organization) (string, error)
}

type Odoo16Client interface {
Read(string, []int64, *odooclient.Options, interface{}) error
CreateSaleOrder(*odooclient.SaleOrder) (int64, error)
}

type Odoo16SaleOrderStorage struct {
client Odoo16Client
options *Odoo16Options
}

func NewOdoo16Storage(credentials *Odoo16Credentials, options *Odoo16Options) (SaleOrderStorage, error) {
client, err := odooclient.NewClient(credentials)
return &Odoo16SaleOrderStorage{
client: client,
options: options,
}, err
}

func NewOdoo16StorageFromClient(client Odoo16Client, options *Odoo16Options) SaleOrderStorage {
return &Odoo16SaleOrderStorage{
client: client,
options: options,
}
}

func (s *Odoo16SaleOrderStorage) CreateSaleOrder(org organizationv1.Organization) (string, error) {
beID, err := k8sIDToOdooID(org.Spec.BillingEntityRef)
if err != nil {
return "", err
}

fetchPartnerFieldOpts := odooclient.NewOptions().FetchFields(
"id",
"parent_id",
)

beRecords := []odooclient.ResPartner{}
err = s.client.Read(odooclient.ResPartnerModel, []int64{int64(beID)}, fetchPartnerFieldOpts, &beRecords)
if err != nil {
return "", fmt.Errorf("fetching accounting contact by ID: %w", err)
}

if len(beRecords) <= 0 {
return "", fmt.Errorf("no results when fetching accounting contact by ID")
}
beRecord := beRecords[0]

if beRecord.ParentId == nil {
return "", fmt.Errorf("accounting contact %d has no parent", beRecord.Id.Get())
}

var clientRef string
if org.Spec.DisplayName != "" {
clientRef = fmt.Sprintf("%s (%s)", s.options.SaleOrderClientReferencePrefix, org.Spec.DisplayName)
} else {
clientRef = fmt.Sprintf("%s (%s)", s.options.SaleOrderClientReferencePrefix, org.ObjectMeta.Name)
}

newSaleOrder := odooclient.SaleOrder{
PartnerInvoiceId: odooclient.NewMany2One(beRecord.Id.Get(), ""),
PartnerId: odooclient.NewMany2One(beRecord.ParentId.ID, ""),
State: odooclient.NewSelection(defaultSaleOrderState),
ClientOrderRef: odooclient.NewString(clientRef),
InternalNote: odooclient.NewString(s.options.SaleOrderInternalNote),
}

soID, err := s.client.CreateSaleOrder(&newSaleOrder)
if err != nil {
return "", fmt.Errorf("creating new sale order: %w", err)
}

return fmt.Sprint(soID), nil
}

func (s *Odoo16SaleOrderStorage) GetSaleOrderName(org organizationv1.Organization) (string, error) {
fetchOrderFieldOpts := odooclient.NewOptions().FetchFields(
"id",
"name",
)
id, err := strconv.Atoi(org.Status.SaleOrderID)
if err != nil {
return "", fmt.Errorf("error parsing saleOrderID %q from organization status: %w", org.Status.SaleOrderID, err)
}
soRecords := []odooclient.SaleOrder{}
err = s.client.Read(odooclient.SaleOrderModel, []int64{int64(id)}, fetchOrderFieldOpts, &soRecords)
if err != nil {
return "", fmt.Errorf("fetching sale order by ID: %w", err)
}

if len(soRecords) <= 0 {
return "", fmt.Errorf("no results when fetching sale orders with ID %q", id)
}

return soRecords[0].Name.Get(), nil

}

func k8sIDToOdooID(id string) (int, error) {
if !strings.HasPrefix(id, "be-") {
return 0, fmt.Errorf("invalid ID, missing prefix: %s", id)
}

return strconv.Atoi(id[3:])
}
140 changes: 140 additions & 0 deletions controllers/saleorder/saleorder_storage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package saleorder_test

import (
"fmt"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

organizationv1 "github.com/appuio/control-api/apis/organization/v1"
"github.com/appuio/control-api/controllers/saleorder"
"github.com/appuio/control-api/controllers/saleorder/mock_saleorder"
odooclient "github.com/appuio/go-odoo"
)

func TestCreate(t *testing.T) {
ctrl, mock, subject := createStorage(t)
defer ctrl.Finish()

tn := time.Now()
st, _ := time.Parse(time.RFC3339, "2023-04-18T14:07:55Z")
statusTime := st.Local()

gomock.InOrder(
mock.EXPECT().Read(gomock.Any(), []int64{int64(123)}, gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{
Id: odooclient.NewInt(456),
CreateDate: odooclient.NewTime(tn),
ParentId: odooclient.NewMany2One(123, ""),
Email: odooclient.NewString("accounting@test.com, notifications@test.com"),
VshnControlApiMetaStatus: odooclient.NewString("{\"conditions\":[{\"type\":\"ConditionFoo\",\"status\":\"False\",\"lastTransitionTime\":\"" + statusTime.Format(time.RFC3339) + "\",\"reason\":\"Whatever\",\"message\":\"Hello World\"}]}"),
}}).Return(nil),
mock.EXPECT().CreateSaleOrder(gomock.Any()).Return(int64(149), nil),
)

soid, err := subject.CreateSaleOrder(organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "myorg",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-123",
},
})
require.NoError(t, err)
assert.Equal(t, "149", soid)
}

func TestGet(t *testing.T) {
ctrl, mock, subject := createStorage(t)
defer ctrl.Finish()

gomock.InOrder(
mock.EXPECT().Read(gomock.Any(), []int64{int64(149)}, gomock.Any(), gomock.Any()).SetArg(3, []odooclient.SaleOrder{{
Id: odooclient.NewInt(456),
Name: odooclient.NewString("SO149"),
}}).Return(nil),
)

soid, err := subject.GetSaleOrderName(organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "myorg",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-123",
},
Status: organizationv1.OrganizationStatus{
SaleOrderID: "149",
},
})
require.NoError(t, err)
assert.Equal(t, "SO149", soid)
}

func TestCreateAttributes(t *testing.T) {
ctrl, mock, subject := createStorage(t)
defer ctrl.Finish()

tn := time.Now()
st, _ := time.Parse(time.RFC3339, "2023-04-18T14:07:55Z")
statusTime := st.Local()

gomock.InOrder(
mock.EXPECT().Read(gomock.Any(), []int64{int64(123)}, gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{
Id: odooclient.NewInt(456),
CreateDate: odooclient.NewTime(tn),
ParentId: odooclient.NewMany2One(123, ""),
Email: odooclient.NewString("accounting@test.com, notifications@test.com"),
VshnControlApiMetaStatus: odooclient.NewString("{\"conditions\":[{\"type\":\"ConditionFoo\",\"status\":\"False\",\"lastTransitionTime\":\"" + statusTime.Format(time.RFC3339) + "\",\"reason\":\"Whatever\",\"message\":\"Hello World\"}]}"),
}}).Return(nil),
mock.EXPECT().CreateSaleOrder(SaleOrderMatcher{
PartnerId: int64(123),
PartnerInvoiceId: int64(456),
State: "sale",
ClientOrderRef: "client-ref (myorg)",
InternalNote: "internal-note",
}).Return(int64(149), nil),
)

soid, err := subject.CreateSaleOrder(organizationv1.Organization{
ObjectMeta: metav1.ObjectMeta{
Name: "myorg",
},
Spec: organizationv1.OrganizationSpec{
BillingEntityRef: "be-123",
},
})
require.NoError(t, err)
assert.Equal(t, "149", soid)
}

type SaleOrderMatcher struct {
PartnerId int64
PartnerInvoiceId int64
State string
ClientOrderRef string
InternalNote string
}

func (s SaleOrderMatcher) Matches(x interface{}) bool {
so := x.(*odooclient.SaleOrder)
return so.PartnerId.ID == s.PartnerId && so.PartnerInvoiceId.ID == s.PartnerInvoiceId && so.State.Get() == s.State && so.ClientOrderRef.Get() == s.ClientOrderRef && so.InternalNote.Get() == s.InternalNote
}
func (s SaleOrderMatcher) String() string {
return fmt.Sprintf("{PartnerId:%d PartnerInvoiceId:%d State:%s ClientOrderRef:%s InternalNote:%s}", s.PartnerId, s.PartnerInvoiceId, s.State, s.ClientOrderRef, s.InternalNote)
}

func createStorage(t *testing.T) (*gomock.Controller, *mock_saleorder.MockOdoo16Client, saleorder.SaleOrderStorage) {
ctrl := gomock.NewController(t)
mock := mock_saleorder.NewMockOdoo16Client(ctrl)

return ctrl, mock, saleorder.NewOdoo16StorageFromClient(
mock,
&saleorder.Odoo16Options{
SaleOrderClientReferencePrefix: "client-ref",
SaleOrderInternalNote: "internal-note",
},
)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ go 1.20

require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/appuio/go-odoo v0.3.0
github.com/appuio/go-odoo v0.4.0
github.com/go-logr/zapr v1.3.0
github.com/google/uuid v1.4.0
github.com/prometheus/client_golang v1.17.0
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -58,8 +58,8 @@ github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPp
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrGr5AyrNMXuA0gves=
github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY=
github.com/appuio/go-odoo v0.3.0 h1:SR53UYq7wiTR1LHDZy63LV4L6uFIKPZYOIzUd+CvGzU=
github.com/appuio/go-odoo v0.3.0/go.mod h1:pN7SdgIUWAS6hMW0L99FaofBG+5pSUA77vRcUT0mxjY=
github.com/appuio/go-odoo v0.4.0 h1:P1INin+2VnuzCjl5Q65p8DI4YdIw3PYLL9+vvS22iZE=
github.com/appuio/go-odoo v0.4.0/go.mod h1:pN7SdgIUWAS6hMW0L99FaofBG+5pSUA77vRcUT0mxjY=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

0 comments on commit 5c79acf

Please sign in to comment.