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:
- Add
seazonatoFLOW_INTEGRATIONS(docs/INTEGRATIONS.md). Its tables are created at the next start. - Set
SEAZONA_MODEand 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. - Start Flow. The log says
[seazona] mode live; background sync on. - Check
GET /health:integrationslists 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-Afterholds every process for that long (at most 60 seconds). - A 429 or 5xx answer is retried at most
SEAZONA_API_MAX_RETRIEStimes, with a growing wait. Each retry counts against the limit. - A request that waits longer than
SEAZONA_API_MAX_WAIT_MSgives up withseazona_api_governor_timeout. - In
liveandsandboxthe 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'slastModified(clients: one second before it). A run that reachesSEAZONA_LIST_PAGES_PER_RUNstores 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 inseazona_sweep_findingsand 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
lastModifiedFlow holds for invoices and for clients./healthshows 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, withreasondeleted_in_seazonaornot_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.moreis 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:
- lists orders from the next day (
GET orders/?ordered=<day>, withpageandpageSizeup 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 afterthroughends the walk unread; - leaves out the orders the live copy already holds;
- reads each other order (
GET orders/<id>), maps it as the live lanes do, and stages it as jobcase:<order id>in the backfill's batch; - 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]
- Upload. An owner or admin posts the file
(
POST /history/batches, atext/csvbody with?fileName=, or JSON{ csv, fileName, columns }, at most 25 MB and 200,000 rows). Flow guesses each column from its header (below);columnscorrects 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. - 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_REFERENCEis on. - 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.
- Reconcile. Each job is compared with what Flow holds. It reads Flow's database only.
- Decide. A person settles each job that waits:
POST /history/batches/:id/decisionswith{ decisions: [{ jobKey, decision: "import" | "skip" | null, clientId? }] }. - Apply.
POST /history/batches/:id/applyreconciles once more, then writes each new job and each job a person chose to import intoseazona_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,scheduledand 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) withcircuitOpenUntilandhaltedReason, andfailedTodayagainstdailyFailureBudget. The last day's failures are split intofaults(what the call guard counts) andnotFound(404s), andfailureslists the top 10 groups by path shape (record ids shown as:id), status and kind (issue #144);scheduler: the next run, the last run, andstuckSincewhile 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 orstale, its last success and error, and the failing orders by id and status;orders_activealso lists the quarantined orders, andinvoicesandclientsshowquarantinedRecordsandrereadDue(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 markedmissing, 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" }
]
}
pathis relative to the API root.queryis optional. Each key it lists must match, and"*"matches any value.- The first matching entry answers. A
sequenceanswers 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):
- Flow records the move in
seazona_stage_movesaswaiting, 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). - The card shows in the new lane with "Waiting for Seazona" (
listWorkgives itpendingMove). - 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. - 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. - 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