Compare commits

..

No commits in common. "079bf8c0f0c8d8ae20ba111ebaa4b77c9d66bd45" and "4b1168dbfa97c1ff617332130f4c6382d95e3969" have entirely different histories.

14 changed files with 8 additions and 1523 deletions

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,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,9 +2,9 @@
## Current status
- main baseline updated through: feat/square-catalog-sync (Stage 10B complete)
- next branch: feat/square-catalog-sync-repositories (Stage 10B continuation — SquareCatalogRepository + SquareInventoryRepository)
- current stage: Stage 10B complete — `SquareProductMapper`, `SquareInventoryMapper`, `ProductIdMapping` domain model, `ProductIdMappingRepository` contract, `FakeProductIdMappingRepository`; integrations 123/123 tests passing; feature_wordpress product_id_mapping 23/23 tests passing; analyze clean; merged to `main`
- 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
@ -572,23 +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

View File

@ -125,8 +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).
### Current narrow edit capabilities on `main`
@ -151,13 +149,12 @@ Rules:
- 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`: `123/123 passed` ← +35 new (Stage 10B)
- latest reported count for `feature_wordpress` (product_id_mapping): `23/23 passed` ← +23 new (Stage 10B)
- 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`: `26/26 passed`
- total: `957/957 passed` (pre-Stage-10B baseline) + 58 new Stage 10B tests
- baseline commit: merge of `feat/square-catalog-sync` into `main` (Stage 10B complete, 2026-07-25)
- 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)
@ -173,8 +170,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch
**`feat/square-catalog-sync-repositories`** — Stage 10B continuation: `SquareCatalogRepository` and `SquareInventoryRepository`.
Branch from latest `main`. Stage 10B (mappers + `ProductIdMapping` domain) is complete. The next slice adds `SquareCatalogRepository` implementing `ProductPublishingRepository` and `SquareInventoryRepository` implementing `InventoryRepository`, wiring the existing `SquareApiClient` and mappers into full repository contracts.
**`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.
---

Binary file not shown.

View File

@ -1,7 +1,6 @@
library;
// Data
export 'src/data/fake_product_id_mapping_repository.dart';
export 'src/data/fake_product_publishing_repository.dart';
export 'src/data/woo_commerce_api_client.dart';
export 'src/data/wordpress_product_mapper.dart';
@ -10,8 +9,6 @@ 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';

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,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,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

@ -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);
});
});
});
}