From 1ca14497b63976778d7d3a8bdae3195d1968512f Mon Sep 17 00:00:00 2001 From: support Date: Fri, 30 Oct 2026 15:27:22 +0100 Subject: [PATCH] Trade credit, PO number and payment due date for company accounts Company accounts can pay by invoice on trade credit, and such an order can carry the buyer's purchase order number and a payment due date. - Customer: CreditLimit and PaymentTermDays on the company owner; a sub-account buys on its owner's credit. Set in Admin and in the Store panel, with validation and the outstanding amount shown on the account. - Order: PurchaseOrderNumber and PaymentDueDateUtc. ICompanyCreditService sums what a company owes (unpaid orders bought on credit, in the primary currency); an order is overdue once its due date has passed. - Storefront: a company with trade credit sees its limit, outstanding and available amounts and payment term on My account > Orders, and what is available on the payment step, so a hidden method explains itself. - Checkout: the chosen payment method decides whether the confirm step asks for the PO number (IPaymentProvider.PurchaseOrderNumberMode, disabled by default). The number is shown on the storefront, in Admin, Store and Vendor, on the PDF invoice and as an e-mail token, always encoded. - Order lists in Admin and Store filter by PO number and overdue payments. - The payment transaction screen in Admin and Store shows the PO number and due date, and staff can correct them in a popup. A due date cannot be cleared or added there, so editing never moves an order on or off credit. - Payments.OnAccount plugin ("Pay by invoice"): offered while the cart subtotal fits the available credit, checked again on the exact amount when the order is placed, so a hidden method posted anyway is refused. It sets the due date through ProcessPaymentResult.PaymentDueDateUtc, and its configuration holds the PO number mode (not asked, optional, required). - Orders bought on credit are left out of the unpaid-order cancellation. - Migration and installer add the OwnerId_PaymentDueDateUtc order index. Known limits: the credit limit is soft (two orders placed at the same moment can overrun it), the term counts from placement, and there are no payment reminders yet. --- GrandNode.slnx | 1 + .../Orders/PlaceOrderCommandHandler.cs | 17 ++ .../Handlers/Orders/GetOrderQueryHandler.cs | 18 ++ .../Services/Orders/CompanyCreditService.cs | 99 ++++++++ .../Services/Orders/OrderService.cs | 9 +- .../Startup/StartupApplication.cs | 1 + .../Services/Pdf/PdfService.cs | 11 +- .../Checkout/Orders/PlaceOrderCommand.cs | 5 + .../Extensions/PaymentExtensions.cs | 20 ++ .../Checkout/Orders/ICompanyCreditService.cs | 32 +++ .../Checkout/Orders/IOrderService.cs | 5 +- .../Checkout/Payments/IPaymentProvider.cs | 5 + .../Queries/Checkout/Orders/GetOrderQuery.cs | 2 + .../Utilities/Checkout/CompanyCredit.cs | 47 ++++ .../Utilities/Checkout/PlaceOrderContainer.cs | 1 + .../Checkout/ProcessPaymentResult.cs | 6 + .../Checkout/PurchaseOrderNumberRules.cs | 42 ++++ .../Services/CustomerService.cs | 2 + .../DotLiquidDrops/LiquidOrder.cs | 6 + src/Core/Grand.Domain/Customers/Customer.cs | 12 + src/Core/Grand.Domain/Orders/Order.cs | 10 + .../Orders/PurchaseOrderNumberMode.cs | 22 ++ .../Services/InstallationService.cs | 4 + .../2.5/MigrationOrderPaymentTerms.cs | 40 +++ .../Controllers/PaymentOnAccountController.cs | 72 ++++++ .../Views/PaymentOnAccount/Configure.cshtml | 27 ++ .../Areas/Admin/Views/_ViewImports.cshtml | 8 + .../Areas/Admin/Views/_ViewStart.cshtml | 3 + .../Controllers/PaymentOnAccountController.cs | 77 ++++++ .../Views/PaymentOnAccount/Configure.cshtml | 26 ++ .../Areas/Store/Views/_ViewImports.cshtml | 8 + src/Plugins/Payments.OnAccount/Manifest.cs | 10 + .../Models/ConfigurationModel.cs | 19 ++ .../OnAccountPaymentDefaults.cs | 8 + .../OnAccountPaymentPlugin.cs | 65 +++++ .../OnAccountPaymentProvider.cs | 200 +++++++++++++++ .../OnAccountPaymentSettings.cs | 19 ++ .../Payments.OnAccount.csproj | 26 ++ .../Payments.OnAccount/StartupApplication.cs | 24 ++ src/Plugins/Payments.OnAccount/logo.svg | 6 + ...ceOrderCommandHandlerPurchaseOrderTests.cs | 203 +++++++++++++++ .../Orders/GetOrderQueryHandlerTests.cs | 52 ++++ .../Orders/CompanyCreditServiceTests.cs | 233 ++++++++++++++++++ .../Services/Orders/OrderServiceTests.cs | 24 ++ .../PurchaseOrderNumberRulesTests.cs | 65 +++++ .../Services/Pdf/PdfServiceTests.cs | 24 ++ .../Services/CustomerServiceTests.cs | 16 ++ .../DotLiquidDrops/LiquidOrderTests.cs | 42 ++++ .../Grand.Modules.Tests.csproj | 1 + .../Payments/OnAccountPaymentProviderTests.cs | 202 +++++++++++++++ .../Plugins/PluginDependencyCycleTests.cs | 5 +- .../BaseFullReportsControllerTests.cs | 4 +- .../BasePaymentTransactionControllerTests.cs | 163 ++++++++++++ .../Services/CustomerViewModelServiceTests.cs | 82 +++++- .../CustomerValidatorTradeCreditTests.cs | 123 +++++++++ .../Controllers/CustomerControllerTests.cs | 15 ++ .../CheckoutControllerConfirmOrderTests.cs | 82 ++++++ .../CheckoutOrderApprovalHandlersTests.cs | 92 ++++++- .../GetCustomerOrderListHandlerTests.cs | 85 +++++++ ...GetOrderDetailsHandlerPaymentTermsTests.cs | 90 +++++++ .../Areas/Admin/Views/Order/List.cshtml | 4 + .../BasePaymentTransactionController.cs | 74 ++++++ .../Models/Customers/CustomerModel.cs | 12 + .../Models/Orders/OrderListModel.cs | 6 + .../Models/Orders/OrderModel.cs | 8 + .../Models/Orders/PaymentTransactionModel.cs | 20 ++ .../Services/CustomerViewModelService.cs | 26 +- .../Services/OrderViewModelService.cs | 12 +- .../Validators/Customers/CustomerValidator.cs | 17 ++ .../Partials/CreateOrUpdate.TabInfo.cshtml | 25 ++ .../Order/Partials/OrderDetails.Info.cshtml | 20 ++ .../PaymentTransaction/Edit.cshtml | 21 ++ .../PaymentTransaction/Partials/Update.cshtml | 20 ++ .../PaymentTermsPopup.cshtml | 55 +++++ .../Checkout/CheckoutConfirmModel.cs | 13 +- .../Checkout/CheckoutPaymentMethodModel.cs | 6 + .../Orders/CustomerOrderListModel.cs | 14 ++ .../Orders/OrderDetailsModel.cs | 3 + .../Areas/Store/Views/Order/List.cshtml | 4 + .../Order/Partials/OrderDetails.Info.cshtml | 7 + .../App_Data/Resources/DefaultLanguage.xml | Bin 1630022 -> 1641900 bytes .../App_Data/Resources/Upgrade/en_250.xml | 126 ++++++++++ .../Controllers/CheckoutController.cs | 5 +- .../Grand.Web/Controllers/OrderController.cs | 1 + .../Checkout/GetConfirmOrderHandler.cs | 15 +- .../Checkout/GetPaymentMethodHandler.cs | 22 ++ .../Orders/GetCustomerOrderListHandler.cs | 26 ++ .../Handlers/Orders/GetOrderDetailsHandler.cs | 4 + .../Models/Orders/GetCustomerOrderList.cs | 2 + .../Checkout/Partials/ConfirmOrder.cshtml | 8 + .../Checkout/Partials/PaymentMethods.cshtml | 3 + .../Views/Order/CustomerOrders.cshtml | 22 ++ src/Web/Grand.Web/Views/Order/Details.cshtml | 16 ++ .../wwwroot/theme/script/public.checkout.js | 10 + 94 files changed, 3228 insertions(+), 17 deletions(-) create mode 100644 src/Business/Grand.Business.Checkout/Services/Orders/CompanyCreditService.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/ICompanyCreditService.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/CompanyCredit.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/PurchaseOrderNumberRules.cs create mode 100644 src/Core/Grand.Domain/Orders/PurchaseOrderNumberMode.cs create mode 100644 src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderPaymentTerms.cs create mode 100644 src/Plugins/Payments.OnAccount/Areas/Admin/Controllers/PaymentOnAccountController.cs create mode 100644 src/Plugins/Payments.OnAccount/Areas/Admin/Views/PaymentOnAccount/Configure.cshtml create mode 100644 src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewImports.cshtml create mode 100644 src/Plugins/Payments.OnAccount/Areas/Admin/Views/_ViewStart.cshtml create mode 100644 src/Plugins/Payments.OnAccount/Areas/Store/Controllers/PaymentOnAccountController.cs create mode 100644 src/Plugins/Payments.OnAccount/Areas/Store/Views/PaymentOnAccount/Configure.cshtml create mode 100644 src/Plugins/Payments.OnAccount/Areas/Store/Views/_ViewImports.cshtml create mode 100644 src/Plugins/Payments.OnAccount/Manifest.cs create mode 100644 src/Plugins/Payments.OnAccount/Models/ConfigurationModel.cs create mode 100644 src/Plugins/Payments.OnAccount/OnAccountPaymentDefaults.cs create mode 100644 src/Plugins/Payments.OnAccount/OnAccountPaymentPlugin.cs create mode 100644 src/Plugins/Payments.OnAccount/OnAccountPaymentProvider.cs create mode 100644 src/Plugins/Payments.OnAccount/OnAccountPaymentSettings.cs create mode 100644 src/Plugins/Payments.OnAccount/Payments.OnAccount.csproj create mode 100644 src/Plugins/Payments.OnAccount/StartupApplication.cs create mode 100644 src/Plugins/Payments.OnAccount/logo.svg create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Services/Orders/CompanyCreditServiceTests.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Utilities/PurchaseOrderNumberRulesTests.cs create mode 100644 src/Tests/Grand.Business.Messages.Tests/DotLiquidDrops/LiquidOrderTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Plugins/Payments/OnAccountPaymentProviderTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTradeCreditTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetCustomerOrderListHandlerTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerPaymentTermsTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PaymentTermsPopup.cshtml 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 0e2b0627c2cd93cc6af70da64be55f9564a04380..7dd15fd1230ba07a5ae2be0ef1d97da8ca54720e 100644 GIT binary patch delta 3321 zcmbVOeQZ#E;qzYxuw7;flbEIvWs;L6)ufari?tOmF zN7Ie%k88ho-#Pc3^E9C_{AH^Y>v%U$Wcryg zm`eMefb21mFTdBnBUn&S1O>92RNhC0m;n?WWcooH@b8DZxKD;_2~LGOxA;w53lm|w zfW2NFvZn7T<`^@=#F-?M!tDe;r|`KSmZHKB>4CNt>xKJ|!fcUkHY*~`7%G(QSZO)z z8$#7SJZcPWPr&3=??s3n5OgSUE){wYOLNd35FUr(>!Jd^H@r&z*|2ccg!cqd;rf{& zrVpl$h#gSZV)9TWin@@-P3jXYp7ZuYVY|=n_>>E1Z%5I6=<8gs{tLPw8gX#Lbw z5|vZT!)K%7yx$3thR2LruiUSv$${)vuN!VFvci>70j~kA&-vAGv>ZVa|93LGn8)zn zMn{VaFHL{Nr^17xa1HL%YDXaQx2gsxJ;jRo*dAev4#lAC_K>JC`Ed8qs=W|-Rpx~X zXYBm+YCp9EQkFj5o#!?(d9ZCZA!pQMx23DM;E0LB<)IRyH{( z1*rGukQl_wBa{gnmLQ%$xbYOqk+c2-N;2h^&Cn!enBg#lAVrFZVE_J2EdetgbeeXH zPY-ZB+;g+?#ax$wOU&oGq%B85T^9CHEZ8JW&AAhh%}AS8DhK-k(iXOTO+E*pU| z3^mv!Zm+H$fZ|W2Mo`}g$@xz&OW$XwugZ_pCcsgY;HxitcSH2IthhPG97PX@Fu+0d zljNriD*j{G){YZ#D4mxYR?v{_A|{nI1|9pnPlKwlUaXZBRD=pDuyj@$hRmCi3V&_V z6j%`CLoT!l9c}JG8|FgXt6KBjLHJorXtPRNyr#{e6|STim611E^-#C8?AV%Ik;DHD&lY z>;q}kOHm4c!G?|aGfF=@#bfjDGN+z}=ypjVo{7-*TBym+crh#0f0Y$#oZBWwp;P75Wn16!_YfwG(e%myVthY)7GWdI-(O= zql5m~1HXmb3u>*{w~(A6!MQ8o-5~BLe(`e@X}`kJgdxS@Zk@jtF6|05arXP=HvP2Y z?W&u?s!IS&_PI=JCfW+cKbZQfJCfE+tFl3G-B~FNvBkibX>Tc)UPnG-z}3?eT$ zZLEdN_b_B0=*{rl5#QGxo%+v@$!X)*CQK-S(F~E!fOyK8;;X5=j`g(d?n9DasLcM2 zW;@9~p*(365oR3fkNVBc_&^G0)yOA;69S;3TYut@uT~DSE!L0H`>n!@A_U_>73D}>a|uk&5G)<94#V@$b2V_illw+KGojtP z4~0HmsaW9#F}x%!e_KBcrBkxkh0O`rZucoDoY7k=VkQ;C?6sgcUPUkg$RKO#vjD+Cn45pC_lZu-(cZi%euUFNSctjrZ;$;dccux%ja)DX*}t1e6|PCl|kcdUg`L##B`>`<$v$KYJ(RK}`{IZ9xz>JTj;t2>gZ& z8U!08ETktAazivE23i`88VZSj{3ze;TBtxei@&jZor&(jRzWJWW1at-Egw3xGB zEvr$`(^3}9BOHTNJ*+Uhf=xH{ia|f9tARTwvAcK+l%nK^=04G(bBGg#SwVuIdt7oF z#=9Uww2tFofAGKUQ#rvX!{4K<@rIAxd836nKNsK>ZipEzReWUCYi;ny>|!wY3jBB+ p&Q3xWT 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*@ +
+ + +