npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@huloglobal/vendure-plugin-business-credit

npm downloads Vendure databases licence

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
  1. Register BusinessCreditPlugin.init({ publicBaseUrl, storefrontBaseUrl }) in vendure-config.ts (full options under Install).
  2. Add BusinessCreditPlugin.uiExtensions to your compileUiExtensions call and rebuild the admin UI.
  3. Restart Vendure. The tables are created on first boot; there is no migration to run.
  4. In the admin, Settings → Payment methods, add Pay on Account (business credit) with the Business credit account eligibility checker.
  5. 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-credit
import { 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):

  1. no_account — no account in this channel (their own, or one they are a member of).
  2. member_viewer — they are a viewer on a company account; viewers cannot order on account.
  3. suspended — the account is on hold.
  4. not_active — pending or closed.
  5. currency_mismatch — the order is not in the account's currency.
  6. po_required — the account (or the channel) requires a purchase-order number and none was sent.
  7. 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.
  8. over_limit — order.totalWithTax is more than available.

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 available immediately, and the daily pass (or POST /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 paid when paid ≥ amount + lateFee, otherwise part_paid (or stays overdue).
  • credit_note and write_off are 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_off settlement.
  • providerRef (the Stripe payment_intent) is unique: a settlement with a provider reference that already exists is returned as duplicate: true and 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? }. method must be a real payment method (bank_transfer, card, direct_debit, cheque, cash, other; credit_note / write_off are refused with 400 invalid_method). The deposit is applied at once to any open invoice, oldest first, and whatever remains is held. The response is the settlement plus applied: { 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 } (premium card_pay_links, like pay links; 409 stripe_not_configured without a Stripe key in the channel, card_settlements_disabled when switched off, account_closed on a closed account). The admin can create the same link to send to the customer with POST /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 is paid immediately and the customer's exposure stays at zero. The ledger shows the usual net-zero adjustment + payment pair per invoice, the settlement's allocations grow, and settlement.credit_applied / invoice.paid (with viaCredit: 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 with creditLimitMinor: 0 and 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 409 insufficient_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 as funds: { heldMinor, currencyCode } on GET /business-credit/my/account and as fundsHeldMinor on GET /business-credit/my/settlements, which lists their deposits and top-ups. The dashboard's creditBalanceHeldMinor ("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 (404 no_customer); the owner themselves (409 is_owner), an existing member (409 already_member) and a customer who holds their own account in the channel (409 owns_account) are refused; role must be buyer or viewer (400 bad_role). Members get 403 on these routes; the per-channel switch Allow member management (allowMemberManagement) turns them off with 403 member_management_disabled, and GET my/account reports it as memberManagement. A new member is emailed member_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 } and DELETE accounts/:id/members/:memberId. Admins are not bound by the channel switch.
  • Events member.added, member.role_changed and member.removed land in the account's audit log. Premium feature members (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:

  1. 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 (409 account_exists) or have an open invitation (409 invitation_pending).
  2. Your storefront's accept page calls the public GET /business-credit/invitations/:token to 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 reads expired.
  3. 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: 403 email_mismatch (signed in as someone else), 409 invitation_expired, invitation_not_pending (accepted or revoked) or account_exists. The channel's notify address gets invitation_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 plan completeds 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 overdue once its due day + grace has passed, the invoice becomes overdue (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.

  1. In Stripe, add a webhook endpoint at https://<api>/business-credit/stripe-webhook for the events checkout.session.completed and checkout.session.async_payment_succeeded.
  2. Put the endpoint's signing secret in stripeWebhookSecret, or per channel in BUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET_<CHANNELCODE> (channel code upper-cased, non-alphanumerics → _; the default channel is BUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET_DEFAULT_CHANNEL). The env var wins.
  3. 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:

  1. Expire temporary limits whose expiresAt has passed, and expire invitations past theirs.
  2. Mark overdue every open / part_paid invoice with dueAt + 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).
  3. Reminders (premium) per the channel's Reminder schedule — days relative to the due date, negative before, 0 on 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.
  4. 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 a fee ledger row and to the invoice's lateFeeMinor, where days counts 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.
  5. 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 suspended email 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.
  6. Review reminders (premium email, free flag): accounts whose reviewAt has passed get an account.review_due event and the review_due email to the notify address — once, then again after 30 days.
  7. Apply held credit on accounts with unallocated money and open invoices.
  8. 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 text

Invoices 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