Skip to content

Trade credit, PO number and payment due date for company accounts - #1001

Merged
KrzysztofPajak merged 1 commit into
developfrom
feature/b2b-payment-terms
Oct 31, 2026
Merged

KrzysztofPajak merged 1 commit into
developfrom
feature/b2b-payment-terms

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

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.CreditLimit and Customer.PaymentTermDays on 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.PurchaseOrderNumber and Order.PaymentDueDateUtc. An order counts as bought on credit when it has a due date, so core never references the plugin's system name.
  • ICompanyCreditService sums 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.
  • A payment is overdue once its due date has passed. The due date is a calendar day in UTC, so an order due today is not overdue yet. Overdue is computed, not a new payment status.
  • 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.
  • 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. A sub-account sees its company's numbers; a customer without credit sees nothing new.
  • The PO number and due date appear on the storefront order page, in Admin and Store, on the PDF invoice and as PurchaseOrderNumber / PaymentDueDate tokens 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.
  • Admin and Store order lists filter by PO number and by overdue payments. SearchOrders gets two optional parameters at the end; the filters are applied in GetOrderQueryHandler.
  • The payment transaction screen in Admin and Store shows the PO number and due date, and PaymentTermsPopup lets 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.
  • Orders bought on credit are left out of CancelExpiredOrders, since they are unpaid on purpose.
  • Index OwnerId_PaymentDueDateUtc on Order, through MigrationOrderPaymentTerms and the installer.

Plugin Payments.OnAccount ("Pay by invoice (trade credit)")

  • Hidden when the company has no credit, when the cart subtotal exceeds the available credit, or when a payment is overdue and BlockWhenOverdue is on (default).
  • ProcessPayment checks 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 new ProcessPaymentResult.PaymentDueDateUtc.
  • It reads the cart subtotal rather than the order total, because a payment provider depending on IOrderCalculationService is a dependency cycle (PluginDependencyCycleTests now covers this provider).
  • Configuration screens in Admin and Store, which also hold the PO number mode: Not asked (default), Optional or Required. To use the method for orders awaiting approval, add Payments.OnAccount to the approval's allowed payment methods.

Known limits

  • The credit limit is soft: two orders placed at the same moment can overrun it.
  • The method can be offered for a cart whose shipping pushes it over the limit; it is then refused at confirm with a clear message.
  • The term counts from placement, also for orders awaiting approval.
  • No payment reminders yet.
  • A store manager can set the credit limit of a company in their store.

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.SearchOrders has two new optional parameters. Callers compile unchanged but need a rebuild; custom implementations and Moq setups must add them.
  • CheckoutController.ConfirmOrder now takes string purchaseOrderNumber, which matters for a subclass overriding it.
  • CustomerViewModelService, GetPaymentMethodHandler and GetCustomerOrderListHandler have a new constructor dependency, ICompanyCreditService.
  • IPaymentProvider has a new default member, so existing payment plugins compile and behave as before.

Testing

Automated: dotnet test on Grand.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.Tests and Grand.Mapping.Tests all 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:

  1. Admin → Payment methods: activate Pay by invoice (trade credit).
  2. In the method's configuration (/Admin/PaymentOnAccount/Configure) set Purchase order number to Required.
  3. Create a company owner with Credit limit 1000 and Payment term 14, then a customer with that owner's e-mail as Owner. Giving this sub-account a credit limit must be refused.
  4. As the sub-account, order for about 600 and pick Pay by invoice. Confirming without a PO shows a warning; with ABC-1 the 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.
  5. Order for about 500: the method is not offered. Post PaymentMethod=Payments.OnAccount to /checkout/SavePaymentMethod, then post to /checkout/ConfirmOrder: the order is refused with "This order exceeds your company's available trade credit."
  6. Mark the first order as paid on its payment transaction: the method is offered again.
  7. Use PO <b>2</b> {{ 7*7 }} as a PO number: it shows literally on the storefront and in Admin.
  8. Set an order's PaymentDueDateUtc to 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.
  9. With sub-accounts enabled, set a buyer's OrderApprovalLimit below 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.
  10. With Customer:RegisterCustomersPerStore on, 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.
  11. Migration: set GrandNodeVersion to 2.4 and restart; the index is created.
  12. As the company owner with no sub-accounts: My account → Orders shows the trade credit. With a cart over the limit the payment step shows what is available and the method is not offered; below the limit it is offered.
  13. With the mode on Required, choose Cash on delivery or Stripe: the confirm step has no PO field and a cash on delivery order is placed without a number. Go back and choose Pay by invoice: the field returns.
  14. On the payment transaction of an order paid by invoice, use Edit PO number and due date. A number over 50 characters, an empty date and text that is not a date are refused with a message. A valid change shows on the transaction, the order and the storefront; a past date marks the payment Overdue. A cash on delivery transaction without a PO number has no button.

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).

Copilot AI balanced review requested due to automatic review settings October 30, 2026 15:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Comment on lines +32 to +36
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - OrderPaymentTerms (2.5)");
return false;
}
@KrzysztofPajak
KrzysztofPajak force-pushed the feature/b2b-payment-terms branch from e30736d to a31938d Compare October 30, 2026 19:56
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
KrzysztofPajak force-pushed the feature/b2b-payment-terms branch from a31938d to 1ca1449 Compare October 30, 2026 21:18
@KrzysztofPajak
KrzysztofPajak merged commit 55d39f8 into develop Oct 31, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/b2b-payment-terms branch October 31, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants