Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ shared/ # shared code
docs/ # docs
```

### Environment
1. You are working on MAC OS - NOT linux! Don't try to use linux commands locally. Use the Mac OS equivalents.
2. our database is in a DOCKER CONTAINER - interact with it there, not via `psql`.

## Problem-Solving Together

When you're stuck or confused:
Expand Down
167 changes: 167 additions & 0 deletions backend/migrations/1760320000000_create-sales-transactions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Migration: Create sales_transactions table
*
* ⚠️ CRITICAL: ES Modules - Use `export const up/down` NOT `exports.up/down`
*
* Purpose: Tier 2 unified sales data table (POS-agnostic)
* Architecture: Multi-provider support (Square, Toast, Clover)
* Related: Issue #21 - Square Sales Data Synchronization
*
* Data Flow:
* Square Orders API → square_orders + square_order_items (Tier 1)
* → POSDataTransformer → sales_transactions (Tier 2)
* → Recipe variance analysis (Issues #41-43)
*
* Query Pattern (Dave's halibut example):
* SELECT COUNT(*) as sales_count
* FROM sales_transactions
* WHERE inventory_item_id = ? AND transaction_date BETWEEN ? AND ?
*
* revenue_loss = variance_per_plate × sales_count
*/

/* eslint-disable camelcase */

export const up = async (pgm) => {
pgm.createTable('sales_transactions', {
id: { type: 'serial', primaryKey: true, notNull: true },

// Foreign Keys
restaurant_id: {
type: 'integer',
notNull: true,
references: 'restaurants',
onDelete: 'CASCADE'
},
inventory_item_id: {
type: 'integer',
notNull: false, // NULL for unmapped items (modifiers, ad-hoc items)
references: 'inventory_items',
onDelete: 'SET NULL'
},

// Transaction Details
transaction_date: {
type: 'timestamptz',
notNull: true,
comment: 'Order closed_at timestamp from POS system'
},
quantity: {
type: 'decimal(10,2)',
notNull: true,
comment: 'Quantity sold (supports fractional: 2.5 lbs, 0.5 portions)'
},
unit_price: {
type: 'decimal(10,2)',
notNull: false,
comment: 'Base price per unit in dollars (converted from cents)'
},
total_amount: {
type: 'decimal(10,2)',
notNull: false,
comment: 'Total line item amount in dollars (after tax/discount)'
},

// POS Source Tracking (Multi-Provider Support)
source_pos_provider: {
type: 'varchar(50)',
notNull: true,
comment: 'POS provider: square, toast, clover'
},
source_pos_order_id: {
type: 'varchar(255)',
notNull: true,
comment: 'Order ID from POS system'
},
source_pos_line_item_id: {
type: 'varchar(255)',
notNull: false,
comment: 'Unique line item ID: square-{uid}, toast-{check_id}-{item_id}'
},
source_pos_data: {
type: 'jsonb',
notNull: false,
comment: 'JSONB escape hatch for provider-specific fields (modifiers, discounts, fulfillment)'
},

// Timestamps
created_at: {
type: 'timestamptz',
notNull: true,
default: pgm.func('NOW()')
}
});

// Constraints
pgm.addConstraint('sales_transactions', 'unique_pos_line_item', {
unique: ['source_pos_provider', 'source_pos_line_item_id'],
comment: 'Prevent duplicate transactions from re-sync'
});

pgm.addConstraint('sales_transactions', 'valid_quantity', {
check: 'quantity > 0',
comment: 'Quantity must be positive (refunds handled separately)'
});

pgm.addConstraint('sales_transactions', 'valid_amounts', {
check: 'unit_price IS NULL OR unit_price >= 0',
comment: 'Prices cannot be negative'
});

// Performance Indexes (Recipe Variance Query Pattern)

// Primary query pattern: Filter by restaurant + date range
pgm.createIndex('sales_transactions',
['restaurant_id', 'transaction_date'],
{
name: 'idx_sales_trans_restaurant_date',
comment: 'Optimize restaurant-level date range queries'
}
);

// Recipe variance query: Filter by inventory item + date range
pgm.createIndex('sales_transactions',
['inventory_item_id', 'transaction_date'],
{
name: 'idx_sales_trans_item_date',
comment: 'Optimize item-level sales count aggregation'
}
);

// Global date sorting/filtering
pgm.createIndex('sales_transactions',
'transaction_date',
{
name: 'idx_sales_trans_date',
comment: 'Optimize global date-based queries'
}
);

// POS source tracking lookup
pgm.createIndex('sales_transactions',
['source_pos_provider', 'source_pos_order_id'],
{
name: 'idx_sales_trans_pos_source',
comment: 'Optimize lookups by POS order ID'
}
);

// Documentation
pgm.sql(`
COMMENT ON TABLE sales_transactions IS
'Tier 2: Unified sales data for recipe variance analysis. POS-agnostic format supports Square, Toast, Clover.';

COMMENT ON COLUMN sales_transactions.inventory_item_id IS
'Links to inventory_items via source_pos_item_id mapping. NULL for unmapped items (modifiers, custom line items).';

COMMENT ON COLUMN sales_transactions.source_pos_line_item_id IS
'Unique identifier for deduplication across syncs. Format: square-{line_item_uid}, toast-{check_id}-{item_id}.';

COMMENT ON COLUMN sales_transactions.source_pos_data IS
'JSONB escape hatch preserves provider-specific data not in core schema (modifiers, discounts, fulfillment details).';
`);
};

export const down = async (pgm) => {
pgm.dropTable('sales_transactions');
};
183 changes: 159 additions & 24 deletions backend/src/adapters/SquareAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import SquareCategory from '../models/SquareCategory.js';
import SquareMenuItem from '../models/SquareMenuItem.js';
import SquareInventoryCount from '../models/SquareInventoryCount.js';
import SquareLocation from '../models/SquareLocation.js';
import SquareOrder from '../models/SquareOrder.js';
import SquareOrderItem from '../models/SquareOrderItem.js';
import OAuthStateService from '../services/OAuthStateService.js';
import TokenEncryptionService from '../services/TokenEncryptionService.js';
import SquareRateLimiter from '../utils/squareRateLimiter.js';
Expand Down Expand Up @@ -1034,50 +1036,183 @@ class SquareAdapter extends POSAdapter {
async syncSales(connection, startDate, endDate) {
this._ensureInitialized();
await this._validateConnection(connection);

const syncResult = {
synced: { orders: 0, lineItems: 0 },
errors: [],
details: { apiCalls: 0, pages: 0, cursor: null }
};

this._logOperation('syncSales', {
connectionId: connection.id,
restaurantId: connection.restaurantId,
locationId: connection.squareLocationId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString()
});

try {
const client = await this._getClientForConnection(connection);
const synced = 0;
const errors = [];

// Progress Note: Full READ ONLY implementation would:
// 1. Use ordersApi.searchOrders() with date range (READ from Square)
// 2. Filter by location_id from connection
// 3. Map Square orders to Sales model in OUR database (future)
// 4. Extract item-level sales for usage calculation (stored in CostFX)
// 5. Handle pagination for large date ranges
//
// CRITICAL: We ONLY read from Square, never write back.
// Square POS remains the authoritative source for sales/order data.

this._logOperation('syncSales', {
connectionId: connection.id,
synced,
errors: errors.length,
status: 'TODO - Full implementation in progress'
}, 'warn');

return { synced, errors };
let cursor = null;

do {
// Rate limiting
await this.rateLimiter.acquireToken(connection.merchantId);

// Search orders with retry policy
const response = await this.retryPolicy.executeWithRetry(async () => {
return await client.ordersApi.searchOrders({
locationIds: [connection.squareLocationId],
query: {
filter: {
dateTimeFilter: {
closedAt: {
startAt: startDate.toISOString(),
endAt: endDate.toISOString()
}
},
stateFilter: {
states: ['COMPLETED', 'OPEN'] // Include OPEN for real-time sync
}
},
sort: {
sortField: 'CLOSED_AT',
sortOrder: 'ASC'
}
},
limit: 500, // Max allowed by Square API
cursor
});
});

const orders = response.result.orders || [];
syncResult.details.apiCalls++;
syncResult.details.pages++;

this._logOperation('syncSales', {
message: `Processing page ${syncResult.details.pages}`,
ordersInPage: orders.length
});

// Process each order
for (const orderData of orders) {
try {
// Upsert to Tier 1: square_orders
const [order] = await SquareOrder.upsert({
restaurantId: connection.restaurantId,
posConnectionId: connection.id,
squareOrderId: orderData.id,
locationId: orderData.locationId,
state: orderData.state,
totalMoneyAmount: orderData.totalMoney?.amount || 0,
totalTaxMoneyAmount: orderData.totalTaxMoney?.amount || 0,
totalDiscountMoneyAmount: orderData.totalDiscountMoney?.amount || 0,
closedAt: orderData.closedAt ? new Date(orderData.closedAt) : null,
squareData: orderData // Full JSONB response
}, {
conflictFields: ['squareOrderId']
});

syncResult.synced.orders++;

// Process line items
for (const lineItem of orderData.lineItems || []) {
try {
await SquareOrderItem.upsert({
squareOrderId: order.id,
restaurantId: connection.restaurantId,
squareLineItemUid: lineItem.uid,
squareCatalogObjectId: lineItem.catalogObjectId || null,
squareVariationId: lineItem.variationId || null,
lineItemData: lineItem, // Full JSONB response
name: lineItem.name,
variationName: lineItem.variationName || null,
quantity: parseFloat(lineItem.quantity),
basePriceMoneyAmount: lineItem.basePriceMoney?.amount || 0,
grossSalesMoneyAmount: lineItem.grossSalesMoney?.amount || 0,
totalTaxMoneyAmount: lineItem.totalTaxMoney?.amount || 0,
totalDiscountMoneyAmount: lineItem.totalDiscountMoney?.amount || 0,
totalMoneyAmount: lineItem.totalMoney?.amount || 0
}, {
conflictFields: ['squareLineItemUid']
});

syncResult.synced.lineItems++;

} catch (lineItemError) {
syncResult.errors.push({
orderId: orderData.id,
lineItemUid: lineItem.uid,
error: lineItemError.message
});

this._logOperation('syncSales', {
level: 'warn',
message: 'Line item upsert failed',
lineItemUid: lineItem.uid,
error: lineItemError.message
});
}
}

} catch (orderError) {
syncResult.errors.push({
orderId: orderData.id,
error: orderError.message
});

this._logOperation('syncSales', {
level: 'error',
message: 'Order upsert failed',
orderId: orderData.id,
error: orderError.message
});
}
}

cursor = response.result.cursor;
if (cursor) {
syncResult.details.cursor = cursor;
}

} while (cursor);

// Update connection lastSyncAt
connection.lastSyncAt = new Date();
await connection.save();

this._logOperation('syncSales', {
level: 'info',
message: 'Sync completed',
synced: syncResult.synced,
errorCount: syncResult.errors.length,
apiCalls: syncResult.details.apiCalls
});

} catch (error) {
this._logOperation('syncSales', {
connectionId: connection.id,
error: error.message
}, 'error');
level: 'error',
message: 'Sync failed',
error: error.message,
stack: error.stack
});

syncResult.errors.push({
phase: 'api_call',
error: error.message,
stack: error.stack
});

throw new POSSyncError(
`Failed to sync Square sales: ${error.message}`,
true, // retryable
null, // result
syncResult, // result
'square' // provider
);
}

return syncResult;
}

/**
Expand Down
Loading
Loading