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-opNullMigrationSource),CompanyMigrationServiceInterface/CompanyMigrationServiceand theMigrationResultvalue 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-adobeconsole command, wired into the Magento command list. - Extended
AdobeCompanyMapperwith the reverse-direction helpersstatusFromAdobe()andfromAdobeCompany().
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
- A fresh
setup:installof the suite failed witheav_entity_type doesn't exist: the installer builds every CLI command before the schema exists, andqoliber:b2b:migrate-adobeeagerly 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_indexwithCatalogAccessIndexServiceInterface(reindexCompany,isVisibleIndexed,reindexAll) andCatalogAccessIndexer(ActionInterface), so PLP visibility decisions read a flat row instead of evaluating rules per request. - Added
CatalogVisibilityResolverInterface::explain()returningVisibilityDecisionInterface(isVisible()+ human-readablegetReason());isVisible()now delegates to it. - Added
ProductCatalogContextInterfaceto 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 (RoleCatalogInterfacemodes 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)anddescribeTarget()for the admin editor.CatalogAccessRuleManagementInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_catalog_access_rule_save_after,qoliber_b2b_catalog_access_rule_delete_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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-indexqoliber_b2b_catalog_access_indexafter upgrading. - Only hidden
(company, product)pairs are stored;product_idadded to the index.reindexCompany/reindexAllaccept an empty sku list meaning "the universe"; newreindexProducts()andgetHiddenCompanyIdsByProduct();ProductCatalogContextInterfacegains batch readers;CatalogVisibilityResolverInterface::isVisibleBatch(). allowandprioritynow 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_entityandcatalog_category_product; product-scoped rule changes re-evaluate the product immediately. db_schema_whitelist.jsonregenerated fromdb_schema.xml(renamed index/constraint referenceIds were missing).- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest). @param/@returndocblocks (FQCN, house style) on everyApiinterface 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:reindexand 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 patchRoleOnlyCatalogsWithoutPathRulesconverts 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 +
B2bConfigreader, 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::einvoicesand::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_guidesandorder_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 onQoliber_B2bOrderGuide(sequence and composer). - B2B Suite → Integrations and Data → Price Feeds (B2bCatalogFeed),
qoliberb2b/feedtoken/*; ACLcatalog_feeds(grid) andcatalog_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\FeedTokenIssueFormlists the active companies. Depends onQoliber_B2bCatalogFeed(composer). - B2B Suite → Catalog and Prices → Documents (compliance document vault, B2bDocumentVault),
qoliberb2b/document/*; ACLdocuments(grid, document page, downloads, access log) anddocuments_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 throughDownloadAuthorizationInterface::authorizeAdmin()(logged) and are sent as attachments, or redirect for a link. - Depends on
Qoliber_B2bDocumentVaultandQoliber_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 byApprovalRuleValidator. "Add New Rule" and Edit on the grid. - Price Explanation page (
qoliberb2b/priceexplanation/index, menu B2B Suite → Catalog and Prices → Price Explanation, ACLprice_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 ACLquote_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\EnumOptionsvirtual 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,SalesRepManagementInterfaceandCompanyRegistrationServiceInterface. - 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 bysales_order_grid.company_idfrom Qoliber_B2bCompanyOrder. Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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_currencyhas noUSDdefault;B2bConfigInterface::getDefaultCreditCurrency()falls back to the default website's base currency. Saving a company without a credit currency uses the company currency.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest). - Role delete now POSTs (confirm dialog posts with the form key); the controller no longer accepts GET.
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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 byentity_idby 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.xmlfor the index field and<deps>. - Company and role forms: the flags imported into
disabled/visible(is_system,is_administrator,is_pending, newis_existing) are real booleans. As strings ("" on a new form, "0" for a custom role) knockout'sattrbinding rendereddisabled="", 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>threwthis.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\PageFactoryis an auto-generated factory whosecreate()never adds the default layout handles, so the pages had no layout andsetActiveMenu()failed on a missing menu block. Caught by the new backend controller integration tests. acl.xml:Magento_Backend::storeswas declared next toMagento_Backend::admininstead 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
CompanyInterfaceto an Adobe Commerce B2B shaped array viaAdobeCompanyMapper, including a purestatusToAdobe()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/jwksroute; 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_checkoutgateway tool (a UCP checkout signed by the store, hash, audience, single-use nonce),submit_purchase_ordertakes 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 withinrefusal_alert_window_minutes(15) reachingrefusal_alert_threshold(20, 0 = off) raise one system-wide warning naming the token behind most of them (depends onQoliber_B2bIntegrationHealth). - Idempotency keys for write tools:
add_to_cart,request_quoteandsubmit_purchase_ordertake an optionalidempotency_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),failedcalls are not remembered (qoliber_b2b_agent_idempotency, keptqoliber_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 byApi\AgentCallStatsInterface. Api\AgentToolCatalogInterface: the registered tools, for the screens that issue tokens.- Company-context MCP server:
POST /b2bmcpspeaks 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 overmax_request_bytes, 415 for non-JSON, 429 withRetry-Afteroverrequests_per_minute;Cache-Control: no-store,nosniff,noindexon 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 mosttoken_max_lifetime_days), revocation with a reason, at mostmax_active_tokens_per_memberactive tokens; issued and revoked by the member, a company administrator or the merchant.Api\AgentTokenManagementInterface(issue, revoke, getById, getByCompanyUser, getByCompany) andApi\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\AgentCallScopeand released infinally(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 actoragent(actor id the member's customer,company_user_id, channelapi) andagent_token_idadded 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, ledgerqoliber_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.addandagent.spend.refused. - CLI
qoliber:b2b:agent:token:issue(prints the plain token and the endpoint once) andqoliber: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(extendsQoliber\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_intakeandqoliber_b2b_order_intake_line. - Enum
IntakeStatus(parsed/needs_review/converted/failed). RawIntakeParserInterfacewith a deterministicHeuristicLineParserand an AI-assistedAiOrderIntakeParserthat degrades gracefully to the heuristic parser when no AI provider is enabled or the AI output is unusable.NullAiProviderdefault implementation ofQoliber\B2bCore\Api\Ai\AiProviderInterface.SkuMatcherresolves tokens to catalog SKUs viaProductRepositoryInterface.AssistedOrderIntakeService:submitRaw(parse, match, persist, audit) andconvertToQuote(draft quote from matched lines).OrderIntakeServiceimplementation ofQoliber\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) behindModel\Ai\ConfiguredAiProvider, the newAiProviderInterfacepreference; configuration Stores → Configuration → Qoliber → B2B AI and Messaging → AI Order Reading. Without a provider or key the deterministic parser reads orders as before. getLines,correctLineandremoveLineonAssistedOrderIntakeServiceInterface(a reviewer corrects or drops lines; counts and status follow); on theemailchannel the deterministic parser reads only lines with a digit.- Indexes
order_intake(company_id, status)and(created_at). OrderIntakeRecordInterfaceandOrderIntakeLineInterfaceextendsExtensibleDataInterface(getExtensionAttributes()/setExtensionAttributes()), the model extendsAbstractExtensibleModeland carries an_eventPrefixfor model-level events; third parties add fields throughextension_attributes.xmlas with the company.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.jsonregenerated fromdb_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-persistingsave, step-hydratedgetById/getList/getActiveRules,delete/deleteById.ApprovalRuleMatcher: precedence-aware matching ([min, max) bracket, company-specific over global, sort_order tiebreak).Model\Rule\DatabaseApprovalRuleadapter wired into theQoliber_B2bCoreApprovalEngine.- Enum
ApprovalRuleEntityTypemirroring the coreB2bEntityType. - 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_hoursandescalate_to_role_id. - Rule steps sharing a
sequenceform a parallel stage (unique key now(rule_id, sequence, approver_role_id));required_approvalsper 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.ApprovalRuleInterfaceextendsExtensibleDataInterface(getExtensionAttributes()/setExtensionAttributes()), the model extendsAbstractExtensibleModeland carries an_eventPrefixfor model-level events; third parties add fields throughextension_attributes.xmlas with the company.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.jsonregenerated fromdb_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, withApprovalWorkflow(submit/approve/reject) andApprovalRequestRepository. Model\SelfService\InboxViewfor 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.ApprovalContextProviderInterfaceandApprovalContextBuilder: 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, cronqoliber_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, andapprove()/reject()taking an optionalstepId. Api\ApprovalDecisionInterface(@api,Model\ApprovalDecision): the one entry point for deciding a request. Requests whose entity type has a registeredApi\ApprovalDecisionHandlerInterface(di argumenthandlers, keyed by entity type) are decided by the owning module; others by the workflow.ApprovalRequestRepositoryInterface::findLatestByEntity()(newest request for an entity of a company) andApprovalWorkflowInterface::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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
- A rejection now skips every other pending step (later steps used to stay
pending);ApprovalAuthorizationauthorizes throughApproverEligibility(the member must be eligible for a pending step of the current stage); decisions lock the request row. @param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.jsonregenerated fromdb_schema.xml(renamed index/constraint referenceIds were missing).
Security
- Four-eyes: the requester can neither approve nor reject their own request (
ApprovalAuthorizationandApprovalWorkflow); a role authorises only for the step currently awaiting action, not for any step of the chain; every state change is an optimistically lockedUPDATE … 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 inQoliber_B2bAdminUi. - Storefront: the Assistance page and the banner (in
Qoliber_B2bFrontend/Qoliber_B2bHyva);etc/frontend/sections.xmlrefreshesqoliber-assisted-sessionon 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\ActivityLogInterfaceand the data interfacesConsentInterface,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(extendsQoliber\B2bCore\Setup\ModuleUninstall): drops the five tables, theqoliber_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 thememberspolicy, 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 policyAssistedBuyingSettings. - Assisted sessions on Login as Customer: resolver
qoliber_b2b_assisted_consentin 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,generateCustomerTokenAsAdminrefused 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_reponsales_rep_assistedwith 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), retentionqoliber_b2b/retention/assisted_activity_days, ACLQoliber_B2bAssistedBuying::consents,::sessions, cronqoliber_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_forgetforIdentityLinkManagementInterface::forget(). - Audit
budget.carryforBudgetServiceInterface::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.refusedon the agent token. - Single sign-ons audited (
sso.loginonsso_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.statusfor every status change (Mage-OS's own screens included). - Company returns are audited (
return.request,return.crediton entityreturn, 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.issuefromissueCreditNote/issueForCreditMemo). company.switchaudit rows (Plugin\CompanySwitcherAuditPlugin): the company switched to, the one left and the customer.- Audit rows
order_review.open,order_review.approve,order_review.rejectandorder_review.policy. - Audit row
company_terms.fallbackswhen 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.replacewith the deliveries) andSourcePreferenceAuditPlugin(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/deletewith schedule and items) andStandingOrderRunAuditPlugin(order_guide.standing_order.runas 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) andDunningAuditPlugin(dunning.notice); idempotent replays (same order, credit reference or remittance reference) leave no row. - Company template audit:
CompanyTemplateAuditPlugin(company_template.save/deletewith the version) andCompanyTemplateApplicationAuditPlugin(company_template.applyper finished application — after-plugins onapplyandprocessQueued, 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/cancelwith price, cost, floor and margin) andQuoteOutcomeAuditPlugin(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) andEInvoiceSettingsAuditPlugin(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;payis 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) andInvoicePaymentMethodAuditPlugin(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.startwith the rep as actor on channelsales_rep_assisted,assisted.session.endone row per ended session),AssistedConfirmationAuditPlugin(assisted.confirmation.request/confirm/decline) andAssistedSettingsAuditPlugin(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.savewith 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) andExemptionCertificateAuditPlugin(tax.certificate.submit/approve/reject/revoke/expire/expiry_remindwith from/to status, deciding admin and note). - Distribution data model audit:
UnitOfMeasureAuditPlugin(distribution.unit_of_measure.create/update/delete),ProductUnitAuditPlugin(distribution.product_units.replacewith the unit set),OrderQtyRuleAuditPluginandPartNumberAuditPlugin(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.updatewith from/to qty and price,quote.item.remove) and attachments (quote.attachment.uploadwith name, type, size and checksum — never the content — andquote.attachment.remove). - Purchase-order
cancelandordertransitions 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_sweepaudit row (system actor) for the invitation expiry cron.pricing.discount.create|update|deleteaudit rows for individual discounts.
Changed
AuditWriter::write()accepts an optional trailingcompanyIdand passes it to the entry.PurchaseOrderAuditPluginforwards the new optional approver note ofapprove()/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,AuditEntryDTO,AuditActorType/AuditChannelenums. 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::AGENTandAuditChannel::SYSTEM. - Retention policy
audit_log(qoliber_b2b/retention/audit_log_days, default 0 = keep forever). Setup\Uninstall.audit_log.company_id(nullable, indexed withcreated_at) +AuditEntryInterface::getCompanyId()— entries can be filtered per company in the admin Audit Log;AuditEntrytakes an optional trailingcompanyId.- Index
audit_log(created_at).
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Available to promise
Added
Api\AvailabilityServiceInterface(getAvailability, batchgetAvailabilities): 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, withApi\SourcePreferenceManagementInterface. - Lead-time rules (dispatch and restock days per SKU, source, or both; most specific first, field by field),
qoliber_b2b_lead_time_rule, withApi\LeadTimeRuleRepositoryInterface. - Incoming supplier deliveries per SKU,
qoliber_b2b_incoming_supply, withApi\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_hintwarns aboutoklines beyond what is available now (configqoliber_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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops the three tables and theqoliber_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/B2bEntityTypeenums,Money/Quantityvalue 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 throughApprovalDecisionInterface.Test\Unit\Console\CommandProxyGuardTest: every CLI command the suite registers must take its non-trivial constructor dependencies as\Proxy, becausesetup:installinstantiates 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, everyEVENT_*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 genericTableRetentionPolicy(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 cronqoliber_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'sSetup\Uninstall(drops the module's tables and core-table columns, deletes its config, cron schedule, indexer/mview state and queue rows) sobin/magento module:uninstall --remove-dataleaves nothing behind;UninstallCoverageTestguards 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()), theCustomerPriceRequest/Line/Price/Tierdata interfaces and theqoliber_b2b_customer_price_resolve_afterevent.
Changed
OutboxRecorderInterface::getUnprocessed()returns events oldest first; the outbox now has a producer and a consumer (B2bIntegration).@param/@returndocblocks (FQCN, house style) on everyApiinterface 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 realcompany_user.user_id; the Magento customer id moved to the newgetCustomerId().CompanyContextResolverInterface::resolveForCustomer(int)added for cart-owner / assisted resolution.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
B2B search
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) andPeriodKeyResolvermapping a period plus a date to a bucket key (Y-m,Y-Qn,Y). BudgetRepositoryInterface:save/getById/delete/deleteById/getListplusresolveBudget(a user-specific active budget outranks a company-wide one).BudgetServiceInterface:getConsumed/getRemaining(ledgerSUM(amount)aggregate),assertWithinBudget(throwsBudgetExceededExceptionwhen 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; eventqoliber_b2b_budget_carry_after.releaseEntity()(release to the original budget and period, clamped to the entity balance, idempotent perreference) andgetEntityBalance(); ledgerreferencecolumn + index.BudgetServiceInterface::consume()— atomic check + record underSELECT … FOR UPDATEon 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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_budget_consume_after,qoliber_b2b_budget_reassign_after,qoliber_b2b_budget_discard_after,qoliber_b2b_budget_release_after. See the README events table.BudgetInterfaceextendsExtensibleDataInterface(getExtensionAttributes()/setExtensionAttributes()), the model extendsAbstractExtensibleModeland carries an_eventPrefixfor model-level events; third parties add fields throughextension_attributes.xmlas with the company.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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.currencyhas no default any more.assertWithinBudget(),consume()andreleaseEntity()accept the amount's currency and convert it into the budget currency before comparing or booking.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.jsonregenerated fromdb_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
referenceonce (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-winsCatalogVisibilityResolverfor 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_companyHTTP 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) carryb2b_catvisandb2b_catvis_<company>; a rule change purges only its company's pages (b2b_catvisfor 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.CleanCategoryCacheOnRuleChangePluginis replaced byPurgeCatalogCachesOnRuleChangePlugin. - 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 onSearchInterface::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 (
CatalogVisibilityContextPluginremoved); the module no longer has area-specific DI. - Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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(afterCatalog\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 andgetAllIds()are correct; the per-sku N+1 lookup inCatalogVisibilityFilteris one query. - Company resolution no longer depends on the frontend customer session; the same company applies on GraphQL and REST (
CompanyGuardContext/CompanyPriceLocator). ProductCollectionVisibilityPluginrecursed infinitely for a logged-in company member:getItems()inside the after-loadplugin re-enteredload(). 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) andApi\CompanyCheckoutSettingsManagementInterface(get/save per company; domain eventqoliber_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 (
beforeCartManagementInterface::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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_over_limit_policy_set_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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, andQoliber_B2bPurchaseOrderguards 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/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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_orderrequest 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_TYPEconstant holds the enum case;->valueis read at runtime (enum property fetch in constant expressions requires PHP 8.2). db_schema_whitelist.jsonregenerated fromdb_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 newbaseCurrencyFor()the website base. - Several companies per customer:
qoliber_b2b/company/allow_multiple_memberships(website, off by default), unique(customer_id, company_id)instead ofcustomer_id,qoliber_b2b_company_selection,Api\CompanySwitcherInterface(memberships,switchTo, eventqoliber_b2b_company_switch_after),Api\MembershipScopeInterface::runAs,CompanyUserRepositoryInterface::getListByCustomerId/getByCustomerAndCompany,ActiveCompanyMemberResolverInterface::findActiveByCompanyUserId/findActiveInCompany;getByCustomerIdanswers the current membership; the customer group follows the current company. - Company customer group:
customer_group_idon the company (CompanyInterface::getCustomerGroupId()/setCustomerGroupId(), validated on save) andCustomerGroupSyncwith its observers and plugin, so members join, follow and leave the company's group. CompanyInterfaceis extensible (ExtensibleDataInterface,getExtensionAttributes()/setExtensionAttributes();CompanyextendsAbstractExtensibleModel), so integrators add company fields throughextension_attributes.xmland repository plugins instead of forking the model.CompanyProfileManagementInterface: actor-scoped self-service profile (getProfile,updateProfilefor name / legal name / tax id) used by the storefront, GraphQL and REST "self" surfaces; merchant-only fields stay onCompanyAdministrationInterface.CompanyCurrencyResolverInterface: the company's website (its own or the default one), that website's default store and base currency, andresolve()which validates a requested currency against it. Every money-carrying service defaults and validates through it.CompanyAdministrationInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.CompanyAdministrationInterface— unscoped company + membership administration for trusted actors (validatedsave,addMember/addMemberByEmailwith one-company-per-customer and same-company-role rules,setMemberAdmin/setMemberStatus/assignMemberRole/removeMemberwith the last-active-admin guard,Exception\LastCompanyAdminException); firesqoliber_b2b_company_member_add_beforeso the permission module can assign the default role. The storefront team service now delegates here.CompanyHttpContext+customer_login/customer_logoutobservers and abeforeExecuteplugin on every storefront action write the acting company (b2b_company) into the full-page-cache vary — inside the login/logout request, like core'sCONTEXT_AUTH, so a member's first page after login is never cached under the guest/no-company hash. Replaces thebeforeDispatchplugins in B2bPricing and B2bCatalogVisibility (whichHttp*ActionInterfacecontrollers never reached).
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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 prefixesqoliber_b2b_company_entityandqoliber_b2b_company_role_entity;SyncMemberGroupsOnCompanySavemoved toqoliber_b2b_company_entity_save_after. - PHPStan level 8 finding on the company context memo key type.
db_schema_whitelist.jsonregenerated fromdb_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.
CustomerCompanyContextResolvernow 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.CustomerCompanyContextResolveris area-agnostic: the actor comes from Magento's compositeUserContextInterface(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 withaddress.use_ownexcepted). 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 onModel\CreditPaymentMethods(di.xml argumentmethods, code =>true).reimburseCompany()(@api): the currency-aware counterpart ofchargeCompany(), used when an approved purchase order is cancelled.CreditManagementInterface::changeLimit()— locked, idempotent-per-reference limit change recorded as a zero-amountlimit_changeledger row.CreditManagementInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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()andchargeCompany()take the amount's currency and convert it into the account currency with the configured rates.qoliber_b2b_credit_account.currencyhas no default any more.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
chargeCompany()/reimburseCompany()rancharge()/reimburse()through the interceptor: every company charge (purchase order approval, pay on account) wrote two audit rows and publishedcredit.charged/credit.reimbursedtwice. They now share a private implementation with the account-level methods.chargeCompany()(and the newreimburseCompany()) move credit through the account internally, which bypassed interception: purchase-order approvals charged credit without anEVENT_CHARGE_AFTERevent or an audit entry. The events plugin andB2bAuditBridgenow 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) soSUM(transaction.amount) == account.balanceholds; idempotent per reference.charge()andreimburse()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 withCreditLimitExceededException.- 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,
InvitationStatusenum, invitation repository (incl.getByToken) andCompanyInvitationManagement(invite, accept with token/expiry validation provisioning a company user, revoke, pureisExpiredhelper). 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_daysandsender_identitysettings. invite()validates the address, refuses inactive companies, foreign roles, addresses already in a company and duplicate pending invitations; recordsinvited_by_company_user_id.accept()recordsaccepted_customer_id/accepted_at.getPendingForCompany(),getByToken().- FK
role_id→qoliber_b2b_company_role(SET NULL); index on(company_id, status). CompanyInvitationManagementInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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()+ cronqoliber_b2b_invitation_expire(every 15 minutes): pending invitations pastexpires_atbecomeexpired(eventqoliber_b2b_company_invitation_expire_sweep_after, auditinvitation.expire_sweep); the accept page andaccept()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/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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-freeInvitationSummaryInterface. 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 conditionalpending → acceptedupdate, so a second accept is refused; the company must be active and the member is added throughCompanyAdministrationInterface::addMember()(friendly duplicate checks, default-role event).revoke()and the newresend()take the acting company-user id and are scoped throughCompanyAdminGuard; only pending invitations can be changed.
Company orders
Added
- Initial release:
company_id,company_user_id,purchase_order_id,cost_center_idonquoteandsales_order(+sales_order_grid.company_id); the buyer's company is stamped on the quote and copied onto the order atsales_model_service_quote_submit_before(every order creation path); extension attributes onOrderInterface/CartInterface; admin order grid column mapping;CompanyOrderHistoryInterface(own orders, or all company orders underorder.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) onquoteandsales_order, copied cart → order byStampCompanyOnSubmit, exposed as thepo_numberextension attribute of carts and orders and in the company orders rows.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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, andPriceListResolverimplementing the B2bCorePriceResolverInterfacepricing 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).IndexedCompanyPriceProvidernow reads the index once per request instead of once per line, prices each line at its break and fillsCustomerPrice::getTiers()with the breaks above one (converted with the price, kept in the price cache, exposed by GraphQLtiers). 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) andCustomerGroupPriceResolver(step 5: the company customer group's tier, special and catalog-rule price);PriceListLookupshares list reads between the resolvers and the index;Model\Company\CompanyAncestrywalks 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_validityre-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 oneblock_htmlkey, so per-customer prices never reach the shared page cache; the browser fetches the visitor's individual prices once per page (Qoliber_B2bFrontendPOST b2b/price/batch, Hyvä counterpart inQoliber_B2bHyva).CompanyPriceLocatorrenders 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 andcompany.price_list_idare 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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_contract_price_save_after,qoliber_b2b_contract_price_delete_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.Model/Customer/CustomerPriceService: asks the registered providers insort_order(DI argumentproviders), first answer per line wins, memoises per request, caches answers in the newqoliber_b2b_pricescache type (tags per customer and company, TTL per answer orqoliber_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) andcompany_index(listing/product/api). Admin: Stores → Configuration → Qoliber → B2B Catalog and Prices → Individual Prices (switch, TTL, cooldown, registered providers).CompanyPriceLocatorresolves through the service and gainsprefetch()(one provider round trip per listing page); the catalog price plugins are unchanged.- New cache type
qoliber_b2b_prices— runbin/magento cache:enable qoliber_b2b_pricesafter 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, tableqoliber_b2b_price_discount, eventsqoliber_b2b_price_discount_save_after/_delete_after), one catalog query per request; saving invalidates the cached prices of the company / customer. Observer/PrefetchListingPrices(frontendcatalog_product_collection_load_after): a listing page is priced in one provider round trip;CompanyPriceLocatorguards against re-entry from a provider's own collection load.
Changed
CompanyPriceLocatormemoises per (company, sku, qty) for the request; the FPC vary plugin moved to Qoliber_B2bCompany (CompanyPriceContextPluginremoved).@param/@returndocblocks (FQCN, house style) on everyApiinterface 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 acurrency(NULL = the company currency) and are converted into the requested currency (ContractPriceResolver).qoliber_b2b_price_list.currencyhas no default any more. - Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
PriceDiscountfired its lifecycle events asqoliber_b2b_price_discount_save_after/_delete_after— the domain event names; its prefix is nowqoliber_b2b_price_discount_entity.ReindexOnParentChangelistens on the company model eventqoliber_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; uniquecontract_price(company_id, sku, valid_from)andprice_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_priceproduct data is right. db_schema_whitelist.jsonregenerated fromdb_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 (
PriceBoxCacheKeyPluginonMagento\Framework\Pricing\Render\PriceBox::getCacheKeyInfo): Magento keys theblock_htmlprice 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 +companyapproval request, purecanActivateguard, 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\CustomerSignupPluginonRegistration::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 (ForceEmailConfirmationPluginonAccountConfirmation), so a company cannot be registered on somebody else's address.qoliber_b2b/registration/enabledswitches self-registration off. - Duplicate detection:
DuplicateCompanyDetectorInterfacerefuses a registration whose name, legal name or tax id matches a non-rejected company (case-insensitive). register()setswebsite_id(defaults to the current website) and takes an optional$websiteId.CompanyRegistrationServiceInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_company_register_after,qoliber_b2b_company_approve_after,qoliber_b2b_company_reject_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
register()writes throughCompanyAdministrationInterface(name validation, friendly "already belongs to company X" instead of a unique-key error) and no longer submits acompanyapproval request — the admin Registration Queue is the decision point, and the registrant no longer receives an "approval requested" mail about their own company.Qoliber_B2bApprovalis no longer a dependency.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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 frompending.
Company returns
Added
ReturnWorkflowInterface::getList(?status, limit)and the backend'sgetList: 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 throughQoliber_B2bReturnsMageOs. Own data types (CompanyReturnInterface,ReturnCommentInterface) instead of Mage-OS's; the merchant workflowReturnWorkflowInterface(approve with quantities, ask for details, reject, receive, resolve, comments) and members'cancel;ReturnStatusChangeInterfacewith eventqoliber_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,addCommentonCompanyReturnServiceInterface;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 byorder.returnandorder.view_all; the resolution typecredit_to_accountrefunds a resolved return with an offline credit memo once (ReturnCreditInterface); eventsqoliber_b2b_return_request_afterandqoliber_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 existingQoliber_B2bCompanytables. - 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 withorder.view_all); not in the Buyer template. - Permission
order.return("Request returns"): members holding it request returns of their orders (of all company orders withorder.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") andinvoice.pay("Pay company invoices") for the AR portal; not in the Buyer template. Observer\AssignDefaultRoleOnMemberAddonqoliber_b2b_company_member_add_before: members added throughCompanyAdministrationInterfacewithout 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);PermissionDefinitionInterfaceexposes 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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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(afterProvisionDefaultRoles) syncs every company; observer onqoliber_b2b_company_save_afterprovisions roles for admin-created companies immediately.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
- PHPStan level 8 findings in
RoleTemplateProviderand 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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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/removeMemberguard the acting admin and then delegate toQoliber\B2bCompany\Api\CompanyAdministrationInterface;Exception\LastCompanyAdminExceptionnow extends the B2bCompany one (catch the parent).@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
CompanyTeamManagementInterface::setStatusdocuments theLocalizedExceptionit 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) andpayment_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 inqoliber_b2b_company_template_version; managed throughApi\CompanyTemplateRepositoryInterface(savevalidates the blueprint and writes a new version only when it changed,getById,getByCode,getList,deleteById,getVersionBlueprint). - Section contributors (
Api\TemplateSectionInterface, di.xml arraysectionsofModel\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) andover_limit_policy. Api\CompanyTemplateManagementInterface:capturea company's setup as a blueprint,preview(dry run) andapplya template inmergeorreplacemode with one transaction per section,processQueued; every application logged inqoliber_b2b_company_template_applicationand read throughApi\TemplateApplicationRepositoryInterface.- Default template on registration approval (website-scoped
qoliber_b2b/company_templates/default_template,group_templates,apply_mode), queued by an observer onqoliber_b2b_company_approve_afterand applied by the cron jobqoliber_b2b_company_template_apply. - Starter templates
smbandenterprise_procurement(data patchInstallStarterTemplates). - 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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops the three tables, theqoliber_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, visibilitypublic/company/internal, statusactive/archived) with SKU (_product, catalog spelling) and company (_company, cascade) assignments;Api\VaultDocumentManagementInterface::save()andApi\VaultDocumentRepositoryInterface. - Versions
qoliber_b2b_vault_document_version: a file stored through B2bDocument's private storage (areavault), an external URL (product passport) or a reference number (EUDR due-diligence statement), withvalid_from,valid_until,review_atand 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 logqoliber_b2b_vault_access_logwithApi\AccessLogRepositoryInterface; retention policyvault_access_log(default 3650 days).Api\ExpiryServiceInterfaceand cronqoliber_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) andDocumentManagement(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 undervar/qoliber_b2b/documentswith 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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
E-invoicing
Added
- XRechnung 3.0 (UBL and CII) and EHF Billing 3.0 as formats:
Model\Format\ProfiledFormatGeneratorprofiles an existing syntax (specification identifier, extra rules),Model\Format\XRechnungRuleschecks the BR-DE rules; configurationseller_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 onsales_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 validatorEn16931Validator(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) andksef_fa3(KSeF FA(3), not yet verified against the official XSD). Api\EInvoiceManagementInterface(issue,issueForInvoice,regenerate,transmit,updateStatus,getDocument), read-onlyApi\EInvoiceRepositoryInterface,Api\CompanySettingsRepositoryInterface; tablesqoliber_b2b_einvoice(fiscal record: no foreign key to sales tables, company SET NULL) andqoliber_b2b_einvoice_company_settings(company CASCADE).- Observer on
sales_order_invoice_save_afterissuing 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\AccessPointInterfacewith the built-instore_onlyaccess point; configuration in Stores → Configuration → Qoliber → B2B Finance and Tax → E-Invoicing; domain eventsqoliber_b2b_einvoice_issue_after,_regenerate_after,_transmit_after,_status_update_after,qoliber_b2b_einvoice_settings_save_after,_delete_after.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall): both tables and theqoliber_b2b/e_invoice/configuration.
GraphQL API
Added
- Company addresses:
b2bCompanyAddresses,saveB2bCompanyAddress,deleteB2bCompanyAddress,setB2bCompanyAddressesOnly,CustomerAddress.b2b_company_address_id. B2bCompanyRole.catalogandsetCompanyRoleCatalog(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'scategories,urlResolver/routeand the category aggregation. Fixed: GraphQL responses were cached without the company — the company is now a factor ofX-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/createCustomerfollow "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.
currencyonCompany.- 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(typeB2bOrderAmendment). - Company returns GraphQL on own types (no Mage-OS requirement):
B2bReturn,B2bReturnLine,B2bReturnComment,B2bReturnOption,B2bReturnLineInput;return_idarguments;cancelB2bReturn. - Company returns over GraphQL:
b2bReturns,b2bReturn,b2bReturnableOrders,b2bReturnableItems,b2bReturnOptions,requestB2bReturn,addB2bReturnComment(Mage-OS RMA'sCustomerReturntype). - Company switching:
b2bCompanies(B2bCompanyMembership: company, role, admin flag, statuses,is_current,is_available) andswitchB2bCompany(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_statusandreview_note(why an order was cancelled after review).- Saved carts:
b2bSavedCarts,b2bSavedCart; mutationssaveB2bCart,restoreB2bSavedCart,renameB2bSavedCart,shareB2bSavedCart,handOffB2bSavedCart,deleteB2bSavedCart. - Checkout references:
b2bCheckoutRequirements,setB2bCheckoutReferences,Cart.b2b_checkout_references;CustomerOrder.b2bgainspo_number,cost_centerandrequested_delivery_date. - Company account for the signed-in member:
b2bAccount(owed, overdue, aging, terms, hold, credit),b2bAccountInvoices/b2bAccountInvoice,b2bAccountStatement,b2bOnAccountDecision,b2bInvoicePaymentMethods,b2bPaymentRequests,b2bRemittanceAdvice; mutationsprepareB2bInvoicePayment,payB2bPaymentRequest,cancelB2bPaymentRequest. - Seller-assisted buying: query
b2bAssistance(consents, pending order confirmations, activity) and mutationsgrantB2bAssistance,revokeB2bAssistance,confirmB2bAssistedOrder,declineB2bAssistedOrder. - Order guides and standing orders: queries
b2bOrderGuide(includeHidden,withCurrentPrices),b2bOrderGuideDue,b2bStandingOrders,b2bStandingOrder(withruns); mutationsannotateB2bOrderGuideLine,addB2bOrderGuideLinesToCart,saveB2bStandingOrder(create or change),pauseB2bStandingOrder,resumeB2bStandingOrder,deleteB2bStandingOrder(resolvers inModel\Resolver\OrderGuide; the order guide services apply their own rules). - Approval depth:
B2bApprovalStepstage, quorum, activation and escalation fields;B2bApprovalRequest.votesandcan_decide;noteon 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); mutationscreateB2bPurchaseOrderFromCart,submitB2bPurchaseOrder,cancelB2bPurchaseOrder,setB2bPurchaseOrderNumber,updateB2bPurchaseOrderItemQty,removeB2bPurchaseOrderItem,b2bPurchaseOrderToCart; typesB2bPurchaseOrder(withcan_*flags for the viewer) andB2bPurchaseOrderItem. - Requisition lists: queries
b2bRequisitionLists,b2bRequisitionList(listId); mutationscreateB2bRequisitionList,renameB2bRequisitionList,shareB2bRequisitionList,deleteB2bRequisitionList,addB2bRequisitionListItems,setB2bRequisitionListItemQty,removeB2bRequisitionListItem,requisitionListFromOrder(listId, orderNumber),requisitionListToCart,requisitionListToQuote,requisitionListToPurchaseOrder. TypesB2bRequisitionList(withis_owner,can_edit,can_delete,owner_name,updated_by_name) andB2bRequisitionListItem. - Quick order: queries
b2bQuickOrderDraft,b2bQuickOrderParse(text),b2bQuickOrderValidate(lines),b2bProductSearch(term, limit); mutationsb2bQuickOrderDraftAdd,b2bQuickOrderDraftSetQty,b2bQuickOrderDraftRemove,b2bQuickOrderDraftClear,b2bQuickOrderAddToCart(lines)(without lines converts the draft). TypesB2bQuickOrderLineInput,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.companyRolesandcompanyPermissionsqueries (non-cacheable) andcreateCompanyRole,renameCompanyRole,deleteCompanyRole,setCompanyRolePermissionsmutations;ActingMemberhelper 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). addB2bQuoteItemno longer takesprice;proposeB2bQuotePriceis a buyer counter-offer (requires quote.create) andacceptB2bQuoteProposalaccepts seller offers only;B2bQuoteItemexposesproduct_id,name,list_price.createB2bQuote(currency)andsetMemberSpendLimit(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/rejectApprovalRequestdecide throughApprovalDecisionInterface, 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,rejectApprovalRequestare 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
CompanyDashboardcomponent + 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 itsb2b-price-checkwrapper. - 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 ofHyva\Theme\Service\Navigation::getCategoryTree().- Quote screens:
Magewire\Quotes,Magewire\QuoteView,Magewire\AgreementView, cart-page "Request a quote";HyvaHandlesTestcoversb2b/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;HyvaHandlesTestnow also covers quick order, requisition lists and purchase orders. Declaresqoliber/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.xmlreplaces the Luma script): onefetchtob2b/price/batchper page, listing prices rewritten in place (Alpine price boxes may be deferred) andupdate-prices-<productId>window events withfinalPrice/oldPrice/tierPricesfor 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.xmlnext 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), sohyva:config:generateadds 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 thewire:modelinputs 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 andmagewirephp/magewire-hyva-theme^3.1 and loads afterMagewirephp_MagewireHyvaTheme(wasMagewirephp_MagewireCompatibilityWithHyva). - Company finance shows the budget and the credit account each in its own currency (
budgetCurrency,creditCurrency);currencyis the company currency.
Fixed
- The README's install command enabled
Magewirephp_MagewireCompatibilityWithHyva(Magewire up to 3.1); it enablesMagewirephp_MagewireHyvaTheme, the module this one depends on (a unit test keeps the command andetc/module.xmltogether). - 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_healthin its own cron groupqoliber_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-inno_success(silence after earlier deliveries). One index-friendly snapshot per run (Model\Monitor\HealthSnapshotProvider); extensions add monitors throughApi\HealthMonitorInterfaceand the di.xml arraymonitors. - Alerts (
qoliber_b2b_integration_alert): one per incident and dedupe key (<monitor>:<endpoint>, uniqueactive_keywhile 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 templateqoliber_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) andreplayEndpoint()(one endpoint's finished deliveries within a time range, by state, limited), throughWebhookDeliveryInterface::redeliver(). - Domain events
qoliber_b2b_integration_alert_open_after,qoliber_b2b_integration_alert_acknowledge_after,qoliber_b2b_integration_alert_resolve_afterandqoliber_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 policyintegration_alert(resolved alerts, 90 days),Setup\Uninstall.
Invoice portal
Added
- REST for the buyer (in
Qoliber_B2bApiAccountRestInterface). - Deposits before the order:
DepositManagementInterface(requesta percent or amount of an approved quote or purchase order,cancel,getForSource,isSettled) — adepositpayment request without lines (source_type,source_idcolumns), paid in the portal and held on account as a B2bPaymentTerms prepayment;DepositPlaceOrderGuardrefuses placing the order while it is unpaid;ApplyDepositToInvoiceapplies it to the order's receivable. Sources inModel\Deposit\DepositSourcePool; eventsqoliber_b2b_invoice_portal_deposit_request_after/…_cancel_after. - Card payments through Stripe Checkout (
stripeadapter, 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/webhookverifies theStripe-Signature(HMAC-SHA256, five-minute tolerance) and confirms (checkout.session.completedpaid,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,getInvoicesopen / 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_lineand attempts…_attempt:Api\PaymentRequestManagementInterface(getMethods,create,pay,cancel,getById,getList,getRemittanceAdvice) withModel\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 itsAR-…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-intestandbank_transferadapters (both off by default); acceptance (Model\Method\MethodAcceptance) and surcharge (Model\Surcharge\SurchargeCalculator) rules per method; per-company rulesqoliber_b2b_invoice_portal_company_methodthroughApi\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), configurationqoliber_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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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'sexecute().
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/createleads 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 needinvoice.view, payersinvoice.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 andcatalog_feed.manageonly. - 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 fromPriceAvailabilityMatrixInterface::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 onQoliber_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) — andb2b/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\QuoteStorefrontshared with Hyvä; account navigation link. - My Company → Purchase Orders (
b2b/purchaseorder): list (all company orders withorder.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 tob2b/purchaseorder/act. The cart page offers "Save as purchase order" (with an optional PO number) to members withorder.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 byifconfig) collects the Luma price boxes, calls it once and rewrites the final/regular prices in place (contentUpdatedre-runs it for ajax listings).- My Company: company admins edit the display name, legal name and tax id in place (
b2b/company/save, throughCompanyProfileManagementInterface). - 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 (POSTb2b/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 underorder.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
hiddenattribute. - 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, theroutersargument ofModel\MailIntake) before the order intake: a claimed mail is recorded asrouted; parsed mail carries its recipients, In-Reply-To and References (Api\Data\InboundMessageInterface); settingorder_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 (templatesreturn_requested,return_status_changed). - The sender of an e-mailed order hears when it became a draft quote or why it was rejected (
intakeMailDecided, templatesintake_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()andadminsAllowed()(role rules, any area). - Order guide notifications (
Plugin\OrderGuideNotificationPluginonReorderDueDetectorInterface::detectandStandingOrderRunnerInterface::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. Templatesorder_guide_reorder_dueandorder_guide_standing_order_run, selectable under Stores → Configuration → Qoliber → B2B Notifications → Notification Templates. - "Approval Escalated" e-mail (
approval.escalated, templateapproval_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_urland 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). NotifierInterfaceevents: 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 cronqoliber_b2b_quote_expiry_reminder,qoliber_b2b/notifications/quote_expiry_reminder_days), purchase order status (approvers holdingorder.approvewhile 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 (
AsyncInvitationMailerreplaces the in-request mailer by preference) and is sent regardless of the notifications switch. Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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.
PurchaseOrderNotificationPluginforwards the new optional approver note ofapprove()/reject().- Delivery is asynchronous by default: notifications are published to the
qoliber.b2b.notification.sendqueue (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.
NotificationSenderis gone; useNotifierInterface.- Budget-exceeded, purchase-order and approval e-mails print the currency next to the amount.
- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.amendchange 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 withinhistory_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, frommin_ordersbuying days) and next-due date;Api\OrderGuideBuilderInterface::rebuild(companyId, skus)keeps member annotations and drops lines without history unless annotated. - Incremental refresh:
Observer\MarkGuideStalequeues the SKUs of placed and cancelled company orders (qoliber_b2b_order_guide_stale); cronsqoliber_b2b_order_guide_refresh(every 5 minutes) andqoliber_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()(cronqoliber_b2b_order_guide_reorder_due, 06:00): overdue (margindue_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 withorder.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) andApi\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 withoutorder.placepauses it); cronqoliber_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(extendsQoliber\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, optionalcompany_id,type,label), upsert per scope and normalised part number through the repository (validation, events, audit), refused rows reported asLine <n>: <reason>;Api\Data\PartNumberImportResultInterface(created / updated / errors).- Part numbers
qoliber_b2b_part_number(company or global, SKU, part number as entered, normalised key, typecustomer/competitor/manufacturer/alias, label) withApi\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,resolveManyin two queries) and the reverse lookupgetCompanyPartNumbersfor 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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops the table.
Payment and shipping method rules
Added
- Initial release: enforces B2bPolicy
payment.use/shipping.usecapabilities at checkout via a plugin onMagento\Checkout\Api\PaymentInformationManagementInterface::savePaymentInformationAndPlaceOrder.
Changed
- Enforcement moved to the domain choke points:
before CartManagementInterface::placeOrder(every channel), apayment_method_is_activeobserver (denied methods are not listed and cannot be imported on the quote) and anafter Shipping::collectRatesfilter (denied carrier methods are not offered). ThePaymentInformationManagementInterfaceplugin is removed. CartMethodPolicyInterfaceresolves the company from the cart's customer, so GraphQL, REST, admin and cron see the same restrictions as the storefront.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
- Payment/shipping restrictions were bypassable via GraphQL
placeOrder, RESTcarts/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, columnfallback_providers). A provider that is down (saysprovider_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_afterandpayment_record_aftercarryreplayed(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 untilapplyUnallocated()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) andReceivablePaymentManagementInterface::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_termswithApi\PaymentTermsRepositoryInterface(save,getById,getByCode,getList,deleteById); typesnet,end_of_month,day_of_next_month(Model\Enum\TermsType::dueDate), optional early-payment discount; validationModel\Terms\TermsRules; data patchInstallPaymentTermsseeds 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\Adaptervirtual typeGateway\OnAccountFacade, payment actionauthorize(Gateway\Command\AuthorizeCommand→Model\Payment\OnAccountAuthorizer),payment.xml(no multishipping), availability throughpayment_method_is_active(Observer\RestrictOnAccountMethod,Api\OnAccountAvailabilityInterface, reasonsModel\Enum\OnAccountDecline). - Luma checkout renderer (
view/frontend: layout, JS renderer, template) with the terms, due date and discount hints fromModel\Checkout\OnAccountConfigProvider/Model\Checkout\TermsPreview. - Credit provider seam
Api\CreditProviderInterface,Model\Provider\CreditProviderPool, built-inModel\Provider\OwnBookCreditProvider(Qoliber_B2bCredit credit line). - Receivables
qoliber_b2b_payment_terms_receivableand ledgerqoliber_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) withModel\Payment\PaymentAllocator(named or oldest due first, early-payment discount, unallocated rest). - Aging
Api\AgingReportInterface(Model\Aging\AgingCalculator,Model\Enum\AgingBucket), dunningApi\DunningManagementInterface(Model\Dunning\DunningSchedule), cronqoliber_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 acreated_atindex on the evaluation log. Setup\Uninstall.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
db_schema_whitelist.jsonregenerated fromdb_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 holdscatalog_feed.manage; another company's token does not exist for the caller. b2b.price_tiersis 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 mosttoken_max_lifetime_days), revocation with a reason, a cap on active tokens per company, usage counters;Api\FeedTokenManagementInterface(issue, revoke, getById, getByCompany) andApi\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 undervar/import_export/qoliber_b2b_catalog_feed/, swapped atomically; item cap with a truncation flag. - Storefront endpoint
GET /b2bfeed/feed?format=json|csvauthenticated only by the token (Bearer header ortokenparameter): 401 without a token, the same bare 404 for any unusable token, 400 for an unknown format, 429 withRetry-Afterover the hourly limit, 503 while no fresh build exists, 304 onIf-None-Match, gzip when accepted,Cache-Control: private, no-store. - Refresh cron
qoliber_b2b_catalog_feed_refresh(every 5 minutes, own cron groupqoliber_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_quantity0) — 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(extendsQoliber\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) andPunchoutSessionRepository. expireStale()+ hourly cronqoliber_b2b_punchout_expire: active PunchOut sessions older thanqoliber_b2b/procurement/session_ttl_hours(default 24) becomeexpired(eventqoliber_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/@returndocblocks (FQCN, house style) on everyApiinterface 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 emittingUSD.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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,
PurchaseOrderStatusworkflow enum (draft, pending_approval, approved, rejected, ordered, cancelled) with guarded transitions,PurchaseOrderManagement(create, add item with grand-total recalculation, submit for approval, approve, reject) andPurchaseOrderRepository. - 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) andtoCart()(an approved order fills the buyer's cart at the approved prices, stamped withpurchase_order_idandpo_number);Plugin\PlaceOrderPurchaseOrderGuardrefuses 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 itordered, linksorder_id/order_increment_idand 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(); eventsEVENT_CANCEL_AFTER,EVENT_ORDERED_AFTER.PurchaseOrderAuthorizationInterface(@api): the owner sees and manages;order.view_allmembers and company admins see all company orders; admins manage them; creating needsorder.place.PurchaseOrderSelfServiceInterface(@api) applies it for every surface.PurchaseOrderRepositoryInterface::getByCompany().Model\PurchaseOrderPresenterbuilds 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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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.PurchaseOrderInterfaceextendsExtensibleDataInterface(getExtensionAttributes()/setExtensionAttributes()), the model extendsAbstractExtensibleModeland carries an_eventPrefixfor model-level events; third parties add fields throughextension_attributes.xmlas with the company.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
approve()andreject()take an optional$note, recorded on the approval step.submitForApproval,approveandrejectrun 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/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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 inQoliber\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_approvaland budget and credit were never booked.Model\Approval\PurchaseOrderDecisionHandlernow routes those decisions throughapprove()/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 ofQuickOrderLinevalue objects with per-line validation. - Line rules:
Api\QuickOrderLineRuleInterface(@api) runs after the product, catalog-access and stock checks; the first refusal marks the lineinvalid_qtywith the rule's message. Built in:MinSaleQtyRule,MaxSaleQtyRule,QtyIncrementsRule(stock item minimum/maximum and case-pack increments). Integrators register their own onModel\QuickOrderValidatorargumentrules. - 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) andmax_lines(default 500, a paste/CSV/XLSX above it is refused as a whole;parseCsv/parsePastedTextnow document theLocalizedException). Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.jsonregenerated fromdb_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 inputitem_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,
UploadedByTypeenum (buyer/seller), andQuoteAttachmentManagement(attach, list, count, remove). - Real file storage:
upload()validates and stores the bytes throughQoliber_B2bDocument'sDocumentStorageInterface, records the content-sniffed MIME type and a SHA-256checksum(new column);get(),getContent();remove()deletes the stored file; open-status rule and a per-quote limit (qoliber_b2b/documents/max_per_quote); domain eventsEVENT_UPLOAD_AFTER/EVENT_REMOVE_AFTER. Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
attach()is deprecated (metadata only); useupload().@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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_ordercolumns,fieldset.xml), so orders are traceable to their quote. PlaceOrderQuoteGuardonCartManagementInterface::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.MarkQuoteOrderedOnPlaceobserver: the quote becomesorderedand recordsorder_idwhen 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 leavescustomer_is_guest = 1on the cart.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
- The order placed from a quote's cart now carries
b2b_quote_id(observerCopyQuoteIdToOrderonsales_model_service_quote_submit_before): core merges the converted order throughOrderInterfaceonly, which dropped thefieldset.xmlcopy — so the quote was never markedorderednor 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
Storemodel. - A converted quote was marked ordered on
sales_order_place_after, which fires insideOrder::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 tosales_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\QuoteDeskStatsInterfaceand their data interfaces. - Seller ownership
qoliber_b2b_quote_desk(one row per quote, FK to the quote CASCADE and toadmin_userSET 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
costattribute, or an ERP throughLineCostProviderInterface), 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 throwsException\MarginExceptionPendingExceptioninstead 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 afterat_risk_percent; seller comments, offers, decisions and transitions answer it; cronqoliber_b2b_quote_desk_slaflags 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), ACLQoliber_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_afterandqoliber_b2b_quote_margin_exception_request_after,_approve_after,_reject_after,_cancel_after(Plugin/Events, sortOrder 3000). Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall): drops both tables, theqoliber_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_expiryside table,QuoteExpirationService(set/get expiry, isExpired, expireOverdue) and a nightlyqoliber_b2b_quote_expirecron 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)onQuoteExpirationServiceInterfaceand thereminder_sent_atcolumn, backing the expiry reminder e-mail (Qoliber_B2bNotification cron).QuoteExpirationServiceInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_quote_expiry_set_after,qoliber_b2b_quote_expire_sweep_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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 calledsetExpiry(), 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 onQuoteManagementviadi.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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest). @param/@returndocblocks (FQCN, house style) on everyApiinterface 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_STATUSESholds enum cases and statuses are compared viaQuoteStatus::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) andProposedByType(buyer/seller). QuoteNegotiationService:proposePrice,acceptProposal(applies proposed price to the quote item),rejectProposal,snapshotVersion(JSON snapshot with sequentialversion_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);reasoncolumn; snapshots keep status, currency and full lines. QuoteHistoryInterface::getHistory()— buyer-readable versions with the changes against the previous one.Observer\WithdrawOpenProposalscloses open proposals when a line's quantity changes.QuoteNegotiationServiceInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_quote_negotiation_propose_after,qoliber_b2b_quote_negotiation_accept_after,qoliber_b2b_quote_negotiation_reject_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Fixed
- Foreign keys
quote_version.quote_id→ quote andquote_line_negotiation.quote_item_id→ quote item (CASCADE); uniquequote_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 asbuyerand 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,
QuoteStatusworkflow enum,QuoteRepository, andQuoteManagement(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) andremoveItem()on editable quotes, with eventsEVENT_ITEM_UPDATE_AFTER/EVENT_ITEM_REMOVE_AFTER.QuoteProposalPdfInterface(Zend_Pdf proposal: seller, company, quote facts, lines, total, seller note) and theQuoteProposalFactProviderInterfacehook for header facts.getCreatedAt()/getUpdatedAt()onQuoteInterface,getCreatedAt()onQuoteCommentInterface.- Index
quote(status, created_at). QuoteTransitionGuardInterfaceextension point run on every transition and viaassertCanTransition(); other modules register guards throughdi.xml.- Quote lines snapshot
product_id,nameandlist_price; quotes carryorder_id(schema). QuoteStatus::isEditable(); an approved quote may now expire until it is ordered.QuoteManagementInterface::EVENT_*constants andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_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.QuoteInterfaceextendsExtensibleDataInterface(getExtensionAttributes()/setExtensionAttributes()), the model extendsAbstractExtensibleModeland carries an_eventPrefixfor model-level events; third parties add fields throughextension_attributes.xmlas with the company.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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.currencyhas no default any more.- Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest). QuoteLinePriceResolverInterface::resolve()takes the buyer (?int $customerId) and prices the line throughCustomerPriceServiceInterface(channelquote), 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;
qtymust 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.manageeditors / admin deleters),getList,getListsFor,renameList,setShared,deleteList,getItems,updateItemQty,removeItem,addFromOrder(reorder from history),updated_byon lists and lines,RequisitionListConversionInterface(toCart,toQuote,toPurchaseOrderon the quick-order validation),EVENT_*_AFTERevents withPlugin/Events. Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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.xmldeclaremagento/module-customer(used byModel\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/customersfollows "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\CustomerSignupClosedPluginonAccountManagementApi). - 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(resourceQoliber_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; resourceQoliber_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_idreplacesentity_id,/returns/:returnId…, andPOST /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,/commentsread and add) —CompanyReturnRestInterface, resourceself. - Merchant routes for e-mailed orders (
Api\IntakeMailRestInterface, ACLQoliber_B2bApi::intake_mail): list, read, lines, assign, correct and remove lines, convert, reject. - Merchant e-invoice routes (
Api\EInvoiceRestInterface, ACLQoliber_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/membershipsandPOST /V1/qoliber-b2b/company/switch(companyId; answers the memberships), resourceself(Api\CompanySwitchRestInterface). - Agent tokens:
GET/POST /V1/qoliber-b2b/agent-tokens,POST …/agent-tokens/:tokenId/revoke(resourceself; the plain token is returned once); for the merchantGET/POST /V1/qoliber-b2b/companies/:companyId/agent-tokensandPOST /V1/qoliber-b2b/merchant/agent-tokens/:tokenId/revoke(ACLQoliber_B2bApi::agent_tokens). - Delivery per line:
GET /V1/qoliber-b2b/ship-to/destinations,GET/POST …/ship-to/cart-lines(resourceself),GET /V1/qoliber-b2b/orders/:orderId/destinations(ACLQoliber_B2bApi::ship_to). GET/PUT /V1/qoliber-b2b/companies/:companyId/branch-pickup(ACLQoliber_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(ACLQoliber_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(resourceself). POST /V1/qoliber-b2b/companies/:companyId/payment-terms/fallback-providers(ACLQoliber_B2bApi::net_terms).- The company account for the buyer (
AccountRestInterfaceunder/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;NetTermsAdminRestInterfacerecords payments, write-offs and holds as the token's admin user). ACLQoliber_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);QuoteDeskRestInterfacedecides price exceptions as the token's admin user (integration tokens refused). ACLQoliber_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 andcatalog_feed.manage; the plain token is returned once) andPOST /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, ACLQoliber_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-templateslist/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/Datainterface 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}]+ optionalcurrency.
Changed
POST /V1/qoliber-b2b/quotes/:quoteId/itemsno longer takesprice; propose/accept follow the buyer/seller party rule.- REST
createQuoteandsetMemberSpendLimitno longer default the currency toUSD; the services resolve the company website's base currency. - Every
Apiinterface carries the@apitag and the license header (guarded byQoliber\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/inviteno longer returns the invitation secret: it answers withInvitationSummaryInterface(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:
MageOsBackendbehindQoliber\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 thecredit_to_accountresolution 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 andPlugin/Eventsafter-plugins (sortOrder 3000, inside the audit transaction) dispatchingqoliber_b2b_company_sales_rep_assign_after,qoliber_b2b_company_sales_rep_unassign_after. See the README events table.Setup\Uninstall(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops this module's tables/columns, config and cron rows.
Changed
@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\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
expandiat; an account is linked by e-mail only whenemail_verifiedis true or the provider trusts its e-mail addresses (newtrust_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 newprivate_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
emailclaim, an e-mail-shapedpreferred_usernamecounted as verified whenemail_verifiedwas true — only theemailclaim 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, templateqoliber_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.viewsee 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.countandvmi.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):skuplus optionalcompany_id,min_qty,qty_multiple; upsert per SKU and company through the repository (validation, events, audit); refused rows reported asLine <n>: <reason>.- Unit-of-measure master
qoliber_b2b_unit_of_measure(code, label, UN/ECE Recommendation 20/21 code, sort order) withApi\UnitOfMeasureRepositoryInterface(save,get,getList,delete; a unit a product is sold in cannot be deleted); data patchInstallUnitsOfMeasureseeds 19 distribution units (EA … BFT). - Pack sizes
qoliber_b2b_product_unit(SKU, unit, factor in base units, base flag) withApi\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 (EAwithout 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) withApi\OrderQtyRuleRepositoryInterfaceandApi\OrderQtyServiceInterface(field-by-field policy, minimum rounded up to the multiple,check,suggestQty, batchgetPolicies). - Quick order:
Model\QuickOrder\OrderQtyLineRuleregistered as quick-order line ruleqoliber_b2b_order_qtyafter 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(extendsQoliber\B2bCore\Setup\ModuleUninstall):module:uninstall --remove-datadrops 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, elseunverifiedand due now. - VIES validation:
Api\VatValidatorInterface→Model\Vies\ViesVatValidator(merchant VAT ID from Store Information as requester → consultation number),Api\ViesTransportInterface→Model\Vies\CurlViesTransport(RESTcheck-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\RevalidationScheduleand cronqoliber_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; lifecycleModel\Enum\CertificateStatus, rulesModel\Certificate\CertificateRules,Jurisdiction,ExpiryWindow; cronqoliber_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_*_afterandqoliber_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) andWebhookDispatcher(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.acceptedandagent_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.requestedandreturn.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.restoredandsaved_cart.deleted. - Webhook topics
deposit.requestedanddeposit.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_deletedand the sweep'svault_document.expiring/expired/review_due. - Webhook topics for order guides:
order_guide.line_annotated/added_to_cart/reorder_dueandstanding_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_updatedandeinvoice_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\TopicCatalogmaps 78 domain events to public webhook topics (purchase_order.approved,company.member_added, …;di.xmlargumenttopics),Observer\RecordOutboxEventon 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\PayloadNormalizerturns 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()(columnevents, 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; uniquesync_log(endpoint_id, event_id)makes a repeated relay harmless. sync_logcolumnsevent_id,response_code,next_attempt_at,delivered_at(+ indexstatus, next_attempt_at); endpoint columnsevents,updated_at;secretwidened 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) aftermax_attempts; rows of inactive endpoints wait. - Signatures:
Api\WebhookSignatureInterface→WebhookSigner—X-Qoliber-Signature: t=<unix>,v1=<HMAC-SHA256(secret, "t.body")>with tolerance-checkedverify(); headersX-Qoliber-Event,-Event-Id,-Delivery,-Attempt. - Transport:
Api\WebhookTransportInterface→CurlTransport(http(s) only, no redirects, connect timeout ≤ 5 s) returningApi\Data\WebhookTransportResultInterface; never throws for a failed request. - Cron
qoliber_b2b_webhooks(every minute: relay, then deliver) andbin/magento qoliber:b2b:webhooks:process [--limit=N]; config Stores → Configuration → Qoliber → B2B Integrations and Data → Webhooks (max_attempts10,timeout10,batch_size100); 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:
TopicCatalogTestfails when a newEVENT_*_AFTERhas 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; onlysuccess/failedrows). Setup\Uninstall.
Changed
WebhookDispatcherInterface::dispatch()queues the row due immediately (next_attempt_at), so the delivery worker sends it;signPayload()is deprecated in favour ofWebhookSignatureInterface.@param/@returndocblocks (FQCN, house style) on everyApiinterface 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
Apiinterface carries the@apitag and the license header (guarded byQoliber\B2bCore\Test\Unit\Api\ApiContractTest).
Security
- Endpoint signing secrets are encrypted at rest (
EncryptorInterfacein the resource model; plaintext only in memory); data patchEncryptEndpointSecretsencrypts any secret a pre-release install stored in plaintext. An endpoint saved without a secret gets a generated one — webhooks are never unsigned.