diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 19f5890..9a6b520 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -1,6 +1,7 @@ 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'; @@ -9,6 +10,8 @@ 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'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart new file mode 100644 index 0000000..ae6d60c --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart @@ -0,0 +1,94 @@ +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 _store = {}; + + FakeProductIdMappingRepository() { + // Seed mappings for the 6 fake products. + // Products 1–3 have both WooCommerce and Square IDs (fully synced). + // Products 4–5 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> getAllMappings() async { + await Future.delayed(const Duration(milliseconds: 50)); + return List.unmodifiable(_store.values.toList()); + } + + @override + Future getMappingByLocalId(String localId) async { + await Future.delayed(const Duration(milliseconds: 50)); + return _store[localId]; + } + + @override + Future getMappingByWooCommerceId(String wooCommerceId) async { + await Future.delayed(const Duration(milliseconds: 50)); + try { + return _store.values.firstWhere((m) => m.wooCommerceId == wooCommerceId); + } catch (_) { + return null; + } + } + + @override + Future getMappingBySquareId(String squareCatalogObjectId) async { + await Future.delayed(const Duration(milliseconds: 50)); + try { + return _store.values.firstWhere((m) => m.squareCatalogObjectId == squareCatalogObjectId); + } catch (_) { + return null; + } + } + + @override + Future saveMapping(ProductIdMapping mapping) async { + await Future.delayed(const Duration(milliseconds: 50)); + _store[mapping.localId] = mapping; + return mapping; + } + + @override + Future deleteMapping(String localId) async { + await Future.delayed(const Duration(milliseconds: 50)); + _store.remove(localId); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_id_mapping.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_id_mapping.dart new file mode 100644 index 0000000..41f77a1 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_id_mapping.dart @@ -0,0 +1,83 @@ +/// 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)'; +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_id_mapping_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_id_mapping_repository.dart new file mode 100644 index 0000000..8ba7309 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_id_mapping_repository.dart @@ -0,0 +1,35 @@ +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> getAllMappings(); + + /// Returns the [ProductIdMapping] for the given [localId], or `null` if + /// no mapping exists for that local product. + Future getMappingByLocalId(String localId); + + /// Returns the [ProductIdMapping] whose [ProductIdMapping.wooCommerceId] + /// matches [wooCommerceId], or `null` if not found. + Future getMappingByWooCommerceId(String wooCommerceId); + + /// Returns the [ProductIdMapping] whose + /// [ProductIdMapping.squareCatalogObjectId] matches [squareCatalogObjectId], + /// or `null` if not found. + Future 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 saveMapping(ProductIdMapping mapping); + + /// Deletes the mapping for the given [localId]. + /// + /// No-ops silently if no mapping exists for that ID. + Future deleteMapping(String localId); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/product_id_mapping_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_id_mapping_test.dart new file mode 100644 index 0000000..bde231e --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/product_id_mapping_test.dart @@ -0,0 +1,248 @@ +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); + }); + }); +} diff --git a/kell_creations_apps/packages/integrations/lib/integrations.dart b/kell_creations_apps/packages/integrations/lib/integrations.dart index db6eec4..d239b3d 100644 --- a/kell_creations_apps/packages/integrations/lib/integrations.dart +++ b/kell_creations_apps/packages/integrations/lib/integrations.dart @@ -12,4 +12,6 @@ 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'; diff --git a/kell_creations_apps/packages/integrations/lib/src/square_inventory_mapper.dart b/kell_creations_apps/packages/integrations/lib/src/square_inventory_mapper.dart new file mode 100644 index 0000000..c72df18 --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/square_inventory_mapper.dart @@ -0,0 +1,100 @@ +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 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 fromSquareInventoryCountList(List> jsonList) { + final results = []; + 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 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> toSquareBatchChangeEntries( + List counts, { + required String locationId, + DateTime? occurredAt, + }) { + return counts + .map((c) => toSquareBatchChangeEntry(c, locationId: locationId, occurredAt: occurredAt)) + .toList(); + } +} diff --git a/kell_creations_apps/packages/integrations/lib/src/square_product_mapper.dart b/kell_creations_apps/packages/integrations/lib/src/square_product_mapper.dart new file mode 100644 index 0000000..1fa6001 --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/square_product_mapper.dart @@ -0,0 +1,150 @@ +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 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) 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) { + final varData = firstVariation['item_variation_data']; + if (varData is Map) { + sku = varData['sku'] as String?; + final priceMoney = varData['price_money']; + if (priceMoney is Map) { + 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 fromSquareCatalogList(List> jsonList) { + final results = []; + 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 toSquareCatalogObject( + ChannelProduct product, { + String squareItemId = '#new-item', + String squareVariationId = '#new-variation', + required String locationId, + }) { + final amountCents = (product.price * 100).round(); + + final variationData = { + '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 = { + '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}; + } +} diff --git a/kell_creations_apps/packages/integrations/test/square_catalog_sync_test.dart b/kell_creations_apps/packages/integrations/test/square_catalog_sync_test.dart new file mode 100644 index 0000000..247ff8e --- /dev/null +++ b/kell_creations_apps/packages/integrations/test/square_catalog_sync_test.dart @@ -0,0 +1,593 @@ +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; + 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; + expect(variation['type'], 'ITEM_VARIATION'); + expect(variation['id'], 'SQ-VAR-001'); + + final varData = variation['item_variation_data'] as Map; + expect(varData['sku'], 'BC-FLR-001'); + + final priceMoney = varData['price_money'] as Map; + 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; + final variations = itemData['variations'] as List; + final varData = + (variations.first as Map)['item_variation_data'] + as Map; + final priceMoney = varData['price_money'] as Map; + + 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; + final variations = itemData['variations'] as List; + expect((variations.first as Map)['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; + 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; + final variations = itemData['variations'] as List; + final varData = + (variations.first as Map)['item_variation_data'] + as Map; + 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; + final variations = itemData['variations'] as List; + final varData = + (variations.first as Map)['item_variation_data'] + as Map; + final overrides = varData['location_overrides'] as List; + + expect(overrides.length, 1); + final override = overrides.first as Map; + 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; + 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; + expect(physicalCount['quantity'], isA()); + 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; + 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)['catalog_object_id'], + 'SQ-VAR-A', + ); + expect( + (entries[1]['physical_count'] as Map)['catalog_object_id'], + 'SQ-VAR-B', + ); + }); + + test('returns empty list for empty input', () { + expect(mapper.toSquareBatchChangeEntries([], locationId: 'LOC123'), isEmpty); + }); + }); + }); +}