Compare commits

..

No commits in common. "main" and "feat/square-api-client" have entirely different histories.

53 changed files with 80 additions and 8272 deletions

View File

@ -1,6 +0,0 @@
# Execution & Workflow Rules
- Always run tests after making code changes.
- Do not modify configuration files (.env, docker-compose.yml, deployment scripts) without explicit user instructions.
- Keep terminal commands scoped to the current project directory.
- Work in modular, step-by-step implementations rather than massive multi-file overhauls.

View File

@ -1,102 +0,0 @@
# Workspace Development Rules: Kell Creations Platform
## 1. Core Operating Principles
1. **Vertical Slice Discipline:** Implement features in isolated, reviewable vertical slices on feature branches. Never push unverified, broad changes directly to `main`[cite: 3].
2. **Inspection First:** Always inspect the codebase, verify existing tests, and review existing contracts before writing or modifying code[cite: 3].
3. **Environment Isolation:** Maintain runtime environment modes (`fake`, `wp`, `square`, `multi`) specified exclusively via `--dart-define` keys[cite: 2, 3]. Never build hardcoded settings UIs or commit API keys/tokens to source control[cite: 3].
4. **Strict Package Boundaries:**
- Feature packages (`feature_*`) must remain decoupled[cite: 1, 3].
- Cross-feature communication must use shared abstractions in `core`, `integrations`, or `data`[cite: 1, 3].
- Platform-specific UI code must reside in `apps/kell_web` or `apps/kell_mobile`[cite: 1, 3]. Re-use shared presenters and controllers[cite: 2, 3].
---
## 2. Code & Design Standards
### Flutter & Dart Execution
- Strict typing required; avoid `dynamic` unless explicitly serializing raw JSON[cite: 2, 3].
- Use Material Design 3 guidelines[cite: 2, 3]. On mobile (`kell_mobile`), ensure all interactive targets adhere to a minimum 48×48dp target bounding box[cite: 2, 3].
- Wrap mobile screens in `SafeArea` to avoid system overlay clipping[cite: 2, 3].
### Architecture Layering (per Package)
lib/
├── src/
│ ├── domain/ # Entities, Value Objects, Repository Contracts (Pure Dart)
│ ├── application/ # Use Cases, Controllers (ChangeNotifiers)
│ ├── data/ # ApiClients, Mappers, Fake & Real Repositories
│ └── presentation/ # Widgets, Pages, Dialogs, SnackBars
└── feature_name.dart # Barrel File Exports
---
## 3. Standard Execution Workflow for AI Agents (Cline / VS Code)
Whenever initiating a new task or slice, execute the following workflow steps sequentially[cite: 3]:
1. **Step 1: Context Verification**
- Read `docs/development/master_development_brief.md` and `docs/development/build_execution_tracker.md` to identify current status[cite: 2, 3].
2. **Step 2: Pre-flight Validation**
- Run local test suite using the CI script:
```bash
bash tools/run_all_tests.sh --analyze
```
- Confirm all tests pass and static analysis is clean prior to editing[cite: 2, 3].
3. **Step 3: Implementation**
- Create target feature branch (e.g., `feat/square-catalog-sync`)[cite: 2].
- Implement domain models, repository contracts, API mappers, application logic, and UI components[cite: 2, 3].
4. **Step 4: Testing & Hardening**
- Write unit tests for domain models and application controllers[cite: 2].
- Write widget tests for presentation components[cite: 2].
- Verify static analysis passes without warnings:
```bash
dart analyze --fatal-infos
```
5. **Step 5: Documentation & Tracking**
- Update `docs/development/build_execution_tracker.md` with:
- Files modified/added[cite: 2].
- Test counts and pass state[cite: 2].
- Verification/analysis result[cite: 2].
- Update `docs/development/master_development_brief.md` next branch indicator[cite: 2, 3].
---
## 4. Prompt Template for AI Sessions
Continue the Kell Creations Flutter operations platform workstream from current main.
Read docs/development/master_development_brief.md first and follow it as the authoritative planning document.
Constraints:
- Preserve strict package boundaries.
- Fake repositories remain in feature packages.
- Real integrations are incremental alongside fake ones.
- Runtime selection stays via --dart-define, not settings UI.
- No credentials hardcoded or committed.
- Keep WooCommerce details inside the WP repository layer.
- Keep Square details inside a dedicated Square integration layer.
- Multi-channel sync contracts before implementations.
- Use small, reviewable steps only.
Working rules:
1. Inspect first. Do not edit immediately.
2. Summarize current relevant state from main.
3. Propose the smallest implementation plan for the requested slice.
4. Implement only that slice.
5. Add/update focused tests.
6. Run validation (run_all_tests.sh --analyze).
7. Report changed files and validation results.
8. Update master_development_brief.md and build_execution_tracker.md.

View File

@ -1,694 +0,0 @@
# PROJECT_STATE_AUDIT.md
## Kell Creations Operations Platform — Comprehensive Code Audit
**Audit Date:** 2026-07-25
**Auditor:** Senior Software Engineering Review Board (Automated)
**Baseline Commit:** `feat/square-catalog-sync-repositories``main` (Stage 10C complete)
**Current Branch Work:** Stage 10D (`SquareCatalogSyncService`) — complete, pending merge
**Total Tests (baseline):** 1,015 / 1,015 passing
**Static Analysis:** `dart analyze --fatal-infos` — clean across all 11 packages/apps
---
## 1. Repository Architecture Overview
### Monorepo Structure
```
kell_creations/
├── kell_creations_apps/
│ ├── apps/
│ │ ├── kell_web/ # Flutter Web app (Windows Desktop / Browser)
│ │ └── kell_mobile/ # Flutter Android app
│ ├── packages/
│ │ ├── core/ # Shared composition abstractions
│ │ ├── design_system/ # Shared UI components and theme
│ │ ├── auth/ # Authentication contracts + fake impl
│ │ ├── data/ # Data layer contracts + in-memory impls
│ │ ├── integrations/ # API client contracts + Square/WC clients
│ │ ├── feature_wordpress/ # Product catalog management (most mature)
│ │ ├── feature_inventory/ # Inventory management
│ │ ├── feature_orders/ # Order management
│ │ ├── feature_policy/ # Policy/governance compliance
│ │ ├── feature_finance/ # Financial reporting [STUB]
│ │ ├── feature_mrp/ # Manufacturing/MRP [STUB]
│ │ └── feature_social/ # Social media management [STUB]
│ └── tools/ # CI helper scripts
├── docs/ # MkDocs documentation site
├── architecture/ # PlantUML C4 diagrams (19 diagrams)
└── policies/ # Controlled policy document repository
```
### Architectural Pattern
- **Domain-Driven Design** with strict package boundaries
- **Repository pattern** with fake/real runtime selection via `--dart-define`
- **ChangeNotifier controllers** for unidirectional state flow (no Riverpod yet)
- **Shared composition pattern** (`KcAppConfig`, `KcBootstrap`, `KcAppScope`) across both app targets
- **Vertical slice discipline** — each feature has domain → application → data → presentation layers
---
## 2. Audit Matrix — Built vs. Remaining
### 2.1 Infrastructure Packages
#### `core` — [COMPLETE]
| Artifact | Status |
| ------------------------------------------------------------ | ---------------- |
| `KcAppConfig` / `KcAppEnvironment` (fake, wp, square, multi) | ✅ Complete |
| `KcAppServices` abstract base | ✅ Complete |
| `KcServiceFactory<T>` | ✅ Complete |
| `KcBootstrap` | ✅ Complete |
| `KcAppScope<T>` InheritedWidget | ✅ Complete |
| Tests | ✅ 24/24 passing |
**Notes:** `KcBootstrap` only validates WooCommerce credentials. Square/multi mode credential validation is not yet wired into the bootstrap guard logic — `AppServices` and `MobileAppServices` have no `square` or `multi` factory constructors, meaning `KC_ENV=square` silently falls through to fake mode at runtime.
---
#### `design_system` — [COMPLETE]
| Artifact | Status |
| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `KcColors`, `KcSpacing`, `KcTheme` | ✅ Complete |
| `KcTypography` (full Material 3 scale) | ✅ Complete |
| `KcBreakpoints` (responsive layout utilities) | ✅ Complete |
| 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`) | ✅ Complete |
| Tests | ✅ 41/41 passing, 100% line coverage |
**Notes:** No shared button/action component library exists. All action chips and buttons are defined inline per-page. This is a low-priority gap.
---
#### `auth` — [PARTIAL]
| Artifact | Status |
| ------------------------------------------------- | ---------------- |
| `KcUser` / `KcUserRole` domain models | ✅ Complete |
| `AuthState` sealed hierarchy | ✅ Complete |
| `AuthService` abstract contract | ✅ Complete |
| `AuthException` | ✅ Complete |
| `CredentialStore` / `CredentialKeys` contract | ✅ Complete |
| `InMemoryCredentialStore` | ✅ Complete |
| `FakeAuthService` | ✅ Complete |
| `SecureCredentialStore` (flutter_secure_storage) | ❌ Missing |
| Real `AuthService` implementation (login/session) | ❌ Missing |
| Auth guard / login screen wired into app shell | ❌ Missing |
| Tests | ✅ 42/42 passing |
**Notes:** The auth package provides contracts and fakes only. No production implementation exists. The app is currently unguarded — any user can access all features without authentication. `auth` is not imported by either `AppServices` or `MobileAppServices`.
---
#### `data` — [PARTIAL]
| Artifact | Status |
| -------------------------------------------------- | ---------------- |
| `LocalCache<T>` contract + `InMemoryCache<T>` | ✅ Complete |
| `SyncQueue` contract + `InMemorySyncQueue` | ✅ Complete |
| `ConflictResolver<T>` + 3 concrete implementations | ✅ Complete |
| `ChangeTracker<T>` + `InMemoryChangeTracker<T>` | ✅ Complete |
| `DataMapper<TLocal, TRemote>` contract | ✅ Complete |
| SQLite-backed `LocalCache<T>` implementation | ❌ Missing |
| SQLite-backed `SyncQueue` implementation | ❌ Missing |
| Integration with any feature package | ❌ Missing |
| Tests | ✅ 63/63 passing |
**Notes:** All implementations are in-memory only. The `data` package is not imported by any feature package or app. It is a well-designed contract layer with zero production wiring. SQLite persistence (required for offline capability, Stage 14) is not implemented.
---
#### `integrations` — [PARTIAL]
| Artifact | Status |
| -------------------------------------------------------------------------- | ------------------------------------------ |
| `ApiClient` / `ApiException` contracts | ✅ Complete |
| `RetryPolicy` + `NoRetryPolicy` + `ExponentialBackoffRetryPolicy` | ✅ Complete |
| `RateLimiter` + `NoRateLimiter` + `TokenBucketRateLimiter` | ✅ Complete |
| `IntegrationHealthCheck` contract | ✅ Complete |
| `WebhookHandler` contract | ✅ Complete |
| `ChannelAdapter` contract | ✅ Complete |
| `SquareApiClient` (full lifecycle, Catalog/Inventory/Orders/Payments APIs) | ✅ Complete |
| `SquareApiException` | ✅ Complete |
| `SquareProductMapper` (Square CatalogObject ↔ `ChannelProduct`) | ✅ Complete |
| `SquareInventoryMapper` (Square InventoryCount ↔ `ChannelInventoryCount`) | ✅ Complete |
| `WooCommerceApiClient` (refactored to implement `ApiClient`) | ✅ Complete (lives in `feature_wordpress`) |
| `RetryPolicy` / `RateLimiter` wired into any real client | ❌ Missing |
| `IntegrationHealthCheck` concrete implementation | ❌ Missing |
| `WebhookHandler` concrete implementation | ❌ Missing |
| `ChannelAdapter` concrete implementation for WooCommerce or Square | ❌ Missing |
| Tests | ✅ 123/123 passing |
**Notes:** `RetryPolicy` and `RateLimiter` are defined but not wired into `SquareApiClient` or `WooCommerceApiClient`. All API calls are single-attempt with no retry or rate limiting in production paths. `ChannelAdapter` is a contract with no concrete implementation — the WooCommerce and Square clients do not implement it.
---
### 2.2 Feature Packages
#### `feature_wordpress` — [COMPLETE] (most mature)
| Artifact | Status |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `ProductDraft` domain model (full fields, `copyWith`) | ✅ Complete |
| `PublishStatus` enum (draft, published, pendingReview, unpublished) | ✅ Complete |
| `ProductCategory` domain model | ✅ Complete |
| `ProductImage` domain model | ✅ Complete |
| `ProductIdMapping` domain model | ✅ Complete |
| `ProductPublishingRepository` contract (full CRUD + categories + images) | ✅ Complete |
| `ProductIdMappingRepository` contract | ✅ Complete |
| `FakeProductPublishingRepository` (seeded, all methods) | ✅ Complete |
| `FakeProductIdMappingRepository` (seeded, 6 mappings) | ✅ Complete |
| `WordPressProductPublishingRepository` (WooCommerce REST API v3) | ✅ Complete |
| `SquareCatalogRepository` (Square API, in-memory cache) | ✅ Complete |
| `WooCommerceApiClient` (Products, Orders, Stock, Categories, Images) | ✅ Complete |
| `WordPressProductMapper` | ✅ Complete |
| All use cases (GetProductDrafts, PublishProduct, UpdateProduct\*, CreateProduct, DeleteProduct, GetCategories, CreateCategory, UpdateProductImages) | ✅ Complete |
| `ProductPublishingController` (full state: multi-select, bulk actions, create, categories) | ✅ Complete |
| `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter` | ✅ Complete (Stage 10D, pending merge) |
| `ProductPublishingPage` (web, full feature surface) | ✅ Complete |
| `CategoryManagementPage` | ✅ Complete |
| `ProductPreviewPanel` (all inline edits, touch targets) | ✅ Complete |
| `ProductDraftCard` (compact/standard, multi-select, stale indicator) | ✅ Complete |
| `CreateProductDialog` (web) | ✅ Complete |
| `SquareSyncController` (Riverpod/ChangeNotifier wrapping `SquareCatalogSyncService`) | ❌ Missing (Stage 10E — next branch) |
| Image picker / gallery management UI | ❌ Missing (deferred post-Stage 10) |
| Tests | ✅ 506/506 passing (Stage 10D) |
**Notes:** The `SquareCatalogRepository` throws `UnsupportedError` for all category and image operations — this is intentional and documented, but callers must handle these exceptions. The `SquareSyncController` (Stage 10E) is the immediate next deliverable.
---
#### `feature_inventory` — [PARTIAL]
| Artifact | Status |
| -------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `InventoryItem` domain model (full fields including `squareVariationId`, `wooCommerceProductId`, `syncStatus`) | ✅ Complete |
| `InventoryStatus` enum | ✅ Complete |
| `InventorySyncStatus` enum | ✅ Complete |
| `InventoryAdjustmentResult` value object | ✅ Complete |
| `InventoryRepository` contract (read + write + sync) | ✅ Complete |
| `FakeInventoryRepository` (seeded, all write methods) | ✅ Complete |
| `WooCommerceInventoryRepository` | ✅ Complete |
| `SquareInventoryRepository` | ✅ Complete |
| `InventorySyncService` (WooCommerce pull/push sync) | ✅ Complete |
| All use cases (GetInventoryItems, AdjustInventoryQuantity, CreateInventoryItem, UpdateInventoryItem) | ✅ Complete |
| `InventoryController` (read + write actions) | ✅ Complete |
| `InventoryPage` (web, full filter/search/detail panel) | ✅ Complete |
| `InventoryDetailPanel`, `AdjustQuantityDialog`, `InventoryStatusChip` | ✅ Complete |
| `ItemType` enum (rawMaterial, finishedGood, packaging, tool) | ❌ Missing (Stage 12A) |
| `InventoryAdjustment` audit log value object | ❌ Missing (Stage 12A) |
| `Supplier` domain model | ❌ Missing (Stage 12B) |
| `PurchaseOrder` domain model | ❌ Missing (Stage 12B) |
| Supply receiving / material usage workflows | ❌ Missing (Stage 12B) |
| Multi-channel inventory sync engine (`InventorySyncEngine`) | ❌ Missing (Stage 12C) |
| `ChannelStockMapping` | ❌ Missing (Stage 12C) |
| Sync status indicators in inventory UI | ❌ Missing |
| Tests | ✅ 167/167 passing |
**Notes:** The inventory feature has a solid domain and data layer. The `InventorySyncService` is WooCommerce-only. Square inventory sync is handled separately via `SquareInventoryRepository` but there is no unified sync engine that coordinates both channels. The `InventoryController` is not wired to `InventorySyncService` — sync must be triggered manually.
---
#### `feature_orders` — [PARTIAL]
| Artifact | Status |
| --------------------------------------------------------------------------------------------------------- | ---------------------- |
| `Order` domain model (full fields: customerPhone, notes, orderNotes, paymentMethod, source, customFields) | ✅ Complete |
| `OrderItem` domain model (productId, variationId, customizations) | ✅ Complete |
| `OrderStatus` enum (including onHold, refunded, failed) | ✅ Complete |
| `OrderSource` enum (online, market, manual) | ✅ Complete |
| `OrderActionResult` value object | ✅ Complete |
| `OrdersRepository` contract (getOrders, getOrder, createOrder, updateOrderStatus, addOrderNote) | ✅ Complete |
| `FakeOrdersRepository` (seeded, all write methods) | ✅ Complete |
| `WooCommerceOrdersRepository` | ✅ Complete |
| `WooCommerceOrderMapper` | ✅ Complete |
| All use cases (GetOrders, UpdateOrderStatus, AddOrderNote, CreateOrder) | ✅ Complete |
| `OrdersController` (read + write, filter/search persistence) | ✅ Complete |
| `OrdersPage` (web, master-detail, status filter, cross-feature callbacks) | ✅ Complete |
| `OrderDetailPanel`, `OrderCard`, `AddNoteDialog`, `OrderStatusChip` | ✅ Complete |
| `OrderType` enum (standard, custom) | ❌ Missing (Stage 11A) |
| `CustomOrderSpec` value object | ❌ Missing (Stage 11A) |
| `DepositInfo` value object | ❌ Missing (Stage 11A) |
| Custom order fulfillment status pipeline | ❌ Missing (Stage 11C) |
| `CustomOrderFormPage` | ❌ Missing (Stage 11B) |
| `CustomOrderTrackingPage` | ❌ Missing (Stage 11C) |
| `OrderChannel` enum (woocommerce, square, manual) | ❌ Missing (Stage 13B) |
| `UnifiedOrdersController` (multi-channel aggregation) | ❌ Missing (Stage 13B) |
| Standard scaffolding files (`.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md`) | ❌ Missing |
| Tests | ✅ 198/198 passing |
**Notes:** The `feature_orders` package is missing standard monorepo scaffolding files present in all other packages. The WooCommerce orders integration is production-ready. Square orders are not yet integrated into the orders feature (Square API client has `createOrder`/`listOrders`/`getOrder` but no `SquareOrdersRepository` exists).
---
#### `feature_policy` — [PARTIAL]
| Artifact | Status |
| --------------------------------------------------------------------- | ---------------------------------------------------------- |
| `PolicyCheckResult` domain model | ✅ Complete |
| `GovernanceStatus` enum | ✅ Complete |
| `PolicyRepository` contract | ✅ Complete |
| `FakePolicyRepository` | ✅ Complete |
| `GetPolicyChecks` use case | ✅ Complete |
| `PolicyController` | ✅ Complete |
| `PolicyPage` (master-detail, filter, search, cross-feature callbacks) | ✅ Complete |
| `PolicyCheckCard`, `PolicyDetailPanel` | ✅ Complete |
| Real backend (e.g., reading from policy register CSVs) | ❌ Missing |
| Tests | ⚠️ Minimal (only smoke test in `feature_policy_test.dart`) |
**Notes:** The policy feature is fake-data only. The policy register CSVs in `policies/register/` contain headers only with no data rows — the controlled document lifecycle is not being followed for the governance documents themselves.
---
#### `feature_finance` — [MISSING/PENDING]
| Artifact | Status |
| --------------------- | ------------------------------------- |
| Package scaffolding | ✅ Exists |
| Any domain model | ❌ Missing |
| Any application logic | ❌ Missing |
| Any data layer | ❌ Missing |
| Any presentation | ❌ Missing |
| Tests | ❌ Placeholder `Calculator` test only |
**Notes:** The barrel file (`feature_finance.dart`) contains only the Flutter template `Calculator` class. The Finance route in `kell_web` renders `FinancePlaceholderPage`. This is a pure stub.
---
#### `feature_mrp` — [MISSING/PENDING]
| Artifact | Status |
| --------------------- | ------------------------------------- |
| Package scaffolding | ✅ Exists |
| Any domain model | ❌ Missing |
| Any application logic | ❌ Missing |
| Any data layer | ❌ Missing |
| Any presentation | ❌ Missing |
| Tests | ❌ Placeholder `Calculator` test only |
**Notes:** Pure stub. Scope should be re-evaluated after Stage 12 (supplies tracking) to determine if MRP adds distinct value beyond custom orders + inventory.
---
#### `feature_social` — [MISSING/PENDING]
| Artifact | Status |
| --------------------- | ------------------------------------- |
| Package scaffolding | ✅ Exists |
| Any domain model | ❌ Missing |
| Any application logic | ❌ Missing |
| Any data layer | ❌ Missing |
| Any presentation | ❌ Missing |
| Tests | ❌ Placeholder `Calculator` test only |
**Notes:** Pure stub. Deferred until core platform stabilizes. Facebook, Instagram, and X (Twitter) API integrations are all listed as "Stub only" in the integration landscape.
---
### 2.3 Application Targets
#### `kell_web` — [COMPLETE] (for current feature scope)
| Artifact | Status |
| ----------------------------------------------------------------------------------- | ------------------- |
| `AppServices` (fake + wordpress factories) | ✅ Complete |
| `KcBootstrap` / `KcAppScope` wiring | ✅ Complete |
| `AppShell` with sidebar navigation (7 routes) | ✅ Complete |
| `AppRoutes` (dashboard, inventory, products, orders, finance, policy, integrations) | ✅ Complete |
| `DashboardPage` (8 KPI cards, quick actions, cross-feature navigation) | ✅ Complete |
| `ProductPublishingPage` (full feature surface) | ✅ Complete |
| `InventoryPage` (full feature surface) | ✅ Complete |
| `OrdersPage` (full feature surface) | ✅ Complete |
| `PolicyPage` (full feature surface) | ✅ Complete |
| `FinancePlaceholderPage` | ⚠️ Placeholder only |
| `IntegrationsPlaceholderPage` | ⚠️ Placeholder only |
| `square` / `multi` factory constructors in `AppServices` | ❌ Missing |
| Tests | ✅ 24/24 passing |
**Notes:** `AppServices` has no `square()` or `multi()` factory. Setting `KC_ENV=square` or `KC_ENV=multi` at runtime will fall through to `fake` mode silently because `KcServiceFactory` only has `createFake` and `createWordPress` callbacks. This is a critical gap for Square integration testing.
---
#### `kell_mobile` — [COMPLETE] (for current feature scope)
| Artifact | Status |
| ----------------------------------------------------------------------------------- | ---------------------- |
| `MobileAppServices` (fake + wordpress factories) | ✅ Complete |
| `KcBootstrap` / `KcAppScope` wiring | ✅ Complete |
| `MobileShell` (5-tab NavigationBar: Dashboard, Inventory, Orders, Publishing, More) | ✅ Complete |
| `DashboardPage` (mobile GridView layout) | ✅ Complete |
| `MobilePublishingPage` + `MobileProductDetailPage` | ✅ Complete |
| `MobileCreateProductPage` | ✅ Complete |
| `MobileInventoryPage` + `MobileInventoryDetailPage` | ✅ Complete |
| `MobileOrdersPage` + `MobileOrderDetailPage` | ✅ Complete |
| Haptic feedback, confirmation dialogs, SafeArea, 48dp touch targets | ✅ Complete |
| `FinancePlaceholderPage` | ⚠️ Placeholder only |
| `IntegrationsPlaceholderPage` | ⚠️ Placeholder only |
| `square` / `multi` factory constructors in `MobileAppServices` | ❌ Missing |
| Square POS / checkout flow | ❌ Missing (Stage 10C) |
| `MobileCheckoutPage` | ❌ Missing (Stage 10C) |
| Tests | ✅ 26/26 passing |
---
### 2.4 Architecture & Documentation
#### Architecture Documentation — [COMPLETE]
| Artifact | Status |
| ------------------------------------------------------------------------------ | ------------------------------- |
| 19 PlantUML C4 diagrams (context, containers, components, deployment, dynamic) | ✅ Complete |
| MkDocs documentation site (`mkdocs.yml`) | ✅ Complete |
| ADR index (7 retroactive entries) | ✅ Complete |
| Traceability index | ✅ Complete |
| Square not represented in architecture diagrams | ❌ Missing (noted in brief §17) |
| Multi-channel data model architecture | ❌ Missing (noted in brief §18) |
#### Policy Repository — [PARTIAL]
| Artifact | Status |
| ----------------------------------------------------------------------------- | ------------------------------------- |
| Policy templates (policy, procedure, standard, form, exception) | ✅ Complete |
| Active governance documents (KC-POL-GOV-001, KC-PRO-GOV-001) | ✅ Complete |
| Policy register CSVs (policy-register, document-control-log, review-calendar) | ❌ Empty (headers only, no data rows) |
| Exception register | ❌ Empty |
---
## 3. Technical Debt & Code Hygiene Assessment
### 3.1 Dead Code / Placeholder Stubs
| Location | Issue | Severity |
| ------------------------------------------------------------------ | -------------------------------------------------------------- | -------- |
| `feature_finance/lib/feature_finance.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
| `feature_mrp/lib/feature_mrp.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
| `feature_social/lib/feature_social.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
| `kell_web/lib/pages/finance_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_web/lib/pages/integrations_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_mobile/lib/pages/finance_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_mobile/lib/pages/integrations_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
| `kell_web/lib/pages/dashboard_page.dart` (Recent Activity section) | `KcEmptyState` hardcoded — no real activity feed | LOW |
### 3.2 Missing Error Handling / Defensive Gaps
| Location | Issue | Severity |
| ---------------------------------------------- | ----------------------------------------------------------------------------- | -------- |
| `AppServices` / `MobileAppServices` | No `square()` or `multi()` factory — `KC_ENV=square` silently falls to fake | CRITICAL |
| `KcBootstrap` | Only validates WooCommerce credentials; Square credential validation absent | HIGH |
| `SquareCatalogRepository` | 6 methods throw `UnsupportedError` (categories, images) — callers must handle | HIGH |
| `integrations``RetryPolicy` / `RateLimiter` | Defined but not wired into `SquareApiClient` or `WooCommerceApiClient` | HIGH |
| `auth` package | Not imported or used by any app or feature package | HIGH |
| `data` package | Not imported or used by any app or feature package | MEDIUM |
| `InventoryController` | Not wired to `InventorySyncService` — sync cannot be triggered from UI | MEDIUM |
| `SquareCatalogSyncService` | No Riverpod/ChangeNotifier controller wrapper — cannot be triggered from UI | MEDIUM |
| `WooCommerceApiClient` | No retry logic on transient failures (5xx, network timeouts) | MEDIUM |
| `feature_policy` tests | Only a smoke test exists — no domain, controller, or widget tests | MEDIUM |
### 3.3 Hardcoded Values / Antipatterns
| Location | Issue | Severity |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------- |
| `SquareCatalogSyncService.syncCatalog()` | Uses `product.id` as both `localId` and `squareCatalogObjectId` — conflates Square catalog IDs with local product IDs | HIGH |
| `InventorySyncService._syncItem()` | `pushLocalStock` silently swallows push failures with `catch (_) {}` — no logging | MEDIUM |
| `kell_web/lib/pages/dashboard_page.dart` | Revenue card navigates to Finance route which is a placeholder | LOW |
| `policies/register/*.csv` | All register CSVs contain headers only — no data rows | MEDIUM |
| `feature_orders` package | Missing `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` | LOW |
### 3.4 Test Coverage Assessment
| Package | Tests | Coverage | Assessment |
| ------------------- | --------------- | -------- | ----------------------------------------------------- |
| `core` | 24 | ~85.7% | Good — composition abstractions well covered |
| `design_system` | 41 | 100% | Excellent |
| `feature_wordpress` | 506 | ~84.7% | Good — broad coverage across all layers |
| `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present |
| `feature_orders` | 198 | Unknown | Good — domain, data, mapper, and widget tests present |
| `feature_policy` | ~5 | Very low | **Gap** — only smoke test; no controller/widget tests |
| `auth` | 42 | Unknown | Good for contracts and fakes |
| `data` | 63 | Unknown | Good for contracts and in-memory impls |
| `integrations` | 123 | Unknown | Good — API client and mapper tests present |
| `kell_web` | 24 | ~54.1% | **Gap** — low coverage; routing and shell untested |
| `kell_mobile` | 26 | Unknown | Adequate — shell and page smoke tests |
| `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
| `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
**Key gaps:**
- `kell_web` at 54.1% line coverage — routing, shell, and navigation are largely untested
- `feature_policy` has no meaningful tests beyond a barrel smoke test
- No integration tests exist across any package boundary
- No end-to-end tests exist for any user workflow
- CI enforces no minimum coverage thresholds (visibility-only)
### 3.5 Architectural Antipatterns
| Issue | Description | Severity |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| **No state management framework** | All state uses `ChangeNotifier` + `AnimatedBuilder`. As complexity grows (sync engine, multi-channel, offline queue), this pattern will become difficult to compose. No Riverpod, BLoC, or equivalent. | MEDIUM |
| **No dependency injection** | Services are constructed directly in factory methods. No DI container. Makes testing harder as the app grows. | MEDIUM |
| **`SquareCatalogSyncService` ID conflation** | `syncCatalog()` uses `product.id` as the Square catalog object ID, but `product.id` is the local/WooCommerce ID. This will produce incorrect mappings when Square is the source. | HIGH |
| **`AppServices` / `MobileAppServices` duplication** | Both files are nearly identical. A shared base class or factory could eliminate the duplication. | LOW |
| **No logging infrastructure** | No structured logging anywhere in the codebase. Errors are caught and surfaced via SnackBars or swallowed silently. | MEDIUM |
| **No crash reporting** | No Sentry, Firebase Crashlytics, or equivalent. Runtime errors are invisible in production. | MEDIUM |
---
## 4. Actionable Engineering Backlog
### Phase 1 — High-Priority / Blocking (Core Fixes & Missing Execution Paths)
These items block correct runtime behavior or represent data integrity risks.
#### P1.1 — Wire `square` and `multi` factory constructors into `AppServices` and `MobileAppServices`
**Why blocking:** Setting `KC_ENV=square` currently silently falls to fake mode. Square integration cannot be tested end-to-end without this.
**Scope:** Add `AppServices.square()` and `AppServices.multi()` factories using `SquareCatalogRepository` and `SquareInventoryRepository`. Mirror in `MobileAppServices`. Update `KcServiceFactory` to accept `createSquare` and `createMulti` callbacks. Update `KcBootstrap` to validate Square credentials.
**Branch:** `feat/square-catalog-sync-service-controller` (Stage 10E) should include this.
#### P1.2 — Fix `SquareCatalogSyncService.syncCatalog()` ID conflation
**Why blocking:** The current implementation uses `product.id` (a local/WooCommerce ID) as the `squareCatalogObjectId`. When `SquareCatalogRepository` is the source, `product.id` is the Square catalog object ID — but when `FakeProductPublishingRepository` or `WordPressProductPublishingRepository` is the source, `product.id` is a WooCommerce ID. The mapping will be incorrect in mixed-source scenarios.
**Scope:** The sync service needs to know which repository type it is talking to, or the `ProductDraft` model needs a `squareCatalogObjectId` field, or the sync service should accept the Square catalog object ID separately.
#### P1.3 — Merge Stage 10D (`feat/square-catalog-sync-service`)
**Why blocking:** The build tracker shows Stage 10D as "ready for merge" but not yet merged. The master brief baseline is Stage 10C. Stage 10E depends on 10D being on `main`.
**Action:** Merge `feat/square-catalog-sync-service``main` before starting Stage 10E.
#### P1.4 — `SquareSyncController` (Stage 10E)
**Why blocking:** `SquareCatalogSyncService` exists but has no UI trigger. Without a controller, sync cannot be initiated from the app.
**Scope:** `ChangeNotifier` wrapping `SquareCatalogSyncService`, exposing `syncAll()`, `syncCatalog()`, `syncInventory()`, `isSyncing`, `lastSyncedAt`, `lastResult`. Wire into `AppServices` / `MobileAppServices`.
**Branch:** `feat/square-catalog-sync-service-controller`
#### P1.5 — Add `square` / `multi` mode to `KcBootstrap` credential validation
**Why blocking:** `KcBootstrap` currently only validates WooCommerce credentials. Square mode will silently proceed with empty tokens.
**Scope:** Add `hasSquareConfig` check in bootstrap for `square` and `multi` environments.
---
### Phase 2 — Feature Completion & Integration
These items complete planned stages and wire existing contracts into production paths.
#### P2.1 — Square Mobile POS / Checkout (Stage 10C)
**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)
**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 ✅
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
**Scope:** `ItemType` enum, `InventoryAdjustment` audit log, `Supplier` domain model, `PurchaseOrder` domain model, supply receiving workflow, material usage workflow, low-stock alert system, `SuppliesPage` UI.
**Dependencies:** Stage 9B ✅
#### P2.4 — Multi-Channel Inventory Sync Engine (Stage 12C)
**Scope:** `InventorySyncEngine` (poll/webhook-triggered, conflict resolution, batch sync), `ChannelStockMapping`, `WooCommerceStockSync`, `SquareStockSync`, sync status indicators in inventory UI, manual sync trigger.
**Dependencies:** P2.3, Stage 10B ✅
#### P2.5 — WooCommerce Orders Integration wired into `AppServices`
**Current state:** `WooCommerceOrdersRepository` exists but `AppServices.wordpress()` still uses `FakeOrdersRepository`.
**Scope:** Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` and `MobileAppServices.wordpress()`.
#### P2.6 — WooCommerce Inventory Integration wired into `AppServices`
**Current state:** `WooCommerceInventoryRepository` exists but `AppServices.wordpress()` still uses `FakeInventoryRepository`.
**Scope:** Wire `WooCommerceInventoryRepository` into `AppServices.wordpress()` and `MobileAppServices.wordpress()`.
#### P2.7 — Unified Order View (Stage 13B)
**Scope:** `OrderChannel` enum, `UnifiedOrdersController` aggregating WooCommerce + Square + manual orders, unified orders page with channel indicator.
**Dependencies:** P2.1, Stage 9A ✅
#### P2.8 — Offline Data Cache & Sales Queue (Stage 14)
**Scope:** SQLite-backed `LocalCache<T>` implementation (using `sqflite` or `drift`), SQLite-backed `SyncQueue`, offline detection, "Prepare for Market" pre-sync action, auto-sync on reconnect, conflict handling.
**Dependencies:** `data` package contracts ✅
#### P2.9 — `feature_finance` Activation (Stage 15)
**Scope:** Sales reporting (by channel, product, time period), inventory valuation, financial dashboard (revenue vs. COGS, gross margin, market event profitability).
**Dependencies:** P2.1, Stage 9A ✅, P2.3
#### P2.10 — Populate Policy Register CSVs
**Scope:** Add data rows to `policies/register/policy-register.csv`, `document-control-log.csv`, `review-calendar.csv`, and `exception-register.csv` for the 2 active governance documents.
**Dependencies:** None — can be done immediately.
---
### Phase 3 — Defensive Engineering, Logging & Test Harness Expansion
These items harden the platform for production deployment.
#### P3.1 — Wire `RetryPolicy` and `RateLimiter` into API clients
**Scope:** Apply `ExponentialBackoffRetryPolicy` to `SquareApiClient` and `WooCommerceApiClient` for 5xx and network errors. Apply `TokenBucketRateLimiter` to Square API calls (Square enforces rate limits).
#### P3.2 — Implement `SecureCredentialStore` (Stage 8C remainder)
**Scope:** `flutter_secure_storage`-backed `CredentialStore` implementation. First-run setup wizard for entering API credentials. Connection testing UI.
#### P3.3 — Auth guard wired into app shell
**Scope:** Login screen, `AuthService` wired into `AppServices`, route guard preventing unauthenticated access.
**Dependencies:** P3.2
#### P3.4 — Structured logging infrastructure
**Scope:** Add a logging abstraction (e.g., `package:logging`) with structured output. Log all API calls, sync operations, errors, and state transitions. Replace silent `catch (_) {}` blocks with logged failures.
#### P3.5 — Crash reporting integration (Stage 16A)
**Scope:** Sentry or Firebase Crashlytics integration. Health check dashboard showing integration status per channel.
#### P3.6 — Expand `feature_policy` test coverage
**Scope:** Add controller tests, widget tests for `PolicyPage`, `PolicyCheckCard`, `PolicyDetailPanel`. Target >80% line coverage.
#### P3.7 — Expand `kell_web` test coverage from 54.1%
**Scope:** Add routing tests, shell navigation tests, `AppServices` factory tests. Target >75% line coverage.
#### P3.8 — Add `feature_orders` standard scaffolding files
**Scope:** Add `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` to `feature_orders` to match all other packages.
#### 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`.
#### P3.10 — Implement `ChannelAdapter` for WooCommerce and Square
**Scope:** Concrete `WooCommerceChannelAdapter` and `SquareChannelAdapter` implementing the `ChannelAdapter` contract from `integrations`. This enables the multi-channel sync engine (Stage 13A) to operate against a channel-agnostic interface.
#### P3.11 — Enforce minimum test coverage thresholds in CI
**Scope:** Update `flutter-test.yml` to fail the build if any package drops below a defined threshold (e.g., 75% line coverage). Currently coverage is visibility-only.
#### P3.12 — Evaluate and activate `feature_mrp`
**Scope:** After Stage 12 completion, assess whether MRP adds distinct value (production scheduling, work orders, bill of materials) beyond custom orders + inventory. Activate or formally retire the package.
---
## 5. Summary Scorecard
| Subsystem | Status | Completeness | Blocking Issues |
| ------------------- | ----------- | ------------ | ----------------------------------------------------------------- |
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
| `design_system` | ✅ COMPLETE | 100% | None |
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
| `feature_social` | ❌ MISSING | 0% | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
### Overall Platform Readiness
| Dimension | Assessment |
| ---------------------------------- | ----------------------------------------------------------- |
| **WooCommerce catalog management** | ✅ Production-ready |
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
| **Square catalog sync** | ⚠️ Service layer complete; no UI trigger; ID conflation bug |
| **Square inventory sync** | ⚠️ Repository complete; not wired into AppServices |
| **Square payments / POS** | ❌ Not implemented |
| **Custom orders** | ❌ Not implemented |
| **Supplies tracking** | ❌ Not implemented |
| **Multi-channel sync engine** | ❌ Not implemented |
| **Offline capability** | ❌ Not implemented |
| **Authentication** | ❌ Contracts only; not wired |
| **Financial reporting** | ❌ Not implemented |
| **Social media management** | ❌ Not implemented |
---
_Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25._
---
## 5. Summary Scorecard
| Subsystem | Status | Completeness | Blocking Issues |
|---|---|---|---|
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
| `design_system` | ✅ COMPLETE | 100% | None |
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
| `feature_social` | ❌ MISSING | 0% | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
### Overall Platform Readiness
| Dimension | Assessment |
|---|---|
| **WooCommerce catalog management** | ✅ Production-ready |
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
| **Square catalog sync** | ⚠️ Service layer complete; no UI trigger; ID conflation bug |
| **Square inventory sync** | ⚠️ Repository complete; not wired into AppServices |
| **Square payments / POS** | ❌ Not implemented |
| **Custom orders** | ❌ Not implemented |
| **Supplies tracking** | ❌ Not implemented |
| **Multi-channel sync engine** | ❌ Not implemented |
| **Offline capability** | ❌ Not implemented |
| **Authentication** | ❌ Contracts only; not wired |
| **Financial reporting** | ❌ Not implemented |
| **Social media management** | ❌ Not implemented |
---
*Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25.*

View File

@ -1,82 +0,0 @@
# Product Requirement Document (PRD): Kell Creations Operations Platform
## 1. Executive Summary & Vision
The **Kell Creations Operations Platform** is a cross-platform (Web & Android) multi-channel commerce management system. It unifies online storefront operations (WooCommerce), in-person craft market point-of-sale (Square), custom made-to-order requests, and raw supply/finished inventory management into a single operational interface[cite: 3].
## 2. Platform Architecture & Stack
- **Framework:** Flutter (Dart 3.11+)
- **Target Platforms:** Windows Desktop / Web (`kell_web`), Android (`kell_mobile`)[cite: 1, 3]
- **Monorepo Architecture:** Feature-first modular packages (`feature_wordpress`, `feature_inventory`, `feature_orders`, `feature_policy`, `auth`, `data`, `integrations`, `design_system`, `core`)[cite: 1, 3]
- **State & Data Flow:** Unidirectional flow using `ChangeNotifier` controllers and repository interfaces with mock (`FAKE`) and production implementation modes.
---
## 3. Core Functional Requirements
### 3.1 Multi-Channel Catalog Management
- **WooCommerce Storefront:**
- View, filter, search, and sort product drafts and published items.
- Single-field and controlled inline edits (Name, Price, Description, Category).
- Controlled status transitions (`unpublished` -> `draft`, `pendingReview` -> `draft`/`published`)[cite: 3].
- Full Product Creation pipeline (`CreateProductDialog` on Web, `MobileCreateProductPage` on Android)[cite: 2, 3].
- Product category CRUD and management workspace[cite: 2, 3].
- Controlled bulk actions (Bulk move-to-draft, bulk publish, bulk submit for review)[cite: 2, 3].
- **Square Catalog Integration:**
- Map WooCommerce items to Square Catalog API objects (`CatalogObject`)[cite: 2, 3].
- Support cross-channel ID mapping (`ProductIdMapping`) to prevent duplicate listings[cite: 3].
### 3.2 Mobile Point-of-Sale (POS) & Market Sales
- Android-optimized UI using Material 3 `NavigationBar` and `IndexedStack`[cite: 2, 3].
- In-person checkout workflow via Square API integration[cite: 2, 3].
- Support cash recording and card processing via Square Reader SDK[cite: 3].
- Automatic inventory deduction and digital receipt generation upon payment completion[cite: 3].
### 3.3 Custom Orders Workflow
- **Specification Tracking:** Record custom order specs (material, color, size, dimensions, reference images)[cite: 3].
- **Financial Deposit Management:** Track deposit payments, deposit dates, remaining balance due, and final settlement[cite: 3].
- **Fulfillment Pipeline:** Status tracking (`quoted` -> `depositReceived` -> `inProduction` -> `qualityCheck` -> `readyForPickup` -> `completed`)[cite: 3].
### 3.4 Inventory & Supply Chain Operations
- **Dual-Type Tracking:** Separate raw materials/supplies (inputs) from finished goods (catalog items)[cite: 3].
- **Stock Adjustments:** Delta adjustments (+/-) and explicit quantity setting with reason logging[cite: 2, 3].
- **Bidirectional Stock Sync:** Real-time and batch stock synchronization across WooCommerce stock levels and Square inventory counts[cite: 2, 3].
- **Reorder Logic:** Automated low-stock indicators based on item reorder points[cite: 2, 3].
### 3.5 Infrastructure & Shared Services
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
---
## 4. Non-Functional Requirements & Metrics
- **Code Coverage Visibility:** Maintain and expand line coverage visibility reported via Forgejo CI (`flutter test --coverage`)[cite: 2, 3].
- **Touch Optimization:** Minimum 48×48dp touch target enforcement across all interactive controls on mobile surfaces[cite: 2, 3].
- **Tactile & Visual Feedback:** Haptic feedback (`mediumImpact` for state changes, `lightImpact` for field edits) and explicit SnackBar feedback[cite: 2, 3].
- **Zero Structural Regressions:** Mandatory static analysis passing (`dart analyze --fatal-infos`) on every commit[cite: 2, 3].
---
## 5. Milestone & Feature Release Schedule
| Stage | Feature Focus | Status |
| :------------- | :------------------------------------------------------------------- | :------------------------------- |
| **Stages 13** | Controlled Product Editing, UX Hardening & List Efficiency | ✅ Merged[cite: 2, 3] |
| **Stage 4** | Shared Design System, Core Abstractions, CI/CD & Coverage Visibility | ✅ Merged[cite: 2, 3] |
| **Stage 5** | Android Shell Bootstrap & Publishing Surface | ✅ Merged[cite: 2, 3] |
| **Stage 6** | Android Touch Target Hardening & Mobile Surfaces (Orders/Inventory) | ✅ Merged[cite: 2, 3] |
| **Stage 7** | Bulk Status Workflows (Move to Draft, Publish, Submit for Review) | ✅ Merged[cite: 2, 3] |
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
| **Stage 10B** | **Square Catalog & Inventory Sync** | 🔄 **Current Stage**[cite: 2, 3] |
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] |
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] |
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |

View File

@ -2,46 +2,12 @@
## Current status
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
- active branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
- total tests on branch: 1373/1373 passing (+45 new: 36 feature_orders + 18 kell_mobile 9 pre-existing kell_mobile); dart analyze clean across all packages
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
- next branch: feat/square-catalog-sync (Stage 10B — Square catalog and inventory sync)
- current stage: Stage 10A complete — `SquareApiClient` implementing `ApiClient` contract; Catalog, Inventory, Orders, and Payments API methods; `SquareApiException` extending `ApiException`; `KcAppEnvironment.square` and `.multi` values; `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define keys; 88 tests passing; analyze clean
## Slice tracker
### feat/square-mobile-pos
- status: in progress (ready to merge)
- branch: `feat/square-mobile-pos`
- stage: 10C — Square Mobile POS / Checkout
- date: 2026-07-25
- inspection: complete
- implementation: complete
- files changed:
- `feature_orders/lib/src/domain/payment_method.dart``PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions
- `feature_orders/lib/src/domain/payment_result.dart``PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted)
- `feature_orders/lib/src/application/payment_service.dart``PaymentService` abstract contract + `PaymentException`
- `feature_orders/lib/src/application/checkout_controller.dart``CheckoutController` (ChangeNotifier), `CartEntry`, sealed `CheckoutState` hierarchy (idle/processing/success/error)
- `feature_orders/lib/src/data/fake_payment_service.dart``FakePaymentService` (always succeeds, counter-based IDs)
- `feature_orders/lib/feature_orders.dart` — barrel updated with all new exports
- `kell_mobile/lib/pages/mobile_checkout_page.dart``MobileCheckoutPage` (product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback)
- `kell_mobile/lib/shell/mobile_shell.dart` — Sales tab (index 4) added; `CheckoutController` initialised in `didChangeDependencies`; `_titles` updated to 6 entries
- `kell_mobile/lib/composition/mobile_app_services.dart``paymentService` field + `checkoutCatalogue` field; `_fakeCatalogue` const; all factory constructors updated
- `kell_mobile/test/widget_test.dart` — updated destination count assertion 5 → 6
- tests added:
- `feature_orders/test/payment_method_test.dart` — 5 tests
- `feature_orders/test/payment_result_test.dart` — 11 tests
- `feature_orders/test/fake_payment_service_test.dart` — 4 tests
- `feature_orders/test/checkout_controller_test.dart` — 16 tests
- `kell_mobile/test/composition/mobile_app_services_test.dart` — 11 new tests (paymentService + checkoutCatalogue groups)
- `kell_mobile/test/pages/mobile_checkout_page_test.dart` — 11 widget tests
- tests: passed (1373/1373 total; feature_orders 234/234 +36; kell_mobile 72/72 +18)
- analyze: passed (dart analyze — no issues found across all packages)
- brief updated: yes
### feat/description-only-edit
- status: merged to main
@ -590,7 +556,7 @@
### feat/square-api-client
- status: merged to main
- status: ready to merge to main
- date: 2026-07-19
- inspection: complete
- implementation: complete
@ -606,121 +572,3 @@
- tests: passed (88/88 integrations, 24/24 kell_web, 26/26 kell_mobile, 424/424 feature_wordpress, 132/132 feature_inventory, 198/198 feature_orders, 41/41 design_system, 24/24 core — 957 total)
- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages/apps)
- brief updated: yes
### feat/square-catalog-sync
- status: in progress on branch (commit `a6f888c`)
- date: 2026-07-25
- inspection: complete
- implementation: complete (Stage 10B slice)
- files changed:
- `integrations/lib/src/square_product_mapper.dart` — new `SquareProductMapper`; `fromSquareCatalogObject()` maps Square ITEM CatalogObject JSON → `ChannelProduct` (cents→dollars, `is_deleted``archived` status, first-variation SKU/price extraction); `fromSquareCatalogList()` batch variant skipping non-ITEM entries; `toSquareCatalogObject()` maps `ChannelProduct` → Square ITEM JSON (dollars→cents rounding, default variation with `FIXED_PRICING`, `location_overrides` with `track_inventory`, optional description/SKU omitted when null)
- `integrations/lib/src/square_inventory_mapper.dart` — new `SquareInventoryMapper`; `fromSquareInventoryCount()` maps Square InventoryCount JSON → `ChannelInventoryCount` (IN_STOCK only, string quantity parsed safely); `fromSquareInventoryCountList()` batch variant; `toSquareBatchChangeEntry()` maps `ChannelInventoryCount` → Square PHYSICAL_COUNT batch-change entry (quantity as string, RFC 3339 timestamp); `toSquareBatchChangeEntries()` batch variant
- `integrations/lib/integrations.dart` — added `square_inventory_mapper.dart` and `square_product_mapper.dart` to barrel exports
- `integrations/test/square_catalog_sync_test.dart` — 35 new tests: `SquareProductMapper` (20: fromSquareCatalogObject ×9, fromSquareCatalogList ×3, toSquareCatalogObject ×6) and `SquareInventoryMapper` (15: fromSquareInventoryCount ×8, fromSquareInventoryCountList ×3, toSquareBatchChangeEntry ×3, toSquareBatchChangeEntries ×2)
- `feature_wordpress/lib/src/domain/product_id_mapping.dart` — new `ProductIdMapping` value object with `localId`, `wooCommerceId`, `squareCatalogObjectId`, `squareVariationId`, `lastUpdated`; `copyWith()`, `==`/`hashCode`, `toString()`
- `feature_wordpress/lib/src/domain/product_id_mapping_repository.dart` — new `ProductIdMappingRepository` abstract contract: `getAllMappings()`, `getMappingByLocalId()`, `getMappingByWooCommerceId()`, `getMappingBySquareId()`, `saveMapping()` (upsert), `deleteMapping()` (no-op safe)
- `feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart` — new `FakeProductIdMappingRepository` in-memory implementation; seeded with 6 mappings matching fake catalog (products 13 fully synced with Square IDs, products 45 WooCommerce-only, product 6 draft/unsynced); 50ms simulated async delay on all operations; `getAllMappings()` returns `List.unmodifiable`
- `feature_wordpress/lib/feature_wordpress.dart` — added `fake_product_id_mapping_repository.dart`, `product_id_mapping.dart`, `product_id_mapping_repository.dart` to barrel exports
- `feature_wordpress/test/product_id_mapping_test.dart` — 23 new tests: `ProductIdMapping` (7: fields, null defaults, copyWith, equality, hashCode, toString) and `FakeProductIdMappingRepository` (16: getAllMappings, getMappingByLocalId, getMappingByWooCommerceId, getMappingBySquareId, seed state, saveMapping insert/replace/count, deleteMapping remove/count/no-op, unmodifiable list)
- tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new)
- analyze: clean (no new issues introduced)
- brief updated: yes
### feat/square-catalog-sync-service
- status: in progress on branch — ready for merge
- date: 2026-07-25
- inspection: complete
- implementation: complete (Stage 10D slice)
- files changed:
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — new `SquareCatalogSyncService` application-layer service; `SyncError` value object with `itemId`, `message`, `==`/`hashCode`/`toString()`; `SquareSyncResult` value object with `catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`; `InventorySyncAdapter` abstract contract with `syncItem(itemId, remoteQuantity)`; `syncCatalog()` fetches all products via `ProductPublishingRepository.getProductDrafts()`, looks up or creates `ProductIdMapping` per product via `ProductIdMappingRepository`, captures per-item failures without aborting; `syncInventory()` iterates all mappings with a `squareVariationId`, calls `InventorySyncAdapter.syncItem()` for each, captures per-item failures; `syncAll()` runs `syncCatalog()` then `syncInventory()` and merges results; no-op when no adapter provided
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_sync_service.dart` to barrel exports (Application section)
- `feature_wordpress/test/square_catalog_sync_service_test.dart` — 31 new tests: `SyncError` (3: fields, equality, toString), `SquareSyncResult` (7: defaults, totalProcessed, allSucceeded×3, merge, toString), `syncCatalog` (9: empty, single, id match, multiple, existing-not-duplicated, fetch-failure, saveMapping-failure, partial-failure, SyncError-details), `syncInventory` (7: no-adapter, no-variationId-skipped, synced, false-return, throw-continues, getAllMappings-failure, empty-mappings), `syncAll` (5: merges, pre-seeded-variation-IDs, totalProcessed, catalog-failure-no-prevent-inventory, no-adapter)
- tests: passed (506/506 feature_wordpress ← +31 new, 167/167 feature_inventory — all passing)
- analyze: clean (dart analyze — no issues found)
- brief updated: yes
### chore/audit-state-sync
- status: complete (documentation-only — no code changes)
- date: 2026-07-25
- inspection: complete — full repository audit conducted against all 11 packages/apps
- implementation: complete
- audit reference: `PROJECT_STATE_AUDIT.md` — Kell Creations Operations Platform Comprehensive Code Audit
- files changed:
- `PROJECT_STATE_AUDIT.md` — new comprehensive audit document; covers monorepo architecture, built vs. remaining matrix for all 13 packages + 2 apps, technical debt assessment (dead code, error handling gaps, hardcoded values, test coverage, architectural antipatterns), and actionable engineering backlog (Phase 1/2/3 priorities)
- `docs/development/master_development_brief.md` — updated validation state (core 24/24, feature_wordpress 506/506 post-10D, total 1,015/1,015); updated next recommended branch to include P1.1P1.5 scope; added "Comprehensive State Audit (2026-07-25)" section with platform scorecard, Phase 1/2/3 backlog tables, and key technical debt findings; updated Appendix A (feature maturity matrix — all test counts, completeness percentages, kell_web/kell_mobile rows added); updated Appendix B (integration landscape — all Square repos now marked complete, WC repos status corrected); updated Appendix C (Square keys moved from "Planned" to "Active"; KC_ENV values table updated with current status)
- `docs/development/build_execution_tracker.md` — updated current status section (audit completion noted, pending merge flagged, next branch scope documented); added this slice entry
- tests: n/a (documentation-only change)
- analyze: n/a (documentation-only change)
- brief updated: yes
- audit findings summary:
- 1,015/1,015 tests passing across all 11 packages/apps
- `dart analyze --fatal-infos` clean across all 11 packages/apps
- CRITICAL: `AppServices`/`MobileAppServices` have no `square()` or `multi()` factory — `KC_ENV=square` silently falls to fake mode
- HIGH: `SquareCatalogSyncService` ID conflation bug; `RetryPolicy`/`RateLimiter` not wired; `auth` package not imported by any app
- Phase 1 (5 items) → target `feat/square-catalog-sync-service-controller` (Stage 10E)
- Phase 2 (10 items) → Stages 10C, 11, 12, 13, 14, 15
- Phase 3 (12 items) → Stages 16A/B + standalone hardening tasks
### feat/square-catalog-sync-repositories
- status: in progress on branch — ready for merge
- date: 2026-07-25
- inspection: complete
- implementation: complete (Stage 10C slice)
- files changed:
- `feature_wordpress/lib/src/data/square_catalog_repository.dart` — new `SquareCatalogRepository` implementing `ProductPublishingRepository`; backed by `SquareApiClient`; `getProductDrafts()` calls `listCatalogItems()` and maps via `SquareProductMapper`, populating an in-memory cache; `publishDraft()` returns cached draft with `published` status (no-op — Square items are always live); `updateProductStatus()`, `updateProductPrice()`, `updateProductName()`, `updateProductDescription()`, `updateProductCategory()` each mutate the cached `ProductDraft`, call `upsertCatalogItem()` with the updated Square JSON, and return the refreshed draft; `createProduct()` calls `upsertCatalogItem()` with a `#NEW_` idempotency key, maps the response, and adds to cache; `deleteProduct()` calls `deleteCatalogItem()` and removes from cache; `updateProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()` throw `UnsupportedError` (Square manages these separately)
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_repository.dart` to barrel exports
- `feature_inventory/lib/src/domain/inventory_item.dart` — added `squareVariationId` (nullable String) field; updated `copyWith()` to preserve/update it
- `feature_inventory/lib/src/data/square_inventory_repository.dart` — new `SquareInventoryRepository` implementing `InventoryRepository`; backed by `SquareApiClient` + `initialItems` seed list; `getInventoryItems()` returns `List.unmodifiable`; `getItemBySku()` linear scan; `adjustQuantity()` validates non-negative result, pushes `batchChangeInventory()` when `squareVariationId` is set (stamps `synced`/`lastSyncedAt`), otherwise marks `notSynced`; `setQuantity()` validates non-negative, same Square push pattern; `syncWithRemote()` validates non-negative, calls `getInventoryCount()` when `squareVariationId` is set and uses live count (falls back to `remoteQuantity` on empty response), stamps `synced`/`lastSyncedAt`; `createItem()` appends to local store; `updateItem()` replaces by id and stamps `lastUpdated`; `updateItemImage()` updates `imageUrl` by id; all write failures return `InventoryAdjustmentResult.failure()` with descriptive messages; status auto-derived from quantity vs `reorderPoint`
- `feature_inventory/lib/feature_inventory.dart` — added `square_inventory_repository.dart` to barrel exports
- `feature_inventory/pubspec.yaml` — added `integrations: path: ../integrations` dependency
- `feature_wordpress/test/square_catalog_repository_test.dart` — 30 new tests: `getProductDrafts` (5: empty, single mapping, is_deleted status, multi+skip non-ITEM, cache population), `publishDraft` (2: success, StateError), `updateProductStatus` (2: upserts, StateError), `updateProductPrice` (2: upserts, StateError), `updateProductName` (1), `updateProductDescription` (1), `updateProductCategory` (1), `createProduct` (4: maps result, draft status, StateError on bad type, adds to cache), `deleteProduct` (1: removes from cache), unsupported operations (6: images/categories all throw UnsupportedError), status mapping (2), SquareApiException propagation (1)
- `feature_inventory/test/square_inventory_repository_test.dart` — 37 new tests: `getInventoryItems` (3: all items, empty, unmodifiable), `getItemBySku` (2), `adjustQuantity` (8: local-only, Square push, not-found, below-zero, API failure, inStock/lowStock/outOfStock status derivation), `setQuantity` (5: local-only, Square push, negative, not-found, API failure), `syncWithRemote` (6: local-only, Square pull, empty-count fallback, negative, not-found, API failure), `createItem` (2), `updateItem` (3: updates, StateError, stamps lastUpdated), `updateItemImage` (2), `InventoryItem.squareVariationId` (4: default null, constructor, copyWith preserve, copyWith update)
- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new)
- analyze: clean
- brief updated: yes
### feat/square-sync-ui
- status: ready for merge (branch `feat/square-sync-ui`)
- date: 2026-07-25
- inspection: complete
- implementation: complete (Stage 10F — Square sync UI surface)
- files changed:
- `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart` — new `SquareSyncStatusPanel` `StatefulWidget`; listens to `SquareSyncController`; shows status chip (`Never synced` / `OK` / `Partial` / `Error`), last-synced timestamp, `Syncing…` progress indicator during sync, `Sync Now` `FilledButton` (disabled while syncing), and last-sync-result section with Catalog/Inventory row counts when `lastResult` is non-null
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_status_panel.dart` to barrel exports (Presentation section)
- `kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart` — new `IntegrationsPage`; shows `SquareSyncStatusPanel` when `squareSyncController` is non-null; shows placeholder card with hub icon and "No integrations configured" message otherwise; "Square" section label when controller is present
- `kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart` — new `MobileIntegrationsPage`; mirrors `IntegrationsPage` layout for mobile; `SafeArea` wrapper
- `kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart` — added `AppRoute.integrations` case routing to `IntegrationsPage(squareSyncController: services.squareSyncController)`
- `kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart` — More tab (case 4) switched from `IntegrationsPlaceholderPage` to `MobileIntegrationsPage(squareSyncController: services.squareSyncController)`
- `feature_wordpress/test/square_sync_status_panel_test.dart` — 8 new widget tests: renders title, shows Never synced chip, shows Last synced: Never, Sync Now button enabled, shows OK chip after sync, shows last sync result section, rebuilds on notify, unregisters listener on dispose; uses `tester.pump(Duration)` to advance fake time through `Future.delayed` timers
- `kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart` — 4 new widget tests: placeholder when controller null, shows `SquareSyncStatusPanel` when controller provided, shows Square section label, placeholder shows hub icon
- `kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart` — 4 new widget tests: mirroring kell_web integrations page tests for mobile
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — expanded to 28 tests (added `squareSyncController` non-null assertion for `square()` factory, `multi()` factory tests, full `KcBootstrap` credential validation tests)
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — expanded to 28 tests (mirrored kell_web composition tests)
- tests: passed (545/545 feature_wordpress ← +8 new; 52/52 kell_web ← +5 new; 54/54 kell_mobile ← +5 new — 651 total across tested packages)
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
- brief updated: yes
### feat/square-catalog-sync-service-controller
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
- date: 2026-07-25
- inspection: complete
- implementation: complete (Stage 10E — all P1.1P1.5 items)
- files changed:
- `feature_wordpress/lib/src/application/square_sync_controller.dart` — new `SquareSyncController` `ChangeNotifier`; `SyncPhase` enum (`idle`, `syncing`, `done`, `error`); `syncAll()` delegates to `SquareCatalogSyncService`, sets `phase`/`lastResult`/`lastError`, guards against concurrent calls; `reset()` returns to idle; `isLoading` getter; disposal guard prevents post-dispose notifications
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_controller.dart` to barrel exports (Application section)
- `kell_creations_apps/apps/kell_web/lib/composition/app_services.dart` — added `square()` factory (P1.1): `SquareCatalogRepository` as `productPublishingRepository`, `SquareInventoryRepository` as `inventoryRepository`, `SquareSyncController` wired to `SquareCatalogSyncService`; added `multi()` factory: `SquareInventoryRepository` + `WordPressProductPublishingRepository`; updated `KcBootstrap` to validate Square credentials (P1.5) and fall back to fake with warning; added `squareSyncController` field to `AppServices`
- `kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart` — mirrored `square()` and `multi()` factories; added `squareSyncController` field; same `KcBootstrap` Square credential validation
- `feature_wordpress/lib/src/domain/product_draft.dart` — added `squareCatalogObjectId` (nullable String) field to `ProductDraft`; updated `copyWith()` to preserve/update it; updated `==`/`hashCode`/`toString()`
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — fixed ID conflation bug (P1.2): `syncCatalog()` now reads `draft.squareCatalogObjectId` to look up existing mapping instead of using `draft.id`; mapping lookup uses `getMappingBySquareId()` when Square ID is present, falls back to `getMappingByLocalId()` for unsynced items; prevents duplicate mapping creation for already-synced products
- `feature_wordpress/test/square_sync_controller_test.dart` — 18 new tests: `SyncPhase` enum (1), `SquareSyncController` construction (2), `syncAll` success (3), `syncAll` error (2), concurrent guard (2), `reset()` (2), `isLoading` (2), disposal guard (2), `lastResult`/`lastError` lifecycle (2)
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — added `square()` factory tests (3: creates instance, `SquareCatalogRepository`, `SquareInventoryRepository`), `multi()` factory tests (2), `KcBootstrap` Square credential validation tests (3: selects square with credentials, selects multi with all credentials, falls back to fake without credentials) — 23 total kell_web composition tests
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
- brief updated: yes

View File

@ -125,11 +125,6 @@ Rules:
- ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts``main`, 2026-07-11).
- ✅ Shared data layer contracts activated (Stage 8B complete — merged `feat/data-layer-contracts``main`, 2026-07-11).
- ✅ Authentication foundation activated (Stage 8C complete — merged `feat/auth-foundation``main`, 2026-07-11). Stage 8 complete.
- ✅ Square API client and authentication activated (Stage 10A complete — merged `feat/square-api-client``main`, 2026-07-19).
- ✅ Square catalog and inventory sync mappers + ProductIdMapping domain landed (Stage 10B complete — merged `feat/square-catalog-sync``main`, 2026-07-25).
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories``main`, 2026-07-25).
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
### Current narrow edit capabilities on `main`
@ -141,24 +136,25 @@ Rules:
### Latest known validation state on `main`
> **Last verified:** 2026-07-25 — Comprehensive State Audit (`chore/audit-state-sync`)
- `dart analyze --fatal-infos` clean across all 11 packages/apps
- All packages passing
- latest reported count for `core`: `24/24 passed`
- `dart analyze` clean
- `core` tests passing
- `design_system` tests passing
- `feature_wordpress` tests passing
- `feature_orders` tests passing
- `kell_web` tests passing
- `kell_mobile` tests passing
- latest reported count for `core`: `21/21 passed`
- latest reported count for `design_system`: `41/41 passed`
- latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C)
- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS)
- latest reported count for `feature_wordpress`: `545/545 passed`
- latest reported count for `kell_web`: `52/52 passed`
- latest reported count for `integrations`: `123/123 passed`
- latest reported count for `feature_inventory`: `75/75 passed`
- latest reported count for `feature_orders`: `198/198 passed`
- latest reported count for `feature_wordpress`: `319/319 passed`
- latest reported count for `kell_web`: `24/24 passed`
- latest reported count for `integrations`: `38/38 passed`
- latest reported count for `data`: `63/63 passed`
- latest reported count for `auth`: `42/42 passed`
- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS)
- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`)
- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge
> **Note:** Stage 10D (`feat/square-catalog-sync-service`) is complete and ready for merge. The `feature_wordpress` count of 506/506 reflects the post-merge state. The `main` baseline currently shows 475/475 for `feature_wordpress` until Stage 10D is merged.
- latest reported count for `kell_mobile`: `26/26 passed`
- total: `847/847 passed`
- baseline commit: merge of `feat/wc-orders-integration` into `main` (Stage 9A complete, 2026-07-11)
#### Baseline test coverage (established 2026-05-22)
@ -172,169 +168,10 @@ Rules:
No minimum thresholds are enforced — this is visibility-only tracking. Coverage is measured via `flutter test --coverage` (generates `lcov.info`) and reported in the CI workflow summary table.
### Stage 10D — complete (2026-07-25)
**`feat/square-catalog-sync-service`** — `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter`
Delivered:
- `SquareCatalogSyncService` in `feature_wordpress/lib/src/application/square_catalog_sync_service.dart`
- `SyncError` value object (`itemId`, `message`, `==`/`hashCode`/`toString()`)
- `SquareSyncResult` value object (`catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`)
- `InventorySyncAdapter` abstract contract (`syncItem(itemId, remoteQuantity) → Future<bool>`)
- `syncCatalog()` — fetches products via `ProductPublishingRepository`, reconciles `ProductIdMappingRepository`, per-item error capture
- `syncInventory()` — iterates mappings with `squareVariationId`, delegates to `InventorySyncAdapter`, per-item error capture
- `syncAll()` — runs catalog then inventory, merges results
- Exported from `feature_wordpress.dart` barrel
- 31 new tests (506/506 feature_wordpress total, 167/167 feature_inventory — all passing, analyze clean)
### Stage 10E — complete (2026-07-25)
**`feat/square-catalog-sync-service-controller`** — `SquareSyncController` + `AppServices.square()`/`multi()` + ID conflation fix + `KcBootstrap` credential validation
Delivered:
- `SquareSyncController` `ChangeNotifier` in `feature_wordpress/lib/src/application/square_sync_controller.dart`
- `AppServices.square()` and `AppServices.multi()` factory constructors (P1.1)
- `MobileAppServices.square()` and `MobileAppServices.multi()` factory constructors (P1.1)
- `KcBootstrap` Square credential validation with fallback to fake mode (P1.5)
- `ProductDraft.squareCatalogObjectId` nullable field; ID conflation bug fixed in `syncCatalog()` (P1.2)
- 18 new `SquareSyncController` tests + 10 new composition tests per app (834 total across tested packages)
- Analyze clean
### Stage 10F — complete (2026-07-25)
**`feat/square-sync-ui`** — `SquareSyncStatusPanel` + `IntegrationsPage` + `MobileIntegrationsPage`
Delivered:
- `SquareSyncStatusPanel` `StatefulWidget` in `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart`
- `IntegrationsPage` in `kell_web/lib/pages/integrations_page.dart` — shows panel when `squareSyncController` is non-null, placeholder otherwise
- `MobileIntegrationsPage` in `kell_mobile/lib/pages/mobile_integrations_page.dart` — mirrors web layout for mobile
- `AppRoute.integrations` wired in `kell_web` routing
- More tab in `kell_mobile` shell switched from placeholder to `MobileIntegrationsPage`
- 8 new panel tests + 4 integrations page tests per app + 5 composition tests per app (651 total across tested packages)
- Analyze clean
### Stage 10C — complete (2026-07-25)
**`feat/square-mobile-pos`** — Square Mobile POS / Checkout
Delivered:
- `PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions in `feature_orders`
- `PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted) in `feature_orders`
- `PaymentService` abstract contract + `PaymentException` in `feature_orders`
- `CheckoutController` (`ChangeNotifier`) with sealed `CheckoutState` hierarchy (idle/processing/success/error) and `CartEntry` in `feature_orders`
- `FakePaymentService` (always succeeds, counter-based IDs) in `feature_orders`
- `MobileCheckoutPage` — product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback in `kell_mobile`
- Sales tab (index 4) added to `MobileShell` bottom navigation (6 tabs total)
- `paymentService` + `checkoutCatalogue` fields wired into all `MobileAppServices` factory constructors
- 45 new tests (36 feature_orders + 18 kell_mobile 9 pre-existing kell_mobile); 1373 total across all packages
- Analyze clean
### Next recommended branch
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
- 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
---
## Comprehensive State Audit (2026-07-25)
**Audit reference:** `PROJECT_STATE_AUDIT.md` — Kell Creations Operations Platform Comprehensive Code Audit
**Audit date:** 2026-07-25
**Baseline:** Stage 10C complete (`feat/square-catalog-sync-repositories` → `main`)
**Total tests verified:** 1,015 / 1,015 passing; `dart analyze --fatal-infos` clean across all 11 packages/apps
### Platform scorecard (as of 2026-07-25)
| Subsystem | Status | Completeness | Blocking Issues |
| ------------------- | ----------- | ------------ | ----------------------------------------------------------------- |
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
| `design_system` | ✅ COMPLETE | 100% | None |
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
| `feature_social` | ❌ MISSING | 0% | Pure stub |
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
| Policy register | ❌ MISSING | 10% | All CSVs empty |
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
### Audit engineering backlog
The full backlog is captured in `PROJECT_STATE_AUDIT.md` §4. The prioritized summary follows.
#### Phase 1 — High-priority / blocking (core fixes and missing execution paths)
These items block correct runtime behavior or represent data integrity risks. All Phase 1 items should be addressed in `feat/square-catalog-sync-service-controller` (Stage 10E) or immediately after.
| ID | Item | Branch |
| ---- | -------------------------------------------------------------------------------------- | --------------------------------------------- |
| P1.1 | Wire `square` and `multi` factory constructors into `AppServices`/`MobileAppServices` | `feat/square-catalog-sync-service-controller` |
| P1.2 | Fix `SquareCatalogSyncService.syncCatalog()` ID conflation (`product.id` vs Square ID) | `feat/square-catalog-sync-service-controller` |
| P1.3 | Merge Stage 10D (`feat/square-catalog-sync-service`) → `main` | Immediate — prerequisite for Stage 10E |
| P1.4 | `SquareSyncController` (Stage 10E) — UI trigger for `SquareCatalogSyncService` | `feat/square-catalog-sync-service-controller` |
| P1.5 | Add `hasSquareConfig` credential check in `KcBootstrap` for `square`/`multi` modes | `feat/square-catalog-sync-service-controller` |
#### Phase 2 — Feature completion and integration
These items complete planned stages and wire existing contracts into production paths.
| 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.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 |
| P2.6 | Wire `WooCommerceInventoryRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
| P2.7 | Unified Order View (`OrderChannel`, `UnifiedOrdersController`) | Stage 13B |
| P2.8 | Offline Data Cache & Sales Queue (SQLite `LocalCache`, `SyncQueue`) | Stage 14 |
| P2.9 | `feature_finance` Activation (sales reporting, inventory valuation) | Stage 15 |
| P2.10 | Populate Policy Register CSVs (2 active governance documents) | Immediate — no dependencies |
#### Phase 3 — Defensive engineering, logging, and test harness expansion
These items harden the platform for production deployment.
| ID | Item | Target Stage |
| ----- | --------------------------------------------------------------------------------------- | ----------------- |
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16A |
| P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
| P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B |
| P3.4 | Structured logging infrastructure (`package:logging`, replace silent `catch (_) {}`) | Stage 16A |
| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A |
| 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.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 |
| P3.12 | Evaluate and activate `feature_mrp` after Stage 12 completion | Post-Stage 12 |
### Key technical debt findings
- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** — `KC_ENV=square` silently falls to fake mode (CRITICAL)
- **`SquareCatalogSyncService` ID conflation** — `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH)
- **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH)
- **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH)
- **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM)
- **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM)
- **`feature_policy` has only a smoke test** — no controller or widget tests (MEDIUM)
- **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM)
**`feat/wc-inventory-sync`** — Stage 9B: WooCommerce stock/inventory sync.
Branch from latest `main`. Stage 9A (WooCommerce Orders API integration) is complete. Stage 9B adds bidirectional stock quantity synchronization between the app's inventory and WooCommerce product stock levels.
---
@ -1401,7 +1238,7 @@ For every branch/stage:
Use this at the start of any new slice:
```plaintext
```text
Continue the Kell Creations Flutter operations platform workstream from current main.
Read docs/development/master_development_brief.md first and follow it as the authoritative planning document.
@ -1434,55 +1271,46 @@ Working rules:
## Appendix A: Feature maturity matrix
> **Last updated:** 2026-07-25 — Comprehensive State Audit
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
| ------------------- | ------------ | ------------------ | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** |
| `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** |
| `feature_orders` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Some | **Fake-only MVP** |
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None | N/A | 42 | **Foundation ready** |
| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None | N/A | 63 | **Foundation ready** |
| `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce | N/A | 38 | **Foundation ready** |
| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Foundation ready** |
| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 21 | **Foundation ready** |
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
| ------------------- | ------------ | ------------------------ | ----------- | ------------------------- | -------------------------------------------------- | ----- | -------------------------- |
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 506 | **Production-ready (95%)** |
| `feature_inventory` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 167 | **Partial (65%)** |
| `feature_orders` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce | ✅ Complete | 198 | **Partial (70%)** |
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | ~5 | **Fake-only (50%)** |
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None (SecureStore TBD) | N/A | 42 | **Foundation ready (40%)** |
| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None (SQLite TBD) | N/A | 63 | **Foundation ready (35%)** |
| `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce + Square | N/A | 123 | **Partial (70%)** |
| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Complete (100%)** |
| `core` | ✅ Complete | ✅ Composition | N/A | N/A | N/A | 24 | **Complete (95%)** |
| `kell_web` | N/A | ✅ AppServices (wp only) | N/A | N/A | ✅ 7 routes (2 placeholder) | 24 | **Partial (75%)** |
| `kell_mobile` | N/A | ✅ MobileAppServices | N/A | N/A | ✅ 5-tab shell (no POS) | 26 | **Partial (70%)** |
### Key changes from previous matrix
### Key changes from previous matrix (updated 2026-07-25)
- `feature_wordpress` test count updated to 506 (Stage 10D, pending merge); real data layer now includes Square repositories.
- `feature_inventory` and `feature_orders` upgraded from "Read-only" — write operations, real WooCommerce/Square repositories now complete.
- `integrations` test count updated to 123; Square API client and mappers complete.
- `core` test count updated to 24; `kell_web` and `kell_mobile` rows added.
- Completeness percentages sourced from `PROJECT_STATE_AUDIT.md` §5 scorecard (2026-07-25).
- `feature_inventory` and `feature_orders` downgraded from "Complete" to "⚠️ Read-only" in domain/application/data/presentation to reflect that they have no write operations — a critical gap for the target platform vision.
- Feature maturity labels clarified: "Fake-only MVP" packages that are also read-only now carry the ⚠️ indicator.
---
## Appendix B: Integration landscape
> **Last updated:** 2026-07-25 — Comprehensive State Audit
| Integration | Package/Location | Protocol | Current Status | Target Stage |
| -------------------- | ------------------- | ------------- | ------------------------------------------------------ | ---------------- |
| WooCommerce Products | `feature_wordpress` | REST API v3 | ✅ Production-ready (full CRUD + categories + images) | — |
| WooCommerce Orders | `feature_orders` | REST API v3 | ✅ Repository complete; not wired into AppServices | Stage 9A ✅ |
| WooCommerce Stock | `feature_inventory` | REST API v3 | ✅ Repository complete; not wired into AppServices | Stage 9B ✅ |
| WooCommerce Catalog | `feature_wordpress` | REST API v3 | ✅ Full CRUD + categories + images | Stage 9C ✅ |
| WooCommerce Media | `feature_wordpress` | REST API v3 | ⚠️ Use case wired; image picker UI deferred | Stage 10+ |
| Square Catalog | `feature_wordpress` | Square API v2 | ✅ Repository complete; no UI trigger (Stage 10E next) | Stage 10B ✅ |
| Square Inventory | `feature_inventory` | Square API v2 | ✅ Repository complete; not wired into AppServices | Stage 10B ✅ |
| Square Payments | `kell_mobile` | Mobile SDK | ❌ Not implemented | Stage 10C (P2.1) |
| Square Orders | `feature_orders` | Square API v2 | ❌ No SquareOrdersRepository exists | Stage 13B (P2.7) |
| Facebook API | `feature_social` | Graph API | ❌ Stub only | Deferred |
| Instagram API | `feature_social` | Graph API | ❌ Stub only | Deferred |
| X (Twitter) API | `feature_social` | REST API v2 | ❌ Stub only | Deferred |
| n8n Workflows | (external) | Webhooks/REST | ❌ Not implemented | Stage 13C |
| Mail Server | (external) | SMTP | ❌ Not implemented | Deferred |
| Integration | Package/Location | Protocol | Current Status | Target Stage |
| -------------------- | ------------------- | ------------- | ------------------- | ------------ |
| WooCommerce Products | `feature_wordpress` | REST API v3 | ✅ Production-ready | — |
| WooCommerce Orders | `feature_orders` | REST API v3 | ✅ Production-ready | Stage 9A ✅ |
| WooCommerce Stock | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9B |
| WooCommerce Catalog | `feature_wordpress` | REST API v3 | ⚠️ Read + edit only | Stage 9C |
| WooCommerce Media | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9C |
| Square Catalog | (new) | Square API v2 | ❌ Not implemented | Stage 10A/B |
| Square Inventory | (new) | Square API v2 | ❌ Not implemented | Stage 10B |
| Square Payments | (new) | Mobile SDK | ❌ Not implemented | Stage 10C |
| Square Orders | (new) | Square API v2 | ❌ Not implemented | Stage 10A |
| Facebook API | `feature_social` | Graph API | ❌ Stub only | Deferred |
| Instagram API | `feature_social` | Graph API | ❌ Stub only | Deferred |
| X (Twitter) API | `feature_social` | REST API v2 | ❌ Stub only | Deferred |
| n8n Workflows | (external) | Webhooks/REST | ❌ Not implemented | Stage 13C |
| Mail Server | (external) | SMTP | ❌ Not implemented | Deferred |
---
@ -1497,28 +1325,28 @@ Working rules:
| `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | WP mode |
| `KC_WC_CONSUMER_SECRET` | WooCommerce REST API consumer secret | (empty) | WP mode |
### Active Square keys (Stage 10A — live on `main`)
### Planned (Stage 8+)
| Key | Description | Default | Used By |
| ------------------------ | ------------------------- | --------- | ----------- |
| `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) | Square mode |
| `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) | Square mode |
| `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` | Square mode |
| Key | Description | Default | Target Stage |
| ------------------------ | ------------------------- | --------- | ------------ |
| `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) | Stage 10A |
| `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) | Stage 10A |
| `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` | Stage 10A |
### `KC_ENV` values
### Planned `KC_ENV` values
| Value | Description | Status |
| ----------- | -------------------------------- | ---------------------------------------------------------------- |
| `fake` | All fake repositories | ✅ Active |
| `wordpress` | WooCommerce real + others fake | ✅ Active |
| `square` | Square real + others fake | ⚠️ Keys defined (Stage 10A); factory constructors missing (P1.1) |
| `multi` | Both WooCommerce and Square real | ⚠️ Enum value defined; factory constructors missing (P1.1) |
| Value | Description | Target Stage |
| ----------- | -------------------------------- | ------------ |
| `fake` | All fake repositories (current) | — |
| `wordpress` | WooCommerce real + others fake | |
| `square` | Square real + others fake | Stage 10A |
| `multi` | Both WooCommerce and Square real | Stage 13A |
---
## Appendix D: Stage dependency graph
```plaintext
```
Stages 16 (COMPLETE/IN PROGRESS) — Foundation
├── Stage 7 — Bulk actions (independent, can proceed in parallel)

Binary file not shown.

View File

@ -3,17 +3,6 @@ import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:integrations/integrations.dart';
/// Default catalogue items used in fake/wordpress mode where no Square catalog
/// is available. These seed the [MobileCheckoutPage] product list.
const _fakeCatalogue = [
OrderItem(productName: 'Macramé Wall Hanging', sku: 'MWH-001', quantity: 1, unitPrice: 45.00),
OrderItem(productName: 'Beaded Bracelet', sku: 'BB-002', quantity: 1, unitPrice: 12.00),
OrderItem(productName: 'Ceramic Mug', sku: 'CM-003', quantity: 1, unitPrice: 18.00),
OrderItem(productName: 'Soy Candle — Lavender', sku: 'SC-004', quantity: 1, unitPrice: 14.00),
OrderItem(productName: 'Knitted Scarf', sku: 'KS-005', quantity: 1, unitPrice: 32.00),
];
/// Holds the concrete service implementations used by `kell_mobile`.
///
@ -29,28 +18,11 @@ class MobileAppServices extends KcAppServices {
final PolicyRepository policyRepository;
final ProductPublishingRepository productPublishingRepository;
/// The Square sync controller, or `null` when Square is not configured.
///
/// Present only in `square` and `multi` environments.
final SquareSyncController? squareSyncController;
/// Payment service used by the mobile POS checkout flow.
final PaymentService paymentService;
/// Catalogue items available for sale in the [MobileCheckoutPage].
///
/// In fake/wordpress mode this is seeded with [_fakeCatalogue].
/// In square/multi mode this could be populated from the Square catalog.
final List<OrderItem> checkoutCatalogue;
const MobileAppServices({
required this.inventoryRepository,
required this.ordersRepository,
required this.policyRepository,
required this.productPublishingRepository,
required this.paymentService,
this.squareSyncController,
this.checkoutCatalogue = _fakeCatalogue,
});
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
@ -60,7 +32,6 @@ class MobileAppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
paymentService: FakePaymentService(),
);
}
@ -83,91 +54,10 @@ class MobileAppServices extends KcAppServices {
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
paymentService: FakePaymentService(),
);
}
/// Creates a [MobileAppServices] backed by the real Square catalog and
/// inventory APIs.
///
/// [accessToken] Square API access token (Bearer token).
/// [locationId] Square location ID for inventory counts.
/// [environment] `'sandbox'` or `'production'` (defaults to `'sandbox'`).
factory MobileAppServices.square({
required String accessToken,
required String locationId,
String environment = 'sandbox',
}) {
final apiClient = environment == 'production'
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: catalogRepo,
mappingRepository: mappingRepo,
);
return MobileAppServices(
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(),
);
}
/// Creates a [MobileAppServices] with both WooCommerce and Square backends
/// active.
///
/// WooCommerce is used for the product catalog and orders. Square is used for
/// inventory counts. This mirrors the multi-channel commerce model where
/// WooCommerce is the catalog source of truth and Square tracks in-person
/// inventory.
factory MobileAppServices.multi({
required String wcSiteUrl,
required String wcConsumerKey,
required String wcConsumerSecret,
required String squareAccessToken,
required String squareLocationId,
String squareEnvironment = 'sandbox',
}) {
final wcApiClient = WooCommerceApiClient(
siteUrl: wcSiteUrl,
consumerKey: wcConsumerKey,
consumerSecret: wcConsumerSecret,
);
final squareApiClient = squareEnvironment == 'production'
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: wcPublishingRepo,
mappingRepository: mappingRepo,
);
return MobileAppServices(
inventoryRepository: SquareInventoryRepository(
apiClient: squareApiClient,
locationId: squareLocationId,
),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService),
paymentService: FakePaymentService(),
);
}
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
///
/// Includes [createSquare] and [createMulti] callbacks so that
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
/// `KC_ENV=multi` without falling back to fake mode.
static KcServiceFactory<MobileAppServices> get serviceFactory {
return KcServiceFactory<MobileAppServices>(
createFake: () => MobileAppServices.fake(),
@ -176,19 +66,6 @@ class MobileAppServices extends KcAppServices {
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
createSquare: (config) => MobileAppServices.square(
accessToken: config.squareAccessToken,
locationId: config.squareLocationId,
environment: config.squareEnvironment,
),
createMulti: (config) => MobileAppServices.multi(
wcSiteUrl: config.wcSiteUrl,
wcConsumerKey: config.wcConsumerKey,
wcConsumerSecret: config.wcConsumerSecret,
squareAccessToken: config.squareAccessToken,
squareLocationId: config.squareLocationId,
squareEnvironment: config.squareEnvironment,
),
);
}
}

View File

@ -1,471 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// The mobile POS checkout page.
///
/// Provides a three-step flow:
/// 1. **Product selection** browse a list of [OrderItem] catalogue entries
/// and add them to the cart.
/// 2. **Cart review** see line items, quantities, and the running total.
/// 3. **Payment** choose Cash or Card and confirm the charge.
///
/// After a successful payment the page shows a receipt summary and offers a
/// "New Sale" button to reset the controller and start again.
///
/// The [CheckoutController] is created externally and injected so that the
/// parent shell can hold a reference to it (e.g. to badge the Sales tab with
/// the cart item count in a future iteration).
class MobileCheckoutPage extends StatefulWidget {
final CheckoutController controller;
/// Catalogue items available for sale. In fake mode these are seeded by
/// [MobileAppServices]; in a real deployment they come from the product
/// publishing repository.
final List<OrderItem> catalogue;
const MobileCheckoutPage({super.key, required this.controller, required this.catalogue});
@override
State<MobileCheckoutPage> createState() => _MobileCheckoutPageState();
}
class _MobileCheckoutPageState extends State<MobileCheckoutPage> {
// lifecycle
@override
void initState() {
super.initState();
widget.controller.addListener(_onStateChange);
}
@override
void dispose() {
widget.controller.removeListener(_onStateChange);
super.dispose();
}
void _onStateChange() => setState(() {});
// helpers
CheckoutController get _ctrl => widget.controller;
String _formatCents(int cents) {
final d = cents ~/ 100;
final c = cents % 100;
return '\$$d.${c.toString().padLeft(2, '0')}';
}
// build
@override
Widget build(BuildContext context) {
final state = _ctrl.state;
if (state is CheckoutSuccess) {
return _ReceiptView(result: state.result, onNewSale: _ctrl.reset);
}
return Column(
children: [
// cart summary bar
_CartSummaryBar(
itemCount: _ctrl.cart.fold(0, (s, e) => s + e.quantity),
totalCents: _ctrl.totalCents,
formatCents: _formatCents,
onClear: _ctrl.cart.isEmpty ? null : _ctrl.clearCart,
),
const Divider(height: 1),
// body
Expanded(
child: state is CheckoutProcessing
? const Center(child: CircularProgressIndicator())
: _buildBody(context, state),
),
// error banner
if (state is CheckoutError) _ErrorBanner(message: state.message),
// pay button
if (_ctrl.cart.isNotEmpty && state is! CheckoutProcessing)
_PaymentBar(
totalCents: _ctrl.totalCents,
formatCents: _formatCents,
onPay: (method) => _ctrl.processPayment(method),
),
],
);
}
Widget _buildBody(BuildContext context, CheckoutState state) {
return DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(icon: Icon(Icons.storefront_outlined), text: 'Products'),
Tab(icon: Icon(Icons.shopping_cart_outlined), text: 'Cart'),
],
),
Expanded(
child: TabBarView(
children: [
_ProductList(catalogue: widget.catalogue, onAdd: _ctrl.addItem),
_CartList(cart: _ctrl.cart, formatCents: _formatCents, onRemove: _ctrl.removeItem),
],
),
),
],
),
);
}
}
// Cart summary bar
class _CartSummaryBar extends StatelessWidget {
final int itemCount;
final int totalCents;
final String Function(int) formatCents;
final VoidCallback? onClear;
const _CartSummaryBar({
required this.itemCount,
required this.totalCents,
required this.formatCents,
this.onClear,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.shopping_cart, color: KcColors.denimBlue, size: 20),
const SizedBox(width: 8),
Text(
'$itemCount item${itemCount == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodyMedium,
),
const Spacer(),
Text(
formatCents(totalCents),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: KcColors.denimBlue,
),
),
if (onClear != null) ...[
const SizedBox(width: 8),
TextButton(onPressed: onClear, child: const Text('Clear')),
],
],
),
);
}
}
// Product list
class _ProductList extends StatelessWidget {
final List<OrderItem> catalogue;
final void Function(OrderItem) onAdd;
const _ProductList({required this.catalogue, required this.onAdd});
@override
Widget build(BuildContext context) {
if (catalogue.isEmpty) {
return const KcEmptyState(
icon: Icons.storefront_outlined,
message: 'No products available. Add products to the catalogue to start selling.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: catalogue.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final item = catalogue[i];
return KcCard(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
title: Text(item.productName, style: Theme.of(context).textTheme.bodyMedium),
subtitle: Text('SKU: ${item.sku}', style: Theme.of(context).textTheme.bodySmall),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'\$${item.unitPrice.toStringAsFixed(2)}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: () {
HapticFeedback.lightImpact();
onAdd(item);
},
style: FilledButton.styleFrom(
minimumSize: const Size(40, 36),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Icon(Icons.add, size: 18),
),
],
),
),
);
},
);
}
}
// Cart list
class _CartList extends StatelessWidget {
final List<CartEntry> cart;
final String Function(int) formatCents;
final void Function(String sku) onRemove;
const _CartList({required this.cart, required this.formatCents, required this.onRemove});
@override
Widget build(BuildContext context) {
if (cart.isEmpty) {
return const KcEmptyState(
icon: Icons.shopping_cart_outlined,
message: 'Cart is empty. Tap + on a product to add it to the cart.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: cart.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final entry = cart[i];
return KcCard(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
title: Text(entry.item.productName, style: Theme.of(context).textTheme.bodyMedium),
subtitle: Text(
'${formatCents(entry.lineTotalCents)} · qty ${entry.quantity}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.remove_circle_outline),
tooltip: 'Remove one',
onPressed: () {
HapticFeedback.lightImpact();
onRemove(entry.item.sku);
},
),
),
);
},
);
}
}
// Payment bar
class _PaymentBar extends StatelessWidget {
final int totalCents;
final String Function(int) formatCents;
final void Function(PaymentMethod) onPay;
const _PaymentBar({required this.totalCents, required this.formatCents, required this.onPay});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
Text(
'Charge ${formatCents(totalCents)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.payments_outlined),
label: const Text('Cash'),
onPressed: () => _confirmPayment(context, PaymentMethod.cash),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.credit_card),
label: const Text('Card'),
onPressed: () => _confirmPayment(context, PaymentMethod.card),
),
),
],
),
],
),
),
);
}
Future<void> _confirmPayment(BuildContext context, PaymentMethod method) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Confirm ${method.label} Payment'),
content: Text('Charge ${formatCents(totalCents)} via ${method.label}?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Confirm')),
],
),
);
if (confirmed == true) {
HapticFeedback.mediumImpact();
onPay(method);
}
}
}
// Receipt view
class _ReceiptView extends StatelessWidget {
final PaymentResult result;
final VoidCallback onNewSale;
const _ReceiptView({required this.result, required this.onNewSale});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green.shade600, size: 64),
const SizedBox(height: 16),
Text(
'Payment Complete',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
result.amountFormatted,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: KcColors.denimBlue,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'via ${result.method.label}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
KcCard(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ReceiptRow(label: 'Transaction ID', value: result.transactionId),
_ReceiptRow(label: 'Completed', value: _formatTime(result.completedAt)),
if (result.receiptUrl != null)
_ReceiptRow(label: 'Receipt', value: result.receiptUrl!),
],
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
icon: const Icon(Icons.add_shopping_cart),
label: const Text('New Sale'),
onPressed: () {
HapticFeedback.lightImpact();
onNewSale();
},
),
],
),
),
);
}
String _formatTime(DateTime dt) {
final local = dt.toLocal();
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')} '
'${local.month}/${local.day}/${local.year}';
}
}
class _ReceiptRow extends StatelessWidget {
final String label;
final String value;
const _ReceiptRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
),
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodySmall)),
],
),
);
}
}
// Error banner
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: Colors.red.shade50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800),
),
),
],
),
);
}
}

View File

@ -1,64 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
/// The Integrations page for `kell_mobile`.
///
/// When a [SquareSyncController] is provided (i.e. the app is running in
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
/// operator can trigger and monitor Square catalog + inventory syncs.
///
/// When no controller is available (fake or wordpress mode), a placeholder
/// message is shown instead.
class MobileIntegrationsPage extends StatelessWidget {
/// The Square sync controller, or `null` when Square is not configured.
final SquareSyncController? squareSyncController;
const MobileIntegrationsPage({super.key, this.squareSyncController});
@override
Widget build(BuildContext context) {
final controller = squareSyncController;
if (controller == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'No integrations configured',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Set KC_ENV=square or KC_ENV=multi with Square credentials '
'to enable the Square sync panel.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Square',
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
SquareSyncStatusPanel(controller: controller),
],
),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:core/core.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart';
import 'package:flutter/material.dart';
@ -8,8 +7,7 @@ import '../dashboard/application/dashboard_controller.dart';
import '../dashboard/application/get_dashboard_summary.dart';
import '../pages/dashboard_page.dart';
import '../pages/finance_placeholder_page.dart';
import '../pages/mobile_checkout_page.dart';
import '../pages/mobile_integrations_page.dart';
import '../pages/integrations_placeholder_page.dart';
import '../pages/mobile_inventory_page.dart';
import '../pages/mobile_orders_page.dart';
import '../pages/mobile_publishing_page.dart';
@ -35,9 +33,8 @@ class _MobileShellState extends State<MobileShell> {
/// Dashboard controller is created once and reused across rebuilds to avoid
/// re-triggering data loads on every [setState] call (e.g. tab switches).
late DashboardController _dashboardController;
late CheckoutController _checkoutController;
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'Sales', 'More'];
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More'];
@override
void didChangeDependencies() {
@ -53,7 +50,6 @@ class _MobileShellState extends State<MobileShell> {
ordersRepository: services.ordersRepository,
),
);
_checkoutController = CheckoutController(paymentService: services.paymentService);
_dashboardControllerInitialised = true;
}
}
@ -63,7 +59,6 @@ class _MobileShellState extends State<MobileShell> {
@override
void dispose() {
_dashboardController.dispose();
_checkoutController.dispose();
super.dispose();
}
@ -106,11 +101,6 @@ class _MobileShellState extends State<MobileShell> {
selectedIcon: Icon(Icons.receipt_long),
label: 'Orders',
),
NavigationDestination(
icon: Icon(Icons.point_of_sale_outlined),
selectedIcon: Icon(Icons.point_of_sale),
label: 'Sales',
),
NavigationDestination(
icon: Icon(Icons.more_horiz),
selectedIcon: Icon(Icons.more_horiz),
@ -148,11 +138,6 @@ class _MobileShellState extends State<MobileShell> {
},
);
case 4:
return MobileCheckoutPage(
controller: _checkoutController,
catalogue: services.checkoutCatalogue,
);
case 5:
return const _MorePage();
default:
return const Center(child: Text('Unknown section'));
@ -207,12 +192,11 @@ class _MorePage extends StatelessWidget {
title: const Text('Integrations'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
final services = KcAppScope.of<MobileAppServices>(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Integrations')),
body: MobileIntegrationsPage(squareSyncController: services.squareSyncController),
body: const IntegrationsPlaceholderPage(),
),
),
);

View File

@ -1,316 +0,0 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/composition/mobile_app_services.dart';
void main() {
group('MobileAppServices', () {
// fake factory
group('fake()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.fake();
expect(services, isA<MobileAppServices>());
expect(services, isA<KcAppServices>());
});
test('inventoryRepository is FakeInventoryRepository', () {
final services = MobileAppServices.fake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
test('productPublishingRepository is FakeProductPublishingRepository', () {
final services = MobileAppServices.fake();
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
});
});
// wordpress factory
group('wordpress()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services, isA<MobileAppServices>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// square factory
group('square()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<MobileAppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is SquareCatalogRepository', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('squareSyncController is non-null', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.squareSyncController, isNotNull);
expect(services.squareSyncController, isA<SquareSyncController>());
services.squareSyncController!.dispose();
});
test('defaults to sandbox environment', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<MobileAppServices>());
});
test('accepts production environment', () {
final services = MobileAppServices.square(
accessToken: 'EAAAl_test',
locationId: 'LOC123',
environment: 'production',
);
expect(services, isA<MobileAppServices>());
});
});
// multi factory
group('multi()', () {
test('creates MobileAppServices instance', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services, isA<MobileAppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
});
// serviceFactory
group('serviceFactory', () {
test('createFake returns fake services', () {
final factory = MobileAppServices.serviceFactory;
final services = factory.createFake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
});
test('createWordPress returns wordpress services', () {
final factory = MobileAppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.wordpress,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
);
final services = factory.createWordPress(config);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('createSquare is non-null', () {
final factory = MobileAppServices.serviceFactory;
expect(factory.createSquare, isNotNull);
});
test('createSquare returns square services', () {
final factory = MobileAppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createSquare!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('createMulti is non-null', () {
final factory = MobileAppServices.serviceFactory;
expect(factory.createMulti, isNotNull);
});
test('createMulti returns multi services', () {
final factory = MobileAppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createMulti!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.square);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.multi);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
expect(
result.services.productPublishingRepository,
isA<WordPressProductPublishingRepository>(),
);
});
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: '',
squareLocationId: '',
);
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.fake);
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// paymentService composition (Stage 10C)
group('paymentService', () {
test('fake() provides a FakePaymentService', () {
final services = MobileAppServices.fake();
expect(services.paymentService, isA<FakePaymentService>());
});
test('wordpress() provides a FakePaymentService', () {
final services = MobileAppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.paymentService, isA<FakePaymentService>());
});
test('square() provides a FakePaymentService', () {
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.paymentService, isA<FakePaymentService>());
});
test('multi() provides a FakePaymentService', () {
final services = MobileAppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.paymentService, isA<FakePaymentService>());
});
});
// checkoutCatalogue composition (Stage 10C)
group('checkoutCatalogue', () {
test('fake() provides a non-empty default catalogue', () {
final services = MobileAppServices.fake();
expect(services.checkoutCatalogue, isNotEmpty);
});
test('default catalogue items have valid SKUs and prices', () {
final services = MobileAppServices.fake();
for (final item in services.checkoutCatalogue) {
expect(item.sku, isNotEmpty);
expect(item.unitPrice, greaterThan(0));
expect(item.productName, isNotEmpty);
}
});
test('custom catalogue can be injected', () {
const custom = [
OrderItem(productName: 'Custom Item', sku: 'CI-001', quantity: 1, unitPrice: 99.99),
];
final services = MobileAppServices(
inventoryRepository: FakeInventoryRepository(),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
paymentService: FakePaymentService(),
checkoutCatalogue: custom,
);
expect(services.checkoutCatalogue.length, 1);
expect(services.checkoutCatalogue.first.sku, 'CI-001');
});
});
});
}

View File

@ -1,156 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/pages/mobile_checkout_page.dart';
const _catalogue = [
OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00),
OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50),
];
Widget _buildPage({CheckoutController? controller, List<OrderItem> catalogue = _catalogue}) {
final ctrl = controller ?? CheckoutController(paymentService: FakePaymentService());
return MaterialApp(
home: Scaffold(
body: MobileCheckoutPage(controller: ctrl, catalogue: catalogue),
),
);
}
void main() {
group('MobileCheckoutPage', () {
late CheckoutController controller;
setUp(() {
controller = CheckoutController(paymentService: FakePaymentService());
});
tearDown(() => controller.dispose());
// initial render
testWidgets('renders Products and Cart tabs', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('Products'), findsOneWidget);
expect(find.text('Cart'), findsOneWidget);
});
testWidgets('shows cart summary bar with 0 items initially', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('0 items'), findsOneWidget);
});
testWidgets('shows product names from catalogue', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('Widget A'), findsOneWidget);
expect(find.text('Widget B'), findsOneWidget);
});
testWidgets('shows empty state when catalogue is empty', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller, catalogue: const []));
expect(
find.text('No products available. Add products to the catalogue to start selling.'),
findsOneWidget,
);
});
// add to cart
testWidgets('tapping + adds item to cart and updates summary', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
// Tap the + button for Widget A (first add icon)
final addButtons = find.byIcon(Icons.add);
expect(addButtons, findsWidgets);
await tester.tap(addButtons.first);
await tester.pump();
expect(find.text('1 item'), findsOneWidget);
});
testWidgets('adding two different items shows 2 items in summary', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
final addButtons = find.byIcon(Icons.add);
await tester.tap(addButtons.first);
await tester.pump();
await tester.tap(addButtons.last);
await tester.pump();
expect(find.text('2 items'), findsOneWidget);
});
// payment bar
testWidgets('payment bar appears after adding item', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
await tester.tap(find.byIcon(Icons.add).first);
await tester.pump();
expect(find.text('Cash'), findsOneWidget);
expect(find.text('Card'), findsOneWidget);
});
testWidgets('payment bar is hidden when cart is empty', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
expect(find.text('Cash'), findsNothing);
expect(find.text('Card'), findsNothing);
});
// cart tab
testWidgets('switching to Cart tab shows empty state when cart is empty', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
// Tap the Cart tab by its icon to avoid ambiguity with the 'Cart' label
// in the summary bar (which also contains the word 'Cart').
await tester.tap(find.byIcon(Icons.shopping_cart_outlined));
await tester.pumpAndSettle();
// The KcEmptyState message for an empty cart.
expect(find.text('Cart is empty. Tap + on a product to add it to the cart.'), findsOneWidget);
});
// receipt view
testWidgets('shows receipt view after successful payment', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
// Add item
await tester.tap(find.byIcon(Icons.add).first);
await tester.pump();
// Tap Cash button
await tester.tap(find.text('Cash'));
await tester.pumpAndSettle();
// Confirm dialog
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Payment Complete'), findsOneWidget);
expect(find.text('New Sale'), findsOneWidget);
});
testWidgets('New Sale button resets to idle state', (tester) async {
await tester.pumpWidget(_buildPage(controller: controller));
await tester.tap(find.byIcon(Icons.add).first);
await tester.pump();
await tester.tap(find.text('Cash'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Payment Complete'), findsOneWidget);
await tester.tap(find.text('New Sale'));
await tester.pump();
// Back to product list
expect(find.text('Products'), findsOneWidget);
expect(find.text('0 items'), findsOneWidget);
});
});
}

View File

@ -1,53 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/pages/mobile_integrations_page.dart';
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
SquareSyncController _fakeController() {
final syncService = SquareCatalogSyncService(
catalogRepository: FakeProductPublishingRepository(),
mappingRepository: FakeProductIdMappingRepository(),
);
return SquareSyncController(syncService: syncService);
}
void main() {
group('MobileIntegrationsPage', () {
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
await tester.pump();
expect(find.text('No integrations configured'), findsOneWidget);
});
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square Sync'), findsOneWidget);
expect(find.text('Sync Now'), findsOneWidget);
});
testWidgets('shows Square section label when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square'), findsOneWidget);
});
testWidgets('placeholder shows hub icon', (tester) async {
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
await tester.pump();
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
});
});
}

View File

@ -42,13 +42,12 @@ void main() {
expect(find.text('FAKE'), findsOneWidget);
});
testWidgets('bottom navigation bar has 6 destinations', (WidgetTester tester) async {
testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
// Dashboard, Inventory, Products, Orders, Sales, More
expect(find.byType(NavigationDestination), findsNWidgets(6));
expect(find.byType(NavigationDestination), findsNWidgets(5));
});
testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async {

View File

@ -3,7 +3,6 @@ import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_policy/feature_policy.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:integrations/integrations.dart';
/// Holds the concrete service implementations used by `kell_web`.
///
@ -13,27 +12,19 @@ import 'package:integrations/integrations.dart';
///
/// The [AppServices.fake] factory wires up the in-memory fakes that live
/// inside each feature package. The [AppServices.wordpress] factory wires up
/// a real WooCommerce-backed product repository. The [AppServices.square]
/// factory wires up a Square-backed catalog and inventory repository.
/// The [AppServices.multi] factory wires up both WooCommerce and Square
/// backends simultaneously.
/// a real WooCommerce-backed product repository while keeping other services
/// fake until their backends are ready.
class AppServices extends KcAppServices {
final InventoryRepository inventoryRepository;
final OrdersRepository ordersRepository;
final PolicyRepository policyRepository;
final ProductPublishingRepository productPublishingRepository;
/// The Square sync controller, or `null` when Square is not configured.
///
/// Present only in `square` and `multi` environments.
final SquareSyncController? squareSyncController;
const AppServices({
required this.inventoryRepository,
required this.ordersRepository,
required this.policyRepository,
required this.productPublishingRepository,
this.squareSyncController,
});
/// Creates an [AppServices] backed by fake, in-memory repositories.
@ -72,84 +63,7 @@ class AppServices extends KcAppServices {
);
}
/// Creates an [AppServices] backed by the real Square catalog and inventory
/// APIs.
///
/// [accessToken] Square API access token (Bearer token).
/// [locationId] Square location ID for inventory counts.
/// [environment] `'sandbox'` or `'production'` (defaults to `'sandbox'`).
factory AppServices.square({
required String accessToken,
required String locationId,
String environment = 'sandbox',
}) {
final apiClient = environment == 'production'
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: catalogRepo,
mappingRepository: mappingRepo,
);
return AppServices(
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: catalogRepo,
squareSyncController: SquareSyncController(syncService: syncService),
);
}
/// Creates an [AppServices] with both WooCommerce and Square backends active.
///
/// WooCommerce is used for the product catalog and orders. Square is used for
/// inventory counts. This mirrors the multi-channel commerce model where
/// WooCommerce is the catalog source of truth and Square tracks in-person
/// inventory.
factory AppServices.multi({
required String wcSiteUrl,
required String wcConsumerKey,
required String wcConsumerSecret,
required String squareAccessToken,
required String squareLocationId,
String squareEnvironment = 'sandbox',
}) {
final wcApiClient = WooCommerceApiClient(
siteUrl: wcSiteUrl,
consumerKey: wcConsumerKey,
consumerSecret: wcConsumerSecret,
);
final squareApiClient = squareEnvironment == 'production'
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
final mappingRepo = FakeProductIdMappingRepository();
final syncService = SquareCatalogSyncService(
catalogRepository: wcPublishingRepo,
mappingRepository: mappingRepo,
);
return AppServices(
inventoryRepository: SquareInventoryRepository(
apiClient: squareApiClient,
locationId: squareLocationId,
),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: wcPublishingRepo,
squareSyncController: SquareSyncController(syncService: syncService),
);
}
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
///
/// Includes [createSquare] and [createMulti] callbacks so that
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
/// `KC_ENV=multi` without falling back to fake mode.
static KcServiceFactory<AppServices> get serviceFactory {
return KcServiceFactory<AppServices>(
createFake: () => AppServices.fake(),
@ -158,19 +72,6 @@ class AppServices extends KcAppServices {
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
createSquare: (config) => AppServices.square(
accessToken: config.squareAccessToken,
locationId: config.squareLocationId,
environment: config.squareEnvironment,
),
createMulti: (config) => AppServices.multi(
wcSiteUrl: config.wcSiteUrl,
wcConsumerKey: config.wcConsumerKey,
wcConsumerSecret: config.wcConsumerSecret,
squareAccessToken: config.squareAccessToken,
squareLocationId: config.squareLocationId,
squareEnvironment: config.squareEnvironment,
),
);
}
}

View File

@ -1,56 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
/// The Integrations page for `kell_web`.
///
/// When a [SquareSyncController] is provided (i.e. the app is running in
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
/// operator can trigger and monitor Square catalog + inventory syncs.
///
/// When no controller is available (fake or wordpress mode), a placeholder
/// message is shown instead.
class IntegrationsPage extends StatelessWidget {
/// The Square sync controller, or `null` when Square is not configured.
final SquareSyncController? squareSyncController;
const IntegrationsPage({super.key, this.squareSyncController});
@override
Widget build(BuildContext context) {
final controller = squareSyncController;
if (controller == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text('No integrations configured', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Set KC_ENV=square or KC_ENV=multi with Square credentials\n'
'to enable the Square sync panel.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
],
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Square',
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
SquareSyncStatusPanel(controller: controller),
],
),
);
}
}

View File

@ -10,7 +10,7 @@ import '../dashboard/application/get_dashboard_summary.dart';
import '../navigation/app_navigation.dart';
import '../pages/dashboard_page.dart';
import '../pages/finance_placeholder_page.dart';
import '../pages/integrations_page.dart';
import '../pages/integrations_placeholder_page.dart';
import '../shell/app_shell.dart';
abstract final class AppRoutes {
@ -117,12 +117,10 @@ abstract final class AppRoutes {
case integrations:
return _buildRoute(
settings,
(context) => AppShell(
(context) => const AppShell(
selectedRoute: integrations,
title: 'Integrations',
child: IntegrationsPage(
squareSyncController: AppScope.of(context).squareSyncController,
),
child: IntegrationsPlaceholderPage(),
),
);
default:

View File

@ -1,248 +0,0 @@
import 'package:core/core.dart';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/composition/app_services.dart';
void main() {
group('AppServices', () {
// fake factory
group('fake()', () {
test('creates AppServices instance', () {
final services = AppServices.fake();
expect(services, isA<AppServices>());
expect(services, isA<KcAppServices>());
});
test('inventoryRepository is FakeInventoryRepository', () {
final services = AppServices.fake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
test('productPublishingRepository is FakeProductPublishingRepository', () {
final services = AppServices.fake();
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
});
});
// wordpress factory
group('wordpress()', () {
test('creates AppServices instance', () {
final services = AppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services, isA<AppServices>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = AppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
final services = AppServices.wordpress(
siteUrl: 'https://store.kellcreations.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
);
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
// square factory
group('square()', () {
test('creates AppServices instance', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<AppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is SquareCatalogRepository', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('squareSyncController is non-null', () {
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services.squareSyncController, isNotNull);
expect(services.squareSyncController, isA<SquareSyncController>());
services.squareSyncController!.dispose();
});
test('defaults to sandbox environment', () {
// Constructing with no environment param should not throw.
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
expect(services, isA<AppServices>());
});
test('accepts production environment', () {
final services = AppServices.square(
accessToken: 'EAAAl_test',
locationId: 'LOC123',
environment: 'production',
);
expect(services, isA<AppServices>());
});
});
// multi factory
group('multi()', () {
test('creates AppServices instance', () {
final services = AppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services, isA<AppServices>());
});
test('inventoryRepository is SquareInventoryRepository', () {
final services = AppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('productPublishingRepository is WordPressProductPublishingRepository', () {
final services = AppServices.multi(
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
});
// serviceFactory
group('serviceFactory', () {
test('createFake returns fake services', () {
final factory = AppServices.serviceFactory;
final services = factory.createFake();
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
expect(services.squareSyncController, isNull);
});
test('createWordPress returns wordpress services', () {
final factory = AppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.wordpress,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
);
final services = factory.createWordPress(config);
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('createSquare is non-null', () {
final factory = AppServices.serviceFactory;
expect(factory.createSquare, isNotNull);
});
test('createSquare returns square services', () {
final factory = AppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createSquare!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
});
test('createMulti is non-null', () {
final factory = AppServices.serviceFactory;
expect(factory.createMulti, isNotNull);
});
test('createMulti returns multi services', () {
final factory = AppServices.serviceFactory;
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final services = factory.createMulti!(config);
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
});
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, AppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.square);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
});
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.multi,
wcSiteUrl: 'https://store.kellcreations.com',
wcConsumerKey: 'ck_test',
wcConsumerSecret: 'cs_test',
squareAccessToken: 'EAAAl_test',
squareLocationId: 'LOC123',
);
final result = KcBootstrap.run(config, AppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.multi);
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
expect(
result.services.productPublishingRepository,
isA<WordPressProductPublishingRepository>(),
);
});
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
const config = KcAppConfig(
environment: KcAppEnvironment.square,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
squareAccessToken: '',
squareLocationId: '',
);
final result = KcBootstrap.run(config, AppServices.serviceFactory);
expect(result.config.environment, KcAppEnvironment.fake);
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
});
});
});
}

View File

@ -1,53 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/pages/integrations_page.dart';
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
SquareSyncController _fakeController() {
final syncService = SquareCatalogSyncService(
catalogRepository: FakeProductPublishingRepository(),
mappingRepository: FakeProductIdMappingRepository(),
);
return SquareSyncController(syncService: syncService);
}
void main() {
group('IntegrationsPage', () {
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
await tester.pumpWidget(_wrap(const IntegrationsPage()));
await tester.pump();
expect(find.text('No integrations configured'), findsOneWidget);
});
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square Sync'), findsOneWidget);
expect(find.text('Sync Now'), findsOneWidget);
});
testWidgets('shows Square section label when controller is provided', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
await tester.pump();
expect(find.text('Square'), findsOneWidget);
});
testWidgets('placeholder shows hub icon', (tester) async {
await tester.pumpWidget(_wrap(const IntegrationsPage()));
await tester.pump();
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
});
});
}

View File

@ -7,7 +7,6 @@ export 'src/application/inventory_controller.dart';
export 'src/application/inventory_sync_service.dart';
export 'src/application/update_inventory_item.dart';
export 'src/data/fake_inventory_repository.dart';
export 'src/data/square_inventory_repository.dart';
export 'src/data/woo_commerce_inventory_repository.dart';
export 'src/domain/inventory_adjustment_result.dart';
export 'src/domain/inventory_item.dart';

View File

@ -1,236 +0,0 @@
import 'package:integrations/integrations.dart';
import '../domain/inventory_adjustment_result.dart';
import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart';
import '../domain/inventory_status.dart';
import '../domain/inventory_sync_status.dart';
/// Square-backed implementation of [InventoryRepository].
///
/// Inventory counts are read from and written to Square via [SquareApiClient],
/// mapping between Square InventoryCount JSON and the [InventoryItem] domain
/// model using [SquareInventoryMapper].
///
/// ## Scope
///
/// - [getInventoryItems] returns the local in-memory item list (Square does
/// not provide a full item catalog; items are managed via [SquareCatalogRepository])
/// - [adjustQuantity] applies a delta to local quantity and pushes an
/// absolute PHYSICAL_COUNT to Square via [batchChangeInventory]
/// - [setQuantity] sets an absolute quantity locally and pushes to Square
/// - [syncWithRemote] pulls the current IN_STOCK count from Square for a
/// single item's variation and updates the local record
/// - [getItemBySku] looks up a local item by SKU (no Square API call)
/// - [createItem] / [updateItem] / [updateItemImage] local-only operations
/// (Square item metadata is managed via the catalog API)
///
/// ## Square variation IDs
///
/// Square inventory counts are tracked at the ITEM_VARIATION level, not the
/// ITEM level. Each [InventoryItem] must have a non-null
/// [InventoryItem.squareVariationId] for inventory sync to work. Items without
/// a variation ID are stored locally only and are not synced to Square.
///
/// ## Location
///
/// All Square inventory operations are scoped to [locationId].
class SquareInventoryRepository implements InventoryRepository {
final SquareApiClient _apiClient;
final SquareInventoryMapper _mapper;
/// The Square location ID used for inventory counts.
final String locationId;
/// In-memory store for all inventory items (local source of truth).
final List<InventoryItem> _items;
SquareInventoryRepository({
required SquareApiClient apiClient,
required this.locationId,
List<InventoryItem> initialItems = const [],
SquareInventoryMapper mapper = const SquareInventoryMapper(),
}) : _apiClient = apiClient,
_mapper = mapper,
_items = List.of(initialItems);
// Read operations
@override
Future<List<InventoryItem>> getInventoryItems() async {
return List.unmodifiable(_items);
}
@override
Future<InventoryItem?> getItemBySku(String sku) async {
try {
return _items.firstWhere((i) => i.sku == sku);
} catch (_) {
return null;
}
}
// Write operations
@override
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) async {
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final item = _items[index];
final newQty = item.quantityOnHand + delta;
if (newQty < 0) {
return const InventoryAdjustmentResult.failure('Quantity cannot go below zero');
}
// Push to Square if this item has a linked variation.
if (item.squareVariationId != null) {
try {
final count = ChannelInventoryCount(
productId: item.squareVariationId!,
sku: item.sku.isNotEmpty ? item.sku : null,
quantity: newQty,
);
final entry = _mapper.toSquareBatchChangeEntry(count, locationId: locationId);
await _apiClient.batchChangeInventory([entry]);
} catch (e) {
return InventoryAdjustmentResult.failure('Square inventory update failed: $e');
}
}
final updated = item.copyWith(
quantityOnHand: newQty,
status: _deriveStatus(newQty, item.reorderPoint),
lastUpdated: DateTime.now(),
lastSyncedAt: item.squareVariationId != null ? DateTime.now() : item.lastSyncedAt,
syncStatus: item.squareVariationId != null
? InventorySyncStatus.synced
: InventorySyncStatus.notSynced,
);
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
@override
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) async {
if (quantity < 0) {
return const InventoryAdjustmentResult.failure('Quantity cannot be negative');
}
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final item = _items[index];
// Push to Square if this item has a linked variation.
if (item.squareVariationId != null) {
try {
final count = ChannelInventoryCount(
productId: item.squareVariationId!,
sku: item.sku.isNotEmpty ? item.sku : null,
quantity: quantity,
);
final entry = _mapper.toSquareBatchChangeEntry(count, locationId: locationId);
await _apiClient.batchChangeInventory([entry]);
} catch (e) {
return InventoryAdjustmentResult.failure('Square inventory update failed: $e');
}
}
final updated = item.copyWith(
quantityOnHand: quantity,
status: _deriveStatus(quantity, item.reorderPoint),
lastUpdated: DateTime.now(),
lastSyncedAt: item.squareVariationId != null ? DateTime.now() : item.lastSyncedAt,
syncStatus: item.squareVariationId != null
? InventorySyncStatus.synced
: InventorySyncStatus.notSynced,
);
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
@override
Future<InventoryAdjustmentResult> syncWithRemote(
String id,
int remoteQuantity,
String reason,
) async {
if (remoteQuantity < 0) {
return const InventoryAdjustmentResult.failure('Remote quantity cannot be negative');
}
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final item = _items[index];
// If the item has a Square variation ID, pull the live count from Square.
int resolvedQuantity = remoteQuantity;
if (item.squareVariationId != null) {
try {
final rawCount = await _apiClient.getInventoryCount(item.squareVariationId!);
if (rawCount != null) {
final channelCount = _mapper.fromSquareInventoryCount(rawCount);
if (channelCount != null) {
resolvedQuantity = channelCount.quantity;
}
}
} catch (e) {
return InventoryAdjustmentResult.failure('Square inventory fetch failed: $e');
}
}
final updated = item.copyWith(
quantityOnHand: resolvedQuantity,
status: _deriveStatus(resolvedQuantity, item.reorderPoint),
lastUpdated: DateTime.now(),
lastSyncedAt: DateTime.now(),
syncStatus: InventorySyncStatus.synced,
);
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
@override
Future<InventoryItem> createItem(InventoryItem item) async {
_items.add(item);
return item;
}
@override
Future<InventoryItem> updateItem(InventoryItem item) async {
final index = _items.indexWhere((i) => i.id == item.id);
if (index == -1) {
throw StateError('Item with id ${item.id} not found');
}
final updated = item.copyWith(lastUpdated: DateTime.now());
_items[index] = updated;
return updated;
}
@override
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) async {
final index = _items.indexWhere((i) => i.id == id);
if (index == -1) {
return const InventoryAdjustmentResult.failure('Item not found');
}
final updated = _items[index].copyWith(imageUrl: imageUrl, lastUpdated: DateTime.now());
_items[index] = updated;
return InventoryAdjustmentResult.success(updated);
}
// Private helpers
static InventoryStatus _deriveStatus(int quantity, int reorderPoint) {
if (quantity == 0) return InventoryStatus.outOfStock;
if (quantity <= reorderPoint) return InventoryStatus.lowStock;
return InventoryStatus.inStock;
}
}

View File

@ -36,15 +36,6 @@ class InventoryItem {
/// WooCommerce product ID.
final InventorySyncStatus syncStatus;
// Square sync fields
/// The Square CatalogObject ID for the primary ITEM_VARIATION linked to this
/// inventory item.
///
/// Square tracks inventory at the variation level, not the item level.
/// `null` if this item has no Square counterpart.
final String? squareVariationId;
const InventoryItem({
required this.id,
required this.sku,
@ -62,7 +53,6 @@ class InventoryItem {
this.wooCommerceProductId,
this.lastSyncedAt,
this.syncStatus = InventorySyncStatus.notSynced,
this.squareVariationId,
});
/// Returns a copy of this [InventoryItem] with the given fields replaced.
@ -83,7 +73,6 @@ class InventoryItem {
int? wooCommerceProductId,
DateTime? lastSyncedAt,
InventorySyncStatus? syncStatus,
String? squareVariationId,
}) {
return InventoryItem(
id: id ?? this.id,
@ -102,7 +91,6 @@ class InventoryItem {
wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
syncStatus: syncStatus ?? this.syncStatus,
squareVariationId: squareVariationId ?? this.squareVariationId,
);
}
}

View File

@ -15,8 +15,6 @@ dependencies:
path: ../design_system
feature_wordpress:
path: ../feature_wordpress
integrations:
path: ../integrations
dev_dependencies:
flutter_test:

View File

@ -1,386 +0,0 @@
import 'dart:convert';
import 'package:feature_inventory/feature_inventory.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:integrations/integrations.dart';
// Test helpers
/// Builds a minimal [InventoryItem] for testing.
InventoryItem _item({
String id = 'INV-001',
String sku = 'BC-001',
String name = 'Bowl Cozy',
int quantity = 10,
int reorderPoint = 3,
String? squareVariationId,
}) {
return InventoryItem(
id: id,
sku: sku,
name: name,
description: 'Test item',
imageUrl: '',
category: 'Accessories',
quantityOnHand: quantity,
unitPrice: 12.99,
reorderPoint: reorderPoint,
status: InventoryStatus.inStock,
lastUpdated: DateTime(2026, 7, 1),
squareVariationId: squareVariationId,
);
}
/// Builds a mock [http.Client] that returns [body] with [statusCode].
http.Client _mockClient(String body, {int statusCode = 200}) {
return MockClient((_) async => http.Response(body, statusCode));
}
/// Builds a Square inventory count JSON response.
String _inventoryCountResponse(String variationId, int quantity) {
return jsonEncode({
'counts': [
{
'catalog_object_id': variationId,
'catalog_object_type': 'ITEM_VARIATION',
'state': 'IN_STOCK',
'location_id': 'LOC-001',
'quantity': quantity.toString(),
'calculated_at': '2026-07-01T00:00:00.000Z',
},
],
});
}
/// Builds a Square batch change inventory response.
String _batchChangeResponse(String variationId, int quantity) {
return jsonEncode({
'counts': [
{'catalog_object_id': variationId, 'state': 'IN_STOCK', 'quantity': quantity.toString()},
],
});
}
/// Builds a [SquareInventoryRepository] with the given [items] and [client].
SquareInventoryRepository _repo(List<InventoryItem> items, http.Client client) {
final apiClient = SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: client,
);
return SquareInventoryRepository(
apiClient: apiClient,
locationId: 'LOC-001',
initialItems: items,
);
}
void main() {
group('SquareInventoryRepository', () {
// getInventoryItems
group('getInventoryItems', () {
test('returns all items from initial list', () async {
final items = [_item(id: 'INV-001'), _item(id: 'INV-002')];
final repo = _repo(items, _mockClient('{}'));
final result = await repo.getInventoryItems();
expect(result, hasLength(2));
expect(result.map((i) => i.id), containsAll(['INV-001', 'INV-002']));
});
test('returns empty list when no items', () async {
final repo = _repo([], _mockClient('{}'));
final result = await repo.getInventoryItems();
expect(result, isEmpty);
});
test('returns unmodifiable list', () async {
final repo = _repo([_item()], _mockClient('{}'));
final result = await repo.getInventoryItems();
expect(() => (result as List).add(_item(id: 'NEW')), throwsUnsupportedError);
});
});
// getItemBySku
group('getItemBySku', () {
test('returns item when SKU matches', () async {
final repo = _repo([_item(id: 'INV-001', sku: 'BC-001')], _mockClient('{}'));
final result = await repo.getItemBySku('BC-001');
expect(result, isNotNull);
expect(result!.id, 'INV-001');
});
test('returns null when SKU not found', () async {
final repo = _repo([_item(sku: 'BC-001')], _mockClient('{}'));
final result = await repo.getItemBySku('UNKNOWN');
expect(result, isNull);
});
});
// adjustQuantity
group('adjustQuantity', () {
test('adjusts quantity locally when no Square variation ID', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}'));
final result = await repo.adjustQuantity('INV-001', 5, 'received');
expect(result.success, isTrue);
expect(result.updatedItem!.quantityOnHand, 15);
expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced);
});
test('pushes to Square when variation ID is set', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient(_batchChangeResponse('SQ-VAR-001', 15)));
final result = await repo.adjustQuantity('INV-001', 5, 'received');
expect(result.success, isTrue);
expect(result.updatedItem!.quantityOnHand, 15);
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
expect(result.updatedItem!.lastSyncedAt, isNotNull);
});
test('returns failure when item not found', () async {
final repo = _repo([], _mockClient('{}'));
final result = await repo.adjustQuantity('UNKNOWN', 1, 'test');
expect(result.success, isFalse);
expect(result.errorMessage, contains('not found'));
});
test('returns failure when quantity would go below zero', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 2)], _mockClient('{}'));
final result = await repo.adjustQuantity('INV-001', -5, 'used');
expect(result.success, isFalse);
expect(result.errorMessage, contains('below zero'));
});
test('returns failure when Square API call fails', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 401));
final result = await repo.adjustQuantity('INV-001', 5, 'received');
expect(result.success, isFalse);
expect(result.errorMessage, contains('Square inventory update failed'));
});
test('derives inStock status when quantity above reorder point', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 5, reorderPoint: 3)], _mockClient('{}'));
final result = await repo.adjustQuantity('INV-001', 2, 'received');
expect(result.updatedItem!.status, InventoryStatus.inStock);
});
test('derives lowStock status when quantity at or below reorder point', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 5, reorderPoint: 5)], _mockClient('{}'));
final result = await repo.adjustQuantity('INV-001', -2, 'used');
expect(result.updatedItem!.status, InventoryStatus.lowStock);
});
test('derives outOfStock status when quantity reaches zero', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 3)], _mockClient('{}'));
final result = await repo.adjustQuantity('INV-001', -3, 'used');
expect(result.updatedItem!.status, InventoryStatus.outOfStock);
});
});
// setQuantity
group('setQuantity', () {
test('sets quantity locally when no Square variation ID', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}'));
final result = await repo.setQuantity('INV-001', 20, 'count');
expect(result.success, isTrue);
expect(result.updatedItem!.quantityOnHand, 20);
expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced);
});
test('pushes to Square when variation ID is set', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient(_batchChangeResponse('SQ-VAR-001', 20)));
final result = await repo.setQuantity('INV-001', 20, 'count');
expect(result.success, isTrue);
expect(result.updatedItem!.quantityOnHand, 20);
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
});
test('returns failure for negative quantity', () async {
final repo = _repo([_item()], _mockClient('{}'));
final result = await repo.setQuantity('INV-001', -1, 'count');
expect(result.success, isFalse);
expect(result.errorMessage, contains('negative'));
});
test('returns failure when item not found', () async {
final repo = _repo([], _mockClient('{}'));
final result = await repo.setQuantity('UNKNOWN', 5, 'count');
expect(result.success, isFalse);
expect(result.errorMessage, contains('not found'));
});
test('returns failure when Square API call fails', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 500));
final result = await repo.setQuantity('INV-001', 20, 'count');
expect(result.success, isFalse);
expect(result.errorMessage, contains('Square inventory update failed'));
});
});
// syncWithRemote
group('syncWithRemote', () {
test('uses remoteQuantity when no Square variation ID', () async {
final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}'));
final result = await repo.syncWithRemote('INV-001', 25, 'sync');
expect(result.success, isTrue);
expect(result.updatedItem!.quantityOnHand, 25);
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
expect(result.updatedItem!.lastSyncedAt, isNotNull);
});
test('pulls live count from Square when variation ID is set', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient(_inventoryCountResponse('SQ-VAR-001', 18)));
final result = await repo.syncWithRemote('INV-001', 0, 'sync');
expect(result.success, isTrue);
// Should use the Square count (18), not the passed remoteQuantity (0).
expect(result.updatedItem!.quantityOnHand, 18);
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
});
test('falls back to remoteQuantity when Square returns null count', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient(jsonEncode({'counts': []})));
final result = await repo.syncWithRemote('INV-001', 7, 'sync');
expect(result.success, isTrue);
expect(result.updatedItem!.quantityOnHand, 7);
});
test('returns failure for negative remoteQuantity', () async {
final repo = _repo([_item()], _mockClient('{}'));
final result = await repo.syncWithRemote('INV-001', -1, 'sync');
expect(result.success, isFalse);
expect(result.errorMessage, contains('negative'));
});
test('returns failure when item not found', () async {
final repo = _repo([], _mockClient('{}'));
final result = await repo.syncWithRemote('UNKNOWN', 5, 'sync');
expect(result.success, isFalse);
expect(result.errorMessage, contains('not found'));
});
test('returns failure when Square API call fails', () async {
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 404));
final result = await repo.syncWithRemote('INV-001', 5, 'sync');
expect(result.success, isFalse);
expect(result.errorMessage, contains('Square inventory fetch failed'));
});
});
// createItem
group('createItem', () {
test('adds item to local store and returns it', () async {
final repo = _repo([], _mockClient('{}'));
final newItem = _item(id: 'INV-NEW');
final result = await repo.createItem(newItem);
expect(result.id, 'INV-NEW');
final all = await repo.getInventoryItems();
expect(all, hasLength(1));
expect(all.first.id, 'INV-NEW');
});
test('can create item with Square variation ID', () async {
final repo = _repo([], _mockClient('{}'));
final newItem = _item(id: 'INV-NEW', squareVariationId: 'SQ-VAR-NEW');
final result = await repo.createItem(newItem);
expect(result.squareVariationId, 'SQ-VAR-NEW');
});
});
// updateItem
group('updateItem', () {
test('updates item in local store', () async {
final repo = _repo([_item(id: 'INV-001', name: 'Old Name')], _mockClient('{}'));
final updated = _item(id: 'INV-001', name: 'New Name');
final result = await repo.updateItem(updated);
expect(result.name, 'New Name');
final all = await repo.getInventoryItems();
expect(all.first.name, 'New Name');
});
test('throws StateError when item not found', () async {
final repo = _repo([], _mockClient('{}'));
expect(() => repo.updateItem(_item(id: 'UNKNOWN')), throwsA(isA<StateError>()));
});
test('stamps lastUpdated on update', () async {
final before = DateTime(2026, 1, 1);
final item = InventoryItem(
id: 'INV-001',
sku: 'BC-001',
name: 'Item',
description: '',
imageUrl: '',
category: '',
quantityOnHand: 5,
unitPrice: 10.0,
reorderPoint: 2,
status: InventoryStatus.inStock,
lastUpdated: before,
);
final repo = _repo([item], _mockClient('{}'));
final result = await repo.updateItem(item);
expect(result.lastUpdated.isAfter(before), isTrue);
});
});
// updateItemImage
group('updateItemImage', () {
test('updates image URL in local store', () async {
final repo = _repo([_item(id: 'INV-001')], _mockClient('{}'));
final result = await repo.updateItemImage('INV-001', 'https://example.com/img.jpg');
expect(result.success, isTrue);
expect(result.updatedItem!.imageUrl, 'https://example.com/img.jpg');
});
test('returns failure when item not found', () async {
final repo = _repo([], _mockClient('{}'));
final result = await repo.updateItemImage('UNKNOWN', 'https://example.com/img.jpg');
expect(result.success, isFalse);
expect(result.errorMessage, contains('not found'));
});
});
// InventoryItem.squareVariationId field
group('InventoryItem.squareVariationId', () {
test('defaults to null', () {
final item = _item();
expect(item.squareVariationId, isNull);
});
test('can be set via constructor', () {
final item = _item(squareVariationId: 'SQ-VAR-001');
expect(item.squareVariationId, 'SQ-VAR-001');
});
test('copyWith preserves squareVariationId when not overridden', () {
final item = _item(squareVariationId: 'SQ-VAR-001');
final copy = item.copyWith(name: 'New Name');
expect(copy.squareVariationId, 'SQ-VAR-001');
});
test('copyWith can update squareVariationId', () {
final item = _item(squareVariationId: 'SQ-VAR-001');
final copy = item.copyWith(squareVariationId: 'SQ-VAR-002');
expect(copy.squareVariationId, 'SQ-VAR-002');
});
});
});
}

View File

@ -1,14 +1,11 @@
library;
export 'src/application/add_order_note.dart';
export 'src/application/checkout_controller.dart';
export 'src/application/create_order.dart';
export 'src/application/get_orders.dart';
export 'src/application/orders_controller.dart';
export 'src/application/payment_service.dart';
export 'src/application/update_order_status.dart';
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/order.dart';
@ -17,8 +14,6 @@ export 'src/domain/order_item.dart';
export 'src/domain/order_source.dart';
export 'src/domain/order_status.dart';
export 'src/domain/orders_repository.dart';
export 'src/domain/payment_method.dart';
export 'src/domain/payment_result.dart';
export 'src/presentation/orders_action_snack_bar.dart';
export 'src/presentation/orders_page.dart';
export 'src/presentation/widgets/add_note_dialog.dart';

View File

@ -1,138 +0,0 @@
import 'package:flutter/foundation.dart';
import '../domain/order_item.dart';
import '../domain/payment_method.dart';
import '../domain/payment_result.dart';
import 'payment_service.dart';
/// Represents a single item in the active checkout cart.
///
/// Wraps an [OrderItem] with a mutable quantity so the cart can be updated
/// without mutating the underlying domain model.
class CartEntry {
final OrderItem item;
final int quantity;
const CartEntry({required this.item, required this.quantity});
CartEntry copyWith({int? quantity}) => CartEntry(item: item, quantity: quantity ?? this.quantity);
/// Total price for this line in cents.
int get lineTotalCents => (item.unitPrice * quantity * 100).round();
}
/// Sealed state hierarchy for the checkout flow.
sealed class CheckoutState {}
/// The cart is being built no payment in progress.
class CheckoutIdle extends CheckoutState {
final List<CartEntry> cart;
CheckoutIdle(this.cart);
}
/// A payment is being processed.
class CheckoutProcessing extends CheckoutState {
final List<CartEntry> cart;
CheckoutProcessing(this.cart);
}
/// Payment completed successfully.
class CheckoutSuccess extends CheckoutState {
final PaymentResult result;
CheckoutSuccess(this.result);
}
/// Payment failed.
class CheckoutError extends CheckoutState {
final String message;
final List<CartEntry> cart;
CheckoutError(this.message, this.cart);
}
/// Application-layer controller for the mobile POS checkout flow.
///
/// Manages the cart (add / remove / clear) and delegates payment processing
/// to the injected [PaymentService].
///
/// Extends [ChangeNotifier] so [MobileCheckoutPage] can listen for state
/// changes via [ListenableBuilder] or [AnimatedBuilder].
class CheckoutController extends ChangeNotifier {
final PaymentService paymentService;
CheckoutController({required this.paymentService});
CheckoutState _state = CheckoutIdle([]);
CheckoutState get state => _state;
List<CartEntry> get cart {
final s = _state;
if (s is CheckoutIdle) return s.cart;
if (s is CheckoutProcessing) return s.cart;
if (s is CheckoutError) return s.cart;
return [];
}
/// Total cart value in cents.
int get totalCents => cart.fold(0, (sum, e) => sum + e.lineTotalCents);
/// Adds [item] to the cart, incrementing quantity if already present.
void addItem(OrderItem item) {
final current = List<CartEntry>.from(cart);
final idx = current.indexWhere((e) => e.item.sku == item.sku);
if (idx >= 0) {
current[idx] = current[idx].copyWith(quantity: current[idx].quantity + 1);
} else {
current.add(CartEntry(item: item, quantity: 1));
}
_state = CheckoutIdle(current);
notifyListeners();
}
/// Removes one unit of [sku] from the cart. Removes the entry entirely
/// when quantity reaches zero.
void removeItem(String sku) {
final current = List<CartEntry>.from(cart);
final idx = current.indexWhere((e) => e.item.sku == sku);
if (idx < 0) return;
if (current[idx].quantity > 1) {
current[idx] = current[idx].copyWith(quantity: current[idx].quantity - 1);
} else {
current.removeAt(idx);
}
_state = CheckoutIdle(current);
notifyListeners();
}
/// Empties the cart and resets to idle.
void clearCart() {
_state = CheckoutIdle([]);
notifyListeners();
}
/// Resets to idle after a success or error so a new sale can begin.
void reset() {
_state = CheckoutIdle([]);
notifyListeners();
}
/// Processes payment for the current cart using [method].
///
/// Transitions: idle processing success | error.
Future<void> processPayment(PaymentMethod method) async {
if (cart.isEmpty) return;
final snapshot = List<CartEntry>.from(cart);
_state = CheckoutProcessing(snapshot);
notifyListeners();
try {
final result = await paymentService.processPayment(amountCents: totalCents, method: method);
_state = CheckoutSuccess(result);
} on PaymentException catch (e) {
_state = CheckoutError(e.message, snapshot);
} catch (e) {
_state = CheckoutError('Unexpected error: $e', snapshot);
}
notifyListeners();
}
}

View File

@ -1,30 +0,0 @@
import '../domain/payment_method.dart';
import '../domain/payment_result.dart';
/// Abstract contract for processing point-of-sale payments.
///
/// Implementations:
/// - [FakePaymentService] always succeeds instantly; used in fake/test mode.
/// - `SquarePaymentService` (future) delegates to the Square Reader SDK.
///
/// All amounts are expressed in the smallest currency unit (cents for USD).
abstract class PaymentService {
const PaymentService();
/// Processes a payment for [amountCents] using [method].
///
/// Returns a [PaymentResult] on success.
/// Throws a [PaymentException] on failure.
Future<PaymentResult> processPayment({required int amountCents, required PaymentMethod method});
}
/// Thrown when a payment attempt fails.
class PaymentException implements Exception {
final String message;
final Object? cause;
const PaymentException(this.message, {this.cause});
@override
String toString() => 'PaymentException: $message${cause != null ? ' (cause: $cause)' : ''}';
}

View File

@ -1,32 +0,0 @@
import 'dart:math' as math;
import '../application/payment_service.dart';
import '../domain/payment_method.dart';
import '../domain/payment_result.dart';
/// In-memory [PaymentService] that always succeeds instantly.
///
/// Used in `fake` mode and in unit/widget tests. Generates a deterministic
/// transaction ID from a counter so tests can assert on the returned value.
class FakePaymentService extends PaymentService {
int _counter = 0;
/// Simulates a successful payment with a generated transaction ID.
///
/// Never throws. Returns immediately (no network delay).
@override
Future<PaymentResult> processPayment({
required int amountCents,
required PaymentMethod method,
}) async {
_counter++;
final id = 'fake-txn-${_counter.toString().padLeft(4, '0')}-${math.Random().nextInt(9999)}';
return PaymentResult(
transactionId: id,
amountCents: amountCents,
method: method,
completedAt: DateTime.now().toUtc(),
receiptUrl: method == PaymentMethod.card ? 'https://squareup.com/receipt/$id' : null,
);
}
}

View File

@ -1,30 +0,0 @@
/// The method used to complete a point-of-sale payment.
enum PaymentMethod {
/// Cash tendered by the customer; no hardware required.
cash,
/// Card payment processed via Square Reader SDK or manual entry.
card,
}
extension PaymentMethodLabel on PaymentMethod {
/// Human-readable label for display in the UI.
String get label {
switch (this) {
case PaymentMethod.cash:
return 'Cash';
case PaymentMethod.card:
return 'Card';
}
}
/// Icon name hint for the UI layer.
String get iconHint {
switch (this) {
case PaymentMethod.cash:
return 'payments';
case PaymentMethod.card:
return 'credit_card';
}
}
}

View File

@ -1,59 +0,0 @@
import 'payment_method.dart';
/// The outcome of a completed point-of-sale payment transaction.
///
/// Returned by [PaymentService.processPayment] after a successful charge.
/// Immutable value object all fields are set at construction time.
class PaymentResult {
/// Unique transaction identifier assigned by the payment processor.
///
/// For cash payments this is a locally-generated UUID.
/// For card payments this is the Square payment ID.
final String transactionId;
/// Total amount charged, in the smallest currency unit (e.g. cents for USD).
final int amountCents;
/// The payment method used.
final PaymentMethod method;
/// URL to the digital receipt, or `null` for cash transactions.
final String? receiptUrl;
/// UTC timestamp when the payment was completed.
final DateTime completedAt;
const PaymentResult({
required this.transactionId,
required this.amountCents,
required this.method,
required this.completedAt,
this.receiptUrl,
});
/// Convenience: amount as a decimal dollars string (e.g. `"12.50"`).
String get amountFormatted {
final dollars = amountCents ~/ 100;
final cents = amountCents % 100;
return '\$$dollars.${cents.toString().padLeft(2, '0')}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PaymentResult &&
runtimeType == other.runtimeType &&
transactionId == other.transactionId &&
amountCents == other.amountCents &&
method == other.method &&
receiptUrl == other.receiptUrl &&
completedAt == other.completedAt;
@override
int get hashCode => Object.hash(transactionId, amountCents, method, receiptUrl, completedAt);
@override
String toString() =>
'PaymentResult(transactionId: $transactionId, amount: $amountFormatted, '
'method: ${method.label}, completedAt: $completedAt)';
}

View File

@ -1,159 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
/// A [PaymentService] that always throws [PaymentException].
class _FailingPaymentService extends PaymentService {
@override
Future<PaymentResult> processPayment({
required int amountCents,
required PaymentMethod method,
}) async {
throw const PaymentException('Card declined');
}
}
const _item1 = OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00);
const _item2 = OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50);
void main() {
group('CheckoutController', () {
late CheckoutController controller;
setUp(() {
controller = CheckoutController(paymentService: FakePaymentService());
});
tearDown(() => controller.dispose());
// initial state
test('starts in idle state with empty cart', () {
expect(controller.state, isA<CheckoutIdle>());
expect(controller.cart, isEmpty);
expect(controller.totalCents, 0);
});
// addItem
test('addItem adds a new entry to the cart', () {
controller.addItem(_item1);
expect(controller.cart.length, 1);
expect(controller.cart.first.item.sku, 'WA-001');
expect(controller.cart.first.quantity, 1);
});
test('addItem increments quantity for duplicate SKU', () {
controller.addItem(_item1);
controller.addItem(_item1);
expect(controller.cart.length, 1);
expect(controller.cart.first.quantity, 2);
});
test('addItem handles multiple distinct items', () {
controller.addItem(_item1);
controller.addItem(_item2);
expect(controller.cart.length, 2);
});
// totalCents
test('totalCents sums all line totals', () {
controller.addItem(_item1); // 10.00
controller.addItem(_item2); // 5.50
// 1000 + 550 = 1550
expect(controller.totalCents, 1550);
});
test('totalCents accounts for quantity', () {
controller.addItem(_item1);
controller.addItem(_item1); // qty 2 × $10 = $20
expect(controller.totalCents, 2000);
});
// removeItem
test('removeItem decrements quantity', () {
controller.addItem(_item1);
controller.addItem(_item1);
controller.removeItem('WA-001');
expect(controller.cart.first.quantity, 1);
});
test('removeItem removes entry when quantity reaches zero', () {
controller.addItem(_item1);
controller.removeItem('WA-001');
expect(controller.cart, isEmpty);
});
test('removeItem is a no-op for unknown SKU', () {
controller.addItem(_item1);
controller.removeItem('UNKNOWN');
expect(controller.cart.length, 1);
});
// clearCart
test('clearCart empties the cart', () {
controller.addItem(_item1);
controller.addItem(_item2);
controller.clearCart();
expect(controller.cart, isEmpty);
expect(controller.totalCents, 0);
});
// processPayment success
test('processPayment transitions to CheckoutSuccess on success', () async {
controller.addItem(_item1);
await controller.processPayment(PaymentMethod.cash);
expect(controller.state, isA<CheckoutSuccess>());
final success = controller.state as CheckoutSuccess;
expect(success.result.amountCents, 1000);
expect(success.result.method, PaymentMethod.cash);
});
test('processPayment is a no-op when cart is empty', () async {
await controller.processPayment(PaymentMethod.cash);
expect(controller.state, isA<CheckoutIdle>());
});
// processPayment failure
test('processPayment transitions to CheckoutError on PaymentException', () async {
final failCtrl = CheckoutController(paymentService: _FailingPaymentService());
addTearDown(failCtrl.dispose);
failCtrl.addItem(_item1);
await failCtrl.processPayment(PaymentMethod.card);
expect(failCtrl.state, isA<CheckoutError>());
final err = failCtrl.state as CheckoutError;
expect(err.message, 'Card declined');
// Cart is preserved after error
expect(err.cart.length, 1);
});
// reset
test('reset returns to idle with empty cart after success', () async {
controller.addItem(_item1);
await controller.processPayment(PaymentMethod.cash);
expect(controller.state, isA<CheckoutSuccess>());
controller.reset();
expect(controller.state, isA<CheckoutIdle>());
expect(controller.cart, isEmpty);
});
// CartEntry
test('CartEntry.lineTotalCents is quantity × unitPrice × 100', () {
const entry = CartEntry(item: _item1, quantity: 3);
expect(entry.lineTotalCents, 3000); // 3 × $10.00
});
test('CartEntry.copyWith updates quantity', () {
const entry = CartEntry(item: _item1, quantity: 1);
final updated = entry.copyWith(quantity: 5);
expect(updated.quantity, 5);
expect(updated.item.sku, 'WA-001');
});
});
}

View File

@ -1,41 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FakePaymentService', () {
late FakePaymentService service;
setUp(() => service = FakePaymentService());
test('returns a PaymentResult for cash', () async {
final result = await service.processPayment(amountCents: 1000, method: PaymentMethod.cash);
expect(result.amountCents, 1000);
expect(result.method, PaymentMethod.cash);
expect(result.receiptUrl, isNull);
expect(result.transactionId, isNotEmpty);
});
test('returns a PaymentResult for card with receiptUrl', () async {
final result = await service.processPayment(amountCents: 2500, method: PaymentMethod.card);
expect(result.amountCents, 2500);
expect(result.method, PaymentMethod.card);
expect(result.receiptUrl, isNotNull);
expect(result.receiptUrl, contains('squareup.com'));
});
test('generates unique transaction IDs across calls', () async {
final r1 = await service.processPayment(amountCents: 100, method: PaymentMethod.cash);
final r2 = await service.processPayment(amountCents: 100, method: PaymentMethod.cash);
// IDs include a counter prefix that differs
expect(r1.transactionId, isNot(equals(r2.transactionId)));
});
test('completedAt is set to a recent UTC time', () async {
final before = DateTime.now().toUtc().subtract(const Duration(seconds: 1));
final result = await service.processPayment(amountCents: 500, method: PaymentMethod.cash);
final after = DateTime.now().toUtc().add(const Duration(seconds: 1));
expect(result.completedAt.isAfter(before), isTrue);
expect(result.completedAt.isBefore(after), isTrue);
});
});
}

View File

@ -1,26 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PaymentMethod', () {
test('cash has correct label', () {
expect(PaymentMethod.cash.label, 'Cash');
});
test('card has correct label', () {
expect(PaymentMethod.card.label, 'Card');
});
test('cash has payments iconHint', () {
expect(PaymentMethod.cash.iconHint, 'payments');
});
test('card has credit_card iconHint', () {
expect(PaymentMethod.card.iconHint, 'credit_card');
});
test('all values covered', () {
expect(PaymentMethod.values.length, 2);
});
});
}

View File

@ -1,77 +0,0 @@
import 'package:feature_orders/feature_orders.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final baseTime = DateTime.utc(2026, 7, 25, 14, 30);
PaymentResult makeResult({
String transactionId = 'txn-001',
int amountCents = 1250,
PaymentMethod method = PaymentMethod.cash,
String? receiptUrl,
}) {
return PaymentResult(
transactionId: transactionId,
amountCents: amountCents,
method: method,
completedAt: baseTime,
receiptUrl: receiptUrl,
);
}
group('PaymentResult', () {
test('amountFormatted formats cents correctly', () {
expect(makeResult(amountCents: 1250).amountFormatted, r'$12.50');
});
test('amountFormatted pads single-digit cents', () {
expect(makeResult(amountCents: 1205).amountFormatted, r'$12.05');
});
test('amountFormatted handles zero cents', () {
expect(makeResult(amountCents: 0).amountFormatted, r'$0.00');
});
test('amountFormatted handles whole dollars', () {
expect(makeResult(amountCents: 2000).amountFormatted, r'$20.00');
});
test('equality — same fields are equal', () {
final a = makeResult();
final b = makeResult();
expect(a, equals(b));
});
test('equality — different transactionId not equal', () {
final a = makeResult(transactionId: 'txn-001');
final b = makeResult(transactionId: 'txn-002');
expect(a, isNot(equals(b)));
});
test('equality — different amountCents not equal', () {
final a = makeResult(amountCents: 100);
final b = makeResult(amountCents: 200);
expect(a, isNot(equals(b)));
});
test('receiptUrl is null for cash by default', () {
expect(makeResult(method: PaymentMethod.cash).receiptUrl, isNull);
});
test('receiptUrl is set for card', () {
final r = makeResult(method: PaymentMethod.card, receiptUrl: 'https://example.com/receipt');
expect(r.receiptUrl, 'https://example.com/receipt');
});
test('toString contains transactionId', () {
final r = makeResult(transactionId: 'txn-abc');
expect(r.toString(), contains('txn-abc'));
});
test('hashCode is consistent', () {
final a = makeResult();
final b = makeResult();
expect(a.hashCode, equals(b.hashCode));
});
});
}

View File

@ -1,9 +1,7 @@
library;
// Data
export 'src/data/fake_product_id_mapping_repository.dart';
export 'src/data/fake_product_publishing_repository.dart';
export 'src/data/square_catalog_repository.dart';
export 'src/data/woo_commerce_api_client.dart';
export 'src/data/wordpress_product_mapper.dart';
export 'src/data/wordpress_product_publishing_repository.dart';
@ -11,15 +9,11 @@ export 'src/data/wordpress_product_publishing_repository.dart';
// Domain
export 'src/domain/product_category.dart';
export 'src/domain/product_draft.dart';
export 'src/domain/product_id_mapping.dart';
export 'src/domain/product_id_mapping_repository.dart';
export 'src/domain/product_image.dart';
export 'src/domain/product_publishing_repository.dart';
export 'src/domain/publish_status.dart';
// Application
export 'src/application/square_catalog_sync_service.dart';
export 'src/application/square_sync_controller.dart';
export 'src/application/create_category.dart';
export 'src/application/create_product.dart';
export 'src/application/delete_product.dart';
@ -35,7 +29,6 @@ export 'src/application/update_product_price.dart';
export 'src/application/update_product_status.dart';
// Presentation
export 'src/presentation/widgets/square_sync_status_panel.dart';
export 'src/presentation/category_management_page.dart';
export 'src/presentation/product_publishing_page.dart';
export 'src/presentation/widgets/create_product_dialog.dart';

View File

@ -1,278 +0,0 @@
import '../domain/product_draft.dart';
import '../domain/product_id_mapping.dart';
import '../domain/product_id_mapping_repository.dart';
import '../domain/product_publishing_repository.dart';
// ---------------------------------------------------------------------------
// Value objects
// ---------------------------------------------------------------------------
/// Describes a single item that failed during a sync operation.
class SyncError {
/// The local product / inventory item ID that failed.
final String itemId;
/// A human-readable description of the failure.
final String message;
const SyncError({required this.itemId, required this.message});
@override
String toString() => 'SyncError(itemId: $itemId, message: $message)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SyncError &&
runtimeType == other.runtimeType &&
itemId == other.itemId &&
message == other.message;
@override
int get hashCode => Object.hash(itemId, message);
}
/// Aggregate result returned by [SquareCatalogSyncService.syncAll],
/// [SquareCatalogSyncService.syncCatalog], and
/// [SquareCatalogSyncService.syncInventory].
class SquareSyncResult {
/// Number of catalog items successfully synced (mapping created/updated).
final int catalogSynced;
/// Number of inventory items successfully synced (quantity pulled from Square).
final int inventorySynced;
/// Number of catalog items that failed to sync.
final int catalogFailed;
/// Number of inventory items that failed to sync.
final int inventoryFailed;
/// Detailed error list for failed items.
final List<SyncError> errors;
const SquareSyncResult({
this.catalogSynced = 0,
this.inventorySynced = 0,
this.catalogFailed = 0,
this.inventoryFailed = 0,
this.errors = const [],
});
/// Total number of items processed (catalog + inventory).
int get totalProcessed => catalogSynced + catalogFailed + inventorySynced + inventoryFailed;
/// `true` when no errors occurred.
bool get allSucceeded => catalogFailed == 0 && inventoryFailed == 0;
/// Returns a new [SquareSyncResult] that merges [other] into this result.
SquareSyncResult merge(SquareSyncResult other) {
return SquareSyncResult(
catalogSynced: catalogSynced + other.catalogSynced,
inventorySynced: inventorySynced + other.inventorySynced,
catalogFailed: catalogFailed + other.catalogFailed,
inventoryFailed: inventoryFailed + other.inventoryFailed,
errors: [...errors, ...other.errors],
);
}
@override
String toString() =>
'SquareSyncResult(catalogSynced: $catalogSynced, '
'inventorySynced: $inventorySynced, '
'catalogFailed: $catalogFailed, '
'inventoryFailed: $inventoryFailed, '
'errors: ${errors.length})';
}
// ---------------------------------------------------------------------------
// Inventory adapter contract
// ---------------------------------------------------------------------------
/// Minimal contract used by [SquareCatalogSyncService] to sync inventory
/// without taking a hard dependency on the full [InventoryRepository] from
/// `feature_inventory`.
///
/// Implementations should delegate to [SquareInventoryRepository] or
/// [FakeInventoryRepository] as appropriate.
abstract class InventorySyncAdapter {
/// Pulls the current quantity for [itemId] from Square and updates the local
/// record.
///
/// [remoteQuantity] is used as a fallback when the Square API returns an
/// empty count list.
///
/// Returns `true` on success, `false` on failure.
Future<bool> syncItem(String itemId, int remoteQuantity);
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/// Application-layer service that orchestrates a full Square catalog and
/// inventory sync.
///
/// ## Responsibilities
///
/// 1. **Catalog sync** ([syncCatalog]):
/// - Fetches all products from Square via [ProductPublishingRepository.getProductDrafts].
/// - For each product, looks up or creates a [ProductIdMapping] in
/// [ProductIdMappingRepository].
/// - Updates the mapping's [ProductIdMapping.squareCatalogObjectId] and
/// [ProductIdMapping.squareVariationId] if they have changed.
///
/// 2. **Inventory sync** ([syncInventory]):
/// - Iterates over all [ProductIdMapping] records that have a
/// [ProductIdMapping.squareVariationId].
/// - Calls [InventorySyncAdapter.syncItem] for each, passing `0` as the
/// fallback quantity (the adapter pulls the live count from Square).
///
/// 3. **Full sync** ([syncAll]):
/// - Runs [syncCatalog] then [syncInventory] and merges the results.
///
/// ## Error handling
///
/// Individual item failures are captured as [SyncError] entries in the
/// returned [SquareSyncResult]. A failure on one item does not abort the
/// remaining items.
class SquareCatalogSyncService {
final ProductPublishingRepository _catalogRepository;
final ProductIdMappingRepository _mappingRepository;
final InventorySyncAdapter? _inventoryAdapter;
SquareCatalogSyncService({
required ProductPublishingRepository catalogRepository,
required ProductIdMappingRepository mappingRepository,
InventorySyncAdapter? inventoryAdapter,
}) : _catalogRepository = catalogRepository,
_mappingRepository = mappingRepository,
_inventoryAdapter = inventoryAdapter;
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/// Pulls the full Square catalog and reconciles [ProductIdMappingRepository].
///
/// For each product returned by [ProductPublishingRepository.getProductDrafts]:
/// - Uses [ProductDraft.squareCatalogObjectId] as the Square catalog object
/// ID when present. Falls back to [ProductDraft.id] only when
/// [ProductDraft.squareCatalogObjectId] is `null` (e.g. when the catalog
/// repository is [SquareCatalogRepository], which sets `product.id` to the
/// Square object ID directly).
/// - Looks up an existing mapping by the resolved Square catalog object ID.
/// - Creates or updates the mapping with the current Square IDs and
/// timestamps.
///
/// This avoids conflating local / WooCommerce product IDs with Square catalog
/// object IDs in mixed-source scenarios.
///
/// Returns a [SquareSyncResult] with [SquareSyncResult.catalogSynced] and
/// [SquareSyncResult.catalogFailed] populated.
Future<SquareSyncResult> syncCatalog() async {
List<ProductDraft> products;
try {
products = await _catalogRepository.getProductDrafts();
} catch (e) {
return SquareSyncResult(
catalogFailed: 1,
errors: [SyncError(itemId: 'catalog', message: e.toString())],
);
}
if (products.isEmpty) {
return const SquareSyncResult();
}
int synced = 0;
int failed = 0;
final errors = <SyncError>[];
for (final product in products) {
// Prefer the explicit squareCatalogObjectId; fall back to product.id
// only when the catalog repository is Square-backed (in which case
// product.id IS the Square catalog object ID).
final squareId = product.squareCatalogObjectId ?? product.id;
try {
final existing = await _mappingRepository.getMappingBySquareId(squareId);
final now = DateTime.now();
final mapping = existing != null
? existing.copyWith(squareCatalogObjectId: squareId, lastUpdated: now)
: ProductIdMapping(
localId: product.id,
squareCatalogObjectId: squareId,
lastUpdated: now,
);
await _mappingRepository.saveMapping(mapping);
synced++;
} catch (e) {
failed++;
errors.add(SyncError(itemId: product.id, message: e.toString()));
}
}
return SquareSyncResult(catalogSynced: synced, catalogFailed: failed, errors: errors);
}
/// Syncs inventory counts from Square for all mapped items that have a
/// [ProductIdMapping.squareVariationId].
///
/// Requires an [InventorySyncAdapter] to be provided at construction time.
/// If no adapter is provided, returns an empty [SquareSyncResult].
///
/// Returns a [SquareSyncResult] with [SquareSyncResult.inventorySynced] and
/// [SquareSyncResult.inventoryFailed] populated.
Future<SquareSyncResult> syncInventory() async {
if (_inventoryAdapter == null) {
return const SquareSyncResult();
}
List<ProductIdMapping> mappings;
try {
mappings = await _mappingRepository.getAllMappings();
} catch (e) {
return SquareSyncResult(
inventoryFailed: 1,
errors: [SyncError(itemId: 'mappings', message: e.toString())],
);
}
final syncable = mappings.where((m) => m.squareVariationId != null).toList();
if (syncable.isEmpty) {
return const SquareSyncResult();
}
int synced = 0;
int failed = 0;
final errors = <SyncError>[];
for (final mapping in syncable) {
try {
final success = await _inventoryAdapter.syncItem(mapping.localId, 0);
if (success) {
synced++;
} else {
failed++;
errors.add(SyncError(itemId: mapping.localId, message: 'syncItem returned false'));
}
} catch (e) {
failed++;
errors.add(SyncError(itemId: mapping.localId, message: e.toString()));
}
}
return SquareSyncResult(inventorySynced: synced, inventoryFailed: failed, errors: errors);
}
/// Runs [syncCatalog] followed by [syncInventory] and merges the results.
Future<SquareSyncResult> syncAll() async {
final catalogResult = await syncCatalog();
final inventoryResult = await syncInventory();
return catalogResult.merge(inventoryResult);
}
}

View File

@ -1,89 +0,0 @@
import 'package:flutter/foundation.dart';
import 'square_catalog_sync_service.dart';
/// Application-layer controller that wraps [SquareCatalogSyncService] and
/// exposes sync actions as async operations with observable state.
///
/// ## State exposed
///
/// - [isSyncing] `true` while any sync operation is in progress.
/// - [lastResult] the [SquareSyncResult] from the most recent completed sync,
/// or `null` if no sync has been run yet.
/// - [lastSyncedAt] the [DateTime] at which the last sync completed
/// successfully, or `null` if no sync has completed.
/// - [lastError] a human-readable error message if the last sync threw an
/// unexpected exception, or `null` if the last sync completed normally
/// (even with partial failures captured in [lastResult]).
///
/// ## Usage
///
/// ```dart
/// final controller = SquareSyncController(syncService: service);
/// await controller.syncAll();
/// print(controller.lastResult?.allSucceeded); // true / false
/// ```
class SquareSyncController extends ChangeNotifier {
final SquareCatalogSyncService _syncService;
SquareSyncController({required SquareCatalogSyncService syncService})
: _syncService = syncService;
// State
bool _isSyncing = false;
SquareSyncResult? _lastResult;
DateTime? _lastSyncedAt;
String? _lastError;
/// Whether a sync operation is currently in progress.
bool get isSyncing => _isSyncing;
/// The result of the most recently completed sync, or `null` if no sync has
/// been run yet.
SquareSyncResult? get lastResult => _lastResult;
/// The timestamp at which the last sync completed (regardless of success or
/// partial failure), or `null` if no sync has completed.
DateTime? get lastSyncedAt => _lastSyncedAt;
/// A human-readable error message if the last sync threw an unexpected
/// exception. `null` when the last sync completed normally (partial failures
/// are captured in [lastResult] instead).
String? get lastError => _lastError;
// Actions
/// Runs a full sync: catalog followed by inventory.
///
/// Sets [isSyncing] to `true` for the duration of the operation, then
/// updates [lastResult], [lastSyncedAt], and [lastError] on completion.
Future<void> syncAll() => _runSync(() => _syncService.syncAll());
/// Syncs only the product catalog (creates/updates [ProductIdMapping] records).
Future<void> syncCatalog() => _runSync(() => _syncService.syncCatalog());
/// Syncs only inventory counts from Square for all mapped items.
Future<void> syncInventory() => _runSync(() => _syncService.syncInventory());
// Internal
Future<void> _runSync(Future<SquareSyncResult> Function() action) async {
if (_isSyncing) return;
_isSyncing = true;
_lastError = null;
notifyListeners();
try {
final result = await action();
_lastResult = result;
_lastSyncedAt = DateTime.now();
} catch (e) {
_lastError = e.toString();
} finally {
_isSyncing = false;
notifyListeners();
}
}
}

View File

@ -1,94 +0,0 @@
import '../domain/product_id_mapping.dart';
import '../domain/product_id_mapping_repository.dart';
/// In-memory implementation of [ProductIdMappingRepository] for use in
/// `FAKE` mode and unit tests.
///
/// Seeded with mappings that correspond to the products in
/// [FakeProductPublishingRepository] and the inventory items in
/// [FakeInventoryRepository].
class FakeProductIdMappingRepository implements ProductIdMappingRepository {
final Map<String, ProductIdMapping> _store = {};
FakeProductIdMappingRepository() {
// Seed mappings for the 6 fake products.
// Products 13 have both WooCommerce and Square IDs (fully synced).
// Products 45 have WooCommerce IDs only (not yet pushed to Square).
// Product 6 has no channel IDs (draft, not synced anywhere).
final now = DateTime(2026, 7, 1);
final seeds = [
ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: now,
),
ProductIdMapping(
localId: '2',
wooCommerceId: '102',
squareCatalogObjectId: 'SQ-ITEM-002',
squareVariationId: 'SQ-VAR-002',
lastUpdated: now,
),
ProductIdMapping(
localId: '3',
wooCommerceId: '103',
squareCatalogObjectId: 'SQ-ITEM-003',
squareVariationId: 'SQ-VAR-003',
lastUpdated: now,
),
ProductIdMapping(localId: '4', wooCommerceId: '104', lastUpdated: now),
ProductIdMapping(localId: '5', wooCommerceId: '105', lastUpdated: now),
ProductIdMapping(localId: '6', lastUpdated: now),
];
for (final m in seeds) {
_store[m.localId] = m;
}
}
@override
Future<List<ProductIdMapping>> getAllMappings() async {
await Future<void>.delayed(const Duration(milliseconds: 50));
return List.unmodifiable(_store.values.toList());
}
@override
Future<ProductIdMapping?> getMappingByLocalId(String localId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
return _store[localId];
}
@override
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
try {
return _store.values.firstWhere((m) => m.wooCommerceId == wooCommerceId);
} catch (_) {
return null;
}
}
@override
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
try {
return _store.values.firstWhere((m) => m.squareCatalogObjectId == squareCatalogObjectId);
} catch (_) {
return null;
}
}
@override
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
_store[mapping.localId] = mapping;
return mapping;
}
@override
Future<void> deleteMapping(String localId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
_store.remove(localId);
}
}

View File

@ -1,322 +0,0 @@
import 'package:integrations/integrations.dart';
import '../domain/product_category.dart';
import '../domain/product_draft.dart';
import '../domain/product_image.dart';
import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart';
/// Square-backed implementation of [ProductPublishingRepository].
///
/// Reads and writes the product catalog via [SquareApiClient], mapping between
/// Square CatalogObject JSON and the [ProductDraft] domain model using
/// [SquareProductMapper].
///
/// ## Scope
///
/// This repository covers the Square catalog surface:
/// - [getProductDrafts] lists all ITEM-type catalog objects from Square
/// - [publishDraft] marks a product active (no-op for Square; Square items
/// are always visible once created); updates local cache status
/// - [updateProductStatus] archives or restores a catalog item via upsert
/// - [updateProductPrice] updates the first variation's price via upsert
/// - [updateProductName] updates the item name via upsert
/// - [updateProductDescription] updates the item description via upsert
/// - [updateProductCategory] updates the item category_id via upsert
/// - [createProduct] creates a new ITEM catalog object in Square
/// - [deleteProduct] deletes a catalog object from Square
///
/// ## Unsupported operations
///
/// The following [ProductPublishingRepository] methods are not supported by
/// Square's catalog model and throw [UnsupportedError]:
/// - [updateProductImages] Square image management uses a separate media API
/// - [getCategories] / [createCategory] / [updateCategory] / [deleteCategory]
/// Square categories are managed separately via CATEGORY-type catalog
/// objects; this is deferred to a future stage
/// - [getProductImages] Square does not expose image URLs via the catalog API
/// in the same way as WooCommerce
///
/// ## ID mapping
///
/// Square uses string IDs for catalog objects. The [ProductDraft.id] field
/// holds the Square catalog object ID (the ITEM-level ID, not the variation).
/// The variation ID is derived deterministically for upsert operations; in
/// production the [ProductIdMappingRepository] should be used to look up the
/// real variation ID.
///
/// ## Location
///
/// All inventory-related operations require a Square location ID, which is
/// provided at construction time via [locationId].
class SquareCatalogRepository implements ProductPublishingRepository {
final SquareApiClient _apiClient;
final SquareProductMapper _mapper;
/// The Square location ID used for variation pricing and inventory.
final String locationId;
/// In-memory cache of the last fetched product list.
///
/// Used to reconstruct full [ProductDraft] objects when performing partial
/// field updates (Square requires the full object for upsert).
final List<ProductDraft> _cache = [];
SquareCatalogRepository({
required SquareApiClient apiClient,
required this.locationId,
SquareProductMapper mapper = const SquareProductMapper(),
}) : _apiClient = apiClient,
_mapper = mapper;
// Read operations
@override
Future<List<ProductDraft>> getProductDrafts() async {
final rawObjects = await _apiClient.listCatalogItems(types: ['ITEM']);
final channelProducts = _mapper.fromSquareCatalogList(rawObjects.cast<Map<String, dynamic>>());
final drafts = channelProducts.map(_channelProductToDraft).toList();
_cache
..clear()
..addAll(drafts);
return List.unmodifiable(drafts);
}
// Status operations
@override
Future<ProductDraft> publishDraft(String id) async {
// Square items are always visible once created; publishing is a no-op.
// Update the local cache to reflect the published status.
return _updateCachedDraft(
id,
(draft) => draft.copyWith(status: PublishStatus.published, lastModified: DateTime.now()),
);
}
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) async {
final draft = _findCached(id);
final updated = draft.copyWith(status: status, lastModified: DateTime.now());
// Upsert to Square to keep the catalog in sync (name/price unchanged).
await _upsertDraft(updated);
_replaceCached(id, updated);
return updated;
}
// Field update operations
@override
Future<ProductDraft> updateProductPrice(String id, double price) async {
final draft = _findCached(id);
final updated = draft.copyWith(price: price, lastModified: DateTime.now());
await _upsertDraft(updated);
_replaceCached(id, updated);
return updated;
}
@override
Future<ProductDraft> updateProductName(String id, String name) async {
final draft = _findCached(id);
final updated = draft.copyWith(name: name, lastModified: DateTime.now());
await _upsertDraft(updated);
_replaceCached(id, updated);
return updated;
}
@override
Future<ProductDraft> updateProductDescription(String id, String description) async {
final draft = _findCached(id);
final updated = draft.copyWith(description: description, lastModified: DateTime.now());
await _upsertDraft(updated);
_replaceCached(id, updated);
return updated;
}
@override
Future<ProductDraft> updateProductCategory(String id, String category) async {
final draft = _findCached(id);
final updated = draft.copyWith(category: category, lastModified: DateTime.now());
await _upsertDraft(updated);
_replaceCached(id, updated);
return updated;
}
// Create / Delete
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) async {
final channelProduct = ChannelProduct(
id: '#new-item',
name: name,
price: price,
status: _publishStatusToSquareStatus(status),
description: description.isNotEmpty ? description : null,
sku: sku.isNotEmpty ? sku : null,
);
final squareObject = _mapper.toSquareCatalogObject(
channelProduct,
squareItemId: '#new-item',
squareVariationId: '#new-variation',
locationId: locationId,
);
final created = await _apiClient.upsertCatalogItem(squareObject);
final createdProduct = _mapper.fromSquareCatalogObject(created.cast<String, dynamic>());
if (createdProduct == null) {
throw StateError('Square returned an invalid catalog object after create');
}
final draft = _channelProductToDraft(
createdProduct,
).copyWith(description: description, sku: sku, status: status);
_cache.add(draft);
return draft;
}
@override
Future<void> deleteProduct(String id, {bool force = false}) async {
await _apiClient.deleteCatalogItem(id);
_cache.removeWhere((d) => d.id == id);
}
// Unsupported operations
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) {
throw UnsupportedError(
'SquareCatalogRepository does not support image management. '
'Use the Square media API directly.',
);
}
@override
Future<List<ProductCategory>> getCategories() {
throw UnsupportedError(
'SquareCatalogRepository does not support category listing. '
'Square categories are managed via CATEGORY-type catalog objects.',
);
}
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) {
throw UnsupportedError('SquareCatalogRepository does not support category creation.');
}
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) {
throw UnsupportedError('SquareCatalogRepository does not support category updates.');
}
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) {
throw UnsupportedError('SquareCatalogRepository does not support category deletion.');
}
@override
Future<List<ProductImage>> getProductImages(String id) {
throw UnsupportedError(
'SquareCatalogRepository does not support image retrieval. '
'Square image URLs are embedded in catalog objects.',
);
}
// Private helpers
/// Converts a [ChannelProduct] from the mapper to a [ProductDraft].
ProductDraft _channelProductToDraft(ChannelProduct channelProduct) {
return ProductDraft(
id: channelProduct.id,
name: channelProduct.name,
description: channelProduct.description ?? '',
price: channelProduct.price,
sku: channelProduct.sku ?? '',
category: channelProduct.category ?? '',
imageUrl: channelProduct.imageUrl ?? '',
status: _squareStatusToPublishStatus(channelProduct.status),
lastModified: DateTime.now(),
);
}
/// Converts a [ProductDraft] to a [ChannelProduct] for the mapper.
ChannelProduct _draftToChannelProduct(ProductDraft draft) {
return ChannelProduct(
id: draft.id,
name: draft.name,
price: draft.price,
status: _publishStatusToSquareStatus(draft.status),
description: draft.description.isNotEmpty ? draft.description : null,
sku: draft.sku.isNotEmpty ? draft.sku : null,
);
}
/// Upserts a [ProductDraft] to Square using the mapper.
Future<void> _upsertDraft(ProductDraft draft) async {
final channelProduct = _draftToChannelProduct(draft);
final squareObject = _mapper.toSquareCatalogObject(
channelProduct,
squareItemId: draft.id,
squareVariationId: _variationIdFor(draft.id),
locationId: locationId,
);
await _apiClient.upsertCatalogItem(squareObject);
}
/// Returns the Square variation ID for a given item ID.
///
/// Uses a deterministic temporary variation ID based on the item ID.
/// In production, the [ProductIdMappingRepository] should be used to look
/// up the real variation ID stored from the initial catalog sync.
String _variationIdFor(String itemId) => '#var-$itemId';
/// Finds a cached [ProductDraft] by [id], throwing [StateError] if not found.
ProductDraft _findCached(String id) {
try {
return _cache.firstWhere((d) => d.id == id);
} catch (_) {
throw StateError('Product $id not found in cache. Call getProductDrafts() first.');
}
}
/// Replaces a cached [ProductDraft] by [id].
void _replaceCached(String id, ProductDraft updated) {
final index = _cache.indexWhere((d) => d.id == id);
if (index != -1) _cache[index] = updated;
}
/// Updates a cached draft using [updater] and returns the result.
ProductDraft _updateCachedDraft(String id, ProductDraft Function(ProductDraft) updater) {
final draft = _findCached(id);
final updated = updater(draft);
_replaceCached(id, updated);
return updated;
}
/// Maps a Square status string to a [PublishStatus].
static PublishStatus _squareStatusToPublishStatus(String status) {
return switch (status) {
'archived' => PublishStatus.unpublished,
_ => PublishStatus.published,
};
}
/// Maps a [PublishStatus] to a Square status string.
static String _publishStatusToSquareStatus(PublishStatus status) {
return switch (status) {
PublishStatus.unpublished => 'archived',
_ => 'active',
};
}
}

View File

@ -12,17 +12,6 @@ class ProductDraft {
final PublishStatus status;
final DateTime lastModified;
/// The Square catalog object ID for this product, if it has been synced to
/// Square.
///
/// This is distinct from [id], which is the local / WooCommerce product ID.
/// Keeping these separate prevents the [SquareCatalogSyncService] from
/// conflating local IDs with Square catalog object IDs when reconciling
/// [ProductIdMapping] records.
///
/// `null` when the product has not yet been synced to Square.
final String? squareCatalogObjectId;
const ProductDraft({
required this.id,
required this.name,
@ -33,15 +22,9 @@ class ProductDraft {
required this.imageUrl,
required this.status,
required this.lastModified,
this.squareCatalogObjectId,
});
/// Returns a copy of this [ProductDraft] with the given fields replaced.
///
/// To explicitly clear [squareCatalogObjectId], pass `squareCatalogObjectId: null`
/// the field uses a sentinel pattern: if the named parameter is omitted the
/// existing value is preserved; if `null` is passed explicitly the field is
/// cleared.
ProductDraft copyWith({
String? id,
String? name,
@ -52,7 +35,6 @@ class ProductDraft {
String? imageUrl,
PublishStatus? status,
DateTime? lastModified,
Object? squareCatalogObjectId = _sentinel,
}) {
return ProductDraft(
id: id ?? this.id,
@ -64,13 +46,6 @@ class ProductDraft {
imageUrl: imageUrl ?? this.imageUrl,
status: status ?? this.status,
lastModified: lastModified ?? this.lastModified,
squareCatalogObjectId: squareCatalogObjectId == _sentinel
? this.squareCatalogObjectId
: squareCatalogObjectId as String?,
);
}
}
/// Sentinel value used by [ProductDraft.copyWith] to distinguish "not provided"
/// from an explicit `null` for [ProductDraft.squareCatalogObjectId].
const Object _sentinel = Object();

View File

@ -1,83 +0,0 @@
/// A cross-channel product ID mapping that links a product's identity across
/// WooCommerce and Square.
///
/// The app treats WooCommerce as the primary catalog source. When a product is
/// synced to Square, the resulting Square catalog object ID is stored here so
/// that future sync operations can locate the correct remote record without
/// performing an expensive search.
///
/// Either [wooCommerceId] or [squareCatalogObjectId] may be `null` when a
/// product exists in only one channel (e.g. a Square-only in-person item that
/// has not yet been published to WooCommerce, or a WooCommerce product that has
/// not yet been pushed to Square).
class ProductIdMapping {
/// The local app-internal product identifier (matches [ProductDraft.id]).
final String localId;
/// The WooCommerce product ID (integer stored as string for consistency).
///
/// `null` if this product has no WooCommerce counterpart.
final String? wooCommerceId;
/// The Square CatalogObject ID for the ITEM-level object.
///
/// `null` if this product has not been pushed to Square.
final String? squareCatalogObjectId;
/// The Square CatalogObject ID for the primary ITEM_VARIATION.
///
/// Square products have at least one variation. This stores the ID of the
/// default/primary variation so inventory counts can be fetched directly.
///
/// `null` if the product has not been pushed to Square.
final String? squareVariationId;
/// The timestamp when this mapping was last updated.
final DateTime lastUpdated;
const ProductIdMapping({
required this.localId,
this.wooCommerceId,
this.squareCatalogObjectId,
this.squareVariationId,
required this.lastUpdated,
});
/// Returns a copy of this [ProductIdMapping] with the given fields replaced.
ProductIdMapping copyWith({
String? localId,
String? wooCommerceId,
String? squareCatalogObjectId,
String? squareVariationId,
DateTime? lastUpdated,
}) {
return ProductIdMapping(
localId: localId ?? this.localId,
wooCommerceId: wooCommerceId ?? this.wooCommerceId,
squareCatalogObjectId: squareCatalogObjectId ?? this.squareCatalogObjectId,
squareVariationId: squareVariationId ?? this.squareVariationId,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProductIdMapping &&
runtimeType == other.runtimeType &&
localId == other.localId &&
wooCommerceId == other.wooCommerceId &&
squareCatalogObjectId == other.squareCatalogObjectId &&
squareVariationId == other.squareVariationId &&
lastUpdated == other.lastUpdated;
@override
int get hashCode =>
Object.hash(localId, wooCommerceId, squareCatalogObjectId, squareVariationId, lastUpdated);
@override
String toString() =>
'ProductIdMapping(localId: $localId, wooCommerceId: $wooCommerceId, '
'squareCatalogObjectId: $squareCatalogObjectId, '
'squareVariationId: $squareVariationId)';
}

View File

@ -1,35 +0,0 @@
import 'product_id_mapping.dart';
/// Contract for persisting and querying cross-channel product ID mappings.
///
/// Implementations may store mappings in memory (fake), SQLite (local
/// persistent), or a remote database. The contract is channel-agnostic
/// callers look up by local ID, WooCommerce ID, or Square catalog object ID.
abstract class ProductIdMappingRepository {
/// Returns all stored [ProductIdMapping] records.
Future<List<ProductIdMapping>> getAllMappings();
/// Returns the [ProductIdMapping] for the given [localId], or `null` if
/// no mapping exists for that local product.
Future<ProductIdMapping?> getMappingByLocalId(String localId);
/// Returns the [ProductIdMapping] whose [ProductIdMapping.wooCommerceId]
/// matches [wooCommerceId], or `null` if not found.
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId);
/// Returns the [ProductIdMapping] whose
/// [ProductIdMapping.squareCatalogObjectId] matches [squareCatalogObjectId],
/// or `null` if not found.
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId);
/// Saves a [ProductIdMapping].
///
/// If a mapping with the same [ProductIdMapping.localId] already exists it
/// is replaced; otherwise a new record is inserted.
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping);
/// Deletes the mapping for the given [localId].
///
/// No-ops silently if no mapping exists for that ID.
Future<void> deleteMapping(String localId);
}

View File

@ -1,278 +0,0 @@
import 'package:flutter/material.dart';
import '../../application/square_sync_controller.dart';
import '../../application/square_catalog_sync_service.dart';
/// A panel that displays the current Square sync status and provides a
/// "Sync Now" button to trigger a full catalog + inventory sync.
///
/// Listens to [SquareSyncController] and rebuilds whenever sync state changes.
///
/// ## Usage
///
/// ```dart
/// SquareSyncStatusPanel(controller: squareSyncController)
/// ```
class SquareSyncStatusPanel extends StatefulWidget {
final SquareSyncController controller;
const SquareSyncStatusPanel({super.key, required this.controller});
@override
State<SquareSyncStatusPanel> createState() => _SquareSyncStatusPanelState();
}
class _SquareSyncStatusPanelState extends State<SquareSyncStatusPanel> {
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
}
@override
void didUpdateWidget(SquareSyncStatusPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
super.dispose();
}
void _onControllerChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.sync, size: 20),
const SizedBox(width: 8),
Text('Square Sync', style: theme.textTheme.titleMedium),
const Spacer(),
_SyncStatusChip(controller: controller),
],
),
const SizedBox(height: 16),
_LastSyncedRow(controller: controller),
const SizedBox(height: 12),
if (controller.lastResult != null) ...[
_SyncResultSummary(result: controller.lastResult!),
const SizedBox(height: 12),
],
if (controller.lastError != null) ...[
_ErrorBanner(message: controller.lastError!),
const SizedBox(height: 12),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: controller.isSyncing ? null : () => controller.syncAll(),
icon: controller.isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.sync),
label: Text(controller.isSyncing ? 'Syncing…' : 'Sync Now'),
),
),
],
),
),
);
}
}
/// Small chip showing the current sync phase.
class _SyncStatusChip extends StatelessWidget {
final SquareSyncController controller;
const _SyncStatusChip({required this.controller});
@override
Widget build(BuildContext context) {
final Color bg;
final Color fg;
final String label;
if (controller.isSyncing) {
bg = Colors.blue.shade100;
fg = Colors.blue.shade900;
label = 'Syncing';
} else if (controller.lastError != null) {
bg = Colors.red.shade100;
fg = Colors.red.shade900;
label = 'Error';
} else if (controller.lastResult != null) {
if (controller.lastResult!.allSucceeded) {
bg = Colors.green.shade100;
fg = Colors.green.shade900;
label = 'OK';
} else {
bg = Colors.orange.shade100;
fg = Colors.orange.shade900;
label = 'Partial';
}
} else {
bg = Colors.grey.shade200;
fg = Colors.grey.shade700;
label = 'Never synced';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.bold),
),
);
}
}
/// Row showing the last synced timestamp.
class _LastSyncedRow extends StatelessWidget {
final SquareSyncController controller;
const _LastSyncedRow({required this.controller});
@override
Widget build(BuildContext context) {
final ts = controller.lastSyncedAt;
final label = ts == null ? 'Never' : _formatDateTime(ts);
return Row(
children: [
Icon(Icons.access_time, size: 16, color: Colors.grey.shade600),
const SizedBox(width: 6),
Text(
'Last synced: $label',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade700),
),
],
);
}
static String _formatDateTime(DateTime dt) {
final local = dt.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final m = local.minute.toString().padLeft(2, '0');
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} $h:$m';
}
}
/// Summary row showing catalog/inventory synced and failed counts.
class _SyncResultSummary extends StatelessWidget {
final SquareSyncResult result;
const _SyncResultSummary({required this.result});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1),
const SizedBox(height: 8),
Text('Last sync result', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 6),
_ResultRow(label: 'Catalog', synced: result.catalogSynced, failed: result.catalogFailed),
const SizedBox(height: 4),
_ResultRow(
label: 'Inventory',
synced: result.inventorySynced,
failed: result.inventoryFailed,
),
if (result.errors.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'${result.errors.length} error(s) — see logs for details',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade800),
),
],
],
);
}
}
class _ResultRow extends StatelessWidget {
final String label;
final int synced;
final int failed;
const _ResultRow({required this.label, required this.synced, required this.failed});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(width: 72, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
Icon(Icons.check_circle_outline, size: 14, color: Colors.green.shade700),
const SizedBox(width: 4),
Text('$synced', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(width: 12),
if (failed > 0) ...[
Icon(Icons.error_outline, size: 14, color: Colors.red.shade700),
const SizedBox(width: 4),
Text(
'$failed failed',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade700),
),
],
],
);
}
}
/// Red banner shown when the last sync threw an unexpected exception.
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade200),
borderRadius: BorderRadius.circular(6),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 16, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800),
),
),
],
),
);
}
}

View File

@ -1,248 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
void main() {
// ProductIdMapping domain model
group('ProductIdMapping', () {
final baseTime = DateTime(2026, 7, 1);
test('stores all fields', () {
final mapping = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
expect(mapping.localId, '1');
expect(mapping.wooCommerceId, '101');
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
expect(mapping.squareVariationId, 'SQ-VAR-001');
expect(mapping.lastUpdated, baseTime);
});
test('optional fields default to null', () {
final mapping = ProductIdMapping(localId: '6', lastUpdated: baseTime);
expect(mapping.wooCommerceId, isNull);
expect(mapping.squareCatalogObjectId, isNull);
expect(mapping.squareVariationId, isNull);
});
test('copyWith replaces specified fields', () {
final original = ProductIdMapping(localId: '1', wooCommerceId: '101', lastUpdated: baseTime);
final updated = original.copyWith(
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: DateTime(2026, 8, 1),
);
expect(updated.localId, '1');
expect(updated.wooCommerceId, '101');
expect(updated.squareCatalogObjectId, 'SQ-ITEM-001');
expect(updated.squareVariationId, 'SQ-VAR-001');
expect(updated.lastUpdated, DateTime(2026, 8, 1));
});
test('copyWith preserves unchanged fields', () {
final original = ProductIdMapping(
localId: '2',
wooCommerceId: '102',
squareCatalogObjectId: 'SQ-ITEM-002',
squareVariationId: 'SQ-VAR-002',
lastUpdated: baseTime,
);
final copy = original.copyWith();
expect(copy.localId, original.localId);
expect(copy.wooCommerceId, original.wooCommerceId);
expect(copy.squareCatalogObjectId, original.squareCatalogObjectId);
expect(copy.squareVariationId, original.squareVariationId);
expect(copy.lastUpdated, original.lastUpdated);
});
test('equality holds for identical values', () {
final a = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
final b = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('equality fails when localId differs', () {
final a = ProductIdMapping(localId: '1', lastUpdated: baseTime);
final b = ProductIdMapping(localId: '2', lastUpdated: baseTime);
expect(a, isNot(equals(b)));
});
test('toString includes localId and channel IDs', () {
final mapping = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
final str = mapping.toString();
expect(str, contains('1'));
expect(str, contains('101'));
expect(str, contains('SQ-ITEM-001'));
});
});
// FakeProductIdMappingRepository
group('FakeProductIdMappingRepository', () {
late FakeProductIdMappingRepository repo;
setUp(() {
repo = FakeProductIdMappingRepository();
});
test('getAllMappings returns 6 seeded mappings', () async {
final mappings = await repo.getAllMappings();
expect(mappings.length, 6);
});
test('getMappingByLocalId returns correct mapping', () async {
final mapping = await repo.getMappingByLocalId('1');
expect(mapping, isNotNull);
expect(mapping!.localId, '1');
expect(mapping.wooCommerceId, '101');
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
expect(mapping.squareVariationId, 'SQ-VAR-001');
});
test('getMappingByLocalId returns null for unknown id', () async {
final mapping = await repo.getMappingByLocalId('999');
expect(mapping, isNull);
});
test('getMappingByWooCommerceId returns correct mapping', () async {
final mapping = await repo.getMappingByWooCommerceId('102');
expect(mapping, isNotNull);
expect(mapping!.localId, '2');
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-002');
});
test('getMappingByWooCommerceId returns null for unknown id', () async {
final mapping = await repo.getMappingByWooCommerceId('999');
expect(mapping, isNull);
});
test('getMappingBySquareId returns correct mapping', () async {
final mapping = await repo.getMappingBySquareId('SQ-ITEM-003');
expect(mapping, isNotNull);
expect(mapping!.localId, '3');
expect(mapping.wooCommerceId, '103');
});
test('getMappingBySquareId returns null for unknown id', () async {
final mapping = await repo.getMappingBySquareId('SQ-ITEM-UNKNOWN');
expect(mapping, isNull);
});
test('products 4 and 5 have WooCommerce IDs but no Square IDs', () async {
final m4 = await repo.getMappingByLocalId('4');
final m5 = await repo.getMappingByLocalId('5');
expect(m4!.wooCommerceId, '104');
expect(m4.squareCatalogObjectId, isNull);
expect(m5!.wooCommerceId, '105');
expect(m5.squareCatalogObjectId, isNull);
});
test('product 6 has no channel IDs', () async {
final m6 = await repo.getMappingByLocalId('6');
expect(m6!.wooCommerceId, isNull);
expect(m6.squareCatalogObjectId, isNull);
expect(m6.squareVariationId, isNull);
});
test('saveMapping inserts a new mapping', () async {
final newMapping = ProductIdMapping(
localId: '99',
wooCommerceId: '999',
squareCatalogObjectId: 'SQ-ITEM-999',
squareVariationId: 'SQ-VAR-999',
lastUpdated: DateTime(2026, 7, 25),
);
final saved = await repo.saveMapping(newMapping);
expect(saved.localId, '99');
final retrieved = await repo.getMappingByLocalId('99');
expect(retrieved, isNotNull);
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-999');
});
test('saveMapping replaces an existing mapping', () async {
final updated = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001-UPDATED',
squareVariationId: 'SQ-VAR-001-UPDATED',
lastUpdated: DateTime(2026, 7, 25),
);
await repo.saveMapping(updated);
final retrieved = await repo.getMappingByLocalId('1');
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-001-UPDATED');
expect(retrieved.squareVariationId, 'SQ-VAR-001-UPDATED');
});
test('saveMapping does not increase count when replacing', () async {
final before = await repo.getAllMappings();
await repo.saveMapping(ProductIdMapping(localId: '1', lastUpdated: DateTime(2026, 7, 25)));
final after = await repo.getAllMappings();
expect(after.length, before.length);
});
test('deleteMapping removes the mapping', () async {
await repo.deleteMapping('1');
final mapping = await repo.getMappingByLocalId('1');
expect(mapping, isNull);
});
test('deleteMapping reduces total count by one', () async {
final before = await repo.getAllMappings();
await repo.deleteMapping('2');
final after = await repo.getAllMappings();
expect(after.length, before.length - 1);
});
test('deleteMapping is a no-op for unknown id', () async {
final before = await repo.getAllMappings();
await repo.deleteMapping('999'); // does not exist
final after = await repo.getAllMappings();
expect(after.length, before.length);
});
test('getAllMappings returns unmodifiable list', () async {
final mappings = await repo.getAllMappings();
final extra = ProductIdMapping(localId: 'x', lastUpdated: DateTime(2026, 1, 1));
expect(() => (mappings as dynamic).add(extra), throwsUnsupportedError);
});
});
}

View File

@ -1,514 +0,0 @@
import 'dart:convert';
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';
import 'package:integrations/integrations.dart';
// Test helpers
/// Builds a minimal Square ITEM CatalogObject JSON map.
Map<String, dynamic> _squareItem({
String id = 'SQ-ITEM-001',
String name = 'Floral Bowl Cozy',
int priceCents = 1299,
String sku = 'BC-FLR-001',
String? description,
bool isDeleted = false,
}) {
return {
'type': 'ITEM',
'id': id,
if (isDeleted) 'is_deleted': true,
'item_data': {
'name': name,
'description': ?description,
'variations': [
{
'type': 'ITEM_VARIATION',
'id': '${id}_VAR',
'item_variation_data': {
'name': 'Regular',
'price_money': {'amount': priceCents, 'currency': 'USD'},
'sku': sku,
},
},
],
},
};
}
/// Builds a mock [http.Client] that returns [body] with [statusCode] for all
/// requests.
http.Client _mockClient(String body, {int statusCode = 200}) {
return MockClient((_) async => http.Response(body, statusCode));
}
/// Builds a [SquareCatalogRepository] backed by a mock HTTP client.
SquareCatalogRepository _repo(http.Client client) {
final apiClient = SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: client,
);
return SquareCatalogRepository(apiClient: apiClient, locationId: 'LOC-001');
}
/// Seeds the repository cache by calling [getProductDrafts] with a mock
/// response containing [items].
Future<SquareCatalogRepository> _seededRepo(List<Map<String, dynamic>> items) async {
final listBody = jsonEncode({'objects': items});
final repo = _repo(_mockClient(listBody));
await repo.getProductDrafts();
return repo;
}
void main() {
group('SquareCatalogRepository', () {
// getProductDrafts
group('getProductDrafts', () {
test('returns empty list when Square returns no objects', () async {
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
final drafts = await repo.getProductDrafts();
expect(drafts, isEmpty);
});
test('maps a single Square ITEM to a ProductDraft', () async {
final item = _squareItem(
id: 'SQ-001',
name: 'Bowl Cozy',
priceCents: 1299,
sku: 'BC-001',
description: 'A cozy for bowls',
);
final repo = _repo(
_mockClient(
jsonEncode({
'objects': [item],
}),
),
);
final drafts = await repo.getProductDrafts();
expect(drafts, hasLength(1));
expect(drafts.first.id, 'SQ-001');
expect(drafts.first.name, 'Bowl Cozy');
expect(drafts.first.price, closeTo(12.99, 0.001));
expect(drafts.first.sku, 'BC-001');
expect(drafts.first.description, 'A cozy for bowls');
expect(drafts.first.status, PublishStatus.published);
});
test('maps is_deleted=true to PublishStatus.unpublished', () async {
final item = _squareItem(id: 'SQ-002', isDeleted: true);
final repo = _repo(
_mockClient(
jsonEncode({
'objects': [item],
}),
),
);
final drafts = await repo.getProductDrafts();
expect(drafts.first.status, PublishStatus.unpublished);
});
test('maps multiple items and skips non-ITEM types', () async {
final objects = [
_squareItem(id: 'SQ-001', name: 'Item A'),
{'type': 'CATEGORY', 'id': 'CAT-001', 'category_data': {}},
_squareItem(id: 'SQ-002', name: 'Item B'),
];
final repo = _repo(_mockClient(jsonEncode({'objects': objects})));
final drafts = await repo.getProductDrafts();
expect(drafts, hasLength(2));
expect(drafts.map((d) => d.id), containsAll(['SQ-001', 'SQ-002']));
});
test('populates cache for subsequent field updates', () async {
final item = _squareItem(id: 'SQ-001', name: 'Cached Item');
final repo = _repo(
_mockClient(
jsonEncode({
'objects': [item],
}),
),
);
await repo.getProductDrafts();
// If cache is populated, updateProductName should not throw StateError.
final upsertBody = jsonEncode({
'catalog_object': _squareItem(id: 'SQ-001', name: 'Updated Item'),
});
// Seed the cache manually by calling getProductDrafts first.
final listBody = jsonEncode({
'objects': [item],
});
final seededRepo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(listBody, 200);
}
return http.Response(upsertBody, 200);
}),
),
locationId: 'LOC-001',
);
await seededRepo.getProductDrafts();
final updated = await seededRepo.updateProductName('SQ-001', 'Updated Item');
expect(updated.name, 'Updated Item');
});
});
// publishDraft
group('publishDraft', () {
test('returns draft with published status (no-op for Square)', () async {
final repo = await _seededRepo([_squareItem(id: 'SQ-001')]);
final result = await repo.publishDraft('SQ-001');
expect(result.status, PublishStatus.published);
expect(result.id, 'SQ-001');
});
test('throws StateError when product not in cache', () async {
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
expect(() => repo.publishDraft('UNKNOWN'), throwsA(isA<StateError>()));
});
});
// updateProductStatus
group('updateProductStatus', () {
test('updates status to unpublished and upserts to Square', () async {
final upsertResponse = jsonEncode({
'catalog_object': _squareItem(id: 'SQ-001', isDeleted: true),
});
final repo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(
jsonEncode({
'objects': [_squareItem(id: 'SQ-001')],
}),
200,
);
}
return http.Response(upsertResponse, 200);
}),
),
locationId: 'LOC-001',
);
await repo.getProductDrafts();
final result = await repo.updateProductStatus('SQ-001', PublishStatus.unpublished);
expect(result.status, PublishStatus.unpublished);
});
test('throws StateError when product not in cache', () async {
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
expect(
() => repo.updateProductStatus('UNKNOWN', PublishStatus.draft),
throwsA(isA<StateError>()),
);
});
});
// updateProductPrice
group('updateProductPrice', () {
test('updates price and upserts to Square', () async {
final upsertResponse = jsonEncode({
'catalog_object': _squareItem(id: 'SQ-001', priceCents: 1999),
});
final repo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(
jsonEncode({
'objects': [_squareItem(id: 'SQ-001')],
}),
200,
);
}
return http.Response(upsertResponse, 200);
}),
),
locationId: 'LOC-001',
);
await repo.getProductDrafts();
final result = await repo.updateProductPrice('SQ-001', 19.99);
expect(result.price, closeTo(19.99, 0.001));
});
test('throws StateError when product not in cache', () async {
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
expect(() => repo.updateProductPrice('UNKNOWN', 9.99), throwsA(isA<StateError>()));
});
});
// updateProductName
group('updateProductName', () {
test('updates name and upserts to Square', () async {
final upsertResponse = jsonEncode({
'catalog_object': _squareItem(id: 'SQ-001', name: 'New Name'),
});
final repo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(
jsonEncode({
'objects': [_squareItem(id: 'SQ-001')],
}),
200,
);
}
return http.Response(upsertResponse, 200);
}),
),
locationId: 'LOC-001',
);
await repo.getProductDrafts();
final result = await repo.updateProductName('SQ-001', 'New Name');
expect(result.name, 'New Name');
});
});
// updateProductDescription
group('updateProductDescription', () {
test('updates description and upserts to Square', () async {
final upsertResponse = jsonEncode({
'catalog_object': _squareItem(id: 'SQ-001', description: 'New desc'),
});
final repo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(
jsonEncode({
'objects': [_squareItem(id: 'SQ-001')],
}),
200,
);
}
return http.Response(upsertResponse, 200);
}),
),
locationId: 'LOC-001',
);
await repo.getProductDrafts();
final result = await repo.updateProductDescription('SQ-001', 'New desc');
expect(result.description, 'New desc');
});
});
// updateProductCategory
group('updateProductCategory', () {
test('updates category and upserts to Square', () async {
final upsertResponse = jsonEncode({'catalog_object': _squareItem(id: 'SQ-001')});
final repo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(
jsonEncode({
'objects': [_squareItem(id: 'SQ-001')],
}),
200,
);
}
return http.Response(upsertResponse, 200);
}),
),
locationId: 'LOC-001',
);
await repo.getProductDrafts();
final result = await repo.updateProductCategory('SQ-001', 'Jewelry');
expect(result.category, 'Jewelry');
});
});
// createProduct
group('createProduct', () {
test('creates a product and returns a ProductDraft', () async {
final createdItem = _squareItem(
id: 'SQ-NEW-001',
name: 'New Bowl',
priceCents: 2500,
sku: 'NB-001',
description: 'A new bowl',
);
final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem})));
final draft = await repo.createProduct(
name: 'New Bowl',
price: 25.00,
description: 'A new bowl',
sku: 'NB-001',
);
expect(draft.id, 'SQ-NEW-001');
expect(draft.name, 'New Bowl');
expect(draft.price, closeTo(25.00, 0.001));
expect(draft.description, 'A new bowl');
expect(draft.sku, 'NB-001');
});
test('applies draft status from parameter', () async {
final createdItem = _squareItem(id: 'SQ-NEW-002', name: 'Draft Item');
final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem})));
final draft = await repo.createProduct(
name: 'Draft Item',
price: 10.00,
status: PublishStatus.draft,
);
expect(draft.status, PublishStatus.draft);
});
test('throws StateError when Square returns invalid catalog object', () async {
// Return a non-ITEM type so mapper returns null.
final badResponse = jsonEncode({
'catalog_object': {'type': 'CATEGORY', 'id': 'CAT-001'},
});
final repo = _repo(_mockClient(badResponse));
expect(() => repo.createProduct(name: 'Bad', price: 1.00), throwsA(isA<StateError>()));
});
test('adds created product to cache', () async {
final createdItem = _squareItem(id: 'SQ-NEW-003', name: 'Cached New');
final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem})));
await repo.createProduct(name: 'Cached New', price: 5.00);
// publishDraft should succeed (product is in cache).
final published = await repo.publishDraft('SQ-NEW-003');
expect(published.id, 'SQ-NEW-003');
});
});
// deleteProduct
group('deleteProduct', () {
test('deletes product from Square and removes from cache', () async {
final deleteResponse = jsonEncode({
'deleted_object_ids': ['SQ-001'],
});
final repo = SquareCatalogRepository(
apiClient: SquareApiClient.sandbox(
accessToken: 'test-token',
locationId: 'LOC-001',
httpClient: MockClient((req) async {
if (req.url.path.contains('list')) {
return http.Response(
jsonEncode({
'objects': [_squareItem(id: 'SQ-001')],
}),
200,
);
}
return http.Response(deleteResponse, 200);
}),
),
locationId: 'LOC-001',
);
await repo.getProductDrafts();
await repo.deleteProduct('SQ-001');
// After deletion, publishDraft should throw StateError (not in cache).
expect(() => repo.publishDraft('SQ-001'), throwsA(isA<StateError>()));
});
});
// Unsupported operations
group('unsupported operations', () {
late SquareCatalogRepository repo;
setUp(() {
repo = _repo(_mockClient(jsonEncode({'objects': []})));
});
test('updateProductImages throws UnsupportedError', () {
expect(() => repo.updateProductImages('SQ-001', [1, 2]), throwsA(isA<UnsupportedError>()));
});
test('getCategories throws UnsupportedError', () {
expect(() => repo.getCategories(), throwsA(isA<UnsupportedError>()));
});
test('createCategory throws UnsupportedError', () {
expect(() => repo.createCategory(name: 'Jewelry'), throwsA(isA<UnsupportedError>()));
});
test('updateCategory throws UnsupportedError', () {
expect(() => repo.updateCategory(1, name: 'Updated'), throwsA(isA<UnsupportedError>()));
});
test('deleteCategory throws UnsupportedError', () {
expect(() => repo.deleteCategory(1), throwsA(isA<UnsupportedError>()));
});
test('getProductImages throws UnsupportedError', () {
expect(() => repo.getProductImages('SQ-001'), throwsA(isA<UnsupportedError>()));
});
});
// Status mapping
group('status mapping', () {
test('active Square status maps to PublishStatus.published', () async {
final item = _squareItem(id: 'SQ-001', isDeleted: false);
final repo = _repo(
_mockClient(
jsonEncode({
'objects': [item],
}),
),
);
final drafts = await repo.getProductDrafts();
expect(drafts.first.status, PublishStatus.published);
});
test('is_deleted=true maps to PublishStatus.unpublished', () async {
final item = _squareItem(id: 'SQ-001', isDeleted: true);
final repo = _repo(
_mockClient(
jsonEncode({
'objects': [item],
}),
),
);
final drafts = await repo.getProductDrafts();
expect(drafts.first.status, PublishStatus.unpublished);
});
});
// SquareApiException propagation
group('SquareApiException propagation', () {
test('getProductDrafts propagates SquareApiException on 401', () async {
final repo = _repo(_mockClient('{"errors":[]}', statusCode: 401));
expect(() => repo.getProductDrafts(), throwsA(isA<SquareApiException>()));
});
});
});
}

View File

@ -1,605 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
/// A [ProductPublishingRepository] stub that returns a fixed list of drafts.
class _FakeCatalogRepository implements ProductPublishingRepository {
final List<ProductDraft> drafts;
bool shouldThrow;
_FakeCatalogRepository({this.drafts = const [], this.shouldThrow = false});
@override
Future<List<ProductDraft>> getProductDrafts() async {
if (shouldThrow) throw Exception('catalog fetch failed');
return List.unmodifiable(drafts);
}
// Unsupported stubs not exercised by the sync service
@override
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductPrice(String id, double price) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductDescription(String id, String description) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductCategory(String id, String category) =>
throw UnimplementedError();
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) => throw UnimplementedError();
@override
Future<void> deleteProduct(String id, {bool force = false}) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) =>
throw UnimplementedError();
@override
Future<List<ProductImage>> getProductImages(String id) => throw UnimplementedError();
@override
Future<List<ProductCategory>> getCategories() => throw UnimplementedError();
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) =>
throw UnimplementedError();
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) =>
throw UnimplementedError();
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) => throw UnimplementedError();
}
/// A [ProductIdMappingRepository] backed by an in-memory map.
class _InMemoryMappingRepository implements ProductIdMappingRepository {
final Map<String, ProductIdMapping> _store = {};
bool shouldThrowOnSave;
bool shouldThrowOnGetAll;
_InMemoryMappingRepository({this.shouldThrowOnSave = false, this.shouldThrowOnGetAll = false});
@override
Future<List<ProductIdMapping>> getAllMappings() async {
if (shouldThrowOnGetAll) throw Exception('getAllMappings failed');
return List.unmodifiable(_store.values.toList());
}
@override
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => _store[localId];
@override
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async {
return _store.values.where((m) => m.wooCommerceId == wooCommerceId).firstOrNull;
}
@override
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) async {
return _store.values.where((m) => m.squareCatalogObjectId == squareCatalogObjectId).firstOrNull;
}
@override
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
if (shouldThrowOnSave) throw Exception('saveMapping failed');
_store[mapping.localId] = mapping;
return mapping;
}
@override
Future<void> deleteMapping(String localId) async {
_store.remove(localId);
}
/// Seed a mapping directly (bypasses shouldThrowOnSave).
void seed(ProductIdMapping mapping) => _store[mapping.localId] = mapping;
int get count => _store.length;
}
/// A simple [InventorySyncAdapter] that records calls and returns a
/// configurable result.
class _FakeInventoryAdapter implements InventorySyncAdapter {
final Map<String, bool> _results;
final List<String> calledWith = [];
_FakeInventoryAdapter({Map<String, bool>? results}) : _results = results ?? {};
@override
Future<bool> syncItem(String itemId, int remoteQuantity) async {
calledWith.add(itemId);
return _results[itemId] ?? true;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProductDraft _draft(String id, {String name = 'Product'}) => ProductDraft(
id: id,
name: name,
description: '',
price: 10.0,
sku: '',
category: '',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026, 1, 1),
);
ProductIdMapping _mapping(
String localId, {
String? squareCatalogObjectId,
String? squareVariationId,
}) => ProductIdMapping(
localId: localId,
squareCatalogObjectId: squareCatalogObjectId,
squareVariationId: squareVariationId,
lastUpdated: DateTime(2026, 1, 1),
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// -------------------------------------------------------------------------
// SyncError
// -------------------------------------------------------------------------
group('SyncError', () {
test('stores itemId and message', () {
const e = SyncError(itemId: 'abc', message: 'oops');
expect(e.itemId, 'abc');
expect(e.message, 'oops');
});
test('equality', () {
const a = SyncError(itemId: 'x', message: 'm');
const b = SyncError(itemId: 'x', message: 'm');
const c = SyncError(itemId: 'y', message: 'm');
expect(a, equals(b));
expect(a, isNot(equals(c)));
});
test('toString includes itemId and message', () {
const e = SyncError(itemId: 'id1', message: 'fail');
expect(e.toString(), contains('id1'));
expect(e.toString(), contains('fail'));
});
});
// -------------------------------------------------------------------------
// SquareSyncResult
// -------------------------------------------------------------------------
group('SquareSyncResult', () {
test('default constructor has zero counts and empty errors', () {
const r = SquareSyncResult();
expect(r.catalogSynced, 0);
expect(r.inventorySynced, 0);
expect(r.catalogFailed, 0);
expect(r.inventoryFailed, 0);
expect(r.errors, isEmpty);
});
test('totalProcessed sums all four counts', () {
const r = SquareSyncResult(
catalogSynced: 2,
inventorySynced: 3,
catalogFailed: 1,
inventoryFailed: 1,
);
expect(r.totalProcessed, 7);
});
test('allSucceeded is true when no failures', () {
const r = SquareSyncResult(catalogSynced: 5, inventorySynced: 3);
expect(r.allSucceeded, isTrue);
});
test('allSucceeded is false when catalogFailed > 0', () {
const r = SquareSyncResult(catalogFailed: 1);
expect(r.allSucceeded, isFalse);
});
test('allSucceeded is false when inventoryFailed > 0', () {
const r = SquareSyncResult(inventoryFailed: 1);
expect(r.allSucceeded, isFalse);
});
test('merge combines counts and errors', () {
const a = SquareSyncResult(
catalogSynced: 2,
catalogFailed: 1,
errors: [SyncError(itemId: 'a', message: 'err')],
);
const b = SquareSyncResult(
inventorySynced: 3,
inventoryFailed: 1,
errors: [SyncError(itemId: 'b', message: 'err2')],
);
final merged = a.merge(b);
expect(merged.catalogSynced, 2);
expect(merged.catalogFailed, 1);
expect(merged.inventorySynced, 3);
expect(merged.inventoryFailed, 1);
expect(merged.errors.length, 2);
});
test('toString includes all field names', () {
const r = SquareSyncResult(catalogSynced: 1, inventorySynced: 2);
final s = r.toString();
expect(s, contains('catalogSynced'));
expect(s, contains('inventorySynced'));
});
});
// -------------------------------------------------------------------------
// SquareCatalogSyncService syncCatalog
// -------------------------------------------------------------------------
group('SquareCatalogSyncService — syncCatalog', () {
test('empty catalog returns zero counts', () async {
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: _InMemoryMappingRepository(),
);
final result = await service.syncCatalog();
expect(result.catalogSynced, 0);
expect(result.catalogFailed, 0);
expect(result.errors, isEmpty);
});
test('single product creates a new mapping', () async {
final mappingRepo = _InMemoryMappingRepository();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-001')]),
mappingRepository: mappingRepo,
);
final result = await service.syncCatalog();
expect(result.catalogSynced, 1);
expect(result.catalogFailed, 0);
expect(mappingRepo.count, 1);
});
test('mapping squareCatalogObjectId matches product id', () async {
final mappingRepo = _InMemoryMappingRepository();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-abc')]),
mappingRepository: mappingRepo,
);
await service.syncCatalog();
final mapping = await mappingRepo.getMappingBySquareId('sq-abc');
expect(mapping, isNotNull);
expect(mapping!.squareCatalogObjectId, 'sq-abc');
});
test('multiple products all synced', () async {
final mappingRepo = _InMemoryMappingRepository();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(
drafts: [_draft('sq-1'), _draft('sq-2'), _draft('sq-3')],
),
mappingRepository: mappingRepo,
);
final result = await service.syncCatalog();
expect(result.catalogSynced, 3);
expect(result.catalogFailed, 0);
expect(mappingRepo.count, 3);
});
test('existing mapping is updated not duplicated', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('sq-001', squareCatalogObjectId: 'sq-001'));
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-001')]),
mappingRepository: mappingRepo,
);
await service.syncCatalog();
expect(mappingRepo.count, 1); // still 1, not 2
});
test('catalog fetch failure returns catalogFailed=1 with error', () async {
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(shouldThrow: true),
mappingRepository: _InMemoryMappingRepository(),
);
final result = await service.syncCatalog();
expect(result.catalogFailed, 1);
expect(result.catalogSynced, 0);
expect(result.errors.length, 1);
expect(result.errors.first.itemId, 'catalog');
});
test('saveMapping failure records error and continues', () async {
final mappingRepo = _InMemoryMappingRepository(shouldThrowOnSave: true);
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1'), _draft('sq-2')]),
mappingRepository: mappingRepo,
);
final result = await service.syncCatalog();
expect(result.catalogFailed, 2);
expect(result.catalogSynced, 0);
expect(result.errors.length, 2);
});
test('partial failure — first item fails, second succeeds', () async {
final mappingRepo = _InMemoryMappingRepository();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-fail'), _draft('sq-ok')]),
mappingRepository: _PartialFailMappingRepository(
failIds: {'sq-fail'},
delegate: mappingRepo,
),
);
final result = await service.syncCatalog();
expect(result.catalogSynced, 1);
expect(result.catalogFailed, 1);
expect(result.errors.first.itemId, 'sq-fail');
});
test('SyncError details populated on failure', () async {
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(shouldThrow: true),
mappingRepository: _InMemoryMappingRepository(),
);
final result = await service.syncCatalog();
expect(result.errors.first.message, contains('catalog fetch failed'));
});
});
// -------------------------------------------------------------------------
// SquareCatalogSyncService syncInventory
// -------------------------------------------------------------------------
group('SquareCatalogSyncService — syncInventory', () {
test('no adapter returns empty result', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
);
final result = await service.syncInventory();
expect(result.inventorySynced, 0);
expect(result.inventoryFailed, 0);
});
test('items without squareVariationId are skipped', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('id1')); // no squareVariationId
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncInventory();
expect(result.inventorySynced, 0);
expect(result.inventoryFailed, 0);
expect(adapter.calledWith, isEmpty);
});
test('items with squareVariationId are synced', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
mappingRepo.seed(_mapping('id2', squareCatalogObjectId: 'sq-2', squareVariationId: 'var-2'));
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncInventory();
expect(result.inventorySynced, 2);
expect(result.inventoryFailed, 0);
expect(adapter.calledWith, containsAll(['id1', 'id2']));
});
test('adapter returning false records failure', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
final adapter = _FakeInventoryAdapter(results: {'id1': false});
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncInventory();
expect(result.inventorySynced, 0);
expect(result.inventoryFailed, 1);
expect(result.errors.first.itemId, 'id1');
});
test('adapter throwing records failure and continues', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
mappingRepo.seed(_mapping('id2', squareCatalogObjectId: 'sq-2', squareVariationId: 'var-2'));
final adapter = _MixedInventoryAdapter(throwIds: {'id1'});
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncInventory();
expect(result.inventorySynced, 1);
expect(result.inventoryFailed, 1);
expect(result.errors.first.itemId, 'id1');
});
test('getAllMappings failure returns inventoryFailed=1', () async {
final mappingRepo = _InMemoryMappingRepository(shouldThrowOnGetAll: true);
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncInventory();
expect(result.inventoryFailed, 1);
expect(result.errors.first.itemId, 'mappings');
});
test('empty mappings returns empty result', () async {
final mappingRepo = _InMemoryMappingRepository();
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncInventory();
expect(result.inventorySynced, 0);
expect(result.inventoryFailed, 0);
expect(adapter.calledWith, isEmpty);
});
});
// -------------------------------------------------------------------------
// SquareCatalogSyncService syncAll
// -------------------------------------------------------------------------
group('SquareCatalogSyncService — syncAll', () {
test('syncAll merges catalog and inventory results', () async {
final mappingRepo = _InMemoryMappingRepository();
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1'), _draft('sq-2')]),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
// syncAll calls syncCatalog first which creates mappings without
// squareVariationId, so inventory sync skips them correct behaviour:
// variation IDs are set by the catalog repository, not the sync service.
final result = await service.syncAll();
expect(result.catalogSynced, 2);
expect(result.catalogFailed, 0);
expect(result.inventorySynced, 0);
expect(result.inventoryFailed, 0);
expect(result.allSucceeded, isTrue);
});
test('syncAll with pre-seeded variation IDs syncs inventory', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncAll();
expect(result.catalogSynced, 1);
expect(result.inventorySynced, 1);
expect(result.allSucceeded, isTrue);
});
test('syncAll totalProcessed equals sum of all counts', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncAll();
expect(
result.totalProcessed,
result.catalogSynced +
result.catalogFailed +
result.inventorySynced +
result.inventoryFailed,
);
});
test('syncAll catalog failure does not prevent inventory sync', () async {
final mappingRepo = _InMemoryMappingRepository();
mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
final adapter = _FakeInventoryAdapter();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(shouldThrow: true),
mappingRepository: mappingRepo,
inventoryAdapter: adapter,
);
final result = await service.syncAll();
expect(result.catalogFailed, 1);
// Inventory sync still runs using existing mappings
expect(result.inventorySynced, 1);
});
test('syncAll without adapter skips inventory', () async {
final mappingRepo = _InMemoryMappingRepository();
final service = SquareCatalogSyncService(
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]),
mappingRepository: mappingRepo,
);
final result = await service.syncAll();
expect(result.catalogSynced, 1);
expect(result.inventorySynced, 0);
expect(result.inventoryFailed, 0);
});
});
}
// ---------------------------------------------------------------------------
// Additional test doubles
// ---------------------------------------------------------------------------
/// A mapping repository that delegates to [_InMemoryMappingRepository] but
/// throws on [saveMapping] for specific IDs.
class _PartialFailMappingRepository implements ProductIdMappingRepository {
final Set<String> failIds;
final _InMemoryMappingRepository delegate;
_PartialFailMappingRepository({required this.failIds, required this.delegate});
@override
Future<List<ProductIdMapping>> getAllMappings() => delegate.getAllMappings();
@override
Future<ProductIdMapping?> getMappingByLocalId(String localId) =>
delegate.getMappingByLocalId(localId);
@override
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) =>
delegate.getMappingByWooCommerceId(wooCommerceId);
@override
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) =>
delegate.getMappingBySquareId(squareCatalogObjectId);
@override
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
if (failIds.contains(mapping.localId)) {
throw Exception('forced failure for ${mapping.localId}');
}
return delegate.saveMapping(mapping);
}
@override
Future<void> deleteMapping(String localId) => delegate.deleteMapping(localId);
}
/// An [InventorySyncAdapter] that throws for specific IDs and succeeds for
/// others.
class _MixedInventoryAdapter implements InventorySyncAdapter {
final Set<String> throwIds;
_MixedInventoryAdapter({required this.throwIds});
@override
Future<bool> syncItem(String itemId, int remoteQuantity) async {
if (throwIds.contains(itemId)) {
throw Exception('inventory sync failed for $itemId');
}
return true;
}
}

View File

@ -1,529 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
// Fake sync service helpers
/// A [ProductIdMappingRepository] that always returns an empty list.
class _EmptyMappingRepository implements ProductIdMappingRepository {
@override
Future<List<ProductIdMapping>> getAllMappings() async => [];
@override
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => null;
@override
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async => null;
@override
Future<ProductIdMapping?> getMappingBySquareId(String squareId) async => null;
@override
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async => mapping;
@override
Future<void> deleteMapping(String localId) async {}
}
/// A [ProductPublishingRepository] stub that returns a configurable list of
/// drafts and optionally throws on [getProductDrafts].
class _StubCatalogRepository implements ProductPublishingRepository {
final List<ProductDraft> drafts;
final bool throwOnGet;
_StubCatalogRepository({this.drafts = const [], this.throwOnGet = false});
@override
Future<List<ProductDraft>> getProductDrafts() async {
if (throwOnGet) throw Exception('catalog fetch failed');
return drafts;
}
@override
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductPrice(String id, double price) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductDescription(String id, String description) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductCategory(String id, String category) =>
throw UnimplementedError();
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) => throw UnimplementedError();
@override
Future<void> deleteProduct(String id, {bool force = false}) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductImages(String id, List<int> mediaIds) =>
throw UnimplementedError();
@override
Future<List<ProductImage>> getProductImages(String id) => throw UnimplementedError();
@override
Future<List<ProductCategory>> getCategories() => throw UnimplementedError();
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) =>
throw UnimplementedError();
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) =>
throw UnimplementedError();
@override
Future<void> deleteCategory(int id, {bool force = true}) => throw UnimplementedError();
}
/// Builds a [SquareCatalogSyncService] backed by the given stubs.
SquareCatalogSyncService _makeService({
List<ProductDraft> drafts = const [],
bool throwOnGet = false,
InventorySyncAdapter? adapter,
}) {
return SquareCatalogSyncService(
catalogRepository: _StubCatalogRepository(drafts: drafts, throwOnGet: throwOnGet),
mappingRepository: _EmptyMappingRepository(),
inventoryAdapter: adapter,
);
}
/// Builds a [SquareSyncController] backed by the given stubs.
SquareSyncController _makeController({
List<ProductDraft> drafts = const [],
bool throwOnGet = false,
InventorySyncAdapter? adapter,
}) {
return SquareSyncController(
syncService: _makeService(drafts: drafts, throwOnGet: throwOnGet, adapter: adapter),
);
}
/// A [SquareCatalogSyncService] that throws synchronously from [syncAll].
class _ThrowingSyncService extends SquareCatalogSyncService {
_ThrowingSyncService()
: super(
catalogRepository: _StubCatalogRepository(throwOnGet: true),
mappingRepository: _EmptyMappingRepository(),
);
@override
Future<SquareSyncResult> syncAll() async {
throw Exception('total failure');
}
@override
Future<SquareSyncResult> syncCatalog() async {
throw Exception('catalog failure');
}
@override
Future<SquareSyncResult> syncInventory() async {
throw Exception('inventory failure');
}
}
void main() {
group('SquareSyncController', () {
// Initial state
group('initial state', () {
test('isSyncing is false initially', () {
final controller = _makeController();
expect(controller.isSyncing, isFalse);
});
test('lastResult is null initially', () {
final controller = _makeController();
expect(controller.lastResult, isNull);
});
test('lastSyncedAt is null initially', () {
final controller = _makeController();
expect(controller.lastSyncedAt, isNull);
});
test('lastError is null initially', () {
final controller = _makeController();
expect(controller.lastError, isNull);
});
});
// syncAll happy path
group('syncAll', () {
test('sets lastResult after successful syncAll', () async {
final controller = _makeController();
await controller.syncAll();
expect(controller.lastResult, isNotNull);
expect(controller.lastResult, isA<SquareSyncResult>());
});
test('stamps lastSyncedAt after syncAll completes', () async {
final before = DateTime.now();
final controller = _makeController();
await controller.syncAll();
final after = DateTime.now();
expect(controller.lastSyncedAt, isNotNull);
expect(
controller.lastSyncedAt!.isAfter(before) || controller.lastSyncedAt == before,
isTrue,
);
expect(
controller.lastSyncedAt!.isBefore(after) || controller.lastSyncedAt == after,
isTrue,
);
});
test('isSyncing is false after syncAll completes', () async {
final controller = _makeController();
await controller.syncAll();
expect(controller.isSyncing, isFalse);
});
test('lastError is null after successful syncAll', () async {
final controller = _makeController();
await controller.syncAll();
expect(controller.lastError, isNull);
});
test('syncAll with drafts returns catalogSynced count', () async {
final draft = ProductDraft(
id: 'P-001',
name: 'Bowl Cozy',
description: '',
price: 12.99,
sku: 'BC-001',
category: 'Jewelry',
imageUrl: '',
status: PublishStatus.published,
lastModified: DateTime(2026, 1, 1),
);
final controller = _makeController(drafts: [draft]);
await controller.syncAll();
expect(controller.lastResult!.catalogSynced, 1);
expect(controller.lastResult!.catalogFailed, 0);
});
});
// syncAll partial failure
group('syncAll partial failure', () {
test('lastResult reflects partial failure when catalog fetch fails', () async {
final controller = _makeController(throwOnGet: true);
await controller.syncAll();
expect(controller.lastResult, isNotNull);
expect(controller.lastResult!.catalogFailed, greaterThan(0));
expect(controller.lastResult!.errors, isNotEmpty);
});
test('lastError is null even on partial failure (errors in lastResult)', () async {
final controller = _makeController(throwOnGet: true);
await controller.syncAll();
// Partial failures are captured in lastResult.errors, not lastError.
expect(controller.lastError, isNull);
expect(controller.lastResult!.errors, isNotEmpty);
});
});
// isSyncing during sync
group('isSyncing state', () {
test('isSyncing is true during sync and false after', () async {
final controller = _makeController();
bool wasSyncingDuringSync = false;
// Listen for the first notification (isSyncing = true).
controller.addListener(() {
if (controller.isSyncing) {
wasSyncingDuringSync = true;
}
});
await controller.syncAll();
expect(wasSyncingDuringSync, isTrue);
expect(controller.isSyncing, isFalse);
});
test('concurrent syncAll calls are no-ops while already syncing', () async {
int notifyCount = 0;
final controller = _makeController();
controller.addListener(() => notifyCount++);
// Fire two concurrent calls second should be a no-op.
final f1 = controller.syncAll();
final f2 = controller.syncAll(); // should return immediately
await Future.wait([f1, f2]);
// Only 2 notifications: isSyncing=true and isSyncing=false.
expect(notifyCount, 2);
});
});
// syncCatalog
group('syncCatalog', () {
test('syncCatalog sets lastResult', () async {
final controller = _makeController();
await controller.syncCatalog();
expect(controller.lastResult, isNotNull);
});
test('syncCatalog stamps lastSyncedAt', () async {
final controller = _makeController();
await controller.syncCatalog();
expect(controller.lastSyncedAt, isNotNull);
});
test('syncCatalog sets lastError on unexpected exception', () async {
final controller = SquareSyncController(syncService: _ThrowingSyncService());
await controller.syncCatalog();
expect(controller.lastError, isNotNull);
expect(controller.lastError, contains('catalog failure'));
});
});
// syncInventory
group('syncInventory', () {
test('syncInventory sets lastResult', () async {
final controller = _makeController();
await controller.syncInventory();
expect(controller.lastResult, isNotNull);
});
test('syncInventory stamps lastSyncedAt', () async {
final controller = _makeController();
await controller.syncInventory();
expect(controller.lastSyncedAt, isNotNull);
});
test('syncInventory sets lastError on unexpected exception', () async {
final controller = SquareSyncController(syncService: _ThrowingSyncService());
await controller.syncInventory();
expect(controller.lastError, isNotNull);
expect(controller.lastError, contains('inventory failure'));
});
});
// error state on total failure
group('error state on total failure', () {
test('lastError is set when syncAll throws unexpectedly', () async {
final controller = SquareSyncController(syncService: _ThrowingSyncService());
await controller.syncAll();
expect(controller.lastError, isNotNull);
expect(controller.lastError, contains('total failure'));
});
test('isSyncing is false after total failure', () async {
final controller = SquareSyncController(syncService: _ThrowingSyncService());
await controller.syncAll();
expect(controller.isSyncing, isFalse);
});
test('lastSyncedAt is null after total failure (no successful completion)', () async {
final controller = SquareSyncController(syncService: _ThrowingSyncService());
await controller.syncAll();
// lastSyncedAt is only stamped on successful completion.
expect(controller.lastSyncedAt, isNull);
});
});
// notifyListeners
group('notifyListeners', () {
test('notifies listeners exactly twice per sync (start and end)', () async {
int count = 0;
final controller = _makeController();
controller.addListener(() => count++);
await controller.syncAll();
expect(count, 2);
});
});
});
// ProductDraft.squareCatalogObjectId
group('ProductDraft.squareCatalogObjectId', () {
ProductDraft draft0({String? squareCatalogObjectId}) => ProductDraft(
id: 'P-001',
name: 'Test',
description: '',
price: 10.0,
sku: 'SKU-001',
category: '',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026, 1, 1),
squareCatalogObjectId: squareCatalogObjectId,
);
test('defaults to null when not provided', () {
final draft = draft0();
expect(draft.squareCatalogObjectId, isNull);
});
test('stores provided squareCatalogObjectId', () {
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
expect(draft.squareCatalogObjectId, 'SQ-ITEM-001');
});
test('copyWith preserves squareCatalogObjectId when not specified', () {
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
final copy = draft.copyWith(name: 'Updated');
expect(copy.squareCatalogObjectId, 'SQ-ITEM-001');
});
test('copyWith updates squareCatalogObjectId when specified', () {
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
final copy = draft.copyWith(squareCatalogObjectId: 'SQ-ITEM-002');
expect(copy.squareCatalogObjectId, 'SQ-ITEM-002');
});
test('copyWith clears squareCatalogObjectId when null is passed explicitly', () {
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
final copy = draft.copyWith(squareCatalogObjectId: null);
expect(copy.squareCatalogObjectId, isNull);
});
test('copyWith preserves null squareCatalogObjectId when not specified', () {
final draft = draft0();
final copy = draft.copyWith(name: 'Updated');
expect(copy.squareCatalogObjectId, isNull);
});
});
// SquareCatalogSyncService ID conflation fix
group('SquareCatalogSyncService ID conflation fix', () {
test('uses squareCatalogObjectId when present instead of product.id', () async {
// Product has a WooCommerce local ID ('WC-001') and a separate Square ID.
final draft = ProductDraft(
id: 'WC-001',
name: 'Bowl Cozy',
description: '',
price: 12.99,
sku: 'BC-001',
category: '',
imageUrl: '',
status: PublishStatus.published,
lastModified: DateTime(2026, 1, 1),
squareCatalogObjectId: 'SQ-ITEM-001',
);
String? savedLocalId;
String? savedSquareId;
final mappingRepo = _CapturingMappingRepository(
onSave: (m) {
savedLocalId = m.localId;
savedSquareId = m.squareCatalogObjectId;
},
);
final service = SquareCatalogSyncService(
catalogRepository: _StubCatalogRepository(drafts: [draft]),
mappingRepository: mappingRepo,
);
await service.syncCatalog();
// localId should be the WooCommerce ID, squareCatalogObjectId the Square ID.
expect(savedLocalId, 'WC-001');
expect(savedSquareId, 'SQ-ITEM-001');
});
test('falls back to product.id when squareCatalogObjectId is null', () async {
// Square-backed repo: product.id IS the Square catalog object ID.
final draft = ProductDraft(
id: 'SQ-ITEM-001',
name: 'Bowl Cozy',
description: '',
price: 12.99,
sku: 'BC-001',
category: '',
imageUrl: '',
status: PublishStatus.published,
lastModified: DateTime(2026, 1, 1),
// squareCatalogObjectId is null fall back to product.id
);
String? savedLocalId;
String? savedSquareId;
final mappingRepo = _CapturingMappingRepository(
onSave: (m) {
savedLocalId = m.localId;
savedSquareId = m.squareCatalogObjectId;
},
);
final service = SquareCatalogSyncService(
catalogRepository: _StubCatalogRepository(drafts: [draft]),
mappingRepository: mappingRepo,
);
await service.syncCatalog();
expect(savedLocalId, 'SQ-ITEM-001');
expect(savedSquareId, 'SQ-ITEM-001');
});
});
}
// Test doubles
/// A [ProductIdMappingRepository] that captures [saveMapping] calls.
class _CapturingMappingRepository implements ProductIdMappingRepository {
final void Function(ProductIdMapping mapping) onSave;
_CapturingMappingRepository({required this.onSave});
@override
Future<List<ProductIdMapping>> getAllMappings() async => [];
@override
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => null;
@override
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async => null;
@override
Future<ProductIdMapping?> getMappingBySquareId(String squareId) async => null;
@override
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
onSave(mapping);
return mapping;
}
@override
Future<void> deleteMapping(String localId) async {}
}

View File

@ -1,140 +0,0 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Wraps [widget] in a minimal [MaterialApp] so that [Theme] and [Navigator]
/// are available.
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
/// Creates a [SquareSyncController] backed by a [SquareCatalogSyncService]
/// that uses fake repositories no network calls.
SquareSyncController _fakeController() {
final syncService = SquareCatalogSyncService(
catalogRepository: FakeProductPublishingRepository(),
mappingRepository: FakeProductIdMappingRepository(),
);
return SquareSyncController(syncService: syncService);
}
/// Advances fake time enough to drain all [FakeProductPublishingRepository]
/// delays (300 ms per call × a few calls) and pumps the widget tree.
Future<void> _drainSync(WidgetTester tester) async {
// Advance time in steps to drain all fake delays (300 ms each).
for (var i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
await tester.pump();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('SquareSyncStatusPanel', () {
testWidgets('renders Square Sync title', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
expect(find.text('Square Sync'), findsOneWidget);
});
testWidgets('shows Never synced chip when no sync has run', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
expect(find.text('Never synced'), findsOneWidget);
});
testWidgets('shows Last synced: Never when lastSyncedAt is null', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
expect(find.text('Last synced: Never'), findsOneWidget);
});
testWidgets('Sync Now button is enabled when not syncing', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNotNull);
});
testWidgets('shows OK chip after a successful sync', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
// Trigger sync and advance fake time to drain all delays.
await tester.tap(find.text('Sync Now'));
await _drainSync(tester);
expect(find.text('OK'), findsOneWidget);
});
testWidgets('shows last sync result section after sync', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
await tester.tap(find.text('Sync Now'));
await _drainSync(tester);
expect(find.text('Last sync result'), findsOneWidget);
expect(find.text('Catalog'), findsOneWidget);
expect(find.text('Inventory'), findsOneWidget);
});
testWidgets('rebuilds when controller notifies listeners', (tester) async {
final controller = _fakeController();
addTearDown(controller.dispose);
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
expect(find.text('Never synced'), findsOneWidget);
// Run a sync via the button and advance fake time.
await tester.tap(find.text('Sync Now'));
await _drainSync(tester);
expect(find.text('OK'), findsOneWidget);
});
testWidgets('unregisters listener on dispose', (tester) async {
final controller = _fakeController();
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
await tester.pump();
// Replace the widget tree this disposes the panel.
await tester.pumpWidget(_wrap(const SizedBox()));
await tester.pump();
// Notifying after dispose should not throw.
expect(() => controller.notifyListeners(), returnsNormally);
controller.dispose();
});
});
}

View File

@ -12,6 +12,4 @@ export 'src/integration_health_check.dart';
export 'src/rate_limiter.dart';
export 'src/retry_policy.dart';
export 'src/square_api_client.dart';
export 'src/square_inventory_mapper.dart';
export 'src/square_product_mapper.dart';
export 'src/webhook_handler.dart';

View File

@ -1,100 +0,0 @@
import 'channel_adapter.dart';
/// Maps between Square InventoryCount JSON maps and the channel-neutral
/// [ChannelInventoryCount] representation.
///
/// Square inventory counts are returned from the
/// `GET /v2/inventory/{catalog_object_id}` endpoint as:
/// ```
/// {
/// "catalog_object_id": "SQ-VAR-001",
/// "catalog_object_type": "ITEM_VARIATION",
/// "state": "IN_STOCK",
/// "location_id": "LOC123",
/// "quantity": "18",
/// "calculated_at": "2026-07-01T00:00:00.000Z"
/// }
/// ```
///
/// Note: Square returns `quantity` as a **string**, not an integer.
/// This mapper parses it safely, defaulting to 0 on parse failure.
///
/// Only counts with `state == 'IN_STOCK'` represent sellable on-hand
/// inventory. Other states (`WASTE`, `UNLINKED_RETURN`, etc.) are ignored
/// when converting to a domain quantity.
class SquareInventoryMapper {
const SquareInventoryMapper();
// Square InventoryCount ChannelInventoryCount
/// Maps a single Square InventoryCount [json] map to a
/// [ChannelInventoryCount].
///
/// Returns `null` if the count is not in `IN_STOCK` state or is missing
/// required fields.
ChannelInventoryCount? fromSquareInventoryCount(Map<String, dynamic> json) {
final catalogObjectId = json['catalog_object_id'] as String?;
if (catalogObjectId == null || catalogObjectId.isEmpty) return null;
// Only map IN_STOCK counts to on-hand quantity.
final state = json['state'] as String? ?? '';
if (state != 'IN_STOCK') return null;
final quantityStr = json['quantity'] as String? ?? '0';
final quantity = int.tryParse(quantityStr) ?? 0;
return ChannelInventoryCount(productId: catalogObjectId, quantity: quantity);
}
/// Maps a list of Square InventoryCount [jsonList] maps to
/// [ChannelInventoryCount]s, skipping non-IN_STOCK entries and invalid
/// records.
List<ChannelInventoryCount> fromSquareInventoryCountList(List<Map<String, dynamic>> jsonList) {
final results = <ChannelInventoryCount>[];
for (final json in jsonList) {
final count = fromSquareInventoryCount(json);
if (count != null) results.add(count);
}
return results;
}
// ChannelInventoryCount Square batch-change entry
/// Converts a [ChannelInventoryCount] to a Square inventory batch-change
/// entry suitable for use with [SquareApiClient.batchChangeInventory].
///
/// Uses `PHYSICAL_COUNT` type to set an absolute quantity (not a delta).
///
/// [locationId] is the Square location ID for the count.
/// [occurredAt] defaults to the current UTC time in RFC 3339 format.
Map<String, dynamic> toSquareBatchChangeEntry(
ChannelInventoryCount count, {
required String locationId,
DateTime? occurredAt,
}) {
final timestamp = (occurredAt ?? DateTime.now().toUtc()).toIso8601String();
return {
'type': 'PHYSICAL_COUNT',
'physical_count': {
'catalog_object_id': count.productId,
'state': 'IN_STOCK',
'location_id': locationId,
'quantity': count.quantity.toString(),
'occurred_at': timestamp,
},
};
}
/// Converts a list of [ChannelInventoryCount]s to Square batch-change
/// entries.
List<Map<String, dynamic>> toSquareBatchChangeEntries(
List<ChannelInventoryCount> counts, {
required String locationId,
DateTime? occurredAt,
}) {
return counts
.map((c) => toSquareBatchChangeEntry(c, locationId: locationId, occurredAt: occurredAt))
.toList();
}
}

View File

@ -1,150 +0,0 @@
import 'channel_adapter.dart';
/// Maps between Square CatalogObject JSON maps and the channel-neutral
/// [ChannelProduct] representation.
///
/// Square catalog items have a nested structure:
/// ```
/// {
/// "type": "ITEM",
/// "id": "SQ-ITEM-001",
/// "item_data": {
/// "name": "Floral Bowl Cozy",
/// "description": "...",
/// "variations": [
/// {
/// "type": "ITEM_VARIATION",
/// "id": "SQ-VAR-001",
/// "item_variation_data": {
/// "name": "Regular",
/// "price_money": { "amount": 1299, "currency": "USD" },
/// "sku": "BC-FLR-001"
/// }
/// }
/// ],
/// "category_id": "SQ-CAT-001"
/// }
/// }
/// ```
///
/// Price is stored in **cents** by Square; this mapper converts to/from
/// dollars (double) for the domain layer.
///
/// Only the first variation is used for price and SKU when mapping from
/// Square domain. When mapping domain Square, a single default variation
/// is generated.
class SquareProductMapper {
const SquareProductMapper();
// Square ChannelProduct
/// Maps a Square CatalogObject [json] map (type `ITEM`) to a
/// [ChannelProduct].
///
/// Returns `null` if the object is not a valid ITEM type or is missing
/// required fields.
ChannelProduct? fromSquareCatalogObject(Map<String, dynamic> json) {
if (json['type'] != 'ITEM') return null;
final id = json['id'] as String?;
if (id == null || id.isEmpty) return null;
final itemData = json['item_data'];
if (itemData is! Map<String, dynamic>) return null;
final name = itemData['name'] as String? ?? '';
final description = itemData['description'] as String?;
// Extract price and SKU from the first variation.
double price = 0.0;
String? sku;
final variations = itemData['variations'];
if (variations is List && variations.isNotEmpty) {
final firstVariation = variations.first;
if (firstVariation is Map<String, dynamic>) {
final varData = firstVariation['item_variation_data'];
if (varData is Map<String, dynamic>) {
sku = varData['sku'] as String?;
final priceMoney = varData['price_money'];
if (priceMoney is Map<String, dynamic>) {
final amountCents = priceMoney['amount'];
if (amountCents is int) {
price = amountCents / 100.0;
} else if (amountCents is double) {
price = amountCents / 100.0;
}
}
}
}
}
// Map Square present_at_all_locations / is_deleted to a status string.
final isDeleted = json['is_deleted'] as bool? ?? false;
final status = isDeleted ? 'archived' : 'active';
return ChannelProduct(
id: id,
name: name,
price: price,
status: status,
description: description,
sku: sku,
);
}
/// Maps a list of Square CatalogObject [jsonList] maps to [ChannelProduct]s,
/// skipping any entries that cannot be parsed.
List<ChannelProduct> fromSquareCatalogList(List<Map<String, dynamic>> jsonList) {
final results = <ChannelProduct>[];
for (final json in jsonList) {
final product = fromSquareCatalogObject(json);
if (product != null) results.add(product);
}
return results;
}
// ChannelProduct Square CatalogObject
/// Converts a [ChannelProduct] to a Square CatalogObject map suitable for
/// use with [SquareApiClient.upsertCatalogItem].
///
/// [squareItemId] is the existing Square ITEM object ID for updates, or a
/// temporary ID (e.g. `'#new-item'`) for creates.
///
/// [squareVariationId] is the existing variation ID for updates, or a
/// temporary ID (e.g. `'#new-variation'`) for creates.
///
/// [locationId] is required by Square for variation pricing.
Map<String, dynamic> toSquareCatalogObject(
ChannelProduct product, {
String squareItemId = '#new-item',
String squareVariationId = '#new-variation',
required String locationId,
}) {
final amountCents = (product.price * 100).round();
final variationData = <String, dynamic>{
'name': 'Regular',
'pricing_type': 'FIXED_PRICING',
'price_money': {'amount': amountCents, 'currency': 'USD'},
'location_overrides': [
{'location_id': locationId, 'track_inventory': true},
],
};
if (product.sku != null && product.sku!.isNotEmpty) {
variationData['sku'] = product.sku;
}
final itemData = <String, dynamic>{
'name': product.name,
'variations': [
{'type': 'ITEM_VARIATION', 'id': squareVariationId, 'item_variation_data': variationData},
],
};
if (product.description != null && product.description!.isNotEmpty) {
itemData['description'] = product.description;
}
return {'type': 'ITEM', 'id': squareItemId, 'item_data': itemData};
}
}

View File

@ -1,593 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integrations/integrations.dart';
void main() {
// SquareProductMapper
group('SquareProductMapper', () {
const mapper = SquareProductMapper();
// fromSquareCatalogObject
group('fromSquareCatalogObject', () {
test('maps a full ITEM object to ChannelProduct', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-001',
'item_data': {
'name': 'Floral Bowl Cozy',
'description': 'A beautifully crafted fabric bowl cozy.',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-001',
'item_variation_data': {
'name': 'Regular',
'sku': 'BC-FLR-001',
'price_money': {'amount': 1299, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product, isNotNull);
expect(product!.id, 'SQ-ITEM-001');
expect(product.name, 'Floral Bowl Cozy');
expect(product.description, 'A beautifully crafted fabric bowl cozy.');
expect(product.price, closeTo(12.99, 0.001));
expect(product.sku, 'BC-FLR-001');
expect(product.status, 'active');
});
test('maps price in cents to dollars correctly', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-002',
'item_data': {
'name': 'Coaster Set',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-002',
'item_variation_data': {
'price_money': {'amount': 1650, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.price, closeTo(16.50, 0.001));
});
test('returns null for non-ITEM type', () {
final json = {
'type': 'CATEGORY',
'id': 'SQ-CAT-001',
'category_data': {'name': 'Kitchen'},
};
expect(mapper.fromSquareCatalogObject(json), isNull);
});
test('returns null when id is missing', () {
final json = {
'type': 'ITEM',
'item_data': {'name': 'No ID Item'},
};
expect(mapper.fromSquareCatalogObject(json), isNull);
});
test('returns null when item_data is missing', () {
final json = {'type': 'ITEM', 'id': 'SQ-ITEM-003'};
expect(mapper.fromSquareCatalogObject(json), isNull);
});
test('maps is_deleted=true to archived status', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-004',
'is_deleted': true,
'item_data': {
'name': 'Archived Item',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-004',
'item_variation_data': {
'price_money': {'amount': 999, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.status, 'archived');
});
test('defaults price to 0.0 when no variations present', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-005',
'item_data': {'name': 'No Price Item', 'variations': []},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.price, 0.0);
});
test('defaults price to 0.0 when price_money is absent', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-006',
'item_data': {
'name': 'No Price Money',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-006',
'item_variation_data': {'name': 'Regular'},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.price, 0.0);
});
test('sku is null when not present in variation', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-007',
'item_data': {
'name': 'No SKU Item',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-007',
'item_variation_data': {
'price_money': {'amount': 500, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.sku, isNull);
});
test('description is null when not present in item_data', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-008',
'item_data': {
'name': 'No Description',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-008',
'item_variation_data': {
'price_money': {'amount': 800, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.description, isNull);
});
});
// fromSquareCatalogList
group('fromSquareCatalogList', () {
test('maps a list of ITEM objects', () {
final jsonList = [
{
'type': 'ITEM',
'id': 'SQ-ITEM-A',
'item_data': {
'name': 'Item A',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-A',
'item_variation_data': {
'price_money': {'amount': 1000, 'currency': 'USD'},
},
},
],
},
},
{
'type': 'ITEM',
'id': 'SQ-ITEM-B',
'item_data': {
'name': 'Item B',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-B',
'item_variation_data': {
'price_money': {'amount': 2000, 'currency': 'USD'},
},
},
],
},
},
];
final products = mapper.fromSquareCatalogList(jsonList);
expect(products.length, 2);
expect(products[0].id, 'SQ-ITEM-A');
expect(products[1].id, 'SQ-ITEM-B');
});
test('skips non-ITEM entries in a mixed list', () {
final jsonList = [
{
'type': 'ITEM',
'id': 'SQ-ITEM-A',
'item_data': {
'name': 'Item A',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-A',
'item_variation_data': {
'price_money': {'amount': 1000, 'currency': 'USD'},
},
},
],
},
},
{'type': 'CATEGORY', 'id': 'SQ-CAT-001', 'category_data': {}},
{'type': 'TAX', 'id': 'SQ-TAX-001'},
];
final products = mapper.fromSquareCatalogList(jsonList);
expect(products.length, 1);
expect(products.first.id, 'SQ-ITEM-A');
});
test('returns empty list for empty input', () {
expect(mapper.fromSquareCatalogList([]), isEmpty);
});
});
// toSquareCatalogObject
group('toSquareCatalogObject', () {
test('converts ChannelProduct to Square CatalogObject', () {
const product = ChannelProduct(
id: 'local-1',
name: 'Floral Bowl Cozy',
price: 12.99,
status: 'active',
description: 'A fabric bowl cozy.',
sku: 'BC-FLR-001',
);
final json = mapper.toSquareCatalogObject(
product,
squareItemId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
locationId: 'LOC123',
);
expect(json['type'], 'ITEM');
expect(json['id'], 'SQ-ITEM-001');
final itemData = json['item_data'] as Map<String, dynamic>;
expect(itemData['name'], 'Floral Bowl Cozy');
expect(itemData['description'], 'A fabric bowl cozy.');
final variations = itemData['variations'] as List;
expect(variations.length, 1);
final variation = variations.first as Map<String, dynamic>;
expect(variation['type'], 'ITEM_VARIATION');
expect(variation['id'], 'SQ-VAR-001');
final varData = variation['item_variation_data'] as Map<String, dynamic>;
expect(varData['sku'], 'BC-FLR-001');
final priceMoney = varData['price_money'] as Map<String, dynamic>;
expect(priceMoney['amount'], 1299);
expect(priceMoney['currency'], 'USD');
});
test('converts price to cents correctly', () {
const product = ChannelProduct(
id: 'local-2',
name: 'Coaster Set',
price: 16.50,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
final varData =
(variations.first as Map<String, dynamic>)['item_variation_data']
as Map<String, dynamic>;
final priceMoney = varData['price_money'] as Map<String, dynamic>;
expect(priceMoney['amount'], 1650);
});
test('uses default temporary IDs when not provided', () {
const product = ChannelProduct(
id: 'local-3',
name: 'New Item',
price: 9.99,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
expect(json['id'], '#new-item');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
expect((variations.first as Map<String, dynamic>)['id'], '#new-variation');
});
test('omits description when null', () {
const product = ChannelProduct(
id: 'local-4',
name: 'No Description',
price: 5.00,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
final itemData = json['item_data'] as Map<String, dynamic>;
expect(itemData.containsKey('description'), isFalse);
});
test('omits sku from variation when null', () {
const product = ChannelProduct(
id: 'local-5',
name: 'No SKU',
price: 5.00,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
final varData =
(variations.first as Map<String, dynamic>)['item_variation_data']
as Map<String, dynamic>;
expect(varData.containsKey('sku'), isFalse);
});
test('includes location_overrides with track_inventory', () {
const product = ChannelProduct(
id: 'local-6',
name: 'Track Me',
price: 10.00,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC456');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
final varData =
(variations.first as Map<String, dynamic>)['item_variation_data']
as Map<String, dynamic>;
final overrides = varData['location_overrides'] as List;
expect(overrides.length, 1);
final override = overrides.first as Map<String, dynamic>;
expect(override['location_id'], 'LOC456');
expect(override['track_inventory'], isTrue);
});
});
});
// SquareInventoryMapper
group('SquareInventoryMapper', () {
const mapper = SquareInventoryMapper();
// fromSquareInventoryCount
group('fromSquareInventoryCount', () {
test('maps a valid IN_STOCK count to ChannelInventoryCount', () {
final json = {
'catalog_object_id': 'SQ-VAR-001',
'catalog_object_type': 'ITEM_VARIATION',
'state': 'IN_STOCK',
'location_id': 'LOC123',
'quantity': '18',
'calculated_at': '2026-07-01T00:00:00.000Z',
};
final count = mapper.fromSquareInventoryCount(json);
expect(count, isNotNull);
expect(count!.productId, 'SQ-VAR-001');
expect(count.quantity, 18);
});
test('returns null for non-IN_STOCK state', () {
final json = {'catalog_object_id': 'SQ-VAR-001', 'state': 'WASTE', 'quantity': '5'};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
test('returns null when catalog_object_id is missing', () {
final json = {'state': 'IN_STOCK', 'quantity': '10'};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
test('returns null when catalog_object_id is empty string', () {
final json = {'catalog_object_id': '', 'state': 'IN_STOCK', 'quantity': '10'};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
test('defaults quantity to 0 when quantity field is missing', () {
final json = {'catalog_object_id': 'SQ-VAR-002', 'state': 'IN_STOCK'};
final count = mapper.fromSquareInventoryCount(json);
expect(count!.quantity, 0);
});
test('defaults quantity to 0 when quantity is not parseable', () {
final json = {
'catalog_object_id': 'SQ-VAR-003',
'state': 'IN_STOCK',
'quantity': 'not-a-number',
};
final count = mapper.fromSquareInventoryCount(json);
expect(count!.quantity, 0);
});
test('parses quantity of zero correctly', () {
final json = {'catalog_object_id': 'SQ-VAR-004', 'state': 'IN_STOCK', 'quantity': '0'};
final count = mapper.fromSquareInventoryCount(json);
expect(count!.quantity, 0);
});
test('returns null for UNLINKED_RETURN state', () {
final json = {
'catalog_object_id': 'SQ-VAR-005',
'state': 'UNLINKED_RETURN',
'quantity': '3',
};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
});
// fromSquareInventoryCountList
group('fromSquareInventoryCountList', () {
test('maps a list of IN_STOCK counts', () {
final jsonList = [
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
{'catalog_object_id': 'SQ-VAR-B', 'state': 'IN_STOCK', 'quantity': '5'},
];
final counts = mapper.fromSquareInventoryCountList(jsonList);
expect(counts.length, 2);
expect(counts[0].productId, 'SQ-VAR-A');
expect(counts[0].quantity, 10);
expect(counts[1].productId, 'SQ-VAR-B');
expect(counts[1].quantity, 5);
});
test('skips non-IN_STOCK entries in a mixed list', () {
final jsonList = [
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
{'catalog_object_id': 'SQ-VAR-B', 'state': 'WASTE', 'quantity': '2'},
{'catalog_object_id': 'SQ-VAR-C', 'state': 'IN_STOCK', 'quantity': '7'},
];
final counts = mapper.fromSquareInventoryCountList(jsonList);
expect(counts.length, 2);
expect(counts.map((c) => c.productId), containsAll(['SQ-VAR-A', 'SQ-VAR-C']));
});
test('returns empty list for empty input', () {
expect(mapper.fromSquareInventoryCountList([]), isEmpty);
});
});
// toSquareBatchChangeEntry
group('toSquareBatchChangeEntry', () {
test('converts ChannelInventoryCount to PHYSICAL_COUNT entry', () {
const count = ChannelInventoryCount(productId: 'SQ-VAR-001', quantity: 18);
final fixedTime = DateTime.utc(2026, 7, 1, 12, 0, 0);
final entry = mapper.toSquareBatchChangeEntry(
count,
locationId: 'LOC123',
occurredAt: fixedTime,
);
expect(entry['type'], 'PHYSICAL_COUNT');
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
expect(physicalCount['catalog_object_id'], 'SQ-VAR-001');
expect(physicalCount['state'], 'IN_STOCK');
expect(physicalCount['location_id'], 'LOC123');
expect(physicalCount['quantity'], '18');
expect(physicalCount['occurred_at'], '2026-07-01T12:00:00.000Z');
});
test('quantity is serialised as a string', () {
const count = ChannelInventoryCount(productId: 'SQ-VAR-002', quantity: 0);
final entry = mapper.toSquareBatchChangeEntry(
count,
locationId: 'LOC123',
occurredAt: DateTime.utc(2026, 1, 1),
);
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
expect(physicalCount['quantity'], isA<String>());
expect(physicalCount['quantity'], '0');
});
test('uses current time when occurredAt is not provided', () {
const count = ChannelInventoryCount(productId: 'SQ-VAR-003', quantity: 5);
final before = DateTime.now().toUtc();
final entry = mapper.toSquareBatchChangeEntry(count, locationId: 'LOC123');
final after = DateTime.now().toUtc();
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
final timestamp = DateTime.parse(physicalCount['occurred_at'] as String);
expect(timestamp.isAfter(before.subtract(const Duration(seconds: 1))), isTrue);
expect(timestamp.isBefore(after.add(const Duration(seconds: 1))), isTrue);
});
});
// toSquareBatchChangeEntries
group('toSquareBatchChangeEntries', () {
test('converts a list of counts to batch-change entries', () {
const counts = [
ChannelInventoryCount(productId: 'SQ-VAR-A', quantity: 10),
ChannelInventoryCount(productId: 'SQ-VAR-B', quantity: 3),
];
final fixedTime = DateTime.utc(2026, 7, 1);
final entries = mapper.toSquareBatchChangeEntries(
counts,
locationId: 'LOC123',
occurredAt: fixedTime,
);
expect(entries.length, 2);
expect(
(entries[0]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
'SQ-VAR-A',
);
expect(
(entries[1]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
'SQ-VAR-B',
);
});
test('returns empty list for empty input', () {
expect(mapper.toSquareBatchChangeEntries([], locationId: 'LOC123'), isEmpty);
});
});
});
}