فهرست منبع

Added Supplier poster

Kenric Nugteren 2 هفته پیش
والد
کامیت
df13c0fa33

+ 1 - 1
prs.shared/Posters/MYOB/CustomerMYOBPoster.cs

@@ -77,7 +77,7 @@ public static class ContactMYOBUtils
             State = address.State.Truncate(255),
             PostCode = address.PostCode.Truncate(11),
             Email = contact.Email.Truncate(255),
-            ContactName = contact.Name.Truncate(25)
+            ContactName = contact.Name.Truncate(25),
         };
         if (mobile.IsNullOrWhiteSpace())
         {

+ 17 - 7
prs.shared/Posters/Xero/BillXeroPoster.cs

@@ -80,6 +80,11 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
         model.SetIsDefault<Supplier>(true, alias: "Bill_Supplier");
         model.SetColumns<Supplier>(RequiredSupplierColumns(), alias: "Bill_Supplier");
 
+        model.AddChildTable<Supplier, SupplierContact>(
+            x => x.ID, x => x.Supplier.ID,
+            columns: SupplierXeroPoster.RequiredSupplierContactColumns(),
+            parentalias: "Bill_Supplier", childalias: "Bill_Supplier_SupplierContact");
+
         ScriptObject?.BeforePost(model);
 
         return true;
@@ -133,6 +138,7 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
             .Add(x => x.Bill.ID)
             .Add(x => x.Description)
             .Add(x => x.IncTax)
+            .Add(x => x.Tax)
             .Add(x => x.PurchaseGL.ID)
             .Add(x => x.PurchaseGL.Code)
             .Add(x => x.PurchaseGL.PostedReference)
@@ -148,7 +154,7 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
 
     public IPostResult<Bill> Process(IDataModel<Bill> model)
     {
-        // https://developer.myob.com/api/myob-business-api/v2/purchase/bill/bill_service/
+        // Documentation: https://developer.xero.com/documentation/api/accounting/invoices
 
         var results = new PostResult<Bill>();
 
@@ -162,9 +168,12 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
 
         var suppliers = model.GetTable<Supplier>("Bill_Supplier")
             .ToObjects<Supplier>().ToDictionary(x => x.ID);
+        var supplierContacts = model.GetTable<Supplier>("Bill_Supplier_SupplierContact")
+            .ToObjects<SupplierContact>().GroupByDictionary(x => x.Supplier.ID);
 
         var billLines = model.GetTable<BillLine>("Bill_BillLine")
-            .ToObjects<BillLine>().GroupBy(x => x.Bill.ID).ToDictionary(x => x.Key, x => x.ToArray());
+            .ToObjects<BillLine>()
+            .GroupByDictionary(x => x.Bill.ID);
         
         foreach(var bill in bills)
         {
@@ -180,8 +189,8 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
             {
                 if(!service.Get(xeroID).Get(out var newBill, out error))
                 {
-                    CoreUtils.LogException("", error, $"Failed to find Bill in MYOB with id {xeroID}");
-                    results.AddFailed(bill, $"Failed to find Bill in MYOB with id {xeroID}: {error.Message}");
+                    CoreUtils.LogException("", error, $"Failed to find Bill in Xero with id {xeroID}");
+                    results.AddFailed(bill, $"Failed to find Bill in Xero with id {xeroID}: {error.Message}");
                     continue;
                 }
                 xeroBill = newBill;
@@ -214,7 +223,8 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
 
             if(suppliers.TryGetValue(bill.Supplier.ID, out var supplier))
             {
-                if(!SupplierXeroPoster.MapSupplier(ConnectionData, supplier).Get(out var supplierID, out error))
+                if(!SupplierXeroPoster.MapSupplier(ConnectionData, supplier, supplierContacts.GetValueOrDefault(supplier.ID) ?? [])
+                    .Get(out var supplierID, out error))
                 {
                     CoreUtils.LogException("", error, $"Error while posting bill {bill.ID}");
                     results.AddFailed(bill, error.Message);
@@ -229,7 +239,7 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
                 xeroBill.LineItems.Clear();
 
                 string? failed = null;
-                for(int i = 0; i < lines.Length; ++i)
+                for(int i = 0; i < lines.Count; ++i)
                 {
                     var billLine = lines[i];
 
@@ -342,7 +352,7 @@ public class BillXeroPoster : IXeroPoster<Bill, BillXeroPosterSettings>
     }
 }
 
-public class BillXeroPosterEngine<T> : XeroPosterEngine<Bill, BillXeroPosterSettings>
+public class BillXeroPosterEngine<T> : XeroPosterEngine<Bill, BillXeroPoster, BillXeroPosterSettings>
 {
     protected override IList<string> RequiredScopes()
     {

+ 59 - 0
prs.shared/Posters/Xero/ContactXeroUtils.cs

@@ -0,0 +1,59 @@
+using Comal.Classes;
+using FluentResults;
+using InABox.Core;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using XeroAddress = Xero.NetStandard.OAuth2.Model.Accounting.Address;
+using XeroPhone = Xero.NetStandard.OAuth2.Model.Accounting.Phone;
+
+namespace PRS.Shared.Posters.Xero;
+
+public static class ContactXeroUtils
+{
+    public static void SplitName(string name, out string firstName, out string lastName)
+    {
+        var names = name.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        firstName = names.Length > 0 ? names[0] : "";
+        lastName = names.Length > 1 ? names[1] : "";
+    }
+
+    public static Address ConvertAddress(XeroAddress address)
+    {
+        var newAddress = new Address
+        {
+            Street = string.Join('\n',
+                new string[] { address.AddressLine1, address.AddressLine2, address.AddressLine3, address.AddressLine4 }
+                .Where(x => !x.IsNullOrWhiteSpace())),
+            City = address.City,
+            State = address.Region,
+            PostCode = address.PostalCode
+        };
+        return newAddress;
+    }
+
+    public static XeroAddress ConvertAddress(Address address, XeroAddress.AddressTypeEnum addressType)
+    {
+        var newAddress = new XeroAddress
+        {
+            AddressLine1 = address.Street.Truncate(500),
+            City = address.City,
+            Region = address.State,
+            PostalCode = address.PostCode,
+            AddressType = addressType
+        };
+        return newAddress;
+    }
+
+    public static XeroPhone ConvertPhone(string phone, XeroPhone.PhoneTypeEnum phoneType)
+    {
+        var newPhone = new XeroPhone
+        {
+            PhoneNumber = phone,
+            PhoneType = phoneType
+        };
+        return newPhone;
+    }
+}

+ 54 - 0
prs.shared/Posters/Xero/Services/ContactService.cs

@@ -0,0 +1,54 @@
+using InABox.Poster.Xero;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Xero.NetStandard.OAuth2.Api;
+using Xero.NetStandard.OAuth2.Model.Accounting;
+
+namespace PRS.Shared.Posters.Xero.Services;
+
+internal class ContactService : BaseXeroService<Contact, ContactWrapper>
+{
+    private AccountingApi _api = new();
+
+    public ContactService(XeroConnectionData connection) : base(connection)
+    {
+    }
+
+    protected override void InitialiseFilterMaps()
+    {
+        AddFilterMap(x => x.ContactID, "ID");
+    }
+
+    protected override async Task<ContactWrapper> GetAsync(string accessToken, string xeroTenantID, Guid id, CancellationToken cancellationToken = default)
+    {
+        return new(await _api.GetContactAsync(accessToken, xeroTenantID, id, cancellationToken));
+    }
+
+    protected override async Task<ContactWrapper> GetAsync(string accessToken, string xeroTenantID, string? where, Dictionary<string, List<object?>> filterMaps, int? page = null, int? pageSize = null, CancellationToken cancellationToken = default)
+    {
+        return new(await _api.GetContactsAsync(accessToken, xeroTenantID,
+            iDs: filterMaps.GetValueOrDefault("ID")?.Cast<Guid>().ToList(),
+            where: where,
+            cancellationToken: cancellationToken));
+    }
+
+    protected override Contact Save(Contact entity)
+    {
+        var contacts = new Contacts();
+        contacts._Contacts.Add(entity);
+        return _api.UpdateOrCreateContactsAsync(Connection.Token.AccessToken, Connection.TenantID.ToString(), contacts)
+            .Result
+            ._Contacts[0];
+    }
+}
+
+internal class ContactWrapper(Contacts contacts) : IXeroGetResultWrapper<Contact>
+{
+    public IList<Contact> GetValues()
+    {
+        return contacts._Contacts;
+    }
+}

+ 473 - 38
prs.shared/Posters/Xero/SupplierXeroPoster.cs

@@ -1,16 +1,214 @@
 using Comal.Classes;
+using InABox.Clients;
 using InABox.Core;
 using InABox.Poster.Xero;
+using InABox.Scripting;
 using System;
 using System.Collections.Generic;
 using System.Linq;
 using System.Text;
 using System.Threading.Tasks;
+using XeroSupplier = Xero.NetStandard.OAuth2.Model.Accounting.Contact;
+using XeroAccounting = Xero.NetStandard.OAuth2.Model.Accounting;
+using PRS.Shared.Posters.Xero.Services;
+using InABox.Core.Postable;
+using InABox.Database;
 
 namespace PRS.Shared.Posters.Xero;
 
-public class SupplierXeroPoster
+public class SupplierXeroPosterSettings : XeroPosterSettings
 {
+    protected override string DefaultScript()
+    {
+        return @"using XeroSupplier = Xero.NetStandard.OAuth2.Model.Accounting.Contact;
+
+// 'Module' *must* implement " + nameof(ISupplierXeroPosterScript) + @"
+public class Module : " + nameof(ISupplierXeroPosterScript) + @"
+{
+    public void BeforePost(IDataModel<Supplier> model)
+    {
+        // Perform pre-processing
+    }
+
+    public void ProcessSupplier(IDataModel<Supplier> model, Supplier supplier, XeroSupplier xeroSupplier)
+    {
+        // Do extra processing for a supplier; throw an exception to fail this supplier.
+    }
+}";
+    }
+}
+public interface ISupplierXeroPosterScript
+{
+    void BeforePost(IDataModel<Supplier> model);
+
+    void ProcessSupplier(IDataModel<Supplier> model, Supplier supplier, XeroSupplier xeroSupplier);
+}
+
+public class SupplierXeroAutoRefresher : IAutoRefresher<Supplier>
+{
+    public bool ShouldRepost(Supplier supplier)
+    {
+        var shouldRepost = supplier.HasOriginalValue(x => x.Name)
+            || supplier.HasOriginalValue(x => x.Code)
+            || supplier.HasOriginalValue(x => x.ABN)
+            || supplier.HasOriginalValue(x => x.Email)
+            || supplier.HasOriginalValue(x => x.Telephone)
+            // || supplier.Delivery.HasOriginalValue(x => x.Street)
+            // || supplier.Delivery.HasOriginalValue(x => x.City)
+            // || supplier.Delivery.HasOriginalValue(x => x.State)
+            // || supplier.Delivery.HasOriginalValue(x => x.PostCode)
+            || supplier.Postal.HasOriginalValue(x => x.Street)
+            || supplier.Postal.HasOriginalValue(x => x.City)
+            || supplier.Postal.HasOriginalValue(x => x.State)
+            || supplier.Postal.HasOriginalValue(x => x.PostCode);
+        if (shouldRepost)
+        {
+            return true;
+        }
+
+        if(supplier.SupplierStatus.HasOriginalValue(x => x.ID))
+        {
+            var originalID = supplier.SupplierStatus.GetOriginalValue(x => x.ID);
+            var currentID = supplier.SupplierStatus.ID;
+
+            var statuses = DbFactory.NewProvider(Logger.Main).Query(
+                Filter<SupplierStatus>.Where(x => x.ID).IsEqualTo(originalID).Or(x => x.ID).IsEqualTo(currentID),
+                Columns.None<SupplierStatus>().Add(x => x.ID).Add(x => x.Active))
+                .ToArray<SupplierStatus>();
+            if (statuses.Length == 2 && statuses[0].Active != statuses[1].Active)
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+}
+public class SupplierXeroPoster : IXeroPoster<Supplier, SupplierXeroPosterSettings>, IAutoRefreshPoster<Supplier, SupplierXeroAutoRefresher>
+{
+    public ScriptDocument? Script { get; set; }
+    public XeroConnectionData ConnectionData { get; set; }
+    public SupplierXeroPosterSettings Settings { get; set; }
+    public IPosterDispatcher Dispatcher { get; set; }
+
+    public ISupplierXeroPosterScript? ScriptObject => Script?.GetObject<ISupplierXeroPosterScript>();
+
+    public bool BeforePost(IDataModel<Supplier> model)
+    {
+        foreach (var (_, table) in model.ModelTables)
+        {
+            table.IsDefault = false;
+        }
+        model.SetIsDefault<Supplier>(true);
+        model.SetColumns<Supplier>(RequiredColumns());
+
+        model.SetIsDefault<SupplierContact>(true, alias: "Supplier_SupplierContact");
+        model.SetColumns<SupplierContact>(RequiredSupplierContactColumns(), alias: "Supplier_SupplierContact");
+
+        ScriptObject?.BeforePost(model);
+
+        return true;
+    }
+
+    #region Script Functions
+
+    private Result<Exception> ProcessSupplier(IDataModel<Supplier> model, Supplier supplier, XeroSupplier xeroSupplier)
+    {
+        try
+        {
+            ScriptObject?.ProcessSupplier(model, supplier, xeroSupplier);
+            return Result.Ok();
+        }
+        catch(Exception e)
+        {
+            return Result.Error(e);
+        }
+    }
+
+    #endregion
+
+    public static Columns<Supplier> RequiredColumns()
+    {
+        return Columns.None<Supplier>()
+            .Add(x => x.ID)
+            .Add(x => x.PostedReference)
+            .Add(x => x.PostedStatus)
+            .Add(x => x.Name)
+            .Add(x => x.Code)
+            .Add(x => x.SupplierStatus.ID)
+            .Add(x => x.SupplierStatus.Active)
+            .Add(x => x.Postal.Street)
+            .Add(x => x.Postal.City)
+            .Add(x => x.Postal.State)
+            .Add(x => x.Postal.PostCode)
+            .Add(x => x.Email)
+            .Add(x => x.Telephone)
+            .Add(x => x.ABN);
+    }
+
+    public static Columns<SupplierContact> RequiredSupplierContactColumns()
+    {
+        return Columns.None<SupplierContact>()
+            .Add(x => x.Supplier.ID)
+            .Add(x => x.Contact.Name)
+            .Add(x => x.Contact.Email);
+    }
+
+    public static Result<Exception> UpdateSupplier(XeroConnectionData data, Supplier supplier, List<SupplierContact> contacts, XeroSupplier xeroSupplier)
+    {
+        // Documentation: https://developer.xero.com/documentation/api/accounting/contacts
+
+        // Since this might be called from some other poster, we need to ensure we have the right columns.
+        Client.EnsureColumns(supplier, RequiredColumns());
+
+        // FirstName
+        // LastName
+        // CompanyNumber
+        // BankAccountDetails
+        // AccountsReceivableTaxType
+        // AccountsPayableTaxType
+        // DefaultCurrency
+        // XeroNetworkKey
+        // SalesDefaultAccountCode
+        // PurchasesDefaultAccountCode
+        // SalesTrackingCategories
+        // PurchasesTrackingCategories
+        // TrackingCategoryName
+        // TrackingOptionName
+        // PaymentTerms
+
+        xeroSupplier.Name = supplier.Name.Replace("<", "").Replace(">", "").Trim().Truncate(255).Trim();
+        xeroSupplier.ContactNumber = supplier.Code.Truncate(50);
+        xeroSupplier.AccountNumber = xeroSupplier.AccountNumber.NotWhiteSpaceOr(xeroSupplier.ContactNumber);
+        xeroSupplier.ContactStatus = supplier.SupplierStatus.ID == Guid.Empty || supplier.SupplierStatus.Active
+            ? XeroSupplier.ContactStatusEnum.ACTIVE
+            : XeroSupplier.ContactStatusEnum.ARCHIVED;
+        xeroSupplier.EmailAddress = supplier.Email.Truncate(255);
+        xeroSupplier.ContactPersons = contacts.Select(x =>
+        {
+            var person = new XeroAccounting.ContactPerson();
+            ContactXeroUtils.SplitName(x.Contact.Name, out var firstName, out var lastName);
+            person.FirstName = firstName;
+            person.LastName = lastName;
+            person.EmailAddress = x.Contact.Email;
+            return person;
+        }).Take(5).ToList();
+        xeroSupplier.TaxNumber = supplier.ABN;
+
+        xeroSupplier.Addresses =
+        [
+            ContactXeroUtils.ConvertAddress(supplier.Postal, XeroAccounting.Address.AddressTypeEnum.POBOX),
+            // Xero says that DELIVERY addresses are not valid for contacts
+            // ContactXeroUtils.ConvertAddress(supplier.Delivery, XeroAccounting.Address.AddressTypeEnum.DELIVERY)
+        ];
+        xeroSupplier.Phones =
+        [
+            ContactXeroUtils.ConvertPhone(supplier.Telephone, XeroAccounting.Phone.PhoneTypeEnum.OFFICE)
+        ];
+
+        return Result.Ok();
+    }
+
     /// <summary>
     /// Try to find a supplier in Xero which matches <paramref name="supplier"/>, and if this fails, create a new one.
     /// </summary>
@@ -21,43 +219,280 @@ public class SupplierXeroPoster
     /// </remarks>
     /// <param name="data"></param>
     /// <param name="supplier">The supplier to map to.</param>
-    /// <returns>The ID of the Xero supplier.</returns>
-    public static Result<Guid, Exception> MapSupplier(XeroConnectionData data, Supplier supplier)
-    {
-        return null;
-        // if(Guid.TryParse(supplier.PostedReference, out var myobID))
-        // {
-        //     return Result.Ok(myobID);
-        // }
-
-        // var service = new SupplierService(data.Configuration, null, data.AuthKey);
-        // var result = service.Query(data, Filter<MYOBSupplier>.Where(x => x.DisplayID).IsEqualTo(supplier.Code), top: 1);
-        // return result.MapOk(suppliers =>
-        // {
-        //     if(suppliers.Items.Length == 0)
-        //     {
-        //         if(supplier.Code.Length > 15)
-        //         {
-        //             return Result.Error(new Exception("Customer code is longer than 15 characters"));
-        //         }
-        //         var myobSupplier = new MYOBSupplier();
-        //         return UpdateSupplier(data, settings, supplier, myobSupplier, true)
-        //             .MapOk(() => service.Save(data, myobSupplier)
-        //                 .MapOk(x =>
-        //                 {
-        //                     supplier.PostedReference = x.UID.ToString();
-        //                     // Marking as repost because a script may not have run.
-        //                     supplier.PostedStatus = PostedStatus.RequiresRepost;
-        //                     return x.UID;
-        //                 })).Flatten();
-        //     }
-        //     else
-        //     {
-        //         supplier.PostedReference = suppliers.Items[0].UID.ToString();
-        //         supplier.PostedStatus = PostedStatus.RequiresRepost;
-        //         return Result.Ok(suppliers.Items[0].UID);
-        //     }
-        // }).Flatten();
+    /// <returns>The UID of the Xero supplier.</returns>
+    public static Result<Guid, Exception> MapSupplier(XeroConnectionData data, Supplier supplier, List<SupplierContact> contacts)
+    {
+        if(Guid.TryParse(supplier.PostedReference, out var myobID))
+        {
+            return Result.Ok(myobID);
+        }
+
+        var result = data.GetItem<ContactService, XeroSupplier>(Filter<XeroSupplier>.Where(x => x.ContactNumber).IsEqualTo(supplier.Code));
+        return result.MapOk(xeroSupplier =>
+        {
+            if(xeroSupplier is null || !xeroSupplier.ContactID.HasValue)
+            {
+                if(supplier.Code.Length > 50)
+                {
+                    return Result.Error(new Exception("Customer code is longer than 50 characters"));
+                }
+                xeroSupplier = new XeroSupplier();
+                return UpdateSupplier(data, supplier, contacts, xeroSupplier)
+                    .MapOk(() => new ContactService(data).Save(xeroSupplier)
+                        .MapOk(x =>
+                        {
+                            supplier.PostedReference = x.ContactID.ToString() ?? "";
+                            // Marking as repost because a script may not have run.
+                            supplier.PostedStatus = PostedStatus.RequiresRepost;
+                            return x.ContactID ?? Guid.Empty;
+                        }))
+                    .Flatten();
+            }
+            else
+            {
+                supplier.PostedReference = xeroSupplier.ContactID.ToString() ?? "";
+                supplier.PostedStatus = PostedStatus.RequiresRepost;
+                return Result.Ok(xeroSupplier.ContactID.Value);
+            }
+        }).Flatten();
+    }
+
+    private static bool IsBlankCode(string code)
+    {
+        return code.IsNullOrWhiteSpace() || code.Equals("*None");
+    }
+
+    public IPullResult<Supplier> Pull()
+    {
+        var result = new PullResult<Supplier>();
+
+        var supplierCodes = new HashSet<string>();
+
+        var page = 1;
+        var pageSize = 400;
+
+        var service = new ContactService(ConnectionData);
+        while (true)
+        {
+            if(!service.Query(null, page: page, pageSize: pageSize).Get(out var xeroSuppliers, out var error))
+            {
+                CoreUtils.LogException("", error);
+                throw new PullFailedMessageException(error.Message);
+            }
+            if(xeroSuppliers.Count == 0)
+            {
+                break;
+            }
+
+            var xeroIDs = xeroSuppliers.ToArray(x => x.ContactID.ToString() ?? "");
+            var xeroCodes = xeroSuppliers.Select(x => x.ContactNumber).Where(x => !IsBlankCode(x)).ToArray();
+            var xeroNames = xeroSuppliers.Where(x => IsBlankCode(x.ContactNumber) && !x.Name.IsNullOrWhiteSpace())
+                .Select(x => x.Name).ToArray();
+
+            var suppliers = Client.Query(
+                Filter<Supplier>.Where(x => x.PostedReference).InList(xeroIDs)
+                    .Or(x => x.Code).InList(xeroCodes)
+                    .Or(x => x.Name).InList(xeroNames),
+                Columns.None<Supplier>().Add(x => x.ID).Add(x => x.PostedReference).Add(x => x.Code).Add(x => x.Name))
+                .ToArray<Supplier>();
+            var supplierDict = suppliers.Where(x => !x.PostedReference.IsNullOrWhiteSpace())
+                .ToDictionary(x => x.PostedReference);
+            var blankSuppliers = suppliers.Where(x => x.PostedReference.IsNullOrWhiteSpace()).ToArray();
+
+            var needCodes = new Dictionary<string, (string prefix, int i, Supplier supplier)>();
+
+            foreach(var xeroSupplier in xeroSuppliers)
+            {
+                if (supplierDict.TryGetValue(xeroSupplier.ContactID.ToString() ?? "", out var supplier))
+                {
+                    // Skipping existing suppliers at this point.
+                    continue;
+                }
+                supplier = !IsBlankCode(xeroSupplier.ContactNumber)
+                    ? blankSuppliers.FirstOrDefault(x => string.Equals(x.Code, xeroSupplier.ContactNumber))
+                    : blankSuppliers.FirstOrDefault(x => string.Equals(x.Name, xeroSupplier.Name));
+                if(supplier is not null)
+                {
+                    supplier.PostedReference = xeroSupplier.ContactID.ToString() ?? "";
+                    result.AddEntity(PullResultType.Linked, supplier);
+                    continue;
+                }
+
+                supplier = new Supplier();
+
+                string code;
+                if (!IsBlankCode(xeroSupplier.ContactNumber))
+                {
+                    code = xeroSupplier.ContactID.ToString() ?? "";
+                }
+                else if (!xeroSupplier.Name.IsNullOrWhiteSpace())
+                {
+                    code = xeroSupplier.Name[..Math.Min(3, xeroSupplier.Name.Length)].ToUpper();
+                }
+                else
+                {
+                    code = "SUP";
+                }
+                int i = 1;
+                supplier.Code = code;
+                while (supplierCodes.Contains(supplier.Code))
+                {
+                    supplier.Code = $"{code}{i:d3}";
+                    ++i;
+                }
+                supplierCodes.Add(supplier.Code);
+
+                supplier.Name = xeroSupplier.Name;
+                supplier.ABN = xeroSupplier.TaxNumber;
+                supplier.Email = xeroSupplier.EmailAddress;
+
+                if(xeroSupplier.Addresses is not null)
+                {
+                    var postal = xeroSupplier.Addresses.FirstOrDefault(x => x.AddressType == XeroAccounting.Address.AddressTypeEnum.POBOX);
+                    if(postal is not null)
+                    {
+                        supplier.Postal.CopyFrom(ContactXeroUtils.ConvertAddress(postal));
+                    }
+                }
+
+                foreach(var contactPerson in xeroSupplier.ContactPersons)
+                {
+                    var contact = new Contact();
+                    contact.Name = $"{contactPerson.FirstName} {contactPerson.LastName}";
+                    contact.Email = contactPerson.EmailAddress;
+                }
+
+                supplier.Telephone = xeroSupplier.Phones.FirstOrDefault()?.PhoneNumber ?? "";
+
+                supplier.PostedReference = xeroSupplier.ContactID.ToString() ?? "";
+                result.AddEntity(PullResultType.New, supplier);
+                needCodes.Add(supplier.Code, (code, i, supplier));
+            }
+
+            // Do code clash checking
+            while(needCodes.Count > 0)
+            {
+                var codes = Client.Query(
+                    Filter<Supplier>.Where(x => x.Code).InList(needCodes.Values.ToArray(x => x.supplier.Code)),
+                    Columns.None<Supplier>().Add(x => x.Code));
+                var newNeedCodes = new Dictionary<string, (string prefix, int i, Supplier supplier)>();
+                foreach(var row in codes.Rows)
+                {
+                    var code = row.Get<Supplier, string>(x => x.Code);
+                    if(needCodes.Remove(code, out var needed))
+                    {
+                        int i = needed.i;
+                        do
+                        {
+                            needed.supplier.Code = $"{needed.prefix}{i:d3}";
+                            ++i;
+                        } while (supplierCodes.Contains(needed.supplier.Code));
+                        supplierCodes.Add(needed.supplier.Code);
+                        newNeedCodes.Add(needed.supplier.Code, (needed.prefix, i, needed.supplier));
+                    }
+                }
+                needCodes = newNeedCodes;
+            }
+
+            ++page;
+        }
+
+        return result;
+    }
+
+    public IPostResult<Supplier> Process(IDataModel<Supplier> model)
+    {
+        var results = new PostResult<Supplier>();
+
+        var service = new ContactService(ConnectionData);
+
+        var suppliers = model.GetTable<Supplier>().ToArray<Supplier>();
+        var contacts = model.GetTable<SupplierContact>(alias: "Supplier_SupplierContact")
+            .ToObjects<SupplierContact>()
+            .GroupByDictionary(x => x.Supplier.ID);
+        
+        foreach(var supplier in suppliers)
+        {
+            if(supplier.Code.Length > 50)
+            {
+                results.AddFailed(supplier, "Code is longer than 50 characters.");
+                continue;
+            }
+            var supplierContacts = contacts.GetValueOrDefault(supplier.ID) ?? [];
+
+            XeroSupplier xeroSupplier;
+            Exception? error;
+            if(Guid.TryParse(supplier.PostedReference, out var xeroID))
+            {
+                if(!service.Get(xeroID).Get(out var newSupplier, out error))
+                {
+                    CoreUtils.LogException("", error, $"Failed to find Supplier in Xero with id {xeroID}");
+                    results.AddFailed(supplier, $"Failed to find Supplier in Xero with id {xeroID}: {error.Message}");
+                    continue;
+                }
+                xeroSupplier = newSupplier;
+            }
+            else
+            {
+                if (service.GetItem(Filter<XeroSupplier>.Where(x => x.ContactNumber).IsEqualTo(supplier.Code))
+                    .Get(out var externalXeroSupplier, out error)
+                    && externalXeroSupplier is not null)
+                {
+                    xeroSupplier = externalXeroSupplier;
+                }
+                else if (service.GetItem(
+                    Filter<XeroSupplier>.Where(x => x.Name).IsEqualTo(supplier.Name)
+                        .And(Filter<XeroSupplier>.Where(x => x.ContactNumber).IsEqualTo(null)
+                            .Or(x => x.ContactNumber).IsEqualTo("")
+                            .Or(x => x.ContactNumber).IsEqualTo("*None")))
+                    .Get(out externalXeroSupplier, out error)
+                    && externalXeroSupplier is not null)
+                {
+                    xeroSupplier = externalXeroSupplier;
+                    xeroSupplier.ContactNumber = supplier.Code;
+                }
+                else if(error is null)
+                {
+                    xeroSupplier = new();
+                }
+                else
+                {
+                    CoreUtils.LogException("", error);
+                    results.AddFailed(supplier, error.Message);
+                    continue;
+                }
+            }
+
+            if(UpdateSupplier(ConnectionData, supplier, supplierContacts, xeroSupplier)
+                .MapOk(() => ProcessSupplier(model, supplier, xeroSupplier)).Flatten()
+                .MapOk(() => service.Save(xeroSupplier)).Flatten()
+                .Get(out var result, out error))
+            {
+                supplier.PostedReference = result.ContactID.ToString() ?? "";
+                results.AddSuccess(supplier);
+            }
+            else
+            {
+                CoreUtils.LogException("", error, $"Error while posting supplier {supplier.ID}");
+                results.AddFailed(supplier, error.Message);
+            }
+        }
+
+        return results;
+    }
+}
+public class SupplierMYOBPosterEngine<T> : XeroPosterEngine<Supplier, SupplierXeroPoster, SupplierXeroPosterSettings>, IPullerEngine<Supplier, SupplierXeroPoster>
+{
+    public IPullResult<Supplier> DoPull()
+    {
+        LoadConnectionData();
+        return Poster.Pull();
     }
 
+    protected override IList<string> RequiredScopes()
+    {
+        return [
+            "accounting.contacts"
+            ];
+    }
 }