Repository navigation
Trade credit, PO number and payment due date for company accounts - #1001
Merged
Merged
Conversation
KrzysztofPajak
force-pushed
the
feature/b2b-payment-terms
branch
from
October 30, 2026 19:56
e30736d to
a31938d
Compare
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.
KrzysztofPajak
force-pushed
the
feature/b2b-payment-terms
branch
from
October 30, 2026 21:18
a31938d to
1ca1449
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type: feature
Issue
There is no issue for this. Company accounts (sub-accounts, purchasing roles and order approval, #991 and #995) could not buy on trade credit: no way to pay by invoice within a payment term, no credit limit, and no place for the buyer's own purchase order number, which many B2B buyers need on every order and invoice.
Solution
Core
Customer.CreditLimitandCustomer.PaymentTermDayson the company owner; a sub-account buys on its owner's credit. Editable in Admin and in the Store panel, with validation and the outstanding amount shown on the account.Order.PurchaseOrderNumberandOrder.PaymentDueDateUtc. An order counts as bought on credit when it has a due date, so core never references the plugin's system name.ICompanyCreditServicesums what a company owes in the primary store currency: unpaid, not cancelled, not deleted orders bought on credit. Credit is released by the existing Mark as paid and Partially paid (offline) actions.IPaymentProvider.PurchaseOrderNumberMode, a default member returning Disabled: the chosen payment method decides whether the confirm step asks for the PO number. Only Pay by invoice can ask, so a buyer paying by card or a customer without trade credit never sees the field. Validated on the server before any payment is taken: trimmed, at most 50 characters, stored as typed.PurchaseOrderNumber/PaymentDueDatetokens in the order drop (no message template uses them yet). Vendor sees the PO number on the order page; the due date reaches a vendor only through the PDF invoice.SearchOrdersgets two optional parameters at the end; the filters are applied inGetOrderQueryHandler.PaymentTermsPopuplets staff correct them under the existing Edit permission on payment transactions. A due date cannot be cleared or added there, so editing never moves an order on or off credit; an order with neither gets no button and a forged post changes nothing. The change is recorded by the audit trail, and the buyer is not notified.CancelExpiredOrders, since they are unpaid on purpose.OwnerId_PaymentDueDateUtconOrder, throughMigrationOrderPaymentTermsand the installer.Plugin
Payments.OnAccount("Pay by invoice (trade credit)")BlockWhenOverdueis on (default).ProcessPaymentchecks the exact order amount again, so a hidden method posted anyway is refused and no order is created. It sets the due date through the newProcessPaymentResult.PaymentDueDateUtc.IOrderCalculationServiceis a dependency cycle (PluginDependencyCycleTestsnow covers this provider).Payments.OnAccountto the approval's allowed payment methods.Known limits
Breaking changes
No data or configuration changes are required: new fields are nullable and the PO mode defaults to Disabled, so existing installations behave as before until it is configured.
For code built against core:
IOrderService.SearchOrdershas two new optional parameters. Callers compile unchanged but need a rebuild; custom implementations and Moq setups must add them.CheckoutController.ConfirmOrdernow takesstring purchaseOrderNumber, which matters for a subclass overriding it.CustomerViewModelService,GetPaymentMethodHandlerandGetCustomerOrderListHandlerhave a new constructor dependency,ICompanyCreditService.IPaymentProviderhas a new default member, so existing payment plugins compile and behave as before.Testing
Automated:
dotnet testonGrand.Business.Checkout.Tests,Grand.Business.Common.Tests,Grand.Business.Customers.Tests,Grand.Business.Messages.Tests,Grand.Web.Tests,Grand.Web.Admin.Tests,Grand.Web.Store.Tests,Grand.Web.Vendor.Tests,Grand.Modules.TestsandGrand.Mapping.Testsall pass when run one project at a time. In a full parallel solution run some Mongo-backed tests fail at random, because the projects share a test database and drop each other's collections; those failures pass when their project is run alone.Manual:
/Admin/PaymentOnAccount/Configure) set Purchase order number to Required.ABC-1the order is placed. The PO and a due date 14 days ahead show on the order page, in Admin and in the PDF. The owner's account shows the outstanding amount.PaymentMethod=Payments.OnAccountto/checkout/SavePaymentMethod, then post to/checkout/ConfirmOrder: the order is refused with "This order exceeds your company's available trade credit."PO <b>2</b> {{ 7*7 }}as a PO number: it shows literally on the storefront and in Admin.PaymentDueDateUtcto a past date in the database: the storefront shows an Overdue badge, the Overdue only filter lists it, and the method disappears. With today's date it is not overdue.OrderApprovalLimitbelow the order total and allow the method for approvals: the order is placed Awaiting approval with its PO and due date, and counts in the outstanding amount.Customer:RegisterCustomersPerStoreon, as a store manager: check the PO mode in the plugin's configuration, the list filters, the order page, the credit limit on a company account. As a vendor: only their own orders, with the PO number and no due date on the order page.GrandNodeVersionto 2.4 and restart; the index is created.Steps 1–10 were run against MongoDB (store manager and vendor included), and steps 1–5 plus the list filters against LiteDB, before the rebase onto #1000 and before the PO mode moved to the payment method. Steps 12 to 14, the new configuration screen in Admin and Store and the removal of the field from the sales settings were run against MongoDB on the final code; only automated tests cover the rest of those earlier steps on it. The "not overdue today" case in step 8 is covered by unit tests only. Not run: plugin uninstall and reinstall, a real e-mail with the PO token, and the index path in the installer (it needs a chosen collation; the migration creating the same index was run).