Hulo Global
Plugins Roadmap

Business Credit changelog

Every release of @huloglobal/vendure-plugin-business-credit. Latest release: v0.3.3 — 28 September 2026.

v0.3.3

28 September 2026 Latest
Changed
  • POST /business-credit/dunning/run answers 202 { started: true } at once and runs the pass in the background (a full pass on a large install outlived reverse-proxy timeouts). The admin *Run now* button polls dunning/status every 3 s and shows the report when running turns false; dunning/status gained lastRun (completed / locked / failed). { wait: true } keeps the synchronous response for scripts and tests.
  • GET /business-credit/dashboard now aggregates exposure, overdue (honouring each account's grace days), due-in-7-days, the aging buckets and the top-10 exposures in SQL instead of loading up to 20 000 invoice rows, and also returns pendingInvitations, activePlans and the channel's loyalty stats. The admin UI loads the Overview with one call instead of five, keeps a 30 s freshness window per list (Refresh, a channel change or any mutation bypasses it), tracks table rows by id and runs with OnPush change detection.
  • Loyalty monthly expiry finds the accounts with something to expire in one aggregate query per batch of 500 (GROUP BY … HAVING) instead of one query per account.
  • Daily run fan-out: the storefront base URL is resolved once per channel; exposure and held credit for every account with a reminder due come from one GROUP BY accountId query; newly-overdue invoices are marked with one bulk UPDATE and one multi-row event INSERT per channel (same event payloads); on the statement day only accounts with ledger activity in the month or a non-zero opening balance are considered, five at a time.
  • Statement periods longer than 400 days are cut back to 400 days from from (X-Statement-Clamped header; the response's to says where it ends); statement emails use the run's clock.
Fixed
  • Loyalty expiry maths: a redeem that was later released (order abandoned) no longer counts as consumption, so the points it briefly held expire when their earnings expire instead of being sheltered.
  • PostgreSQL: every column of Vendure's own tables the plugin reads (customer.firstName / lastName / emailAddress / deletedAt, order.subTotalWithTax / shippingWithTax / customerId, order_channels_channel and customer_channels_channel keys, channel.defaultCurrencyCode) is now quoted; TypeORM creates them camelCase on Postgres, so the unquoted reads failed there. Found by the new src/pg-corpus.test.ts, which runs every raw SQL statement of the package against a real PostgreSQL (skipped unless HULO_PG_URL is set).
Documentation
  • Calendar-day arithmetic (due dates, grace, reminder days, days overdue, late-fee days, statement day and period) is UTC — now said so in the README.

v0.3.2

25 September 2026
Fixed
  • Credit limit race. The limit check and the invoice insert for an order paid on account now run under a per-account database lock with availability re-read inside it, so two orders placed at the same moment can no longer both squeeze through the last of a limit. A lock held for more than 10 s declines with account_busy.
  • Settlements on the same invoice. paidMinor is now incremented in the database (paidMinor = paidMinor + ?) with the paid/part-paid status decided from the live row, instead of written back from a copy loaded earlier; two payments landing together can no longer overwrite each other.
  • Duplicate Stripe settlements. providerRef has a unique index; a webhook delivered twice concurrently inserts once and returns the existing settlement as duplicate.
  • Crons across processes. The daily dunning pass and the loyalty sweep take a cross-process lock (GET_LOCK on MariaDB/MySQL, pg_try_advisory_lock on Postgres). Late fees are additionally claimed with a conditional UPDATE on lastFeeAppliedAt, and monthly statements are claimed on a new lastStatementPeriod column before the email goes out — so an overlapping *Run now* or a second worker never charges or sends twice.
  • Loyalty double-spend. Redemptions and negative adjustments debit with a compare-and-set (balancePoints >= ?); two simultaneous redemptions can no longer take a balance negative. Points are held before the surcharge is added and given back if the order update fails.
  • Tier downgrade email. A downgrade after a cancellation is audited but no longer emails the customer "you have reached the … tier".
  • Viewers can no longer create pay links, top-ups or plan requests from the storefront (403).
  • Closing an account that still holds unallocated credit is refused (credit_held) until it is refunded.
  • Due dates cover the whole due day (23:59:59 UTC), so an invoice issued at 09:00 is not one second from overdue on its due date.
  • Licence settings report crons.enabled as it is resolved (true only when forced on this process); the evaluation lead request has a 10 s timeout.
Changed
  • SMTP uses one pooled transport (3 connections, 10 s connect/greeting and 30 s socket timeouts) instead of a fresh TLS+AUTH session per email; a failed send drops the pool so the next email reconnects.
  • Channel config is cached for 15 s per channel (every order, reminder and storefront page reads it); writes invalidate the cache.
  • formatMinor caches its Intl.NumberFormat and honours zero- and three-decimal currencies.
  • Plugin email columns are compared without LOWER() (they are stored lower-case), so the indexes are used.
  • New indexes: idx_bci_channel_issued, idx_bce_channel_created, idx_bclt_channel_created (the admin overview's loyalty stats no longer scan the whole transaction table).
  • The loyalty sweep loads only an order's surcharges instead of every relation.

v0.3.1

18 September 2026
Changed
  • Vendure plugin directory readiness. The plugin class, every service and the BusinessCreditEvent carry @category JSDoc tags (Plugin / Services / Events); the runtime compatibility declaration now matches the tested range >=3.5.0 <4.0.0 instead of ^3.0.0.
  • README: npm, download, Vendure and database badges, a five-step *Quick start* at the top (including the admin-UI compile step and the payment-method setup) and a *Compatibility* section. No functional changes.

v0.3.0

16 September 2026
Added
  • Multi-user company accounts (members). The account holder (owner) adds colleagues by e-mail as buyer (may order on account) or viewer (invoices and statements only). Every storefront route, the Pay-on-Account handler and the eligibility checker now resolve "my account" through AccountsService.resolveAccountForCustomer — the customer's own account, else the one they are a member of. A viewer at checkout is declined with member_viewer ("Your account role does not allow placing orders on account"). Admin: GET/POST /business-credit/accounts/:id/members, POST accounts/:id/members/:memberId { role }, DELETE accounts/:id/members/:memberId. Storefront: GET /business-credit/my/members (owner or buyer), POST my/members { email, role } and DELETE my/members/:memberId (owner only; per-channel switch allowMemberManagement); GET my/account gains role, isOwner and memberManagement. Refusals: 404 no_customer, 409 is_owner / already_member / owns_account, 400 bad_role. Events member.added (e-mails the new member), member.role_changed, member.removed. Table business_credit_member. Premium members.
  • Invitations. Offer an account on pre-agreed terms without an application: POST /business-credit/invitations { email, creditLimitMinor, termsDays?, companyName?, message? } e-mails the invitee a 64-hex-character token link (<storefront>/account/credit/accept?token=…) that expires after the channel's invitationDays (default 14). GET invitations?status&email, POST invitations/:id/revoke, POST invitations/:id/resend (bumps sentCount). A public GET /business-credit/invitations/:token feeds the accept page with the terms and a masked e-mail (in***@example.com), never the token. The invitee signs in with the same address and calls POST /business-credit/my/invitations/accept { token } → an active account on the invited terms, approvedBy: 'invitation', first review one cycle out; the merchant's notify address gets invitation_accepted. Refusals: 403 email_mismatch, 409 invitation_expired / invitation_not_pending / account_exists / invitation_pending. The daily pass expires stale invitations (invitationsExpired in the report). Table business_credit_invitation. Premium invitations.
  • Auto-approval. With autoApproveEnabled a storefront application is approved on the spot (actor auto, note "Auto-approved by rule") when the customer has at least autoApproveMinOrders settled orders worth autoApproveMinSpendMinor in the channel and — if autoApproveRequireCompanyNumber — gave a company number. The limit granted is the requested one (or the channel default) capped at autoApproveMaxLimitMinor; a rule whose cap is 0 never approves. POST /business-credit/my/apply answers { application, account, availability, autoApproved }. Anything that fails a check stays pending for a human. Premium auto_approval; pure rule in src/accounts/rules.ts.
  • Credit reviews. Every approval sets reviewAt = now + reviewEveryMonths (default 12; 0 = off). Accounts carry reviewAt, reviewNote and reviewDue; GET accounts?reviewDueOnly=true and the dashboard's reviewsDue list what is waiting. The daily pass publishes account.review_due and e-mails review_due to the notify address (once, then again after 30 days). POST /business-credit/accounts/:id/review { note? } records the review and moves reviewAt on one cycle (from today when it was long overdue). Event account.reviewed.
  • Payment plans. Split an open invoice's balance into instalments: POST /business-credit/invoices/:id/plan { instalments: 3 | [{ dueAt, amountMinor }], startAt?, intervalDays?, note? } (equal split with the last instalment taking the rounding, 2–60 instalments, default 30 days apart). Money allocated to the invoice pays the instalments in order; the plan completes when the invoice is paid and reopens on a refund. While a plan is active the daily pass judges lateness by the next unpaid instalment — marks it overdue, sends the reminders against its due date (with an "instalment 2 of 3" line), charges no late fee, and counts auto-suspend from the overdue instalment. GET invoices/:id/plan, POST invoices/:id/plan/cancel { note? } (the invoice goes back to overdue when it is past its own due date), GET plans?channelId&accountId&invoiceId&status. Customers see the schedule on GET /business-credit/my/invoices/:id (plan) and can ask for one with POST my/invoices/:id/plan/request { instalments, message? } → 202 and a plan_requested e-mail to the merchant (per-channel switch allowCustomerPlanRequests). Events plan.created (e-mails the schedule), plan.requested, plan.completed, plan.cancelled, plan.reopened. Report gains planInstalmentsOverdue. Table business_credit_plan. Premium payment_plans.
  • Reward points (loyalty) programme. Per-channel config (GET/POST /business-credit/loyalty/config): enabled, programmeName, earnPointsPer100Minor, redeemValueMinorPerPoint, minRedeemPoints, maxRedeemPercent, expiryMonths, signupBonusPoints, earnOnAccountOrders, tiers (default Bronze / Silver 2 000 / Gold 10 000 at ×1 / ×1.25 / ×1.5). Points are earned once per order on PaymentSettled (and on OrderPlacedEvent for orders that settle at once, de-duplicated): floor(totalWithTax / 100 × rate × tierMultiplier); a tier change e-mails tier_changed; cancelling the order writes a reversal (clamped at zero) and releases any redemption. Redeeming at checkout: POST /business-credit/my/loyalty/redeem { points } validates the minimum, the balance and the maxRedeemPercent cap of subTotalWithTax (400 below_minimum / insufficient_points / over_max_percent with maxPoints), then adds a negative surcharge (sku LOYALTY-POINTS, tax-inclusive) to the active order and books a pending redeem; POST my/loyalty/unredeem removes it; placing the order finalises it; a second redeem replaces the first. GET my/loyalty → balance, value, tier, next tier, expiring-soon, earn rate, redeem rules, pending redemption; GET my/loyalty/transactions; public GET /business-credit/loyalty/preview?amountMinor= for product pages (the shopper's tier when signed in). Admin: accounts (search, tier), account detail, adjust (positive adjustments count towards tiers), transactions, stats, top-customers, export.csv (premium exports), POST loyalty/run { expire? }. LoyaltyCron at 03:30 on the worker releases stale pending redemptions (7 days) daily and expires points on the 1st: expirable = max(0, min(balance, Σ expired credits − Σ every deduction)), oldest points consumed first. Tables business_credit_loyalty_config, business_credit_loyalty_account, business_credit_loyalty_tx. Premium loyalty; when unlicensed or switched off nothing is earned and the storefront reads { enabled: false }.
  • E-mails. Seven new kinds: invitation, invitation_accepted, member_added, plan_created, plan_requested, review_due, tier_changed — all with preview and test-send.
  • Events. account.created, invitation.created / accepted / revoked / expired, member.added / role_changed / removed, account.reviewed, account.review_due, plan.*, loyalty.tier_changed, loyalty.adjusted, loyalty.config_updated.
  • Channel config gains autoApproveEnabled, autoApproveMaxLimitMinor, autoApproveMinOrders, autoApproveMinSpendMinor, autoApproveRequireCompanyNumber, reviewEveryMonths, invitationDays, allowCustomerPlanRequests, allowMemberManagement.
  • A second MariaDB end-to-end suite (e2e/business-credit-platform.e2e-spec.ts) covering invitations, members and roles at checkout, auto-approval, reviews, payment plans through dunning and cancellation, plan requests, the reward programme end to end (earning, tiers, redeem / unredeem / replace / finalise, cancellation reversal, on-account earning, admin stats and exports, expiry) and the unlicensed behaviour of every new surface.
Fixed
  • GET /business-credit/my/account offered canApply: true to a customer who already trades through a company account they are a member of; it now considers the resolved account, not only the customer's own.
  • addColumnIfMissing looked up information_schema.columns without restricting it to the current database, so on a server hosting another database that already had a column (staging next to production, a second Vendure instance) the upgrade ALTER TABLE was skipped and the new column never appeared. The lookup is now scoped to the current database (DATABASE() on MySQL / MariaDB, current_database() + current_schema() on PostgreSQL).

v0.2.0

16 September 2026
Added
  • Prepaid funds. Money held on an account before any invoice exists. An admin deposit (POST /business-credit/accounts/:id/deposit { method, amountMinor, reference?, receivedAt?, note? }; real payment methods only) or a customer card top-up through Stripe Checkout (POST /business-credit/my/top-up, admin-generated link via POST /business-credit/accounts/:id/top-up-link; premium card_pay_links) is applied to any open invoice at once and the remainder held as funds, raising the available credit. Withdraw (POST /business-credit/accounts/:id/withdraw, 409 insufficient_funds above the balance) books the return on the ledger. GET /business-credit/accounts/:id/funds → { fundsHeldMinor }.
  • Automatic settlement from funds. The settlements service subscribes to invoice.issued and applies held funds the moment an invoice is raised, so an order placed on account against funds is paid immediately (settlement.credit_applied + invoice.paid with viaCredit). Funds count towards eligibility, so an account with a zero credit limit can trade prepaid only.
  • Top-up webhook. Checkout Sessions carrying { business_credit: '1', topup: '1', accountId, channelId, amountMinor } (client_reference_id BC-<id>-TOPUP) are recorded by the existing signed webhook as a card settlement with no allocations, then applied to open invoices; the outcome carries topUp: true. Replays of the same payment_intent remain duplicates. parseBusinessCreditMetadata now returns topUp; new buildTopUpSessionParams.
  • Storefront. GET /business-credit/my/account includes funds: { heldMinor, currencyCode }; new GET /business-credit/my/settlements lists the customer's payments, deposits and top-ups with fundsHeldMinor.
  • Admin UI. A *Funds* pane on the account detail: funds held, available and open balance, a card top-up link (premium-locked like pay links, copied to the clipboard), *Record a deposit* (method limited to real payment methods, amount, reference, date, note) and *Withdraw funds* (amount, note, confirmation). The Overview KPI is now *Funds held*; settlement rows with no invoice allocations are labelled *Funds top-up / deposit*.
  • E2E coverage for deposits, settlement of a new on-account order from funds, the top-up webhook (with replay), withdrawals and the 409 refusal, the customer's settlement list, and a prepaid-only account (limit 0, funds 200.00: a 120.00 order is eligible and settles, 400.00 is not).

v0.1.0

16 September 2026
Added
  • Credit accounts. One account per customer per channel with a credit limit, net terms, grace days, purchase-order requirement, company / VAT details, billing email and an accountRef (BC-000007). Statuses pending, active, suspended, closed (closing needs zero exposure). Permanent and temporary limit changes (with expiry) recorded in business_credit_limit_change; every state change written to the business_credit_event audit log and, for the customer-facing ones, published as a BusinessCreditEvent on Vendure's EventBus.
  • Applications. Storefront applications (POST /business-credit/my/apply, premium; per-channel on/off), admin-created applications, an approve / reject queue that creates or updates the account, customer withdrawal, and the received / approved / rejected emails.
  • Pay on Account. A business-credit payment handler (settles the order at once, raises the invoice, returns the invoice details in metadata.public, records a credit note on refund) and a business-credit-eligibility checker (active account, enough available credit, no overdue hold, optional minimum order) whose reason strings surface as Vendure's eligibilityMessage / paymentErrorMessage. Purchase-order number from the addPaymentToOrder metadata or an Order custom field.
  • Invoices. Per-channel numbering (INV- prefix + zero-padded sequence, contention-safe), dueAt = issued + terms, statuses open / part_paid / paid / overdue / written_off / void, late fees, reminders-sent tracking, pay-link reference; admin listing with search and filters, customer listing scoped to their own account.
  • Settlements. Bank transfer, card, direct debit, cheque, cash, credit note and write-off; oldest-due-first or explicit allocation; unallocated money held as account credit and applied by the daily pass or on demand; settlement refunds (unallocated first, then proportional); invoice void, write-off and manual fees; a signed ledger (charge, payment, credit_note, write_off, fee, adjustment, refund) whose sum is the account balance; idempotency on providerRef.
  • Stripe pay links. Checkout Sessions over plain fetch (no SDK) from the admin or the storefront, keyed by the channel's existing Vendure Stripe payment method; a raw-body, signature-verified webhook (POST /business-credit/stripe-webhook) for checkout.session.completed / checkout.session.async_payment_succeeded that records the card settlement exactly once and answers 5xx on internal failure so Stripe retries. Per-channel secrets via BUSINESS_CREDIT_STRIPE_WEBHOOK_SECRET_<CHANNELCODE>.
  • Statements. Opening balance, running lines, closing balance, debit / credit totals, open invoices and 1–30 / 31–60 / 61–90 / 90+ aging for any period, as JSON, HTML and text; admin and customer endpoints.
  • Dunning. A daily pass (07:00 on the worker, crons.dailyAt / crons.enabled, admin *Run now*) that expires temporary limits, marks invoices overdue after grace, sends reminders on a per-channel schedule relative to the due date (default -3, 0, 7, 14, 30; one email per invoice per run, earlier days caught up silently), charges a pro-rata monthly late fee in 30-day chunks after a fee grace period, auto-suspends accounts past a threshold, applies held credit and emails last month's statement on the statement day; a report with skipped counts on the free tier.
  • Emails. Ten kinds with {{var}} templates in a branded HTML shell; SMTP from the smtp option or SMTP_* env; preview and test-send from the settings tab; a test seam (MailerService.setSender) for hosts and the e2e suite.
  • CSV exports of invoices, settlements and an account ledger with running balance (premium).
  • Admin dashboard at *Sales → Business credit*: Overview, Accounts (detail with availability, limit history, invoices, ledger, record settlement, statement, events), Applications, Invoices, Settlements, Settings, and the Licence & billing card (14-day evaluation, buy from the admin, billing portal, self-update).
  • Storefront REST under /business-credit/my: account and availability, apply / application / withdraw, invoices, pay links, statement, ledger.
  • Free tier: accounts, limits with audit trail, Pay-on-Account handler and checker, invoices, manual settlements and allocation, ledger, statements, dashboard. Premium: Stripe pay links, reminders and dunning emails, late fees, auto-suspend, statement emails, CSV exports, storefront applications.
  • MariaDB end-to-end suite covering the whole flow (application → approval → order on account → over-limit refusal → bank-transfer settlement → temporary limit and expiry → overdue, reminder, late fee and auto-suspend → reactivation with overdue hold → Stripe webhook with duplicate delivery → refund as credit note → CSV exports → statement balancing to the ledger).