From 86c630d670983e1687168027dfba0e93fa40cd75 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 29 Aug 2026 15:19:29 -0400 Subject: [PATCH] feat(stage-11a): custom order domain and data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT_STATE_AUDIT.md | 16 +- docs/development/build_execution_tracker.md | 49 +- docs/development/master_development_brief.md | 137 ++- .../dashboard_controller_test.dart | 23 + .../packages/feature_orders/.metadata | 10 + .../packages/feature_orders/CHANGELOG.md | 3 + .../packages/feature_orders/LICENSE | 1 + .../packages/feature_orders/README.md | 99 ++ .../feature_orders/lib/feature_orders.dart | 4 + .../lib/src/data/fake_orders_repository.dart | 379 ++++++- .../src/data/woo_commerce_order_mapper.dart | 226 +++- .../data/woo_commerce_orders_repository.dart | 326 +++++- .../lib/src/domain/custom_order_spec.dart | 139 +++ .../lib/src/domain/deposit_info.dart | 141 +++ .../lib/src/domain/fulfillment_status.dart | 139 +++ .../feature_orders/lib/src/domain/order.dart | 59 ++ .../lib/src/domain/order_type.dart | 33 + .../lib/src/domain/orders_repository.dart | 61 ++ .../packages/feature_orders/pubspec.yaml | 4 +- .../test/custom_order_spec_test.dart | 286 +++++ .../test/deposit_info_test.dart | 333 ++++++ .../fake_orders_repository_custom_test.dart | 619 +++++++++++ .../test/fake_orders_repository_test.dart | 13 +- .../test/fulfillment_status_test.dart | 361 +++++++ .../feature_orders/test/order_type_test.dart | 60 ++ .../test/orders_controller_test.dart | 28 +- .../test/widgets/orders_page_test.dart | 26 +- .../test/woo_commerce_custom_order_test.dart | 984 ++++++++++++++++++ .../lib/src/data/woo_commerce_api_client.dart | 31 + 29 files changed, 4503 insertions(+), 87 deletions(-) create mode 100644 kell_creations_apps/packages/feature_orders/.metadata create mode 100644 kell_creations_apps/packages/feature_orders/CHANGELOG.md create mode 100644 kell_creations_apps/packages/feature_orders/LICENSE create mode 100644 kell_creations_apps/packages/feature_orders/README.md create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/custom_order_spec.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/deposit_info.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_status.dart create mode 100644 kell_creations_apps/packages/feature_orders/lib/src/domain/order_type.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/custom_order_spec_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/deposit_info_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/fake_orders_repository_custom_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/fulfillment_status_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/order_type_test.dart create mode 100644 kell_creations_apps/packages/feature_orders/test/woo_commerce_custom_order_test.dart diff --git a/PROJECT_STATE_AUDIT.md b/PROJECT_STATE_AUDIT.md index 32c3fe4..a3bd1e5 100644 --- a/PROJECT_STATE_AUDIT.md +++ b/PROJECT_STATE_AUDIT.md @@ -502,10 +502,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. **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. -**Dependencies:** Stage 9A ✅ +> Domain and data layers delivered on `feat/custom-order-domain` (Stage 11A, 2026-08-29). + +**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) @@ -582,10 +586,14 @@ These items harden the platform for production deployment. **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. +`.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 **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`. diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 68f438e..1abee35 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -9,13 +9,58 @@ - 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 - main baseline updated through: `feat/observability-error-logging` (Stage 16A complete, merged 2026-08-29) -- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data -- 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 +- current branch: `feat/custom-order-domain` (Stage 11A) — complete, awaiting merge +- 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 - release review: `docs/operations/release-checklist.md` created 2026-08-29 — per-environment release gates for Development, Test/QA, Staging, and Production ## Slice tracker +### feat/custom-order-domain + +- status: complete — awaiting merge +- 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) +- 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 - status: merged to main diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 103b4d8..8bed02c 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -143,24 +143,25 @@ Rules: ### 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 - All packages passing -- latest reported count for `core`: `90/90 passed` ← +56 new (Stage 16A observability) -- latest reported count for `design_system`: `51/51 passed` ← +10 new (Stage 16A bug report dialog) +- latest reported count for `core`: `90/90 passed` +- latest reported count for `design_system`: `51/51 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 `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 `data`: `63/63 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) -- 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) -- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data +- current branch awaiting merge: `feat/custom-order-domain` (Stage 11A complete) +- 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. @@ -272,14 +273,93 @@ Validation: - 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 +### 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 -**`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 -- Extend `Order` model and `OrdersRepository` contract -- Implement in `FakeOrdersRepository` and `WooCommerceOrdersRepository` -- Tests for all new domain models and repository methods +- `CustomOrderFormPage` — customer capture, spec fields, reference image + capture, pricing and deposit entry, estimated completion picker +- `CustomOrderConfirmationPage` — summary with a deposit receipt +- Wire into mobile shell navigation and web routing +- Tests for form validation and the creation flow --- @@ -335,7 +415,7 @@ These items complete planned stages and wire existing contracts into production | ID | Item | Target Stage | | ----- | ---------------------------------------------------------------------------------- | --------------------------- | | 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.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C | | P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone | @@ -358,7 +438,7 @@ These items harden the platform for production deployment. | P3.5 | ~~Crash reporting integration~~ - DONE: `KcErrorReporter` (Stage 16A) | Complete | | 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.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.10 | Implement `ChannelAdapter` for WooCommerce and Square | Stage 13A | | P3.11 | Enforce minimum test coverage thresholds in CI (e.g., 75% floor) | Standalone | @@ -918,7 +998,10 @@ Custom orders extend the existing `Order` domain model with additional fields ra - `feat/custom-order-ui` - `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 @@ -926,13 +1009,21 @@ Extend the order domain model and repository to support custom order creation wi ##### Requirements -- Add `OrderType` enum: `standard`, `custom` -- Add `CustomOrderSpec` value object: material, color, dimensions, special instructions, reference images, estimated completion date -- Add `DepositInfo` value object: depositAmount, depositPaid, depositDate, balanceDue -- Extend `Order` model: `orderType`, `customSpec` (nullable), `depositInfo` (nullable), `estimatedCompletionDate`, `internalNotes` -- Extend `OrdersRepository`: `createCustomOrder()`, `updateCustomOrderSpec()`, `recordDeposit()`, `updateEstimatedCompletion()` -- Implement in both `FakeOrdersRepository` and `WooCommerceOrdersRepository` (WooCommerce custom orders use order meta fields) -- Tests for domain models and repository methods +- ✅ Add `OrderType` enum: `standard`, `custom` +- ✅ Add `CustomOrderSpec` value object: material, color, size, dimensions, reference image URLs, free-text notes, estimated completion date +- ✅ 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`, `customOrderSpec` (nullable), `depositInfo` (nullable), `fulfillmentStatus` (nullable). All existing fields and equality semantics preserved +- ✅ 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 +- ✅ Extend `OrdersRepository`: `createCustomOrder()`, `updateCustomOrderSpec()`, `recordDeposit()`, `advanceFulfillmentStatus()` +- ✅ 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 diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart index f323f8c..f41e639 100644 --- a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart +++ b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart @@ -124,6 +124,29 @@ class _StubOrdersRepository implements OrdersRepository { @override Future createOrder(Order order) => throw UnimplementedError(); + + @override + Future createCustomOrder( + Order order, { + required CustomOrderSpec spec, + DepositInfo? deposit, + }) => throw UnimplementedError(); + + @override + Future updateCustomOrderSpec(String id, CustomOrderSpec spec) => + throw UnimplementedError(); + + @override + Future recordDeposit( + String id, { + required int amountCents, + required DateTime paidAt, + int? totalAmountCents, + }) => throw UnimplementedError(); + + @override + Future advanceFulfillmentStatus(String id, FulfillmentStatus next) => + throw UnimplementedError(); } class _FailingInventoryRepository implements InventoryRepository { diff --git a/kell_creations_apps/packages/feature_orders/.metadata b/kell_creations_apps/packages/feature_orders/.metadata new file mode 100644 index 0000000..fea2ac0 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/.metadata @@ -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 diff --git a/kell_creations_apps/packages/feature_orders/CHANGELOG.md b/kell_creations_apps/packages/feature_orders/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/kell_creations_apps/packages/feature_orders/LICENSE b/kell_creations_apps/packages/feature_orders/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/kell_creations_apps/packages/feature_orders/README.md b/kell_creations_apps/packages/feature_orders/README.md new file mode 100644 index 0000000..6aa1375 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/README.md @@ -0,0 +1,99 @@ + + +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/`. diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart index b6a255f..13b5bc9 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -11,11 +11,15 @@ export 'src/data/fake_orders_repository.dart'; export 'src/data/fake_payment_service.dart'; export 'src/data/woo_commerce_order_mapper.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_action_result.dart'; export 'src/domain/order_item.dart'; export 'src/domain/order_source.dart'; export 'src/domain/order_status.dart'; +export 'src/domain/order_type.dart'; export 'src/domain/orders_repository.dart'; export 'src/domain/payment_method.dart'; export 'src/domain/payment_result.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart index 54fd96e..5433986 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart @@ -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_action_result.dart'; import '../domain/order_item.dart'; +import '../domain/order_source.dart'; import '../domain/order_status.dart'; +import '../domain/order_type.dart'; import '../domain/orders_repository.dart'; /// Stubbed implementation of [OrdersRepository] with sample /// 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 { + /// 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 _orders = [ Order( 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 @@ -157,7 +353,11 @@ class FakeOrdersRepository implements OrdersRepository { await Future.delayed(const Duration(milliseconds: 200)); final index = _orders.indexWhere((o) => o.id == id); 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); _orders[index] = updated; @@ -168,11 +368,15 @@ class FakeOrdersRepository implements OrdersRepository { Future addOrderNote(String id, String note) async { await Future.delayed(const Duration(milliseconds: 200)); 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); 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 updated = existing.copyWith(orderNotes: [...existing.orderNotes, note.trim()]); @@ -184,9 +388,176 @@ class FakeOrdersRepository implements OrdersRepository { Future createOrder(Order order) async { await Future.delayed(const Duration(milliseconds: 200)); 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); return OrderActionResult.success(order); } + + // ── Custom orders ──────────────────────────────────────────────────────── + + @override + Future createCustomOrder( + Order order, { + required CustomOrderSpec spec, + DepositInfo? deposit, + }) async { + await Future.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 updateCustomOrderSpec(String id, CustomOrderSpec spec) async { + await Future.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 recordDeposit( + String id, { + required int amountCents, + required DateTime paidAt, + int? totalAmountCents, + }) async { + await Future.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 advanceFulfillmentStatus(String id, FulfillmentStatus next) async { + await Future.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 context, + }) { + logger.warning( + 'Order operation rejected: $message', + context: {'operation': operation, ...context}, + ); + return OrderActionResult.failure(message); + } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart index a9532be..d5419b6 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart @@ -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_item.dart'; import '../domain/order_source.dart'; import '../domain/order_status.dart'; +import '../domain/order_type.dart'; /// Maps raw WooCommerce REST API v3 order JSON into [Order] domain objects. /// /// 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 { 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]. Order fromJson(Map json) { final billing = json['billing'] as Map? ?? {}; @@ -28,9 +116,14 @@ class WooCommerceOrderMapper { final customerNote = (json['customer_note'] as String?) ?? ''; final orderNotes = customerNote.isNotEmpty ? [customerNote] : []; - // Collect custom fields from meta_data. + // Collect custom fields from public meta_data (`_`-prefixed keys excluded). 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( id: json['id']?.toString() ?? '', customerName: customerName, @@ -44,6 +137,12 @@ class WooCommerceOrderMapper { paymentMethod: (json['payment_method_title'] as String?), source: OrderSource.online, 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(), + 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> customOrderMetaData(Order order) { + if (!order.isCustom) return const []; + + final entries = { + 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 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 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> toMetaDataList(Map entries) => + entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList(); + // ── Private helpers ────────────────────────────────────────────────────── + /// Rebuilds a [CustomOrderSpec] from internal meta, or `null` when the + /// required material and colour are both absent. + static CustomOrderSpec? _specFromMeta(Map 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 [] + : 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 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 _extractInternalMetaData(dynamic metaData) { + final result = {}; + if (metaData is! List) return result; + for (final entry in metaData.cast>()) { + 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. static OrderStatus _mapStatus(String? status) { switch (status) { diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart index 9a35a8d..d0a7f51 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart @@ -1,15 +1,38 @@ +import 'package:core/core.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_action_result.dart'; import '../domain/order_status.dart'; +import '../domain/order_type.dart'; import '../domain/orders_repository.dart'; import 'woo_commerce_order_mapper.dart'; /// Real [OrdersRepository] backed by the WooCommerce REST API v3. /// /// 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 /// transport and authentication. @@ -17,11 +40,20 @@ class WooCommerceOrdersRepository implements OrdersRepository { final WooCommerceApiClient _apiClient; final WooCommerceOrderMapper _mapper; + /// Structured logger for API and validation failures. + /// + /// Defaults to [KcLogger.silent] so tests need not supply one. + final KcLogger logger; + WooCommerceOrdersRepository({ required WooCommerceApiClient apiClient, WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(), + KcLogger? logger, }) : _apiClient = apiClient, - _mapper = mapper; + _mapper = mapper, + logger = (logger ?? KcLogger.silent()).forSource(_logSource); + + static const String _logSource = 'WooCommerceOrdersRepository'; @override Future> getOrders() async { @@ -34,56 +66,280 @@ class WooCommerceOrdersRepository implements OrdersRepository { try { final json = await _apiClient.getOrder(id); return _mapper.fromJson(json); - } on WooCommerceApiException catch (e) { - if (e.statusCode == 404) return null; + } on WooCommerceApiException catch (e, stackTrace) { + 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; } } @override - Future updateOrderStatus(String id, OrderStatus newStatus) async { - try { - final wooStatus = WooCommerceOrderMapper.toWooCommerceStatus(newStatus); - final json = await _apiClient.updateOrderStatus(id, wooStatus); - final updated = _mapper.fromJson(json); - return OrderActionResult.success(updated); - } on WooCommerceApiException catch (e) { - return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}'); - } catch (e) { - return OrderActionResult.failure('Unexpected error: $e'); - } + Future updateOrderStatus(String id, OrderStatus newStatus) { + return _guard( + operation: 'updateOrderStatus', + context: {'orderId': id, 'requestedStatus': newStatus.name}, + action: () async { + final wooStatus = WooCommerceOrderMapper.toWooCommerceStatus(newStatus); + final json = await _apiClient.updateOrderStatus(id, wooStatus); + return _mapper.fromJson(json); + }, + ); } @override Future addOrderNote(String id, String note) async { 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 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 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 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 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 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 { - // 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); - final updated = _mapper.fromJson(json); - return OrderActionResult.success(updated); + order = await getOrder(id); } 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 _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 _guard({ + required String operation, + required Map context, + required Future 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}'); - } 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'); } } - @override - Future createOrder(Order order) async { - try { - final body = _mapper.toCreateJson(order); - final json = await _apiClient.createOrder(body); - final created = _mapper.fromJson(json); - return OrderActionResult.success(created); - } on WooCommerceApiException catch (e) { - return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}'); - } catch (e) { - return OrderActionResult.failure('Unexpected error: $e'); - } + /// Builds a failure result for a domain-level rejection and logs a warning. + OrderActionResult _rejected( + String message, { + required String operation, + required Map context, + }) { + logger.warning( + 'Order operation rejected: $message', + context: {'operation': operation, ...context}, + ); + return OrderActionResult.failure(message); } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/custom_order_spec.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/custom_order_spec.dart new file mode 100644 index 0000000..eb87562 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/custom_order_spec.dart @@ -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 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 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 = [ + 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? 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 a, List 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; + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/deposit_info.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/deposit_info.dart new file mode 100644 index 0000000..e8270d2 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/deposit_info.dart @@ -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)'; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_status.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_status.dart new file mode 100644 index 0000000..b113de1 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/fulfillment_status.dart @@ -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 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 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.'; + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart index 886845c..a1b1f5c 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart @@ -1,8 +1,17 @@ +import 'custom_order_spec.dart'; +import 'deposit_info.dart'; +import 'fulfillment_status.dart'; import 'order_item.dart'; import 'order_source.dart'; import 'order_status.dart'; +import 'order_type.dart'; /// 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 { final String id; final String customerName; @@ -32,6 +41,31 @@ class Order { /// channel-specific fields (e.g. WooCommerce order meta). final Map 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({ required this.id, required this.customerName, @@ -46,6 +80,10 @@ class Order { this.paymentMethod, this.source = OrderSource.online, this.customFields = const {}, + this.orderType = OrderType.standard, + this.customOrderSpec, + this.depositInfo, + this.fulfillmentStatus, }); /// The total value of the order. @@ -54,7 +92,20 @@ class Order { /// The number of individual items in the order. 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. + /// + /// Nullable fields cannot be cleared through [copyWith]; construct a new + /// [Order] when a field must be removed. Order copyWith({ String? id, String? customerName, @@ -69,6 +120,10 @@ class Order { String? paymentMethod, OrderSource? source, Map? customFields, + OrderType? orderType, + CustomOrderSpec? customOrderSpec, + DepositInfo? depositInfo, + FulfillmentStatus? fulfillmentStatus, }) { return Order( id: id ?? this.id, @@ -84,6 +139,10 @@ class Order { paymentMethod: paymentMethod ?? this.paymentMethod, source: source ?? this.source, customFields: customFields ?? this.customFields, + orderType: orderType ?? this.orderType, + customOrderSpec: customOrderSpec ?? this.customOrderSpec, + depositInfo: depositInfo ?? this.depositInfo, + fulfillmentStatus: fulfillmentStatus ?? this.fulfillmentStatus, ); } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_type.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_type.dart new file mode 100644 index 0000000..f3cfece --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_type.dart @@ -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; + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart index 40c8c22..59e2556 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart @@ -1,8 +1,15 @@ +import 'custom_order_spec.dart'; +import 'deposit_info.dart'; +import 'fulfillment_status.dart'; import 'order.dart'; import 'order_action_result.dart'; import 'order_status.dart'; /// 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 { /// Returns all orders. Future> getOrders(); @@ -24,4 +31,58 @@ abstract class OrdersRepository { /// /// Returns an [OrderActionResult] with the created order on success. Future 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 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 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 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 advanceFulfillmentStatus(String id, FulfillmentStatus next); } diff --git a/kell_creations_apps/packages/feature_orders/pubspec.yaml b/kell_creations_apps/packages/feature_orders/pubspec.yaml index 7bc6cc7..29ac1cf 100644 --- a/kell_creations_apps/packages/feature_orders/pubspec.yaml +++ b/kell_creations_apps/packages/feature_orders/pubspec.yaml @@ -1,5 +1,5 @@ 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 publish_to: "none" homepage: @@ -11,6 +11,8 @@ environment: dependencies: flutter: sdk: flutter + core: + path: ../core design_system: path: ../design_system feature_wordpress: diff --git a/kell_creations_apps/packages/feature_orders/test/custom_order_spec_test.dart b/kell_creations_apps/packages/feature_orders/test/custom_order_spec_test.dart new file mode 100644 index 0000000..2a0dc34 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/custom_order_spec_test.dart @@ -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 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'))); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/deposit_info_test.dart b/kell_creations_apps/packages/feature_orders/test/deposit_info_test.dart new file mode 100644 index 0000000..4a36977 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/deposit_info_test.dart @@ -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'))); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_custom_test.dart b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_custom_test.dart new file mode 100644 index 0000000..d59900d --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_custom_test.dart @@ -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.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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart index 8f315c9..68e5914 100644 --- a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart @@ -10,9 +10,16 @@ void main() { }); 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(); - 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 { @@ -172,7 +179,7 @@ void main() { await repository.createOrder(newOrder); final orders = await repository.getOrders(); - expect(orders.length, 7); + expect(orders.length, 12); expect(orders.any((o) => o.id == 'KC-8888'), true); }); diff --git a/kell_creations_apps/packages/feature_orders/test/fulfillment_status_test.dart b/kell_creations_apps/packages/feature_orders/test/fulfillment_status_test.dart new file mode 100644 index 0000000..c829fe8 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/fulfillment_status_test.dart @@ -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.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('@'))); + } + } + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/order_type_test.dart b/kell_creations_apps/packages/feature_orders/test/order_type_test.dart new file mode 100644 index 0000000..ae44ae2 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/order_type_test.dart @@ -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); + } + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart index 747b538..21988f4 100644 --- a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart @@ -38,7 +38,8 @@ void main() { await controller.load(); 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!.id, 'KC-1001'); expect(controller.error, isNull); @@ -56,8 +57,9 @@ void main() { test('setFilter filters by order status', () async { await controller.load(); + // KC-1004, KC-1006, and the quoted custom order KC-C2001 are pending. controller.setFilter('pending'); - expect(controller.orders.length, 2); + expect(controller.orders.length, 3); expect(controller.orders.every((o) => o.status == OrderStatus.pending), true); }); @@ -68,7 +70,7 @@ void main() { expect(controller.orders.length, 1); controller.setFilter(null); - expect(controller.orders.length, 6); + expect(controller.orders.length, 11); }); test('setSearchQuery filters by customer name', () async { @@ -151,16 +153,16 @@ void main() { test('reloads order list after status update', () async { await controller.load(); - // Initially 2 pending orders. + // Initially 3 pending orders (KC-1004, KC-1006, KC-C2001). controller.setFilter('pending'); - expect(controller.orders.length, 2); + expect(controller.orders.length, 3); controller.setFilter(null); await controller.updateOrderStatus('KC-1004', OrderStatus.processing); - // Now only 1 pending order. + // Now only 2 pending orders. controller.setFilter('pending'); - expect(controller.orders.length, 1); + expect(controller.orders.length, 2); }); test('sets failure result for unknown order ID', () async { @@ -253,7 +255,7 @@ void main() { group('OrdersController — createOrder', () { test('creates order and adds it to the list', () async { await controller.load(); - expect(controller.orders.length, 6); + expect(controller.orders.length, 11); final newOrder = Order( id: 'KC-9999', @@ -270,7 +272,7 @@ void main() { await controller.createOrder(newOrder); expect(controller.lastActionResult!.success, true); - expect(controller.orders.length, 7); + expect(controller.orders.length, 12); }); test('new order appears in list after creation', () async { @@ -330,7 +332,7 @@ void main() { await readOnlyController.createOrder(newOrder); expect(readOnlyController.lastActionResult, isNull); - expect(readOnlyController.orders.length, 6); + expect(readOnlyController.orders.length, 11); readOnlyController.dispose(); }); }); @@ -360,13 +362,13 @@ void main() { test('active filter is preserved after status update', () async { await controller.load(); controller.setFilter('pending'); - expect(controller.orders.length, 2); + expect(controller.orders.length, 3); 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.orders.length, 1); + expect(controller.orders.length, 2); }); test('search query is preserved after note addition', () async { diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart index b6008c9..dbf86d3 100644 --- a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart @@ -78,6 +78,29 @@ class _EmptyOrdersRepository implements OrdersRepository { @override Future createOrder(Order order) async => const OrderActionResult.failure('Not implemented.'); + + @override + Future createCustomOrder( + Order order, { + required CustomOrderSpec spec, + DepositInfo? deposit, + }) async => const OrderActionResult.failure('Not implemented.'); + + @override + Future updateCustomOrderSpec(String id, CustomOrderSpec spec) async => + const OrderActionResult.failure('Not implemented.'); + + @override + Future recordDeposit( + String id, { + required int amountCents, + required DateTime paidAt, + int? totalAmountCents, + }) async => const OrderActionResult.failure('Not implemented.'); + + @override + Future advanceFulfillmentStatus(String id, FulfillmentStatus next) async => + const OrderActionResult.failure('Not implemented.'); } // ── Tests ────────────────────────────────────────────────────────────────────── @@ -290,7 +313,8 @@ void main() { testWidgets('shows order count', (tester) async { await tester.pumpWidget(_buildPage(FakeOrdersRepository())); 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 { diff --git a/kell_creations_apps/packages/feature_orders/test/woo_commerce_custom_order_test.dart b/kell_creations_apps/packages/feature_orders/test/woo_commerce_custom_order_test.dart new file mode 100644 index 0000000..874de89 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/woo_commerce_custom_order_test.dart @@ -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 orderJson({ + String id = '3001', + String status = 'processing', + List> 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': >[], + }, + ], + 'meta_data': metaData, + }; +} + +/// The full custom-order meta set for a `depositReceived` commission. +List> 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> 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>(); + 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>? sentMeta; + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + 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>? sentMeta; + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + 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()); + }); + + 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 = []; + 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? putBody; + final client = MockClient((request) async { + if (request.method == 'PUT') { + putBody = jsonDecode(request.body) as Map; + } + 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>? sentMeta; + final client = MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + } + 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>? sentMeta; + final client = MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + } + 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 = []; + 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> 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>? sentMeta; + final client = MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + 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>? sentMeta; + final client = MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + 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>? sentMeta; + final client = MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + 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>? sentMeta; + final client = MockClient((request) async { + if (request.method == 'PUT') { + final body = jsonDecode(request.body) as Map; + sentMeta = (body['meta_data'] as List).cast>(); + } + 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 = []; + 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())); + + 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart index 8504944..82f2847 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -218,6 +218,37 @@ class WooCommerceApiClient implements ApiClient { 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> updateOrder(String orderId, Map 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) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } + /// Creates a new order in WooCommerce. /// /// [body] should contain the WooCommerce order fields.