Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GrandNode.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<Project Path="src/Plugins/ExchangeRate.McExchange/ExchangeRate.McExchange.csproj" />
<Project Path="src/Plugins/Payments.BrainTree/Payments.BrainTree.csproj" />
<Project Path="src/Plugins/Payments.CashOnDelivery/Payments.CashOnDelivery.csproj" />
<Project Path="src/Plugins/Payments.OnAccount/Payments.OnAccount.csproj" />
<Project Path="src/Plugins/Payments.StripeCheckout/Payments.StripeCheckout.csproj" />
<Project Path="src/Plugins/Shipping.ByWeight/Shipping.ByWeight.csproj" />
<Project Path="src/Plugins/Shipping.FixedRateShipping/Shipping.FixedRateShipping.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,21 @@ public async Task<PlaceOrderResult> 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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,6 +93,23 @@ public async Task<IPagedList<Order>> 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Company credit service
/// </summary>
public class CompanyCreditService : ICompanyCreditService
{
#region Ctor

public CompanyCreditService(IRepository<Order> orderRepository, ICustomerService customerService)
{
_orderRepository = orderRepository;
_customerService = customerService;
}

#endregion

#region Fields

private readonly IRepository<Order> _orderRepository;
private readonly ICustomerService _customerService;

#endregion

#region Utilities

/// <summary>
/// Gets the company's orders bought on credit that are not paid yet
/// </summary>
private IQueryable<Order> 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<CompanyCredit> 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<double> 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<bool> 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ public virtual async Task<IPagedList<Order>> 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,
Expand All @@ -206,7 +207,9 @@ public virtual async Task<IPagedList<Order>> SearchOrders(string storeId = "",
WarehouseId = warehouseId,
OrderTagId = orderTagId,
OwnerId = ownerId,
SalesEmployeeId = salesEmployeeId
SalesEmployeeId = salesEmployeeId,
PurchaseOrderNumber = purchaseOrderNumber,
OverdueOnly = overdueOnly
};
return await _mediator.Send(queryModel);
}
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ private static void RegisterOrdersService(IServiceCollection serviceCollection)
serviceCollection.AddScoped<ILoyaltyPointsCalculationService, LoyaltyPointsCalculationService>();
serviceCollection.AddScoped<IMerchandiseReturnService, MerchandiseReturnService>();
serviceCollection.AddScoped<IOrderApprovalService, OrderApprovalService>();
serviceCollection.AddScoped<ICompanyCreditService, CompanyCreditService>();
serviceCollection.AddScoped<ILoyaltyPointsService, LoyaltyPointsService>();
serviceCollection.AddScoped<IShoppingCartService, ShoppingCartService>();
serviceCollection.AddScoped<IShoppingCartValidator, ShoppingCartValidator>();
Expand Down
11 changes: 10 additions & 1 deletion src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -398,6 +398,15 @@ private async Task<byte[]> LoadLogo()
}
}

//the PO number is the buyer's own text, printed as typed; the due date is a calendar date
private IEnumerable<string> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ public class PlaceOrderCommand : IRequest<PlaceOrderResult>
/// Staff member who places the order while impersonating the customer, if any
/// </summary>
public Customer OriginalCustomerIfImpersonated { get; set; }

/// <summary>
/// Buyer's own purchase order number, if any
/// </summary>
public string PurchaseOrderNumber { get; set; }
}
20 changes: 20 additions & 0 deletions src/Business/Grand.Business.Core/Extensions/PaymentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,24 @@ public static string GetPaymentMethodName(this IPaymentProvider paymentMethod, O

return string.IsNullOrEmpty(name) ? order.PaymentMethodSystemName : name;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="order">Order</param>
/// <param name="utcNow">Current date and time in UTC</param>
/// <returns>Result</returns>
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Grand.Business.Core.Utilities.Checkout;
using Grand.Domain.Customers;

namespace Grand.Business.Core.Interfaces.Checkout.Orders;

/// <summary>
/// Trade credit of company accounts: the limit and term set on the company owner, and what the company owes
/// on orders bought on credit
/// </summary>
public interface ICompanyCreditService
{
/// <summary>
/// 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.
/// </summary>
/// <param name="customer">Customer</param>
Task<CompanyCredit> GetCompanyCredit(Customer customer);

/// <summary>
/// Gets the unpaid amount of the company's orders bought on credit, in the primary store currency
/// </summary>
/// <param name="companyOwnerId">Identifier of the company owner</param>
Task<double> GetOutstandingAmount(string companyOwnerId);

/// <summary>
/// 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)
/// </summary>
/// <param name="companyOwnerId">Identifier of the company owner</param>
/// <param name="utcNow">Current date and time in UTC</param>
Task<bool> HasOverdue(string companyOwnerId, DateTime utcNow);
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ public interface IOrderService
/// <param name="pageIndex">Page index</param>
/// <param name="pageSize">Page size</param>
/// <param name="orderTagId">Order tag identifier</param>
/// <param name="purchaseOrderNumber">Buyer's purchase order number, matched exactly; null to load all orders</param>
/// <param name="overdueOnly">Load only orders bought on credit whose payment is overdue</param>
/// <returns>Orders</returns>
Task<IPagedList<Order>> SearchOrders(string storeId = "",
string vendorId = "", string customerId = "",
Expand All @@ -105,7 +107,8 @@ Task<IPagedList<Order>> 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);

/// <summary>
/// Inserts an order
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ public interface IPaymentProvider : IProvider
/// </summary>
PaymentMethodType PaymentMethodType { get; }

/// <summary>
/// Gets whether the checkout asks for the buyer's purchase order number when this method is chosen
/// </summary>
PurchaseOrderNumberMode PurchaseOrderNumberMode => PurchaseOrderNumberMode.Disabled;

/// <summary>
/// Gets a value indicating whether we should display a payment information page for this plugin
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ public class GetOrderQuery : IRequest<IPagedList<Order>>
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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Grand.Business.Core.Utilities.Checkout;

/// <summary>
/// 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.
/// </summary>
/// <param name="CompanyOwnerId">Identifier of the company owner the credit belongs to</param>
/// <param name="CreditLimit">Credit limit; null means no trade credit</param>
/// <param name="PaymentTermDays">Payment term in days; null means no trade credit</param>
/// <param name="OutstandingAmount">Unpaid amount of the company's orders bought on credit</param>
/// <param name="HasOverdue">Whether any of those orders is past its due date</param>
public sealed record CompanyCredit(
string CompanyOwnerId,
double? CreditLimit,
int? PaymentTermDays,
double OutstandingAmount,
bool HasOverdue)
{
/// <summary>
/// Gets a value indicating whether the company may buy on credit: both the limit and the term are set
/// </summary>
public bool Enabled => CreditLimit.HasValue && PaymentTermDays.HasValue;

/// <summary>
/// Gets the credit still available
/// </summary>
public double Available => Enabled ? Math.Max(0, CreditLimit!.Value - OutstandingAmount) : 0;

/// <summary>
/// Gets a value indicating whether an order of the given amount may be bought on credit
/// </summary>
/// <param name="amountInPrimaryCurrency">Order amount in the primary store currency</param>
/// <param name="blockWhenOverdue">Whether an overdue payment blocks further buying on credit</param>
public bool Covers(double amountInPrimaryCurrency, bool blockWhenOverdue)
{
return Enabled && !(blockWhenOverdue && HasOverdue) && amountInPrimaryCurrency <= Available;
}

/// <summary>
/// Gets the credit of a company that cannot buy on credit
/// </summary>
/// <param name="companyOwnerId">Identifier of the company owner</param>
public static CompanyCredit None(string companyOwnerId)
{
return new CompanyCredit(companyOwnerId, null, null, 0, false);
}
}
Loading
Loading