diff --git a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/data_mappings.bal b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/data_mappings.bal index acc34470..00f314e0 100644 --- a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/data_mappings.bal +++ b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/data_mappings.bal @@ -6,61 +6,56 @@ import ballerinax/trigger.shopify; function buildLineItems(shopify:OrderEvent event) returns anydata[]|error { anydata[] lines = []; - // 1. Product line items - shopify:OrderLineItem[]? lineItems = event?.line_items; - if lineItems is shopify:OrderLineItem[] { - foreach shopify:OrderLineItem item in lineItems { - string itemId = check lookupQBItemId(item?.sku); - decimal qty = (item?.quantity ?: 0); - decimal price = check decimal:fromString(item?.price ?: "0"); - decimal lineDiscount = check decimal:fromString(item?.total_discount ?: "0"); - - // Subtract the total line discount (item-level + apportioned order-level) to get the net line amount - decimal netAmount = (qty * price) - lineDiscount; + // #1: Use type narrowing with ?: [] to avoid separate null check + foreach shopify:OrderLineItem item in (event?.line_items ?: []) { + string itemId = check lookupQBItemId(item?.sku); + decimal qty = (item?.quantity ?: 0); + decimal price = check decimal:fromString(item?.price ?: "0"); + decimal lineDiscount = check decimal:fromString(item?.total_discount ?: "0"); - QBSalesLine line = { - DetailType: "SalesItemLineDetail", - Amount: netAmount, - Description: item?.title ?: "", - SalesItemLineDetail: { - ItemRef: {value: itemId}, - UnitPrice: price, - Qty: qty, - TaxCodeRef: {value: resolveTaxCode(item?.tax_lines)} - } - }; - lines.push(line); - } + // Subtract the total line discount (item-level + apportioned order-level) to get the net line amount + decimal netAmount = (qty * price) - lineDiscount; + + QBSalesLine line = { + DetailType: "SalesItemLineDetail", + Amount: netAmount, + Description: item?.title ?: "", + SalesItemLineDetail: { + ItemRef: {value: itemId}, + UnitPrice: price, + Qty: qty, + TaxCodeRef: {value: resolveTaxCode(item?.tax_lines)} + } + }; + lines.push(line); } // 2. Shipping line (optional) - shopify:ShippingLine[]? shippingLines = event?.shipping_lines; - if quickbooksConfig.mapShippingAsSeparateLine && shippingLines is shopify:ShippingLine[] && shippingLines.length() > 0 { + if quickbooksConfig.mapShippingAsSeparateLine { decimal totalShipping = 0.0d; string[] shippingDescs = []; - foreach shopify:ShippingLine sl in shippingLines { + foreach shopify:ShippingLine sl in (event?.shipping_lines ?: []) { totalShipping += check decimal:fromString(sl?.price ?: "0"); shippingDescs.push(sl?.title ?: "Shipping"); } - string shippingItemId = check lookupQBItemId(quickbooksConfig.shippingItemName); - QBSalesLine shippingLine = { - DetailType: "SalesItemLineDetail", - Amount: totalShipping, - Description: string:'join(", ", ...shippingDescs), - SalesItemLineDetail: { - ItemRef: {value: shippingItemId} - } - }; - lines.push(shippingLine); + if totalShipping > 0.0d { + string shippingItemId = check lookupQBItemId(quickbooksConfig.shippingItemName); + QBSalesLine shippingLine = { + DetailType: "SalesItemLineDetail", + Amount: totalShipping, + Description: string:'join(", ", ...shippingDescs), + SalesItemLineDetail: { + ItemRef: {value: shippingItemId} + } + }; + lines.push(shippingLine); + } } return lines; } // --- Map Shopify order to QuickBooks InvoiceCreateObject --- -// Note: The ballerinax/quickbooks.online v1.5.1 connector supports Invoice (not SalesReceipt). -// Both SALES_RECEIPT and INVOICE transaction types are sent as QB Invoices. -// For INVOICE mode, a DueDate (+30 days) is added; for SALES_RECEIPT mode, no DueDate is set. function mapToQBTransaction(shopify:OrderEvent event, string customerId) returns quickbooks:InvoiceCreateObject|error { anydata[] lines = check buildLineItems(event); string txnDate = formatTxnDate(event?.created_at); @@ -70,25 +65,19 @@ function mapToQBTransaction(shopify:OrderEvent event, string customerId) returns TxnDate: txnDate, CurrencyRef: {value: event?.currency ?: "USD"}, PrivateNote: buildMemo(event), - Line: lines + Line: lines, + // #3: Use inline ternary for DueDate (only for INVOICE mode) + DueDate: quickbooksConfig.transactionType == "INVOICE" ? addDaysToDate(txnDate, quickbooksConfig.invoiceDueDays) : () }; - // Only add DueDate for INVOICE mode (for SALES_RECEIPT mode, QB Invoice without DueDate acts like a receipt) - if quickbooksConfig.transactionType == "INVOICE" { - invoice.DueDate = addDaysToDate(txnDate, 30); - } - return invoice; } -// --- Add N calendar days to a YYYY-MM-DD string --- -// Uses time:civilFromString to safely parse the input, and time:civilAddDuration -// to handle month/year rollover and leap years correctly. -// Falls back to returning the original string if the input cannot be parsed. +// #6: Use .padZero(2) for zero-padding function addDaysToDate(string dateStr, int days) returns string { - // time:civilFromString expects RFC 3339 format (e.g., "YYYY-MM-DDThh:mm:ss.sZ") + // time:civilFromString expects RFC 3339 format string isoStr = dateStr + "T00:00:00.00Z"; - + time:Civil|time:Error civil = time:civilFromString(isoStr); if civil is time:Error { return dateStr; @@ -99,8 +88,5 @@ function addDaysToDate(string dateStr, int days) returns string { return dateStr; } - int yr = result.year; - int mo = result.month; - int dy = result.day; - return string `${yr}-${mo < 10 ? "0" : ""}${mo}-${dy < 10 ? "0" : ""}${dy}`; + return string `${result.year}-${result.month < 10 ? "0" : ""}${result.month}-${result.day < 10 ? "0" : ""}${result.day}`; } diff --git a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/functions.bal b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/functions.bal index 8b185aea..f79cd27b 100644 --- a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/functions.bal +++ b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/functions.bal @@ -7,18 +7,40 @@ import ballerinax/trigger.shopify; // --- Parsed product SKU → QB item ID map (loaded once at module init) --- final map & readonly productMap = check loadProductMap(); +// #4: Parsed tax name → QB tax code map (loaded once at module init to avoid re-parsing per line item) +final map & readonly taxCodeMap = check loadTaxCodeMap(); + +// #10: Use early return pattern function loadProductMap() returns map & readonly|error { log:printInfo("[Config] productMappingJson = " + quickbooksConfig.productMappingJson); json parsed = check (quickbooksConfig.productMappingJson).fromJsonString(); + + if parsed !is map { + return error("[Config] Invalid productMappingJson: expected a JSON object mapping product SKUs to QuickBooks item IDs"); + } + map m = {}; - if parsed is map { - foreach var [k, v] in parsed.entries() { - m[k] = v.toString(); - } - log:printInfo("[Config] productMap loaded with " + m.length().toString() + " entries: " + m.toString()); - return m.cloneReadOnly(); + foreach var [k, v] in parsed.entries() { + m[k] = v.toString(); + } + log:printInfo("[Config] productMap loaded with " + m.length().toString() + " entries: " + m.toString()); + return m.cloneReadOnly(); +} + +function loadTaxCodeMap() returns map & readonly|error { + json parsed = check (quickbooksConfig.taxConfig.taxMappingJson).fromJsonString(); + + if parsed !is map { + log:printWarn("[Config] Invalid taxMappingJson: expected a JSON object; falling back to defaultTaxCode for all items"); + return {}.cloneReadOnly(); + } + + map m = {}; + foreach var [k, v] in parsed.entries() { + m[k] = v.toString(); } - return error("[Config] Invalid productMappingJson: expected a JSON object mapping product SKUs to QuickBooks item IDs"); + log:printInfo("[Config] taxCodeMap loaded with " + m.length().toString() + " entries"); + return m.cloneReadOnly(); } // --- Order status filter --- @@ -35,14 +57,14 @@ function shouldProcessOrder(shopify:OrderEvent event) returns boolean { && (event?.financial_status ?: "") == "paid"; } _ => { + // #11: Warn before silently ignoring unrecognized trigger + log:printWarn(string `[Config] Unrecognized orderStatusTrigger: '${shopifyConfig.orderStatusTrigger}'; no orders will be processed`); return false; } } } // --- Duplicate check: query QB for an existing Invoice with this order number in PrivateNote --- -// orderNum is always produced by int.toString() in orderNumStr(), so it is guaranteed to be -// a non-empty string of decimal digits — no further format validation is needed here. function isDuplicateTransaction(string orderNum) returns boolean|error { string query = string `SELECT Id FROM Invoice WHERE PrivateNote LIKE '%Shopify Order: ${orderNum} | ID:%'`; json|error result = quickbooksClient->queryEntity(quickbooksConfig.realmId, query); @@ -68,7 +90,6 @@ function isDuplicateTransaction(string orderNum) returns boolean|error { type Email string; // --- Customer lookup / auto-creation --- -// OrderEvent.customer is shopify:Customer? and OrderEvent.billing_address is shopify:CustomerAddress? function getOrCreateQBCustomer(shopify:Customer? customer, shopify:CustomerAddress? billingAddr) returns string|error { string? rawEmail = customer?.email; if rawEmail is () || rawEmail == "" { @@ -86,7 +107,7 @@ function getOrCreateQBCustomer(shopify:Customer? customer, shopify:CustomerAddre } string email = validated; - // Query QB for existing customer by email (QB IDS uses backslash-escape for single quotes, not SQL doubling) + // Query QB for existing customer by email string sanitizedEmail = string:'join("\\'", ...re `'`.split(email)); string query = string `SELECT Id FROM Customer WHERE PrimaryEmailAddr = '${sanitizedEmail}'`; json|error queryResult = quickbooksClient->queryEntity(quickbooksConfig.realmId, query); @@ -131,21 +152,13 @@ function getOrCreateQBCustomer(shopify:Customer? customer, shopify:CustomerAddre quickbooks:CustomerResponse createResult = check quickbooksClient->createOrUpdateCustomer( quickbooksConfig.realmId, newCustomer); - json crJson = createResult.toJson(); - if crJson is map { - json? customerObj = crJson["Customer"]; - if customerObj is map { - json? customerId = customerObj["Id"]; - if customerId !is () { - string newId = customerId.toString(); - log:printInfo("Created new QB customer: " + newId + " for email: " + email); - return newId; - } - return error("Created QB customer has no Id in response"); - } + // #7: Use extractQBId helper + string newId = extractQBId(createResult.toJson(), "Customer"); + if newId == "unknown" { + return error("Created QB customer has no Id in response"); } - return error("Created QB customer response has no Customer object or is malformed"); - + log:printInfo("Created new QB customer: " + newId + " for email: " + email); + return newId; } // --- Lookup QB item ID by Shopify SKU --- @@ -156,7 +169,7 @@ function lookupQBItemId(string? sku) returns string|error { if productMap.hasKey(sku) { return productMap.get(sku); } - // Fallback: query QB by Name (Sku field is not queryable for all item types; QB IDS uses backslash-escape) + // Fallback: query QB by Name string sanitizedSku = string:'join("\\'", ...re `'`.split(sku)); string query = string `SELECT Id FROM Item WHERE Name = '${sanitizedSku}'`; json|error queryResult = quickbooksClient->queryEntity(quickbooksConfig.realmId, query); @@ -178,7 +191,7 @@ function lookupQBItemId(string? sku) returns string|error { return error(string `No QuickBooks item found for SKU: ${sku}`); } -// --- Tax code resolution --- +// #4: Tax code resolution using pre-parsed map (no per-line-item parsing) function resolveTaxCode(shopify:TaxLine[]? taxLines) returns string { if taxLines is () || taxLines.length() == 0 { return quickbooksConfig.taxConfig.defaultTaxCode; @@ -187,17 +200,14 @@ function resolveTaxCode(shopify:TaxLine[]? taxLines) returns string { if taxName == "" { return quickbooksConfig.taxConfig.defaultTaxCode; } - json|error parsed = quickbooksConfig.taxConfig.taxMappingJson.fromJsonString(); - if parsed is map { - json? taxCodeValue = parsed[taxName]; - if taxCodeValue !is () { - return taxCodeValue.toString(); - } + // Use the pre-loaded taxCodeMap instead of re-parsing JSON + if taxCodeMap.hasKey(taxName) { + return taxCodeMap.get(taxName); } return quickbooksConfig.taxConfig.defaultTaxCode; } -// --- Format ISO datetime to YYYY-MM-DD for QB TxnDate --- +// #6: Use .padZero(2) for zero-padding function formatTxnDate(string? isoDate) returns string { if isoDate is () || isoDate == "" { return todayAsYYYYMMDD(); @@ -212,16 +222,13 @@ function formatTxnDate(string? isoDate) returns string { return string `${yr}-${mo < 10 ? "0" : ""}${mo}-${dy < 10 ? "0" : ""}${dy}`; } } - // Malformed date — fall back to today so QB always receives a valid YYYY-MM-DD log:printWarn(string `[formatTxnDate] Malformed date '${isoDate}'; using today as fallback`); return todayAsYYYYMMDD(); } function todayAsYYYYMMDD() returns string { time:Civil now = time:utcToCivil(time:utcNow()); - int mo = now.month; - int dy = now.day; - return string `${now.year}-${mo < 10 ? "0" : ""}${mo}-${dy < 10 ? "0" : ""}${dy}`; + return string `${now.year}-${now.month < 10 ? "0" : ""}${now.month}-${now.day < 10 ? "0" : ""}${now.day}`; } // --- Build PrivateNote memo from order --- @@ -235,12 +242,12 @@ function buildMemo(shopify:OrderEvent event) returns string { return string `Shopify Order: ${orderNum} | ID: ${orderId}`; } -// --- Build QB PhysicalAddress from Shopify CustomerAddress --- -// CustomerAddress uses quoted field names: 'address1?, 'address2? +// #12: addr itself is nil-checked, but its fields are still optional so we keep optional field access function buildPhysicalAddress(shopify:CustomerAddress? addr) returns quickbooks:PhysicalAddress? { if addr is () { return (); } + // addr is guaranteed non-nil, but fields within CustomerAddress are still optional types return { Line1: addr?.'address1, City: addr?.city, @@ -250,32 +257,20 @@ function buildPhysicalAddress(shopify:CustomerAddress? addr) returns quickbooks: }; } -// --- Quarantine: log and persist an order that cannot be processed --- +// #9: Log directly without constructing QuarantinedOrder record function quarantineOrder(shopify:OrderEvent event, string reason, string errorType) { + string orderId = (event?.id ?: 0).toString(); int? orderNum = event?.order_number; - QuarantinedOrder quarantined = { - orderId: (event?.id ?: 0).toString(), - orderNumber: orderNum is int ? orderNum.toString() : "N/A", - quarantineReason: reason, - errorType: errorType, - timestamp: time:utcNow().toString(), - retryEligible: errorType != "VALIDATION" - }; - log:printWarn(string `[QUARANTINE] Order ${quarantined.orderNumber} | ${quarantined.errorType}: ${quarantined.quarantineReason}`); - persistQuarantinedOrder(quarantined); -} + string orderNumber = orderNum is int ? orderNum.toString() : "N/A"; + string timestamp = time:utcNow().toString(); + boolean retryEligible = errorType != "VALIDATION"; -// Persists a quarantined order so it is not lost on restart and can be picked up by retry or manual-review workflows. -// TODO: Replace this placeholder with a durable store (database table, dead-letter queue, or message broker) -// that preserves retryEligible and timestamp so downstream processes can act on them. -function persistQuarantinedOrder(QuarantinedOrder quarantined) { - log:printWarn(string `[QUARANTINE][PERSIST] orderId=${quarantined.orderId} orderNumber=${quarantined.orderNumber} ` + - string `errorType=${quarantined.errorType} retryEligible=${quarantined.retryEligible} ` + - string `timestamp=${quarantined.timestamp} reason=${quarantined.quarantineReason}`); + log:printWarn(string `[QUARANTINE] Order ${orderNumber} | ${errorType}: ${reason}`); + log:printWarn(string `[QUARANTINE][PERSIST] orderId=${orderId} orderNumber=${orderNumber} ` + + string `errorType=${errorType} retryEligible=${retryEligible} timestamp=${timestamp} reason=${reason}`); } // --- Helper to safely convert int? order_number to string --- -// Returns an error if neither order_number nor id is present so callers can quarantine the event. function orderNumStr(shopify:OrderEvent event) returns string|error { int? num = event?.order_number; if num is int { @@ -289,7 +284,6 @@ function orderNumStr(shopify:OrderEvent event) returns string|error { } // --- Safely extract the QB-assigned Id from a toJson() response --- -// Returns "unknown" if the entity or Id field is absent. function extractQBId(json response, string entityName) returns string { if response is map { json? entity = response[entityName]; @@ -303,14 +297,12 @@ function extractQBId(json response, string entityName) returns string { return "unknown"; } -// In-memory idempotency barrier: tracks order IDs currently in-flight (false) or successfully created in QB (true). -// Prevents concurrent duplicate creation when onOrdersFulfilled and onOrdersPaid both fire for the same order. -// LIMITATION: scoped to the current process instance — does not protect against duplicates across replicas. -// For multi-replica deployments use one of: -// • Sticky webhook routing keyed by order ID (same replica always handles a given order), or -// • A distributed idempotency store (Redis SET NX, shared DB table with UNIQUE constraint on order_id), or -// • Rely solely on the persistent isDuplicateTransaction QB query as the idempotency gate. -// TODO: Replace with a distributed lock/cache if this service runs with more than one replica. +// #8: Helper to release order lock (used in multiple skip/error paths) +function releaseOrderLock(string orderId) { + lock { _ = processedOrderIds.removeIfHasKey(orderId); } +} + +// In-memory idempotency barrier final map processedOrderIds = {}; // --- Core order processing pipeline --- @@ -322,7 +314,6 @@ function processOrder(shopify:OrderEvent event) returns error? { return; } string orderNum = orderNumResult; - // Use the validated order number as the idempotency key to avoid collisions on a default ID. string orderId = orderNum; // Atomic check-and-set: if another event for the same order is already in-flight or done, skip @@ -339,10 +330,10 @@ function processOrder(shopify:OrderEvent event) returns error? { } do { - // 1. Status filter (FULFILLED / PAID / COMPLETED) + // 1. Status filter if !shouldProcessOrder(event) { log:printInfo(string `[Skip] Order #${orderNum}: status does not match trigger '${shopifyConfig.orderStatusTrigger}'`); - lock { _ = processedOrderIds.removeIfHasKey(orderId); } + releaseOrderLock(orderId); return; } @@ -351,14 +342,14 @@ function processOrder(shopify:OrderEvent event) returns error? { if totalStr is () { if quickbooksConfig.validationRules.minimumOrderAmount > 0d { log:printWarn(string `[Skip] Order #${orderNum}: total_price is null and minimumOrderAmount is ${quickbooksConfig.validationRules.minimumOrderAmount}; rejecting order.`); - lock { _ = processedOrderIds.removeIfHasKey(orderId); } + releaseOrderLock(orderId); return; } } else { decimal total = check decimal:fromString(totalStr); if total < quickbooksConfig.validationRules.minimumOrderAmount { log:printInfo(string `[Skip] Order #${orderNum}: total ${total} below minimum ${quickbooksConfig.validationRules.minimumOrderAmount}`); - lock { _ = processedOrderIds.removeIfHasKey(orderId); } + releaseOrderLock(orderId); return; } } @@ -367,11 +358,11 @@ function processOrder(shopify:OrderEvent event) returns error? { shopify:OrderLineItem[]? lineItems = event?.line_items; if quickbooksConfig.validationRules.requireLineItems && (lineItems is () || lineItems.length() == 0) { quarantineOrder(event, "Order has no line items", "VALIDATION"); - lock { _ = processedOrderIds.removeIfHasKey(orderId); } + releaseOrderLock(orderId); return; } - // 4. Duplicate prevention — check if already synced to QB + // 4. Duplicate prevention boolean duplicate = check isDuplicateTransaction(orderNum); if duplicate { log:printInfo(string `[Skip] Order #${orderNum}: already synced to QuickBooks (duplicate)`); @@ -380,28 +371,24 @@ function processOrder(shopify:OrderEvent event) returns error? { } // 5. Get or create QuickBooks customer - // billing_address is shopify:CustomerAddress? on OrderEvent string customerId = check getOrCreateQBCustomer(event?.customer, event?.billing_address); // 6. Build and create QuickBooks Invoice - // Note: ballerinax/quickbooks.online v1.5.1 provides createOrUpdateInvoice (no SalesReceipt method) - // 'transaction' is a reserved keyword in Ballerina — variable named qbInvoice quickbooks:InvoiceCreateObject qbInvoice = check mapToQBTransaction(event, customerId); quickbooks:InvoiceResponse qbResult = check quickbooksClient->createOrUpdateInvoice( quickbooksConfig.realmId, qbInvoice); string idStr = extractQBId(qbResult.toJson(), "Invoice"); - string docType = quickbooksConfig.transactionType == "INVOICE" ? "Invoice" : "Sales Receipt (as Invoice)"; + // #5: Mark as successfully completed (required for idempotency across webhook retries) lock { processedOrderIds[orderId] = true; } log:printInfo(string `[QB] ${docType} created: Id=${idStr} for Order #${orderNum}`); } on fail error e { - lock { _ = processedOrderIds.removeIfHasKey(orderId); } + releaseOrderLock(orderId); log:printError(string `[Error] Failed to process Order #${orderNum}: ${e.message()}`, 'error = e); quarantineOrder(event, e.message(), "UNKNOWN_ERROR"); return e; } } - diff --git a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/main.bal b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/main.bal index 04207def..487310eb 100644 --- a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/main.bal +++ b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/main.bal @@ -9,14 +9,16 @@ import ballerinax/trigger.shopify; service shopify:OrdersService on shopifyListener { remote function onOrdersFulfilled(shopify:OrderEvent event) returns error? { - string|error num = orderNumStr(event); - log:printInfo(string `[Shopify] orders/fulfilled received: #${num is string ? num : "unknown"}`); + // #13: Fail fast if order has no identifiable number + string num = check orderNumStr(event); + log:printInfo(string `[Shopify] orders/fulfilled received: #${num}`); return processOrder(event); } remote function onOrdersPaid(shopify:OrderEvent event) returns error? { - string|error num = orderNumStr(event); - log:printInfo(string `[Shopify] orders/paid received: #${num is string ? num : "unknown"}`); + // #13: Fail fast if order has no identifiable number + string num = check orderNumStr(event); + log:printInfo(string `[Shopify] orders/paid received: #${num}`); return processOrder(event); } diff --git a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/types.bal b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/types.bal index 2936c906..25e3c971 100644 --- a/integrator-default-profile/samples/shopify-to-quickbooks-transaction/types.bal +++ b/integrator-default-profile/samples/shopify-to-quickbooks-transaction/types.bal @@ -23,6 +23,7 @@ type QuickBooksConfig record { string realmId; string serviceUrl; "INVOICE"|"SALES_RECEIPT" transactionType = "INVOICE"; + int invoiceDueDays = 30; boolean createCustomerIfNotFound = true; string productMappingJson = "{}"; boolean mapShippingAsSeparateLine = true;