Scan Diagnostics — Dataprocessor

Integration middleware that keeps the WooCommerce webshop and Visma.net (ERP) in sync.

Maintained by Aros Tech · Last updated June 29, 2026

About this page

This is the living technical documentation for the Dataprocessor. It is served directly from the production server, so it is always available and always reflects the deployed system — you do not need to store or track this file yourselves. It is written in English to be useful to any future developer who works on the system.

Purpose

Scan Diagnostics sells laboratory and diagnostics products through a WooCommerce webshop, and runs accounting, inventory and order fulfilment in Visma.net. These two systems must agree without anyone re-typing data by hand.

The Dataprocessor is the automated bridge between them. It runs unattended once per day and moves data in two one-directional flows. There is no user interface — it is a headless background service.

Data flows

1 · Products   Visma → WooCommerce

Visma is the source of truth for the product catalogue. Each run pulls new or recently edited inventory items from Visma, filters them down to the items that belong in the shop, translates them into WooCommerce products (including placing them in the correct category, e.g. Mikrobiologi, Prøveforbehandling), and pushes them to WooCommerce. Existing items are updated in place rather than duplicated.

Visma inventory Filter Map WooCommerce products

2 · Orders   WooCommerce → Visma

WooCommerce is the source of truth for sales. Each run pulls newly placed orders from WooCommerce, maps them to Visma sales orders (resolving or creating the customer and contact in Visma), and posts them to Visma for fulfilment and accounting.

WooCommerce orders Map + resolve customer Visma sales order

Architecture

Both flows share the same shape: a scheduled command invokes a service that orchestrates the run, calling integration clients (the external APIs) and mappers (which translate between the two data models).

Scheduler Console command Service API clients + mappers
TriggerCommandService
Daily 00:00process:productsProductsService
Daily 00:00process:ordersOrdersService

Code layout (app/):

The local database holds almost no business state — only a failure log (pipeline_errors), a SKU lookup table (visma_products), and standard framework tables. The systems of record are Visma and WooCommerce.

Scheduling & execution

Both syncs are registered in routes/console.php with Laravel's scheduler and run once daily at 00:00. They are scheduled as commands, which means each run executes synchronously — there is no background queue and no worker process to maintain.

Deliberate: run once, no retries

A failed run is not automatically retried. Error handling is explicit throughout the services and clients, so the system fails loudly and visibly rather than silently repeating work. This is an intentional design decision, not an omission.

Products and orders are independent and are allowed to run concurrently.

Sync window

Rather than comparing entire datasets, each run pulls "everything changed in the last 24 hours". The cutoff is defined once, centrally, in App\Support\SyncWindow and shared by both flows. Because the scheduler fires at midnight, the cutoff is the previous midnight — so consecutive daily runs tile perfectly, with no gap and no overlap.

The same instant is formatted differently for each API (ISO-8601 with a T for WooCommerce, space-separated for Visma), but it is always the same moment in time.

Error handling & monitoring

Planned — not yet active

Automatic email alerts to stakeholders are intended but not implemented yet. Today, failures are only logged and stored in pipeline_errors — no email is sent. Until the email notifier ships, a failed run is visible by reading the log or the pipeline_errors table.

Tech stack

Language / frameworkPHP 8.3+, Laravel 13
ExecutionLaravel scheduler (cron-driven), synchronous commands
DatabaseSQLite (failure log + SKU lookup; minimal state)
External systemsVisma.net REST API (OAuth2 client-credentials), WooCommerce REST API (basic auth)
NotificationsNone active yet — failures logged + stored in pipeline_errors (email planned)
HostingLaravel Forge

Configuration

All credentials are supplied through environment variables on the server. The following must be present:

# Visma
VISMA_API_BASE_URL=
VISMA_CLIENT_ID=
VISMA_CLIENT_SECRET=
VISMA_TENANT_ID=

# WooCommerce / WordPress
WORDPRESS_API_BASE_URL=
WORDPRESS_CONSUMER_KEY=
WORDPRESS_CONSUMER_SECRET=

Mail credentials are not required yet — email notifications are planned but not implemented, and MAIL_MAILER is currently log.

Note

A missing credential is a fatal error at boot — the integration clients require these values, so they must all be set in the server environment before deploying.

Deployment

The application is deployed via Laravel Forge. A normal deploy runs composer install and rebuilds the optimized autoloader. Because the syncs run as scheduled commands, the only background process Forge needs is the Scheduler:

# Forge → Server → Scheduler · every minute · user: forge
php /home/forge/<site>/current/artisan schedule:run

That single cron entry runs every minute, checks what is due, and fires the daily syncs at 00:00. No queue worker or daemon is required.

Timezone

"Midnight" is interpreted in the application timezone (UTC). If the business day should follow Norwegian/Danish local time, set the app timezone accordingly — otherwise the 00:00 cutoff is in UTC.

Operations runbook

Useful commands when operating or debugging the system on the server:

# See what is scheduled and when it next runs
php artisan schedule:list

# Run a sync immediately (synchronous, bypasses the scheduler)
php artisan process:products
php artisan process:orders

# Trigger a scheduled task on demand (interactive picker)
php artisan schedule:test

# Inspect recorded failures
php artisan tinker --execute="App\Models\PipelineError::latest('created_at')->take(20)->get()->each(fn(\$e) => print_r(\$e->toArray()));"

Reading the production logs (Laravel Forge)

The sync writes an entry to the application log on every run. To read it on the production server through Forge:

  1. Open the server production-01.
  2. Open the site laravel-dataprocessor-jgirogcl.on-forge.com.
  3. Click the search field in the upper-right corner.
  4. Type terminal.
  5. Click Launch terminal.
  6. Change into the log directory: cd storage/logs/
  7. Show the current log: cat laravel.log
  8. List every log file, including older rotated/compressed ones: ls

Logs rotate daily, so older days are gzip-compressed (e.g. laravel.log.1.gz). Read a compressed one without unpacking it with zcat laravel.log.1.gz.

Known limitations & future work

Orders are not yet idempotent

The orders flow has no check for "does this WooCommerce order already exist in Visma?". To avoid creating duplicate sales orders, the sync window is kept at an exact 24 hours with no overlap. The consequence: with no retries and no overlap, if a nightly run fails or is missed, that day's orders are not re-sent automatically.

The recommended next step is to make orders idempotent — e.g. a local table mapping the WooCommerce order to its Visma counterpart, or looking the order up in Visma by a stored reference before creating it. Once orders can be safely re-sent, a small overlap can be reintroduced to make the system resilient to a missed run, the same way the products flow already tolerates re-processing through SKU updates.