@huloglobal/vendure-plugin-business-credit
v0.3.1
Published
Trade credit accounts for Vendure B2B: credit applications and approvals, per-customer credit limits with temporary increases and a full audit trail, a Pay-on-Account payment method with an eligibility checker, invoices with net terms, settlements by bank
Maintainers
Readme
@huloglobal/vendure-plugin-business-credit
Trade credit for Vendure B2B. A business customer applies for an account, you approve a limit and payment terms, and from then on they choose Pay on account at checkout: the order is placed and paid at once, an invoice with a due date is raised against their account, and the money arrives later by bank transfer, card (a Stripe pay link), direct debit or cheque. The plugin keeps the ledger, sends the reminders, charges the late fees, suspends accounts that stop paying, emails statements, and gives you one admin page to run all of it. Around the account it adds what a trade customer expects: colleagues on the same account, invitations instead of applications, rules that approve good customers on the spot, periodic reviews, instalment plans for a struggling invoice, prepaid funds, and reward points on every order.
Plugin page & pricing: https://huloglobal.com/vendure-plugins/business-credit/
Quick start
yarn add @huloglobal/vendure-plugin-business-credit # or npm install / pnpm add- Register
BusinessCreditPlugin.init({ publicBaseUrl, storefrontBaseUrl })invendure-config.ts(full options under Install). - Add
BusinessCreditPlugin.uiExtensionsto yourcompileUiExtensionscall and rebuild the admin UI. - Restart Vendure. The tables are created on first boot; there is no migration to run.
- In the admin, Settings → Payment methods, add Pay on Account (business credit) with the Business credit account eligibility checker.
- Open Sales → Business credit and click Start 14-day free trial to switch every premium feature on, or leave it on the free tier (see Tiers).
What it does
| Feature | What you get |
|---|---|
| Credit applications | A storefront form (POST /business-credit/my/apply) and an admin queue. Approve with a limit, terms and a note, or reject with a note; the applicant is emailed either way. Admins can also open an account directly for any customer. |
| Accounts & limits | One account per customer per channel with a credit limit, net terms, grace days, purchase-order requirement, company / VAT details and a billing email. Permanent and temporary limit changes (with an expiry) are written to a limit-change history, and every state change lands in an audit log. |
| Pay on Account | A business-credit payment handler and a business-credit-eligibility checker. The method is offered only when the signed-in customer has an active account with enough available credit and nothing overdue; paying settles the order immediately and issues the invoice. A Vendure refund becomes a credit note against that invoice. |
| Invoices with net terms | INV-000042-style numbering per channel, dueAt = issued + terms, open / part-paid / paid / overdue / written-off / void, PO number, late fees, pay-link reference. Customers see their own invoices under /business-credit/my. |
| Settlements & allocation | Record bank transfer, card, direct debit, cheque, cash, credit note or write-off. Money is allocated oldest-due-first (or exactly as you say); anything left over is held as credit on the account and applied to the next invoice. Refund a settlement, void or write off an invoice, add a fee. |
| Prepaid funds | Money held on the account before any invoice — an admin deposit (bank transfer, cheque, cash…) or a card top-up the customer pays from the storefront. Held funds raise available and settle every invoice automatically the moment it is issued, so an order placed on account against funds is paid at once. Withdraw funds back to the customer; an account with a zero limit can trade on funds alone. |
| Stripe pay links | A signed Stripe Checkout Session per invoice set, from the admin or from the storefront ("pay now"), a top-up session for funds, and a webhook (POST /business-credit/stripe-webhook) that records the card settlement exactly once. |
| Statements & aging | Opening balance, running lines, closing balance and a 1–30 / 31–60 / 61–90 / 90+ aging for any period; JSON and HTML for the admin and the customer; monthly statement emails. |
| Dunning | A daily pass that marks invoices overdue, sends reminders on a schedule relative to the due date, charges a monthly late fee, auto-suspends accounts past a threshold, applies held credit, expires invitations, flags reviews and emails statements. |
| Company accounts & members | The account holder adds colleagues by email as buyers (order on account) or viewers (see invoices and statements). Members see the company account under /business-credit/my; a viewer at checkout is told their role does not allow it. |
| Invitations | Offer a business an account on pre-agreed terms with one email: a signed link, a public accept page with a masked address, and an active account the moment the invitee signs in and accepts. |
| Auto-approval | A per-channel rule approves storefront applications on the spot for customers with enough settled orders (and a company number, if you insist), capped at a limit you set. Everything else waits for a human. |
| Credit reviews | Every account gets a review date (12 months by default). The daily pass flags what is due and emails you; Mark reviewed moves it on a cycle. |
| Payment plans | Split an overdue invoice into instalments. Settlements pay them off in order, reminders follow the next instalment, no late fee accrues while the plan is kept, and the customer can ask for one from their invoice page. |
| Reward points | A loyalty programme per channel: points on every settled order with tier multipliers, redeemed at checkout as a negative surcharge, with expiry, admin adjustments, stats and a product-page preview. |
| Admin dashboard | Sales → Business credit: Overview (exposure, overdue, credit held, due in 7 days, reviews due, aging, top exposures), Accounts (detail with members, invoices, ledger, record settlement, funds, statement, review, events), Applications, Invitations, Invoices (with plans), Settlements, Loyalty, Settings, plus the Licence & billing card. |
Install
yarn add @huloglobal/vendure-plugin-business-creditimport { BusinessCreditPlugin } from '@huloglobal/vendure-plugin-business-credit';
export const config: VendureConfig = {
plugins: [
BusinessCreditPlugin.init({
publicBaseUrl: 'https://api.example.com',
storefrontBaseUrl: 'https://shop.example.com',
licenceKey: process.env.HULO_LICENCE_KEY_BUSINESS_CREDIT,
stripeWebhookSecret: process.env.BUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET,
smtp: {
host: 'smtp.example.com', port: 587,
user: process.env.SMTP_USER!, pass: process.env.SMTP_PASSWORD!,
from: '[email protected]',
},
crons: { dailyAt: '07:00' },
}),
],
};| Option | Required | What it does |
|---|---|---|
| publicBaseUrl | yes | Public host of the Vendure server. Used for licence domain matching and as the last fallback for links in emails. |
| storefrontBaseUrl | no | Base URL of the storefront that the customer emails link to (/account/credit, /account/credit/invoices/:id). The per-channel setting Storefront URL overrides it. Falls back to publicBaseUrl. |
| licenceKey | no | Your licence key from huloglobal.com. Without one the plugin runs the 14-day evaluation and then drops to the free tier (see Tiers). A key activated in the admin is stored in the database and used whenever this option is missing or invalid. |
| smtp | no | { host, port, user, pass, from } for reminders, statements and decisions. Without it the SMTP_SERVER / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM env vars are used; without either, emails are skipped and reported. |
| stripeWebhookSecret | no | Signing secret of the Stripe webhook endpoint that delivers to /business-credit/stripe-webhook. A per-channel BUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET_<CHANNELCODE> env var overrides it. |
| crons | no | { enabled?, dailyAt? }. The daily dunning pass runs at 07:00 server time on the worker process. enabled: true forces it onto the current process too (single-process installs); otherwise only the worker runs it. dailyAt: 'HH:MM' moves the time. The admin Run now button and POST /business-credit/dunning/run work on any process. |
Admin UI: add the extension to your compileUiExtensions call:
import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
import { BusinessCreditPlugin } from '@huloglobal/vendure-plugin-business-credit';
compileUiExtensions({
outputPath: path.join(__dirname, 'admin-ui'),
extensions: [BusinessCreditPlugin.uiExtensions],
});The page appears under Sales → Business credit for administrators with
ReadCustomer; write actions need UpdateCustomer.
Payment method: in the Vendure admin, Settings → Payment methods → Add, one per channel:
- Handler: Pay on Account (business credit) (
business-credit) — it has no arguments. - Eligibility checker: Business credit account eligibility (
business-credit-eligibility) — optional Minimum order total (minor units), default 0.
Give it a code your storefront will use (the examples below use
business-credit). With the checker attached, Vendure lists the method in
eligiblePaymentMethods with isEligible: false and an eligibilityMessage
whenever the customer cannot pay on account, so the storefront can show why.
Database: the tables below are created on boot (MySQL / MariaDB / PostgreSQL through the licence SDK's dialect adapter). No migration to run. Every service creates them idempotently, so a multi-instance deployment is fine.
How credit works
Money is integer minor units throughout (50000 = 500.00). Per account:
balance(invoice) = amount + lateFee − paid (never below 0)
exposure = Σ balance over open | part_paid | overdue invoices
creditBalance = Σ unallocated money from completed settlements
effectiveLimit = temporary limit while it is unexpired, else the permanent limit
available = max(0, effectiveLimit − exposure + creditBalance)When a customer tries to pay on account the plugin decides in this order and
returns the first reason that applies (the storefront sees it as the
eligibility message or the declined payment's errorMessage):
no_account— no account in this channel (their own, or one they are a member of).member_viewer— they are a viewer on a company account; viewers cannot order on account.suspended— the account is on hold.not_active— pending or closed.currency_mismatch— the order is not in the account's currency.po_required— the account (or the channel) requires a purchase-order number and none was sent.overdue_hold— any invoice is past due + grace (or, under a payment plan, an instalment is); nothing further can be placed on account until it is settled.over_limit—order.totalWithTaxis more thanavailable.
Statuses. pending (created but not yet approved), active,
suspended (manual, or automatic when an invoice is autoSuspendAfterDays
overdue — reactivate from the account page once the customer has paid) and
closed (only with zero exposure; a closed account cannot be reopened, but
the customer may apply again).
Terms and grace. dueAt = issuedAt + termsDays. Grace days do not move
the due date; they only delay the moment an invoice is marked overdue
(and therefore the overdue hold, reminders and the late fee). Days-overdue
counts are always measured from dueAt.
Temporary limits. Adjust limit → temporary with an expiry raises (or
lowers) the effective limit until that moment; the daily pass clears expired
ones and records an expired entry in the limit history, so the audit trail
shows what applied when.
Purchase orders. Tick Require purchase order on the channel or on the
account and the storefront must pass purchaseOrder in the
addPaymentToOrder metadata (an Order custom field purchaseOrder or
poNumber is also read). The PO is stored on the invoice, printed on the
statement and included in the invoice email.
Settlements and allocation
POST /business-credit/settlements (or Record settlement on the account
page) takes method, amountMinor, an optional reference, receivedAt
and note, and either allocations: [{ invoiceId, amountMinor }] or
"auto":
- Automatic allocation pays invoices oldest-due-first (
dueAt, then id) until the money runs out. - Explicit allocation is validated: only open invoices of that account, never more than an invoice's balance, never more than the settlement.
- Anything unallocated becomes credit on the account. It raises
availableimmediately, and the daily pass (orPOST /business-credit/accounts/:id/apply-credit) applies it to open invoices, oldest first, writing a net-zero pair of ledger rows so the invoice history stays complete. - An invoice becomes
paidwhenpaid ≥ amount + lateFee, otherwisepart_paid(or staysoverdue). credit_noteandwrite_offare settlement methods too, so a goodwill credit or a bad debt goes through the same allocation and shows up on the statement. A write-off must be allocated in full.- Refunding a settlement (
POST /business-credit/settlements/:id/refund) reverses the ledger only — unallocated credit first, then the invoice allocations proportionally — and winds the invoices' paid amounts back. The money itself leaves through your bank or the Stripe dashboard. - Refunding an order in the Vendure admin calls the handler's
createRefund, which records a credit note against the invoice. If the invoice was already paid, the excess becomes credit on the account. - Void (nothing paid against it) reverses the charge; write off
books the open balance as a
write_offsettlement. providerRef(the Stripepayment_intent) is unique: a settlement with a provider reference that already exists is returned asduplicate: trueand nothing is written.
Ledger sign convention: charge and fee positive (the customer owes
more), payment, credit_note and write_off negative, refund positive,
adjustment either. The account balance is the plain sum, so statements are
a running total of the table.
Prepaid funds
Funds are settlement money that is not (yet) allocated to an invoice: the
unallocatedMinor of completed settlements, summed per account. The plugin
already held such money as "credit on account"; in 0.2.0 it becomes a
first-class feature:
- Deposit —
POST /business-credit/accounts/:id/deposit(or Accounts → Funds → Record a deposit) records money received with no invoice to pay:{ method, amountMinor, reference?, receivedAt?, note? }.methodmust be a real payment method (bank_transfer,card,direct_debit,cheque,cash,other;credit_note/write_offare refused with 400invalid_method). The deposit is applied at once to any open invoice, oldest first, and whatever remains is held. The response is the settlement plusapplied: { appliedMinor, allocations }. - Card top-up — the customer pays an arbitrary amount through Stripe
Checkout:
POST /business-credit/my/top-up { amountMinor, successUrl?, cancelUrl? }→{ url, sessionId, amountMinor, currencyCode }(premiumcard_pay_links, like pay links; 409stripe_not_configuredwithout a Stripe key in the channel,card_settlements_disabledwhen switched off,account_closedon a closed account). The admin can create the same link to send to the customer withPOST /business-credit/accounts/:id/top-up-link { amountMinor, successUrl?, cancelUrl? }. The return URLs default to<storefront>/account/credit?topup=success/?topup=cancelled. - Automatic settlement — the moment an invoice is issued (a new order
paid on account,
invoice.issued) the plugin applies held funds to it, so the invoice ispaidimmediately and the customer'sexposurestays at zero. The ledger shows the usual net-zeroadjustment+paymentpair per invoice, the settlement'sallocationsgrow, andsettlement.credit_applied/invoice.paid(withviaCredit: true) are logged. This happens on the event bus right after the payment handler returns, so allow a moment before reading the invoice back. - Eligibility —
available = limit − exposure + funds, so funds count towards Pay-on-Account eligibility. An account withcreditLimitMinor: 0and 200.00 of funds can place a 120.00 order (settled from the funds) but not a 300.00 one — a pure prepaid account, with no credit extended. - Withdraw —
POST /business-credit/accounts/:id/withdraw { amountMinor, note? }→{ ok, withdrawnMinor, settlements }books the return of funds on the ledger (as a settlement refund, most recent deposit first) and refuses more than is held with 409insufficient_funds { availableMinor }. The money itself goes back through your bank or a Stripe refund. - Reading the balance —
GET /business-credit/accounts/:id/funds→{ accountId, fundsHeldMinor }; the customer sees it asfunds: { heldMinor, currencyCode }onGET /business-credit/my/accountand asfundsHeldMinoronGET /business-credit/my/settlements, which lists their deposits and top-ups. The dashboard'screditBalanceHeldMinor("Funds held") is the total across accounts.
The daily pass still applies held funds to anything open (a safety net if
the event was missed), and POST /business-credit/accounts/:id/apply-credit
does it on demand.
Company accounts and members
An account belongs to one customer — the owner — but a business rarely has one person placing orders. The owner (or an admin) adds colleagues as members of the account:
| Role | Can |
|---|---|
| owner | Everything: order on account, see invoices and statements, manage members. The account holder; not a member row. |
| buyer | Order on account and see the invoices, statements, ledger and member list. |
| viewer | See the invoices, statements and ledger. At checkout Pay on account is refused with member_viewer; the member list is hidden. |
Every storefront route, the payment handler and the eligibility checker
resolve "my account" the same way (AccountsService.resolveAccountForCustomer):
the customer's own account if they have one that is not closed, otherwise
the account they are a member of (the earliest membership, if several).
GET /business-credit/my/account says which with role and isOwner; a
member's invoices are the company's invoices, and an order they place on
account raises the invoice on the company's account (the invoice's
customerId is the holder; orderId points at the buyer's order).
- Owner, from the storefront:
GET /business-credit/my/members,POST /business-credit/my/members { email, role },DELETE /business-credit/my/members/:memberId. The colleague must already be a registered customer in the channel (404no_customer); the owner themselves (409is_owner), an existing member (409already_member) and a customer who holds their own account in the channel (409owns_account) are refused;rolemust bebuyerorviewer(400bad_role). Members get 403 on these routes; the per-channel switch Allow member management (allowMemberManagement) turns them off with 403member_management_disabled, andGET my/accountreports it asmemberManagement. A new member is emailedmember_added. - Admin: the Members card on the account page, or
GET /business-credit/accounts/:id/members,POST accounts/:id/members { email | customerId, role },POST accounts/:id/members/:memberId { role }andDELETE accounts/:id/members/:memberId. Admins are not bound by the channel switch. - Events
member.added,member.role_changedandmember.removedland in the account's audit log. Premium featuremembers(the storefront management routes answer 402 without a licence; admins can always manage).
A customer who trades through a company account is not offered a new
application (canApply: false); their own application would only make
sense once they leave the company account.
Invitations
Instead of waiting for an application you can invite a business onto an account on the terms you have already agreed with them:
POST /business-credit/invitations { email, creditLimitMinor, termsDays?, companyName?, message? }(or Invitations → Invite) creates a pending invitation with a 64-character random token and emails the invitee (invitation: your business name, the limit, the terms, your message and the accept link<storefront>/account/credit/accept?token=…). It expires after the channel's Invitation validity (invitationDays, default 14). The email must not already hold an account in the channel (409account_exists) or have an open invitation (409invitation_pending).- Your storefront's accept page calls the public
GET /business-credit/invitations/:tokento show what is on offer:{ invitation: { status, email, companyName, creditLimitMinor, termsDays, currencyCode, message, expiresAt, businessName, channelId } }. The address is masked (in***@example.com) and the token is never echoed, so a forwarded link leaks nothing. Unknown or malformed tokens are 404; a pending invitation past its expiry already readsexpired. - The invitee signs in (or registers) with the invited address and the
page calls
POST /business-credit/my/invitations/accept { token }→ 201{ invitation, account, availability }: an active account on the invited limit and terms,approvedBy: 'invitation', billing email set to the invited address, first review one cycle out. A closed account of theirs is reopened on the new terms. Refusals: 403email_mismatch(signed in as someone else), 409invitation_expired,invitation_not_pending(accepted or revoked) oraccount_exists. The channel's notify address getsinvitation_accepted.
GET /business-credit/invitations?channelId&status&email lists them
(pending first), POST invitations/:id/resend sends the same link again
(sentCount, lastSentAt), POST invitations/:id/revoke withdraws a
pending one. The daily pass marks pending invitations past their expiry
expired (invitationsExpired in the report). Premium feature
invitations.
Auto-approval
For customers who have already proved themselves you can let the channel
approve storefront applications on the spot. In Settings → Auto-approval
(or POST /business-credit/config):
| Field | Meaning |
|---|---|
| autoApproveEnabled | Master switch (off by default). |
| autoApproveMinOrders | Settled orders (PaymentSettled / Shipped / PartiallyShipped / Delivered) the customer must have in this channel. Default 1. |
| autoApproveMinSpendMinor | Their total spend across those orders. |
| autoApproveRequireCompanyNumber | The application must carry a company number. Default on. |
| autoApproveMaxLimitMinor | Cap on the limit the rule may grant. 0 means the rule never approves. |
When a storefront application passes every check it is approved at once
by actor auto with the note Auto-approved by rule, on the channel's
default terms and a limit of min(requested || defaultLimitMinor,
autoApproveMaxLimitMinor). POST /business-credit/my/apply then answers
{ application, account, availability, autoApproved: true } and the
approved email goes out; your storefront can send the customer straight
to the checkout. Anything that fails a check (or an admin-created
application) stays pending for a human, exactly as before. The rule is
premium (auto_approval) and lives in src/accounts/rules.ts if you want
to read it.
Credit reviews
Trade credit should be looked at now and then. Every approval — manual,
automatic or by invitation — sets reviewAt = now + reviewEveryMonths
(channel setting Review every, default 12 months; 0 switches reviews
off). Accounts carry reviewAt, reviewNote and a computed reviewDue;
GET /business-credit/accounts?reviewDueOnly=true and the dashboard's
reviewsDue show what is waiting. The daily pass publishes
account.review_due for each due account and emails review_due to the
channel's notify address (once, then again after 30 days if nobody acts).
Mark reviewed on the account page — POST /business-credit/accounts/:id/review
{ note? } — records the note, logs account.reviewed and moves reviewAt
on one cycle (from today when the review was long overdue, so an account
is never immediately due again).
Payment plans
When a customer cannot clear an invoice in one go, split its open balance
into dated instalments. POST /business-credit/invoices/:id/plan (or
Set up a plan on the invoice) takes either a number of equal instalments
— the last one takes the rounding — or an explicit schedule:
{ "instalments": 3, "startAt": "2026-10-01", "intervalDays": 30, "note": "Agreed by phone" }
{ "instalments": [{ "dueAt": "2026-10-01", "amountMinor": 20000 }, { "dueAt": "2026-11-01", "amountMinor": 20000 }] }Two to sixty instalments; an explicit schedule must add up to the balance
exactly (400 instalments_mismatch). The invoice must be open with a
balance and no active plan (409 invoice_not_open / plan_exists). The
customer is emailed the schedule (plan_created) and an overdue invoice
goes back to open / part_paid — its next instalment is what is due now.
While a plan is active:
- Money allocated to the invoice pays the instalments in order
(
paidMinor,status: 'paid'); a refund winds them back. The plancompleteds when the invoice is paid and reopens if a refund unpays it. - The daily pass judges lateness by the next unpaid instalment: it is
marked
overdueonce its due day + grace has passed, the invoice becomesoverdue(and the account gets its overdue hold) only then, and the reminders run against the instalment's due date, with an "instalment 2 of 3" line and the instalment amount. Reminder bookkeeping lives on the instalment (remindersSent), not the invoice. - No late fee accrues, and auto-suspend counts days from the overdue instalment, not the invoice's original due date.
GET /business-credit/invoices/:id/plan → { plan | null } with the
instalments and the derived totalMinor, paidMinor, remainingMinor,
nextDueAt, nextInstalment, paidCount, overdueCount;
GET /business-credit/plans?channelId&accountId&invoiceId&status lists
them. POST /business-credit/invoices/:id/plan/cancel { note? } ends the
plan: the invoice keeps its own due date, so it is overdue again if that
has passed, and fees resume on the next pass. Voiding or writing off the
invoice closes the plan too. Statuses: active, completed, cancelled,
defaulted.
Customers see an active (or completed) plan on
GET /business-credit/my/invoices/:id as plan, and can ask for one:
POST /business-credit/my/invoices/:id/plan/request { instalments, message? }
→ 202. Nothing is created — the channel's notify address gets
plan_requested with the invoice, the wish and the message, and you decide
in the admin. Switch requests off per channel with Allow plan requests
(allowCustomerPlanRequests, 403 plan_requests_disabled). Premium
feature payment_plans.
Stripe pay links + webhook
Card settlements use Stripe Checkout with no Stripe SDK. The plugin reads
the channel's Stripe secret key from the channel's existing Vendure Stripe
payment method (handler stripe, argument apiKey) — one key per channel,
in one place.
- In Stripe, add a webhook endpoint at
https://<api>/business-credit/stripe-webhookfor the eventscheckout.session.completedandcheckout.session.async_payment_succeeded. - Put the endpoint's signing secret in
stripeWebhookSecret, or per channel inBUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET_<CHANNELCODE>(channel code upper-cased, non-alphanumerics →_; the default channel isBUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET_DEFAULT_CHANNEL). The env var wins. - Leave Card settlements on in the channel settings.
A pay link is a Checkout Session in payment mode with one line item per
invoice for its open balance and metadata
{ business_credit: '1', invoiceIds: '12,13', accountId, channelId }. The
admin creates one from an invoice
(POST /business-credit/invoices/:id/pay-link, more invoices of the same
account via invoiceIds); the customer creates their own from the
storefront (POST /business-credit/my/invoices/pay-link). Both answer
{ url, sessionId, amountMinor, currencyCode, invoiceIds } — redirect the
customer to url. The storefront route must send successUrl and
cancelUrl; the admin route defaults them to
<storefront>/account/credit?paid=1&session={CHECKOUT_SESSION_ID} and
<storefront>/account/credit?cancelled=1.
A funds top-up is a Checkout Session with a single line item and the
metadata { business_credit: '1', topup: '1', accountId, channelId,
amountMinor } (and client_reference_id: 'BC-<accountId>-TOPUP'). The
same webhook handles it: the payment is recorded as a card settlement
with no allocations — held as funds — then applied to anything open, and
the outcome carries topUp: true with an empty invoiceIds.
The webhook verifies the signature against the raw body (timestamp
tolerance 300 s, never a fallback), ignores events that are not ours,
records a card settlement for amount_total allocated to the linked
invoices (or held as funds for a top-up), and answers
{ handled, duplicate, settlementId, invoiceIds, topUp? }.
Bad signatures get 400; an internal failure after a good signature gets
5xx so Stripe retries. Retries of the same payment_intent are duplicates
and book nothing. The plugin registers its own raw-body middleware for
this route, so nothing else is needed in your config.
Dunning, late fees, auto-suspend, statements
The daily pass (DunningCron, 07:00 on the worker, or Settings → Run
dunning now, or POST /business-credit/dunning/run) does, per channel:
- Expire temporary limits whose
expiresAthas passed, and expire invitations past theirs. - Mark overdue every
open/part_paidinvoice withdueAt + graceDays < now(account grace days override the channel's). An invoice under a payment plan goes by its next unpaid instalment instead (see Payment plans). - Reminders (premium) per the channel's Reminder schedule — days
relative to the due date, negative before,
0on the day, positive after; default-3, 0, 7, 14, 30. Each schedule day is sent once per invoice (remindersSent). If several days are due at once (the cron was down, the invoice was back-dated) only the latest is sent and the earlier ones are marked as sent, so a customer never gets a burst. A "before" reminder that would fall before the invoice was issued is skipped. Kinds:reminder_before,reminder_due,reminder_overdue. - Late fees (premium) when Late fee % per month is set: once an
invoice is overdue by more than Late fee grace days, and at least 30
days since the due date (or since the last fee), the plugin adds
round(balance × pct / 100 × days / 30)as afeeledger row and to the invoice'slateFeeMinor, wheredayscounts from the due date (or the last fee) to today. So at 2 % a 400.00 invoice 40 days overdue is charged 10.67, and nothing more until 30 days later. Invoices under an active payment plan are never charged. - Auto-suspend (premium) when Auto-suspend after days is set: an
active account with any invoice that many days past due is suspended
with a hold reason naming the invoice, and the
suspendedemail goes out. Reactivate from the account page once they have paid; the overdue hold still blocks new orders until the invoice is actually settled. Under a plan the days count from the overdue instalment. - Review reminders (premium email, free flag): accounts whose
reviewAthas passed get anaccount.review_dueevent and thereview_dueemail to the notify address — once, then again after 30 days. - Apply held credit on accounts with unallocated money and open invoices.
- Statement emails (premium) on the channel's Statement day of the month, for the previous calendar month, to every active or suspended account with activity — once per account per month.
The run returns a report (scanned, markedOverdue, remindersSent,
feesApplied, suspended, tempLimitsExpired, invitationsExpired,
reviewsDue, planInstalmentsOverdue, statementsSent, creditApplied,
errors, errorDetails, and skipped counts for premium steps on the
free tier). GET /business-credit/dunning/status shows the
SMTP state, whether the cron runs on this process, when it next runs and
the last report.
Statements (GET /business-credit/accounts/:id/statement?from&to, .html
for the rendered page, and GET /business-credit/my/statement for the
customer) cover any period; without one, the previous calendar month.
Reward points programme
A loyalty scheme that lives next to the credit account — for every
customer in the channel, not only account holders. Switch it on per channel
under Loyalty → Settings (POST /business-credit/loyalty/config):
| Field | Default | Meaning |
|---|---|---|
| enabled | off | The programme is live for customers. |
| programmeName | Reward points | Shown in the storefront, the surcharge line and the emails. |
| earnPointsPer100Minor | 1 | Points per 1.00 of order.totalWithTax (1 point per £1). |
| redeemValueMinorPerPoint | 1 | Minor units one point is worth when redeemed (1p per point). |
| minRedeemPoints | 100 | Smallest redemption. |
| maxRedeemPercent | 50 | Points may cover at most this share of order.subTotalWithTax. |
| expiryMonths | 12 | Earned points expire after this; null = never. |
| signupBonusPoints | 0 | Granted when a customer's loyalty account is first created. |
| earnOnAccountOrders | on | Also earn on orders paid on account. |
| tiers | Bronze 0 ×1 · Silver 2 000 ×1.25 · Gold 10 000 ×1.5 | [{ name, minLifetimePoints, multiplier }], by lifetime points earned. |
Earning. When an order reaches PaymentSettled (or is placed with a
payment that settles at once, such as Pay on Account — the two events are
de-duplicated per order) the customer earns
floor(totalWithTax / 100 × earnPointsPer100Minor × tierMultiplier) as one
earn row with an expiresAt. Balance and lifetime points go up, the
tier is re-derived from lifetime points, and a change is emailed
(tier_changed). Cancelling the order writes a reversal (clamped so the
balance never goes below zero — the note records any shortfall) and
releases any redemption on it.
Redeeming. At checkout the customer spends points on the active order:
POST /business-credit/my/loyalty/redeem { points } checks the minimum,
the balance and the percentage cap, then adds a negative surcharge
(sku: 'LOYALTY-POINTS', description <programme>: <n> points, tax
inclusive, no tax lines) so the order total drops by the points' value,
and books a pending redeem row. POST my/loyalty/unredeem takes it
off again (release row); redeeming a different amount replaces the
first; placing the order makes the redeem final. A pending redemption
whose order is never placed is given back by the daily sweep after seven
days (the surcharge is removed if the order still exists).
Expiry. On the 1st of each month (or POST /business-credit/loyalty/run
{ expire: true }) each account expires what is left of the earnings whose
expiresAt has passed, on the assumption that redemptions used the oldest
points first: expirable = max(0, min(balance, Σ expired credits − Σ every
deduction)), written as one expire row. Expiry never lowers lifetime
points, so tiers are kept. GET my/loyalty shows the next batch due
within 90 days as expiringSoon.
Everything is premium (loyalty): without a licence, or with the channel
switched off, nothing is earned, the storefront reads { enabled: false }
and writes answer 402 / 409 loyalty_disabled. Points, earning and
redemption are in business_credit_loyalty_account /
business_credit_loyalty_tx (signed points; the balance is the running
sum), audited under loyalty.* in the event log. See
Storefront integration for the points panel,
the product-page preview and the checkout.
Emails and SMTP
Seventeen kinds, all plain text wrapped in a small branded HTML shell (business name header, call-to-action button, footer):
| Kind | Sent when |
|---|---|
| application_received | An application is submitted (plus a summary to the channel's Notify email). |
| application_approved / application_rejected | An admin (or the auto-approval rule) decides. |
| invoice_issued | An order is paid on account. |
| settlement_received | A bank transfer, card, direct debit, cheque or cash settlement is recorded (not credit notes or write-offs). |
| reminder_before / reminder_due / reminder_overdue | The dunning schedule (against the next instalment when a plan is active). |
| suspended | Auto-suspend, or a manual suspension from the admin. |
| statement | The monthly statement. |
| invitation | To the invitee: business name, limit, terms, your message, the accept link and the expiry. Sent again on Resend. |
| invitation_accepted | To the notify address: who accepted, on what terms. |
| member_added | To the colleague added to a company account: which account, their role, a link to the account area. |
| plan_created | To the customer: the instalment schedule, your note, the invoice link. |
| plan_requested | To the notify address: which invoice, how many instalments, the customer's message. |
| review_due | To the notify address: the account whose review date has passed, its limit and availability. |
| tier_changed | To the customer: the tier they reached, their balance, a link to <storefront>/account/rewards. |
Recipient: the account's Billing email, else the customer's email. Sender
name: the channel's Business name; reply-to: the channel's Reply-to.
Every email links to the storefront (<storefront>/account/credit,
<storefront>/account/credit/invoices/:id,
<storefront>/account/credit/accept?token=… and
<storefront>/account/rewards) — make sure your storefront serves those
routes, or set Storefront URL per channel to wherever your account area
lives. Settings → Preview renders any kind with sample data; Send test
delivers it to an address of your choice.
SMTP comes from the smtp option, else SMTP_SERVER, SMTP_PORT
(default 587; 465 = implicit TLS), SMTP_USER, SMTP_PASSWORD, SMTP_FROM.
Sending never throws: a failure is logged, counted in the dunning report and
never blocks a settlement or an order.
Hosts that want their own templates can subscribe to BusinessCreditEvent
on Vendure's EventBus (type is application.received,
application.approved, application.rejected, invoice.issued,
account.created, account.suspended, account.reactivated,
account.limit_changed, account.reviewed, account.review_due,
settlement.recorded, invitation.created (with token and acceptUrl),
invitation.accepted, member.added, member.removed, plan.created,
plan.requested, plan.completed, plan.cancelled,
loyalty.tier_changed, …; payload carries the customer email, name,
invoice number, amounts and due date).
Compatibility
Vendure >=3.5.0 <4.0.0 (tested on 3.5, 3.6 and 3.7). MySQL, MariaDB and
PostgreSQL through the licence SDK's dialect adapter. Node 20 LTS or newer.
A boot-time check logs a non-fatal warning if @vendure/core is outside the
declared range. The plugin exposes REST routes under /business-credit
rather than GraphQL extensions; see REST reference.
Tiers
Free: accounts, credit limits with the audit trail, the Pay-on-Account handler and eligibility checker, invoices, manual settlements with allocation, the ledger, statements (view), review dates, the admin dashboard.
Licensed (or the 14-day evaluation): card pay links (Stripe), reminders
and dunning emails, late fees, auto-suspend, statement emails, CSV exports,
credit applications from the storefront, and the 0.3.0 platform features —
invitations, auto_approval, members (storefront member management),
payment_plans and loyalty (the reward points programme).
On the free tier a locked action answers HTTP 402
{ error: 'licence_required', feature } (the admin UI shows a lock hint),
the daily pass counts what it would have done under skipped, and
GET /business-credit/my/account reports canApply: false. Admins can
still create applications and accounts on a customer's behalf.
REST reference
All routes are under /business-credit. Admin routes need a session with
ReadCustomer (reads) or UpdateCustomer (writes) and answer
403 { error: 'forbidden' } otherwise; the licence routes need
ReadOrder / UpdateOrder. Storefront routes under /business-credit/my
need a signed-in customer (401 { error: 'unauthenticated' }) and are
scoped to that customer in the request's channel. Errors are
{ error: '<code>', message, ...extra } with a meaningful status (400
validation, 402 licence, 404 not found, 409 conflict, 502 Stripe). Bodies
and responses are JSON; channelId defaults to the request's channel and
accepts all on listings.
Admin — accounts
| Method | Path | Body / query → response |
|---|---|---|
| GET | dashboard?channelId= | { accountsByStatus, totalAccounts, totalExposureMinor, totalOverdueMinor, creditBalanceHeldMinor, dueIn7Days: { count, amountMinor }, openInvoiceCount, overdueInvoiceCount, pendingApplications, reviewsDue, aging: { current, d1_30, d31_60, d61_90, d90plus }, topExposures: [...], currencyCode } |
| GET | config?channelId= | { premium, config } — see the config fields below; channelId=all → { premium, configs: [...] } |
| POST | config | { channelId?, ...patch } → { config } |
| GET | accounts?channelId&status&search&overdueOnly&reviewDueOnly&page&pageSize | { items: AccountRow[], total, page, pageSize } — each row carries exposureMinor, creditBalanceMinor, effectiveLimitMinor, availableMinor, overdueMinor, oldestOverdueDays, openInvoiceCount, reviewAt, reviewNote, reviewDue, customerName, customerEmail |
| POST | accounts | { customerId, channelId?, creditLimitMinor?, termsDays?, graceDays?, requirePurchaseOrder?, currencyCode?, companyName?, companyNumber?, vatNumber?, billingEmail?, notes?, status? } → 201 { account } (409 no_customer / account_exists) |
| GET | accounts/:id | { account, availability, events, limitHistory, members, premium } |
| POST | accounts/:id/review | { note? } → { account } — review done, reviewAt moves on one cycle (409 account_closed) |
| GET | accounts/:id/members | { items: [{ id, customerId, role, addedBy, createdAt, customerName, customerEmail }], owner: { customerId, name, email }, premium } |
| POST | accounts/:id/members | { email \| customerId, role: 'buyer' \| 'viewer' } → 201 { member } (404 no_customer, 409 is_owner / already_member / owns_account, 400 bad_role) |
| POST | accounts/:id/members/:memberId | { role } → { member } |
| DELETE | accounts/:id/members/:memberId | → { ok, member } (404 member_not_found) |
| GET | invitations?channelId&status&email&page&pageSize | { items: InvitationRow[], total, page, pageSize, premium } — pending first; status is pending / accepted / expired / revoked / all |
| POST | invitations | { channelId?, email, creditLimitMinor, termsDays?, companyName?, message?, currencyCode? } → 201 { invitation } incl. token, acceptUrl, expiresAt, sentCount (400 email_required / bad_limit, 409 account_exists / invitation_pending; premium) |
| POST | invitations/:id/revoke · invitations/:id/resend | → { invitation } (409 invitation_accepted / not_pending) |
| GET | invitations/:token | public → { invitation: { status, email (masked), companyName, creditLimitMinor, termsDays, currencyCode, message, expiresAt, businessName, channelId } } (404 invitation_not_found) |
| POST | accounts/:id | { termsDays?, graceDays?, requirePurchaseOrder?, currencyCode?, companyName?, companyNumber?, vatNumber?, billingEmail?, notes? } → { account } |
| POST | accounts/:id/status | { status: 'active' \| 'suspended' \| 'closed', reason? } → { account } (409 exposure_outstanding when closing with open invoices) |
| POST | accounts/:id/limit | { newLimitMinor, kind: 'permanent' \| 'temporary', expiresAt?, reason? } → { account } (400 bad_expiry) |
| GET | accounts/:id/limit-history | { items: [{ oldLimitMinor, newLimitMinor, kind: 'permanent' \| 'temporary' \| 'expired', expiresAt, reason, actorName, createdAt }] } |
| GET | accounts/:id/events?limit= | { items: [{ type, payload, actorName, createdAt }] } |
| GET | applications?channelId&status&page&pageSize | { items: ApplicationRow[], total, page, pageSize } (pending first) |
| POST | applications | { customerId? \| email, companyName, companyNumber?, vatNumber?, contactName?, phone?, website?, requestedLimitMinor?, requestedTermsDays?, tradingSince?, tradeReferences?, message? } → 201 { application } |
| POST | applications/:id/approve | { creditLimitMinor, termsDays?, note? } → { application, account } (409 no_customer if nobody has registered with that email yet) |
| POST | applications/:id/reject | { note? } → { application } |
| GET | customers/search?q=&channelId= | { items: [{ id, name, emailAddress, hasAccount, accountId, accountStatus }] } |
availability is { accountId, status, currencyCode, creditLimitMinor, effectiveLimitMinor, tempLimitMinor, tempLimitExpiresAt, exposureMinor, creditBalanceMinor, availableMinor, overdueMinor, oldestOverdueDays, overdueHold, termsDays, graceDays, requirePurchaseOrder }.
InvitationRow is { id, channelId, email, customerId, token, creditLimitMinor, termsDays, currencyCode, companyName, message, status, expiresAt, acceptedAt, accountId, sentCount, lastSentAt, createdBy, createdAt, updatedAt, acceptUrl }.
Admin — invoices, plans, settlements, statements
| Method | Path | Body / query → response |
|---|---|---|
| GET | invoices?accountId&channelId&customerId&status&overdueOnly&search&page&pageSize | { items: InvoiceRow[], total, page, pageSize, premium } — status is one of the six statuses or open_any |
| GET | invoices/:id | { invoice, ledger, settlements, plan } |
| POST | invoices/:id/write-off | { note? } → { ok, invoice } |
| POST | invoices/:id/void | { note? } → { ok, invoice } (409 invoice_has_payments) |
| POST | invoices/:id/fee | { amountMinor, note? } → { ok, invoice } |
| POST | invoices/:id/pay-link | { successUrl?, cancelUrl?, invoiceIds? } → { ok, url, sessionId, amountMinor, currencyCode, invoiceIds } (premium) |
| GET | invoices/:id/plan | { plan: PlanRow \| null, premium } — the active plan, else the latest |
| POST | invoices/:id/plan | { instalments: n \| [{ dueAt, amountMinor }], startAt?, intervalDays?, note? } → 201 { ok, plan, invoice } (400 invalid_instalments / instalments_mismatch / …, 409 invoice_not_open / plan_exists; premium payment_plans) |
| POST | invoices/:id/plan/cancel | { note? } → { ok, plan, invoice } (404 plan_not_found) |
| GET | plans?channelId&accountId&invoiceId&status&page&pageSize | { items: PlanRow[], total, page, pageSize, premium } — active first |
| GET | settlements?accountId&channelId&method&status&page&pageSize | { items: SettlementRow[], total, page, pageSize } |
| POST | settlements | { accountId, method, amountMinor, reference?, providerRef?, receivedAt?, allocations?: [{ invoiceId, amountMinor }] \| 'auto', note? } → 201 { ok, settlement, duplicate, paidInvoiceIds } (200 when duplicate) |
| POST | settlements/:id/refund | { amountMinor, note? } → { ok, settlement } |
| POST | accounts/:id/apply-credit | → { ok, appliedMinor, allocations } |
| POST | accounts/:id/deposit | { method, amountMinor, reference?, receivedAt?, note? } → 201 { ok, settlement, duplicate, paidInvoiceIds, applied: { appliedMinor, allocations } } — money held as funds and applied to open invoices (400 invalid_method for credit_note / write_off) |
| POST | accounts/:id/withdraw | { amountMinor, note? } → { ok, withdrawnMinor, settlements } (409 insufficient_funds { availableMinor }) |
| POST | accounts/:id/top-up-link | { amountMinor, successUrl?, cancelUrl? } → { ok, url, sessionId, amountMinor, currencyCode } — Stripe Checkout link that adds funds (premium; 409 stripe_not_configured) |
| GET | accounts/:id/funds | { accountId, fundsHeldMinor } |
| GET | accounts/:id/ledger?page&pageSize | { items: LedgerRow[], total, page, pageSize, balanceMinor } |
| GET | accounts/:id/statement?from&to | { account, from, to, openingBalanceMinor, lines: [{ date, type, reference, description, debitMinor, creditMinor, balanceMinor, invoiceId, settlementId }], closingBalanceMinor, totals: { debitMinor, creditMinor }, aging, openInvoices, currencyCode, generatedAt } |
| GET | accounts/:id/statement.html?from&to | the rendered statement (text/html) |
| GET | export/invoices.csv · export/settlements.csv · export/ledger.csv?accountId= | CSV download, same filters as the listings (premium) |
| POST | stripe-webhook | Stripe (signed, public) → { handled, duplicate?, settlementId?, invoiceIds?, topUp?, reason? } |
InvoiceRow is { id, accountId, channelId, customerId, orderId, orderCode, invoiceNumber, currencyCode, amountMinor, paidMinor, lateFeeMinor, balanceMinor, status, issuedAt, dueAt, paidAt, daysOverdue, purchaseOrder, remindersSent, lastReminderAt, lastFeeAppliedAt, payLinkRef, notes, createdAt, updatedAt, companyName?, customerName?, customerEmail? }.
SettlementRow is { id, accountId, channelId, method, status, amountMinor, currencyCode, reference, providerRef, receivedAt, allocations: [{ invoiceId, amountMinor, refundedMinor? }], unallocatedMinor, refundedMinor, note, actorName, createdAt, updatedAt }.
LedgerRow is { id, kind, amountMinor, currencyCode, invoiceId, invoiceNumber, orderCode, settlementId, settlementMethod, orderId, reference, note, actorName, createdAt }.
PlanRow is { id, invoiceId, accountId, channelId, status, instalments: [{ n, dueAt, amountMinor, paidMinor, status: 'due' \| 'paid' \| 'overdue', remindersSent? }], note, createdBy, createdAt, updatedAt, totalMinor, paidMinor, remainingMinor, nextDueAt, nextInstalment, paidCount, overdueCount, invoiceNumber?, currencyCode?, invoiceStatus?, companyName?, customerEmail? }.
Admin — loyalty (/business-credit/loyalty)
| Method | Path | Body / query → response |
|---|---|---|
| GET | config?channelId | { config, premium, defaults: { tiers } } |
| POST | config | { channelId?, ...patch } → { ok, config } (premium) |
| GET | accounts?channelId&search&tier&page&pageSize | { items: [{ id, channelId, customerId, balancePoints, lifetimePoints, tier, customerName, customerEmail, balanceValueMinor, createdAt, updatedAt }], total, page, pageSize, premium } — by lifetime points |
| GET | accounts/:customerId?channelId | { account, summary, transactions, premium } (404 no_loyalty_account) |
| POST | accounts/:customerId/adjust | { points, note?, channelId? } → { ok, account, tx } — positive counts towards tiers (400 invalid_points / insufficient_points; premium) |
| GET | transactions?channelId&customerId&orderId&kind&status&page&pageSize | { items: LoyaltyTxRow[], total, page, pageSize, premium } |
| GET | stats?channelId | { channelId, currencyCode, premium, enabled, members, pointsOutstanding, valueOutstandingMinor, pendingRedeemPoints, earnedLast30d, redeemedLast30d, redeemedValueLast30dMinor, expiredLast30d, tiers: [{ name, members }], cron: { nextRunAt, running } } |
| GET | top-customers?channelId&limit | { items, premium } |
| GET | export.csv?kind=accounts\|transactions&channelId&customerId | CSV download (premium exports) |
| POST | run | { expire? } → { ok, trigger, ranAt, sweep: { scanned, released, surchargesRemoved, finalised, errors }, expiry: { premium, accounts, expired, pointsExpired, errors } \| null } (409 already_running) |
| GET | preview?amountMinor= | public → { enabled, points, programmeName, multiplier, tier } — the shopper's tier when signed in |
LoyaltyTxRow is { id, loyaltyAccountId, channelId, customerId, kind: 'earn' \| 'bonus' \| 'redeem' \| 'release' \| 'reversal' \| 'expire' \| 'adjust', points (signed), orderId, orderCode, surchargeId, valueMinor, status: 'final' \| 'pending' \| 'released', note, expiresAt, actorName, createdAt }.
Admin — dunning, plugin, licence
| Method | Path | Body / query → response |
|---|---|---|
| GET | dunning/status | { premium, smtp: { configured, host, from, source }, kinds, running, runsOnThisProcess, nextRunAt, lastReport } |
| POST | dunning/run | { now? } → the report (409 already_running) |
| GET | dunning/preview?kind=&channelId= | { kind, subject, text, html } |
| POST | dunning/test-email | { kind, to, channelId? } → { ok, reason? } |
| GET | meta · settings | version, update status, licence tier; effective options with secrets redacted |
| POST | licence/activate · licence/deactivate · licence/purchase-link · licence/portal-link, GET licence/claim-status | licence lifecycle used by the admin card |
| POST | update/run | { version? } — one-click self-update of the plugin package |
Storefront — /business-credit/my
| Method | Path | Body / query → response |
|---|---|---|
| GET | account | { account, availability, funds: { heldMinor, currencyCode }, role, isOwner, memberManagement, canApply, pendingApplication } — account is null without one (own or via membership); otherwise { id, accountRef, status, currencyCode, creditLimitMinor, effectiveLimitMinor, tempLimitExpiresAt, termsDays, requirePurchaseOrder, companyName, companyNumber, vatNumber, billingEmail, holdReason, approvedAt, suspendedAt, createdAt }; role is owner / buyer / viewer |
| POST | apply | { companyName, companyNumber?, vatNumber?, contactName?, phone?, website?, requestedLimitMinor?, requestedTermsDays?, tradingSince?, tradeReferences?, message? } → 201 { application, account, availability, autoApproved } — account is set when the channel's rule approved it on the spot (402 without a licence, 403 applications_disabled, 409 account_exists / application_pending) |
| GET | application | { application } — the latest, or null |
| POST | application/withdraw | { id? } → { application } |
| GET | members | { items: [{ id, customerId, role, name, email, createdAt }], role, isOwner, canManage, owner: { customerId, name, email } } (owner or buyer; 403 for a viewer, 404 no_account) |
| POST | members | { email, role: 'buyer' \| 'viewer' } → 201 { member } (owner only: 403 forbidden; 402 licence_required; 403 member_management_disabled; 404 no_customer; 409 is_owner / already_member / owns_account) |
| DELETE | members/:memberId | → { ok, member } (same gates) |
| POST | invitations/accept | { token } → 201 { invitation: { id, status, companyName, acceptedAt }, account, availability } (400 token_required, 403 email_mismatch, 404 invitation_not_found, 409 invitation_expired / invitation_not_pending / account_exists) |
| GET | invoices?status&page&pageSize | { items, total, page, pageSize, cardPayLinks } ({ account: null, items: [] } without an account) |
| GET | invoices/:id | { invoice, ledger, settlements, plan, cardPayLinks, planRequests } — admin-only fields stripped; plan is the active or completed plan without its internal note; 404 for another customer's invoice |
| POST | invoices/:id/plan/request | { instalments (2–60), message? } → 202 { ok, invoiceId, invoiceNumber, instalments, message, requestedAt } — emails the merchant, creates nothing (402 without a licence, 403 plan_requests_disabled, 404 invoice_not_found, 409 invoice_not_open / plan_exists, 400 invalid_instalments) |
| POST | invoices/pay-link | { invoiceIds, successUrl, cancelUrl } → { ok, url, sessionId, amountMinor, currencyCode, invoiceIds } (premium) |
| POST | top-up | { amountMinor, successUrl?, cancelUrl? } → { ok, url, sessionId, amountMinor, currencyCode } — Stripe Checkout that adds prepaid funds (premium; 404 no_account, 409 stripe_not_configured) |
| GET | settlements?page&pageSize | { items, total, page, pageSize, fundsHeldMinor } — the customer's payments, deposits and top-ups (admin-only fields stripped) |
| GET | statement?from&to&format=html | the statement (JSON, or the rendered page) |
| GET | ledger?page&pageSize | { items, total, page, pageSize, balanceMinor } |
Storefront — /business-credit/my/loyalty
| Method | Path | Body / query → response |
|---|---|---|
| GET | / | { enabled, programmeName, balancePoints, balanceValueMinor, currencyCode, lifetimePoints, tier, nextTier: { name, pointsToGo } \| null, expiringSoon: { points, at } \| null, earnRate: { pointsPer100Minor, multiplier }, redeem: { minPoints, maxPercent, valueMinorPerPoint }, pendingRedemption: { points, valueMinor, orderCode } \| null } — or { enabled: false, programmeName } |
| GET | transactions?page&pageSize | { enabled, items, total, page, pageSize } (admin-only fields stripped) |
| POST | redeem | { points } → { ok, pointsRedeemed, valueMinor, order: { code, totalWithTax } } — adds the LOYALTY-POINTS surcharge to the active order (400 invalid_points / below_minimum / insufficient_points / over_max_percent with maxPoints, 402 without a licence, 404 no_active_order, 409 loyalty_disabled / order_not_editable) |
| POST | unredeem | → { ok, pointsReleased, order: { code, totalWithTax } } |
Channel config fields
currencyCode, defaultTermsDays (30), defaultLimitMinor (0),
graceDays (0), invoicePrefix (INV-), statementDay (1–28, default 1),
statementEmails (off), reminderSchedule ([-3, 0, 7, 14, 30]; array or
comma list), autoSuspendAfterDays (null = never), lateFeePercentMonthly
(null = none), lateFeeGraceDays (0), requirePurchaseOrder (off),
allowStorefrontApplications (on), cardSettlements (on),
remittanceText, businessName, notifyEmail, replyTo,
storefrontBaseUrl, emailFooter; 0.3.0: autoApproveEnabled (off),
autoApproveMaxLimitMinor (0), autoApproveMinOrders (1),
autoApproveMinSpendMinor (0), autoApproveRequireCompanyNumber (on),
reviewEveryMonths (12; 0 = off), invitationDays (14),
allowCustomerPlanRequests (on), allowMemberManagement (on).
Storefront integration
The Shop API needs nothing new: a signed-in customer's session token works
on the REST routes exactly as it does on /shop-api. The examples use
fetch with a bearer token; with cookie sessions send credentials: 'include'
instead.
Show the credit position on the account page and at checkout:
const res = await fetch(`${API}/business-credit/my/account`, { headers: { authorization: `Bearer ${token}` } });
const { account, availability, funds, canApply, pendingApplication } = await res.json();
if (account) {
// "Credit available: £3,800.00 of £5,000.00 · 30-day terms"
// availability.availableMinor / availability.effectiveLimitMinor / account.termsDays
// funds.heldMinor → "Funds on account: £200.00" (counts towards availableMinor)
// account.status === 'suspended' → show account.holdReason
// availability.overdueHold → "settle your overdue invoices to order on account"
} else if (canApply) {
// link to your application form → POST /business-credit/my/apply
}Pay on account — the method appears in eligiblePaymentMethods like
any other; show eligibilityMessage when it is not eligible. Send the
customer's purchase-order number in the metadata:
mutation PayOnAccount($po: String) {
addPaymentToOrder(input: { method: "business-credit", metadata: { purchaseOrder: $po } }) {
... on Order { id code state payments { id state metadata } }
... on PaymentDeclinedError { errorCode message paymentErrorMessage }
... on IneligiblePaymentMethodError { errorCode message eligibilityCheckerMessage }
... on ErrorResult { errorCode message }
}
}On success the order is PaymentSettled — treat it as paid and fulfil as
usual. A decline leaves the order in ArrangingPayment with the reason in
paymentErrorMessage (and payment.metadata.public.reason), so offer the
other methods.
Confirmation page — the payment's public metadata carries what the
customer needs to see (Vendure exposes only metadata.public to the Shop
API):
const p = order.payments.find(p => p.method === 'business-credit');
const inv = p?.metadata?.public;
// inv.invoiceNumber "INV-000042"
// inv.dueAt ISO date the invoice is due
// inv.termsDays 30
// inv.amountMinor / inv.currencyCode
// inv.accountRef "BC-000007"
// inv.purchaseOrder what you sent, or null
// inv.remittanceText the channel's bank details / how-to-pay textInvoices and pay links on the account page:
const { items } = await (await fetch(`${API}/business-credit/my/invoices?status=open_any`, { headers })).json();
// list items: invoiceNumber, dueAt, balanceMinor, daysOverdue, status
// "Pay now" (card, premium): one link for one or more open invoices
const link = await (await fetch(`${API}/business-credit/my/invoices/pay-link`, {
method: 'POST', headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({
invoiceIds: selected.map(i => i.id),
successUrl: `${SHOP}/account/credit?paid=1&session={CHECKOUT_SESSION_ID}`,
cancelUrl: `${SHOP}/account/credit?cancelled=1`,
}),
})).json();
window.location.href = link.url;
// After Stripe redirects back, re-fetch the invoices: the webhook has marked them paid.Top up funds (card, premium) — the customer pays an amount in advance; the webhook holds it as funds and every later order on account settles from it immediately:
const topUp = await (await fetch(`${API}/business-credit/my/top-up`, {
method: 'POST', headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ amountMinor: 20000, successUrl: `${SHOP}/account/credit?topup=success`, cancelUrl: `${SHOP}/account/credit?topup=cancelled` }),
})).json();
window.location.href = topUp.url;
// Back from Stripe: GET /business-credit/my/account → funds.heldMinor is up by the amount,
// GET /business-credit/my/settlements lists the top-up (method 'card') with fundsHeldMinor.Members — on the account page, show who else uses the account and let the holder manage them:
const { account, role, isOwner, memberManagement } = await (await fetch(`${API}/business-credit/my/account`, { headers })).json();
// role: 'owner' | 'buyer' | 'viewer' — a buyer/viewer sees the company's account, invoices and statements
if (role !== 'viewer') {
const { items, canManage, owner } = await (await fetch(`${API}/business-credit/my/members`, { headers })).json();
// list owner + items; when canManage (owner, licensed, channel switch on) offer:
await fetch(`${API}/business-credit/my/members`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ email, role: 'buyer' }) });
await fetch(`${API}/business-credit/my/members/${memberId}`, { method: 'DELETE', headers });
}
// At checkout a viewer sees business-credit with isEligible:false and the "role does not allow" message.Accept an invitation at /account/credit/accept?token=… (the route the
invitation email links to):
const token = new URLSearchParams(location.search).get('token');
const { invitation } = aw