Credit Limit Resolvers
Hyvä POS is in closed beta
Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change - possibly in backwards-incompatible ways - before the general release. Want to take part? Sign up at hyva.io/pos.
A B2B customer stands at the register and asks to pay on account. Before the cashier can offer the invoice tender, the register needs one number: how much credit does this customer have left. A credit-limit resolver supplies that number from wherever your credit decisions live - a BNPL provider, ERP receivables, a B2B credit module.
The app requests the limit through GET /V1/pos/customers/:customerId/credit-limit. On the Magento side, CreditLimitService asks the resolver pool for the customer's limit, computes used (the sum of the customer's open, unpaid invoices) and available (max(0, limit - used)), and returns the row. The app renders the headroom next to the pay-by-invoice option and makes the option unavailable when the limit is zero.
This page covers only the resolver. For module scaffolding - composer.json, module.xml, the registration guard - follow Building a Bridge first.
The Interface
One interface, one method:
namespace Hyva\Pos\Api;
interface CreditLimitResolverInterface
{
/**
* @param int $customerId
* @param int $websiteId
* @return \Hyva\Pos\Api\Data\Customer\CreditLimitInterface|null
*/
public function resolve(int $customerId, int $websiteId): ?\Hyva\Pos\Api\Data\Customer\CreditLimitInterface;
}
On the returned CreditLimitInterface row, set exactly three fields:
limit- the absolute credit ceiling, in the website's currency. Zero means "no credit extended": the app renders pay-by-invoice as unavailable, not as "limit reached".currency- the ISO currency code the limit is denominated in.source- a stable token identifying your resolver (for exampleacme_erp). It drives the receipt, the audit trail, and the cashier's read on where the number came from.
Do not set used or available. CreditLimitService recomputes used from Magento's open invoices and derives available after your resolver returns; anything you write into those two fields is overwritten, so do not query your backend for them.
Return null when your resolver has no opinion on this customer - the pool walks on to the next resolver. Return null on a lookup failure too, after logging it: a thrown exception aborts the whole pool walk and denies the cashier a limit that a later resolver could have supplied.
Contract Rules
- First non-null wins. The pool (
Hyva\Pos\Model\Customer\CreditLimitResolverPool) walks resolvers in declared order and returns the first non-null result. Earlier resolvers take precedence. - Null means "no opinion", zero means "no credit". A
nullreturn lets the pool ask the next resolver. A row withlimitset to0.0is a definitive deny that stops the pool - return it only when you positively mean to refuse credit, never for "customer not found in our system". - Never throw. Catch your own lookup failures, log them, and return
null. Null is the safe degradation. - No resolver, clean default. When every resolver returns
null,CreditLimitServicereturns a "no limit set" row:limitandavailableare0.0,sourceisnone, andusedstill carries the customer's outstanding receivables so staff see historical open invoices even when nobody extends credit anymore. - Two built-ins ship with the core, in this order:
b2b_company_credit(Adobe Commerce B2B Company Credit, inert on Open Source) andhyva_pos(the per-customerhyva_pos_credit_limitattribute). Both returnnullwhen they hold no data for a customer, so your resolver is still asked in the common case.
Build It
1. Implement the Resolver
Constructor-inject your backend client and the row factory. This example reads a fictional ERP:
<?php
declare(strict_types=1);
namespace Acme\PosBridge\Model;
use Acme\Erp\Api\CreditAccountRepositoryInterface;
use Hyva\Pos\Api\CreditLimitResolverInterface;
use Hyva\Pos\Api\Data\Customer\CreditLimitInterface;
use Hyva\Pos\Api\Data\Customer\CreditLimitInterfaceFactory;
use Psr\Log\LoggerInterface;
class CreditLimitResolver implements CreditLimitResolverInterface
{
public function __construct(
private readonly CreditAccountRepositoryInterface $accounts,
private readonly CreditLimitInterfaceFactory $limitFactory,
private readonly LoggerInterface $logger
) {
}
public function resolve(int $customerId, int $websiteId): ?CreditLimitInterface
{
try {
$account = $this->accounts->getByCustomerId($customerId); // your lookup
} catch (\Throwable $e) {
$this->logger->warning('Acme_PosBridge: ERP credit lookup failed', [
'customerId' => $customerId,
'exception' => $e->getMessage(),
]);
return null; // never throw: it aborts the whole pool walk
}
if ($account === null || !$account->extendsCredit()) {
return null; // no opinion: the pool asks the next resolver
}
$row = $this->limitFactory->create();
$row->setCustomerId($customerId)
->setLimit((float) $account->getCreditCeiling())
->setCurrency($account->getCurrencyCode())
->setSource('acme_erp');
return $row; // used + available are filled in by the service
}
}
2. Register It in the Pool
The di.xml argument name is resolvers:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Hyva\Pos\Model\Customer\CreditLimitResolverPool">
<arguments>
<argument name="resolvers" xsi:type="array">
<item name="acme_erp" xsi:type="object">Acme\PosBridge\Model\CreditLimitResolver</item>
</argument>
</arguments>
</type>
</config>
Use xsi:type="object" for this pool. It validates entries against the interface at walk time and silently skips anything that is not an instance - a string entry never runs. The missing-vendor case is handled by the registration guard from Building a Bridge, which keeps the whole bridge (including this di.xml) out of a compile when the vendor module is absent.
3. Verify at the Register
Run bin/magento setup:upgrade && bin/magento setup:di:compile, then check the wiring end to end:
- Attach a customer your backend extends credit to and start a checkout. Pay-by-invoice is offered, with the available headroom shown next to it.
- Exhaust the limit (open unpaid invoices at or above the limit) and try again. The pay-by-invoice option is blocked.
You can also hit the endpoint directly:
curl -H "Authorization: Bearer <token>" \
https://example.test/rest/V1/pos/customers/42/credit-limit
Confirm limit, currency, and source carry your values, and that used and available reflect the customer's open invoices.
Prove It
There is no abstract conformance test for credit-limit resolvers in Hyva\Pos\TestFramework\Conformance - the contract for this pool is documented at docblock level on the interface. Cover it with targeted unit tests instead. Three scenarios matter:
- Limit found: a known customer returns a row with
limit,currency, andsourceset. - No data: an unknown customer returns
null. - Backend failure: a lookup exception returns
nullwithout throwing.
public function testBackendFailureReturnsNullInsteadOfThrowing(): void
{
$this->accounts->method('getByCustomerId')
->willThrowException(new \RuntimeException('ERP down'));
self::assertNull($this->resolver->resolve(42, 1));
}
Gotchas
- Do not return a zero-limit row for missing data. Zero is a definitive "no credit" that short-circuits the pool; a later resolver that could have extended credit is never asked. Missing data is
null. usedandavailableare not yours. The service overwrites both from Magento's open-invoice aggregate. Skipping your own receivables query is not just allowed, it is the contract - two sources of "outstanding balance" will disagree.- Return the limit in the website's currency.
usedis computed from Magento invoice totals in the website's currency, so a limit denominated in anything else produces wrong headroom. Convert before you return. - The merchant's own limit wins over yours. Your resolver is appended after the built-ins, so a per-customer
hyva_pos_credit_limitattribute (or a B2B company credit limit on Adobe Commerce) takes precedence. Clear the attribute for customers your backend owns. A customer with a limit set and the explicit deny toggle on short-circuits the pool with a zero row by design - your resolver is not asked. - Log before returning null on failure. Null is silent by design; without the log entry, a backend outage looks identical to "no credit extended" and is miserable to diagnose from the cash drawer.