Merge feat/custom-order-domain: Stage 11A custom order domain and data (1898/1898 tests passing)
This commit is contained in:
commit
85501fdaaa
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,29 @@ class _StubOrdersRepository implements OrdersRepository {
|
|||
|
||||
@override
|
||||
Future<OrderActionResult> createOrder(Order order) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> createCustomOrder(
|
||||
Order order, {
|
||||
required CustomOrderSpec spec,
|
||||
DepositInfo? deposit,
|
||||
}) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> recordDeposit(
|
||||
String id, {
|
||||
required int amountCents,
|
||||
required DateTime paidAt,
|
||||
int? totalAmountCents,
|
||||
}) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
class _FailingInventoryRepository implements InventoryRepository {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
|
|
@ -0,0 +1 @@
|
|||
TODO: Add your license here.
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
Order management for the Kell Creations operations platform. Covers both
|
||||
standard catalogue orders and made-to-order custom commissions behind a single
|
||||
`Order` aggregate and one `OrdersRepository` contract.
|
||||
|
||||
## Features
|
||||
|
||||
- **Domain model** — `Order`, `OrderItem`, `OrderStatus`, `OrderSource`,
|
||||
`OrderType`, `PaymentMethod`, `PaymentResult`.
|
||||
- **Custom orders** — `CustomOrderSpec` (material, colour, size, dimensions,
|
||||
reference images, notes, estimated completion) and `DepositInfo` (deposit and
|
||||
total amounts in integer cents, deposit/settlement dates, derived balance).
|
||||
- **Fulfillment pipeline** — `FulfillmentStatus` with enforced transitions:
|
||||
`quoted → depositReceived → inProduction → qualityCheck → readyForPickup →
|
||||
completed`, plus `cancelled` from any non-terminal stage and a
|
||||
`qualityCheck → inProduction` rework path. Illegal jumps are rejected by the
|
||||
domain, not silently applied.
|
||||
- **Repository contract** — `OrdersRepository` with read, status, note, and
|
||||
create operations plus `createCustomOrder`, `updateCustomOrderSpec`,
|
||||
`recordDeposit`, and `advanceFulfillmentStatus`.
|
||||
- **Implementations** — `FakeOrdersRepository` (seeded standard and custom
|
||||
orders across every pipeline stage) and `WooCommerceOrdersRepository`
|
||||
(REST API v3, custom order state round-tripped through `_kc_`-prefixed order
|
||||
meta-data).
|
||||
- **Application layer** — `OrdersController`, `CheckoutController`, and the
|
||||
`GetOrders` / `UpdateOrderStatus` / `AddOrderNote` / `CreateOrder` use cases.
|
||||
- **Presentation** — `OrdersPage`, `OrderCard`, `OrderDetailPanel`,
|
||||
`OrderStatusChip`.
|
||||
|
||||
## Getting started
|
||||
|
||||
Add the package as a path dependency and import the barrel:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
feature_orders:
|
||||
path: ../feature_orders
|
||||
```
|
||||
|
||||
```dart
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```dart
|
||||
final repository = FakeOrdersRepository(logger: observability.logger);
|
||||
|
||||
final result = await repository.createCustomOrder(
|
||||
order,
|
||||
spec: CustomOrderSpec(
|
||||
material: 'Organic cotton',
|
||||
color: 'Sage green',
|
||||
size: 'Large',
|
||||
),
|
||||
deposit: const DepositInfo.quoted(
|
||||
totalAmountCents: 7200,
|
||||
depositAmountCents: 2400,
|
||||
),
|
||||
);
|
||||
|
||||
// Advance through the pipeline — illegal jumps are rejected.
|
||||
await repository.recordDeposit(
|
||||
order.id,
|
||||
amountCents: 2400,
|
||||
paidAt: DateTime.now(),
|
||||
);
|
||||
await repository.advanceFulfillmentStatus(order.id, FulfillmentStatus.inProduction);
|
||||
```
|
||||
|
||||
## Observability and privacy
|
||||
|
||||
Repository implementations accept an optional `KcLogger` from `core`. Failed
|
||||
and rejected write operations are logged with the operation name, order ID, and
|
||||
status names only. Customer identity, addresses, money amounts, and deposit
|
||||
payment identifiers are never logged, and every context map is additionally
|
||||
passed through `KcRedactor`. There are no silent `catch (_) {}` blocks.
|
||||
|
||||
The WooCommerce meta-data mapping deliberately excludes the deposit
|
||||
transaction ID — it lives on `PaymentResult` alone. See the class-level
|
||||
documentation on `WooCommerceOrderMapper` for the complete key table.
|
||||
|
||||
## Additional information
|
||||
|
||||
Part of the `kell_creations_apps` monorepo. Run the package tests with
|
||||
`flutter test` from this directory, or the whole suite with
|
||||
`./tools/run_all_tests.sh --analyze` from `kell_creations_apps/`.
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<Order> _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<void>.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<OrderActionResult> addOrderNote(String id, String note) async {
|
||||
await Future<void>.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<OrderActionResult> createOrder(Order order) async {
|
||||
await Future<void>.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<OrderActionResult> createCustomOrder(
|
||||
Order order, {
|
||||
required CustomOrderSpec spec,
|
||||
DepositInfo? deposit,
|
||||
}) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
if (_orders.any((o) => o.id == order.id)) {
|
||||
return _rejected(
|
||||
'Order with ID ${order.id} already exists.',
|
||||
operation: 'createCustomOrder',
|
||||
context: {'orderId': order.id},
|
||||
);
|
||||
}
|
||||
|
||||
final created = order.copyWith(
|
||||
orderType: OrderType.custom,
|
||||
customOrderSpec: spec,
|
||||
depositInfo: deposit,
|
||||
fulfillmentStatus: FulfillmentStatus.quoted,
|
||||
);
|
||||
_orders.add(created);
|
||||
return OrderActionResult.success(created);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
final index = _orders.indexWhere((o) => o.id == id);
|
||||
if (index == -1) {
|
||||
return _rejected(
|
||||
'Order not found.',
|
||||
operation: 'updateCustomOrderSpec',
|
||||
context: {'orderId': id},
|
||||
);
|
||||
}
|
||||
if (!_orders[index].isCustom) {
|
||||
return _rejected(
|
||||
'Order $id is not a custom order.',
|
||||
operation: 'updateCustomOrderSpec',
|
||||
context: {'orderId': id, 'orderType': _orders[index].orderType.name},
|
||||
);
|
||||
}
|
||||
|
||||
final updated = _orders[index].copyWith(customOrderSpec: spec);
|
||||
_orders[index] = updated;
|
||||
return OrderActionResult.success(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> recordDeposit(
|
||||
String id, {
|
||||
required int amountCents,
|
||||
required DateTime paidAt,
|
||||
int? totalAmountCents,
|
||||
}) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
if (amountCents <= 0) {
|
||||
return _rejected(
|
||||
'Deposit amount must be greater than zero.',
|
||||
operation: 'recordDeposit',
|
||||
context: {'orderId': id},
|
||||
);
|
||||
}
|
||||
|
||||
final index = _orders.indexWhere((o) => o.id == id);
|
||||
if (index == -1) {
|
||||
return _rejected('Order not found.', operation: 'recordDeposit', context: {'orderId': id});
|
||||
}
|
||||
|
||||
final existing = _orders[index];
|
||||
if (!existing.isCustom) {
|
||||
return _rejected(
|
||||
'Order $id is not a custom order.',
|
||||
operation: 'recordDeposit',
|
||||
context: {'orderId': id, 'orderType': existing.orderType.name},
|
||||
);
|
||||
}
|
||||
if (existing.depositInfo?.depositPaid ?? false) {
|
||||
return _rejected(
|
||||
'A deposit has already been recorded for order $id.',
|
||||
operation: 'recordDeposit',
|
||||
context: {'orderId': id},
|
||||
);
|
||||
}
|
||||
|
||||
final base =
|
||||
existing.depositInfo ??
|
||||
DepositInfo(
|
||||
depositAmountCents: amountCents,
|
||||
totalAmountCents: totalAmountCents ?? amountCents,
|
||||
);
|
||||
final revised = totalAmountCents != null
|
||||
? base.copyWith(totalAmountCents: totalAmountCents)
|
||||
: base;
|
||||
|
||||
final updated = existing.copyWith(
|
||||
depositInfo: revised.markDepositPaid(paidAt: paidAt, amountCents: amountCents),
|
||||
);
|
||||
_orders[index] = updated;
|
||||
return OrderActionResult.success(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
final index = _orders.indexWhere((o) => o.id == id);
|
||||
if (index == -1) {
|
||||
return _rejected(
|
||||
'Order not found.',
|
||||
operation: 'advanceFulfillmentStatus',
|
||||
context: {'orderId': id, 'requestedStage': next.name},
|
||||
);
|
||||
}
|
||||
|
||||
final existing = _orders[index];
|
||||
if (!existing.isCustom) {
|
||||
return _rejected(
|
||||
'Order $id is not a custom order.',
|
||||
operation: 'advanceFulfillmentStatus',
|
||||
context: {'orderId': id, 'orderType': existing.orderType.name},
|
||||
);
|
||||
}
|
||||
|
||||
final current = existing.fulfillmentStatus ?? FulfillmentStatus.quoted;
|
||||
final rejection = current.rejectionReason(next);
|
||||
if (rejection != null) {
|
||||
return _rejected(
|
||||
rejection,
|
||||
operation: 'advanceFulfillmentStatus',
|
||||
context: {'orderId': id, 'fromStage': current.name, 'toStage': next.name},
|
||||
);
|
||||
}
|
||||
|
||||
final updated = existing.copyWith(fulfillmentStatus: next);
|
||||
_orders[index] = updated;
|
||||
return OrderActionResult.success(updated);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Builds a failure result and records a structured warning.
|
||||
///
|
||||
/// [context] must contain only identifiers and status names. Customer
|
||||
/// identity, addresses, money amounts, and payment identifiers are never
|
||||
/// included; [KcLogger] additionally routes every value through
|
||||
/// [KcRedactor].
|
||||
OrderActionResult _rejected(
|
||||
String message, {
|
||||
required String operation,
|
||||
required Map<String, Object?> context,
|
||||
}) {
|
||||
logger.warning(
|
||||
'Order operation rejected: $message',
|
||||
context: {'operation': operation, ...context},
|
||||
);
|
||||
return OrderActionResult.failure(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> json) {
|
||||
final billing = json['billing'] as Map<String, dynamic>? ?? {};
|
||||
|
|
@ -28,9 +116,14 @@ class WooCommerceOrderMapper {
|
|||
final customerNote = (json['customer_note'] as String?) ?? '';
|
||||
final orderNotes = customerNote.isNotEmpty ? [customerNote] : <String>[];
|
||||
|
||||
// Collect custom fields from meta_data.
|
||||
// Collect custom fields from public meta_data (`_`-prefixed keys excluded).
|
||||
final customFields = _extractMetaData(json['meta_data']);
|
||||
|
||||
// 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<Map<String, dynamic>> customOrderMetaData(Order order) {
|
||||
if (!order.isCustom) return const [];
|
||||
|
||||
final entries = <String, String>{
|
||||
metaOrderType: order.orderType.wireValue,
|
||||
if (order.fulfillmentStatus != null)
|
||||
metaFulfillmentStatus: order.fulfillmentStatus!.wireValue,
|
||||
...specMetaEntries(order.customOrderSpec),
|
||||
...depositMetaEntries(order.depositInfo),
|
||||
};
|
||||
|
||||
return entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList();
|
||||
}
|
||||
|
||||
/// Builds the `meta_data` entries for [spec], or an empty map when `null`.
|
||||
Map<String, String> specMetaEntries(CustomOrderSpec? spec) {
|
||||
if (spec == null) return const {};
|
||||
return {
|
||||
metaSpecMaterial: spec.material,
|
||||
metaSpecColor: spec.color,
|
||||
if (spec.size != null) metaSpecSize: spec.size!,
|
||||
if (spec.dimensions != null) metaSpecDimensions: spec.dimensions!,
|
||||
if (spec.referenceImageUrls.isNotEmpty)
|
||||
metaSpecReferenceImages: spec.referenceImageUrls.join(referenceImageSeparator),
|
||||
if (spec.notes != null) metaSpecNotes: spec.notes!,
|
||||
if (spec.estimatedCompletionDate != null)
|
||||
metaSpecEstimatedCompletion: spec.estimatedCompletionDate!.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds the `meta_data` entries for [deposit], or an empty map when `null`.
|
||||
///
|
||||
/// `balanceDueCents` is intentionally omitted — it is derived from the
|
||||
/// deposit and total amounts on read.
|
||||
Map<String, String> depositMetaEntries(DepositInfo? deposit) {
|
||||
if (deposit == null) return const {};
|
||||
return {
|
||||
metaDepositAmountCents: deposit.depositAmountCents.toString(),
|
||||
metaDepositTotalCents: deposit.totalAmountCents.toString(),
|
||||
metaDepositPaid: deposit.depositPaid.toString(),
|
||||
if (deposit.depositDate != null) metaDepositDate: deposit.depositDate!.toIso8601String(),
|
||||
if (deposit.settlementDate != null)
|
||||
metaSettlementDate: deposit.settlementDate!.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts a `key → value` map into WooCommerce `meta_data` list form.
|
||||
static List<Map<String, dynamic>> toMetaDataList(Map<String, String> entries) =>
|
||||
entries.entries.map((e) => {'key': e.key, 'value': e.value}).toList();
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Rebuilds a [CustomOrderSpec] from internal meta, or `null` when the
|
||||
/// required material and colour are both absent.
|
||||
static CustomOrderSpec? _specFromMeta(Map<String, String> meta) {
|
||||
final material = meta[metaSpecMaterial];
|
||||
final color = meta[metaSpecColor];
|
||||
if (material == null && color == null) return null;
|
||||
|
||||
final packedImages = meta[metaSpecReferenceImages];
|
||||
final images = (packedImages == null || packedImages.isEmpty)
|
||||
? const <String>[]
|
||||
: packedImages
|
||||
.split(referenceImageSeparator)
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
return CustomOrderSpec(
|
||||
material: material ?? '',
|
||||
color: color ?? '',
|
||||
size: meta[metaSpecSize],
|
||||
dimensions: meta[metaSpecDimensions],
|
||||
referenceImageUrls: images,
|
||||
notes: meta[metaSpecNotes],
|
||||
estimatedCompletionDate: _parseNullableDate(meta[metaSpecEstimatedCompletion]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuilds a [DepositInfo] from internal meta, or `null` when no deposit
|
||||
/// amount was recorded.
|
||||
static DepositInfo? _depositFromMeta(Map<String, String> meta) {
|
||||
final depositCents = int.tryParse(meta[metaDepositAmountCents] ?? '');
|
||||
if (depositCents == null) return null;
|
||||
|
||||
return DepositInfo(
|
||||
depositAmountCents: depositCents,
|
||||
totalAmountCents: int.tryParse(meta[metaDepositTotalCents] ?? '') ?? depositCents,
|
||||
depositPaid: meta[metaDepositPaid] == 'true',
|
||||
depositDate: _parseNullableDate(meta[metaDepositDate]),
|
||||
settlementDate: _parseNullableDate(meta[metaSettlementDate]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses an ISO 8601 string, returning `null` for absent or invalid values.
|
||||
static DateTime? _parseNullableDate(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
/// Extracts `_`-prefixed (internal) meta_data entries as a String→String map.
|
||||
///
|
||||
/// Complements [_extractMetaData], which returns only the public entries.
|
||||
static Map<String, String> _extractInternalMetaData(dynamic metaData) {
|
||||
final result = <String, String>{};
|
||||
if (metaData is! List) return result;
|
||||
for (final entry in metaData.cast<Map<String, dynamic>>()) {
|
||||
final key = entry['key'] as String?;
|
||||
final value = entry['value'];
|
||||
if (key != null && key.startsWith('_') && value != null) {
|
||||
result[key] = value.toString();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Maps the WooCommerce order `status` string to our [OrderStatus] enum.
|
||||
static OrderStatus _mapStatus(String? status) {
|
||||
switch (status) {
|
||||
|
|
|
|||
|
|
@ -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<List<Order>> 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<OrderActionResult> 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<OrderActionResult> 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<OrderActionResult> 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<OrderActionResult> createOrder(Order order) {
|
||||
return _guard(
|
||||
operation: 'createOrder',
|
||||
context: {'orderId': order.id, 'orderType': order.orderType.name},
|
||||
action: () async {
|
||||
final body = _mapper.toCreateJson(order);
|
||||
final json = await _apiClient.createOrder(body);
|
||||
return _mapper.fromJson(json);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom orders ────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> createCustomOrder(
|
||||
Order order, {
|
||||
required CustomOrderSpec spec,
|
||||
DepositInfo? deposit,
|
||||
}) {
|
||||
final custom = order.copyWith(
|
||||
orderType: OrderType.custom,
|
||||
customOrderSpec: spec,
|
||||
depositInfo: deposit,
|
||||
fulfillmentStatus: FulfillmentStatus.quoted,
|
||||
);
|
||||
|
||||
return _guard(
|
||||
operation: 'createCustomOrder',
|
||||
context: {'orderId': order.id},
|
||||
action: () async {
|
||||
final body = _mapper.toCreateJson(custom);
|
||||
final json = await _apiClient.createOrder(body);
|
||||
return _mapper.fromJson(json);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) async {
|
||||
final loaded = await _loadCustomOrder(id, operation: 'updateCustomOrderSpec');
|
||||
if (loaded.failure != null) return loaded.failure!;
|
||||
|
||||
final updated = loaded.order!.copyWith(customOrderSpec: spec);
|
||||
return _pushCustomOrderMeta(id, updated, operation: 'updateCustomOrderSpec');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> recordDeposit(
|
||||
String id, {
|
||||
required int amountCents,
|
||||
required DateTime paidAt,
|
||||
int? totalAmountCents,
|
||||
}) async {
|
||||
if (amountCents <= 0) {
|
||||
return _rejected(
|
||||
'Deposit amount must be greater than zero.',
|
||||
operation: 'recordDeposit',
|
||||
context: {'orderId': id},
|
||||
);
|
||||
}
|
||||
|
||||
final loaded = await _loadCustomOrder(id, operation: 'recordDeposit');
|
||||
if (loaded.failure != null) return loaded.failure!;
|
||||
|
||||
final order = loaded.order!;
|
||||
if (order.depositInfo?.depositPaid ?? false) {
|
||||
return _rejected(
|
||||
'A deposit has already been recorded for order $id.',
|
||||
operation: 'recordDeposit',
|
||||
context: {'orderId': id},
|
||||
);
|
||||
}
|
||||
|
||||
final base =
|
||||
order.depositInfo ??
|
||||
DepositInfo(
|
||||
depositAmountCents: amountCents,
|
||||
totalAmountCents: totalAmountCents ?? amountCents,
|
||||
);
|
||||
final revised = totalAmountCents != null
|
||||
? base.copyWith(totalAmountCents: totalAmountCents)
|
||||
: base;
|
||||
|
||||
final updated = order.copyWith(
|
||||
depositInfo: revised.markDepositPaid(paidAt: paidAt, amountCents: amountCents),
|
||||
);
|
||||
return _pushCustomOrderMeta(id, updated, operation: 'recordDeposit');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async {
|
||||
final loaded = await _loadCustomOrder(id, operation: 'advanceFulfillmentStatus');
|
||||
if (loaded.failure != null) return loaded.failure!;
|
||||
|
||||
final order = loaded.order!;
|
||||
final current = order.fulfillmentStatus ?? FulfillmentStatus.quoted;
|
||||
final rejection = current.rejectionReason(next);
|
||||
if (rejection != null) {
|
||||
return _rejected(
|
||||
rejection,
|
||||
operation: 'advanceFulfillmentStatus',
|
||||
context: {'orderId': id, 'fromStage': current.name, 'toStage': next.name},
|
||||
);
|
||||
}
|
||||
|
||||
final updated = order.copyWith(fulfillmentStatus: next);
|
||||
return _pushCustomOrderMeta(id, updated, operation: 'advanceFulfillmentStatus');
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Fetches [id] and verifies it is a custom order.
|
||||
///
|
||||
/// Returns a record carrying either the loaded order or the failure result
|
||||
/// to hand back to the caller — never both.
|
||||
Future<({Order? order, OrderActionResult? failure})> _loadCustomOrder(
|
||||
String id, {
|
||||
required String operation,
|
||||
}) async {
|
||||
final Order? order;
|
||||
try {
|
||||
// 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<OrderActionResult> _pushCustomOrderMeta(
|
||||
String id,
|
||||
Order order, {
|
||||
required String operation,
|
||||
}) {
|
||||
return _guard(
|
||||
operation: operation,
|
||||
context: {'orderId': id},
|
||||
action: () async {
|
||||
final json = await _apiClient.updateOrder(id, {
|
||||
'meta_data': _mapper.customOrderMetaData(order),
|
||||
});
|
||||
return _mapper.fromJson(json);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs [action], converting any exception into a logged failure result.
|
||||
///
|
||||
/// [context] must contain only identifiers, status names, and counts —
|
||||
/// never customer identity, money amounts, or payment identifiers.
|
||||
Future<OrderActionResult> _guard({
|
||||
required String operation,
|
||||
required Map<String, Object?> context,
|
||||
required Future<Order> Function() action,
|
||||
}) async {
|
||||
try {
|
||||
return OrderActionResult.success(await action());
|
||||
} on WooCommerceApiException catch (e, stackTrace) {
|
||||
logger.error(
|
||||
'WooCommerce order operation failed.',
|
||||
context: {'operation': operation, ...context, 'statusCode': e.statusCode},
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}');
|
||||
} 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<OrderActionResult> 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<String, Object?> context,
|
||||
}) {
|
||||
logger.warning(
|
||||
'Order operation rejected: $message',
|
||||
context: {'operation': operation, ...context},
|
||||
);
|
||||
return OrderActionResult.failure(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
/// The customer-agreed specification for a made-to-order commission.
|
||||
///
|
||||
/// Immutable value object. Every field is optional except [material] and
|
||||
/// [color] because market intake is often incremental — an operator captures
|
||||
/// what the customer knows at the stall and refines the rest later via
|
||||
/// `OrdersRepository.updateCustomOrderSpec`.
|
||||
///
|
||||
/// Money is not modelled here; deposit and balance amounts live on
|
||||
/// [DepositInfo] as integer cents.
|
||||
class CustomOrderSpec {
|
||||
/// The primary material (e.g. `'Cotton yarn'`, `'Slate'`, `'Birch ply'`).
|
||||
final String material;
|
||||
|
||||
/// The requested colour or colourway (e.g. `'Sage green'`).
|
||||
final String color;
|
||||
|
||||
/// A named size where the shop offers standard sizes (e.g. `'Large'`).
|
||||
///
|
||||
/// `null` when the piece is dimensioned rather than sized.
|
||||
final String? size;
|
||||
|
||||
/// Free-form dimensions when [size] does not apply (e.g. `'12" x 8" x 3"'`).
|
||||
final String? dimensions;
|
||||
|
||||
/// URLs of customer-supplied reference images.
|
||||
///
|
||||
/// Stored as an unmodifiable list. These are references to uploaded media,
|
||||
/// never raw image bytes.
|
||||
final List<String> referenceImageUrls;
|
||||
|
||||
/// Free-text notes captured from the customer (special instructions,
|
||||
/// occasion, personalisation text).
|
||||
final String? notes;
|
||||
|
||||
/// The date the operator expects the piece to be ready.
|
||||
///
|
||||
/// `null` until the operator has scheduled production.
|
||||
final DateTime? estimatedCompletionDate;
|
||||
|
||||
CustomOrderSpec({
|
||||
required this.material,
|
||||
required this.color,
|
||||
this.size,
|
||||
this.dimensions,
|
||||
List<String> referenceImageUrls = const [],
|
||||
this.notes,
|
||||
this.estimatedCompletionDate,
|
||||
}) : referenceImageUrls = List.unmodifiable(referenceImageUrls);
|
||||
|
||||
/// Whether the specification carries enough detail to start production.
|
||||
///
|
||||
/// A spec needs a material, a colour, and either a named size or explicit
|
||||
/// dimensions before it can be quoted reliably.
|
||||
bool get isProductionReady =>
|
||||
material.trim().isNotEmpty &&
|
||||
color.trim().isNotEmpty &&
|
||||
((size != null && size!.trim().isNotEmpty) ||
|
||||
(dimensions != null && dimensions!.trim().isNotEmpty));
|
||||
|
||||
/// Whether the customer supplied any reference imagery.
|
||||
bool get hasReferenceImages => referenceImageUrls.isNotEmpty;
|
||||
|
||||
/// A single-line summary suitable for list rows and order cards.
|
||||
///
|
||||
/// Contains only specification detail — never customer identity.
|
||||
String get summary {
|
||||
final parts = <String>[
|
||||
material,
|
||||
color,
|
||||
if (size != null && size!.trim().isNotEmpty) size!,
|
||||
if (dimensions != null && dimensions!.trim().isNotEmpty) dimensions!,
|
||||
].where((p) => p.trim().isNotEmpty);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/// Returns a copy of this specification with the given fields replaced.
|
||||
///
|
||||
/// Nullable fields cannot be cleared through [copyWith]; construct a new
|
||||
/// [CustomOrderSpec] when a field must be removed. This mirrors the
|
||||
/// behaviour of `Order.copyWith` and `OrderItem.copyWith`.
|
||||
CustomOrderSpec copyWith({
|
||||
String? material,
|
||||
String? color,
|
||||
String? size,
|
||||
String? dimensions,
|
||||
List<String>? referenceImageUrls,
|
||||
String? notes,
|
||||
DateTime? estimatedCompletionDate,
|
||||
}) {
|
||||
return CustomOrderSpec(
|
||||
material: material ?? this.material,
|
||||
color: color ?? this.color,
|
||||
size: size ?? this.size,
|
||||
dimensions: dimensions ?? this.dimensions,
|
||||
referenceImageUrls: referenceImageUrls ?? this.referenceImageUrls,
|
||||
notes: notes ?? this.notes,
|
||||
estimatedCompletionDate: estimatedCompletionDate ?? this.estimatedCompletionDate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CustomOrderSpec &&
|
||||
runtimeType == other.runtimeType &&
|
||||
material == other.material &&
|
||||
color == other.color &&
|
||||
size == other.size &&
|
||||
dimensions == other.dimensions &&
|
||||
_listEquals(referenceImageUrls, other.referenceImageUrls) &&
|
||||
notes == other.notes &&
|
||||
estimatedCompletionDate == other.estimatedCompletionDate;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
material,
|
||||
color,
|
||||
size,
|
||||
dimensions,
|
||||
Object.hashAll(referenceImageUrls),
|
||||
notes,
|
||||
estimatedCompletionDate,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CustomOrderSpec(material: $material, color: $color, size: $size, '
|
||||
'dimensions: $dimensions, referenceImages: ${referenceImageUrls.length}, '
|
||||
'estimatedCompletionDate: $estimatedCompletionDate)';
|
||||
|
||||
static bool _listEquals(List<String> a, List<String> b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/// The production pipeline stage of a custom order.
|
||||
///
|
||||
/// This is **separate from** `OrderStatus`, which describes the commercial
|
||||
/// state of any order (pending / processing / shipped / …). A custom order
|
||||
/// carries both: `OrderStatus` for channel and reporting purposes, and
|
||||
/// [FulfillmentStatus] for the workshop pipeline.
|
||||
///
|
||||
/// ## Pipeline
|
||||
///
|
||||
/// ```text
|
||||
/// quoted → depositReceived → inProduction → qualityCheck → readyForPickup → completed
|
||||
/// │ │ │ │ │
|
||||
/// └────────────┴────────────────┴──────────────┴────────────────┴──→ cancelled
|
||||
/// ```
|
||||
///
|
||||
/// Transitions are enforced by [FulfillmentTransition.canTransitionTo] — the
|
||||
/// domain rejects illegal jumps rather than silently accepting them. A stage
|
||||
/// may be re-entered backwards only where rework is a real workshop
|
||||
/// operation: [qualityCheck] can return to [inProduction].
|
||||
enum FulfillmentStatus {
|
||||
/// A price has been quoted; no deposit has been taken.
|
||||
quoted('Quoted', 0),
|
||||
|
||||
/// The deposit has been collected and the commission is committed.
|
||||
depositReceived('Deposit Received', 1),
|
||||
|
||||
/// The piece is being made.
|
||||
inProduction('In Production', 2),
|
||||
|
||||
/// The piece is finished and awaiting operator inspection.
|
||||
qualityCheck('Quality Check', 3),
|
||||
|
||||
/// The piece has passed inspection and is awaiting collection or dispatch.
|
||||
readyForPickup('Ready for Pickup', 4),
|
||||
|
||||
/// The customer has taken delivery and the balance has been settled.
|
||||
completed('Completed', 5),
|
||||
|
||||
/// The commission was abandoned before completion.
|
||||
///
|
||||
/// Terminal. Ordinal is deliberately outside the linear pipeline.
|
||||
cancelled('Cancelled', -1);
|
||||
|
||||
/// Human-readable label for display in the UI.
|
||||
final String label;
|
||||
|
||||
/// Position in the linear pipeline; `-1` for [cancelled].
|
||||
///
|
||||
/// Used for progress indicators, not for transition validation.
|
||||
final int stageIndex;
|
||||
|
||||
const FulfillmentStatus(this.label, this.stageIndex);
|
||||
|
||||
/// The value persisted to channel meta-data (see
|
||||
/// `WooCommerceOrderMapper.metaFulfillmentStatus`).
|
||||
String get wireValue => name;
|
||||
|
||||
/// Parses a persisted [wireValue], defaulting to [FulfillmentStatus.quoted].
|
||||
static FulfillmentStatus fromWire(String? value) {
|
||||
if (value == null) return FulfillmentStatus.quoted;
|
||||
for (final status in values) {
|
||||
if (status.name == value) return status;
|
||||
}
|
||||
return FulfillmentStatus.quoted;
|
||||
}
|
||||
|
||||
/// The number of stages in the linear pipeline, excluding [cancelled].
|
||||
static int get pipelineLength => values.where((s) => s.stageIndex >= 0).length;
|
||||
|
||||
/// The linear pipeline stages in order, excluding [cancelled].
|
||||
static List<FulfillmentStatus> get pipeline =>
|
||||
List.unmodifiable(values.where((s) => s.stageIndex >= 0).toList()..sort(_byStageIndex));
|
||||
|
||||
static int _byStageIndex(FulfillmentStatus a, FulfillmentStatus b) =>
|
||||
a.stageIndex.compareTo(b.stageIndex);
|
||||
}
|
||||
|
||||
/// Transition rules for the custom order fulfillment pipeline.
|
||||
extension FulfillmentTransition on FulfillmentStatus {
|
||||
/// Whether this stage is terminal — no further transitions are permitted.
|
||||
bool get isTerminal => this == FulfillmentStatus.completed || this == FulfillmentStatus.cancelled;
|
||||
|
||||
/// Whether the commission reached a successful conclusion.
|
||||
bool get isSuccessful => this == FulfillmentStatus.completed;
|
||||
|
||||
/// Whether a deposit is expected to have been collected by this stage.
|
||||
bool get requiresDeposit => stageIndex >= FulfillmentStatus.depositReceived.stageIndex;
|
||||
|
||||
/// The stages that may legally follow this one.
|
||||
///
|
||||
/// Returns an unmodifiable, possibly empty list. Terminal stages return an
|
||||
/// empty list.
|
||||
List<FulfillmentStatus> get allowedNextStatuses {
|
||||
switch (this) {
|
||||
case FulfillmentStatus.quoted:
|
||||
return const [FulfillmentStatus.depositReceived, FulfillmentStatus.cancelled];
|
||||
case FulfillmentStatus.depositReceived:
|
||||
return const [FulfillmentStatus.inProduction, FulfillmentStatus.cancelled];
|
||||
case FulfillmentStatus.inProduction:
|
||||
return const [FulfillmentStatus.qualityCheck, FulfillmentStatus.cancelled];
|
||||
case FulfillmentStatus.qualityCheck:
|
||||
// Failed inspection sends the piece back to the bench for rework.
|
||||
return const [
|
||||
FulfillmentStatus.readyForPickup,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.cancelled,
|
||||
];
|
||||
case FulfillmentStatus.readyForPickup:
|
||||
return const [FulfillmentStatus.completed, FulfillmentStatus.cancelled];
|
||||
case FulfillmentStatus.completed:
|
||||
case FulfillmentStatus.cancelled:
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether moving from this stage to [next] is legal.
|
||||
///
|
||||
/// A transition to the same stage is **not** legal — advancing must make
|
||||
/// progress, and a no-op advance almost always signals a double submission.
|
||||
bool canTransitionTo(FulfillmentStatus next) => allowedNextStatuses.contains(next);
|
||||
|
||||
/// A human-readable explanation of why moving to [next] is rejected, or
|
||||
/// `null` when the transition is legal.
|
||||
///
|
||||
/// Messages contain only status names — never customer or payment detail —
|
||||
/// so they are safe to surface in the UI and to attach to log context.
|
||||
String? rejectionReason(FulfillmentStatus next) {
|
||||
if (canTransitionTo(next)) return null;
|
||||
if (next == this) {
|
||||
return 'Order is already ${label.toLowerCase()}.';
|
||||
}
|
||||
if (isTerminal) {
|
||||
return 'Cannot change fulfillment status: order is already ${label.toLowerCase()}.';
|
||||
}
|
||||
final allowed = allowedNextStatuses.map((s) => s.label).join(', ');
|
||||
return 'Cannot move from ${label.toLowerCase()} to ${next.label.toLowerCase()}. '
|
||||
'Allowed next: $allowed.';
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> customFields;
|
||||
|
||||
/// Whether this is a catalogue purchase or a made-to-order commission.
|
||||
///
|
||||
/// Defaults to [OrderType.standard] so every existing call site and every
|
||||
/// order deserialised from a channel that predates custom orders keeps its
|
||||
/// previous meaning.
|
||||
final OrderType orderType;
|
||||
|
||||
/// The agreed specification, for custom orders only.
|
||||
///
|
||||
/// `null` for standard orders.
|
||||
final CustomOrderSpec? customOrderSpec;
|
||||
|
||||
/// Deposit and balance state, for custom orders only.
|
||||
///
|
||||
/// `null` for standard orders and for custom orders that have not been
|
||||
/// quoted yet.
|
||||
final DepositInfo? depositInfo;
|
||||
|
||||
/// The workshop pipeline stage, for custom orders only.
|
||||
///
|
||||
/// `null` for standard orders. Advanced through
|
||||
/// `OrdersRepository.advanceFulfillmentStatus`, which enforces the legal
|
||||
/// transitions defined by [FulfillmentTransition].
|
||||
final FulfillmentStatus? fulfillmentStatus;
|
||||
|
||||
const Order({
|
||||
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<String, String>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<Order>> getOrders();
|
||||
|
|
@ -24,4 +31,58 @@ abstract class OrdersRepository {
|
|||
///
|
||||
/// Returns an [OrderActionResult] with the created order on success.
|
||||
Future<OrderActionResult> createOrder(Order order);
|
||||
|
||||
// ── Custom orders ──────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a made-to-order commission and persists it.
|
||||
///
|
||||
/// [order] is stamped with `OrderType.custom`, the supplied [spec], the
|
||||
/// optional [deposit], and an initial [FulfillmentStatus.quoted] stage.
|
||||
/// Any custom-order fields already present on [order] are overwritten by
|
||||
/// the explicit arguments so the caller cannot create an inconsistent
|
||||
/// aggregate.
|
||||
///
|
||||
/// Returns a failure result when the order ID already exists.
|
||||
Future<OrderActionResult> createCustomOrder(
|
||||
Order order, {
|
||||
required CustomOrderSpec spec,
|
||||
DepositInfo? deposit,
|
||||
});
|
||||
|
||||
/// Replaces the specification on the custom order identified by [id].
|
||||
///
|
||||
/// Returns a failure result when the order does not exist or is not a
|
||||
/// custom order.
|
||||
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec);
|
||||
|
||||
/// Records a deposit payment against the custom order identified by [id].
|
||||
///
|
||||
/// [amountCents] is the amount collected, in integer cents. [paidAt] is the
|
||||
/// timestamp of collection. When the order has no [DepositInfo] yet, one is
|
||||
/// created using [totalAmountCents] as the agreed price; when it already has
|
||||
/// one, [totalAmountCents] (if supplied) revises the agreed price.
|
||||
///
|
||||
/// Implementations must **not** accept or persist a payment identifier here
|
||||
/// — transaction IDs belong to `PaymentResult` and must never reach order
|
||||
/// meta-data or a log record.
|
||||
///
|
||||
/// Returns a failure result when the order does not exist, is not a custom
|
||||
/// order, [amountCents] is not positive, or the deposit was already
|
||||
/// recorded.
|
||||
Future<OrderActionResult> recordDeposit(
|
||||
String id, {
|
||||
required int amountCents,
|
||||
required DateTime paidAt,
|
||||
int? totalAmountCents,
|
||||
});
|
||||
|
||||
/// Advances the custom order identified by [id] to [next].
|
||||
///
|
||||
/// The transition is validated against [FulfillmentTransition]; illegal
|
||||
/// jumps are rejected with an explanatory failure result and leave the
|
||||
/// order unchanged.
|
||||
///
|
||||
/// Returns a failure result when the order does not exist, is not a custom
|
||||
/// order, or the transition is illegal.
|
||||
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final completion = DateTime(2026, 5, 1);
|
||||
|
||||
CustomOrderSpec makeSpec({
|
||||
String material = 'Organic cotton',
|
||||
String color = 'Sage green',
|
||||
String? size = 'Large',
|
||||
String? dimensions,
|
||||
List<String> referenceImageUrls = const [],
|
||||
String? notes,
|
||||
DateTime? estimatedCompletionDate,
|
||||
}) {
|
||||
return CustomOrderSpec(
|
||||
material: material,
|
||||
color: color,
|
||||
size: size,
|
||||
dimensions: dimensions,
|
||||
referenceImageUrls: referenceImageUrls,
|
||||
notes: notes,
|
||||
estimatedCompletionDate: estimatedCompletionDate,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Construction ──────────────────────────────────────────────────────────
|
||||
|
||||
group('CustomOrderSpec — construction', () {
|
||||
test('retains required fields', () {
|
||||
final spec = makeSpec();
|
||||
expect(spec.material, 'Organic cotton');
|
||||
expect(spec.color, 'Sage green');
|
||||
});
|
||||
|
||||
test('optional fields default to null', () {
|
||||
final spec = CustomOrderSpec(material: 'Slate', color: 'Charcoal');
|
||||
expect(spec.size, isNull);
|
||||
expect(spec.dimensions, isNull);
|
||||
expect(spec.notes, isNull);
|
||||
expect(spec.estimatedCompletionDate, isNull);
|
||||
});
|
||||
|
||||
test('referenceImageUrls defaults to empty', () {
|
||||
final spec = CustomOrderSpec(material: 'Slate', color: 'Charcoal');
|
||||
expect(spec.referenceImageUrls, isEmpty);
|
||||
});
|
||||
|
||||
test('referenceImageUrls is unmodifiable', () {
|
||||
final spec = makeSpec(referenceImageUrls: const ['https://example.test/a.jpg']);
|
||||
expect(
|
||||
() => spec.referenceImageUrls.add('https://example.test/b.jpg'),
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('mutating the source list does not affect the spec', () {
|
||||
final source = ['https://example.test/a.jpg'];
|
||||
final spec = makeSpec(referenceImageUrls: source);
|
||||
source.add('https://example.test/b.jpg');
|
||||
expect(spec.referenceImageUrls.length, 1);
|
||||
});
|
||||
|
||||
test('retains all optional fields when supplied', () {
|
||||
final spec = makeSpec(
|
||||
size: null,
|
||||
dimensions: '16" x 10"',
|
||||
notes: 'Felt feet please.',
|
||||
estimatedCompletionDate: completion,
|
||||
);
|
||||
expect(spec.dimensions, '16" x 10"');
|
||||
expect(spec.notes, 'Felt feet please.');
|
||||
expect(spec.estimatedCompletionDate, completion);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Derived state ─────────────────────────────────────────────────────────
|
||||
|
||||
group('CustomOrderSpec — isProductionReady', () {
|
||||
test('true with material, color, and size', () {
|
||||
expect(makeSpec().isProductionReady, true);
|
||||
});
|
||||
|
||||
test('true with material, color, and dimensions', () {
|
||||
expect(makeSpec(size: null, dimensions: '4" round').isProductionReady, true);
|
||||
});
|
||||
|
||||
test('false without a size or dimensions', () {
|
||||
expect(makeSpec(size: null).isProductionReady, false);
|
||||
});
|
||||
|
||||
test('false with a blank material', () {
|
||||
expect(makeSpec(material: ' ').isProductionReady, false);
|
||||
});
|
||||
|
||||
test('false with a blank color', () {
|
||||
expect(makeSpec(color: '').isProductionReady, false);
|
||||
});
|
||||
|
||||
test('false when size is whitespace only and no dimensions', () {
|
||||
expect(makeSpec(size: ' ').isProductionReady, false);
|
||||
});
|
||||
|
||||
test('true when size is whitespace but dimensions are set', () {
|
||||
expect(makeSpec(size: ' ', dimensions: '4" round').isProductionReady, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('CustomOrderSpec — hasReferenceImages', () {
|
||||
test('false when no images supplied', () {
|
||||
expect(makeSpec().hasReferenceImages, false);
|
||||
});
|
||||
|
||||
test('true when images supplied', () {
|
||||
expect(makeSpec(referenceImageUrls: const ['a']).hasReferenceImages, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('CustomOrderSpec — summary', () {
|
||||
test('joins material, color, and size', () {
|
||||
expect(makeSpec().summary, 'Organic cotton · Sage green · Large');
|
||||
});
|
||||
|
||||
test('includes dimensions when present', () {
|
||||
expect(
|
||||
makeSpec(size: null, dimensions: '4" round').summary,
|
||||
'Organic cotton · Sage green · 4" round',
|
||||
);
|
||||
});
|
||||
|
||||
test('includes both size and dimensions when both present', () {
|
||||
expect(
|
||||
makeSpec(size: 'Large', dimensions: '4" round').summary,
|
||||
'Organic cotton · Sage green · Large · 4" round',
|
||||
);
|
||||
});
|
||||
|
||||
test('omits blank segments', () {
|
||||
expect(makeSpec(size: ' ').summary, 'Organic cotton · Sage green');
|
||||
});
|
||||
|
||||
test('never contains customer identity', () {
|
||||
final spec = makeSpec(notes: 'For Nadia Okafor, nadia@example.com');
|
||||
expect(spec.summary, isNot(contains('Nadia')));
|
||||
expect(spec.summary, isNot(contains('@')));
|
||||
});
|
||||
});
|
||||
|
||||
// ── copyWith ──────────────────────────────────────────────────────────────
|
||||
|
||||
group('CustomOrderSpec — copyWith', () {
|
||||
test('returns an equal object when no fields are supplied', () {
|
||||
final spec = makeSpec();
|
||||
expect(spec.copyWith(), spec);
|
||||
});
|
||||
|
||||
test('replaces material', () {
|
||||
expect(makeSpec().copyWith(material: 'Linen').material, 'Linen');
|
||||
});
|
||||
|
||||
test('replaces color', () {
|
||||
expect(makeSpec().copyWith(color: 'Rust').color, 'Rust');
|
||||
});
|
||||
|
||||
test('replaces size', () {
|
||||
expect(makeSpec().copyWith(size: 'Small').size, 'Small');
|
||||
});
|
||||
|
||||
test('replaces dimensions', () {
|
||||
expect(makeSpec().copyWith(dimensions: '2" x 2"').dimensions, '2" x 2"');
|
||||
});
|
||||
|
||||
test('replaces referenceImageUrls', () {
|
||||
final updated = makeSpec().copyWith(referenceImageUrls: const ['https://example.test/x.png']);
|
||||
expect(updated.referenceImageUrls, ['https://example.test/x.png']);
|
||||
});
|
||||
|
||||
test('replaces notes', () {
|
||||
expect(makeSpec().copyWith(notes: 'Rush job').notes, 'Rush job');
|
||||
});
|
||||
|
||||
test('replaces estimatedCompletionDate', () {
|
||||
final updated = makeSpec().copyWith(estimatedCompletionDate: completion);
|
||||
expect(updated.estimatedCompletionDate, completion);
|
||||
});
|
||||
|
||||
test('leaves untouched fields intact', () {
|
||||
final updated = makeSpec(notes: 'Original').copyWith(material: 'Linen');
|
||||
expect(updated.color, 'Sage green');
|
||||
expect(updated.size, 'Large');
|
||||
expect(updated.notes, 'Original');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Equality ──────────────────────────────────────────────────────────────
|
||||
|
||||
group('CustomOrderSpec — equality', () {
|
||||
test('specs with identical fields are equal', () {
|
||||
expect(makeSpec(), makeSpec());
|
||||
});
|
||||
|
||||
test('identical specs share a hashCode', () {
|
||||
expect(makeSpec().hashCode, makeSpec().hashCode);
|
||||
});
|
||||
|
||||
test('differing material breaks equality', () {
|
||||
expect(makeSpec(), isNot(makeSpec(material: 'Linen')));
|
||||
});
|
||||
|
||||
test('differing color breaks equality', () {
|
||||
expect(makeSpec(), isNot(makeSpec(color: 'Rust')));
|
||||
});
|
||||
|
||||
test('differing size breaks equality', () {
|
||||
expect(makeSpec(), isNot(makeSpec(size: 'Small')));
|
||||
});
|
||||
|
||||
test('differing dimensions breaks equality', () {
|
||||
expect(makeSpec(), isNot(makeSpec(dimensions: '2" x 2"')));
|
||||
});
|
||||
|
||||
test('differing notes breaks equality', () {
|
||||
expect(makeSpec(), isNot(makeSpec(notes: 'Rush')));
|
||||
});
|
||||
|
||||
test('differing estimatedCompletionDate breaks equality', () {
|
||||
expect(makeSpec(), isNot(makeSpec(estimatedCompletionDate: completion)));
|
||||
});
|
||||
|
||||
test('reference image lists compare by value, not identity', () {
|
||||
final a = makeSpec(referenceImageUrls: ['https://example.test/a.jpg']);
|
||||
final b = makeSpec(referenceImageUrls: ['https://example.test/a.jpg']);
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
|
||||
test('differing reference image order breaks equality', () {
|
||||
final a = makeSpec(referenceImageUrls: const ['a', 'b']);
|
||||
final b = makeSpec(referenceImageUrls: const ['b', 'a']);
|
||||
expect(a, isNot(b));
|
||||
});
|
||||
|
||||
test('differing reference image count breaks equality', () {
|
||||
final a = makeSpec(referenceImageUrls: const ['a']);
|
||||
final b = makeSpec(referenceImageUrls: const ['a', 'b']);
|
||||
expect(a, isNot(b));
|
||||
});
|
||||
|
||||
test('is not equal to an unrelated type', () {
|
||||
expect(makeSpec(), isNot(equals('Organic cotton')));
|
||||
});
|
||||
|
||||
test('is equal to itself', () {
|
||||
final spec = makeSpec();
|
||||
expect(spec, spec);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toString ──────────────────────────────────────────────────────────────
|
||||
|
||||
group('CustomOrderSpec — toString', () {
|
||||
test('includes material and color', () {
|
||||
final text = makeSpec().toString();
|
||||
expect(text, contains('Organic cotton'));
|
||||
expect(text, contains('Sage green'));
|
||||
});
|
||||
|
||||
test('reports the reference image count, not the URLs', () {
|
||||
final text = makeSpec(referenceImageUrls: const ['https://secret.test/a.jpg']).toString();
|
||||
expect(text, contains('referenceImages: 1'));
|
||||
expect(text, isNot(contains('secret.test')));
|
||||
});
|
||||
|
||||
test('includes the estimated completion date', () {
|
||||
expect(
|
||||
makeSpec(estimatedCompletionDate: completion).toString(),
|
||||
contains(completion.toString()),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not leak free-text notes', () {
|
||||
final text = makeSpec(notes: 'Customer phone 828-555-0101').toString();
|
||||
expect(text, isNot(contains('828-555-0101')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,619 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late KcInMemoryLogSink sink;
|
||||
late FakeOrdersRepository repository;
|
||||
|
||||
final paidAt = DateTime.utc(2026, 4, 10, 12, 0);
|
||||
|
||||
setUp(() {
|
||||
sink = KcInMemoryLogSink();
|
||||
repository = FakeOrdersRepository(
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
);
|
||||
});
|
||||
|
||||
CustomOrderSpec spec({String material = 'Linen', String color = 'Ivory'}) =>
|
||||
CustomOrderSpec(material: material, color: color, size: 'Medium');
|
||||
|
||||
Order newOrder({String id = 'KC-C9001'}) => Order(
|
||||
id: id,
|
||||
customerName: 'Test Customer',
|
||||
customerEmail: 'test@example.com',
|
||||
orderDate: DateTime(2026, 4, 10),
|
||||
status: OrderStatus.pending,
|
||||
items: const [
|
||||
OrderItem(productName: 'Commission', sku: 'CUSTOM-9001', quantity: 1, unitPrice: 80.00),
|
||||
],
|
||||
shippingAddress: '1 Test St, Test City, TC 00000',
|
||||
source: OrderSource.manual,
|
||||
);
|
||||
|
||||
// ── Seeded data ───────────────────────────────────────────────────────────
|
||||
|
||||
group('FakeOrdersRepository — seeded custom orders', () {
|
||||
test('getOrders returns eleven orders in total', () async {
|
||||
expect((await repository.getOrders()).length, 11);
|
||||
});
|
||||
|
||||
test('seeds five custom orders', () async {
|
||||
final orders = await repository.getOrders();
|
||||
expect(orders.where((o) => o.isCustom).length, 5);
|
||||
});
|
||||
|
||||
test('seeds six standard orders', () async {
|
||||
final orders = await repository.getOrders();
|
||||
expect(orders.where((o) => o.orderType == OrderType.standard).length, 6);
|
||||
});
|
||||
|
||||
test('seeded custom orders use the KC-C prefix', () async {
|
||||
final orders = await repository.getOrders();
|
||||
for (final order in orders.where((o) => o.isCustom)) {
|
||||
expect(order.id, startsWith('KC-C'));
|
||||
}
|
||||
});
|
||||
|
||||
test('covers every non-terminal pipeline stage', () async {
|
||||
final orders = await repository.getOrders();
|
||||
final stages = orders.where((o) => o.isCustom).map((o) => o.fulfillmentStatus).toSet();
|
||||
expect(
|
||||
stages,
|
||||
containsAll(<FulfillmentStatus>[
|
||||
FulfillmentStatus.quoted,
|
||||
FulfillmentStatus.depositReceived,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.qualityCheck,
|
||||
FulfillmentStatus.readyForPickup,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('every custom order carries a specification', () async {
|
||||
final orders = await repository.getOrders();
|
||||
for (final order in orders.where((o) => o.isCustom)) {
|
||||
expect(order.customOrderSpec, isNotNull, reason: '${order.id} has no spec');
|
||||
}
|
||||
});
|
||||
|
||||
test('every custom order carries deposit info', () async {
|
||||
final orders = await repository.getOrders();
|
||||
for (final order in orders.where((o) => o.isCustom)) {
|
||||
expect(order.depositInfo, isNotNull, reason: '${order.id} has no deposit info');
|
||||
}
|
||||
});
|
||||
|
||||
test('standard orders carry no custom order fields', () async {
|
||||
final orders = await repository.getOrders();
|
||||
for (final order in orders.where((o) => !o.isCustom)) {
|
||||
expect(order.customOrderSpec, isNull);
|
||||
expect(order.depositInfo, isNull);
|
||||
expect(order.fulfillmentStatus, isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('the quoted order has an unpaid deposit', () async {
|
||||
final order = await repository.getOrder('KC-C2001');
|
||||
expect(order!.fulfillmentStatus, FulfillmentStatus.quoted);
|
||||
expect(order.hasDeposit, false);
|
||||
});
|
||||
|
||||
test('orders past quoted have a paid deposit', () async {
|
||||
final orders = await repository.getOrders();
|
||||
final past = orders.where(
|
||||
(o) => o.isCustom && o.fulfillmentStatus != FulfillmentStatus.quoted,
|
||||
);
|
||||
for (final order in past) {
|
||||
expect(order.hasDeposit, true, reason: '${order.id} should have a paid deposit');
|
||||
}
|
||||
});
|
||||
|
||||
test('at least one seeded order carries reference images', () async {
|
||||
final orders = await repository.getOrders();
|
||||
final withImages = orders.where((o) => o.customOrderSpec?.hasReferenceImages ?? false);
|
||||
expect(withImages, isNotEmpty);
|
||||
});
|
||||
|
||||
test('getOrder resolves a seeded custom order by ID', () async {
|
||||
final order = await repository.getOrder('KC-C2003');
|
||||
expect(order, isNotNull);
|
||||
expect(order!.isCustom, true);
|
||||
expect(order.fulfillmentStatus, FulfillmentStatus.inProduction);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createCustomOrder ─────────────────────────────────────────────────────
|
||||
|
||||
group('FakeOrdersRepository — createCustomOrder', () {
|
||||
test('creates the order and returns it', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.id, 'KC-C9001');
|
||||
});
|
||||
|
||||
test('stamps the order as custom', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
expect(result.updatedOrder!.orderType, OrderType.custom);
|
||||
expect(result.updatedOrder!.isCustom, true);
|
||||
});
|
||||
|
||||
test('attaches the supplied specification', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
expect(result.updatedOrder!.customOrderSpec, spec());
|
||||
});
|
||||
|
||||
test('starts the pipeline at quoted', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.quoted);
|
||||
});
|
||||
|
||||
test('overrides an inconsistent fulfillment status supplied by the caller', () async {
|
||||
final inconsistent = newOrder().copyWith(fulfillmentStatus: FulfillmentStatus.completed);
|
||||
final result = await repository.createCustomOrder(inconsistent, spec: spec());
|
||||
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.quoted);
|
||||
});
|
||||
|
||||
test('overrides an inconsistent order type supplied by the caller', () async {
|
||||
final inconsistent = newOrder().copyWith(orderType: OrderType.standard);
|
||||
final result = await repository.createCustomOrder(inconsistent, spec: spec());
|
||||
expect(result.updatedOrder!.orderType, OrderType.custom);
|
||||
});
|
||||
|
||||
test('leaves depositInfo null when no deposit is supplied', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
expect(result.updatedOrder!.depositInfo, isNull);
|
||||
});
|
||||
|
||||
test('attaches the supplied deposit', () async {
|
||||
const deposit = DepositInfo.quoted(totalAmountCents: 8000, depositAmountCents: 2500);
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec(), deposit: deposit);
|
||||
expect(result.updatedOrder!.depositInfo, deposit);
|
||||
});
|
||||
|
||||
test('preserves the standard order fields', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
final created = result.updatedOrder!;
|
||||
expect(created.customerName, 'Test Customer');
|
||||
expect(created.customerEmail, 'test@example.com');
|
||||
expect(created.status, OrderStatus.pending);
|
||||
expect(created.source, OrderSource.manual);
|
||||
expect(created.items.length, 1);
|
||||
});
|
||||
|
||||
test('the new order appears in subsequent reads', () async {
|
||||
await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
final orders = await repository.getOrders();
|
||||
expect(orders.length, 12);
|
||||
expect(orders.any((o) => o.id == 'KC-C9001'), true);
|
||||
});
|
||||
|
||||
test('rejects a duplicate order ID', () async {
|
||||
final result = await repository.createCustomOrder(newOrder(id: 'KC-1001'), spec: spec());
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already exists'));
|
||||
});
|
||||
|
||||
test('a duplicate rejection does not alter the existing order', () async {
|
||||
await repository.createCustomOrder(newOrder(id: 'KC-1001'), spec: spec());
|
||||
final existing = await repository.getOrder('KC-1001');
|
||||
expect(existing!.isCustom, false);
|
||||
expect(existing.customerName, 'Sarah Mitchell');
|
||||
});
|
||||
|
||||
test('a duplicate rejection is logged', () async {
|
||||
await repository.createCustomOrder(newOrder(id: 'KC-1001'), spec: spec());
|
||||
final records = sink.recordsAtLeast(KcLogLevel.warning);
|
||||
expect(records, isNotEmpty);
|
||||
expect(records.last.context['operation'], 'createCustomOrder');
|
||||
expect(records.last.context['orderId'], 'KC-1001');
|
||||
});
|
||||
|
||||
test('a successful creation is not logged as a failure', () async {
|
||||
await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
expect(sink.recordsAtLeast(KcLogLevel.warning), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateCustomOrderSpec ─────────────────────────────────────────────────
|
||||
|
||||
group('FakeOrdersRepository — updateCustomOrderSpec', () {
|
||||
test('replaces the specification', () async {
|
||||
final revised = spec(material: 'Wool', color: 'Slate');
|
||||
final result = await repository.updateCustomOrderSpec('KC-C2001', revised);
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.customOrderSpec, revised);
|
||||
});
|
||||
|
||||
test('persists across subsequent reads', () async {
|
||||
final revised = spec(material: 'Wool', color: 'Slate');
|
||||
await repository.updateCustomOrderSpec('KC-C2001', revised);
|
||||
final order = await repository.getOrder('KC-C2001');
|
||||
expect(order!.customOrderSpec, revised);
|
||||
});
|
||||
|
||||
test('leaves the fulfillment stage untouched', () async {
|
||||
await repository.updateCustomOrderSpec('KC-C2003', spec());
|
||||
final order = await repository.getOrder('KC-C2003');
|
||||
expect(order!.fulfillmentStatus, FulfillmentStatus.inProduction);
|
||||
});
|
||||
|
||||
test('leaves the deposit untouched', () async {
|
||||
final before = await repository.getOrder('KC-C2003');
|
||||
await repository.updateCustomOrderSpec('KC-C2003', spec());
|
||||
final after = await repository.getOrder('KC-C2003');
|
||||
expect(after!.depositInfo, before!.depositInfo);
|
||||
});
|
||||
|
||||
test('rejects an unknown order ID', () async {
|
||||
final result = await repository.updateCustomOrderSpec('UNKNOWN', spec());
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('rejects a standard order', () async {
|
||||
final result = await repository.updateCustomOrderSpec('KC-1001', spec());
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not a custom order'));
|
||||
});
|
||||
|
||||
test('a rejected standard order is left unchanged', () async {
|
||||
await repository.updateCustomOrderSpec('KC-1001', spec());
|
||||
final order = await repository.getOrder('KC-1001');
|
||||
expect(order!.customOrderSpec, isNull);
|
||||
expect(order.orderType, OrderType.standard);
|
||||
});
|
||||
|
||||
test('rejections are logged with the operation and order ID', () async {
|
||||
await repository.updateCustomOrderSpec('KC-1001', spec());
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.context['operation'], 'updateCustomOrderSpec');
|
||||
expect(record.context['orderId'], 'KC-1001');
|
||||
expect(record.context['orderType'], 'standard');
|
||||
});
|
||||
});
|
||||
|
||||
// ── recordDeposit ─────────────────────────────────────────────────────────
|
||||
|
||||
group('FakeOrdersRepository — recordDeposit', () {
|
||||
test('marks the deposit paid', () async {
|
||||
final result = await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.depositInfo!.depositPaid, true);
|
||||
});
|
||||
|
||||
test('records the payment date', () async {
|
||||
final result = await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
|
||||
expect(result.updatedOrder!.depositInfo!.depositDate, paidAt);
|
||||
});
|
||||
|
||||
test('records the collected amount', () async {
|
||||
final result = await repository.recordDeposit('KC-C2001', amountCents: 3000, paidAt: paidAt);
|
||||
expect(result.updatedOrder!.depositInfo!.depositAmountCents, 3000);
|
||||
});
|
||||
|
||||
test('recomputes the balance due', () async {
|
||||
// KC-C2001 is quoted at 7200 with a 2400 deposit.
|
||||
final result = await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
|
||||
expect(result.updatedOrder!.balanceDueCents, 4800);
|
||||
});
|
||||
|
||||
test('revises the total when supplied', () async {
|
||||
final result = await repository.recordDeposit(
|
||||
'KC-C2001',
|
||||
amountCents: 2400,
|
||||
paidAt: paidAt,
|
||||
totalAmountCents: 9000,
|
||||
);
|
||||
expect(result.updatedOrder!.depositInfo!.totalAmountCents, 9000);
|
||||
expect(result.updatedOrder!.balanceDueCents, 6600);
|
||||
});
|
||||
|
||||
test('persists across subsequent reads', () async {
|
||||
await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
|
||||
final order = await repository.getOrder('KC-C2001');
|
||||
expect(order!.hasDeposit, true);
|
||||
});
|
||||
|
||||
test('leaves the fulfillment stage untouched', () async {
|
||||
await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
|
||||
final order = await repository.getOrder('KC-C2001');
|
||||
expect(order!.fulfillmentStatus, FulfillmentStatus.quoted);
|
||||
});
|
||||
|
||||
test('creates a deposit record when the order has none', () async {
|
||||
await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
final result = await repository.recordDeposit(
|
||||
'KC-C9001',
|
||||
amountCents: 2000,
|
||||
paidAt: paidAt,
|
||||
totalAmountCents: 8000,
|
||||
);
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.depositInfo!.totalAmountCents, 8000);
|
||||
expect(result.updatedOrder!.depositInfo!.depositAmountCents, 2000);
|
||||
});
|
||||
|
||||
test('rejects a zero amount', () async {
|
||||
final result = await repository.recordDeposit('KC-C2001', amountCents: 0, paidAt: paidAt);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('greater than zero'));
|
||||
});
|
||||
|
||||
test('rejects a negative amount', () async {
|
||||
final result = await repository.recordDeposit('KC-C2001', amountCents: -500, paidAt: paidAt);
|
||||
expect(result.success, false);
|
||||
});
|
||||
|
||||
test('rejects an unknown order ID', () async {
|
||||
final result = await repository.recordDeposit('UNKNOWN', amountCents: 100, paidAt: paidAt);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('rejects a standard order', () async {
|
||||
final result = await repository.recordDeposit('KC-1001', amountCents: 100, paidAt: paidAt);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not a custom order'));
|
||||
});
|
||||
|
||||
test('rejects a second deposit on the same order', () async {
|
||||
final result = await repository.recordDeposit('KC-C2002', amountCents: 1000, paidAt: paidAt);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already been recorded'));
|
||||
});
|
||||
|
||||
test('a duplicate deposit leaves the original untouched', () async {
|
||||
final before = await repository.getOrder('KC-C2002');
|
||||
await repository.recordDeposit('KC-C2002', amountCents: 1000, paidAt: paidAt);
|
||||
final after = await repository.getOrder('KC-C2002');
|
||||
expect(after!.depositInfo, before!.depositInfo);
|
||||
});
|
||||
|
||||
test('rejections are logged without the money amount', () async {
|
||||
await repository.recordDeposit('KC-C2002', amountCents: 1000, paidAt: paidAt);
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.context['operation'], 'recordDeposit');
|
||||
expect(record.context['orderId'], 'KC-C2002');
|
||||
expect(record.context.containsKey('amountCents'), false);
|
||||
expect(record.format(), isNot(contains('1000')));
|
||||
});
|
||||
|
||||
test('a successful deposit is not logged as a failure', () async {
|
||||
await repository.recordDeposit('KC-C2001', amountCents: 2400, paidAt: paidAt);
|
||||
expect(sink.recordsAtLeast(KcLogLevel.warning), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── advanceFulfillmentStatus ──────────────────────────────────────────────
|
||||
|
||||
group('FakeOrdersRepository — advanceFulfillmentStatus (legal)', () {
|
||||
test('quoted → depositReceived succeeds', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2001',
|
||||
FulfillmentStatus.depositReceived,
|
||||
);
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.depositReceived);
|
||||
});
|
||||
|
||||
test('depositReceived → inProduction succeeds', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2002',
|
||||
FulfillmentStatus.inProduction,
|
||||
);
|
||||
expect(result.success, true);
|
||||
});
|
||||
|
||||
test('inProduction → qualityCheck succeeds', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2003',
|
||||
FulfillmentStatus.qualityCheck,
|
||||
);
|
||||
expect(result.success, true);
|
||||
});
|
||||
|
||||
test('qualityCheck → readyForPickup succeeds', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2004',
|
||||
FulfillmentStatus.readyForPickup,
|
||||
);
|
||||
expect(result.success, true);
|
||||
});
|
||||
|
||||
test('qualityCheck → inProduction rework succeeds', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2004',
|
||||
FulfillmentStatus.inProduction,
|
||||
);
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.inProduction);
|
||||
});
|
||||
|
||||
test('readyForPickup → completed succeeds', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2005',
|
||||
FulfillmentStatus.completed,
|
||||
);
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.fulfillmentStatus, FulfillmentStatus.completed);
|
||||
});
|
||||
|
||||
test('any non-terminal stage can be cancelled', () async {
|
||||
for (final id in ['KC-C2001', 'KC-C2002', 'KC-C2003', 'KC-C2004', 'KC-C2005']) {
|
||||
final result = await repository.advanceFulfillmentStatus(id, FulfillmentStatus.cancelled);
|
||||
expect(result.success, true, reason: '$id should be cancellable');
|
||||
}
|
||||
});
|
||||
|
||||
test('the full pipeline can be walked end to end', () async {
|
||||
await repository.createCustomOrder(newOrder(), spec: spec());
|
||||
for (final next in [
|
||||
FulfillmentStatus.depositReceived,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.qualityCheck,
|
||||
FulfillmentStatus.readyForPickup,
|
||||
FulfillmentStatus.completed,
|
||||
]) {
|
||||
final result = await repository.advanceFulfillmentStatus('KC-C9001', next);
|
||||
expect(result.success, true, reason: 'advance to ${next.name} should succeed');
|
||||
}
|
||||
final order = await repository.getOrder('KC-C9001');
|
||||
expect(order!.fulfillmentStatus, FulfillmentStatus.completed);
|
||||
});
|
||||
|
||||
test('persists across subsequent reads', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
|
||||
final order = await repository.getOrder('KC-C2001');
|
||||
expect(order!.fulfillmentStatus, FulfillmentStatus.depositReceived);
|
||||
});
|
||||
|
||||
test('leaves the specification untouched', () async {
|
||||
final before = await repository.getOrder('KC-C2001');
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
|
||||
final after = await repository.getOrder('KC-C2001');
|
||||
expect(after!.customOrderSpec, before!.customOrderSpec);
|
||||
});
|
||||
|
||||
test('a legal advance is not logged as a failure', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.depositReceived);
|
||||
expect(sink.recordsAtLeast(KcLogLevel.warning), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('FakeOrdersRepository — advanceFulfillmentStatus (illegal)', () {
|
||||
test('quoted cannot skip to inProduction', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2001',
|
||||
FulfillmentStatus.inProduction,
|
||||
);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('Cannot move from quoted'));
|
||||
});
|
||||
|
||||
test('quoted cannot skip to completed', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2001',
|
||||
FulfillmentStatus.completed,
|
||||
);
|
||||
expect(result.success, false);
|
||||
});
|
||||
|
||||
test('depositReceived cannot skip to readyForPickup', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2002',
|
||||
FulfillmentStatus.readyForPickup,
|
||||
);
|
||||
expect(result.success, false);
|
||||
});
|
||||
|
||||
test('inProduction cannot skip to readyForPickup', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2003',
|
||||
FulfillmentStatus.readyForPickup,
|
||||
);
|
||||
expect(result.success, false);
|
||||
});
|
||||
|
||||
test('a stage cannot advance to itself', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2001',
|
||||
FulfillmentStatus.quoted,
|
||||
);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already'));
|
||||
});
|
||||
|
||||
test('a completed order cannot be reopened', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2005', FulfillmentStatus.completed);
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2005',
|
||||
FulfillmentStatus.inProduction,
|
||||
);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already completed'));
|
||||
});
|
||||
|
||||
test('a cancelled order cannot be resumed', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.cancelled);
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-C2001',
|
||||
FulfillmentStatus.depositReceived,
|
||||
);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already cancelled'));
|
||||
});
|
||||
|
||||
test('an illegal advance leaves the stage unchanged', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
|
||||
final order = await repository.getOrder('KC-C2001');
|
||||
expect(order!.fulfillmentStatus, FulfillmentStatus.quoted);
|
||||
});
|
||||
|
||||
test('an illegal advance leaves the rest of the order unchanged', () async {
|
||||
final before = await repository.getOrder('KC-C2001');
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
|
||||
final after = await repository.getOrder('KC-C2001');
|
||||
expect(after!.customOrderSpec, before!.customOrderSpec);
|
||||
expect(after.depositInfo, before.depositInfo);
|
||||
expect(after.status, before.status);
|
||||
});
|
||||
|
||||
test('rejects an unknown order ID', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'UNKNOWN',
|
||||
FulfillmentStatus.inProduction,
|
||||
);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('rejects a standard order', () async {
|
||||
final result = await repository.advanceFulfillmentStatus(
|
||||
'KC-1001',
|
||||
FulfillmentStatus.inProduction,
|
||||
);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not a custom order'));
|
||||
});
|
||||
|
||||
test('an illegal advance is logged with both stages', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.context['operation'], 'advanceFulfillmentStatus');
|
||||
expect(record.context['orderId'], 'KC-C2001');
|
||||
expect(record.context['fromStage'], 'quoted');
|
||||
expect(record.context['toStage'], 'completed');
|
||||
});
|
||||
|
||||
test('log records carry no customer identity', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.format(), isNot(contains('Nadia')));
|
||||
expect(record.format(), isNot(contains('@')));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Observability defaults ────────────────────────────────────────────────
|
||||
|
||||
group('FakeOrdersRepository — observability', () {
|
||||
test('defaults to a silent logger when none is injected', () async {
|
||||
final silent = FakeOrdersRepository();
|
||||
final result = await silent.advanceFulfillmentStatus('UNKNOWN', FulfillmentStatus.completed);
|
||||
// A missing logger must never turn a rejection into a crash.
|
||||
expect(result.success, false);
|
||||
});
|
||||
|
||||
test('tags records with the repository source', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.source, 'FakeOrdersRepository');
|
||||
});
|
||||
|
||||
test('records rejections at warning severity, not error', () async {
|
||||
await repository.advanceFulfillmentStatus('KC-C2001', FulfillmentStatus.completed);
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.level, KcLogLevel.warning);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Every legal transition in the custom order pipeline.
|
||||
///
|
||||
/// Any `(from, to)` pair absent from this set must be rejected.
|
||||
const _legalTransitions = <(FulfillmentStatus, FulfillmentStatus)>{
|
||||
(FulfillmentStatus.quoted, FulfillmentStatus.depositReceived),
|
||||
(FulfillmentStatus.quoted, FulfillmentStatus.cancelled),
|
||||
(FulfillmentStatus.depositReceived, FulfillmentStatus.inProduction),
|
||||
(FulfillmentStatus.depositReceived, FulfillmentStatus.cancelled),
|
||||
(FulfillmentStatus.inProduction, FulfillmentStatus.qualityCheck),
|
||||
(FulfillmentStatus.inProduction, FulfillmentStatus.cancelled),
|
||||
(FulfillmentStatus.qualityCheck, FulfillmentStatus.readyForPickup),
|
||||
(FulfillmentStatus.qualityCheck, FulfillmentStatus.inProduction),
|
||||
(FulfillmentStatus.qualityCheck, FulfillmentStatus.cancelled),
|
||||
(FulfillmentStatus.readyForPickup, FulfillmentStatus.completed),
|
||||
(FulfillmentStatus.readyForPickup, FulfillmentStatus.cancelled),
|
||||
};
|
||||
|
||||
void main() {
|
||||
// ── Enum shape ────────────────────────────────────────────────────────────
|
||||
|
||||
group('FulfillmentStatus — enum shape', () {
|
||||
test('has seven values', () {
|
||||
expect(FulfillmentStatus.values.length, 7);
|
||||
});
|
||||
|
||||
test('exposes every pipeline stage named in the brief', () {
|
||||
expect(
|
||||
FulfillmentStatus.values,
|
||||
containsAll(<FulfillmentStatus>[
|
||||
FulfillmentStatus.quoted,
|
||||
FulfillmentStatus.depositReceived,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.qualityCheck,
|
||||
FulfillmentStatus.readyForPickup,
|
||||
FulfillmentStatus.completed,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes cancelled', () {
|
||||
expect(FulfillmentStatus.values, contains(FulfillmentStatus.cancelled));
|
||||
});
|
||||
|
||||
test('every stage has a non-empty label', () {
|
||||
for (final status in FulfillmentStatus.values) {
|
||||
expect(status.label, isNotEmpty, reason: '${status.name} has no label');
|
||||
}
|
||||
});
|
||||
|
||||
test('labels are human readable', () {
|
||||
expect(FulfillmentStatus.quoted.label, 'Quoted');
|
||||
expect(FulfillmentStatus.depositReceived.label, 'Deposit Received');
|
||||
expect(FulfillmentStatus.inProduction.label, 'In Production');
|
||||
expect(FulfillmentStatus.qualityCheck.label, 'Quality Check');
|
||||
expect(FulfillmentStatus.readyForPickup.label, 'Ready for Pickup');
|
||||
expect(FulfillmentStatus.completed.label, 'Completed');
|
||||
expect(FulfillmentStatus.cancelled.label, 'Cancelled');
|
||||
});
|
||||
|
||||
test('linear stages are indexed in pipeline order', () {
|
||||
expect(FulfillmentStatus.quoted.stageIndex, 0);
|
||||
expect(FulfillmentStatus.depositReceived.stageIndex, 1);
|
||||
expect(FulfillmentStatus.inProduction.stageIndex, 2);
|
||||
expect(FulfillmentStatus.qualityCheck.stageIndex, 3);
|
||||
expect(FulfillmentStatus.readyForPickup.stageIndex, 4);
|
||||
expect(FulfillmentStatus.completed.stageIndex, 5);
|
||||
});
|
||||
|
||||
test('cancelled sits outside the linear pipeline', () {
|
||||
expect(FulfillmentStatus.cancelled.stageIndex, -1);
|
||||
});
|
||||
|
||||
test('pipeline excludes cancelled', () {
|
||||
expect(FulfillmentStatus.pipeline, isNot(contains(FulfillmentStatus.cancelled)));
|
||||
});
|
||||
|
||||
test('pipeline has six stages', () {
|
||||
expect(FulfillmentStatus.pipeline.length, 6);
|
||||
expect(FulfillmentStatus.pipelineLength, 6);
|
||||
});
|
||||
|
||||
test('pipeline is ordered by stage index', () {
|
||||
final indices = FulfillmentStatus.pipeline.map((s) => s.stageIndex).toList();
|
||||
expect(indices, [0, 1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('pipeline is unmodifiable', () {
|
||||
expect(
|
||||
() => FulfillmentStatus.pipeline.add(FulfillmentStatus.cancelled),
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Wire format ───────────────────────────────────────────────────────────
|
||||
|
||||
group('FulfillmentStatus — wire format', () {
|
||||
test('wireValue matches the enum name', () {
|
||||
for (final status in FulfillmentStatus.values) {
|
||||
expect(status.wireValue, status.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('fromWire round-trips every value', () {
|
||||
for (final status in FulfillmentStatus.values) {
|
||||
expect(FulfillmentStatus.fromWire(status.wireValue), status);
|
||||
}
|
||||
});
|
||||
|
||||
test('fromWire defaults to quoted for null', () {
|
||||
expect(FulfillmentStatus.fromWire(null), FulfillmentStatus.quoted);
|
||||
});
|
||||
|
||||
test('fromWire defaults to quoted for an unknown value', () {
|
||||
expect(FulfillmentStatus.fromWire('shipped'), FulfillmentStatus.quoted);
|
||||
});
|
||||
|
||||
test('fromWire defaults to quoted for an empty string', () {
|
||||
expect(FulfillmentStatus.fromWire(''), FulfillmentStatus.quoted);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stage predicates ──────────────────────────────────────────────────────
|
||||
|
||||
group('FulfillmentTransition — stage predicates', () {
|
||||
test('completed is terminal', () {
|
||||
expect(FulfillmentStatus.completed.isTerminal, true);
|
||||
});
|
||||
|
||||
test('cancelled is terminal', () {
|
||||
expect(FulfillmentStatus.cancelled.isTerminal, true);
|
||||
});
|
||||
|
||||
test('every in-flight stage is non-terminal', () {
|
||||
for (final status in [
|
||||
FulfillmentStatus.quoted,
|
||||
FulfillmentStatus.depositReceived,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.qualityCheck,
|
||||
FulfillmentStatus.readyForPickup,
|
||||
]) {
|
||||
expect(status.isTerminal, false, reason: '${status.name} should not be terminal');
|
||||
}
|
||||
});
|
||||
|
||||
test('only completed is successful', () {
|
||||
for (final status in FulfillmentStatus.values) {
|
||||
expect(status.isSuccessful, status == FulfillmentStatus.completed);
|
||||
}
|
||||
});
|
||||
|
||||
test('quoted does not require a deposit', () {
|
||||
expect(FulfillmentStatus.quoted.requiresDeposit, false);
|
||||
});
|
||||
|
||||
test('cancelled does not require a deposit', () {
|
||||
expect(FulfillmentStatus.cancelled.requiresDeposit, false);
|
||||
});
|
||||
|
||||
test('every stage from depositReceived onward requires a deposit', () {
|
||||
for (final status in [
|
||||
FulfillmentStatus.depositReceived,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.qualityCheck,
|
||||
FulfillmentStatus.readyForPickup,
|
||||
FulfillmentStatus.completed,
|
||||
]) {
|
||||
expect(status.requiresDeposit, true, reason: '${status.name} should require a deposit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Legal transitions ─────────────────────────────────────────────────────
|
||||
|
||||
group('FulfillmentTransition — legal transitions', () {
|
||||
for (final (from, to) in _legalTransitions) {
|
||||
test('${from.name} → ${to.name} is allowed', () {
|
||||
expect(from.canTransitionTo(to), true);
|
||||
});
|
||||
|
||||
test('${from.name} → ${to.name} has no rejection reason', () {
|
||||
expect(from.rejectionReason(to), isNull);
|
||||
});
|
||||
|
||||
test('${from.name} lists ${to.name} among allowed next statuses', () {
|
||||
expect(from.allowedNextStatuses, contains(to));
|
||||
});
|
||||
}
|
||||
|
||||
test('the happy path runs end to end', () {
|
||||
var current = FulfillmentStatus.quoted;
|
||||
for (final next in [
|
||||
FulfillmentStatus.depositReceived,
|
||||
FulfillmentStatus.inProduction,
|
||||
FulfillmentStatus.qualityCheck,
|
||||
FulfillmentStatus.readyForPickup,
|
||||
FulfillmentStatus.completed,
|
||||
]) {
|
||||
expect(
|
||||
current.canTransitionTo(next),
|
||||
true,
|
||||
reason: '${current.name} → ${next.name} should be legal',
|
||||
);
|
||||
current = next;
|
||||
}
|
||||
expect(current, FulfillmentStatus.completed);
|
||||
});
|
||||
|
||||
test('qualityCheck can send the piece back for rework', () {
|
||||
expect(FulfillmentStatus.qualityCheck.canTransitionTo(FulfillmentStatus.inProduction), true);
|
||||
});
|
||||
|
||||
test('every non-terminal stage can be cancelled', () {
|
||||
for (final status in FulfillmentStatus.values.where((s) => !s.isTerminal)) {
|
||||
expect(
|
||||
status.canTransitionTo(FulfillmentStatus.cancelled),
|
||||
true,
|
||||
reason: '${status.name} should be cancellable',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Illegal transitions ───────────────────────────────────────────────────
|
||||
|
||||
group('FulfillmentTransition — illegal transitions', () {
|
||||
// Exhaustive 7 × 7 matrix: every pair not in _legalTransitions is rejected.
|
||||
for (final from in FulfillmentStatus.values) {
|
||||
for (final to in FulfillmentStatus.values) {
|
||||
if (_legalTransitions.contains((from, to))) continue;
|
||||
|
||||
test('${from.name} → ${to.name} is rejected', () {
|
||||
expect(from.canTransitionTo(to), false);
|
||||
});
|
||||
|
||||
test('${from.name} → ${to.name} explains the rejection', () {
|
||||
final reason = from.rejectionReason(to);
|
||||
expect(reason, isNotNull);
|
||||
expect(reason, isNotEmpty);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('quoted cannot skip straight to inProduction', () {
|
||||
expect(FulfillmentStatus.quoted.canTransitionTo(FulfillmentStatus.inProduction), false);
|
||||
});
|
||||
|
||||
test('quoted cannot skip straight to completed', () {
|
||||
expect(FulfillmentStatus.quoted.canTransitionTo(FulfillmentStatus.completed), false);
|
||||
});
|
||||
|
||||
test('depositReceived cannot skip qualityCheck', () {
|
||||
expect(
|
||||
FulfillmentStatus.depositReceived.canTransitionTo(FulfillmentStatus.readyForPickup),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('inProduction cannot skip to readyForPickup', () {
|
||||
expect(
|
||||
FulfillmentStatus.inProduction.canTransitionTo(FulfillmentStatus.readyForPickup),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('completed cannot be reopened', () {
|
||||
for (final to in FulfillmentStatus.values) {
|
||||
expect(
|
||||
FulfillmentStatus.completed.canTransitionTo(to),
|
||||
false,
|
||||
reason: 'completed → ${to.name} must be rejected',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelled cannot be resumed', () {
|
||||
for (final to in FulfillmentStatus.values) {
|
||||
expect(
|
||||
FulfillmentStatus.cancelled.canTransitionTo(to),
|
||||
false,
|
||||
reason: 'cancelled → ${to.name} must be rejected',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stage cannot transition to itself', () {
|
||||
for (final status in FulfillmentStatus.values) {
|
||||
expect(
|
||||
status.canTransitionTo(status),
|
||||
false,
|
||||
reason: '${status.name} → itself must be rejected',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('backwards moves other than qualityCheck rework are rejected', () {
|
||||
expect(
|
||||
FulfillmentStatus.inProduction.canTransitionTo(FulfillmentStatus.depositReceived),
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
FulfillmentStatus.readyForPickup.canTransitionTo(FulfillmentStatus.qualityCheck),
|
||||
false,
|
||||
);
|
||||
expect(FulfillmentStatus.depositReceived.canTransitionTo(FulfillmentStatus.quoted), false);
|
||||
});
|
||||
|
||||
test('terminal stages expose no allowed next statuses', () {
|
||||
expect(FulfillmentStatus.completed.allowedNextStatuses, isEmpty);
|
||||
expect(FulfillmentStatus.cancelled.allowedNextStatuses, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rejection messages ────────────────────────────────────────────────────
|
||||
|
||||
group('FulfillmentTransition — rejectionReason', () {
|
||||
test('a same-stage move reports the order is already at that stage', () {
|
||||
final reason = FulfillmentStatus.inProduction.rejectionReason(FulfillmentStatus.inProduction);
|
||||
expect(reason, contains('already'));
|
||||
expect(reason, contains('in production'));
|
||||
});
|
||||
|
||||
test('a move from completed reports the terminal stage', () {
|
||||
final reason = FulfillmentStatus.completed.rejectionReason(FulfillmentStatus.inProduction);
|
||||
expect(reason, contains('already'));
|
||||
expect(reason, contains('completed'));
|
||||
});
|
||||
|
||||
test('a move from cancelled reports the terminal stage', () {
|
||||
final reason = FulfillmentStatus.cancelled.rejectionReason(FulfillmentStatus.inProduction);
|
||||
expect(reason, contains('cancelled'));
|
||||
});
|
||||
|
||||
test('an out-of-order move lists the allowed next stages', () {
|
||||
final reason = FulfillmentStatus.quoted.rejectionReason(FulfillmentStatus.completed);
|
||||
expect(reason, contains('Allowed next'));
|
||||
expect(reason, contains('Deposit Received'));
|
||||
expect(reason, contains('Cancelled'));
|
||||
});
|
||||
|
||||
test('an out-of-order move names both stages', () {
|
||||
final reason = FulfillmentStatus.quoted.rejectionReason(FulfillmentStatus.completed);
|
||||
expect(reason, contains('quoted'));
|
||||
expect(reason, contains('completed'));
|
||||
});
|
||||
|
||||
test('messages carry no customer or money detail', () {
|
||||
for (final from in FulfillmentStatus.values) {
|
||||
for (final to in FulfillmentStatus.values) {
|
||||
final reason = from.rejectionReason(to);
|
||||
if (reason == null) continue;
|
||||
expect(reason, isNot(contains(r'$')));
|
||||
expect(reason, isNot(contains('@')));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,29 @@ class _EmptyOrdersRepository implements OrdersRepository {
|
|||
@override
|
||||
Future<OrderActionResult> createOrder(Order order) async =>
|
||||
const OrderActionResult.failure('Not implemented.');
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> createCustomOrder(
|
||||
Order order, {
|
||||
required CustomOrderSpec spec,
|
||||
DepositInfo? deposit,
|
||||
}) async => const OrderActionResult.failure('Not implemented.');
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> updateCustomOrderSpec(String id, CustomOrderSpec spec) async =>
|
||||
const OrderActionResult.failure('Not implemented.');
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> recordDeposit(
|
||||
String id, {
|
||||
required int amountCents,
|
||||
required DateTime paidAt,
|
||||
int? totalAmountCents,
|
||||
}) async => const OrderActionResult.failure('Not implemented.');
|
||||
|
||||
@override
|
||||
Future<OrderActionResult> advanceFulfillmentStatus(String id, FulfillmentStatus next) async =>
|
||||
const OrderActionResult.failure('Not implemented.');
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,984 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:core/core.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
const _mapper = WooCommerceOrderMapper();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds WooCommerce order JSON, optionally carrying custom order meta.
|
||||
Map<String, dynamic> orderJson({
|
||||
String id = '3001',
|
||||
String status = 'processing',
|
||||
List<Map<String, dynamic>> metaData = const [],
|
||||
}) {
|
||||
return {
|
||||
'id': int.parse(id),
|
||||
'status': status,
|
||||
'date_created': '2026-04-06T09:00:00',
|
||||
'customer_note': '',
|
||||
'payment_method_title': 'Credit Card',
|
||||
'billing': const {
|
||||
'first_name': 'Nadia',
|
||||
'last_name': 'Okafor',
|
||||
'email': 'nadia@example.com',
|
||||
'phone': '828-555-0701',
|
||||
'address_1': '15 Riverside Way',
|
||||
'city': 'Asheville',
|
||||
'state': 'NC',
|
||||
'postcode': '28801',
|
||||
'country': 'US',
|
||||
},
|
||||
'shipping': const {'address_1': '15 Riverside Way', 'city': 'Asheville', 'country': 'US'},
|
||||
'line_items': const [
|
||||
{
|
||||
'name': 'Custom Bowl Cozy Set',
|
||||
'sku': 'CUSTOM-BC-2001',
|
||||
'quantity': 4,
|
||||
'price': '18.00',
|
||||
'product_id': 0,
|
||||
'variation_id': 0,
|
||||
'meta_data': <Map<String, dynamic>>[],
|
||||
},
|
||||
],
|
||||
'meta_data': metaData,
|
||||
};
|
||||
}
|
||||
|
||||
/// The full custom-order meta set for a `depositReceived` commission.
|
||||
List<Map<String, dynamic>> customMeta() => [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'depositReceived'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Organic cotton'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Sage green'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecSize, 'value': 'Large'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecDimensions, 'value': '8" round'},
|
||||
{
|
||||
'key': WooCommerceOrderMapper.metaSpecReferenceImages,
|
||||
'value': 'https://cdn.test/a.jpg\nhttps://cdn.test/b.jpg',
|
||||
},
|
||||
{'key': WooCommerceOrderMapper.metaSpecNotes, 'value': 'Housewarming gift.'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecEstimatedCompletion, 'value': '2026-05-01T00:00:00.000'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': '2400'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositTotalCents, 'value': '7200'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositPaid, 'value': 'true'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositDate, 'value': '2026-04-06T09:00:00.000'},
|
||||
];
|
||||
|
||||
WooCommerceOrdersRepository repoWith(MockClient client, {KcLogger? logger}) {
|
||||
return WooCommerceOrdersRepository(
|
||||
apiClient: WooCommerceApiClient(
|
||||
siteUrl: 'https://example.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
httpClient: client,
|
||||
),
|
||||
logger: logger,
|
||||
);
|
||||
}
|
||||
|
||||
CustomOrderSpec sampleSpec() => CustomOrderSpec(
|
||||
material: 'Organic cotton',
|
||||
color: 'Sage green',
|
||||
size: 'Large',
|
||||
dimensions: '8" round',
|
||||
referenceImageUrls: const ['https://cdn.test/a.jpg', 'https://cdn.test/b.jpg'],
|
||||
notes: 'Housewarming gift.',
|
||||
estimatedCompletionDate: DateTime(2026, 5, 1),
|
||||
);
|
||||
|
||||
Order sampleCustomOrder() => Order(
|
||||
id: 'KC-C3001',
|
||||
customerName: 'Nadia Okafor',
|
||||
customerEmail: 'nadia@example.com',
|
||||
orderDate: DateTime(2026, 4, 6),
|
||||
status: OrderStatus.pending,
|
||||
items: const [
|
||||
OrderItem(productName: 'Custom Bowl Cozy Set', sku: 'CUSTOM-BC', quantity: 4, unitPrice: 18.0),
|
||||
],
|
||||
shippingAddress: '15 Riverside Way, Asheville, NC 28801',
|
||||
orderType: OrderType.custom,
|
||||
fulfillmentStatus: FulfillmentStatus.depositReceived,
|
||||
customOrderSpec: sampleSpec(),
|
||||
depositInfo: DepositInfo(
|
||||
depositAmountCents: 2400,
|
||||
totalAmountCents: 7200,
|
||||
depositPaid: true,
|
||||
depositDate: DateTime(2026, 4, 6),
|
||||
),
|
||||
);
|
||||
|
||||
/// Extracts a meta value from a WooCommerce `meta_data` list.
|
||||
String? metaValue(List<Map<String, dynamic>> meta, String key) {
|
||||
for (final entry in meta) {
|
||||
if (entry['key'] == key) return entry['value'] as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// ── Mapper: reading custom orders ─────────────────────────────────────────
|
||||
|
||||
group('WooCommerceOrderMapper — reads custom order meta', () {
|
||||
test('maps orderType from meta', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.orderType, OrderType.custom);
|
||||
expect(order.isCustom, true);
|
||||
});
|
||||
|
||||
test('maps fulfillmentStatus from meta', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.fulfillmentStatus, FulfillmentStatus.depositReceived);
|
||||
});
|
||||
|
||||
test('maps the full specification', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.customOrderSpec, sampleSpec());
|
||||
});
|
||||
|
||||
test('splits newline-separated reference images', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.customOrderSpec!.referenceImageUrls, [
|
||||
'https://cdn.test/a.jpg',
|
||||
'https://cdn.test/b.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
test('maps deposit amounts as integer cents', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.depositInfo!.depositAmountCents, 2400);
|
||||
expect(order.depositInfo!.totalAmountCents, 7200);
|
||||
});
|
||||
|
||||
test('maps the deposit paid flag and date', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.depositInfo!.depositPaid, true);
|
||||
expect(order.depositInfo!.depositDate, DateTime.parse('2026-04-06T09:00:00.000'));
|
||||
});
|
||||
|
||||
test('derives the balance rather than reading it from meta', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.balanceDueCents, 4800);
|
||||
});
|
||||
|
||||
test('leaves settlementDate null when absent', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.depositInfo!.settlementDate, isNull);
|
||||
});
|
||||
|
||||
test('internal meta keys are excluded from customFields', () {
|
||||
final order = _mapper.fromJson(orderJson(metaData: customMeta()));
|
||||
expect(order.customFields, isEmpty);
|
||||
});
|
||||
|
||||
test('public meta still populates customFields alongside custom order meta', () {
|
||||
final meta = [
|
||||
...customMeta(),
|
||||
{'key': 'gift_message', 'value': 'Happy Birthday!'},
|
||||
];
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.customFields['gift_message'], 'Happy Birthday!');
|
||||
expect(order.isCustom, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mapper: standard and partial orders ───────────────────────────────────
|
||||
|
||||
group('WooCommerceOrderMapper — standard and partial orders', () {
|
||||
test('an order with no meta is standard', () {
|
||||
expect(_mapper.fromJson(orderJson()).orderType, OrderType.standard);
|
||||
});
|
||||
|
||||
test('a standard order carries no custom order fields', () {
|
||||
final order = _mapper.fromJson(orderJson());
|
||||
expect(order.customOrderSpec, isNull);
|
||||
expect(order.depositInfo, isNull);
|
||||
expect(order.fulfillmentStatus, isNull);
|
||||
});
|
||||
|
||||
test('custom meta on a standard-typed order is ignored', () {
|
||||
final meta = customMeta()
|
||||
.where((m) => m['key'] != WooCommerceOrderMapper.metaOrderType)
|
||||
.toList();
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.orderType, OrderType.standard);
|
||||
expect(order.customOrderSpec, isNull);
|
||||
expect(order.depositInfo, isNull);
|
||||
});
|
||||
|
||||
test('an unknown fulfillment status falls back to quoted', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'nonsense'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
|
||||
];
|
||||
expect(
|
||||
_mapper.fromJson(orderJson(metaData: meta)).fulfillmentStatus,
|
||||
FulfillmentStatus.quoted,
|
||||
);
|
||||
});
|
||||
|
||||
test('a custom order without spec meta yields a null spec', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
];
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.isCustom, true);
|
||||
expect(order.customOrderSpec, isNull);
|
||||
});
|
||||
|
||||
test('a custom order without deposit meta yields null deposit info', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
|
||||
];
|
||||
expect(_mapper.fromJson(orderJson(metaData: meta)).depositInfo, isNull);
|
||||
});
|
||||
|
||||
test('a non-numeric deposit amount yields null deposit info', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': 'oops'},
|
||||
];
|
||||
expect(_mapper.fromJson(orderJson(metaData: meta)).depositInfo, isNull);
|
||||
});
|
||||
|
||||
test('a missing total falls back to the deposit amount', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': '1500'},
|
||||
];
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.depositInfo!.totalAmountCents, 1500);
|
||||
});
|
||||
|
||||
test('an unparseable estimated completion date is dropped', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecEstimatedCompletion, 'value': 'not-a-date'},
|
||||
];
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.customOrderSpec!.estimatedCompletionDate, isNull);
|
||||
});
|
||||
|
||||
test('empty reference image meta yields an empty list', () {
|
||||
final meta = [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Linen'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Ivory'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecReferenceImages, 'value': ''},
|
||||
];
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.customOrderSpec!.referenceImageUrls, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mapper: writing custom order meta ─────────────────────────────────────
|
||||
|
||||
group('WooCommerceOrderMapper — customOrderMetaData', () {
|
||||
test('returns an empty list for a standard order', () {
|
||||
final standard = sampleCustomOrder().copyWith(orderType: OrderType.standard);
|
||||
expect(_mapper.customOrderMetaData(standard), isEmpty);
|
||||
});
|
||||
|
||||
test('writes the order type', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaOrderType), 'custom');
|
||||
});
|
||||
|
||||
test('writes the fulfillment status', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaFulfillmentStatus), 'depositReceived');
|
||||
});
|
||||
|
||||
test('writes every specification field', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecMaterial), 'Organic cotton');
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecColor), 'Sage green');
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecSize), 'Large');
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecDimensions), '8" round');
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaSpecNotes), 'Housewarming gift.');
|
||||
});
|
||||
|
||||
test('joins reference images with a newline', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
expect(
|
||||
metaValue(meta, WooCommerceOrderMapper.metaSpecReferenceImages),
|
||||
'https://cdn.test/a.jpg\nhttps://cdn.test/b.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('writes deposit amounts as integer cent strings', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaDepositAmountCents), '2400');
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaDepositTotalCents), '7200');
|
||||
});
|
||||
|
||||
test('writes the deposit paid flag', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaDepositPaid), 'true');
|
||||
});
|
||||
|
||||
test('omits the derived balance', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
final keys = meta.map((m) => m['key'] as String);
|
||||
expect(keys.any((k) => k.contains('balance')), false);
|
||||
});
|
||||
|
||||
test('never writes a payment or transaction identifier', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
final keys = meta.map((m) => m['key'] as String).toList();
|
||||
expect(keys.any((k) => k.contains('transaction')), false);
|
||||
expect(keys.any((k) => k.contains('payment_id')), false);
|
||||
expect(keys.any((k) => k.contains('txn')), false);
|
||||
});
|
||||
|
||||
test('every key uses the _kc_ prefix', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
for (final entry in meta) {
|
||||
expect(entry['key'] as String, startsWith('_kc_'));
|
||||
}
|
||||
});
|
||||
|
||||
test('omits absent optional fields rather than writing blanks', () {
|
||||
final minimal = sampleCustomOrder().copyWith(
|
||||
customOrderSpec: CustomOrderSpec(material: 'Linen', color: 'Ivory'),
|
||||
);
|
||||
final meta = _mapper.customOrderMetaData(minimal);
|
||||
final keys = meta.map((m) => m['key'] as String);
|
||||
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSpecSize)));
|
||||
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSpecDimensions)));
|
||||
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSpecNotes)));
|
||||
});
|
||||
|
||||
test('omits the settlement date until the balance is settled', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
final keys = meta.map((m) => m['key'] as String);
|
||||
expect(keys, isNot(contains(WooCommerceOrderMapper.metaSettlementDate)));
|
||||
});
|
||||
|
||||
test('writes the settlement date once settled', () {
|
||||
final settled = sampleCustomOrder().copyWith(
|
||||
depositInfo: sampleCustomOrder().depositInfo!.markSettled(settledAt: DateTime(2026, 5, 10)),
|
||||
);
|
||||
final meta = _mapper.customOrderMetaData(settled);
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaSettlementDate), isNotNull);
|
||||
});
|
||||
|
||||
test('specMetaEntries returns an empty map for a null spec', () {
|
||||
expect(_mapper.specMetaEntries(null), isEmpty);
|
||||
});
|
||||
|
||||
test('depositMetaEntries returns an empty map for a null deposit', () {
|
||||
expect(_mapper.depositMetaEntries(null), isEmpty);
|
||||
});
|
||||
|
||||
test('toMetaDataList converts a key map into WooCommerce list form', () {
|
||||
final list = WooCommerceOrderMapper.toMetaDataList({'_kc_a': '1'});
|
||||
expect(list, [
|
||||
{'key': '_kc_a', 'value': '1'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mapper: round trip ────────────────────────────────────────────────────
|
||||
|
||||
group('WooCommerceOrderMapper — custom order round trip', () {
|
||||
test('meta written by the mapper reads back into an equal spec', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.customOrderSpec, sampleCustomOrder().customOrderSpec);
|
||||
});
|
||||
|
||||
test('meta written by the mapper reads back into an equal deposit', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.depositInfo, sampleCustomOrder().depositInfo);
|
||||
});
|
||||
|
||||
test('order type and fulfillment stage survive the round trip', () {
|
||||
final meta = _mapper.customOrderMetaData(sampleCustomOrder());
|
||||
final order = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(order.orderType, OrderType.custom);
|
||||
expect(order.fulfillmentStatus, FulfillmentStatus.depositReceived);
|
||||
});
|
||||
|
||||
test('every pipeline stage survives the round trip', () {
|
||||
for (final stage in FulfillmentStatus.values) {
|
||||
final order = sampleCustomOrder().copyWith(fulfillmentStatus: stage);
|
||||
final meta = _mapper.customOrderMetaData(order);
|
||||
final parsed = _mapper.fromJson(orderJson(metaData: meta));
|
||||
expect(parsed.fulfillmentStatus, stage, reason: '${stage.name} did not round-trip');
|
||||
}
|
||||
});
|
||||
|
||||
test('toCreateJson includes custom order meta for a custom order', () {
|
||||
final body = _mapper.toCreateJson(sampleCustomOrder());
|
||||
final meta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
expect(metaValue(meta, WooCommerceOrderMapper.metaOrderType), 'custom');
|
||||
});
|
||||
|
||||
test('toCreateJson omits meta_data for a standard order', () {
|
||||
final standard = sampleCustomOrder().copyWith(orderType: OrderType.standard);
|
||||
expect(_mapper.toCreateJson(standard).containsKey('meta_data'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repository: createCustomOrder ─────────────────────────────────────────
|
||||
|
||||
group('WooCommerceOrdersRepository — createCustomOrder', () {
|
||||
test('POSTs the order and returns the created domain object', () async {
|
||||
final client = MockClient((request) async {
|
||||
expect(request.method, 'POST');
|
||||
expect(request.url.path, contains('/orders'));
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
expect(result.success, true);
|
||||
expect(result.updatedOrder!.isCustom, true);
|
||||
});
|
||||
|
||||
test('sends the custom order meta in the request body', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
|
||||
});
|
||||
|
||||
await repoWith(client).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaOrderType), 'custom');
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaSpecMaterial), 'Organic cotton');
|
||||
});
|
||||
|
||||
test('always starts the pipeline at quoted', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
|
||||
});
|
||||
|
||||
// The caller supplies depositReceived; the repository must override it.
|
||||
await repoWith(client).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaFulfillmentStatus), 'quoted');
|
||||
});
|
||||
|
||||
test('never sends a payment identifier', () async {
|
||||
String? sentBody;
|
||||
final client = MockClient((request) async {
|
||||
sentBody = request.body;
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 201);
|
||||
});
|
||||
|
||||
await repoWith(client).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
expect(sentBody, isNot(contains('transaction')));
|
||||
expect(sentBody, isNot(contains('txn')));
|
||||
});
|
||||
|
||||
test('returns a failure result on an API error', () async {
|
||||
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('400'));
|
||||
});
|
||||
|
||||
test('logs the API failure with the operation and status code', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
|
||||
final record = sink.recordsAtLeast(KcLogLevel.error).last;
|
||||
expect(record.context['operation'], 'createCustomOrder');
|
||||
expect(record.context['statusCode'], 400);
|
||||
expect(record.error, isA<WooCommerceApiException>());
|
||||
});
|
||||
|
||||
test('log context carries no customer identity', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).createCustomOrder(sampleCustomOrder(), spec: sampleSpec());
|
||||
|
||||
final record = sink.recordsAtLeast(KcLogLevel.error).last;
|
||||
final rendered = record.context.toString();
|
||||
expect(rendered, isNot(contains('Nadia')));
|
||||
expect(rendered, isNot(contains('@')));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repository: updateCustomOrderSpec ─────────────────────────────────────
|
||||
|
||||
group('WooCommerceOrdersRepository — updateCustomOrderSpec', () {
|
||||
test('GETs the order then PUTs the revised meta', () async {
|
||||
final methods = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
methods.add(request.method);
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(result.success, true);
|
||||
expect(methods, ['GET', 'PUT']);
|
||||
});
|
||||
|
||||
test('sends only meta_data on the PUT', () async {
|
||||
Map<String, dynamic>? putBody;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
putBody = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(putBody!.keys, ['meta_data']);
|
||||
});
|
||||
|
||||
test('sends the revised specification', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaSpecMaterial), 'Wool');
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaSpecColor), 'Slate');
|
||||
});
|
||||
|
||||
test('preserves the existing fulfillment stage in the PUT', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaFulfillmentStatus), 'depositReceived');
|
||||
});
|
||||
|
||||
test('rejects a standard order without issuing a PUT', () async {
|
||||
final methods = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
methods.add(request.method);
|
||||
return http.Response(jsonEncode(orderJson()), 200);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not a custom order'));
|
||||
expect(methods, ['GET']);
|
||||
});
|
||||
|
||||
test('rejects an order that does not exist', () async {
|
||||
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('9999', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('returns a failure result when the PUT fails', () async {
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
return http.Response('{"message":"Server error"}', 500);
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).updateCustomOrderSpec('3001', CustomOrderSpec(material: 'Wool', color: 'Slate'));
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('500'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repository: recordDeposit ─────────────────────────────────────────────
|
||||
|
||||
group('WooCommerceOrdersRepository — recordDeposit', () {
|
||||
/// Meta for a quoted commission with an unpaid deposit.
|
||||
List<Map<String, dynamic>> quotedMeta() => [
|
||||
{'key': WooCommerceOrderMapper.metaOrderType, 'value': 'custom'},
|
||||
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'quoted'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecMaterial, 'value': 'Organic cotton'},
|
||||
{'key': WooCommerceOrderMapper.metaSpecColor, 'value': 'Sage green'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositAmountCents, 'value': '2400'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositTotalCents, 'value': '7200'},
|
||||
{'key': WooCommerceOrderMapper.metaDepositPaid, 'value': 'false'},
|
||||
];
|
||||
|
||||
test('marks the deposit paid and PUTs the revised meta', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).recordDeposit('3001', amountCents: 2400, paidAt: DateTime.utc(2026, 4, 6));
|
||||
expect(result.success, true);
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositPaid), 'true');
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositAmountCents), '2400');
|
||||
});
|
||||
|
||||
test('sends the deposit date', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
|
||||
});
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
).recordDeposit('3001', amountCents: 2400, paidAt: DateTime.utc(2026, 4, 6));
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositDate), isNotNull);
|
||||
});
|
||||
|
||||
test('revises the total when supplied', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
|
||||
});
|
||||
|
||||
await repoWith(client).recordDeposit(
|
||||
'3001',
|
||||
amountCents: 2400,
|
||||
paidAt: DateTime.utc(2026, 4, 6),
|
||||
totalAmountCents: 9000,
|
||||
);
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaDepositTotalCents), '9000');
|
||||
});
|
||||
|
||||
test('never sends a payment identifier', () async {
|
||||
String? putBody;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
putBody = request.body;
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: quotedMeta())), 200);
|
||||
});
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
).recordDeposit('3001', amountCents: 2400, paidAt: DateTime.utc(2026, 4, 6));
|
||||
expect(putBody, isNot(contains('transaction')));
|
||||
expect(putBody, isNot(contains('txn')));
|
||||
});
|
||||
|
||||
test('rejects a non-positive amount without any HTTP call', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).recordDeposit('3001', amountCents: 0, paidAt: DateTime.utc(2026, 4, 6));
|
||||
expect(result.success, false);
|
||||
expect(calls, 0);
|
||||
});
|
||||
|
||||
test('rejects a second deposit', () async {
|
||||
// customMeta() already has depositPaid = true.
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
|
||||
);
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).recordDeposit('3001', amountCents: 1000, paidAt: DateTime.utc(2026, 4, 6));
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already been recorded'));
|
||||
});
|
||||
|
||||
test('rejects a standard order', () async {
|
||||
final client = MockClient((_) async => http.Response(jsonEncode(orderJson()), 200));
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).recordDeposit('3001', amountCents: 1000, paidAt: DateTime.utc(2026, 4, 6));
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not a custom order'));
|
||||
});
|
||||
|
||||
test('rejections do not log the money amount', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
|
||||
);
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).recordDeposit('3001', amountCents: 1234, paidAt: DateTime.utc(2026, 4, 6));
|
||||
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.context['operation'], 'recordDeposit');
|
||||
expect(record.format(), isNot(contains('1234')));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repository: advanceFulfillmentStatus ──────────────────────────────────
|
||||
|
||||
group('WooCommerceOrdersRepository — advanceFulfillmentStatus', () {
|
||||
test('a legal advance PUTs the new stage', () async {
|
||||
List<Map<String, dynamic>>? sentMeta;
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
sentMeta = (body['meta_data'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
// Remote stage is depositReceived; inProduction is the legal next step.
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
|
||||
expect(result.success, true);
|
||||
expect(metaValue(sentMeta!, WooCommerceOrderMapper.metaFulfillmentStatus), 'inProduction');
|
||||
});
|
||||
|
||||
test('a legal advance can cancel the commission', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
|
||||
);
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.cancelled);
|
||||
expect(result.success, true);
|
||||
});
|
||||
|
||||
test('an illegal jump is rejected without issuing a PUT', () async {
|
||||
final methods = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
methods.add(request.method);
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
// depositReceived → completed skips three stages.
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.completed);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('Cannot move from deposit received'));
|
||||
expect(methods, ['GET']);
|
||||
});
|
||||
|
||||
test('an advance to the same stage is rejected', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
|
||||
);
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.depositReceived);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already'));
|
||||
});
|
||||
|
||||
test('a completed remote order cannot be reopened', () async {
|
||||
final meta = [
|
||||
...customMeta().where((m) => m['key'] != WooCommerceOrderMapper.metaFulfillmentStatus),
|
||||
{'key': WooCommerceOrderMapper.metaFulfillmentStatus, 'value': 'completed'},
|
||||
];
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode(orderJson(metaData: meta)), 200),
|
||||
);
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('already completed'));
|
||||
});
|
||||
|
||||
test('rejects a standard order', () async {
|
||||
final client = MockClient((_) async => http.Response(jsonEncode(orderJson()), 200));
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not a custom order'));
|
||||
});
|
||||
|
||||
test('rejects an order that does not exist', () async {
|
||||
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('9999', FulfillmentStatus.inProduction);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('an illegal jump is logged with both stages', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode(orderJson(metaData: customMeta())), 200),
|
||||
);
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.completed);
|
||||
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.context['operation'], 'advanceFulfillmentStatus');
|
||||
expect(record.context['fromStage'], 'depositReceived');
|
||||
expect(record.context['toStage'], 'completed');
|
||||
});
|
||||
|
||||
test('returns a failure result when the PUT fails', () async {
|
||||
final client = MockClient((request) async {
|
||||
if (request.method == 'PUT') {
|
||||
return http.Response('{"message":"Server error"}', 500);
|
||||
}
|
||||
return http.Response(jsonEncode(orderJson(metaData: customMeta())), 200);
|
||||
});
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
).advanceFulfillmentStatus('3001', FulfillmentStatus.inProduction);
|
||||
expect(result.success, false);
|
||||
expect(result.errorMessage, contains('500'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repository: observability on existing operations ──────────────────────
|
||||
|
||||
group('WooCommerceOrdersRepository — observability', () {
|
||||
test('getOrder logs a 404 at warning severity and returns null', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
|
||||
|
||||
final order = await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).getOrder('9999');
|
||||
|
||||
expect(order, isNull);
|
||||
final record = sink.recordsAtLeast(KcLogLevel.warning).last;
|
||||
expect(record.level, KcLogLevel.warning);
|
||||
expect(record.context['operation'], 'getOrder');
|
||||
expect(record.context['statusCode'], 404);
|
||||
});
|
||||
|
||||
test('getOrder logs a non-404 failure at error severity and rethrows', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{"message":"Boom"}', 500));
|
||||
|
||||
final repo = repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
);
|
||||
await expectLater(repo.getOrder('3001'), throwsA(isA<WooCommerceApiException>()));
|
||||
|
||||
final record = sink.recordsAtLeast(KcLogLevel.error).last;
|
||||
expect(record.context['statusCode'], 500);
|
||||
});
|
||||
|
||||
test('updateOrderStatus logs API failures', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).updateOrderStatus('3001', OrderStatus.processing);
|
||||
|
||||
final record = sink.recordsAtLeast(KcLogLevel.error).last;
|
||||
expect(record.context['operation'], 'updateOrderStatus');
|
||||
expect(record.context['requestedStatus'], 'processing');
|
||||
});
|
||||
|
||||
test('addOrderNote logs an empty-note rejection', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{}', 200));
|
||||
|
||||
final result = await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).addOrderNote('3001', ' ');
|
||||
|
||||
expect(result.success, false);
|
||||
expect(sink.recordsAtLeast(KcLogLevel.warning).last.context['operation'], 'addOrderNote');
|
||||
});
|
||||
|
||||
test('records are tagged with the repository source', () async {
|
||||
final sink = KcInMemoryLogSink();
|
||||
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
|
||||
|
||||
await repoWith(
|
||||
client,
|
||||
logger: KcLogger(sink: sink, minimumLevel: KcLogLevel.debug),
|
||||
).updateOrderStatus('3001', OrderStatus.processing);
|
||||
|
||||
expect(sink.recordsAtLeast(KcLogLevel.error).last.source, 'WooCommerceOrdersRepository');
|
||||
});
|
||||
|
||||
test('defaults to a silent logger when none is injected', () async {
|
||||
final client = MockClient((_) async => http.Response('{"message":"Bad"}', 400));
|
||||
final result = await repoWith(client).updateOrderStatus('3001', OrderStatus.processing);
|
||||
// A missing logger must never turn an API failure into a crash.
|
||||
expect(result.success, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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<Map<String, dynamic>> updateOrder(String orderId, Map<String, dynamic> body) async {
|
||||
final uri = Uri.parse('$_baseEndpoint/orders/$orderId');
|
||||
final response = await _httpClient.put(uri, headers: _authHeaders, body: jsonEncode(body));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw WooCommerceApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to update order $orderId: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw WooCommerceApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/// Creates a new order in WooCommerce.
|
||||
///
|
||||
/// [body] should contain the WooCommerce order fields.
|
||||
|
|
|
|||
Loading…
Reference in New Issue