diff --git a/GrandNode.slnx b/GrandNode.slnx index 08363a56ae..8d3a5539a7 100644 --- a/GrandNode.slnx +++ b/GrandNode.slnx @@ -42,6 +42,7 @@ + diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs index 1d19609c18..adf806f572 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs @@ -169,6 +169,21 @@ public async Task Handle(PlaceOrderCommand command, Cancellati if (result.Errors.Any()) return result; + //the buyer's purchase order number is checked before any payment is taken; the chosen method decides + //whether it is asked for + var purchaseOrderNumberMode = + _paymentService.LoadPaymentMethodBySystemName(details.PaymentMethodSystemName)?.PurchaseOrderNumberMode + ?? PurchaseOrderNumberMode.Disabled; + details.PurchaseOrderNumber = PurchaseOrderNumberRules.Normalize(command.PurchaseOrderNumber, + purchaseOrderNumberMode); + var purchaseOrderNumberError = PurchaseOrderNumberRules.Validate(details.PurchaseOrderNumber, + purchaseOrderNumberMode); + if (purchaseOrderNumberError != null) + { + result.AddError(_translationService.GetResource(purchaseOrderNumberError, command.Language.Id)); + return result; + } + //an order over the customer's limit is placed awaiting an approval, with an allowed payment method only var approval = await _orderApprovalService.RequiresApproval(details.Cart, details.Context); details.ApprovalRequired = approval.Required; @@ -922,6 +937,7 @@ protected virtual Order PrepareOrderHeader(PaymentTransaction paymentTransaction StoreId = paymentTransaction.StoreId, OrderGuid = paymentTransaction.OrderGuid, Code = paymentTransaction.OrderCode, + PurchaseOrderNumber = details.PurchaseOrderNumber, CustomerId = details.Customer.Id, OwnerId = string.IsNullOrEmpty(details.Customer.OwnerId) ? details.Customer.Id : details.Customer.OwnerId, SeId = details.Customer.SeId, @@ -957,6 +973,7 @@ protected virtual Order PrepareOrderHeader(PaymentTransaction paymentTransaction PaymentStatusId = paymentStatus, PaidAmount = processPaymentResult.PaidAmount, PaidDateUtc = processPaymentResult.PaidAmount > 0 ? DateTime.UtcNow : null, + PaymentDueDateUtc = processPaymentResult.PaymentDueDateUtc, BillingAddress = details.BillingAddress, ShippingAddress = details.ShippingAddress, ShippingStatusId = details.ShippingStatus, diff --git a/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/GetOrderQueryHandler.cs b/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/GetOrderQueryHandler.cs index 6786a9a164..960891442a 100644 --- a/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/GetOrderQueryHandler.cs +++ b/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/GetOrderQueryHandler.cs @@ -2,6 +2,7 @@ using Grand.Data; using Grand.Domain; using Grand.Domain.Orders; +using Grand.Domain.Payments; using Grand.Mediator; namespace Grand.Business.Checkout.Queries.Handlers.Orders; @@ -92,6 +93,23 @@ public async Task> Handle(GetOrderQuery request, CancellationT if (!string.IsNullOrEmpty(request.OrderTagId)) query = query.Where(o => o.OrderTags.Any(y => y == request.OrderTagId)); + if (!string.IsNullOrWhiteSpace(request.PurchaseOrderNumber)) + { + var purchaseOrderNumber = request.PurchaseOrderNumber.Trim(); + query = query.Where(o => o.PurchaseOrderNumber == purchaseOrderNumber); + } + + //the same rule as PaymentExtensions.IsPaymentOverdue + if (request.OverdueOnly) + { + var today = DateTime.UtcNow.Date; + query = query.Where(o => o.PaymentDueDateUtc != null && o.PaymentDueDateUtc < today && + o.OrderStatusId != (int)OrderStatusSystem.Cancelled && + (o.PaymentStatusId == PaymentStatus.Pending || + o.PaymentStatusId == PaymentStatus.Authorized || + o.PaymentStatusId == PaymentStatus.PartiallyPaid)); + } + query = query.Where(o => !o.Deleted); query = query.OrderByDescending(o => o.CreatedOnUtc); diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/CompanyCreditService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/CompanyCreditService.cs new file mode 100644 index 0000000000..8641030f15 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/CompanyCreditService.cs @@ -0,0 +1,99 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Payments; + +namespace Grand.Business.Checkout.Services.Orders; + +/// +/// Company credit service +/// +public class CompanyCreditService : ICompanyCreditService +{ + #region Ctor + + public CompanyCreditService(IRepository orderRepository, ICustomerService customerService) + { + _orderRepository = orderRepository; + _customerService = customerService; + } + + #endregion + + #region Fields + + private readonly IRepository _orderRepository; + private readonly ICustomerService _customerService; + + #endregion + + #region Utilities + + /// + /// Gets the company's orders bought on credit that are not paid yet + /// + private IQueryable UnpaidCreditOrders(string companyOwnerId) + { + return _orderRepository.Table.Where(o => + o.OwnerId == companyOwnerId && + o.PaymentDueDateUtc != null && + !o.Deleted && + o.OrderStatusId != (int)OrderStatusSystem.Cancelled && + (o.PaymentStatusId == PaymentStatus.Pending || + o.PaymentStatusId == PaymentStatus.Authorized || + o.PaymentStatusId == PaymentStatus.PartiallyPaid)); + } + + #endregion + + #region Methods + + public virtual async Task GetCompanyCredit(Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + + var companyOwnerId = OrderApprovalRules.CompanyId(customer); + var owner = string.IsNullOrEmpty(customer.OwnerId) + ? customer + : await _customerService.GetCustomerById(customer.OwnerId); + + if (owner is not { Active: true, Deleted: false } || + !owner.CreditLimit.HasValue || !owner.PaymentTermDays.HasValue) + return CompanyCredit.None(companyOwnerId); + + var outstanding = await GetOutstandingAmount(companyOwnerId); + var hasOverdue = await HasOverdue(companyOwnerId, DateTime.UtcNow); + + return new CompanyCredit(companyOwnerId, owner.CreditLimit, owner.PaymentTermDays, outstanding, + hasOverdue); + } + + public virtual async Task GetOutstandingAmount(string companyOwnerId) + { + if (string.IsNullOrEmpty(companyOwnerId)) + return 0; + + var query = UnpaidCreditOrders(companyOwnerId) + .Select(o => new { o.OrderTotal, o.PaidAmount, o.CurrencyRate }); + var orders = await _orderRepository.ToListAsync(query); + + // the same conversion to the primary currency as the order reports use + return orders.Sum(o => (o.OrderTotal - o.PaidAmount) / (o.CurrencyRate > 0 ? o.CurrencyRate : 1)); + } + + public virtual async Task HasOverdue(string companyOwnerId, DateTime utcNow) + { + if (string.IsNullOrEmpty(companyOwnerId)) + return false; + + //the due date is a calendar day: overdue once it has passed, as in PaymentExtensions.IsPaymentOverdue + var today = utcNow.Date; + return await _orderRepository.AnyAsync( + UnpaidCreditOrders(companyOwnerId).Where(o => o.PaymentDueDateUtc < today)); + } + + #endregion +} diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs index 9e74ca04f3..65ef260396 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs @@ -182,7 +182,8 @@ public virtual async Task> SearchOrders(string storeId = "", DateTime? createdFromUtc = null, DateTime? createdToUtc = null, int? os = null, PaymentStatus? ps = null, ShippingStatus? ss = null, string billingEmail = null, string billingLastName = "", string orderGuid = null, - string orderCode = null, int pageIndex = 0, int pageSize = int.MaxValue, string orderTagId = "") + string orderCode = null, int pageIndex = 0, int pageSize = int.MaxValue, string orderTagId = "", + string purchaseOrderNumber = null, bool overdueOnly = false) { var queryModel = new GetOrderQuery { AffiliateId = affiliateId, @@ -206,7 +207,9 @@ public virtual async Task> SearchOrders(string storeId = "", WarehouseId = warehouseId, OrderTagId = orderTagId, OwnerId = ownerId, - SalesEmployeeId = salesEmployeeId + SalesEmployeeId = salesEmployeeId, + PurchaseOrderNumber = purchaseOrderNumber, + OverdueOnly = overdueOnly }; return await _mediator.Send(queryModel); } @@ -266,7 +269,9 @@ public virtual async Task UpdateOrder(Order order) public async Task CancelExpiredOrders(DateTime expirationDateUtc, CancellationToken cancellationToken = default) { var orders = _orderRepository.Table + //an order bought on credit is unpaid on purpose until its due date, not an abandoned payment .Where(o => o.CreatedOnUtc < expirationDateUtc && o.PaymentStatusId == PaymentStatus.Pending && + o.PaymentDueDateUtc == null && o.OrderStatusId == (int)OrderStatusSystem.Pending && (o.ShippingStatusId == ShippingStatus.Pending || o.ShippingStatusId == ShippingStatus.ShippingNotRequired)) diff --git a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs index ceec4b8b39..42661d52c9 100644 --- a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs @@ -51,6 +51,7 @@ private static void RegisterOrdersService(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs b/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs index 3060e6fc5c..c2bab63c79 100644 --- a/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs +++ b/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs @@ -174,7 +174,7 @@ private async Task ComposeOrder(PdfComposer pdf, Order order, string vendorId, b await _mediator.Publish(paymentLines); billing = await AddressCell(order.BillingAddress, "PDFInvoice", "PDFInvoice.BillingInformation", lang, Format("PDFInvoice.PaymentMethod", lang, paymentMethod.GetPaymentMethodName(order)), - paymentLines.Lines.Select(PdfHtmlText.ToPlainText)); + paymentLines.Lines.Select(PdfHtmlText.ToPlainText).Concat(PaymentTermsLines(order, lang))); } var shipping = order.ShippingAddress == null @@ -398,6 +398,15 @@ private async Task LoadLogo() } } + //the PO number is the buyer's own text, printed as typed; the due date is a calendar date + private IEnumerable PaymentTermsLines(Order order, string lang) + { + if (!string.IsNullOrEmpty(order.PurchaseOrderNumber)) + yield return Format("PDFInvoice.PurchaseOrderNumber", lang, order.PurchaseOrderNumber); + if (order.PaymentDueDateUtc.HasValue) + yield return Format("PDFInvoice.PaymentDueDate", lang, order.PaymentDueDateUtc.Value.ToString("yyyy-MM-dd")); + } + private string Format(string resourceKey, string lang, object value) { return string.Format(_translationService.GetResource(resourceKey, lang), value); diff --git a/src/Business/Grand.Business.Core/Commands/Checkout/Orders/PlaceOrderCommand.cs b/src/Business/Grand.Business.Core/Commands/Checkout/Orders/PlaceOrderCommand.cs index f372553d64..be01818514 100644 --- a/src/Business/Grand.Business.Core/Commands/Checkout/Orders/PlaceOrderCommand.cs +++ b/src/Business/Grand.Business.Core/Commands/Checkout/Orders/PlaceOrderCommand.cs @@ -27,4 +27,9 @@ public class PlaceOrderCommand : IRequest /// Staff member who places the order while impersonating the customer, if any /// public Customer OriginalCustomerIfImpersonated { get; set; } + + /// + /// Buyer's own purchase order number, if any + /// + public string PurchaseOrderNumber { get; set; } } diff --git a/src/Business/Grand.Business.Core/Extensions/PaymentExtensions.cs b/src/Business/Grand.Business.Core/Extensions/PaymentExtensions.cs index 1e407d6492..2e7528256d 100644 --- a/src/Business/Grand.Business.Core/Extensions/PaymentExtensions.cs +++ b/src/Business/Grand.Business.Core/Extensions/PaymentExtensions.cs @@ -50,4 +50,24 @@ public static string GetPaymentMethodName(this IPaymentProvider paymentMethod, O return string.IsNullOrEmpty(name) ? order.PaymentMethodSystemName : name; } + + /// + /// Gets a value indicating whether the payment of an order bought on credit is overdue: the order is not + /// cancelled, not paid yet and its due date has passed. The due date is a calendar day (UTC), so the + /// payment becomes overdue the day after it. + /// + /// Order + /// Current date and time in UTC + /// Result + public static bool IsPaymentOverdue(this Order order, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(order); + + return order.PaymentDueDateUtc.HasValue && + order.PaymentDueDateUtc.Value < utcNow.Date && + !order.Deleted && + order.OrderStatusId != (int)OrderStatusSystem.Cancelled && + order.PaymentStatusId is PaymentStatus.Pending or PaymentStatus.Authorized + or PaymentStatus.PartiallyPaid; + } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/ICompanyCreditService.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/ICompanyCreditService.cs new file mode 100644 index 0000000000..f0325736a6 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/ICompanyCreditService.cs @@ -0,0 +1,32 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Interfaces.Checkout.Orders; + +/// +/// Trade credit of company accounts: the limit and term set on the company owner, and what the company owes +/// on orders bought on credit +/// +public interface ICompanyCreditService +{ + /// + /// Gets the trade credit the customer buys with: the customer's own for a company owner, the owner's for + /// a sub-account. The credit is disabled when the owner is missing, deleted or inactive. + /// + /// Customer + Task GetCompanyCredit(Customer customer); + + /// + /// Gets the unpaid amount of the company's orders bought on credit, in the primary store currency + /// + /// Identifier of the company owner + Task GetOutstandingAmount(string companyOwnerId); + + /// + /// Gets a value indicating whether any of the company's orders bought on credit is past its due date + /// (the due date is a calendar day: an order due today is not overdue yet) + /// + /// Identifier of the company owner + /// Current date and time in UTC + Task HasOverdue(string companyOwnerId, DateTime utcNow); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderService.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderService.cs index 5d6464e0a8..d0118bc7a2 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderService.cs @@ -96,6 +96,8 @@ public interface IOrderService /// Page index /// Page size /// Order tag identifier + /// Buyer's purchase order number, matched exactly; null to load all orders + /// Load only orders bought on credit whose payment is overdue /// Orders Task> SearchOrders(string storeId = "", string vendorId = "", string customerId = "", @@ -105,7 +107,8 @@ Task> SearchOrders(string storeId = "", DateTime? createdFromUtc = null, DateTime? createdToUtc = null, int? os = null, PaymentStatus? ps = null, ShippingStatus? ss = null, string billingEmail = null, string billingLastName = "", string orderGuid = null, - string orderCode = null, int pageIndex = 0, int pageSize = int.MaxValue, string orderTagId = ""); + string orderCode = null, int pageIndex = 0, int pageSize = int.MaxValue, string orderTagId = "", + string purchaseOrderNumber = null, bool overdueOnly = false); /// /// Inserts an order diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Payments/IPaymentProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Payments/IPaymentProvider.cs index b983b92532..f624ae4e24 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Checkout/Payments/IPaymentProvider.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Payments/IPaymentProvider.cs @@ -122,6 +122,11 @@ public interface IPaymentProvider : IProvider /// PaymentMethodType PaymentMethodType { get; } + /// + /// Gets whether the checkout asks for the buyer's purchase order number when this method is chosen + /// + PurchaseOrderNumberMode PurchaseOrderNumberMode => PurchaseOrderNumberMode.Disabled; + /// /// Gets a value indicating whether we should display a payment information page for this plugin /// diff --git a/src/Business/Grand.Business.Core/Queries/Checkout/Orders/GetOrderQuery.cs b/src/Business/Grand.Business.Core/Queries/Checkout/Orders/GetOrderQuery.cs index 814cb4fe94..2ff6055973 100644 --- a/src/Business/Grand.Business.Core/Queries/Checkout/Orders/GetOrderQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Checkout/Orders/GetOrderQuery.cs @@ -31,4 +31,6 @@ public class GetOrderQuery : IRequest> public int PageIndex { get; set; } = 0; public int PageSize { get; set; } = int.MaxValue; public string OrderTagId { get; set; } = ""; + public string PurchaseOrderNumber { get; set; } = null; + public bool OverdueOnly { get; set; } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/CompanyCredit.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/CompanyCredit.cs new file mode 100644 index 0000000000..d67eeb2dd7 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/CompanyCredit.cs @@ -0,0 +1,47 @@ +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// Trade credit of a company: its limit and payment term, and what it still owes on orders bought on credit. +/// Amounts are in the primary store currency. +/// +/// Identifier of the company owner the credit belongs to +/// Credit limit; null means no trade credit +/// Payment term in days; null means no trade credit +/// Unpaid amount of the company's orders bought on credit +/// Whether any of those orders is past its due date +public sealed record CompanyCredit( + string CompanyOwnerId, + double? CreditLimit, + int? PaymentTermDays, + double OutstandingAmount, + bool HasOverdue) +{ + /// + /// Gets a value indicating whether the company may buy on credit: both the limit and the term are set + /// + public bool Enabled => CreditLimit.HasValue && PaymentTermDays.HasValue; + + /// + /// Gets the credit still available + /// + public double Available => Enabled ? Math.Max(0, CreditLimit!.Value - OutstandingAmount) : 0; + + /// + /// Gets a value indicating whether an order of the given amount may be bought on credit + /// + /// Order amount in the primary store currency + /// Whether an overdue payment blocks further buying on credit + public bool Covers(double amountInPrimaryCurrency, bool blockWhenOverdue) + { + return Enabled && !(blockWhenOverdue && HasOverdue) && amountInPrimaryCurrency <= Available; + } + + /// + /// Gets the credit of a company that cannot buy on credit + /// + /// Identifier of the company owner + public static CompanyCredit None(string companyOwnerId) + { + return new CompanyCredit(companyOwnerId, null, null, 0, false); + } +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs index 5e83302d1b..ceeab46948 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs @@ -38,6 +38,7 @@ public class PlaceOrderContainer public double CurrencyRate { get; set; } public string PrimaryCurrencyCode { get; set; } public string PaymentMethodSystemName { get; set; } + public string PurchaseOrderNumber { get; set; } /// /// The order total exceeds the customer's order limit, so the order is placed awaiting an approval diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/ProcessPaymentResult.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/ProcessPaymentResult.cs index 9875f37e6f..21d9458468 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/ProcessPaymentResult.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/ProcessPaymentResult.cs @@ -36,6 +36,12 @@ public ProcessPaymentResult() /// public double PaidAmount { get; set; } + /// + /// Gets or sets the date the payment is due; set by a payment method that defers payment, the order is then + /// bought on credit + /// + public DateTime? PaymentDueDateUtc { get; set; } + /// /// Add error /// diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/PurchaseOrderNumberRules.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PurchaseOrderNumberRules.cs new file mode 100644 index 0000000000..e205f2d0c5 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PurchaseOrderNumberRules.cs @@ -0,0 +1,42 @@ +using Grand.Domain.Orders; + +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// How the buyer's purchase order number is cleaned and checked before it is stored on the order +/// +public static class PurchaseOrderNumberRules +{ + /// + /// Maximum length of a purchase order number + /// + public const int MaxLength = 50; + + /// + /// Gets the number to store: trimmed and kept as typed, null when empty or when the checkout does not ask + /// for it + /// + /// Number entered by the buyer + /// Purchase order number mode + public static string Normalize(string value, PurchaseOrderNumberMode mode) + { + if (mode == PurchaseOrderNumberMode.Disabled || string.IsNullOrWhiteSpace(value)) + return null; + + return value.Trim(); + } + + /// + /// Checks a normalized number + /// + /// Number returned by + /// Purchase order number mode + /// Resource key of the error; null when the number is valid + public static string Validate(string normalized, PurchaseOrderNumberMode mode) + { + if (normalized == null) + return mode == PurchaseOrderNumberMode.Required ? "Checkout.PurchaseOrderNumber.Required" : null; + + return normalized.Length > MaxLength ? "Checkout.PurchaseOrderNumber.TooLong" : null; + } +} diff --git a/src/Business/Grand.Business.Customers/Services/CustomerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerService.cs index 6fe3747ebd..99d37d8437 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerService.cs @@ -564,6 +564,8 @@ public virtual async Task UpdateCustomerInAdminPanel(Customer customer) .Set(x => x.OwnerId, customer.OwnerId) .Set(x => x.StaffStoreId, customer.StaffStoreId) .Set(x => x.StoreId, customer.StoreId) + .Set(x => x.CreditLimit, customer.CreditLimit) + .Set(x => x.PaymentTermDays, customer.PaymentTermDays) .Set(x => x.Attributes, customer.Attributes); await _customerRepository.UpdateOneAsync(x => x.Id == customer.Id, update); diff --git a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrder.cs b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrder.cs index 0e9151c773..df08b826ab 100644 --- a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrder.cs +++ b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrder.cs @@ -7,6 +7,7 @@ using Grand.Domain.Stores; using Grand.Domain.Vendors; using Grand.SharedKernel.Extensions; +using System.Net; namespace Grand.Business.Messages.DotLiquidDrops; @@ -46,6 +47,11 @@ public LiquidOrder(Order order, Customer customer, Language language, Currency c public string OrderCode => _order.Code; + public string PurchaseOrderNumber => WebUtility.HtmlEncode(_order.PurchaseOrderNumber ?? ""); + + //the due date is a calendar date, so it is shown as stored, without a time zone shift + public string PaymentDueDate => _order.PaymentDueDateUtc?.ToString("D") ?? ""; + public string CustomerFullName => $"{_order.BillingAddress.FirstName} {_order.BillingAddress.LastName}"; public string CustomerEmail => _order.BillingAddress.Email; diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index c64c76dea7..333fb5f8ce 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -114,6 +114,18 @@ public Customer() /// public double? OrderApprovalLimit { get; set; } + /// + /// Gets or sets the trade credit limit of a company owner, in the primary store currency; + /// null means no trade credit + /// + public double? CreditLimit { get; set; } + + /// + /// Gets or sets the payment term, in days, of a company owner's orders bought on credit; + /// null means no trade credit + /// + public int? PaymentTermDays { get; set; } + /// /// Gets or sets the sales employee identifier /// diff --git a/src/Core/Grand.Domain/Orders/Order.cs b/src/Core/Grand.Domain/Orders/Order.cs index f4116b0eda..560169310f 100644 --- a/src/Core/Grand.Domain/Orders/Order.cs +++ b/src/Core/Grand.Domain/Orders/Order.cs @@ -38,6 +38,11 @@ public class Order : BaseEntity /// public string Code { get; set; } + /// + /// Gets or sets the buyer's own purchase order number + /// + public string PurchaseOrderNumber { get; set; } + /// /// Gets or sets the store identifier /// @@ -209,6 +214,11 @@ public class Order : BaseEntity /// public DateTime? PaidDateUtc { get; set; } + /// + /// Gets or sets the date the payment is due; set only when the order is bought on credit + /// + public DateTime? PaymentDueDateUtc { get; set; } + /// /// Gets or sets the refunded amount /// diff --git a/src/Core/Grand.Domain/Orders/PurchaseOrderNumberMode.cs b/src/Core/Grand.Domain/Orders/PurchaseOrderNumberMode.cs new file mode 100644 index 0000000000..38facdd478 --- /dev/null +++ b/src/Core/Grand.Domain/Orders/PurchaseOrderNumberMode.cs @@ -0,0 +1,22 @@ +namespace Grand.Domain.Orders; + +/// +/// Whether the checkout asks for the buyer's purchase order number +/// +public enum PurchaseOrderNumberMode +{ + /// + /// The checkout does not ask for it + /// + Disabled = 0, + + /// + /// The buyer may enter it + /// + Optional = 10, + + /// + /// The buyer has to enter it + /// + Required = 20 +} diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index a1a083ee90..388c804599 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -898,6 +898,10 @@ await dbContext.CreateIndex(_orderRepository, OrderBuilder.Create().Ascen "OrderItemProductId"); await dbContext.CreateIndex(_orderRepository, OrderBuilder.Create().Ascending("OrderItems.Id"), "OrderItems._id"); + //a company's orders bought on credit - their unpaid total is the credit already used + await dbContext.CreateIndex(_orderRepository, + OrderBuilder.Create().Ascending(x => x.OwnerId).Ascending(x => x.PaymentDueDateUtc), + "OwnerId_PaymentDueDateUtc"); await dbContext.CreateIndex(_orderStatusRepository, OrderBuilder.Create().Ascending(x => x.StatusId), "StatusId", true); diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderPaymentTerms.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderPaymentTerms.cs new file mode 100644 index 0000000000..0443693e27 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderPaymentTerms.cs @@ -0,0 +1,40 @@ +using Grand.Data; +using Grand.Domain.Orders; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationOrderPaymentTerms : IMigration +{ + private const string IndexName = "OwnerId_PaymentDueDateUtc"; + + public int Priority => 1; + public DbVersion Version => new(2, 5); + public Guid Identity => new("c996c2ad-f2c5-4ca5-b3e8-ff43c9f6a42c"); + public string Name => "Index on Order owner and payment due date 2.5"; + + public async Task UpgradeProcess(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + //the trade credit a company has used is the unpaid total of its orders bought on credit, summed on + //every checkout that offers paying by invoice + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.OwnerId).Ascending(x => x.PaymentDueDateUtc), + IndexName); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - OrderPaymentTerms (2.5)"); + return false; + } + + return true; + } +} diff --git a/src/Plugins/Payments.OnAccount/Areas/Admin/Controllers/PaymentOnAccountController.cs b/src/Plugins/Payments.OnAccount/Areas/Admin/Controllers/PaymentOnAccountController.cs new file mode 100644 index 0000000000..3c8be96f2e --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Admin/Controllers/PaymentOnAccountController.cs @@ -0,0 +1,72 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Domain.Permissions; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Helpers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.OnAccount.Models; + +namespace Payments.OnAccount.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area("Admin")] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentOnAccountController : BasePaymentController +{ + private readonly IAdminStoreService _adminStoreService; + private readonly ISettingService _settingService; + private readonly IRequestTranslationService _translationService; + + public PaymentOnAccountController( + ISettingService settingService, + IRequestTranslationService translationService, + IAdminStoreService adminStoreService) + { + _settingService = settingService; + _translationService = translationService; + _adminStoreService = adminStoreService; + } + + public async Task Configure() + { + //load settings for a chosen store scope + var storeScope = await _adminStoreService.GetActiveStore(); + var settings = await _settingService.LoadSetting(storeScope); + + var model = new ConfigurationModel { + BlockWhenOverdue = settings.BlockWhenOverdue, + DisplayOrder = settings.DisplayOrder, + PurchaseOrderNumberMode = settings.PurchaseOrderNumberMode, + ActiveStore = storeScope + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + //load settings for a chosen store scope + var storeScope = await _adminStoreService.GetActiveStore(); + var settings = await _settingService.LoadSetting(storeScope); + + settings.BlockWhenOverdue = model.BlockWhenOverdue; + settings.DisplayOrder = model.DisplayOrder; + settings.PurchaseOrderNumberMode = model.PurchaseOrderNumberMode; + + await _settingService.SaveSetting(settings, storeScope); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Payments.OnAccount/Areas/Admin/Views/PaymentOnAccount/Configure.cshtml b/src/Plugins/Payments.OnAccount/Areas/Admin/Views/PaymentOnAccount/Configure.cshtml new file mode 100644 index 0000000000..bed3d5164a --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Admin/Views/PaymentOnAccount/Configure.cshtml @@ -0,0 +1,27 @@ +@{ + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Plugins.Payments.OnAccount.FriendlyName"]; +} +@model Payments.OnAccount.Models.ConfigurationModel +@inject IEnumTranslationService EnumTranslationService + +@* the credit limit and payment term are set on each company account; here only how the method behaves *@ +
+ + + + + + @await Component.InvokeAsync("StoreScope") + + +
+ + + +
+
+
+
diff --git a/src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewImports.cshtml b/src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..4a79413f8d --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc \ No newline at end of file diff --git a/src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewStart.cshtml b/src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewStart.cshtml new file mode 100644 index 0000000000..734b482f05 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = ""; +} \ No newline at end of file diff --git a/src/Plugins/Payments.OnAccount/Areas/Store/Controllers/PaymentOnAccountController.cs b/src/Plugins/Payments.OnAccount/Areas/Store/Controllers/PaymentOnAccountController.cs new file mode 100644 index 0000000000..ca6aa32a4e --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Store/Controllers/PaymentOnAccountController.cs @@ -0,0 +1,77 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.OnAccount.Models; + +namespace Payments.OnAccount.Areas.Store.Controllers; + +/// +/// Store-manager configuration of paying by invoice, read and written for the store the manager is bound to +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentOnAccountController : BasePaymentController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ISettingService _settingService; + private readonly IRequestTranslationService _translationService; + + public PaymentOnAccountController( + ISettingService settingService, + IRequestTranslationService translationService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current store manager is bound to; AuthorizeStore rejects a customer without one + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + var settings = await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + BlockWhenOverdue = settings.BlockWhenOverdue, + DisplayOrder = settings.DisplayOrder, + PurchaseOrderNumberMode = settings.PurchaseOrderNumberMode, + ActiveStore = CurrentStoreId + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var settings = await _settingService.LoadSetting(CurrentStoreId); + + settings.BlockWhenOverdue = model.BlockWhenOverdue; + settings.DisplayOrder = model.DisplayOrder; + settings.PurchaseOrderNumberMode = model.PurchaseOrderNumberMode; + + await _settingService.SaveSetting(settings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Payments.OnAccount/Areas/Store/Views/PaymentOnAccount/Configure.cshtml b/src/Plugins/Payments.OnAccount/Areas/Store/Views/PaymentOnAccount/Configure.cshtml new file mode 100644 index 0000000000..5eb06ed3e8 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Store/Views/PaymentOnAccount/Configure.cshtml @@ -0,0 +1,26 @@ +@model Payments.OnAccount.Models.ConfigurationModel +@inject IEnumTranslationService EnumTranslationService +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +@* the credit limit and payment term are set on each company account; here only how the method behaves *@ +
+ + + + + + +
+ + + +
+
+
+
diff --git a/src/Plugins/Payments.OnAccount/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.OnAccount/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.OnAccount/Manifest.cs b/src/Plugins/Payments.OnAccount/Manifest.cs new file mode 100644 index 0000000000..c340e1a977 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Manifest.cs @@ -0,0 +1,10 @@ +using Grand.Infrastructure.Plugins; +using Payments.OnAccount; + +[assembly: PluginInfo( + FriendlyName = "Pay by invoice (trade credit)", + Group = "Payment methods", + SystemName = OnAccountPaymentDefaults.ProviderSystemName, + Author = "grandnode team", + Version = "2.5.0" +)] diff --git a/src/Plugins/Payments.OnAccount/Models/ConfigurationModel.cs b/src/Plugins/Payments.OnAccount/Models/ConfigurationModel.cs new file mode 100644 index 0000000000..641c0a5cd8 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Models/ConfigurationModel.cs @@ -0,0 +1,19 @@ +using Grand.Domain.Orders; +using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.Models; + +namespace Payments.OnAccount.Models; + +public class ConfigurationModel : BaseModel +{ + public string ActiveStore { get; set; } + + [GrandResourceDisplayName("Plugins.Payments.OnAccount.BlockWhenOverdue")] + public bool BlockWhenOverdue { get; set; } + + [GrandResourceDisplayName("Plugins.Payments.OnAccount.DisplayOrder")] + public int DisplayOrder { get; set; } + + [GrandResourceDisplayName("Plugins.Payments.OnAccount.PurchaseOrderNumberMode")] + public PurchaseOrderNumberMode PurchaseOrderNumberMode { get; set; } +} diff --git a/src/Plugins/Payments.OnAccount/OnAccountPaymentDefaults.cs b/src/Plugins/Payments.OnAccount/OnAccountPaymentDefaults.cs new file mode 100644 index 0000000000..c4449a8cdc --- /dev/null +++ b/src/Plugins/Payments.OnAccount/OnAccountPaymentDefaults.cs @@ -0,0 +1,8 @@ +namespace Payments.OnAccount; + +public static class OnAccountPaymentDefaults +{ + public const string ProviderSystemName = "Payments.OnAccount"; + public const string FriendlyName = "Plugins.Payments.OnAccount.FriendlyName"; + public const string ConfigurationUrl = "/Admin/PaymentOnAccount/Configure"; +} diff --git a/src/Plugins/Payments.OnAccount/OnAccountPaymentPlugin.cs b/src/Plugins/Payments.OnAccount/OnAccountPaymentPlugin.cs new file mode 100644 index 0000000000..6708301659 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/OnAccountPaymentPlugin.cs @@ -0,0 +1,65 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Infrastructure.Plugins; + +namespace Payments.OnAccount; + +/// +/// Pay by invoice on the company's trade credit +/// +public class OnAccountPaymentPlugin( + ISettingService settingService, + IPluginTranslateResource pluginTranslateResource) + : BasePlugin, IPlugin +{ + private static readonly Dictionary Resources = new() { + { OnAccountPaymentDefaults.FriendlyName, "Pay by invoice (trade credit)" }, + { + "Plugins.Payments.OnAccount.Description", + "Pay by invoice: your company pays within its payment term (trade credit)." + }, + { "Plugins.Payments.OnAccount.CreditExceeded", "This order exceeds your company's available trade credit." }, + { "Plugins.Payments.OnAccount.BlockWhenOverdue", "Block when a payment is overdue" }, + { + "Plugins.Payments.OnAccount.BlockWhenOverdue.Hint", + "A company with an overdue payment cannot buy on credit until it pays." + }, + { "Plugins.Payments.OnAccount.DisplayOrder", "Display order" }, + { "Plugins.Payments.OnAccount.DisplayOrder.Hint", "The order in which the payment method is listed at checkout." }, + { "Plugins.Payments.OnAccount.PurchaseOrderNumberMode", "Purchase order number" }, + { + "Plugins.Payments.OnAccount.PurchaseOrderNumberMode.Hint", + "Whether the checkout asks buyers paying by invoice for their own purchase order (PO) number. It is stored on the order and shown on the invoice." + } + }; + + #region Methods + + /// + /// Gets a configuration page URL + /// + public override string ConfigurationUrl() + { + return OnAccountPaymentDefaults.ConfigurationUrl; + } + + public override async Task Install() + { + await settingService.SaveSetting(new OnAccountPaymentSettings()); + foreach (var (name, value) in Resources) + await pluginTranslateResource.AddOrUpdatePluginTranslateResource(name, value); + + await base.Install(); + } + + public override async Task Uninstall() + { + await settingService.DeleteSetting(); + foreach (var name in Resources.Keys) + await pluginTranslateResource.DeletePluginTranslationResource(name); + + await base.Uninstall(); + } + + #endregion +} diff --git a/src/Plugins/Payments.OnAccount/OnAccountPaymentProvider.cs b/src/Plugins/Payments.OnAccount/OnAccountPaymentProvider.cs new file mode 100644 index 0000000000..8bac033b20 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/OnAccountPaymentProvider.cs @@ -0,0 +1,200 @@ +using Grand.Business.Core.Enums.Checkout; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Infrastructure; +using Grand.Web.Common.Localization; + +namespace Payments.OnAccount; + +/// +/// Pay by invoice: the order is placed on the company's trade credit and paid later, within the payment term. +/// The method is offered only while the credit covers the cart, and the credit is checked again when the order +/// is placed, so a hidden method posted anyway is refused. +/// +public class OnAccountPaymentProvider : IPaymentProvider +{ + private readonly ICompanyCreditService _companyCreditService; + private readonly IContextAccessor _contextAccessor; + private readonly ICurrencyService _currencyService; + private readonly ICustomerService _customerService; + private readonly IShoppingCartSubTotalService _shoppingCartSubTotalService; + private readonly IPaymentTransactionService _paymentTransactionService; + private readonly OnAccountPaymentSettings _onAccountPaymentSettings; + private readonly IRequestTranslationService _translationService; + + public OnAccountPaymentProvider( + IRequestTranslationService translationService, + ICompanyCreditService companyCreditService, + ICustomerService customerService, + IShoppingCartSubTotalService shoppingCartSubTotalService, + ICurrencyService currencyService, + IContextAccessor contextAccessor, + IPaymentTransactionService paymentTransactionService, + OnAccountPaymentSettings onAccountPaymentSettings) + { + _translationService = translationService; + _companyCreditService = companyCreditService; + _customerService = customerService; + _shoppingCartSubTotalService = shoppingCartSubTotalService; + _currencyService = currencyService; + _contextAccessor = contextAccessor; + _paymentTransactionService = paymentTransactionService; + _onAccountPaymentSettings = onAccountPaymentSettings; + } + + public string ConfigurationUrl => OnAccountPaymentDefaults.ConfigurationUrl; + + public string SystemName => OnAccountPaymentDefaults.ProviderSystemName; + + public string FriendlyName => _translationService.GetResource(OnAccountPaymentDefaults.FriendlyName); + + public string GetFriendlyName(string languageId) => + _translationService.GetResource(OnAccountPaymentDefaults.FriendlyName, languageId, returnEmptyIfNotFound: true); + + public int Priority => _onAccountPaymentSettings.DisplayOrder; + + public IList LimitedToStores => new List(); + + public IList LimitedToGroups => new List(); + + public Task InitPaymentTransaction() + { + return Task.FromResult(null); + } + + public async Task ProcessPayment(PaymentTransaction paymentTransaction) + { + var result = new ProcessPaymentResult { + NewPaymentTransactionStatus = TransactionStatus.Pending + }; + + var customer = await _customerService.GetCustomerById(paymentTransaction.CustomerId); + var credit = customer != null ? await _companyCreditService.GetCompanyCredit(customer) : null; + //the transaction amount is in the order's currency, the credit limit in the primary store currency + var rate = paymentTransaction.CurrencyRate > 0 ? paymentTransaction.CurrencyRate : 1; + if (credit == null || + !credit.Covers(paymentTransaction.TransactionAmount / rate, _onAccountPaymentSettings.BlockWhenOverdue)) + { + result.AddError(_translationService.GetResource("Plugins.Payments.OnAccount.CreditExceeded")); + return result; + } + + //the due date is a calendar date, counted from the day the order is placed + result.PaymentDueDateUtc = DateTime.UtcNow.Date.AddDays(credit.PaymentTermDays!.Value); + return result; + } + + public Task PostProcessPayment(PaymentTransaction paymentTransaction) + { + //nothing + return Task.CompletedTask; + } + + public Task PostRedirectPayment(PaymentTransaction paymentTransaction) + { + //nothing + return Task.FromResult(string.Empty); + } + + public async Task HidePaymentMethod(IList cart) + { + var workContext = _contextAccessor.WorkContext; + var credit = await _companyCreditService.GetCompanyCredit(workContext.CurrentCustomer); + if (!credit.Enabled) + return true; + + //the subtotal (with discounts and tax) decides here: a payment provider cannot depend on the order + //total, which is calculated through the payment service. Shipping and fees are not in it, so the exact + //amount is checked again when the order is placed + var subtotal = await _shoppingCartSubTotalService.GetShoppingCartSubTotal(cart, + new CalculationContext(workContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore, + workContext.WorkingCurrency, workContext.TaxDisplayType), true); + var amount = await _currencyService.ConvertToPrimaryStoreCurrency(subtotal.SubTotalWithDiscount, + workContext.WorkingCurrency); + + return !credit.Covers(amount, _onAccountPaymentSettings.BlockWhenOverdue); + } + + public Task GetAdditionalHandlingFee(IList cart) + { + return Task.FromResult(0d); + } + + public Task> ValidatePaymentForm(IDictionary model) + { + return Task.FromResult>(new List()); + } + + public Task SavePaymentInfo(IDictionary model) + { + return Task.FromResult(null); + } + + public Task GetControllerRouteName() + { + //the payment info step is always skipped (see SkipPaymentInfo), so there is no payment info page + return Task.FromResult(string.Empty); + } + + public Task Capture(PaymentTransaction paymentTransaction) + { + var result = new CapturePaymentResult(); + result.AddError("Capture method not supported"); + return Task.FromResult(result); + } + + public Task CanRePostRedirectPayment(PaymentTransaction paymentTransaction) + { + ArgumentNullException.ThrowIfNull(paymentTransaction); + + //it's not a redirection payment method + return Task.FromResult(false); + } + + public Task Refund(RefundPaymentRequest refundPaymentRequest) + { + var result = new RefundPaymentResult(); + result.AddError("Refund method not supported"); + return Task.FromResult(result); + } + + public Task Void(PaymentTransaction paymentTransaction) + { + var result = new VoidPaymentResult(); + result.AddError("Void method not supported"); + return Task.FromResult(result); + } + + public async Task CancelPayment(PaymentTransaction paymentTransaction) + { + paymentTransaction.TransactionStatus = TransactionStatus.Canceled; + await _paymentTransactionService.UpdatePaymentTransaction(paymentTransaction); + } + + public Task SupportCapture() => Task.FromResult(false); + + public Task SupportPartiallyRefund() => Task.FromResult(false); + + public Task SupportRefund() => Task.FromResult(false); + + public Task SupportVoid() => Task.FromResult(false); + + public PaymentMethodType PaymentMethodType => PaymentMethodType.Standard; + + public PurchaseOrderNumberMode PurchaseOrderNumberMode => _onAccountPaymentSettings.PurchaseOrderNumberMode; + + public Task SkipPaymentInfo() => Task.FromResult(true); + + public Task Description() + { + //the available credit is not printed: it changes with every order and would be stale here + return Task.FromResult(_translationService.GetResource("Plugins.Payments.OnAccount.Description")); + } + + public string LogoURL => "/Plugins/Payments.OnAccount/logo.svg"; +} diff --git a/src/Plugins/Payments.OnAccount/OnAccountPaymentSettings.cs b/src/Plugins/Payments.OnAccount/OnAccountPaymentSettings.cs new file mode 100644 index 0000000000..a45e246da9 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/OnAccountPaymentSettings.cs @@ -0,0 +1,19 @@ +using Grand.Domain.Configuration; +using Grand.Domain.Orders; + +namespace Payments.OnAccount; + +public class OnAccountPaymentSettings : ISettings +{ + public int DisplayOrder { get; set; } + + /// + /// Gets or sets a value indicating whether a company with an overdue payment can no longer buy on credit + /// + public bool BlockWhenOverdue { get; set; } = true; + + /// + /// Gets or sets whether the checkout asks for the buyer's purchase order number with this method + /// + public PurchaseOrderNumberMode PurchaseOrderNumberMode { get; set; } +} diff --git a/src/Plugins/Payments.OnAccount/Payments.OnAccount.csproj b/src/Plugins/Payments.OnAccount/Payments.OnAccount.csproj new file mode 100644 index 0000000000..823ec9f1b1 --- /dev/null +++ b/src/Plugins/Payments.OnAccount/Payments.OnAccount.csproj @@ -0,0 +1,26 @@ + + + + + true + false + + + + ..\..\Web\Grand.Web\Plugins\Payments.OnAccount\ + $(OutputPath) + + + + ..\..\Web\Grand.Web\Plugins\Payments.OnAccount\ + $(OutputPath) + + + + + + Always + + + + \ No newline at end of file diff --git a/src/Plugins/Payments.OnAccount/StartupApplication.cs b/src/Plugins/Payments.OnAccount/StartupApplication.cs new file mode 100644 index 0000000000..202c87fc1b --- /dev/null +++ b/src/Plugins/Payments.OnAccount/StartupApplication.cs @@ -0,0 +1,24 @@ +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Payments.OnAccount; + +public class StartupApplication : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + } + + public int Priority => 10; + + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) + { + } + + public bool BeforeConfigure => false; +} diff --git a/src/Plugins/Payments.OnAccount/logo.svg b/src/Plugins/Payments.OnAccount/logo.svg new file mode 100644 index 0000000000..3f5b80669b --- /dev/null +++ b/src/Plugins/Payments.OnAccount/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs new file mode 100644 index 0000000000..be15d049ef --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs @@ -0,0 +1,203 @@ +using Grand.Business.Checkout.Commands.Handlers.Orders; +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Domain.Shipping; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Commands.Handlers.Orders; + +[TestClass] +public class PlaceOrderCommandHandlerPurchaseOrderTests +{ + private const string RequiredText = "Enter your purchase order number."; + private PlaceOrderCommand _command; + private PlaceOrderContainer _details; + private TestHandler _handler; + private Mock _provider; + + [TestInitialize] + public void Init() + { + var approvalService = new Mock(); + var translation = new Mock(); + translation.Setup(x => x.GetResource("Checkout.PurchaseOrderNumber.Required", "pl", It.IsAny(), + It.IsAny())).Returns(RequiredText); + + var customer = new Customer { Id = "buyer", OwnerId = "owner" }; + var store = new Store { Id = "store" }; + var context = new CalculationContext(customer, store, new Currency { CurrencyCode = "EUR", Rate = 1 }, + TaxDisplayType.IncludingTax); + _command = new PlaceOrderCommand { + Context = context, + Language = new Language { Id = "pl" }, + Host = new DomainHost { Url = "https://shop.example/" } + }; + _details = new PlaceOrderContainer { + Context = context, + Customer = customer, + Store = store, + Language = new Language { Id = "pl" }, + Currency = new Currency { CurrencyCode = "EUR", Rate = 1 }, + PrimaryCurrencyCode = "EUR", + PaymentMethodSystemName = "Payments.Card", + OrderTotal = 500 + }; + approvalService.Setup(x => x.RequiresApproval(_details.Cart, _command.Context)) + .ReturnsAsync(new OrderApprovalCheckoutState(false, null, 500)); + _provider = new Mock(); + var paymentService = new Mock(); + paymentService.Setup(x => x.LoadPaymentMethodBySystemName("Payments.Card")).Returns(_provider.Object); + + _handler = new TestHandler(_details, approvalService, new Mock(), translation, paymentService); + } + + [TestMethod] + public async Task Required_Missing_FailsWithoutCallingPaymentProvider() + { + _provider.Setup(x => x.PurchaseOrderNumberMode).Returns(PurchaseOrderNumberMode.Required); + _command.PurchaseOrderNumber = " "; + + var result = await _handler.Handle(_command, CancellationToken.None); + + CollectionAssert.Contains(result.Errors.ToList(), RequiredText); + Assert.IsNull(result.PlacedOrder); + Assert.AreEqual(0, _handler.PaymentCalls); + } + + [TestMethod] + public async Task Optional_Given_StoredTrimmedOnOrder() + { + _provider.Setup(x => x.PurchaseOrderNumberMode).Returns(PurchaseOrderNumberMode.Optional); + _command.PurchaseOrderNumber = " PO-2026/17 "; + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual("PO-2026/17", _handler.LastHeader.PurchaseOrderNumber); + } + + [TestMethod] + public async Task Disabled_Given_NotStored() + { + _command.PurchaseOrderNumber = "PO-1"; + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.IsNull(_handler.LastHeader.PurchaseOrderNumber); + } + + [TestMethod] + public async Task NoPaymentMethod_Given_NotStored() + { + _details.PaymentMethodSystemName = string.Empty; + _command.PurchaseOrderNumber = "PO-1"; + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.IsNull(_handler.LastHeader.PurchaseOrderNumber); + } + + [TestMethod] + public async Task PaymentDueDate_FromProcessPaymentResult_StoredOnOrder() + { + var due = new DateTime(2026, 11, 13, 0, 0, 0, DateTimeKind.Utc); + _handler.PaymentDueDateUtc = due; + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual(due, _handler.LastHeader.PaymentDueDateUtc); + } + + [TestMethod] + public async Task NoDueDate_OrderNotOnCredit() + { + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.IsNull(_handler.LastHeader.PaymentDueDateUtc); + } + + private class TestHandler : PlaceOrderCommandHandler + { + private readonly PlaceOrderContainer _details; + + public TestHandler(PlaceOrderContainer details, Mock approval, Mock mediator, + Mock translation, Mock paymentService) : base(new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, paymentService.Object, + new Mock().Object, new Mock>().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, mediator.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, approval.Object, + translation.Object, new ShippingSettings(), new ShoppingCartSettings(), new PaymentSettings(), + new OrderSettings(), new OidcLoginConfig(), new TaxSettings()) + { + _details = details; + } + + public int PaymentCalls { get; private set; } + public DateTime? PaymentDueDateUtc { get; set; } + public Order LastHeader { get; private set; } + + protected override Task PreparePlaceOrderDetails(PlaceOrderCommand command) + { + return Task.FromResult(_details); + } + + protected override Task<(ProcessPaymentResult paymentResult, PaymentTransaction paymentTransaction)> + PrepareProcessPayment(PlaceOrderContainer details) + { + PaymentCalls++; + return Task.FromResult(( + new ProcessPaymentResult { PaymentDueDateUtc = PaymentDueDateUtc }, + new PaymentTransaction { + Id = "tx", StoreId = "store", PaymentMethodSystemName = details.PaymentMethodSystemName + })); + } + + protected override Order PrepareOrderHeader(PaymentTransaction paymentTransaction, + ProcessPaymentResult processPaymentResult, PlaceOrderContainer details) + { + LastHeader = base.PrepareOrderHeader(paymentTransaction, processPaymentResult, details); + return LastHeader; + } + + protected override Task SaveOrderDetails(PlaceOrderContainer details, Order order) + { + return Task.FromResult(order); + } + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/GetOrderQueryHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/GetOrderQueryHandlerTests.cs index 49e7dfa634..acbdf3a113 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/GetOrderQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/GetOrderQueryHandlerTests.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Queries.Checkout.Orders; using Grand.Data.Tests.MongoDb; using Grand.Domain.Orders; +using Grand.Domain.Payments; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Grand.Business.Checkout.Tests.Queries.Handlers.Orders; @@ -39,4 +40,55 @@ public async Task Handle_FiltersByCustomerAndPages() Assert.AreEqual(2, result.Count); Assert.AreEqual("NEWEST", result[0].Code); } + + [TestMethod] + public async Task Handle_FiltersByPurchaseOrderNumber() + { + await _repository.Collection.InsertOneAsync(new Order { PurchaseOrderNumber = "PO-1", Code = "A" }); + await _repository.Collection.InsertOneAsync(new Order { PurchaseOrderNumber = "PO-10", Code = "B" }); + await _repository.Collection.InsertOneAsync(new Order { Code = "C" }); + + var result = await _handler.Handle(new GetOrderQuery { PurchaseOrderNumber = " PO-1 " }, + CancellationToken.None); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("A", result[0].Code); + } + + [TestMethod] + public async Task Handle_OverdueOnly() + { + var now = DateTime.UtcNow; + await _repository.Collection.InsertOneAsync(new Order { + Code = "OVERDUE", PaymentDueDateUtc = now.AddDays(-1), PaymentStatusId = PaymentStatus.Pending + }); + await _repository.Collection.InsertOneAsync(new Order { + Code = "PAID", PaymentDueDateUtc = now.AddDays(-1), PaymentStatusId = PaymentStatus.Paid + }); + await _repository.Collection.InsertOneAsync(new Order { + Code = "CANCELLED", PaymentDueDateUtc = now.AddDays(-1), PaymentStatusId = PaymentStatus.Pending, + OrderStatusId = (int)OrderStatusSystem.Cancelled + }); + await _repository.Collection.InsertOneAsync(new Order { + Code = "NOTDUE", PaymentDueDateUtc = now.AddDays(1), PaymentStatusId = PaymentStatus.Pending + }); + await _repository.Collection.InsertOneAsync(new Order { Code = "NOCREDIT", PaymentStatusId = PaymentStatus.Pending }); + + var result = await _handler.Handle(new GetOrderQuery { OverdueOnly = true }, CancellationToken.None); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("OVERDUE", result[0].Code); + } + + [TestMethod] + public async Task Handle_OverdueOnly_DueTodayIsNotListed() + { + await _repository.Collection.InsertOneAsync(new Order { + Code = "DUETODAY", PaymentDueDateUtc = DateTime.UtcNow.Date, PaymentStatusId = PaymentStatus.Pending + }); + + var result = await _handler.Handle(new GetOrderQuery { OverdueOnly = true }, CancellationToken.None); + + Assert.AreEqual(0, result.Count); + } } diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/CompanyCreditServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/CompanyCreditServiceTests.cs new file mode 100644 index 0000000000..a57bec71a3 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/CompanyCreditServiceTests.cs @@ -0,0 +1,233 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +[TestClass] +public class CompanyCreditServiceTests +{ + private const string OwnerId = "owner1"; + + private Mock _customerServiceMock; + private IRepository _orderRepository; + private CompanyCreditService _service; + + [TestInitialize] + public void Init() + { + _orderRepository = new MongoDBRepositoryTest(); + _customerServiceMock = new Mock(); + _service = new CompanyCreditService(_orderRepository, _customerServiceMock.Object); + } + + private async Task AddOrder(double total, double rate = 1, double paid = 0, + PaymentStatus paymentStatus = PaymentStatus.Pending, string ownerId = OwnerId, + DateTime? dueDateUtc = null, bool onCredit = true, bool deleted = false, + OrderStatusSystem orderStatus = OrderStatusSystem.Pending) + { + var order = new Order { + OwnerId = ownerId, + OrderTotal = total, + CurrencyRate = rate, + PaidAmount = paid, + PaymentStatusId = paymentStatus, + OrderStatusId = (int)orderStatus, + Deleted = deleted, + PaymentDueDateUtc = onCredit ? dueDateUtc ?? DateTime.UtcNow.AddDays(14) : null + }; + await _orderRepository.InsertAsync(order); + return order; + } + + #region Outstanding amount + + [TestMethod] + public async Task GetOutstandingAmount_SumsUnpaidCreditOrdersInPrimaryCurrency() + { + await AddOrder(100); + await AddOrder(400, 4); + await AddOrder(50, paid: 20, paymentStatus: PaymentStatus.PartiallyPaid); + + var outstanding = await _service.GetOutstandingAmount(OwnerId); + + Assert.AreEqual(230, outstanding, 0.0001); + } + + [TestMethod] + public async Task GetOutstandingAmount_IgnoresPaidCancelledDeletedAndNonCreditOrders() + { + await AddOrder(100, paid: 100, paymentStatus: PaymentStatus.Paid); + await AddOrder(100, orderStatus: OrderStatusSystem.Cancelled); + await AddOrder(100, deleted: true); + await AddOrder(100, onCredit: false); + + var outstanding = await _service.GetOutstandingAmount(OwnerId); + + Assert.AreEqual(0, outstanding); + } + + [TestMethod] + public async Task GetOutstandingAmount_IgnoresOtherCompanies() + { + await AddOrder(100, ownerId: "owner2"); + await AddOrder(70); + + var outstanding = await _service.GetOutstandingAmount(OwnerId); + + Assert.AreEqual(70, outstanding); + } + + #endregion + + #region Overdue + + [TestMethod] + public async Task HasOverdue_TrueOnlyWhenUnpaidAndPastDue() + { + var now = DateTime.UtcNow; + await AddOrder(100, dueDateUtc: now.AddDays(-1), paid: 100, paymentStatus: PaymentStatus.Paid); + await AddOrder(100, dueDateUtc: now.AddDays(1)); + Assert.IsFalse(await _service.HasOverdue(OwnerId, now)); + + await AddOrder(100, dueDateUtc: now.AddDays(-1)); + Assert.IsTrue(await _service.HasOverdue(OwnerId, now)); + } + + [TestMethod] + [DataRow(-1, PaymentStatus.Pending, OrderStatusSystem.Pending, true)] + [DataRow(-1, PaymentStatus.PartiallyPaid, OrderStatusSystem.Processing, true)] + [DataRow(-1, PaymentStatus.Paid, OrderStatusSystem.Complete, false)] + [DataRow(1, PaymentStatus.Pending, OrderStatusSystem.Pending, false)] + [DataRow(-1, PaymentStatus.Pending, OrderStatusSystem.Cancelled, false)] + public void IsPaymentOverdue(int dueInDays, PaymentStatus paymentStatus, OrderStatusSystem orderStatus, + bool expected) + { + var now = DateTime.UtcNow; + var order = new Order { + PaymentDueDateUtc = now.AddDays(dueInDays), + PaymentStatusId = paymentStatus, + OrderStatusId = (int)orderStatus + }; + + Assert.AreEqual(expected, order.IsPaymentOverdue(now)); + } + + [TestMethod] + public void IsPaymentOverdue_OnTheDueDate_NotYetOverdue() + { + //the due date is a calendar day: the buyer has the whole of it to pay + var order = new Order { + PaymentDueDateUtc = new DateTime(2026, 11, 13, 0, 0, 0, DateTimeKind.Utc), + PaymentStatusId = PaymentStatus.Pending + }; + + Assert.IsFalse(order.IsPaymentOverdue(new DateTime(2026, 11, 13, 23, 59, 0, DateTimeKind.Utc))); + Assert.IsTrue(order.IsPaymentOverdue(new DateTime(2026, 11, 14, 0, 1, 0, DateTimeKind.Utc))); + } + + [TestMethod] + public async Task HasOverdue_OnTheDueDate_False() + { + var dueDate = new DateTime(2026, 11, 13, 0, 0, 0, DateTimeKind.Utc); + await AddOrder(100, dueDateUtc: dueDate); + + Assert.IsFalse(await _service.HasOverdue(OwnerId, dueDate.AddHours(15))); + Assert.IsTrue(await _service.HasOverdue(OwnerId, dueDate.AddDays(1).AddHours(1))); + } + + [TestMethod] + public void IsPaymentOverdue_NotOnCredit_False() + { + var order = new Order { PaymentStatusId = PaymentStatus.Pending }; + + Assert.IsFalse(order.IsPaymentOverdue(DateTime.UtcNow)); + } + + #endregion + + #region Company credit + + [TestMethod] + public async Task GetCompanyCredit_SubAccountUsesOwnersNumbers() + { + var owner = new Customer { Id = OwnerId, Active = true, CreditLimit = 1000, PaymentTermDays = 30 }; + _customerServiceMock.Setup(x => x.GetCustomerById(OwnerId)).ReturnsAsync(owner); + await AddOrder(250); + + var credit = await _service.GetCompanyCredit(new Customer { Id = "buyer1", OwnerId = OwnerId, Active = true }); + + Assert.IsTrue(credit.Enabled); + Assert.AreEqual(OwnerId, credit.CompanyOwnerId); + Assert.AreEqual(1000, credit.CreditLimit); + Assert.AreEqual(30, credit.PaymentTermDays); + Assert.AreEqual(250, credit.OutstandingAmount); + Assert.AreEqual(750, credit.Available); + } + + [TestMethod] + public async Task GetCompanyCredit_OwnerUsesOwnNumbers() + { + var owner = new Customer { Id = OwnerId, Active = true, CreditLimit = 500, PaymentTermDays = 14 }; + + var credit = await _service.GetCompanyCredit(owner); + + Assert.IsTrue(credit.Enabled); + Assert.AreEqual(OwnerId, credit.CompanyOwnerId); + _customerServiceMock.Verify(x => x.GetCustomerById(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetCompanyCredit_InactiveOwner_Disabled() + { + var owner = new Customer { Id = OwnerId, Active = false, CreditLimit = 1000, PaymentTermDays = 30 }; + _customerServiceMock.Setup(x => x.GetCustomerById(OwnerId)).ReturnsAsync(owner); + + var credit = await _service.GetCompanyCredit(new Customer { Id = "buyer1", OwnerId = OwnerId, Active = true }); + + Assert.IsFalse(credit.Enabled); + Assert.IsNull(credit.CreditLimit); + Assert.IsNull(credit.PaymentTermDays); + } + + [TestMethod] + public async Task GetCompanyCredit_MissingOwner_Disabled() + { + var credit = await _service.GetCompanyCredit(new Customer { Id = "buyer1", OwnerId = OwnerId, Active = true }); + + Assert.IsFalse(credit.Enabled); + } + + [TestMethod] + public async Task GetCompanyCredit_OnlyLimitSet_Disabled() + { + var credit = await _service.GetCompanyCredit(new Customer { Id = OwnerId, Active = true, CreditLimit = 1000 }); + + Assert.IsFalse(credit.Enabled); + Assert.AreEqual(0, credit.Available); + } + + [TestMethod] + [DataRow(1000d, 900d, 100d, false, true, true)] + [DataRow(1000d, 900d, 100.01d, false, true, false)] + [DataRow(1000d, 0d, 100d, true, true, false)] + [DataRow(1000d, 0d, 100d, true, false, true)] + [DataRow(null, 0d, 100d, false, true, false)] + public void CompanyCredit_Covers(double? limit, double outstanding, double amount, bool hasOverdue, + bool blockWhenOverdue, bool expected) + { + var credit = new CompanyCredit(OwnerId, limit, limit.HasValue ? 30 : null, outstanding, hasOverdue); + + Assert.AreEqual(expected, credit.Covers(amount, blockWhenOverdue)); + } + + #endregion +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs index fe1f7555d4..f3c7964b3f 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs @@ -1,5 +1,6 @@ using Grand.Business.Checkout.Services.Orders; using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Queries.Checkout.Orders; using Grand.Data; using Grand.Domain.Orders; using Grand.Domain.Payments; @@ -181,6 +182,20 @@ public async Task CancelExpiredOrders_OrderWithoutApproval_IsCancelled() It.IsAny()), Times.Once); } + [TestMethod] + public async Task CancelExpiredOrders_OrderBoughtOnCredit_IsNotCancelled() + { + //its payment is deferred on purpose: it is not an abandoned payment + var order = NewExpiredOrder("1"); + order.PaymentDueDateUtc = DateTime.UtcNow.AddDays(30); + _orderRepositoryMock.Setup(c => c.Table).Returns(new List { order }.AsQueryable()); + + await _service.CancelExpiredOrders(DateTime.UtcNow.AddDays(-7)); + + _mediatorMock.Verify(c => c.Send(It.IsAny(), It.IsAny()), + Times.Never); + } + [TestMethod] public async Task CancelExpiredOrders_OrderApprovedAfterTheCutoff_IsSkipped() { @@ -236,4 +251,13 @@ private static Order NewExpiredOrder(string id) ShippingStatusId = ShippingStatus.Pending }; } + + [TestMethod] + public async Task SearchOrders_PassesPurchaseOrderNumberAndOverdueOnly() + { + await _service.SearchOrders(purchaseOrderNumber: "PO-1", overdueOnly: true); + + _mediatorMock.Verify(c => c.Send(It.Is(q => q.PurchaseOrderNumber == "PO-1" && q.OverdueOnly), + It.IsAny()), Times.Once); + } } diff --git a/src/Tests/Grand.Business.Checkout.Tests/Utilities/PurchaseOrderNumberRulesTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Utilities/PurchaseOrderNumberRulesTests.cs new file mode 100644 index 0000000000..1f9247d9a1 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Utilities/PurchaseOrderNumberRulesTests.cs @@ -0,0 +1,65 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Checkout.Tests.Utilities; + +[TestClass] +public class PurchaseOrderNumberRulesTests +{ + [TestMethod] + public void Normalize_Trims() + { + Assert.AreEqual("PO-1", PurchaseOrderNumberRules.Normalize(" PO-1 ", PurchaseOrderNumberMode.Optional)); + } + + [TestMethod] + public void Normalize_Whitespace_Null() + { + Assert.IsNull(PurchaseOrderNumberRules.Normalize(" ", PurchaseOrderNumberMode.Optional)); + } + + [TestMethod] + public void Normalize_Disabled_IgnoresInput() + { + Assert.IsNull(PurchaseOrderNumberRules.Normalize("PO-1", PurchaseOrderNumberMode.Disabled)); + } + + [TestMethod] + public void Normalize_KeepsCase() + { + Assert.AreEqual("po-Ab", PurchaseOrderNumberRules.Normalize("po-Ab", PurchaseOrderNumberMode.Required)); + } + + [TestMethod] + public void Validate_RequiredMissing_RequiredError() + { + Assert.AreEqual("Checkout.PurchaseOrderNumber.Required", + PurchaseOrderNumberRules.Validate(null, PurchaseOrderNumberMode.Required)); + } + + [TestMethod] + public void Validate_OptionalMissing_Valid() + { + Assert.IsNull(PurchaseOrderNumberRules.Validate(null, PurchaseOrderNumberMode.Optional)); + } + + [TestMethod] + public void Validate_TooLong_TooLongError() + { + Assert.AreEqual("Checkout.PurchaseOrderNumber.TooLong", + PurchaseOrderNumberRules.Validate(new string('x', 51), PurchaseOrderNumberMode.Optional)); + } + + [TestMethod] + public void Validate_MaxLength_Valid() + { + Assert.IsNull(PurchaseOrderNumberRules.Validate(new string('x', 50), PurchaseOrderNumberMode.Required)); + } + + [TestMethod] + public void Validate_Markup_StoredAsTyped() + { + Assert.IsNull(PurchaseOrderNumberRules.Validate(" + } + + diff --git a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs index 75c4ab94ed..44211f724c 100644 --- a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs +++ b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs @@ -1,4 +1,5 @@ -using Grand.Infrastructure.Models; +using Grand.Domain.Orders; +using Grand.Infrastructure.Models; using Grand.Web.Models.Common; namespace Grand.Web.Models.Checkout; @@ -23,6 +24,16 @@ public class CheckoutConfirmModel : BaseModel /// public string OrderApprovalButtonText { get; set; } + /// + /// Whether the confirm step asks for the buyer's purchase order number + /// + public PurchaseOrderNumberMode PurchaseOrderNumberMode { get; set; } + + /// + /// Maximum length of the purchase order number + /// + public int PurchaseOrderNumberMaxLength { get; set; } + public IList Warnings { get; set; } = new List(); public OrderReviewDataModel OrderReviewData { get; set; } = new(); diff --git a/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs b/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs index 43af68fea8..b5b01d7d21 100644 --- a/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs +++ b/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs @@ -18,6 +18,12 @@ public class CheckoutPaymentMethodModel : BaseModel /// public string NoPaymentMethodMessage { get; set; } + /// + /// Set when the customer's company buys on trade credit: what is still available, so that a hidden + /// pay by invoice method explains itself + /// + public string TradeCreditNotice { get; set; } + #region Nested classes public class PaymentMethodModel : BaseModel diff --git a/src/Web/Grand.Web.Models/Orders/CustomerOrderListModel.cs b/src/Web/Grand.Web.Models/Orders/CustomerOrderListModel.cs index c75794cad6..c48ab86623 100644 --- a/src/Web/Grand.Web.Models/Orders/CustomerOrderListModel.cs +++ b/src/Web/Grand.Web.Models/Orders/CustomerOrderListModel.cs @@ -7,8 +7,22 @@ public class CustomerOrderListModel : BaseModel public OrderPagingModel PagingContext { get; set; } = new(); public IList Orders { get; set; } = new List(); + /// + /// Gets or sets the trade credit of the customer's company; null when it does not buy on credit + /// + public TradeCreditModel TradeCredit { get; set; } + #region Nested classes + public class TradeCreditModel : BaseModel + { + public string CreditLimit { get; set; } + public string Outstanding { get; set; } + public string Available { get; set; } + public int PaymentTermDays { get; set; } + public bool HasOverdue { get; set; } + } + public class OrderDetailsModel : BaseEntityModel { public string OrderTotal { get; set; } diff --git a/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs b/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs index 2ededeeaec..6f08e19c92 100644 --- a/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs +++ b/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs @@ -32,6 +32,9 @@ public class OrderDetailsModel : BaseEntityModel public string VatNumber { get; set; } public int OrderNumber { get; set; } public string OrderCode { get; set; } + public string PurchaseOrderNumber { get; set; } + public DateTime? PaymentDueDate { get; set; } + public bool IsPaymentOverdue { get; set; } public string PaymentMethod { get; set; } public string PaymentMethodStatus { get; set; } public bool CanRePostProcessPayment { get; set; } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Order/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Order/List.cshtml index fa37c5ae6e..831cee8456 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Order/List.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Order/List.cshtml @@ -74,6 +74,8 @@ + + @@ -260,6 +262,8 @@ OrderGuid: $('#@Html.IdFor(model => model.OrderGuid)').val(), GoDirectlyToNumber: $('#@Html.IdFor(model => model.GoDirectlyToNumber)').val(), OrderTag: $('#@Html.IdFor(model => model.OrderTag)').val(), + PurchaseOrderNumber: $('#@Html.IdFor(model => model.PurchaseOrderNumber)').val(), + OverdueOnly: $('#@Html.IdFor(model => model.OverdueOnly)').is(':checked'), }; addAntiForgeryToken(data); return data; diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Order/Partials/OrderDetails.Info.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Order/Partials/OrderDetails.Info.cshtml index 9b6bf8ad1d..5a53dda176 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Order/Partials/OrderDetails.Info.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Order/Partials/OrderDetails.Info.cshtml @@ -25,6 +25,13 @@
@Model.Code
} + @if (!string.IsNullOrEmpty(Model.PurchaseOrderNumber)) + { +
+ +
@Model.PurchaseOrderNumber
+
+ }
diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 0e2b0627c2..7dd15fd123 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index cb66bf108c..b549b75b9b 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -639,4 +639,130 @@ Storefront + + Trade credit + + + Credit limit + + + Outstanding + + + Available + + + Payment term + + + {0} days + + + You have an overdue payment. Buying on credit may be unavailable until it is paid. + + + Trade credit available: {0} of {1}. An order above this amount cannot be paid by invoice. + + + You have an overdue payment, so paying by invoice may be unavailable until it is paid. + + + Your PO number + + + Enter your purchase order number. + + + The purchase order number can be at most 50 characters long. + + + Not asked + + + Optional + + + Required + + + PO number + + + Payment due + + + Overdue + + + PO number: {0} + + + Payment due: {0} + + + Edit PO number and due date + + + Enter the date the payment is due. + + + PO number + + + The buyer's own purchase order number, entered at checkout. + + + Payment due + + + The date the payment is due. Set only for orders bought on trade credit. + + + Overdue + + + PO number + + + Search by the buyer's purchase order number (exact match). + + + Overdue payments only + + + Show only orders bought on trade credit whose payment is past its due date. + + + Trade credit + + + Credit limit + + + In the primary store currency. Leave empty for no trade credit. + + + The credit limit cannot be negative. + + + Payment term (days) + + + Days from placing an order bought on credit until its payment is due. Leave empty for no trade credit. + + + The payment term must be between 0 and 365 days. + + + Outstanding on credit + + + Set both the credit limit and the payment term, or leave both empty. + + + A sub-account buys on its owner's trade credit. Set the credit limit and payment term on the owner. + + + Payment overdue + diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index 9f5ecd96c8..e5c5855149 100644 --- a/src/Web/Grand.Web/Controllers/CheckoutController.cs +++ b/src/Web/Grand.Web/Controllers/CheckoutController.cs @@ -710,7 +710,7 @@ public virtual async Task Completed(string orderId) } [HttpPost] - public virtual async Task ConfirmOrder() + public virtual async Task ConfirmOrder(string purchaseOrderNumber) { try { @@ -732,7 +732,8 @@ public virtual async Task ConfirmOrder() Context = _contextAccessor.CalculationContext(), Language = _contextAccessor.WorkContext.WorkingLanguage, Host = _contextAccessor.StoreContext.CurrentStore.LinkHost(_contextAccessor.StoreContext.CurrentHost), - OriginalCustomerIfImpersonated = _contextAccessor.WorkContext.OriginalCustomerIfImpersonated + OriginalCustomerIfImpersonated = _contextAccessor.WorkContext.OriginalCustomerIfImpersonated, + PurchaseOrderNumber = purchaseOrderNumber }); if (placeOrderResult.Success) { diff --git a/src/Web/Grand.Web/Controllers/OrderController.cs b/src/Web/Grand.Web/Controllers/OrderController.cs index 6a82a9bcb8..d9fe9b8ffd 100644 --- a/src/Web/Grand.Web/Controllers/OrderController.cs +++ b/src/Web/Grand.Web/Controllers/OrderController.cs @@ -75,6 +75,7 @@ public virtual async Task> CustomerOrders(OrderPa var model = await _mediator.Send(new GetCustomerOrderList { Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, + Currency = _contextAccessor.WorkContext.WorkingCurrency, Store = _contextAccessor.StoreContext.CurrentStore, Command = command }); diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs index c3a8a850a3..10b8ad51b4 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs @@ -56,13 +56,26 @@ public async Task Handle(GetConfirmOrder request, Cancella { var model = new CheckoutConfirmModel { //terms of service - TermsOfServiceOnOrderConfirmPage = _orderSettings.TermsOfServiceOnOrderConfirmPage + TermsOfServiceOnOrderConfirmPage = _orderSettings.TermsOfServiceOnOrderConfirmPage, + PurchaseOrderNumberMode = GetPurchaseOrderNumberMode(request), + PurchaseOrderNumberMaxLength = PurchaseOrderNumberRules.MaxLength }; await PrepareOrderReviewData(model, request); await PrepareOrderApproval(model, request); return model; } + /// + /// The chosen payment method decides whether the buyer is asked for a purchase order number + /// + private PurchaseOrderNumberMode GetPurchaseOrderNumberMode(GetConfirmOrder request) + { + var paymentMethod = request.Customer.GetUserFieldFromEntity( + SystemCustomerFieldNames.SelectedPaymentMethod, request.Store.Id); + return _paymentService.LoadPaymentMethodBySystemName(paymentMethod)?.PurchaseOrderNumberMode + ?? PurchaseOrderNumberMode.Disabled; + } + /// /// An order over the buyer's limit is placed at once and waits for an approval; the buyer is told so /// diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs index e7f2d32d66..1976040893 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs @@ -19,6 +19,7 @@ namespace Grand.Web.Features.Handlers.Checkout; public class GetPaymentMethodHandler : IRequestHandler { + private readonly ICompanyCreditService _companyCreditService; private readonly ICurrencyService _currencyService; private readonly ILoyaltyPointsService _loyaltyPointsService; @@ -40,6 +41,7 @@ public GetPaymentMethodHandler(ILoyaltyPointsService loyaltyPointsService, IPaymentService paymentService, ITaxService taxService, IOrderApprovalService orderApprovalService, + ICompanyCreditService companyCreditService, IRequestTranslationService translationService, LoyaltyPointsSettings loyaltyPointsSettings, PaymentSettings paymentSettings) @@ -52,6 +54,7 @@ public GetPaymentMethodHandler(ILoyaltyPointsService loyaltyPointsService, _paymentService = paymentService; _taxService = taxService; _orderApprovalService = orderApprovalService; + _companyCreditService = companyCreditService; _translationService = translationService; _loyaltyPointsSettings = loyaltyPointsSettings; _paymentSettings = paymentSettings; @@ -103,6 +106,8 @@ await _loyaltyPointsCalculationService.ConvertLoyaltyPointsToAmount(loyaltyPoint model.NoPaymentMethodMessage = _translationService.GetResource("Checkout.OrderApproval.NoPaymentMethod"); } + model.TradeCreditNotice = await GetTradeCreditNotice(request); + foreach (var pm in availablepaymentMethods) { var pmModel = new CheckoutPaymentMethodModel.PaymentMethodModel { @@ -146,4 +151,21 @@ await _loyaltyPointsCalculationService.ConvertLoyaltyPointsToAmount(loyaltyPoint return model; } + + private async Task GetTradeCreditNotice(GetPaymentMethod request) + { + var credit = await _companyCreditService.GetCompanyCredit(request.Customer); + if (!credit.Enabled) + return null; + + var available = await _currencyService.ConvertFromPrimaryStoreCurrency(credit.Available, request.Currency); + var limit = await _currencyService.ConvertFromPrimaryStoreCurrency(credit.CreditLimit!.Value, request.Currency); + var notice = _translationService.GetResource("Checkout.TradeCredit.Available") + .Replace("{0}", _priceFormatter.FormatPrice(available, request.Currency)) + .Replace("{1}", _priceFormatter.FormatPrice(limit, request.Currency)); + + return credit.HasOverdue + ? $"{notice} {_translationService.GetResource("Checkout.TradeCredit.Overdue")}" + : notice; + } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/GetCustomerOrderListHandler.cs b/src/Web/Grand.Web/Features/Handlers/Orders/GetCustomerOrderListHandler.cs index cb1a43c56a..f400a56576 100644 --- a/src/Web/Grand.Web/Features/Handlers/Orders/GetCustomerOrderListHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetCustomerOrderListHandler.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Queries.Checkout.Orders; +using Grand.Domain.Directory; using Grand.Domain.Orders; using Grand.Web.Common.Localization; using Grand.Web.Features.Models.Orders; @@ -12,6 +13,7 @@ namespace Grand.Web.Features.Handlers.Orders; public class GetCustomerOrderListHandler : IRequestHandler { + private readonly ICompanyCreditService _companyCreditService; private readonly ICurrencyService _currencyService; private readonly IDateTimeService _dateTimeService; private readonly IGroupService _groupService; @@ -30,6 +32,7 @@ public GetCustomerOrderListHandler( IOrderStatusService orderStatusService, IOrderService orderService, ICurrencyService currencyService, + ICompanyCreditService companyCreditService, OrderSettings orderSettings, IEnumTranslationService enumTranslationService) { @@ -41,6 +44,7 @@ public GetCustomerOrderListHandler( _orderSettings = orderSettings; _enumTranslationService = enumTranslationService; _currencyService = currencyService; + _companyCreditService = companyCreditService; _mediator = mediator; } @@ -48,9 +52,31 @@ public async Task Handle(GetCustomerOrderList request, C { var model = new CustomerOrderListModel(); await PrepareOrder(model, request); + await PrepareTradeCredit(model, request); return model; } + private async Task PrepareTradeCredit(CustomerOrderListModel model, GetCustomerOrderList request) + { + var credit = await _companyCreditService.GetCompanyCredit(request.Customer); + if (!credit.Enabled) + return; + + model.TradeCredit = new CustomerOrderListModel.TradeCreditModel { + CreditLimit = await FormatCreditAmount(credit.CreditLimit!.Value, request.Currency), + Outstanding = await FormatCreditAmount(credit.OutstandingAmount, request.Currency), + Available = await FormatCreditAmount(credit.Available, request.Currency), + PaymentTermDays = credit.PaymentTermDays!.Value, + HasOverdue = credit.HasOverdue + }; + } + + private async Task FormatCreditAmount(double amountInPrimaryCurrency, Currency currency) + { + var amount = await _currencyService.ConvertFromPrimaryStoreCurrency(amountInPrimaryCurrency, currency); + return _priceFormatter.FormatPrice(amount, currency); + } + private async Task PrepareOrder(CustomerOrderListModel model, GetCustomerOrderList request) { if (request.Command.PageSize <= 0) request.Command.PageSize = _orderSettings.PageSize; diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs index 5150f87f54..ce6e3df66e 100644 --- a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs @@ -95,6 +95,10 @@ public async Task Handle(GetOrderDetails request, Cancellatio Id = request.Order.Id, OrderNumber = request.Order.OrderNumber, OrderCode = request.Order.Code, + PurchaseOrderNumber = request.Order.PurchaseOrderNumber, + //a calendar date: shown as stored, a time zone shift could move it to the day before + PaymentDueDate = request.Order.PaymentDueDateUtc?.Date, + IsPaymentOverdue = request.Order.IsPaymentOverdue(DateTime.UtcNow), CreatedOn = _dateTimeService.ConvertToUserTime(request.Order.CreatedOnUtc, DateTimeKind.Utc), OrderStatus = (await _orderStatusService.GetByStatusId(request.Order.OrderStatusId))?.Name, IsReOrderAllowed = _orderSettings.IsReOrderAllowed, diff --git a/src/Web/Grand.Web/Features/Models/Orders/GetCustomerOrderList.cs b/src/Web/Grand.Web/Features/Models/Orders/GetCustomerOrderList.cs index 4bda0ced38..64b7318796 100644 --- a/src/Web/Grand.Web/Features/Models/Orders/GetCustomerOrderList.cs +++ b/src/Web/Grand.Web/Features/Models/Orders/GetCustomerOrderList.cs @@ -1,4 +1,5 @@ using Grand.Domain.Customers; +using Grand.Domain.Directory; using Grand.Domain.Localization; using Grand.Domain.Stores; using Grand.Web.Models.Orders; @@ -11,5 +12,6 @@ public class GetCustomerOrderList : IRequest public Customer Customer { get; set; } public Domain.Stores.Store Store { get; set; } public Language Language { get; set; } + public Currency Currency { get; set; } public OrderPagingModel Command { get; set; } = new(); } \ No newline at end of file diff --git a/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml b/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml index 21a5c08fd3..ba8720134b 100644 --- a/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml +++ b/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml @@ -14,6 +14,14 @@
{{vmorder.OrderApprovalMessage}}
+ @*the buyer's own PO number; v-model binds it as text, never as markup*@ +
+ + +