Arc Forge Labs Docs v2026.10.53

Seazona adapter

Seazona is the lab's case system. The Seazona adapter reads what billing needs through the Seazona REST API: invoices, orders, clients, products and users. It never writes to Seazona, and it never drives a browser. The code is in server/integrations/seazona/. Core code never imports it (CONTRIBUTING.md).

Status: the adapter is on the integration port. It keeps Seazona's invoices, orders, clients, products and users in step and gives invoice preparation a billing feed. Invoice preparation itself is not built yet.

Turn it on

In Flow, an owner opens Integrations, then Seazona: choose the mode, paste the API address, client id and client secret, and press Save and turn Seazona on. Seazona starts at once; live mode starts the background sync. The secret is stored sealed (docs/INTEGRATIONS.md, "Set up in Flow").

A host can instead turn it on with environment settings:

  1. Add seazona to FLOW_INTEGRATIONS (docs/INTEGRATIONS.md). Its tables are created at the next start.
  2. Set SEAZONA_MODE and the settings for that mode (below). With Seazona on and no mode, or a malformed setting, Flow does not start: the adapter never guesses which account it acts on.
  3. Start Flow. The log says [seazona] mode live; background sync on.
  4. Check GET /health: integrations lists Seazona with its mode, each lane and the rate governor.

In sandbox or replay, sync when you choose:

DATABASE_URL=postgres://… FLOW_INTEGRATIONS=seazona SEAZONA_MODE=sandbox … \
  node server/integrations/seazona/sync-once.js [lane …]

It runs the named lanes (all when none is named) once, through the rate governor, and prints what each did.

Modes

The mode says which environment the adapter acts on (issue #10). It comes from SEAZONA_MODE only. There is no default: with no mode the adapter does nothing.

Mode Acts on Background sync Network
live The installation's real Seazona account Yes Seazona API
sandbox A Seazona test account Never; runs only when asked Seazona API
replay A file of recorded, synthetic responses Never None

Development and CI use replay or sandbox. Never give a development workspace live credentials.

Settings

Setting Meaning
SEAZONA_MODE live, sandbox or replay. Required.
SEAZONA_API_BASE_URL The API root of the account, https://…, with or without /v1. Required in live and sandbox.
SEAZONA_API_CLIENT_ID, SEAZONA_API_CLIENT_SECRET The API client. Secrets: keep them in the platform's secret store. Required in live and sandbox.
SEAZONA_REPLAY_FILE The replay file. Required in replay.
SEAZONA_API_REQUESTS_PER_MINUTE Default and maximum 30. A value can only lower it.
SEAZONA_API_MAX_RETRIES Retries after a 429 or 5xx answer. Default 2.
SEAZONA_API_TIMEOUT_MS Time limit for one request. Default 30000.
SEAZONA_API_MAX_WAIT_MS How long a request may wait for a free slot. Default 120000.
SEAZONA_API_TELEMETRY_RETENTION_DAYS How long request telemetry is kept. Default 35, so last month's question from Seazona can be answered. Maximum 90.
SEAZONA_ORDER_URL Optional. The address of one order in Seazona's own screens, with {ref} where the order number goes, for example https://<your Seazona>/orders/{ref}. A person may instead paste the address of any one order as the browser shows it: Flow turns the order id in it (a GUID) into {ref}. A stage move on the Jobs board opens it, and so does Open in Seazona on a changed job. Must use https and hold {ref} or an order id; otherwise Flow ignores it: the Jobs board shows the order number to copy, and with the API address https://{lab}.seazonaapi.net, Flow opens the order at https://{lab}.seazona.net/OrderEdit.aspx?id={order id}. Also on the Seazona integration screen as "Order address".
SEAZONA_KEEP_PATIENT_REFERENCE on keeps Seazona's patient field on orders and invoices as the patient reference an invoice prints (the {patient} token of the Reference rule). Default off: no patient field is stored. Also on the Seazona integration screen as "Keep patient names". When on, the Jobs list can show a Patient column.
SEAZONA_INVOICE_CADENCE_MS, SEAZONA_ORDER_CADENCE_MS, SEAZONA_ACTIVE_ORDER_CADENCE_MS, SEAZONA_CLIENT_CADENCE_MS, SEAZONA_REFERENCE_CADENCE_MS How often each lane runs in live mode. Defaults below.
SEAZONA_ORDER_LOOKBACK_DAYS How far back the recent-order lane reads. Default 30.
SEAZONA_ORDER_DAYS_PER_RUN Days the recent-order lane reads per run. Default 6.
SEAZONA_INVOICE_LOOKBACK_DAYS Days before today the first invoice run reads. Default 0.
SEAZONA_ACTIVE_ORDERS_PER_RUN Open orders the active-order lane re-reads per run. Default 25.
SEAZONA_LIST_CAP A list this long may be cut off, so its lane reads on (see Sync lanes). Also the order page size, at most 500. Default 50.
SEAZONA_LIST_PAGES_PER_RUN The most list calls one lane makes in a run; the next run goes on from where it stopped. Default 10.
SEAZONA_ORDER_QUARANTINE_FAILURES, SEAZONA_ORDER_QUARANTINE_MS Failed reads in a row before an order, an invoice or a client is quarantined, and for how long. Defaults 3 and 1 day.
SEAZONA_RECORD_REREADS_PER_RUN Quarantined invoices or clients whose wait has ended that the lane reads again by id in one run. Default 5.
SEAZONA_CIRCUIT_FAILURES, SEAZONA_CIRCUIT_WINDOW_MS Failed calls inside the window that open the circuit. Defaults 5 and 10 minutes.
SEAZONA_CIRCUIT_PAUSE_MS The first pause when the circuit opens. It doubles after each failed probe, to at most 6 hours. Default 5 minutes.
SEAZONA_DAILY_FAILURE_BUDGET Failed calls allowed in one UTC day. When they are spent, calls stop until the next day. Default 50.
SEAZONA_SWEEP_CALLS_PER_RUN, SEAZONA_SWEEP_CALLS_PER_DAY, SEAZONA_SWEEP_CYCLE_DAYS, SEAZONA_SWEEP_CADENCE_MS The sweep's calls per run and per UTC day, how often each kind of record is checked again, and how often the lane runs. Defaults 10, 100, 7 days and 15 minutes. 0 calls a day turns the sweep off.
SEAZONA_HISTORY_CALLS_PER_RUN, SEAZONA_HISTORY_CADENCE_MS, SEAZONA_HISTORY_PAGE_SIZE Calls the history backfill makes per run, how often it runs, and orders per list page. Defaults 10, 2 minutes and 500. It calls Seazona only while a person has started one.

The code holds no Seazona host or account. Every address and credential is configuration.

Rate governor

Seazona publishes a limit of 60 requests a minute for each API client. Flow stays at 30, and counts every process that shares the database as one client, because Seazona does (server/integrations/seazona/governor.js):

  • A request waits until fewer than 30 requests went out in the last minute, and at least 2 seconds have passed since the last one.
  • A 429 answer with Retry-After holds every process for that long (at most 60 seconds).
  • A 429 or 5xx answer is retried at most SEAZONA_API_MAX_RETRIES times, with a growing wait. Each retry counts against the limit.
  • A request that waits longer than SEAZONA_API_MAX_WAIT_MS gives up with seazona_api_governor_timeout.
  • In live and sandbox the client refuses to start without the governor, so no code path can call Seazona ungoverned.

Failed calls (issue #101)

Seazona can cut off an account's API access when it sees many failed calls, even inside the rate limit. So the governor also stops calls, through the shared call guard (docs/INTEGRATIONS.md, "Outbound calls", issue #111) on the governor's own tables. A stopped call fails at once with SeazonaCallsStopped and makes no request.

flowchart LR
  call[Call] --> ok{Answer}
  ok -- 2xx or 404 on one record --> closed[Circuit closed]
  ok -- 401 or 403 --> halt[Halted: no call until a person saves the settings]
  ok -- 5xx, 429, other 4xx, timeout --> count[Count the failure]
  count -- 5 in 10 min --> open[Open: no call for the pause]
  open -- pause ends --> probe[One probe call]
  probe -- fails --> open2[Open again, pause doubled, at most 6 h]
  probe -- works --> closed
  count -- 50 today --> day[No call until the next UTC day]
Failure What Flow does Starts again
401 or 403 Halts every call. Every lane is stopped. A person saves the Seazona settings or turns Seazona on. New credentials are a new client, so a new start.
400 or 404 on a list Stops that lane. A restart (for example a new release), or a person saving the settings.
404 on one record Marks the order deleted (issue #12). Not a failure.
429 Waits for Retry-After, as before. Counts as a failure.
5xx, timeout, network Retries, then the lane backs off. Counts as a failure.
The same record fails 3 times Quarantines the order, invoice or client for a day. The lane skips it and its cursor moves on. The quarantine ends. An invoice or a client is then read again by id, even when no list names it (issue #142): a success clears it, a 404 lets it go, a failure waits again with no new report.
5 failures in 10 minutes Opens the circuit. After the pause, one probe.
50 failures in a UTC day Stops calls. The next UTC day.

Each held failure is reported once to the error services that are on (log.error, docs/SENTRY.md): a lane that starts failing or changes its failure code, a stopped lane, an open circuit, a spent budget, and records newly quarantined. A repeat of the same failure is a warning in the host log only. The report text names the lane and the code, never a record, a host or a credential, so the error service groups repeats. With no error service on, the host log has the same lines.

Each request leaves one row in seazona_api_requests: when it went, the request path (for example /v1/orders/<order id>), the status or failure kind, the time it took and whether it was a retry. The row never holds the host, the query, a credential or a response. governor.summarise({ from, to }) gives the totals for a period and each failing path with its count, which answers a question such as "why did this account make thousands of failed reads last month?".

The background sync

Only live mode starts it (server/integrations/seazona/scheduler.js). It first runs 5 seconds after start, runs every lane that is due, then sleeps until the next lane is due (at least 15 seconds, at most 30 minutes). One run at a time; a lane that another process is running is skipped.

Sync lanes

Each lane keeps one kind of record fresh, on its own cadence (server/integrations/seazona/lanes.js). One failing lane never stops another.

Lane Reads Cadence
invoices GET invoices/?lastModified= since the cursor (less 5 minutes), then each changed invoice's detail 2 minutes
orders_recent GET orders/?ordered=&page=&pageSize= for each day of the last 30 (6 days a run), page by page, then each new or changed order's detail 5 minutes
orders_active Each open order Flow already knows, oldest first, 25 a run. "Complete" stays open until Seazona says it shipped. 5 minutes
clients The whole list the first time, then changes since the cursor, then each changed client's detail 30 minutes
products, users The whole list Daily
history Past orders, one UTC day at a time, a few calls a run (History import, below) Every 2 minutes, only while a backfill runs
sweep Whole lists again, slowly, for what the other lanes missed (Sweep, below) 15 minutes, 10 calls a run, 100 a day

A list row whose list fields did not change costs no detail read.

A list SEAZONA_LIST_CAP long may be cut off, so the lane reads on (issue #125):

  • Invoices and clients have no paging. Seazona's guide says the invoice list answers changes on or after lastModified, oldest first. So a full list is one part of the window: the next part starts at its last row's lastModified (clients: one second before it). A run that reaches SEAZONA_LIST_PAGES_PER_RUN stores the exact start of the next part, and the next run goes on from it, with no 5-minute overlap.
  • Orders are paged. A full page goes on to the next page. A short page, or a page whose last order is two days past the day, ends the day. A run that stops inside a day goes on from that page next time.

The lane holds (held), keeps where it got to and tries again in an hour only where it cannot go on: a full list whose rows are not oldest first, a full list inside one second, a next order page that answers the same orders again, or a products or users list exactly at the cap (those lists come whole, with no paging).

A failed lane waits 1 minute, doubling to at most 6 hours, or as long as a 429 asked. A lane that says it is running for more than 20 minutes is taken to have died, and another run may claim it.

Sweep (issue #126)

The cursor lanes see only what their queries return. An edit to an order older than the recent-order window that has left the active rotation, a record deleted in Seazona, and a change behind a cursor that moved too far can pass them by. The sweep lane (server/integrations/seazona/sweep.js) reads whole lists again on a slow cycle and compares them with Flow's copy:

Kind Reads Finds
Old orders GET orders/?ordered=<day> and its pages, for each day before the recent-order window that Flow holds orders for, newest day first A listed order whose list fields changed is read again. An order Flow holds for the day that the list leaves out is read again; a 404 marks it deleted in Seazona (Bad orders, below), so the billing feed withdraws it and its invoice goes to review.
Clients The whole list, read on from the last row while it is full A client Flow holds that the list leaves out is read by its id.
Invoices Every change since the invoice lane first read (the UTC day of its first run, less SEAZONA_INVOICE_LOOKBACK_DAYS), read on the same way. An invoice last changed before that was never the lane's to read, so it is not a miss. An invoice Flow holds that the list leaves out is read by its id; the orders on it are read again.
  • A client or invoice that answers 404 is marked missing. Its row is never deleted. The mark clears when Seazona lists it again.
  • A change at or after where the owning lane reads next is left to that lane and costs no call.
  • Each kind starts again at most once a cycle (7 days). The sweep makes at most 10 calls a run and 100 a UTC day, through the same rate governor and call guard as every lane, and keeps its place in the lane's cursor.
  • An order or record whose read fails is recorded as in any lane, and is not read again in the same cycle.
  • What the sweep finds that the lanes missed (changed, created, deleted, missing) is kept in seazona_sweep_findings and reported to the error services once a run. A count above zero is a signal to fix a lane.
  • The source high-water mark is the latest lastModified Flow holds for invoices and for clients. /health shows it beside where the owning lane reads next. When Seazona's time is ahead of Flow's clock by more than the 5-minute cursor overlap, the sweep reports it once.

Bad orders (issue #12)

Every order read follows these rules:

  • A 404 marks the order deleted in Seazona. Its row and history stay and its sync time is refreshed, so it never comes first again. It reads as "Deleted in Seazona", leaves the active-order rotation and is out of billing. If it shows up in an order list again it is read, and a successful read clears the mark.
  • Each other failure is recorded per order (seazona_order_read_failures): the HTTP status or the kind (empty_response, timeout, invalid_json, network). A failed order moves to the back of the rotation.
  • An empty answer is a failure, never a success.
  • After 3 failures in a row an order is quarantined for 1 day, then gets one more read. A failure then quarantines it again; a success clears it.
  • A lane fails only when most of its reads fail. A few bad orders never push every other order into the lane's backoff.
  • A 429, or a request that waits too long for a slot, stops the lane without counting against any order: it is about Seazona, not the order.
  • Errors name each failing order. A failed lane's error lists up to 10 as ORD-1 (HTTP 500), ORD-2 (timeout). The lane's failure details hold up to 25, with the quarantined orders. readSeazonaSyncHealth() returns them for /health. None of it holds patient data.

What is kept

The mirror keeps what billing needs, in integer cents as Seazona reported them: order and invoice numbers, practice and dentist, status, dates, lines (description, code, amount, taxable) and totals; client account, email, phone, address and dentist names; product prices; user names. It keeps no notes, files, settings, staff email or dentist phone, and no patient field unless SEAZONA_KEEP_PATIENT_REFERENCE is on.

Billing feed

The adapter's pullWorkItems(cursor) capability is what invoice preparation reads (server/integrations/seazona/feed.js). Each call returns the orders that changed since the cursor, oldest change first, up to 200:

  • items: complete and shipped orders. Each has the order (sourceId, invoiceNumber, status, shipped, practice, dentist, orderedOn, dueOn, lines, subtotalCents, patientReference), the Seazona invoice that carries it (invoice, or null) and the practice's account and email (client).
  • withdrawn: every other changed order, with reason deleted_in_seazona or not_billable, so work already held for it can be dropped. An order deleted in Seazona is never an item, from any cursor.
  • cursor: pass it to the next call. more is true when the page was full.

Each item also carries ledgerJob and each withdrawn entry ledgerWithdrawn, shaped for the billing obligation ledger (below).

A change to an order or to its Seazona invoice puts the order at the end of the feed again. A read that finds no change does too, at most once an hour, so the ledger's report of the order never gets older than a day while a lane still reads it (the ledger refuses a draft from a report over a day old). An order no lane reads again (shipped, and placed before the recent-order lookback) is not reported again. Before Flow drafts or sends from such a job, it reads the order now (readWorkItem, server/integrations/seazona/act.js; server/core/read-before-act.js): one GET orders/{id} through the call guard, stored through the mirror, answered as the feed would answer it. Call registry.whenEnabled('seazona', …) to reach it, so a turned-off adapter answers as turned off (docs/INTEGRATIONS.md).

Billing ledger

The feed is shaped for the billing obligation ledger (server/core/ledger.js, issue #3). One Seazona order is one billable job (server/integrations/seazona/ledger.js):

for (const item of page.items) {
  await observeJob(db, organisationId, { ...item.ledgerJob, contactId: contactFor(item.client) });
}
for (const { ledgerWithdrawn } of page.withdrawn) {
  await observeWithdrawn(db, organisationId, ledgerWithdrawn);
}
Ledger field From Seazona
sourceSystem, sourceJobRef seazona and the order id
sourceInvoiceRef The Seazona invoice number
caseRef The order id, which is the case number. Never patient data.
practitioner The dentist on the order
shippedOn Always null: the API gives a shipped status, not a date
lines The Seazona invoice's lines, discounts included, when that invoice carries this order and no other (linesFrom: 'invoice'); otherwise the order's lines (linesFrom: 'order'), so an invoice is never split or billed twice. Each line has quantity 1, the line's code as itemCode, and its amount. An unknown amount or tax treatment is left for the price item with the same code.
observedAt The last successful read from Seazona, so the ledger's out-of-date check is real
contactId Not set: the core resolves the Flow contact from item.client

Amounts are tax-exclusive, as Seazona reports them, and the ledger adds GST per line. An order deleted in Seazona sends its obligation to review (ledgerWithdrawn); a person decides what happens to any invoice already made. An order whose status moves back (for example from Shipped to In Production) loses an unsent ready mark, and goes to review when its invoice was made (docs/DATA_SPINE.md, "When a job changes after its invoice"). The ledger's "paid" write-back to Seazona is not built: this adapter only reads.

History import

The API sync reads forward from the day it started. The history import brings in the years before that, so Flow holds the lab's full job history. It has two sources, and both end in the same batches, reconciliation and history table:

  • The API backfill (the main source, below). It reads every past order from the Seazona API. Each order it reads is a batch row: the record of what the API confirmed.
  • A CSV export (parked). Seazona's help pages document no export format, so this path is only for a file a person has. It is not built out further unless the API backfill cannot do the job.

The earlier app's own links from orders to Xero invoices are already in prior_links (imported at start); the matcher uses them as evidence for each history job (docs/JOB_INVOICE_LINKS.md). The earlier app read only 30 days of orders, so its archive is not a full history.

The API backfill

An owner or admin starts it on Integrations › Seazona, History: pick the day in Read from and press Start (Stop ends it). Resume goes on where a stopped read stopped. Orders that any earlier read staged are skipped, so a read again over the same days costs only the list calls. The card shows the shared SyncStatus row: pulse until a real result, total jobs from history.jobs, the day rail and an approximate time remaining. Waiting shows the retry countdown. The page polls every five seconds only while visible and running or waiting. Resume starts a new clock without resetting the total. No steps dropdown is shown until persisted steps are available. GET /api/seazona/history gives the run's startedAt, persisted endedAt (after Stop or Done, so elapsed time survives reload), and its phase: running, waiting (the guard holds or a run failed; nextRunAt is the retry), failed (Seazona halted calls, or 3 runs failed in a row; cause says why), stopped or done. The page calls POST /api/seazona/history/backfill with { "from": "2019-01-01" } (and through, by default the day before the recent-order lane's window). The history lane then:

  1. lists orders from the next day (GET orders/?ordered=<day>, with page and pageSize up to 500). Seazona's guide says the list answers the orders placed on or after that time, oldest first, in stable pages; the recent-order lane was built when it answered one day. The walk works either way: a full page goes on to the next page, a short page to the next day, and an order placed after through ends the walk unread;
  2. leaves out the orders the live copy already holds;
  3. reads each other order (GET orders/<id>), maps it as the live lanes do, and stages it as job case:<order id> in the backfill's batch;
  4. reconciles each staged job at once, and writes a new one to Flow's history; a job that needs a person waits in the batch.

It makes at most SEAZONA_HISTORY_CALLS_PER_RUN calls a run (default 10, at most 25), every SEAZONA_HISTORY_CADENCE_MS (default 2 minutes), through the same rate governor, circuit and failed-call budget as the live lanes. At the default, about 300 calls an hour. A 404 means the order is gone and is skipped. Other failed reads are retried later in the walk and quarantined after SEAZONA_ORDER_QUARANTINE_FAILURES. A refused or halted call stops the lane as it stops the others. The lane keeps its place (day, page and orders still to read) in its cursor, so it resumes after a restart. When the last day is done, the batch waits like an upload for a person to decide what is left. POST /api/seazona/history/backfill/stop ends it early. GET /api/seazona/history shows backfill: the next day, days done, the latest order day reached, orders read and orders still to read.

CSV upload

The code is in server/integrations/seazona/history/; the routes are under /api/seazona/history (routes.js). There is no screen yet.

flowchart LR
  file[Seazona CSV export] -- upload --> stage[Staged batch<br/>rows and jobs]
  stage --> rec[Reconcile]
  mirror[API copy<br/>orders, invoices, clients] --> rec
  hist[Flow history] --> rec
  rec -- new --> apply
  rec -- same --> skip[Skipped]
  rec -- needs a person or changed --> person[Person decides]
  person -- import --> apply[Apply]
  person -- skip --> skip
  apply --> hist
  hist -- pullHistoryJobs --> core[Core: ledger and matcher]
  1. Upload. An owner or admin posts the file (POST /history/batches, a text/csv body with ?fileName=, or JSON { csv, fileName, columns }, at most 25 MB and 200,000 rows). Flow guesses each column from its header (below); columns corrects a guess ({ "doctorName": "Client Name" }). A file without a practice, an item, an amount and a date is refused, and the answer lists the headers it has. The same file uploaded again answers its first batch.
  2. Read. Each row is read into Flow fields. A row with a bad or missing date, practice, item or amount joins no job and is listed with its problems. Columns Flow does not know are listed by name; their values are never stored. The patient column groups rows into jobs and is stored only when SEAZONA_KEEP_PATIENT_REFERENCE is on.
  3. Group. Rows with the same invoice number are one job; with no invoice number, the same case number; with neither, the same date, practice, doctor and patient, as the earlier app grouped them. A job with no number is keyed by its content without the patient, so no patient value or hash is kept.
  4. Reconcile. Each job is compared with what Flow holds. It reads Flow's database only.
  5. Decide. A person settles each job that waits: POST /history/batches/:id/decisions with { decisions: [{ jobKey, decision: "import" | "skip" | null, clientId? }] }.
  6. Apply. POST /history/batches/:id/apply reconciles once more, then writes each new job and each job a person chose to import into seazona_history_jobs. A batch is applied once. Nothing in the API copy changes.
Finding Blocks Meaning
mixed_company, mixed_doctorName Yes One invoice number carries two practices or doctors
conflicting_totals Yes The job's rows give different invoice totals
total_differs_from_lines No The total is not the lines, with or without GST
differs_from_api_copy No The API copy holds the same invoice or order at another amount
invoice_has_several_orders No The API copy has more than one order on the invoice
api_copy_ambiguous Yes With no number, more than one order in the API copy fits
client_unknown No No Seazona client has the practice name; the job imports without one
client_ambiguous Yes More than one Seazona client has the name; the person picks clientId
differs_from_earlier_import Yes An applied batch holds the same job with other values (outcome changed)

Outcomes: new (apply writes it), same (already in Flow's history, skipped), changed and needs_person (wait for a decision). A difference is a finding, never a silent change.

The headers Flow reads (GET /history/columns). They compare without case, spaces or punctuation:

Field Headers
Invoice number Invoice #, Invoice Number, Invoice No
Case number Case #, Case Number, Order #, Order Number, Order ID
Dates Invoice Date or Date; Due Date; Ship Date or Date Shipped
Practice Company Name, Company, Practice, Account Name
Account number Account #, Account Number
Doctor Doctor Name, Doctor, Dentist, Practitioner
Patient Patient, Patient Name
Status Status, Case Status
Line Item, Product, Description, Service; Item Code or Code; Qty or Quantity; Tooth
Line amount Item Amount, Amount, Line Amount, Line Total, Price
Line taxable Item Taxable, Taxable (Yes/No)
Invoice total Total, Invoice Total

"Client Name" is not guessed: in Seazona it can mean the doctor or the practice, so map it with columns.

pullHistoryJobs(cursor) gives the applied jobs as ledger jobs marked historical, oldest change first. A job the API copy also holds uses the same order id as sourceJobRef, so the two are one job; others use the case number, else history:<job key>. health() shows history: jobs held, how many the API copy also has, the first and last job date, and open batches.

History jobs in the ledger

The pipeline reads pullHistoryJobs after the live feed (server/core/pipeline.js, ingestHistory; server/core/history.js):

Case What happens
The ledger has no job with that order id The job is made and held: hold_state held, reason history_import, actor import:seazona_history. A held job never drafts.
The live feed made the job first Nothing changes: not its lines, hold, ready mark or invoice.
History made the job, then the live feed reads it The live read updates its lines. The hold stays.
A later import changes a job only history has reported The job takes the new lines and stays held.

The matcher runs on held jobs too, so each history job gets its accounting state. GET /api/seazona/history/ledger lists the held history jobs by that state; ?state=needs_person gives not_found, ambiguous and conflict. A matched job stays held: it is history and already invoiced. Only a person's release (POST /api/seazona/history/ledger/:obligationId/release) moves a job into drafting. The Jobs board and GET /api/invoicing/jobs leave held history jobs out; /jobs?history=include shows them, and its counts.history gives how many there are.

Issues on job cards

A history job a person must decide (outcome needs_person, or changed with no decision) is an issue on the card of the job it belongs to: the order with that id on the Jobs board. The adapter's jobIssues gives each issue as one short phrase (for example "Seazona client is unclear") and a link to the Seazona integration's reconciliation, /integrations/seazona?batch=<id>&job=<job key>. A person's decision there clears the issue: the board reads issues again each time. health() shows history.waitingForPerson, the count for the integration's card.

Health

health() reads Flow's database only; it never calls Seazona. GET /health shows it under integrations:

  • mode, scheduled and the request limit;
  • governor: requests in the last minute, any 429 cooldown, the last day's requests, failures, 429s and retries, the circuit (closed, open, half_open, halted) with circuitOpenUntil and haltedReason, and failedToday against dailyFailureBudget. The last day's failures are split into faults (what the call guard counts) and notFound (404s), and failures lists the top 10 groups by path shape (record ids shown as :id), status and kind (issue #144);
  • scheduler: the next run, the last run, and stuckSince while a run has not ended after 20 minutes. The scheduler then reports it once and goes on with the next run without it (issue #143);
  • lanes: for each lane its status (ok, failed, held, stopped, processing, never_run), whether it is fresh or stale, its last success and error, and the failing orders by id and status; orders_active also lists the quarantined orders, and invoices and clients show quarantinedRecords and rereadDue (waits ended, re-read not yet made);
  • deletedOrders: how many orders are marked deleted in Seazona;
  • readsBeforeAct: today's reads before a draft or a send (reads, changed, withdrawn, unavailable), counted in memory since the last start, or null when there was none;
  • sweep (issue #126): its calls today against the daily share, when each kind of record was last checked, what it found in the last 7 days by record kind, the clients and invoices marked missing, and the source high-water mark for invoices and clients (sourceLastModified, laneReadsFrom, gapMs, seazonaAhead).

ok is false while a lane is failed, held, stopped or stale, or a run is stuck. A lane is stale when it ran before and has not succeeded within its freshness limit, or two of its cadences if that is longer (issue #143). An integration problem never fails Flow's own health check.

alerts() tells owners and admins on the Integrations page what the sync cannot fix by itself: a refused API client, a read Seazona refused (a stopped lane), and reads that are late (stale lanes). None of it holds a host, a credential or patient data.

Replay files

A replay file is JSON with a responses list. Each entry answers one request:

{
  "responses": [
    { "path": "invoices/", "query": { "lastModified": "*" }, "status": 200, "body": [] },
    { "path": "orders/ORD-1001", "status": 404 },
    { "path": "orders/ORD-1002", "sequence": [{ "status": 500 }, { "status": 200, "body": { "id": "ORD-1002" } }] },
    { "path": "orders/ORD-1003", "rawBody": "" },
    { "path": "orders/ORD-1004", "error": "timeout" }
  ]
}
  • path is relative to the API root.
  • query is optional. Each key it lists must match, and "*" matches any value.
  • The first matching entry answers. A sequence answers in order, then repeats its last answer.
  • A request that no entry matches fails with the kind replay_miss.

A replay file holds synthetic data only: no real lab, practice, dentist or patient (CONTRIBUTING.md).

Stage moves

Seazona's API has no call that changes an order's status. So when a person moves a job to another Seazona stage on the Jobs board, Flow never calls Seazona. The move becomes a hand-off (stage-moves.js):

  1. Flow records the move in seazona_stage_moves as waiting, and answers what to do: the order number, the new stage, and a link that opens the order in Seazona (SEAZONA_ORDER_URL, or the lab's Seazona order page).
  2. The card shows in the new lane with "Waiting for Seazona" (listWork gives it pendingMove).
  3. The person changes the status in Seazona. The next sync reads the order, and the move settles as done. Any new status settles it: Seazona has the last word.
  4. After a day with no change, the move is expired. The card goes back to Seazona's stage. No alert follows: the person saw what to do in Seazona when they moved the card.
  5. A person can take a waiting move back (cancelled).

Each person chooses whether the board asks before a stage move: "Ask before stage moves" under Your settings on the Seazona integration screen (seazona_person_settings, on by default). When Seazona adds an API call that changes a status, moveWork can make the change itself, and the hand-off stays only for a failed call (docs/JOB_BOARD_MOVES.md).

Tables

The adapter owns its tables (issue #13). They are created only while the adapter is turned on, from its own migrations in server/integrations/seazona/migrations/, and are not part of the core schema. One installation talks to one Seazona account, so they are installation-wide.

Table What it holds
seazona_api_governor For each API client: when the next request may go, any 429 cooldown, the circuit, and a halt that waits for a person
seazona_api_requests One row per request sent: the rate limit counts these, and they are the telemetry
seazona_orders Orders: status, lines, totals, the deleted-in-Seazona mark
seazona_invoices Seazona invoices, by invoice number, with lines and totals
seazona_clients Practices: account, contact, address, dentists
seazona_reference Products and users
seazona_lane_state Each lane's cursor, next run, status and last failure
seazona_order_read_failures Failed reads per order, and quarantine
seazona_record_read_failures Failed detail reads per invoice or client, the id to read it again by, and quarantine (issues #101, #142)
seazona_history_batches Each uploaded CSV export: its columns, status and counts
seazona_history_rows Each row of an upload as Flow read it, and its problems
seazona_history_batch_jobs Each job of an upload: what reconciliation found and what a person decided
seazona_history_jobs Flow's own record of past work from applied uploads
seazona_sweep_findings What the sweep found that the lanes missed, per record and kind, and the missing marks (issue #126)
seazona_stage_moves The latest stage move per order that a person must make in Seazona, and how it settled
seazona_person_settings Each person's own Seazona choices, such as "Ask before stage moves"

To change them, edit server/integrations/seazona/schema.js and run:

npx drizzle-kit generate --config server/integrations/seazona/drizzle.config.js