diff --git a/api.go b/api.go index 928bb133..33f8baf5 100644 --- a/api.go +++ b/api.go @@ -19,6 +19,7 @@ import ( "github.com/appuio/control-api/apiserver/authwrapper" billingStore "github.com/appuio/control-api/apiserver/billing" "github.com/appuio/control-api/apiserver/billing/odoostorage" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo16" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8/countries" orgStore "github.com/appuio/control-api/apiserver/organization" @@ -55,16 +56,26 @@ func APICommand() *cobra.Command { cmd.Flags().StringVar(&usernamePrefix, "username-prefix", "", "Prefix prepended to username claims. Usually the same as \"--oidc-username-prefix\" of the Kubernetes API server") cmd.Flags().BoolVar(&allowEmptyBillingEntity, "allow-empty-billing-entity", true, "Allow empty billing entity references") - cmd.Flags().StringVar(&ob.billingEntityStorage, "billing-entity-storage", "fake", "Storage backend for billing entities. Supported values: fake, odoo8") + cmd.Flags().StringVar(&ob.billingEntityStorage, "billing-entity-storage", "fake", "Storage backend for billing entities. Supported values: fake, odoo8, odoo16") + cmd.Flags().BoolVar(&ob.billingEntityFakeMetadataSupport, "billing-entity-fake-metadata-support", false, "Enable metadata support for the fake storage backend") + cmd.Flags().StringVar(&ob.odoo8URL, "billing-entity-odoo8-url", "http://localhost:8069", "URL of the Odoo instance to use for billing entities") cmd.Flags().BoolVar(&ob.odoo8DebugTransport, "billing-entity-odoo8-debug-transport", false, "Enable debug logging for the Odoo transport") cmd.Flags().StringVar(&ob.odoo8CountryListPath, "billing-entity-odoo8-country-list", "countries.yaml", "Path to the country list file in the format of [{name: \"Germany\", code: \"DE\", id: 81},...]") - cmd.Flags().StringVar(&ob.odoo8AccountingContactDisplayName, "billing-entity-odoo8-accounting-contact-display-name", "Accounting", "Display name of the accounting contact") cmd.Flags().StringVar(&ob.odoo8LanguagePreference, "billing-entity-odoo8-language-preference", "en_US", "Language preference of the Odoo record") cmd.Flags().IntVar(&ob.odoo8PaymentTermID, "billing-entity-odoo8-payment-term-id", 2, "Payment term ID of the Odoo record") + cmd.Flags().StringVar(&ob.odoo16URL, "billing-entity-odoo16-url", "http://localhost:8069", "URL of the Odoo instance to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16Db, "billing-entity-odoo16-db", "odooDB", "Database of the Odoo instance to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16Account, "billing-entity-odoo16-account", "Admin", "Odoo Account name to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16Password, "billing-entity-odoo16-password", "superSecret1238", "Odoo Account password to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16CountryListPath, "billing-entity-odoo16-country-list", "countries.yaml", "Path to the country list file in the format of [{name: \"Germany\", code: \"DE\", id: 81},...]") + cmd.Flags().StringVar(&ob.odoo16AccountingContactDisplayName, "billing-entity-odoo16-accounting-contact-display-name", "Accounting", "Display name of the accounting contact") + cmd.Flags().StringVar(&ob.odoo16LanguagePreference, "billing-entity-odoo16-language-preference", "en_US", "Language preference of the Odoo record") + cmd.Flags().IntVar(&ob.odoo16PaymentTermID, "billing-entity-odoo16-payment-term-id", 2, "Payment term ID of the Odoo record") + cmd.Flags().StringVar(&ib.backingNS, "invitation-storage-backing-ns", "default", "Namespace to store invitation secrets in") rf := cmd.Run @@ -86,10 +97,14 @@ func APICommand() *cobra.Command { } type odooStorageBuilder struct { - billingEntityStorage, odoo8URL, odoo8CountryListPath string - odoo8AccountingContactDisplayName, odoo8LanguagePreference string - odoo8PaymentTermID int - billingEntityFakeMetadataSupport, odoo8DebugTransport bool + billingEntityStorage, odoo8URL, odoo8CountryListPath string + odoo8AccountingContactDisplayName, odoo8LanguagePreference string + odoo8PaymentTermID int + billingEntityFakeMetadataSupport, odoo8DebugTransport bool + odoo16AccountingContactDisplayName, odoo16LanguagePreference string + odoo16URL, odoo16CountryListPath string + odoo16Db, odoo16Account, odoo16Password string + odoo16PaymentTermID int } func (o *odooStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) { @@ -107,6 +122,23 @@ func (o *odooStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOpti PaymentTermID: o.odoo8PaymentTermID, CountryIDs: countryIDs, }).(authwrapper.StorageScoper))(s, g) + case "odoo16": + countryIDs, err := countries.LoadCountryIDs(o.odoo16CountryListPath) + if err != nil { + return nil, err + } + return billingStore.New(odoostorage.NewOdoo16Storage( + odoo16.OdooCredentials{ + URL: o.odoo16URL, + Admin: o.odoo16Account, + Password: o.odoo16Password, + Database: o.odoo16Db, + }, odoo16.Config{ + AccountingContactDisplayName: o.odoo16AccountingContactDisplayName, + LanguagePreference: o.odoo16LanguagePreference, + PaymentTermID: o.odoo16PaymentTermID, + CountryIDs: countryIDs, + }).(authwrapper.StorageScoper))(s, g) default: return nil, fmt.Errorf("unknown billing entity storage: %s", o.billingEntityStorage) } diff --git a/apiserver/billing/odoostorage/odoo/odoo16/odoo16.go b/apiserver/billing/odoostorage/odoo/odoo16/odoo16.go new file mode 100644 index 00000000..b867c44f --- /dev/null +++ b/apiserver/billing/odoostorage/odoo/odoo16/odoo16.go @@ -0,0 +1,502 @@ +package odoo16 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/klog/v2" + + billingv1 "github.com/appuio/control-api/apis/billing/v1" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo" + + odooclient "github.com/appuio/go-odoo" +) + +const VSHNAccountingContactNameKey = "billing.appuio.io/vshn-accounting-contact-name" + +// Used to identify the accounting contact of a company. +const roleAccountCategory = 70 +const companyCategory = 2 +const invoiceType = "invoice" + +// Used to generate the UUID for the .metadata.uid field. +var metaUIDNamespace = uuid.MustParse("51887759-C769-4829-9910-BB9D5F92767D") + +var roleAccountFilter = odooclient.NewCriterion("category_id", "in", []int{roleAccountCategory}) +var activeFilter = odooclient.NewCriterion("active", "=", true) +var notInflightFilter = odooclient.NewCriterion("vshn_control_api_inflight", "=", false) +var mustInflightFilter = odooclient.NewCriterion("vshn_control_api_inflight", "!=", false) + +var fetchPartnerFieldOpts = odooclient.NewOptions().FetchFields( + "id", + "type", + "name", + "display_name", + "country_id", + "commercial_partner_id", + "contact_address", + + "child_ids", + "user_ids", + + "email", + "phone", + "street", + "street2", + "city", + "zip", + "country_id", + + "parent_id", + "vshn_control_api_meta_status", + "vshn_control_api_inflight", + "x_invoice_contact", +) + +var ( + // There's a ton of fields we don't want to override in Odoo. + // Sadly Odoo overrides them with an empty value if the key in JSON is present even if the value is null or false. + // The only chance to not override them is removing the key from the serialized object. + // json:"blub,omitempty" won't omit the keys since we use custom marshalling to work around some other Odoo quirks. + companyUpdateAllowedFields = newSet( + "name", + + "street", + "street2", + "city", + "zip", + "country_id", + + "email", + "phone", + ) + accountingContactUpdateAllowedFields = newSet( + "x_invoice_contact", + "vshn_control_api_meta_status", + "email", + ) +) + +type OdooCredentials = odooclient.ClientConfig + +type Config struct { + CountryIDs map[string]int + AccountingContactDisplayName string + LanguagePreference string + PaymentTermID int +} + +var _ odoo.OdooStorage = &Odoo16Storage{} + +func NewOdoo16Storage(credentials OdooCredentials, conf Config) *Odoo16Storage { + return &Odoo16Storage{ + config: conf, + sessionCreator: func(ctx context.Context) (*odooclient.Client, error) { + return odooclient.NewClient(&credentials) + }, + } +} + +func NewFailedRecordScrubber(credentials OdooCredentials) *FailedRecordScrubber { + return &FailedRecordScrubber{ + sessionCreator: func(ctx context.Context) (*odooclient.Client, error) { + return odooclient.NewClient(&credentials) + }, + } +} + +type Odoo16Storage struct { + config Config + + sessionCreator func(ctx context.Context) (*odooclient.Client, error) +} + +type FailedRecordScrubber struct { + sessionCreator func(ctx context.Context) (*odooclient.Client, error) +} + +func (s *Odoo16Storage) Get(ctx context.Context, name string) (*billingv1.BillingEntity, error) { + company, accountingContact, err := s.get(ctx, name) + if err != nil { + return nil, err + } + + be := mapPartnersToBillingEntity(ctx, company, accountingContact) + return &be, nil +} + +func (s *Odoo16Storage) get(ctx context.Context, name string) (company odooclient.ResPartner, accountingContact odooclient.ResPartner, err error) { + id, err := k8sIDToOdooID(name) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, err + } + + session, err := s.sessionCreator(ctx) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, err + } + + u := []odooclient.ResPartner{} + err = session.Read(odooclient.ResPartnerModel, []int64{int64(id)}, fetchPartnerFieldOpts, &u) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("fetching accounting contact by ID: %w", err) + } + accountingContact = u[0] + + if accountingContact.ParentId == nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("accounting contact %d has no parent", id) + } + + err = session.Read(odooclient.ResPartnerModel, []int64{accountingContact.ParentId.ID}, fetchPartnerFieldOpts, &u) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("fetching parent %d of accounting contact %d failed: %w", accountingContact.ParentId.ID, id, err) + } + company = u[0] + + return company, accountingContact, nil +} + +func (s *Odoo16Storage) List(ctx context.Context) ([]billingv1.BillingEntity, error) { + l := klog.FromContext(ctx) + + session, err := s.sessionCreator(ctx) + if err != nil { + return nil, err + } + + // criteria := odooclient.NewCriteria().AddCriterion(roleAccountFilter).AddCriterion(activeFilter).AddCriterion(notInflightFilter) + criteria := odooclient.NewCriteria().AddCriterion(roleAccountFilter).AddCriterion(activeFilter).AddCriterion(notInflightFilter) + accPartners, err := session.FindResPartners(criteria, fetchPartnerFieldOpts) + + if err != nil { + return nil, err + } + + companyIDs := make([]int, 0, len(*accPartners)) + for _, p := range *accPartners { + if p.ParentId == nil { + l.Info("role account has no parent", "id", p.Id) + continue + } + companyIDs = append(companyIDs, int(p.ParentId.ID)) + } + + criteria = odooclient.NewCriteria().AddCriterion(activeFilter).AddCriterion(odooclient.NewCriterion("id", "in", companyIDs)) + companies, err := session.FindResPartners(criteria, fetchPartnerFieldOpts) + if err != nil { + return nil, err + } + + companySet := make(map[int]odooclient.ResPartner, len(*companies)) + for _, p := range *companies { + companySet[int(p.Id.Get())] = p + } + + bes := make([]billingv1.BillingEntity, 0, len(*accPartners)) + for _, p := range *accPartners { + if p.ParentId == nil { + continue + } + mp, ok := companySet[int(p.ParentId.ID)] + if !ok { + l.Info("could not load parent partner (maybe no longer active?)", "parent_id", p.ParentId.ID, "id", p.Id) + continue + } + bes = append(bes, mapPartnersToBillingEntity(ctx, mp, p)) + } + + return bes, nil +} + +func (s *Odoo16Storage) Create(ctx context.Context, be *billingv1.BillingEntity) error { + l := klog.FromContext(ctx) + + if be == nil { + return errors.New("billing entity is nil") + } + company, accounting, err := mapBillingEntityToPartners(*be, s.config.CountryIDs) + if err != nil { + return fmt.Errorf("failed mapping billing entity to partners: %w", err) + } + + inflight := uuid.New().String() + l = l.WithValues("debug_inflight", inflight) + company.VshnControlApiInflight = odooclient.NewString(inflight) + accounting.VshnControlApiInflight = odooclient.NewString(inflight) + setStaticCompanyFields(s.config, &company) + setStaticAccountingContactFields(s.config, &accounting) + + session, err := s.sessionCreator(ctx) + if err != nil { + return err + } + + l.Info("about to create partner") + companyID, err := session.CreateResPartner(&company) + if err != nil { + return fmt.Errorf("error creating company: %w", err) + } + l.Info("created company (parent)", "id", companyID) + + accounting.ParentId = odooclient.NewMany2One(companyID, "") + accountingID, err := session.CreateResPartner(&accounting) + if err != nil { + return fmt.Errorf("error creating accounting contact: %w", err) + } + l.Info("created accounting contact", "id", accountingID, "parent_id", companyID) + + // reset inflight flag + if err := session.Update(odooclient.ResPartnerModel, []int64{companyID, accountingID}, map[string]any{ + "vshn_control_api_inflight": false, + }); err != nil { + return fmt.Errorf("error resetting inflight flag: %w", err) + } + + nbe, err := s.Get(ctx, odooIDToK8sID(int(accountingID))) + if err != nil { + return fmt.Errorf("error fetching newly created billing entity: %w", err) + } + *be = *nbe + return nil +} + +func (s *Odoo16Storage) Update(ctx context.Context, be *billingv1.BillingEntity) error { + l := klog.FromContext(ctx) + + if be == nil { + return errors.New("billing entity is nil") + } + + company, accounting, err := mapBillingEntityToPartners(*be, s.config.CountryIDs) + if err != nil { + return fmt.Errorf("failed mapping billing entity to partners: %w", err) + } + + origCompany, origAccounting, err := s.get(ctx, be.Name) + if err != nil { + return fmt.Errorf("error fetching billing entity to update: %w", err) + } + + session, err := s.sessionCreator(ctx) + if err != nil { + return err + } + + company.Id = origCompany.Id + accounting.Id = origAccounting.Id + + if err := session.UpdateResPartner(&company); err != nil { + return fmt.Errorf("error updating company: %w", err) + } + l.Info("updated company (parent)", "id", origCompany.Id.Get()) + + if err := session.UpdateResPartner(&accounting); err != nil { + return fmt.Errorf("error updating accounting contact: %w", err) + } + l.Info("updated accounting contact", "id", origAccounting.Id.Get(), "parent_id", origCompany.Id.Get()) + + ube, err := s.Get(ctx, odooIDToK8sID(int(origAccounting.Id.Get()))) + if err != nil { + return fmt.Errorf("error fetching updated billing entity: %w", err) + } + *be = *ube + return nil +} + +// CleanupIncompleteRecords looks for partner records in Odoo that still have the "inflight" flag set despite being older than `minAge`. Those records are then deleted. +// Such records might come into existence due to a partially failed creation request. +func (s *FailedRecordScrubber) CleanupIncompleteRecords(ctx context.Context, minAge time.Duration) error { + l := klog.FromContext(ctx) + l.Info("Looking for stale inflight partner records...") + + session, err := s.sessionCreator(ctx) + if err != nil { + return err + } + + inflightRecords, err := session.FindResPartners(odooclient.NewCriteria().AddCriterion(mustInflightFilter), fetchPartnerFieldOpts) + if err != nil { + return err + } + + ids := []int64{} + + for _, record := range *inflightRecords { + createdTime := record.CreateDate.Get() + + if createdTime.Before(time.Now().Add(-1 * minAge)) { + ids = append(ids, record.Id.Get()) + l.Info("Preparing to delete inflight partner record", "name", record.Name, "id", record.Id.Get()) + } + } + + if len(ids) != 0 { + return session.DeleteResPartners(ids) + } + return 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:]) +} + +func odooIDToK8sID(id int) string { + return fmt.Sprintf("be-%d", id) +} + +func mapPartnersToBillingEntity(ctx context.Context, company odooclient.ResPartner, accounting odooclient.ResPartner) billingv1.BillingEntity { + l := klog.FromContext(ctx) + name := odooIDToK8sID(int(accounting.Id.Get())) + + var status billingv1.BillingEntityStatus + if accounting.VshnControlApiMetaStatus.Get() != "" { + err := json.Unmarshal([]byte(accounting.VshnControlApiMetaStatus.Get()), &status) + + if err != nil { + l.Error(err, "Could not unmarshal BillingEntityStatus", "billingEntityName", name, "rawStatus", accounting.VshnControlApiMetaStatus.Get()) + } + } + + return billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: metav1.Time{ + Time: accounting.CreateDate.Get(), + }, + Annotations: map[string]string{ + VSHNAccountingContactNameKey: accounting.Name.Get(), + }, + // Since Odoo does not reuse IDs AFAIK, we can use the id from Odoo as UID. + // Without UID patch operations will fail. + UID: types.UID(uuid.NewSHA1(metaUIDNamespace, []byte(name)).String()), + }, + Spec: billingv1.BillingEntitySpec{ + Name: company.Name.Get(), + Phone: company.Phone.Get(), + Emails: splitCommaSeparated(company.Email.Get()), + Address: billingv1.BillingEntityAddress{ + Line1: company.Street.Get(), + Line2: company.Street2.Get(), + City: company.City.Get(), + PostalCode: company.Zip.Get(), + Country: company.CountryId.Name, + }, + AccountingContact: billingv1.BillingEntityContact{ + Name: accounting.XInvoiceContact.Get(), + Emails: splitCommaSeparated(accounting.Email.Get()), + }, + LanguagePreference: "", + }, + Status: status, + } +} + +func mapBillingEntityToPartners(be billingv1.BillingEntity, countryIDs map[string]int) (company odooclient.ResPartner, accounting odooclient.ResPartner, err error) { + countryID, ok := countryIDs[be.Spec.Address.Country] + if !ok { + return company, accounting, fmt.Errorf("unknown country %q", be.Spec.Address.Country) + } + + st, err := json.Marshal(be.Status) + if err != nil { + return company, accounting, err + } + statusString := string(st) + + company = odooclient.ResPartner{ + Name: odooclient.NewString(be.Spec.Name), + Phone: odooclient.NewString(be.Spec.Phone), + + Street: odooclient.NewString(be.Spec.Address.Line1), + Street2: odooclient.NewString(be.Spec.Address.Line2), + City: odooclient.NewString(be.Spec.Address.City), + Zip: odooclient.NewString(be.Spec.Address.PostalCode), + CountryId: odooclient.NewMany2One(int64(countryID), ""), + Email: odooclient.NewString(strings.Join(be.Spec.Emails, ", ")), + } + + accounting = odooclient.ResPartner{ + XInvoiceContact: odooclient.NewString(be.Spec.AccountingContact.Name), + VshnControlApiMetaStatus: odooclient.NewString(statusString), + Email: odooclient.NewString(strings.Join(be.Spec.AccountingContact.Emails, ", ")), + } + + return company, accounting, nil +} + +func setStaticAccountingContactFields(conf Config, a *odooclient.ResPartner) { + a.CategoryId = odooclient.NewRelation() + a.CategoryId.AddRecord(int64(roleAccountCategory)) + a.Name = odooclient.NewString(conf.AccountingContactDisplayName) + a.Lang = odooclient.NewSelection(conf.LanguagePreference) + a.Type = odooclient.NewSelection(invoiceType) + // a.NotifyEmail = "always" + a.PropertyPaymentTermId = odooclient.NewMany2One(int64(conf.PaymentTermID), "") + // a.UseParentAddress = true +} + +func setStaticCompanyFields(conf Config, a *odooclient.ResPartner) { + a.CategoryId = odooclient.NewRelation() + a.CategoryId.AddRecord(int64(companyCategory)) + a.Lang = odooclient.NewSelection(conf.LanguagePreference) + // a.NotifyEmail = "none" + a.PropertyPaymentTermId = odooclient.NewMany2One(int64(conf.PaymentTermID), "") +} + +func filterFields(p odooclient.ResPartner, allowed set) (map[string]any, error) { + sb, err := json.Marshal(p) + if err != nil { + return nil, err + } + + var pf map[string]any + if err := json.Unmarshal(sb, &pf); err != nil { + return nil, err + } + + for k := range pf { + if !allowed.has(k) { + delete(pf, k) + } + } + + return pf, nil +} + +type set map[string]struct{} + +func (s set) has(key string) bool { + _, ok := s[key] + return ok +} + +func newSet(keys ...string) set { + s := set{} + for _, k := range keys { + s[k] = struct{}{} + } + return s +} + +func splitCommaSeparated(s string) []string { + if s == "" { + return []string{} + } + p := strings.Split(s, ",") + for i, v := range p { + p[i] = strings.TrimSpace(v) + } + return p +} diff --git a/apiserver/billing/odoostorage/odoostorage.go b/apiserver/billing/odoostorage/odoostorage.go index cca49e05..5f79a07e 100644 --- a/apiserver/billing/odoostorage/odoostorage.go +++ b/apiserver/billing/odoostorage/odoostorage.go @@ -7,6 +7,7 @@ import ( billingv1 "github.com/appuio/control-api/apis/billing/v1" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/fake" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo16" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8" ) @@ -24,6 +25,13 @@ func NewOdoo8Storage(odooURL string, debugTransport bool, conf odoo8.Config) Sto } } +// NewOdoo16Storage returns a new storage provider for BillingEntities +func NewOdoo16Storage(credentials odoo16.OdooCredentials, config odoo16.Config) Storage { + return &billingEntityStorage{ + storage: odoo16.NewOdoo16Storage(credentials, config), + } +} + type billingEntityStorage struct { storage odoo.OdooStorage } diff --git a/go.mod b/go.mod index da26ea7c..224bef56 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/Masterminds/sprig/v3 v3.2.3 + github.com/appuio/go-odoo v0.3.0 github.com/go-logr/zapr v1.2.3 github.com/golang/mock v1.6.0 github.com/google/uuid v1.3.0 @@ -30,6 +31,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect + github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect diff --git a/go.sum b/go.sum index b13882b0..312eda41 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,10 @@ 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.2.0 h1:3WzaEkIqbNG3KUNi+h1TZubafFuQrLSlD5e5aUq91pA= +github.com/appuio/go-odoo v0.2.0/go.mod h1:pN7SdgIUWAS6hMW0L99FaofBG+5pSUA77vRcUT0mxjY= +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/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -279,6 +283,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= +github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=