spits-online / laravel-wefact
Work with WeFact and HostFact in Laravel
Requires
- php: ^8.3
- illuminate/contracts: ^12.0||^13.0
- illuminate/http: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- nunomaduro/collision: ^8.6
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.0||^5.0
- pestphp/pest-plugin-arch: ^4.0||^5.0
- pestphp/pest-plugin-laravel: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Work with WeFact and HostFact in Laravel
WeFact is online invoicing software; HostFact
is its self-hosted sibling for hosting companies. Both speak the same API, and this
package talks to either one through a single driver setting. Calls read like
Eloquent, answers come back as typed objects, lists page themselves, and
WeFact::fake() lets you test your app without the API.
use SpitsOnline\WeFact\Data\Line; use SpitsOnline\WeFact\Facades\WeFact; $quote = WeFact::quotes()->create( debtor: 12, lines: [Line::create('Website redesign', priceExcl: 4500)], ); $quote->accept(createInvoice: true); WeFact::debtor(12)->bill( Line::create('Support', priceExcl: 95, quantity: 1.5), );
Requirements
- PHP 8.3 or higher
- Laravel 12 or 13
- A WeFact account, or a HostFact installation, with the API enabled
Installation
composer require spits-online/laravel-wefact
Add the driver and your API key to .env:
WEFACT_DRIVER=hostfact WEFACT_URL=https://administratie.example.com/apiv2/api.php WEFACT_KEY=your-api-key WEFACT_TIMEOUT=10 WEFACT_TIMEZONE=Europe/Amsterdam
WEFACT_TIMEOUT (in seconds) and WEFACT_TIMEZONE are optional. Where to find
each value:
WEFACT_DRIVER:wefact(the default) orhostfact.WEFACT_KEY: the "beveiligingscode" under Instellingen → API in WeFact, or Instellingen → HostFact voorkeuren → API in HostFact.- The IP whitelist is on the same page. Add the IP address of every server
that calls the API, or each request is refused with
AccessDenied. WEFACT_URL: WeFact's API lives athttps://api.mijnwefact.nl/v2/, which is used when you leave it out. For HostFact it is required: theapiv2/api.phpunder your HostFact address.WEFACT_TIMEZONE: the timezone the API's server runs in. The API sends dates without an offset; they are read, and dates you send are written, in this timezone. It defaults to the app's timezone.
There's no config file to publish. To change a default, create
config/wefact.php with only the keys you change. It merges over the
package's defaults key by key:
<?php return [ 'timeout' => 20, ];
Usage
Every call starts from the WeFact facade, SpitsOnline\WeFact\Facades\WeFact.
Picking one record, such as WeFact::quote(51), sends no request; each method
you call on it sends one. Records you fetched can act on themselves, so
$quote->accept() works the same as WeFact::quote($quote->id)->accept().
Finding debtors
$debtor = WeFact::debtors()->findByCode('DB10001'); $debtor = WeFact::debtors()->find(12); $debtor?->companyName; $debtor?->emailAddress; $debtor?->invoiceAddress;
find() and findByCode() return null when the debtor doesn't exist.
WeFact::debtor(12)->get() throws NotFound instead.
To walk every debtor, use get(). It returns a LazyCollection that fetches
the next page of 1,000 only when you get there:
use Carbon\CarbonImmutable; $changed = WeFact::debtors()->get( modifiedSince: CarbonImmutable::now()->subDay(), ); foreach ($changed as $debtor) { // … } $debtors = WeFact::debtors()->get(search: 'Acme');
A debtor from get() holds the API's short version: the id, code, names, email
address and modification date. find() returns every field. Fields the package
doesn't model are in $debtor->raw, in the API's own keys:
$debtor = WeFact::debtors()->find(12); $debtor?->raw['Sex'];
Creating and changing debtors
$debtor = WeFact::debtors()->create( companyName: 'Acme', emailAddress: 'billing@acme.test', ); $debtor = WeFact::debtor(12)->update(comment: 'Pays late');
update() changes only the arguments you pass; pass '' to clear a field. Both
methods take any other field the API documents through attributes, in its own
keys: attributes: ['Sex' => 'f'].
Building lines
Quote and invoice lines are built with Line::create():
use SpitsOnline\WeFact\Data\Line; Line::create('Programming', priceExcl: 95, quantity: 1.5, unit: 'hour');
A product code takes the product's description and price from the API:
Line::create(productCode: 'P001', quantity: 12);
A 100% discount makes a line that is shown but not charged, and a description alone makes a text line:
Line::create('Travel', priceExcl: 0.23, quantity: 42, discountPercentage: 100); Line::create('Prices exclude VAT.');
A line needs a description or a product code. Leave out the price to use the
product's price, the date to use today, and the tax percentage to use the API's
default rate. Amounts are floats; the package writes them the way the API takes
them (1250.5, never 1,250.50).
Creating quotes
$quote = WeFact::quotes()->create( debtor: 12, lines: [ Line::create('Website redesign', priceExcl: 4500), Line::create(productCode: 'HOSTING', quantity: 12), ], referenceNumber: 'Website 2026', ); $quote->code; // OF0014 $quote->status; // QuoteStatus::CONCEPT $quote->amountExcl;
debtor takes the debtor's id, or a Debtor you fetched. create() also takes
a date (today by default), a description and attributes.
Reading quotes and their lines
use SpitsOnline\WeFact\Enums\QuoteStatus; $quote = WeFact::quote(51)->get(); foreach ($quote->lines as $line) { $line->description; $line->quantity; $line->priceExcl; $line->amountExcl; } $open = WeFact::quotes()->get( status: [QuoteStatus::SENT, QuoteStatus::ACCEPTED], debtor: 12, );
Quotes from get() have no lines: $quote->lines is null there, so fetch the
quote with get() or find() to read them. findByCode('OF0014') finds a quote
by its code. Archived quotes don't appear in lists.
Changing quotes and their lines
use SpitsOnline\WeFact\Enums\QuoteStatus; $quote = WeFact::quote(51)->update( referenceNumber: 'Website 2026, v2', status: QuoteStatus::SENT, ); $quote->lines()->add( Line::create('Extra page', priceExcl: 350), Line::create('Hosting', priceExcl: 15), ); $quote->lines()->remove($quote->lines[0]);
add() and remove() send one request for all their lines. remove() takes
the LineItems from $quote->lines, or their ids. The API keeps at least one
line on a quote, so remove() can't take them all.
To swap every line for new ones, use replace(). It adds the new lines before
it removes the old ones, so a refused line never leaves the quote half-edited,
and it takes at least one line:
WeFact::quote(51)->lines()->replace( Line::create('Website redesign', priceExcl: 4500), Line::create('Hosting', priceExcl: 15), );
Accepting, declining and archiving quotes
$quote = WeFact::quote(51)->accept(); $quote = WeFact::quote(51)->accept(createInvoice: true); $quote = WeFact::quote(51)->decline(); WeFact::quote(51)->archive();
accept(createInvoice: true) also turns the quote into a concept invoice.
accept() throws RequestFailed when the quote can't be accepted, such as a
declined quote: the API answers success there, but leaves the quote declined. An
accepted quote can still be declined. After accept(createInvoice: true) the
quote's status is QuoteStatus::INVOICED; $quote->status?->isAccepted() is
true for both. An archived quote no longer appears in WeFact::quotes()->get();
on WeFact, only accepted, invoiced and declined quotes can be archived.
Billing work to a debtor
bill() puts lines on the debtor's concept invoice, to be sent later: its
newest concept invoice when it has one, or a new one. It returns that invoice.
$invoice = WeFact::debtor(12)->bill( Line::create('Programming', priceExcl: 95, quantity: 1.5), Line::create('Travel', priceExcl: 0.23, quantity: 42), );
A concept invoice can also come from accepting a quote with createInvoice, so
the lines may land on that one.
Creating invoices
use SpitsOnline\WeFact\Enums\InvoiceStatus; $invoice = WeFact::invoices()->create( debtor: 12, lines: [Line::create('Website redesign', priceExcl: 4500)], ); $concepts = WeFact::invoices()->get( status: InvoiceStatus::CONCEPT, debtor: 12, );
Filtering on debtor matches the debtor's id exactly. create() makes a concept
invoice, and takes the same referenceNumber, date, description and
attributes as quotes.
Reading, changing and deleting invoices
$invoice = WeFact::invoice(18)->get(); $invoice->status; // InvoiceStatus::SENT $invoice->amountIncl; $invoice->amountOpen; $invoice->payBefore; $invoice->lines()->remove($invoice->lines[0]); $invoice = WeFact::invoices()->findByCode('F2026-0001'); WeFact::invoice(19)->delete();
findByCode() returns null when there is no invoice with that code; an invoice
that hasn't been sent has a code like [concept]0009. Only concept invoices can
be deleted. Invoice lines work like quote lines,
replace() included: the API keeps at least one line on an invoice. Invoices
from get() have no lines.
Products
use SpitsOnline\WeFact\Enums\ProductType; $product = WeFact::products()->findByCode('P001'); $product?->name; $product?->priceExcl; $product?->period; // Period::MONTH, or null for a one-off price $general = WeFact::products()->get() ->filter(fn ($product) => $product->type === ProductType::OTHER);
products()->find($id) finds a product by id, and get(search: 'SEO') searches
the code, name and key phrase.
Subscriptions
use SpitsOnline\WeFact\Enums\SubscriptionStatus; foreach (WeFact::subscriptions()->get() as $subscription) { $subscription->debtorCode; $subscription->productCode; $subscription->priceExcl; $subscription->nextDate; } $ended = WeFact::subscriptions()->get( status: SubscriptionStatus::TERMINATED, debtor: 12, );
Like the API, get() returns the active subscriptions; status: null returns
them all. On HostFact this includes the subscriptions of domains and hosting
accounts; $subscription->type says which.
Domains
Only HostFact manages domains. On the wefact driver, domains() throws
UnsupportedFeature.
use SpitsOnline\WeFact\Enums\DomainStatus; $domains = WeFact::domains()->get(status: DomainStatus::ACTIVE); foreach ($domains as $domain) { $domain->name; // example.com $domain->debtorId; $domain->expirationDate; } $domain = WeFact::domains()->find(2);
get() also filters on debtor. Only find() sends $domain->autoRenew.
Anything else the API offers
WeFact::request() sends any call the API documents, with the API's own keys,
and returns its answer as an array. It throws the same exceptions as every
other call.
$answer = WeFact::request('hosting', 'list', ['status' => 4]);
Switching from HostFact to WeFact
Every call above works the same on both drivers, except domains. Switch by
changing WEFACT_DRIVER, WEFACT_URL and WEFACT_KEY. The package takes care
of the differences between the two APIs, such as how a quote is archived and
which field holds what is still to be paid. WeFact::driver() returns the
driver in use, as a SpitsOnline\WeFact\Enums\Driver.
Error handling
Every exception extends SpitsOnline\WeFact\Exceptions\WeFactException, so
one catch covers them all:
| Exception | When |
|---|---|
AccessDenied |
The API refused the key or this server's IP address, or WeFact blocked the IP address for going over its rate limits. |
NotFound |
The record doesn't exist. find() and findByCode() return null instead. |
RequestFailed |
The API refused the request. $e->errors holds its messages and $e->body the whole answer. |
ConnectionFailed |
The API couldn't be reached, or didn't answer within WEFACT_TIMEOUT seconds. |
MissingConfiguration |
WEFACT_URL, WEFACT_KEY or WEFACT_DRIVER isn't set right. |
UnsupportedFeature |
The driver doesn't have this, such as domains on WeFact. |
InvalidLine |
A Line has neither a description nor a product code. |
NotFound and AccessDenied extend RequestFailed.
use SpitsOnline\WeFact\Exceptions\AccessDenied; use SpitsOnline\WeFact\Exceptions\RequestFailed; use SpitsOnline\WeFact\Exceptions\WeFactException; try { WeFact::quote(51)->accept(); } catch (AccessDenied $e) { report($e); } catch (RequestFailed $e) { return back()->withErrors($e->errors); } catch (WeFactException $e) { report($e); }
AccessDenied is a setup problem, not a missing quote. RequestFailed holds
the API's own error messages in $errors, such as why it refused. Catching
WeFactException covers everything else, such as an API that can't be reached.
The API answers errors with HTTP 200, so the package reads them from the answer itself. WeFact limits each IP address to 200 calls a minute and 3,600 an hour, and blocks an IP address that goes over; you have to contact WeFact to be unblocked.
Testing your app
WeFact::fake() swaps the client for an in-memory WeFact (or HostFact, when
that's your driver). Seed it in the API's own keys, then assert on what changed:
use SpitsOnline\WeFact\Data\Line; use SpitsOnline\WeFact\Facades\WeFact; it('bills the hours to the concept invoice', function () { $fake = WeFact::fake() ->withDebtor(['CompanyName' => 'Acme']) ->withInvoice(['Debtor' => 1]); WeFact::debtor(1)->bill(Line::create('Programming', quantity: 1.5)); $fake->assertInvoiceLinesAdded( 1, fn (array $lines) => $lines[0]->quantity === 1.5, ); });
The bill() call stands in for the code under test, usually an action of your
app. The fake answers the same requests the real client sends, so it behaves like
the API: lists filter and page, a missing record throws NotFound, a declined
quote can't be accepted, only concept invoices can be deleted, a quote or invoice
keeps at least one line, and accept(createInvoice: true) creates a concept
invoice. Ids start at 1, debtor codes at DB10001.
Seed records with withDebtor(), withProduct(), withQuote(),
withInvoice(), withSubscription() and withDomain(). Quotes and invoices take
their lines as a second argument:
$fake = WeFact::fake() ->withDebtor() ->withProduct(['ProductCode' => 'P001', 'PriceExcl' => '95']) ->withQuote(['Debtor' => 1], [Line::create('Website')]);
The assertions, each with an optional callback that must return true:
| Assertion | The callback receives |
|---|---|
assertDebtorCreated(?callback) |
the Debtor |
assertDebtorUpdated($id, ?callback) |
the changed fields, e.g. ['Comment' => 'Pays late'] |
assertQuoteCreated(?callback) |
the Quote |
assertQuoteUpdated($id, ?callback) |
the changed fields |
assertQuoteAccepted($id) |
|
assertQuoteDeclined($id) |
|
assertQuoteArchived($id) |
|
assertQuoteLinesAdded($id, ?callback) |
the added LineItems |
assertQuoteLinesRemoved($id, ?callback) |
the removed line ids |
assertInvoiceCreated(?callback) |
the Invoice |
assertInvoiceDeleted($id) |
|
assertInvoiceLinesAdded($id, ?callback) |
the added LineItems |
assertInvoiceLinesRemoved($id, ?callback) |
the removed line ids |
assertNothingChanged() |
To fake the other driver, pass it: WeFact::fake(Driver::WEFACT).
The package sends its requests through Laravel's HTTP client, so Http::fake()
works too when a test needs the exact request.
Testing
composer test
composer check runs Pint, PHPStan and the tests, like CI.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Upgrading
Please see UPGRADE for how to upgrade from 1.x.
Contributing
Please see CONTRIBUTING for details.
Security vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.