Skip to main content

stateset_db/
portability.rs

1//! Full-fidelity structured export and import.
2//!
3//! Where [`crate::maintenance`] moves *bytes* (an exact database image),
4//! this module moves *records* — a versioned, engine-independent JSON
5//! document you can diff, archive, migrate across schema versions, or load
6//! into a different backend.
7//!
8//! # Envelope format
9//!
10//! ```json
11//! {
12//!   "format_version": 1,
13//!   "engine_version": "1.19.0",
14//!   "exported_at": "2026-07-20T12:00:00Z",
15//!   "schema_version": "066_search_configs",
16//!   "domains": {
17//!     "customers": [ { ... }, { ... } ],
18//!     "orders":    [ { ... } ]
19//!   }
20//! }
21//! ```
22//!
23//! Domains are streamed one at a time and each page is serialized straight to
24//! the writer, so peak memory is one page (default 500 records), not the whole
25//! database.
26//!
27//! # What export covers
28//!
29//! | Domain | Export | Import |
30//! |---|---|---|
31//! | `customers` | yes | yes |
32//! | `products` (with variants) | yes | yes |
33//! | `inventory_items` | yes | yes |
34//! | `warehouses` | yes | yes |
35//! | `suppliers` | yes | yes |
36//! | `gl_accounts` | yes | yes |
37//! | `orders` (with line items) | yes | yes |
38//! | `purchase_orders` (with line items) | yes | yes |
39//! | `journal_entries` (with lines) | yes | yes |
40//! | `returns` | yes | no (export only) |
41//! | `invoices` | yes | no (export only) |
42//! | `payments` | yes | no (export only) |
43//! | `bills` | yes | no (export only) |
44//!
45//! # What export does NOT cover
46//!
47//! This is a *business-record* export, not a database image. Use
48//! [`crate::maintenance::backup_to`] when you need byte-exact recovery.
49//! Deliberately omitted:
50//!
51//! - **Derived / ledger-internal state**: inventory transactions and
52//!   reservations, GL trial balances, period roll-forwards, allocations.
53//!   These are recomputed from the records that are exported.
54//! - **Operational plumbing**: `_migrations`, idempotency keys, HTTP
55//!   idempotency responses, audit log, saga state, outbox rows, job queues.
56//! - **Search / ML artifacts**: FTS5 shadow tables, vector embeddings, search
57//!   configs. Rebuild these by reindexing after import.
58//! - **Agent + payment protocol state**: x402 payment intents and credits, A2A
59//!   quotes/purchases, agent cards, identities, reputation, ERC-8004 registries.
60//!   These carry cryptographic material and nonces that must not be replayed.
61//! - **The long tail of ~50 other domains** (carts, subscriptions, promotions,
62//!   loyalty, quality, lots, serials, EDI, fixed assets, revenue recognition,
63//!   …). They are exportable in principle; the registry is designed to be
64//!   extended one `DomainSpec` at a time.
65//!
66//! # Import semantics and the ID caveat
67//!
68//! Import replays records through the ordinary repository `create` methods, so
69//! every invariant, validation rule and derived-field computation the engine
70//! enforces is applied. The consequence is that **IDs are not preserved**:
71//! `CreateCustomer` and friends have no `id` field, and the repository mints a
72//! new one. Import therefore maintains an `IdRemap` and rewrites foreign keys
73//! as it goes (customer → order, product → order item, supplier → purchase
74//! order, GL account → journal entry line), inserting domains in dependency
75//! order.
76//!
77//! The practical rule: import is **content-preserving, not identity-preserving**.
78//! If you need identity preserved — the disaster-recovery case — restore a
79//! backup instead.
80//!
81//! Idempotency is controlled by [`ImportOptions::on_conflict`].
82
83use crate::Database;
84use chrono::Utc;
85use serde::{Deserialize, Serialize};
86use serde_json::{Map, Value};
87use stateset_core::{CommerceError, Result};
88use std::collections::HashMap;
89use std::io::{Read, Write};
90
91/// The envelope format version this build writes and accepts.
92pub const FORMAT_VERSION: u32 = 1;
93
94/// Records fetched per page when walking a domain.
95///
96/// Kept below the engine's 1000-row LIMIT policy so a page is never silently
97/// truncated by the repository layer.
98pub const PAGE_SIZE: u32 = 500;
99
100/// Header fields of an export envelope, without the (streamed) domain payload.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct ExportHeader {
104    /// Envelope format version.
105    pub format_version: u32,
106    /// Engine version that produced the export.
107    pub engine_version: String,
108    /// When the export was taken.
109    pub exported_at: String,
110    /// Highest applied migration name of the source database.
111    pub schema_version: String,
112}
113
114/// Options controlling [`export_all`].
115#[derive(Debug, Clone)]
116pub struct ExportOptions {
117    /// Restrict the export to these domain names. Empty means "all".
118    pub domains: Vec<String>,
119    /// Records fetched per page.
120    pub page_size: u32,
121    /// Pretty-print the JSON.
122    pub pretty: bool,
123    /// Schema version to record in the header.
124    pub schema_version: String,
125}
126
127impl Default for ExportOptions {
128    fn default() -> Self {
129        Self {
130            domains: Vec::new(),
131            page_size: PAGE_SIZE,
132            pretty: false,
133            schema_version: String::new(),
134        }
135    }
136}
137
138/// Per-domain export statistics.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct ExportReport {
142    /// Records written per domain, in export order.
143    pub counts: Vec<(String, usize)>,
144    /// Total records written.
145    pub total: usize,
146}
147
148/// How import should react to a record that already exists.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum ConflictPolicy {
153    /// Leave the existing record alone and continue (idempotent re-import).
154    #[default]
155    Skip,
156    /// Abort the whole import.
157    Fail,
158}
159
160/// Options controlling [`import_all`].
161#[derive(Debug, Clone, Default)]
162pub struct ImportOptions {
163    /// Restrict the import to these domain names. Empty means "all importable".
164    pub domains: Vec<String>,
165    /// Behaviour when a record already exists.
166    pub on_conflict: ConflictPolicy,
167    /// Parse and validate without writing anything.
168    pub dry_run: bool,
169}
170
171/// Result of an [`import_all`] run.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[non_exhaustive]
174pub struct ImportReport {
175    /// Records created per domain.
176    pub created: Vec<(String, usize)>,
177    /// Records skipped as already present, per domain.
178    pub skipped: Vec<(String, usize)>,
179    /// Domains present in the envelope that this build cannot import.
180    pub unsupported_domains: Vec<String>,
181    /// Total records created.
182    pub total_created: usize,
183}
184
185/// Maps source-export IDs onto the IDs minted during import.
186#[derive(Debug, Default)]
187struct IdRemap {
188    by_domain: HashMap<&'static str, HashMap<String, String>>,
189}
190
191impl IdRemap {
192    fn record(&mut self, domain: &'static str, old: &str, new: String) {
193        self.by_domain.entry(domain).or_default().insert(old.to_owned(), new);
194    }
195
196    fn lookup(&self, domain: &str, old: &str) -> Option<&String> {
197        self.by_domain.get(domain).and_then(|m| m.get(old))
198    }
199}
200
201// ===========================================================================
202// Domain registry
203// ===========================================================================
204
205/// A page fetcher: `(db, limit, offset) -> records as JSON`.
206type Fetch = fn(&dyn Database, u32, u32) -> Result<Vec<Value>>;
207/// An importer: `(db, record, remap, policy) -> Outcome`.
208type Import = fn(&dyn Database, &Value, &mut IdRemap, ConflictPolicy) -> Result<Outcome>;
209
210/// What happened to a single imported record.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212enum Outcome {
213    Created,
214    Skipped,
215}
216
217/// Describes one exportable domain.
218struct DomainSpec {
219    /// Key used in the envelope's `domains` map.
220    name: &'static str,
221    /// Paginated reader.
222    fetch: Fetch,
223    /// Writer, or `None` for export-only domains.
224    import: Option<Import>,
225}
226
227/// All registered domains, in dependency order: a domain never appears before
228/// something it references.
229fn registry() -> Vec<DomainSpec> {
230    vec![
231        DomainSpec { name: "customers", fetch: fetch_customers, import: Some(import_customer) },
232        DomainSpec { name: "products", fetch: fetch_products, import: Some(import_product) },
233        DomainSpec {
234            name: "inventory_items",
235            fetch: fetch_inventory_items,
236            import: Some(import_inventory_item),
237        },
238        DomainSpec { name: "warehouses", fetch: fetch_warehouses, import: Some(import_warehouse) },
239        DomainSpec { name: "suppliers", fetch: fetch_suppliers, import: Some(import_supplier) },
240        DomainSpec {
241            name: "gl_accounts",
242            fetch: fetch_gl_accounts,
243            import: Some(import_gl_account),
244        },
245        DomainSpec { name: "orders", fetch: fetch_orders, import: Some(import_order) },
246        DomainSpec {
247            name: "purchase_orders",
248            fetch: fetch_purchase_orders,
249            import: Some(import_purchase_order),
250        },
251        DomainSpec { name: "gl_periods", fetch: fetch_gl_periods, import: Some(import_gl_period) },
252        DomainSpec {
253            name: "journal_entries",
254            fetch: fetch_journal_entries,
255            import: Some(import_journal_entry),
256        },
257        // Export-only: these carry posting/state-machine history that cannot be
258        // faithfully replayed through a single `create` call.
259        DomainSpec { name: "returns", fetch: fetch_returns, import: None },
260        DomainSpec { name: "invoices", fetch: fetch_invoices, import: None },
261        DomainSpec { name: "payments", fetch: fetch_payments, import: None },
262        DomainSpec { name: "bills", fetch: fetch_bills, import: None },
263    ]
264}
265
266/// Names of every domain the export covers, in export order.
267#[must_use]
268pub fn exportable_domains() -> Vec<&'static str> {
269    registry().into_iter().map(|d| d.name).collect()
270}
271
272/// Names of the domains import can write.
273#[must_use]
274pub fn importable_domains() -> Vec<&'static str> {
275    registry().into_iter().filter(|d| d.import.is_some()).map(|d| d.name).collect()
276}
277
278// ===========================================================================
279// Export
280// ===========================================================================
281
282fn json_err(err: serde_json::Error) -> CommerceError {
283    CommerceError::ValidationError(format!("json error: {err}"))
284}
285
286fn io_err(err: std::io::Error) -> CommerceError {
287    CommerceError::DatabaseError(format!("export io error: {err}"))
288}
289
290fn to_values<T: Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
291    items.into_iter().map(|item| serde_json::to_value(item).map_err(json_err)).collect()
292}
293
294/// Write a full structured export of `db` to `writer`.
295///
296/// Domains are streamed sequentially and each page is written as it is read,
297/// so memory use is bounded by [`ExportOptions::page_size`] regardless of
298/// database size.
299///
300/// # Errors
301///
302/// Returns an error if a repository read fails, a record cannot be serialized,
303/// or the writer fails.
304pub fn export_all<W: Write>(
305    db: &dyn Database,
306    writer: &mut W,
307    options: &ExportOptions,
308) -> Result<ExportReport> {
309    let page_size = options.page_size.clamp(1, 1000);
310    let header = ExportHeader {
311        format_version: FORMAT_VERSION,
312        engine_version: env!("CARGO_PKG_VERSION").to_owned(),
313        exported_at: Utc::now().to_rfc3339(),
314        schema_version: options.schema_version.clone(),
315    };
316
317    let mut out = Vec::new();
318    write!(
319        out,
320        "{{\"format_version\":{},\"engine_version\":{},\"exported_at\":{},\"schema_version\":{},\"domains\":{{",
321        header.format_version,
322        serde_json::to_string(&header.engine_version).map_err(json_err)?,
323        serde_json::to_string(&header.exported_at).map_err(json_err)?,
324        serde_json::to_string(&header.schema_version).map_err(json_err)?,
325    )
326    .map_err(io_err)?;
327    writer.write_all(&out).map_err(io_err)?;
328
329    let mut counts = Vec::new();
330    let mut total = 0_usize;
331    let mut first_domain = true;
332
333    for spec in registry() {
334        if !options.domains.is_empty() && !options.domains.iter().any(|d| d == spec.name) {
335            continue;
336        }
337        if !first_domain {
338            writer.write_all(b",").map_err(io_err)?;
339        }
340        first_domain = false;
341        write!(writer, "{}:[", serde_json::to_string(spec.name).map_err(json_err)?)
342            .map_err(io_err)?;
343
344        let mut domain_count = 0_usize;
345        let mut offset = 0_u32;
346        loop {
347            let page = (spec.fetch)(db, page_size, offset)?;
348            let fetched = u32::try_from(page.len()).unwrap_or(u32::MAX);
349            for record in page {
350                if domain_count > 0 {
351                    writer.write_all(b",").map_err(io_err)?;
352                }
353                let encoded = if options.pretty {
354                    serde_json::to_vec_pretty(&record)
355                } else {
356                    serde_json::to_vec(&record)
357                }
358                .map_err(json_err)?;
359                writer.write_all(&encoded).map_err(io_err)?;
360                domain_count += 1;
361            }
362            if fetched < page_size {
363                break;
364            }
365            offset = offset.saturating_add(page_size);
366            // Defensive stop: a repository that ignores offset would otherwise
367            // loop forever re-emitting page zero.
368            if offset > 10_000_000 {
369                break;
370            }
371        }
372
373        writer.write_all(b"]").map_err(io_err)?;
374        total += domain_count;
375        counts.push((spec.name.to_owned(), domain_count));
376    }
377
378    writer.write_all(b"}}").map_err(io_err)?;
379    writer.flush().map_err(io_err)?;
380
381    Ok(ExportReport { counts, total })
382}
383
384// ===========================================================================
385// Import
386// ===========================================================================
387
388/// Read an export envelope from `reader` and replay it into `db`.
389///
390/// See the [module docs](self) for the identity caveat.
391///
392/// # Errors
393///
394/// Returns an error if the envelope is malformed, its `format_version` is
395/// unsupported, a record fails validation, or (under
396/// [`ConflictPolicy::Fail`]) a record already exists.
397pub fn import_all<R: Read>(
398    db: &dyn Database,
399    reader: &mut R,
400    options: &ImportOptions,
401) -> Result<ImportReport> {
402    let mut buf = String::new();
403    reader
404        .read_to_string(&mut buf)
405        .map_err(|e| CommerceError::DatabaseError(format!("import io error: {e}")))?;
406    let envelope: Value = serde_json::from_str(&buf).map_err(json_err)?;
407
408    let format_version =
409        envelope.get("format_version").and_then(Value::as_u64).ok_or_else(|| {
410            CommerceError::ValidationError("export envelope is missing format_version".to_owned())
411        })?;
412    if format_version != u64::from(FORMAT_VERSION) {
413        return Err(CommerceError::ValidationError(format!(
414            "unsupported export format_version {format_version}; this build reads {FORMAT_VERSION}"
415        )));
416    }
417
418    let domains = envelope.get("domains").and_then(Value::as_object).ok_or_else(|| {
419        CommerceError::ValidationError("export envelope is missing a domains object".to_owned())
420    })?;
421
422    let mut remap = IdRemap::default();
423    let mut created = Vec::new();
424    let mut skipped = Vec::new();
425    let mut total_created = 0_usize;
426
427    let specs = registry();
428    for spec in &specs {
429        let Some(importer) = spec.import else { continue };
430        if !options.domains.is_empty() && !options.domains.iter().any(|d| d == spec.name) {
431            continue;
432        }
433        let Some(records) = domains.get(spec.name).and_then(Value::as_array) else { continue };
434
435        let mut n_created = 0_usize;
436        let mut n_skipped = 0_usize;
437        for record in records {
438            if options.dry_run {
439                n_skipped += 1;
440                continue;
441            }
442            match importer(db, record, &mut remap, options.on_conflict)? {
443                Outcome::Created => n_created += 1,
444                Outcome::Skipped => n_skipped += 1,
445            }
446        }
447        total_created += n_created;
448        created.push((spec.name.to_owned(), n_created));
449        skipped.push((spec.name.to_owned(), n_skipped));
450    }
451
452    let importable = importable_domains();
453    let unsupported_domains = domains
454        .keys()
455        .filter(|k| !importable.contains(&k.as_str()))
456        .filter(|k| domains.get(*k).and_then(Value::as_array).is_some_and(|a| !a.is_empty()))
457        .cloned()
458        .collect();
459
460    Ok(ImportReport { created, skipped, unsupported_domains, total_created })
461}
462
463// ===========================================================================
464// Field helpers
465// ===========================================================================
466
467fn obj(record: &Value) -> Result<&Map<String, Value>> {
468    record
469        .as_object()
470        .ok_or_else(|| CommerceError::ValidationError("export record is not an object".to_owned()))
471}
472
473fn str_field(record: &Value, field: &str) -> Result<String> {
474    obj(record)?
475        .get(field)
476        .and_then(Value::as_str)
477        .map(ToOwned::to_owned)
478        .ok_or_else(|| CommerceError::ValidationError(format!("record is missing '{field}'")))
479}
480
481fn opt_str(record: &Value, field: &str) -> Option<String> {
482    record.get(field).and_then(Value::as_str).map(ToOwned::to_owned)
483}
484
485/// Deserialize the first of `fields` that is present and non-null.
486///
487/// Model structs and their `Create*` inputs sometimes name the same concept
488/// differently (`PurchaseOrderItem::quantity_ordered` vs
489/// `CreatePurchaseOrderItem::quantity`); this bridges the two.
490fn any_field_as<T: for<'de> Deserialize<'de>>(record: &Value, fields: &[&str]) -> Result<T> {
491    let value = fields
492        .iter()
493        .find_map(|field| record.get(*field).filter(|v| !v.is_null()).cloned())
494        .unwrap_or(Value::Null);
495    serde_json::from_value(value).map_err(|e| {
496        CommerceError::ValidationError(format!("field '{}': {e}", fields.join("' / '")))
497    })
498}
499
500/// Take a subobject of a record and deserialize it into `T`.
501fn field_as<T: for<'de> Deserialize<'de>>(record: &Value, field: &str) -> Result<T> {
502    let value = record.get(field).cloned().unwrap_or(Value::Null);
503    serde_json::from_value(value)
504        .map_err(|e| CommerceError::ValidationError(format!("field '{field}': {e}")))
505}
506
507/// Deserialize an entire record into a `Create*` input by reusing the model's
508/// own serde representation, dropping fields the input does not declare.
509fn record_as<T: for<'de> Deserialize<'de>>(record: &Value) -> Result<T> {
510    serde_json::from_value(record.clone())
511        .map_err(|e| CommerceError::ValidationError(format!("cannot decode record: {e}")))
512}
513
514/// Resolve a foreign key through the remap, falling back to the original value
515/// when the referenced domain was not part of this import.
516fn remapped_id(remap: &IdRemap, domain: &str, raw: &str) -> String {
517    remap.lookup(domain, raw).cloned().unwrap_or_else(|| raw.to_owned())
518}
519
520fn parse_uuid(raw: &str, field: &str) -> Result<uuid::Uuid> {
521    raw.parse::<uuid::Uuid>()
522        .map_err(|e| CommerceError::ValidationError(format!("field '{field}' is not a uuid: {e}")))
523}
524
525// ===========================================================================
526// Per-domain fetch + import
527// ===========================================================================
528
529fn fetch_customers(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
530    let filter = stateset_core::CustomerFilter {
531        limit: Some(limit),
532        offset: Some(offset),
533        ..Default::default()
534    };
535    to_values(db.customers().list(filter)?)
536}
537
538fn import_customer(
539    db: &dyn Database,
540    record: &Value,
541    remap: &mut IdRemap,
542    policy: ConflictPolicy,
543) -> Result<Outcome> {
544    let old_id = str_field(record, "id")?;
545    let email = str_field(record, "email")?;
546    if let Some(existing) = db.customers().get_by_email(&email)? {
547        if policy == ConflictPolicy::Fail {
548            return Err(CommerceError::ValidationError(format!(
549                "customer with email '{email}' already exists"
550            )));
551        }
552        remap.record("customers", &old_id, existing.id.to_string());
553        return Ok(Outcome::Skipped);
554    }
555    let input = stateset_core::CreateCustomer {
556        email,
557        first_name: str_field(record, "first_name")?,
558        last_name: str_field(record, "last_name")?,
559        phone: opt_str(record, "phone"),
560        accepts_marketing: record.get("accepts_marketing").and_then(Value::as_bool),
561        tags: field_as::<Option<Vec<String>>>(record, "tags")?,
562        metadata: record.get("metadata").cloned().filter(|v| !v.is_null()),
563    };
564    let created = db.customers().create(input)?;
565    remap.record("customers", &old_id, created.id.to_string());
566    Ok(Outcome::Created)
567}
568
569fn fetch_products(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
570    let filter = stateset_core::ProductFilter {
571        limit: Some(limit),
572        offset: Some(offset),
573        ..Default::default()
574    };
575    to_values(db.products().list(filter)?)
576}
577
578fn import_product(
579    db: &dyn Database,
580    record: &Value,
581    remap: &mut IdRemap,
582    policy: ConflictPolicy,
583) -> Result<Outcome> {
584    let old_id = str_field(record, "id")?;
585    let name = str_field(record, "name")?;
586    let slug = opt_str(record, "slug");
587
588    if let Some(slug) = slug.as_deref() {
589        let existing = db.products().list(stateset_core::ProductFilter {
590            search: Some(slug.to_owned()),
591            limit: Some(1000),
592            ..Default::default()
593        })?;
594        if let Some(found) = existing.iter().find(|p| p.slug == slug) {
595            if policy == ConflictPolicy::Fail {
596                return Err(CommerceError::ValidationError(format!(
597                    "product with slug '{slug}' already exists"
598                )));
599            }
600            remap.record("products", &old_id, found.id.to_string());
601            return Ok(Outcome::Skipped);
602        }
603    }
604
605    let variants: Option<Vec<stateset_core::CreateProductVariant>> =
606        match record.get("variants").and_then(Value::as_array) {
607            Some(list) if !list.is_empty() => Some(
608                list.iter()
609                    .map(record_as::<stateset_core::CreateProductVariant>)
610                    .collect::<Result<Vec<_>>>()?,
611            ),
612            _ => None,
613        };
614
615    let input = stateset_core::CreateProduct {
616        name,
617        slug,
618        description: opt_str(record, "description"),
619        product_type: field_as(record, "product_type")?,
620        attributes: field_as(record, "attributes")?,
621        seo: field_as(record, "seo")?,
622        variants,
623    };
624    let created = db.products().create(input)?;
625    remap.record("products", &old_id, created.id.to_string());
626    Ok(Outcome::Created)
627}
628
629fn fetch_inventory_items(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
630    let filter = stateset_core::InventoryFilter {
631        limit: Some(limit),
632        offset: Some(offset),
633        ..Default::default()
634    };
635    to_values(db.inventory().list(filter)?)
636}
637
638fn import_inventory_item(
639    db: &dyn Database,
640    record: &Value,
641    _remap: &mut IdRemap,
642    policy: ConflictPolicy,
643) -> Result<Outcome> {
644    let sku = str_field(record, "sku")?;
645    if db.inventory().get_item_by_sku(&sku)?.is_some() {
646        if policy == ConflictPolicy::Fail {
647            return Err(CommerceError::ValidationError(format!(
648                "inventory item with sku '{sku}' already exists"
649            )));
650        }
651        return Ok(Outcome::Skipped);
652    }
653    let input = stateset_core::CreateInventoryItem {
654        sku,
655        name: str_field(record, "name")?,
656        description: opt_str(record, "description"),
657        unit_of_measure: opt_str(record, "unit_of_measure"),
658        initial_quantity: field_as(record, "initial_quantity")?,
659        location_id: record
660            .get("location_id")
661            .and_then(Value::as_i64)
662            .and_then(|v| i32::try_from(v).ok()),
663        reorder_point: field_as(record, "reorder_point")?,
664        safety_stock: field_as(record, "safety_stock")?,
665    };
666    db.inventory().create_item(input)?;
667    Ok(Outcome::Created)
668}
669
670fn fetch_warehouses(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
671    let filter = stateset_core::WarehouseFilter {
672        limit: Some(limit),
673        offset: Some(offset),
674        ..Default::default()
675    };
676    to_values(db.warehouse().list_warehouses(filter)?)
677}
678
679fn import_warehouse(
680    db: &dyn Database,
681    record: &Value,
682    remap: &mut IdRemap,
683    policy: ConflictPolicy,
684) -> Result<Outcome> {
685    let code = str_field(record, "code")?;
686    let old_id = record.get("id").map(ToString::to_string).unwrap_or_default();
687    if let Some(existing) = db.warehouse().get_warehouse_by_code(&code)? {
688        if policy == ConflictPolicy::Fail {
689            return Err(CommerceError::ValidationError(format!(
690                "warehouse with code '{code}' already exists"
691            )));
692        }
693        remap.record("warehouses", &old_id, existing.id.to_string());
694        return Ok(Outcome::Skipped);
695    }
696    let input = stateset_core::CreateWarehouse {
697        code,
698        name: str_field(record, "name")?,
699        warehouse_type: field_as(record, "warehouse_type")?,
700        address: field_as(record, "address")?,
701        timezone: opt_str(record, "timezone"),
702    };
703    let created = db.warehouse().create_warehouse(input)?;
704    remap.record("warehouses", &old_id, created.id.to_string());
705    Ok(Outcome::Created)
706}
707
708fn fetch_suppliers(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
709    let filter = stateset_core::SupplierFilter {
710        limit: Some(limit),
711        offset: Some(offset),
712        ..Default::default()
713    };
714    to_values(db.purchase_orders().list_suppliers(filter)?)
715}
716
717fn import_supplier(
718    db: &dyn Database,
719    record: &Value,
720    remap: &mut IdRemap,
721    policy: ConflictPolicy,
722) -> Result<Outcome> {
723    let old_id = str_field(record, "id")?;
724    let code = opt_str(record, "supplier_code");
725    if let Some(code) = code.as_deref() {
726        if let Some(existing) = db.purchase_orders().get_supplier_by_code(code)? {
727            if policy == ConflictPolicy::Fail {
728                return Err(CommerceError::ValidationError(format!(
729                    "supplier with code '{code}' already exists"
730                )));
731            }
732            remap.record("suppliers", &old_id, existing.id.to_string());
733            return Ok(Outcome::Skipped);
734        }
735    }
736    let input = stateset_core::CreateSupplier {
737        name: str_field(record, "name")?,
738        supplier_code: code,
739        contact_name: opt_str(record, "contact_name"),
740        email: opt_str(record, "email"),
741        phone: opt_str(record, "phone"),
742        website: opt_str(record, "website"),
743        address: opt_str(record, "address"),
744        city: opt_str(record, "city"),
745        state: opt_str(record, "state"),
746        postal_code: opt_str(record, "postal_code"),
747        country: opt_str(record, "country"),
748        tax_id: opt_str(record, "tax_id"),
749        payment_terms: field_as(record, "payment_terms")?,
750        currency: field_as(record, "currency")?,
751        lead_time_days: record
752            .get("lead_time_days")
753            .and_then(Value::as_i64)
754            .and_then(|v| i32::try_from(v).ok()),
755        minimum_order: field_as(record, "minimum_order")?,
756        notes: opt_str(record, "notes"),
757    };
758    let created = db.purchase_orders().create_supplier(input)?;
759    remap.record("suppliers", &old_id, created.id.to_string());
760    Ok(Outcome::Created)
761}
762
763fn fetch_gl_accounts(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
764    let filter = stateset_core::GlAccountFilter {
765        limit: Some(limit),
766        offset: Some(offset),
767        ..Default::default()
768    };
769    to_values(db.general_ledger().list_accounts(filter)?)
770}
771
772fn import_gl_account(
773    db: &dyn Database,
774    record: &Value,
775    remap: &mut IdRemap,
776    policy: ConflictPolicy,
777) -> Result<Outcome> {
778    let old_id = str_field(record, "id")?;
779    let account_number = str_field(record, "account_number")?;
780    if let Some(existing) = db.general_ledger().get_account_by_number(&account_number)? {
781        if policy == ConflictPolicy::Fail {
782            return Err(CommerceError::ValidationError(format!(
783                "gl account '{account_number}' already exists"
784            )));
785        }
786        remap.record("gl_accounts", &old_id, existing.id.to_string());
787        return Ok(Outcome::Skipped);
788    }
789    // Parent accounts appear in the same page and are remapped if already seen.
790    let parent_account_id = match opt_str(record, "parent_account_id") {
791        Some(parent) => {
792            Some(parse_uuid(&remapped_id(remap, "gl_accounts", &parent), "parent_account_id")?)
793        }
794        None => None,
795    };
796    let input = stateset_core::CreateGlAccount {
797        account_number,
798        name: str_field(record, "name")?,
799        description: opt_str(record, "description"),
800        account_type: field_as(record, "account_type")?,
801        account_sub_type: field_as(record, "account_sub_type")?,
802        parent_account_id,
803        is_header: record.get("is_header").and_then(Value::as_bool),
804        is_posting: record.get("is_posting").and_then(Value::as_bool),
805        currency: field_as(record, "currency")?,
806    };
807    let created = db.general_ledger().create_account(input)?;
808    remap.record("gl_accounts", &old_id, created.id.to_string());
809    Ok(Outcome::Created)
810}
811
812fn fetch_orders(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
813    let filter = stateset_core::OrderFilter {
814        limit: Some(limit),
815        offset: Some(offset),
816        ..Default::default()
817    };
818    to_values(db.orders().list(filter)?)
819}
820
821fn import_order(
822    db: &dyn Database,
823    record: &Value,
824    remap: &mut IdRemap,
825    _policy: ConflictPolicy,
826) -> Result<Outcome> {
827    let raw_customer = str_field(record, "customer_id")?;
828    let customer_id = parse_uuid(&remapped_id(remap, "customers", &raw_customer), "customer_id")?;
829
830    let items = record
831        .get("items")
832        .and_then(Value::as_array)
833        .map(|list| {
834            list.iter()
835                .map(|item| {
836                    let raw_product = str_field(item, "product_id")?;
837                    Ok(stateset_core::CreateOrderItem {
838                        product_id: parse_uuid(
839                            &remapped_id(remap, "products", &raw_product),
840                            "product_id",
841                        )?
842                        .into(),
843                        variant_id: None,
844                        sku: str_field(item, "sku")?,
845                        name: str_field(item, "name")?,
846                        quantity: item
847                            .get("quantity")
848                            .and_then(Value::as_i64)
849                            .and_then(|q| i32::try_from(q).ok())
850                            .unwrap_or(0),
851                        unit_price: field_as(item, "unit_price")?,
852                        discount: field_as(item, "discount")?,
853                        tax_amount: field_as(item, "tax_amount")?,
854                    })
855                })
856                .collect::<Result<Vec<_>>>()
857        })
858        .transpose()?
859        .unwrap_or_default();
860
861    let input = stateset_core::CreateOrder {
862        customer_id: customer_id.into(),
863        items,
864        currency: field_as(record, "currency")?,
865        shipping_address: field_as(record, "shipping_address")?,
866        billing_address: field_as(record, "billing_address")?,
867        notes: opt_str(record, "notes"),
868        payment_method: opt_str(record, "payment_method"),
869        shipping_method: opt_str(record, "shipping_method"),
870    };
871    let created = db.orders().create(input)?;
872    if let Ok(old_id) = str_field(record, "id") {
873        remap.record("orders", &old_id, created.id.to_string());
874    }
875    Ok(Outcome::Created)
876}
877
878fn fetch_purchase_orders(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
879    let filter = stateset_core::PurchaseOrderFilter {
880        limit: Some(limit),
881        offset: Some(offset),
882        ..Default::default()
883    };
884    to_values(db.purchase_orders().list(filter)?)
885}
886
887fn import_purchase_order(
888    db: &dyn Database,
889    record: &Value,
890    remap: &mut IdRemap,
891    _policy: ConflictPolicy,
892) -> Result<Outcome> {
893    let raw_supplier = str_field(record, "supplier_id")?;
894    let supplier_id = parse_uuid(&remapped_id(remap, "suppliers", &raw_supplier), "supplier_id")?;
895
896    let items = record
897        .get("items")
898        .and_then(Value::as_array)
899        .map(|list| {
900            list.iter()
901                .map(|item| {
902                    let product_id = match opt_str(item, "product_id") {
903                        Some(raw) => Some(
904                            parse_uuid(&remapped_id(remap, "products", &raw), "product_id")?.into(),
905                        ),
906                        None => None,
907                    };
908                    Ok(stateset_core::CreatePurchaseOrderItem {
909                        product_id,
910                        sku: str_field(item, "sku")?,
911                        name: str_field(item, "name")?,
912                        supplier_sku: opt_str(item, "supplier_sku"),
913                        quantity: any_field_as(item, &["quantity", "quantity_ordered"])?,
914                        unit_of_measure: opt_str(item, "unit_of_measure"),
915                        unit_cost: field_as(item, "unit_cost")?,
916                        tax_amount: field_as(item, "tax_amount")?,
917                        discount_amount: field_as(item, "discount_amount")?,
918                        expected_date: field_as(item, "expected_date")?,
919                        notes: opt_str(item, "notes"),
920                    })
921                })
922                .collect::<Result<Vec<_>>>()
923        })
924        .transpose()?
925        .unwrap_or_default();
926
927    let input = stateset_core::CreatePurchaseOrder {
928        supplier_id,
929        order_date: field_as(record, "order_date")?,
930        expected_date: field_as(record, "expected_date")?,
931        ship_to_address: opt_str(record, "ship_to_address"),
932        ship_to_city: opt_str(record, "ship_to_city"),
933        ship_to_state: opt_str(record, "ship_to_state"),
934        ship_to_postal_code: opt_str(record, "ship_to_postal_code"),
935        ship_to_country: opt_str(record, "ship_to_country"),
936        payment_terms: field_as(record, "payment_terms")?,
937        currency: field_as(record, "currency")?,
938        tax_amount: field_as(record, "tax_amount")?,
939        shipping_cost: field_as(record, "shipping_cost")?,
940        discount_amount: field_as(record, "discount_amount")?,
941        notes: opt_str(record, "notes"),
942        supplier_notes: opt_str(record, "supplier_notes"),
943        items,
944    };
945    db.purchase_orders().create(input)?;
946    Ok(Outcome::Created)
947}
948
949fn fetch_gl_periods(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
950    let filter = stateset_core::GlPeriodFilter {
951        limit: Some(limit),
952        offset: Some(offset),
953        ..Default::default()
954    };
955    to_values(db.general_ledger().list_periods(filter)?)
956}
957
958fn import_gl_period(
959    db: &dyn Database,
960    record: &Value,
961    remap: &mut IdRemap,
962    policy: ConflictPolicy,
963) -> Result<Outcome> {
964    let old_id = str_field(record, "id")?;
965    let fiscal_year = record.get("fiscal_year").and_then(Value::as_i64).unwrap_or(0);
966    let period_number = record.get("period_number").and_then(Value::as_i64).unwrap_or(0);
967
968    let existing = db.general_ledger().list_periods(stateset_core::GlPeriodFilter {
969        fiscal_year: i32::try_from(fiscal_year).ok(),
970        limit: Some(1000),
971        ..Default::default()
972    })?;
973    if let Some(found) = existing.into_iter().find(|p| i64::from(p.period_number) == period_number)
974    {
975        if policy == ConflictPolicy::Fail {
976            return Err(CommerceError::ValidationError(format!(
977                "gl period {fiscal_year}-{period_number} already exists"
978            )));
979        }
980        remap.record("gl_periods", &old_id, found.id.to_string());
981        return Ok(Outcome::Skipped);
982    }
983
984    let input = stateset_core::CreateGlPeriod {
985        period_name: str_field(record, "period_name")?,
986        fiscal_year: i32::try_from(fiscal_year).unwrap_or_default(),
987        period_number: i32::try_from(period_number).unwrap_or_default(),
988        start_date: field_as(record, "start_date")?,
989        end_date: field_as(record, "end_date")?,
990    };
991    let created = db.general_ledger().create_period(input)?;
992    // Preserve the open/closed lifecycle so journal entries can still post.
993    if opt_str(record, "status").as_deref() == Some("open") {
994        db.general_ledger().open_period(created.id)?;
995    }
996    remap.record("gl_periods", &old_id, created.id.to_string());
997    Ok(Outcome::Created)
998}
999
1000fn fetch_journal_entries(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1001    let filter = stateset_core::JournalEntryFilter {
1002        limit: Some(limit),
1003        offset: Some(offset),
1004        ..Default::default()
1005    };
1006    to_values(db.general_ledger().list_journal_entries(filter)?)
1007}
1008
1009fn import_journal_entry(
1010    db: &dyn Database,
1011    record: &Value,
1012    remap: &mut IdRemap,
1013    _policy: ConflictPolicy,
1014) -> Result<Outcome> {
1015    let lines = record
1016        .get("lines")
1017        .and_then(Value::as_array)
1018        .map(|list| {
1019            list.iter()
1020                .map(|line| {
1021                    let raw_account = str_field(line, "account_id")?;
1022                    Ok(stateset_core::CreateJournalEntryLine {
1023                        account_id: parse_uuid(
1024                            &remapped_id(remap, "gl_accounts", &raw_account),
1025                            "account_id",
1026                        )?,
1027                        description: opt_str(line, "description"),
1028                        debit_amount: field_as(line, "debit_amount")?,
1029                        credit_amount: field_as(line, "credit_amount")?,
1030                        reference_type: opt_str(line, "reference_type"),
1031                        reference_id: match opt_str(line, "reference_id") {
1032                            Some(raw) => Some(parse_uuid(&raw, "reference_id")?),
1033                            None => None,
1034                        },
1035                    })
1036                })
1037                .collect::<Result<Vec<_>>>()
1038        })
1039        .transpose()?
1040        .unwrap_or_default();
1041
1042    let input = stateset_core::CreateJournalEntry {
1043        entry_date: field_as(record, "entry_date")?,
1044        entry_type: field_as(record, "entry_type")?,
1045        description: str_field(record, "description")?,
1046        lines,
1047        source_document_type: opt_str(record, "source_document_type"),
1048        source_document_id: match opt_str(record, "source_document_id") {
1049            Some(raw) => Some(parse_uuid(&raw, "source_document_id")?),
1050            None => None,
1051        },
1052        auto_post: Some(false),
1053    };
1054    db.general_ledger().create_journal_entry(input)?;
1055    Ok(Outcome::Created)
1056}
1057
1058// --- export-only domains ---------------------------------------------------
1059
1060fn fetch_returns(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1061    let filter = stateset_core::ReturnFilter {
1062        limit: Some(limit),
1063        offset: Some(offset),
1064        ..Default::default()
1065    };
1066    to_values(db.returns().list(filter)?)
1067}
1068
1069fn fetch_invoices(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1070    let filter = stateset_core::InvoiceFilter {
1071        limit: Some(limit),
1072        offset: Some(offset),
1073        ..Default::default()
1074    };
1075    to_values(db.invoices().list(filter)?)
1076}
1077
1078fn fetch_payments(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1079    let filter = stateset_core::PaymentFilter {
1080        limit: Some(limit),
1081        offset: Some(offset),
1082        ..Default::default()
1083    };
1084    to_values(db.payments().list(filter)?)
1085}
1086
1087fn fetch_bills(db: &dyn Database, limit: u32, offset: u32) -> Result<Vec<Value>> {
1088    let filter = stateset_core::BillFilter {
1089        limit: Some(limit),
1090        offset: Some(offset),
1091        ..Default::default()
1092    };
1093    to_values(db.accounts_payable().list_bills(filter)?)
1094}