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.