Compare commits

...

6 Commits

Author SHA1 Message Date
Mike Kell 0e3987bc1f Merge chore/state-reconciliation-flutter-347: working tree reconciliation and master document re-baseline (1898/1898 tests passing)
Publish Docs / publish-docs (push) Successful in 54s Details
2026-09-25 14:19:45 -04:00
Mike Kell ea4b76b407 chore: reconcile working tree with main and re-baseline master documents
Reconciles repository state after the local toolchain moved to Flutter 3.47.0
/ Dart 3.13.0. No product code changed - no lib/ or test/ file was modified.

Working tree drift:

1. analysis_options.yaml churn (accepted, 11 files). Flutter 3.47 ships
   AnalysisOptionsMigration, which unconditionally writes a fixed
   analyzer.exclude block into every analysis_options.yaml. It runs from
   ensureReadyForPlatformSpecificTooling, so pub get / analyze / run / build
   all restore it, and there is no opt-out (flutter#191160, flutter#191131).
   Commit 92d83e0 previously reverted this; the revert did not hold - a full
   test sweep regenerated the block in 8 more packages. Accepted and committed
   rather than fighting the tool. Verified the risky web/** entry drops
   nothing: neither kell_web/web/ nor kell_mobile/web/ contains .dart files.

2. kell_mobile web scaffold (reverted). A stray `flutter create
   --platforms=web` had added an untracked web/ directory and rewritten
   .metadata so the android platform entry was replaced by web. kell_mobile is
   the Android target, so .metadata was restored and the scaffold deleted.

3. kell_mobile/pubspec.lock (accepted). Transitive bumps resolved under the
   newer SDK: clock, matcher, meta, stack_trace, test_api, vector_math,
   vm_service. All SDK-pinned.

Master document reconciliation:

- PROJECT_STATE_AUDIT.md: removed a duplicated "## 5. Summary Scorecard"
  section - the scorecard and readiness table were present twice from a bad
  append. Re-baselined the remaining §5 against main, added a test-count
  column, and corrected §3.4 counts and §3.5 antipattern status. Stages
  10C-10F, 11A, and 16A had landed but were still recorded as missing.
- master_development_brief.md: Appendix A maturity matrix refreshed to the
  verified counts; new "Toolchain baseline" section documents the Flutter 3.47
  behaviour so a future slice does not try to revert it again.
- build_execution_tracker.md: added the reconciliation slice entry and a
  toolchain note.
- Fixed a pre-existing MD040 lint error (unlabelled code fence).

Verification:
- dart analyze --fatal-infos: clean across all 11 packages and apps
- tests: 1,898 / 1,898 passing (core 90, design_system 51, auth 42,
  integrations 123, data 63, feature_wordpress 545, feature_inventory 167,
  feature_orders 657, feature_policy 17, kell_web 62, kell_mobile 81) -
  identical to the Stage 11A total, confirming no behaviour change
- markdownlint-cli2: 0 issues
2026-09-25 14:19:36 -04:00
Mike Kell 92d83e00e2 chore: revert unrelated Flutter tool churn to analysis_options.yaml and pubspec.lock 2026-08-29 15:20:35 -04:00
Mike Kell a2354ccd36 chore: update tracker - feat/custom-order-domain merged to main (Stage 11A complete) 2026-08-29 15:20:19 -04:00
Mike Kell 85501fdaaa Merge feat/custom-order-domain: Stage 11A custom order domain and data (1898/1898 tests passing) 2026-08-29 15:19:45 -04:00
Mike Kell 86c630d670 feat(stage-11a): custom order domain and data
Adds the custom/made-to-order commission workflow to feature_orders at the
domain and data layers. No UI in this slice — Stage 11B/11C cover the
CustomOrderFormPage, confirmation, and tracking surfaces.

Custom orders extend the existing Order aggregate rather than forming a
parallel system, so they flow through the same repository contract and the
same order management pipeline as standard orders.

Domain:

- OrderType enum (standard, custom) with wireValue/fromWire; fromWire defaults
  to standard so an order written by an older client is never mis-classified
  as a commission.
- CustomOrderSpec value object — material, color, size, dimensions,
  unmodifiable reference image URLs, free-text notes, estimated completion.
  Immutable with copyWith, list-aware ==/hashCode, and a toString that reports
  the reference image count rather than the URLs.
- DepositInfo value object — all money as integer cents, consistent with
  PaymentResult.amountCents, so repeated deposit/balance/settlement arithmetic
  cannot drift. Derived balanceDueCents clamps at zero rather than reporting a
  credit when a deposit exceeds the total.
- FulfillmentStatus pipeline: quoted -> depositReceived -> inProduction ->
  qualityCheck -> readyForPickup -> completed, plus cancelled from any
  non-terminal stage. FulfillmentTransition enforces legality in the domain —
  illegal jumps, same-stage advances, and any move out of a terminal stage are
  rejected with an explanatory reason rather than silently applied.
  qualityCheck -> inProduction is permitted: failed inspection sending a piece
  back to the bench is a real workshop operation.
- Order extended with orderType (defaults to standard), nullable
  customOrderSpec, depositInfo, and fulfillmentStatus, plus isCustom,
  hasDeposit, and balanceDueCents. Every existing field, default, and copyWith
  semantic is preserved.
- OrdersRepository extended with createCustomOrder, updateCustomOrderSpec,
  recordDeposit, and advanceFulfillmentStatus.

Data:

- FakeOrdersRepository implements all four methods and seeds five custom
  orders (KC-C2001..KC-C2005) covering every non-terminal pipeline stage. The
  six standard orders are unchanged.
- WooCommerceOrdersRepository implements all four methods. Custom order state
  round-trips through 13 _kc_-prefixed order meta keys; the full mapping table
  is documented on WooCommerceOrderMapper. Spec edits and stage advances PUT
  only meta, so they can never mutate a price, a line item, or a customer
  record. balanceDueCents is derived on read, never persisted, to avoid a
  second source of truth.
- WooCommerceApiClient.updateOrder added in feature_wordpress — only
  updateOrderStatus existed, which cannot carry meta.

Observability (Stage 16A layer):

- KcLogger injected into both repositories, defaulting to KcLogger.silent().
  Domain rejections log at warning; API failures log at error with the status
  code and originating exception.
- No silent catch (_) {} blocks — every swallowed error produces both a log
  record and a failure result.
- Log context is limited to identifiers, operation names, and status names.
  Customer identity, addresses, money amounts, and payment identifiers are
  never logged, and all context additionally passes through KcRedactor.
- The deposit transaction ID is deliberately absent from both DepositInfo and
  the WooCommerce meta mapping — it lives on PaymentResult alone, because
  order meta is readable by any store administrator. Asserted by tests on both
  the object and the outgoing request body.

Also closes P3.8: adds .metadata, CHANGELOG.md, LICENSE, and README.md to
feature_orders so it matches every other package (.gitignore already existed).
The README documents the pipeline, the meta mapping, and the privacy rules.

Deviations from the brief, recorded in the Stage 11A section:

- estimatedCompletionDate lives on CustomOrderSpec rather than Order, and
  there is no separate updateEstimatedCompletion — a date revision goes
  through updateCustomOrderSpec. Deferred for review in Stage 11C.
- internalNotes not added; Order.notes already serves that purpose.

Validation:

- 423 new tests in feature_orders (234 -> 657), including an exhaustive 7x7
  legal/illegal transition matrix and coverage of every new repository method
  on both implementations.
- 1,898/1,898 passing across all packages; no regression from the 1,475
  baseline at 666abd2.
- dart analyze --fatal-infos clean across all 11 packages and apps.
- dart format --line-length 100 clean on every file touched by this slice.

Closes: P2.2 (domain + data portion), P3.8
2026-08-29 15:19:29 -04:00
41 changed files with 4826 additions and 210 deletions

View File

@ -3,19 +3,28 @@
## Kell Creations Operations Platform — Comprehensive Code Audit ## Kell Creations Operations Platform — Comprehensive Code Audit
**Audit Date:** 2026-07-25 **Audit Date:** 2026-07-25
**Last Reconciled:** 2026-09-25 (`chore/state-reconciliation-flutter-347`)
**Auditor:** Senior Software Engineering Review Board (Automated) **Auditor:** Senior Software Engineering Review Board (Automated)
**Baseline Commit:** `feat/square-catalog-sync-repositories` → `main` (Stage 10C complete) **Baseline Commit:** `feat/square-catalog-sync-repositories` → `main` (Stage 10C complete)
**Current Branch Work:** Stage 10D (`SquareCatalogSyncService`) — complete, pending merge **Current Branch Work:** Stage 10D (`SquareCatalogSyncService`) — complete, pending merge
**Total Tests (baseline):** 1,015 / 1,015 passing **Total Tests (baseline):** 1,015 / 1,015 passing
**Static Analysis:** `dart analyze --fatal-infos` — clean across all 11 packages/apps **Static Analysis:** `dart analyze --fatal-infos` — clean across all 11 packages/apps
> **Reconciliation notice (2026-09-25).** Sections 1–4 below are preserved as the
> historical 2026-07-25 audit record and are **not** rewritten in place. Since that
> date Stages 10C (POS), 10D, 10E, 10F, 11A, and 16A have all merged to `main`, so
> many "❌ Missing" findings in those sections are now closed. §5 has been
> re-baselined against `main` as of 2026-09-25 and is the authoritative scorecard.
> A duplicated copy of §5 (a bad append in an earlier edit) was removed on the same
> date.
--- ---
## 1. Repository Architecture Overview ## 1. Repository Architecture Overview
### Monorepo Structure ### Monorepo Structure
``` ```text
kell_creations/ kell_creations/
├── kell_creations_apps/ ├── kell_creations_apps/
│ ├── apps/ │ ├── apps/
@ -420,19 +429,23 @@ kell_creations/
### 3.4 Test Coverage Assessment ### 3.4 Test Coverage Assessment
> Test counts re-verified 2026-09-25 by a full local run — **1,898 / 1,898 passing**.
> Coverage percentages are from the original 2026-07-25 audit and have not been
> re-measured; treat them as indicative only.
| Package | Tests | Coverage | Assessment | | Package | Tests | Coverage | Assessment |
| ------------------- | --------------- | -------- | ----------------------------------------------------- | | ------------------- | --------------- | -------- | ----------------------------------------------------- |
| `core` | 24 | ~85.7% | Good — composition abstractions well covered | | `core` | 90 | ~85.7% | Good — composition + observability well covered |
| `design_system` | 41 | 100% | Excellent | | `design_system` | 51 | 100% | Excellent |
| `feature_wordpress` | 506 | ~84.7% | Good — broad coverage across all layers | | `feature_wordpress` | 545 | ~84.7% | Good — broad coverage across all layers |
| `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present | | `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present |
| `feature_orders` | 198 | Unknown | Good — domain, data, mapper, and widget tests present | | `feature_orders` | 657 | Unknown | Excellent — custom order domain exhaustively tested |
| `feature_policy` | ~5 | Very low | **Gap** — only smoke test; no controller/widget tests | | `feature_policy` | 17 | Very low | **Gap** — thin; no controller/widget tests |
| `auth` | 42 | Unknown | Good for contracts and fakes | | `auth` | 42 | Unknown | Good for contracts and fakes |
| `data` | 63 | Unknown | Good for contracts and in-memory impls | | `data` | 63 | Unknown | Good for contracts and in-memory impls |
| `integrations` | 123 | Unknown | Good — API client and mapper tests present | | `integrations` | 123 | Unknown | Good — API client and mapper tests present |
| `kell_web` | 24 | ~54.1% | **Gap** — low coverage; routing and shell untested | | `kell_web` | 62 | ~54.1% | **Gap** — low coverage; routing and shell untested |
| `kell_mobile` | 26 | Unknown | Adequate — shell and page smoke tests | | `kell_mobile` | 81 | Unknown | Adequate — shell, page, and checkout tests |
| `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only | | `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only | | `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only | | `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
@ -447,14 +460,16 @@ kell_creations/
### 3.5 Architectural Antipatterns ### 3.5 Architectural Antipatterns
| Issue | Description | Severity | > Status column added 2026-09-25. Three findings below were closed by Stages 10E and 16A.
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| **No state management framework** | All state uses `ChangeNotifier` + `AnimatedBuilder`. As complexity grows (sync engine, multi-channel, offline queue), this pattern will become difficult to compose. No Riverpod, BLoC, or equivalent. | MEDIUM | | Issue | Description | Severity | Status |
| **No dependency injection** | Services are constructed directly in factory methods. No DI container. Makes testing harder as the app grows. | MEDIUM | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------- |
| **`SquareCatalogSyncService` ID conflation** | `syncCatalog()` uses `product.id` as the Square catalog object ID, but `product.id` is the local/WooCommerce ID. This will produce incorrect mappings when Square is the source. | HIGH | | **No state management framework** | All state uses `ChangeNotifier` + `AnimatedBuilder`. As complexity grows (sync engine, multi-channel, offline queue), this pattern will become difficult to compose. No Riverpod, BLoC, or equivalent. | MEDIUM | Open |
| **`AppServices` / `MobileAppServices` duplication** | Both files are nearly identical. A shared base class or factory could eliminate the duplication. | LOW | | **No dependency injection** | Services are constructed directly in factory methods. No DI container. Makes testing harder as the app grows. | MEDIUM | Open |
| **No logging infrastructure** | No structured logging anywhere in the codebase. Errors are caught and surfaced via SnackBars or swallowed silently. | MEDIUM | | **`SquareCatalogSyncService` ID conflation** | `syncCatalog()` used `product.id` as the Square catalog object ID when it is really the local/WooCommerce ID. | HIGH | ✅ Resolved (10E) |
| **No crash reporting** | No Sentry, Firebase Crashlytics, or equivalent. Runtime errors are invisible in production. | MEDIUM | | **`AppServices` / `MobileAppServices` duplication** | Both files are nearly identical. A shared base class or factory could eliminate the duplication. | LOW | Open |
| **No logging infrastructure** | Structured logging landed in `core`: `KcLogger`, `KcLogLevel`, `KcLogRecord`, `KcLogSink`, `KcRedactor`. Repositories log failures with redacted context instead of swallowing them. | MEDIUM | ✅ Resolved (16A) |
| **No crash reporting** | `KcErrorReporter`, `KcObservability`, `KcBugReport`, and `KcBugReportService` capture crashes and user-submitted bug reports. No third-party SaaS dependency was introduced. | MEDIUM | ✅ Resolved (16A) |
--- ---
@ -502,10 +517,14 @@ These items complete planned stages and wire existing contracts into production
**Scope:** `PaymentService` abstraction, `PaymentResult` domain model, `MobileCheckoutPage` (product selection → cart → payment), Square Reader SDK integration for Android, cash recording, digital receipt, post-payment order creation and inventory deduction, `Sales` tab in mobile navigation. **Scope:** `PaymentService` abstraction, `PaymentResult` domain model, `MobileCheckoutPage` (product selection → cart → payment), Square Reader SDK integration for Android, cash recording, digital receipt, post-payment order creation and inventory deduction, `Sales` tab in mobile navigation.
**Dependencies:** Stage 10A ✅, Stage 10B ✅, Stage 10D ✅, P1.1, P1.4 **Dependencies:** Stage 10A ✅, Stage 10B ✅, Stage 10D ✅, P1.1, P1.4
#### P2.2 — Custom Orders Workflow (Stage 11) #### P2.2 — Custom Orders Workflow (Stage 11) — ⚠️ PARTIALLY RESOLVED
**Scope:** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object, extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` repository methods, `CustomOrderFormPage`, `CustomOrderConfirmationPage`, `CustomOrderTrackingPage`, fulfillment status pipeline. > Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
**Dependencies:** Stage 9A ✅
**Delivered:** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object (money as integer cents), `FulfillmentStatus` pipeline with domain-enforced legal transitions (`quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled` and a `qualityCheck → inProduction` rework path), extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` / `advanceFulfillmentStatus()` on `OrdersRepository`, implemented in both `FakeOrdersRepository` (seeded across every stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data). 423 new tests.
**Still open:** `CustomOrderFormPage`, `CustomOrderConfirmationPage` (Stage 11B); `CustomOrderTrackingPage`, status-change timeline, and inventory deduction on completion (Stage 11C).
**Dependencies:** Stage 9A ✅, Stage 11A ✅
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B) #### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
@ -582,10 +601,14 @@ These items harden the platform for production deployment.
**Scope:** Add routing tests, shell navigation tests, `AppServices` factory tests. Target >75% line coverage. **Scope:** Add routing tests, shell navigation tests, `AppServices` factory tests. Target >75% line coverage.
#### P3.8 — Add `feature_orders` standard scaffolding files #### ~~P3.8 — Add `feature_orders` standard scaffolding files~~ ✅ RESOLVED
> Delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29).
**Scope:** Add `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` to `feature_orders` to match all other packages. **Scope:** Add `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` to `feature_orders` to match all other packages.
`.gitignore` already existed; `.metadata`, `CHANGELOG.md`, `LICENSE`, and `README.md` were added. The README documents the fulfillment pipeline, the WooCommerce meta mapping, and the observability/privacy rules rather than carrying the default template placeholders.
#### P3.9 — Update architecture diagrams for Square and multi-channel #### P3.9 — Update architecture diagrams for Square and multi-channel
**Scope:** Add Square to `system-landscape.puml`, `enterprise-integration-orchestration-architecture.puml`, `context-kellcreations-platform.puml`. Create `dynamic-market-sale-checkout.puml`. Create `enterprise-multi-channel-architecture.puml`. **Scope:** Add Square to `system-landscape.puml`, `enterprise-integration-orchestration-architecture.puml`, `context-kellcreations-platform.puml`. Create `dynamic-market-sale-checkout.puml`. Create `enterprise-multi-channel-architecture.puml`.
@ -606,25 +629,30 @@ These items harden the platform for production deployment.
## 5. Summary Scorecard ## 5. Summary Scorecard
| Subsystem | Status | Completeness | Blocking Issues | > **Re-baselined 2026-09-25** against `main` after Stages 10C–10F, 11A, and 16A
| ------------------- | ----------- | ------------ | ----------------------------------------------------------------- | > merged. Test counts are the verified per-package totals from a full local run
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing | > (**1,898 / 1,898 passing**); `dart analyze --fatal-infos` is clean across all
| `design_system` | ✅ COMPLETE | 100% | None | > 11 packages and apps.
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature | | Subsystem | Status | Completeness | Tests | Blocking Issues |
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented | | ------------------- | ----------- | ------------ | ----- | --------------------------------------------------------------------- |
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending | | `core` | ✅ COMPLETE | 100% | 90 | None — square/multi bootstrap validation landed (Stage 10E) |
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing | | `design_system` | ✅ COMPLETE | 100% | 51 | None |
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices | | `auth` | ⚠️ PARTIAL | 40% | 42 | No production impl; `SecureCredentialStore` TBD; not wired into shell |
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty | | `data` | ⚠️ PARTIAL | 35% | 63 | No SQLite impl; not wired into any feature |
| `feature_finance` | ❌ MISSING | 0% | Pure stub | | `integrations` | ⚠️ PARTIAL | 70% | 123 | RetryPolicy/RateLimiter not wired; `ChannelAdapter` not implemented |
| `feature_mrp` | ❌ MISSING | 0% | Pure stub | | `feature_wordpress` | ✅ COMPLETE | 100% | 545 | None — `SquareSyncController` landed (Stage 10E) |
| `feature_social` | ❌ MISSING | 0% | Pure stub | | `feature_inventory` | ⚠️ PARTIAL | 65% | 167 | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory | | `feature_orders` | ⚠️ PARTIAL | 85% | 657 | Custom order **domain+data** landed (11A); custom order **UI** open |
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory | | `feature_policy` | ⚠️ PARTIAL | 50% | 17 | Fake-only; minimal tests; policy register empty |
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing | | `feature_finance` | ❌ MISSING | 0% | 1 | Pure stub |
| Policy register | ❌ MISSING | 10% | All CSVs empty | | `feature_mrp` | ❌ MISSING | 0% | 1 | Pure stub |
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced | | `feature_social` | ❌ MISSING | 0% | 1 | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 85% | 62 | Finance placeholder remains; Integrations page now real |
| `kell_mobile` | ⚠️ PARTIAL | 85% | 81 | Finance placeholder remains; POS/checkout landed (Stage 10C) |
| Architecture docs | ⚠️ PARTIAL | 80% | — | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | — | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | — | No coverage thresholds enforced |
### Overall Platform Readiness ### Overall Platform Readiness
@ -633,10 +661,11 @@ These items harden the platform for production deployment.
| **WooCommerce catalog management** | ✅ Production-ready | | **WooCommerce catalog management** | ✅ Production-ready |
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices | | **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices | | **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
| **Square catalog sync** | ⚠️ Service layer complete; no UI trigger; ID conflation bug | | **Square catalog sync** | ✅ Service + controller + UI panel; ID bug fixed (10E/10F) |
| **Square inventory sync** | ⚠️ Repository complete; not wired into AppServices | | **Square inventory sync** | ✅ Repository wired via `square()` / `multi()` factories |
| **Square payments / POS** | ❌ Not implemented | | **Square payments / POS** | ✅ Mobile checkout flow implemented (Stage 10C) |
| **Custom orders** | ❌ Not implemented | | **Custom orders** | ⚠️ Domain + data done (Stage 11A); UI open (Stage 11B) |
| **Observability / error logging** | ✅ `KcLogger`, `KcErrorReporter`, bug reports (Stage 16A) |
| **Supplies tracking** | ❌ Not implemented | | **Supplies tracking** | ❌ Not implemented |
| **Multi-channel sync engine** | ❌ Not implemented | | **Multi-channel sync engine** | ❌ Not implemented |
| **Offline capability** | ❌ Not implemented | | **Offline capability** | ❌ Not implemented |
@ -647,48 +676,3 @@ These items harden the platform for production deployment.
--- ---
_Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25._ _Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25._
---
## 5. Summary Scorecard
| Subsystem | Status | Completeness | Blocking Issues |
|---|---|---|---|
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
| `design_system` | ✅ COMPLETE | 100% | None |
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
| `feature_social` | ❌ MISSING | 0% | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
### Overall Platform Readiness
| Dimension | Assessment |
|---|---|
| **WooCommerce catalog management** | ✅ Production-ready |
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
| **Square catalog sync** | ⚠️ Service layer complete; no UI trigger; ID conflation bug |
| **Square inventory sync** | ⚠️ Repository complete; not wired into AppServices |
| **Square payments / POS** | ❌ Not implemented |
| **Custom orders** | ❌ Not implemented |
| **Supplies tracking** | ❌ Not implemented |
| **Multi-channel sync engine** | ❌ Not implemented |
| **Offline capability** | ❌ Not implemented |
| **Authentication** | ❌ Contracts only; not wired |
| **Financial reporting** | ❌ Not implemented |
| **Social media management** | ❌ Not implemented |
---
*Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25.*

View File

@ -8,14 +8,135 @@
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25) - main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
- merged: `feat/square-mobile-pos` (Stage 10C POS) — merged to main, commit `7bf8f55` - merged: `feat/square-mobile-pos` (Stage 10C POS) — merged to main, commit `7bf8f55`
- merged: `feat/observability-error-logging` (Stage 16A) — error logging, crash capture, and bug reporting; merged to main 2026-08-29 - merged: `feat/observability-error-logging` (Stage 16A) — error logging, crash capture, and bug reporting; merged to main 2026-08-29
- main baseline updated through: `feat/observability-error-logging` (Stage 16A complete, merged 2026-08-29) - merged: `feat/custom-order-domain` (Stage 11A) — custom order domain and data; merged to main 2026-08-29, commit `85501fd`
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data - main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29)
- total tests on branch: 1475/1475 passing (+85 new: 56 core + 10 design_system + 10 kell_web + 9 kell_mobile); dart analyze --fatal-infos clean across all packages - reconciled: `chore/state-reconciliation-flutter-347` — working tree vs. `main` reconciled 2026-09-25; toolchain moved to Flutter 3.47.0 / Dart 3.13.0; master documents re-baselined
- next branch: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI
- total tests on branch: 1898/1898 passing (+423 new, all in feature_orders); dart analyze --fatal-infos clean across all 11 packages and apps
- note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count - note: previously quoted totals omitted `feature_policy` (17 tests); it is now included in the count
- release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production - release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production
- toolchain: Flutter 3.47.0 / Dart 3.13.0. Flutter 3.47 auto-generates an `analyzer.exclude` block in every `analysis_options.yaml` on each `pub get` / `analyze` / `run` / `build` with no opt-out ([flutter#191160](https://github.com/flutter/flutter/issues/191160)). **Do not open a slice to revert it** — see "Toolchain baseline" in `master_development_brief.md`
## Slice tracker ## Slice tracker
### chore/state-reconciliation-flutter-347
- status: merged to main
- branch: `chore/state-reconciliation-flutter-347`
- stage: n/a — repository state reconciliation, no product code change
- date: 2026-09-25
- baseline: `92d83e0` (main, Stage 11A complete)
- inspection: complete
- implementation: complete
- trigger: the working tree had drifted from `main` and local `main` was 4 commits ahead of `origin/main`
- scope: **documentation and toolchain hygiene only** — no `lib/` or `test/` source file was modified
#### What the drift actually was
1. **Flutter 3.47.0 `analysis_options.yaml` churn (accepted).** The local toolchain moved from
Flutter 3.44.x to **Flutter 3.47.0 / Dart 3.13.0**. Flutter 3.47 ships `AnalysisOptionsMigration`,
which unconditionally writes a fixed seven-entry `analyzer.exclude` block
(`build/**`, `android/**`, `ios/**`, `web/**`, `windows/**`, `macos/**`, `linux/**`)
into **every** `analysis_options.yaml` it finds. Upstream bugs:
[flutter#191160](https://github.com/flutter/flutter/issues/191160) and
[flutter#191131](https://github.com/flutter/flutter/issues/191131).
- Commit `92d83e0` previously tried to revert this. **That revert does not hold.** The migration
runs from `ensureReadyForPlatformSpecificTooling`, so `flutter pub get`, `flutter analyze`,
`flutter run`, and `flutter build` all rewrite the block. There is **no opt-out**.
This was confirmed empirically: a full `flutter test` sweep regenerated the block in 8
further packages that were clean beforehand.
- **Decision: accept and commit the block** in all 11 packages/apps rather than fight the tool.
Reverting it only produces a permanently dirty working tree and misleading diffs.
- **Safety check:** the `web/**` entry is the dangerous one (it can hide real Dart sources in
`dart create -t web` packages). Verified that neither `kell_web/web/` nor `kell_mobile/web/`
contains any `.dart` file, so nothing is dropped from analysis here. `dart analyze
--fatal-infos` remains clean across all 11 packages and apps.
2. **`kell_mobile` web scaffold (reverted).** Someone had run `flutter create --platforms=web`
inside `kell_mobile`, which produced an untracked `web/` directory (`index.html`,
`manifest.json`, `favicon.png`, 4 icons) and rewrote `.metadata` so the `android` platform
entry was **replaced** by `web`. `kell_mobile` is the Android target and its `android/`
directory is tracked, so this was unintended tool output, not a deliberate platform addition.
- **Decision: reverted.** `.metadata` restored to the tracked Android-only revision
(`ad70ec46…`); the untracked `web/` scaffold deleted. `kell_mobile` remains Android-only.
3. **`kell_mobile/pubspec.lock` transitive bumps (accepted).** Resolved under the newer SDK:
`clock` 1.1.2→1.1.3, `matcher` 0.12.19→0.12.20, `meta` 1.18.0→1.18.3,
`stack_trace` 1.12.1→1.12.2, `test_api` 0.7.11→0.7.12, `vector_math` 2.2.0→2.4.2,
plus `vm_service`. All transitive, all pinned by the Flutter SDK. Committed so the lock file
matches the toolchain that is actually in use.
#### Master document corrections
- `PROJECT_STATE_AUDIT.md` — **removed a duplicated `## 5. Summary Scorecard` section.** The file
carried the entire scorecard and readiness table twice (lines 615–657 and 658–702), the second
copy being a bad append. Removed the duplicate, leaving one authoritative §5.
- `PROJECT_STATE_AUDIT.md` — added a reconciliation notice clarifying that §1–§4 are the preserved
historical 2026-07-25 record while §5 is re-baselined to `main`.
- `PROJECT_STATE_AUDIT.md` §3.4 — test counts corrected to the verified run
(`core` 24→90, `design_system` 41→51, `feature_wordpress` 506→545, `feature_orders` 198→657,
`feature_policy` ~5→17, `kell_web` 24→62, `kell_mobile` 26→81).
- `PROJECT_STATE_AUDIT.md` §3.5 — marked three antipatterns resolved:
`SquareCatalogSyncService` ID conflation (10E), no logging infrastructure (16A),
no crash reporting (16A).
- `PROJECT_STATE_AUDIT.md` §5 — scorecard re-baselined with a test-count column; Square POS,
Square catalog/inventory sync, and observability moved to ✅; custom orders moved to ⚠️
(domain+data done, UI open).
- `master_development_brief.md` Appendix A — feature maturity matrix refreshed to the same
verified counts and maturity levels.
- `master_development_brief.md` — toolchain baseline recorded (Flutter 3.47.0 / Dart 3.13.0) with
the `analysis_options.yaml` caveat so the next slice does not try to revert it again.
- tests: passed (**1,898 / 1,898** — core 90, design_system 51, auth 42, integrations 123, data 63,
feature_wordpress 545, feature_inventory 167, feature_orders 657, feature_policy 17,
kell_web 62, kell_mobile 81). Identical to the Stage 11A total, confirming this slice changed
no behaviour.
- analyze: passed (`dart analyze --fatal-infos` — no issues found across all 11 packages and apps)
- brief updated: yes
### feat/custom-order-domain
- status: merged to main
- branch: `feat/custom-order-domain`
- stage: 11A — Custom order domain and data
- date: 2026-08-29
- baseline: `666abd2` (main, Stage 16A complete, 1,475/1,475 passing)
- commits: `86c630d` (implementation), `85501fd` (merge to main)
- inspection: complete
- implementation: complete
- scope: domain and data layers only — no UI in this slice
- closes audit findings: P2.2 (domain + data portion; UI remains open as Stage 11B/11C) and P3.8 (`feature_orders` scaffolding files)
- files changed:
- `feature_orders/lib/src/domain/order_type.dart` — new `OrderType` enum (standard/custom) with `label`, `wireValue`, `fromWire()` defaulting to `standard`
- `feature_orders/lib/src/domain/custom_order_spec.dart` — new immutable `CustomOrderSpec` (material, color, size, dimensions, unmodifiable `referenceImageUrls`, notes, `estimatedCompletionDate`); `copyWith`, list-aware `==`/`hashCode`, `toString`, `isProductionReady`, `hasReferenceImages`, `summary`
- `feature_orders/lib/src/domain/deposit_info.dart` — new immutable `DepositInfo`; money as integer cents (`depositAmountCents`, `totalAmountCents`), `depositDate`, `settlementDate`, `depositPaid`, derived `balanceDueCents` (clamped at zero), `markDepositPaid()`, `markSettled()`, `formatCents()`, `.quoted()`; deliberately carries **no** payment identifier
- `feature_orders/lib/src/domain/fulfillment_status.dart` — new `FulfillmentStatus` (quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed, plus cancelled) with `label`, `stageIndex`, `pipeline`, `wireValue`, `fromWire()`; `FulfillmentTransition` extension with `allowedNextStatuses`, `canTransitionTo()`, `rejectionReason()`, `isTerminal`, `isSuccessful`, `requiresDeposit`
- `feature_orders/lib/src/domain/order.dart` — added `orderType` (defaults to `standard`), nullable `customOrderSpec`, `depositInfo`, `fulfillmentStatus`; added `isCustom`, `hasDeposit`, `balanceDueCents`; all existing fields, defaults, and `copyWith` semantics preserved
- `feature_orders/lib/src/domain/orders_repository.dart` — added `createCustomOrder()`, `updateCustomOrderSpec()`, `recordDeposit()`, `advanceFulfillmentStatus()`
- `feature_orders/lib/src/data/fake_orders_repository.dart` — implemented all four methods; injected `KcLogger` (defaults to `KcLogger.silent()`); five seeded custom orders `KC-C2001`…`KC-C2005` covering every non-terminal pipeline stage; existing rejection paths now logged via `_rejected()`
- `feature_orders/lib/src/data/woo_commerce_orders_repository.dart` — implemented all four methods; injected `KcLogger`; `_guard()` converts every exception into a logged failure result; `_loadCustomOrder()` validates existence and order type before any write; `_pushCustomOrderMeta()` sends only `_kc_` meta so commercial fields are never disturbed
- `feature_orders/lib/src/data/woo_commerce_order_mapper.dart` — 13 `_kc_`-prefixed meta keys with a documented mapping table; `customOrderMetaData()`, `specMetaEntries()`, `depositMetaEntries()`, `toMetaDataList()`; `fromJson()` reconstructs spec/deposit/type/stage; internal `_`-prefixed keys stay out of `Order.customFields`
- `feature_orders/lib/feature_orders.dart` — barrel updated with 4 new domain exports
- `feature_orders/pubspec.yaml` — added `core` path dependency (for `KcLogger`); description expanded
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — new `updateOrder(orderId, body)` generic order PUT, mirroring `updateProduct()`
- `feature_orders/.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` — P3.8 scaffolding (`.gitignore` already existed)
- `feature_orders/test/fake_orders_repository_test.dart` — seeded-count assertions updated 6 → 11 and 7 → 12; added a standard-order count test
- `feature_orders/test/orders_controller_test.dart` — count assertions updated for the larger seed set (11 total, 3 pending)
- `feature_orders/test/widgets/orders_page_test.dart` — order-count label updated to `11 orders`; `_EmptyOrdersRepository` satisfies the widened contract
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — `_StubOrdersRepository` satisfies the widened contract
- tests added:
- `feature_orders/test/order_type_test.dart` — 12 tests (enum shape, labels, wire round-trip, fallbacks)
- `feature_orders/test/custom_order_spec_test.dart` — 46 tests (construction, unmodifiable list, derived state, `summary` PII safety, `copyWith`, equality, `toString` leak checks)
- `feature_orders/test/deposit_info_test.dart` — 58 tests (cents arithmetic, balance derivation and clamping, formatting parity with `PaymentResult`, transitions, `copyWith`, equality, no-payment-identifier check)
- `feature_orders/test/fulfillment_status_test.dart` — 150 tests including the **exhaustive 7 × 7 transition matrix**: every legal pair asserted allowed, every other pair asserted rejected with an explanatory reason
- `feature_orders/test/fake_orders_repository_custom_test.dart` — 77 tests (seeded data shape, all four new methods, legal and illegal advances, log-context assertions, PII and money redaction checks)
- `feature_orders/test/woo_commerce_custom_order_test.dart` — 79 tests (meta read/write, round trip through every pipeline stage, malformed-meta tolerance, all four methods over `MockClient`, observability on pre-existing operations, no-payment-identifier-on-the-wire checks)
- observability: `KcLogger` injected into both repositories; rejections logged at `warning`, API failures at `error` with status code and originating exception; **no silent `catch (_) {}`**; log context limited to identifiers, operation names, and status names — never customer identity, addresses, money amounts, or payment identifiers; all context additionally passes through `KcRedactor`
- WooCommerce mapping: custom order state persisted as 13 `_kc_`-prefixed order meta keys (`_kc_order_type`, `_kc_fulfillment_status`, `_kc_spec_*`, `_kc_deposit_*`, `_kc_settlement_date`). `balanceDueCents` is derived on read, never persisted. The deposit transaction ID is deliberately excluded — order meta is readable by any store administrator
- tests: passed (1898/1898 total; feature_orders 657/657 +423; all other packages unchanged — core 90, design_system 51, auth 42, integrations 123, data 63, feature_wordpress 545, feature_inventory 167, kell_web 62, kell_mobile 81, feature_policy 17)
- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages and apps)
- format: `dart format --line-length 100` applied to all files touched by this slice (matches the repo's `.vscode/settings.json` `dart.lineLength: 100`); `--set-exit-if-changed` clean across `feature_orders`, and the two files touched in `feature_wordpress` / `kell_web`
- known pre-existing issues (not introduced here, left untouched): `kell_web/lib/composition/app_services.dart` and `kell_web/lib/shell/app_nav_item.dart` are not format-clean at 100 cols; `tools/run_all_tests.sh` reports `TOTAL 0` under Git Bash on Windows because its `grep` for the `✓` glyph does not match the console encoding — per-package "All tests passed!" lines and the analyze gate remain accurate
- brief updated: yes
### feat/observability-error-logging ### feat/observability-error-logging
- status: merged to main - status: merged to main

View File

@ -143,24 +143,24 @@ Rules:
### Latest known validation state on `main` ### Latest known validation state on `main`
> **Last verified:** 2026-08-29 — Release Readiness Review (`feat/observability-error-logging`) > **Last verified:** 2026-08-29 — Stage 11A validation run (`feat/custom-order-domain`)
- `dart analyze --fatal-infos` clean across all 11 packages/apps - `dart analyze --fatal-infos` clean across all 11 packages/apps
- All packages passing - All packages passing
- latest reported count for `core`: `90/90 passed` ← +56 new (Stage 16A observability) - latest reported count for `core`: `90/90 passed`
- latest reported count for `design_system`: `51/51 passed` ← +10 new (Stage 16A bug report dialog) - latest reported count for `design_system`: `51/51 passed`
- latest reported count for `feature_inventory`: `167/167 passed` - latest reported count for `feature_inventory`: `167/167 passed`
- latest reported count for `feature_orders`: `234/234 passed` - latest reported count for `feature_orders`: `657/657 passed` ← +423 new (Stage 11A custom orders)
- latest reported count for `feature_wordpress`: `545/545 passed` - latest reported count for `feature_wordpress`: `545/545 passed`
- latest reported count for `kell_web`: `62/62 passed` ← +10 new (Stage 16A shell wiring) - latest reported count for `kell_web`: `62/62 passed`
- latest reported count for `integrations`: `123/123 passed` - latest reported count for `integrations`: `123/123 passed`
- latest reported count for `data`: `63/63 passed` - latest reported count for `data`: `63/63 passed`
- latest reported count for `auth`: `42/42 passed` - latest reported count for `auth`: `42/42 passed`
- latest reported count for `kell_mobile`: `81/81 passed` ← +9 new (Stage 16A shell wiring) - latest reported count for `kell_mobile`: `81/81 passed`
- latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file) - latest reported count for `feature_policy`: `17/17 passed` (single smoke-test file)
- total: `1,475/1,475 passed` (+85 new from Stage 16A branch `feat/observability-error-logging`) - total: `1,898/1,898 passed` (+423 new from Stage 11A branch `feat/custom-order-domain`)
- main baseline updated through: `feat/observability-error-logging` (Stage 16A complete, merged 2026-08-29) - main baseline updated through: `feat/custom-order-domain` (Stage 11A complete, merged 2026-08-29, commit `85501fd`)
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data - next branch: `feat/custom-order-ui` (Stage 11B) — Custom order creation UI
> **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin — all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker. > **Note:** The historically quoted total of `1,458` excluded `feature_policy`, which was not listed in the tracker's counted set. Including its 17 tests, the true grand total is `1,475`. `feature_policy` coverage remains thin — all 17 tests live in one smoke-test file with no controller or widget tests. Expanding it is tracked as P3.6 and remains a Test/QA release blocker.
@ -272,14 +272,93 @@ Validation:
- 85 new tests (56 `core`, 10 `design_system`, 10 `kell_web`, 9 `kell_mobile`); 1,475 total - 85 new tests (56 `core`, 10 `design_system`, 10 `kell_web`, 9 `kell_mobile`); 1,475 total
- `dart analyze --fatal-infos` clean across all packages and apps - `dart analyze --fatal-infos` clean across all packages and apps
### Stage 11A — complete (2026-08-29)
**`feat/custom-order-domain`** — Custom order domain and data
Closes audit finding P2.2 (domain and data layers only — the custom order UI
remains open as Stage 11B/11C) and P3.8 (`feature_orders` scaffolding files).
Delivered in `feature_orders` — domain:
- `OrderType` — `standard` / `custom` with `label`, `wireValue`, and a
`fromWire()` parser that defaults to `standard` so orders written by an older
client are never mis-classified as commissions
- `CustomOrderSpec` — immutable value object (material, color, size,
dimensions, reference image URLs, notes, estimated completion) with
`copyWith`, `==`/`hashCode` (list-aware), `toString`, plus `isProductionReady`,
`hasReferenceImages`, and `summary`
- `DepositInfo` — immutable value object; **all money as integer cents**,
consistent with `PaymentResult.amountCents`. Deposit and total amounts,
deposit/settlement dates, paid flag, derived `balanceDueCents` (clamped at
zero), `markDepositPaid()`, `markSettled()`, `formatCents()`
- `FulfillmentStatus` — the workshop pipeline `quoted → depositReceived →
inProduction → qualityCheck → readyForPickup → completed`, plus `cancelled`
from any non-terminal stage and a `qualityCheck → inProduction` rework path
- `FulfillmentTransition` extension — `allowedNextStatuses`,
`canTransitionTo()`, and `rejectionReason()`. **Illegal jumps are rejected by
the domain**, not silently applied; same-stage advances and any move out of a
terminal stage are rejected
- `Order` extended with `orderType`, nullable `customOrderSpec`, nullable
`depositInfo`, and nullable `fulfillmentStatus`, plus `isCustom`,
`hasDeposit`, and `balanceDueCents`. Every existing field, default, and
`copyWith` semantic is preserved — `orderType` defaults to `standard`
- `OrdersRepository` extended with `createCustomOrder()`,
`updateCustomOrderSpec()`, `recordDeposit()`, and
`advanceFulfillmentStatus()`
Delivered in `feature_orders` — data:
- `FakeOrdersRepository` — all four methods implemented, plus five seeded
custom orders (`KC-C2001`…`KC-C2005`) covering every non-terminal pipeline
stage; the six standard orders are unchanged
- `WooCommerceOrdersRepository` — all four methods implemented. Custom order
state round-trips through `_kc_`-prefixed order `meta_data`; the complete key
table is documented on `WooCommerceOrderMapper`
- `WooCommerceOrderMapper` — reads and writes the custom order meta set,
keeping internal `_`-prefixed keys out of `Order.customFields`
- `WooCommerceApiClient.updateOrder()` — new generic order PUT in
`feature_wordpress`, mirroring `updateProduct()`, so meta can be written back
without disturbing an order's commercial fields
Observability (Stage 16A layer):
- Both repositories accept an injected `KcLogger`, defaulting to
`KcLogger.silent()`. Domain rejections log at `warning`; API failures log at
`error` with the status code and the originating exception
- **No silent `catch (_) {}` blocks** — every swallowed error produces both a
log record and a failure result
- Log context is restricted to identifiers, operation names, and status names.
Customer identity, addresses, money amounts, and payment identifiers are
never logged; `KcLogger` additionally routes all context through `KcRedactor`
- The deposit payment/transaction identifier is deliberately **absent from
`DepositInfo` and from the WooCommerce meta mapping** — it lives on
`PaymentResult` alone, because order meta is readable by any store admin
Also closed:
- P3.8 — `.metadata`, `CHANGELOG.md`, `LICENSE`, and `README.md` added to
`feature_orders` so it matches every other package (`.gitignore` already
existed). The README documents the pipeline, the meta mapping, and the
privacy rules
Validation:
- 423 new tests in `feature_orders` (234 → 657): every value object, the full
7 × 7 legal/illegal transition matrix, and every new repository method on
both implementations
- 1,898 total across all packages; no regression from the 1,475 baseline
- `dart analyze --fatal-infos` clean across all 11 packages and apps
### Next recommended branch ### Next recommended branch
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data **`feat/custom-order-ui`** — Stage 11B: Custom order creation UI
- Add `OrderType` enum, `CustomOrderSpec`, `DepositInfo` value objects - `CustomOrderFormPage` — customer capture, spec fields, reference image
- Extend `Order` model and `OrdersRepository` contract capture, pricing and deposit entry, estimated completion picker
- Implement in `FakeOrdersRepository` and `WooCommerceOrdersRepository` - `CustomOrderConfirmationPage` — summary with a deposit receipt
- Tests for all new domain models and repository methods - Wire into mobile shell navigation and web routing
- Tests for form validation and the creation flow
--- ---
@ -335,7 +414,7 @@ These items complete planned stages and wire existing contracts into production
| ID | Item | Target Stage | | ID | Item | Target Stage |
| ----- | ---------------------------------------------------------------------------------- | --------------------------- | | ----- | ---------------------------------------------------------------------------------- | --------------------------- |
| P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C | | P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C |
| P2.2 | Custom Orders Workflow (`OrderType`, `CustomOrderSpec`, `DepositInfo`, UI) | Stage 11 | | P2.2 | Custom Orders Workflow — domain + data DONE (Stage 11A); UI open | Stage 11B/11C |
| P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B | | P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B |
| P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C | | P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C |
| P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone | | P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
@ -358,7 +437,7 @@ These items harden the platform for production deployment.
| P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete | | P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete |
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone | | P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
| P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone | | P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone |
| P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone | | P3.8 | ~~Add `feature_orders` standard scaffolding files~~ - DONE (Stage 11A) | Complete |
| P3.9 | Update architecture diagrams for Square and multi-channel | Stage 13 planning | | P3.9 | Update architecture diagrams for Square and multi-channel | Stage 13 planning |
| P3.10 | Implement `ChannelAdapter` for WooCommerce and Square | Stage 13A | | P3.10 | Implement `ChannelAdapter` for WooCommerce and Square | Stage 13A |
| P3.11 | Enforce minimum test coverage thresholds in CI (e.g., 75% floor) | Standalone | | P3.11 | Enforce minimum test coverage thresholds in CI (e.g., 75% floor) | Standalone |
@ -486,6 +565,53 @@ These must be preserved in all future work:
--- ---
## Toolchain baseline
> **Last verified:** 2026-09-25 — `chore/state-reconciliation-flutter-347`
| Tool | Version |
| -------------------------------------- | -------------------------------------- |
| Flutter | 3.47.0 (stable, revision `4cf2416426`) |
| Dart | 3.13.0 |
| SDK constraint in every `pubspec.yaml` | `^3.11.0` |
### Known toolchain caveat — `analysis_options.yaml` is tool-managed
Flutter 3.47 ships `AnalysisOptionsMigration`, which **unconditionally rewrites every
`analysis_options.yaml`** in the workspace to contain:
```yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
```
The list is a hardcoded constant — it is not derived from the platforms a project
actually has. The migration runs from `ensureReadyForPlatformSpecificTooling`, so
`flutter pub get`, `flutter analyze`, `flutter run`, and `flutter build` all restore it.
**There is no opt-out.** Upstream:
[flutter#191160](https://github.com/flutter/flutter/issues/191160),
[flutter#191131](https://github.com/flutter/flutter/issues/191131).
**Rule for all future slices:** treat this block as generated content and leave it in place.
Do **not** open a slice to strip it — an earlier attempt (`92d83e0`) was undone by the very
next `flutter` command and only produced a permanently dirty working tree. If a diff shows
only this block appearing, commit it or ignore it; it is never a real change.
**Caveat to re-check if the repo layout changes:** the `web/**` entry will silently drop real
Dart sources from analysis in any package that uses `web/` as a source directory (as
`dart create -t web` does). This is safe today because neither `kell_web/web/` nor
`kell_mobile/web/` contains `.dart` files — they are pure browser-asset scaffolds. Re-verify
this if a Dart web package is ever added.
---
## Development stages ## Development stages
### Stage 1 — Web application completion: controlled product editing ### Stage 1 — Web application completion: controlled product editing
@ -918,7 +1044,10 @@ Custom orders extend the existing `Order` domain model with additional fields ra
- `feat/custom-order-ui` - `feat/custom-order-ui`
- `feat/custom-order-tracking` - `feat/custom-order-tracking`
#### Stage 11A — Custom order domain and data #### ~~Stage 11A — Custom order domain and data~~ ✅ COMPLETE
> Delivered on `feat/custom-order-domain` (2026-08-29).
> See "Stage 11A — complete (2026-08-29)" above for the full manifest.
##### Goal ##### Goal
@ -926,13 +1055,21 @@ Extend the order domain model and repository to support custom order creation wi
##### Requirements ##### Requirements
- Add `OrderType` enum: `standard`, `custom` - ✅ Add `OrderType` enum: `standard`, `custom`
- Add `CustomOrderSpec` value object: material, color, dimensions, special instructions, reference images, estimated completion date - ✅ Add `CustomOrderSpec` value object: material, color, size, dimensions, reference image URLs, free-text notes, estimated completion date
- Add `DepositInfo` value object: depositAmount, depositPaid, depositDate, balanceDue - ✅ Add `DepositInfo` value object: deposit amount, deposit date, balance due, settlement date, paid flag — all money as integer cents, consistent with `PaymentResult`
- Extend `Order` model: `orderType`, `customSpec` (nullable), `depositInfo` (nullable), `estimatedCompletionDate`, `internalNotes` - ✅ Extend `Order` model: `orderType`, `customOrderSpec` (nullable), `depositInfo` (nullable), `fulfillmentStatus` (nullable). All existing fields and equality semantics preserved
- Extend `OrdersRepository`: `createCustomOrder()`, `updateCustomOrderSpec()`, `recordDeposit()`, `updateEstimatedCompletion()` - ✅ Extend the fulfillment pipeline: `quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed`, with legal transitions enforced in the domain (`FulfillmentTransition`) — illegal jumps are rejected, not silently allowed
- Implement in both `FakeOrdersRepository` and `WooCommerceOrdersRepository` (WooCommerce custom orders use order meta fields) - ✅ Extend `OrdersRepository`: `createCustomOrder()`, `updateCustomOrderSpec()`, `recordDeposit()`, `advanceFulfillmentStatus()`
- Tests for domain models and repository methods - ✅ Implement in both `FakeOrdersRepository` (seeded custom orders across every pipeline stage) and `WooCommerceOrdersRepository` (`_kc_`-prefixed order meta-data; mapping documented on `WooCommerceOrderMapper`)
- ✅ `KcLogger` injected into both repositories; no silent `catch (_) {}`; no customer PII or deposit payment identifiers in log context
- ✅ Tests for domain models and repository methods (423 new)
> **Deferred to Stage 11C:** `estimatedCompletionDate` currently lives on
> `CustomOrderSpec` rather than on `Order`, and there is no dedicated
> `updateEstimatedCompletion()` — a date revision goes through
> `updateCustomOrderSpec()`. `internalNotes` was not added; `Order.notes`
> already serves that purpose.
#### Stage 11B — Custom order creation UI #### Stage 11B — Custom order creation UI
@ -1516,32 +1653,35 @@ Working rules:
## Appendix A: Feature maturity matrix ## Appendix A: Feature maturity matrix
> **Last updated:** 2026-07-25 — Comprehensive State Audit > **Last updated:** 2026-09-25 — `chore/state-reconciliation-flutter-347`
> (test counts verified by a full local run: **1,898 / 1,898 passing**)
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity | | Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
| ------------------- | ------------ | ------------------------ | ----------- | ------------------------- | -------------------------------------------------- | ----- | -------------------------- | | ------------------- | ------------ | ---------------------------- | ----------- | ------------------------- | -------------------------------------------------- | ----- | --------------------------- |
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 506 | **Production-ready (95%)** | | `feature_wordpress` | ✅ Complete | ✅ Complete + SquareSync | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete + sync panel | 545 | **Production-ready (100%)** |
| `feature_inventory` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 167 | **Partial (65%)** | | `feature_inventory` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 167 | **Partial (65%)** |
| `feature_orders` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce | ✅ Complete | 198 | **Partial (70%)** | | `feature_orders` | ✅ Complete | ✅ Write ops + custom orders | ✅ Complete | ✅ WooCommerce | ⚠️ Standard orders only (custom order UI open) | 657 | **Partial (85%)** |
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | ~5 | **Fake-only (50%)** | | `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | 17 | **Fake-only (50%)** |
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** | | `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** | | `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** | | `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None (SecureStore TBD) | N/A | 42 | **Foundation ready (40%)** | | `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None (SecureStore TBD) | N/A | 42 | **Foundation ready (40%)** |
| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None (SQLite TBD) | N/A | 63 | **Foundation ready (35%)** | | `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None (SQLite TBD) | N/A | 63 | **Foundation ready (35%)** |
| `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce + Square | N/A | 123 | **Partial (70%)** | | `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce + Square | N/A | 123 | **Partial (70%)** |
| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Complete (100%)** | | `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 51 | **Complete (100%)** |
| `core` | ✅ Complete | ✅ Composition | N/A | N/A | N/A | 24 | **Complete (95%)** | | `core` | ✅ Complete | ✅ Composition + observ. | N/A | N/A | N/A | 90 | **Complete (100%)** |
| `kell_web` | N/A | ✅ AppServices (wp only) | N/A | N/A | ✅ 7 routes (2 placeholder) | 24 | **Partial (75%)** | | `kell_web` | N/A | ✅ AppServices ×4 modes | N/A | N/A | ✅ 7 routes (1 placeholder) | 62 | **Partial (85%)** |
| `kell_mobile` | N/A | ✅ MobileAppServices | N/A | N/A | ✅ 5-tab shell (no POS) | 26 | **Partial (70%)** | | `kell_mobile` | N/A | ✅ MobileAppServices ×4 | N/A | N/A | ✅ 5-tab shell + POS checkout | 81 | **Partial (85%)** |
### Key changes from previous matrix (updated 2026-07-25) ### Key changes from previous matrix (updated 2026-09-25)
- `feature_wordpress` test count updated to 506 (Stage 10D, pending merge); real data layer now includes Square repositories. - All test counts re-verified by a full local run — **1,898 / 1,898 passing**; previous figures predated Stages 10C–10F, 11A, and 16A.
- `feature_inventory` and `feature_orders` upgraded from "Read-only" — write operations, real WooCommerce/Square repositories now complete. - `feature_orders` 198 → 657 and upgraded to 85%: custom order domain and data landed (Stage 11A). Custom order **UI** is the remaining gap (Stage 11B).
- `integrations` test count updated to 123; Square API client and mappers complete. - `feature_wordpress` 506 → 545 and promoted to 100%: `SquareSyncController` and the sync panel landed (Stages 10E/10F).
- `core` test count updated to 24; `kell_web` and `kell_mobile` rows added. - `core` 24 → 90 and promoted to 100%: observability (`KcLogger`, `KcErrorReporter`, `KcRedactor`) plus square/multi bootstrap validation landed.
- Completeness percentages sourced from `PROJECT_STATE_AUDIT.md` §5 scorecard (2026-07-25). - `kell_web` 24 → 62 and `kell_mobile` 26 → 81, both to 85%: all four `KC_ENV` modes are wired, the Integrations page is real, and mobile POS checkout landed (Stage 10C). Finance remains a placeholder on both.
- `design_system` 41 → 51, `feature_policy` ~5 → 17.
- Completeness percentages sourced from the re-baselined `PROJECT_STATE_AUDIT.md` §5 scorecard (2026-09-25).
--- ---

View File

@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:

View File

@ -29,10 +29,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@ -176,10 +176,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.20"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@ -192,10 +192,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.3"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -221,10 +221,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" version: "1.12.2"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@ -253,10 +253,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.12"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -269,18 +269,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.4.2"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" version: "15.3.0"
web: web:
dependency: transitive dependency: transitive
description: description:

View File

@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:

View File

@ -124,6 +124,29 @@ class _StubOrdersRepository implements OrdersRepository {
@override @override
Future<OrderActionResult> createOrder(Order order) => throw UnimplementedError(); Future<OrderActionResult> createOrder(Order order) => throw UnimplementedError();
@override
Future<OrderActionResult> createCustomOrder(
Order order, {
required CustomOrderSpec spec,
DepositInfo? deposit,
}) => throw UnimplementedError();
@override
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) =>
throw UnimplementedError();
@override
Future<OrderActionResult> recordDeposit(
String id, {
required int amountCents,
required DateTime paidAt,
int? totalAmountCents,
}) => throw UnimplementedError();
@override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) =>
throw UnimplementedError();
} }
class _FailingInventoryRepository implements InventoryRepository { class _FailingInventoryRepository implements InventoryRepository {

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable"
project_type: package

View File

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

View File

@ -0,0 +1 @@
TODO: Add your license here.

View File

@ -0,0 +1,99 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/to/develop-packages).
-->
Order management for the Kell Creations operations platform. Covers both
standard catalogue orders and made-to-order custom commissions behind a single
`Order` aggregate and one `OrdersRepository` contract.
## Features
- **Domain model** — `Order`, `OrderItem`, `OrderStatus`, `OrderSource`,
`OrderType`, `PaymentMethod`, `PaymentResult`.
- **Custom orders** — `CustomOrderSpec` (material, colour, size, dimensions,
reference images, notes, estimated completion) and `DepositInfo` (deposit and
total amounts in integer cents, deposit/settlement dates, derived balance).
- **Fulfillment pipeline** — `FulfillmentStatus` with enforced transitions:
`quoted → depositReceived → inProduction → qualityCheck → readyForPickup →
completed`, plus `cancelled` from any non-terminal stage and a
`qualityCheck → inProduction` rework path. Illegal jumps are rejected by the
domain, not silently applied.
- **Repository contract** — `OrdersRepository` with read, status, note, and
create operations plus `createCustomOrder`, `updateCustomOrderSpec`,
`recordDeposit`, and `advanceFulfillmentStatus`.
- **Implementations** — `FakeOrdersRepository` (seeded standard and custom
orders across every pipeline stage) and `WooCommerceOrdersRepository`
(REST API v3, custom order state round-tripped through `_kc_`-prefixed order
meta-data).
- **Application layer** — `OrdersController`, `CheckoutController`, and the
`GetOrders` / `UpdateOrderStatus` / `AddOrderNote` / `CreateOrder` use cases.
- **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`,
`OrderStatusChip`.
## Getting started
Add the package as a path dependency and import the barrel:
```yaml
dependencies:
feature_orders:
path: ../feature_orders
```
```dart
import 'package:feature_orders/feature_orders.dart';
```
## Usage
```dart
final repository = FakeOrdersRepository(logger: observability.logger);
final result = await repository.createCustomOrder(
order,
spec: CustomOrderSpec(
material: 'Organic cotton',
color: 'Sage green',
size: 'Large',
),
deposit: const DepositInfo.quoted(
totalAmountCents: 7200,
depositAmountCents: 2400,
),
);
// Advance through the pipeline — illegal jumps are rejected.
await repository.recordDeposit(
order.id,
amountCents: 2400,
paidAt: DateTime.now(),
);
await repository.advanceFulfillmentStatus(order.id, FulfillmentStatus.inProduction);
```
## Observability and privacy
Repository implementations accept an optional `KcLogger` from `core`. Failed
and rejected write operations are logged with the operation name, order ID, and
status names only. Customer identity, addresses, money amounts, and deposit
payment identifiers are never logged, and every context map is additionally
passed through `KcRedactor`. There are no silent `catch (_) {}` blocks.
The WooCommerce meta-data mapping deliberately excludes the deposit
transaction ID — it lives on `PaymentResult` alone. See the class-level
documentation on `WooCommerceOrderMapper` for the complete key table.
## Additional information
Part of the `kell_creations_apps` monorepo. Run the package tests with
`flutter test` from this directory, or the whole suite with
`./tools/run_all_tests.sh --analyze` from `kell_creations_apps/`.

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -11,11 +11,15 @@ export 'src/data/fake_orders_repository.dart';
export 'src/data/fake_payment_service.dart'; export 'src/data/fake_payment_service.dart';
export 'src/data/woo_commerce_order_mapper.dart'; export 'src/data/woo_commerce_order_mapper.dart';
export 'src/data/woo_commerce_orders_repository.dart'; export 'src/data/woo_commerce_orders_repository.dart';
export 'src/domain/custom_order_spec.dart';
export 'src/domain/deposit_info.dart';
export 'src/domain/fulfillment_status.dart';
export 'src/domain/order.dart'; export 'src/domain/order.dart';
export 'src/domain/order_action_result.dart'; export 'src/domain/order_action_result.dart';
export 'src/domain/order_item.dart'; export 'src/domain/order_item.dart';
export 'src/domain/order_source.dart'; export 'src/domain/order_source.dart';
export 'src/domain/order_status.dart'; export 'src/domain/order_status.dart';
export 'src/domain/order_type.dart';
export 'src/domain/orders_repository.dart'; export 'src/domain/orders_repository.dart';
export 'src/domain/payment_method.dart'; export 'src/domain/payment_method.dart';
export 'src/domain/payment_result.dart'; export 'src/domain/payment_result.dart';

View File

@ -1,12 +1,38 @@
import 'package:core/core.dart';
import '../domain/custom_order_spec.dart';
import '../domain/deposit_info.dart';
import '../domain/fulfillment_status.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_action_result.dart'; import '../domain/order_action_result.dart';
import '../domain/order_item.dart'; import '../domain/order_item.dart';
import '../domain/order_source.dart';
import '../domain/order_status.dart'; import '../domain/order_status.dart';
import '../domain/order_type.dart';
import '../domain/orders_repository.dart'; import '../domain/orders_repository.dart';
/// Stubbed implementation of [OrdersRepository] with sample /// Stubbed implementation of [OrdersRepository] with sample
/// Kell Creations orders. No real WooCommerce or shipping API calls are made. /// Kell Creations orders. No real WooCommerce or shipping API calls are made.
///
/// Seeds six standard orders (`KC-1001`…`KC-1006`) and five custom orders
/// (`KC-C2001`…`KC-C2005`) spread across the fulfillment pipeline so the
/// custom order surfaces have realistic data at every stage.
///
/// Rejected write operations are logged through [logger] at
/// [KcLogLevel.warning] with an order ID and, where relevant, the attempted
/// transition. Customer identity and money amounts are never logged.
class FakeOrdersRepository implements OrdersRepository { class FakeOrdersRepository implements OrdersRepository {
/// Structured logger for rejected write operations.
///
/// Defaults to [KcLogger.silent] so tests and fake-mode callers that do not
/// care about observability need not supply one.
final KcLogger logger;
FakeOrdersRepository({KcLogger? logger})
: logger = (logger ?? KcLogger.silent()).forSource(_logSource);
static const String _logSource = 'FakeOrdersRepository';
final List<Order> _orders = [ final List<Order> _orders = [
Order( Order(
id: 'KC-1001', id: 'KC-1001',
@ -137,6 +163,176 @@ class FakeOrdersRepository implements OrdersRepository {
), ),
], ],
), ),
// ── Custom orders ──────────────────────────────────────────────────────
// One per pipeline stage so every custom-order surface has data.
Order(
id: 'KC-C2001',
customerName: 'Nadia Okafor',
customerEmail: 'nadia.okafor@example.com',
customerPhone: '828-555-0701',
orderDate: DateTime(2026, 4, 6),
status: OrderStatus.pending,
shippingAddress: '15 Riverside Way, Asheville, NC 28801',
source: OrderSource.manual,
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.quoted,
items: const [
OrderItem(
productName: 'Custom Bowl Cozy Set',
sku: 'CUSTOM-BC-2001',
quantity: 4,
unitPrice: 18.00,
customizations: {'material': 'Organic cotton', 'color': 'Sage green'},
),
],
customOrderSpec: CustomOrderSpec(
material: 'Organic cotton',
color: 'Sage green',
size: 'Large',
notes: 'Matching set of four for a housewarming gift.',
),
depositInfo: const DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400),
),
Order(
id: 'KC-C2002',
customerName: 'Theo Brandt',
customerEmail: 'theo.brandt@example.com',
orderDate: DateTime(2026, 4, 5),
status: OrderStatus.processing,
shippingAddress: '204 Chestnut St, Knoxville, TN 37902',
source: OrderSource.market,
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.depositReceived,
items: const [
OrderItem(
productName: 'Engraved Slate Serving Board',
sku: 'CUSTOM-SB-2002',
quantity: 1,
unitPrice: 64.00,
customizations: {'material': 'Welsh slate', 'engraving': 'Family crest'},
),
],
customOrderSpec: CustomOrderSpec(
material: 'Welsh slate',
color: 'Natural charcoal',
dimensions: '16" x 10" x 0.4"',
notes: 'Family crest engraved centre-left; felt feet on the underside.',
estimatedCompletionDate: DateTime(2026, 5, 1),
),
depositInfo: DepositInfo(
depositAmountCents: 2000,
totalAmountCents: 6400,
depositPaid: true,
depositDate: DateTime(2026, 4, 5),
),
),
Order(
id: 'KC-C2003',
customerName: 'Priya Raman',
customerEmail: 'priya.raman@example.com',
customerPhone: '704-555-0703',
orderDate: DateTime(2026, 3, 30),
status: OrderStatus.processing,
shippingAddress: '88 Laurel Ct, Charlotte, NC 28202',
source: OrderSource.online,
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.inProduction,
items: const [
OrderItem(
productName: 'Custom Nightlight — Nursery',
sku: 'CUSTOM-NL-2003',
quantity: 2,
unitPrice: 42.50,
customizations: {'material': 'Resin', 'color': 'Dusk lavender'},
),
],
customOrderSpec: CustomOrderSpec(
material: 'Cast resin',
color: 'Dusk lavender',
dimensions: '5" x 5" x 7"',
referenceImageUrls: const [
'https://cdn.kellcreations.test/refs/kc-c2003-nursery-a.jpg',
'https://cdn.kellcreations.test/refs/kc-c2003-nursery-b.jpg',
],
notes: 'Warm-white LED. Pair should be tonally matched.',
estimatedCompletionDate: DateTime(2026, 4, 24),
),
depositInfo: DepositInfo(
depositAmountCents: 3000,
totalAmountCents: 8500,
depositPaid: true,
depositDate: DateTime(2026, 3, 30),
),
),
Order(
id: 'KC-C2004',
customerName: 'Lucas Ferreira',
customerEmail: 'lucas.ferreira@example.com',
orderDate: DateTime(2026, 3, 22),
status: OrderStatus.processing,
shippingAddress: '9 Harbour Row, Greenville, SC 29601',
source: OrderSource.market,
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.qualityCheck,
items: const [
OrderItem(
productName: 'Custom Skillet Handle Sleeve Pair',
sku: 'CUSTOM-SH-2004',
quantity: 2,
unitPrice: 22.00,
customizations: {'material': 'Heavy duck canvas', 'color': 'Rust'},
),
],
customOrderSpec: CustomOrderSpec(
material: 'Heavy duck canvas',
color: 'Rust',
size: 'Medium',
notes: 'Double-layer batting; must fit a 10" cast iron handle.',
estimatedCompletionDate: DateTime(2026, 4, 12),
),
depositInfo: DepositInfo(
depositAmountCents: 1500,
totalAmountCents: 4400,
depositPaid: true,
depositDate: DateTime(2026, 3, 22),
),
),
Order(
id: 'KC-C2005',
customerName: 'Hannah Wexler',
customerEmail: 'hannah.wexler@example.com',
customerPhone: '404-555-0705',
orderDate: DateTime(2026, 3, 14),
status: OrderStatus.processing,
shippingAddress: '512 Peachtree Pl, Atlanta, GA 30301',
source: OrderSource.manual,
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.readyForPickup,
items: const [
OrderItem(
productName: 'Custom Coaster Set — Wedding Favours',
sku: 'CUSTOM-CS-2005',
quantity: 12,
unitPrice: 9.50,
customizations: {'material': 'Birch ply', 'color': 'Natural'},
),
],
customOrderSpec: CustomOrderSpec(
material: 'Birch ply',
color: 'Natural',
dimensions: '4" round, 6mm thick',
referenceImageUrls: const ['https://cdn.kellcreations.test/refs/kc-c2005-monogram.png'],
notes: 'Monogram burned centre; twelve favours, boxed in sets of four.',
estimatedCompletionDate: DateTime(2026, 4, 8),
),
depositInfo: DepositInfo(
depositAmountCents: 4000,
totalAmountCents: 11400,
depositPaid: true,
depositDate: DateTime(2026, 3, 14),
),
),
]; ];
@override @override
@ -157,7 +353,11 @@ class FakeOrdersRepository implements OrdersRepository {
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
final index = _orders.indexWhere((o) => o.id == id); final index = _orders.indexWhere((o) => o.id == id);
if (index == -1) { if (index == -1) {
return const OrderActionResult.failure('Order not found.'); return _rejected(
'Order not found.',
operation: 'updateOrderStatus',
context: {'orderId': id, 'requestedStatus': newStatus.name},
);
} }
final updated = _orders[index].copyWith(status: newStatus); final updated = _orders[index].copyWith(status: newStatus);
_orders[index] = updated; _orders[index] = updated;
@ -168,11 +368,15 @@ class FakeOrdersRepository implements OrdersRepository {
Future<OrderActionResult> addOrderNote(String id, String note) async { Future<OrderActionResult> addOrderNote(String id, String note) async {
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
if (note.trim().isEmpty) { if (note.trim().isEmpty) {
return const OrderActionResult.failure('Note cannot be empty.'); return _rejected(
'Note cannot be empty.',
operation: 'addOrderNote',
context: {'orderId': id},
);
} }
final index = _orders.indexWhere((o) => o.id == id); final index = _orders.indexWhere((o) => o.id == id);
if (index == -1) { if (index == -1) {
return const OrderActionResult.failure('Order not found.'); return _rejected('Order not found.', operation: 'addOrderNote', context: {'orderId': id});
} }
final existing = _orders[index]; final existing = _orders[index];
final updated = existing.copyWith(orderNotes: [...existing.orderNotes, note.trim()]); final updated = existing.copyWith(orderNotes: [...existing.orderNotes, note.trim()]);
@ -184,9 +388,176 @@ class FakeOrdersRepository implements OrdersRepository {
Future<OrderActionResult> createOrder(Order order) async { Future<OrderActionResult> createOrder(Order order) async {
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
if (_orders.any((o) => o.id == order.id)) { if (_orders.any((o) => o.id == order.id)) {
return OrderActionResult.failure('Order with ID ${order.id} already exists.'); return _rejected(
'Order with ID ${order.id} already exists.',
operation: 'createOrder',
context: {'orderId': order.id, 'orderType': order.orderType.name},
);
} }
_orders.add(order); _orders.add(order);
return OrderActionResult.success(order); return OrderActionResult.success(order);
} }
// ── Custom orders ────────────────────────────────────────────────────────
@override
Future<OrderActionResult> createCustomOrder(
Order order, {
required CustomOrderSpec spec,
DepositInfo? deposit,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
if (_orders.any((o) => o.id == order.id)) {
return _rejected(
'Order with ID ${order.id} already exists.',
operation: 'createCustomOrder',
context: {'orderId': order.id},
);
}
final created = order.copyWith(
orderType: OrderType.custom,
customOrderSpec: spec,
depositInfo: deposit,
fulfillmentStatus: FulfillmentStatus.quoted,
);
_orders.add(created);
return OrderActionResult.success(created);
}
@override
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
final index = _orders.indexWhere((o) => o.id == id);
if (index == -1) {
return _rejected(
'Order not found.',
operation: 'updateCustomOrderSpec',
context: {'orderId': id},
);
}
if (!_orders[index].isCustom) {
return _rejected(
'Order $id is not a custom order.',
operation: 'updateCustomOrderSpec',
context: {'orderId': id, 'orderType': _orders[index].orderType.name},
);
}
final updated = _orders[index].copyWith(customOrderSpec: spec);
_orders[index] = updated;
return OrderActionResult.success(updated);
}
@override
Future<OrderActionResult> recordDeposit(
String id, {
required int amountCents,
required DateTime paidAt,
int? totalAmountCents,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
if (amountCents <= 0) {
return _rejected(
'Deposit amount must be greater than zero.',
operation: 'recordDeposit',
context: {'orderId': id},
);
}
final index = _orders.indexWhere((o) => o.id == id);
if (index == -1) {
return _rejected('Order not found.', operation: 'recordDeposit', context: {'orderId': id});
}
final existing = _orders[index];
if (!existing.isCustom) {
return _rejected(
'Order $id is not a custom order.',
operation: 'recordDeposit',
context: {'orderId': id, 'orderType': existing.orderType.name},
);
}
if (existing.depositInfo?.depositPaid ?? false) {
return _rejected(
'A deposit has already been recorded for order $id.',
operation: 'recordDeposit',
context: {'orderId': id},
);
}
final base =
existing.depositInfo ??
DepositInfo(
depositAmountCents: amountCents,
totalAmountCents: totalAmountCents ?? amountCents,
);
final revised = totalAmountCents != null
? base.copyWith(totalAmountCents: totalAmountCents)
: base;
final updated = existing.copyWith(
depositInfo: revised.markDepositPaid(paidAt: paidAt, amountCents: amountCents),
);
_orders[index] = updated;
return OrderActionResult.success(updated);
}
@override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
final index = _orders.indexWhere((o) => o.id == id);
if (index == -1) {
return _rejected(
'Order not found.',
operation: 'advanceFulfillmentStatus',
context: {'orderId': id, 'requestedStage': next.name},
);
}
final existing = _orders[index];
if (!existing.isCustom) {
return _rejected(
'Order $id is not a custom order.',
operation: 'advanceFulfillmentStatus',
context: {'orderId': id, 'orderType': existing.orderType.name},
);
}
final current = existing.fulfillmentStatus ?? FulfillmentStatus.quoted;
final rejection = current.rejectionReason(next);
if (rejection != null) {
return _rejected(
rejection,
operation: 'advanceFulfillmentStatus',
context: {'orderId': id, 'fromStage': current.name, 'toStage': next.name},
);
}
final updated = existing.copyWith(fulfillmentStatus: next);
_orders[index] = updated;
return OrderActionResult.success(updated);
}
// ── Private helpers ──────────────────────────────────────────────────────
/// Builds a failure result and records a structured warning.
///
/// [context] must contain only identifiers and status names. Customer
/// identity, addresses, money amounts, and payment identifiers are never
/// included; [KcLogger] additionally routes every value through
/// [KcRedactor].
OrderActionResult _rejected(
String message, {
required String operation,
required Map<String, Object?> context,
}) {
logger.warning(
'Order operation rejected: $message',
context: {'operation': operation, ...context},
);
return OrderActionResult.failure(message);
}
} }

View File

@ -1,14 +1,102 @@
import '../domain/custom_order_spec.dart';
import '../domain/deposit_info.dart';
import '../domain/fulfillment_status.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_item.dart'; import '../domain/order_item.dart';
import '../domain/order_source.dart'; import '../domain/order_source.dart';
import '../domain/order_status.dart'; import '../domain/order_status.dart';
import '../domain/order_type.dart';
/// Maps raw WooCommerce REST API v3 order JSON into [Order] domain objects. /// Maps raw WooCommerce REST API v3 order JSON into [Order] domain objects.
/// ///
/// See https://woocommerce.github.io/woocommerce-rest-api-docs/#order-properties /// See https://woocommerce.github.io/woocommerce-rest-api-docs/#order-properties
///
/// ## Custom order meta-data mapping
///
/// WooCommerce has no native concept of a made-to-order commission, so the
/// custom order fields are round-tripped through order `meta_data` entries.
/// All keys are prefixed `_kc_` — the leading underscore marks them as
/// internal to WooCommerce (they are hidden from the customer-facing order
/// screen) and the `kc` segment namespaces them to this platform.
///
/// | Meta key | Domain field | Format |
/// | ------------------------------- | --------------------------------------------- | ---------------------------- |
/// | `_kc_order_type` | `Order.orderType` | `standard` \| `custom` |
/// | `_kc_fulfillment_status` | `Order.fulfillmentStatus` | enum name, e.g. `inProduction` |
/// | `_kc_spec_material` | `CustomOrderSpec.material` | free text |
/// | `_kc_spec_color` | `CustomOrderSpec.color` | free text |
/// | `_kc_spec_size` | `CustomOrderSpec.size` | free text |
/// | `_kc_spec_dimensions` | `CustomOrderSpec.dimensions` | free text |
/// | `_kc_spec_reference_images` | `CustomOrderSpec.referenceImageUrls` | newline-separated URLs |
/// | `_kc_spec_notes` | `CustomOrderSpec.notes` | free text |
/// | `_kc_spec_estimated_completion` | `CustomOrderSpec.estimatedCompletionDate` | ISO 8601 |
/// | `_kc_deposit_amount_cents` | `DepositInfo.depositAmountCents` | integer cents |
/// | `_kc_deposit_total_cents` | `DepositInfo.totalAmountCents` | integer cents |
/// | `_kc_deposit_paid` | `DepositInfo.depositPaid` | `true` \| `false` |
/// | `_kc_deposit_date` | `DepositInfo.depositDate` | ISO 8601 |
/// | `_kc_settlement_date` | `DepositInfo.settlementDate` | ISO 8601 |
///
/// **Deliberately not mapped:** the deposit payment / transaction identifier.
/// It lives on `PaymentResult` only and is never written to order meta-data,
/// because WooCommerce order meta is readable by any store administrator.
///
/// `DepositInfo.balanceDueCents` is derived, not persisted — writing it would
/// create a second source of truth that could drift from the amounts.
///
/// Because these keys begin with `_`, they are excluded from
/// `Order.customFields` (which only carries public meta), so a round trip
/// never duplicates custom order state into two places.
class WooCommerceOrderMapper { class WooCommerceOrderMapper {
const WooCommerceOrderMapper(); const WooCommerceOrderMapper();
// ── Meta-data keys ────────────────────────────────────────────────────────
/// Meta key holding the [OrderType] wire value.
static const String metaOrderType = '_kc_order_type';
/// Meta key holding the [FulfillmentStatus] wire value.
static const String metaFulfillmentStatus = '_kc_fulfillment_status';
/// Meta key holding [CustomOrderSpec.material].
static const String metaSpecMaterial = '_kc_spec_material';
/// Meta key holding [CustomOrderSpec.color].
static const String metaSpecColor = '_kc_spec_color';
/// Meta key holding [CustomOrderSpec.size].
static const String metaSpecSize = '_kc_spec_size';
/// Meta key holding [CustomOrderSpec.dimensions].
static const String metaSpecDimensions = '_kc_spec_dimensions';
/// Meta key holding [CustomOrderSpec.referenceImageUrls], newline-separated.
static const String metaSpecReferenceImages = '_kc_spec_reference_images';
/// Meta key holding [CustomOrderSpec.notes].
static const String metaSpecNotes = '_kc_spec_notes';
/// Meta key holding [CustomOrderSpec.estimatedCompletionDate] as ISO 8601.
static const String metaSpecEstimatedCompletion = '_kc_spec_estimated_completion';
/// Meta key holding [DepositInfo.depositAmountCents].
static const String metaDepositAmountCents = '_kc_deposit_amount_cents';
/// Meta key holding [DepositInfo.totalAmountCents].
static const String metaDepositTotalCents = '_kc_deposit_total_cents';
/// Meta key holding [DepositInfo.depositPaid].
static const String metaDepositPaid = '_kc_deposit_paid';
/// Meta key holding [DepositInfo.depositDate] as ISO 8601.
static const String metaDepositDate = '_kc_deposit_date';
/// Meta key holding [DepositInfo.settlementDate] as ISO 8601.
static const String metaSettlementDate = '_kc_settlement_date';
/// Separator used to pack [CustomOrderSpec.referenceImageUrls] into a single
/// meta value.
static const String referenceImageSeparator = '\n';
/// Converts a single WooCommerce order JSON map to an [Order]. /// Converts a single WooCommerce order JSON map to an [Order].
Order fromJson(Map<String, dynamic> json) { Order fromJson(Map<String, dynamic> json) {
final billing = json['billing'] as Map<String, dynamic>? ?? {}; final billing = json['billing'] as Map<String, dynamic>? ?? {};
@ -28,9 +116,14 @@ class WooCommerceOrderMapper {
final customerNote = (json['customer_note'] as String?) ?? ''; final customerNote = (json['customer_note'] as String?) ?? '';
final orderNotes = customerNote.isNotEmpty ? [customerNote] : <String>[]; final orderNotes = customerNote.isNotEmpty ? [customerNote] : <String>[];
// Collect custom fields from meta_data. // Collect custom fields from public meta_data (`_`-prefixed keys excluded).
final customFields = _extractMetaData(json['meta_data']); final customFields = _extractMetaData(json['meta_data']);
// Collect internal Kell Creations meta_data for custom order state.
final internalMeta = _extractInternalMetaData(json['meta_data']);
final orderType = OrderType.fromWire(internalMeta[metaOrderType]);
final isCustom = orderType == OrderType.custom;
return Order( return Order(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
customerName: customerName, customerName: customerName,
@ -44,6 +137,12 @@ class WooCommerceOrderMapper {
paymentMethod: (json['payment_method_title'] as String?), paymentMethod: (json['payment_method_title'] as String?),
source: OrderSource.online, source: OrderSource.online,
customFields: customFields, customFields: customFields,
orderType: orderType,
customOrderSpec: isCustom ? _specFromMeta(internalMeta) : null,
depositInfo: isCustom ? _depositFromMeta(internalMeta) : null,
fulfillmentStatus: isCustom
? FulfillmentStatus.fromWire(internalMeta[metaFulfillmentStatus])
: null,
); );
} }
@ -103,11 +202,136 @@ class WooCommerceOrderMapper {
}, },
) )
.toList(), .toList(),
if (order.isCustom) 'meta_data': customOrderMetaData(order),
}; };
} }
// ── Custom order meta-data ───────────────────────────────────────────────
/// Builds the full `meta_data` list encoding [order]'s custom order state.
///
/// Returns an empty list for standard orders. Absent optional fields are
/// omitted rather than written as empty strings so a partial update never
/// blanks a value that was already set remotely.
///
/// Never includes a payment or transaction identifier — see the class-level
/// mapping table.
List<Map<String, dynamic>> customOrderMetaData(Order order) {
if (!order.isCustom) return const [];
final entries = <String, String>{
metaOrderType: order.orderType.wireValue,
if (order.fulfillmentStatus != null)
metaFulfillmentStatus: order.fulfillmentStatus!.wireValue,
...specMetaEntries(order.customOrderSpec),
...depositMetaEntries(order.depositInfo),
};
return entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList();
}
/// Builds the `meta_data` entries for [spec], or an empty map when `null`.
Map<String, String> specMetaEntries(CustomOrderSpec? spec) {
if (spec == null) return const {};
return {
metaSpecMaterial: spec.material,
metaSpecColor: spec.color,
if (spec.size != null) metaSpecSize: spec.size!,
if (spec.dimensions != null) metaSpecDimensions: spec.dimensions!,
if (spec.referenceImageUrls.isNotEmpty)
metaSpecReferenceImages: spec.referenceImageUrls.join(referenceImageSeparator),
if (spec.notes != null) metaSpecNotes: spec.notes!,
if (spec.estimatedCompletionDate != null)
metaSpecEstimatedCompletion: spec.estimatedCompletionDate!.toIso8601String(),
};
}
/// Builds the `meta_data` entries for [deposit], or an empty map when `null`.
///
/// `balanceDueCents` is intentionally omitted — it is derived from the
/// deposit and total amounts on read.
Map<String, String> depositMetaEntries(DepositInfo? deposit) {
if (deposit == null) return const {};
return {
metaDepositAmountCents: deposit.depositAmountCents.toString(),
metaDepositTotalCents: deposit.totalAmountCents.toString(),
metaDepositPaid: deposit.depositPaid.toString(),
if (deposit.depositDate != null) metaDepositDate: deposit.depositDate!.toIso8601String(),
if (deposit.settlementDate != null)
metaSettlementDate: deposit.settlementDate!.toIso8601String(),
};
}
/// Converts a `key → value` map into WooCommerce `meta_data` list form.
static List<Map<String, dynamic>> toMetaDataList(Map<String, String> entries) =>
entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList();
// ── Private helpers ────────────────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────────────────
/// Rebuilds a [CustomOrderSpec] from internal meta, or `null` when the
/// required material and colour are both absent.
static CustomOrderSpec? _specFromMeta(Map<String, String> meta) {
final material = meta[metaSpecMaterial];
final color = meta[metaSpecColor];
if (material == null && color == null) return null;
final packedImages = meta[metaSpecReferenceImages];
final images = (packedImages == null || packedImages.isEmpty)
? const <String>[]
: packedImages
.split(referenceImageSeparator)
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
return CustomOrderSpec(
material: material ?? '',
color: color ?? '',
size: meta[metaSpecSize],
dimensions: meta[metaSpecDimensions],
referenceImageUrls: images,
notes: meta[metaSpecNotes],
estimatedCompletionDate: _parseNullableDate(meta[metaSpecEstimatedCompletion]),
);
}
/// Rebuilds a [DepositInfo] from internal meta, or `null` when no deposit
/// amount was recorded.
static DepositInfo? _depositFromMeta(Map<String, String> meta) {
final depositCents = int.tryParse(meta[metaDepositAmountCents] ?? '');
if (depositCents == null) return null;
return DepositInfo(
depositAmountCents: depositCents,
totalAmountCents: int.tryParse(meta[metaDepositTotalCents] ?? '') ?? depositCents,
depositPaid: meta[metaDepositPaid] == 'true',
depositDate: _parseNullableDate(meta[metaDepositDate]),
settlementDate: _parseNullableDate(meta[metaSettlementDate]),
);
}
/// Parses an ISO 8601 string, returning `null` for absent or invalid values.
static DateTime? _parseNullableDate(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
/// Extracts `_`-prefixed (internal) meta_data entries as a String→String map.
///
/// Complements [_extractMetaData], which returns only the public entries.
static Map<String, String> _extractInternalMetaData(dynamic metaData) {
final result = <String, String>{};
if (metaData is! List) return result;
for (final entry in metaData.cast<Map<String, dynamic>>()) {
final key = entry['key'] as String?;
final value = entry['value'];
if (key != null && key.startsWith('_') && value != null) {
result[key] = value.toString();
}
}
return result;
}
/// Maps the WooCommerce order `status` string to our [OrderStatus] enum. /// Maps the WooCommerce order `status` string to our [OrderStatus] enum.
static OrderStatus _mapStatus(String? status) { static OrderStatus _mapStatus(String? status) {
switch (status) { switch (status) {

View File

@ -1,15 +1,38 @@
import 'package:core/core.dart';
import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:feature_wordpress/feature_wordpress.dart';
import '../domain/custom_order_spec.dart';
import '../domain/deposit_info.dart';
import '../domain/fulfillment_status.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_action_result.dart'; import '../domain/order_action_result.dart';
import '../domain/order_status.dart'; import '../domain/order_status.dart';
import '../domain/order_type.dart';
import '../domain/orders_repository.dart'; import '../domain/orders_repository.dart';
import 'woo_commerce_order_mapper.dart'; import 'woo_commerce_order_mapper.dart';
/// Real [OrdersRepository] backed by the WooCommerce REST API v3. /// Real [OrdersRepository] backed by the WooCommerce REST API v3.
/// ///
/// Fetches orders from WooCommerce, maps them to domain [Order] objects, /// Fetches orders from WooCommerce, maps them to domain [Order] objects,
/// and supports status updates, note additions, and order creation. /// and supports status updates, note additions, order creation, and the full
/// custom order workflow.
///
/// ## Custom order persistence
///
/// WooCommerce has no native custom order concept, so [CustomOrderSpec],
/// [DepositInfo], [OrderType], and [FulfillmentStatus] are round-tripped
/// through `_kc_`-prefixed order `meta_data`. The complete key mapping is
/// documented on [WooCommerceOrderMapper]. Deposit payment identifiers are
/// deliberately excluded from that mapping.
///
/// ## Observability
///
/// Every API failure is recorded through [logger] with the failing operation,
/// the order ID, and the HTTP status code. Customer identity, addresses, money
/// amounts, and payment identifiers are never logged; [KcLogger] additionally
/// routes all context through [KcRedactor]. There are no silent `catch (_) {}`
/// blocks — a swallowed error is always accompanied by a log record and a
/// failure result.
/// ///
/// Depends on [WooCommerceApiClient] from `feature_wordpress` for HTTP /// Depends on [WooCommerceApiClient] from `feature_wordpress` for HTTP
/// transport and authentication. /// transport and authentication.
@ -17,11 +40,20 @@ class WooCommerceOrdersRepository implements OrdersRepository {
final WooCommerceApiClient _apiClient; final WooCommerceApiClient _apiClient;
final WooCommerceOrderMapper _mapper; final WooCommerceOrderMapper _mapper;
/// Structured logger for API and validation failures.
///
/// Defaults to [KcLogger.silent] so tests need not supply one.
final KcLogger logger;
WooCommerceOrdersRepository({ WooCommerceOrdersRepository({
required WooCommerceApiClient apiClient, required WooCommerceApiClient apiClient,
WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(), WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(),
KcLogger? logger,
}) : _apiClient = apiClient, }) : _apiClient = apiClient,
_mapper = mapper; _mapper = mapper,
logger = (logger ?? KcLogger.silent()).forSource(_logSource);
static const String _logSource = 'WooCommerceOrdersRepository';
@override @override
Future<List<Order>> getOrders() async { Future<List<Order>> getOrders() async {
@ -34,56 +66,280 @@ class WooCommerceOrdersRepository implements OrdersRepository {
try { try {
final json = await _apiClient.getOrder(id); final json = await _apiClient.getOrder(id);
return _mapper.fromJson(json); return _mapper.fromJson(json);
} on WooCommerceApiException catch (e) { } on WooCommerceApiException catch (e, stackTrace) {
if (e.statusCode == 404) return null; if (e.statusCode == 404) {
logger.warning(
'Order not found in WooCommerce.',
context: {'operation': 'getOrder', 'orderId': id, 'statusCode': e.statusCode},
);
return null;
}
logger.error(
'Failed to fetch order from WooCommerce.',
context: {'operation': 'getOrder', 'orderId': id, 'statusCode': e.statusCode},
error: e,
stackTrace: stackTrace,
);
rethrow; rethrow;
} }
} }
@override @override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async { Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) {
try { return _guard(
final wooStatus = WooCommerceOrderMapper.toWooCommerceStatus(newStatus); operation: 'updateOrderStatus',
final json = await _apiClient.updateOrderStatus(id, wooStatus); context: {'orderId': id, 'requestedStatus': newStatus.name},
final updated = _mapper.fromJson(json); action: () async {
return OrderActionResult.success(updated); final wooStatus = WooCommerceOrderMapper.toWooCommerceStatus(newStatus);
} on WooCommerceApiException catch (e) { final json = await _apiClient.updateOrderStatus(id, wooStatus);
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}'); return _mapper.fromJson(json);
} catch (e) { },
return OrderActionResult.failure('Unexpected error: $e'); );
}
} }
@override @override
Future<OrderActionResult> addOrderNote(String id, String note) async { Future<OrderActionResult> addOrderNote(String id, String note) async {
if (note.trim().isEmpty) { if (note.trim().isEmpty) {
return const OrderActionResult.failure('Note cannot be empty.'); return _rejected(
'Note cannot be empty.',
operation: 'addOrderNote',
context: {'orderId': id},
);
} }
return _guard(
operation: 'addOrderNote',
context: {'orderId': id},
action: () async {
// Add the note via the WooCommerce notes endpoint.
await _apiClient.addOrderNote(id, note.trim());
// Fetch the updated order to return the refreshed domain object.
final json = await _apiClient.getOrder(id);
return _mapper.fromJson(json);
},
);
}
@override
Future<OrderActionResult> createOrder(Order order) {
return _guard(
operation: 'createOrder',
context: {'orderId': order.id, 'orderType': order.orderType.name},
action: () async {
final body = _mapper.toCreateJson(order);
final json = await _apiClient.createOrder(body);
return _mapper.fromJson(json);
},
);
}
// ── Custom orders ────────────────────────────────────────────────────────
@override
Future<OrderActionResult> createCustomOrder(
Order order, {
required CustomOrderSpec spec,
DepositInfo? deposit,
}) {
final custom = order.copyWith(
orderType: OrderType.custom,
customOrderSpec: spec,
depositInfo: deposit,
fulfillmentStatus: FulfillmentStatus.quoted,
);
return _guard(
operation: 'createCustomOrder',
context: {'orderId': order.id},
action: () async {
final body = _mapper.toCreateJson(custom);
final json = await _apiClient.createOrder(body);
return _mapper.fromJson(json);
},
);
}
@override
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) async {
final loaded = await _loadCustomOrder(id, operation: 'updateCustomOrderSpec');
if (loaded.failure != null) return loaded.failure!;
final updated = loaded.order!.copyWith(customOrderSpec: spec);
return _pushCustomOrderMeta(id, updated, operation: 'updateCustomOrderSpec');
}
@override
Future<OrderActionResult> recordDeposit(
String id, {
required int amountCents,
required DateTime paidAt,
int? totalAmountCents,
}) async {
if (amountCents <= 0) {
return _rejected(
'Deposit amount must be greater than zero.',
operation: 'recordDeposit',
context: {'orderId': id},
);
}
final loaded = await _loadCustomOrder(id, operation: 'recordDeposit');
if (loaded.failure != null) return loaded.failure!;
final order = loaded.order!;
if (order.depositInfo?.depositPaid ?? false) {
return _rejected(
'A deposit has already been recorded for order $id.',
operation: 'recordDeposit',
context: {'orderId': id},
);
}
final base =
order.depositInfo ??
DepositInfo(
depositAmountCents: amountCents,
totalAmountCents: totalAmountCents ?? amountCents,
);
final revised = totalAmountCents != null
? base.copyWith(totalAmountCents: totalAmountCents)
: base;
final updated = order.copyWith(
depositInfo: revised.markDepositPaid(paidAt: paidAt, amountCents: amountCents),
);
return _pushCustomOrderMeta(id, updated, operation: 'recordDeposit');
}
@override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async {
final loaded = await _loadCustomOrder(id, operation: 'advanceFulfillmentStatus');
if (loaded.failure != null) return loaded.failure!;
final order = loaded.order!;
final current = order.fulfillmentStatus ?? FulfillmentStatus.quoted;
final rejection = current.rejectionReason(next);
if (rejection != null) {
return _rejected(
rejection,
operation: 'advanceFulfillmentStatus',
context: {'orderId': id, 'fromStage': current.name, 'toStage': next.name},
);
}
final updated = order.copyWith(fulfillmentStatus: next);
return _pushCustomOrderMeta(id, updated, operation: 'advanceFulfillmentStatus');
}
// ── Private helpers ──────────────────────────────────────────────────────
/// Fetches [id] and verifies it is a custom order.
///
/// Returns a record carrying either the loaded order or the failure result
/// to hand back to the caller — never both.
Future<({Order? order, OrderActionResult? failure})> _loadCustomOrder(
String id, {
required String operation,
}) async {
final Order? order;
try { try {
// Add the note via the WooCommerce notes endpoint. order = await getOrder(id);
await _apiClient.addOrderNote(id, note.trim());
// Fetch the updated order to return the refreshed domain object.
final json = await _apiClient.getOrder(id);
final updated = _mapper.fromJson(json);
return OrderActionResult.success(updated);
} on WooCommerceApiException catch (e) { } on WooCommerceApiException catch (e) {
// getOrder has already logged this failure with its status code.
return (
order: null,
failure: OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}'),
);
} catch (e, stackTrace) {
logger.error(
'Unexpected error while loading custom order.',
context: {'operation': operation, 'orderId': id},
error: e,
stackTrace: stackTrace,
);
return (order: null, failure: OrderActionResult.failure('Unexpected error: $e'));
}
if (order == null) {
return (
order: null,
failure: _rejected('Order not found.', operation: operation, context: {'orderId': id}),
);
}
if (!order.isCustom) {
return (
order: null,
failure: _rejected(
'Order $id is not a custom order.',
operation: operation,
context: {'orderId': id, 'orderType': order.orderType.name},
),
);
}
return (order: order, failure: null);
}
/// Writes [order]'s custom order meta-data back to WooCommerce.
///
/// Only the `_kc_`-prefixed meta entries are sent, so a spec edit or a
/// pipeline advance can never mutate a price, a line item, or a customer
/// record.
Future<OrderActionResult> _pushCustomOrderMeta(
String id,
Order order, {
required String operation,
}) {
return _guard(
operation: operation,
context: {'orderId': id},
action: () async {
final json = await _apiClient.updateOrder(id, {
'meta_data': _mapper.customOrderMetaData(order),
});
return _mapper.fromJson(json);
},
);
}
/// Runs [action], converting any exception into a logged failure result.
///
/// [context] must contain only identifiers, status names, and counts —
/// never customer identity, money amounts, or payment identifiers.
Future<OrderActionResult> _guard({
required String operation,
required Map<String, Object?> context,
required Future<Order> Function() action,
}) async {
try {
return OrderActionResult.success(await action());
} on WooCommerceApiException catch (e, stackTrace) {
logger.error(
'WooCommerce order operation failed.',
context: {'operation': operation, ...context, 'statusCode': e.statusCode},
error: e,
stackTrace: stackTrace,
);
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}'); return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}');
} catch (e) { } catch (e, stackTrace) {
logger.error(
'Unexpected error during WooCommerce order operation.',
context: {'operation': operation, ...context},
error: e,
stackTrace: stackTrace,
);
return OrderActionResult.failure('Unexpected error: $e'); return OrderActionResult.failure('Unexpected error: $e');
} }
} }
@override /// Builds a failure result for a domain-level rejection and logs a warning.
Future<OrderActionResult> createOrder(Order order) async { OrderActionResult _rejected(
try { String message, {
final body = _mapper.toCreateJson(order); required String operation,
final json = await _apiClient.createOrder(body); required Map<String, Object?> context,
final created = _mapper.fromJson(json); }) {
return OrderActionResult.success(created); logger.warning(
} on WooCommerceApiException catch (e) { 'Order operation rejected: $message',
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}'); context: {'operation': operation, ...context},
} catch (e) { );
return OrderActionResult.failure('Unexpected error: $e'); return OrderActionResult.failure(message);
}
} }
} }

View File

@ -0,0 +1,139 @@
/// The customer-agreed specification for a made-to-order commission.
///
/// Immutable value object. Every field is optional except [material] and
/// [color] because market intake is often incremental — an operator captures
/// what the customer knows at the stall and refines the rest later via
/// `OrdersRepository.updateCustomOrderSpec`.
///
/// Money is not modelled here; deposit and balance amounts live on
/// [DepositInfo] as integer cents.
class CustomOrderSpec {
/// The primary material (e.g. `'Cotton yarn'`, `'Slate'`, `'Birch ply'`).
final String material;
/// The requested colour or colourway (e.g. `'Sage green'`).
final String color;
/// A named size where the shop offers standard sizes (e.g. `'Large'`).
///
/// `null` when the piece is dimensioned rather than sized.
final String? size;
/// Free-form dimensions when [size] does not apply (e.g. `'12" x 8" x 3"'`).
final String? dimensions;
/// URLs of customer-supplied reference images.
///
/// Stored as an unmodifiable list. These are references to uploaded media,
/// never raw image bytes.
final List<String> referenceImageUrls;
/// Free-text notes captured from the customer (special instructions,
/// occasion, personalisation text).
final String? notes;
/// The date the operator expects the piece to be ready.
///
/// `null` until the operator has scheduled production.
final DateTime? estimatedCompletionDate;
CustomOrderSpec({
required this.material,
required this.color,
this.size,
this.dimensions,
List<String> referenceImageUrls = const [],
this.notes,
this.estimatedCompletionDate,
}) : referenceImageUrls = List.unmodifiable(referenceImageUrls);
/// Whether the specification carries enough detail to start production.
///
/// A spec needs a material, a colour, and either a named size or explicit
/// dimensions before it can be quoted reliably.
bool get isProductionReady =>
material.trim().isNotEmpty &&
color.trim().isNotEmpty &&
((size != null && size!.trim().isNotEmpty) ||
(dimensions != null && dimensions!.trim().isNotEmpty));
/// Whether the customer supplied any reference imagery.
bool get hasReferenceImages => referenceImageUrls.isNotEmpty;
/// A single-line summary suitable for list rows and order cards.
///
/// Contains only specification detail — never customer identity.
String get summary {
final parts = <String>[
material,
color,
if (size != null && size!.trim().isNotEmpty) size!,
if (dimensions != null && dimensions!.trim().isNotEmpty) dimensions!,
].where((p) => p.trim().isNotEmpty);
return parts.join(' · ');
}
/// Returns a copy of this specification with the given fields replaced.
///
/// Nullable fields cannot be cleared through [copyWith]; construct a new
/// [CustomOrderSpec] when a field must be removed. This mirrors the
/// behaviour of `Order.copyWith` and `OrderItem.copyWith`.
CustomOrderSpec copyWith({
String? material,
String? color,
String? size,
String? dimensions,
List<String>? referenceImageUrls,
String? notes,
DateTime? estimatedCompletionDate,
}) {
return CustomOrderSpec(
material: material ?? this.material,
color: color ?? this.color,
size: size ?? this.size,
dimensions: dimensions ?? this.dimensions,
referenceImageUrls: referenceImageUrls ?? this.referenceImageUrls,
notes: notes ?? this.notes,
estimatedCompletionDate: estimatedCompletionDate ?? this.estimatedCompletionDate,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CustomOrderSpec &&
runtimeType == other.runtimeType &&
material == other.material &&
color == other.color &&
size == other.size &&
dimensions == other.dimensions &&
_listEquals(referenceImageUrls, other.referenceImageUrls) &&
notes == other.notes &&
estimatedCompletionDate == other.estimatedCompletionDate;
@override
int get hashCode => Object.hash(
material,
color,
size,
dimensions,
Object.hashAll(referenceImageUrls),
notes,
estimatedCompletionDate,
);
@override
String toString() =>
'CustomOrderSpec(material: $material, color: $color, size: $size, '
'dimensions: $dimensions, referenceImages: ${referenceImageUrls.length}, '
'estimatedCompletionDate: $estimatedCompletionDate)';
static bool _listEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}

View File

@ -0,0 +1,141 @@
/// Deposit and balance state for a custom order.
///
/// Immutable value object. All money is held as **integer cents**, consistent
/// with `PaymentResult.amountCents` — never as `double`, so repeated
/// arithmetic across the deposit → balance → settlement sequence cannot drift.
///
/// ## Privacy
///
/// This object deliberately carries **no payment identifiers**. The Square
/// payment ID / transaction ID for a deposit belongs to `PaymentResult` and
/// must never be copied here, logged, or written to channel meta-data.
class DepositInfo {
/// The agreed deposit amount, in cents.
final int depositAmountCents;
/// The full agreed price of the commission, in cents.
///
/// [balanceDueCents] is derived from this and [depositAmountCents].
final int totalAmountCents;
/// When the deposit was taken, or `null` if it has not been paid yet.
final DateTime? depositDate;
/// When the remaining balance was settled, or `null` if still outstanding.
final DateTime? settlementDate;
/// Whether the deposit has been collected.
final bool depositPaid;
const DepositInfo({
required this.depositAmountCents,
required this.totalAmountCents,
this.depositDate,
this.settlementDate,
this.depositPaid = false,
});
/// A quote with no deposit taken yet.
const DepositInfo.quoted({required int totalAmountCents, required int depositAmountCents})
: this(
depositAmountCents: depositAmountCents,
totalAmountCents: totalAmountCents,
depositPaid: false,
);
/// The amount still owed by the customer, in cents.
///
/// Once [settlementDate] is set the balance is zero. Before the deposit is
/// paid the full [totalAmountCents] is outstanding. Never negative — an
/// over-collected deposit clamps to zero rather than reporting a credit.
int get balanceDueCents {
if (isSettled) return 0;
final collected = depositPaid ? depositAmountCents : 0;
final remaining = totalAmountCents - collected;
return remaining < 0 ? 0 : remaining;
}
/// Whether the balance has been settled in full.
bool get isSettled => settlementDate != null;
/// Whether money is still owed on this order.
bool get isOutstanding => balanceDueCents > 0;
/// The deposit amount as a display string (e.g. `"$45.00"`).
String get depositFormatted => formatCents(depositAmountCents);
/// The total agreed price as a display string.
String get totalFormatted => formatCents(totalAmountCents);
/// The outstanding balance as a display string.
String get balanceDueFormatted => formatCents(balanceDueCents);
/// Formats [cents] as a dollar string (e.g. `4500` → `"$45.00"`).
///
/// Mirrors `PaymentResult.amountFormatted` so deposit and payment amounts
/// render identically across the app.
static String formatCents(int cents) {
final negative = cents < 0;
final absolute = cents.abs();
final dollars = absolute ~/ 100;
final remainder = absolute % 100;
final body = '\$$dollars.${remainder.toString().padLeft(2, '0')}';
return negative ? '-$body' : body;
}
/// Returns a copy with the deposit marked paid on [paidAt].
///
/// [amountCents] overrides the agreed deposit when the customer paid a
/// different amount than quoted.
DepositInfo markDepositPaid({required DateTime paidAt, int? amountCents}) {
return copyWith(
depositAmountCents: amountCents ?? depositAmountCents,
depositDate: paidAt,
depositPaid: true,
);
}
/// Returns a copy with the balance settled on [settledAt].
DepositInfo markSettled({required DateTime settledAt}) => copyWith(settlementDate: settledAt);
/// Returns a copy of this deposit record with the given fields replaced.
///
/// Nullable date fields cannot be cleared through [copyWith]; construct a
/// new [DepositInfo] when a date must be removed.
DepositInfo copyWith({
int? depositAmountCents,
int? totalAmountCents,
DateTime? depositDate,
DateTime? settlementDate,
bool? depositPaid,
}) {
return DepositInfo(
depositAmountCents: depositAmountCents ?? this.depositAmountCents,
totalAmountCents: totalAmountCents ?? this.totalAmountCents,
depositDate: depositDate ?? this.depositDate,
settlementDate: settlementDate ?? this.settlementDate,
depositPaid: depositPaid ?? this.depositPaid,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DepositInfo &&
runtimeType == other.runtimeType &&
depositAmountCents == other.depositAmountCents &&
totalAmountCents == other.totalAmountCents &&
depositDate == other.depositDate &&
settlementDate == other.settlementDate &&
depositPaid == other.depositPaid;
@override
int get hashCode =>
Object.hash(depositAmountCents, totalAmountCents, depositDate, settlementDate, depositPaid);
@override
String toString() =>
'DepositInfo(deposit: $depositFormatted, total: $totalFormatted, '
'balanceDue: $balanceDueFormatted, depositPaid: $depositPaid, '
'depositDate: $depositDate, settlementDate: $settlementDate)';
}

View File

@ -0,0 +1,139 @@
/// The production pipeline stage of a custom order.
///
/// This is **separate from** `OrderStatus`, which describes the commercial
/// state of any order (pending / processing / shipped / …). A custom order
/// carries both: `OrderStatus` for channel and reporting purposes, and
/// [FulfillmentStatus] for the workshop pipeline.
///
/// ## Pipeline
///
/// ```text
/// quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed
/// │ │ │ │ │
/// └────────────┴────────────────┴──────────────┴────────────────┴──→ cancelled
/// ```
///
/// Transitions are enforced by [FulfillmentTransition.canTransitionTo] — the
/// domain rejects illegal jumps rather than silently accepting them. A stage
/// may be re-entered backwards only where rework is a real workshop
/// operation: [qualityCheck] can return to [inProduction].
enum FulfillmentStatus {
/// A price has been quoted; no deposit has been taken.
quoted('Quoted', 0),
/// The deposit has been collected and the commission is committed.
depositReceived('Deposit Received', 1),
/// The piece is being made.
inProduction('In Production', 2),
/// The piece is finished and awaiting operator inspection.
qualityCheck('Quality Check', 3),
/// The piece has passed inspection and is awaiting collection or dispatch.
readyForPickup('Ready for Pickup', 4),
/// The customer has taken delivery and the balance has been settled.
completed('Completed', 5),
/// The commission was abandoned before completion.
///
/// Terminal. Ordinal is deliberately outside the linear pipeline.
cancelled('Cancelled', -1);
/// Human-readable label for display in the UI.
final String label;
/// Position in the linear pipeline; `-1` for [cancelled].
///
/// Used for progress indicators, not for transition validation.
final int stageIndex;
const FulfillmentStatus(this.label, this.stageIndex);
/// The value persisted to channel meta-data (see
/// `WooCommerceOrderMapper.metaFulfillmentStatus`).
String get wireValue => name;
/// Parses a persisted [wireValue], defaulting to [FulfillmentStatus.quoted].
static FulfillmentStatus fromWire(String? value) {
if (value == null) return FulfillmentStatus.quoted;
for (final status in values) {
if (status.name == value) return status;
}
return FulfillmentStatus.quoted;
}
/// The number of stages in the linear pipeline, excluding [cancelled].
static int get pipelineLength => values.where((s) => s.stageIndex >= 0).length;
/// The linear pipeline stages in order, excluding [cancelled].
static List<FulfillmentStatus> get pipeline =>
List.unmodifiable(values.where((s) => s.stageIndex >= 0).toList()..sort(_byStageIndex));
static int _byStageIndex(FulfillmentStatus a, FulfillmentStatus b) =>
a.stageIndex.compareTo(b.stageIndex);
}
/// Transition rules for the custom order fulfillment pipeline.
extension FulfillmentTransition on FulfillmentStatus {
/// Whether this stage is terminal — no further transitions are permitted.
bool get isTerminal => this == FulfillmentStatus.completed || this == FulfillmentStatus.cancelled;
/// Whether the commission reached a successful conclusion.
bool get isSuccessful => this == FulfillmentStatus.completed;
/// Whether a deposit is expected to have been collected by this stage.
bool get requiresDeposit => stageIndex >= FulfillmentStatus.depositReceived.stageIndex;
/// The stages that may legally follow this one.
///
/// Returns an unmodifiable, possibly empty list. Terminal stages return an
/// empty list.
List<FulfillmentStatus> get allowedNextStatuses {
switch (this) {
case FulfillmentStatus.quoted:
return const [FulfillmentStatus.depositReceived, FulfillmentStatus.cancelled];
case FulfillmentStatus.depositReceived:
return const [FulfillmentStatus.inProduction, FulfillmentStatus.cancelled];
case FulfillmentStatus.inProduction:
return const [FulfillmentStatus.qualityCheck, FulfillmentStatus.cancelled];
case FulfillmentStatus.qualityCheck:
// Failed inspection sends the piece back to the bench for rework.
return const [
FulfillmentStatus.readyForPickup,
FulfillmentStatus.inProduction,
FulfillmentStatus.cancelled,
];
case FulfillmentStatus.readyForPickup:
return const [FulfillmentStatus.completed, FulfillmentStatus.cancelled];
case FulfillmentStatus.completed:
case FulfillmentStatus.cancelled:
return const [];
}
}
/// Whether moving from this stage to [next] is legal.
///
/// A transition to the same stage is **not** legal — advancing must make
/// progress, and a no-op advance almost always signals a double submission.
bool canTransitionTo(FulfillmentStatus next) => allowedNextStatuses.contains(next);
/// A human-readable explanation of why moving to [next] is rejected, or
/// `null` when the transition is legal.
///
/// Messages contain only status names — never customer or payment detail —
/// so they are safe to surface in the UI and to attach to log context.
String? rejectionReason(FulfillmentStatus next) {
if (canTransitionTo(next)) return null;
if (next == this) {
return 'Order is already ${label.toLowerCase()}.';
}
if (isTerminal) {
return 'Cannot change fulfillment status: order is already ${label.toLowerCase()}.';
}
final allowed = allowedNextStatuses.map((s) => s.label).join(', ');
return 'Cannot move from ${label.toLowerCase()} to ${next.label.toLowerCase()}. '
'Allowed next: $allowed.';
}
}

View File

@ -1,8 +1,17 @@
import 'custom_order_spec.dart';
import 'deposit_info.dart';
import 'fulfillment_status.dart';
import 'order_item.dart'; import 'order_item.dart';
import 'order_source.dart'; import 'order_source.dart';
import 'order_status.dart'; import 'order_status.dart';
import 'order_type.dart';
/// A customer order placed through the Kell Creations store. /// A customer order placed through the Kell Creations store.
///
/// A single aggregate serves both catalogue purchases and made-to-order
/// commissions. [orderType] discriminates between the two; the custom-order
/// fields ([customOrderSpec], [depositInfo], [fulfillmentStatus]) are `null`
/// for standard orders.
class Order { class Order {
final String id; final String id;
final String customerName; final String customerName;
@ -32,6 +41,31 @@ class Order {
/// channel-specific fields (e.g. WooCommerce order meta). /// channel-specific fields (e.g. WooCommerce order meta).
final Map<String, String> customFields; final Map<String, String> customFields;
/// Whether this is a catalogue purchase or a made-to-order commission.
///
/// Defaults to [OrderType.standard] so every existing call site and every
/// order deserialised from a channel that predates custom orders keeps its
/// previous meaning.
final OrderType orderType;
/// The agreed specification, for custom orders only.
///
/// `null` for standard orders.
final CustomOrderSpec? customOrderSpec;
/// Deposit and balance state, for custom orders only.
///
/// `null` for standard orders and for custom orders that have not been
/// quoted yet.
final DepositInfo? depositInfo;
/// The workshop pipeline stage, for custom orders only.
///
/// `null` for standard orders. Advanced through
/// `OrdersRepository.advanceFulfillmentStatus`, which enforces the legal
/// transitions defined by [FulfillmentTransition].
final FulfillmentStatus? fulfillmentStatus;
const Order({ const Order({
required this.id, required this.id,
required this.customerName, required this.customerName,
@ -46,6 +80,10 @@ class Order {
this.paymentMethod, this.paymentMethod,
this.source = OrderSource.online, this.source = OrderSource.online,
this.customFields = const {}, this.customFields = const {},
this.orderType = OrderType.standard,
this.customOrderSpec,
this.depositInfo,
this.fulfillmentStatus,
}); });
/// The total value of the order. /// The total value of the order.
@ -54,7 +92,20 @@ class Order {
/// The number of individual items in the order. /// The number of individual items in the order.
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity); int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
/// Whether this order is a made-to-order commission.
bool get isCustom => orderType == OrderType.custom;
/// Whether a deposit has been collected on this order.
bool get hasDeposit => depositInfo?.depositPaid ?? false;
/// The outstanding balance in cents, or `null` when no deposit record
/// exists (standard orders, or custom orders that have not been quoted).
int? get balanceDueCents => depositInfo?.balanceDueCents;
/// Returns a copy of this order with the given fields replaced. /// Returns a copy of this order with the given fields replaced.
///
/// Nullable fields cannot be cleared through [copyWith]; construct a new
/// [Order] when a field must be removed.
Order copyWith({ Order copyWith({
String? id, String? id,
String? customerName, String? customerName,
@ -69,6 +120,10 @@ class Order {
String? paymentMethod, String? paymentMethod,
OrderSource? source, OrderSource? source,
Map<String, String>? customFields, Map<String, String>? customFields,
OrderType? orderType,
CustomOrderSpec? customOrderSpec,
DepositInfo? depositInfo,
FulfillmentStatus? fulfillmentStatus,
}) { }) {
return Order( return Order(
id: id ?? this.id, id: id ?? this.id,
@ -84,6 +139,10 @@ class Order {
paymentMethod: paymentMethod ?? this.paymentMethod, paymentMethod: paymentMethod ?? this.paymentMethod,
source: source ?? this.source, source: source ?? this.source,
customFields: customFields ?? this.customFields, customFields: customFields ?? this.customFields,
orderType: orderType ?? this.orderType,
customOrderSpec: customOrderSpec ?? this.customOrderSpec,
depositInfo: depositInfo ?? this.depositInfo,
fulfillmentStatus: fulfillmentStatus ?? this.fulfillmentStatus,
); );
} }
} }

View File

@ -0,0 +1,33 @@
/// Whether an order is a catalogue purchase or a made-to-order commission.
///
/// Custom orders extend the same [Order] aggregate rather than forming a
/// parallel system — they carry a [CustomOrderSpec], a [DepositInfo], and a
/// [FulfillmentStatus] in addition to every standard order field.
enum OrderType {
/// A normal catalogue order fulfilled from existing stock.
standard('Standard'),
/// A made-to-order commission with a specification and (usually) a deposit.
custom('Custom');
/// Human-readable label for display in the UI.
final String label;
const OrderType(this.label);
/// The value persisted to channel meta-data (see
/// `WooCommerceOrderMapper.metaOrderType`).
String get wireValue => name;
/// Parses a persisted [wireValue], defaulting to [OrderType.standard].
///
/// Unknown and missing values fall back to [OrderType.standard] so an order
/// written by an older client is never mis-classified as a commission.
static OrderType fromWire(String? value) {
if (value == null) return OrderType.standard;
for (final type in values) {
if (type.name == value) return type;
}
return OrderType.standard;
}
}

View File

@ -1,8 +1,15 @@
import 'custom_order_spec.dart';
import 'deposit_info.dart';
import 'fulfillment_status.dart';
import 'order.dart'; import 'order.dart';
import 'order_action_result.dart'; import 'order_action_result.dart';
import 'order_status.dart'; import 'order_status.dart';
/// Contract for fetching and managing customer orders. /// Contract for fetching and managing customer orders.
///
/// Covers both standard catalogue orders and made-to-order commissions.
/// Implementations must reject illegal fulfillment transitions rather than
/// silently applying them — see [advanceFulfillmentStatus].
abstract class OrdersRepository { abstract class OrdersRepository {
/// Returns all orders. /// Returns all orders.
Future<List<Order>> getOrders(); Future<List<Order>> getOrders();
@ -24,4 +31,58 @@ abstract class OrdersRepository {
/// ///
/// Returns an [OrderActionResult] with the created order on success. /// Returns an [OrderActionResult] with the created order on success.
Future<OrderActionResult> createOrder(Order order); Future<OrderActionResult> createOrder(Order order);
// ── Custom orders ──────────────────────────────────────────────────────
/// Creates a made-to-order commission and persists it.
///
/// [order] is stamped with `OrderType.custom`, the supplied [spec], the
/// optional [deposit], and an initial [FulfillmentStatus.quoted] stage.
/// Any custom-order fields already present on [order] are overwritten by
/// the explicit arguments so the caller cannot create an inconsistent
/// aggregate.
///
/// Returns a failure result when the order ID already exists.
Future<OrderActionResult> createCustomOrder(
Order order, {
required CustomOrderSpec spec,
DepositInfo? deposit,
});
/// Replaces the specification on the custom order identified by [id].
///
/// Returns a failure result when the order does not exist or is not a
/// custom order.
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec);
/// Records a deposit payment against the custom order identified by [id].
///
/// [amountCents] is the amount collected, in integer cents. [paidAt] is the
/// timestamp of collection. When the order has no [DepositInfo] yet, one is
/// created using [totalAmountCents] as the agreed price; when it already has
/// one, [totalAmountCents] (if supplied) revises the agreed price.
///
/// Implementations must **not** accept or persist a payment identifier here
/// — transaction IDs belong to `PaymentResult` and must never reach order
/// meta-data or a log record.
///
/// Returns a failure result when the order does not exist, is not a custom
/// order, [amountCents] is not positive, or the deposit was already
/// recorded.
Future<OrderActionResult> recordDeposit(
String id, {
required int amountCents,
required DateTime paidAt,
int? totalAmountCents,
});
/// Advances the custom order identified by [id] to [next].
///
/// The transition is validated against [FulfillmentTransition]; illegal
/// jumps are rejected with an explanatory failure result and leave the
/// order unchanged.
///
/// Returns a failure result when the order does not exist, is not a custom
/// order, or the transition is illegal.
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next);
} }

View File

@ -1,5 +1,5 @@
name: feature_orders name: feature_orders
description: "Order management feature for Kell Creations." description: "Order management feature for the Kell Creations operations platform. Provides the standard and custom order domain models, repository contracts, fake data layer, and WooCommerce orders integration."
version: 0.0.1 version: 0.0.1
publish_to: "none" publish_to: "none"
homepage: homepage:
@ -11,6 +11,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
core:
path: ../core
design_system: design_system:
path: ../design_system path: ../design_system
feature_wordpress: feature_wordpress:

View File

@ -0,0 +1,286 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final completion = DateTime(2026, 5, 1);
CustomOrderSpec makeSpec({
String material = 'Organic cotton',
String color = 'Sage green',
String? size = 'Large',
String? dimensions,
List<String> referenceImageUrls = const [],
String? notes,
DateTime? estimatedCompletionDate,
}) {
return CustomOrderSpec(
material: material,
color: color,
size: size,
dimensions: dimensions,
referenceImageUrls: referenceImageUrls,
notes: notes,
estimatedCompletionDate: estimatedCompletionDate,
);
}
// ── Construction ──────────────────────────────────────────────────────────
group('CustomOrderSpec — construction', () {
test('retains required fields', () {
final spec = makeSpec();
expect(spec.material, 'Organic cotton');
expect(spec.color, 'Sage green');
});
test('optional fields default to null', () {
final spec = CustomOrderSpec(material: 'Slate', color: 'Charcoal');
expect(spec.size, isNull);
expect(spec.dimensions, isNull);
expect(spec.notes, isNull);
expect(spec.estimatedCompletionDate, isNull);
});
test('referenceImageUrls defaults to empty', () {
final spec = CustomOrderSpec(material: 'Slate', color: 'Charcoal');
expect(spec.referenceImageUrls, isEmpty);
});
test('referenceImageUrls is unmodifiable', () {
final spec = makeSpec(referenceImageUrls: const ['https://example.test/a.jpg']);
expect(
() => spec.referenceImageUrls.add('https://example.test/b.jpg'),
throwsUnsupportedError,
);
});
test('mutating the source list does not affect the spec', () {
final source = ['https://example.test/a.jpg'];
final spec = makeSpec(referenceImageUrls: source);
source.add('https://example.test/b.jpg');
expect(spec.referenceImageUrls.length, 1);
});
test('retains all optional fields when supplied', () {
final spec = makeSpec(
size: null,
dimensions: '16" x 10"',
notes: 'Felt feet please.',
estimatedCompletionDate: completion,
);
expect(spec.dimensions, '16" x 10"');
expect(spec.notes, 'Felt feet please.');
expect(spec.estimatedCompletionDate, completion);
});
});
// ── Derived state ─────────────────────────────────────────────────────────
group('CustomOrderSpec — isProductionReady', () {
test('true with material, color, and size', () {
expect(makeSpec().isProductionReady, true);
});
test('true with material, color, and dimensions', () {
expect(makeSpec(size: null, dimensions: '4" round').isProductionReady, true);
});
test('false without a size or dimensions', () {
expect(makeSpec(size: null).isProductionReady, false);
});
test('false with a blank material', () {
expect(makeSpec(material: ' ').isProductionReady, false);
});
test('false with a blank color', () {
expect(makeSpec(color: '').isProductionReady, false);
});
test('false when size is whitespace only and no dimensions', () {
expect(makeSpec(size: ' ').isProductionReady, false);
});
test('true when size is whitespace but dimensions are set', () {
expect(makeSpec(size: ' ', dimensions: '4" round').isProductionReady, true);
});
});
group('CustomOrderSpec — hasReferenceImages', () {
test('false when no images supplied', () {
expect(makeSpec().hasReferenceImages, false);
});
test('true when images supplied', () {
expect(makeSpec(referenceImageUrls: const ['a']).hasReferenceImages, true);
});
});
group('CustomOrderSpec — summary', () {
test('joins material, color, and size', () {
expect(makeSpec().summary, 'Organic cotton · Sage green · Large');
});
test('includes dimensions when present', () {
expect(
makeSpec(size: null, dimensions: '4" round').summary,
'Organic cotton · Sage green · 4" round',
);
});
test('includes both size and dimensions when both present', () {
expect(
makeSpec(size: 'Large', dimensions: '4" round').summary,
'Organic cotton · Sage green · Large · 4" round',
);
});
test('omits blank segments', () {
expect(makeSpec(size: ' ').summary, 'Organic cotton · Sage green');
});
test('never contains customer identity', () {
final spec = makeSpec(notes: 'For Nadia Okafor, nadia@example.com');
expect(spec.summary, isNot(contains('Nadia')));
expect(spec.summary, isNot(contains('@')));
});
});
// ── copyWith ──────────────────────────────────────────────────────────────
group('CustomOrderSpec — copyWith', () {
test('returns an equal object when no fields are supplied', () {
final spec = makeSpec();
expect(spec.copyWith(), spec);
});
test('replaces material', () {
expect(makeSpec().copyWith(material: 'Linen').material, 'Linen');
});
test('replaces color', () {
expect(makeSpec().copyWith(color: 'Rust').color, 'Rust');
});
test('replaces size', () {
expect(makeSpec().copyWith(size: 'Small').size, 'Small');
});
test('replaces dimensions', () {
expect(makeSpec().copyWith(dimensions: '2" x 2"').dimensions, '2" x 2"');
});
test('replaces referenceImageUrls', () {
final updated = makeSpec().copyWith(referenceImageUrls: const ['https://example.test/x.png']);
expect(updated.referenceImageUrls, ['https://example.test/x.png']);
});
test('replaces notes', () {
expect(makeSpec().copyWith(notes: 'Rush job').notes, 'Rush job');
});
test('replaces estimatedCompletionDate', () {
final updated = makeSpec().copyWith(estimatedCompletionDate: completion);
expect(updated.estimatedCompletionDate, completion);
});
test('leaves untouched fields intact', () {
final updated = makeSpec(notes: 'Original').copyWith(material: 'Linen');
expect(updated.color, 'Sage green');
expect(updated.size, 'Large');
expect(updated.notes, 'Original');
});
});
// ── Equality ──────────────────────────────────────────────────────────────
group('CustomOrderSpec — equality', () {
test('specs with identical fields are equal', () {
expect(makeSpec(), makeSpec());
});
test('identical specs share a hashCode', () {
expect(makeSpec().hashCode, makeSpec().hashCode);
});
test('differing material breaks equality', () {
expect(makeSpec(), isNot(makeSpec(material: 'Linen')));
});
test('differing color breaks equality', () {
expect(makeSpec(), isNot(makeSpec(color: 'Rust')));
});
test('differing size breaks equality', () {
expect(makeSpec(), isNot(makeSpec(size: 'Small')));
});
test('differing dimensions breaks equality', () {
expect(makeSpec(), isNot(makeSpec(dimensions: '2" x 2"')));
});
test('differing notes breaks equality', () {
expect(makeSpec(), isNot(makeSpec(notes: 'Rush')));
});
test('differing estimatedCompletionDate breaks equality', () {
expect(makeSpec(), isNot(makeSpec(estimatedCompletionDate: completion)));
});
test('reference image lists compare by value, not identity', () {
final a = makeSpec(referenceImageUrls: ['https://example.test/a.jpg']);
final b = makeSpec(referenceImageUrls: ['https://example.test/a.jpg']);
expect(a, b);
expect(a.hashCode, b.hashCode);
});
test('differing reference image order breaks equality', () {
final a = makeSpec(referenceImageUrls: const ['a', 'b']);
final b = makeSpec(referenceImageUrls: const ['b', 'a']);
expect(a, isNot(b));
});
test('differing reference image count breaks equality', () {
final a = makeSpec(referenceImageUrls: const ['a']);
final b = makeSpec(referenceImageUrls: const ['a', 'b']);
expect(a, isNot(b));
});
test('is not equal to an unrelated type', () {
expect(makeSpec(), isNot(equals('Organic cotton')));
});
test('is equal to itself', () {
final spec = makeSpec();
expect(spec, spec);
});
});
// ── toString ──────────────────────────────────────────────────────────────
group('CustomOrderSpec — toString', () {
test('includes material and color', () {
final text = makeSpec().toString();
expect(text, contains('Organic cotton'));
expect(text, contains('Sage green'));
});
test('reports the reference image count, not the URLs', () {
final text = makeSpec(referenceImageUrls: const ['https://secret.test/a.jpg']).toString();
expect(text, contains('referenceImages: 1'));
expect(text, isNot(contains('secret.test')));
});
test('includes the estimated completion date', () {
expect(
makeSpec(estimatedCompletionDate: completion).toString(),
contains(completion.toString()),
);
});
test('does not leak free-text notes', () {
final text = makeSpec(notes: 'Customer phone 828-555-0101').toString();
expect(text, isNot(contains('828-555-0101')));
});
});
}

View File

@ -0,0 +1,333 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final paidAt = DateTime.utc(2026, 4, 5, 10, 30);
final settledAt = DateTime.utc(2026, 5, 1, 16, 0);
DepositInfo makeDeposit({
int depositAmountCents = 2000,
int totalAmountCents = 6400,
DateTime? depositDate,
DateTime? settlementDate,
bool depositPaid = false,
}) {
return DepositInfo(
depositAmountCents: depositAmountCents,
totalAmountCents: totalAmountCents,
depositDate: depositDate,
settlementDate: settlementDate,
depositPaid: depositPaid,
);
}
// ── Construction ──────────────────────────────────────────────────────────
group('DepositInfo — construction', () {
test('retains amounts as integer cents', () {
final deposit = makeDeposit();
expect(deposit.depositAmountCents, 2000);
expect(deposit.totalAmountCents, 6400);
});
test('depositPaid defaults to false', () {
expect(makeDeposit().depositPaid, false);
});
test('dates default to null', () {
final deposit = makeDeposit();
expect(deposit.depositDate, isNull);
expect(deposit.settlementDate, isNull);
});
test('quoted constructor leaves the deposit unpaid', () {
const deposit = DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400);
expect(deposit.depositPaid, false);
expect(deposit.depositDate, isNull);
expect(deposit.settlementDate, isNull);
});
test('quoted constructor retains both amounts', () {
const deposit = DepositInfo.quoted(totalAmountCents: 7200, depositAmountCents: 2400);
expect(deposit.totalAmountCents, 7200);
expect(deposit.depositAmountCents, 2400);
});
});
// ── balanceDueCents ───────────────────────────────────────────────────────
group('DepositInfo — balanceDueCents', () {
test('is the full total before the deposit is paid', () {
expect(makeDeposit().balanceDueCents, 6400);
});
test('is total minus deposit once the deposit is paid', () {
expect(makeDeposit(depositPaid: true).balanceDueCents, 4400);
});
test('is zero once settled', () {
final deposit = makeDeposit(depositPaid: true, settlementDate: settledAt);
expect(deposit.balanceDueCents, 0);
});
test('is zero when settled even if the deposit was never marked paid', () {
expect(makeDeposit(settlementDate: settledAt).balanceDueCents, 0);
});
test('clamps to zero when the deposit exceeds the total', () {
final deposit = makeDeposit(depositAmountCents: 9000, depositPaid: true);
expect(deposit.balanceDueCents, 0);
});
test('is zero when the deposit exactly covers the total', () {
final deposit = makeDeposit(depositAmountCents: 6400, depositPaid: true);
expect(deposit.balanceDueCents, 0);
});
});
group('DepositInfo — derived flags', () {
test('isSettled is false without a settlement date', () {
expect(makeDeposit(depositPaid: true).isSettled, false);
});
test('isSettled is true with a settlement date', () {
expect(makeDeposit(settlementDate: settledAt).isSettled, true);
});
test('isOutstanding is true while a balance remains', () {
expect(makeDeposit(depositPaid: true).isOutstanding, true);
});
test('isOutstanding is false once settled', () {
expect(makeDeposit(settlementDate: settledAt).isOutstanding, false);
});
});
// ── Formatting ────────────────────────────────────────────────────────────
group('DepositInfo.formatCents', () {
test('formats whole dollars', () {
expect(DepositInfo.formatCents(4500), r'$45.00');
});
test('pads single-digit cents', () {
expect(DepositInfo.formatCents(4505), r'$45.05');
});
test('formats zero', () {
expect(DepositInfo.formatCents(0), r'$0.00');
});
test('formats amounts under a dollar', () {
expect(DepositInfo.formatCents(7), r'$0.07');
});
test('formats large amounts', () {
expect(DepositInfo.formatCents(1234567), r'$12345.67');
});
test('formats negative amounts with a leading sign', () {
expect(DepositInfo.formatCents(-250), r'-$2.50');
});
test('matches PaymentResult.amountFormatted for the same cents', () {
final payment = PaymentResult(
transactionId: 'txn-1',
amountCents: 4505,
method: PaymentMethod.cash,
completedAt: paidAt,
);
expect(DepositInfo.formatCents(4505), payment.amountFormatted);
});
});
group('DepositInfo — formatted getters', () {
test('depositFormatted renders the deposit amount', () {
expect(makeDeposit().depositFormatted, r'$20.00');
});
test('totalFormatted renders the total amount', () {
expect(makeDeposit().totalFormatted, r'$64.00');
});
test('balanceDueFormatted renders the outstanding balance', () {
expect(makeDeposit(depositPaid: true).balanceDueFormatted, r'$44.00');
});
});
// ── Transitions ───────────────────────────────────────────────────────────
group('DepositInfo — markDepositPaid', () {
test('sets depositPaid', () {
expect(makeDeposit().markDepositPaid(paidAt: paidAt).depositPaid, true);
});
test('sets the deposit date', () {
expect(makeDeposit().markDepositPaid(paidAt: paidAt).depositDate, paidAt);
});
test('keeps the agreed deposit amount when none is supplied', () {
expect(makeDeposit().markDepositPaid(paidAt: paidAt).depositAmountCents, 2000);
});
test('overrides the deposit amount when supplied', () {
final updated = makeDeposit().markDepositPaid(paidAt: paidAt, amountCents: 2500);
expect(updated.depositAmountCents, 2500);
});
test('recomputes the balance from the overridden amount', () {
final updated = makeDeposit().markDepositPaid(paidAt: paidAt, amountCents: 2500);
expect(updated.balanceDueCents, 3900);
});
test('leaves the total untouched', () {
expect(makeDeposit().markDepositPaid(paidAt: paidAt).totalAmountCents, 6400);
});
test('does not settle the order', () {
expect(makeDeposit().markDepositPaid(paidAt: paidAt).isSettled, false);
});
});
group('DepositInfo — markSettled', () {
test('sets the settlement date', () {
final updated = makeDeposit(depositPaid: true).markSettled(settledAt: settledAt);
expect(updated.settlementDate, settledAt);
});
test('zeroes the balance', () {
final updated = makeDeposit(depositPaid: true).markSettled(settledAt: settledAt);
expect(updated.balanceDueCents, 0);
});
test('reports isSettled', () {
final updated = makeDeposit(depositPaid: true).markSettled(settledAt: settledAt);
expect(updated.isSettled, true);
});
test('preserves the deposit date', () {
final updated = makeDeposit(
depositPaid: true,
depositDate: paidAt,
).markSettled(settledAt: settledAt);
expect(updated.depositDate, paidAt);
});
});
// ── copyWith ──────────────────────────────────────────────────────────────
group('DepositInfo — copyWith', () {
test('returns an equal object when no fields are supplied', () {
final deposit = makeDeposit();
expect(deposit.copyWith(), deposit);
});
test('replaces depositAmountCents', () {
expect(makeDeposit().copyWith(depositAmountCents: 3000).depositAmountCents, 3000);
});
test('replaces totalAmountCents', () {
expect(makeDeposit().copyWith(totalAmountCents: 9900).totalAmountCents, 9900);
});
test('replaces depositDate', () {
expect(makeDeposit().copyWith(depositDate: paidAt).depositDate, paidAt);
});
test('replaces settlementDate', () {
expect(makeDeposit().copyWith(settlementDate: settledAt).settlementDate, settledAt);
});
test('replaces depositPaid', () {
expect(makeDeposit().copyWith(depositPaid: true).depositPaid, true);
});
test('leaves untouched fields intact', () {
final updated = makeDeposit(depositDate: paidAt).copyWith(totalAmountCents: 9900);
expect(updated.depositAmountCents, 2000);
expect(updated.depositDate, paidAt);
});
});
// ── Equality ──────────────────────────────────────────────────────────────
group('DepositInfo — equality', () {
test('deposits with identical fields are equal', () {
expect(makeDeposit(), makeDeposit());
});
test('identical deposits share a hashCode', () {
expect(makeDeposit().hashCode, makeDeposit().hashCode);
});
test('differing depositAmountCents breaks equality', () {
expect(makeDeposit(), isNot(makeDeposit(depositAmountCents: 3000)));
});
test('differing totalAmountCents breaks equality', () {
expect(makeDeposit(), isNot(makeDeposit(totalAmountCents: 9900)));
});
test('differing depositDate breaks equality', () {
expect(makeDeposit(), isNot(makeDeposit(depositDate: paidAt)));
});
test('differing settlementDate breaks equality', () {
expect(makeDeposit(), isNot(makeDeposit(settlementDate: settledAt)));
});
test('differing depositPaid breaks equality', () {
expect(makeDeposit(), isNot(makeDeposit(depositPaid: true)));
});
test('is not equal to an unrelated type', () {
expect(makeDeposit(), isNot(equals(2000)));
});
test('is equal to itself', () {
final deposit = makeDeposit();
expect(deposit, deposit);
});
test('quoted matches an equivalent full constructor', () {
const quoted = DepositInfo.quoted(totalAmountCents: 6400, depositAmountCents: 2000);
expect(quoted, makeDeposit());
});
});
// ── toString ──────────────────────────────────────────────────────────────
group('DepositInfo — toString', () {
test('includes the formatted deposit and total', () {
final text = makeDeposit().toString();
expect(text, contains(r'$20.00'));
expect(text, contains(r'$64.00'));
});
test('includes the outstanding balance', () {
expect(makeDeposit(depositPaid: true).toString(), contains(r'$44.00'));
});
test('includes the paid flag', () {
expect(makeDeposit(depositPaid: true).toString(), contains('depositPaid: true'));
});
test('includes the deposit and settlement dates', () {
final text = makeDeposit(depositDate: paidAt, settlementDate: settledAt).toString();
expect(text, contains(paidAt.toString()));
expect(text, contains(settledAt.toString()));
});
});
// ── Privacy ───────────────────────────────────────────────────────────────
group('DepositInfo — privacy', () {
test('carries no payment identifier field', () {
// Enforced structurally: DepositInfo exposes only amounts, dates, and a
// paid flag. A transaction ID would be visible in toString().
final text = makeDeposit(depositPaid: true, depositDate: paidAt).toString();
expect(text, isNot(contains('transactionId')));
expect(text, isNot(contains('txn')));
});
});
}

View File

@ -0,0 +1,619 @@
import 'package:core/core.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late KcInMemoryLogSink sink;
late FakeOrdersRepository repository;
final paidAt = DateTime.utc(2026, 4, 10, 12, 0);
setUp(() {
sink = KcInMemoryLogSink();
repository = FakeOrdersRepository(
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
);
});
CustomOrderSpec spec({String material = 'Linen', String color = 'Ivory'}) =>
CustomOrderSpec(material: material, color: color, size: 'Medium');
Order newOrder({String id = 'KC-C9001'}) => Order(
id: id,
customerName: 'Test Customer',
customerEmail: 'test@example.com',
orderDate: DateTime(2026, 4, 10),
status: OrderStatus.pending,
items: const [
OrderItem(productName: 'Commission', sku: 'CUSTOM-9001', quantity: 1, unitPrice: 80.00),
],
shippingAddress: '1 Test St, Test City, TC 00000',
source: OrderSource.manual,
);
// ── Seeded data ───────────────────────────────────────────────────────────
group('FakeOrdersRepository — seeded custom orders', () {
test('getOrders returns eleven orders in total', () async {
expect((await repository.getOrders()).length, 11);
});
test('seeds five custom orders', () async {
final orders = await repository.getOrders();
expect(orders.where((o) => o.isCustom).length, 5);
});
test('seeds six standard orders', () async {
final orders = await repository.getOrders();
expect(orders.where((o) => o.orderType == OrderType.standard).length, 6);
});
test('seeded custom orders use the KC-C prefix', () async {
final orders = await repository.getOrders();
for (final order in orders.where((o) => o.isCustom)) {
expect(order.id, startsWith('KC-C'));
}
});
test('covers every non-terminal pipeline stage', () async {
final orders = await repository.getOrders();
final stages = orders.where((o) => o.isCustom).map((o) => o.fulfillmentStatus).toSet();
expect(
stages,
containsAll(<FulfillmentStatus>[
FulfillmentStatus.quoted,
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
]),
);
});
test('every custom order carries a specification', () async {
final orders = await repository.getOrders();
for (final order in orders.where((o) => o.isCustom)) {
expect(order.customOrderSpec, isNotNull, reason: '${order.id} has no spec');
}
});
test('every custom order carries deposit info', () async {
final orders = await repository.getOrders();
for (final order in orders.where((o) => o.isCustom)) {
expect(order.depositInfo, isNotNull, reason: '${order.id} has no deposit info');
}
});
test('standard orders carry no custom order fields', () async {
final orders = await repository.getOrders();
for (final order in orders.where((o) => !o.isCustom)) {
expect(order.customOrderSpec, isNull);
expect(order.depositInfo, isNull);
expect(order.fulfillmentStatus, isNull);
}
});
test('the quoted order has an unpaid deposit', () async {
final order = await repository.getOrder('KC-C2001');
expect(order!.fulfillmentStatus, FulfillmentStatus.quoted);
expect(order.hasDeposit, false);
});
test('orders past quoted have a paid deposit', () async {
final orders = await repository.getOrders();
final past = orders.where(
(o) => o.isCustom && o.fulfillmentStatus != FulfillmentStatus.quoted,
);
for (final order in past) {
expect(order.hasDeposit, true, reason: '${order.id} should have a paid deposit');
}
});
test('at least one seeded order carries reference images', () async {
final orders = await repository.getOrders();
final withImages = orders.where((o) => o.customOrderSpec?.hasReferenceImages ?? false);
expect(withImages, isNotEmpty);
});
test('getOrder resolves a seeded custom order by ID', () async {
final order = await repository.getOrder('KC-C2003');
expect(order, isNotNull);
expect(order!.isCustom, true);
expect(order.fulfillmentStatus, FulfillmentStatus.inProduction);
});
});
// ── createCustomOrder ─────────────────────────────────────────────────────
group('FakeOrdersRepository — createCustomOrder', () {
test('creates the order and returns it', () async {
final result = await repository.createCustomOrder(newOrder(), spec: spec());
expect(result.success, true);
expect(result.updatedOrder!.id, 'KC-C9001');
});
test('stamps the order as custom', () async {
final result = await repository.createCustomOrder(newOrder(), spec: spec());
expect(result.updatedOrder!.orderType, OrderType.custom);
expect(result.updatedOrder!.isCustom, true);
});
test('attaches the supplied specification', () async {
final result = await repository.createCustomOrder(newOrder(), spec: spec());
expect(result.updatedOrder!.customOrderSpec, spec());
});
test('starts the pipeline at quoted', () async {
final result = await repository.createCustomOrder(newOrder(), spec: spec());
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.quoted);
});
test('overrides an inconsistent fulfillment status supplied by the caller', () async {
final inconsistent = newOrder().copyWith(fulfillmentStatus: FulfillmentStatus.completed);
final result = await repository.createCustomOrder(inconsistent, spec: spec());
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.quoted);
});
test('overrides an inconsistent order type supplied by the caller', () async {
final inconsistent = newOrder().copyWith(orderType: OrderType.standard);
final result = await repository.createCustomOrder(inconsistent, spec: spec());
expect(result.updatedOrder!.orderType, OrderType.custom);
});
test('leaves depositInfo null when no deposit is supplied', () async {
final result = await repository.createCustomOrder(newOrder(), spec: spec());
expect(result.updatedOrder!.depositInfo, isNull);
});
test('attaches the supplied deposit', () async {
const deposit = DepositInfo.quoted(totalAmountCents: 8000, depositAmountCents: 2500);
final result = await repository.createCustomOrder(newOrder(), spec: spec(), deposit: deposit);
expect(result.updatedOrder!.depositInfo, deposit);
});
test('preserves the standard order fields', () async {
final result = await repository.createCustomOrder(newOrder(), spec: spec());
final created = result.updatedOrder!;
expect(created.customerName, 'Test Customer');
expect(created.customerEmail, 'test@example.com');
expect(created.status, OrderStatus.pending);
expect(created.source, OrderSource.manual);
expect(created.items.length, 1);
});
test('the new order appears in subsequent reads', () async {
await repository.createCustomOrder(newOrder(), spec: spec());
final orders = await repository.getOrders();
expect(orders.length, 12);
expect(orders.any((o) => o.id == 'KC-C9001'), true);
});
test('rejects a duplicate order ID', () async {
final result = await repository.createCustomOrder(newOrder(id: 'KC-1001'), spec: spec());
expect(result.success, false);
expect(result.errorMessage, contains('already exists'));
});
test('a duplicate rejection does not alter the existing order', () async {
await repository.createCustomOrder(newOrder(id: 'KC-1001'), spec: spec());
final existing = await repository.getOrder('KC-1001');
expect(existing!.isCustom, false);
expect(existing.customerName, 'Sarah Mitchell');
});
test('a duplicate rejection is logged', () async {
await repository.createCustomOrder(newOrder(id: 'KC-1001'), spec: spec());
final records = sink.recordsAtLeast(KcLogLevel.warning);
expect(records, isNotEmpty);
expect(records.last.context['operation'], 'createCustomOrder');
expect(records.last.context['orderId'], 'KC-1001');
});
test('a successful creation is not logged as a failure', () async {
await repository.createCustomOrder(newOrder(), spec: spec());
expect(sink.recordsAtLeast(KcLogLevel.warning), isEmpty);
});
});
// ── updateCustomOrderSpec ─────────────────────────────────────────────────
group('FakeOrdersRepository — updateCustomOrderSpec', () {
test('replaces the specification', () async {
final revised = spec(material: 'Wool', color: 'Slate');
final result = await repository.updateCustomOrderSpec('KC-C2001', revised);
expect(result.success, true);
expect(result.updatedOrder!.customOrderSpec, revised);
});
test('persists across subsequent reads', () async {
final revised = spec(material: 'Wool', color: 'Slate');
await repository.updateCustomOrderSpec('KC-C2001', revised);
final order = await repository.getOrder('KC-C2001');
expect(order!.customOrderSpec, revised);
});
test('leaves the fulfillment stage untouched', () async {
await repository.updateCustomOrderSpec('KC-C2003', spec());
final order = await repository.getOrder('KC-C2003');
expect(order!.fulfillmentStatus, FulfillmentStatus.inProduction);
});
test('leaves the deposit untouched', () async {
final before = await repository.getOrder('KC-C2003');
await repository.updateCustomOrderSpec('KC-C2003', spec());
final after = await repository.getOrder('KC-C2003');
expect(after!.depositInfo, before!.depositInfo);
});
test('rejects an unknown order ID', () async {
final result = await repository.updateCustomOrderSpec('UNKNOWN', spec());
expect(result.success, false);
expect(result.errorMessage, contains('not found'));
});
test('rejects a standard order', () async {
final result = await repository.updateCustomOrderSpec('KC-1001', spec());
expect(result.success, false);
expect(result.errorMessage, contains('not a custom order'));
});
test('a rejected standard order is left unchanged', () async {
await repository.updateCustomOrderSpec('KC-1001', spec());
final order = await repository.getOrder('KC-1001');
expect(order!.customOrderSpec, isNull);
expect(order.orderType, OrderType.standard);
});
test('rejections are logged with the operation and order ID', () async {
await repository.updateCustomOrderSpec('KC-1001', spec());
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.context['operation'], 'updateCustomOrderSpec');
expect(record.context['orderId'], 'KC-1001');
expect(record.context['orderType'], 'standard');
});
});
// ── recordDeposit ─────────────────────────────────────────────────────────
group('FakeOrdersRepository — recordDeposit', () {
test('marks the deposit paid', () async {
final result = await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
expect(result.success, true);
expect(result.updatedOrder!.depositInfo!.depositPaid, true);
});
test('records the payment date', () async {
final result = await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
expect(result.updatedOrder!.depositInfo!.depositDate, paidAt);
});
test('records the collected amount', () async {
final result = await repository.recordDeposit('KC-C2001', amountCents: 3000, paidAt: paidAt);
expect(result.updatedOrder!.depositInfo!.depositAmountCents, 3000);
});
test('recomputes the balance due', () async {
// KC-C2001 is quoted at 7200 with a 2400 deposit.
final result = await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
expect(result.updatedOrder!.balanceDueCents, 4800);
});
test('revises the total when supplied', () async {
final result = await repository.recordDeposit(
'KC-C2001',
amountCents: 2400,
paidAt: paidAt,
totalAmountCents: 9000,
);
expect(result.updatedOrder!.depositInfo!.totalAmountCents, 9000);
expect(result.updatedOrder!.balanceDueCents, 6600);
});
test('persists across subsequent reads', () async {
await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
final order = await repository.getOrder('KC-C2001');
expect(order!.hasDeposit, true);
});
test('leaves the fulfillment stage untouched', () async {
await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
final order = await repository.getOrder('KC-C2001');
expect(order!.fulfillmentStatus, FulfillmentStatus.quoted);
});
test('creates a deposit record when the order has none', () async {
await repository.createCustomOrder(newOrder(), spec: spec());
final result = await repository.recordDeposit(
'KC-C9001',
amountCents: 2000,
paidAt: paidAt,
totalAmountCents: 8000,
);
expect(result.success, true);
expect(result.updatedOrder!.depositInfo!.totalAmountCents, 8000);
expect(result.updatedOrder!.depositInfo!.depositAmountCents, 2000);
});
test('rejects a zero amount', () async {
final result = await repository.recordDeposit('KC-C2001', amountCents: 0, paidAt: paidAt);
expect(result.success, false);
expect(result.errorMessage, contains('greater than zero'));
});
test('rejects a negative amount', () async {
final result = await repository.recordDeposit('KC-C2001', amountCents: -500, paidAt: paidAt);
expect(result.success, false);
});
test('rejects an unknown order ID', () async {
final result = await repository.recordDeposit('UNKNOWN', amountCents: 100, paidAt: paidAt);
expect(result.success, false);
expect(result.errorMessage, contains('not found'));
});
test('rejects a standard order', () async {
final result = await repository.recordDeposit('KC-1001', amountCents: 100, paidAt: paidAt);
expect(result.success, false);
expect(result.errorMessage, contains('not a custom order'));
});
test('rejects a second deposit on the same order', () async {
final result = await repository.recordDeposit('KC-C2002', amountCents: 1000, paidAt: paidAt);
expect(result.success, false);
expect(result.errorMessage, contains('already been recorded'));
});
test('a duplicate deposit leaves the original untouched', () async {
final before = await repository.getOrder('KC-C2002');
await repository.recordDeposit('KC-C2002', amountCents: 1000, paidAt: paidAt);
final after = await repository.getOrder('KC-C2002');
expect(after!.depositInfo, before!.depositInfo);
});
test('rejections are logged without the money amount', () async {
await repository.recordDeposit('KC-C2002', amountCents: 1000, paidAt: paidAt);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.context['operation'], 'recordDeposit');
expect(record.context['orderId'], 'KC-C2002');
expect(record.context.containsKey('amountCents'), false);
expect(record.format(), isNot(contains('1000')));
});
test('a successful deposit is not logged as a failure', () async {
await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
expect(sink.recordsAtLeast(KcLogLevel.warning), isEmpty);
});
});
// ── advanceFulfillmentStatus ──────────────────────────────────────────────
group('FakeOrdersRepository — advanceFulfillmentStatus (legal)', () {
test('quoted → depositReceived succeeds', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
);
expect(result.success, true);
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.depositReceived);
});
test('depositReceived → inProduction succeeds', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2002',
FulfillmentStatus.inProduction,
);
expect(result.success, true);
});
test('inProduction → qualityCheck succeeds', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2003',
FulfillmentStatus.qualityCheck,
);
expect(result.success, true);
});
test('qualityCheck → readyForPickup succeeds', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2004',
FulfillmentStatus.readyForPickup,
);
expect(result.success, true);
});
test('qualityCheck → inProduction rework succeeds', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2004',
FulfillmentStatus.inProduction,
);
expect(result.success, true);
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.inProduction);
});
test('readyForPickup → completed succeeds', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2005',
FulfillmentStatus.completed,
);
expect(result.success, true);
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.completed);
});
test('any non-terminal stage can be cancelled', () async {
for (final id in ['KC-C2001', 'KC-C2002', 'KC-C2003', 'KC-C2004', 'KC-C2005']) {
final result = await repository.advanceFulfillmentStatus(id, FulfillmentStatus.cancelled);
expect(result.success, true, reason: '$id should be cancellable');
}
});
test('the full pipeline can be walked end to end', () async {
await repository.createCustomOrder(newOrder(), spec: spec());
for (final next in [
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
FulfillmentStatus.completed,
]) {
final result = await repository.advanceFulfillmentStatus('KC-C9001', next);
expect(result.success, true, reason: 'advance to ${next.name} should succeed');
}
final order = await repository.getOrder('KC-C9001');
expect(order!.fulfillmentStatus, FulfillmentStatus.completed);
});
test('persists across subsequent reads', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
final order = await repository.getOrder('KC-C2001');
expect(order!.fulfillmentStatus, FulfillmentStatus.depositReceived);
});
test('leaves the specification untouched', () async {
final before = await repository.getOrder('KC-C2001');
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
final after = await repository.getOrder('KC-C2001');
expect(after!.customOrderSpec, before!.customOrderSpec);
});
test('a legal advance is not logged as a failure', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
expect(sink.recordsAtLeast(KcLogLevel.warning), isEmpty);
});
});
group('FakeOrdersRepository — advanceFulfillmentStatus (illegal)', () {
test('quoted cannot skip to inProduction', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.inProduction,
);
expect(result.success, false);
expect(result.errorMessage, contains('Cannot move from quoted'));
});
test('quoted cannot skip to completed', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.completed,
);
expect(result.success, false);
});
test('depositReceived cannot skip to readyForPickup', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2002',
FulfillmentStatus.readyForPickup,
);
expect(result.success, false);
});
test('inProduction cannot skip to readyForPickup', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2003',
FulfillmentStatus.readyForPickup,
);
expect(result.success, false);
});
test('a stage cannot advance to itself', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.quoted,
);
expect(result.success, false);
expect(result.errorMessage, contains('already'));
});
test('a completed order cannot be reopened', () async {
await repository.advanceFulfillmentStatus('KC-C2005', FulfillmentStatus.completed);
final result = await repository.advanceFulfillmentStatus(
'KC-C2005',
FulfillmentStatus.inProduction,
);
expect(result.success, false);
expect(result.errorMessage, contains('already completed'));
});
test('a cancelled order cannot be resumed', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.cancelled);
final result = await repository.advanceFulfillmentStatus(
'KC-C2001',
FulfillmentStatus.depositReceived,
);
expect(result.success, false);
expect(result.errorMessage, contains('already cancelled'));
});
test('an illegal advance leaves the stage unchanged', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
final order = await repository.getOrder('KC-C2001');
expect(order!.fulfillmentStatus, FulfillmentStatus.quoted);
});
test('an illegal advance leaves the rest of the order unchanged', () async {
final before = await repository.getOrder('KC-C2001');
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
final after = await repository.getOrder('KC-C2001');
expect(after!.customOrderSpec, before!.customOrderSpec);
expect(after.depositInfo, before.depositInfo);
expect(after.status, before.status);
});
test('rejects an unknown order ID', () async {
final result = await repository.advanceFulfillmentStatus(
'UNKNOWN',
FulfillmentStatus.inProduction,
);
expect(result.success, false);
expect(result.errorMessage, contains('not found'));
});
test('rejects a standard order', () async {
final result = await repository.advanceFulfillmentStatus(
'KC-1001',
FulfillmentStatus.inProduction,
);
expect(result.success, false);
expect(result.errorMessage, contains('not a custom order'));
});
test('an illegal advance is logged with both stages', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.context['operation'], 'advanceFulfillmentStatus');
expect(record.context['orderId'], 'KC-C2001');
expect(record.context['fromStage'], 'quoted');
expect(record.context['toStage'], 'completed');
});
test('log records carry no customer identity', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.format(), isNot(contains('Nadia')));
expect(record.format(), isNot(contains('@')));
});
});
// ── Observability defaults ────────────────────────────────────────────────
group('FakeOrdersRepository — observability', () {
test('defaults to a silent logger when none is injected', () async {
final silent = FakeOrdersRepository();
final result = await silent.advanceFulfillmentStatus('UNKNOWN', FulfillmentStatus.completed);
// A missing logger must never turn a rejection into a crash.
expect(result.success, false);
});
test('tags records with the repository source', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.source, 'FakeOrdersRepository');
});
test('records rejections at warning severity, not error', () async {
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.level, KcLogLevel.warning);
});
});
}

View File

@ -10,9 +10,16 @@ void main() {
}); });
group('FakeOrdersRepository — read', () { group('FakeOrdersRepository — read', () {
test('getOrders returns six sample orders', () async { test('getOrders returns eleven sample orders', () async {
// Six standard orders (KC-1001…KC-1006) plus five seeded custom orders
// (KC-C2001…KC-C2005) spread across the fulfillment pipeline.
final orders = await repository.getOrders(); final orders = await repository.getOrders();
expect(orders.length, 6); expect(orders.length, 11);
});
test('getOrders returns six standard orders', () async {
final orders = await repository.getOrders();
expect(orders.where((o) => o.orderType == OrderType.standard).length, 6);
}); });
test('getOrders returns orders with expected IDs', () async { test('getOrders returns orders with expected IDs', () async {
@ -172,7 +179,7 @@ void main() {
await repository.createOrder(newOrder); await repository.createOrder(newOrder);
final orders = await repository.getOrders(); final orders = await repository.getOrders();
expect(orders.length, 7); expect(orders.length, 12);
expect(orders.any((o) => o.id == 'KC-8888'), true); expect(orders.any((o) => o.id == 'KC-8888'), true);
}); });

View File

@ -0,0 +1,361 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
/// Every legal transition in the custom order pipeline.
///
/// Any `(from, to)` pair absent from this set must be rejected.
const _legalTransitions = <(FulfillmentStatus, FulfillmentStatus)>{
(FulfillmentStatus.quoted, FulfillmentStatus.depositReceived),
(FulfillmentStatus.quoted, FulfillmentStatus.cancelled),
(FulfillmentStatus.depositReceived, FulfillmentStatus.inProduction),
(FulfillmentStatus.depositReceived, FulfillmentStatus.cancelled),
(FulfillmentStatus.inProduction, FulfillmentStatus.qualityCheck),
(FulfillmentStatus.inProduction, FulfillmentStatus.cancelled),
(FulfillmentStatus.qualityCheck, FulfillmentStatus.readyForPickup),
(FulfillmentStatus.qualityCheck, FulfillmentStatus.inProduction),
(FulfillmentStatus.qualityCheck, FulfillmentStatus.cancelled),
(FulfillmentStatus.readyForPickup, FulfillmentStatus.completed),
(FulfillmentStatus.readyForPickup, FulfillmentStatus.cancelled),
};
void main() {
// ── Enum shape ────────────────────────────────────────────────────────────
group('FulfillmentStatus — enum shape', () {
test('has seven values', () {
expect(FulfillmentStatus.values.length, 7);
});
test('exposes every pipeline stage named in the brief', () {
expect(
FulfillmentStatus.values,
containsAll(<FulfillmentStatus>[
FulfillmentStatus.quoted,
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
FulfillmentStatus.completed,
]),
);
});
test('exposes cancelled', () {
expect(FulfillmentStatus.values, contains(FulfillmentStatus.cancelled));
});
test('every stage has a non-empty label', () {
for (final status in FulfillmentStatus.values) {
expect(status.label, isNotEmpty, reason: '${status.name} has no label');
}
});
test('labels are human readable', () {
expect(FulfillmentStatus.quoted.label, 'Quoted');
expect(FulfillmentStatus.depositReceived.label, 'Deposit Received');
expect(FulfillmentStatus.inProduction.label, 'In Production');
expect(FulfillmentStatus.qualityCheck.label, 'Quality Check');
expect(FulfillmentStatus.readyForPickup.label, 'Ready for Pickup');
expect(FulfillmentStatus.completed.label, 'Completed');
expect(FulfillmentStatus.cancelled.label, 'Cancelled');
});
test('linear stages are indexed in pipeline order', () {
expect(FulfillmentStatus.quoted.stageIndex, 0);
expect(FulfillmentStatus.depositReceived.stageIndex, 1);
expect(FulfillmentStatus.inProduction.stageIndex, 2);
expect(FulfillmentStatus.qualityCheck.stageIndex, 3);
expect(FulfillmentStatus.readyForPickup.stageIndex, 4);
expect(FulfillmentStatus.completed.stageIndex, 5);
});
test('cancelled sits outside the linear pipeline', () {
expect(FulfillmentStatus.cancelled.stageIndex, -1);
});
test('pipeline excludes cancelled', () {
expect(FulfillmentStatus.pipeline, isNot(contains(FulfillmentStatus.cancelled)));
});
test('pipeline has six stages', () {
expect(FulfillmentStatus.pipeline.length, 6);
expect(FulfillmentStatus.pipelineLength, 6);
});
test('pipeline is ordered by stage index', () {
final indices = FulfillmentStatus.pipeline.map((s) => s.stageIndex).toList();
expect(indices, [0, 1, 2, 3, 4, 5]);
});
test('pipeline is unmodifiable', () {
expect(
() => FulfillmentStatus.pipeline.add(FulfillmentStatus.cancelled),
throwsUnsupportedError,
);
});
});
// ── Wire format ───────────────────────────────────────────────────────────
group('FulfillmentStatus — wire format', () {
test('wireValue matches the enum name', () {
for (final status in FulfillmentStatus.values) {
expect(status.wireValue, status.name);
}
});
test('fromWire round-trips every value', () {
for (final status in FulfillmentStatus.values) {
expect(FulfillmentStatus.fromWire(status.wireValue), status);
}
});
test('fromWire defaults to quoted for null', () {
expect(FulfillmentStatus.fromWire(null), FulfillmentStatus.quoted);
});
test('fromWire defaults to quoted for an unknown value', () {
expect(FulfillmentStatus.fromWire('shipped'), FulfillmentStatus.quoted);
});
test('fromWire defaults to quoted for an empty string', () {
expect(FulfillmentStatus.fromWire(''), FulfillmentStatus.quoted);
});
});
// ── Stage predicates ──────────────────────────────────────────────────────
group('FulfillmentTransition — stage predicates', () {
test('completed is terminal', () {
expect(FulfillmentStatus.completed.isTerminal, true);
});
test('cancelled is terminal', () {
expect(FulfillmentStatus.cancelled.isTerminal, true);
});
test('every in-flight stage is non-terminal', () {
for (final status in [
FulfillmentStatus.quoted,
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
]) {
expect(status.isTerminal, false, reason: '${status.name} should not be terminal');
}
});
test('only completed is successful', () {
for (final status in FulfillmentStatus.values) {
expect(status.isSuccessful, status == FulfillmentStatus.completed);
}
});
test('quoted does not require a deposit', () {
expect(FulfillmentStatus.quoted.requiresDeposit, false);
});
test('cancelled does not require a deposit', () {
expect(FulfillmentStatus.cancelled.requiresDeposit, false);
});
test('every stage from depositReceived onward requires a deposit', () {
for (final status in [
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
FulfillmentStatus.completed,
]) {
expect(status.requiresDeposit, true, reason: '${status.name} should require a deposit');
}
});
});
// ── Legal transitions ─────────────────────────────────────────────────────
group('FulfillmentTransition — legal transitions', () {
for (final (from, to) in _legalTransitions) {
test('${from.name} → ${to.name} is allowed', () {
expect(from.canTransitionTo(to), true);
});
test('${from.name} → ${to.name} has no rejection reason', () {
expect(from.rejectionReason(to), isNull);
});
test('${from.name} lists ${to.name} among allowed next statuses', () {
expect(from.allowedNextStatuses, contains(to));
});
}
test('the happy path runs end to end', () {
var current = FulfillmentStatus.quoted;
for (final next in [
FulfillmentStatus.depositReceived,
FulfillmentStatus.inProduction,
FulfillmentStatus.qualityCheck,
FulfillmentStatus.readyForPickup,
FulfillmentStatus.completed,
]) {
expect(
current.canTransitionTo(next),
true,
reason: '${current.name} → ${next.name} should be legal',
);
current = next;
}
expect(current, FulfillmentStatus.completed);
});
test('qualityCheck can send the piece back for rework', () {
expect(FulfillmentStatus.qualityCheck.canTransitionTo(FulfillmentStatus.inProduction), true);
});
test('every non-terminal stage can be cancelled', () {
for (final status in FulfillmentStatus.values.where((s) => !s.isTerminal)) {
expect(
status.canTransitionTo(FulfillmentStatus.cancelled),
true,
reason: '${status.name} should be cancellable',
);
}
});
});
// ── Illegal transitions ───────────────────────────────────────────────────
group('FulfillmentTransition — illegal transitions', () {
// Exhaustive 7 × 7 matrix: every pair not in _legalTransitions is rejected.
for (final from in FulfillmentStatus.values) {
for (final to in FulfillmentStatus.values) {
if (_legalTransitions.contains((from, to))) continue;
test('${from.name} → ${to.name} is rejected', () {
expect(from.canTransitionTo(to), false);
});
test('${from.name} → ${to.name} explains the rejection', () {
final reason = from.rejectionReason(to);
expect(reason, isNotNull);
expect(reason, isNotEmpty);
});
}
}
test('quoted cannot skip straight to inProduction', () {
expect(FulfillmentStatus.quoted.canTransitionTo(FulfillmentStatus.inProduction), false);
});
test('quoted cannot skip straight to completed', () {
expect(FulfillmentStatus.quoted.canTransitionTo(FulfillmentStatus.completed), false);
});
test('depositReceived cannot skip qualityCheck', () {
expect(
FulfillmentStatus.depositReceived.canTransitionTo(FulfillmentStatus.readyForPickup),
false,
);
});
test('inProduction cannot skip to readyForPickup', () {
expect(
FulfillmentStatus.inProduction.canTransitionTo(FulfillmentStatus.readyForPickup),
false,
);
});
test('completed cannot be reopened', () {
for (final to in FulfillmentStatus.values) {
expect(
FulfillmentStatus.completed.canTransitionTo(to),
false,
reason: 'completed → ${to.name} must be rejected',
);
}
});
test('cancelled cannot be resumed', () {
for (final to in FulfillmentStatus.values) {
expect(
FulfillmentStatus.cancelled.canTransitionTo(to),
false,
reason: 'cancelled → ${to.name} must be rejected',
);
}
});
test('a stage cannot transition to itself', () {
for (final status in FulfillmentStatus.values) {
expect(
status.canTransitionTo(status),
false,
reason: '${status.name} → itself must be rejected',
);
}
});
test('backwards moves other than qualityCheck rework are rejected', () {
expect(
FulfillmentStatus.inProduction.canTransitionTo(FulfillmentStatus.depositReceived),
false,
);
expect(
FulfillmentStatus.readyForPickup.canTransitionTo(FulfillmentStatus.qualityCheck),
false,
);
expect(FulfillmentStatus.depositReceived.canTransitionTo(FulfillmentStatus.quoted), false);
});
test('terminal stages expose no allowed next statuses', () {
expect(FulfillmentStatus.completed.allowedNextStatuses, isEmpty);
expect(FulfillmentStatus.cancelled.allowedNextStatuses, isEmpty);
});
});
// ── Rejection messages ────────────────────────────────────────────────────
group('FulfillmentTransition — rejectionReason', () {
test('a same-stage move reports the order is already at that stage', () {
final reason = FulfillmentStatus.inProduction.rejectionReason(FulfillmentStatus.inProduction);
expect(reason, contains('already'));
expect(reason, contains('in production'));
});
test('a move from completed reports the terminal stage', () {
final reason = FulfillmentStatus.completed.rejectionReason(FulfillmentStatus.inProduction);
expect(reason, contains('already'));
expect(reason, contains('completed'));
});
test('a move from cancelled reports the terminal stage', () {
final reason = FulfillmentStatus.cancelled.rejectionReason(FulfillmentStatus.inProduction);
expect(reason, contains('cancelled'));
});
test('an out-of-order move lists the allowed next stages', () {
final reason = FulfillmentStatus.quoted.rejectionReason(FulfillmentStatus.completed);
expect(reason, contains('Allowed next'));
expect(reason, contains('Deposit Received'));
expect(reason, contains('Cancelled'));
});
test('an out-of-order move names both stages', () {
final reason = FulfillmentStatus.quoted.rejectionReason(FulfillmentStatus.completed);
expect(reason, contains('quoted'));
expect(reason, contains('completed'));
});
test('messages carry no customer or money detail', () {
for (final from in FulfillmentStatus.values) {
for (final to in FulfillmentStatus.values) {
final reason = from.rejectionReason(to);
if (reason == null) continue;
expect(reason, isNot(contains(r'$')));
expect(reason, isNot(contains('@')));
}
}
});
});
}

View File

@ -0,0 +1,60 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('OrderType', () {
test('has exactly two values', () {
expect(OrderType.values.length, 2);
});
test('exposes standard and custom', () {
expect(OrderType.values, contains(OrderType.standard));
expect(OrderType.values, contains(OrderType.custom));
});
test('standard has a human-readable label', () {
expect(OrderType.standard.label, 'Standard');
});
test('custom has a human-readable label', () {
expect(OrderType.custom.label, 'Custom');
});
test('wireValue matches the enum name', () {
expect(OrderType.standard.wireValue, 'standard');
expect(OrderType.custom.wireValue, 'custom');
});
});
group('OrderType.fromWire', () {
test('parses standard', () {
expect(OrderType.fromWire('standard'), OrderType.standard);
});
test('parses custom', () {
expect(OrderType.fromWire('custom'), OrderType.custom);
});
test('defaults to standard for null', () {
expect(OrderType.fromWire(null), OrderType.standard);
});
test('defaults to standard for an unknown value', () {
expect(OrderType.fromWire('bespoke'), OrderType.standard);
});
test('defaults to standard for an empty string', () {
expect(OrderType.fromWire(''), OrderType.standard);
});
test('is case sensitive — Custom does not parse', () {
expect(OrderType.fromWire('Custom'), OrderType.standard);
});
test('round-trips every value through wireValue', () {
for (final type in OrderType.values) {
expect(OrderType.fromWire(type.wireValue), type);
}
});
});
}

View File

@ -38,7 +38,8 @@ void main() {
await controller.load(); await controller.load();
expect(controller.isLoading, false); expect(controller.isLoading, false);
expect(controller.orders.length, 6); // Six standard orders plus five seeded custom orders.
expect(controller.orders.length, 11);
expect(controller.selectedOrder, isNotNull); expect(controller.selectedOrder, isNotNull);
expect(controller.selectedOrder!.id, 'KC-1001'); expect(controller.selectedOrder!.id, 'KC-1001');
expect(controller.error, isNull); expect(controller.error, isNull);
@ -56,8 +57,9 @@ void main() {
test('setFilter filters by order status', () async { test('setFilter filters by order status', () async {
await controller.load(); await controller.load();
// KC-1004, KC-1006, and the quoted custom order KC-C2001 are pending.
controller.setFilter('pending'); controller.setFilter('pending');
expect(controller.orders.length, 2); expect(controller.orders.length, 3);
expect(controller.orders.every((o) => o.status == OrderStatus.pending), true); expect(controller.orders.every((o) => o.status == OrderStatus.pending), true);
}); });
@ -68,7 +70,7 @@ void main() {
expect(controller.orders.length, 1); expect(controller.orders.length, 1);
controller.setFilter(null); controller.setFilter(null);
expect(controller.orders.length, 6); expect(controller.orders.length, 11);
}); });
test('setSearchQuery filters by customer name', () async { test('setSearchQuery filters by customer name', () async {
@ -151,16 +153,16 @@ void main() {
test('reloads order list after status update', () async { test('reloads order list after status update', () async {
await controller.load(); await controller.load();
// Initially 2 pending orders. // Initially 3 pending orders (KC-1004, KC-1006, KC-C2001).
controller.setFilter('pending'); controller.setFilter('pending');
expect(controller.orders.length, 2); expect(controller.orders.length, 3);
controller.setFilter(null); controller.setFilter(null);
await controller.updateOrderStatus('KC-1004', OrderStatus.processing); await controller.updateOrderStatus('KC-1004', OrderStatus.processing);
// Now only 1 pending order. // Now only 2 pending orders.
controller.setFilter('pending'); controller.setFilter('pending');
expect(controller.orders.length, 1); expect(controller.orders.length, 2);
}); });
test('sets failure result for unknown order ID', () async { test('sets failure result for unknown order ID', () async {
@ -253,7 +255,7 @@ void main() {
group('OrdersController — createOrder', () { group('OrdersController — createOrder', () {
test('creates order and adds it to the list', () async { test('creates order and adds it to the list', () async {
await controller.load(); await controller.load();
expect(controller.orders.length, 6); expect(controller.orders.length, 11);
final newOrder = Order( final newOrder = Order(
id: 'KC-9999', id: 'KC-9999',
@ -270,7 +272,7 @@ void main() {
await controller.createOrder(newOrder); await controller.createOrder(newOrder);
expect(controller.lastActionResult!.success, true); expect(controller.lastActionResult!.success, true);
expect(controller.orders.length, 7); expect(controller.orders.length, 12);
}); });
test('new order appears in list after creation', () async { test('new order appears in list after creation', () async {
@ -330,7 +332,7 @@ void main() {
await readOnlyController.createOrder(newOrder); await readOnlyController.createOrder(newOrder);
expect(readOnlyController.lastActionResult, isNull); expect(readOnlyController.lastActionResult, isNull);
expect(readOnlyController.orders.length, 6); expect(readOnlyController.orders.length, 11);
readOnlyController.dispose(); readOnlyController.dispose();
}); });
}); });
@ -360,13 +362,13 @@ void main() {
test('active filter is preserved after status update', () async { test('active filter is preserved after status update', () async {
await controller.load(); await controller.load();
controller.setFilter('pending'); controller.setFilter('pending');
expect(controller.orders.length, 2); expect(controller.orders.length, 3);
await controller.updateOrderStatus('KC-1004', OrderStatus.processing); await controller.updateOrderStatus('KC-1004', OrderStatus.processing);
// Filter should still be active; now only 1 pending order. // Filter should still be active; now only 2 pending orders.
expect(controller.activeFilter, 'pending'); expect(controller.activeFilter, 'pending');
expect(controller.orders.length, 1); expect(controller.orders.length, 2);
}); });
test('search query is preserved after note addition', () async { test('search query is preserved after note addition', () async {

View File

@ -78,6 +78,29 @@ class _EmptyOrdersRepository implements OrdersRepository {
@override @override
Future<OrderActionResult> createOrder(Order order) async => Future<OrderActionResult> createOrder(Order order) async =>
const OrderActionResult.failure('Not implemented.'); const OrderActionResult.failure('Not implemented.');
@override
Future<OrderActionResult> createCustomOrder(
Order order, {
required CustomOrderSpec spec,
DepositInfo? deposit,
}) async => const OrderActionResult.failure('Not implemented.');
@override
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) async =>
const OrderActionResult.failure('Not implemented.');
@override
Future<OrderActionResult> recordDeposit(
String id, {
required int amountCents,
required DateTime paidAt,
int? totalAmountCents,
}) async => const OrderActionResult.failure('Not implemented.');
@override
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async =>
const OrderActionResult.failure('Not implemented.');
} }
// ── Tests ────────────────────────────────────────────────────────────────────── // ── Tests ──────────────────────────────────────────────────────────────────────
@ -290,7 +313,8 @@ void main() {
testWidgets('shows order count', (tester) async { testWidgets('shows order count', (tester) async {
await tester.pumpWidget(_buildPage(FakeOrdersRepository())); await tester.pumpWidget(_buildPage(FakeOrdersRepository()));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('6 orders'), findsOneWidget); // Six standard orders plus five seeded custom orders.
expect(find.text('11 orders'), findsOneWidget);
}); });
testWidgets('shows empty state when repository returns no orders', (tester) async { testWidgets('shows empty state when repository returns no orders', (tester) async {

View File

@ -0,0 +1,984 @@
import 'dart:convert';
import 'package:core/core.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const _mapper = WooCommerceOrderMapper();
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Builds WooCommerce order JSON, optionally carrying custom order meta.
Map<String, dynamic> orderJson({
String id = '3001',
String status = 'processing',
List<Map<String, dynamic>> metaData = const [],
}) {
return {
'id': int.parse(id),
'status': status,
'date_created': '2026-04-06T09:00:00',
'customer_note': '',
'payment_method_title': 'Credit Card',
'billing': const {
'first_name': 'Nadia',
'last_name': 'Okafor',
'email': 'nadia@example.com',
'phone': '828-555-0701',
'address_1': '15 Riverside Way',
'city': 'Asheville',
'state': 'NC',
'postcode': '28801',
'country': 'US',
},
'shipping': const {'address_1': '15 Riverside Way', 'city': 'Asheville', 'country': 'US'},
'line_items': const [
{
'name': 'Custom Bowl Cozy Set',
'sku': 'CUSTOM-BC-2001',
'quantity': 4,
'price': '18.00',
'product_id': 0,
'variation_id': 0,
'meta_data': <Map<String, dynamic>>[],
},
],
'meta_data': metaData,
};
}
/// The full custom-order meta set for a `depositReceived` commission.
List<Map<String, dynamic>> customMeta() => [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'depositReceived'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Organic cotton'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Sage green'},
{'key': WooCommerceOrderMapper.metaSpecSize, 'value': 'Large'},
{'key': WooCommerceOrderMapper.metaSpecDimensions, 'value': '8" round'},
{
'key': WooCommerceOrderMapper.metaSpecReferenceImages,
'value': 'https://cdn.test/a.jpg\nhttps://cdn.test/b.jpg',
},
{'key': WooCommerceOrderMapper.metaSpecNotes, 'value': 'Housewarming gift.'},
{'key': WooCommerceOrderMapper.metaSpecEstimatedCompletion, 'value': '2026-05-01T00:00:00.000'},
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': '2400'},
{'key': WooCommerceOrderMapper.metaDepositTotalCents, 'value': '7200'},
{'key': WooCommerceOrderMapper.metaDepositPaid, 'value': 'true'},
{'key': WooCommerceOrderMapper.metaDepositDate, 'value': '2026-04-06T09:00:00.000'},
];
WooCommerceOrdersRepository repoWith(MockClient client, {KcLogger? logger}) {
return WooCommerceOrdersRepository(
apiClient: WooCommerceApiClient(
siteUrl: 'https://example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: client,
),
logger: logger,
);
}
CustomOrderSpec sampleSpec() => CustomOrderSpec(
material: 'Organic cotton',
color: 'Sage green',
size: 'Large',
dimensions: '8" round',
referenceImageUrls: const ['https://cdn.test/a.jpg', 'https://cdn.test/b.jpg'],
notes: 'Housewarming gift.',
estimatedCompletionDate: DateTime(2026, 5, 1),
);
Order sampleCustomOrder() => Order(
id: 'KC-C3001',
customerName: 'Nadia Okafor',
customerEmail: 'nadia@example.com',
orderDate: DateTime(2026, 4, 6),
status: OrderStatus.pending,
items: const [
OrderItem(productName: 'Custom Bowl Cozy Set', sku: 'CUSTOM-BC', quantity: 4, unitPrice: 18.0),
],
shippingAddress: '15 Riverside Way, Asheville, NC 28801',
orderType: OrderType.custom,
fulfillmentStatus: FulfillmentStatus.depositReceived,
customOrderSpec: sampleSpec(),
depositInfo: DepositInfo(
depositAmountCents: 2400,
totalAmountCents: 7200,
depositPaid: true,
depositDate: DateTime(2026, 4, 6),
),
);
/// Extracts a meta value from a WooCommerce `meta_data` list.
String? metaValue(List<Map<String, dynamic>> meta, String key) {
for (final entry in meta) {
if (entry['key'] == key) return entry['value'] as String?;
}
return null;
}
void main() {
// ── Mapper: reading custom orders ─────────────────────────────────────────
group('WooCommerceOrderMapper — reads custom order meta', () {
test('maps orderType from meta', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.orderType, OrderType.custom);
expect(order.isCustom, true);
});
test('maps fulfillmentStatus from meta', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.fulfillmentStatus, FulfillmentStatus.depositReceived);
});
test('maps the full specification', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.customOrderSpec, sampleSpec());
});
test('splits newline-separated reference images', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.customOrderSpec!.referenceImageUrls, [
'https://cdn.test/a.jpg',
'https://cdn.test/b.jpg',
]);
});
test('maps deposit amounts as integer cents', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.depositInfo!.depositAmountCents, 2400);
expect(order.depositInfo!.totalAmountCents, 7200);
});
test('maps the deposit paid flag and date', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.depositInfo!.depositPaid, true);
expect(order.depositInfo!.depositDate, DateTime.parse('2026-04-06T09:00:00.000'));
});
test('derives the balance rather than reading it from meta', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.balanceDueCents, 4800);
});
test('leaves settlementDate null when absent', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.depositInfo!.settlementDate, isNull);
});
test('internal meta keys are excluded from customFields', () {
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
expect(order.customFields, isEmpty);
});
test('public meta still populates customFields alongside custom order meta', () {
final meta = [
...customMeta(),
{'key': 'gift_message', 'value': 'Happy Birthday!'},
];
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.customFields['gift_message'], 'Happy Birthday!');
expect(order.isCustom, true);
});
});
// ── Mapper: standard and partial orders ───────────────────────────────────
group('WooCommerceOrderMapper — standard and partial orders', () {
test('an order with no meta is standard', () {
expect(_mapper.fromJson(orderJson()).orderType, OrderType.standard);
});
test('a standard order carries no custom order fields', () {
final order = _mapper.fromJson(orderJson());
expect(order.customOrderSpec, isNull);
expect(order.depositInfo, isNull);
expect(order.fulfillmentStatus, isNull);
});
test('custom meta on a standard-typed order is ignored', () {
final meta = customMeta()
.where((m) => m['key'] != WooCommerceOrderMapper.metaOrderType)
.toList();
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.orderType, OrderType.standard);
expect(order.customOrderSpec, isNull);
expect(order.depositInfo, isNull);
});
test('an unknown fulfillment status falls back to quoted', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'nonsense'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
];
expect(
_mapper.fromJson(orderJson(metaData: meta)).fulfillmentStatus,
FulfillmentStatus.quoted,
);
});
test('a custom order without spec meta yields a null spec', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
];
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.isCustom, true);
expect(order.customOrderSpec, isNull);
});
test('a custom order without deposit meta yields null deposit info', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
];
expect(_mapper.fromJson(orderJson(metaData: meta)).depositInfo, isNull);
});
test('a non-numeric deposit amount yields null deposit info', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': 'oops'},
];
expect(_mapper.fromJson(orderJson(metaData: meta)).depositInfo, isNull);
});
test('a missing total falls back to the deposit amount', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': '1500'},
];
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.depositInfo!.totalAmountCents, 1500);
});
test('an unparseable estimated completion date is dropped', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
{'key': WooCommerceOrderMapper.metaSpecEstimatedCompletion, 'value': 'not-a-date'},
];
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.customOrderSpec!.estimatedCompletionDate, isNull);
});
test('empty reference image meta yields an empty list', () {
final meta = [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
{'key': WooCommerceOrderMapper.metaSpecReferenceImages, 'value': ''},
];
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.customOrderSpec!.referenceImageUrls, isEmpty);
});
});
// ── Mapper: writing custom order meta ─────────────────────────────────────
group('WooCommerceOrderMapper — customOrderMetaData', () {
test('returns an empty list for a standard order', () {
final standard = sampleCustomOrder().copyWith(orderType: OrderType.standard);
expect(_mapper.customOrderMetaData(standard), isEmpty);
});
test('writes the order type', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
expect(metaValue(meta, WooCommerceOrderMapper.metaOrderType), 'custom');
});
test('writes the fulfillment status', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
expect(metaValue(meta, WooCommerceOrderMapper.metaFulfillmentStatus), 'depositReceived');
});
test('writes every specification field', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecMaterial), 'Organic cotton');
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecColor), 'Sage green');
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecSize), 'Large');
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecDimensions), '8" round');
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecNotes), 'Housewarming gift.');
});
test('joins reference images with a newline', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
expect(
metaValue(meta, WooCommerceOrderMapper.metaSpecReferenceImages),
'https://cdn.test/a.jpg\nhttps://cdn.test/b.jpg',
);
});
test('writes deposit amounts as integer cent strings', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
expect(metaValue(meta, WooCommerceOrderMapper.metaDepositAmountCents), '2400');
expect(metaValue(meta, WooCommerceOrderMapper.metaDepositTotalCents), '7200');
});
test('writes the deposit paid flag', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
expect(metaValue(meta, WooCommerceOrderMapper.metaDepositPaid), 'true');
});
test('omits the derived balance', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
final keys = meta.map((m) => m['key'] as String);
expect(keys.any((k) => k.contains('balance')), false);
});
test('never writes a payment or transaction identifier', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
final keys = meta.map((m) => m['key'] as String).toList();
expect(keys.any((k) => k.contains('transaction')), false);
expect(keys.any((k) => k.contains('payment_id')), false);
expect(keys.any((k) => k.contains('txn')), false);
});
test('every key uses the _kc_ prefix', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
for (final entry in meta) {
expect(entry['key'] as String, startsWith('_kc_'));
}
});
test('omits absent optional fields rather than writing blanks', () {
final minimal = sampleCustomOrder().copyWith(
customOrderSpec: CustomOrderSpec(material: 'Linen', color: 'Ivory'),
);
final meta = _mapper.customOrderMetaData(minimal);
final keys = meta.map((m) => m['key'] as String);
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSpecSize)));
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSpecDimensions)));
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSpecNotes)));
});
test('omits the settlement date until the balance is settled', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
final keys = meta.map((m) => m['key'] as String);
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSettlementDate)));
});
test('writes the settlement date once settled', () {
final settled = sampleCustomOrder().copyWith(
depositInfo: sampleCustomOrder().depositInfo!.markSettled(settledAt: DateTime(2026, 5, 10)),
);
final meta = _mapper.customOrderMetaData(settled);
expect(metaValue(meta, WooCommerceOrderMapper.metaSettlementDate), isNotNull);
});
test('specMetaEntries returns an empty map for a null spec', () {
expect(_mapper.specMetaEntries(null), isEmpty);
});
test('depositMetaEntries returns an empty map for a null deposit', () {
expect(_mapper.depositMetaEntries(null), isEmpty);
});
test('toMetaDataList converts a key map into WooCommerce list form', () {
final list = WooCommerceOrderMapper.toMetaDataList({'_kc_a': '1'});
expect(list, [
{'key': '_kc_a', 'value': '1'},
]);
});
});
// ── Mapper: round trip ────────────────────────────────────────────────────
group('WooCommerceOrderMapper — custom order round trip', () {
test('meta written by the mapper reads back into an equal spec', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.customOrderSpec, sampleCustomOrder().customOrderSpec);
});
test('meta written by the mapper reads back into an equal deposit', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.depositInfo, sampleCustomOrder().depositInfo);
});
test('order type and fulfillment stage survive the round trip', () {
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
final order = _mapper.fromJson(orderJson(metaData: meta));
expect(order.orderType, OrderType.custom);
expect(order.fulfillmentStatus, FulfillmentStatus.depositReceived);
});
test('every pipeline stage survives the round trip', () {
for (final stage in FulfillmentStatus.values) {
final order = sampleCustomOrder().copyWith(fulfillmentStatus: stage);
final meta = _mapper.customOrderMetaData(order);
final parsed = _mapper.fromJson(orderJson(metaData: meta));
expect(parsed.fulfillmentStatus, stage, reason: '${stage.name} did not round-trip');
}
});
test('toCreateJson includes custom order meta for a custom order', () {
final body = _mapper.toCreateJson(sampleCustomOrder());
final meta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
expect(metaValue(meta, WooCommerceOrderMapper.metaOrderType), 'custom');
});
test('toCreateJson omits meta_data for a standard order', () {
final standard = sampleCustomOrder().copyWith(orderType: OrderType.standard);
expect(_mapper.toCreateJson(standard).containsKey('meta_data'), false);
});
});
// ── Repository: createCustomOrder ─────────────────────────────────────────
group('WooCommerceOrdersRepository — createCustomOrder', () {
test('POSTs the order and returns the created domain object', () async {
final client = MockClient((request) async {
expect(request.method, 'POST');
expect(request.url.path, contains('/orders'));
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
});
final result = await repoWith(
client,
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
expect(result.success, true);
expect(result.updatedOrder!.isCustom, true);
});
test('sends the custom order meta in the request body', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
});
await repoWith(client).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaOrderType), 'custom');
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaSpecMaterial), 'Organic cotton');
});
test('always starts the pipeline at quoted', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
});
// The caller supplies depositReceived; the repository must override it.
await repoWith(client).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaFulfillmentStatus), 'quoted');
});
test('never sends a payment identifier', () async {
String? sentBody;
final client = MockClient((request) async {
sentBody = request.body;
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
});
await repoWith(client).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
expect(sentBody, isNot(contains('transaction')));
expect(sentBody, isNot(contains('txn')));
});
test('returns a failure result on an API error', () async {
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
final result = await repoWith(
client,
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
expect(result.success, false);
expect(result.errorMessage, contains('400'));
});
test('logs the API failure with the operation and status code', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
final record = sink.recordsAtLeast(KcLogLevel.error).last;
expect(record.context['operation'], 'createCustomOrder');
expect(record.context['statusCode'], 400);
expect(record.error, isA<WooCommerceApiException>());
});
test('log context carries no customer identity', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
final record = sink.recordsAtLeast(KcLogLevel.error).last;
final rendered = record.context.toString();
expect(rendered, isNot(contains('Nadia')));
expect(rendered, isNot(contains('@')));
});
});
// ── Repository: updateCustomOrderSpec ─────────────────────────────────────
group('WooCommerceOrdersRepository — updateCustomOrderSpec', () {
test('GETs the order then PUTs the revised meta', () async {
final methods = <String>[];
final client = MockClient((request) async {
methods.add(request.method);
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
final result = await repoWith(
client,
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(result.success, true);
expect(methods, ['GET', 'PUT']);
});
test('sends only meta_data on the PUT', () async {
Map<String, dynamic>? putBody;
final client = MockClient((request) async {
if (request.method == 'PUT') {
putBody = jsonDecode(request.body) as Map<String, dynamic>;
}
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
await repoWith(
client,
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(putBody!.keys, ['meta_data']);
});
test('sends the revised specification', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
}
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
await repoWith(
client,
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaSpecMaterial), 'Wool');
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaSpecColor), 'Slate');
});
test('preserves the existing fulfillment stage in the PUT', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
}
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
await repoWith(
client,
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaFulfillmentStatus), 'depositReceived');
});
test('rejects a standard order without issuing a PUT', () async {
final methods = <String>[];
final client = MockClient((request) async {
methods.add(request.method);
return http.Response(jsonEncode(orderJson()), 200);
});
final result = await repoWith(
client,
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(result.success, false);
expect(result.errorMessage, contains('not a custom order'));
expect(methods, ['GET']);
});
test('rejects an order that does not exist', () async {
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
final result = await repoWith(
client,
).updateCustomOrderSpec('9999', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(result.success, false);
expect(result.errorMessage, contains('not found'));
});
test('returns a failure result when the PUT fails', () async {
final client = MockClient((request) async {
if (request.method == 'PUT') {
return http.Response('{"message":"Server error"}', 500);
}
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
final result = await repoWith(
client,
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
expect(result.success, false);
expect(result.errorMessage, contains('500'));
});
});
// ── Repository: recordDeposit ─────────────────────────────────────────────
group('WooCommerceOrdersRepository — recordDeposit', () {
/// Meta for a quoted commission with an unpaid deposit.
List<Map<String, dynamic>> quotedMeta() => [
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'quoted'},
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Organic cotton'},
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Sage green'},
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': '2400'},
{'key': WooCommerceOrderMapper.metaDepositTotalCents, 'value': '7200'},
{'key': WooCommerceOrderMapper.metaDepositPaid, 'value': 'false'},
];
test('marks the deposit paid and PUTs the revised meta', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
}
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
});
final result = await repoWith(
client,
).recordDeposit('3001', amountCents: 2400, paidAt: DateTime.utc(2026, 4, 6));
expect(result.success, true);
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositPaid), 'true');
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositAmountCents), '2400');
});
test('sends the deposit date', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
}
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
});
await repoWith(
client,
).recordDeposit('3001', amountCents: 2400, paidAt: DateTime.utc(2026, 4, 6));
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositDate), isNotNull);
});
test('revises the total when supplied', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
}
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
});
await repoWith(client).recordDeposit(
'3001',
amountCents: 2400,
paidAt: DateTime.utc(2026, 4, 6),
totalAmountCents: 9000,
);
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositTotalCents), '9000');
});
test('never sends a payment identifier', () async {
String? putBody;
final client = MockClient((request) async {
if (request.method == 'PUT') {
putBody = request.body;
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
}
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
});
await repoWith(
client,
).recordDeposit('3001', amountCents: 2400, paidAt: DateTime.utc(2026, 4, 6));
expect(putBody, isNot(contains('transaction')));
expect(putBody, isNot(contains('txn')));
});
test('rejects a non-positive amount without any HTTP call', () async {
var calls = 0;
final client = MockClient((_) async {
calls++;
return http.Response('{}', 200);
});
final result = await repoWith(
client,
).recordDeposit('3001', amountCents: 0, paidAt: DateTime.utc(2026, 4, 6));
expect(result.success, false);
expect(calls, 0);
});
test('rejects a second deposit', () async {
// customMeta() already has depositPaid = true.
final client = MockClient(
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
);
final result = await repoWith(
client,
).recordDeposit('3001', amountCents: 1000, paidAt: DateTime.utc(2026, 4, 6));
expect(result.success, false);
expect(result.errorMessage, contains('already been recorded'));
});
test('rejects a standard order', () async {
final client = MockClient((_) async => http.Response(jsonEncode(orderJson()), 200));
final result = await repoWith(
client,
).recordDeposit('3001', amountCents: 1000, paidAt: DateTime.utc(2026, 4, 6));
expect(result.success, false);
expect(result.errorMessage, contains('not a custom order'));
});
test('rejections do not log the money amount', () async {
final sink = KcInMemoryLogSink();
final client = MockClient(
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
);
await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).recordDeposit('3001', amountCents: 1234, paidAt: DateTime.utc(2026, 4, 6));
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.context['operation'], 'recordDeposit');
expect(record.format(), isNot(contains('1234')));
});
});
// ── Repository: advanceFulfillmentStatus ──────────────────────────────────
group('WooCommerceOrdersRepository — advanceFulfillmentStatus', () {
test('a legal advance PUTs the new stage', () async {
List<Map<String, dynamic>>? sentMeta;
final client = MockClient((request) async {
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
}
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
// Remote stage is depositReceived; inProduction is the legal next step.
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
expect(result.success, true);
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaFulfillmentStatus), 'inProduction');
});
test('a legal advance can cancel the commission', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
);
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.cancelled);
expect(result.success, true);
});
test('an illegal jump is rejected without issuing a PUT', () async {
final methods = <String>[];
final client = MockClient((request) async {
methods.add(request.method);
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
// depositReceived → completed skips three stages.
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.completed);
expect(result.success, false);
expect(result.errorMessage, contains('Cannot move from deposit received'));
expect(methods, ['GET']);
});
test('an advance to the same stage is rejected', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
);
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.depositReceived);
expect(result.success, false);
expect(result.errorMessage, contains('already'));
});
test('a completed remote order cannot be reopened', () async {
final meta = [
...customMeta().where((m) => m['key'] != WooCommerceOrderMapper.metaFulfillmentStatus),
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'completed'},
];
final client = MockClient(
(_) async => http.Response(jsonEncode(orderJson(metaData: meta)), 200),
);
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
expect(result.success, false);
expect(result.errorMessage, contains('already completed'));
});
test('rejects a standard order', () async {
final client = MockClient((_) async => http.Response(jsonEncode(orderJson()), 200));
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
expect(result.success, false);
expect(result.errorMessage, contains('not a custom order'));
});
test('rejects an order that does not exist', () async {
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
final result = await repoWith(
client,
).advanceFulfillmentStatus('9999', FulfillmentStatus.inProduction);
expect(result.success, false);
expect(result.errorMessage, contains('not found'));
});
test('an illegal jump is logged with both stages', () async {
final sink = KcInMemoryLogSink();
final client = MockClient(
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
);
await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).advanceFulfillmentStatus('3001', FulfillmentStatus.completed);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.context['operation'], 'advanceFulfillmentStatus');
expect(record.context['fromStage'], 'depositReceived');
expect(record.context['toStage'], 'completed');
});
test('returns a failure result when the PUT fails', () async {
final client = MockClient((request) async {
if (request.method == 'PUT') {
return http.Response('{"message":"Server error"}', 500);
}
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
});
final result = await repoWith(
client,
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
expect(result.success, false);
expect(result.errorMessage, contains('500'));
});
});
// ── Repository: observability on existing operations ──────────────────────
group('WooCommerceOrdersRepository — observability', () {
test('getOrder logs a 404 at warning severity and returns null', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
final order = await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).getOrder('9999');
expect(order, isNull);
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
expect(record.level, KcLogLevel.warning);
expect(record.context['operation'], 'getOrder');
expect(record.context['statusCode'], 404);
});
test('getOrder logs a non-404 failure at error severity and rethrows', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{"message":"Boom"}', 500));
final repo = repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
);
await expectLater(repo.getOrder('3001'), throwsA(isA<WooCommerceApiException>()));
final record = sink.recordsAtLeast(KcLogLevel.error).last;
expect(record.context['statusCode'], 500);
});
test('updateOrderStatus logs API failures', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).updateOrderStatus('3001', OrderStatus.processing);
final record = sink.recordsAtLeast(KcLogLevel.error).last;
expect(record.context['operation'], 'updateOrderStatus');
expect(record.context['requestedStatus'], 'processing');
});
test('addOrderNote logs an empty-note rejection', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{}', 200));
final result = await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).addOrderNote('3001', ' ');
expect(result.success, false);
expect(sink.recordsAtLeast(KcLogLevel.warning).last.context['operation'], 'addOrderNote');
});
test('records are tagged with the repository source', () async {
final sink = KcInMemoryLogSink();
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
await repoWith(
client,
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
).updateOrderStatus('3001', OrderStatus.processing);
expect(sink.recordsAtLeast(KcLogLevel.error).last.source, 'WooCommerceOrdersRepository');
});
test('defaults to a silent logger when none is injected', () async {
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
final result = await repoWith(client).updateOrderStatus('3001', OrderStatus.processing);
// A missing logger must never turn an API failure into a crash.
expect(result.success, false);
});
});
}

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@ -218,6 +218,37 @@ class WooCommerceApiClient implements ApiClient {
return decoded; return decoded;
} }
/// Sends a partial update (PUT) to a single WooCommerce order.
///
/// Only the fields present in [body] are changed on the remote order — this
/// is how custom order `meta_data` is written back without disturbing the
/// order's commercial fields.
///
/// Returns the full updated order JSON that WooCommerce echoes back.
Future<Map<String, dynamic>> updateOrder(String orderId, Map<String, dynamic> body) async {
final uri = Uri.parse('$_baseEndpoint/orders/$orderId');
final response = await _httpClient.put(uri, headers: _authHeaders, body: jsonEncode(body));
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to update order $orderId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Creates a new order in WooCommerce. /// Creates a new order in WooCommerce.
/// ///
/// [body] should contain the WooCommerce order fields. /// [body] should contain the WooCommerce order fields.

View File

@ -1,3 +1,12 @@
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at # Additional information about this file can be found at