115 min readOct 16, 2026by jakub

Changelog

Current version: 1.0.0

Changes of every module of the suite, newest first. "Unreleased" lists changes made since the modules' last numbered entry.

1.1.0

Adobe Commerce B2B migration

Added

  • Added Adobe Commerce B2B migration tooling: MigrationSourceInterface (with default no-op NullMigrationSource), CompanyMigrationServiceInterface / CompanyMigrationService and the MigrationResult value object. The service imports companies, company users (linked to existing customers by email) and credit accounts into the Qoliber B2B suite, is idempotent on company name and collects per-company errors.
  • Added the qoliber:b2b:migrate-adobe console command, wired into the Magento command list.
  • Extended AdobeCompanyMapper with the reverse-direction helpers statusFromAdobe() and fromAdobeCompany().

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Migrated credit accounts without a currency take the company currency instead of USD.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • A fresh setup:install of the suite failed with eav_entity_type doesn't exist: the installer builds every CLI command before the schema exists, and qoliber:b2b:migrate-adobe eagerly constructed the migration service → customer repository → customer EAV resource model. The command now receives its services as \Proxy (di.xml).
  • Migrated companies get their system roles and each imported member lands on Administrator or Buyer instead of role_id = NULL.

Catalog access rules

Added

  • CatalogAudience::getRoleIdOfMember(): a member's catalog role read as committed, for the page cache's check of in-flight pages.
  • Added flat catalog-access index qoliber_b2b_catalog_access_index with CatalogAccessIndexServiceInterface (reindexCompany, isVisibleIndexed, reindexAll) and CatalogAccessIndexer (ActionInterface), so PLP visibility decisions read a flat row instead of evaluating rules per request.
  • Added CatalogVisibilityResolverInterface::explain() returning VisibilityDecisionInterface (isVisible() + human-readable getReason()); isVisible() now delegates to it.
  • Added ProductCatalogContextInterface to resolve sku → product id / category ids, keeping the index service decoupled from the catalog source.
  • CatalogAccessIndexServiceInterface::getHiddenSkus() (bulk lookup for sku lists).
  • RoleCatalogManagementInterface: a company admin decides what each role sees — the whole company catalog, the company catalog without some categories, or only some categories (RoleCatalogInterface modes company / hide / only).
  • CatalogAudienceInterface: the acting member's company and — when it has catalog rules of its own — role.
  • Rules for a company role (role_id): a role narrows its company's catalog for its members — its own rules decide only what the company may see (a role's allow never re-opens what the company is denied), so a role can also be an allow-list. Resolvers and the index service take an optional role; the index keeps a role's hidden products in rows of their own (role_id), getHiddenRoleIdsByProduct(); a role rule must name a role of its company, deleting the role deletes its rules.
  • CategoryVisibilityResolverInterface: the categories hidden for a company (rules on the category and its ancestors, product rule order), with reasons.
  • CatalogAccessRuleManagementInterface — validated rule writes for trusted actors (known scope/effect, existing product or category target, existing company or global, priority ≥ 0), resolveProductId(sku) and describeTarget() for the admin editor.
  • CatalogAccessRuleManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_catalog_access_rule_save_after, qoliber_b2b_catalog_access_rule_delete_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • A category rule reaches the products of the category's subcategories (a product's categories now include their ancestors in ProductCatalogContext); re-index qoliber_b2b_catalog_access_index after upgrading.
  • Only hidden (company, product) pairs are stored; product_id added to the index. reindexCompany/reindexAll accept an empty sku list meaning "the universe"; new reindexProducts() and getHiddenCompanyIdsByProduct(); ProductCatalogContextInterface gains batch readers; CatalogVisibilityResolverInterface::isVisibleBatch().
  • allow and priority now mean something: lower priority wins, company rules outrank global ones at equal priority, deny outranks allow; the first applicable rule decides.
  • mview subscriptions on catalog_product_entity and catalog_category_product; product-scoped rule changes re-evaluate the product immediately.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.

Fixed

  • The role-catalog upgrade preserves disabled policies and converts only the legacy editor's complete rule shape; merchant and mixed-state rules stay unchanged.
  • RoleCatalogManagement::save() uses a literal default scope so the module parses on its declared PHP 8.1 minimum.
  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.
  • indexer:reindex and the invalidation cron actually build the index: executeFull() walks the whole product universe (batched, SQL-only) for every affected company; it no longer deletes every row and inserts nothing. Rows are replaced inside a transaction, so there is no window with an emptied (allow-by-default) index.
  • Global (company_id NULL) rules are materialised for every active company.
  • A role limited to some categories still saw products assigned only to a parent of a chosen category and products in no category. Rule scope catalog (the whole catalog, also products in no category); "only" is a whole-catalog deny with the chosen categories' allows, and navigation is separate from access — a hidden category that is the way to a visible one stays in the menu without showing its own products. Data patch RoleOnlyCatalogsWithoutPathRules converts roles limited before.
  • Deleting a global rule while another company had rules of its own left the product hidden for every other company until a full reindex — a product reindex and a full reindex now drop the rows of companies no rule restricts any more (a full reindex with no rule left empties the index).

1.0.0

Admin interface

Added

  • Initial release: B2B Suite admin menu, system configuration section + B2bConfig reader, and a Price List UI-component grid.
  • Catalog access rules: scope "Catalog" (the whole catalog) without a target.
  • B2B Suite → Orders → Company Addresses: every company's billing and delivery addresses, who of the company may use them and the company's "company addresses only" switch (ACL Qoliber_B2bAdminUi::company_address).
  • Catalog access rules: optional company role (form field and grid column).
  • The B2B Suite menu groups its 44 screens by area (Companies and Users, Approvals and Spend, Catalog and Prices, Quotes, Orders, Finance and Tax, AI and Messaging, Integrations and Data, System) in five columns.
  • Single Sign-On: the provider tells how many members are linked and forgets the linked sign-ins ("Forget linked sign-ins").
  • Single Sign-On: "Trust its e-mail addresses" for identity providers that send no email_verified.
  • The B2B Suite settings are grouped into nine sections by area under the Qoliber tab (B2B Suite, B2B Catalog and Prices, B2B Ordering and Checkout, B2B Quotes, B2B Finance and Tax, B2B AI and Messaging, B2B Sales and Reports, B2B Notifications, B2B Integrations and Data); every setting keeps its stored path; B2B Suite → System → Configuration opens the first.
  • Company form: Also buys on websites.
  • B2B Suite → AI and Messaging → Messaging Log: every copilot message in and out.
  • B2B Suite → Companies and Users → My Accounts: the signed-in admin's accounts as a sales rep with signals and the next action; every rep's with See Every Rep's Accounts.
  • B2B Suite → Orders → Stock Bins: every company's bins and their state, par level CSV import per company.
  • The part number import help lists the gtin type.
  • The seller's quote page lists a kit's components under the kit line.
  • "Buys in" on the company form.
  • Agent Access shows the members' mandate keys (revoke), each token's mandate requirement and the mandates agents presented.
  • B2B Suite → System → Single Sign-On: companies' identity providers (OIDC, SAML) and what to tell them (ACL Qoliber_B2bAdminUi::sso).
  • B2B Suite → AI and Messaging → Messages: every company's conversations with unread counts, a thread with replies (and files), internal notes, close/reopen, new conversations with a company (ACL Qoliber_B2bAdminUi::conversations).
  • B2B Suite → Orders → Order Amendments: amendments by status, one amendment with the lines before and after, apply (the order is replaced) or reject with a reason (ACL Qoliber_B2bAdminUi::order_amendments, ::order_amendments_decide).
  • B2B Suite → Orders → Company Returns: company returns by status and one return with lines, comments and internal notes; approve (fewer than asked), ask for details, reject, receive, resolve (credit to account), comment — on every returns backend (ACL Qoliber_B2bAdminUi::returns, ::returns_decide).
  • B2B Suite → Orders → Order Intake Review: e-mailed orders by status; one message with its text and lines — assign the sender by e-mail, correct or remove lines, convert to a draft quote, reject (ACL intake_mail, intake_mail_decide).
  • The e-credit note panel on the credit memo page (download, regenerate, send, issue for a credit memo without one).
  • B2B Suite → Finance and Tax → E-Invoices (every e-invoice by status: download, regenerate, send) and the e-invoice panel on the invoice page (status, download, regenerate into any format, send to the access point, issue one for an invoice without); ACL Qoliber_B2bAdminUi::einvoices and ::einvoice_manage.
  • B2B Suite → AI and Messaging → Agent Calls: calls per agent token and tool over 1, 7, 30 or 90 days with the refusals and their reasons, for every company or one; ACL Qoliber_B2bAdminUi::agent_calls.
  • Company → Agent Access: the agent tokens of the company's members (tools, limits, expiry, use), issue one for a member (shown once on the result page, never flashed) and revoke; ACL Qoliber_B2bAdminUi::agent_access.
  • Order view: the lines by destination (Delivery per Line) for packing and shipping.
  • Company → Branch Pickup: whether the company may collect at a branch (store default, yes, no); ACL Qoliber_B2bAdminUi::branch_pickup.
  • B2B Suite → Orders → Orders to Review: held company orders with why (waiting, approved, rejected, all), approved with a note or rejected with a reason; the same on the order's page; Company → Order Review policy (exempt, every order, amount, first orders). ACL order_review, order_review_decide, order_review_policy.
  • Company → Checkout References: what the company's buyers give at checkout (PO number, unique PO number, cost center, requested delivery date — store default, off, optional or required); ACL Qoliber_B2bAdminUi::checkout_references. PO Number column on the order grid.
  • Company terms page: the providers to fall back to when the credit provider is unavailable.
  • Net terms: B2B Suite → Finance and Tax → Payment Terms (catalog grid, create/edit/delete), a company's Payment Terms page (assign terms and provider, place or release a hold, aging, open invoices) with a button on the company, and B2B Suite → Finance and Tax → Receivables (grid; a receivable's page with its history and forms to record a payment, give a credit or write off). ACL payment_terms, payment_terms_manage, payment_terms_assign, receivables, receivables_post.
  • Assisted buying: B2B Suite → Companies and Users → Assisted Buying, the rep's desk (buyers who let the rep assist now with "Shop as this buyer" through Login as Customer; the rep's open sessions, ended from there) and a company's page (who may grant, its consents revoked from there, the assisted activity); "Assisted Buying" button on the company page.
  • B2B Suite → Companies and Users → View as Company (ACL view_as_company): products found by SKU/name or category with whether the company may see them and why, and what it pays (catalog price when no B2B price applies) with the source; a "View as Company" button on the company page.
  • Company templates: B2B Suite → Companies and Users → Company Templates grid; the edit page (code, name, description, active, blueprint JSON, or captured from a company; delete); the apply page (company, merge or replace, sections) with a preview of what would change, applying, and the company's applications. ACL company_template, _manage, _apply.
  • Quote desk screens: the quote page's desk panel (seller with assignment, first-response state, outcome and loss reason, line commercials, the quote's price exceptions with approve/reject); a seller price held below the floor is a notice, not an error; B2B Suite → Quotes → Price Exceptions (the pending queue, decided in place) and Quote Desk Statistics (a period, overall and per seller); quote grid columns seller, first response and outcome.
  • Deposits: Invoice Payments asks a deposit on a quote or purchase order (ACL invoice_payments_deposit), a deposit's page cancels it while pending, and an approved quote's page shows its deposit or a form to ask one; the type filter knows deposits.
  • B2B Suite → Finance and Tax → Invoice Payments (B2bInvoicePortal), ACL invoice_payments, invoice_payments_settle, invoice_payments_schedule: every company's payment requests with company, type, status, amounts, method and the last attempt; a request's page with its invoices and, for a transfer on its way, "Confirm the money arrived" (bank reference, day received) and "It will not arrive"; a form that bills an open invoice in stages by its order number.
  • B2B Suite → Orders → Order Guides and → Standing Orders (B2bOrderGuide), ACL order_guides and order_guides_rebuild: every company's guide lines with company, SKU, history, cadence, next due and the reorder status computed in SQL exactly as the order guide service computes it (filterable), a form that rebuilds one company's guide now; every standing order with company, owner e-mail, schedule, target, item count, next and last run. Depends on Qoliber_B2bOrderGuide (sequence and composer).
  • B2B Suite → Integrations and Data → Price Feeds (B2bCatalogFeed), qoliberb2b/feedtoken/*; ACL catalog_feeds (grid) and catalog_feeds_manage (issue, revoke): the companies' feed tokens with company, member e-mail, state (active, expired, revoked — computed in SQL, filterable), expiry, last use and use count, never the secret hash; mass action Revoke; an issue form (company, purpose, optional member e-mail on the company's website, lifetime) whose result page shows the plain token and the feed address once, uncached, never flashed or stored. ViewModel\FeedTokenIssueForm lists the active companies. Depends on Qoliber_B2bCatalogFeed (composer).
  • B2B Suite → Catalog and Prices → Documents (compliance document vault, B2bDocumentVault), qoliberb2b/document/*; ACL documents (grid, document page, downloads, access log) and documents_manage (new, save, delete, versions, mass actions):
    • Grid: type, language, visibility, status, SKU and company counts, the effective version with its state (current, expired, scheduled, none; the vault's calendar rule in SQL) and valid-until, all filterable; mass actions Change Status (Active / Archived) and Delete.
    • Form: type, title, description, language (ISO 639-1), translation key, visibility (no default), status, SKUs (one per line or comma-separated; every unknown SKU named in one message), companies (active ones, plus any the document is already released to; kept only for company documents). A refused save keeps what was typed.
    • Document page: the version table (file / link / reference, validity, review date, state, uploader, change note) with Download / Open Link and Delete, an Add Version form (multipart upload, link or reference, dates, note) and the latest 50 downloads. Every write goes through VaultDocumentManagementInterface; downloads go through DownloadAuthorizationInterface::authorizeAdmin() (logged) and are sent as attachments, or redirect for a link.
    • Depends on Qoliber_B2bDocumentVault and Qoliber_B2bDocument (module sequence and composer).
  • Company page: a read-only VAT section (VAT ID, VIES status, consultation number, registered trader, last/next check, last error) and a Validate VAT ID button (qoliberb2b/company/validateVat) reporting VIES's verdict; an outage keeps the previous verdict.
  • Order Quantity Rules grid (B2bUnit) with a CSV import form and mass delete; ACL order_qty_rules, order_qty_rules_manage.
  • Part Numbers grid (B2bPartNumber) with a CSV import form (5 MB, per-line errors listed) and mass delete; ACL part_numbers, part_numbers_manage.
  • Tax Exemption Certificates grid (B2bTax) with Approve / Reject / Revoke mass actions as the logged-in admin; ACL tax_certificates, tax_certificates_decide.
  • Integration Alerts grid (B2bIntegrationHealth) with Acknowledge / Resolve mass actions as the logged-in admin; ACL integration_alerts, integration_alerts_manage.
  • Webhook Endpoints (grid + editor with topics, decrypted signing secret, regenerate by clearing it, Send Test Ping, delete) and Webhook Deliveries as a job dashboard (outbox backlog and lag, queued / dead letters / delivered tiles, per-endpoint health, mass action Redeliver; new columns HTTP code, next attempt, delivered at, event id). ACL webhook_endpoints, sync_log_redeliver.
  • Approval rule editor (qoliberb2b/approvalrule/new|edit|save|delete): company, entity type, amount bracket, currency, priority, conditions (requester roles, SKUs, categories) and steps as dynamic rows (stage, role, approvers needed, escalation); validated by ApprovalRuleValidator. "Add New Rule" and Edit on the grid.
  • Price Explanation page (qoliberb2b/priceexplanation/index, menu B2B Suite → Catalog and Prices → Price Explanation, ACL price_explanation) and an Explain Prices button on the company page.
  • Company form Customer Group field (members join, follow and leave the company's group).
  • Seller quote page qoliberb2b/quote/view (offer prices, accept/decline counters, approve, reject, expiry, comments, files, history, PDF) with ACL quote_respond; "View" on the Quotes grid.
  • Quote Agreements grid and page with activation (limits per line, end date, call-off limit), decline and close; ACL quote_template / quote_template_manage; menu entry.
  • B2B Suite → Catalog and Prices → Price Lists editor: form with code/name/currency/website/active, the tier table as dynamic rows (SKU, from-quantity, price) and the companies on the list (multiselect; a company sits on one list); Contract Prices grid + form (company, SKU, price, validity dates). Writes go through PriceListManagementInterface / ContractPriceRepositoryInterface; the company form's price list now also updates the assignment table the price index reads.
  • B2B Suite → Catalog and Prices → Catalog Access Rules: grid (company or global, scope, target with resolved SKU, effect, active, priority) and a form (company, scope switcher, SKU or category picker, effect, priority, active); writes go through CatalogAccessRuleManagementInterface, so the access index follows every save/delete.
  • Read grids under B2B Suite: Quotes, Purchase Orders (with the requester e-mail), Audit Log (filterable by company, actor type, channel, action, entity) and Webhook Deliveries (endpoint code, status, attempts, last error). Enum-backed option sources via Model\Source\EnumOptions virtual types.
  • B2B Suite → Companies and Users → Companies: grid (status, website, admin e-mail, members, credit, sales rep, price list) and a form to create/edit companies with their commercial terms (price list, credit account open / limit change, sales rep) and members (member listing with admin/status/remove actions, add-by-e-mail). Registration Queue grid with Approve/Reject (also as buttons on a pending company's form). Writes go through CompanyAdministrationInterface, CreditManagementInterface, SalesRepManagementInterface and CompanyRegistrationServiceInterface.
  • B2B Suite → Companies and Users → Company Roles: grid (company / role / system flag) and a form to create custom roles, rename them and tick the permission matrix; system roles keep their names, the Administrator role is read-only, roles still held cannot be deleted. Writes go through RoleAdministrationInterface, the same rules as the storefront editor.
  • Sales > Orders grid: a Company column with a select filter (all companies), fed by sales_order_grid.company_id from Qoliber_B2bCompanyOrder.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.
  • Company form → Commercial → Individual Discount (%): the company-wide percentage discount (empty removes it).

Changed

  • Company Roles grid lists newest roles first, like the other grids.
  • qoliber_b2b/general/default_credit_currency has no USD default; B2bConfigInterface::getDefaultCreditCurrency() falls back to the default website's base currency. Saving a company without a credit currency uses the company currency.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).
  • Role delete now POSTs (confirm dialog posts with the form key); the controller no longer accepts GET.
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.

Fixed

  • Grids showed days kept without a time (receivable Issued/Due, invoice payment Due, contract price Valid From/To, tax certificate Valid From/Until, quote agreement Valid Until) converted from UTC to the admin's timezone, so west of UTC each showed a day early, with a time: they show the stored day. A unit guard checks every grid column backed by a date-only database column.
  • A catalog access rule for one company role opened without its role, so saving it widened the rule to the whole company: the form loads the rule's role.
  • View as Company showed the catalog price for a company with an individual discount; it now shows what the storefront and the APIs charge, the individual-price service's answer first.
  • Price Explanation, Part Numbers, Order Quantity Rules and Price Feeds: the forms above the lists use the admin's own field layout; their labels were clipped and covered by the inputs.
  • Price lists and approval rules opened with their Active toggle off, so saving an active one deactivated it: the form gets "1"/"0" for the toggle.
  • Every listing declares storageConfig/indexField = its primary key. Magento's grid client indexes rows by entity_id by default, so a grid keyed otherwise rendered its last row N times as soon as it held more than one row (the Registration Queue showed the newest registration six times and hid the others). A static test now checks every *_listing.xml for the index field and <deps>.
  • Company and role forms: the flags imported into disabled/visible (is_system, is_administrator, is_pending, new is_existing) are real booleans. As strings ("" on a new form, "0" for a custom role) knockout's attr binding rendered disabled="", so the company select on a new role, the name of every custom role and a company's status select could not be edited. Found by the Playwright admin flow.
  • Every listing declares <deps> on its data source. Without it the grid component initialised before its provider, the browser showed "We couldn't find any records." for every B2B grid (rows were fetched but never rendered) and columns with a default <sorting> threw this.source is not a function. Found by the Playwright admin flow; server-side tests cannot see it.
  • Every admin page controller now injects Magento\Framework\View\Result\PageFactory (as core does). Magento\Backend\Model\View\Result\PageFactory is an auto-generated factory whose create() never adds the default layout handles, so the pages had no layout and setActiveMenu() failed on a missing menu block. Caught by the new backend controller integration tests.
  • acl.xml: Magento_Backend::stores was declared next to Magento_Backend::admin instead of inside it, which produced an untitled duplicate node in the merged ACL and broke ACL building whenever the config cache was cold ("Element 'resource': The attribute 'title' is required but missing").

Adobe Commerce B2B migration

  • Initial release: stateless optional interop layer mapping the Qoliber B2B CompanyInterface to an Adobe Commerce B2B shaped array via AdobeCompanyMapper, including a pure statusToAdobe() status mapping helper.

Agent purchase mandates

Added

  • Initial release.
  • Surfaces: mandate keys and "purchase orders need a mandate" on My Company → AI Agents (Luma + Hyvä) with the mandates agents presented; Agent Access in the admin shows keys, requirements and mandates; REST and GraphQL for keys and requirements (only the member's own tokens, an administrator's company's); the b2bagent/jwks route; records by company and member; browser tests signing AP2 mandates with Node's crypto on Mage-OS and Magento Open Source.
  • Agent purchasing mandates (domain): members' mandate keys (ES256, EdDSA; RFC 7638 thumbprints), the store's signing key per website and merchant identity, the prepare_checkout gateway tool (a UCP checkout signed by the store, hash, audience, single-use nonce), submit_purchase_order takes an AP2 v0.2 checkout mandate (Delegate SD-JWT chain: member key → agent key → checkout) verified before the purchase order and answered with a signed checkout receipt, AP2 error codes and error receipts on refusal, mandates required per token or by default, records, events, webhook topics (agent_mandate.accepted, agent_mandate.refused), audit.

AI agent gateway

Added

  • AgentToolRunnerInterface: run a gateway tool for a token outside MCP, on the MCP path's checks.
  • The wiring test lets other modules add tools to the pool after the gateway's own (prepare_checkout).
  • Several companies per customer: a call runs with the token's membership pinned (MembershipScopeInterface), and tokens are issued and verified against that membership, whatever company the customer chose.
  • Integration-health monitor agent_refusals: refused or failed agent calls within refusal_alert_window_minutes (15) reaching refusal_alert_threshold (20, 0 = off) raise one system-wide warning naming the token behind most of them (depends on Qoliber_B2bIntegrationHealth).
  • Idempotency keys for write tools: add_to_cart, request_quote and submit_purchase_order take an optional idempotency_key; a retry with the same key answers the first answer and changes nothing again, a key reused for another tool is refused (idempotency_key_reused), failed calls are not remembered (qoliber_b2b_agent_idempotency, kept qoliber_b2b/retention/agent_idempotency_days, 2 by default).
  • Spend given back: a quote an agent submitted that is rejected or expires, or a purchase order that is rejected or cancelled, no longer counts against the token's period limit (AgentSpendGuardInterface::release, qoliber_b2b_agent_spend.released_at).
  • Call log and analytics: every tools/call is logged (tool, outcome — ok, a refusal code, unknown_tool, invalid_arguments — and duration; kept qoliber_b2b/retention/agent_call_days, 90 by default) and summed per token and tool by Api\AgentCallStatsInterface.
  • Api\AgentToolCatalogInterface: the registered tools, for the screens that issue tokens.
  • Company-context MCP server: POST /b2bmcp speaks MCP 2025-06-18 over streamable HTTP with JSON answers (JSON-RPC 2.0; initialize, ping, tools/list, tools/call; notifications and client responses answered 202; no SSE, sessions or batches). One bare 401 for any unusable credential; 403 for plain HTTP (require_https) and browser origins nobody listed (allowed_origins); 404 switched off, 405 for anything but POST, 413 over max_request_bytes, 415 for non-JSON, 429 with Retry-After over requests_per_minute; Cache-Control: no-store, nosniff, noindex on every answer; the form key does not apply (bearer only).
  • Agent tokens per company member (qoliber_b2b_agent_token): qag_<selector>_<secret>, SHA-256 of the 256-bit secret, constant-time comparison, header-only (Authorization: Bearer); label, an explicit list of allowed tools, optional spend limits per order and per rolling period in the company currency, mandatory expiry (default 30 days, at most token_max_lifetime_days), revocation with a reason, at most max_active_tokens_per_member active tokens; issued and revoked by the member, a company administrator or the merchant. Api\AgentTokenManagementInterface (issue, revoke, getById, getByCompanyUser, getByCompany) and Api\AgentTokenVerifierInterface (one generic refusal; dies with the membership and the company's activity).
  • Every call runs as the member: an agent user context first in the storefront's composite user context, bound only inside Model\Call\AgentCallScope and released in finally (company context, catalog visibility and company prices in the cart apply as on the storefront; the customer session is never touched), on a store of the company's website, with the audit actor agent (actor id the member's customer, company_user_id, channel api) and agent_token_id added to every audit row of the call.
  • Nine tools in a di.xml tool pool (Api\AgentToolInterface, Model\Tool\ToolPool): search_catalog, get_price_availability, add_to_cart, request_quote, get_quote_status, submit_purchase_order, get_approval_status, get_order_status, get_invoice_status — thin adapters over the quick-order search and cart, the price and availability matrix, quote and purchase-order self-service, the approval read contracts, the company order history and the invoice portal.
  • Guard rails: allowed tools per token and tools switched off shop-wide (unknown otherwise), the member's company permissions per tool, JSON Schema validation of every argument (unknown arguments refused), spend limits checked before adding to the cart and before submitting a quote or purchase order (Api\AgentSpendGuardInterface, ledger qoliber_b2b_agent_spend, conversion with the configured rates or refusal), an hourly write limit and one write at a time per token, and before-plugins that refuse every approval decision while an agent call runs.
  • Audit rows for what no module audits: agent.cart.add and agent.spend.refused.
  • CLI qoliber:b2b:agent:token:issue (prints the plain token and the endpoint once) and qoliber:b2b:agent:token:revoke (services as proxies).
  • Configuration qoliber_b2b/agent_gateway/* (Stores → Configuration → Qoliber → B2B AI and Messaging → Agent Gateway).
  • Domain events qoliber_b2b_agent_token_issue_after, qoliber_b2b_agent_token_revoke_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): both tables and the configuration.

AI order intake

Added

  • Initial release: AI-assisted order intake that parses unstructured B2B order text into a SKU-matched draft quote.
  • Tables qoliber_b2b_order_intake and qoliber_b2b_order_intake_line.
  • Enum IntakeStatus (parsed/needs_review/converted/failed).
  • RawIntakeParserInterface with a deterministic HeuristicLineParser and an AI-assisted AiOrderIntakeParser that degrades gracefully to the heuristic parser when no AI provider is enabled or the AI output is unusable.
  • NullAiProvider default implementation of Qoliber\B2bCore\Api\Ai\AiProviderInterface.
  • SkuMatcher resolves tokens to catalog SKUs via ProductRepositoryInterface.
  • AssistedOrderIntakeService: submitRaw (parse, match, persist, audit) and convertToQuote (draft quote from matched lines).
  • OrderIntakeService implementation of Qoliber\B2bCore\Api\OrderIntake\OrderIntakeServiceInterface.
  • AssistedOrderIntakeServiceInterface::submitRaw(): companyId defaults to null, so REST can submit without a company.
  • A real AI provider: Model\Ai\AnthropicProvider (Anthropic Messages API — key encrypted, model, endpoint, token limit, timeout) behind Model\Ai\ConfiguredAiProvider, the new AiProviderInterface preference; configuration Stores → Configuration → Qoliber → B2B AI and Messaging → AI Order Reading. Without a provider or key the deterministic parser reads orders as before.
  • getLines, correctLine and removeLine on AssistedOrderIntakeServiceInterface (a reviewer corrects or drops lines; counts and status follow); on the email channel the deterministic parser reads only lines with a digit.
  • Indexes order_intake(company_id, status) and (created_at).
  • OrderIntakeRecordInterface and OrderIntakeLineInterface extends ExtensibleDataInterface (getExtensionAttributes() / setExtensionAttributes()), the model extends AbstractExtensibleModel and carries an _eventPrefix for model-level events; third parties add fields through extension_attributes.xml as with the company.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).
  • Converted intake lines are priced by the quote service (server-side, catalog fallback) instead of 0.0, which the quote-to-cart bridge used to lock into the cart as a free line.
  • An Anthropic API key saved in the admin was decrypted twice and never worked.

Security

  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Approval rules

Added

  • Initial release: admin-configurable, DB-backed multi-step approval rules (qoliber_b2b_approval_rule) and their approver-role step chains (qoliber_b2b_approval_rule_step).
  • ApprovalRuleRepository: cascade-persisting save, step-hydrated getById/getList/getActiveRules, delete/deleteById.
  • ApprovalRuleMatcher: precedence-aware matching ([min, max) bracket, company-specific over global, sort_order tiebreak).
  • Model\Rule\DatabaseApprovalRule adapter wired into the Qoliber_B2bCore ApprovalEngine.
  • Enum ApprovalRuleEntityType mirroring the core B2bEntityType.
  • Rules saved without a currency stay in the website base currency when a company buys in another currency.
  • Approval rules can apply to order_amendment.
  • ApprovalRuleValidator: a named company rule of a known entity type, a sane bracket, at least one step, every role of the rule's company.
  • Rule conditions: requester roles, SKUs and categories (a category covers its subcategories); ApprovalContextInterface; ApprovalRuleMatcherInterface::match() takes an optional context.
  • Rule steps carry escalate_after_hours and escalate_to_role_id.
  • Rule steps sharing a sequence form a parallel stage (unique key now (rule_id, sequence, approver_role_id)); required_approvals per step.
  • ApprovalRuleEntityType::SALES_ORDER — storefront checkout spend-limit approvals can now be configured as rules.
  • qoliber_b2b_approval_rule.currency (+ getCurrency()/setCurrency(), NULL = the company currency, the default website's base currency for global rules); ApprovalRuleMatcherInterface::match() takes the amount's currency and converts the amount into each rule's currency before bracketing.
  • ApprovalRuleInterface extends ExtensibleDataInterface (getExtensionAttributes() / setExtensionAttributes()), the model extends AbstractExtensibleModel and carries an _eventPrefix for model-level events; third parties add fields through extension_attributes.xml as with the company.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).

Approval workflow

Added

  • Initial release: generic approval request persistence and history ledger on top of Qoliber_B2bCore's approval engine, with ApprovalWorkflow (submit/approve/reject) and ApprovalRequestRepository.
  • Model\SelfService\InboxView for the storefront screens; canDecide() is false for a member who already approved a step of the current stage.
  • ApprovalSelfServiceInterface: votes, can-decide and out-of-office management authorized from the customer.
  • ApprovalContextProviderInterface and ApprovalContextBuilder: submit() matches rules against the requester's role and the approvable's lines.
  • Out-of-office delegation (ApprovalDelegationManagementInterface, qoliber_b2b_approval_delegation): the delegate decides on the delegator's behalf while it is active; eligibility follows it.
  • Escalation timers (ApprovalEscalationInterface, cron qoliber_b2b_approval_escalation): an overdue step opens to its escalation role (or the company admins) once, with a history row, an audit row and an e-mail.
  • Parallel stages (rule steps sharing a sequence), per-step quorum (required_approvals), votes (qoliber_b2b_approval_vote, ApprovalWorkflowInterface::getVotes()), ApproverEligibility, and approve() / reject() taking an optional stepId.
  • Api\ApprovalDecisionInterface (@api, Model\ApprovalDecision): the one entry point for deciding a request. Requests whose entity type has a registered Api\ApprovalDecisionHandlerInterface (di argument handlers, keyed by entity type) are decided by the owning module; others by the workflow.
  • ApprovalRequestRepositoryInterface::findLatestByEntity() (newest request for an entity of a company) and ApprovalWorkflowInterface::cancel() (pending → cancelled, awaiting step marked skipped), so callers can resume or supersede a request instead of opening duplicates.
  • qoliber_b2b_approval_request.currency (+ getCurrency()/setCurrency()); ApprovalWorkflowInterface::submit() takes the amount's currency (default: the company currency) and records it.
  • ApprovalWorkflowInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_approval_request_submit_after, qoliber_b2b_approval_request_approve_after, qoliber_b2b_approval_request_reject_after, qoliber_b2b_approval_request_cancel_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • A rejection now skips every other pending step (later steps used to stay pending); ApprovalAuthorization authorizes through ApproverEligibility (the member must be eligible for a pending step of the current stage); decisions lock the request row.
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE NO ACTION, i.e. restrict): a company with this kind of record cannot be deleted until the records are dealt with.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).

Security

  • Four-eyes: the requester can neither approve nor reject their own request (ApprovalAuthorization and ApprovalWorkflow); a role authorises only for the step currently awaiting action, not for any step of the chain; every state change is an optimistically locked UPDATE … WHERE state = ? AND current_step = ?, so two approvers acting at once cannot both finally approve.
  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Assisted buying

Added

  • Assisted buying activity — days under Stores → Configuration → Qoliber → B2B Integrations and Data → Data Retention: the activity log's retention was registered but could only be set from the command line (default 0 keeps it).
  • Several companies per customer: a consent is granted for the member's membership even when the member acts for another of their companies now.
  • ACL Qoliber_B2bAssistedBuying::grant_policy (change who may grant consents); the admin desk lives in Qoliber_B2bAdminUi.
  • Storefront: the Assistance page and the banner (in Qoliber_B2bFrontend / Qoliber_B2bHyva); etc/frontend/sections.xml refreshes qoliber-assisted-session on Login as Customer, login and logout.
  • Service contracts for seller-assisted buying with buyer consent: Api\ConsentManagementInterface, Api\ConsentRepositoryInterface, Api\AssistedBuyingSettingsInterface, Api\AssistedSessionManagementInterface, Api\AssistedSessionRepositoryInterface, Api\AssistedSessionContextInterface, Api\AssistedActionPolicyInterface, Api\OrderConfirmationManagementInterface, Api\ActivityLogInterface and the data interfaces ConsentInterface, AssistedSessionInterface, OrderConfirmationInterface, ActivityEntryInterface.
  • Schema: qoliber_b2b_assisted_company_setting (who may grant, per company), qoliber_b2b_assisted_consent (time-boxed, scoped consents; admin users deliberately not foreign keys), qoliber_b2b_assisted_session (sessions with the SHA-256 of their token, unique), qoliber_b2b_assisted_order_confirmation (buyer confirmations of orders above the threshold, bound to the cart), qoliber_b2b_assisted_activity (append-only buyer-visible log).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): drops the five tables, the qoliber_b2b/assisted_buying/ configuration and the sweep cron.
  • Consent: ConsentManagement (grant from the buyer's own identity only — never from an assisted session; members for themselves under the members policy, company managers for anybody in their company; scopes with implication, bounded duration, optional named rep; supersedes the member's current consent; revocation by the member, the granter, a company manager or a merchant admin ends the consent's sessions at once; expiry recorded once by the sweep), ConsentRepository, company grant policy AssistedBuyingSettings.
  • Assisted sessions on Login as Customer: resolver qoliber_b2b_assisted_consent in the LAC chain, the assistance checkbox replaced by the consent for company members, the storefront authentication re-checks, opens and binds the session (SHA-256 token), releases end the rep's sessions, generateCustomerTokenAsAdmin refused for company members.
  • Server-side enforcement on every storefront, REST and GraphQL request (SessionGuard, front-controller plugin): consent expiry and revocation, member and rep state, assignment, idle timeout, LAC release, the switch; a company member logged in through LAC without a valid session is logged out.
  • Attribution: the audit actor is sales_rep on sales_rep_assisted with the member recorded; audit rows written in a session name the session and the buyer and are mirrored into the buyer-visible, append-only activity log (ActivityLog), except lifecycle and merchant-only rows.
  • Guard rails (AssistedActionPolicy): forbidden actions (configurable, all six by default), scopes, buyer confirmation above a website threshold (OrderConfirmationManagement, cart fingerprint, one use), the B2B policy engine as extension point; before-plugins on cart, checkout, account, address book, approvals, company, roles, quotes and negotiation; cart changes and orders recorded for the buyer.
  • Storefront flag: customer-data section qoliber-assisted-session, ViewModel\AssistedSessionBanner.
  • Domain events qoliber_b2b_assisted_consent_grant_after, _consent_revoke_after, _consent_expire_after, _session_start_after, _session_end_after, _order_confirmation_request_after, _confirm_after, _decline_after, qoliber_b2b_assisted_settings_save_after (Plugin/Events, sortOrder 3000).
  • Configuration qoliber_b2b/assisted_buying/* (Stores → Configuration → Qoliber → B2B Suite → Assisted Buying), retention qoliber_b2b/retention/assisted_activity_days, ACL Qoliber_B2bAssistedBuying::consents, ::sessions, cron qoliber_b2b_assisted_buying_sweep (every five minutes).
  • Unit tests (21 files) and integration tests (13 files) for consent, sessions, Login-as-Customer gating, attribution, the buyer log, guard rails, events, wiring and schema.

Audit bridge

Added

  • Initial release: interceptor plugins that write a B2bAudit entry for every approval, quote, purchase-order, budget and credit workflow transition.
  • Company address changes are audited on CompanyAddressAdministrationInterface (the merchant's and the company managers' changes alike).
  • Company address changes are audited: company.address.save, company.address.delete, company.address.policy.
  • Audit sso.links_forget for IdentityLinkManagementInterface::forget().
  • Audit budget.carry for BudgetServiceInterface::carryOver().
  • Audits company.websites.change.
  • Audit rows for linking and unlinking messaging addresses.
  • Audit rows for stock bins, par levels, counts and replenishments.
  • Audit rows agent.mandate.accepted / agent.mandate.refused on the agent token.
  • Single sign-ons audited (sso.login on sso_provider).
  • Conversations audited (conversation.start, .reply, .note, .close, .reopen).
  • Order amendments audited (order_amendment.request, .cancel, .apply, .fail, .reject).
  • Company returns audited on any backend, with return.status for every status change (Mage-OS's own screens included).
  • Company returns are audited (return.request, return.credit on entity return, with the company).
  • E-mailed orders are audited (intake_mail.receive, .assign, .correct_line, .remove_line, .convert, .reject).
  • E-credit notes are audited like e-invoices (einvoice.issue from issueCreditNote / issueForCreditMemo).
  • company.switch audit rows (Plugin\CompanySwitcherAuditPlugin): the company switched to, the one left and the customer.
  • Audit rows order_review.open, order_review.approve, order_review.reject and order_review.policy.
  • Audit row company_terms.fallbacks when a company's credit provider fallbacks change.
  • Audits prepayments (receivable_payment.prepayment) and deposits asked and cancelled (deposit.request, deposit.cancel).
  • Availability audit: LeadTimeRuleAuditPlugin (distribution.lead_time_rule.save/delete), IncomingSupplyAuditPlugin (distribution.incoming_supply.replace with the deliveries) and SourcePreferenceAuditPlugin (distribution.source_preference.replace, with the company).
  • Document vault audit: VaultDocumentAuditPlugin (vault.document.save/delete, vault.version.add/delete); the row lists the document's SKUs and companies, and a version row its number, file, link or reference and dates.
  • Order guide audit: OrderGuideAuditPlugin (order_guide.line.annotate, order_guide.add_to_cart, by the member), StandingOrderAuditPlugin (order_guide.standing_order.save/pause/resume/delete with schedule and items) and StandingOrderRunAuditPlugin (order_guide.standing_order.run as the system; a skipped run that is not recorded leaves no row).
  • Net terms audit: PaymentTermsAuditPlugin (payment_terms.save/delete), CompanyTermsAuditPlugin (company_terms.assign/unassign, with the previous terms), CreditHoldAuditPlugin (credit_hold.place/release), ReceivableAuditPlugin (receivable.issue/credit/write_off), ReceivablePaymentAuditPlugin (receivable_payment.record/apply) and DunningAuditPlugin (dunning.notice); idempotent replays (same order, credit reference or remittance reference) leave no row.
  • Company template audit: CompanyTemplateAuditPlugin (company_template.save/delete with the version) and CompanyTemplateApplicationAuditPlugin (company_template.apply per finished application — after-plugins on apply and processQueued, so a section failure never rolls back the others; changes counted per section; queued runs as the system).
  • Quote desk audit (entity quote): QuoteSellerAssignmentAuditPlugin (quote.seller.assign/auto_assign/unassign, only when the seller changes, with the previous seller), MarginExceptionAuditPlugin (quote.margin_exception.request/approve/reject/cancel with price, cost, floor and margin) and QuoteOutcomeAuditPlugin (quote.loss_reason).
  • E-invoice audit: EInvoiceAuditPlugin (einvoice.issue/regenerate/transmit/status, the archived document identified by its checksum; an invoice that is not e-invoiced leaves no row) and EInvoiceSettingsAuditPlugin (einvoice.settings.save/delete).
  • Price feed audit: CatalogFeedTokenAuditPlugin (catalog_feed.token.issue/revoke, identified by id and public selector; the plain token and the feed URLs that embed it are never recorded).
  • AR portal audit: PaymentRequestAuditPlugin (invoice_payment_request.create/pay/cancel; pay is audited after it returns, outside any transaction, because the payment adapter is called there), PaymentSettlementAuditPlugin (invoice_payment_attempt.confirm/fail), BillingScheduleAuditPlugin (billing_schedule.create/cancel) and InvoicePaymentMethodAuditPlugin (invoice_payment_method.company_rule_save/company_rule_reset); replays leave no row; idempotency keys, bank instructions and redirect addresses are never recorded.
  • Assisted buying audit: AssistedConsentAuditPlugin (assisted.consent.grant/revoke/expire; a revoke of an inactive consent leaves no row), AssistedSessionAuditPlugin (assisted.session.start with the rep as actor on channel sales_rep_assisted, assisted.session.end one row per ended session), AssistedConfirmationAuditPlugin (assisted.confirmation.request/confirm/decline) and AssistedSettingsAuditPlugin (assisted.settings.grant_policy, only on a change); never the session token or the cart fingerprint.
  • Agent gateway audit: AgentTokenAuditPlugin (agent_token.issue/revoke, attributed to the issuing member or administrator, with tools and spend limits; never the plain token).
  • Checkout reference settings audit: CheckoutReferenceSettingsAuditPlugin (checkout_reference_settings.save with the new and previous per-company overrides and the administrator; a save that changes nothing leaves no row).
  • Tax audit: VatRegistrationAuditPlugin (tax.vat.register/validate/remove, the validation row keeps the VIES consultation number) and ExemptionCertificateAuditPlugin (tax.certificate.submit/approve/reject/revoke/expire/expiry_remind with from/to status, deciding admin and note).
  • Distribution data model audit: UnitOfMeasureAuditPlugin (distribution.unit_of_measure.create/update/delete), ProductUnitAuditPlugin (distribution.product_units.replace with the unit set), OrderQtyRuleAuditPlugin and PartNumberAuditPlugin (distribution.order_qty_rule.*, distribution.part_number.*, with the company) — around-plugins at sortOrder 1000, so the domain events fire inside the audit transaction.
  • Audit rows for approval delegations set and revoked, and for escalated approval steps.
  • QuoteTemplateAuditPlugin: quote agreements requested, activated (with the seller's limits), declined, closed, expired by the sweep (system actor) and every call-off with its linked quote (quote_template.*).
  • Audit rows for quote lines (quote.item.add, quote.item.update with from/to qty and price, quote.item.remove) and attachments (quote.attachment.upload with name, type, size and checksum — never the content — and quote.attachment.remove).
  • Purchase-order cancel and order transitions are audited; company-level credit charges and reimbursements (chargeCompany, reimburseCompany) are audited like account-level ones.
  • RequisitionListAuditPlugin: requisition list create, rename, share/unshare and delete are audited (requisition_list.*).
  • invitation.expire_sweep audit row (system actor) for the invitation expiry cron.
  • pricing.discount.create|update|delete audit rows for individual discounts.

Changed

  • AuditWriter::write() accepts an optional trailing companyId and passes it to the entry.
  • PurchaseOrderAuditPlugin forwards the new optional approver note of approve()/reject().
  • Quote, credit, purchase-order, approval and budget plugins pass the new optional currency arguments through and record the resolved currency.

Fixed

  • The package required magento/module-store ^1.0 (no such version; ^101.0) and could not be installed with Composer.

Audit log

Added

  • Initial release: append-only audit log (qoliber_b2b_audit_log), AuditLoggerInterface, AuditEntry DTO, AuditActorType / AuditChannel enums.
  • ActorContextInterface: the actor of the current request from Magento's composite user context (customer / admin / integration token → agent / system) with the channel derived from the area (storefront, admin, api, system); runAs() overrides for jobs and assisted actions.
  • AuditTrailInterface::record(): one call per transition (action, entity, company, summary, data, actor); AuditedTransactionInterface::run() executes a transition and its rows in one DB transaction (nested-safe), so the rows exist exactly when the transition committed.
  • Enum cases AuditActorType::AGENT and AuditChannel::SYSTEM.
  • Retention policy audit_log (qoliber_b2b/retention/audit_log_days, default 0 = keep forever).
  • Setup\Uninstall.
  • audit_log.company_id (nullable, indexed with created_at) + AuditEntryInterface::getCompanyId() — entries can be filtered per company in the admin Audit Log; AuditEntry takes an optional trailing companyId.
  • Index audit_log(created_at).

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Available to promise

Added

  • Api\AvailabilityServiceInterface (getAvailability, batch getAvailabilities): available-to-promise on the MSI stock of a company's website — available now (on hand + reservations − threshold, capped by the salable quantity), orderability, the split over sources in preferred order, incoming deliveries after the backlog, the backorder promise, short quantity, the dispatch date and branch pickup options (Api\Data\AvailabilityInterface, AllocationInterface, PickupOptionInterface, AvailabilityRequestInterface; Model\Enum\AllocationType).
  • MSI read through service contracts only, batched: one source-item query per request, MSI's warm-up pool filled once before the per-SKU reads (Model\StockSnapshotReader, Model\StockSourceProvider).
  • Preferred sources per company and company location, qoliber_b2b_source_preference, with Api\SourcePreferenceManagementInterface.
  • Lead-time rules (dispatch and restock days per SKU, source, or both; most specific first, field by field), qoliber_b2b_lead_time_rule, with Api\LeadTimeRuleRepositoryInterface.
  • Incoming supplier deliveries per SKU, qoliber_b2b_incoming_supply, with Api\IncomingSupplyManagementInterface (replace as a whole, batch read).
  • Business calendar per store: qoliber_b2b/availability/non_working_days, holidays, cutoff_time, default_restock_days (Model\BusinessCalendar, Model\Config, Model\Clock).
  • Branch pickup from MSI in-store pickup's "Use as Pickup Location" flag, read without depending on the in-store pickup modules.
  • Quick order: after-plugin qoliber_b2b_availability_quick_order_hint warns about ok lines beyond what is available now (config qoliber_b2b/availability/quick_order_hint, on by default).
  • Domain events qoliber_b2b_lead_time_rule_save_after, qoliber_b2b_lead_time_rule_delete_after, qoliber_b2b_incoming_supply_replace_after, qoliber_b2b_source_preference_replace_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops the three tables and the qoliber_b2b/availability/ configuration.

B2B core

Added

  • Initial release: CompanyContext seam (+ guest null-object), universal Approval engine, OrderIntake contracts + request DTO, pluggable Pricing pipeline contracts, AI provider contracts, Outbox (qoliber_b2b_outbox) + recorder, ApprovalState/B2bEntityType enums, Money/Quantity value objects, ACL roots.
  • UninstallCoverageTest accepts a suite table a module only adds columns to (listed in getCoreTableColumns()).
  • Model\\Transaction\\Savepoint: work undone on its own when it fails — a transaction, or a savepoint inside the caller's (which a nested rollBack() would mark rolled back).
  • Config\Source\EmailTemplate: the template dropdown of a setting shown outside the qoliber_b2b section finds its default template by the stored path; guard test for the stored paths of the B2B Suite settings.
  • StoredSecret: config secrets read in plain whether Magento decrypted them on read or not; guard test for every secret reader.
  • Entity type order_amendment (B2bEntityType) for order amendments going through approval.
  • The agent call log is one of the retention policies (agent_call).
  • Test\Unit\Api\NestedInterceptionGuardTest: a contract method with plugins may not call another plugged method of its own contract through $this (the interceptor runs that method's audit and event plugins a second time).
  • Test\Unit\Approval\ApprovalSurfaceGuardTest: storefront, GraphQL, REST and admin code must decide approvals through ApprovalDecisionInterface.
  • Test\Unit\Console\CommandProxyGuardTest: every CLI command the suite registers must take its non-trivial constructor dependencies as \Proxy, because setup:install instantiates all commands before a single table exists.
  • Index outbox(aggregate_type, aggregate_id).
  • B2bEntityType::CART — the entity a checkout budget hold is keyed on before an order exists.
  • CurrencyServiceInterface (Api/Currency): a website's base currency, the currencies it allows (base + every store's display currencies), validation (assertAllowed) and conversion through Magento's rate table. Depends on Magento_Store and Magento_Directory now.
  • PriceResolutionPipelineInterface::resolveFor() takes ?string $currency = null (the company website's base currency) instead of a hard-coded 'USD'.
  • Test/Unit/Api/ApiContractTest: suite-wide guard that every Api interface is @api-tagged and licensed, every EVENT_* constant on a contract is dispatched by its module and documented, and the extensible data interfaces are backed by extensible models.
  • Data retention framework: RetentionPolicyInterface / RetentionRunnerInterface, the generic TableRetentionPolicy (declare one per log as a di.xml virtual type), config group Stores → Configuration → Qoliber → B2B Integrations and Data → Data Retention (qoliber_b2b/retention/<code>_days, 0 = keep forever, batch size, per-run cap) and the nightly cron qoliber_b2b_retention. Processed outbox events are the first policy (30 days); the outbox gains a (processed, created_at) index.
  • Setup\ModuleUninstall: base class for every module's Setup\Uninstall (drops the module's tables and core-table columns, deletes its config, cron schedule, indexer/mview state and queue rows) so bin/magento module:uninstall --remove-data leaves nothing behind; UninstallCoverageTest guards that every module with state ships one covering it.
  • Individual customer prices: Api/Pricing/CustomerPriceProviderInterface (the hook: batched, per customer/company/store/currency/channel), CustomerPriceServiceInterface (composite with memo, tagged cache, circuit breaker, currency conversion, invalidate()), the CustomerPriceRequest/Line/Price/Tier data interfaces and the qoliber_b2b_customer_price_resolve_after event.

Changed

  • OutboxRecorderInterface::getUnprocessed() returns events oldest first; the outbox now has a producer and a consumer (B2bIntegration).
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • CompanyContextInterface::getCompanyUserId() now returns the real company_user.user_id; the Magento customer id moved to the new getCustomerId(). CompanyContextResolverInterface::resolveForCustomer(int) added for cart-owner / assisted resolution.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Added

  • Initial release.
  • Code lookups respect the member's role catalog (Qoliber_B2bCatalog role rules).
  • Surfaces: Find by part numbers on Luma and Hyvä with add to cart, REST and GraphQL, e2e on Mage-OS and Magento Open Source. The search box answers with a redirect result (its part-number note shows), and the table reads the member's company from the company context (cacheable listings render without the session).
  • B2B search: part-number-first storefront search (one exact code — the company's part number, our SKU, a cross-reference — opens the product; several codes take a member to Find by part numbers), the code lookup (CodeLookupInterface) with quick order's checks, and the Table view of category and search listings with the member's part numbers, technical attribute columns and sorting; Luma and Hyvä.

Branch pickup

Added

  • Branch pickup for company members: whether a company may pick up at a branch (store default per website, company override), enforced on MSI's in-store pickup rate on every channel; the branches that can serve a member's cart (lines held in full, what is missing, when everything is ready) from B2bAvailability.

Budgets

Added

  • Initial release: periodic spend budgets per company and optionally per company-user (qoliber_b2b_budget) with a signed ledger (qoliber_b2b_budget_ledger).
  • Enum BudgetPeriod (monthly/quarterly/annual) and PeriodKeyResolver mapping a period plus a date to a bucket key (Y-m, Y-Qn, Y).
  • BudgetRepositoryInterface: save/getById/delete/deleteById/getList plus resolveBudget (a user-specific active budget outranks a company-wide one).
  • BudgetServiceInterface: getConsumed/getRemaining (ledger SUM(amount) aggregate), assertWithinBudget (throws BudgetExceededException when over cap, no-op when no budget applies), record (positive spend), release (negative refund).
  • BudgetServiceInterface::carryOver(): a replaced entity's spend moves to its replacement in the same budget and period and the difference is booked there; event qoliber_b2b_budget_carry_after.
  • releaseEntity() (release to the original budget and period, clamped to the entity balance, idempotent per reference) and getEntityBalance(); ledger reference column + index.
  • BudgetServiceInterface::consume() — atomic check + record under SELECT … FOR UPDATE on the budget row, idempotent per (entity_type, entity_id), optional overrun for policy-approved spend; reassign() re-keys a hold (cart → order); discard() drops a hold that never materialised. Index on (entity_type, entity_id).
  • BudgetServiceInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_budget_consume_after, qoliber_b2b_budget_reassign_after, qoliber_b2b_budget_discard_after, qoliber_b2b_budget_release_after. See the README events table.
  • BudgetInterface extends ExtensibleDataInterface (getExtensionAttributes() / setExtensionAttributes()), the model extends AbstractExtensibleModel and carries an _eventPrefix for model-level events; third parties add fields through extension_attributes.xml as with the company.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • BudgetRepositoryInterface::save() resolves an empty currency to the company website's base currency and refuses one the website does not allow; qoliber_b2b_budget.currency has no default any more.
  • assertWithinBudget(), consume() and releaseEntity() accept the amount's currency and convert it into the budget currency before comparing or booking.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).
  • A refund processed twice at once (or committed while another transaction had already read the ledger) was released twice, a release could take the balance below zero, and two carts could overspend the cap — under the budget lock the decisive reads are locking reads now, and the ledger holds a reference once (unique key). A missing rate no longer rolls back the caller's transaction.

Buyer copilot messaging

Added

  • The buyer copilot on SMS, WhatsApp (Twilio) and Microsoft Teams (outgoing webhook): addresses linked by a one-time code sent from them, an agent token per identity, commands, reorders ("same as the March order"), order lines through the order intake, proposals confirmed with YES, signed webhooks, a message log; events, webhook topics and audit.

Fixed

  • Linking an address when the member holds as many agent tokens as allowed made the webhook fail (500); the reply now says why and the address stays unlinked.

Catalog access rules

  • Initial release: catalog access rules (qoliber_b2b_catalog_access_rule), repository, and a deny-wins CatalogVisibilityResolver for company-scoped product visibility.

Catalog visibility

Added

  • Initial release: enforces per-company catalog visibility for logged-in company members. Removes hidden products from storefront collections, 404s hidden product detail pages, and varies Full Page Cache per company (b2b_company HTTP context value).
  • A whole-catalog rule change purges every category's menu cache.
  • Company roles: a role with catalog rules of its own narrows its members' listings, search (b2b_hidden_for_role), product loads, menu and category pages, and is part of the page cache's vary (b2b_catalog_role) — full-page cache also varies by member to revoke access safely in existing sessions.
  • Hidden categories leave the layered navigation's category filter (Luma and Hyvä).
  • Page cache by company tag: every page cached for a member and the category menu's ESI response (Varnish; Luma catalog.topnav, Hyvä topmenu_generic) carry b2b_catvis and b2b_catvis_<company>; a rule change purges only its company's pages (b2b_catvis for a rule without a company) instead of the whole page cache, and guests' pages stay. Fixed: a product rule purged nothing (the company kept seeing the product on cached listings and its page); a category rule's products left the listings only when the access index was rebuilt, which purged nothing — re-indexing now purges the companies it rebuilt. CleanCategoryCacheOnRuleChangePlugin is replaced by PurgeCatalogCachesOnRuleChangePlugin.
  • Category navigation: categories denied to the company (and their subcategories) leave the Luma top menu and the Hyvä navigation and answer 404; the menu block cache varies by the hidden set; rule changes clean the affected block and page caches. Frontend DI and events are back (etc/frontend).
  • Search-engine enforcement: product documents carry b2b_hidden_for, every product search container excludes documents hidden for the acting company (bound on SearchInterface::search), and access-index changes re-index the affected products in the search engine. Collection, repository and search plugins are registered globally (storefront, GraphQL, REST); the FPC vary plugin stays frontend-only.

Changed

  • The FPC vary plugin moved to Qoliber_B2bCompany (CatalogVisibilityContextPlugin removed); the module no longer has area-specific DI.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • A page rendered for a member while their membership or catalog role changed could reach Varnish after the change's purge and be stored under the member's old variant, which Varnish then kept serving them (200 HIT where Magento answered 404). A cacheable page is now checked as it leaves against the member's company and role as committed (from the member in the vary, since the page is depersonalised): changed, it is sent uncacheable and with the member's current vary, and Magento's built-in page cache does not store it either.
  • Existing sessions stop serving warmed pages after role, status, administrator or membership changes. Resolve membership before cache lookup, bind built-in cache keys and Varnish vary to the member, and purge company tags after the membership transaction commits. Responses to stale vary cookies are uncacheable until the browser adopts the current context.
  • Mage-OS 3.5 / Magento 2.4.9: a category hidden for the member's company answered an empty 200 page (the core controller now expects Adobe's category permissions to have redirected); Plugin\HiddenCategoryNotFoundPlugin (after Catalog\Controller\Category\View::execute) forwards to 404 again.
  • Visibility is enforced inside the collection SELECT (LEFT JOIN on the hidden-pairs index) instead of removing items after load, so getSize(), pagination and getAllIds() are correct; the per-sku N+1 lookup in CatalogVisibilityFilter is one query.
  • Company resolution no longer depends on the frontend customer session; the same company applies on GraphQL and REST (CompanyGuardContext / CompanyPriceLocator).
  • ProductCollectionVisibilityPlugin recursed infinitely for a logged-in company member: getItems() inside the after-load plugin re-entered load(). A re-entrancy guard stops it (the JOIN-based filter that replaces post-load removal follows).

Checkout references

Added

  • The buyer's own references on a company order: PO number (trimmed, 1–64 printable characters, optionally unique per company), a cost center of the company and a requested delivery date (lead days, max days, weekdays only), each off / optional / required as a store default per website with per-company overrides (qoliber_b2b_checkout_reference_company).
  • Api\CheckoutReferenceManagementInterface (requirements, get/set on the member's own active cart, completeness at placement) and Api\CompanyCheckoutSettingsManagementInterface (get/save per company; domain event qoliber_b2b_checkout_reference_settings_save_after).
  • Luma checkout "Your references" (PO number, cost center, requested delivery date) above the payment methods, with a client check of required references and a save to the cart right before the order is placed; the buyer's order page and the admin order view show the references.
  • Enforcement on every buyer channel (before CartManagementInterface::placeOrder, sortOrder 25) naming everything missing or no longer valid; a purchase order's cart keeps its number; the requested delivery date (quote/sales_order.requested_delivery_date) is copied to the order and references the company no longer uses are left off it.
  • The references in the order, invoice and shipment e-mails (above the items; none when the order carries none).
  • The requested delivery date on the admin order grid (column with a date-range filter; backfilled for earlier orders).

Checkout spend limits

Added

  • Initial release: place-order plugin that enforces a member spend limit (per-member B2bBudget) at storefront checkout, with a configurable over-limit policy resolved per member -> per company -> store-config default (require approval / allow + reported / block).
  • Checkout with multiple addresses is no longer offered to company members, and creating its orders is refused for them: it bypassed CartManagementInterface::placeOrder, so approval rules, budgets, spend limits, payment and shipping policies, checkout references and deposits never applied to those orders. Other shoppers keep it.
  • Approval rule conditions see the governed cart's lines (CartApprovalContextProvider).
  • Budget released on order cancel (order_cancel_after) and on refunded credit memos (sales_order_creditmemo_save_after), clamped to what the order still has booked and idempotent per document.
  • OverLimitPolicyManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_over_limit_policy_set_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • A cart that fulfils a purchase order (purchase_order_id) is not governed again: its budget and credit were booked when the purchase order was approved, and Qoliber_B2bPurchaseOrder guards that the cart still matches it.
  • Placement plugin ordered after the payment/shipping policy (sortOrder 30) so a disallowed method never opens an approval request.
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Checkout governance compares the cart's base grand total converted into the budget currency (was the display grand total with no currency); the approval request and rule match carry that currency; order-cancel and credit-memo releases convert the base totals the same way.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.
  • No double spend: the budget is now consumed as an atomic, row-locked hold keyed on the cart before the order is created (reserve), re-keyed onto the order after placement and discarded when placement fails; the previous check-then-record-after-order let two concurrent carts both pass.
  • "Require approval" now resumes: an approved sales_order request for the same cart and total releases the placement, a pending one is awaited instead of re-submitted on every retry, a rejected one stays blocked until the cart changes, and a changed total cancels the stale pending request and opens a new one. Without a matching approval rule the order is blocked explicitly instead of being silently auto-approved.
  • PHP 8.1 compatibility: ENTITY_TYPE constant holds the enum case; ->value is read at runtime (enum property fetch in constant expressions requires PHP 8.2).
  • db_schema_whitelist.json regenerated from db_schema.xml.
  • An approval covered another configuration of the same product (another engraving text, configurable option or bundle selection at the same price and quantity) — the cart fingerprint includes each line's options.
  • An approval covered any cart of the same total — another product, another currency, or a rule that applies since (another approver) placed under it. A request now remembers the cart's fingerprint (lines, currencies, applicable rule; qoliber_b2b_checkout_approval) and only that cart places under it. Rules with role, product or category conditions are matched with the cart's context before a request is opened (they never matched: "no approval rule is configured").
  • Cancelling a partly invoiced order gave the invoiced part back to the budget too — only what was not invoiced is released.
  • An amended or edited order (cancelled with a relation_child_id) gave its whole spend back while the replacement booked nothing — its spend is now carried to the replacement.

Security

  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Companies

Added

  • Companies on several websites: CompanyWebsitesInterface and qoliber_b2b_company_website; members from any of the company's websites, the storefront company context and switcher per website, CompanyCurrencyResolverInterface::currencyOnWebsite()/storefrontCurrencyFor(); event qoliber_b2b_company_websites_save_after.
  • Companies may buy in another currency of their website: currency (validated: allowed, exchange rates), CompanyCurrencyResolverInterface::currencyFor() answers it and the new baseCurrencyFor() the website base.
  • Several companies per customer: qoliber_b2b/company/allow_multiple_memberships (website, off by default), unique (customer_id, company_id) instead of customer_id, qoliber_b2b_company_selection, Api\CompanySwitcherInterface (memberships, switchTo, event qoliber_b2b_company_switch_after), Api\MembershipScopeInterface::runAs, CompanyUserRepositoryInterface::getListByCustomerId/getByCustomerAndCompany, ActiveCompanyMemberResolverInterface::findActiveByCompanyUserId/findActiveInCompany; getByCustomerId answers the current membership; the customer group follows the current company.
  • Company customer group: customer_group_id on the company (CompanyInterface::getCustomerGroupId()/setCustomerGroupId(), validated on save) and CustomerGroupSync with its observers and plugin, so members join, follow and leave the company's group.
  • CompanyInterface is extensible (ExtensibleDataInterface, getExtensionAttributes()/setExtensionAttributes(); Company extends AbstractExtensibleModel), so integrators add company fields through extension_attributes.xml and repository plugins instead of forking the model.
  • CompanyProfileManagementInterface: actor-scoped self-service profile (getProfile, updateProfile for name / legal name / tax id) used by the storefront, GraphQL and REST "self" surfaces; merchant-only fields stay on CompanyAdministrationInterface.
  • CompanyCurrencyResolverInterface: the company's website (its own or the default one), that website's default store and base currency, and resolve() which validates a requested currency against it. Every money-carrying service defaults and validates through it.
  • CompanyAdministrationInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_company_save_after, qoliber_b2b_company_member_add_after, qoliber_b2b_company_member_admin_after, qoliber_b2b_company_member_status_after, qoliber_b2b_company_member_role_after, qoliber_b2b_company_member_remove_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.
  • CompanyAdministrationInterface — unscoped company + membership administration for trusted actors (validated save, addMember/addMemberByEmail with one-company-per-customer and same-company-role rules, setMemberAdmin/setMemberStatus/assignMemberRole/removeMember with the last-active-admin guard, Exception\LastCompanyAdminException); fires qoliber_b2b_company_member_add_before so the permission module can assign the default role. The storefront team service now delegates here.
  • CompanyHttpContext + customer_login/customer_logout observers and a beforeExecute plugin on every storefront action write the acting company (b2b_company) into the full-page-cache vary — inside the login/logout request, like core's CONTEXT_AUTH, so a member's first page after login is never cached under the guest/no-company hash. Replaces the beforeDispatch plugins in B2bPricing and B2bCatalogVisibility (which Http*ActionInterface controllers never reached).

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • The company and role models fired their lifecycle events under the domain event names (qoliber_b2b_company_save_after, qoliber_b2b_company_role_save_after, _delete_after), so observers of the domain events also received every raw model save. The models now use the prefixes qoliber_b2b_company_entity and qoliber_b2b_company_role_entity; SyncMemberGroupsOnCompanySave moved to qoliber_b2b_company_entity_save_after.
  • PHPStan level 8 finding on the company context memo key type.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).
  • Company context on cacheable storefront pages: Magento depersonalises the customer session before category and product pages render, so company prices (and anything else reading the context) silently fell back to guest. CustomerCompanyContextResolver now recovers the company from the HTTP context (part of the page-cache vary) as a company-level context with no member.

Security

  • ActiveCompanyMemberResolverInterface: the single gate answering whether a customer may currently act as a company member (user ACTIVE and company ACTIVE). Every token- and session-derived actor lookup in the suite now goes through it, so disabling a member or a pending/rejected company takes effect on GraphQL and REST as well as the storefront.
  • CustomerCompanyContextResolver is area-agnostic: the actor comes from Magento's composite UserContextInterface (customer session on the storefront, bearer token on REST/GraphQL) instead of the frontend session, with a per-request memo.

Company addresses

Added

  • Company addresses (Api\CompanyAddressManagementInterface): billing and delivery addresses of a company with the company defaults, for every member or for chosen members and teams; a company switch limits members to them (administrators and roles with address.use_own excepted). Who may use what: Api\CompanyAddressAccessInterface. Events, webhook topics and audit actions for saving, deleting and the switch.
  • Address book copies: members find the company addresses they may use in their address book, kept in step with the address, who may use it, the membership and the team tree; the company defaults become a member's defaults when they have none or are limited to company addresses.
  • Enforcement: a member limited to company addresses bills and delivers only to company addresses they may use, on every channel (placement), cannot add or change own addresses, and nobody changes a company address copy outside the sync; the storefront address form sends both back to the address book.
  • Storefront: the Luma checkout offers a limited member only the company addresses they may use, without "New Address"; a limited member's empty address book opens instead of looping through the new address form. CompanyAddressManagementInterface::canManage().
  • CompanyAddressAdministrationInterface: the merchant side (any company's addresses by company) and the one way every change goes — the management service checks who acts and calls it as the acting member; the events (now declared here), the audit trail and the address books follow it.

Fixed

  • Concurrent address-book synchronization locks the customer and reads committed links inside an atomic transaction, preventing duplicate copies and orphaned company addresses.

Company credit

Added

  • Initial release: credit accounts + signed-amount ledger, CreditManagement (create account, available credit, charge with limit enforcement, reimburse).
  • Api\CreditPaymentMethodsInterface (drawsOnCredit, getCodes): the payment methods whose orders are paid from the company credit line, registered by the modules providing them on Model\CreditPaymentMethods (di.xml argument methods, code => true).
  • reimburseCompany() (@api): the currency-aware counterpart of chargeCompany(), used when an approved purchase order is cancelled.
  • CreditManagementInterface::changeLimit() — locked, idempotent-per-reference limit change recorded as a zero-amount limit_change ledger row.
  • CreditManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_credit_account_create_after, qoliber_b2b_credit_limit_change_after, qoliber_b2b_credit_charge_after, qoliber_b2b_credit_reimburse_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • createAccount() currency is optional and validated against the company website (was 'USD'); assertWithinCredit() and chargeCompany() take the amount's currency and convert it into the account currency with the configured rates. qoliber_b2b_credit_account.currency has no default any more.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • chargeCompany()/reimburseCompany() ran charge()/reimburse() through the interceptor: every company charge (purchase order approval, pay on account) wrote two audit rows and published credit.charged/credit.reimbursed twice. They now share a private implementation with the account-level methods.
  • chargeCompany() (and the new reimburseCompany()) move credit through the account internally, which bypassed interception: purchase-order approvals charged credit without an EVENT_CHARGE_AFTER event or an audit entry. The events plugin and B2bAuditBridge now cover the company-level methods.
  • Unique credit_transaction(account_id, type, reference) — a reference can be booked once per account and type.
  • Foreign key company_id → qoliber_b2b_company (ON DELETE NO ACTION, i.e. restrict): a company with this kind of record cannot be deleted until the records are dealt with.
  • reimburse() records the amount actually applied (clamped to the outstanding balance) so SUM(transaction.amount) == account.balance holds; idempotent per reference.
  • charge() and reimburse() run in a transaction with the account row locked (SELECT … FOR UPDATE), so concurrent charges cannot both fit into the same available credit; charge() is idempotent per reference and refuses with CreditLimitExceededException.
  • A charge or reimbursement working inside a transaction that had read the account earlier wrote back a balance missing what others committed since (and a reimbursement replayed meanwhile failed on the unique key) — the locked account and the reference are read as committed now.

Company invitations

Added

  • Initial release: tokenized company user invitations, InvitationStatus enum, invitation repository (incl. getByToken) and CompanyInvitationManagement (invite, accept with token/expiry validation provisioning a company user, revoke, pure isExpired helper).
  • InvitationSignupInterface::join(token, firstname, lastname, password): an invitee without an account joins in one step — the account is created for the invited address on the company's website (no confirmation e-mail: the invitation proves the address) and the invitation accepted together; works when Magento's own sign-up is closed.
  • Several companies per customer: where the website allows it, a member of another company is invited, and accepting switches them to the new company.
  • Invitation e-mail (qoliber_b2b_company_invitation, InvitationMailerInterface) with the accept link for the company's website store; qoliber_b2b/invitations/ttl_days and sender_identity settings.
  • invite() validates the address, refuses inactive companies, foreign roles, addresses already in a company and duplicate pending invitations; records invited_by_company_user_id. accept() records accepted_customer_id/accepted_at. getPendingForCompany(), getByToken().
  • FK role_id → qoliber_b2b_company_role (SET NULL); index on (company_id, status).
  • CompanyInvitationManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_company_invitation_invite_after, qoliber_b2b_company_invitation_accept_after, qoliber_b2b_company_invitation_revoke_after, qoliber_b2b_company_invitation_resend_after. See the README events table.
  • expireOverdue() + cron qoliber_b2b_invitation_expire (every 15 minutes): pending invitations past expires_at become expired (event qoliber_b2b_company_invitation_expire_sweep_after, audit invitation.expire_sweep); the accept page and accept() say "expired" for them.
  • Retention policy company_invitation (default 90 days; accepted, revoked and expired only) and a (status, created_at) index.
  • Setup\Uninstall.
  • Indexes company_invitation(email) and (status, expires_at).

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • A revoke or resend that read the invitation before another request accepted it overwrote the acceptance, and an acceptance (or sign-up by invitation) with a link a concurrent resend had replaced still went through — the decisions are now taken on the locked, committed row.
  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.

Security

  • Invitation secrets are stored as SHA-256 hashes; the plain secret exists only in the e-mail and on the object invite()/resend() return. REST/UI surfaces use the token-free InvitationSummaryInterface.
  • accept() is bound to the invitee: the accepting customer's e-mail must match the invitation. It runs in one transaction with a row lock and a conditional pending → accepted update, so a second accept is refused; the company must be active and the member is added through CompanyAdministrationInterface::addMember() (friendly duplicate checks, default-role event).
  • revoke() and the new resend() take the acting company-user id and are scoped through CompanyAdminGuard; only pending invitations can be changed.

Company orders

Added

  • Initial release: company_id, company_user_id, purchase_order_id, cost_center_id on quote and sales_order (+ sales_order_grid.company_id); the buyer's company is stamped on the quote and copied onto the order at sales_model_service_quote_submit_before (every order creation path); extension attributes on OrderInterface / CartInterface; admin order grid column mapping; CompanyOrderHistoryInterface (own orders, or all company orders under order.view_all) and a shared row mapper for the storefront screens.
  • CompanyOrderHistoryInterface::getOrdersBetween(): a member's orders in a time range.
  • The PO number on the admin order grid (sales_order_grid.po_number, filled by the grid sync, backfilled for earlier orders); uninstall removes every column the module adds.
  • po_number (the buyer's purchase order number) on quote and sales_order, copied cart → order by StampCompanyOnSubmit, exposed as the po_number extension attribute of carts and orders and in the company orders rows.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • The Composer autoload namespace carried an extra escape, so none of the module's classes loaded once installed with Composer (setup:install stopped at its data patch).

Company pricing

Added

  • Initial release: price lists (qoliber_b2b_price_list), tiered items, company assignments, PriceListRepository, and PriceListResolver implementing the B2bCore PriceResolverInterface pricing pipeline.
  • The company price index holds a row set for every currency the company buys in on its websites, is rebuilt when they change and is read in the currency of the website asking; getIndexedPrice()/getIndexedTiers() take an optional currency.
  • Company prices on product and listing pages are converted into the display currency like catalog prices (they were shown as base-currency amounts in any other display currency).
  • The company price index is per currency: built and read in the currency the company buys in (lists natively, contracts converted, legacy contracts from the base), re-indexed on a company currency or website change, on saving exchange rates and on a price list header save; catalog-based prices convert from the base.
  • Quantity breaks from the price index: PriceIndexServiceInterface::getIndexedTiers(companyId, skus) reads the indexed breaks of many SKUs in one query (ascending, unknown SKUs left out). IndexedCompanyPriceProvider now reads the index once per request instead of once per line, prices each line at its break and fills CustomerPrice::getTiers() with the breaks above one (converted with the price, kept in the price cache, exposed by GraphQL tiers).
  • PriceExplainerInterface (+ PriceExplanationReportInterface, PriceExplanationStepInterface): every precedence step for a company line with why it has no price, beside the storefront index and the individual-price answer; PriceResolutionPipeline::explainSteps(); CustomerGroupPriceResolver::describeGroup().
  • ParentPriceListResolver (step 4) and CustomerGroupPriceResolver (step 5: the company customer group's tier, special and catalog-rule price); PriceListLookup shares list reads between the resolvers and the index; Model\Company\CompanyAncestry walks parents and subsidiaries.
  • The price index merges the company list with its ancestors' lists; subsidiaries are re-indexed with their parent and after a parent change (Observer\ReindexOnParentChange).
  • Daily cron qoliber_b2b_contract_price_validity re-indexes companies whose contract prices started or ended.
  • Private prices (qoliber_b2b/pricing/private_prices, default off): category and product pages render the catalog price for everyone and price boxes share one block_html key, so per-customer prices never reach the shared page cache; the browser fetches the visitor's individual prices once per page (Qoliber_B2bFrontend POST b2b/price/batch, Hyvä counterpart in Qoliber_B2bHyva). CompanyPriceLocator renders nothing on the catalog channels while the mode is on; cart, quotes, GraphQL and REST are unaffected.
  • PriceListManagementInterface — validated price list saves (unique URL-safe code, name, ISO currency, known website), replaceItems() (the tier table replaced in one transaction: known SKUs, qty > 0, price ≥ 0, no duplicate tiers), setAssignedCompanies() / assignCompany() (one list per company; the assignment table and company.price_list_id are written together) — the price index is refreshed for every company touched. ContractPriceRepositoryInterface — validated contract prices (company and SKU exist, price ≥ 0, date order); collections for tiers and contract prices.
  • PriceListManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_price_list_save_after, qoliber_b2b_price_list_items_replace_after, qoliber_b2b_price_list_assign_after, qoliber_b2b_company_price_list_assign_after. See the README events table.
  • ContractPriceRepositoryInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_contract_price_save_after, qoliber_b2b_contract_price_delete_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.
  • Model/Customer/CustomerPriceService: asks the registered providers in sort_order (DI argument providers), first answer per line wins, memoises per request, caches answers in the new qoliber_b2b_prices cache type (tags per customer and company, TTL per answer or qoliber_b2b/pricing/customer_price_ttl), opens a circuit for a provider that throws (provider_failure_cooldown) and converts answers into the request currency. Built-in providers: pipeline (cart/quote, live ERP → contract → price list) and company_index (listing/product/api). Admin: Stores → Configuration → Qoliber → B2B Catalog and Prices → Individual Prices (switch, TTL, cooldown, registered providers).
  • CompanyPriceLocator resolves through the service and gains prefetch() (one provider round trip per listing page); the catalog price plugins are unchanged.
  • New cache type qoliber_b2b_prices — run bin/magento cache:enable qoliber_b2b_prices after upgrading (new types are disabled until enabled).
  • Reference provider PercentageDiscountPriceProvider (sort_order 5000): a percent off the catalog price per company or per customer (PriceDiscountRepositoryInterface, table qoliber_b2b_price_discount, events qoliber_b2b_price_discount_save_after / _delete_after), one catalog query per request; saving invalidates the cached prices of the company / customer.
  • Observer/PrefetchListingPrices (frontend catalog_product_collection_load_after): a listing page is priced in one provider round trip; CompanyPriceLocator guards against re-entry from a provider's own collection load.

Changed

  • CompanyPriceLocator memoises per (company, sku, qty) for the request; the FPC vary plugin moved to Qoliber_B2bCompany (CompanyPriceContextPlugin removed).
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Price list currency is validated against the list's website (CurrencyServiceInterface); an empty currency becomes that website's base currency. A price list in another currency than the requested one no longer applies (PriceListResolver). Contract prices carry a currency (NULL = the company currency) and are converted into the requested currency (ContractPriceResolver). qoliber_b2b_price_list.currency has no default any more.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • PriceDiscount fired its lifecycle events as qoliber_b2b_price_discount_save_after / _delete_after — the domain event names; its prefix is now qoliber_b2b_price_discount_entity. ReindexOnParentChange listens on the company model event qoliber_b2b_company_entity_save_after.
  • Price precedence follows the strategy: the pipeline asks contract, ERP, company price list, parent company price list, customer group — contract used to come after the ERP. In the price index a contract now wins at every quantity (a list tier at 10+ used to undercut it) and a price list in another currency than the company's base currency is no longer indexed as if it were.
  • Foreign keys company_id → qoliber_b2b_company (CASCADE) on price list assignments, contract prices and the price index; unique contract_price(company_id, sku, valid_from) and price_list_item(price_list_id, sku, qty_from); the price index rebuild replaces a company's rows inside one transaction.
  • Custom-option surcharges are no longer lost for company members: the company price replaces the base price (Type\Price::getBasePrice, Pricing\Price\BasePrice::getValue) so _applyOptionsPrice, the final-price event and the regular-price strike-through compose on top; final_price product data is right.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).
  • Company resolution no longer depends on the frontend customer session; the same company applies on GraphQL and REST (CompanyGuardContext / CompanyPriceLocator).
  • Rendered price boxes are cached per company (PriceBoxCacheKeyPlugin on Magento\Framework\Pricing\Render\PriceBox::getCacheKeyInfo): Magento keys the block_html price cache by customer group only, so members of every company saw the first company's cached prices on listings and product pages.
  • A provider that prices per store view got the first store view's answer for every store view of the website — the price cache and the request memo key by store view (and the memo by membership) too.
  • A customer in several companies got the price cached for the company they had left — the price cache is keyed by company, membership and channel too (a cached listing answer no longer stands in for a live quote price), and the storefront memo is kept per acting customer and company (and reset between requests).

Company registration

Added

  • Initial release: CompanyRegistrationService (self-registration creating a pending company + active admin user + company approval request, pure canActivate guard, approve/reject transitions).
  • "Allow Customer Sign-up" (qoliber_b2b/registration/customer_signup, per store view, default Yes): set to No to close Magento's own customer sign-up — its form, submission and links on Luma and Hyvä (Plugin\CustomerSignupPlugin on Registration::isAllowed()); with company self-registration on, sign-up then goes through the company application only, with both off accounts come only from the merchant, invitations, single sign-on or imports.
  • CompanyOnboardingInterface: storefront registration as one unit of work — registerWithNewAccount() creates the Magento customer account (password policy applies) and the pending company in a single transaction; registerExistingCustomer() for logged-in customers. Result DTO tells the caller whether e-mail confirmation is pending.
  • E-mail verification: qoliber_b2b/registration/require_email_confirmation (default on) forces Magento account confirmation for accounts created through company registration (ForceEmailConfirmationPlugin on AccountConfirmation), so a company cannot be registered on somebody else's address. qoliber_b2b/registration/enabled switches self-registration off.
  • Duplicate detection: DuplicateCompanyDetectorInterface refuses a registration whose name, legal name or tax id matches a non-rejected company (case-insensitive).
  • register() sets website_id (defaults to the current website) and takes an optional $websiteId.
  • CompanyRegistrationServiceInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_company_register_after, qoliber_b2b_company_approve_after, qoliber_b2b_company_reject_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • register() writes through CompanyAdministrationInterface (name validation, friendly "already belongs to company X" instead of a unique-key error) and no longer submits a company approval request — the admin Registration Queue is the decision point, and the registrant no longer receives an "approval requested" mail about their own company. Qoliber_B2bApproval is no longer a dependency.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Registration creates the company's system roles and puts the founding admin on Administrator (was role_id = NULL, which failed every permission check). The three writes + approval submission run in one transaction; rejection is only possible from pending.

Company returns

Added

  • ReturnWorkflowInterface::getList(?status, limit) and the backend's getList: company returns of every company for the merchant's screens and REST.
  • Platform-neutral: no Mage-OS dependency any more. Returns are kept by a backend (Api\Backend\ReturnBackendInterface, qoliber_b2b/returns/backend): the suite's own tables (native, default — Magento Open Source) or Mage-OS RMA through Qoliber_B2bReturnsMageOs. Own data types (CompanyReturnInterface, ReturnCommentInterface) instead of Mage-OS's; the merchant workflow ReturnWorkflowInterface (approve with quantities, ask for details, reject, receive, resolve, comments) and members' cancel; ReturnStatusChangeInterface with event qoliber_b2b_return_status_change_after; credit to account on resolve from whichever backend reports it; settings under Stores → Configuration → Qoliber → B2B Ordering and Checkout → Company Returns. The Mage-OS stamp table, guard, storefront filter, grid column and resolution patch moved to the bridge.
  • Company column on Sales → Returns (filterable); e2e seed and specs for My Company → Returns on Luma and Hyvä.
  • Api\Data\ReturnRequestLineInterface, the input line of REST and GraphQL return requests.
  • getReturnableItems, getLines, getComments, addComment on CompanyReturnServiceInterface; ReturnOptionsInterface (reasons, resolutions, item conditions, statuses with store labels); Mage-OS RMA's own customer and guest returns of company orders follow the company rights (StockReturnGuard), and the storefront offers a company order for return only to a member who may return it.
  • Company returns on Mage-OS RMA: every return of a company order is stamped with the company; CompanyReturnServiceInterface (returnable orders, many-line requests within what is left, the returns a member may see) gated by order.return and order.view_all; the resolution type credit_to_account refunds a resolved return with an offline credit memo once (ReturnCreditInterface); events qoliber_b2b_return_request_after and qoliber_b2b_return_credit_after.

Fixed

  • Two requests at once could reserve more of an order than was ordered, and a late decision could overwrite one already taken (an approval after a rejection, a withdrawal after a decision). A new return now locks the order and counts what is reserved as committed; every decision locks the return, checks the transition on its status as committed and writes its quantities and status as one change. A refusal leaves an enclosing transaction usable.

Changed

  • ReturnBackendInterface::lockStatus(): a backend reads a return's status under a row lock, for the workflow's decisions.

Company roles and permissions

Added

  • Initial release: canonical permission catalogue (PermissionResources), role → permission matrix management (RolePermissionManagement: grant, revoke, get, set), and permission check service (PermissionCheckService: by role, by company user). Reuses the existing Qoliber_B2bCompany tables.
  • Permission address.use_own ("Use own addresses at checkout"): exempts a role from its company's "company addresses only" limit (Qoliber_B2bCompanyAddress).
  • Permissions vmi.count (in the Buyer template) and vmi.manage for stock bins.
  • Permission conversation.view_all ("See all company conversations") for B2bConversation.
  • Permission order.amend ("Amend orders"): members holding it change their placed orders before fulfilment (all company orders with order.view_all); not in the Buyer template.
  • Permission order.return ("Request returns"): members holding it request returns of their orders (of all company orders with order.view_all); not in the Buyer template.
  • Permission catalog_feed.manage ("Manage price feeds"): issue and revoke the company's price feed tokens; not in the Buyer template.
  • Permissions invoice.view ("View company invoices and statements") and invoice.pay ("Pay company invoices") for the AR portal; not in the Buyer template.
  • Observer\AssignDefaultRoleOnMemberAdd on qoliber_b2b_company_member_add_before: members added through CompanyAdministrationInterface without a role get Administrator/Buyer by their admin flag.
  • RoleAdministrationInterface — unscoped role and permission writes for trusted actors (backend users, system): save/rename/delete roles, count holders, get/set permissions (Administrator permissions refused). The storefront services (CompanyRoleManagement, RolePermissionManagement) delegate here after their company-admin guards, so both surfaces enforce the same rules.
  • Permission order.view_all ("View all company orders") in the catalogue; administrators hold it, the Buyer system role does not.
  • CompanyRoleManagementInterface: company admins list, create, rename and delete custom roles of their own company (system roles immutable, roles still assigned undeletable, names unique per company); PermissionDefinitionInterface exposes the grantable permission catalogue.
  • RoleProvisionerInterface: every company gets the system roles Administrator (all permissions) and Buyer (view, quote, order, lists); members without a role are placed on the matching one. Data patch backfills existing companies.
  • Company administrators hold every permission regardless of role (PermissionCheckService).
  • RoleAdministrationInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_company_role_save_after, qoliber_b2b_company_role_delete_after, qoliber_b2b_company_role_permissions_after. See the README events table.
  • RolePermissionManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_company_role_permission_grant_after, qoliber_b2b_company_role_permission_revoke_after. See the README events table.
  • RoleTemplateProviderInterface / RoleTemplateProvider: the system role templates (Administrator = *, Buyer = the historical five) come from di.xml instead of PHP; RoleProvisionerInterface::syncSystemRoles() creates missing system roles and grants template permissions an existing one lacks (never revokes).
  • Data patch SyncSystemRoleTemplates (after ProvisionDefaultRoles) syncs every company; observer on qoliber_b2b_company_save_after provisions roles for admin-created companies immediately.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • PHPStan level 8 findings in RoleTemplateProvider and the role events plugin (found by running the gate over the whole suite).

Company structure

Added

  • Initial release: company locations / ship-tos with single-default switching, hierarchical org teams with cycle-safe re-parenting and idempotent membership, and per-company cost centers with unique codes.
  • Index company_team(parent_team_id).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.

Company team management

Added

  • Initial release: guarded CompanyTeamManagement (invite, assign role, set/revoke admin, suspend, remove, per-member monthly spend limit) with admin-only + same-company + keep-one-admin rules.
  • listInvitations(), revokeInvitation(), resendInvitation() — pending invitations of the acting admin's company, guarded like every other team write; inviteMember() records the inviter.

Changed

  • setAdmin/setStatus/removeMember guard the acting admin and then delegate to Qoliber\B2bCompany\Api\CompanyAdministrationInterface; Exception\LastCompanyAdminException now extends the B2bCompany one (catch the parent).
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • setMemberSpendLimit() currency is optional and validated against the company website (was 'USD').
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • CompanyTeamManagementInterface::setStatus documents the LocalizedException it throws for invalid status values.

Company templates

Added

  • Templates carry whole-catalog catalog rules (scope catalog, target 0).
  • Sections credit (open the credit line with a limit; replace changes the limit; an account is never removed) and payment_terms (assign, replace or unassign the terms and provider), through B2bCredit and B2bPaymentTerms.
  • Admin configuration fields (template for new companies, template per customer group, apply mode); admin screens and REST routes (in Qoliber_B2bAdminUi, Qoliber_B2bApi).
  • Company setup templates qoliber_b2b_company_template (code, name, description, active flag; extensible data interface) holding a JSON blueprint (schema 1) of company setup sections, versioned in qoliber_b2b_company_template_version; managed through Api\CompanyTemplateRepositoryInterface (save validates the blueprint and writes a new version only when it changed, getById, getByCode, getList, deleteById, getVersionBlueprint).
  • Section contributors (Api\TemplateSectionInterface, di.xml array sections of Model\Section\SectionPool): roles, approval_rules (role references by name, remapped to the target company's roles), budgets (company-wide), catalog_access (products by SKU), commercial (customer group, price list by code) and over_limit_policy.
  • Api\CompanyTemplateManagementInterface: capture a company's setup as a blueprint, preview (dry run) and apply a template in merge or replace mode with one transaction per section, processQueued; every application logged in qoliber_b2b_company_template_application and read through Api\TemplateApplicationRepositoryInterface.
  • Default template on registration approval (website-scoped qoliber_b2b/company_templates/default_template, group_templates, apply_mode), queued by an observer on qoliber_b2b_company_approve_after and applied by the cron job qoliber_b2b_company_template_apply.
  • Starter templates smb and enterprise_procurement (data patch InstallStarterTemplates).
  • Domain events qoliber_b2b_company_template_save_after, qoliber_b2b_company_template_delete_after, qoliber_b2b_company_template_apply_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops the three tables, the qoliber_b2b/company_templates/ configuration and the cron job.

Compliance document vault

Added

  • Product documents follow the member's role catalog (Qoliber_B2bCatalog role rules).
  • Vault documents qoliber_b2b_vault_document (type catalog, title, description, ISO 639-1 language, translation key, visibility public / company / internal, status active / archived) with SKU (_product, catalog spelling) and company (_company, cascade) assignments; Api\VaultDocumentManagementInterface::save() and Api\VaultDocumentRepositoryInterface.
  • Versions qoliber_b2b_vault_document_version: a file stored through B2bDocument's private storage (area vault), an external URL (product passport) or a reference number (EUDR due-diligence statement), with valid_from, valid_until, review_at and a change note; numbers never reused; addVersion(), deleteVersion(), delete(). A new version supersedes the previous one on its first day; only the current version is served.
  • Api\VaultDocumentResolverInterface: documents of one or many SKUs, of a company, and general documents as a viewer sees them — visibility, catalog access rules (a hidden product yields nothing), current version, language with fallback.
  • Api\DownloadAuthorizationInterface (customer via active membership, admin any version) and the access log qoliber_b2b_vault_access_log with Api\AccessLogRepositoryInterface; retention policy vault_access_log (default 3650 days).
  • Api\ExpiryServiceInterface and cron qoliber_b2b_document_vault_expiry_sweep (hourly at :35): expiring, expired and review-due notices, each recorded once.
  • Domain events qoliber_b2b_vault_document_save_after, _delete_after, _version_add_after, _version_delete_after, qoliber_b2b_vault_document_expiring_after, _expired_after, _review_due_after (Plugin/Events, sortOrder 3000).
  • Configuration in Stores → Configuration → Qoliber → B2B Catalog and Prices → Compliance Vault (expiry warning days, fallback language, batch size); Setup\Uninstall.

Conversations

Added

  • Initial release.
  • Surfaces: My Company → Messages on Luma and Hyvä ("Message us" on company orders), B2B Suite → AI and Messaging → Messages, member and merchant REST, GraphQL, e2e on Mage-OS and Magento Open Source.
  • Conversations (domain and e-mail bridge): threads about orders and general ones between company members and the merchant, with files in the private document storage, unread counts, internal notes, closing and reopening; who sees which (own, written in, orders they may see, conversation.view_all); e-mails both ways with per-recipient signed reply addresses, and e-mail replies taken by the suite's mailbox; events, webhook topics, audit.

Document storage

Added

  • Initial release: document metadata (PDFs/attachments/product docs, metadata only — not file storage) keyed by (entity_type, entity_id), DocumentRepository (save/getById/delete) and DocumentManagement (attach, listForEntity, countForEntity).
  • Model\Download\DownloadResultFactory: file downloads answered from memory (attachment disposition with a safe ASCII name and the UTF-8 name, nosniff, no-store).
  • DocumentContentInterface — a file for API clients (name, type, size, base64 content).
  • DocumentStorageInterface → Model\Storage\DocumentStorage: private file storage under var/qoliber_b2b/documents with an extension allow-list, content-sniffed MIME check (HTML, SVG, scripts and executables always refused), size cap and SHA-256; Stores → Configuration → Qoliber → B2B Catalog and Prices → Documents config (allowed_extensions, max_size_mb, max_per_quote); config removed on uninstall.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

E-invoicing

Added

  • XRechnung 3.0 (UBL and CII) and EHF Billing 3.0 as formats: Model\Format\ProfiledFormatGenerator profiles an existing syntax (specification identifier, extra rules), Model\Format\XRechnungRules checks the BR-DE rules; configuration seller_contact_name (BT-41) and access points per new format. Written offline: validate with the KoSIT validator before production.
  • E-credit notes: every saved credit memo of an e-invoiced company becomes an e-credit note (381) referring to the invoice it credits — Peppol BIS as a UBL CreditNote, CII with InvoiceReferencedDocument; issueCreditNote, issueForCreditMemo, getByCreditMemoId, observer on sales_order_creditmemo_save_after. Adjustment refunds/fees and KSeF corrections (KOR) are refused with the reason.
  • EN 16931 semantic model (Model\Document\*, immutable), TotalsCalculator (VAT breakdown and totals in cents, source taxes summed per group) and the offline core validator En16931Validator (BR-02 … BR-CO-25 and the per-category rules).
  • VAT categories from the B2bTax treatment and the charged rate (Model\Tax\VatCategoryResolver): S, E, K for intra-Community goods, AE for reverse-charged services, G for exports, Z.
  • Format generators behind Api\FormatGeneratorInterface: peppol_bis3 (Peppol BIS Billing 3.0 UBL), cii_en16931 (Factur-X / ZUGFeRD EN 16931 XML) and ksef_fa3 (KSeF FA(3), not yet verified against the official XSD).
  • Api\EInvoiceManagementInterface (issue, issueForInvoice, regenerate, transmit, updateStatus, getDocument), read-only Api\EInvoiceRepositoryInterface, Api\CompanySettingsRepositoryInterface; tables qoliber_b2b_einvoice (fiscal record: no foreign key to sales tables, company SET NULL) and qoliber_b2b_einvoice_company_settings (company CASCADE).
  • Observer on sales_order_invoice_save_after issuing the e-invoice of every invoice of an e-invoiced company (idempotent, never blocks invoicing); documents archived in B2bDocument's private layout (Model\Storage\XmlArchive).
  • Api\AccessPointInterface with the built-in store_only access point; configuration in Stores → Configuration → Qoliber → B2B Finance and Tax → E-Invoicing; domain events qoliber_b2b_einvoice_issue_after, _regenerate_after, _transmit_after, _status_update_after, qoliber_b2b_einvoice_settings_save_after, _delete_after.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): both tables and the qoliber_b2b/e_invoice/ configuration.

GraphQL API

Added

  • Company addresses: b2bCompanyAddresses, saveB2bCompanyAddress, deleteB2bCompanyAddress, setB2bCompanyAddressesOnly, CustomerAddress.b2b_company_address_id.
  • B2bCompanyRole.catalog and setCompanyRoleCatalog(roleId, mode, categoryIds).
  • The GraphQL cache id carries the member's role when the role has catalog rules of its own.
  • Hidden categories leave every GraphQL category read for a member: categoryList, categories (counts included), children, category, a product's categories, urlResolver/route and the category aggregation. Fixed: GraphQL responses were cached without the company — the company is now a factor of X-Magento-Cache-Id (Varnish) and members' responses carry the company catalog tags; the built-in page cache, which looks GraphQL up before the token is read (a member could be served a guest's unfiltered response), is no longer read or written for authenticated requests.
  • createCustomerV2 / createCustomer follow "Allow Customer Sign-up" (Qoliber_B2bCompanyRegistration): refused with "Customer sign-up is closed." (graphql-authorization) while it is closed (Plugin\CustomerSignupClosedPlugin).
  • Company.website_ids.
  • b2bMessagingIdentities, startB2bMessagingLink, unlinkB2bMessagingIdentity.
  • b2bStockBins, b2bStockBin, b2bVmiScan, b2bBinCount; saveB2bStockBin, deleteB2bStockBin, setB2bParLevel, removeB2bParLevel, recordB2bBinCount, replenishB2bBinCount.
  • addB2bQuoteKit mutation and B2bKitSelectionInput; B2bQuoteItem has product_type and components, and quote items list only top-level lines.
  • currency on Company.
  • Mandate keys and agent tokens' mandate requirement over GraphQL.
  • Find by part numbers: b2bFindByCodes.
  • Conversations: b2bConversations, b2bConversation, b2bConversationAttachment, b2bUnreadMessages, startB2bConversation, replyB2bConversation.
  • Spend analytics: b2bSpendReport, b2bSpendSubscriptions, subscribeB2bSpendReport, unsubscribeB2bSpendReport.
  • Order amendments: b2bCanAmendOrder, b2bOrderAmendments, b2bOrderAmendment, requestB2bOrderAmendment, cancelB2bOrderAmendment (type B2bOrderAmendment).
  • Company returns GraphQL on own types (no Mage-OS requirement): B2bReturn, B2bReturnLine, B2bReturnComment, B2bReturnOption, B2bReturnLineInput; return_id arguments; cancelB2bReturn.
  • Company returns over GraphQL: b2bReturns, b2bReturn, b2bReturnableOrders, b2bReturnableItems, b2bReturnOptions, requestB2bReturn, addB2bReturnComment (Mage-OS RMA's CustomerReturn type).
  • Company switching: b2bCompanies (B2bCompanyMembership: company, role, admin flag, statuses, is_current, is_available) and switchB2bCompany(companyId).
  • Agent tokens: b2bAgentTools, b2bAgentTokens, issueB2bAgentToken (the plain token once), revokeB2bAgentToken.
  • Delivery per line: b2bShipToDestinations, b2bCartLineDestinations, setB2bLineDestinations, CustomerOrder.b2b_destinations.
  • b2bBranchPickup: whether the member's company may pick up at a branch and the branches that can serve their cart.
  • CustomerOrder.b2b.review_status and review_note (why an order was cancelled after review).
  • Saved carts: b2bSavedCarts, b2bSavedCart; mutations saveB2bCart, restoreB2bSavedCart, renameB2bSavedCart, shareB2bSavedCart, handOffB2bSavedCart, deleteB2bSavedCart.
  • Checkout references: b2bCheckoutRequirements, setB2bCheckoutReferences, Cart.b2b_checkout_references; CustomerOrder.b2b gains po_number, cost_center and requested_delivery_date.
  • Company account for the signed-in member: b2bAccount (owed, overdue, aging, terms, hold, credit), b2bAccountInvoices / b2bAccountInvoice, b2bAccountStatement, b2bOnAccountDecision, b2bInvoicePaymentMethods, b2bPaymentRequests, b2bRemittanceAdvice; mutations prepareB2bInvoicePayment, payB2bPaymentRequest, cancelB2bPaymentRequest.
  • Seller-assisted buying: query b2bAssistance (consents, pending order confirmations, activity) and mutations grantB2bAssistance, revokeB2bAssistance, confirmB2bAssistedOrder, declineB2bAssistedOrder.
  • Order guides and standing orders: queries b2bOrderGuide (includeHidden, withCurrentPrices), b2bOrderGuideDue, b2bStandingOrders, b2bStandingOrder (with runs); mutations annotateB2bOrderGuideLine, addB2bOrderGuideLinesToCart, saveB2bStandingOrder (create or change), pauseB2bStandingOrder, resumeB2bStandingOrder, deleteB2bStandingOrder (resolvers in Model\Resolver\OrderGuide; the order guide services apply their own rules).
  • Approval depth: B2bApprovalStep stage, quorum, activation and escalation fields; B2bApprovalRequest.votes and can_decide; note on approve/reject; approvalDelegations, setApprovalDelegation, revokeApprovalDelegation.
  • Quote depth: updateB2bQuoteItemQty, removeB2bQuoteItem, requestB2bQuoteFromCart, uploadB2bQuoteAttachment, removeB2bQuoteAttachment, b2bQuoteAttachmentContent, b2bQuotePdf; B2bQuote.history, template_id, created_at/updated_at; comment and attachment timestamps, attachment checksum.
  • Quote agreements: b2bQuoteTemplates, b2bQuoteTemplate, requestB2bQuoteTemplate, callOffB2bQuoteTemplate, releaseB2bCallOff.
  • Resolver\Quote\QuoteGqlContext: quote access refusals are authorization errors (they were input errors).
  • README regenerated from the schema.
  • Purchase orders: queries b2bPurchaseOrders, b2bPurchaseOrder(poId); mutations createB2bPurchaseOrderFromCart, submitB2bPurchaseOrder, cancelB2bPurchaseOrder, setB2bPurchaseOrderNumber, updateB2bPurchaseOrderItemQty, removeB2bPurchaseOrderItem, b2bPurchaseOrderToCart; types B2bPurchaseOrder (with can_* flags for the viewer) and B2bPurchaseOrderItem.
  • Requisition lists: queries b2bRequisitionLists, b2bRequisitionList(listId); mutations createB2bRequisitionList, renameB2bRequisitionList, shareB2bRequisitionList, deleteB2bRequisitionList, addB2bRequisitionListItems, setB2bRequisitionListItemQty, removeB2bRequisitionListItem, requisitionListFromOrder(listId, orderNumber), requisitionListToCart, requisitionListToQuote, requisitionListToPurchaseOrder. Types B2bRequisitionList (with is_owner, can_edit, can_delete, owner_name, updated_by_name) and B2bRequisitionListItem.
  • Quick order: queries b2bQuickOrderDraft, b2bQuickOrderParse(text), b2bQuickOrderValidate(lines), b2bProductSearch(term, limit); mutations b2bQuickOrderDraftAdd, b2bQuickOrderDraftSetQty, b2bQuickOrderDraftRemove, b2bQuickOrderDraftClear, b2bQuickOrderAddToCart(lines) (without lines converts the draft). Types B2bQuickOrderLineInput, B2bQuickOrderValidatedLine, B2bQuickOrderDraftItem, B2bQuickOrderCartResult, B2bProductSearchResult.
  • updateCompanyProfile(input: {name, legal_name, tax_id}): the acting company admin edits the customer-facing profile; buyers get an authorization error.
  • CustomerOrder.b2b { company_id company_user_id purchase_order_id cost_center_id } — the company context an order was placed under.
  • companyRoles and companyPermissions queries (non-cacheable) and createCompanyRole, renameCompanyRole, deleteCompanyRole, setCompanyRolePermissions mutations; ActingMember helper for token-derived actor resolution in resolvers.
  • Integration test asserting the cacheability of company-scoped queries; unit tests for the error mapping.
  • b2bCustomerPrices(lines: [{sku, qty}], currency): batched individual prices for the current customer (non-cacheable), returning sku, qty, price, currency, list_price, source, detail and tiers.

Changed

  • member mutations catch Qoliber\B2bCompany\Exception\LastCompanyAdminException (the rule moved to B2bCompany; the B2bCompanyTeam exception is now a subclass).
  • addB2bQuoteItem no longer takes price; proposeB2bQuotePrice is a buyer counter-offer (requires quote.create) and acceptB2bQuoteProposal accepts seller offers only; B2bQuoteItem exposes product_id, name, list_price.
  • createB2bQuote(currency) and setMemberSpendLimit(currency) default to the company website's base currency (the schema default "USD" is gone) and reject currencies the website does not allow.

Fixed

  • approveApprovalRequest / rejectApprovalRequest decide through ApprovalDecisionInterface, so purchase orders follow their request.
  • All seven company-scoped queries are now @cache(cacheable: false); company data was full-page-cacheable across customers.
  • Domain errors in convertOrderIntake, submitOrderIntake, setMemberStatus, approveApprovalRequest, rejectApprovalRequest are mapped to typed GraphQL errors instead of surfacing as internal server errors.

Security

  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Hyvä storefront

Added

  • Initial release: Hyvä "My Company" dashboard via a Magewire CompanyDashboard component + Tailwind template.
  • Company Addresses and the address book's company block with Hyvä markup.
  • Roles page (Magewire): what each role sees of the catalog (catalogDrafts, saveCatalog()).
  • With customer sign-up closed (Qoliber_B2bCompanyRegistration "Allow Customer Sign-up"), Hyvä's login dialog leaves out its "Create an Account" section, which Hyvä renders without asking Registration::isAllowed() (Plugin\AuthenticationPopupSignupPlugin).
  • Invitations: the invitation page lets an invitee without an account create it and join in one step (same controller as Luma).
  • My Company → Messaging on Hyvä.
  • Stock Bins pages on Hyvä: bins, par levels, the count page with the scan field, the proposal, labels.
  • The Hyvä quote page lists a kit's components under the kit line.
  • Agent mandates on the Hyvä AI Agents page.
  • The company sign-in on the Hyvä login page.
  • Find by Part Numbers in Hyvä markup.
  • The Table view of category and search listings in Hyvä markup (B2bSearch).
  • Messages pages and the "Message us" link on company orders in Hyvä markup.
  • Company Spend page in Hyvä markup.
  • Order amendments pages and the "Amend" link on company orders in Hyvä markup.
  • Hyvä return page: withdraw a return.
  • Hyvä templates of My Company → Returns (list, request, return page).
  • The company switcher of My Company with Hyvä markup (company/switcher.phtml).
  • My Company → AI Agents in Hyvä.
  • Delivery per line on the cart page and the order page in Hyvä.
  • The branch-pickup block on the cart page in Hyvä.
  • The order-review notice on the order page in Hyvä.
  • Saved carts in Hyvä: the list, a cart's page and "Save cart" on the cart page.
  • Hyvä versions of My Company → Assistance and of the assisted-session banner (plain script on private-content-loaded, CSP-registered).
  • Hyvä versions of the requested payments list, the deposit request page and the quote deposit notice.
  • Hyvä markup for My Company → Invoices, the payment request and the statement (hyva_b2b_invoices_index|request|statement.xml).
  • Hyvä markup for My Company → Order Guide and Standing Orders (hyva_b2b_orderguide_index.xml, hyva_b2b_standingorder_index.xml, hyva_b2b_standingorder_edit.xml).
  • Hyvä markup for My Company → Price Feeds (hyva_b2b_feed_index.xml, hyva_b2b_feed_issue.xml, feed/index.phtml, feed/issued.phtml); the Price Check page gets its b2b-price-check wrapper.
  • Hyvä markup for My Company → Price Check (hyva_b2b_pricecheck_index.xml, pricecheck/index.phtml).
  • Hyvä markup for the product page's Documents section (hyva_catalog_product_view.xml, product/documents.phtml).
  • Approval inbox (Magewire): stages, parallel steps and votes, a note per decision, out-of-office set and revoked.
  • Plugin\HideCompanyCategoriesInNavigationPlugin: categories hidden for the company (B2bCatalogVisibility) are filtered out of Hyva\Theme\Service\Navigation::getCategoryTree().
  • Quote screens: Magewire\Quotes, Magewire\QuoteView, Magewire\AgreementView, cart-page "Request a quote"; HyvaHandlesTest covers b2b/quote.
  • Purchase orders as Magewire components (Magewire\PurchaseOrders, Magewire\PurchaseOrderView), the cart page form (hyva_checkout_cart_index.xml) and the PO number column in Company Orders; HyvaHandlesTest now also covers quick order, requisition lists and purchase orders. Declares qoliber/b2b-frontend (the cart form uses its view model).
  • Quick Order as a Magewire component (Magewire\QuickOrder, hyva_b2b_quickorder_index.xml): paste, debounced catalog search with pick, draft edits and add-to-cart without page loads; the CSV/XLSX upload posts to the Luma controller and returns to the page.
  • Private-price mode for the Hyvä theme (hyva_default.xml replaces the Luma script): one fetch to b2b/price/batch per page, listing prices rewritten in place (Alpine price boxes may be deferred) and update-prices-<productId> window events with finalPrice/oldPrice/tierPrices for the product-page price component.
  • Standard Magewire bundle selected over the CSP-friendly default (Hyvä 1.5 templates and argument expressions need the standard Alpine evaluator); documented in the README.
  • Layouts for every B2B screen (hyva_b2b_{company,team,approval,finance,orderintake}_index.xml next to the existing roles and orders), plus Tailwind templates for the registration form and the invitation landing page — the module is now mounted on a Hyvä store instead of only shipping components.
  • Registered with the Hyvä theme build through Qoliber_HyvaModuleRegistration (etc/frontend/di.xml), so hyva:config:generate adds the module's templates to the Tailwind purge sources.
  • #[Locked] (Magewire 3) on every server-derived public property (hasCompany, isAdmin, members, roles, invitations, budget/credit figures, …); only the wire:model inputs stay writable from the browser.
  • Playwright project hyva (base URL of the Hyvä store view) with guards, dashboard, roles and team invitation flows; integration test asserting each Hyvä handle swaps the Luma block.
  • Dashboard component: legal name and tax id shown; admins edit name / legal name / tax id in place (saveProfile).
  • Team component: pending invitations with resend / revoke, role select for invitations.
  • Magewire\CompanyRoles + hyva_b2b_roles_index.xml — the Roles & Permissions screen for the Hyvä theme.
  • Magewire\CompanyOrders + hyva_b2b_orders_index.xml — the first screen wired for the Hyvä theme (Luma block swapped for the Magewire component in the same container).

Changed

  • Magewire 3.2+ ships the Hyvä compatibility module separately: the module now requires magewirephp/magewire ^3.7 and magewirephp/magewire-hyva-theme ^3.1 and loads after Magewirephp_MagewireHyvaTheme (was Magewirephp_MagewireCompatibilityWithHyva).
  • Company finance shows the budget and the credit account each in its own currency (budgetCurrency, creditCurrency); currency is the company currency.

Fixed

  • The README's install command enabled Magewirephp_MagewireCompatibilityWithHyva (Magewire up to 3.1); it enables Magewirephp_MagewireHyvaTheme, the module this one depends on (a unit test keeps the command and etc/module.xml together).
  • Tables in 22 screens (order guide, standing orders, invoices, returns, stock bins, amendments, addresses, …) pad their cells; Tailwind's reset had left the columns running into each other.
  • The invoice list exposes previous and next pages while keeping its filter, so companies can view and pay invoices beyond the first 50.
  • The Hyvä approval inbox decides through ApprovalDecisionInterface (purchase orders are approved, not only their request).

Security

  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Integration health and alerts

Added

  • Health monitors of the webhook layer, evaluated every minute by cron job qoliber_b2b_integration_health in its own cron group qoliber_b2b_health (separate process): outbox_lag (oldest unrelayed outbox event, SLA 15 minutes), dead_letters (per endpoint within the window), failure_streak (failures since the newest success), error_rate (failing share of the attempted deliveries in the window, minimum sample), delivery_backlog (queued deliveries) and the opt-in no_success (silence after earlier deliveries). One index-friendly snapshot per run (Model\Monitor\HealthSnapshotProvider); extensions add monitors through Api\HealthMonitorInterface and the di.xml array monitors.
  • Alerts (qoliber_b2b_integration_alert): one per incident and dedupe key (<monitor>:<endpoint>, unique active_key while open or acknowledged), opened → refreshed (reminder after the quiet period unless acknowledged, escalation when the severity rises, first notice retried when no channel took it) → resolved once the condition has been gone for the auto-resolve window; a monitor that fails keeps its alerts. Api\HealthCheckInterface (evaluate, run), Api\AlertManagementInterface (acknowledge, resolve), Api\AlertRepositoryInterface (getById, getActive).
  • Alert routing through Api\AlertChannelInterface: e-mail (Magento transport, admin template qoliber_b2b_integration_health_email_template, one message per recipient) and webhook (B2bIntegration transport; generic signed JSON, Slack, Teams Adaptive Card, PagerDuty Events API v2 with one incident per alert). Encrypted URL, secret and routing key, marked sensitive; per-channel minimum severity; per-code settings for more webhook destinations.
  • Replay: Api\DeliveryReplayInterface::replayAggregate() (every finished delivery of an aggregate's events) and replayEndpoint() (one endpoint's finished deliveries within a time range, by state, limited), through WebhookDeliveryInterface::redeliver().
  • Domain events qoliber_b2b_integration_alert_open_after, qoliber_b2b_integration_alert_acknowledge_after, qoliber_b2b_integration_alert_resolve_after and qoliber_b2b_integration_delivery_replay_after (Plugin/Events, sortOrder 3000).
  • Configuration in Stores → Configuration → Qoliber → B2B Integrations and Data → Health and Alerts (qoliber_b2b/integration_health/*), retention policy integration_alert (resolved alerts, 90 days), Setup\Uninstall.

Invoice portal

Added

  • REST for the buyer (in Qoliber_B2bApi AccountRestInterface).
  • Deposits before the order: DepositManagementInterface (request a percent or amount of an approved quote or purchase order, cancel, getForSource, isSettled) — a deposit payment request without lines (source_type, source_id columns), paid in the portal and held on account as a B2bPaymentTerms prepayment; DepositPlaceOrderGuard refuses placing the order while it is unpaid; ApplyDepositToInvoice applies it to the order's receivable. Sources in Model\Deposit\DepositSourcePool; events qoliber_b2b_invoice_portal_deposit_request_after / …_cancel_after.
  • Card payments through Stripe Checkout (stripe adapter, off by default): a Checkout Session per attempt (smallest currency unit, the attempt's idempotency key as Stripe's), the buyer pays on Stripe's page; POST /b2bpay/stripe/webhook verifies the Stripe-Signature (HMAC-SHA256, five-minute tolerance) and confirms (checkout.session.completed paid, async_payment_succeeded) or fails (expired, async_payment_failed) the attempt; unknown sessions are acknowledged. Secret key (per website) and webhook signing secret are stored encrypted.
  • PaymentSettlementInterface::getRequest(requestId): any payment request with its lines and last attempt, for the merchant's screen (trusted callers).
  • Company accounts-receivable portal.
  • Api\InvoicePortalInterface (getSummary, getInvoices open / settled / all with paging, getInvoice): the company's B2bPaymentTerms receivables with the figures of the day (days overdue, early-payment discount available, payable today) and the order's Magento invoices, credit memos and B2bEInvoice e-invoices (Model\Document\DocumentCollector); summary with aging, terms, hold and the B2bCredit credit line.
  • Api\StatementServiceInterface (getStatement, getStatementPdf): opening balance, invoices, payments, discounts, credits and write-offs with the running balance, closing balance, aging at the end of the period and money held on account (Model\Statement\StatementCalculator, StatementPeriod, Model\Ledger\CompanyLedgerReader); A4 PDF (Model\Pdf\StatementPdf, StatementCanvas).
  • Api\DocumentServiceInterface: Magento invoice and credit memo PDFs of the company's orders and the archived e-invoice document.
  • Payment requests qoliber_b2b_invoice_portal_request / …_request_line and attempts …_attempt: Api\PaymentRequestManagementInterface (getMethods, create, pay, cancel, getById, getList, getRemittanceAdvice) with Model\Request\RequestPlanner (amounts from the receivables, early-payment discount, one partial line last), idempotency keys (Model\Idempotency\IdempotencyKey), re-validation before paying, one B2bPaymentTerms payment per request under its AR-… reference (Model\Request\PaymentSettler, Model\Request\AttemptRecorder) and remittance advice (Model\Remittance\RemittanceBuilder).
  • Api\PaymentSettlementInterface (confirm, fail, getAttempt, findAttemptByTransactionId) for pending attempts.
  • Payment seam Api\PaymentAdapterInterface, Model\Adapter\PaymentAdapterPool, built-in test and bank_transfer adapters (both off by default); acceptance (Model\Method\MethodAcceptance) and surcharge (Model\Surcharge\SurchargeCalculator) rules per method; per-company rules qoliber_b2b_invoice_portal_company_method through Api\CompanyMethodManagementInterface.
  • Staged billing: Api\BillingScheduleManagementInterface (schedule, getSchedule, cancelSchedule) — milestone payment requests of an open receivable (Model\Staged\StageSplitter).
  • Access by the acting member with the permissions invoice.view / invoice.pay (Model\Access\InvoicePortalAccess).
  • Ten domain events (Plugin/Events, sortOrder 3000), configuration qoliber_b2b/invoice_portal/ (config.xml, system.xml), Setup\Uninstall.

Fixed

  • A confirmation, a failure and a late "pending" answer for one payment attempt, arriving from different requests, no longer overwrite each other — each is decided on the attempt as committed with the request locked (a failure after a confirmation left the invoices paid and the request payable again); confirming a failed attempt books no money; cancelling a request, a deposit or a schedule's stages no longer cancels one whose payment started meanwhile.
  • The Stripe secret key and webhook secret saved in the admin were decrypted twice and never worked.

Login to browse

Added

  • Initial release: optional "Require Login to Browse Catalog" gate (Stores → Configuration → Qoliber → B2B Catalog and Prices → Catalog Guard (Login to Browse)) that redirects guests off catalog/category/product/search pages to the login.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • The default gated prefixes are catalog,b2b_document: with "require login" on, product document downloads need a login too. Stores that configured their own prefixes keep them.

Fixed

  • The catalog guard's page-cache context never reached the cache key: its plugin intercepted dispatch(), which storefront actions do not have, so after Require Login to Browse Catalog was switched on guests could still get catalog pages cached before. It now runs before each action's execute().

Luma storefront

Added

  • Initial release: Luma "My Company" account dashboard (route, account-guarded controller, CompanyDashboard view model, template, _module.less).
  • My Company → Company Addresses (b2b/addresses): the company's billing and delivery addresses, who may use them and the "company addresses only" limit; the address book shows company addresses apart from own ones and offers a limited member no new address.
  • Roles page: per role, what it sees of the catalog — the whole company catalog, without some categories, or only some (set_catalog, Model\RoleCatalogCategories: the company's catalog two levels deep).
  • Invitations: an invitee without an account creates it on the invitation page (b2b/invitation/join — name and password, e-mail fixed to the invited address) and is a logged-in member at once; the page no longer sends them to Magento's sign-up. With customer sign-up closed, customer/account/create leads to the company application while company self-registration is on (Plugin\CustomerSignupClosedPlugin).
  • The storefront currency of a member follows the currency the company buys in on the current website.
  • My Company → Messaging: link a phone or Teams account with a one-time code, the linked addresses, unlinking.
  • My Company → Stock Bins: bins and par levels, the count page for phones and scanners, the replenishment proposal, printable labels.
  • The quote page lists a kit's components under the kit line.
  • Members of a company that buys in a currency of its own shop in it: the store's current currency and the switcher follow the company (Luma, Hyvä).
  • Agent mandates: mandate keys (add, revoke), "purchase orders need a mandate" and the mandates agents presented on My Company → AI Agents.
  • Single sign-on: "Sign in with your company account" on the login page, the OIDC callback, the SAML assertion consumer and SP metadata.
  • Find by Part Numbers: paste SKUs, own part numbers or cross-references, see what each stands for and whether it can be ordered, add the orderable ones to the cart.
  • My Company → Messages: conversations with the merchant — threads with unread counts, new messages (general, or about an order: "Message us" on company orders), a thread with files and replies.
  • My Company → Company Spend: the company's spend grouped by period, member, cost center, category or product with filters, drill-downs to products, CSV export and e-mailed reports, for members holding "View reports".
  • My Company → Order Amendments: an "Amend" link on company orders that can be amended, the amendment form (new quantities, added lines, PO number, comment), the member's amendments and one amendment with withdraw.
  • Company returns no longer need Mage-OS: the returns pages use B2bReturns' own types (any backend), and a member withdraws a return (b2b/returns/cancel).
  • My Company → Returns: the company returns a member may see, a request in two steps (the order, then quantities, conditions, reason, resolution and a comment), the page of a return with its lines and comments and a reply (b2b/returns, /create, /submit, /view, /comment; navigation "Company Returns").
  • My Company → "Your companies" for a customer of several companies, with a switch per available company (b2b/company/select, POST): the session's customer group and the page-cache company follow at once and every private-content section reloads (etc/frontend/sections.xml).
  • My Company → AI Agents: a member's agent tokens (an administrator's: the company's), connect an agent (token shown once with the MCP address) and revoke.
  • Delivery per line: the cart page sends each line to one of the company's locations (the rest goes to the shipping address); the buyer's order page lists the lines by destination.
  • The cart page lists the branches that can serve a company member's cart (everything ready on a date, or what is missing) when in-store pickup is on and their company may pick up.
  • The buyer's order page says when an order is being reviewed before it ships, or why it was cancelled after review.
  • My Company → Saved Carts: the member's carts, those handed to them and those shared in the company; a cart's page with its lines, "Add to cart" (or replace the cart; lines that cannot be added are named), and for the owner share, rename, hand off to a colleague and delete. "Save cart" on the cart page (a name, a note, optionally emptying the cart).
  • My Company → Assistance: the consent given to the sales rep (scopes, until when, the note) and stopping it; granting one (scopes, duration, a note; company managers for any member); orders the rep asked the member to confirm (confirm or decline); the activity the member may see. A "You are being assisted by …" banner on every page from the private section qoliber-assisted-session.
  • My Company → Invoices lists the payments the supplier asked (billing stages, deposits) to pay; a deposit's page names it and offers no cancel; the quote page tells the buyer about its deposit with a link to pay it.
  • My Company → Invoices (b2b/invoices, ViewModel\InvoicePortal, B2bInvoicePortal): the account summary (outstanding, overdue, credit available, terms, hold) and the company's invoices (open, settled, all) with the amount payable today, the early-payment discount and their documents (Magento invoice and credit memo PDFs, e-invoices); payers choose invoices and pay them through a payment request (methods with their fees, instructions of a pending transfer, the provider's page for a redirect, cancel); the statement of a period with its PDF. Readers need invoice.view, payers invoice.pay. Idempotency keys come with each form, so a double submit never pays twice.
  • My Company → Order Guide (b2b/orderguide, ViewModel\OrderGuide): the company's guide with reorder status (due, overdue, lapsed), hidden lines on request, pin / hide / note per line and chosen lines to the cart at the given quantities; My Company → Standing Orders (b2b/standingorder, ViewModel\StandingOrders): the list with schedule, target, status, next and last run, a form that creates or changes one (items as "SKU quantity" lines) with the run history, pause / resume / delete. Account navigation links.
  • My Company → Price Feeds (b2b/feed, Controller\Feed\Index|Issue|Revoke, ViewModel\FeedTokens): the company's feed tokens with member, state, expiry and use; issuing shows the plain token and the feed addresses once on an uncached result page (never flashed); revoking per token; account navigation link. Company administrators and catalog_feed.manage only.
  • My Company → Price Check (b2b/pricecheck, ViewModel\PriceCheck): a member pastes SKUs or the company's part numbers with quantities and gets, per line, the company price, the row total and availability from PriceAvailabilityMatrixInterface::getForCustomer(); unknown and hidden products are answered alike; guests go to the login page, customers outside a company back to their account; account navigation link. Depends on Qoliber_B2bCatalogFeed (composer).
  • Product documents (B2bDocumentVault): a "Documents" section on the product page (catalog_product_view.xml, ViewModel\ProductDocuments) listing what the viewer may download — public documents for everyone, the company's own for its members, in the store language where translated (the page cache varies by company) — and b2b/document/download/id/<id>: a stored file as an attachment, a link document as a redirect, anything unknown or not visible the store's 404; the customer comes from the session only.
  • My Company → Approvals: each pending request shows its stage, parallel steps (approvals, escalation) and votes; decisions take a note; "on behalf of" when a delegation lets the member decide; an out-of-office section (set for a colleague until a date, list, revoke) — Controller\Approval\Delegate, RevokeDelegation.
  • My Company → Quotes (b2b/quote): list with agreements, quote page (lines, offers, counter-offers, comments, files, history, PDF, submit, order, release, request agreement), agreement page with call-off, "Request a quote" on the cart page; Model\Quote\QuoteStorefront shared with Hyvä; account navigation link.
  • My Company → Purchase Orders (b2b/purchaseorder): list (all company orders with order.view_all), detail (b2b/purchaseorder/view/id/N) with the PO number, draft line edits, submit for approval, cancel with a reason, "Order now" for an approved order (fills the cart at the approved prices) and a link to the sales order once ordered; all actions post to b2b/purchaseorder/act. The cart page offers "Save as purchase order" (with an optional PO number) to members with order.place, and tells a buyer when the cart fulfils an approved purchase order. Company Orders shows the PO number.
  • My Company → Quick Order (b2b/quickorder): paste "SKU quantity" lines, upload a CSV/XLSX (size and line caps from Stores → Configuration → Qoliber → B2B Ordering and Checkout → Quick Order), SKU/name autocomplete (b2b/quickorder/search, JSON, members only), a persisted draft validated per line (catalog access, stock, minimum/maximum, increments) with quantity edit and remove, "Add to cart" (skipped lines reported) and "Clear draft"; account navigation link.
  • POST b2b/price/batch (Controller\Price\Batch): the visitor's individual prices for a list of product ids, formatted in the display currency, Cache-Control: no-store, session-authenticated, 404 while private prices are off; js/private-prices (loaded on every page by ifconfig) collects the Luma price boxes, calls it once and rewrites the final/regular prices in place (contentUpdated re-runs it for ajax listings).
  • My Company: company admins edit the display name, legal name and tax id in place (b2b/company/save, through CompanyProfileManagementInterface).
  • Invitations on "My Company → Team": pending invitations with resend / revoke, roles picked from a select instead of a typed id; the invite e-mail's link lands on b2b/invitation?token=…, which explains the invitation, sends visitors to log in / create an account with the invited address and returns them, and lets the matching customer accept (POST b2b/invitation/accept).
  • "Register your company" (b2b/registration): visitors create their account and a pending company in one step (Magento password policy, forced e-mail confirmation when configured); logged-in customers without a company register one for their account. Duplicates and existing memberships are refused with the reason and the form is re-filled. Linked from the login and create-account pages; My Company shows "awaiting review" / "declined" for a pending or rejected registration and offers the registration link otherwise.
  • "My Company → Roles & Permissions" (b2b/roles): company admins create, rename and delete custom roles and edit the permission matrix; system roles are read-only by name, the Administrator role always holds everything.
  • "My Company → Company Orders" (b2b/orders): own orders, or every order of the company under order.view_all; own orders link to the order view; paged.

Fixed

  • AI Order Intake (Luma): after Parse order the page only reported how many lines matched and went no further. It now lists the lines read from the order (as written, SKU, quantity, review state) and Convert to quote turns the matched lines into a draft quote and opens it, as on Hyvä.
  • Stock bin count (Luma): without a barcode detector the page showed a Scan with the camera button that did nothing and an empty video box, because Luma's button and video styles override the hidden attribute.
  • Stock bins and company addresses (Luma): the text fields declare their type, so Luma draws them as full-width form fields instead of narrow unstyled boxes; a unit guard checks every input in the module's templates.
  • Assistance (Luma): the "Your consent" title no longer runs its border through the line naming the sales rep.
  • Quick Order (Luma): the product search opens its results as a list over the page, one product per row; they were unstyled bullets that pushed the form down.
  • The invoice list exposes previous and next pages while keeping its filter, so companies can view and pay invoices beyond the first 50.
  • The approval inbox decides through ApprovalDecisionInterface, so approving a purchase order approves it (status, budget, credit) instead of only recording the decision.
  • The invitation page shows the "expired" state for invitations swept to expired (was "revoked").

Security

  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Mailbox order intake

Added

  • The mailbox asks routers (Api\InboundMailRouterInterface, the routers argument of Model\MailIntake) before the order intake: a claimed mail is recorded as routed; parsed mail carries its recipients, In-Reply-To and References (Api\Data\InboundMessageInterface); setting order_intake (default on) lets the mailbox take only what routers claim.
  • Review screens, REST and the reply e-mails — see the README.
  • Mailbox-to-order intake: e-mailed orders from a drop folder or a signed inbound webhook, read by a MIME parser, matched to the sender's company, run through the order intake, reviewed (assign, correct or remove lines, convert to a draft quote, reject); auto-convert of fully matched orders; events, webhook topics, audit, retention.

Fixed

  • The inbound webhook secret saved in the admin was decrypted twice, so signed e-mails were refused.

Notifications

Added

  • Initial release: interceptor plugins that email company admins on approval-requested, approval-decided and budget-exceeded workflow events, gated by a store-config flag.
  • Conversation e-mails: a member's message to the admin users allowed Qoliber_B2bAdminUi::conversations, the merchant's to the thread's members, each with the recipient's reply address as Reply-To; RecipientResolverInterface::adminUsersAllowed().
  • NotificationDispatcherInterface::dispatch() takes an optional Reply-To, carried through the queue.
  • Order amendment e-mails: to the merchant once an amendment waits for them (asked for, or approved by the company), to the member when it was applied or rejected.
  • Company return e-mails, for returns the suite keeps itself: the merchant (admins allowed Qoliber_B2bAdminUi::returns_decide) on a new return, the customer on each decision with the merchant's message (templates return_requested, return_status_changed).
  • The sender of an e-mailed order hears when it became a draft quote or why it was rejected (intakeMailDecided, templates intake_mail_converted, intake_mail_rejected).
  • E-mails for the order-review gate: an order waits for review (to the admins who may decide, with why and the queue's link) and an order rejected after review (to the buyer, with the reason).
  • E-mail to the colleague a saved cart is handed to (whose cart, its name, the note, the link); template saved_cart_handed_off.
  • Net terms e-mails: invoice on account issued, overdue reminder per dunning level, credit hold placed and released, payment receipt; replays send nothing.
  • Seller-assisted buying e-mails from its domain events: consent granted or revoked (to the rep), session started and order to confirm (to the buyer), the buyer's answer (to the rep); five admin-selectable templates.
  • Quote desk e-mails from its domain events: the seller assigned, the seller when the first response is at risk or overdue, the admins allowed to decide price exceptions when a price is held (never the requester), the requester when decided. RecipientResolverInterface::adminUser() and adminsAllowed() (role rules, any area).
  • Order guide notifications (Plugin\OrderGuideNotificationPlugin on ReorderDueDetectorInterface::detect and StandingOrderRunnerInterface::run): NotifierInterface::reorderDue() — the lines due again to the members who last ordered them (still active), the company administrators when none is, with a link to the order guide; NotifierInterface::standingOrderRun() — each recorded run to its owner, plus the company administrators when it failed or was skipped, with a link to the standing order. Templates order_guide_reorder_due and order_guide_standing_order_run, selectable under Stores → Configuration → Qoliber → B2B Notifications → Notification Templates.
  • "Approval Escalated" e-mail (approval.escalated, template approval_escalated.html, admin template field) to the escalation role or the company admins.
  • Quote agreement e-mail (quote_template_status): the merchant hears about a request, the buyer and company admins about activation, decline and closing, with a link to the agreement page.
  • Quote e-mails (status, proposal, expiry reminder) carry quote_url and a "View the quote" button to the storefront quote page in the company's store; integration test for the expiry reminder cron (sent once, only when due).
  • NotifierInterface events: registration received (merchant + founder receipt), registration approved / rejected (with the reason), invitation accepted (inviter), quote status changes (submitted → merchant and sales rep; decisions and expiry → buyer and admins), quote price proposals (the other party), quote expiry reminders (daily cron qoliber_b2b_quote_expiry_reminder, qoliber_b2b/notifications/quote_expiry_reminder_days), purchase order status (approvers holding order.approve while pending, requester and admins on decisions), approval cancelled, budget exceeded.
  • RecipientResolverInterface: company admins, members with a role, members with a permission, customer / company-user, the company's sales representative (admin user e-mail) and the configured merchant recipients (qoliber_b2b/notifications/merchant_recipients, fallback general contact).
  • The invitation e-mail travels through the same queue (AsyncInvitationMailer replaces the in-request mailer by preference) and is sent regardless of the notifications switch.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • Approval e-mails go to the approvers of every pending step of the current stage; an approval inside a stage that still waits for its parallel steps sends nothing, the next stage is told when the stage completes.
  • PurchaseOrderNotificationPlugin forwards the new optional approver note of approve()/reject().
  • Delivery is asynchronous by default: notifications are published to the qoliber.b2b.notification.send queue (NotificationDispatcherInterface) and sent by its consumer; with the MySQL queue the message shares the DB transaction of the action that produced it, so a rolled-back transition sends nothing. qoliber_b2b/notifications/async = 0 restores in-request sending. Notifications are on by default now.
  • One e-mail per recipient (addresses are no longer exposed to each other), rendered for the company website's default store, with an admin-selectable template per notification (Stores → Configuration → Qoliber → B2B Notifications → Notification Templates).
  • Approval requested goes to the members holding the current step's approver role (admins only when nobody does) and is not sent for auto-approved requests; a step advance notifies the next step's approvers; cancellations inform the requester.
  • NotificationSender is gone; use NotifierInterface.
  • Budget-exceeded, purchase-order and approval e-mails print the currency next to the amount.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).
  • The approval-decided email also goes to the requester (by requested_by), not only to company admins.

Order amendments

Added

  • Surfaces: storefront Luma and Hyvä, admin B2B Suite → Orders → Order Amendments, member and merchant REST, GraphQL, e-mails, e2e on Mage-OS and Magento Open Source. Orders are read through a collection so a member with every company order can amend a colleague's order over REST.
  • Order amendments before fulfilment (domain): members holding order.amend change placed company orders (quantities, removed and added lines, PO number); increases go to the company's approval rules (order_amendment), the merchant applies or rejects; applying replaces the order with <number>-<n> the way Magento's order edit does and cancels the original; events, webhook topics, audit.

Fixed

  • A rejection or a withdrawal that had read the amendment as pending overwrote an approval completed meanwhile (the replacement order stayed active next to a rejected or withdrawn amendment) — every decision moves the amendment only while it is undecided.
  • Approved in a context that sells in another currency, a replacement turned an order's €5 line into $5 — the replacement (and the request's estimate) is built and placed in the order's currency; kept lines keep their price in it, base amounts follow today's rate; a currency no longer sold or without a rate refuses the amendment.
  • When the original refused to be cancelled after its replacement was placed, both orders stayed active — applying is all or nothing now (a savepoint inside the caller's transaction), and the amendment is locked while it is applied so a second approval cannot place a second replacement.
  • A replacement order is placed as the membership the original was placed for (company, membership and cost center kept, whichever company the buyer acts for by then); an order that can no longer be cancelled is not replaced.

Order guides and standing orders

Added

  • CompanyReorderReaderInterface: a company's due lines for the merchant's side.
  • Several companies per customer: a standing order runs for its owner's membership (pinned), whatever company the owner chose.
  • Surfaces in their modules: storefront (Luma + Hyvä), REST, GraphQL, admin grids and notifications; README lists them.
  • Order guide per company (qoliber_b2b_order_guide_line) built from the top-level items of the company's orders within history_months (cancelled orders left out): times ordered, total, last and typical (median) quantity, last price and currency, first/last order, last buyer, cadence (median days between buying days, from min_orders buying days) and next-due date; Api\OrderGuideBuilderInterface::rebuild(companyId, skus) keeps member annotations and drops lines without history unless annotated.
  • Incremental refresh: Observer\MarkGuideStale queues the SKUs of placed and cancelled company orders (qoliber_b2b_order_guide_stale); crons qoliber_b2b_order_guide_refresh (every 5 minutes) and qoliber_b2b_order_guide_rebuild (02:30).
  • Api\OrderGuideManagementInterface: guide of the actor's company (pinned first, then most often ordered), due lines, pin/hide/note (requisition.manage), current company prices through the customer price service, guide lines to the cart through the quick-order cart path.
  • Reorder-due detection Api\ReorderDueDetectorInterface::detect() (cron qoliber_b2b_order_guide_reorder_due, 06:00): overdue (margin due_margin_percent), not lapsed (lapse_cycles), visible lines alert once per due cycle, one alert per company.
  • Standing orders (qoliber_b2b_standing_order, _item, _run): Api\StandingOrderManagementInterface (owner with order.place, company admins; weekly every 1-52 weeks or monthly every 1-12 months, month ends clamped and anchored; pause, resume without making up missed runs) and Api\StandingOrderRunnerInterface::run() (slot claimed once; owner's cart or a draft purchase order at company prices, optionally submitted for approval; never places or pays; owner gone or without order.place pauses it); cron qoliber_b2b_standing_orders (hourly at :10).
  • Configuration qoliber_b2b/order_guide/* (default scope).
  • Domain events qoliber_b2b_order_guide_rebuild_after, qoliber_b2b_order_guide_line_annotate_after, qoliber_b2b_order_guide_add_to_cart_after, qoliber_b2b_order_guide_reorder_due_after, qoliber_b2b_standing_order_save_after, qoliber_b2b_standing_order_pause_after, qoliber_b2b_standing_order_resume_after, qoliber_b2b_standing_order_delete_after, qoliber_b2b_standing_order_run_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): the five tables, the configuration and the four cron codes.

Order review

Added

  • The order-review gate: company orders placed by buyers are held when a rule of the company's policy matches — every order, from an amount, a company's first orders, or a rule another module registers — and released on approval or cancelled on rejection (with the reason in the order's history). Store defaults per website, company overrides (exempt, every order, threshold, first orders). Off by default.
  • Api\OrderReviewManagementInterface, Api\ReviewPolicyManagementInterface, Api\OrderReviewRuleInterface; domain events for open, approve, reject and the company policy.

Part numbers

Added

  • Part number type gtin: only valid GTIN-8/12/13/14 (check digit) are saved; a scanned GTIN that matches nothing as typed finds the same GTIN stored at another length (UPC-A ↔ EAN-13 ↔ GTIN-14); getGtins() per SKU for labels.
  • Api\PartNumberImportInterface::import(csv): header-mapped CSV (sku, part_number, optional company_id, type, label), upsert per scope and normalised part number through the repository (validation, events, audit), refused rows reported as Line <n>: <reason>; Api\Data\PartNumberImportResultInterface (created / updated / errors).
  • Part numbers qoliber_b2b_part_number (company or global, SKU, part number as entered, normalised key, type customer/competitor/manufacturer/alias, label) with Api\PartNumberRepositoryInterface (validated; a key is unique within its scope and maps to one SKU).
  • Api\PartNumberResolverInterface: company part number → catalog SKU → global cross-reference (resolve, resolveMany in two queries) and the reverse lookup getCompanyPartNumbers for documents.
  • Resolution plugins: quick-order validation (lines typed as a part number are validated as the mapped SKU and say "Matched part number X."), quick-order product search (an exact part-number hit first), order-intake SKU matcher (global cross-references).
  • Domain events qoliber_b2b_part_number_save_after, qoliber_b2b_part_number_delete_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops the table.

Payment and shipping method rules

Added

  • Initial release: enforces B2bPolicy payment.use / shipping.use capabilities at checkout via a plugin on Magento\Checkout\Api\PaymentInformationManagementInterface::savePaymentInformationAndPlaceOrder.

Changed

  • Enforcement moved to the domain choke points: before CartManagementInterface::placeOrder (every channel), a payment_method_is_active observer (denied methods are not listed and cannot be imported on the quote) and an after Shipping::collectRates filter (denied carrier methods are not offered). The PaymentInformationManagementInterface plugin is removed.
  • CartMethodPolicyInterface resolves the company from the cart's customer, so GraphQL, REST, admin and cron see the same restrictions as the storefront.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Payment/shipping restrictions were bypassable via GraphQL placeOrder, REST carts/mine/order, two-step payment-information and any token-authenticated call (the check was bound to the customer session and to one checkout method).
  • Uses the context's customer id (was the misnamed company-user id) and inherits the area-agnostic actor resolution.

Payment terms

Added

  • Credit provider failover: a company's provider is followed by an ordered list of fallback providers (CompanyTermsManagementInterface::setFallbackProviders, column fallback_providers). A provider that is down (says provider_unavailable, throws, or is no longer registered) hands the check and the authorization to the next; a credit decline is final. The receivable and every release go to the provider that took the order (Model\Provider\ProviderFailover).
  • REST for the merchant and the buyer's on-account decision (in Qoliber_B2bApi).
  • Events receivable_issue_after and payment_record_after carry replayed (true for an order invoiced or a reference recorded before), so receivers that must act once can skip repeats.
  • Admin screens (in Qoliber_B2bAdminUi): terms catalog, a company's terms page, receivables grid and receivable page.
  • ReceivablePaymentManagementInterface::recordPrepayment(): money received before its invoice exists, held on account and allocated to nothing until applyUnallocated() names the receivable (idempotent per reference, same payment-recorded event).
  • Company-wide ledger reads for statements and the AR portal: ReceivableRepositoryInterface::getByCompany() (every status, by issue day, optional day range), ::getEntriesByCompany() (oldest first) and ReceivablePaymentManagementInterface::getByCompany() (by receipt day, optional day range).
  • "Pay on account" is registered as a credit-drawing method (Qoliber\B2bCredit\Model\CreditPaymentMethods), so a purchase order ordered on account keeps its approval-time credit reservation as the order's charge.
  • ReceivablePaymentManagementInterface::findByReference(companyId, reference): the payment recorded under a remittance reference (trimmed, per company), so an import or the audit trail can tell a replay from a new remittance.
  • Terms catalog qoliber_b2b_payment_terms with Api\PaymentTermsRepositoryInterface (save, getById, getByCode, getList, deleteById); types net, end_of_month, day_of_next_month (Model\Enum\TermsType::dueDate), optional early-payment discount; validation Model\Terms\TermsRules; data patch InstallPaymentTerms seeds NET15, NET30, NET45, NET60, EOM, NET30_EOM, PROX10, 2_10_NET30.
  • Company terms and credit hold qoliber_b2b_payment_terms_company: Api\CompanyTermsManagementInterface (assign, unassign, findByCompanyId), Api\CreditHoldManagementInterface (place, release, evaluate; Model\Hold\HoldDecider).
  • Payment method qoliber_b2b_on_account ("Pay on account"): Magento\Payment\Model\Method\Adapter virtual type Gateway\OnAccountFacade, payment action authorize (Gateway\Command\AuthorizeCommand → Model\Payment\OnAccountAuthorizer), payment.xml (no multishipping), availability through payment_method_is_active (Observer\RestrictOnAccountMethod, Api\OnAccountAvailabilityInterface, reasons Model\Enum\OnAccountDecline).
  • Luma checkout renderer (view/frontend: layout, JS renderer, template) with the terms, due date and discount hints from Model\Checkout\OnAccountConfigProvider / Model\Checkout\TermsPreview.
  • Credit provider seam Api\CreditProviderInterface, Model\Provider\CreditProviderPool, built-in Model\Provider\OwnBookCreditProvider (Qoliber_B2bCredit credit line).
  • Receivables qoliber_b2b_payment_terms_receivable and ledger qoliber_b2b_payment_terms_receivable_entry: Api\ReceivableManagementInterface (issueForOrder, credit, writeOff), Api\ReceivableRepositoryInterface; Model\Receivable\ReceivableLedger (row lock, idempotent per reference, capped, credit released per entry); observers on quote submit success/failure, order cancel and credit memo save.
  • Payments qoliber_b2b_payment_terms_payment: Api\ReceivablePaymentManagementInterface (recordPayment, applyUnallocated, getById, getAllocations) with Model\Payment\PaymentAllocator (named or oldest due first, early-payment discount, unallocated rest).
  • Aging Api\AgingReportInterface (Model\Aging\AgingCalculator, Model\Enum\AgingBucket), dunning Api\DunningManagementInterface (Model\Dunning\DunningSchedule), cron qoliber_b2b_payment_terms_collections (hourly at :35).
  • Twelve domain events (Plugin/Events, sortOrder 3000), config (payment/qoliber_b2b_on_account/, qoliber_b2b/payment_terms/, system.xml), Setup\Uninstall.

Fixed

  • A credit, payment or write-off applied inside a transaction that had read the receivable earlier overwrote what others had applied since (and the same reference failed on the unique key) — the locked receivable and the reference are read as committed now.

Policy engine

Added

  • Retention policy policy_log (default 30 days) and a created_at index on the evaluation log.
  • Setup\Uninstall.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).

Price and availability feeds

Added

  • Several companies per customer: a member token is issued and verified for the customer's membership in the token's company, not their current company.
  • Company self-service for feed tokens: Api\CompanyFeedTokenServiceInterface (list, issue — for the company or one of its active members by e-mail — and revoke, always for the customer's own company) for company administrators and members whose role holds catalog_feed.manage; another company's token does not exist for the caller.
  • b2b.price_tiers is filled: the company's quantity breaks from its price lists (min_qty, price, ascending, above one); a contract price has none.
  • Feed access tokens per company, optionally acting for one of its members (qoliber_b2b_catalog_feed_token): qcf_<selector>_<secret>, 256-bit secret stored only as its SHA-256 and compared in constant time, mandatory expiry (default 90 days, at most token_max_lifetime_days), revocation with a reason, a cap on active tokens per company, usage counters; Api\FeedTokenManagementInterface (issue, revoke, getById, getByCompany) and Api\FeedTokenVerifierInterface (one generic refusal whatever the reason; a member token dies with the membership).
  • Per-company feed build (Api\FeedBuilderInterface, qoliber_b2b_catalog_feed_build): the company store's catalog walked in keyset chunks, hidden products dropped through the catalog access index, prices from the suite's customer price service in the company currency (catalog price of the company's customer group otherwise), availability of the smallest orderable quantity, customer part numbers, base unit and pack sizes with UN/CEFACT codes, order quantity rules and the vault documents the company may see; OpenAI product-feed shaped JSON and a CSV usable as a PunchOut Level-2 index, written in one pass as gzip files under var/import_export/qoliber_b2b_catalog_feed/, swapped atomically; item cap with a truncation flag.
  • Storefront endpoint GET /b2bfeed/feed?format=json|csv authenticated only by the token (Bearer header or token parameter): 401 without a token, the same bare 404 for any unusable token, 400 for an unknown format, 429 with Retry-After over the hourly limit, 503 while no fresh build exists, 304 on If-None-Match, gzip when accepted, Cache-Control: private, no-store.
  • Refresh cron qoliber_b2b_catalog_feed_refresh (every 5 minutes, own cron group qoliber_b2b_feed, separate process): builds missing or stale feeds of companies with an active token, oldest first, and removes the feeds nobody can fetch any more.
  • Price and availability matrix Api\PriceAvailabilityMatrixInterface (paste SKUs or part numbers with quantities; live, batch reads; hidden products answered like unknown ones).
  • CLI qoliber:b2b:feed:token:issue (prints the plain token and feed URLs once), qoliber:b2b:feed:token:revoke, qoliber:b2b:feed:build (services injected as proxies).
  • Configuration qoliber_b2b/catalog_feed/* (default scope; Stores → Configuration → Qoliber → B2B Catalog and Prices → Catalog Feed); stock quantities are off by default (show_stock_quantity 0) — the availability status, orderability and ship-by date are always shown.
  • Domain events qoliber_b2b_catalog_feed_token_issue_after, qoliber_b2b_catalog_feed_token_revoke_after, qoliber_b2b_catalog_feed_build_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): both tables, the configuration, the cron group settings and the refresh job.

PunchOut sessions

Added

  • Initial release: PunchOut / cXML procurement sessions with PunchoutManagement (start session, complete session, build cXML cart) and PunchoutSessionRepository.
  • expireStale() + hourly cron qoliber_b2b_punchout_expire: active PunchOut sessions older than qoliber_b2b/procurement/session_ttl_hours (default 24) become expired (event qoliber_b2b_punchout_session_expire_sweep_after).
  • Retention policy punchout_session (default 90 days; non-active only) and a (status, created_at) index.
  • Setup\Uninstall.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • buildCxmlCart() takes the currency of the prices (default: the default website's base currency) instead of emitting USD.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.

Purchase orders

Added

  • Initial release: purchase orders + items, PurchaseOrderStatus workflow enum (draft, pending_approval, approved, rejected, ordered, cancelled) with guarded transitions, PurchaseOrderManagement (create, add item with grand-total recalculation, submit for approval, approve, reject) and PurchaseOrderRepository.
  • Purchase orders saved without a currency stay in the website base currency.
  • Approval rule conditions see the purchase order's lines (PurchaseOrderApprovalContextProvider).
  • Model\PurchaseOrderStorefront: screen data shared by the Luma view model and the Hyvä components (list, detail, what the cart page offers).
  • Purchase order → sales order: PurchaseOrderCheckoutInterface (@api) createFromCart() (the buyer's cart becomes a draft; the cart is emptied) and toCart() (an approved order fills the buyer's cart at the approved prices, stamped with purchase_order_id and po_number); Plugin\PlaceOrderPurchaseOrderGuard refuses the placement unless the order is still approved and the cart matches it exactly (no coupon); Observer\MarkPurchaseOrderOrdered (sales_model_service_quote_submit_success) marks it ordered, links order_id / order_increment_id and moves the budget booking to the sales order.
  • cancel() (draft, pending: its approval request is cancelled, approved: budget released and credit paid back), markOrdered(), setReference() (the PO number, until the order is final), updateItemQty() / removeItem() on drafts, getItems(); events EVENT_CANCEL_AFTER, EVENT_ORDERED_AFTER.
  • PurchaseOrderAuthorizationInterface (@api): the owner sees and manages; order.view_all members and company admins see all company orders; admins manage them; creating needs order.place. PurchaseOrderSelfServiceInterface (@api) applies it for every surface. PurchaseOrderRepositoryInterface::getByCompany(). Model\PurchaseOrderPresenter builds display data.
  • Columns order_id, order_increment_id; getCreatedAt() / getUpdatedAt() on the data interface.
  • qoliber_b2b_purchase_order.currency (+ getCurrency()/setCurrency()); createPurchaseOrder() takes an optional currency defaulting to the company currency. Budget, credit and approval checks convert the order total into their own currency.
  • PurchaseOrderManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_purchase_order_create_after, qoliber_b2b_purchase_order_submit_after, qoliber_b2b_purchase_order_approve_after, qoliber_b2b_purchase_order_reject_after. See the README events table.
  • PurchaseOrderInterface extends ExtensibleDataInterface (getExtensionAttributes() / setExtensionAttributes()), the model extends AbstractExtensibleModel and carries an _eventPrefix for model-level events; third parties add fields through extension_attributes.xml as with the company.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • approve() and reject() take an optional $note, recorded on the approval step.
  • submitForApproval, approve and reject run in one DB transaction: the approval decision, the atomic budget consume, the credit charge and the status change stand or fall together (a refused budget/credit at approval time leaves the request pending).
  • Budget is consumed through the atomic, row-locked BudgetServiceInterface::consume() at approval time (auto-approve and manual approve), before the purchase order is marked approved, so a budget that moved since submission is re-checked and cannot be overspent by concurrent approvals.
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • A purchase-order cart in another currency than the order (the company moved to another currency after the approval) placed the approved amounts in that currency — the placement guard compares the currency.
  • The credit an approved purchase order reserved stayed charged when its order was paid by card, check or any method that does not draw on the credit line; markOrdered() now gives it back unless the order's payment method is registered in Qoliber\B2bCredit\Api\CreditPaymentMethodsInterface (e.g. "Pay on account", whose receivable then carries the charge).
  • Approving or rejecting a purchase-order request from the approval inbox, GraphQL or REST only recorded the decision: the order stayed pending_approval and budget and credit were never booked. Model\Approval\PurchaseOrderDecisionHandler now routes those decisions through approve()/reject(), which move the order and book spend in one transaction; a stale request (not the order's current one) is refused.
  • Foreign key company_id → qoliber_b2b_company (ON DELETE NO ACTION, i.e. restrict): a company with this kind of record cannot be deleted until the records are dealt with.

Quick order

Added

  • Initial release: stateless quick-order parser (QuickOrderParser) turning CSV / pasted "SKU QTY" text into a list of QuickOrderLine value objects with per-line validation.
  • Line rules: Api\QuickOrderLineRuleInterface (@api) runs after the product, catalog-access and stock checks; the first refusal marks the line invalid_qty with the rule's message. Built in: MinSaleQtyRule, MaxSaleQtyRule, QtyIncrementsRule (stock item minimum/maximum and case-pack increments). Integrators register their own on Model\QuickOrderValidator argument rules.
  • Upload and line caps (Stores → Configuration → Qoliber → B2B Ordering and Checkout → Quick Order): max_upload_kb (default 2048, checked before the spreadsheet is unpacked, with an inflation guard on the zip entries) and max_lines (default 500, a paste/CSV/XLSX above it is refused as a whole; parseCsv/parsePastedText now document the LocalizedException).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Lines added outside a storefront request (standing orders from cron, admin) go into a cart in the company's website — the current store is kept only when it belongs to that website, else the website's default store (CompanyCurrencyResolverInterface::storeFor); the default store of another website was used before.
  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.
  • db_schema_whitelist.json regenerated from db_schema.xml (renamed index/constraint referenceIds were missing).
  • Integration tests run in the frontend area (actor from the composite user context) and reset the product repository cache between tests (order-dependent failure).

Quote agreements

Added

  • CallOffLineInterface (API input item_id + qty).
  • Quote agreements: request from an approved/ordered quote, seller activation with per-line price, minimum and maximum per call-off and committed quantity, end date and call-off limit; decline, close, hourly expiry cron.
  • Call-offs as linked, already approved quotes at the agreed prices, validated under a row lock; release; remaining quantities; buyer-scoped reads on the quote permissions.
  • Domain events, Setup\Uninstall, configuration in Stores → Configuration → Qoliber → B2B Quotes → Quote Agreements.

Quote attachments

Added

  • Initial release: quote attachment metadata entity + repository, UploadedByType enum (buyer/seller), and QuoteAttachmentManagement (attach, list, count, remove).
  • Real file storage: upload() validates and stores the bytes through Qoliber_B2bDocument's DocumentStorageInterface, records the content-sniffed MIME type and a SHA-256 checksum (new column); get(), getContent(); remove() deletes the stored file; open-status rule and a per-quote limit (qoliber_b2b/documents/max_per_quote); domain events EVENT_UPLOAD_AFTER / EVENT_REMOVE_AFTER.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • attach() is deprecated (metadata only); use upload().
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key quote_id → qoliber_b2b_quote (CASCADE).

Quote checkout

Added

  • Initial release: approved-quote → Magento cart conversion with locked negotiated line prices; quote transitions to ordered.
  • A kit ordered from a quote shows its agreed price as the cart's unit price (Magento's Bundle module recomputed it from the components' catalog prices); requires Magento_Bundle and Magento_Tax.
  • Kits: bundles in the cart become quote kits; an approved kit becomes the bundle in the cart with the negotiated price locked (kit or components) and passes the place-order check.
  • QuoteSelfServiceInterface: every buyer action on a quote, authorised once for all surfaces (company scope, quote.view / quote.create, line, proposal and file ids must belong to the quote, buyer files only).
  • QuoteRequestInterface::createFromCart() — "Request a quote" from the cart (server-priced draft, cart emptied, one transaction, quote.create).
  • The cart created from a quote carries b2b_quote_id; the order inherits it (quote + sales_order columns, fieldset.xml), so orders are traceable to their quote.
  • PlaceOrderQuoteGuard on CartManagementInterface::placeOrder (every channel): the quote must still be approved and orderable (expiry guards) and the cart must still match it line for line — same SKUs, quantities and negotiated prices, no coupon.
  • MarkQuoteOrderedOnPlace observer: the quote becomes ordered and records order_id when the order is placed, not when the cart is created.
  • convertToCart() is idempotent (returns the buyer's active cart for that quote) and no longer leaves customer_is_guest = 1 on the cart.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Converting a quote to a cart is refused when the store's current currency differs from the quote currency.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • The order placed from a quote's cart now carries b2b_quote_id (observer CopyQuoteIdToOrder on sales_model_service_quote_submit_before): core merges the converted order through OrderInterface only, which dropped the fieldset.xml copy — so the quote was never marked ordered nor linked to its order.
  • The package required magento/module-bundle ^1.0 and magento/module-tax ^1.0 (no such versions; ^101.0 and ^100.4) and could not be installed with Composer.
  • PHPStan level 8 finding: the store currency is read only from a Store model.
  • A converted quote was marked ordered on sales_order_place_after, which fires inside Order::place() before the order is saved: the quote was linked to order id 0, and it was marked ordered even when saving the order failed afterwards. The observer now listens to sales_model_service_quote_submit_success.
  • Conversion refuses to lock a zero or negative line price into the cart.

Quote desk

Added

  • QuoteDeskRepositoryInterface::getByCompany(): a company's open and lately closed quotes.
  • Admin screens, REST routes and merchant e-mails (in Qoliber_B2bAdminUi, Qoliber_B2bApi, Qoliber_B2bNotification); desk data stays out of GraphQL and the storefront.
  • Service contracts for the merchant-side quote desk: Api\QuoteDeskRepositoryInterface, Api\QuoteSellerAssignmentInterface, Api\QuoteLineCommercialsInterface, Api\LineCostProviderInterface, Api\MarginExceptionManagementInterface, Api\QuoteResponseSlaInterface, Api\QuoteOutcomeInterface, Api\QuoteDeskStatsInterface and their data interfaces.
  • Seller ownership qoliber_b2b_quote_desk (one row per quote, FK to the quote CASCADE and to admin_user SET NULL): auto-assignment on the first submission (company sales rep → round-robin seller pool → default seller, active admin users only), manual reassignment, a seller queue.
  • Line commercials: cost (product cost attribute, or an ERP through LineCostProviderInterface), gross margin, floor price, contract price (price explanation) and the company's last paid price (latest non-cancelled company order), in the quote currency.
  • Margin guardrail: a seller offer or a seller acceptance of a buyer counter-offer below the floor is held as a margin exception (qoliber_b2b_quote_margin_exception) and throws Exception\MarginExceptionPendingException instead of reaching the buyer; approval by another merchant user carries it out, rejection keeps it away; a pending exception blocks approving the quote (Model\Guard\PendingMarginExceptionGuard); newer requests, quantity changes and the end of the quote withdraw it.
  • First-response SLA: clock from the first submission, due after response_hours (optionally business hours in the store timezone), at risk after at_risk_percent; seller comments, offers, decisions and transitions answer it; cron qoliber_b2b_quote_desk_sla flags at-risk and breached clocks once each.
  • Win/loss (ordered = won; rejected, expired = lost) with the loss reason catalog, and cohort statistics (win rate, time to first response, SLA compliance, loss reasons) overall, per seller and by seller.
  • Configuration qoliber_b2b/quote_desk/* (Stores → Configuration → Qoliber → B2B Quotes → Quote Desk), ACL Qoliber_B2bQuoteDesk::margin_exception_decide, ::assign, ::stats.
  • Domain events qoliber_b2b_quote_desk_assign_after, _unassign_after, _sla_start_after, _sla_respond_after, _sla_stop_after, _sla_at_risk_after, _sla_breach_after, _close_after, _loss_reason_after and qoliber_b2b_quote_margin_exception_request_after, _approve_after, _reject_after, _cancel_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): drops both tables, the qoliber_b2b/quote_desk/ configuration and the cron job.

Changed

  • A sales rep acting in a buyer's assisted session (audit channel sales_rep_assisted) is not a seller: it neither stops the response SLA nor counts as a seller acceptance.

Quote expiry

Added

  • Initial release: qoliber_b2b_quote_expiry side table, QuoteExpirationService (set/get expiry, isExpired, expireOverdue) and a nightly qoliber_b2b_quote_expire cron job that transitions overdue non-terminal quotes to expired.
  • "Valid until" on the quote proposal PDF (Model\Pdf\ValidUntilFact).
  • findExpiringWithin(int $days) / markReminded(int $quoteId) on QuoteExpirationServiceInterface and the reminder_sent_at column, backing the expiry reminder e-mail (Qoliber_B2bNotification cron).
  • QuoteExpirationServiceInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_quote_expiry_set_after, qoliber_b2b_quote_expire_sweep_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.
  • Default validity: every submitted quote gets qoliber_b2b/quote/expiry_days (default 30, 0 disables) unless an expiry was set explicitly — before this nothing ever called setExpiry(), so the module was a no-op in production.
  • ExpiryTransitionGuard: an expired quote can neither be approved nor ordered (also blocks quote-to-cart conversion), registered on QuoteManagement via di.xml.

Changed

  • Approved quotes are expirable until ordered; the cron runs every 15 minutes, only scans non-terminal quotes and uses Magento's UTC clock on both the PHP and SQL side.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.

Fixed

  • Foreign key quote_id → qoliber_b2b_quote (CASCADE).
  • PHP 8.1 compatibility: EXPIRABLE_STATUSES holds enum cases and statuses are compared via QuoteStatus::tryFrom(); unknown statuses are never expired.

Quote negotiation

Added

  • Initial release: line-level price negotiation (qoliber_b2b_quote_line_negotiation) and quote version snapshots (qoliber_b2b_quote_version).
  • Enums NegotiationLineStatus (proposed/accepted/rejected) and ProposedByType (buyer/seller).
  • QuoteNegotiationService: proposePrice, acceptProposal (applies proposed price to the quote item), rejectProposal, snapshotVersion (JSON snapshot with sequential version_no), getNegotiationsForItem.
  • Light repositories for negotiations and versions (save/getById).
  • A kit is negotiated as a whole: component lines are refused.
  • Automatic versions on submit, negotiation, approval and each accepted price (Observer\RecordQuoteVersion); reason column; snapshots keep status, currency and full lines.
  • QuoteHistoryInterface::getHistory() — buyer-readable versions with the changes against the previous one.
  • Observer\WithdrawOpenProposals closes open proposals when a line's quantity changes.
  • QuoteNegotiationServiceInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_quote_negotiation_propose_after, qoliber_b2b_quote_negotiation_accept_after, qoliber_b2b_quote_negotiation_reject_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign keys quote_version.quote_id → quote and quote_line_negotiation.quote_item_id → quote item (CASCADE); unique quote_version(quote_id, version_no).
  • Proposals require a positive price and an editable quote; accepting a proposal recalculates the quote total.

Security

  • A party cannot accept its own proposal (acceptProposal($id, $acceptingParty)); customer surfaces now propose as buyer and may only accept seller offers, so a company member can no longer set and self-approve a price.

Quotes

Added

  • Initial release: negotiable quotes with items and comments, QuoteStatus workflow enum, QuoteRepository, and QuoteManagement (create quote, add item with total recalculation, add comment, validated status transitions, recalculate total).
  • Kits: addKit() quotes a bundle with its chosen components as one kit line with component lines, checked against the bundle and priced for the company (dynamic: components at the company prices; fixed: its price plus the selections), totals count the kit line only.
  • Quote list prices convert the catalog price from the website base currency, also for a company buying in another currency.
  • QuoteManagementInterface::updateItemQty() (price resolved again for the new quantity) and removeItem() on editable quotes, with events EVENT_ITEM_UPDATE_AFTER / EVENT_ITEM_REMOVE_AFTER.
  • QuoteProposalPdfInterface (Zend_Pdf proposal: seller, company, quote facts, lines, total, seller note) and the QuoteProposalFactProviderInterface hook for header facts.
  • getCreatedAt() / getUpdatedAt() on QuoteInterface, getCreatedAt() on QuoteCommentInterface.
  • Index quote(status, created_at).
  • QuoteTransitionGuardInterface extension point run on every transition and via assertCanTransition(); other modules register guards through di.xml.
  • Quote lines snapshot product_id, name and list_price; quotes carry order_id (schema).
  • QuoteStatus::isEditable(); an approved quote may now expire until it is ordered.
  • QuoteManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_quote_create_after, qoliber_b2b_quote_item_add_after, qoliber_b2b_quote_comment_after, qoliber_b2b_quote_transition_after. See the README events table.
  • QuoteInterface extends ExtensibleDataInterface (getExtensionAttributes() / setExtensionAttributes()), the model extends AbstractExtensibleModel and carries an _eventPrefix for model-level events; third parties add fields through extension_attributes.xml as with the company.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • QuoteManagementInterface::createQuote() currency is optional and defaults to the company website's base currency; anything else must be allowed on that website (was a hard-coded 'USD'). The catalog fallback price of a line is converted from the base currency into the quote currency. qoliber_b2b_quote.currency has no default any more.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).
  • QuoteLinePriceResolverInterface::resolve() takes the buyer (?int $customerId) and prices the line through CustomerPriceServiceInterface (channel quote), so per-customer providers apply to quotes; the catalog price remains the fallback.

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE NO ACTION, i.e. restrict): a company with this kind of record cannot be deleted until the records are dealt with.
  • Lines can only be added while the quote is draft / submitted / in negotiation; qty must be positive; unknown SKUs are rejected.

Security

  • addItem() no longer accepts a caller-supplied price. The unit price is resolved on the server for the quote's company (QuoteLinePriceResolverInterface: pricing pipeline → catalog price) and a zero/missing price is refused, so a buyer can no longer write 0.01 into a quote that locks prices in the cart.
  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Rep signals

Added

  • Daily digest e-mail to reps with accounts that have signals; thresholds under Stores → Configuration → Qoliber → B2B Sales and Reports → Rep Signals.
  • Each sales rep's accounts with signals from the suite's own data — credit hold, overdue receivables, credit utilisation, quotes at risk or past their response time, quotes won and lost, order guide lines due and overdue, order cadence slipping, a shrinking basket — and a next best action from explicit rules.

Requisition lists

Added

  • Initial release: requisition lists + items, RequisitionListManagement (create list, add item with SKU-merge, item count).
  • Sharing semantics (RequisitionListAuthorizationInterface: owner / shared-list viewers / requisition.manage editors / admin deleters), getList, getListsFor, renameList, setShared, deleteList, getItems, updateItemQty, removeItem, addFromOrder (reorder from history), updated_by on lists and lines, RequisitionListConversionInterface (toCart, toQuote, toPurchaseOrder on the quick-order validation), EVENT_*_AFTER events with Plugin/Events.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Setting a line to quantity 0 removed it through the intercepted removeItem(): the item-deleted event (and webhook) went out twice.
  • composer.json / module.xml declare magento/module-customer (used by Model\ListPresenter).
  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.

REST API

Added

  • Initial release: consolidated REST routes for company (read) and price-list (read/save) service contracts, with ACL.
  • Company addresses: /V1/qoliber-b2b/company-addresses (list, save, get, delete) and /V1/qoliber-b2b/company-address-policy (the member's view; set the limit).
  • GET / POST /V1/qoliber-b2b/company-roles/:roleId/catalog: what a role sees of the catalog (company admin; POST because of the array body).
  • Magento's anonymous sign-up POST /V1/customers follows "Allow Customer Sign-up" (Qoliber_B2bCompanyRegistration): while it is closed a guest or customer gets 401 "Customer sign-up is closed."; admin and integration tokens still create accounts (Plugin\CustomerSignupClosedPlugin on AccountManagementApi).
  • GET/POST /V1/qoliber-b2b/companies/:companyId/websites (ACL Qoliber_B2bApi::company_websites for POST).
  • Routes /V1/qoliber-b2b/messaging/identities (list, link, unlink) for members.
  • Admin routes /V1/qoliber-b2b/rep-signals/accounts and /accounts/:companyId (Qoliber_B2bApi::rep_signals).
  • VMI routes /V1/qoliber-b2b/vmi/... for bins, par levels, scanning, counts and replenishment.
  • POST /V1/qoliber-b2b/quotes/:quoteId/kits adds a kit (a bundle with chosen components) to a quote.
  • Mandate keys and agent tokens' mandate requirement over REST.
  • Find by part numbers over REST: POST /V1/qoliber-b2b/search/codes.
  • Conversations over REST: members at /V1/qoliber-b2b/conversations, the merchant at /V1/qoliber-b2b/merchant/conversations (resource Qoliber_B2bApi::conversations); files as base64.
  • Spend analytics over REST: GET /V1/qoliber-b2b/spend, GET|POST /V1/qoliber-b2b/spend/subscriptions, DELETE …/subscriptions/:subscriptionId.
  • Order amendments over REST: the member's (GET /V1/qoliber-b2b/orders/:orderId/amendable, POST …/orders/:orderId/amendments, GET /V1/qoliber-b2b/order-amendments, GET …/:amendmentId, POST …/:amendmentId/cancel) and the merchant's (/V1/qoliber-b2b/merchant/order-amendments, /apply, /reject; resource Qoliber_B2bApi::order_amendments).
  • Merchant company returns REST: /V1/qoliber-b2b/merchant/returns… list, read, lines, comments, approve, request-details, reject, receive, resolve (Qoliber_B2bApi::returns, MerchantReturnRestInterface).
  • Company returns REST on B2bReturns' own types (no Mage-OS requirement): return_id replaces entity_id, /returns/:returnId…, and POST /V1/qoliber-b2b/returns/:returnId/cancel.
  • Company returns over REST: /V1/qoliber-b2b/returnable-orders (+ /:orderId/items), /return-options, /returns (list, request), /returns/:rmaId (+ /lines, /comments read and add) — CompanyReturnRestInterface, resource self.
  • Merchant routes for e-mailed orders (Api\IntakeMailRestInterface, ACL Qoliber_B2bApi::intake_mail): list, read, lines, assign, correct and remove lines, convert, reject.
  • Merchant e-invoice routes (Api\EInvoiceRestInterface, ACL Qoliber_B2bApi::einvoices): a company's e-invoices, one record, its document (base64), regenerate, transmit, issue for an invoice.
  • Company switching for customers of several companies: GET /V1/qoliber-b2b/company/memberships and POST /V1/qoliber-b2b/company/switch (companyId; answers the memberships), resource self (Api\CompanySwitchRestInterface).
  • Agent tokens: GET/POST /V1/qoliber-b2b/agent-tokens, POST …/agent-tokens/:tokenId/revoke (resource self; the plain token is returned once); for the merchant GET/POST /V1/qoliber-b2b/companies/:companyId/agent-tokens and POST /V1/qoliber-b2b/merchant/agent-tokens/:tokenId/revoke (ACL Qoliber_B2bApi::agent_tokens).
  • Delivery per line: GET /V1/qoliber-b2b/ship-to/destinations, GET/POST …/ship-to/cart-lines (resource self), GET /V1/qoliber-b2b/orders/:orderId/destinations (ACL Qoliber_B2bApi::ship_to).
  • GET/PUT /V1/qoliber-b2b/companies/:companyId/branch-pickup (ACL Qoliber_B2bApi::branch_pickup).
  • The order-review gate for the merchant: GET /V1/qoliber-b2b/order-reviews, GET …/order-reviews/:reviewId, POST …/:reviewId/approve|reject (as the token's admin user), GET/PUT /V1/qoliber-b2b/companies/:companyId/order-review-policy (ACL Qoliber_B2bApi::order_review).
  • Saved carts for the token's member: GET/POST /V1/qoliber-b2b/saved-carts, GET/PUT/DELETE …/saved-carts/:savedCartId, POST …/:savedCartId/restore|share|handoff (resource self).
  • POST /V1/qoliber-b2b/companies/:companyId/payment-terms/fallback-providers (ACL Qoliber_B2bApi::net_terms).
  • The company account for the buyer (AccountRestInterface under /V1/qoliber-b2b/account/…: summary and aging, invoices, statement, on-account decision with the reason, payment methods and payment requests — prepare, pay, cancel, remittance) and net terms for the merchant (receivables, aging, terms catalog and assignment, credits; NetTermsAdminRestInterface records payments, write-offs and holds as the token's admin user). ACL Qoliber_B2bApi::net_terms.
  • Seller-assisted buying routes: for the token's member (AssistanceRestInterface: consents, grant, revoke, activity, order confirmations confirm/decline; admin tokens refused) and for the merchant (a company's consents and activity, its grant policy, revoking a consent).
  • Company template admin routes: create/update, read by id or code, delete, capture a company, preview and apply to a company, the company's applications (ACL Qoliber_B2bApi::company_template). Test helper: REST calls clear the shared request's params (setParams([]) merged, so a URL parameter of an earlier call could override a body field).
  • Quote desk admin routes under /V1/qoliber-b2b/quote-desk/… (desk, commercials, seller, loss reason, open quotes per seller, price exceptions, statistics); QuoteDeskRestInterface decides price exceptions as the token's admin user (integration tokens refused). ACL Qoliber_B2bApi::quote_desk.
  • Order guide and standing orders for the token's customer (Api\OrderGuideRestInterface, self): GET /V1/qoliber-b2b/order-guide (includeHidden, withCurrentPrices), GET .../order-guide/due, POST .../order-guide/lines/:lineId/annotate, POST .../order-guide/add-to-cart; GET|POST /V1/qoliber-b2b/standing-orders, GET|PUT|DELETE .../standing-orders/:standingOrderId, POST .../:standingOrderId/pause|resume, GET .../:standingOrderId/runs.
  • Price feeds and the price check for the token's customer: GET/POST /V1/qoliber-b2b/catalog-feed/tokens, POST /V1/qoliber-b2b/catalog-feed/tokens/:tokenId/revoke (company administrators and catalog_feed.manage; the plain token is returned once) and POST /V1/qoliber-b2b/price-check (Api\CatalogFeedRestInterface, self).
  • Checkout references (Qoliber_B2bCheckoutReference) for the token's customer: GET /V1/qoliber-b2b/checkout-references/requirements, GET/PUT /V1/qoliber-b2b/checkout-references/cart (Api\CheckoutReferenceRestInterface, self; the active cart only).
  • Approval depth over REST (customer token): GET /approval-requests/:requestId/votes, GET|POST /approval-delegations, POST /approval-delegations/:delegationId/revoke; steps carry stage, quorum and escalation.
  • GET /V1/qoliber-b2b/companies/:companyId/price-explanation (admin token, ACL Qoliber_B2bApi::price_explanation): every price precedence step for one company line, the winner, the storefront index and the individual-price answer.
  • Quote depth over REST (customer token): PUT|DELETE /quotes/:quoteId/items/:itemId, POST /quotes/from-cart, GET /quotes/:quoteId/history, attachments list/upload (base64)/download/remove, GET /quotes/:quoteId/pdf.
  • QuoteTemplateRestInterface: /quote-templates list/one/lines, POST /quotes/:quoteId/template, POST /quote-templates/:templateId/call-off, POST /quotes/:quoteId/release.
  • README regenerated from webapi.xml (every route, its service method and resource).
  • Purchase order self-service routes (PurchaseOrderRestInterface): /V1/qoliber-b2b/purchase-orders (GET), /from-cart (POST), /:poId (GET), /:poId/items (GET), /:poId/submit, /:poId/cancel, /:poId/cart (POST), /:poId/reference (PUT), /:poId/items/:itemId (PUT, DELETE).
  • Requisition list self-service routes (RequisitionListRestInterface): /V1/qoliber-b2b/requisition-lists (GET, POST), /:listId (GET, DELETE), /:listId/name (PUT), /:listId/shared (PUT), /:listId/items (GET, POST), /requisition-list-items/:itemId (PUT, DELETE), /:listId/from-order/:orderId, /:listId/cart, /:listId/quote, /:listId/purchase-order (POST).
  • Quick order self-service routes (token-derived member): POST /V1/qoliber-b2b/quick-order/parse, POST …/validate, GET|POST|DELETE …/draft, PUT|DELETE …/draft/:itemId, POST …/cart, GET …/search (QuickOrderRestInterface).
  • GET / PUT /V1/qoliber-b2b/company/profile (self): the caller's company, and the admin-only edit of name / legal name / tax id.
  • GET /V1/qoliber-b2b/company-team/invitations, DELETE …/invitations/:invitationId (revoke), POST …/invitations/:invitationId/resend.
  • Role administration routes under /V1/qoliber-b2b/company-roles (list, create, rename, delete, get/set permissions) and /V1/qoliber-b2b/company-permissions; the acting member is derived from the customer token.
  • Integration contract test: every routed service method and every Api/Data interface must pass webapi reflection (previously six DTOs had no docblocks and every quote/team/invitation route 500ed at output serialisation).
  • POST /V1/qoliber-b2b/prices (customer token): batched individual prices, lines: [{sku, qty}] + optional currency.

Changed

  • POST /V1/qoliber-b2b/quotes/:quoteId/items no longer takes price; propose/accept follow the buyer/seller party rule.
  • REST createQuote and setMemberSpendLimit no longer default the currency to USD; the services resolve the company website's base currency.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • REST could not send null where the contract allows it (the company-wide over-limit policy, clearing a member's role or spend limit): those parameters default to null; guard test for every routed method.
  • The approval approve/reject routes decide through ApprovalDecisionInterface, so purchase orders follow their request (the approver note reaches the purchase-order step too).
  • POST /V1/qoliber-b2b/company-roles/:roleId/permissions (was PUT): Magento's REST body-id override on PUT reflects every array body parameter as a class and crashed with "Class string[] does not exist" — found by the new in-process REST test.

Security

  • POST /V1/qoliber-b2b/company-team/invite no longer returns the invitation secret: it answers with InvitationSummaryInterface (id, e-mail, role, status, expiry). The secret travels only in the invitee's e-mail.
  • Actor resolution goes through ActiveCompanyMemberResolverInterface: disabled members and members of non-active companies are treated as non-members.

Returns: Mage-OS RMA bridge

Added

  • Mage-OS RMA as the backend of company returns: MageOsBackend behind Qoliber\B2bReturns\Api\Backend\ReturnBackendInterface, the company of every return of a company order (qoliber_b2b_return_rma), Mage-OS status changes reported to the returns status hook (credit to account, events, audit), the guard on Mage-OS RMA's own customer and guest returns, the storefront eligibility filter, the Company column on Sales → Returns and the credit_to_account resolution type.

Fixed

  • Two requests at once could reserve more of an order than was ordered (Mage-OS RMA counts earlier requests without a lock): the backend locks the order and counts every request as committed before Mage-OS creates the return; decisions lock the return's row (lockStatus()).

Sales rep assignment

Added

  • Initial release: sales-rep to company assignment with SalesRepManagement (upsert assign, lookup rep for company, unassign).
  • getCompaniesForRep() and getRepAdminUserIds(); index on rep_admin_user_id.
  • SalesRepManagementInterface::EVENT_* constants and Plugin/Events after-plugins (sortOrder 3000, inside the audit transaction) dispatching qoliber_b2b_company_sales_rep_assign_after, qoliber_b2b_company_sales_rep_unassign_after. See the README events table.
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops this module's tables/columns, config and cron rows.

Changed

  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Fixed

  • Foreign key company_id → qoliber_b2b_company (ON DELETE CASCADE): deleting a company no longer orphans these rows.

Saved carts

Added

  • Saved carts for company members: a named snapshot of the active cart (every line with its buy request and option labels, the subtotal), optionally emptying the cart; at most 20 carts per member and 500 lines per cart (configurable).
  • Api\SavedCartManagementInterface: save, getList (own, handed to the member, shared in their company), get, restore (add to or replace the active cart; lines that can no longer be added are reported, the rest are added; a recipient restoring a hand-off takes it over), rename, setShared, handOff (to an active colleague), delete (owner or company administrator). A cart the member may not see is "not found".
  • Domain events qoliber_b2b_saved_cart_save_after, …_handoff_after, …_restore_after, …_delete_after.
  • Configuration in Stores → Configuration → Qoliber → B2B Ordering and Checkout → Saved Carts; uninstall drops the tables and the configuration.
  • A cart stays with its company: on a company switch a non-empty cart is saved for the company left (is_parked) and emptied, and the newest cart parked for the company switched to comes back (Plugin\ParkCartOnCompanySwitch, outside the audited switch).

Changed

  • The member's active cart is looked up in any store of their website, whatever store the call runs in (REST, GraphQL, crons), and restoring uses that cart.

Ship-to per line

Added

  • Ship-to per line: lines of a company member's cart go to the company's locations; checked again at placement on every channel; each order item keeps its location and the address of the day; the order's lines grouped by destination (Api\ShipToManagementInterface).

Single sign-on

Added

  • Initial release.
  • Api\\IdentityLinkManagementInterface (getLinkCount, forget): links of an identity provider set up again are forgotten and made again by a verified e-mail; the refusal of a linked account tells the member to contact the store.
  • OIDC sign-in requires exp and iat; an account is linked by e-mail only when email_verified is true or the provider trusts its e-mail addresses (new trust_email, for Entra ID); an account linked to one subject is not taken over by another; subjects over 255 characters are refused (they were truncated).
  • Surfaces: the login-page sign-in (Luma + Hyvä), provider links, OIDC callback, SAML assertion consumer and SP metadata; B2B Suite → System → Single Sign-On; the flow state in the cache bound by a SameSite=None cookie (the SAML POST is cross-site); SP addresses from the company website's default store; repeated SAML attribute names allowed and library errors refused cleanly; the storefront password refusal keeps its message; Keycloak e2e on Mage-OS and Magento Open Source.
  • Single sign-on (domain): company identity providers by e-mail domain — OpenID Connect (code + PKCE, ID token verified against the issuer's JWKS, nonce) and SAML 2.0 (signed assertions via onelogin/php-saml, answers to our own requests only, once) —, members linked by subject, found by e-mail or created with a default role, password sign-in refused for required domains (storefront and tokens), event, webhook topic, audit.

Fixed

  • A company without a website of its own: with customer accounts kept per website, its members were looked up (and new members created) on website 0, so an existing member was told they had no account, and the provider form gave the admin store's addresses. Both use the company's website or, without one, the storefront's (the default store's in the admin).
  • After a single sign-on the storefront kept the guest's private blocks (Luma header greeting, mini cart; Hyvä customer data): the identity provider returns with a GET, which reloads nothing. The sign-in now sends the reload signals Magento uses: section_data_clean (Luma) and a new private_content_version (Hyvä).
  • The admin's provider form told the identity provider admin addresses with a secret key (redirect URI, entity id, assertion consumer); it now shows the storefront addresses.
  • Without an email claim, an e-mail-shaped preferred_username counted as verified when email_verified was true — only the email claim is verified by it; a username links an account only for a provider trusted with its e-mail addresses.
  • After a provider's issuer was changed, the new identity provider's user with the same subject as a linked member signed into that member's account — links carry the issuer's (or SAML entity id's) namespace; a former issuer's links match nobody and are replaced when the member is linked again by a verified e-mail.

Spend analytics

Added

  • Initial release.
  • CompanyActivityInterface: a company's net spend and order times for the merchant's side.
  • The scheduled report e-mail template follows the suite convention (qoliber_b2b/notification_templates/spend_report, template qoliber_b2b_notification_templates_spend_report) and is the one configured, chosen with the other notification templates: the B2B Notifications configuration page no longer fails with "Email template is not defined".
  • Surfaces: My Company → Company Spend on Luma and Hyvä (filters, drill-downs, CSV, e-mailed reports), REST and GraphQL, e2e on Mage-OS and Magento Open Source.
  • Buyer spend analytics (domain): members holding report.view see their company's spend — goods net of discounts and refunds of orders not cancelled — by period (day, ISO week, month, quarter, year), member, cost center, top-level category or product over a date range, filtered by member, cost center or category; CSV; weekly and monthly e-mailed reports from a daily cron.

Stock bins (VMI)

Added

  • Par level CSV import per company; the bin resource model for grids.
  • Stock bins per company with min/max par levels per product, counts by scan or typing, replenishment proposed up to the maximum in the product's order rules into a cart, a requisition list or a draft purchase order, Code 128 bin and product labels; permissions vmi.count and vmi.manage; events, webhook topics and audit.

Fixed

  • Printed labels: the barcode fills its box instead of running over the code printed under it.

Units and order quantities

Added

  • Api\OrderQtyRuleImportInterface::import(csv): sku plus optional company_id, min_qty, qty_multiple; upsert per SKU and company through the repository (validation, events, audit); refused rows reported as Line <n>: <reason>.
  • Unit-of-measure master qoliber_b2b_unit_of_measure (code, label, UN/ECE Recommendation 20/21 code, sort order) with Api\UnitOfMeasureRepositoryInterface (save, get, getList, delete; a unit a product is sold in cannot be deleted); data patch InstallUnitsOfMeasure seeds 19 distribution units (EA … BFT).
  • Pack sizes qoliber_b2b_product_unit (SKU, unit, factor in base units, base flag) with Api\ProductUnitManagementInterface (getUnits, replaceUnits — one base unit with factor 1, positive factors, each unit once, known units, existing product).
  • Api\UnitConverterInterface: base unit of a product (EA without units), quantity conversion both ways, price of one unit and price per base unit (4 decimals).
  • Order quantity rules qoliber_b2b_order_qty_rule (minimum and/or multiple per SKU, per company or for every company) with Api\OrderQtyRuleRepositoryInterface and Api\OrderQtyServiceInterface (field-by-field policy, minimum rounded up to the multiple, check, suggestQty, batch getPolicies).
  • Quick order: Model\QuickOrder\OrderQtyLineRule registered as quick-order line rule qoliber_b2b_order_qty after the stock-item rules.
  • Domain events qoliber_b2b_unit_of_measure_save_after, qoliber_b2b_unit_of_measure_delete_after, qoliber_b2b_product_units_replace_after, qoliber_b2b_order_qty_rule_save_after, qoliber_b2b_order_qty_rule_delete_after (Plugin/Events, sortOrder 3000).
  • Setup\Uninstall (extends Qoliber\B2bCore\Setup\ModuleUninstall): module:uninstall --remove-data drops the three tables.

VAT and tax exemptions

Added

  • Company VAT registration (qoliber_b2b_tax_vat_registration, one per company): Api\VatRegistrationManagementInterface (register, validate, remove, getHistory), Api\VatRegistrationRepositoryInterface. Local normalisation and per-member-state format check (Model\Vat\VatNumberNormalizer, EL/GR and XI handled); malformed → invalid, outside VIES → unsupported, else unverified and due now.
  • VIES validation: Api\VatValidatorInterface → Model\Vies\ViesVatValidator (merchant VAT ID from Store Information as requester → consultation number), Api\ViesTransportInterface → Model\Vies\CurlViesTransport (REST check-vat-number, http(s) only, no redirects, never throws), Model\Vies\ViesResponseParser (valid / invalid / invalid_format / unavailable with the error code).
  • Evidence: every check stored in qoliber_b2b_tax_vat_check (consultation number, trader, requester, error code); kept on removal and on company deletion (FK SET NULL). An unavailable VIES keeps the last verdict.
  • Revalidation: Model\Vat\RevalidationSchedule and cron qoliber_b2b_tax_vat_revalidate (every 15 minutes, oldest due first, batch size, through the contract).
  • Company tax ID bridge: after-plugin on CompanyRepositoryInterface::save() registers an EU-prefixed tax ID when it changes.
  • Exemption certificates (qoliber_b2b_tax_exemption_certificate): Api\ExemptionCertificateManagementInterface (submit, approve, reject, revoke, expire, remindExpiry, getExpiringSoon, getEffective), Api\ExemptionCertificateRepositoryInterface; lifecycle Model\Enum\CertificateStatus, rules Model\Certificate\CertificateRules, Jurisdiction, ExpiryWindow; cron qoliber_b2b_tax_certificate_sweep (hourly: expiry + one reminder).
  • Tax treatment: Api\TaxTreatmentResolverInterface → exempt / reverse_charge / standard with reason, buyer VAT ID, consultation number, certificate and invoice note (Model\Treatment\ReverseChargeRule).
  • Checkout hook: after-plugin on Quote::getCustomerTaxClassId() returns the configured exempt customer tax class for untaxed company supplies (none configured = no change).
  • Domain events qoliber_b2b_tax_vat_*_after and qoliber_b2b_tax_certificate_*_after (Plugin/Events, sortOrder 3000).
  • Config in Stores → Configuration → Qoliber → B2B Finance and Tax → Tax (qoliber_b2b/tax/), Setup\Uninstall.

Webhooks

Added

  • Initial release: ERP/webhook integration layer with integration endpoints + sync log, IntegrationEndpointRepository (save/getById/delete) and WebhookDispatcher (dispatch, shouldRetry, signPayload).
  • Webhook topics company_address.saved, company_address.deleted, company_address_policy.set.
  • Webhook topic budget.carried (qoliber_b2b_budget_carry_after).
  • Webhook topic company.websites_changed.
  • Webhook topics messaging_identity.linked and messaging_identity.unlinked.
  • Webhook topics vmi_location.saved, vmi_location.deleted, vmi_count.recorded, vmi_count.replenished.
  • Webhook topics agent_mandate.accepted and agent_mandate.refused.
  • Webhook topic sso.login.
  • Webhook topics conversation.message_posted, conversation.closed.
  • Webhook topics order_amendment.requested, .applied, .rejected.
  • Webhook topic return.status_changed.
  • Webhook topics return.requested and return.credited (B2bReturns).
  • Webhook topics intake_mail.received, intake_mail.converted, intake_mail.rejected.
  • The branch-pickup settings event is merchant configuration (no webhook topic).
  • Webhook topics order_review.opened, order_review.approved, order_review.rejected; saved_cart.saved, saved_cart.handed_off, saved_cart.restored and saved_cart.deleted.
  • Webhook topics deposit.requested and deposit.cancelled.
  • Webhook topics for availability: lead_time_rule.saved/deleted, incoming_supply.replaced, source_preference.replaced.
  • Webhook topics for the document vault: vault_document.saved/deleted/version_added/version_deleted and the sweep's vault_document.expiring/expired/review_due.
  • Webhook topics for order guides: order_guide.line_annotated/added_to_cart/reorder_due and standing_order.saved/paused/resumed/deleted/executed (a guide rebuild is derived data and has no topic).
  • Webhook topics for net terms: payment_terms.saved/deleted, company_payment_terms.assigned/unassigned, credit_hold.placed/released, receivable.issued/credited/written_off, receivable_payment.recorded/applied, dunning.notice_raised.
  • Webhook topics for company templates: company_template.saved/deleted/applied (an application, manual or from the registration queue; never a preview).
  • Webhook topics for the quote desk: quote.seller_assigned/seller_unassigned, quote.sla_started/first_response/sla_at_risk/sla_breached, quote.margin_exception_requested/approved/rejected/cancelled (payloads carry cost and margin: merchant endpoints only), quote.closed, quote.loss_reason_recorded; the SLA stop is bookkeeping and has no topic.
  • Webhook topics for e-invoicing: einvoice.issued/regenerated/transmitted/status_updated and einvoice_settings.saved/deleted.
  • Webhook topics for price feeds: catalog_feed_token.issued/revoked (payloads never carry the plain token); a feed build is derived data and has no topic.
  • Webhook topics for the AR portal: invoice_payment_request.created/attempted/paid/cancelled, invoice_payment_attempt.confirmed/failed, billing_schedule.created/cancelled, invoice_payment_method.company_rule_saved/company_rule_reset.
  • Webhook topics for seller-assisted buying: assisted_buying.consent_granted/revoked/expired, assisted_buying.session_started/ended, assisted_buying.order_confirmation_requested/confirmed/declined (payloads never carry the session token); the grant-policy setting has no topic.
  • Webhook topics for the MCP agent gateway: agent_token.issued/revoked (payloads never carry the plain token); tool calls have no topic.
  • Webhook topics for tax: tax_vat.registered/validated/removed, tax_certificate.submitted/approved/rejected/revoked/expired/expiring.
  • Index sync_log(event_id) (replay by aggregate in B2bIntegrationHealth looks deliveries up by outbox event).
  • Webhook topics for the distribution data model: unit_of_measure.saved/deleted, product_units.replaced, order_qty_rule.saved/deleted, part_number.saved/deleted.
  • Outbox producers: Model\Outbox\TopicCatalog maps 78 domain events to public webhook topics (purchase_order.approved, company.member_added, …; di.xml argument topics), Observer\RecordOutboxEvent on each of them (etc/events.xml) writes an outbox row inside the transition's transaction while an active endpoint subscribes to the topic; Model\Outbox\PayloadNormalizer turns event data into JSON (data objects → data, credentials-looking keys dropped) and names the aggregate. A failed write is logged, never thrown.
  • Endpoint subscriptions: IntegrationEndpointInterface::getEvents()/setEvents() (column events, JSON; empty = every topic); endpoints are validated on save (absolute http(s) URL, known topics).
  • Outbox consumer: Api\OutboxRelayInterface::relay(limit) settles events oldest first into one pending delivery per active subscribed endpoint (envelope {id, type, occurred_at, aggregate, company_id, data}), in one transaction per event; unique sync_log(endpoint_id, event_id) makes a repeated relay harmless.
  • sync_log columns event_id, response_code, next_attempt_at, delivered_at (+ index status, next_attempt_at); endpoint columns events, updated_at; secret widened to 1024 for the encrypted value.
  • Delivery: Api\WebhookDeliveryInterface (deliverDue, deliver, redeliver, ping) claims due rows with a conditional update (lease), signs and POSTs them, backs off exponentially (Model\Delivery\RetrySchedule: 1 min doubling, capped at 6 h) and dead-letters (failed) after max_attempts; rows of inactive endpoints wait.
  • Signatures: Api\WebhookSignatureInterface → WebhookSigner — X-Qoliber-Signature: t=<unix>,v1=<HMAC-SHA256(secret, "t.body")> with tolerance-checked verify(); headers X-Qoliber-Event, -Event-Id, -Delivery, -Attempt.
  • Transport: Api\WebhookTransportInterface → CurlTransport (http(s) only, no redirects, connect timeout ≤ 5 s) returning Api\Data\WebhookTransportResultInterface; never throws for a failed request.
  • Cron qoliber_b2b_webhooks (every minute: relay, then deliver) and bin/magento qoliber:b2b:webhooks:process [--limit=N]; config Stores → Configuration → Qoliber → B2B Integrations and Data → Webhooks (max_attempts 10, timeout 10, batch_size 100); uninstall covers both.
  • Model\Health\WebhookHealth::getSummary(): outbox backlog and oldest event, deliveries per status, per endpoint queued / dead / delivered in 24 h / last success / last error (the admin job dashboard; grouped queries).
  • Model\ResourceModel\IntegrationEndpoint\Collection (secrets stay encrypted in collections).
  • Guards: TopicCatalogTest fails when a new EVENT_*_AFTER has no topic, a catalogued event has no observer, or a model lifecycle event shares a domain event name.
  • Index sync_log(status, created_at).
  • Retention policy sync_log (default 30 days; only success/failed rows).
  • Setup\Uninstall.

Changed

  • WebhookDispatcherInterface::dispatch() queues the row due immediately (next_attempt_at), so the delivery worker sends it; signPayload() is deprecated in favour of WebhookSignatureInterface.
  • @param/@return docblocks (FQCN, house style) on every Api interface method so Magento webapi reflection (TypeProcessor) can serialise the contracts; PHPStan-only generics replaced by webapi-parsable array types where a data interface is involved.
  • Every Api interface carries the @api tag and the license header (guarded by Qoliber\B2bCore\Test\Unit\Api\ApiContractTest).

Security

  • Endpoint signing secrets are encrypted at rest (EncryptorInterface in the resource model; plaintext only in memory); data patch EncryptEndpointSecrets encrypts any secret a pre-release install stored in plaintext. An endpoint saved without a secret gets a generated one — webhooks are never unsigned.
Changelog — B2B Suite — B2B Commerce — Extensions | qoliber Docs